feat: media duplicate purge, part 3 of 3: the SPA (Settings "Media cleanup" card, PurgeDuplicatesDialog, Media Duplicates toolbar button)
This commit is contained in:
1 parent
1a55a39425
commit
2303b12377
26 files changed
+3856
-83
No files matched your search
@@ -0,0 +1,385 @@
|
||||
Feature: Media cleanup in the SPA: Settings card, purge dialog and Media Duplicates button
|
||||
|
||||
As a RenewCMS owner or admin
|
||||
I want to turn on automatic duplicate purging and to purge duplicates by hand
|
||||
So that identical images are merged without leaving Settings or the Media library
|
||||
|
||||
Background:
|
||||
Given the API of media_purge.feature and media_purge_scheduler.feature is live
|
||||
And every call goes through useApi; 401 is global (/login?redirect=<path>&reason=expired);
|
||||
a 403 on a verified account has no local copy and redirects nowhere
|
||||
And the calls are:
|
||||
| call | body or response |
|
||||
| GET /v1/media/purge-settings | {auto_purge_enabled, auto_purge_interval_days, last_purge_at, last_purge_merged, last_purge_articles_updated, enabled_at, next_purge_at} |
|
||||
| PATCH /v1/media/purge-settings | {"auto_purge_enabled": bool} or {"auto_purge_interval_days": int}, answers the settings above |
|
||||
| GET /v1/media/purge-preview | {groups, copies, articles, list: [{keep_id, filename, url, used_in, merged}], more, fingerprint} |
|
||||
| POST /v1/media/purge | {"fingerprint": "<from the preview>"}, answers {groups_total, groups_purged, merged, articles_updated, failed_groups} |
|
||||
And errors are plain text; 409 carries "The library changed, so nothing was purged. Check the new
|
||||
summary and try again." (stale fingerprint) or "A purge is already running." (busy)
|
||||
And "owner or admin" is session.role in ('owner', 'admin'); only those two roles exist, so any
|
||||
other role is a mocked value in tests
|
||||
And /settings is the verified route of settings_page.feature; the Media cleanup card is its second
|
||||
card, below Version and Updates, 20 px under the previous card, in a page at most 720 px wide
|
||||
And the card is --ui-bg with a 1 px --ui-border, 20 px padding (16 px on mobile), 16 px gap, as
|
||||
drawn in docs/design/source/SettingsFrame.dc.html
|
||||
And the components are Nuxt UI v4 only (UCard, USwitch, UInputNumber, UFormField, UAlert, UButton,
|
||||
UModal, USkeleton, useToast) and design tokens only
|
||||
And PurgeDuplicatesDialog is one component shared by the card and the Media Duplicates tab: props
|
||||
open, preview, status (loading, ready, purging, nothing, error, partial, conflict); it emits
|
||||
purged with the counts; the parent shows the toast
|
||||
And plural rules: "1 group" / "N groups", "1 copy" / "N copies", "1 article" / "N articles"
|
||||
And skeleton placeholders show only after 150 ms of loading
|
||||
And a Partial result is a 200 whose failed_groups is above 0 (the board's 207 is only a label)
|
||||
And dates are shown in the browser's timezone, from last_purge_at and next_purge_at in fixtures
|
||||
And no state here is clock-sensitive
|
||||
|
||||
Rule: The Media cleanup card on /settings
|
||||
|
||||
Scenario: Default: off, never run
|
||||
Given GET /v1/media/purge-settings resolves 200 with auto_purge_enabled false,
|
||||
auto_purge_interval_days 30, last_purge_at null, next_purge_at null
|
||||
And the user is the owner
|
||||
When the user opens /settings
|
||||
Then the card titled "Media cleanup" reads "Merge identical images so your library stays tidy.
|
||||
Articles that use a copy are switched to the most used one."
|
||||
And "Automatic purge" with the helper "Runs in the background. Off by default." and a switch that is off
|
||||
And no "Every" row and no "Next purge" line
|
||||
And the line "Never run" is shown
|
||||
And a divider, then "Purge now" with the helper "Merge every group of duplicates that has an image
|
||||
in use, right away." and an outline button "Purge duplicates" with the merge icon
|
||||
And the card has no Save button
|
||||
|
||||
Scenario: On: 30 days, last run
|
||||
Given GET resolves auto_purge_enabled true, auto_purge_interval_days 30,
|
||||
last_purge_at "2026-06-14T09:00:00Z", last_purge_merged 3, last_purge_articles_updated 5,
|
||||
next_purge_at "2026-07-14T09:00:00Z"
|
||||
When the user opens /settings
|
||||
Then the switch is on
|
||||
And "Every" with the helper "Between 1 and 365 days." and a number input showing 30 with the suffix "days"
|
||||
And the lines "Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated" and "Next purge: 14 Jul 2026"
|
||||
|
||||
Scenario Outline: The Last purge line is pluralised
|
||||
Given last_purge_at is "2026-06-14T09:00:00Z", last_purge_merged <merged> and last_purge_articles_updated <articles>
|
||||
When the card renders
|
||||
Then the line reads "<line>"
|
||||
|
||||
Examples:
|
||||
| merged | articles | line |
|
||||
| 3 | 5 | Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated |
|
||||
| 1 | 1 | Last purge: 14 Jun 2026 · 1 copy merged · 1 article updated |
|
||||
| 0 | 0 | Last purge: 14 Jun 2026 · 0 copies merged · 0 articles updated |
|
||||
|
||||
Scenario: Turning the switch on saves at once
|
||||
Given the Default state
|
||||
When the user turns the switch on
|
||||
Then PATCH /v1/media/purge-settings is sent with exactly {"auto_purge_enabled": true}
|
||||
And while it is pending "Saving…" with loader-circle sits in the card header and the controls stay usable
|
||||
When it resolves 200 with auto_purge_interval_days 30 and next_purge_at "2026-11-04T09:00:00Z"
|
||||
Then "Saving…" is replaced by "Saved" with a check, in the success colour, in the card header
|
||||
And the "Every" row shows 30 and "Next purge: 4 Nov 2026" is shown
|
||||
And "Saved" is announced politely and disappears after 2 s
|
||||
|
||||
Scenario: Turning the switch off saves at once and hides the schedule
|
||||
Given the On state
|
||||
When the user turns the switch off
|
||||
Then PATCH is sent with exactly {"auto_purge_enabled": false}
|
||||
And on 200 with next_purge_at null the "Every" row and the "Next purge" line are hidden
|
||||
And the "Last purge" line is unchanged
|
||||
|
||||
Scenario Outline: The days field saves on blur or Enter, once
|
||||
Given the On state and the days field holds 30
|
||||
When the user types "<typed>" and <commit>
|
||||
Then PATCH is sent exactly once with {"auto_purge_interval_days": <sent>}
|
||||
And on 200 "Saved" shows in the header and "Next purge" follows the response's next_purge_at
|
||||
|
||||
Examples:
|
||||
| typed | commit | sent |
|
||||
| 45 | leaves the field | 45 |
|
||||
| 45 | presses Enter | 45 |
|
||||
| 1 | presses Enter | 1 |
|
||||
| 365 | leaves the field | 365 |
|
||||
|
||||
Scenario: Enter then the resulting blur is one request
|
||||
Given the On state
|
||||
When the user types 45, presses Enter and the field then loses focus
|
||||
Then exactly one PATCH is sent
|
||||
|
||||
Scenario: An unchanged value sends nothing
|
||||
Given the On state and the days field holds 30
|
||||
When the user focuses the field and leaves it without changing it
|
||||
Then no PATCH is sent and no "Saving…" shows
|
||||
|
||||
Scenario Outline: Validation error: nothing is sent
|
||||
Given the On state
|
||||
When the user types "<typed>" and leaves the field or presses Enter
|
||||
Then no PATCH is sent
|
||||
And the field keeps "<typed>" and gets the error border
|
||||
And under the field, with circle-alert, the message "Enter a whole number of days between 1 and 365." shows
|
||||
And no "Saving…" and no "Saved" show
|
||||
|
||||
Examples:
|
||||
| typed |
|
||||
| |
|
||||
| 0 |
|
||||
| 366 |
|
||||
| 400 |
|
||||
| 1.5 |
|
||||
|
||||
Scenario: A validation error clears when the value becomes valid
|
||||
Given the Validation error state with "400"
|
||||
When the user types 60 and presses Enter
|
||||
Then the message disappears and PATCH is sent with {"auto_purge_interval_days": 60}
|
||||
|
||||
Scenario Outline: Save failed: the control reverts
|
||||
Given the On state with 30 days
|
||||
When the user <action> and PATCH answers <failure>
|
||||
Then the control returns to its previous value (<previous>)
|
||||
And "Couldn't save. Try again in a moment." shows under the field (under the "Automatic purge" row for the switch)
|
||||
And no "Saved" shows
|
||||
|
||||
Examples:
|
||||
| action | failure | previous |
|
||||
| types 45 and presses Enter | 500 | 30 |
|
||||
| types 45 and presses Enter | a network error | 30 |
|
||||
| turns the switch off | 500 | on |
|
||||
|
||||
Scenario: The save-failed message goes away on the next successful save
|
||||
Given the Save failed state
|
||||
When the user types 45 and presses Enter and PATCH answers 200
|
||||
Then the failure message is gone and "Saved" shows
|
||||
|
||||
Scenario: Role changed (403 on PATCH)
|
||||
Given the On state for an owner who has been changed to a role that is not owner or admin
|
||||
When the user turns the switch off and PATCH answers 403
|
||||
Then the switch returns to on, with no message, no toast and no redirect
|
||||
And the session is refreshed and the card switches to the Read-only state
|
||||
|
||||
Scenario: Read-only (non-admin)
|
||||
Given the session role is not owner or admin
|
||||
When the user opens /settings
|
||||
Then GET /v1/media/purge-settings is not sent
|
||||
And the switch is off, the days input shows 30 and the line "Never run" shows, as placeholders
|
||||
And the switch and the days input are disabled
|
||||
And an info alert reads "Only owners and admins can change this."
|
||||
And the "Purge now" divider, row and button are not rendered
|
||||
And no PATCH can be sent from the card
|
||||
|
||||
Scenario: Loading skeleton after 150 ms
|
||||
Given GET /v1/media/purge-settings has not resolved
|
||||
When the user opens /settings
|
||||
Then during the first 150 ms the card shows no skeleton
|
||||
And after 150 ms a skeleton replaces the card body (title bar, two text bars, switch row, schedule bar, divider, button row), the card is aria-busy="true" and the button is disabled
|
||||
When it resolves
|
||||
Then the Default or On state replaces it and aria-busy is gone
|
||||
|
||||
Scenario Outline: The settings could not be loaded
|
||||
Given GET /v1/media/purge-settings <failure>
|
||||
When the user opens /settings
|
||||
Then the card shows an error alert "Couldn't reach RenewCMS. Check your connection and try again." with "Try again" (rotate-cw)
|
||||
And the Version and Updates cards are unaffected
|
||||
When the user activates "Try again"
|
||||
Then the loading state shows and GET is sent again
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| answers 500 |
|
||||
| fails (network) |
|
||||
|
||||
Scenario: Offline
|
||||
Given navigator.onLine is false
|
||||
When the user opens /settings
|
||||
Then a banner reads "You're offline. Changing automatic purge and purging duplicates need a connection."
|
||||
And the switch, the days input and "Purge duplicates" are disabled (40 % opacity)
|
||||
And nothing is sent when they are activated
|
||||
|
||||
Scenario: 401 is global
|
||||
Given any call of the card answers 401
|
||||
Then the user goes to /login?redirect=/settings&reason=expired and the card shows no copy of its own
|
||||
|
||||
Rule: PurgeDuplicatesDialog opened from "Purge duplicates" in the card
|
||||
|
||||
Background:
|
||||
Given an owner on /settings and the card in the On state
|
||||
|
||||
Scenario: Opening shows the preview loading
|
||||
When the user activates "Purge duplicates"
|
||||
Then GET /v1/media/purge-preview is sent
|
||||
And a dialog opens (UModal, sm:max-w-md, a bottom sheet on mobile) titled "Purge duplicates?" with the description
|
||||
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
|
||||
And focus is on the first button, "Cancel"
|
||||
And after 150 ms skeleton rows replace the summary and list, and the confirm button, labelled
|
||||
"Purge duplicates", is disabled
|
||||
|
||||
Scenario: Confirm: summary, up to 5 groups and the link
|
||||
Given the preview is groups 4, copies 9, articles 12, a list of 4 groups, more 0
|
||||
When it resolves
|
||||
Then the summary reads "4 groups · 9 copies will be deleted · 12 articles will be updated"
|
||||
And a bordered list shows a row per group: a 40 x 30 thumbnail of its url, the filename, "Kept · used in N articles"
|
||||
and, right-aligned, "N copies merged"
|
||||
And no "and N more" line
|
||||
And the link "Review in Duplicates" (arrow-right) points to /media?filter=duplicates
|
||||
And the buttons are "Cancel" and the danger confirm "Purge 4 groups"
|
||||
|
||||
Scenario Outline: The summary and rows are pluralised
|
||||
Given the preview is groups <g>, copies <c>, articles <a> with one group kept in <u> article(s) and <m> merged
|
||||
Then the summary reads "<summary>"
|
||||
And the confirm reads "<confirm>"
|
||||
And the row reads "Kept · used in <ku>" and "<km>"
|
||||
|
||||
Examples:
|
||||
| g | c | a | u | m | summary | confirm | ku | km |
|
||||
| 4 | 9 | 12 | 4 | 2 | 4 groups · 9 copies will be deleted · 12 articles will be updated | Purge 4 groups | 4 articles | 2 copies merged |
|
||||
| 1 | 1 | 2 | 2 | 1 | 1 group · 1 copy will be deleted · 2 articles will be updated | Purge 1 group | 2 articles | 1 copy merged |
|
||||
| 1 | 1 | 1 | 1 | 1 | 1 group · 1 copy will be deleted · 1 article will be updated | Purge 1 group | 1 article | 1 copy merged |
|
||||
|
||||
Scenario: Confirm: many groups
|
||||
Given the preview is groups 8, copies 15, articles 24, a list of 5 groups and more 3
|
||||
When it resolves
|
||||
Then exactly 5 rows show, followed by "and 3 more groups"
|
||||
And the confirm reads "Purge 8 groups"
|
||||
|
||||
Scenario: "and 1 more group" is singular
|
||||
Given the preview has more 1
|
||||
Then the line reads "and 1 more group"
|
||||
|
||||
Scenario: Nothing to purge
|
||||
Given the preview is groups 0 with an empty list
|
||||
When it resolves
|
||||
Then the dialog shows an info icon, "Nothing to purge" and "No duplicate is used in an article, so there is nothing to merge."
|
||||
And the only button is "Close"; there is no confirm, no summary and no link
|
||||
And "Close" closes the dialog and sends nothing else
|
||||
|
||||
Scenario: The preview could not be loaded
|
||||
Given GET /v1/media/purge-preview answers 500 or fails
|
||||
Then the dialog shows the error notice "Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
|
||||
And the confirm is disabled and offers no purge until the preview loads again
|
||||
|
||||
Scenario: Cancelling changes nothing
|
||||
Given the Confirm state
|
||||
When the user activates "Cancel", presses Escape or clicks the overlay
|
||||
Then the dialog closes, no POST is sent and focus returns to "Purge duplicates"
|
||||
|
||||
Scenario: The fingerprint goes from the preview to the purge
|
||||
Given the preview carries fingerprint "ab12"
|
||||
When the user activates "Purge 4 groups"
|
||||
Then POST /v1/media/purge is sent once with exactly {"fingerprint": "ab12"}
|
||||
|
||||
Scenario: In progress: the dialog cannot be dismissed
|
||||
Given POST /v1/media/purge has not resolved
|
||||
Then the confirm reads "Purging…" with loader-circle and is disabled
|
||||
And "Cancel" is disabled
|
||||
And Escape, a click on the overlay and a swipe-down do nothing
|
||||
|
||||
Scenario: Success: the dialog closes, a toast shows and Last purge updates in place
|
||||
Given the preview was groups 4, copies 9, articles 12
|
||||
When POST answers 200 with {groups_total 4, groups_purged 4, merged 9, articles_updated 12, failed_groups 0}
|
||||
Then the dialog closes, the dialog emits purged with merged 9 and articles_updated 12
|
||||
And a toast with circle-check shows for 5 s reading "Duplicates purged" / "9 copies merged. 12 articles updated."
|
||||
And GET /v1/media/purge-settings is sent again
|
||||
And with last_purge_at "2026-10-05T09:00:00Z", last_purge_merged 9, last_purge_articles_updated 12 the line reads
|
||||
"Last purge: 5 Oct 2026 · 9 copies merged · 12 articles updated", without a page reload
|
||||
And focus returns to "Purge duplicates"
|
||||
|
||||
Scenario: Success with automatic purge on moves Next purge forward
|
||||
Given automatic purge is on with interval 30
|
||||
When a purge succeeds and the refreshed settings carry next_purge_at "2026-11-04T09:00:00Z"
|
||||
Then "Next purge: 4 Nov 2026" shows, and with automatic purge off no "Next purge" line shows
|
||||
|
||||
Scenario: The toast is pluralised
|
||||
When POST answers 200 with merged 1, articles_updated 1 and failed_groups 0
|
||||
Then the toast reads "Duplicates purged" / "1 copy merged. 1 article updated."
|
||||
|
||||
Scenario Outline: Error: the dialog stays open and confirm retries
|
||||
Given the Confirm state with fingerprint "ab12"
|
||||
When POST answers <failure>
|
||||
Then an error notice (soft alert, circle-x) reads "Couldn't purge the duplicates. Nothing was changed. Try again in a moment." above the summary
|
||||
And the dialog stays open with the summary and list unchanged, and "Cancel" and "Purge 4 groups" enabled
|
||||
When the user activates "Purge 4 groups" again
|
||||
Then POST is sent again with {"fingerprint": "ab12"}
|
||||
And no toast shows and "Last purge" is unchanged
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| 500 |
|
||||
| a network error |
|
||||
|
||||
Scenario: Partial: some groups failed
|
||||
Given the preview was 4 groups
|
||||
When POST answers 200 with {groups_total 4, groups_purged 3, merged 8, articles_updated 10, failed_groups 1}
|
||||
Then a warning notice (soft alert, triangle-alert) reads "Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish."
|
||||
And GET /v1/media/purge-preview is sent again and the summary and list refresh to the remaining groups
|
||||
And GET /v1/media/purge-settings is sent again so "Last purge" refreshes
|
||||
And the buttons are "Close" and "Retry" (rotate-cw, danger)
|
||||
And no toast shows and the dialog does not close
|
||||
When the user activates "Retry"
|
||||
Then POST is sent with the refreshed fingerprint
|
||||
|
||||
Scenario: Partial that leaves nothing to purge
|
||||
Given a Partial result and the refreshed preview has groups 0
|
||||
Then the dialog shows "Nothing to purge" with "Close"
|
||||
|
||||
Scenario: Conflict (409): the library changed
|
||||
Given the Confirm state with fingerprint "ab12"
|
||||
When POST answers 409 with "The library changed, so nothing was purged. Check the new summary and try again."
|
||||
Then a warning notice reads "The library changed, so nothing was purged. Check the new summary and try again."
|
||||
And GET /v1/media/purge-preview is sent again and the summary refreshes (for example 3 groups · 7 copies · 10 articles)
|
||||
And the buttons are "Cancel" and "Purge 3 groups"
|
||||
And no toast shows and "Last purge" is unchanged
|
||||
When the user activates "Purge 3 groups"
|
||||
Then POST is sent with the new fingerprint
|
||||
|
||||
Scenario: Conflict (409): a purge is already running
|
||||
When POST answers 409 with "A purge is already running."
|
||||
Then a warning notice shows the server's text, the preview refreshes and the dialog stays open
|
||||
|
||||
Scenario: Offline
|
||||
Given navigator.onLine is false
|
||||
Then "Purge duplicates" in the card is disabled and the dialog cannot be opened
|
||||
|
||||
Rule: The Duplicates toolbar button on /media
|
||||
|
||||
Scenario: The button shows on the Duplicates tab for an owner or admin
|
||||
Given the user is owner or admin and /media?filter=duplicates holds at least one duplicate group
|
||||
Then the secondary (outline) button "Purge duplicates" with the merge icon shows after the sort menu
|
||||
and before the view toggle in the toolbar, as MediaFrame.dc.html draws it
|
||||
And on a stacked mobile toolbar it is the last row, full width and 44 px high, under the sort and view row
|
||||
|
||||
Scenario Outline: The button is hidden
|
||||
Given <condition>
|
||||
Then the toolbar has no "Purge duplicates" button
|
||||
|
||||
Examples:
|
||||
| condition |
|
||||
| the filter is All |
|
||||
| the filter is Unused |
|
||||
| the Duplicates tab has no groups ("No duplicates" shows) |
|
||||
| the session role is not owner or admin |
|
||||
|
||||
Scenario: Offline
|
||||
Given navigator.onLine is false on the Duplicates tab
|
||||
Then the button is disabled, the existing offline banner of Media shows and nothing opens
|
||||
|
||||
Scenario: The button opens the same dialog
|
||||
When the user activates "Purge duplicates" on the Duplicates tab
|
||||
Then GET /v1/media/purge-preview is sent and PurgeDuplicatesDialog opens in the loading state
|
||||
And every status, notice, button and request of the previous Rule behaves identically
|
||||
|
||||
Scenario: After a purge from Media the list reloads like after a merge
|
||||
Given the Duplicates tab lists 4 groups
|
||||
When a purge succeeds (200, failed_groups 0)
|
||||
Then the toast "Duplicates purged" / "9 copies merged. 12 articles updated." shows
|
||||
And the list and the tab counts are requested again, as after "Merge into this one"
|
||||
And when no group is left "No duplicates" shows and the button is gone
|
||||
|
||||
Scenario: Partial from Media reloads the list when the dialog closes
|
||||
When the purge answers 200 with failed_groups 1 and the user activates "Close"
|
||||
Then the list and the tab counts are requested again
|
||||
|
||||
Scenario: "Review in Duplicates" from Media only closes the dialog
|
||||
Given the dialog is open on /media?filter=duplicates
|
||||
When the user follows "Review in Duplicates"
|
||||
Then the dialog closes and the route is unchanged
|
||||
|
||||
Scenario: "Merge into this one" is unchanged
|
||||
Given the Duplicates tab lists a group
|
||||
Then each group still shows "Merge into this one" and it still sends POST /v1/media/merge with {keep_id, merge_ids}
|
||||
@@ -0,0 +1,102 @@
|
||||
# QA: Media cleanup in the SPA (Settings card, purge dialog, Media Duplicates button)
|
||||
|
||||
No Go test: the API contract is already covered by media_purge.qa.md and media_purge_scheduler.qa.md. This slice has no Go change. The SPA never reaches `persistence`, `mailer` or `gorm`, and every `/v1` call goes through `useApi`.
|
||||
|
||||
Settled decisions: a non-admin sees the Read-only card with placeholders and no settings GET; Partial is a 200 with `failed_groups > 0`; a 403 on PATCH reverts silently, refreshes the session and switches the card to Read-only; dates use the browser timezone.
|
||||
|
||||
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Suggested files, next to the existing view specs: `web/src/views/__tests__/SettingsMediaCleanup.spec.ts`, `web/src/views/__tests__/PurgeDuplicatesDialog.spec.ts`, `web/src/views/__tests__/MediaPurgeEntry.spec.ts`. Reuse `mediaTestKit.ts` and the existing Settings/Media mount helpers.
|
||||
|
||||
Setup:
|
||||
- Mock `useApi().request` per route and method: GET and PATCH `/v1/media/purge-settings`, GET `/v1/media/purge-preview`, POST `/v1/media/purge`. Assert the exact path, method and JSON body of every call.
|
||||
- Mock the session store: `role: 'owner'`, `role: 'admin'`, and for the non-admin cases a role cast to `UserRole` (for example `'editor' as never`) or undefined.
|
||||
- Fake timers for the 150 ms skeleton, the 2 s "Saved" and the 5 s toast. No test freezes the system date: every date comes from a fixture ISO string with a midday UTC time (for example `2026-06-14T09:00:00Z`), formatted by the SPA.
|
||||
- Offline: set `navigator.onLine` to false and dispatch `offline`, or set `isOffline` from `useApi`.
|
||||
- Reject with `ApiError(status, undefined, body)` and `NetworkError` as in the existing specs.
|
||||
- Fixtures reuse the board data: settings `last 14 Jun 2026 / 3 copies / 5 articles`, `next 14 Jul 2026`; preview 4 groups / 9 copies / 12 articles with floorboards.jpg (4, 2), IMG_4471.jpg (2, 1), kitchen-after.webp (3, 3), logo-renew.svg (5, 3); many: 8 / 15 / 24 with lime-plaster.jpg (2, 1) and more 3.
|
||||
|
||||
Cases (one `it` per scenario or Examples row; `it.each` for outlines):
|
||||
|
||||
Settings card
|
||||
1. Default: one GET on open, title, description, label, helper, switch off, no "Every", no "Next purge", "Never run", divider, "Purge now" row, button "Purge duplicates", no Save button.
|
||||
2. On: switch on, "Every" 30 with "days" suffix and helper, both lines; the Last purge pluralisation rows (3/5, 1/1, 0/0).
|
||||
3. Toggle on: PATCH body is exactly `{ auto_purge_enabled: true }`; "Saving…" while pending; then "Saved" in a polite live region; gone after 2 s; the "Every" row and "Next purge" follow the response.
|
||||
4. Toggle off: body exactly `{ auto_purge_enabled: false }`; schedule hidden, Last purge kept.
|
||||
5. Days commit, table-driven (45 blur, 45 Enter, 1 Enter, 365 blur): exactly one PATCH with `{ auto_purge_interval_days: n }`. Enter then blur is one PATCH. Unchanged value sends nothing.
|
||||
6. Validation, table-driven ("", 0, 366, 400, 1.5): no `request` call, field keeps the typed text, error message "Enter a whole number of days between 1 and 365." under the field, error border; a valid value afterwards clears it and sends.
|
||||
7. Save failed (PATCH 500, then NetworkError, for the days field and for the toggle): value reverts, the "Couldn't save. Try again in a moment." copy shows in the right place, no "Saved"; a later success clears it.
|
||||
8. Read-only: role not owner/admin leads to no call to `/v1/media/purge-settings`, placeholders (switch off, 30, "Never run"), disabled switch and input, the info alert "Only owners and admins can change this.", no divider, no "Purge now" row, no button.
|
||||
9. 403 on PATCH: the control reverts, no toast, no redirect, `session.refresh` is called, and a role that is no longer owner/admin yields the Read-only card.
|
||||
10. Loading: 149 ms no skeleton, 150 ms skeleton and `aria-busy="true"`, button disabled; resolve and assert both are gone.
|
||||
11. Settings load failure (500, network): the inline alert, "Try again" reloads.
|
||||
12. Offline: banner text "You're offline. Changing automatic purge and purging duplicates need a connection.", switch, input and button disabled, nothing sent.
|
||||
13. 401 on any call: `redirectToLogin` path used, no local copy in the card.
|
||||
|
||||
Dialog
|
||||
14. Open: GET preview sent, title and description, focus on "Cancel", skeleton after 150 ms (not before), confirm disabled with the label "Purge duplicates".
|
||||
15. Confirm: summary, rows (thumbnail src from `url`, filename, "Kept · used in N articles", "N copies merged"), link `href="/media?filter=duplicates"` with "Review in Duplicates", buttons, confirm label "Purge 4 groups". Singular rows (1/1/2, 1/1/1).
|
||||
16. Many groups: 5 rows then "and 3 more groups"; "and 1 more group" for more 1.
|
||||
17. Nothing: groups 0 gives "Nothing to purge" copy and a single "Close"; no confirm, summary or link.
|
||||
18. Cancel, Escape and overlay close without POST and return focus to the opener.
|
||||
19. Fingerprint: POST body is exactly `{ fingerprint: '<preview fingerprint>' }`, sent once.
|
||||
20. In progress (POST unresolved): label "Purging…" with the loader icon, confirm and Cancel disabled; Escape, overlay click and a close attempt do nothing.
|
||||
21. Success: dialog closes, `purged` emitted with `{ merged, articles_updated }`, toast "Duplicates purged" / "9 copies merged. 12 articles updated." with a 5 s duration, a second GET `/v1/media/purge-settings`, "Last purge" and "Next purge" updated without remount, focus back on the opener; singular toast.
|
||||
22. Error (500 and network): error notice, dialog open, summary unchanged, confirm retries with the same fingerprint, no toast, Last purge unchanged.
|
||||
23. Partial (200, failed_groups 1, groups_total 4, groups_purged 3): the warning text with "3 of 4", a second GET preview, summary refreshed, settings refetched, buttons "Close" and "Retry", no toast, a Retry POST uses the new fingerprint; a refreshed empty preview gives "Nothing to purge".
|
||||
24. Conflict (409 with the conflict text): warning, preview refetched, summary and confirm label refreshed, no toast; the next confirm sends the new fingerprint. Busy 409 text shows the server's text.
|
||||
25. Preview failure: error notice and no purge possible until it loads.
|
||||
26. Offline: the button is disabled and no dialog opens.
|
||||
|
||||
Media
|
||||
27. Button presence table: shown for owner and admin on the Duplicates tab with at least one group; hidden on All, on Unused, with no groups, and for a non-owner/admin role.
|
||||
28. Offline: disabled, the existing banner shows, nothing opens.
|
||||
29. DOM order of the toolbar: Sort, then "Purge duplicates", then the view toggle; the button carries the mobile classes for full width and a 44 px height (`h-11`) in the stacked toolbar.
|
||||
30. Opening from Media: same dialog statuses as cases 14 to 25, driven through the same mocks.
|
||||
31. After a successful purge: the toast, and the media list and counts are requested again (same assertion helper as the merge success case in `MediaDuplicates.spec.ts`); the last group leaving shows "No duplicates" and removes the button. Partial then "Close" reloads the list.
|
||||
32. "Review in Duplicates" from Media closes the dialog and leaves the route unchanged.
|
||||
33. "Merge into this one" regression: the existing merge cases still pass unchanged.
|
||||
|
||||
Amendments to existing specs, in the same change: `SettingsView.spec.ts` and the other Settings specs must tolerate a second card (any assertion about "no switch or input" is scoped to the Version card). `features/settings_page.feature` wording ("one card") is updated at its next amendment, not in this slice.
|
||||
|
||||
## Visual (`task visual:check -- settings` and `task visual:check -- media`, desktop and mobile, light and dark)
|
||||
|
||||
References: `docs/design/screens/settings.md` ("Media cleanup (second card)"), `docs/design/screens/media.md`, `docs/design/source/RenewCMS Screens Media cleanup.dc.html` and the frames `SettingsFrame.dc.html`, `MediaFrame.dc.html` (exact values). Add every state below to `web/visual/states.ts`, with the mocked `/v1` calls the state needs. The gate is hard: every capture within 10 %. Nothing is skipped: no state depends on the clock, because dates come from fixture timestamps (midday UTC, so the day is stable).
|
||||
|
||||
Board sample data to reuse:
|
||||
- Last purge fixture: `last_purge_at` 14 Jun 2026, merged 3, articles 5; "on" has 30 days and next 14 Jul 2026 (`next_purge_at` "2026-07-14T09:00:00Z").
|
||||
- Success fixtures: last 5 Oct 2026, 9 copies, 12 articles. "Success" has automatic purge off (no Next purge); "Success, automatic purge on" has 30 days and next 4 Nov 2026. Both show the toast "Duplicates purged" / "9 copies merged. 12 articles updated."
|
||||
- Preview groups (ids 12, 7, 2, 4, 11): floorboards.jpg (used 4, merged 2), IMG_4471.jpg (2, 1), kitchen-after.webp (3, 3), logo-renew.svg (5, 3, SVG with alpha), lime-plaster.jpg (2, 1). Default preview: 4 groups, 9 copies, 12 articles with the first four. Many: 8 / 15 / 24 with all five and more 3. Partial: refreshed preview 1 group / 1 copy / 2 articles (IMG_4471.jpg), POST answers 200 `groups_total 4, groups_purged 3, failed_groups 1`. Conflict: POST 409, refreshed preview 3 groups / 7 copies / 10 articles (floorboards.jpg, kitchen-after.webp, logo-renew.svg).
|
||||
- Thumbnails: reuse the thumbnail fixtures the existing media states draw.
|
||||
- Frame values: page max width 720, 20 px between cards; card padding 20 (16 mobile), 16 gap; title 15/24 semibold; switch 36 x 20 with a 44 px hit area on mobile; days input 140 px wide (100 % on mobile), 40 px high (44 mobile), IBM Plex Mono 13 px (16 px on mobile), suffix "days" in mono; rows stack on mobile; "Purge duplicates" is 32 px high (44 mobile, full width); dialog 448 px wide, 140 px from the top on desktop, bottom sheet on mobile with the buttons column-reversed; toast 356 px wide at the bottom right (full width on mobile).
|
||||
|
||||
Settings screen, group "Settings: Media cleanup":
|
||||
- [ ] Default: off, never run
|
||||
- [ ] On: 30 days, last run
|
||||
- [ ] Saving (PATCH hangs, days field focused)
|
||||
- [ ] Saved
|
||||
- [ ] Save failed
|
||||
- [ ] Validation error (days field holds "400")
|
||||
- [ ] Read-only (non-admin) (mocked role, placeholders, no GET)
|
||||
- [ ] Loading skeleton
|
||||
- [ ] Offline
|
||||
|
||||
Settings screen, group "Purge duplicates: dialog":
|
||||
- [ ] Loading preview, [ ] Confirm, [ ] Confirm: many groups, [ ] In progress, [ ] Nothing to purge, [ ] Error, [ ] Partial failure, [ ] Conflict (409)
|
||||
|
||||
Settings screen, group "Results and feedback":
|
||||
- [ ] Success, [ ] Success, automatic purge on (the setup holds the toast open so its 5 s timeout cannot end before the capture)
|
||||
|
||||
Media screen, group "Media: Duplicates tab entry point":
|
||||
- [ ] Duplicates tab with the button, [ ] Dialog from Media, [ ] Member: button hidden (mocked role), [ ] Offline (button disabled, existing banner copy)
|
||||
|
||||
Reuse the existing `mediaDuplicatesApi` fixtures for the Duplicates tab and add the purge-preview mock.
|
||||
|
||||
Not covered by screenshots, check by hand: Tab order (switch, days field, "Purge duplicates"; in the dialog Cancel then confirm then the link); focus ring on the switch and field; Enter and Escape in the dialog; focus return to the opener after close; the polite announcement of "Saving…" then "Saved"; hover states; reduced motion on the skeleton and the loader icons; swipe-down is ignored while purging on a touch device.
|
||||
|
||||
## Manual verification (real API, `task dev`)
|
||||
|
||||
- With an owner, turn automatic purge on, set 7 days, reload /settings: the values persist and "Next purge" matches `next_purge_at`.
|
||||
- Upload two identical images, use the copy in an article, press "Purge duplicates": the preview matches, the purge merges them, the toast shows, "Last purge" updates, the file of the copy is gone from `UPLOAD_DIR`.
|
||||
- Open the dialog, upload another duplicate from a second tab, confirm: the Conflict notice and the refreshed summary show.
|
||||
- Confirm the embedded SPA (built without `-tags nofrontend`) serves /settings and /media?filter=duplicates on a hard refresh, since Vitest never exercises the embedded frontend.
|
||||
@@ -23,6 +23,7 @@ const props = withDefaults(
|
||||
stickyFooter?: boolean
|
||||
fullBleed?: boolean
|
||||
compactPadding?: boolean
|
||||
bleedOfflineBanner?: boolean
|
||||
renderWhileChecking?: boolean
|
||||
offlineMessage?: string
|
||||
offlineIcon?: string
|
||||
@@ -36,6 +37,7 @@ const props = withDefaults(
|
||||
offlineIcon: undefined,
|
||||
fullBleed: false,
|
||||
compactPadding: false,
|
||||
bleedOfflineBanner: false,
|
||||
renderWhileChecking: false,
|
||||
},
|
||||
)
|
||||
@@ -301,7 +303,8 @@ async function onLogout() {
|
||||
<OfflineBanner
|
||||
:message="offlineMessage"
|
||||
:icon="offlineIcon"
|
||||
:bleed="compactPadding"
|
||||
:bleed="compactPadding || bleedOfflineBanner"
|
||||
:roomy="!compactPadding && bleedOfflineBanner"
|
||||
/>
|
||||
|
||||
<AppBoot v-if="showBoot" />
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
|
||||
import { useMediaCleanupSettings } from '@/composables/useMediaCleanupSettings'
|
||||
import type { PurgeResult } from '@/composables/useMediaPurge'
|
||||
import {
|
||||
DEFAULT_INTERVAL_DAYS,
|
||||
REVIEW_DUPLICATES_PATH,
|
||||
lastPurgeLine,
|
||||
nextPurgeLine,
|
||||
parseIntervalDays,
|
||||
purgedToast,
|
||||
} from '@/lib/mediaPurge'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||
const SAVE_FAILED_COPY = "Couldn't save. Try again in a moment."
|
||||
const INVALID_DAYS_COPY = 'Enter a whole number of days between 1 and 365.'
|
||||
const READ_ONLY_COPY = 'Only owners and admins can change this.'
|
||||
const SKELETON_BAR = 'rounded-none bg-accented'
|
||||
const OUTLINE_BUTTON =
|
||||
'h-11 justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:w-full md:h-8'
|
||||
|
||||
const session = useSessionStore()
|
||||
const toast = useLocalizedToast()
|
||||
const router = useRouter()
|
||||
const online = useOnline()
|
||||
|
||||
const isAdmin = computed(
|
||||
() => session.session?.role === 'owner' || session.session?.role === 'admin',
|
||||
)
|
||||
const readOnly = computed(() => !isAdmin.value)
|
||||
const offline = computed(() => !online.value)
|
||||
|
||||
const cleanup = useMediaCleanupSettings(() => {
|
||||
session.refresh()
|
||||
})
|
||||
const { settings, loadState, showSkeleton, saveStatus, saveFailure } = cleanup
|
||||
|
||||
const enabled = ref(false)
|
||||
const daysText = ref(String(DEFAULT_INTERVAL_DAYS))
|
||||
const committedDays = ref(DEFAULT_INTERVAL_DAYS)
|
||||
const daysInvalid = ref(false)
|
||||
const daysFocused = ref(false)
|
||||
const purgeOpen = ref(false)
|
||||
const purgeButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
const loading = computed(() => !readOnly.value && loadState.value === 'loading')
|
||||
const skeletonVisible = computed(() => loading.value && showSkeleton.value)
|
||||
const loadFailed = computed(() => !readOnly.value && loadState.value === 'error')
|
||||
const showBody = computed(() => readOnly.value || loadState.value === 'ready')
|
||||
const controlsDisabled = computed(() => readOnly.value || offline.value)
|
||||
const switchChecked = computed(() => !readOnly.value && enabled.value)
|
||||
const showEvery = computed(() => readOnly.value || enabled.value)
|
||||
const lastLine = computed(() =>
|
||||
readOnly.value ? lastPurgeLine(null) : lastPurgeLine(settings.value),
|
||||
)
|
||||
const nextIso = computed(() =>
|
||||
!readOnly.value && enabled.value ? (settings.value?.next_purge_at ?? null) : null,
|
||||
)
|
||||
const daysError = computed(() => (daysInvalid.value ? t(INVALID_DAYS_COPY) : ''))
|
||||
const daysSaveError = computed(() => (saveFailure.value === 'days' ? t(SAVE_FAILED_COPY) : ''))
|
||||
const switchSaveError = computed(() => (saveFailure.value === 'enabled' ? t(SAVE_FAILED_COPY) : ''))
|
||||
const fieldError = computed(() => daysError.value || daysSaveError.value)
|
||||
|
||||
watch(settings, (next) => {
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
enabled.value = next.auto_purge_enabled
|
||||
committedDays.value = next.auto_purge_interval_days
|
||||
if (!daysInvalid.value && !daysFocused.value) {
|
||||
daysText.value = String(next.auto_purge_interval_days)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
isAdmin,
|
||||
(admin) => {
|
||||
if (admin) {
|
||||
cleanup.load()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function toggle(value: boolean) {
|
||||
if (controlsDisabled.value) {
|
||||
return
|
||||
}
|
||||
const previous = enabled.value
|
||||
enabled.value = value
|
||||
const saved = await cleanup.save({ auto_purge_enabled: value }, 'enabled')
|
||||
if (!saved) {
|
||||
enabled.value = previous
|
||||
}
|
||||
}
|
||||
|
||||
async function commitDays() {
|
||||
if (controlsDisabled.value) {
|
||||
return
|
||||
}
|
||||
const days = parseIntervalDays(daysText.value)
|
||||
if (days === null) {
|
||||
daysInvalid.value = true
|
||||
return
|
||||
}
|
||||
daysInvalid.value = false
|
||||
if (days === committedDays.value) {
|
||||
daysText.value = String(days)
|
||||
return
|
||||
}
|
||||
const previous = committedDays.value
|
||||
committedDays.value = days
|
||||
const saved = await cleanup.save({ auto_purge_interval_days: days }, 'days')
|
||||
if (!saved) {
|
||||
committedDays.value = previous
|
||||
daysText.value = String(previous)
|
||||
}
|
||||
}
|
||||
|
||||
function onDaysBlur() {
|
||||
daysFocused.value = false
|
||||
commitDays()
|
||||
}
|
||||
|
||||
function openPurge() {
|
||||
if (!offline.value) {
|
||||
purgeOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function closePurge() {
|
||||
purgeOpen.value = false
|
||||
await nextTick()
|
||||
purgeButton.value?.$el?.focus()
|
||||
}
|
||||
|
||||
function onPurged(result: PurgeResult) {
|
||||
toast.add(purgedToast(result))
|
||||
cleanup.refresh()
|
||||
}
|
||||
|
||||
function reviewDuplicates() {
|
||||
purgeOpen.value = false
|
||||
router.push(REVIEW_DUPLICATES_PATH)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
aria-labelledby="media-cleanup-h"
|
||||
data-testid="media-cleanup-card"
|
||||
:aria-busy="skeletonVisible ? 'true' : undefined"
|
||||
class="flex flex-col gap-4 border border-default bg-default p-4 lg:p-5"
|
||||
>
|
||||
<template v-if="skeletonVisible">
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-4 w-35']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-9/10']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-11/20']" />
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-30']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-2.75 w-45']" />
|
||||
</div>
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-5 w-9']" />
|
||||
</div>
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-3/5']" />
|
||||
<USeparator />
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.25 w-25']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-2.75 w-55']" />
|
||||
</div>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-merge"
|
||||
disabled
|
||||
:class="OUTLINE_BUTTON"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
>
|
||||
{{ t('Purge duplicates') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<h2 id="media-cleanup-h" class="text-lg font-semibold text-highlighted">
|
||||
{{ t('Media cleanup') }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">
|
||||
{{
|
||||
t(
|
||||
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
aria-live="polite"
|
||||
data-testid="cleanup-save-status"
|
||||
class="flex h-6 shrink-0 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="saveStatus === 'saving'"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-muted"
|
||||
>
|
||||
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
|
||||
{{ t('Saving…') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="saveStatus === 'saved'"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-success"
|
||||
>
|
||||
<UIcon name="i-lucide-check" class="size-3.5" />
|
||||
{{ t('Saved') }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="loadFailed"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:description="t(UNREACHABLE_COPY)"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="cleanup.load"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<template v-else-if="showBody">
|
||||
<UAlert
|
||||
v-if="readOnly"
|
||||
role="status"
|
||||
color="info"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
icon="i-lucide-info"
|
||||
:description="t(READ_ONLY_COPY)"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span id="auto-purge-label" class="font-medium text-highlighted">{{
|
||||
t('Automatic purge')
|
||||
}}</span>
|
||||
<span class="text-xs/5 text-muted">{{
|
||||
t('Runs in the background. Off by default.')
|
||||
}}</span>
|
||||
<span
|
||||
v-if="switchSaveError"
|
||||
role="alert"
|
||||
data-testid="cleanup-switch-error"
|
||||
class="flex items-start gap-1.5 text-xs/5 text-error"
|
||||
>
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
{{ switchSaveError }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center md:h-6 md:w-9">
|
||||
<USwitch
|
||||
:model-value="switchChecked"
|
||||
:disabled="controlsDisabled"
|
||||
aria-labelledby="auto-purge-label"
|
||||
:ui="{ base: 'data-[disabled]:opacity-40' }"
|
||||
@update:model-value="toggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showEvery"
|
||||
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<label for="auto-purge-days" class="font-medium text-highlighted">{{
|
||||
t('Every')
|
||||
}}</label>
|
||||
<span class="text-xs/5 text-muted">{{ t('Between 1 and 365 days.') }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 md:items-end">
|
||||
<UInput
|
||||
id="auto-purge-days"
|
||||
v-model="daysText"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
:disabled="controlsDisabled"
|
||||
:highlight="fieldError !== ''"
|
||||
:color="fieldError ? 'error' : 'neutral'"
|
||||
:aria-invalid="fieldError ? 'true' : undefined"
|
||||
class="w-full md:w-35"
|
||||
:ui="{
|
||||
base: 'h-11 pe-14 font-mono text-lg disabled:bg-default disabled:opacity-40 disabled:ring-(--ui-border-accented) md:h-10 md:text-sm',
|
||||
trailing: 'pe-3',
|
||||
}"
|
||||
@focus="daysFocused = true"
|
||||
@blur="onDaysBlur"
|
||||
@keydown.enter="commitDays"
|
||||
>
|
||||
<template #trailing>
|
||||
<span class="font-mono text-sm text-muted">{{ t('days') }}</span>
|
||||
</template>
|
||||
</UInput>
|
||||
<span
|
||||
v-if="fieldError"
|
||||
role="alert"
|
||||
data-testid="cleanup-days-error"
|
||||
class="flex items-start gap-1.5 text-xs/5 text-error md:max-w-75 md:text-right"
|
||||
>
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
{{ fieldError }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 text-toned">
|
||||
<span data-testid="cleanup-last">{{ lastLine }}</span>
|
||||
<span v-if="nextIso" data-testid="cleanup-next">{{
|
||||
nextPurgeLine(nextIso)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="!readOnly">
|
||||
<USeparator />
|
||||
<div
|
||||
class="flex flex-col items-stretch gap-3 md:flex-row md:items-start md:justify-between md:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="font-medium text-highlighted">{{ t('Purge now') }}</span>
|
||||
<span class="text-xs/5 text-muted">{{
|
||||
t(
|
||||
'Merge every group of duplicates that has an image in use, right away.',
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex md:shrink-0">
|
||||
<UButton
|
||||
ref="purgeButton"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-merge"
|
||||
:disabled="offline"
|
||||
:class="OUTLINE_BUTTON"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
@click="openPurge"
|
||||
>
|
||||
{{ t('Purge duplicates') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<PurgeDuplicatesDialog
|
||||
v-if="purgeOpen"
|
||||
@close="closePurge"
|
||||
@purged="onPurged"
|
||||
@partial="cleanup.refresh"
|
||||
@review="reviewDuplicates"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -40,16 +40,22 @@ const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const props = defineProps<{
|
||||
controls: MediaControls
|
||||
counts: MediaCounts | null
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
controls: MediaControls
|
||||
counts: MediaCounts | null
|
||||
showPurge?: boolean
|
||||
purgeDisabled?: boolean
|
||||
}>(),
|
||||
{ showPurge: false, purgeDisabled: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:q': [value: string]
|
||||
'update:filter': [value: MediaFilter]
|
||||
'update:sort': [value: MediaSort]
|
||||
'update:view': [value: MediaView]
|
||||
purge: []
|
||||
}>()
|
||||
|
||||
const searchText = ref(props.controls.q)
|
||||
@@ -191,7 +197,9 @@ const sortItems = computed(() =>
|
||||
</template>
|
||||
</UInput>
|
||||
|
||||
<div class="order-3 flex gap-2 md:order-none md:mb-1.5">
|
||||
<div
|
||||
class="order-3 flex flex-wrap gap-x-2 gap-y-3 md:order-none md:mb-1.5 md:flex-nowrap md:gap-2"
|
||||
>
|
||||
<UDropdownMenu :items="sortItems" :content="{ align: 'start' }">
|
||||
<UButton
|
||||
color="neutral"
|
||||
@@ -205,6 +213,20 @@ const sortItems = computed(() =>
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
|
||||
<UButton
|
||||
v-if="showPurge"
|
||||
data-testid="purge-duplicates"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-merge"
|
||||
:disabled="purgeDisabled"
|
||||
class="order-last h-11 w-full justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:order-none md:h-8 md:w-auto"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
@click="emit('purge')"
|
||||
>
|
||||
{{ t('Purge duplicates') }}
|
||||
</UButton>
|
||||
|
||||
<div role="group" :aria-label="t('View')" class="flex shrink-0 border border-accented">
|
||||
<UButton
|
||||
v-for="option in VIEW_OPTIONS"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { translate } from '@/i18n'
|
||||
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const DEFAULT_MESSAGE =
|
||||
"You're offline. You can keep writing, but saving and publishing will wait until you're back online."
|
||||
|
||||
const ROOMY_BLEED_ROOT = 'w-auto -mx-4 -mt-5 mb-5 lg:-mx-8 lg:-mt-8 lg:mb-8'
|
||||
|
||||
const BLEED_UI = {
|
||||
root: 'w-auto -mx-4 -mt-4 mb-4 lg:-mx-6 lg:-mt-6 lg:mb-6',
|
||||
container: 'h-auto min-h-10 max-w-none justify-start gap-2.5 px-4 py-2.5 sm:px-4 lg:px-4',
|
||||
@@ -14,15 +16,18 @@ const BLEED_UI = {
|
||||
title: 'whitespace-normal font-normal text-wrap',
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
message?: string
|
||||
icon?: string
|
||||
bleed?: boolean
|
||||
roomy?: boolean
|
||||
}>(),
|
||||
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false },
|
||||
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false, roomy: false },
|
||||
)
|
||||
|
||||
const bleedUi = computed(() => (props.roomy ? { ...BLEED_UI, root: ROOMY_BLEED_ROOT } : BLEED_UI))
|
||||
|
||||
const isOffline = ref(!navigator.onLine)
|
||||
|
||||
function updateOnlineStatus() {
|
||||
@@ -46,6 +51,6 @@ onUnmounted(() => {
|
||||
color="neutral"
|
||||
:icon="icon"
|
||||
:title="translate(message)"
|
||||
:ui="bleed ? BLEED_UI : undefined"
|
||||
:ui="bleed ? bleedUi : undefined"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,375 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useMediaPurge, type PurgePreview, type PurgeResult } from '@/composables/useMediaPurge'
|
||||
import {
|
||||
PURGE_BUSY_COPY,
|
||||
PURGE_STALE_COPY,
|
||||
REVIEW_DUPLICATES_PATH,
|
||||
isForbidden,
|
||||
isHandledGlobally,
|
||||
keptLine,
|
||||
mergedCopies,
|
||||
moreGroupsLine,
|
||||
pluralGroups,
|
||||
previewSummary,
|
||||
thumbnailSource,
|
||||
} from '@/lib/mediaPurge'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
type Status = 'loading' | 'ready' | 'purging' | 'nothing' | 'error' | 'partial' | 'conflict'
|
||||
type Notice = 'failed' | 'partial' | 'stale' | 'busy'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const SKELETON_ROWS = 3
|
||||
const HTTP_CONFLICT = 409
|
||||
const FAILED_COPY = "Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
|
||||
const DESCRIPTION =
|
||||
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
|
||||
const NOTHING_COPY = 'No duplicate is used in an article, so there is nothing to merge.'
|
||||
const PARTIAL_COPY =
|
||||
"Some groups couldn't be merged. {0} of {1} groups were purged. Try again to finish."
|
||||
const BUTTON_BASE = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
|
||||
const MODAL_CONTENT =
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-md sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
purged: [result: PurgeResult]
|
||||
partial: [result: PurgeResult]
|
||||
review: []
|
||||
}>()
|
||||
|
||||
const { getPreview, purge } = useMediaPurge()
|
||||
const session = useSessionStore()
|
||||
|
||||
const open = ref(true)
|
||||
const status = ref<Status>('loading')
|
||||
const notice = ref<Notice | null>(null)
|
||||
const preview = ref<PurgePreview | null>(null)
|
||||
const partialCounts = ref({ purged: 0, total: 0 })
|
||||
const refreshing = ref(false)
|
||||
const showSkeleton = ref(false)
|
||||
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const purging = computed(() => status.value === 'purging')
|
||||
const busy = computed(() => purging.value || refreshing.value)
|
||||
const nothing = computed(() => status.value === 'nothing')
|
||||
const partial = computed(() => status.value === 'partial')
|
||||
const waitingForPreview = computed(() => status.value === 'loading')
|
||||
const canConfirm = computed(() => preview.value !== null && !busy.value)
|
||||
const confirmLabel = computed(() => {
|
||||
if (purging.value) {
|
||||
return t('Purging…')
|
||||
}
|
||||
return preview.value
|
||||
? t('Purge {0}', [pluralGroups(preview.value.groups)])
|
||||
: t('Purge duplicates')
|
||||
})
|
||||
const noticeText = computed(() => {
|
||||
if (notice.value === 'partial') {
|
||||
return t(PARTIAL_COPY, [partialCounts.value.purged, partialCounts.value.total])
|
||||
}
|
||||
return {
|
||||
failed: t(FAILED_COPY),
|
||||
stale: t(PURGE_STALE_COPY),
|
||||
busy: t(PURGE_BUSY_COPY),
|
||||
none: '',
|
||||
}[notice.value ?? 'none']
|
||||
})
|
||||
const noticeIsError = computed(() => notice.value === 'failed')
|
||||
const ui = computed(() => ({
|
||||
content: MODAL_CONTENT,
|
||||
header: nothing.value
|
||||
? 'min-h-0 justify-center px-6 pt-7 pb-0 text-center sm:px-6'
|
||||
: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
description: nothing.value
|
||||
? 'mx-auto mt-3 max-w-85 text-sm text-muted text-pretty'
|
||||
: 'mt-1 text-sm text-muted text-pretty',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: 'px-6 py-5 sm:px-6',
|
||||
}))
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
clearTimeout(skeletonTimer)
|
||||
skeletonTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function giveUpAfterAuthFailure(error: unknown) {
|
||||
if (isForbidden(error)) {
|
||||
session.refresh()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function applyPreview(next: PurgePreview) {
|
||||
preview.value = next
|
||||
if (next.groups === 0) {
|
||||
status.value = 'nothing'
|
||||
notice.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
skeletonTimer = setTimeout(() => {
|
||||
showSkeleton.value = true
|
||||
}, SKELETON_DELAY_MS)
|
||||
try {
|
||||
const first = await getPreview()
|
||||
applyPreview(first)
|
||||
if (first.groups > 0) {
|
||||
status.value = 'ready'
|
||||
}
|
||||
} catch (error) {
|
||||
if (isHandledGlobally(error)) {
|
||||
giveUpAfterAuthFailure(error)
|
||||
return
|
||||
}
|
||||
status.value = 'error'
|
||||
notice.value = 'failed'
|
||||
} finally {
|
||||
clearSkeletonTimer()
|
||||
showSkeleton.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPreview() {
|
||||
refreshing.value = true
|
||||
try {
|
||||
applyPreview(await getPreview())
|
||||
} catch (error) {
|
||||
if (isHandledGlobally(error)) {
|
||||
giveUpAfterAuthFailure(error)
|
||||
}
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
open.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function settle(result: PurgeResult) {
|
||||
if (result.failed_groups === 0) {
|
||||
emit('purged', result)
|
||||
close()
|
||||
return
|
||||
}
|
||||
partialCounts.value = { purged: result.groups_purged, total: result.groups_total }
|
||||
notice.value = 'partial'
|
||||
status.value = 'partial'
|
||||
emit('partial', result)
|
||||
await refreshPreview()
|
||||
}
|
||||
|
||||
async function fail(error: unknown) {
|
||||
if (isHandledGlobally(error)) {
|
||||
giveUpAfterAuthFailure(error)
|
||||
return
|
||||
}
|
||||
if (error instanceof ApiError && error.status === HTTP_CONFLICT) {
|
||||
notice.value = error.body === PURGE_BUSY_COPY ? 'busy' : 'stale'
|
||||
status.value = 'conflict'
|
||||
await refreshPreview()
|
||||
return
|
||||
}
|
||||
notice.value = 'failed'
|
||||
status.value = 'error'
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!preview.value || busy.value) {
|
||||
return
|
||||
}
|
||||
const { fingerprint } = preview.value
|
||||
status.value = 'purging'
|
||||
try {
|
||||
await settle(await purge(fingerprint))
|
||||
} catch (error) {
|
||||
await fail(error)
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
if (!value && busy.value) {
|
||||
return
|
||||
}
|
||||
if (!value) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (!busy.value) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function onReview(event: MouseEvent) {
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
emit('review')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadPreview()
|
||||
await nextTick()
|
||||
cancelButton.value?.$el?.focus()
|
||||
})
|
||||
|
||||
onBeforeUnmount(clearSkeletonTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
:title="nothing ? t('Nothing to purge') : t('Purge duplicates?')"
|
||||
:description="nothing ? t(NOTHING_COPY) : t(DESCRIPTION)"
|
||||
:dismissible="!busy"
|
||||
:close="false"
|
||||
:content="{ role: 'alertdialog', 'aria-busy': busy ? 'true' : undefined }"
|
||||
:ui="ui"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<template v-if="nothing" #title>
|
||||
<span class="flex flex-col items-center gap-3">
|
||||
<span
|
||||
class="flex size-11 items-center justify-center bg-(--rc-info-soft) text-info"
|
||||
>
|
||||
<UIcon name="i-lucide-copy" class="size-5" />
|
||||
</span>
|
||||
{{ t('Nothing to purge') }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="!nothing" #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<UAlert
|
||||
v-if="notice && noticeText"
|
||||
:color="noticeIsError ? 'error' : 'warning'"
|
||||
variant="soft"
|
||||
:icon="noticeIsError ? 'i-lucide-circle-x' : 'i-lucide-triangle-alert'"
|
||||
:description="noticeText"
|
||||
:close="false"
|
||||
role="alert"
|
||||
/>
|
||||
|
||||
<template v-if="waitingForPreview && showSkeleton">
|
||||
<USkeleton
|
||||
data-testid="purge-skeleton"
|
||||
class="h-4 w-4/5 rounded-none bg-accented"
|
||||
/>
|
||||
<div class="border border-default">
|
||||
<div
|
||||
v-for="row in SKELETON_ROWS"
|
||||
:key="row"
|
||||
class="flex items-center gap-3 border-muted px-3 py-2 not-first:border-t"
|
||||
>
|
||||
<USkeleton class="h-7.5 w-10 rounded-none bg-accented" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<USkeleton class="h-3.25 w-9/20 rounded-none bg-accented" />
|
||||
<USkeleton class="h-2.75 w-3/5 rounded-none bg-accented" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="preview">
|
||||
<span data-testid="purge-summary" class="font-medium text-highlighted">{{
|
||||
previewSummary(preview)
|
||||
}}</span>
|
||||
<ul data-testid="purge-groups" class="border border-default">
|
||||
<li
|
||||
v-for="group in preview.list"
|
||||
:key="group.keep_id"
|
||||
data-testid="purge-group"
|
||||
class="flex items-center gap-3 border-muted px-3 py-2 not-first:border-t"
|
||||
>
|
||||
<MediaThumb
|
||||
:item="thumbnailSource(group.url, group.filename)"
|
||||
size="sm"
|
||||
class="h-7.5 w-10 border border-default"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate font-medium text-highlighted">{{
|
||||
group.filename
|
||||
}}</span>
|
||||
<span class="text-xs/5 text-muted">{{
|
||||
keptLine(group.used_in)
|
||||
}}</span>
|
||||
</div>
|
||||
<span class="text-xs whitespace-nowrap text-toned">{{
|
||||
mergedCopies(group.merged)
|
||||
}}</span>
|
||||
</li>
|
||||
<li
|
||||
v-if="preview.more > 0"
|
||||
data-testid="purge-more"
|
||||
class="border-t border-muted px-3 py-2 text-muted"
|
||||
>
|
||||
{{ moreGroupsLine(preview.more) }}
|
||||
</li>
|
||||
</ul>
|
||||
<a
|
||||
:href="REVIEW_DUPLICATES_PATH"
|
||||
class="inline-flex min-h-11 items-center gap-1 self-start font-medium text-highlighted underline underline-offset-3 md:min-h-5"
|
||||
@click="onReview"
|
||||
>
|
||||
{{ t('Review in Duplicates') }}
|
||||
<UIcon name="i-lucide-arrow-right" class="size-3.5" />
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
ref="cancelButton"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
:class="[
|
||||
BUTTON_BASE,
|
||||
'text-highlighted disabled:text-dimmed disabled:opacity-100',
|
||||
]"
|
||||
:disabled="busy"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ nothing || partial ? t('Close') : t('Cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="partial"
|
||||
color="error"
|
||||
icon="i-lucide-rotate-cw"
|
||||
block
|
||||
:class="BUTTON_BASE"
|
||||
:disabled="!canConfirm"
|
||||
@click="confirm"
|
||||
>
|
||||
{{ t('Retry') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else-if="!nothing"
|
||||
color="error"
|
||||
:loading="purging"
|
||||
block
|
||||
:class="BUTTON_BASE"
|
||||
:disabled="!canConfirm"
|
||||
@click="confirm"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,129 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import {
|
||||
useMediaPurge,
|
||||
type PurgeSettings,
|
||||
type PurgeSettingsPatch,
|
||||
} from '@/composables/useMediaPurge'
|
||||
import { isForbidden, isHandledGlobally } from '@/lib/mediaPurge'
|
||||
|
||||
export type CleanupLoadState = 'loading' | 'ready' | 'error'
|
||||
export type SaveStatus = 'idle' | 'saving' | 'saved'
|
||||
export type SaveFailure = 'enabled' | 'days'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const SAVED_VISIBLE_MS = 2000
|
||||
|
||||
export function useMediaCleanupSettings(onForbidden: () => void) {
|
||||
const { getSettings, updateSettings } = useMediaPurge()
|
||||
|
||||
const settings = ref<PurgeSettings | null>(null)
|
||||
const loadState = ref<CleanupLoadState>('loading')
|
||||
const showSkeleton = ref(false)
|
||||
const saveStatus = ref<SaveStatus>('idle')
|
||||
const saveFailure = ref<SaveFailure | null>(null)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let savedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let latestLoad = 0
|
||||
let pendingSaves = 0
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
clearTimeout(skeletonTimer)
|
||||
skeletonTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearSavedTimer() {
|
||||
if (savedTimer !== null) {
|
||||
clearTimeout(savedTimer)
|
||||
savedTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
latestLoad += 1
|
||||
const ticket = latestLoad
|
||||
loadState.value = 'loading'
|
||||
showSkeleton.value = false
|
||||
clearSkeletonTimer()
|
||||
skeletonTimer = setTimeout(() => {
|
||||
showSkeleton.value = true
|
||||
}, SKELETON_DELAY_MS)
|
||||
try {
|
||||
const loaded = await getSettings()
|
||||
if (ticket === latestLoad) {
|
||||
settings.value = loaded
|
||||
loadState.value = 'ready'
|
||||
}
|
||||
} catch (error) {
|
||||
if (ticket !== latestLoad) {
|
||||
return
|
||||
}
|
||||
if (isHandledGlobally(error)) {
|
||||
if (isForbidden(error)) {
|
||||
onForbidden()
|
||||
}
|
||||
return
|
||||
}
|
||||
loadState.value = 'error'
|
||||
} finally {
|
||||
if (ticket === latestLoad) {
|
||||
clearSkeletonTimer()
|
||||
showSkeleton.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
settings.value = await getSettings()
|
||||
} catch (error) {
|
||||
if (isForbidden(error)) {
|
||||
onForbidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finishSaving(succeeded: boolean) {
|
||||
if (pendingSaves > 0) {
|
||||
return
|
||||
}
|
||||
if (!succeeded) {
|
||||
saveStatus.value = 'idle'
|
||||
return
|
||||
}
|
||||
saveStatus.value = 'saved'
|
||||
savedTimer = setTimeout(() => {
|
||||
saveStatus.value = 'idle'
|
||||
}, SAVED_VISIBLE_MS)
|
||||
}
|
||||
|
||||
async function save(patch: PurgeSettingsPatch, field: SaveFailure) {
|
||||
pendingSaves += 1
|
||||
clearSavedTimer()
|
||||
saveStatus.value = 'saving'
|
||||
let succeeded = false
|
||||
try {
|
||||
settings.value = await updateSettings(patch)
|
||||
saveFailure.value = null
|
||||
succeeded = true
|
||||
} catch (error) {
|
||||
if (isForbidden(error)) {
|
||||
onForbidden()
|
||||
} else if (!isHandledGlobally(error)) {
|
||||
saveFailure.value = field
|
||||
}
|
||||
} finally {
|
||||
pendingSaves -= 1
|
||||
finishSaving(succeeded)
|
||||
}
|
||||
return succeeded
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearSkeletonTimer()
|
||||
clearSavedTimer()
|
||||
})
|
||||
|
||||
return { settings, loadState, showSkeleton, saveStatus, saveFailure, load, refresh, save }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { bumpMediaRevision } from '@/lib/mediaRevision'
|
||||
|
||||
export interface PurgeSettings {
|
||||
auto_purge_enabled: boolean
|
||||
auto_purge_interval_days: number
|
||||
last_purge_at: string | null
|
||||
last_purge_merged: number
|
||||
last_purge_articles_updated: number
|
||||
enabled_at: string | null
|
||||
next_purge_at: string | null
|
||||
}
|
||||
|
||||
export type PurgeSettingsPatch =
|
||||
| { auto_purge_enabled: boolean }
|
||||
| { auto_purge_interval_days: number }
|
||||
|
||||
export interface PurgePreviewGroup {
|
||||
keep_id: number
|
||||
filename: string
|
||||
url: string
|
||||
used_in: number
|
||||
merged: number
|
||||
}
|
||||
|
||||
export interface PurgePreview {
|
||||
groups: number
|
||||
copies: number
|
||||
articles: number
|
||||
list: PurgePreviewGroup[]
|
||||
more: number
|
||||
fingerprint: string
|
||||
}
|
||||
|
||||
export interface PurgeResult {
|
||||
groups_total: number
|
||||
groups_purged: number
|
||||
merged: number
|
||||
articles_updated: number
|
||||
failed_groups: number
|
||||
}
|
||||
|
||||
const SETTINGS_PATH = '/v1/media/purge-settings'
|
||||
const PREVIEW_PATH = '/v1/media/purge-preview'
|
||||
const PURGE_PATH = '/v1/media/purge'
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' }
|
||||
|
||||
export function useMediaPurge() {
|
||||
const { request } = useApi()
|
||||
|
||||
function getSettings() {
|
||||
return request<PurgeSettings>(SETTINGS_PATH)
|
||||
}
|
||||
|
||||
function updateSettings(patch: PurgeSettingsPatch) {
|
||||
return request<PurgeSettings>(SETTINGS_PATH, {
|
||||
method: 'PATCH',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
}
|
||||
|
||||
function getPreview() {
|
||||
return request<PurgePreview>(PREVIEW_PATH)
|
||||
}
|
||||
|
||||
async function purge(fingerprint: string) {
|
||||
const result = await request<PurgeResult>(PURGE_PATH, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ fingerprint }),
|
||||
})
|
||||
bumpMediaRevision()
|
||||
return result
|
||||
}
|
||||
|
||||
return { getSettings, updateSettings, getPreview, purge }
|
||||
}
|
||||
@@ -955,6 +955,55 @@ export const messages = {
|
||||
'Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default.':
|
||||
'Les routes d’articles acceptent `?format=markdown`, `?format=html` ou `?format=json` pour choisir le format de `body`. Avec `json`, `body` est la structure JSON stockée. Les articles ont `body_format: markdown` par défaut.',
|
||||
Meaning: 'Signification',
|
||||
'Media cleanup': 'Nettoyage des médias',
|
||||
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.':
|
||||
'Fusionnez les images identiques pour garder votre médiathèque ordonnée. Les articles qui utilisent une copie passent à la plus utilisée.',
|
||||
'Automatic purge': 'Purge automatique',
|
||||
'Runs in the background. Off by default.': 'S’exécute en arrière-plan. Désactivée par défaut.',
|
||||
Every: 'Tous les',
|
||||
'Between 1 and 365 days.': 'Entre 1 et 365 jours.',
|
||||
days: 'jours',
|
||||
'Never run': 'Jamais exécutée',
|
||||
'Last purge: {0} · {1} merged · {2} updated':
|
||||
'Dernière purge : {0} · {1} fusionnées · {2} mis à jour',
|
||||
'Next purge: {0}': 'Prochaine purge : {0}',
|
||||
"Couldn't save. Try again in a moment.": 'Impossible d’enregistrer. Réessayez dans un instant.',
|
||||
'Enter a whole number of days between 1 and 365.':
|
||||
'Saisissez un nombre entier de jours entre 1 et 365.',
|
||||
'Only owners and admins can change this.':
|
||||
'Seuls les propriétaires et les administrateurs peuvent modifier ceci.',
|
||||
'Purge now': 'Purger maintenant',
|
||||
'Merge every group of duplicates that has an image in use, right away.':
|
||||
'Fusionnez tout de suite chaque groupe de doublons dont une image est utilisée.',
|
||||
'Purge duplicates': 'Purger les doublons',
|
||||
"You're offline. Changing automatic purge and purging duplicates need a connection.":
|
||||
'Vous êtes hors ligne. Modifier la purge automatique et purger les doublons nécessitent une connexion.',
|
||||
'Purge duplicates?': 'Purger les doublons ?',
|
||||
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone.":
|
||||
'Les articles qui utilisent un doublon passeront à la copie la plus utilisée de cette image, et les autres copies seront supprimées. Cette action est irréversible.',
|
||||
'1 group': '1 groupe',
|
||||
'{0} groups': '{0} groupes',
|
||||
'{0} · {1} will be deleted · {2} will be updated':
|
||||
'{0} · {1} seront supprimées · {2} seront mis à jour',
|
||||
'Kept · used in {0}': 'Conservée · utilisée dans {0}',
|
||||
'{0} merged': '{0} fusionnées',
|
||||
'and 1 more group': 'et 1 autre groupe',
|
||||
'and {0} more groups': 'et {0} autres groupes',
|
||||
'Review in Duplicates': 'Voir dans Doublons',
|
||||
'Purge {0}': 'Purger {0}',
|
||||
'Purging…': 'Purge…',
|
||||
Close: 'Fermer',
|
||||
'Nothing to purge': 'Rien à purger',
|
||||
'No duplicate is used in an article, so there is nothing to merge.':
|
||||
'Aucun doublon n’est utilisé dans un article, il n’y a donc rien à fusionner.',
|
||||
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment.":
|
||||
'Impossible de purger les doublons. Rien n’a été modifié. Réessayez dans un instant.',
|
||||
"Some groups couldn't be merged. {0} of {1} groups were purged. Try again to finish.":
|
||||
'Certains groupes n’ont pas pu être fusionnés. {0} groupes sur {1} ont été purgés. Réessayez pour terminer.',
|
||||
'The library changed, so nothing was purged. Check the new summary and try again.':
|
||||
'La médiathèque a changé, rien n’a donc été purgé. Vérifiez le nouveau récapitulatif et réessayez.',
|
||||
'A purge is already running.': 'Une purge est déjà en cours.',
|
||||
'Duplicates purged': 'Doublons purgés',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,107 @@
|
||||
import { t } from '@/i18n'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import type { PurgeSettings } from '@/composables/useMediaPurge'
|
||||
import { formatMediaDate, pluralArticles } from '@/lib/mediaFormat'
|
||||
|
||||
export const MIN_INTERVAL_DAYS = 1
|
||||
export const MAX_INTERVAL_DAYS = 365
|
||||
export const DEFAULT_INTERVAL_DAYS = 30
|
||||
export const PURGE_TOAST_DURATION_MS = 5000
|
||||
export const REVIEW_DUPLICATES_PATH = '/media?filter=duplicates'
|
||||
export const PURGE_STALE_COPY =
|
||||
'The library changed, so nothing was purged. Check the new summary and try again.'
|
||||
export const PURGE_BUSY_COPY = 'A purge is already running.'
|
||||
|
||||
const HTTP_UNAUTHORIZED = 401
|
||||
const HTTP_FORBIDDEN = 403
|
||||
const WHOLE_NUMBER = /^\d+$/
|
||||
const SVG_EXTENSION = /\.svg$/i
|
||||
const PNG_EXTENSION = /\.png$/i
|
||||
|
||||
export function isForbidden(error: unknown) {
|
||||
return error instanceof ApiError && error.status === HTTP_FORBIDDEN
|
||||
}
|
||||
|
||||
export function isHandledGlobally(error: unknown) {
|
||||
return error instanceof ApiError && (error.status === HTTP_UNAUTHORIZED || isForbidden(error))
|
||||
}
|
||||
|
||||
export function parseIntervalDays(text: string) {
|
||||
const trimmed = text.trim()
|
||||
if (!WHOLE_NUMBER.test(trimmed)) {
|
||||
return null
|
||||
}
|
||||
const days = Number.parseInt(trimmed, 10)
|
||||
return days >= MIN_INTERVAL_DAYS && days <= MAX_INTERVAL_DAYS ? days : null
|
||||
}
|
||||
|
||||
export function pluralCopies(count: number) {
|
||||
return t(count === 1 ? '1 copy' : '{0} copies', [count])
|
||||
}
|
||||
|
||||
export function pluralGroups(count: number) {
|
||||
return t(count === 1 ? '1 group' : '{0} groups', [count])
|
||||
}
|
||||
|
||||
export function mergedCopies(count: number) {
|
||||
return t('{0} merged', [pluralCopies(count)])
|
||||
}
|
||||
|
||||
export function lastPurgeLine(settings: PurgeSettings | null) {
|
||||
if (!settings?.last_purge_at) {
|
||||
return t('Never run')
|
||||
}
|
||||
return t('Last purge: {0} · {1} merged · {2} updated', [
|
||||
formatMediaDate(settings.last_purge_at),
|
||||
pluralCopies(settings.last_purge_merged ?? 0),
|
||||
pluralArticles(settings.last_purge_articles_updated ?? 0),
|
||||
])
|
||||
}
|
||||
|
||||
export function nextPurgeLine(iso: string) {
|
||||
return t('Next purge: {0}', [formatMediaDate(iso)])
|
||||
}
|
||||
|
||||
export function previewSummary(counts: { groups: number; copies: number; articles: number }) {
|
||||
return t('{0} · {1} will be deleted · {2} will be updated', [
|
||||
pluralGroups(counts.groups),
|
||||
pluralCopies(counts.copies),
|
||||
pluralArticles(counts.articles),
|
||||
])
|
||||
}
|
||||
|
||||
export function keptLine(usedIn: number) {
|
||||
return t('Kept · used in {0}', [pluralArticles(usedIn)])
|
||||
}
|
||||
|
||||
export function moreGroupsLine(more: number) {
|
||||
return t(more === 1 ? 'and 1 more group' : 'and {0} more groups', [more])
|
||||
}
|
||||
|
||||
export function thumbnailSource(url: string, filename: string) {
|
||||
let contentType = ''
|
||||
if (SVG_EXTENSION.test(filename)) {
|
||||
contentType = 'image/svg+xml'
|
||||
} else if (PNG_EXTENSION.test(filename)) {
|
||||
contentType = 'image/png'
|
||||
}
|
||||
return { url, content_type: contentType, sha256: '' }
|
||||
}
|
||||
|
||||
export function purgedToast(result: { merged: number; articles_updated: number }) {
|
||||
return {
|
||||
id: 'media-purged',
|
||||
color: 'success' as const,
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Duplicates purged')
|
||||
},
|
||||
get description() {
|
||||
return t('{0} merged. {1} updated.', [
|
||||
pluralCopies(result.merged),
|
||||
pluralArticles(result.articles_updated),
|
||||
])
|
||||
},
|
||||
duration: PURGE_TOAST_DURATION_MS,
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import MediaTable from '@/components/MediaTable.vue'
|
||||
import MediaToolbar from '@/components/MediaToolbar.vue'
|
||||
import MediaUploadPanel from '@/components/MediaUploadPanel.vue'
|
||||
import MergeMediaDialog from '@/components/MergeMediaDialog.vue'
|
||||
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
|
||||
import RenameMediaDialog from '@/components/RenameMediaDialog.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
} from '@/composables/useMedia'
|
||||
import { useMediaSelection } from '@/composables/useMediaSelection'
|
||||
import { useMediaUpload } from '@/composables/useMediaUpload'
|
||||
import type { PurgeResult } from '@/composables/useMediaPurge'
|
||||
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
|
||||
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
|
||||
import { joinNames } from '@/lib/mediaFormat'
|
||||
@@ -41,7 +43,9 @@ import {
|
||||
toRouteQuery,
|
||||
type MediaControls,
|
||||
} from '@/lib/mediaQuery'
|
||||
import { purgedToast } from '@/lib/mediaPurge'
|
||||
import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const TOAST_DURATION_MS = 5000
|
||||
@@ -71,6 +75,7 @@ const OFFLINE_COPY =
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useLocalizedToast()
|
||||
const session = useSessionStore()
|
||||
const media = useMedia()
|
||||
const selection = useMediaSelection(media.items)
|
||||
const upload = useMediaUpload({ onAdded: refreshInBackground })
|
||||
@@ -91,12 +96,14 @@ const deleteOpen = ref(false)
|
||||
const bulkDeleteOpen = ref(false)
|
||||
const bulkTargets = ref<MediaItem[]>([])
|
||||
const mergeTarget = ref<{ group: DuplicateGroup; keep: MediaItem } | null>(null)
|
||||
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
|
||||
let skipFocusRestore = false
|
||||
let pendingSelectId: number | null = null
|
||||
let bulkDeletedTotal = 0
|
||||
@@ -138,6 +145,12 @@ const filterLabel = computed(
|
||||
],
|
||||
)
|
||||
const showList = computed(() => ready.value && !nothingToShow.value && !emptyLibrary.value)
|
||||
const canPurge = computed(
|
||||
() => session.session?.role === 'owner' || session.session?.role === 'admin',
|
||||
)
|
||||
const showPurge = computed(
|
||||
() => canPurge.value && isDuplicates.value && showList.value && groups.value.length > 0,
|
||||
)
|
||||
const dragging = computed(() => dragDepth.value > 0)
|
||||
const uploadBlocked = computed(() => offline.value || upload.limited.value)
|
||||
const uploadLabel = computed(() =>
|
||||
@@ -150,6 +163,7 @@ const dialogOpen = computed(
|
||||
renameOpen.value ||
|
||||
deleteOpen.value ||
|
||||
bulkDeleteOpen.value ||
|
||||
purgeOpen.value ||
|
||||
mergeTarget.value !== null,
|
||||
)
|
||||
const hasSelection = computed(() => selection.count.value > 0)
|
||||
@@ -724,6 +738,33 @@ function openMerge(group: DuplicateGroup, keep: MediaItem) {
|
||||
mergeTarget.value = { group, keep }
|
||||
}
|
||||
|
||||
function openPurge(event?: Event) {
|
||||
if (offline.value) {
|
||||
return
|
||||
}
|
||||
opener = (event?.currentTarget as HTMLElement | null) ?? null
|
||||
purgeChangedList = false
|
||||
purgeOpen.value = true
|
||||
}
|
||||
|
||||
function onPurged(result: PurgeResult) {
|
||||
toast.add(purgedToast(result))
|
||||
purgeChangedList = true
|
||||
}
|
||||
|
||||
function onPurgePartial() {
|
||||
purgeChangedList = true
|
||||
}
|
||||
|
||||
function closePurge() {
|
||||
purgeOpen.value = false
|
||||
if (purgeChangedList) {
|
||||
purgeChangedList = false
|
||||
refreshInBackground()
|
||||
}
|
||||
restoreFocusToOpener(null)
|
||||
}
|
||||
|
||||
function mergeText(merged: number, articlesUpdated: number) {
|
||||
const copiesText = merged === 1 ? t('1 copy') : t('{0} copies', [merged])
|
||||
const articlesText =
|
||||
@@ -873,10 +914,13 @@ async function mergeAction() {
|
||||
<MediaToolbar
|
||||
:controls="controls"
|
||||
:counts="counts"
|
||||
:show-purge="showPurge"
|
||||
:purge-disabled="offline"
|
||||
@update:q="updateControls({ q: $event })"
|
||||
@update:filter="updateControls({ filter: $event })"
|
||||
@update:sort="updateControls({ sort: $event })"
|
||||
@update:view="updateControls({ view: $event })"
|
||||
@purge="openPurge"
|
||||
/>
|
||||
|
||||
<MediaUploadPanel
|
||||
@@ -1022,6 +1066,14 @@ async function mergeAction() {
|
||||
@close="bulkDeleteOpen = false"
|
||||
/>
|
||||
|
||||
<PurgeDuplicatesDialog
|
||||
v-if="purgeOpen"
|
||||
@close="closePurge"
|
||||
@purged="onPurged"
|
||||
@partial="onPurgePartial"
|
||||
@review="purgeOpen = false"
|
||||
/>
|
||||
|
||||
<MergeMediaDialog
|
||||
v-if="mergeTarget"
|
||||
:keep="mergeTarget.keep"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import MediaCleanupCard from '@/components/MediaCleanupCard.vue'
|
||||
import RestartOverlay from '@/components/RestartOverlay.vue'
|
||||
import UpdatesCard from '@/components/UpdatesCard.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
@@ -19,6 +20,8 @@ import { useUpdateStore, type RestartOutcome, type RestartRequestResult } from '
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const POLL_INTERVAL_MS = 1000
|
||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||
const OFFLINE_COPY =
|
||||
"You're offline. Changing automatic purge and purging duplicates need a connection."
|
||||
const RESTART_FAILED_COPY =
|
||||
"Couldn't start the restart. Nothing was changed and your site is still running. Try again in a moment."
|
||||
const RESTART_WARNING =
|
||||
@@ -372,7 +375,11 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell>
|
||||
<DashboardShell
|
||||
bleed-offline-banner
|
||||
:offline-message="t(OFFLINE_COPY)"
|
||||
offline-icon="i-lucide-wifi-off"
|
||||
>
|
||||
<RestartOverlay
|
||||
v-if="update.restartRun"
|
||||
:kind="update.restartRun.kind"
|
||||
@@ -449,6 +456,8 @@ onBeforeUnmount(() => {
|
||||
@update="onUpdate"
|
||||
@restart="onRestart"
|
||||
/>
|
||||
|
||||
<MediaCleanupCard />
|
||||
</div>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -0,0 +1,217 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
callsTo,
|
||||
deferred,
|
||||
dialog,
|
||||
findButton,
|
||||
fixture,
|
||||
listBody,
|
||||
listCalls,
|
||||
mountPage,
|
||||
settle,
|
||||
textOf,
|
||||
unmountPage,
|
||||
type Handlers,
|
||||
} from './mediaTestKit'
|
||||
import { FAILED_COPY, previewOf, purgeResult } from './purgeTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const DUPLICATES = '/media?filter=duplicates'
|
||||
const DUPLICATE_IDS = [5, 6, 7, 9, 12]
|
||||
const COUNTS = { all: 12, unused: 2, duplicates: 2 }
|
||||
|
||||
function duplicatesList(ids = DUPLICATE_IDS): Handlers['list'] {
|
||||
return (path) =>
|
||||
path.includes('filter=duplicates')
|
||||
? listBody(fixture(ids), { ...COUNTS, duplicates: ids.length ? 2 : 0 })
|
||||
: listBody(fixture())
|
||||
}
|
||||
|
||||
function purgeButton() {
|
||||
return document.body.querySelector<HTMLButtonElement>('[data-testid="purge-duplicates"]')
|
||||
}
|
||||
|
||||
async function openFromMedia(handlers: Handlers = {}) {
|
||||
await mountPage({ list: duplicatesList(), preview: () => previewOf(), ...handlers }, DUPLICATES)
|
||||
purgeButton()!.focus()
|
||||
purgeButton()!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(unmountPage)
|
||||
|
||||
describe('The Duplicates toolbar button', () => {
|
||||
it.each(['owner', 'admin'])('shows for %s on the Duplicates tab with groups', async (role) => {
|
||||
await mountPage({ list: duplicatesList() }, DUPLICATES, undefined, { role })
|
||||
|
||||
expect(purgeButton()).not.toBeNull()
|
||||
expect(textOf(purgeButton())).toBe('Purge duplicates')
|
||||
expect(purgeButton()!.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the filter is All', '/media', 'owner', duplicatesList()],
|
||||
['the filter is Unused', '/media?filter=unused', 'owner', duplicatesList()],
|
||||
['there is no group', DUPLICATES, 'owner', duplicatesList([])],
|
||||
['the role is not owner or admin', DUPLICATES, 'editor', duplicatesList()],
|
||||
])('is hidden when %s', async (_name, url, role, list) => {
|
||||
await mountPage({ list }, url, undefined, { role })
|
||||
|
||||
expect(purgeButton()).toBeNull()
|
||||
})
|
||||
|
||||
it('is disabled offline, opens nothing and keeps the existing banner', async () => {
|
||||
await mountPage({ list: duplicatesList(), preview: () => previewOf() }, DUPLICATES)
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
await flushPromises()
|
||||
|
||||
purgeButton()!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(purgeButton()!.disabled).toBe(true)
|
||||
expect(document.body.textContent).toContain("You're offline.")
|
||||
expect(dialog()).toBeNull()
|
||||
expect(callsTo('GET', /purge-preview/)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sits between the sort menu and the view toggle, full width and 44 px high when stacked', async () => {
|
||||
await mountPage({ list: duplicatesList() }, DUPLICATES, MOBILE_WIDTH)
|
||||
|
||||
const sort = document.body.querySelector('[aria-label="Sort"]')!
|
||||
const toggle = document.body.querySelector('[role="group"][aria-label="View"]')!
|
||||
const button = purgeButton()!
|
||||
expect(sort.compareDocumentPosition(button)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
expect(button.compareDocumentPosition(toggle)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
expect(button.className).toContain('h-11')
|
||||
expect(button.className).toContain('w-full')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The purge dialog from Media', () => {
|
||||
it('opens in the loading state and sends the preview request', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await openFromMedia({ preview: () => pending.promise })
|
||||
|
||||
expect(callsTo('GET', /purge-preview/)).toHaveLength(1)
|
||||
expect(textOf(dialog())).toContain('Purge duplicates?')
|
||||
expect(findButton('Purge duplicates', dialog()!)!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('confirms with the preview fingerprint and shows the same summary', async () => {
|
||||
await openFromMedia()
|
||||
|
||||
expect(textOf(dialog()!.querySelector('[data-testid="purge-summary"]'))).toBe(
|
||||
'4 groups · 9 copies will be deleted · 12 articles will be updated',
|
||||
)
|
||||
findButton('Purge 4 groups', dialog()!)!.click()
|
||||
await flushPromises()
|
||||
const [call] = callsTo('POST', /\/v1\/media\/purge$/)
|
||||
expect(JSON.parse(call![1].body)).toEqual({ fingerprint: 'ab12' })
|
||||
})
|
||||
|
||||
it('shows the error notice and keeps the dialog open on a failure', async () => {
|
||||
await openFromMedia({ purge: () => Promise.reject(new ApiError(500)) })
|
||||
|
||||
findButton('Purge 4 groups', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(textOf(dialog()!.querySelector('[role="alert"]'))).toBe(FAILED_COPY)
|
||||
})
|
||||
|
||||
it('toasts, reloads the list and counts, and drops the button with the last group', async () => {
|
||||
let purged = false
|
||||
await openFromMedia({
|
||||
list: (path) =>
|
||||
path.includes('filter=duplicates')
|
||||
? listBody(fixture(purged ? [] : DUPLICATE_IDS), {
|
||||
all: 12,
|
||||
unused: 2,
|
||||
duplicates: purged ? 0 : 2,
|
||||
})
|
||||
: listBody(fixture()),
|
||||
purge: () => {
|
||||
purged = true
|
||||
return purgeResult()
|
||||
},
|
||||
})
|
||||
const reloads = listCalls().length
|
||||
|
||||
findButton('Purge 4 groups', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Duplicates purged',
|
||||
description: '9 copies merged. 12 articles updated.',
|
||||
duration: 5000,
|
||||
}),
|
||||
)
|
||||
expect(listCalls().length).toBe(reloads + 1)
|
||||
expect(document.body.textContent).toContain('No duplicates')
|
||||
expect(purgeButton()).toBeNull()
|
||||
})
|
||||
|
||||
it('reloads the list when Close follows a partial result', async () => {
|
||||
await openFromMedia({
|
||||
purge: () => purgeResult({ groups_purged: 3, failed_groups: 1 }),
|
||||
})
|
||||
findButton('Purge 4 groups', dialog()!)!.click()
|
||||
await settle()
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
const reloads = listCalls().length
|
||||
|
||||
findButton('Close', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(listCalls().length).toBe(reloads + 1)
|
||||
})
|
||||
|
||||
it('only closes the dialog when Review in Duplicates is followed', async () => {
|
||||
await openFromMedia()
|
||||
const before = router.currentRoute.value.fullPath
|
||||
|
||||
dialog()!.querySelector('a')!.click()
|
||||
await settle()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(router.currentRoute.value.fullPath).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Merge into this one stays', () => {
|
||||
it('still sends the merge request with the ids', async () => {
|
||||
const merge = vi.fn(() => ({ articles_updated: 5, merged: 2 }))
|
||||
await mountPage({ list: duplicatesList(), merge }, DUPLICATES)
|
||||
|
||||
const first = findButton('Merge into this one')!
|
||||
first.click()
|
||||
await flushPromises()
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(merge).toHaveBeenCalledWith({ keep_id: 12, merge_ids: [5, 6] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,529 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
|
||||
import {
|
||||
BUSY_COPY,
|
||||
FAILED_COPY,
|
||||
PREVIEW_PATH,
|
||||
PURGE_PATH,
|
||||
SETTINGS_PATH,
|
||||
STALE_COPY,
|
||||
bodyOf,
|
||||
callsTo,
|
||||
deferredValue,
|
||||
dialogEl,
|
||||
emptyPreview,
|
||||
enabledSettings,
|
||||
findButton,
|
||||
goOffline,
|
||||
groupRows,
|
||||
isBefore,
|
||||
lastLine,
|
||||
manyPreview,
|
||||
mountSettings,
|
||||
nextLine,
|
||||
openDialog,
|
||||
previewOf,
|
||||
pressEscape,
|
||||
purgeButton,
|
||||
purgeResult,
|
||||
singlePreview,
|
||||
textOf,
|
||||
unmountSettings,
|
||||
type PurgeMocks,
|
||||
} from './purgeTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const DESCRIPTION =
|
||||
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
|
||||
const SUMMARY = '4 groups · 9 copies will be deleted · 12 articles will be updated'
|
||||
const LAST_RUN_LINE = 'Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated'
|
||||
const AFTER_PURGE_LINE = 'Last purge: 5 Oct 2026 · 9 copies merged · 12 articles updated'
|
||||
|
||||
async function opened(mocks: PurgeMocks = {}) {
|
||||
await mountSettings({ settings: () => enabledSettings(), ...mocks })
|
||||
return openDialog()
|
||||
}
|
||||
|
||||
function buttonLabels() {
|
||||
return Array.from(dialogEl()!.querySelectorAll('button')).map(textOf)
|
||||
}
|
||||
|
||||
function confirmButton(label: string) {
|
||||
return findButton(label, dialogEl()!)!
|
||||
}
|
||||
|
||||
function noticeText() {
|
||||
return textOf(dialogEl()!.querySelector('[role="alert"]'))
|
||||
}
|
||||
|
||||
function afterRefresh(settings = enabledSettings()) {
|
||||
let reads = 0
|
||||
return () => (reads++ === 0 ? enabledSettings() : settings)
|
||||
}
|
||||
|
||||
function purgedSettings(overrides = {}) {
|
||||
return enabledSettings({
|
||||
last_purge_at: '2026-10-05T09:00:00Z',
|
||||
last_purge_merged: 9,
|
||||
last_purge_articles_updated: 12,
|
||||
next_purge_at: '2026-11-04T09:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(unmountSettings)
|
||||
|
||||
describe('Purge dialog: opening', () => {
|
||||
it('sends the preview request, shows the title, description, focuses Cancel and delays the skeleton', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const pending = deferredValue<unknown>()
|
||||
await opened({ preview: () => pending.promise })
|
||||
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(1)
|
||||
const box = dialogEl()!
|
||||
expect(textOf(box)).toContain('Purge duplicates?')
|
||||
expect(textOf(box)).toContain(DESCRIPTION)
|
||||
expect(document.activeElement).toBe(findButton('Cancel', box))
|
||||
|
||||
vi.advanceTimersByTime(149)
|
||||
await flushPromises()
|
||||
expect(box.querySelector('[data-testid="purge-skeleton"]')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(box.querySelector('[data-testid="purge-skeleton"]')).not.toBeNull()
|
||||
expect(box.querySelectorAll('.animate-pulse').length).toBeGreaterThan(3)
|
||||
expect(confirmButton('Purge duplicates').disabled).toBe(true)
|
||||
expect(buttonLabels()).toEqual(['Cancel', 'Purge duplicates'])
|
||||
})
|
||||
|
||||
it('is not openable while offline', async () => {
|
||||
await mountSettings({ settings: () => enabledSettings() })
|
||||
await goOffline()
|
||||
|
||||
purgeButton().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(purgeButton().disabled).toBe(true)
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Purge dialog: the preview', () => {
|
||||
it('shows the summary, the rows, the link and both buttons', async () => {
|
||||
await opened()
|
||||
|
||||
const box = dialogEl()!
|
||||
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(SUMMARY)
|
||||
const rows = groupRows()
|
||||
expect(rows).toHaveLength(4)
|
||||
expect(
|
||||
rows.map((row) =>
|
||||
Array.from(row.querySelectorAll('span')).map(textOf).filter(Boolean).join(' '),
|
||||
),
|
||||
).toEqual([
|
||||
'floorboards.jpg Kept · used in 4 articles 2 copies merged',
|
||||
'IMG_4471.jpg Kept · used in 2 articles 1 copy merged',
|
||||
'kitchen-after.webp Kept · used in 3 articles 3 copies merged',
|
||||
'logo-renew.svg Kept · used in 5 articles 3 copies merged',
|
||||
])
|
||||
expect(rows[0]!.querySelector('img')!.getAttribute('src')).toBe('/media/floorboards.jpg')
|
||||
expect(box.querySelector('[data-testid="purge-more"]')).toBeNull()
|
||||
const link = box.querySelector('a')!
|
||||
expect(link.getAttribute('href')).toBe('/media?filter=duplicates')
|
||||
expect(textOf(link)).toBe('Review in Duplicates')
|
||||
expect(link.querySelector('svg')).not.toBeNull()
|
||||
expect(buttonLabels()).toEqual(['Cancel', 'Purge 4 groups'])
|
||||
expect(confirmButton('Purge 4 groups').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[previewOf(), SUMMARY, 'Purge 4 groups', 'Kept · used in 4 articles', '2 copies merged'],
|
||||
[
|
||||
singlePreview(2, 1, 2),
|
||||
'1 group · 1 copy will be deleted · 2 articles will be updated',
|
||||
'Purge 1 group',
|
||||
'Kept · used in 2 articles',
|
||||
'1 copy merged',
|
||||
],
|
||||
[
|
||||
singlePreview(1, 1, 1),
|
||||
'1 group · 1 copy will be deleted · 1 article will be updated',
|
||||
'Purge 1 group',
|
||||
'Kept · used in 1 article',
|
||||
'1 copy merged',
|
||||
],
|
||||
])(
|
||||
'pluralises the summary, the confirm and the rows',
|
||||
async (preview, summary, confirm, kept, merged) => {
|
||||
await opened({ preview: () => preview })
|
||||
|
||||
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-summary"]'))).toBe(summary)
|
||||
expect(buttonLabels()).toEqual(['Cancel', confirm])
|
||||
const row = textOf(groupRows()[0])
|
||||
expect(row).toContain(kept)
|
||||
expect(row).toContain(merged)
|
||||
},
|
||||
)
|
||||
|
||||
it('stops at 5 rows and says how many groups are left', async () => {
|
||||
await opened({ preview: manyPreview })
|
||||
|
||||
expect(groupRows()).toHaveLength(5)
|
||||
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-more"]'))).toBe(
|
||||
'and 3 more groups',
|
||||
)
|
||||
expect(buttonLabels()).toEqual(['Cancel', 'Purge 8 groups'])
|
||||
})
|
||||
|
||||
it('says "and 1 more group" for one', async () => {
|
||||
await opened({ preview: () => ({ ...manyPreview(), more: 1 }) })
|
||||
|
||||
expect(textOf(dialogEl()!.querySelector('[data-testid="purge-more"]'))).toBe(
|
||||
'and 1 more group',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows Nothing to purge with a single Close button', async () => {
|
||||
await opened({ preview: emptyPreview })
|
||||
|
||||
const box = dialogEl()!
|
||||
expect(textOf(box)).toContain('Nothing to purge')
|
||||
expect(textOf(box)).toContain(
|
||||
'No duplicate is used in an article, so there is nothing to merge.',
|
||||
)
|
||||
expect(box.querySelector('svg')).not.toBeNull()
|
||||
expect(buttonLabels()).toEqual(['Close'])
|
||||
expect(box.querySelector('[data-testid="purge-summary"]')).toBeNull()
|
||||
expect(box.querySelector('a')).toBeNull()
|
||||
|
||||
findButton('Close', box)!.click()
|
||||
await flushPromises()
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a 500', () => Promise.reject(new ApiError(500))],
|
||||
['a network failure', () => Promise.reject(new NetworkError())],
|
||||
])('shows the error notice after %s and offers no purge', async (_name, preview) => {
|
||||
await opened({ preview })
|
||||
|
||||
expect(noticeText()).toBe(FAILED_COPY)
|
||||
expect(confirmButton('Purge duplicates').disabled).toBe(true)
|
||||
expect(findButton('Cancel', dialogEl()!)!.disabled).toBe(false)
|
||||
expect(dialogEl()!.querySelector('[data-testid="purge-summary"]')).toBeNull()
|
||||
confirmButton('Purge duplicates').click()
|
||||
await flushPromises()
|
||||
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Purge dialog: leaving', () => {
|
||||
it('closes with Cancel, sends nothing and returns focus to the opener', async () => {
|
||||
const opener = await opened()
|
||||
|
||||
findButton('Cancel', dialogEl()!)!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('closes with Escape, sends nothing and returns focus to the opener', async () => {
|
||||
const opener = await opened()
|
||||
|
||||
pressEscape(dialogEl())
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('closes with a click on the overlay and returns focus to the opener', async () => {
|
||||
const opener = await opened()
|
||||
|
||||
const overlay = document.body.querySelector('[data-slot="overlay"]')!
|
||||
overlay.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }))
|
||||
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(callsTo('POST', PURGE_PATH)).toHaveLength(0)
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('follows the review link to the Duplicates tab', async () => {
|
||||
await opened()
|
||||
|
||||
dialogEl()!.querySelector('a')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/media'))
|
||||
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Purge dialog: purging', () => {
|
||||
it('sends exactly the fingerprint of the preview, once', async () => {
|
||||
await opened({ preview: () => previewOf({ fingerprint: 'ab12' }) })
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const calls = callsTo('POST', PURGE_PATH)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(bodyOf(calls[0]!)).toEqual({ fingerprint: 'ab12' })
|
||||
expect(Object.keys(bodyOf(calls[0]!))).toEqual(['fingerprint'])
|
||||
})
|
||||
|
||||
it('cannot be dismissed while the request runs', async () => {
|
||||
const pending = deferredValue<unknown>()
|
||||
await opened({ purge: () => pending.promise })
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const confirm = confirmButton('Purging…')
|
||||
expect(confirm.disabled).toBe(true)
|
||||
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
|
||||
expect(findButton('Cancel', dialogEl()!)!.disabled).toBe(true)
|
||||
pressEscape(dialogEl())
|
||||
document.body
|
||||
.querySelector('[data-slot="overlay"]')
|
||||
?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
expect(dialogEl()).not.toBeNull()
|
||||
|
||||
pending.resolve(purgeResult())
|
||||
await flushPromises()
|
||||
expect(dialogEl()).toBeNull()
|
||||
})
|
||||
|
||||
it('closes, shows the toast, refreshes Last purge in place and returns focus on success', async () => {
|
||||
const opener = await opened({ settings: afterRefresh(purgedSettings()) })
|
||||
expect(lastLine()).toBe(LAST_RUN_LINE)
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
expect(toasts.add).toHaveBeenCalledTimes(1)
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Duplicates purged',
|
||||
description: '9 copies merged. 12 articles updated.',
|
||||
duration: 5000,
|
||||
}),
|
||||
)
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
|
||||
expect(lastLine()).toBe(AFTER_PURGE_LINE)
|
||||
expect(textOf(nextLine())).toBe('Next purge: 4 Nov 2026')
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('shows no Next purge when automatic purge is off', async () => {
|
||||
await opened({
|
||||
settings: afterRefresh(
|
||||
purgedSettings({ auto_purge_enabled: false, next_purge_at: null }),
|
||||
),
|
||||
})
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(lastLine()).toBe(AFTER_PURGE_LINE)
|
||||
expect(nextLine()).toBeNull()
|
||||
})
|
||||
|
||||
it('pluralises the toast', async () => {
|
||||
await opened({ purge: () => purgeResult({ merged: 1, articles_updated: 1 }) })
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ description: '1 copy merged. 1 article updated.' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits purged with the counts and then closes', async () => {
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn(),
|
||||
request: vi.fn(async (path: string) =>
|
||||
path === PREVIEW_PATH ? previewOf() : purgeResult(),
|
||||
),
|
||||
})
|
||||
const wrapper = mount(PurgeDuplicatesDialog, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('purged')).toEqual([[purgeResult()]])
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Purge dialog: failures', () => {
|
||||
it.each([
|
||||
['a 500', () => Promise.reject(new ApiError(500))],
|
||||
['a network error', () => Promise.reject(new NetworkError())],
|
||||
])(
|
||||
'keeps the dialog open after %s and retries with the same fingerprint',
|
||||
async (_name, purge) => {
|
||||
await opened({ preview: () => previewOf({ fingerprint: 'ab12' }), purge })
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const box = dialogEl()!
|
||||
const alert = box.querySelector('[role="alert"]')!
|
||||
expect(textOf(alert)).toBe(FAILED_COPY)
|
||||
expect(alert.querySelector('svg')).not.toBeNull()
|
||||
expect(isBefore(alert, box.querySelector('[data-testid="purge-summary"]')!)).toBe(true)
|
||||
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(SUMMARY)
|
||||
expect(groupRows()).toHaveLength(4)
|
||||
expect(findButton('Cancel', box)!.disabled).toBe(false)
|
||||
expect(confirmButton('Purge 4 groups').disabled).toBe(false)
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
expect(lastLine()).toBe(LAST_RUN_LINE)
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const calls = callsTo('POST', PURGE_PATH)
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(bodyOf(calls[1]!)).toEqual({ fingerprint: 'ab12' })
|
||||
},
|
||||
)
|
||||
|
||||
it('warns about a partial result, refreshes the preview and the settings, and retries with the new fingerprint', async () => {
|
||||
let previews = 0
|
||||
await opened({
|
||||
settings: afterRefresh(purgedSettings()),
|
||||
preview: () => (previews++ === 0 ? previewOf() : singlePreview(2, 1, 2)),
|
||||
purge: () =>
|
||||
purgeResult({
|
||||
groups_total: 4,
|
||||
groups_purged: 3,
|
||||
merged: 8,
|
||||
articles_updated: 10,
|
||||
failed_groups: 1,
|
||||
}),
|
||||
})
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const box = dialogEl()!
|
||||
expect(noticeText()).toBe(
|
||||
"Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish.",
|
||||
)
|
||||
expect(box.querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
|
||||
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(
|
||||
'1 group · 1 copy will be deleted · 2 articles will be updated',
|
||||
)
|
||||
expect(buttonLabels()).toEqual(['Close', 'Retry'])
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
expect(lastLine()).toBe(AFTER_PURGE_LINE)
|
||||
|
||||
confirmButton('Retry').click()
|
||||
await flushPromises()
|
||||
|
||||
const calls = callsTo('POST', PURGE_PATH)
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(bodyOf(calls[1]!)).toEqual({ fingerprint: 'one' })
|
||||
})
|
||||
|
||||
it('shows Nothing to purge when a partial result leaves no group', async () => {
|
||||
let previews = 0
|
||||
await opened({
|
||||
preview: () => (previews++ === 0 ? previewOf() : emptyPreview()),
|
||||
purge: () => purgeResult({ groups_purged: 3, failed_groups: 1 }),
|
||||
})
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(dialogEl())).toContain('Nothing to purge')
|
||||
expect(buttonLabels()).toEqual(['Close'])
|
||||
})
|
||||
|
||||
it('refreshes the summary after the library changed and confirms with the new fingerprint', async () => {
|
||||
let previews = 0
|
||||
const refreshed = previewOf({
|
||||
groups: 3,
|
||||
copies: 7,
|
||||
articles: 10,
|
||||
list: previewOf().list.slice(0, 3),
|
||||
fingerprint: 'cd34',
|
||||
})
|
||||
await opened({
|
||||
preview: () => (previews++ === 0 ? previewOf({ fingerprint: 'ab12' }) : refreshed),
|
||||
purge: () => Promise.reject(new ApiError(409, undefined, STALE_COPY)),
|
||||
})
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
const box = dialogEl()!
|
||||
expect(noticeText()).toBe(STALE_COPY)
|
||||
expect(box.querySelector('[role="alert"] svg')).not.toBeNull()
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
|
||||
expect(textOf(box.querySelector('[data-testid="purge-summary"]'))).toBe(
|
||||
'3 groups · 7 copies will be deleted · 10 articles will be updated',
|
||||
)
|
||||
expect(buttonLabels()).toEqual(['Cancel', 'Purge 3 groups'])
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
expect(lastLine()).toBe(LAST_RUN_LINE)
|
||||
|
||||
confirmButton('Purge 3 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(bodyOf(callsTo('POST', PURGE_PATH)[1]!)).toEqual({ fingerprint: 'cd34' })
|
||||
})
|
||||
|
||||
it('shows the server text when a purge is already running', async () => {
|
||||
await opened({ purge: () => Promise.reject(new ApiError(409, undefined, BUSY_COPY)) })
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(noticeText()).toBe(BUSY_COPY)
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(2)
|
||||
expect(dialogEl()).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
stubClipboard,
|
||||
upToDate,
|
||||
visibleButtonLabels,
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
@@ -41,6 +43,9 @@ async function mountSettings(role: UserRole, status: UpdateStatus) {
|
||||
if (path === STATUS_PATH) {
|
||||
return Promise.resolve(status)
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError } from '@/composables/useApi'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import {
|
||||
INVALID_DAYS_COPY,
|
||||
OFFLINE_COPY,
|
||||
PREVIEW_PATH,
|
||||
SAVE_FAILED_COPY,
|
||||
SETTINGS_PATH,
|
||||
bodyOf,
|
||||
callsTo,
|
||||
cardEl,
|
||||
commitByBlur,
|
||||
commitByEnter,
|
||||
daysError,
|
||||
daysInput,
|
||||
deferredValue,
|
||||
enabledSettings,
|
||||
findButton,
|
||||
goOffline,
|
||||
lastLine,
|
||||
mountSettings,
|
||||
nextLine,
|
||||
purgeButton,
|
||||
purgeKit,
|
||||
purgeSettings,
|
||||
saveStatus,
|
||||
switchEl,
|
||||
switchError,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountSettings,
|
||||
type SettingsCall,
|
||||
} from './purgeTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const DESCRIPTION =
|
||||
'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.'
|
||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||
|
||||
function patching(onPatch: (call: SettingsCall) => unknown, read = () => enabledSettings()) {
|
||||
return (call: SettingsCall) => (call.method === 'PATCH' ? onPatch(call) : read())
|
||||
}
|
||||
|
||||
function savedInterval(call: SettingsCall) {
|
||||
const days = (call.body as { auto_purge_interval_days: number }).auto_purge_interval_days
|
||||
return enabledSettings({ auto_purge_interval_days: days })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(unmountSettings)
|
||||
|
||||
describe('Media cleanup card: default and on', () => {
|
||||
it('Default: off, never run', async () => {
|
||||
await mountSettings({ settings: () => purgeSettings() })
|
||||
|
||||
const card = cardEl()
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(1)
|
||||
expect(textOf(card.querySelector('h2'))).toBe('Media cleanup')
|
||||
expect(textOf(card)).toContain(DESCRIPTION)
|
||||
expect(textOf(card)).toContain('Automatic purge')
|
||||
expect(textOf(card)).toContain('Runs in the background. Off by default.')
|
||||
expect(switchEl().getAttribute('aria-checked')).toBe('false')
|
||||
expect(daysInput()).toBeNull()
|
||||
expect(textOf(card)).not.toContain('Every')
|
||||
expect(nextLine()).toBeNull()
|
||||
expect(lastLine()).toBe('Never run')
|
||||
expect(card.querySelector('[role="separator"]')).not.toBeNull()
|
||||
expect(textOf(card)).toContain('Purge now')
|
||||
expect(textOf(card)).toContain(
|
||||
'Merge every group of duplicates that has an image in use, right away.',
|
||||
)
|
||||
expect(purgeButton().disabled).toBe(false)
|
||||
expect(findButton('Save', card)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('On: 30 days, last run', async () => {
|
||||
await mountSettings({ settings: () => enabledSettings() })
|
||||
|
||||
const card = cardEl()
|
||||
expect(switchEl().getAttribute('aria-checked')).toBe('true')
|
||||
expect(textOf(card)).toContain('Every')
|
||||
expect(textOf(card)).toContain('Between 1 and 365 days.')
|
||||
expect(daysInput()!.value).toBe('30')
|
||||
expect(textOf(card)).toContain('days')
|
||||
expect(lastLine()).toBe('Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated')
|
||||
expect(textOf(nextLine())).toBe('Next purge: 14 Jul 2026')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[3, 5, 'Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated'],
|
||||
[1, 1, 'Last purge: 14 Jun 2026 · 1 copy merged · 1 article updated'],
|
||||
[0, 0, 'Last purge: 14 Jun 2026 · 0 copies merged · 0 articles updated'],
|
||||
])(
|
||||
'pluralises the Last purge line for %i merged and %i articles',
|
||||
async (merged, articles, line) => {
|
||||
await mountSettings({
|
||||
settings: () =>
|
||||
enabledSettings({
|
||||
last_purge_merged: merged,
|
||||
last_purge_articles_updated: articles,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(lastLine()).toBe(line)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Media cleanup card: the switch', () => {
|
||||
it('turning it on sends exactly the flag, shows Saving, then Saved for 2 s', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const patch = deferredValue<unknown>()
|
||||
await mountSettings({
|
||||
settings: patching(
|
||||
() => patch.promise,
|
||||
() => purgeSettings(),
|
||||
),
|
||||
})
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
|
||||
const [call] = callsTo('PATCH', SETTINGS_PATH)
|
||||
expect(call![0]).toBe(SETTINGS_PATH)
|
||||
expect(bodyOf(call!)).toEqual({ auto_purge_enabled: true })
|
||||
expect(Object.keys(bodyOf(call!))).toEqual(['auto_purge_enabled'])
|
||||
expect(saveStatus()).toBe('Saving…')
|
||||
expect(
|
||||
cardEl()
|
||||
.querySelector('[data-testid="cleanup-save-status"]')!
|
||||
.getAttribute('aria-live'),
|
||||
).toBe('polite')
|
||||
expect(switchEl().disabled).toBe(false)
|
||||
|
||||
patch.resolve(
|
||||
enabledSettings({ last_purge_at: null, next_purge_at: '2026-11-04T09:00:00Z' }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(saveStatus()).toBe('Saved')
|
||||
expect(daysInput()!.value).toBe('30')
|
||||
expect(textOf(nextLine())).toBe('Next purge: 4 Nov 2026')
|
||||
vi.advanceTimersByTime(1999)
|
||||
await flushPromises()
|
||||
expect(saveStatus()).toBe('Saved')
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(saveStatus()).toBe('')
|
||||
})
|
||||
|
||||
it('turning it off sends exactly the flag and hides the schedule but keeps Last purge', async () => {
|
||||
await mountSettings({
|
||||
settings: patching(() =>
|
||||
enabledSettings({ auto_purge_enabled: false, next_purge_at: null }),
|
||||
),
|
||||
})
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
|
||||
const [call] = callsTo('PATCH', SETTINGS_PATH)
|
||||
expect(bodyOf(call!)).toEqual({ auto_purge_enabled: false })
|
||||
expect(switchEl().getAttribute('aria-checked')).toBe('false')
|
||||
expect(daysInput()).toBeNull()
|
||||
expect(nextLine()).toBeNull()
|
||||
expect(lastLine()).toBe('Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media cleanup card: the days field', () => {
|
||||
it.each([
|
||||
['45', 'blur', 45],
|
||||
['45', 'Enter', 45],
|
||||
['1', 'Enter', 1],
|
||||
['365', 'blur', 365],
|
||||
])('saves %s once when it %s commits', async (typed, commit, sent) => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
const input = daysInput()!
|
||||
|
||||
await typeInto(input, typed)
|
||||
await (commit === 'blur' ? commitByBlur(input) : commitByEnter(input))
|
||||
|
||||
const calls = callsTo('PATCH', SETTINGS_PATH)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(bodyOf(calls[0]!)).toEqual({ auto_purge_interval_days: sent })
|
||||
expect(saveStatus()).toBe('Saved')
|
||||
expect(textOf(nextLine())).toBe('Next purge: 14 Jul 2026')
|
||||
})
|
||||
|
||||
it('sends one request when Enter is followed by the blur', async () => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
const input = daysInput()!
|
||||
|
||||
await typeInto(input, '45')
|
||||
await commitByEnter(input)
|
||||
await commitByBlur(input)
|
||||
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sends nothing for an unchanged value', async () => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
const input = daysInput()!
|
||||
|
||||
input.focus()
|
||||
await commitByBlur(input)
|
||||
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(saveStatus()).toBe('')
|
||||
})
|
||||
|
||||
it.each(['', '0', '366', '400', '1.5'])(
|
||||
'rejects "%s" without sending anything and keeps the text',
|
||||
async (typed) => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
const input = daysInput()!
|
||||
|
||||
await typeInto(input, typed)
|
||||
await commitByBlur(input)
|
||||
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(input.value).toBe(typed)
|
||||
expect(input.getAttribute('aria-invalid')).toBe('true')
|
||||
expect(textOf(daysError())).toBe(INVALID_DAYS_COPY)
|
||||
expect(daysError()!.querySelector('svg')).not.toBeNull()
|
||||
expect(saveStatus()).toBe('')
|
||||
},
|
||||
)
|
||||
|
||||
it('also validates on Enter', async () => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
|
||||
await typeInto(daysInput()!, '366')
|
||||
await commitByEnter(daysInput()!)
|
||||
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(textOf(daysError())).toBe(INVALID_DAYS_COPY)
|
||||
})
|
||||
|
||||
it('clears the validation error once the value is valid and sends it', async () => {
|
||||
await mountSettings({ settings: patching(savedInterval) })
|
||||
const input = daysInput()!
|
||||
await typeInto(input, '400')
|
||||
await commitByBlur(input)
|
||||
expect(daysError()).not.toBeNull()
|
||||
|
||||
await typeInto(input, '60')
|
||||
await commitByEnter(input)
|
||||
|
||||
expect(daysError()).toBeNull()
|
||||
expect(input.getAttribute('aria-invalid')).toBeNull()
|
||||
expect(bodyOf(callsTo('PATCH', SETTINGS_PATH)[0]!)).toEqual({
|
||||
auto_purge_interval_days: 60,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media cleanup card: save failures', () => {
|
||||
it.each([
|
||||
['a 500', () => Promise.reject(new ApiError(500))],
|
||||
['a network error', () => Promise.reject(new NetworkError())],
|
||||
])('reverts the days field after %s and shows the message under it', async (_name, failure) => {
|
||||
await mountSettings({ settings: patching(failure) })
|
||||
const input = daysInput()!
|
||||
|
||||
await typeInto(input, '45')
|
||||
await commitByEnter(input)
|
||||
|
||||
expect(input.value).toBe('30')
|
||||
expect(textOf(daysError())).toBe(SAVE_FAILED_COPY)
|
||||
expect(saveStatus()).toBe('')
|
||||
})
|
||||
|
||||
it('reverts the switch after a 500 and shows the message under Automatic purge', async () => {
|
||||
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(500))) })
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(switchEl().getAttribute('aria-checked')).toBe('true')
|
||||
expect(textOf(switchError())).toBe(SAVE_FAILED_COPY)
|
||||
expect(saveStatus()).toBe('')
|
||||
})
|
||||
|
||||
it('removes the message on the next successful save', async () => {
|
||||
let failing = true
|
||||
await mountSettings({
|
||||
settings: patching((call) =>
|
||||
failing ? Promise.reject(new ApiError(500)) : savedInterval(call),
|
||||
),
|
||||
})
|
||||
const input = daysInput()!
|
||||
await typeInto(input, '45')
|
||||
await commitByEnter(input)
|
||||
expect(daysError()).not.toBeNull()
|
||||
|
||||
failing = false
|
||||
await typeInto(input, '45')
|
||||
await commitByEnter(input)
|
||||
|
||||
expect(daysError()).toBeNull()
|
||||
expect(saveStatus()).toBe('Saved')
|
||||
})
|
||||
|
||||
it('reverts silently on a 403, refreshes the session and becomes read-only', async () => {
|
||||
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(403))) })
|
||||
const refresh = vi.spyOn(useSessionStore(), 'refresh')
|
||||
purgeKit.meRole = 'editor'
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
expect(router.currentRoute.value.path).toBe('/settings')
|
||||
expect(switchError()).toBeNull()
|
||||
expect(textOf(cardEl())).toContain('Only owners and admins can change this.')
|
||||
expect(findButton('Purge duplicates', cardEl())).toBeUndefined()
|
||||
expect(switchEl().disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media cleanup card: read-only', () => {
|
||||
it('never reads the settings and shows placeholders, disabled, with the info alert', async () => {
|
||||
await mountSettings({ settings: () => enabledSettings() }, { role: 'editor' })
|
||||
|
||||
const card = cardEl()
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(switchEl().getAttribute('aria-checked')).toBe('false')
|
||||
expect(switchEl().disabled).toBe(true)
|
||||
expect(daysInput()!.value).toBe('30')
|
||||
expect(daysInput()!.disabled).toBe(true)
|
||||
expect(lastLine()).toBe('Never run')
|
||||
expect(nextLine()).toBeNull()
|
||||
expect(textOf(card.querySelector('[role="status"]'))).toBe(
|
||||
'Only owners and admins can change this.',
|
||||
)
|
||||
expect(card.querySelector('[role="separator"]')).toBeNull()
|
||||
expect(textOf(card)).not.toContain('Purge now')
|
||||
expect(findButton('Purge duplicates', card)).toBeUndefined()
|
||||
|
||||
switchEl().click()
|
||||
await commitByBlur(daysInput()!)
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('treats a missing role the same way', async () => {
|
||||
await mountSettings({}, { role: undefined })
|
||||
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(findButton('Purge duplicates', cardEl())).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media cleanup card: loading and failure to load', () => {
|
||||
it('shows the skeleton only after 150 ms, with aria-busy and a disabled button', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const pending = deferredValue<unknown>()
|
||||
await mountSettings({ settings: () => pending.promise })
|
||||
|
||||
vi.advanceTimersByTime(149)
|
||||
await flushPromises()
|
||||
expect(cardEl().hasAttribute('aria-busy')).toBe(false)
|
||||
expect(cardEl().querySelector('.animate-pulse')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(cardEl().getAttribute('aria-busy')).toBe('true')
|
||||
expect(cardEl().querySelector('.animate-pulse')).not.toBeNull()
|
||||
expect(purgeButton().disabled).toBe(true)
|
||||
|
||||
pending.resolve(purgeSettings())
|
||||
await flushPromises()
|
||||
expect(cardEl().hasAttribute('aria-busy')).toBe(false)
|
||||
expect(cardEl().querySelector('.animate-pulse')).toBeNull()
|
||||
expect(lastLine()).toBe('Never run')
|
||||
expect(purgeButton().disabled).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a 500', () => Promise.reject(new ApiError(500))],
|
||||
['a network failure', () => Promise.reject(new NetworkError())],
|
||||
])('shows the inline alert after %s and Try again loads it again', async (_name, failure) => {
|
||||
let failing = true
|
||||
await mountSettings({ settings: () => (failing ? failure() : purgeSettings()) })
|
||||
|
||||
const alert = cardEl().querySelector('[role="alert"]')!
|
||||
expect(textOf(alert)).toContain(UNREACHABLE_COPY)
|
||||
expect(alert.querySelector('button[aria-label="Close"]')).toBeNull()
|
||||
expect(document.body.querySelector('[data-testid="settings-version"]')).not.toBeNull()
|
||||
|
||||
failing = false
|
||||
findButton('Try again', cardEl())!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(callsTo('GET', SETTINGS_PATH)).toHaveLength(2)
|
||||
expect(cardEl().querySelector('[role="alert"]')).toBeNull()
|
||||
expect(lastLine()).toBe('Never run')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Media cleanup card: offline and 401', () => {
|
||||
it('shows the banner and disables the controls, sending nothing', async () => {
|
||||
await mountSettings({ settings: () => enabledSettings() })
|
||||
await goOffline()
|
||||
|
||||
expect(document.body.textContent).toContain(OFFLINE_COPY)
|
||||
expect(switchEl().disabled).toBe(true)
|
||||
expect(daysInput()!.disabled).toBe(true)
|
||||
expect(purgeButton().disabled).toBe(true)
|
||||
|
||||
switchEl().click()
|
||||
purgeButton().click()
|
||||
await flushPromises()
|
||||
expect(callsTo('PATCH', SETTINGS_PATH)).toHaveLength(0)
|
||||
expect(callsTo('GET', PREVIEW_PATH)).toHaveLength(0)
|
||||
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('goes to the login page on a 401 and shows no copy of its own', async () => {
|
||||
await mountSettings(
|
||||
{ settings: () => Promise.reject(new ApiError(401)) },
|
||||
{ role: 'owner', realApi: true },
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
redirect: '/settings',
|
||||
reason: 'expired',
|
||||
})
|
||||
expect(textOf(cardEl())).not.toContain(UNREACHABLE_COPY)
|
||||
expect(cardEl().querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no save failure for a 401 on a change', async () => {
|
||||
await mountSettings(
|
||||
{ settings: patching(() => Promise.reject(new ApiError(401))) },
|
||||
{ role: 'owner', realApi: true },
|
||||
)
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(textOf(cardEl())).not.toContain(SAVE_FAILED_COPY)
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
updateSucceeded,
|
||||
verifying,
|
||||
visibleButtonLabels,
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
@@ -135,6 +137,9 @@ async function mountSettings(options: MountOptions) {
|
||||
}
|
||||
const handler = handlers[path]
|
||||
if (!handler) {
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
}
|
||||
return handler()
|
||||
@@ -262,6 +267,9 @@ function silenceStatusReads() {
|
||||
if (path === STATUS_PATH) {
|
||||
return Promise.reject(new NetworkError())
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
}
|
||||
@@ -650,6 +658,9 @@ describe('Settings, the overlay ends when the site answers', () => {
|
||||
if (path === STATUS_PATH) {
|
||||
return answers()
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
siteUp = healthy
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
startable,
|
||||
verifying,
|
||||
visibleButtonLabels,
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
@@ -89,6 +91,9 @@ async function mountSettings(options: MountOptions) {
|
||||
if (path === DOWNLOAD_PATH) {
|
||||
return downloadHandler()
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
getMeMock = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
stubClipboard,
|
||||
upToDate,
|
||||
visibleButtonLabels,
|
||||
DEFAULT_PURGE_SETTINGS,
|
||||
PURGE_SETTINGS_PATH,
|
||||
} from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
@@ -77,6 +79,9 @@ async function mountSettings(options: MountOptions) {
|
||||
if (path === CHECK_PATH) {
|
||||
return checkHandler()
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
getMeMock = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -6,6 +6,7 @@ import router from '@/router'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
import { DEFAULT_PURGE_SETTINGS, PURGE_SETTINGS_PATH } from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -38,6 +39,9 @@ async function mountPage(settings: () => Promise<unknown>) {
|
||||
if (path === SETTINGS_PATH) {
|
||||
return settings()
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
|
||||
@@ -236,6 +236,8 @@ export interface Handlers {
|
||||
hash?: (path: string) => unknown
|
||||
remove?: (id: number) => unknown
|
||||
merge?: (body: { keep_id: number; merge_ids: number[] }) => unknown
|
||||
preview?: () => unknown
|
||||
purge?: (body: { fingerprint: string }) => unknown
|
||||
}
|
||||
|
||||
export function libraryHandlers(
|
||||
@@ -274,6 +276,12 @@ function dispatcher(handlers: Handlers) {
|
||||
if (method === 'GET' && path.startsWith('/v1/media/?')) {
|
||||
return (handlers.list ?? (() => listBody(fixture())))(path)
|
||||
}
|
||||
if (method === 'GET' && path === '/v1/media/purge-preview') {
|
||||
return (handlers.preview ?? (() => undefined))()
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/media/purge') {
|
||||
return (handlers.purge ?? (() => undefined))(JSON.parse(String(options?.body)))
|
||||
}
|
||||
const usage = /^\/v1\/media\/(\d+)\/usage$/.exec(path)
|
||||
if (method === 'GET' && usage) {
|
||||
return (handlers.usage ?? (() => []))(Number(usage[1]))
|
||||
@@ -316,12 +324,18 @@ export async function mountPage(
|
||||
handlers: Handlers = {},
|
||||
url = '/media',
|
||||
width = DESKTOP_WIDTH,
|
||||
options: { realApi?: boolean } = {},
|
||||
options: { realApi?: boolean; role?: string } = {},
|
||||
) {
|
||||
setViewportWidth(width)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, displayName: '', role: 'owner' }
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: (options.role ?? 'owner') as never,
|
||||
}
|
||||
session.status = 'ready'
|
||||
const dispatch = dispatcher(handlers)
|
||||
if (options.realApi) {
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { PurgePreview, PurgeResult, PurgeSettings } from '@/composables/useMediaPurge'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
|
||||
export const SETTINGS_PATH = '/v1/media/purge-settings'
|
||||
export const PREVIEW_PATH = '/v1/media/purge-preview'
|
||||
export const PURGE_PATH = '/v1/media/purge'
|
||||
export const STALE_COPY =
|
||||
'The library changed, so nothing was purged. Check the new summary and try again.'
|
||||
export const BUSY_COPY = 'A purge is already running.'
|
||||
export const FAILED_COPY =
|
||||
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
|
||||
export const SAVE_FAILED_COPY = "Couldn't save. Try again in a moment."
|
||||
export const INVALID_DAYS_COPY = 'Enter a whole number of days between 1 and 365.'
|
||||
export const OFFLINE_COPY =
|
||||
"You're offline. Changing automatic purge and purging duplicates need a connection."
|
||||
|
||||
export function purgeSettings(overrides: Partial<PurgeSettings> = {}): PurgeSettings {
|
||||
return {
|
||||
auto_purge_enabled: false,
|
||||
auto_purge_interval_days: 30,
|
||||
last_purge_at: null,
|
||||
last_purge_merged: 0,
|
||||
last_purge_articles_updated: 0,
|
||||
enabled_at: null,
|
||||
next_purge_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function enabledSettings(overrides: Partial<PurgeSettings> = {}) {
|
||||
return purgeSettings({
|
||||
auto_purge_enabled: true,
|
||||
last_purge_at: '2026-06-14T09:00:00Z',
|
||||
last_purge_merged: 3,
|
||||
last_purge_articles_updated: 5,
|
||||
enabled_at: '2026-06-14T09:00:00Z',
|
||||
next_purge_at: '2026-07-14T09:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
const GROUPS = [
|
||||
{
|
||||
keep_id: 12,
|
||||
filename: 'floorboards.jpg',
|
||||
url: '/media/floorboards.jpg',
|
||||
used_in: 4,
|
||||
merged: 2,
|
||||
},
|
||||
{ keep_id: 7, filename: 'IMG_4471.jpg', url: '/media/IMG_4471.jpg', used_in: 2, merged: 1 },
|
||||
{
|
||||
keep_id: 2,
|
||||
filename: 'kitchen-after.webp',
|
||||
url: '/media/kitchen-after.webp',
|
||||
used_in: 3,
|
||||
merged: 3,
|
||||
},
|
||||
{ keep_id: 4, filename: 'logo-renew.svg', url: '/media/logo-renew.svg', used_in: 5, merged: 3 },
|
||||
{
|
||||
keep_id: 11,
|
||||
filename: 'lime-plaster.jpg',
|
||||
url: '/media/lime-plaster.jpg',
|
||||
used_in: 2,
|
||||
merged: 1,
|
||||
},
|
||||
]
|
||||
|
||||
export function previewOf(overrides: Partial<PurgePreview> = {}): PurgePreview {
|
||||
return {
|
||||
groups: 4,
|
||||
copies: 9,
|
||||
articles: 12,
|
||||
list: GROUPS.slice(0, 4),
|
||||
more: 0,
|
||||
fingerprint: 'ab12',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function manyPreview() {
|
||||
return previewOf({ groups: 8, copies: 15, articles: 24, list: GROUPS, more: 3 })
|
||||
}
|
||||
|
||||
export function emptyPreview() {
|
||||
return previewOf({ groups: 0, copies: 0, articles: 0, list: [], fingerprint: 'none' })
|
||||
}
|
||||
|
||||
export function singlePreview(usedIn: number, merged: number, articles: number, copies = merged) {
|
||||
return previewOf({
|
||||
groups: 1,
|
||||
copies,
|
||||
articles,
|
||||
list: [{ ...GROUPS[0]!, used_in: usedIn, merged }],
|
||||
fingerprint: 'one',
|
||||
})
|
||||
}
|
||||
|
||||
export function purgeResult(overrides: Partial<PurgeResult> = {}): PurgeResult {
|
||||
return {
|
||||
groups_total: 4,
|
||||
groups_purged: 4,
|
||||
merged: 9,
|
||||
articles_updated: 12,
|
||||
failed_groups: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsCall {
|
||||
method: string
|
||||
body: unknown
|
||||
}
|
||||
|
||||
export interface PurgeMocks {
|
||||
settings?: (call: SettingsCall) => unknown
|
||||
preview?: () => unknown
|
||||
purge?: (body: { fingerprint: string }) => unknown
|
||||
}
|
||||
|
||||
export const purgeKit = {
|
||||
requestMock: vi.fn(),
|
||||
wrapper: null as VueWrapper | null,
|
||||
meRole: 'owner' as string | undefined,
|
||||
getMe: vi.fn(),
|
||||
}
|
||||
|
||||
export function installPurgeApi(mocks: PurgeMocks) {
|
||||
return (path: string, options?: RequestInit) => {
|
||||
const method = options?.method ?? 'GET'
|
||||
if (path === SETTINGS_PATH) {
|
||||
const body = options?.body ? JSON.parse(String(options.body)) : undefined
|
||||
return (mocks.settings ?? (() => purgeSettings()))({ method, body })
|
||||
}
|
||||
if (path === PREVIEW_PATH && method === 'GET') {
|
||||
return (mocks.preview ?? (() => previewOf()))()
|
||||
}
|
||||
if (path === PURGE_PATH && method === 'POST') {
|
||||
return (mocks.purge ?? (() => purgeResult()))(JSON.parse(String(options?.body)))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function fetchFrom(request: (path: string, init?: RequestInit) => Promise<unknown>) {
|
||||
return vi.fn(async (path: string, init?: RequestInit) => {
|
||||
const reply = (status: number, body?: unknown) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: new Headers(),
|
||||
json: async () => body,
|
||||
text: async () => '',
|
||||
})
|
||||
try {
|
||||
const body = await request(path, init)
|
||||
return reply(body === undefined ? 204 : 200, body)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return reply(error.status)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function mountSettings(
|
||||
mocks: PurgeMocks = {},
|
||||
options: { role?: string | undefined; realApi?: boolean } = { role: 'owner' },
|
||||
) {
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
setActivePinia(createPinia())
|
||||
purgeKit.meRole = options.role
|
||||
const session = useSessionStore()
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: options.role as never,
|
||||
}
|
||||
session.status = 'ready'
|
||||
const dispatch = installPurgeApi(mocks)
|
||||
purgeKit.requestMock = vi.fn(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/v1/settings/') {
|
||||
return { version: 'v2.2.0-beta' }
|
||||
}
|
||||
if (path === '/v1/system/update') {
|
||||
return new Promise(() => undefined)
|
||||
}
|
||||
return dispatch(path, init)
|
||||
})
|
||||
purgeKit.getMe = vi.fn(async () => ({
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: purgeKit.meRole,
|
||||
}))
|
||||
if (options.realApi) {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('@/composables/useApi')>('@/composables/useApi')
|
||||
vi.mocked(useApi).mockImplementation(actual.useApi)
|
||||
vi.stubGlobal('fetch', fetchFrom(purgeKit.requestMock))
|
||||
} else {
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: purgeKit.getMe,
|
||||
request: purgeKit.requestMock,
|
||||
})
|
||||
}
|
||||
await router.push('/settings')
|
||||
purgeKit.wrapper = mount(SettingsView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return purgeKit.wrapper
|
||||
}
|
||||
|
||||
export async function unmountSettings() {
|
||||
purgeKit.wrapper?.unmount()
|
||||
purgeKit.wrapper = null
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
|
||||
export function cardEl() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="media-cleanup-card"]')!
|
||||
}
|
||||
|
||||
export function textOf(element: Element | null | undefined) {
|
||||
return (element?.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
export function findButton(label: string, root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||
(button) => textOf(button) === label,
|
||||
)
|
||||
}
|
||||
|
||||
export function purgeButton() {
|
||||
return findButton('Purge duplicates', cardEl())!
|
||||
}
|
||||
|
||||
export function switchEl() {
|
||||
return cardEl().querySelector<HTMLButtonElement>('[role="switch"]')!
|
||||
}
|
||||
|
||||
export function daysInput() {
|
||||
return cardEl().querySelector<HTMLInputElement>('#auto-purge-days')
|
||||
}
|
||||
|
||||
export function daysError() {
|
||||
return cardEl().querySelector('[data-testid="cleanup-days-error"]')
|
||||
}
|
||||
|
||||
export function switchError() {
|
||||
return cardEl().querySelector('[data-testid="cleanup-switch-error"]')
|
||||
}
|
||||
|
||||
export function saveStatus() {
|
||||
return textOf(cardEl().querySelector('[data-testid="cleanup-save-status"]'))
|
||||
}
|
||||
|
||||
export function lastLine() {
|
||||
return textOf(cardEl().querySelector('[data-testid="cleanup-last"]'))
|
||||
}
|
||||
|
||||
export function nextLine() {
|
||||
return cardEl().querySelector('[data-testid="cleanup-next"]')
|
||||
}
|
||||
|
||||
export function dialogEl() {
|
||||
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
|
||||
}
|
||||
|
||||
export function callsTo(method: string, path: string) {
|
||||
return purgeKit.requestMock.mock.calls.filter(
|
||||
([called, options]) => called === path && (options?.method ?? 'GET') === method,
|
||||
)
|
||||
}
|
||||
|
||||
export function bodyOf(call: unknown[]) {
|
||||
return JSON.parse(String((call[1] as RequestInit).body))
|
||||
}
|
||||
|
||||
export async function typeInto(input: HTMLInputElement, value: string) {
|
||||
input.focus()
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function commitByBlur(input: HTMLInputElement) {
|
||||
input.blur()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function commitByEnter(input: HTMLInputElement) {
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function goOffline() {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function pressEscape(target: Element | null) {
|
||||
;(target ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
)
|
||||
}
|
||||
|
||||
export async function openDialog() {
|
||||
const opener = purgeButton()
|
||||
opener.focus()
|
||||
opener.click()
|
||||
await flushPromises()
|
||||
return opener
|
||||
}
|
||||
|
||||
export function isBefore(first: Element, second: Element) {
|
||||
return first.compareDocumentPosition(second) === Node.DOCUMENT_POSITION_FOLLOWING
|
||||
}
|
||||
|
||||
export function groupRows() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="purge-group"]'))
|
||||
}
|
||||
|
||||
export function deferredValue<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
@@ -134,3 +134,15 @@ export function plainRestartDone(): UpdateStatus {
|
||||
finished_at: RESTART_FINISHED_AT,
|
||||
})
|
||||
}
|
||||
|
||||
export const PURGE_SETTINGS_PATH = '/v1/media/purge-settings'
|
||||
|
||||
export const DEFAULT_PURGE_SETTINGS = {
|
||||
auto_purge_enabled: false,
|
||||
auto_purge_interval_days: 30,
|
||||
last_purge_at: null,
|
||||
last_purge_merged: 0,
|
||||
last_purge_articles_updated: 0,
|
||||
enabled_at: null,
|
||||
next_purge_at: null,
|
||||
}
|
||||
+164
-69
@@ -11,11 +11,80 @@ import { visualStates, type ApiMock, type VisualState } from './states'
|
||||
type Viewport = 'desktop' | 'mobile'
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
interface Source {
|
||||
board: string
|
||||
group: string
|
||||
states: string[]
|
||||
alias: Record<string, string>
|
||||
}
|
||||
|
||||
interface Screen {
|
||||
name: string
|
||||
board: string
|
||||
group: string
|
||||
states: string[]
|
||||
sources: Source[]
|
||||
}
|
||||
|
||||
const cleanupBoard = 'RenewCMS Screens Media cleanup.dc.html'
|
||||
|
||||
const extraSources: Record<string, Source[]> = {
|
||||
settings: [
|
||||
{
|
||||
board: cleanupBoard,
|
||||
group: 'Settings',
|
||||
states: [
|
||||
'Default: off, never run',
|
||||
'On: 30 days, last run',
|
||||
'Saving',
|
||||
'Saved',
|
||||
'Save failed',
|
||||
'Validation error',
|
||||
'Read-only (non-admin)',
|
||||
'Loading skeleton',
|
||||
'Offline',
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: cleanupBoard,
|
||||
group: 'Dialog',
|
||||
states: [
|
||||
'Loading preview',
|
||||
'Confirm',
|
||||
'Confirm: many groups',
|
||||
'In progress',
|
||||
'Nothing to purge',
|
||||
'Error',
|
||||
'Partial failure',
|
||||
'Conflict (409)',
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: cleanupBoard,
|
||||
group: 'Results',
|
||||
states: ['Success', 'Success, automatic purge on'],
|
||||
alias: {},
|
||||
},
|
||||
],
|
||||
media: [
|
||||
{
|
||||
board: cleanupBoard,
|
||||
group: 'Media',
|
||||
states: [
|
||||
'Duplicates tab with the button',
|
||||
'Dialog from Media',
|
||||
'Member: button hidden',
|
||||
'Offline: button disabled',
|
||||
],
|
||||
alias: { 'Offline: button disabled': 'Offline' },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const boardAlias: Record<string, Record<string, string>> = {
|
||||
media: { 'Duplicates: purge all': 'Purge duplicates: confirm' },
|
||||
}
|
||||
|
||||
const webRoot = resolve(import.meta.dirname, '..')
|
||||
@@ -95,25 +164,42 @@ async function readScreen(name: string): Promise<Screen> {
|
||||
throw new Error(`docs/design/screens/${name}.md has no visual reference line`)
|
||||
}
|
||||
const statesStart = markdown.search(/^## States$/m)
|
||||
const statesMarkdown = statesStart === -1 ? markdown : markdown.slice(statesStart)
|
||||
const afterStates = statesStart === -1 ? markdown : markdown.slice(statesStart)
|
||||
const nextSection = afterStates.slice(1).search(/^## /m)
|
||||
const statesMarkdown = nextSection === -1 ? afterStates : afterStates.slice(0, nextSection + 1)
|
||||
const headingStates = [...statesMarkdown.matchAll(/^### (.+)$/gm)].map((match) =>
|
||||
match[1]!.trim(),
|
||||
)
|
||||
const states = headingStates.length ? headingStates : tableStates(statesMarkdown)
|
||||
return { name, board: reference[1]!, group: reference[2]!, states }
|
||||
const primary: Source = {
|
||||
board: reference[1]!,
|
||||
group: reference[2]!,
|
||||
states,
|
||||
alias: boardAlias[name] ?? {},
|
||||
}
|
||||
return {
|
||||
name,
|
||||
board: primary.board,
|
||||
group: primary.group,
|
||||
states,
|
||||
sources: [primary, ...(extraSources[name] ?? [])],
|
||||
}
|
||||
}
|
||||
|
||||
function pickStates(screen: Screen, wanted?: string) {
|
||||
function pickSources(screen: Screen, wanted?: string) {
|
||||
if (!wanted) {
|
||||
return screen.states
|
||||
return screen.sources
|
||||
}
|
||||
const match = screen.states.find(
|
||||
(state) => state.toLowerCase() === wanted.toLowerCase() || slug(state) === slug(wanted),
|
||||
)
|
||||
if (!match) {
|
||||
throw new Error(`Unknown state "${wanted}". Known: ${screen.states.join(', ')}`)
|
||||
const matches = (state: string) =>
|
||||
state.toLowerCase() === wanted.toLowerCase() || slug(state) === slug(wanted)
|
||||
const picked = screen.sources
|
||||
.map((source) => ({ ...source, states: source.states.filter(matches) }))
|
||||
.filter((source) => source.states.length > 0)
|
||||
if (picked.length === 0) {
|
||||
const known = screen.sources.flatMap((source) => source.states)
|
||||
throw new Error(`Unknown state "${wanted}". Known: ${known.join(', ')}`)
|
||||
}
|
||||
return [match]
|
||||
return picked
|
||||
}
|
||||
|
||||
async function serveDesign() {
|
||||
@@ -444,7 +530,7 @@ async function diff(browser: Browser, designPath: string, appPath: string, diffP
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const screen = await readScreen(args.screen)
|
||||
const states = pickStates(screen, args.state)
|
||||
const sources = pickSources(screen, args.state)
|
||||
const setups = visualStates[screen.name] ?? {}
|
||||
|
||||
const design = await serveDesign()
|
||||
@@ -458,67 +544,76 @@ async function main() {
|
||||
try {
|
||||
for (const viewport of args.viewports) {
|
||||
for (const theme of args.themes) {
|
||||
const board = await openBoard(browser, design.origin, screen, theme)
|
||||
for (const state of states) {
|
||||
const skip = setups[state]?.skip
|
||||
if (skip === 'pending') {
|
||||
pending++
|
||||
lines.push(`PENDING ${state} · ${viewport} · ${theme}: slice B2`)
|
||||
continue
|
||||
}
|
||||
if (skip) {
|
||||
skipped++
|
||||
lines.push(`SKIP ${state} · ${viewport} · ${theme}: ${skip}`)
|
||||
continue
|
||||
}
|
||||
const directory = join(outRoot, screen.name, slug(state))
|
||||
await mkdir(directory, { recursive: true })
|
||||
const suffix = `${viewport}-${theme}.png`
|
||||
const designPath = join(directory, `design-${suffix}`)
|
||||
await captureDesign(board, screen, state, viewport, designPath)
|
||||
|
||||
const setup = setups[state]
|
||||
if (!setup) {
|
||||
failures++
|
||||
lines.push(
|
||||
`FAIL ${state} · ${viewport} · ${theme}: no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
|
||||
for (const source of sources) {
|
||||
const sourceScreen = { ...screen, board: source.board, group: source.group }
|
||||
const board = await openBoard(browser, design.origin, sourceScreen, theme)
|
||||
for (const state of source.states) {
|
||||
const skip = setups[state]?.skip
|
||||
if (skip === 'pending') {
|
||||
pending++
|
||||
lines.push(`PENDING ${state} · ${viewport} · ${theme}: slice B2`)
|
||||
continue
|
||||
}
|
||||
if (skip) {
|
||||
skipped++
|
||||
lines.push(`SKIP ${state} · ${viewport} · ${theme}: ${skip}`)
|
||||
continue
|
||||
}
|
||||
const directory = join(outRoot, screen.name, slug(state))
|
||||
await mkdir(directory, { recursive: true })
|
||||
const suffix = `${viewport}-${theme}.png`
|
||||
const designPath = join(directory, `design-${suffix}`)
|
||||
await captureDesign(
|
||||
board,
|
||||
sourceScreen,
|
||||
source.alias[state] ?? state,
|
||||
viewport,
|
||||
designPath,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const appPath = join(directory, `app-${suffix}`)
|
||||
const diffPath = join(directory, `diff-${suffix}`)
|
||||
const unmocked = await captureApp(
|
||||
browser,
|
||||
app.origin,
|
||||
setup,
|
||||
viewport,
|
||||
theme,
|
||||
appPath,
|
||||
)
|
||||
const result = await diff(browser, designPath, appPath, diffPath)
|
||||
const percent = result.ratio * 100
|
||||
const budget = setup.exception?.maxPercent ?? maxDiffPercent
|
||||
const problems = [
|
||||
...(percent > budget
|
||||
? [`${percent.toFixed(2)}% exceeds ${budget}% budget`]
|
||||
: []),
|
||||
...(result.sizes.split(' vs ')[0] !== result.sizes.split(' vs ')[1]
|
||||
? [`size mismatch ${result.sizes}`]
|
||||
: []),
|
||||
...unmocked.map((key) => `unmocked ${key}`),
|
||||
]
|
||||
if (problems.length) {
|
||||
failures++
|
||||
const setup = setups[state]
|
||||
if (!setup) {
|
||||
failures++
|
||||
lines.push(
|
||||
`FAIL ${state} · ${viewport} · ${theme}: no setup in web/visual/states.ts → ${relative(repoRoot, designPath)}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const appPath = join(directory, `app-${suffix}`)
|
||||
const diffPath = join(directory, `diff-${suffix}`)
|
||||
const unmocked = await captureApp(
|
||||
browser,
|
||||
app.origin,
|
||||
setup,
|
||||
viewport,
|
||||
theme,
|
||||
appPath,
|
||||
)
|
||||
const result = await diff(browser, designPath, appPath, diffPath)
|
||||
const percent = result.ratio * 100
|
||||
const budget = setup.exception?.maxPercent ?? maxDiffPercent
|
||||
const problems = [
|
||||
...(percent > budget
|
||||
? [`${percent.toFixed(2)}% exceeds ${budget}% budget`]
|
||||
: []),
|
||||
...(result.sizes.split(' vs ')[0] !== result.sizes.split(' vs ')[1]
|
||||
? [`size mismatch ${result.sizes}`]
|
||||
: []),
|
||||
...unmocked.map((key) => `unmocked ${key}`),
|
||||
]
|
||||
if (problems.length) {
|
||||
failures++
|
||||
}
|
||||
const exception = setup.exception
|
||||
? ` [exception ${setup.exception.maxPercent}% approved by ${setup.exception.approvedBy}: ${setup.exception.reason}]`
|
||||
: ''
|
||||
lines.push(
|
||||
`${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}`,
|
||||
)
|
||||
}
|
||||
const exception = setup.exception
|
||||
? ` [exception ${setup.exception.maxPercent}% approved by ${setup.exception.approvedBy}: ${setup.exception.reason}]`
|
||||
: ''
|
||||
lines.push(
|
||||
`${problems.length ? 'FAIL' : 'PASS'} ${state} · ${viewport} · ${theme}: ${percent.toFixed(2)}% pixels differ → ${relative(repoRoot, directory)}${problems.length ? ` (${problems.join('; ')})` : ''}${exception}`,
|
||||
)
|
||||
await board.context().close()
|
||||
}
|
||||
await board.context().close()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -535,7 +630,7 @@ async function main() {
|
||||
: `PASS: all ${checked} captures within budget (${maxDiffPercent}% unless an approved exception is listed${skippedNote})`
|
||||
const report = [
|
||||
`# Visual check: ${screen.name}`,
|
||||
`Board: ${screen.board}, group "${screen.group}"`,
|
||||
`Boards: ${sources.map((source) => `${source.board} / ${source.group}`).join('; ')}`,
|
||||
'',
|
||||
verdict,
|
||||
'',
|
||||
|
||||
+356
-1
@@ -2246,6 +2246,336 @@ const apiKeysStates: Record<string, VisualState> = {
|
||||
},
|
||||
}
|
||||
|
||||
const purgeSettingsPath = 'GET /v1/media/purge-settings'
|
||||
const purgePatchPath = 'PATCH /v1/media/purge-settings'
|
||||
const purgePreviewPath = 'GET /v1/media/purge-preview'
|
||||
const purgeRunPath = 'POST /v1/media/purge'
|
||||
const purgeStaleCopy =
|
||||
'The library changed, so nothing was purged. Check the new summary and try again.'
|
||||
|
||||
function purgeSettingsBody(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
auto_purge_enabled: false,
|
||||
auto_purge_interval_days: 30,
|
||||
last_purge_at: null,
|
||||
last_purge_merged: 0,
|
||||
last_purge_articles_updated: 0,
|
||||
enabled_at: null,
|
||||
next_purge_at: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const purgeLastRun = {
|
||||
last_purge_at: '2026-06-14T09:00:00Z',
|
||||
last_purge_merged: 3,
|
||||
last_purge_articles_updated: 5,
|
||||
}
|
||||
|
||||
const purgeOnBody = purgeSettingsBody({
|
||||
...purgeLastRun,
|
||||
auto_purge_enabled: true,
|
||||
enabled_at: '2026-06-14T09:00:00Z',
|
||||
next_purge_at: '2026-07-14T09:00:00Z',
|
||||
})
|
||||
|
||||
const purgeDoneLast = {
|
||||
last_purge_at: '2026-10-05T09:00:00Z',
|
||||
last_purge_merged: 9,
|
||||
last_purge_articles_updated: 12,
|
||||
}
|
||||
|
||||
const purgeGroups = [
|
||||
{
|
||||
keep_id: 12,
|
||||
filename: 'floorboards.jpg',
|
||||
url: '/media/floorboards.jpg',
|
||||
used_in: 4,
|
||||
merged: 2,
|
||||
},
|
||||
{ keep_id: 7, filename: 'IMG_4471.jpg', url: '/media/IMG_4471.jpg', used_in: 2, merged: 1 },
|
||||
{
|
||||
keep_id: 2,
|
||||
filename: 'kitchen-after.webp',
|
||||
url: '/media/kitchen-after.webp',
|
||||
used_in: 3,
|
||||
merged: 3,
|
||||
},
|
||||
{ keep_id: 4, filename: 'logo-renew.svg', url: '/media/logo-renew.svg', used_in: 5, merged: 3 },
|
||||
{
|
||||
keep_id: 11,
|
||||
filename: 'lime-plaster.jpg',
|
||||
url: '/media/lime-plaster.jpg',
|
||||
used_in: 2,
|
||||
merged: 1,
|
||||
},
|
||||
]
|
||||
|
||||
function purgePreviewBody(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
groups: 4,
|
||||
copies: 9,
|
||||
articles: 12,
|
||||
list: purgeGroups.slice(0, 4),
|
||||
more: 0,
|
||||
fingerprint: 'ab12',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const purgePreviewMany = purgePreviewBody({
|
||||
groups: 8,
|
||||
copies: 15,
|
||||
articles: 24,
|
||||
list: purgeGroups,
|
||||
more: 3,
|
||||
})
|
||||
const purgePreviewOne = purgePreviewBody({
|
||||
groups: 1,
|
||||
copies: 1,
|
||||
articles: 2,
|
||||
list: [purgeGroups[1]],
|
||||
fingerprint: 'one',
|
||||
})
|
||||
const purgePreviewThree = purgePreviewBody({
|
||||
groups: 3,
|
||||
copies: 7,
|
||||
articles: 10,
|
||||
list: [purgeGroups[0], purgeGroups[2], purgeGroups[3]],
|
||||
fingerprint: 'cd34',
|
||||
})
|
||||
const purgePreviewNone = purgePreviewBody({
|
||||
groups: 0,
|
||||
copies: 0,
|
||||
articles: 0,
|
||||
list: [],
|
||||
fingerprint: 'none',
|
||||
})
|
||||
|
||||
const purgeRunPartial = {
|
||||
groups_total: 4,
|
||||
groups_purged: 3,
|
||||
merged: 8,
|
||||
articles_updated: 10,
|
||||
failed_groups: 1,
|
||||
}
|
||||
const purgeRunDone = {
|
||||
groups_total: 4,
|
||||
groups_purged: 4,
|
||||
merged: 9,
|
||||
articles_updated: 12,
|
||||
failed_groups: 0,
|
||||
}
|
||||
|
||||
function sessionWithRole(role: string): ApiMock {
|
||||
return { status: 200, body: { ...(verifiedSession.body as object), role } }
|
||||
}
|
||||
|
||||
function cleanupApi(
|
||||
settings: ApiMock,
|
||||
overrides: Record<string, ApiMock> = {},
|
||||
role = 'owner',
|
||||
): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': sessionWithRole(role),
|
||||
'GET /v1/settings/': { status: 200, body: { version: updateRunningVersion } },
|
||||
'GET /v1/system/update': { status: 200, hang: true },
|
||||
[purgeSettingsPath]: settings,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function settingsBody(body: unknown): ApiMock {
|
||||
return { status: 200, body }
|
||||
}
|
||||
|
||||
function cleanupState(
|
||||
api: Record<string, ApiMock>,
|
||||
steps?: (page: Page) => Promise<void>,
|
||||
): VisualState {
|
||||
return { path: '/settings', api, steps }
|
||||
}
|
||||
|
||||
async function openCleanupDialog(page: Page) {
|
||||
await page.getByRole('button', { name: 'Purge duplicates', exact: true }).click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
function confirmCleanupDialog(label: string, wait?: string) {
|
||||
return async (page: Page) => {
|
||||
await openCleanupDialog(page)
|
||||
await page
|
||||
.getByRole('alertdialog')
|
||||
.getByRole('button', { name: label, exact: true })
|
||||
.click()
|
||||
if (wait) {
|
||||
await page.getByText(wait, { exact: true }).first().waitFor()
|
||||
}
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
async function typeDays(page: Page, value: string) {
|
||||
const field = page.locator('#auto-purge-days')
|
||||
await field.fill(value)
|
||||
await field.press('Enter')
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
function afterFirst(first: unknown, later: unknown): ApiMock {
|
||||
return { status: 200, bodyFor: (_url, call) => (call === 1 ? first : later) }
|
||||
}
|
||||
|
||||
function cleanupDialogApi(overrides: Record<string, ApiMock> = {}) {
|
||||
return cleanupApi(settingsBody(purgeSettingsBody()), {
|
||||
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
async function holdToast(page: Page) {
|
||||
await page.getByText('Duplicates purged', { exact: true }).hover()
|
||||
}
|
||||
|
||||
function successState(settings: ApiMock) {
|
||||
return cleanupState(
|
||||
cleanupApi(settings, {
|
||||
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
|
||||
[purgeRunPath]: settingsBody(purgeRunDone),
|
||||
}),
|
||||
async (page) => {
|
||||
await confirmCleanupDialog('Purge 4 groups', 'Duplicates purged')(page)
|
||||
await holdToast(page)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const cleanupStates: Record<string, VisualState> = {
|
||||
'Default: off, never run': cleanupState(cleanupApi(settingsBody(purgeSettingsBody()))),
|
||||
'On: 30 days, last run': cleanupState(cleanupApi(settingsBody(purgeOnBody))),
|
||||
Saving: cleanupState(
|
||||
cleanupApi(settingsBody(purgeSettingsBody(purgeLastRun)), {
|
||||
[purgePatchPath]: { status: 200, hang: true },
|
||||
}),
|
||||
async (page) => {
|
||||
await page.getByRole('switch').click()
|
||||
await page.locator('#auto-purge-days').focus()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
Saved: cleanupState(
|
||||
cleanupApi(settingsBody(purgeSettingsBody(purgeLastRun)), {
|
||||
[purgePatchPath]: settingsBody(purgeOnBody),
|
||||
}),
|
||||
async (page) => {
|
||||
await page.getByRole('switch').click()
|
||||
await page.getByText('Saved', { exact: true }).waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
),
|
||||
'Save failed': cleanupState(
|
||||
cleanupApi(settingsBody(purgeOnBody), { [purgePatchPath]: { status: 500 } }),
|
||||
async (page) => {
|
||||
await typeDays(page, '45')
|
||||
await page.getByText("Couldn't save. Try again in a moment.").waitFor()
|
||||
},
|
||||
),
|
||||
'Validation error': cleanupState(cleanupApi(settingsBody(purgeOnBody)), async (page) => {
|
||||
await typeDays(page, '400')
|
||||
await page.getByText('Enter a whole number of days between 1 and 365.').waitFor()
|
||||
}),
|
||||
'Read-only (non-admin)': cleanupState(cleanupApi(settingsBody(purgeOnBody), {}, 'member')),
|
||||
'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true })),
|
||||
Offline: cleanupState(cleanupApi(settingsBody(purgeOnBody)), goOffline),
|
||||
'Loading preview': cleanupState(
|
||||
cleanupDialogApi({ [purgePreviewPath]: { status: 200, hang: true } }),
|
||||
openCleanupDialog,
|
||||
),
|
||||
Confirm: cleanupState(cleanupDialogApi(), openCleanupDialog),
|
||||
'Confirm: many groups': cleanupState(
|
||||
cleanupDialogApi({ [purgePreviewPath]: { status: 200, body: purgePreviewMany } }),
|
||||
openCleanupDialog,
|
||||
),
|
||||
'In progress': cleanupState(
|
||||
cleanupDialogApi({ [purgeRunPath]: { status: 200, hang: true } }),
|
||||
confirmCleanupDialog('Purge 4 groups'),
|
||||
),
|
||||
'Nothing to purge': cleanupState(
|
||||
cleanupDialogApi({ [purgePreviewPath]: { status: 200, body: purgePreviewNone } }),
|
||||
openCleanupDialog,
|
||||
),
|
||||
Error: cleanupState(
|
||||
cleanupDialogApi({ [purgeRunPath]: { status: 500 } }),
|
||||
confirmCleanupDialog(
|
||||
'Purge 4 groups',
|
||||
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment.",
|
||||
),
|
||||
),
|
||||
'Partial failure': cleanupState(
|
||||
cleanupDialogApi({
|
||||
[purgePreviewPath]: afterFirst(purgePreviewBody(), purgePreviewOne),
|
||||
[purgeRunPath]: settingsBody(purgeRunPartial),
|
||||
}),
|
||||
confirmCleanupDialog(
|
||||
'Purge 4 groups',
|
||||
"Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish.",
|
||||
),
|
||||
),
|
||||
'Conflict (409)': cleanupState(
|
||||
cleanupDialogApi({
|
||||
[purgePreviewPath]: afterFirst(purgePreviewBody(), purgePreviewThree),
|
||||
[purgeRunPath]: { status: 409, body: purgeStaleCopy },
|
||||
}),
|
||||
confirmCleanupDialog('Purge 4 groups', purgeStaleCopy),
|
||||
),
|
||||
Success: successState(afterFirst(purgeSettingsBody(), purgeSettingsBody(purgeDoneLast))),
|
||||
'Success, automatic purge on': successState(
|
||||
afterFirst(
|
||||
purgeOnBody,
|
||||
purgeSettingsBody({
|
||||
...purgeDoneLast,
|
||||
auto_purge_enabled: true,
|
||||
next_purge_at: '2026-11-04T09:00:00Z',
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
async function openMediaPurgeDialog(page: Page) {
|
||||
await page.getByRole('button', { name: 'Purge duplicates', exact: true }).click()
|
||||
await page.getByRole('alertdialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
const mediaPurgeStates: Record<string, VisualState> = {
|
||||
'Duplicates: purge all': mediaState(
|
||||
mediaDuplicatesApi(undefined, {
|
||||
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
|
||||
}),
|
||||
openMediaPurgeDialog,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Duplicates tab with the button': mediaState(
|
||||
mediaDuplicatesApi(),
|
||||
undefined,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Dialog from Media': mediaState(
|
||||
mediaDuplicatesApi(undefined, {
|
||||
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
|
||||
}),
|
||||
openMediaPurgeDialog,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Member: button hidden': mediaState(
|
||||
mediaDuplicatesApi(undefined, { 'GET /v1/auth/me': sessionWithRole('member') }),
|
||||
undefined,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Offline: button disabled': mediaState(mediaDuplicatesApi(), goOffline, mediaDuplicatesPath),
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
profile: {
|
||||
'Account menu': {
|
||||
@@ -2391,6 +2721,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
'Invite sent': { path: '/users', skip: 'clock-sensitive' },
|
||||
},
|
||||
settings: {
|
||||
...cleanupStates,
|
||||
'Up to date': { path: '/settings', skip: 'clock-sensitive' },
|
||||
Checking: {
|
||||
path: '/settings',
|
||||
@@ -3364,6 +3695,30 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
steps: submitAcceptInvite('lea', 'limewash2026'),
|
||||
},
|
||||
},
|
||||
media: mediaStates,
|
||||
media: { ...mediaStates, ...mediaPurgeStates },
|
||||
'media-picker': pickerStates,
|
||||
}
|
||||
|
||||
const hideCleanupCard = '[data-testid="media-cleanup-card"] { display: none !important }'
|
||||
|
||||
function withoutCleanupCard(state: VisualState): VisualState {
|
||||
if (state.skip) {
|
||||
return state
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
api: { ...state.api, [purgeSettingsPath]: { status: 200, hang: true } },
|
||||
steps: async (page) => {
|
||||
await page.addStyleTag({ content: hideCleanupCard })
|
||||
await state.steps?.(page)
|
||||
await page.addStyleTag({ content: hideCleanupCard })
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, state] of Object.entries(visualStates.settings!)) {
|
||||
if (!(name in cleanupStates)) {
|
||||
visualStates.settings![name] = withoutCleanupCard(state)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user