227 lines
18 KiB
Gherkin
227 lines
18 KiB
Gherkin
Feature: Inserting, editing and removing a link in the article body
|
|
|
|
As a verified RenewCMS owner writing an article
|
|
I want to turn text into a link from the toolbar or with Ctrl/Cmd+K, and to edit or remove it later
|
|
So that readers can follow references, and no link I add can run script
|
|
|
|
Background:
|
|
Given this slice is SPA only; no Go code, route or API contract changes
|
|
And the screen is /article/:id/edit (EditorPage in DashboardShell), per docs/design/screens/editor.md
|
|
rows 11 to 27, docs/design/components.md "Editor: link" and the Editor group of the board
|
|
"RenewCMS Screens Back-office.dc.html"; docs/design/README.md says "11 states" but all 17 rows are in scope
|
|
And a link change is a body edit only: it moves useArticleDraft to "dirty" ("Unsaved changes", Save enabled,
|
|
PublishControl "Save and publish" on a draft) and sends no request; the existing PATCH /v1/article/{id}
|
|
with {"body": "<html>"} saves it, unchanged
|
|
And the body stays Tiptap HTML, a link being <a href="...">; ArticleEditor keeps its props, events and
|
|
exposed focus, insertImage, imageSrc and removeImage
|
|
And the toolbar has a link button (icon i-lucide-link) after the list buttons, before "Clear formatting"
|
|
and "Image"; 28 px on desktop, 40 px on mobile; text colour --ui-text-toned
|
|
And Ctrl+K (Cmd+K on macOS) with the editor focused opens the same dialog as the button and prevents the
|
|
browser's own shortcut
|
|
And LinkModal is a dialog at desktop width and a bottom sheet at mobile width (viewport); fields "Link text"
|
|
and "URL", toggle "Open in new tab" (off by default) with the helper text "Links open in the same tab unless
|
|
you turn on a new tab."; title "Insert link" or "Edit link"; Cancel left, "Insert link" or "Save link" right;
|
|
in edit mode a text button "Remove link"
|
|
And a valid URL, after trimming, is http(s)://host..., mailto:address, a path with exactly one leading "/",
|
|
or #anchor; everything else fails with "Enter a valid URL. Use http(s), mailto, a path or #anchor."
|
|
(so javascript:, data:, vbscript:, file:, ftp:, tel:, //host, page.html and ../x are all invalid, in any
|
|
letter case, with leading whitespace, control characters or entity escapes)
|
|
And "Link text" must not be blank, else "Enter the link text."
|
|
And validation runs on submit, and on blur for the URL; an invalid submit inserts nothing, keeps the dialog
|
|
open and focuses the first invalid field
|
|
And "Open in new tab" writes target="_blank" rel="noopener noreferrer" on the anchor; off writes neither
|
|
And the link popover and its sheet follow the input type, not the width: a popover for a fine pointer, a bottom
|
|
sheet for pointer: coarse
|
|
And the popover is 288 px wide under the link: the URL in mono, then "Edit", "Open", "Remove" (error colour);
|
|
the sheet shows the link text, the URL and 48 px rows "Edit link", "Open link", "Remove link"
|
|
And Remove takes no confirmation and is undone by the editor's undo; Open opens a new tab with
|
|
rel="noopener noreferrer"
|
|
And selection edge cases follow the editor's default mark behaviour; no custom rules
|
|
And automatic URL linking is untouched by this slice
|
|
And target and rel exist only in ?format=html; the json and markdown reads carry only the href (accepted)
|
|
And every new English string has a French entry in messages.ts
|
|
|
|
Scenario Outline: The link button across its states
|
|
Given the user is on /article/12/edit in the Saved state, <situation>
|
|
Then the link button shows <expected>
|
|
|
|
Examples:
|
|
| situation | expected |
|
|
| the cursor is in plain text | aria-pressed "false", --ui-text-toned |
|
|
| the pointer rests on it for 400 ms (Windows or Linux) | the tooltip "Insert link (Ctrl+K)" |
|
|
| the pointer rests on it for 400 ms (macOS) | the tooltip "Insert link (⌘K)" |
|
|
| the input is touch | no tooltip |
|
|
| it takes keyboard focus | a 2 px --ui-primary outline, 1 px offset; Enter or Space opens the dialog |
|
|
| the cursor is inside a link | aria-pressed "true", --rc-sand fill, --rc-on-sand icon; pressing it opens the edit dialog |
|
|
| part or all of a link's text is selected | the same active look |
|
|
| an image is selected | 40% opacity, aria-disabled "true", no tooltip; pressing it and Ctrl/Cmd+K open nothing |
|
|
| Preview is on, or ArticleEditor has disabled true | Ctrl/Cmd+K opens nothing |
|
|
|
|
Scenario Outline: Creating a link
|
|
Given the user is on /article/12/edit with the body "<p>We also check the timber windows before any tiling starts.</p>"
|
|
And <start>
|
|
When the user presses Ctrl+K, or activates the link button
|
|
Then LinkModal opens titled "Insert link" with "Link text" <text-field>, the toggle off, and "Cancel" and "Insert link"
|
|
And <focus-target> has focus
|
|
When the user sets "Link text" to "<text>", "URL" to "/articles/winter-checklist" and activates "Insert link"
|
|
Then the dialog closes and the body holds <a href="/articles/winter-checklist"><text></a> at that place
|
|
And focus is back in the editor, SaveStatus reads "Unsaved changes", and no request has been sent
|
|
|
|
Examples:
|
|
| start | text-field | focus-target | text |
|
|
| "timber windows" is selected | prefilled "timber windows" | "URL" | timber windows |
|
|
| "timber windows" is selected | prefilled, then edited | "URL" | timber sash windows |
|
|
| nothing is selected | empty, placeholder "What the reader sees" | "Link text" | winter checklist |
|
|
|
|
Scenario Outline: Editing and removing an existing link
|
|
Given the body has the link "timber windows" to "/articles/winter-checklist" opening in a new tab
|
|
When the user <opens-edit>
|
|
Then LinkModal opens titled "Edit link" with the text, the URL (focused) and the toggle on prefilled, and "Remove link", "Cancel", "Save link"
|
|
When the user <action>
|
|
Then <result>
|
|
|
|
Examples:
|
|
| opens-edit | action | result |
|
|
| presses Edit in the link popover | sets the URL to "https://example.org/windows" and activates "Save link" | the same anchor has that href and still opens in a new tab; SaveStatus "Unsaved changes" |
|
|
| presses Ctrl+K with the cursor in the link | activates "Save link" with nothing changed | the dialog closes, the body is identical and a Saved draft stays Saved |
|
|
| presses Ctrl+K with the cursor in the link | activates "Remove link" | the text stays without the link, no confirmation, "Unsaved changes"; undo brings the link back with its target |
|
|
|
|
Scenario Outline: Validation in the dialog
|
|
Given the dialog is open
|
|
When the user sets "Link text" to "<text>", "URL" to "<url>" and activates "Insert link"
|
|
Then <outcome>
|
|
And nothing is inserted, the dialog stays open and the draft's dirty state is unchanged
|
|
|
|
Examples:
|
|
| text | url | outcome |
|
|
| timber windows | www.example | "URL" shows "Enter a valid URL. Use http(s), mailto, a path or #anchor." |
|
|
| timber windows | example.com | the same URL error |
|
|
| timber windows | javascript:alert(1) | the same URL error |
|
|
| timber windows | JaVaScRiPt:alert(1) | the same URL error |
|
|
| timber windows | javascript:alert(1) | the same URL error |
|
|
| timber windows | javascript:alert(1) | the same URL error |
|
|
| timber windows | data:text/html;base64,PHA+ | the same URL error |
|
|
| timber windows | vbscript:msgbox(1) | the same URL error |
|
|
| timber windows | file:///etc/passwd | the same URL error |
|
|
| timber windows | ftp://example.org/a | the same URL error |
|
|
| timber windows | tel:+33123456789 | the same URL error |
|
|
| timber windows | //evil.example | the same URL error |
|
|
| timber windows | page.html | the same URL error |
|
|
| timber windows | ../x | the same URL error |
|
|
| timber windows | http:// | the same URL error |
|
|
| timber windows | https://exa mple.org | the same URL error |
|
|
| | #measuring | "Link text" shows "Enter the link text." and has focus |
|
|
| (spaces) | #measuring | the same text error |
|
|
| | www.example | both errors show and focus goes to "Link text" |
|
|
|
|
Scenario Outline: Accepted URL forms
|
|
Given the dialog is open with "Link text" "x"
|
|
When the user enters "<url>" and activates "Insert link"
|
|
Then the link is inserted with href "<href>"
|
|
|
|
Examples:
|
|
| url | href |
|
|
| https://example.org/a?b=1#c | https://example.org/a?b=1#c |
|
|
| HTTP://Example.org | HTTP://Example.org |
|
|
| mailto:hello@example.org | mailto:hello@example.org |
|
|
| /articles/winter-checklist | /articles/winter-checklist |
|
|
| #measuring | #measuring |
|
|
| (padded) " /page " | /page |
|
|
|
|
Scenario: The URL validates on blur
|
|
Given the dialog is open and the user typed "www.example" in "URL"
|
|
When focus leaves "URL"
|
|
Then the URL error shows and nothing is inserted
|
|
|
|
Scenario: Link: long URL
|
|
Given the edit dialog is open on "planning guidance" with the 90-character URL
|
|
"https://www.example.org/planning/guidance/listed-buildings/windows-and-doors/…/2026-update.pdf"
|
|
Then "URL" stays on one line, does not widen the dialog, scrolls to reveal the whole value, and its title attribute holds the full URL
|
|
And "Save link" keeps its place and stays reachable at desktop and mobile width
|
|
|
|
Scenario Outline: Leaving the dialog without saving
|
|
Given the dialog is open with changed fields
|
|
When the user <leaves>
|
|
Then it closes, the body and the draft's dirty state are unchanged, and focus returns to the editor with the earlier selection
|
|
|
|
Examples:
|
|
| leaves |
|
|
| activates "Cancel" |
|
|
| presses Esc |
|
|
| clicks or taps outside it |
|
|
|
|
Scenario Outline: Dialog or sheet by viewport, popover or sheet by input
|
|
Given a link "timber windows" to "/articles/winter-checklist" in the body
|
|
When <action>
|
|
Then <result>
|
|
|
|
Examples:
|
|
| action | result |
|
|
| the user opens the link dialog at 1440 px wide | a dialog |
|
|
| the user opens the link dialog at 390 px wide | a bottom sheet with the same fields, errors and footer |
|
|
| a fine pointer clicks the link, or Enter on the focused link | a 288 px popover under the link with the URL, "Edit", "Open", "Remove"; the link button is active; Esc closes it and keeps the cursor in the link |
|
|
| a coarse pointer taps the link | a bottom sheet with "timber windows", the URL and 48 px rows "Edit link", "Open link", "Remove link"; Esc or a tap outside closes it |
|
|
| the user activates "Open" or "Open link" | the URL opens in a new tab with rel "noopener noreferrer" |
|
|
| the user activates "Remove" or "Remove link" | the link is removed with no confirmation, "Unsaved changes", and undo restores it |
|
|
|
|
Scenario Outline: Rendered link states in both themes
|
|
Given the body has the link "timber windows" and the theme is <theme>
|
|
Then the link <state>
|
|
|
|
Examples:
|
|
| state | theme |
|
|
| is --ui-text-highlighted with a 1 px underline at a 3 px offset | light |
|
|
| is --ui-text-highlighted with a 1 px underline at a 3 px offset | dark |
|
|
| shows a 2 px underline on pointer hover | light |
|
|
| shows a 2 px underline on pointer hover | dark |
|
|
| shows a 2 px --ui-primary outline at 2 px offset when reached by Tab or arrows | light |
|
|
| shows a 2 px --ui-primary outline at 2 px offset when reached by Tab or arrows | dark |
|
|
| has an --rc-sand fill and --rc-on-sand text when the selection crosses it | light |
|
|
| has an --rc-sand fill and --rc-on-sand text when the selection crosses it | dark |
|
|
|
|
Scenario: Toolbar overflow at 390 px
|
|
Given the viewport is 390 px wide
|
|
Then the full toolbar scrolls sideways in one row without wrapping
|
|
And a 24 or 32 px fade marks each side that has hidden buttons, and none on a side with nothing hidden
|
|
When the toolbar is scrolled to the end
|
|
Then the link, "Clear formatting" and "Image" buttons are visible, each 40 px
|
|
And at 1440 px the toolbar is unchanged and shows no fade
|
|
|
|
Scenario Outline: Selections that straddle a link or span blocks
|
|
Given the body is "<p>We also check the <a href=\"/a\">timber windows</a> before</p><p>any tiling starts.</p>"
|
|
And the selection is <selection>
|
|
When the user opens the dialog with the text unchanged and submits "/b"
|
|
Then the new link covers the whole selected range and replaces any partial link inside it
|
|
And no error occurs, and no text of the body is lost
|
|
|
|
Examples:
|
|
| selection |
|
|
| "check the timber" (plain text and part of a link)|
|
|
| "windows before" (part of a link and plain text) |
|
|
| "before" to "any tiling" (across two paragraphs) |
|
|
|
|
Scenario: Links survive the HTML body format and reach the other formats
|
|
Given a link "timber windows" to "/articles/winter-checklist" opening in a new tab is in the body
|
|
When the user saves and reloads /article/12/edit
|
|
Then PATCH /v1/article/12 carried the anchor with href, target="_blank" and rel="noopener noreferrer"
|
|
And the reloaded editor shows the link, and its edit dialog has the toggle on
|
|
And a link saved without the toggle carries neither target nor rel
|
|
When a client sends GET /v1/article/12?format=json, or ?format=markdown
|
|
Then json holds a link mark with href "/articles/winter-checklist" on "timber windows", and markdown holds [timber windows](/articles/winter-checklist), with no target or rel
|
|
|
|
Scenario Outline: An unsafe href already in a body is neutralised, and the next save rewrites the stored body
|
|
Given GET /v1/article/12 resolves a body containing <a href="<href>">x</a>, or the user pastes that HTML into the body
|
|
When the editor loads the body
|
|
Then the text "x" is kept without a link, the link button is not active on it, and clicking it navigates nowhere
|
|
And the load alone does not mark the draft dirty and sends no request, so the stored body is still unchanged
|
|
When the user makes any edit and saves
|
|
Then PATCH /v1/article/12 carries the neutralised body, without the unsafe href, so the stored body is silently rewritten without the link
|
|
And the body the editor serialises never contains "javascript:", "data:" or "vbscript:" in an href
|
|
And a paste is itself an edit, so in the paste case the draft is dirty straight away
|
|
|
|
Examples:
|
|
| href |
|
|
| javascript:alert(1) |
|
|
| data:text/html,<b> |
|
|
| vbscript:msgbox(1) |
|