feat(editor): link insertion full UI and flow
This commit is contained in:
1 parent
03b39e64f2
commit
6f9ae6207a
16 files changed
+2865
-14
No files matched your search
@@ -0,0 +1,226 @@
|
||||
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) |
|
||||
@@ -0,0 +1,60 @@
|
||||
# QA: Inserting, editing and removing a link in the article body
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mount EditorPage at `/article/12/edit` with the `editor_core.qa.md` fixture and the body
|
||||
`<p>Every bathroom is different. We also check the timber windows before any tiling starts.</p>`.
|
||||
Mock `request` for GET and PATCH /v1/article/12 only. Tiptap runs for real in jsdom; drive selection
|
||||
through the editor instance. `request` is never called except by steps 8 and 9. Mock `matchMedia` for
|
||||
`pointer: coarse` and for the viewport width.
|
||||
|
||||
1. **Button states, Examples-driven** - default, tooltip text per platform (Windows and Linux "Insert link (Ctrl+K)", macOS "Insert link (⌘K)", none on touch), focus ring class, active for cursor-in and for partial or full selection, disabled for an image selection, and Ctrl/Cmd+K inert in Preview and with `disabled`.
|
||||
2. **Create, Examples-driven (3 rows)** - Ctrl+K and the button: title, prefill, focus target, toggle off; submit; body HTML holds the anchor; "Unsaved changes"; zero requests.
|
||||
3. **Edit and remove, Examples-driven (3 rows)** - Edit and Ctrl+K entry, prefilled fields incl. the toggle, change href in place, no-op leaves a Saved draft Saved, "Remove link" with no confirm and undo restoring href and target.
|
||||
4. **Validation, Examples-driven** - every row of both validation Outlines, plus the accepted-forms Outline: exact error text, dialog open, body and dirty state unchanged, focus target, and the trimmed href; the blur case. For the unsafe rows assert the body HTML never has "javascript:", "data:" or "vbscript:" in an href.
|
||||
5. **Long URL** - the `title` attribute holds the full 90-character value, the dialog has no horizontal overflow, "Save link" is enabled.
|
||||
6. **Leave without saving, Examples-driven** - Cancel, Esc, outside click: nothing changes; focus returns.
|
||||
7. **Dialog/sheet and popover/sheet, Examples-driven** - width mock gives dialog or sheet; fine pointer gives popover (Esc closes), coarse gives sheet with three 48 px rows; Open calls `window.open(url, '_blank', 'noopener,noreferrer')`; Remove has no confirm and is undoable; "Open in new tab" gives `target="_blank" rel="noopener noreferrer"`, off gives neither.
|
||||
8. **Round trip** - save with a new-tab link: the PATCH body has the anchor with target and rel; remount with it: link and toggle restored; without the toggle no target or rel.
|
||||
9. **Unsafe hrefs, Examples-driven** - on load: text kept, no link, button not active, draft not dirty, zero requests; then an edit and Save: the PATCH body has no unsafe href. Paste of the same HTML: same neutralising, draft dirty.
|
||||
10. **Selection edge cases, Examples-driven (3 rows)** - the new link covers the range, a partial link is replaced, no throw, and the text content before and after is identical. Then the French completeness check: every new key is in `messages.ts` and the dialog title and two error strings are spot-checked.
|
||||
|
||||
## Follow-ups and notes (not asserted here)
|
||||
|
||||
- **Follow-up Go slice (out of scope):** `?format=html` returns the stored HTML as is, and `safeHref` runs only for markdown and json. An API client that PATCHes `<a href="javascript:...">` gets it back in the html read. A separate Go slice should sanitise href schemes in the converter and probably reject them on write. The SPA sanitising here is defence in depth only.
|
||||
- **Board-vs-reality note for the next Claude Design pass:** row 16 says the URL field "truncates in the middle of the line", and the board draws an ellipsis in the text. CSS cannot truncate the middle of an `<input>`. The implementation is a single scrolling line with the full URL in `title`.
|
||||
- **Design gap for the next pass:** the helper text "Links open in the same tab unless you turn on a new tab." is in the board only. `components.md` ("LinkModal") needs it.
|
||||
- **README undercount:** `docs/design/README.md` says "11 states"; `screens/editor.md` has 17 link rows (11-27). All 17 are covered.
|
||||
|
||||
## Visual: `task visual:check -- editor`
|
||||
|
||||
Setups go in `web/visual/states.ts` with the board's sample data: article "How we price a bathroom renovation", SaveStatus "Unsaved changes", link text "timber windows", URL "/articles/winter-checklist" (rows 13, 20, 22-26), "www.example" (14), "#measuring" (15), and for row 16 the text "planning guidance" with the 90-character URL above. Hover, focus and selection (rows 18, 23-26) are forced pseudo-states in the setup. If a row cannot be matched within 10 %, stop and ask the user: no per-state exception without `approvedBy`. No row is clock-sensitive.
|
||||
|
||||
| Row | State |
|
||||
|---|---|
|
||||
| 11 | Link: create from selection |
|
||||
| 12 | Link: create with no selection |
|
||||
| 13 | Link: edit existing (toggle on, Remove link) |
|
||||
| 14 | Link: invalid URL |
|
||||
| 15 | Link: empty text |
|
||||
| 16 | Link: long URL |
|
||||
| 17 | Link button: default |
|
||||
| 18 | Link button: hover and tooltip (desktop only) |
|
||||
| 19 | Link button: focus-visible |
|
||||
| 20 | Link button: active |
|
||||
| 21 | Link button: disabled (image selected) |
|
||||
| 22 | Link popover (desktop) and sheet (mobile) |
|
||||
| 23-26 | Rendered link: default, hover, focus, selected |
|
||||
| 27 | Toolbar overflow (390 px, scrolled to the end; desktop unchanged) |
|
||||
|
||||
- [ ] Keyboard and focus: Tab order, Enter/Space on the button, focus trap and return, Esc on the popover, Enter on a focused link.
|
||||
- [ ] Hover and motion: tooltip delay, underline thickening, sheet slide.
|
||||
- [ ] Screen reader: aria-pressed, dialog labelling, error association.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Ctrl/Cmd+K** in Chrome, Firefox, Safari and Edge: the browser shortcut is prevented only inside the focused editor.
|
||||
- **macOS tooltip** on a real Mac.
|
||||
- **Real touch** on a phone: a tap on a link opens the sheet and does not follow the link; the sideways toolbar scroll and fades work, including iOS momentum.
|
||||
- **IME** in the Link text field.
|
||||
- **Save and public read** against `task dev`: save a new-tab link, then GET ?format=html, ?format=json and ?format=markdown, and open the public page.
|
||||
Reference in new issue
Block a user