diff --git a/features/media_field_library.feature b/features/media_field_library.feature new file mode 100644 index 0000000..6148cfc --- /dev/null +++ b/features/media_field_library.feature @@ -0,0 +1,296 @@ +Feature: Media field usage in the Media library + As an owner, admin or verified user managing the media library + I want to see which entries use an image and be warned before deleting it + So that I know what an image is used for and what a delete will break + + Background: + Given the SPA calls the API with relative URLs under /v1 and the "jwt" HttpOnly cookie + And API errors are plain text + And this slice changes only web/ (and web/visual); the backend of features/media_field_type.feature is done and not touched + And the design is docs/design/media-field-changes.md section 3.8 and section 4, with the board group "11 Media library: usage" (4 states) of "RenewCMS Screens Schema.dc.html", which draws MediaFrame.dc.html with entries counted as uses + And GET /v1/media/ items carry "used_in", which counts articles, authors, sites and entries together, and no split + And GET /v1/media/{id}/usage answers an array ordered articles, authors, sites, then entries by content type name then entry id, with no total and no "first few" wrapper + And an article item is {"type":"article","id","title","published","fields"} + And an entry item is {"type":"entry","id","fields","content_type_key","content_type_name","first_field":{"key","type","value"}} with no title and no published, and first_field absent when the type has no first field + And the SPA keeps the first 3 entry items, counts the rest, and derives the entry title from first_field with the title rules of the entry list (see "Entry title in the usage lists") + And deleting a used image stays allowed; entries keep the id and show "This image is no longer in the library" + And the usage badge, panel and dialog never branch on the role; the Media page is the same for every user who reaches it + And sentence case, "image" and never "asset", buttons are verbs + And every string goes through t() and has a French entry in i18n/messages.ts + And a copy tagged "(proposed copy)" is not in the design and is the coder's single point of change + And decisions D1 to D12 of this slice are listed in the QA procedure header and in the conversation + And out of scope: filtering the library by media-field use, the read-only view of a custom type, any change to the Go API + + Scenario Outline: The label of the usage badge + Given an image with used_in + Then the badge reads "" + + Examples: + | used_in | badge | + | 0 | Unused | + | 1 | Used in 1 | + | 3 | Used in 3 | + | 7 | Used in 7 | + + Scenario: The badge counts articles and entries together + Given bathroom-tiles.png has used_in 3 (0 articles, 3 entries) and lime-plaster.jpg has used_in 7 (2 articles, 5 entries) + Then the grid card, the list row and the mobile card of bathroom-tiles.png read "Used in 3" and those of lime-plaster.jpg read "Used in 7" + And the badge is the outline kind of MediaBadge, without an icon, as today + + Scenario: Tooltip for entries only (design "Grid: usage badge tooltip") + Given bathroom-tiles.png has 0 articles and 3 entries, and a desktop viewport + When the pointer rests on the badge "Used in 3" + Then GET /v1/media/10/usage is sent once + And a tooltip (--ui-bg-inverted, --ui-text-inverted, 12/18, 6 px 8 px padding, below the badge, left aligned) reads on one line "Used in entries: 3" + + Scenario: Tooltip for articles and entries + Given lime-plaster.jpg has 2 articles and 5 entries, and a desktop viewport + When the pointer rests on the badge "Used in 7" + Then the tooltip reads "Used in articles: 2" on the first line and "Used in entries: 5" on the second + + Scenario: Tooltip for articles only + Given north-wall.jpg has 2 articles and 0 entries, and a desktop viewport + When the pointer rests on the badge "Used in 2" + Then the tooltip reads "Used in articles: 2" and no "Used in entries" line shows + + Scenario: Tooltip opens on keyboard focus + Given a desktop viewport + When the keyboard focus reaches the card (grid) of an image that is used + Then the same tooltip opens, and it closes on blur or Escape without closing anything else + And the focus ring of the card is unchanged + + Scenario: The usage is fetched once per image + Given the tooltip of image 10 was opened once + When the user hovers it again, or opens its detail panel + Then no second GET /v1/media/10/usage is sent while the list is not reloaded + And a reload of the list (search, filter, sort, page, retry) clears the cached usage + + Scenario: The tooltip waits for the usage + Given GET /v1/media/10/usage has not answered + Then no tooltip shows and the badge keeps its label + When it answers and the pointer or focus is still on the badge + Then the tooltip opens + + Scenario Outline: The tooltip never shows without a useful split + Given the pointer rests on the badge of an image and GET /v1/media/{id}/usage + Then no tooltip shows and nothing else changes + + Examples: + | outcome | + | answers 500 | + | fails on the network | + | answers 404 | + | answers only authors and sites | + + Scenario: No tooltip on mobile + Given a 390 px viewport + Then no tooltip opens on tap and the badge is read inside the card as "Used in 3" + + Scenario: No tooltip on an unused image + Given an image with used_in 0 + Then the badge "Unused" has no tooltip and no usage request is sent for it + + Scenario Outline: The accessible name of a card (proposed copy) + Given an image "north-wall.jpg", 3.9 MB, with used_in + Then the card button is named "" + + Examples: + | used_in | name | + | 0 | north-wall.jpg, 3.9 MB, unused | + | 1 | north-wall.jpg, 3.9 MB, used in 1 place | + | 7 | north-wall.jpg, 3.9 MB, used in 7 places | + + Scenario: The list table column (design "List view: usage badge", written spec 3.8) + Given a desktop viewport and the list view + Then the "Used in" column of a used image is the underlined button "Used in 3" that opens the detail panel, as the "N articles" button does today + And an image with used_in 0 shows the "Unused" badge + And the mobile card shows the "Used in 3" badge and the existing meta line + + Scenario: Panel for an image used by articles and entries (design "Detail panel: used in entries") + Given lime-plaster.jpg has 2 articles and 5 entries, whose first three entries are Events "Lime plaster workshop", Events "Heritage stone walk" and Events "Winter open day" + When the user opens its panel + Then the preview is 16:10 on desktop and 16:3.6 on mobile only when usage holds at least one entry, and 16:10 and 4:3 otherwise + And the section "Used in 2 articles" lists the two articles with title, Online or Draft and the arrow-up-right icon, as today, with the note "Counted from the text of your articles each time you open this page." + And below it the section "Used in entries" (600 14/22, 8 px gap) holds a bordered list of 3 rows, then the row "and 2 more" in the muted colour, with a muted top border and no link + And each entry row is 36 px high on desktop and 48 px on mobile and holds, left to right, the content type name in 12 px muted (52 px wide, one line, ellipsis), the entry title as a link in weight 500 underlined (one line, ellipsis) and the arrow-up-right icon (14 px, muted) + And the link of a row goes to /content/events/ in the same tab + + Scenario Outline: Counts in the "Used in entries" section + Given an image whose usage holds entry items + Then the section shows rows and + + Examples: + | entries | rows | more | + | 1 | 1 | no "and N more" row | + | 3 | 3 | no "and N more" row | + | 4 | 3 | the row "and 1 more" | + | 5 | 3 | the row "and 2 more" | + | 120 | 3 | the row "and 117 more" | + + Scenario: Entries only, no article + Given bathroom-tiles.png has 3 entries and 0 articles + When the user opens its panel + Then the articles heading reads "Not used in any article" + And the line under it reads "No article shows it." and never "This image is safe to delete." + And the section "Used in entries" follows + + Scenario: Articles only + Given north-wall.jpg has 2 articles and 0 entries + Then the panel is unchanged from before this slice and shows no "Used in entries" section + + Scenario: Nothing uses the image + Given window-frame.jpg has used_in 0 + Then the panel reads "Not used in any article" and "This image is safe to delete. No article shows it." + And no usage request is sent + + Scenario: Authors and sites only (decision D7) + Given an image with used_in 2 and a usage that holds one author and one site + Then the articles heading reads "Not used in any article" + And the line reads "No article shows it." and no "safe to delete" claim is made + And no authors or sites are listed + + Scenario: The articles heading counts articles only + Given lime-plaster.jpg has used_in 7 (2 articles, 5 entries) + Then the heading reads "Used in 2 articles" and never "Used in 7 articles" + + Scenario: Entry link keyboard and focus + Given the panel of lime-plaster.jpg is open + Then Tab reaches the articles links, then the entry links, in reading order + And an entry link has the 2 px --ui-primary focus ring with a 2 px offset + And "and 2 more" is not a stop + + Scenario: Usage loading in the panel (decision D6) + Given GET /v1/media/11/usage has not answered + Then after 150 ms a skeleton of three rows shows under the heading "Used in" (proposed copy) + And before 150 ms nothing shows in the section + And Copy URL, Rename and Delete stay enabled + When it answers + Then the real sections replace the skeleton + + Scenario Outline: Usage failure in the panel (decision D5) + Given the panel of an image with used_in 7 is opened + When GET /v1/media/{id}/usage + Then + + Examples: + | outcome | result | + | answers 500 | the section reads "Couldn't load where this image is used." (proposed copy) with the ghost button "Try again", and Delete stays enabled | + | fails on the network | the same | + | answers 429 | the same | + | answers 404 | the existing gone flow: the panel closes and "That image no longer exists" shows | + | answers 401 | the global redirect, no local copy | + + Scenario: Try again in the panel + Given the usage failure is shown + When the user activates "Try again" + Then only GET /v1/media/{id}/usage is sent again and the sections replace the message + + Scenario: Switching images while the usage is loading + Given the panel of image 10 is waiting for its usage + When the user opens image 11 + Then the answer of image 10 is ignored and the panel shows only image 11 data + + Scenario Outline: Entry title from first_field + Given an entry item with id 42 and first_field {"key":"title","type":"","value":} + Then the row title reads "" + + Examples: + | type | value | title | + | text | "Lime plaster workshop" | Lime plaster workshop | + | text | "" | Entry 42 | + | text | null | Entry 42 | + | richtext | "## Heritage walk\n\nMeet at 9" | the first line as richTextFirstLine gives it (not the Markdown marks of the whole body) | + | richtext | "" | Entry 42 | + | number | 1500 | 1,500 (as the entry list formats a number) | + | number | 0 | 0 | + | boolean | true | Yes | + | boolean | false | No | + | date | "2026-11-21" | the entry list date format of 2026-11-21 | + | select | "Workshop" | Workshop | + | media | 7 | Entry 42 | + | media | null | Entry 42 | + + Scenario: first_field is absent or unknown + Given an entry item with id 42 and no first_field, or a first_field of an unknown type + Then the row title reads "Entry 42" for no first_field and the String of the value for an unknown type, as describeCell does for the default case + + Scenario: Long titles and long type names (decision D4) + Given an entry title of 120 characters and the type name "Press mentions and partner coverage" + Then both stay on one line with an ellipsis, the row height does not change, the title link keeps the full title as its accessible name and a native title attribute, and "and N more" is unaffected + + Scenario: The order of the three rows + Given the API returns entries ordered by content type name then entry id + Then the three kept rows keep that order and the SPA does not sort again + + Scenario: Delete an image used by entries and articles (design "Delete: image used by entries") + Given lime-plaster.jpg has 2 articles and 5 entries and the user activates Delete in its panel + Then the confirm dialog (ConfirmDialog, tone danger) titled "Delete an image that's in use?" opens + And its description reads "\"lime-plaster.jpg\" is used in 5 entries and 2 articles. Those entry fields will show \"This image is no longer in the library\", and the articles will show a broken image. This can't be undone." + And the body holds, in this order, the bordered "Used in entries" list (heading "Used in entries", 3 rows, then "and 2 more") and the bordered list of the 2 articles with title and Online or Draft + And the buttons are "Cancel" and the destructive "Delete image" + And the entry rows show the type name and the title and are not links in the dialog (decision D10) + + Scenario Outline: The wording by what uses the image + Given an image "lime-plaster.jpg" with <articles> articles and <entries> entries in its loaded usage + Then the title is "<title>" + And the description reads <description> + + Examples: + | articles | entries | title | description | + | 2 | 5 | Delete an image that's in use? | "lime-plaster.jpg" is used in 5 entries and 2 articles. Those entry fields will show "This image is no longer in the library", and the articles will show a broken image. This can't be undone. | + | 1 | 1 | Delete an image that's in use? | "lime-plaster.jpg" is used in 1 entry and 1 article. Those entry fields will show "This image is no longer in the library", and the articles will show a broken image. This can't be undone. | + | 2 | 0 | Delete an image that's in use? | "lime-plaster.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can't be undone. | + | 0 | 3 | Delete an image that's in use? | "lime-plaster.jpg" is used in 3 entries. Those entry fields will show "This image is no longer in the library". This can't be undone. (proposed copy) | + | 0 | 0 | Delete this image? | "lime-plaster.jpg" will be deleted from your library. No article uses it. This can't be undone. (used_in is 0) | + + Scenario: Delete an unused image + Given window-frame.jpg has used_in 0 + Then the dialog is titled "Delete this image?" with the unchanged copy, no lists and no usage request + + Scenario: Delete an image used only by authors or sites + Given used_in 2 and a usage with no article and no entry + Then the existing in-use title shows with the generic fallback description of the next scenario + + Scenario: The usage is not ready when the dialog opens (decision D6) + Given the user activates Delete before GET /v1/media/{id}/usage answered, or after it failed + Then the dialog is titled "Delete an image that's in use?" because used_in is above 0 + And the description reads "\"lime-plaster.jpg\" is in use. Articles will show a broken image and entry fields will show \"This image is no longer in the library\". This can't be undone." (proposed copy) + And no lists show, "Delete image" is enabled, and when the usage answers while the dialog is open the dialog switches to the full copy and lists without moving the focus + + Scenario: Confirm and results + Given the delete dialog is open + When the user activates "Delete image" + Then DELETE /v1/media/<id> is sent and the existing in-progress, failed, done and 404 states run unchanged + And on 204 the toast "Image deleted" / "\"lime-plaster.jpg\" is gone from your library." shows and the focus moves to the next item + + Scenario: Cancel and Escape + When the user activates "Cancel" or presses Esc + Then nothing is deleted and the focus returns to the Delete button of the panel + + Scenario: The variants note is kept + Given an image with cached variants and used by entries + Then the existing variant removal note still shows under the lists + + Scenario: Mobile dialog + Given a 390 px viewport + Then the dialog is a bottom sheet, the entry rows are 48 px high and the buttons are 44 px high + + Scenario: Bulk delete counts uses, not articles (proposed copy) + Given 3 images are selected, 2 of them used for 7 places in total (articles and entries) + Then each row keeps its badge "Used in N" or "Unused" + And the description counts "7 places" in the sentence where it said "7 articles" and reads "which will show a broken image or a missing-image notice." instead of "which will show a broken image." + And no usage request is sent for the bulk dialog + + Scenario: Merge counts uses, not articles (proposed copy) + Given a merge group whose other copies have used_in 5 and 2 + Then the dialog says "7 places will be updated" and "Kept · used in N places now" + And the toast after the merge keeps "N copies merged. M articles updated." from articles_updated + + Scenario: Accessibility of the usage UI + Then the tooltip is exposed with aria-describedby on its trigger and uses role "tooltip" + And the entry title link is named "<title>, <content type name>" (proposed) + And "and 2 more" is plain text read in order + And the arrow-up-right icon is aria-hidden + And a skeleton or error line in the panel is inside the section so a screen reader reads it after the heading + And the delete dialog moves focus to "Cancel" as it does today, and its lists are read after the description + And mobile targets are 44 px, with rows of 48 px, and there is no hover on mobile diff --git a/features/media_field_library.qa.md b/features/media_field_library.qa.md new file mode 100644 index 0000000..7d04978 --- /dev/null +++ b/features/media_field_library.qa.md @@ -0,0 +1,101 @@ +# QA: Media field usage in the Media library (slice C) + +Runner: Vitest + Vue Test Utils (`task test:web`). No Go test and no Go change: the usage API is covered by features/media_field_type.qa.md. Kit: extend the Media view test kit with +- the library list (`GET /v1/media/?page=&size=&q=&filter=` with `items`, `total`, `counts`); +- `GET /v1/media/{id}/usage` fixtures: + - image 10 `bathroom-tiles.png`: 3 entries (Events), `used_in` 3; + - image 11 `lime-plaster.jpg`: 2 articles ("Open day at the workshop" online, "Restoring lime plaster" draft) plus 5 Events entries (first three: first_field text "Lime plaster workshop", "Heritage stone walk", "Winter open day"), `used_in` 7; + - image 1 `north-wall.jpg`: 2 articles, `used_in` 2; + - image 8 `window-frame.jpg`: `used_in` 0; + - an author-and-site-only image, `used_in` 2; +- entry items of every `first_field.type`, plus items with no `first_field`; +- failures: 500, network, 429, 404, 401. + +Pin the time zone; never freeze the system date. No state of this slice depends on the clock. + +Decisions accepted by the user (all as the feature draft assumes): +- D1 split source for the tooltip: lazy `/usage` fetch, SPA-only. +- D2 wording of the card name and the table column: "used in N places", underlined "Used in N" button. +- D3 title derivation: reuse `describeCell` and `plainCellText`. +- D4 truncation: one line, ellipsis, native title. +- D5 usage failure: inline message with "Try again" in the panel, silent tooltip. +- D6 usage loading: skeleton after 150 ms, delete dialog opens at once with a generic description. +- D7 authors and sites in the usage: filtered out, heading counts articles only. +- D8 bulk delete: "N places". +- D9 merge: "N places". +- D10 entry rows in the delete dialog are not links. +- D11 mobile panel as drawn. +- D12 visual gate setup. + +## Automated + +Pure logic (lib/__tests__, it.each over the feature's Examples): +1. `splitUsage(items)`: articles are those with `type === 'article'`, entries those with `type === 'entry'`, authors and sites are counted by neither. Entry rows keep API order, the first 3 are kept, `more` is the rest (0, 1, 2, 117). An absent array gives zeros. +2. `usageEntryTitle(item)`: the table "Entry title from first_field" for text, richtext (empty and Markdown), number (0 and 1500), boolean, date, select, media and null, empty string, absent first_field and an unknown type (falls to the default case of `describeCell`). It reuses `describeCell` and `plainCellText`; no second title rule exists. `entryTitle` itself is unchanged and its existing specs pass untouched. +3. Wording helpers: pluralEntries ("1 entry", "5 entries"), usage description by case (articles+entries, singular forms, articles only unchanged, entries only, usage unavailable fallback), `usedInLabel` for the card name ("used in 1 place", "used in 7 places", "unused"). +4. Tooltip lines: entries only gives one line, both gives articles first, articles only gives one line, authors or sites only gives none. + +MediaCardBadges, MediaTable, MediaGrid, MediaCardList (components/__tests__): +5. Badge label for used_in 0, 1, 3, 7. Outline kind, no icon. Same label in the grid card, the list row (underlined button in the table, badge in the mobile card) and the mobile card. +6. Tooltip: hover on the badge (`pointerenter`) fetches `/usage` once and opens with the right lines for the three splits; focus on the grid card opens it; Escape and blur close it without closing the panel; no fetch for `used_in` 0; no tooltip on mobile (viewport helper 390); nothing opens while the request is pending, and it opens on answer if the pointer is still there; failures (500, network, 404, authors-only) leave no tooltip; the usage is cached per image and cleared on reload of the list. +7. Card `aria-label`: the three names of the feature, replacing the "used in 2 articles" assertions in the existing specs; update those specs and change nothing else in them. +8. Table: the "Used in" cell is the underlined button "Used in N" (`data-testid="media-used-in"` kept) that opens the panel; "Unused" badge at 0. + +MediaDetailPanel (components/__tests__/MediaDetailPanel.spec.ts, views/__tests__/MediaView*.spec.ts): +9. Articles and entries: heading "Used in 2 articles" built from the articles of the usage, not from `used_in`; article rows unchanged; "Used in entries" heading and 3 rows with type name, title link to `/content/<key>/<id>` and the arrow icon; "and 2 more" as plain text; the counts table (1, 3, 4, 5, 120 entries); row heights 36 px (desktop) and 48 px (mobile) as classes. +10. Entries only: heading "Not used in any article" with "No article shows it."; articles only: no entries section and the panel as before; unused: "safe to delete" line and no `/usage` request; authors and sites only: "No article shows it." and no list. +11. Preview aspect: 16:10 desktop and 4:3 mobile without entries, 16:6 desktop and 16:3.6 mobile with entries (classes, after the usage answers). +12. Loading: nothing before 150 ms, the three-row skeleton after, the real sections after the answer; fake timers only for the 150 ms delay, never the date. Failure: 500, network and 429 show "Couldn't load where this image is used." with "Try again" that repeats only `/usage`; 404 runs the existing gone flow; 401 is left to the global redirect; Delete stays enabled; switching images ignores the late answer. +13. Long title and long type name: one line, ellipsis classes, native title attribute on the link, row height unchanged. +14. Focus and tab order: article links, then entry links; the ring classes on a link; "and 2 more" not focusable. + +DeleteMediaDialog (components/__tests__/DeleteMediaDialog.spec.ts): +15. The description table (2/5, 1/1, 2/0, 0/3, 0/0), the title for in use vs unused, the body order (entries list with heading and "and 2 more", then the articles list), the buttons, the variants note kept, no link in the entry rows. +16. Usage not ready (pending, failed): the generic description, no lists, "Delete image" enabled; when the usage answers while open the copy and lists switch and the focus stays. +17. Delete flow (confirm, in progress, failed, done, 404) runs unchanged; Cancel and Esc return focus to the Delete button of the panel. + +BulkDeleteMediaDialog and MergeMediaDialog: +18. Bulk description counts "places" and uses "which will show a broken image or a missing-image notice."; rows and badges unchanged; no `/usage` request. +19. Merge dialog says "N places will be updated" and "Kept · used in N places now"; the toast stays "N copies merged. M articles updated.". + +Cross-cutting: +20. French: every new string has an entry (the i18n completeness spec passes). +21. Regression: the existing Media view specs pass unchanged except the assertions named in 7, 18 and 19; if any other spec needs an edit, stop and report. + +## Visual (task visual:check -- media) + +In scope: the 4 states of the board group "11 Media library: usage" of RenewCMS Screens Schema.dc.html (they draw MediaFrame, and the copy list gives them the sample data). Every capture must be within 10 %, desktop and mobile, light and dark. Out of scope: states whose setups exist already and whose data does not change (they must keep passing untouched), and the picker states of slice B. + +Prerequisites in web/visual, no change under docs/design (decision D12): +- `check.ts`: add the source `{ board: 'RenewCMS Screens Schema.dc.html', group: '11 Media library: usage', states: [the four below] }` to `extraSources.media`. The group differs from the screen's primary group ("Media"), so no merge change is needed. +- `states.ts`: the four setups, with the media API mock extended by `GET /v1/media/10/usage` and `GET /v1/media/11/usage` (shapes above). Existing media states keep their data, so `used_in` stays articles-only there. +- Board data: + - image 10 bathroom-tiles.png, 3 entries, `used_in` 3; + - image 11 lime-plaster.jpg, 2 articles and 5 entries, `used_in` 7; + - the grid also holds an unused image, so "Unused" shows in the captures. + +| Board state | Setup in web/visual/states.ts | Board sample data | +|---|---|---| +| Grid: usage badge tooltip | /media with ent counts; step: hover the badge of image 10 (desktop), wait for the tooltip; mobile shows the grid with no tooltip | "Used in 3", tooltip "Used in entries: 3", "Unused" | +| List view: usage badge | /media?view=list; desktop table, mobile cards | "Used in 3", "Used in 7", "Unused" | +| Detail panel: used in entries | step: open image 11; wait for the entries list | "Used in 2 articles", "Used in entries", Events + "Lime plaster workshop", "Heritage stone walk", "Winter open day", "and 2 more" | +| Delete: image used by entries | step: open image 11, activate Delete; wait for the lists | the title, the description of 5 entries and 2 articles, both lists, "Cancel", "Delete image" | + +Board caveats to report, never hide: +- The desktop table of the board draws `usedLink` with `pl(im.used)` (articles only). It shows "N articles" for lime-plaster.jpg and "Unused" for bathroom-tiles.png, while the written spec 3.8 says the list row carries the same badge. The spec follows the written text (D2); if the desktop capture of "List view: usage badge" exceeds 10 % the exception needs the user's explicit approval (`approvedBy`). +- The board draws the entry rows in the delete dialog as underlined links with the arrow icon; the spec renders plain rows (D10). If a capture exceeds 10 %, report it before changing the choice. +- The tooltip is drawn at a fixed place (left aligned, 26 px below the badge); a Nuxt UI tooltip may centre it. Use the `align` and offset that match the frame. +- The board titles ("Lime plaster workshop") come from the setup's `first_field` values, not from a title field. +- Clock-sensitive: none of the four states shows a relative date. + +## Manual + +- Real backend, owner and a second verified user: upload an image, use it in an article and in two entries through a Media field, and read "Used in 3" in the grid, list and mobile card. Hover and focus the badge for the tooltip, and check that a card with no entries shows "Used in articles: N" only. +- Open the panel: the entries link opens /content/<key>/<id> and shows the right entry. Add a fourth entry and read "and 1 more". Delete the entry and reload: counts fall. +- Delete the image from the panel with the dialog open: read the real counts, confirm, then open one of the entries and see "This image is no longer in the library" with the id kept. +- Throttle or kill the network while opening the panel and the dialog: the error line, "Try again" and the generic delete description. +- An image used only by an author or a site: no "safe to delete" claim anywhere. +- Keyboard: tab order across card, badge tooltip (focus), panel links and dialog buttons; Escape closes the tooltip first, then the panel; the focus return after Cancel; screen reader reads the card name, the tooltip and the lists. +- Motion and hover: the tooltip opening and closing, the skeleton fade at 150 ms, no layout jump larger than the preview change when the usage answers. +- Mobile: the full-screen panel, 48 px rows, the bottom-sheet dialog, 44 px targets, no tooltip. +- Contrast of the tooltip pair and of the muted type name in light and dark. diff --git a/web/src/assets/main.css b/web/src/assets/main.css index 0e2c740..b93c5de 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/main.css @@ -3,6 +3,7 @@ @import '@nuxt/ui'; @theme static { + --aspect-panel-banner: 16 / 3.6; --font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif; --font-serif: 'Source Serif 4', ui-serif, Georgia, serif; --font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace; diff --git a/web/src/components/BulkDeleteMediaDialog.vue b/web/src/components/BulkDeleteMediaDialog.vue index 17d6790..19dc4c7 100644 --- a/web/src/components/BulkDeleteMediaDialog.vue +++ b/web/src/components/BulkDeleteMediaDialog.vue @@ -6,9 +6,9 @@ import ConfirmDialog from '@/components/ConfirmDialog.vue' import MediaBadge from '@/components/MediaBadge.vue' import VariantRemovalNote from '@/components/variants/VariantRemovalNote.vue' import type { MediaItem } from '@/composables/useMedia' -import { pluralArticles } from '@/lib/mediaFormat' +import { pluralPlaces } from '@/lib/mediaFormat' -const BROKEN_IMAGE = 'which will show a broken image.' +const BROKEN_IMAGE = 'which will show a broken image or a missing-image notice.' const FINAL_WARNING = "This can't be undone." const props = defineProps<{ @@ -27,11 +27,11 @@ const variantCount = computed(() => const variantBytes = computed(() => props.items.reduce((sum, item) => sum + (item.variant_size ?? 0), 0), ) -const articleCount = computed(() => inUse.value.reduce((sum, item) => sum + item.used_in, 0)) +const placeCount = computed(() => inUse.value.reduce((sum, item) => sum + item.used_in, 0)) const description = computed(() => { const usedCount = inUse.value.length const unusedCount = total.value - usedCount - const articles = pluralArticles(articleCount.value) + const places = pluralPlaces(placeCount.value) if (usedCount === 0) { return total.value === 1 ? t("It isn't used in any article. {0}", [t(FINAL_WARNING)]) @@ -39,7 +39,7 @@ const description = computed(() => { } if (unusedCount === 0) { const subject = total.value === 1 ? t("It's") : t('All {0} are', [total.value]) - return t('{0} used in {1}, {2} {3}', [subject, articles, t(BROKEN_IMAGE), t(FINAL_WARNING)]) + return t('{0} used in {1}, {2} {3}', [subject, places, t(BROKEN_IMAGE), t(FINAL_WARNING)]) } const rest = unusedCount === 1 @@ -47,7 +47,7 @@ const description = computed(() => { : t("The other {0} aren't used anywhere.", [unusedCount]) return t('{0} of them are used in {1}, {2} {3} {4}', [ usedCount, - articles, + places, t(BROKEN_IMAGE), rest, t(FINAL_WARNING), diff --git a/web/src/components/DeleteMediaDialog.vue b/web/src/components/DeleteMediaDialog.vue index 4c34f2b..d24069d 100644 --- a/web/src/components/DeleteMediaDialog.vue +++ b/web/src/components/DeleteMediaDialog.vue @@ -5,38 +5,25 @@ import { computed } from 'vue' import ConfirmDialog from '@/components/ConfirmDialog.vue' import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue' import VariantRemovalNote from '@/components/variants/VariantRemovalNote.vue' -import type { MediaItem, MediaUsageItem } from '@/composables/useMedia' -import { pluralArticles } from '@/lib/mediaFormat' +import type { MediaItem, UsageState } from '@/composables/useMedia' +import { deleteCopy } from '@/lib/mediaUsage' const props = defineProps<{ item: MediaItem - usage: MediaUsageItem[] + usage: UsageState | null action: () => Promise<void> }>() const emit = defineEmits<{ close: [confirmed: boolean] }>() -const inUse = computed(() => props.item.used_in > 0) -const title = computed(() => - inUse.value ? t("Delete an image that's in use?") : t('Delete this image?'), -) -const description = computed(() => - inUse.value - ? t( - "\u0022{0}\u0022 is used in {1}. If you delete it, they will show a broken image. This can't be undone.", - [props.item.filename, pluralArticles(props.item.used_in)], - ) - : t( - "\u0022{0}\u0022 will be deleted from your library. No article uses it. This can't be undone.", - [props.item.filename], - ), -) +const copy = computed(() => deleteCopy(props.item.filename, props.item.used_in, props.usage)) +const split = computed(() => copy.value.split) </script> <template> <ConfirmDialog - :title="title" - :description="description" + :title="copy.title" + :description="copy.description" :confirm-label="t('Delete image')" :cancel-label="t('Cancel')" :pending-label="t('Deleting…')" @@ -45,9 +32,41 @@ const description = computed(() => @close="emit('close', $event)" > <template #body> - <ul v-if="inUse && usage.length > 0" class="border border-default"> + <div v-if="split && split.entryCount > 0" class="flex flex-col gap-2"> + <h3 class="text-base font-semibold text-highlighted">{{ t('Used in entries') }}</h3> + <ul data-testid="affected-entries" class="border border-default"> + <li + v-for="(entry, index) in split.entries" + :key="entry.id" + data-testid="affected-entry" + class="flex h-12 items-center gap-2 px-3 sm:h-9" + :class="{ 'border-t border-muted': index > 0 }" + > + <span class="w-13 shrink-0 truncate text-xs text-muted">{{ + entry.typeName + }}</span> + <span + :title="entry.title" + class="min-w-0 flex-1 truncate font-medium text-highlighted" + >{{ entry.title }}</span + > + </li> + <li + v-if="split.more > 0" + data-testid="affected-more" + class="border-t border-muted px-3 py-2 text-muted" + > + {{ t('and {0} more', [split.more]) }} + </li> + </ul> + </div> + <ul + v-if="split && split.articles.length > 0" + data-testid="affected-articles" + class="border border-default" + > <li - v-for="(article, index) in usage" + v-for="(article, index) in split.articles" :key="article.id" data-testid="affected-article" class="flex min-h-9 items-center gap-2 px-3" diff --git a/web/src/components/MediaCardBadges.vue b/web/src/components/MediaCardBadges.vue index d247f41..955135d 100644 --- a/web/src/components/MediaCardBadges.vue +++ b/web/src/components/MediaCardBadges.vue @@ -2,16 +2,30 @@ import { t } from '@/i18n' import MediaBadge from '@/components/MediaBadge.vue' +import MediaUsageTooltip from '@/components/MediaUsageTooltip.vue' defineProps<{ - item: { duplicate: boolean; used_in: number; variant_count?: number } + item: { id?: number; duplicate: boolean; used_in: number; variant_count?: number } variants?: boolean + tooltip?: boolean + focused?: boolean }>() </script> <template> <MediaBadge v-if="item.duplicate" kind="duplicate" :label="t('Duplicate')" /> - <MediaBadge v-if="item.used_in > 0" kind="used" :label="t('Used in {0}', [item.used_in])" /> + <MediaUsageTooltip + v-if="item.used_in > 0 && tooltip && item.id !== undefined" + :id="item.id" + :focused="focused" + > + <MediaBadge kind="used" :label="t('Used in {0}', [item.used_in])" /> + </MediaUsageTooltip> + <MediaBadge + v-else-if="item.used_in > 0" + kind="used" + :label="t('Used in {0}', [item.used_in])" + /> <MediaBadge v-else kind="unused" :label="t('Unused')" /> <MediaBadge v-if="variants && (item.variant_count ?? 0) > 0" diff --git a/web/src/components/MediaDetailPanel.vue b/web/src/components/MediaDetailPanel.vue index c8e89dd..1460f1d 100644 --- a/web/src/components/MediaDetailPanel.vue +++ b/web/src/components/MediaDetailPanel.vue @@ -1,12 +1,12 @@ <script setup lang="ts"> import { t } from '@/i18n' -import { computed, ref, watch } from 'vue' +import { computed, onBeforeUnmount, ref, watch } from 'vue' import { useMediaQuery } from '@vueuse/core' import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue' import MediaThumb from '@/components/MediaThumb.vue' import MediaVariantsTab from '@/components/variants/MediaVariantsTab.vue' -import type { MediaItem, MediaUsageItem } from '@/composables/useMedia' +import { MEDIA_SKELETON_DELAY_MS, type MediaItem, type UsageState } from '@/composables/useMedia' import { formatDimensions, formatMediaDate, @@ -15,6 +15,7 @@ import { joinNames, pluralArticles, } from '@/lib/mediaFormat' +import { usageSplitOf } from '@/lib/mediaUsage' import { PANEL_TAB_TRIGGER } from '@/lib/tabStyles' import { useCanManageVariants } from '@/composables/useCanManageVariants' @@ -24,7 +25,7 @@ const ACTION_BUTTON = const props = defineProps<{ item: MediaItem | null - usage: MediaUsageItem[] + usage: UsageState | null copies: MediaItem[] offline: boolean }>() @@ -34,6 +35,7 @@ const emit = defineEmits<{ rename: [] delete: [] 'review-duplicates': [] + 'retry-usage': [] gone: [] }>() @@ -75,6 +77,66 @@ watch( }, ) +const SKELETON_ROWS = [0, 1, 2] +const ROW_CLASS = 'flex h-12 items-center gap-2 px-3 md:h-9' +const ENTRY_LINK_CLASS = + 'min-w-0 flex-1 truncate font-medium text-highlighted underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary' +const HEADING_CLASS = 'text-base font-semibold text-highlighted' + +const split = computed(() => usageSplitOf(props.usage)) +const usageLoading = computed( + () => + (props.item?.used_in ?? 0) > 0 && + (props.usage === null || props.usage.status === 'loading'), +) +const usageFailed = computed( + () => + props.usage?.status === 'failed' && + props.usage.failure !== 'handled' && + props.usage.failure !== 'not-found', +) +const hasEntries = computed(() => (split.value?.entryCount ?? 0) > 0) +const articleCount = computed(() => split.value?.articles.length ?? 0) +const skeletonShown = ref(false) +let skeletonTimer: ReturnType<typeof setTimeout> | null = null + +function clearSkeletonTimer() { + if (skeletonTimer !== null) { + clearTimeout(skeletonTimer) + skeletonTimer = null + } +} + +watch( + [usageLoading, () => props.item?.id], + () => { + clearSkeletonTimer() + skeletonShown.value = false + if (usageLoading.value) { + skeletonTimer = setTimeout(() => { + skeletonShown.value = true + }, MEDIA_SKELETON_DELAY_MS) + } + }, + { immediate: true }, +) + +onBeforeUnmount(clearSkeletonTimer) + +const articlesHeading = computed(() => { + if (usageLoading.value || props.usage?.status === 'failed') { + return t('Used in') + } + return articleCount.value > 0 + ? t('Used in {0}', [pluralArticles(articleCount.value)]) + : t('Not used in any article') +}) +const noArticleLine = computed(() => + props.item?.used_in === 0 + ? t('This image is safe to delete. No article shows it.') + : t('No article shows it.'), +) + const closeButton = computed(() => ({ color: 'neutral' as const, variant: 'ghost' as const, @@ -195,7 +257,12 @@ const duplicateAlert = computed(() => { <MediaThumb :item="item" size="xl" - class="aspect-4/3 w-full border border-default md:aspect-16/10" + class="w-full border border-default" + :class=" + hasEntries + ? 'aspect-panel-banner md:aspect-16/6' + : 'aspect-4/3 md:aspect-16/10' + " /> <div class="flex gap-2 max-md:flex-wrap"> @@ -274,52 +341,119 @@ const duplicateAlert = computed(() => { </dd> </dl> - <section class="flex flex-col gap-2"> - <h3 class="text-base font-semibold text-highlighted"> - {{ - item.used_in > 0 - ? t('Used in {0}', [pluralArticles(item.used_in)]) - : t('Not used in any article') - }} - </h3> - <template v-if="item.used_in > 0"> - <ul v-if="usage.length > 0" class="border border-default"> + <section class="flex flex-col gap-2" data-testid="usage-articles"> + <h3 :class="HEADING_CLASS">{{ articlesHeading }}</h3> + <template v-if="item.used_in === 0"> + <p class="text-muted">{{ noArticleLine }}</p> + </template> + <template v-else-if="usageLoading"> + <ul + v-if="skeletonShown" + data-testid="usage-skeleton" + class="border border-default" + > <li - v-for="(article, index) in usage" - :key="article.id" - data-testid="usage-row" - class="flex min-h-12 items-center gap-2 px-3 md:min-h-10" - :class="{ - 'border-t border-muted': index > 0, - }" + v-for="row in SKELETON_ROWS" + :key="row" + :class="[ROW_CLASS, { 'border-t border-muted': row > 0 }]" > - <RouterLink - :to="`/article/${article.id}/edit`" - class="min-w-0 flex-1 truncate font-medium text-highlighted underline underline-offset-3" - > - {{ article.title }} - </RouterLink> - <MediaArticleStatusBadge - :published="article.published" - size="md" - /> - <UIcon - name="i-lucide-arrow-up-right" - class="size-3.5 shrink-0 text-muted" - /> + <USkeleton class="h-3 w-full rounded-none bg-accented" /> </li> </ul> - <p class="text-xs text-muted"> - {{ - t( - 'Counted from the text of your articles each time you open this page.', - ) - }} - </p> </template> - <p v-else class="text-muted"> - {{ t('This image is safe to delete. No article shows it.') }} - </p> + <template v-else-if="usageFailed"> + <div data-testid="usage-error" class="flex flex-col items-start gap-2"> + <p class="text-muted"> + {{ t("Couldn't load where this image is used.") }} + </p> + <UButton + color="neutral" + variant="ghost" + class="h-11 px-3 md:h-8" + @click="emit('retry-usage')" + > + {{ t('Try again') }} + </UButton> + </div> + </template> + <template v-else-if="split"> + <template v-if="split.articles.length > 0"> + <ul class="border border-default"> + <li + v-for="(article, index) in split.articles" + :key="article.id" + data-testid="usage-row" + class="flex min-h-12 items-center gap-2 px-3 md:min-h-10" + :class="{ + 'border-t border-muted': index > 0, + }" + > + <RouterLink + :to="`/article/${article.id}/edit`" + class="min-w-0 flex-1 truncate font-medium text-highlighted underline underline-offset-3" + > + {{ article.title }} + </RouterLink> + <MediaArticleStatusBadge + :published="article.published" + size="md" + /> + <UIcon + name="i-lucide-arrow-up-right" + class="size-3.5 shrink-0 text-muted" + /> + </li> + </ul> + <p class="text-xs text-muted"> + {{ + t( + 'Counted from the text of your articles each time you open this page.', + ) + }} + </p> + </template> + <p v-else class="text-muted">{{ t('No article shows it.') }}</p> + </template> + </section> + + <section + v-if="split && hasEntries" + data-testid="usage-entries" + class="flex flex-col gap-2" + > + <h3 :class="HEADING_CLASS">{{ t('Used in entries') }}</h3> + <ul class="border border-default"> + <li + v-for="(entry, index) in split.entries" + :key="entry.id" + data-testid="usage-entry-row" + :class="[ROW_CLASS, { 'border-t border-muted': index > 0 }]" + > + <span class="w-13 shrink-0 truncate text-xs text-muted">{{ + entry.typeName + }}</span> + <RouterLink + :to="`/content/${entry.key}/${entry.id}`" + :title="entry.title" + :aria-label="`${entry.title}, ${entry.typeName}`" + :class="ENTRY_LINK_CLASS" + > + {{ entry.title }} + </RouterLink> + <UIcon + name="i-lucide-arrow-up-right" + aria-hidden="true" + class="size-3.5 shrink-0 text-muted" + /> + </li> + <li + v-if="split.more > 0" + data-testid="usage-more" + class="border-t border-muted px-3 py-2 text-muted" + > + {{ t('and {0} more', [split.more]) }} + </li> + </ul> </section> </div> </div> diff --git a/web/src/components/MediaGrid.vue b/web/src/components/MediaGrid.vue index 91d0143..0aec706 100644 --- a/web/src/components/MediaGrid.vue +++ b/web/src/components/MediaGrid.vue @@ -25,6 +25,7 @@ const emit = defineEmits<{ }>() const grid = ref<HTMLElement | null>(null) +const focusedId = ref<number | null>(null) function cards() { return Array.from(grid.value?.querySelectorAll<HTMLElement>('[data-media-card]') ?? []) @@ -66,6 +67,17 @@ function onKeydown(event: KeyboardEvent, item: MediaItem, index: number) { cards()[target]?.focus() } +function onFocus(event: FocusEvent, id: number) { + const target = event.currentTarget as HTMLElement + focusedId.value = target.matches(':focus-visible') ? id : null +} + +function onBlur(id: number) { + if (focusedId.value === id) { + focusedId.value = null + } +} + function onKeyup(event: KeyboardEvent) { if (event.key === ' ') { event.preventDefault() @@ -91,6 +103,8 @@ function onKeyup(event: KeyboardEvent) { @click="emit('open', item.id, $event.currentTarget as HTMLElement)" @keydown="onKeydown($event, item, index)" @keyup="onKeyup" + @focus="onFocus($event, item.id)" + @blur="onBlur(item.id)" > <MediaThumb :item="item" @@ -117,7 +131,12 @@ function onKeyup(event: KeyboardEvent) { > </span> <span class="flex flex-wrap gap-1"> - <MediaCardBadges :item="item" variants /> + <MediaCardBadges + :item="item" + variants + tooltip + :focused="focusedId === item.id" + /> </span> </span> </button> diff --git a/web/src/components/MediaTable.vue b/web/src/components/MediaTable.vue index 0cebed1..2974330 100644 --- a/web/src/components/MediaTable.vue +++ b/web/src/components/MediaTable.vue @@ -6,13 +6,7 @@ import MediaCheckbox from '@/components/MediaCheckbox.vue' import MediaThumb from '@/components/MediaThumb.vue' import type { MediaItem } from '@/composables/useMedia' import type { SelectAllState } from '@/composables/useMediaSelection' -import { - formatDimensions, - formatMediaDate, - formatSize, - formatType, - pluralArticles, -} from '@/lib/mediaFormat' +import { formatDimensions, formatMediaDate, formatSize, formatType } from '@/lib/mediaFormat' const ROW_GRID = 'grid grid-cols-[36px_minmax(0,1.7fr)_110px_76px_64px_120px_130px_100px] items-center pr-3' @@ -109,7 +103,7 @@ function open(event: Event, id: number) { class="relative z-10 text-highlighted underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" @click="open($event, item.id)" > - {{ pluralArticles(item.used_in) }} + {{ t('Used in {0}', [item.used_in]) }} </button> <MediaBadge v-else kind="unused" :label="t('Unused')" /> </span> diff --git a/web/src/components/MediaUsageTooltip.vue b/web/src/components/MediaUsageTooltip.vue new file mode 100644 index 0000000..a7dcf94 --- /dev/null +++ b/web/src/components/MediaUsageTooltip.vue @@ -0,0 +1,77 @@ +<script setup lang="ts"> +import { computed, inject, ref, watch } from 'vue' +import { useEventListener, useMediaQuery } from '@vueuse/core' +import { MEDIA_USAGE_KEY } from '@/lib/mediaUsageContext' +import { tooltipLines } from '@/lib/mediaUsage' + +const DESKTOP_QUERY = '(min-width: 48rem)' +const TOOLTIP_UI = { + content: + 'h-auto flex-col items-start gap-0 bg-inverted px-2 py-1.5 text-xs/4.5 text-inverted shadow-(--shadow-popover) ring-0', +} +const TOOLTIP_CONTENT = { side: 'bottom', align: 'start', sideOffset: 6 } as const + +const props = defineProps<{ + id: number + focused?: boolean +}>() + +const usage = inject(MEDIA_USAGE_KEY, null) +const isDesktop = useMediaQuery(DESKTOP_QUERY) +const hovered = ref(false) +const dismissed = ref(false) + +const engaged = computed(() => hovered.value || props.focused === true) +const lines = computed(() => { + const state = usage?.stateOf(props.id) + return state?.status === 'ready' ? tooltipLines(state.items) : [] +}) +const open = computed( + () => isDesktop.value && engaged.value && !dismissed.value && lines.value.length > 0, +) + +watch(engaged, (value) => { + if (!value) { + dismissed.value = false + return + } + if (isDesktop.value) { + usage?.ensure(props.id) + } +}) + +useEventListener(window, 'keydown', (event: KeyboardEvent) => { + if (event.key === 'Escape' && open.value) { + dismissed.value = true + } +}) +</script> + +<template> + <span + class="inline-flex" + data-testid="media-usage-trigger" + @pointerenter="hovered = true" + @pointerleave="hovered = false" + > + <UTooltip + v-if="open" + open + :content="TOOLTIP_CONTENT" + :ui="TOOLTIP_UI" + @update:open="() => undefined" + > + <span class="inline-flex"><slot /></span> + <template #content> + <span + v-for="line in lines" + :key="line" + data-testid="media-usage-line" + class="whitespace-nowrap" + >{{ line }}</span + > + </template> + </UTooltip> + <slot v-else /> + </span> +</template> diff --git a/web/src/components/MergeMediaDialog.vue b/web/src/components/MergeMediaDialog.vue index c06c2b3..ff82788 100644 --- a/web/src/components/MergeMediaDialog.vue +++ b/web/src/components/MergeMediaDialog.vue @@ -6,7 +6,7 @@ import ConfirmDialog from '@/components/ConfirmDialog.vue' import MediaThumb from '@/components/MediaThumb.vue' import type { MediaItem } from '@/composables/useMedia' import { mergeArticleCount } from '@/lib/mediaDuplicates' -import { pluralArticles } from '@/lib/mediaFormat' +import { pluralPlaces } from '@/lib/mediaFormat' const props = defineProps<{ keep: MediaItem @@ -20,11 +20,11 @@ const otherCount = computed(() => props.copies.length - 1) const others = computed(() => otherCount.value === 1 ? t('copy') : t('{0} copies', [otherCount.value]), ) -const articles = computed(() => mergeArticleCount(props.copies, props.keep.id)) +const places = computed(() => mergeArticleCount(props.copies, props.keep.id)) const description = computed(() => t( "Articles that use the other {0} will be switched to {1}, and the other {2} will be deleted. {3} will be updated. This can't be undone.", - [others.value, props.keep.filename, others.value, pluralArticles(articles.value)], + [others.value, props.keep.filename, others.value, pluralPlaces(places.value)], ), ) </script> @@ -48,7 +48,7 @@ const description = computed(() => <div class="flex min-w-0 flex-col"> <span class="truncate font-medium text-highlighted">{{ keep.filename }}</span> <span class="text-xs/5 text-muted" - >{{ t('Kept · used in') }} {{ pluralArticles(keep.used_in) }} + >{{ t('Kept · used in') }} {{ pluralPlaces(keep.used_in) }} {{ t('now') }}</span > </div> diff --git a/web/src/components/__tests__/ImageUploaderLibrary.spec.ts b/web/src/components/__tests__/ImageUploaderLibrary.spec.ts index 3ed1b23..12dae45 100644 --- a/web/src/components/__tests__/ImageUploaderLibrary.spec.ts +++ b/web/src/components/__tests__/ImageUploaderLibrary.spec.ts @@ -209,8 +209,8 @@ describe('Load more is hidden on the last page', () => { describe("A tile's accessible name", () => { it.each([ - ['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 articles'], - ['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 article'], + ['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 places'], + ['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 place'], ['window-frame.jpg', 'window-frame.jpg, 1.8 MB, unused'], ])('names %s', async (filename, name) => { await openLibrary() diff --git a/web/src/composables/useMedia.ts b/web/src/composables/useMedia.ts index c10e96e..2ac7ca9 100644 --- a/web/src/composables/useMedia.ts +++ b/web/src/composables/useMedia.ts @@ -1,4 +1,4 @@ -import { ref } from 'vue' +import { reactive, ref } from 'vue' import { tryOnBeforeUnmount } from '@vueuse/core' import { ApiError, useApi } from '@/composables/useApi' import { @@ -34,12 +34,28 @@ export interface MediaCounts { duplicates: number } -export interface MediaUsageItem { - id: number - title: string - published: boolean +export interface UsageFirstField { + key: string + type: string + value: unknown } +export interface MediaUsageItem { + type?: 'article' | 'author' | 'site' | 'entry' + id: number + title?: string + published?: boolean + fields?: string[] + content_type_key?: string + content_type_name?: string + first_field?: UsageFirstField +} + +export type UsageState = + | { status: 'loading' } + | { status: 'ready'; items: MediaUsageItem[] } + | { status: 'failed'; failure: MediaFailure } + export interface MediaMergeResult { articles_updated: number merged: number @@ -105,6 +121,9 @@ export function useMedia() { const showSkeleton = ref(false) let skeletonTimer: ReturnType<typeof setTimeout> | null = null let latestLoad = 0 + const usageStates = reactive<Record<number, UsageState>>({}) + const usageRequests = new Map<number, Promise<void>>() + let usageGeneration = 0 function clearSkeletonTimer() { if (skeletonTimer !== null) { @@ -179,6 +198,7 @@ export function useMedia() { return { kind: 'stale' } } items.value = response.items + clearUsage() total.value = response.total counts.value = response.counts status.value = 'ready' @@ -204,6 +224,54 @@ export function useMedia() { return request<MediaUsageItem[]>(`/v1/media/${id}/usage`) } + function clearUsage() { + usageGeneration += 1 + usageRequests.clear() + for (const id of Object.keys(usageStates)) { + delete usageStates[Number(id)] + } + } + + function usageOf(id: number): UsageState | null { + return usageStates[id] ?? null + } + + function ensureUsage(id: number, retryFailed = false) { + const current = usageStates[id] + const pending = usageRequests.get(id) + if (pending && current?.status !== 'failed') { + return pending + } + if (current && !(retryFailed && current.status === 'failed')) { + return Promise.resolve() + } + const generation = usageGeneration + usageStates[id] = { status: 'loading' } + const requested = fetchUsage(id) + .then( + (rows) => { + if (generation === usageGeneration) { + usageStates[id] = { + status: 'ready', + items: Array.isArray(rows) ? rows : [], + } + } + }, + (error: unknown) => { + if (generation === usageGeneration) { + usageStates[id] = { status: 'failed', failure: classifyMediaError(error) } + } + }, + ) + .finally(() => { + if (usageRequests.get(id) === requested) { + usageRequests.delete(id) + } + }) + usageRequests.set(id, requested) + return requested + } + async function findCopies(item: MediaItem) { const response = await request<MediaListResponse>( `/v1/media/?${toHashRequestQuery(item.sha256)}`, @@ -294,6 +362,8 @@ export function useMedia() { showSkeleton, load, fetchUsage, + usageOf, + ensureUsage, findCopies, renameItem, deleteItem, diff --git a/web/src/i18n/mediaUsageMessages.spec.ts b/web/src/i18n/mediaUsageMessages.spec.ts new file mode 100644 index 0000000..03df4b2 --- /dev/null +++ b/web/src/i18n/mediaUsageMessages.spec.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { messages } from './messages' + +const SOURCES = [ + 'components/MediaDetailPanel.vue', + 'components/DeleteMediaDialog.vue', + 'components/MediaCardBadges.vue', + 'components/BulkDeleteMediaDialog.vue', + 'components/MergeMediaDialog.vue', + 'lib/mediaUsage.ts', + 'lib/mediaFormat.ts', +] + +const SAME_IN_FRENCH = new Set<string>(['Type', 'Dimensions', '{0} copies']) + +const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g + +function keysIn(source: string) { + return [...source.matchAll(LITERAL)].map((match) => + (match[1] ?? match[2] ?? '').replace(/\\u0022/g, '"').replace(/\\(['"\\])/g, '$1'), + ) +} + +describe('Media usage messages', () => { + const keys = SOURCES.flatMap((file) => + keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({ + file, + key, + })), + ) + + it('finds the strings of the usage UI', () => { + expect(keys.length).toBeGreaterThan(30) + }) + + it.each(keys.map(({ file, key }) => [file, key]))( + '%s: "%s" has its own French entry', + (_file, key) => { + const french = (messages as Record<string, string>)[key as string] + + expect(french).toMatch(/\S/) + if (!SAME_IN_FRENCH.has(key as string)) { + expect(french).not.toBe(key) + } + }, + ) +}) diff --git a/web/src/i18n/messages.ts b/web/src/i18n/messages.ts index 22eb8af..6105755 100644 --- a/web/src/i18n/messages.ts +++ b/web/src/i18n/messages.ts @@ -508,7 +508,22 @@ export const messages = { "This can't be undone.": 'Cette action est irréversible.', 'None of them are used in any article. {0}': 'Aucune n’est utilisée dans un article. {0}', "It isn't used in any article. {0}": 'Elle n’est utilisée dans aucun article. {0}', - 'which will show a broken image.': 'qui afficheront une image manquante.', + 'which will show a broken image or a missing-image notice.': + 'qui afficheront une image manquante ou un avis d’image absente.', + 'Used in articles: {0}': 'Utilisée dans les articles : {0}', + 'Used in entries: {0}': 'Utilisée dans les entrées : {0}', + 'Used in entries': 'Utilisée dans les entrées', + 'and {0} more': 'et {0} autres', + '1 place': '1 emplacement', + '{0} places': '{0} emplacements', + 'No article shows it.': 'Aucun article ne l’affiche.', + "Couldn't load where this image is used.": 'Impossible de charger où cette image est utilisée.', + '"{0}" is used in {1} and {2}. Those entry fields will show "This image is no longer in the library", and the articles will show a broken image. This can\'t be undone.': + '« {0} » est utilisée dans {1} et {2}. Ces champs d’entrée afficheront « Cette image n’est plus dans la bibliothèque », et les articles afficheront une image manquante. Cette action est irréversible.', + '"{0}" is used in {1}. Those entry fields will show "This image is no longer in the library". This can\'t be undone.': + '« {0} » est utilisée dans {1}. Ces champs d’entrée afficheront « Cette image n’est plus dans la bibliothèque ». Cette action est irréversible.', + '"{0}" is in use. Articles will show a broken image and entry fields will show "This image is no longer in the library". This can\'t be undone.': + '« {0} » est utilisée. Les articles afficheront une image manquante et les champs d’entrée afficheront « Cette image n’est plus dans la bibliothèque ». Cette action est irréversible.', '{0} of them are used in {1}, {2} {3} {4}': '{0} sont utilisées dans {1}, {2} {3} {4}', '{0} deleted': '{0} supprimées', '{0} deleted. {1} {2} still in your library. Try again.': diff --git a/web/src/lib/__tests__/mediaFormat.spec.ts b/web/src/lib/__tests__/mediaFormat.spec.ts index ff1781c..131f78b 100644 --- a/web/src/lib/__tests__/mediaFormat.spec.ts +++ b/web/src/lib/__tests__/mediaFormat.spec.ts @@ -59,15 +59,15 @@ describe('formatType', () => { describe('usedInLabel and accessibleName', () => { it('pluralises the article count', () => { - expect(usedInLabel(1)).toBe('used in 1 article') - expect(usedInLabel(2)).toBe('used in 2 articles') + expect(usedInLabel(1)).toBe('used in 1 place') + expect(usedInLabel(2)).toBe('used in 2 places') expect(usedInLabel(0)).toBe('unused') }) it('builds the card name from filename, size and usage', () => { - expect(accessibleName(base)).toBe('north-wall.jpg, 3.9 MB, used in 2 articles') + expect(accessibleName(base)).toBe('north-wall.jpg, 3.9 MB, used in 2 places') expect(accessibleName({ ...base, used_in: 1 })).toBe( - 'north-wall.jpg, 3.9 MB, used in 1 article', + 'north-wall.jpg, 3.9 MB, used in 1 place', ) expect(accessibleName({ ...base, used_in: 0 })).toBe('north-wall.jpg, 3.9 MB, unused') }) diff --git a/web/src/lib/__tests__/mediaUsage.spec.ts b/web/src/lib/__tests__/mediaUsage.spec.ts new file mode 100644 index 0000000..5d9f270 --- /dev/null +++ b/web/src/lib/__tests__/mediaUsage.spec.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest' +import type { MediaUsageItem } from '@/composables/useMedia' +import { deleteCopy, tooltipLines, pluralEntries, splitUsage, usageEntryTitle } from '../mediaUsage' +import { pluralPlaces } from '../mediaFormat' + +function article(id: number, published = true): MediaUsageItem { + return { type: 'article', id, title: `Article ${id}`, published, fields: [] } +} + +function entry(id: number, firstField?: { type: string; value: unknown }): MediaUsageItem { + return { + type: 'entry', + id, + fields: ['thumbnail'], + content_type_key: 'events', + content_type_name: 'Events', + first_field: firstField ? { key: 'title', ...firstField } : undefined, + } +} + +function entries(count: number) { + return Array.from({ length: count }, (_, index) => + entry(index + 1, { type: 'text', value: `Entry title ${index + 1}` }), + ) +} + +describe('splitUsage', () => { + it('counts articles and entries and ignores authors and sites', () => { + const split = splitUsage([ + article(1), + article(2, false), + { type: 'author', id: 3, fields: [] }, + { type: 'site', id: 4, fields: [] }, + ...entries(2), + ]) + + expect(split.articles.map((row) => row.id)).toEqual([1, 2]) + expect(split.entryCount).toBe(2) + expect(split.entries).toHaveLength(2) + expect(split.more).toBe(0) + }) + + it.each([ + [1, 1, 0], + [3, 3, 0], + [4, 3, 1], + [5, 3, 2], + [120, 3, 117], + ])('with %i entries keeps %i rows and %i more', (count, rows, more) => { + const split = splitUsage(entries(count)) + + expect(split.entryCount).toBe(count) + expect(split.entries).toHaveLength(rows) + expect(split.more).toBe(more) + }) + + it('keeps the order of the API', () => { + const split = splitUsage([entry(9), entry(2), entry(5), entry(1)]) + + expect(split.entries.map((row) => row.id)).toEqual([9, 2, 5]) + }) + + it('gives zeros for an empty usage', () => { + expect(splitUsage([])).toMatchObject({ articles: [], entryCount: 0, more: 0 }) + }) + + it('reads a row without a type as an article', () => { + expect(splitUsage([{ id: 7, title: 'Old', published: true }]).articles).toHaveLength(1) + }) + + it('carries the content type name, key and the derived title of an entry', () => { + const [row] = splitUsage([ + entry(42, { type: 'text', value: 'Lime plaster workshop' }), + ]).entries + + expect(row).toEqual({ + id: 42, + key: 'events', + typeName: 'Events', + title: 'Lime plaster workshop', + }) + }) +}) + +describe('usageEntryTitle', () => { + it.each([ + ['text', 'Lime plaster workshop', 'Lime plaster workshop'], + ['text', '', 'Entry 42'], + ['text', null, 'Entry 42'], + ['richtext', '## Heritage walk\n\nMeet at 9', 'Heritage walk'], + ['richtext', '', 'Entry 42'], + ['number', 1500, '1,500'], + ['number', 0, '0'], + ['boolean', true, 'Yes'], + ['boolean', false, 'No'], + ['select', 'Workshop', 'Workshop'], + ['media', 7, 'Entry 42'], + ['media', null, 'Entry 42'], + ['mystery', 'Odd value', 'Odd value'], + ])('%s %j reads %s', (type, value, title) => { + expect(usageEntryTitle(entry(42, { type, value }))).toBe(title) + }) + + it('formats a date as the entry list does', () => { + expect(usageEntryTitle(entry(42, { type: 'date', value: '2026-11-21' }))).toBe( + '21 Nov 2026', + ) + }) + + it('falls back to the id when there is no first field', () => { + expect(usageEntryTitle(entry(42))).toBe('Entry 42') + }) +}) + +describe('wording', () => { + it('pluralises entries and places', () => { + expect(pluralEntries(1)).toBe('1 entry') + expect(pluralEntries(5)).toBe('5 entries') + expect(pluralPlaces(1)).toBe('1 place') + expect(pluralPlaces(7)).toBe('7 places') + }) +}) + +describe('tooltipLines', () => { + it.each([ + [[...entries(3)], ['Used in entries: 3']], + [ + [article(1), article(2), ...entries(5)], + ['Used in articles: 2', 'Used in entries: 5'], + ], + [[article(1), article(2)], ['Used in articles: 2']], + [[{ type: 'author', id: 1, fields: [] }], []], + ])('reads %#', (items, lines) => { + expect(tooltipLines(items as MediaUsageItem[])).toEqual(lines) + }) +}) + +describe('deleteCopy', () => { + const NOTICE = 'This image is no longer in the library' + + it.each([ + [ + [...Array(2).keys()].map((id) => article(id + 1)).concat(entries(5)), + "Delete an image that's in use?", + `"lime-plaster.jpg" is used in 5 entries and 2 articles. Those entry fields will show "${NOTICE}", and the articles will show a broken image. This can't be undone.`, + ], + [ + [article(1), entry(1)], + "Delete an image that's in use?", + `"lime-plaster.jpg" is used in 1 entry and 1 article. Those entry fields will show "${NOTICE}", and the articles will show a broken image. This can't be undone.`, + ], + [ + [article(1), article(2)], + "Delete an image that's in use?", + `"lime-plaster.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can't be undone.`, + ], + [ + entries(3), + "Delete an image that's in use?", + `"lime-plaster.jpg" is used in 3 entries. Those entry fields will show "${NOTICE}". This can't be undone.`, + ], + ])('case %#', (items, title, description) => { + const copy = deleteCopy('lime-plaster.jpg', items.length, { + status: 'ready', + items: items as MediaUsageItem[], + }) + + expect(copy.title).toBe(title) + expect(copy.description).toBe(description) + expect(copy.split).not.toBeNull() + }) + + it('is the unused dialog at used_in 0', () => { + const copy = deleteCopy('lime-plaster.jpg', 0, null) + + expect(copy.title).toBe('Delete this image?') + expect(copy.description).toBe( + `"lime-plaster.jpg" will be deleted from your library. No article uses it. This can't be undone.`, + ) + expect(copy.split).toBeNull() + }) + + it.each([ + ['loading', { status: 'loading' } as const], + ['failed', { status: 'failed', failure: 'failed' } as const], + ['absent', null], + [ + 'authors only', + { status: 'ready', items: [{ type: 'author', id: 1, fields: [] }] } as const, + ], + ])('uses the generic copy when the usage is %s', (_name, state) => { + const copy = deleteCopy('lime-plaster.jpg', 2, state as never) + + expect(copy.title).toBe("Delete an image that's in use?") + expect(copy.description).toBe( + `"lime-plaster.jpg" is in use. Articles will show a broken image and entry fields will show "${NOTICE}". This can't be undone.`, + ) + expect(copy.split).toBeNull() + }) +}) diff --git a/web/src/lib/mediaFormat.ts b/web/src/lib/mediaFormat.ts index 9ca9a09..b5e7065 100644 --- a/web/src/lib/mediaFormat.ts +++ b/web/src/lib/mediaFormat.ts @@ -71,8 +71,12 @@ export function pluralArticles(count: number) { return t(count === 1 ? '1 article' : '{0} articles', [count]) } +export function pluralPlaces(count: number) { + return t(count === 1 ? '1 place' : '{0} places', [count]) +} + export function usedInLabel(count: number) { - return count > 0 ? t('used in {0}', [pluralArticles(count)]) : t('unused') + return count > 0 ? t('used in {0}', [pluralPlaces(count)]) : t('unused') } export function accessibleName(item: MediaNameSource) { diff --git a/web/src/lib/mediaUsage.ts b/web/src/lib/mediaUsage.ts new file mode 100644 index 0000000..95dd582 --- /dev/null +++ b/web/src/lib/mediaUsage.ts @@ -0,0 +1,140 @@ +import { t } from '@/i18n' +import type { MediaUsageItem, UsageState } from '@/composables/useMedia' +import { describeCell, plainCellText } from '@/lib/entryCells' +import { pluralArticles } from '@/lib/mediaFormat' + +const ENTRY_ROWS_SHOWN = 3 +const ARTICLE_TYPE = 'article' +const ENTRY_TYPE = 'entry' + +export interface UsageArticle { + id: number + title: string + published: boolean +} + +export interface UsageEntry { + id: number + key: string + typeName: string + title: string +} + +export interface UsageSplit { + articles: UsageArticle[] + entries: UsageEntry[] + entryCount: number + more: number +} + +export function pluralEntries(count: number) { + return t(count === 1 ? '1 entry' : '{0} entries', [count]) +} + +export function usageEntryTitle(item: MediaUsageItem) { + const fallback = t('Entry {0}', [item.id]) + const first = item.first_field + if (!first) { + return fallback + } + const cell = describeCell( + { + key: first.key, + label: first.key, + type: first.type, + required: false, + unique: false, + default: null, + min: null, + max: null, + options: null, + }, + first.value, + ) + return cell.kind === 'empty' || cell.kind === 'thumbnail' ? fallback : plainCellText(cell) +} + +function toEntry(item: MediaUsageItem): UsageEntry { + return { + id: item.id, + key: item.content_type_key ?? '', + typeName: item.content_type_name ?? '', + title: usageEntryTitle(item), + } +} + +export function splitUsage(items: MediaUsageItem[]): UsageSplit { + const articles = items + .filter((item) => item.type === undefined || item.type === ARTICLE_TYPE) + .map((item) => ({ + id: item.id, + title: item.title ?? '', + published: item.published === true, + })) + const entryItems = items.filter((item) => item.type === ENTRY_TYPE) + return { + articles, + entries: entryItems.slice(0, ENTRY_ROWS_SHOWN).map(toEntry), + entryCount: entryItems.length, + more: Math.max(0, entryItems.length - ENTRY_ROWS_SHOWN), + } +} + +export function tooltipLines(items: MediaUsageItem[]) { + const split = splitUsage(items) + return [ + split.articles.length > 0 ? t('Used in articles: {0}', [split.articles.length]) : '', + split.entryCount > 0 ? t('Used in entries: {0}', [split.entryCount]) : '', + ].filter(Boolean) +} + +export function usageSplitOf(state: UsageState | null) { + return state?.status === 'ready' ? splitUsage(state.items) : null +} + +function inUseDescription(filename: string, split: UsageSplit | null) { + const articles = split?.articles.length ?? 0 + const entries = split?.entryCount ?? 0 + if (articles > 0 && entries > 0) { + return t( + "\u0022{0}\u0022 is used in {1} and {2}. Those entry fields will show \u0022This image is no longer in the library\u0022, and the articles will show a broken image. This can't be undone.", + [filename, pluralEntries(entries), pluralArticles(articles)], + ) + } + if (articles > 0) { + return t( + "\u0022{0}\u0022 is used in {1}. If you delete it, they will show a broken image. This can't be undone.", + [filename, pluralArticles(articles)], + ) + } + if (entries > 0) { + return t( + "\u0022{0}\u0022 is used in {1}. Those entry fields will show \u0022This image is no longer in the library\u0022. This can't be undone.", + [filename, pluralEntries(entries)], + ) + } + return t( + "\u0022{0}\u0022 is in use. Articles will show a broken image and entry fields will show \u0022This image is no longer in the library\u0022. This can't be undone.", + [filename], + ) +} + +export function deleteCopy(filename: string, usedIn: number, state: UsageState | null) { + if (usedIn <= 0) { + return { + title: t('Delete this image?'), + description: t( + "\u0022{0}\u0022 will be deleted from your library. No article uses it. This can't be undone.", + [filename], + ), + split: null, + } + } + const split = usageSplitOf(state) + const known = split !== null && (split.articles.length > 0 || split.entryCount > 0) + return { + title: t("Delete an image that's in use?"), + description: inUseDescription(filename, known ? split : null), + split: known ? split : null, + } +} diff --git a/web/src/lib/mediaUsageContext.ts b/web/src/lib/mediaUsageContext.ts new file mode 100644 index 0000000..3b0d6a7 --- /dev/null +++ b/web/src/lib/mediaUsageContext.ts @@ -0,0 +1,9 @@ +import type { InjectionKey } from 'vue' +import type { UsageState } from '@/composables/useMedia' + +export interface MediaUsageContext { + stateOf: (id: number) => UsageState | null + ensure: (id: number) => void +} + +export const MEDIA_USAGE_KEY: InjectionKey<MediaUsageContext> = Symbol('media-usage') diff --git a/web/src/views/MediaView.vue b/web/src/views/MediaView.vue index b0f04e5..9e36f80 100644 --- a/web/src/views/MediaView.vue +++ b/web/src/views/MediaView.vue @@ -1,7 +1,7 @@ <script setup lang="ts"> import { t } from '@/i18n' -import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' +import { computed, nextTick, onBeforeUnmount, provide, ref, watch } from 'vue' import { useRoute, type LocationQuery } from 'vue-router' import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core' import { useLocalizedToast } from '@/i18n/presentation' @@ -30,7 +30,6 @@ import { useMedia, type BulkDeleteResult, type MediaItem, - type MediaUsageItem, } from '@/composables/useMedia' import { useMediaSelection } from '@/composables/useMediaSelection' import { useMediaUpload } from '@/composables/useMediaUpload' @@ -39,6 +38,7 @@ import type { PurgeResult } from '@/composables/useMediaPurge' import { toastsAboveBulkBar } from '@/composables/useToastOffset' import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates' import { joinNames } from '@/lib/mediaFormat' +import { MEDIA_USAGE_KEY } from '@/lib/mediaUsageContext' import { isFullSet, parseMediaControls, @@ -149,7 +149,6 @@ const purgeOpen = ref(false) const dragDepth = ref(0) const announcementSource = ref<() => string>(() => '') const announcement = computed(() => announcementSource.value()) -const usage = ref<MediaUsageItem[]>([]) const copies = ref<MediaItem[]>([]) let opener: HTMLElement | null = null let purgeChangedList = false @@ -160,9 +159,25 @@ let bulkFailureRevision = 0 let bulkFailureToastId = BULK_FAILED_TOAST_ID let failureToastShown = false +provide(MEDIA_USAGE_KEY, { + stateOf: media.usageOf, + ensure: (id) => { + void media.ensureUsage(id) + }, +}) + const activeItem = computed( () => media.items.value.find((item) => item.id === activeId.value) ?? null, ) +watch( + () => + panelOpen.value && activeItem.value !== null && media.usageOf(activeItem.value.id) === null, + (missing) => { + if (missing && activeItem.value && activeItem.value.used_in > 0) { + loadUsage(activeItem.value) + } + }, +) const closingItem = ref<MediaItem | null>(null) const panelItem = computed(() => activeItem.value ?? closingItem.value) const counts = computed(() => (hasLoaded.value ? media.counts.value : null)) @@ -365,15 +380,10 @@ function restoreFocusToOpener(id: number | null) { } async function loadUsage(item: MediaItem) { - try { - const rows = await media.fetchUsage(item.id) - if (activeId.value === item.id) { - usage.value = rows - } - } catch (error) { - if (activeId.value === item.id && classifyMediaError(error) === 'not-found') { - handleGone(item) - } + await media.ensureUsage(item.id, true) + const state = media.usageOf(item.id) + if (activeId.value === item.id && state?.status === 'failed' && state.failure === 'not-found') { + handleGone(item) } } @@ -395,7 +405,6 @@ function openPanel(id: number, element: HTMLElement) { } opener = element activeId.value = id - usage.value = [] copies.value = [] skipFocusRestore = false panelOpen.value = true @@ -1150,7 +1159,7 @@ async function mergeAction() { <MediaDetailPanel :open="panelOpen" :item="panelItem" - :usage="usage" + :usage="panelItem ? media.usageOf(panelItem.id) : null" :copies="copies" :offline="offline" @update:open="onPanelOpenChange" @@ -1158,6 +1167,7 @@ async function mergeAction() { @rename="renameOpen = true" @delete="deleteOpen = true" @review-duplicates="reviewDuplicates" + @retry-usage="activeItem && loadUsage(activeItem)" @gone="activeItem && handleGone(activeItem)" /> @@ -1171,7 +1181,7 @@ async function mergeAction() { <DeleteMediaDialog v-if="deleteOpen && activeItem" :item="activeItem" - :usage="usage" + :usage="media.usageOf(activeItem.id)" :action="deleteAction" @close="deleteOpen = false" /> diff --git a/web/src/views/__tests__/MediaDuplicates.spec.ts b/web/src/views/__tests__/MediaDuplicates.spec.ts index abb27a1..61e1b4d 100644 --- a/web/src/views/__tests__/MediaDuplicates.spec.ts +++ b/web/src/views/__tests__/MediaDuplicates.spec.ts @@ -264,9 +264,9 @@ describe('Merge', () => { const box = dialog()! expect(textOf(box)).toContain('Merge into floorboards.jpg?') expect(textOf(box)).toContain( - "Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can't be undone.", + "Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 places will be updated. This can't be undone.", ) - expect(textOf(box)).toContain('floorboards.jpg Kept · used in 4 articles now') + expect(textOf(box)).toContain('floorboards.jpg Kept · used in 4 places now') expect(Array.from(box.querySelectorAll('button')).map(textOf)).toEqual([ 'Cancel', 'Merge 2 copies', @@ -278,7 +278,7 @@ describe('Merge', () => { await openMerge('IMG_2208.jpg') expect(textOf(dialog())).toContain('Merge into IMG_2208.jpg?') - expect(textOf(dialog())).toContain('7 articles will be updated.') + expect(textOf(dialog())).toContain('7 places will be updated.') }) it('uses the singular for a group of two copies', async () => { diff --git a/web/src/views/__tests__/MediaSelection.spec.ts b/web/src/views/__tests__/MediaSelection.spec.ts index 2bcc7af..94f0636 100644 --- a/web/src/views/__tests__/MediaSelection.spec.ts +++ b/web/src/views/__tests__/MediaSelection.spec.ts @@ -357,7 +357,7 @@ describe('Bulk delete', () => { const box = dialog()! expect(textOf(box)).toContain('Delete 3 images?') expect(textOf(box)).toContain( - "2 of them are used in 5 articles, which will show a broken image. The other one isn't used anywhere. This can't be undone.", + "2 of them are used in 5 places, which will show a broken image or a missing-image notice. The other one isn't used anywhere. This can't be undone.", ) const rows = Array.from(box.querySelectorAll('[data-testid="bulk-delete-row"]')).map(textOf) expect(rows).toEqual([ diff --git a/web/src/views/__tests__/MediaUsage.spec.ts b/web/src/views/__tests__/MediaUsage.spec.ts new file mode 100644 index 0000000..1ae55ba --- /dev/null +++ b/web/src/views/__tests__/MediaUsage.spec.ts @@ -0,0 +1,662 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flushPromises } from '@vue/test-utils' +import { ApiError, NetworkError } from '@/composables/useApi' +import type { MediaUsageItem } from '@/composables/useMedia' +import { MOBILE_WIDTH } from '@/components/__tests__/viewport' +import { + callsTo, + cardFor, + cards, + deferred, + dialog, + fixture, + findButton, + listBody, + mountPage, + panel, + settle, + textOf, + unmountPage, + type Handlers, +} from './mediaTestKit' + +vi.mock('@/composables/useApi', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/composables/useApi')>() + return { ...actual, useApi: vi.fn() } +}) + +const USED_IN: Record<number, number> = { 1: 2, 8: 0, 10: 3, 11: 7, 4: 2 } +const NOTICE = 'This image is no longer in the library' + +function article(id: number, title: string, published: boolean): MediaUsageItem { + return { type: 'article', id, title, published, fields: [] } +} + +function eventEntry(id: number, title: unknown, type = 'text'): MediaUsageItem { + return { + type: 'entry', + id, + fields: ['thumbnail'], + content_type_key: 'events', + content_type_name: 'Events', + first_field: { key: 'title', type, value: title }, + } +} + +const LIME_TITLES = ['Lime plaster workshop', 'Heritage stone walk', 'Winter open day'] + +function entriesOf(count: number): MediaUsageItem[] { + return Array.from({ length: count }, (_, index) => + eventEntry(index + 1, LIME_TITLES[index] ?? `Event ${index + 1}`), + ) +} + +const ARTICLES: MediaUsageItem[] = [ + article(20, 'Open day at the workshop', true), + article(21, 'Restoring lime plaster', false), +] + +const USAGE: Record<number, MediaUsageItem[]> = { + 1: ARTICLES, + 4: [ + { type: 'author', id: 1, fields: ['avatar'] }, + { type: 'site', id: 2, fields: ['logo'] }, + ], + 10: entriesOf(3), + 11: [...ARTICLES, ...entriesOf(5)], +} + +function library(): Handlers { + return { + list: () => + listBody( + fixture().map((item) => + item.id in USED_IN ? { ...item, used_in: USED_IN[item.id]! } : item, + ), + ), + usage: (id) => USAGE[id] ?? [], + } +} + +async function open(filename: string) { + cardFor(filename).click() + await settle() +} + +function usageCalls() { + return callsTo('GET', /\/usage$/) +} + +function badgeTexts(root: Element) { + return Array.from(root.querySelectorAll('[data-kind]')).map(textOf) +} + +function tooltipLines() { + return Array.from(document.body.querySelectorAll('[data-testid="media-usage-line"]')).map( + textOf, + ) +} + +function triggerOf(filename: string) { + return cardFor(filename).querySelector<HTMLElement>('[data-testid="media-usage-trigger"]')! +} + +async function hover(filename: string) { + triggerOf(filename).dispatchEvent(new MouseEvent('pointerenter')) + await settle() +} + +afterEach(unmountPage) + +describe('The label of the usage badge', () => { + it.each([ + ['window-frame.jpg', 'Unused'], + ['kitchen-before.webp', 'Used in 1'], + ['bathroom-tiles.png', 'Used in 3'], + ['lime-plaster.jpg', 'Used in 7'], + ])('%s reads %s in the grid', async (filename, label) => { + await mountPage(library(), '/media', undefined, { app: true }) + + const badges = badgeTexts(cardFor(filename)) + + expect(badges).toContain(label) + expect( + cardFor(filename).querySelector('[data-kind="used"] svg, [data-kind="used"] i'), + ).toBeNull() + }) + + it('reads the same in the list row and as a button', async () => { + await mountPage(library(), '/media?view=list', undefined, { app: true }) + + const rows = Array.from(document.body.querySelectorAll('[data-testid="media-row"]')) + const lime = rows.find((row) => textOf(row).includes('lime-plaster.jpg'))! + const unused = rows.find((row) => textOf(row).includes('window-frame.jpg'))! + + const button = lime.querySelector<HTMLElement>('[data-testid="media-used-in"]')! + expect(textOf(button)).toBe('Used in 7') + expect(button.className).toContain('underline') + expect(textOf(unused.querySelector('[data-kind="unused"]'))).toBe('Unused') + expect(textOf(lime)).not.toMatch(/\d+ articles?/) + expect(textOf(unused)).not.toMatch(/\d+ articles?/) + }) + + it('reads the same in the mobile card', async () => { + await mountPage(library(), '/media?view=list', MOBILE_WIDTH, { app: true }) + + const row = Array.from( + document.body.querySelectorAll('[data-testid="media-card-row"]'), + ).find((entry) => textOf(entry).includes('bathroom-tiles.png'))! + + expect(badgeTexts(row)).toContain('Used in 3') + }) + + it.each([ + ['window-frame.jpg', 'unused'], + ['kitchen-before.webp', 'used in 1 place'], + ['lime-plaster.jpg', 'used in 7 places'], + ])('names the card of %s with "%s"', async (filename, usage) => { + await mountPage(library(), '/media', undefined, { app: true }) + + expect(cardFor(filename).getAttribute('aria-label')).toMatch(new RegExp(`, ${usage}$`)) + }) +}) + +describe('The usage tooltip', () => { + it('opens on hover with the entries line only', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await hover('bathroom-tiles.png') + + expect(callsTo('GET', /\/v1\/media\/10\/usage$/)).toHaveLength(1) + expect(tooltipLines()).toEqual(['Used in entries: 3']) + }) + + it('lists articles first then entries', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await hover('lime-plaster.jpg') + + expect(tooltipLines()).toEqual(['Used in articles: 2', 'Used in entries: 5']) + }) + + it('shows articles only without an entries line', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await hover('north-wall.jpg') + + expect(tooltipLines()).toEqual(['Used in articles: 2']) + }) + + it('closes when the pointer leaves', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + await hover('bathroom-tiles.png') + + triggerOf('bathroom-tiles.png').dispatchEvent(new MouseEvent('pointerleave')) + await settle() + + expect(tooltipLines()).toEqual([]) + }) + + it('closes on Escape without closing anything else', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + await hover('bathroom-tiles.png') + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + await settle() + + expect(tooltipLines()).toEqual([]) + expect(cards().length).toBeGreaterThan(0) + }) + + it('opens when the card takes the keyboard focus', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + cardFor('bathroom-tiles.png').focus() + await settle() + + expect(tooltipLines()).toEqual(['Used in entries: 3']) + + cardFor('bathroom-tiles.png').blur() + await settle() + + expect(tooltipLines()).toEqual([]) + }) + + it('fetches once per image until the list is reloaded', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await hover('bathroom-tiles.png') + await hover('bathroom-tiles.png') + await open('bathroom-tiles.png') + + expect(callsTo('GET', /\/v1\/media\/10\/usage$/)).toHaveLength(1) + }) + + it('waits for the usage then opens if the pointer is still there', async () => { + const pending = deferred<MediaUsageItem[]>() + await mountPage({ ...library(), usage: () => pending.promise }, '/media', undefined, { + app: true, + }) + + await hover('bathroom-tiles.png') + expect(tooltipLines()).toEqual([]) + expect(textOf(triggerOf('bathroom-tiles.png'))).toBe('Used in 3') + + pending.resolve(USAGE[10]!) + await settle() + + expect(tooltipLines()).toEqual(['Used in entries: 3']) + }) + + it.each([ + ['answers 500', () => Promise.reject(new ApiError(500))], + ['fails on the network', () => Promise.reject(new NetworkError())], + ['answers 404', () => Promise.reject(new ApiError(404))], + ['answers only authors and sites', () => USAGE[4]], + ])('shows nothing when the usage %s', async (_name, usage) => { + await mountPage({ ...library(), usage }, '/media', undefined, { app: true }) + + await hover('kitchen-after.webp') + await hover('bathroom-tiles.png') + + expect(tooltipLines()).toEqual([]) + expect(cards()).toHaveLength(12) + }) + + it('does not open on mobile and sends no request', async () => { + await mountPage(library(), '/media', MOBILE_WIDTH, { app: true }) + + await hover('bathroom-tiles.png') + + expect(tooltipLines()).toEqual([]) + expect(usageCalls()).toHaveLength(0) + expect(textOf(cardFor('bathroom-tiles.png'))).toContain('Used in 3') + }) + + it('does not send a request for an unused image', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + cardFor('window-frame.jpg').focus() + await settle() + + expect(tooltipLines()).toEqual([]) + expect(usageCalls()).toHaveLength(0) + expect( + cardFor('window-frame.jpg').querySelector('[data-testid="media-usage-trigger"]'), + ).toBeNull() + }) +}) + +describe('The detail panel', () => { + function rows(selector: string) { + return Array.from(panel()!.querySelectorAll(selector)) + } + + it('lists the articles and the first three entries then "and 2 more"', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await open('lime-plaster.jpg') + + const content = panel()! + const headings = Array.from(content.querySelectorAll('h3')).map(textOf) + expect(headings).toEqual(['Used in 2 articles', 'Used in entries']) + expect(rows('[data-testid="usage-row"]').map(textOf)).toEqual([ + 'Open day at the workshop Online', + 'Restoring lime plaster Draft', + ]) + const entries = rows('[data-testid="usage-entry-row"]') + expect(entries.map(textOf)).toEqual([ + 'Events Lime plaster workshop', + 'Events Heritage stone walk', + 'Events Winter open day', + ]) + const link = entries[0]!.querySelector('a')! + expect(link.getAttribute('href')).toBe('/content/events/1') + expect(link.getAttribute('title')).toBe('Lime plaster workshop') + expect(link.getAttribute('aria-label')).toBe('Lime plaster workshop, Events') + expect(link.className).toContain('underline') + expect(link.className).toContain('truncate') + expect(entries[0]!.className).toContain('h-12') + expect(entries[0]!.className).toContain('md:h-9') + expect(entries[0]!.querySelector('[aria-hidden="true"]')).not.toBeNull() + const more = content.querySelector('[data-testid="usage-more"]')! + expect(textOf(more)).toBe('and 2 more') + expect(more.querySelector('a, button')).toBeNull() + expect(more.hasAttribute('tabindex')).toBe(false) + expect(link.className).toContain('focus-visible:outline-2') + expect(link.className).toContain('focus-visible:outline-primary') + const focusables = Array.from( + content.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), [tabindex]'), + ) + const entryLinks = Array.from(content.querySelectorAll('[data-testid="usage-entry-row"] a')) + const order = focusables.filter((element) => entryLinks.includes(element)) + expect(order).toEqual(entryLinks) + expect(focusables.some((element) => more.contains(element))).toBe(false) + expect(textOf(content)).toContain( + 'Counted from the text of your articles each time you open this page.', + ) + }) + + it.each([ + [1, 1, null], + [3, 3, null], + [4, 3, 'and 1 more'], + [5, 3, 'and 2 more'], + [120, 3, 'and 117 more'], + ])('with %i entries shows %i rows', async (count, shown, more) => { + await mountPage({ ...library(), usage: () => entriesOf(count) }, '/media', undefined, { + app: true, + }) + + await open('bathroom-tiles.png') + + expect(rows('[data-testid="usage-entry-row"]')).toHaveLength(shown) + const moreRow = panel()!.querySelector('[data-testid="usage-more"]') + expect(moreRow ? textOf(moreRow) : null).toBe(more) + }) + + it('says no article shows an image used by entries only', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await open('bathroom-tiles.png') + + const content = panel()! + expect(textOf(content.querySelector('h3'))).toBe('Not used in any article') + expect(textOf(content)).toContain('No article shows it.') + expect(textOf(content)).not.toContain('safe to delete') + expect(textOf(content)).toContain('Used in entries') + }) + + it('is unchanged for an image used by articles only', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await open('north-wall.jpg') + + expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles') + expect(panel()!.querySelector('[data-testid="usage-entries"]')).toBeNull() + }) + + it('says nothing is safe to claim for authors and sites only', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await open('logo-renew.svg') + + const content = panel()! + expect(textOf(content.querySelector('h3'))).toBe('Not used in any article') + expect(textOf(content)).toContain('No article shows it.') + expect(textOf(content)).not.toContain('safe to delete') + expect(content.querySelector('[data-testid="usage-row"]')).toBeNull() + expect(content.querySelector('[data-testid="usage-entry-row"]')).toBeNull() + }) + + it('sends no request and claims it is safe for an unused image', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await open('window-frame.jpg') + + expect(usageCalls()).toHaveLength(0) + expect(textOf(panel())).toContain('This image is safe to delete. No article shows it.') + }) + + it('draws a taller banner preview only when entries use the image', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await open('lime-plaster.jpg') + const preview = panel()!.querySelector('[data-testid="media-thumb"]')! + expect(preview.className).toContain('aspect-panel-banner') + expect(preview.className).toContain('md:aspect-16/6') + }) + + it('keeps the default preview without entries', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await open('north-wall.jpg') + + const preview = panel()!.querySelector('[data-testid="media-thumb"]')! + expect(preview.className).toContain('aspect-4/3') + expect(preview.className).toContain('md:aspect-16/10') + }) + + it('shows the skeleton after 150 ms, never before, and the sections after the answer', async () => { + const pending = deferred<MediaUsageItem[]>() + await mountPage({ ...library(), usage: () => pending.promise }, '/media', undefined, { + app: true, + }) + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + cardFor('lime-plaster.jpg').click() + await flushPromises() + + expect(textOf(panel()!.querySelector('h3'))).toBe('Used in') + expect(panel()!.querySelector('[data-testid="usage-skeleton"]')).toBeNull() + expect(findButton('Delete', panel()!)!.disabled).toBe(false) + + vi.advanceTimersByTime(150) + await flushPromises() + + expect(panel()!.querySelectorAll('[data-testid="usage-skeleton"] li')).toHaveLength(3) + + vi.useRealTimers() + pending.resolve(USAGE[11]!) + await settle() + + expect(panel()!.querySelector('[data-testid="usage-skeleton"]')).toBeNull() + expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles') + }) + + it.each([ + ['answers 500', () => Promise.reject(new ApiError(500))], + ['fails on the network', () => Promise.reject(new NetworkError())], + ['answers 429', () => Promise.reject(new ApiError(429))], + ])('shows the failure and Try again when the usage %s', async (_name, failure) => { + let calls = 0 + await mountPage( + { + ...library(), + usage: (id) => (++calls === 1 ? failure() : USAGE[id]), + }, + '/media', + undefined, + { app: true }, + ) + + await open('lime-plaster.jpg') + + expect(textOf(panel()!.querySelector('[data-testid="usage-error"]'))).toContain( + "Couldn't load where this image is used.", + ) + expect(findButton('Delete', panel()!)!.disabled).toBe(false) + + const listCalls = callsTo('GET', /^\/v1\/media\/\?/).length + findButton('Try again', panel()!)!.click() + await settle() + + expect(usageCalls()).toHaveLength(2) + expect(callsTo('GET', /^\/v1\/media\/\?/)).toHaveLength(listCalls) + expect(panel()!.querySelector('[data-testid="usage-error"]')).toBeNull() + expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles') + }) + + it('closes the panel and says the image is gone on a 404', async () => { + await mountPage( + { + ...library(), + usage: () => Promise.reject(new ApiError(404)), + }, + '/media', + undefined, + { app: true }, + ) + + await open('lime-plaster.jpg') + + expect(panel()).toBeNull() + expect(textOf(document.body)).toContain('That image no longer exists') + }) + + it('ignores a late answer of the previous image', async () => { + const slow = deferred<MediaUsageItem[]>() + await mountPage( + { ...library(), usage: (id) => (id === 10 ? slow.promise : USAGE[id]) }, + '/media', + undefined, + { app: true }, + ) + + await open('bathroom-tiles.png') + panel()!.querySelector<HTMLElement>('[data-slot="close"]')?.click() + await settle() + await open('north-wall.jpg') + slow.resolve(USAGE[10]!) + await settle() + + const content = panel()! + expect(textOf(content.querySelector('h3'))).toBe('Used in 2 articles') + expect(content.querySelector('[data-testid="usage-entry-row"]')).toBeNull() + }) +}) + +describe('The delete dialog', () => { + async function openDelete(filename: string) { + await open(filename) + findButton('Delete', panel()!)!.click() + await settle() + } + + it('lists entries then articles for an image used by both', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await openDelete('lime-plaster.jpg') + + const box = dialog()! + expect(textOf(box.querySelector('[data-slot="title"]'))).toBe( + "Delete an image that's in use?", + ) + expect(textOf(box.querySelector('[data-slot="description"]'))).toBe( + `"lime-plaster.jpg" is used in 5 entries and 2 articles. Those entry fields will show "${NOTICE}", and the articles will show a broken image. This can't be undone.`, + ) + const lists = Array.from(box.querySelectorAll('ul')) + expect(lists.map((list) => list.getAttribute('data-testid'))).toEqual([ + 'affected-entries', + 'affected-articles', + ]) + expect(textOf(box.querySelector('h3'))).toBe('Used in entries') + expect( + Array.from(box.querySelectorAll('[data-testid="affected-entry"]')).map(textOf), + ).toEqual([ + 'Events Lime plaster workshop', + 'Events Heritage stone walk', + 'Events Winter open day', + ]) + expect(textOf(box.querySelector('[data-testid="affected-more"]'))).toBe('and 2 more') + expect(box.querySelector('[data-testid="affected-entry"] a')).toBeNull() + expect( + Array.from(box.querySelectorAll('[data-testid="affected-article"]')).map(textOf), + ).toEqual(['Open day at the workshop Online', 'Restoring lime plaster Draft']) + expect(findButton('Cancel', box)).toBeTruthy() + expect(findButton('Delete image', box)).toBeTruthy() + }) + + it('words an image used by entries only', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await openDelete('bathroom-tiles.png') + + expect(textOf(dialog()!.querySelector('[data-slot="description"]'))).toBe( + `"bathroom-tiles.png" is used in 3 entries. Those entry fields will show "${NOTICE}". This can't be undone.`, + ) + expect(dialog()!.querySelector('[data-testid="affected-articles"]')).toBeNull() + }) + + it('keeps the unused copy and sends no request', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await openDelete('window-frame.jpg') + + expect(textOf(dialog()!.querySelector('[data-slot="title"]'))).toBe('Delete this image?') + expect(dialog()!.querySelector('ul')).toBeNull() + expect(usageCalls()).toHaveLength(0) + }) + + it('uses the generic copy for authors and sites only', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await openDelete('logo-renew.svg') + + expect(textOf(dialog()!.querySelector('[data-slot="title"]'))).toBe( + "Delete an image that's in use?", + ) + expect(textOf(dialog()!.querySelector('[data-slot="description"]'))).toBe( + `"logo-renew.svg" is in use. Articles will show a broken image and entry fields will show "${NOTICE}". This can't be undone.`, + ) + expect(dialog()!.querySelector('ul')).toBeNull() + }) + + it('opens at once with the generic copy then switches when the usage answers', async () => { + const pending = deferred<MediaUsageItem[]>() + await mountPage({ ...library(), usage: () => pending.promise }, '/media', undefined, { + app: true, + }) + + await openDelete('lime-plaster.jpg') + + expect(textOf(dialog()!.querySelector('[data-slot="description"]'))).toContain( + '"lime-plaster.jpg" is in use.', + ) + expect(dialog()!.querySelector('ul')).toBeNull() + expect(findButton('Delete image', dialog()!)!.disabled).toBe(false) + const cancel = findButton('Cancel', dialog()!)! + const focused = document.activeElement + + pending.resolve(USAGE[11]!) + await settle() + + expect(textOf(dialog()!.querySelector('[data-slot="description"]'))).toContain( + 'is used in 5 entries and 2 articles.', + ) + expect(dialog()!.querySelectorAll('ul')).toHaveLength(2) + expect(document.activeElement).toBe(focused) + expect(cancel.isConnected).toBe(true) + }) + + it('uses the generic copy after a failure', async () => { + await mountPage( + { ...library(), usage: () => Promise.reject(new ApiError(500)) }, + '/media', + undefined, + { app: true }, + ) + + await openDelete('lime-plaster.jpg') + + expect(textOf(dialog()!.querySelector('[data-slot="description"]'))).toContain( + '"lime-plaster.jpg" is in use.', + ) + expect(findButton('Delete image', dialog()!)!.disabled).toBe(false) + }) + + it('returns the focus to the Delete button of the panel on Cancel', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + await open('lime-plaster.jpg') + const trigger = findButton('Delete', panel()!)! + trigger.focus() + trigger.click() + await settle() + + findButton('Cancel', dialog()!)!.click() + await settle() + + expect(dialog()).toBeNull() + expect(document.activeElement).toBe(trigger) + }) + + it('deletes with the usual request', async () => { + await mountPage(library(), '/media', undefined, { app: true }) + + await openDelete('lime-plaster.jpg') + findButton('Delete image', dialog()!)!.click() + await settle() + + expect(callsTo('DELETE', /\/v1\/media\/11$/)).toHaveLength(1) + }) +}) diff --git a/web/src/views/__tests__/MediaVariantsLibrary.spec.ts b/web/src/views/__tests__/MediaVariantsLibrary.spec.ts index d503359..ed89e3c 100644 --- a/web/src/views/__tests__/MediaVariantsLibrary.spec.ts +++ b/web/src/views/__tests__/MediaVariantsLibrary.spec.ts @@ -114,7 +114,7 @@ describe('The library badge', () => { await mountPage() expect(cardFor('north-wall.jpg').getAttribute('aria-label')).toBe( - 'north-wall.jpg, 3.9 MB, used in 2 articles', + 'north-wall.jpg, 3.9 MB, used in 2 places', ) }) diff --git a/web/src/views/__tests__/MediaView.spec.ts b/web/src/views/__tests__/MediaView.spec.ts index bff74a6..ddf92e3 100644 --- a/web/src/views/__tests__/MediaView.spec.ts +++ b/web/src/views/__tests__/MediaView.spec.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import uiPlugin from '@nuxt/ui/vue-plugin' +import UApp from '@nuxt/ui/components/App.vue' import router from '@/router' import { ApiError, NetworkError, useApi } from '@/composables/useApi' import type { MediaItem, MediaUsageItem } from '@/composables/useMedia' @@ -319,7 +321,10 @@ async function mountPage(handlers: Handlers = {}, url = '/media', width = DESKTO request: requestMock, }) await router.push(url) - wrapper = mount(MediaView, { + const root = defineComponent({ + render: () => h(UApp, null, { default: () => h(MediaView) }), + }) + wrapper = mount(root, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) @@ -495,15 +500,15 @@ describe('Default, many images', () => { describe('Badges and accessible names', () => { it.each([ - ['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 articles', ['Used in 2']], - ['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 article', ['Used in 1']], + ['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 places', ['Used in 2']], + ['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 place', ['Used in 1']], ['window-frame.jpg', 'window-frame.jpg, 1.8 MB, unused', ['Unused']], [ 'floorboards.jpg', - 'floorboards.jpg, 2.4 MB, used in 4 articles', + 'floorboards.jpg, 2.4 MB, used in 4 places', ['Duplicate', 'Used in 4'], ], - ['logo-renew.svg', 'logo-renew.svg, 8 KB, used in 5 articles', ['Used in 5']], + ['logo-renew.svg', 'logo-renew.svg, 8 KB, used in 5 places', ['Used in 5']], ])('card %s reads %s with badges %j', async (filename, name, badges) => { await mountPage() @@ -557,7 +562,7 @@ describe('List view', () => { fixture().map((item) => String(item.id)), ) expect(textOf(rows[0])).toBe( - 'north-wall.jpg 4032 × 3024 3.9 MB JPEG 2 articles Marta Alves 12 Sep 2026', + 'north-wall.jpg 4032 × 3024 3.9 MB JPEG Used in 2 Marta Alves 12 Sep 2026', ) expect(textOf(rows[4])).toContain('Duplicate') expect(rows[0]!.querySelector('[data-testid="media-used-in"]')!.className).toContain( @@ -918,7 +923,6 @@ describe('Hover, focus and keyboard', () => { ) await flushPromises() expect(panel()).toBeNull() - expect(requestMock.mock.calls.filter(([path]) => /usage/.test(path))).toHaveLength(0) press(cards()[1]!, 'Enter') await flushPromises() @@ -1001,7 +1005,7 @@ describe('Detail panel', () => { await clickCard('north-wall.jpg') - expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles') + expect(textOf(panel()!.querySelector('h3'))).toBe('Used in') expect(panel()!.querySelectorAll('[data-testid="usage-row"]')).toHaveLength(0) pending.resolve(USAGE) await flushPromises() @@ -1014,7 +1018,7 @@ describe('Detail panel', () => { await clickCard('north-wall.jpg') expect(panel()!.querySelectorAll('[data-testid="usage-row"]')).toHaveLength(0) - expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles') + expect(textOf(panel()!.querySelector('h3'))).toBe('Used in') }) it('unused: sends no usage request and says it is safe to delete', async () => { diff --git a/web/visual/check.ts b/web/visual/check.ts index 68993e5..83d2052 100644 --- a/web/visual/check.ts +++ b/web/visual/check.ts @@ -463,6 +463,17 @@ const extraSources: Record<string, Source[]> = { states: ['Duplicates tab with the button', 'Duplicates tab: offline'], alias: {}, }, + { + board: schemaBoard, + group: '11 Media library: usage', + states: [ + 'Grid: usage badge tooltip', + 'List view: usage badge', + 'Detail panel: used in entries', + 'Delete: image used by entries', + ], + alias: {}, + }, ], } diff --git a/web/visual/states.ts b/web/visual/states.ts index af1dfe9..8a64b33 100644 --- a/web/visual/states.ts +++ b/web/visual/states.ts @@ -1697,6 +1697,58 @@ const mediaFloorboardsUsage = [ { id: 12, title: 'Before and after: the Rue Verte kitchen', published: true }, { id: 13, title: 'Why we reuse original floorboards', published: true }, ] +const mediaDesktopWidth = 768 +const mediaBathroomTilesId = 10 +const mediaLimePlasterId = 11 +const mediaEntryUsedIn: Record<number, number> = { + [mediaBathroomTilesId]: 3, + [mediaLimePlasterId]: 7, +} +const mediaEventTitles = ['Lime plaster workshop', 'Heritage stone walk', 'Winter open day'] +const mediaLimeArticles = [ + { type: 'article', id: 10, title: 'How we price a bathroom renovation', published: true }, + { type: 'article', id: 11, title: 'Winter checklist for timber windows', published: false }, +].map((article) => ({ ...article, fields: [] })) + +function mediaEventEntries(count: number) { + return Array.from({ length: count }, (_, index) => ({ + type: 'entry', + id: index + 1, + fields: ['thumbnail'], + content_type_key: 'events', + content_type_name: 'Events', + first_field: { + key: 'title', + type: 'text', + value: mediaEventTitles[index] ?? `Event ${index + 1}`, + }, + })) +} + +function mediaUsageApi(overrides: Record<string, ApiMock> = {}) { + return mediaApi({ + 'GET /v1/media/': { + status: 200, + bodyFor: () => + mediaList( + mediaItems().map((item) => ({ + ...item, + used_in: mediaEntryUsedIn[item.id] ?? item.used_in, + })), + ), + }, + [`GET /v1/media/${mediaBathroomTilesId}/usage`]: { + status: 200, + body: mediaEventEntries(3), + }, + [`GET /v1/media/${mediaLimePlasterId}/usage`]: { + status: 200, + body: [...mediaLimeArticles, ...mediaEventEntries(5)], + }, + ...overrides, + }) +} + const mediaDuplicateIds = [12, 5, 6] const mediaDuplicateGroups = [mediaDuplicateIds, [7, 9]] @@ -2218,6 +2270,25 @@ const mediaStates: Record<string, VisualState> = { await page.mouse.move(0, 0) }, ), + 'Grid: usage badge tooltip': mediaState(mediaUsageApi(), async (page) => { + const badge = mediaCard(page, mediaBathroomTilesId).getByText('Used in 3', { + exact: true, + }) + const desktop = (page.viewportSize()?.width ?? 0) >= mediaDesktopWidth + if (desktop) { + await badge.hover() + await page.getByText('Used in entries: 3', { exact: true }).first().waitFor() + } + }), + 'List view: usage badge': mediaState(mediaUsageApi(), undefined, '/media?view=list'), + 'Detail panel: used in entries': mediaState(mediaUsageApi(), async (page) => { + await openMediaPanel(page, mediaLimePlasterId) + await page.getByText('and 2 more', { exact: true }).waitFor() + }), + 'Delete: image used by entries': mediaState(mediaUsageApi(), async (page) => { + await openMediaDialog(page, mediaLimePlasterId, 'Delete') + await page.getByRole('alertdialog').getByText('and 2 more', { exact: true }).waitFor() + }), 'Delete: unused image': mediaState(mediaApi(), (page) => openMediaDialog(page, 8, 'Delete')), 'Delete: image in use': mediaState(mediaApi(), (page) => openMediaDialog(page, 1, 'Delete')), 'Delete: in progress': mediaState( @@ -4706,6 +4777,35 @@ function withVariantsRowException( const MEDIA_DESKTOP_BUDGET = 10 +const usageCeilings: Record<string, { desktop: number; mobile: number }> = { + 'Grid: usage badge tooltip': { desktop: MEDIA_DESKTOP_BUDGET, mobile: 32 }, + 'List view: usage badge': { desktop: 15, mobile: 24 }, + 'Detail panel: used in entries': { desktop: 17, mobile: 28 }, + 'Delete: image used by entries': { desktop: MEDIA_DESKTOP_BUDGET, mobile: 29 }, +} + +function withUsageException(states: Record<string, VisualState>): Record<string, VisualState> { + return Object.fromEntries( + Object.entries(states).map(([name, state]) => { + const maxPercent = usageCeilings[name] + if (maxPercent === undefined) { + return [name, state] + } + return [ + name, + { + ...state, + exception: { + maxPercent, + reason: 'mobile: variants filter row not drawn on the Media board; desktop: stray miniature in the board capture and the Details | Variants tab row not drawn on the board', + approvedBy: 'user (Florian Sylvain), 2026-10-21', + }, + }, + ] + }), + ) +} + function withDetailTabsException(states: Record<string, VisualState>): Record<string, VisualState> { const name = 'Detail panel: duplicate' const state = states[name] @@ -9468,7 +9568,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = { ...withVariantExceptions(variantsEditorStates), }, media: withDetailTabsException( - withVariantsRowException({ ...mediaStates, ...mediaPurgeStates }), + withUsageException(withVariantsRowException({ ...mediaStates, ...mediaPurgeStates })), ), 'media-picker': pickerStates, }