feat(media): pagination
This commit is contained in:
1 parent
04dc151ed7
commit
63c1e7076e
65 files changed
+2960
-365
No files matched your search
@@ -4,8 +4,8 @@
|
||||
|
||||
Mock `XMLHttpRequest` (a fake with `open`, `setRequestHeader`, `send`, `abort`, firing `upload.progress`, `load`, `error`) for `POST /v1/media/`. Mock useApi's `request` for `GET /v1/media/` (list and `?hash=`), reusing the mock from `api_client_global_error_handling.qa.md`. Stub `hashFile` where a hash is needed, and add one test with `crypto.subtle` deleted. Mount EditorPage inside DashboardShell at `/article/12/edit` with the `editor_core.qa.md` fixture article, GET already resolved 200. Assert URLs by parsing the query, not by string equality.
|
||||
|
||||
1. **Toolbar button, empty library** - `GET /v1/media/?limit=24` resolves `{items: [], next_cursor: null, counts: {all: 0, unused: 0, duplicates: 0}}`; click "Image"; assert the dialog "Add an image", both tabs, the Upload tab selected, the drop zone copy, footer "Cancel" only, no XHR.
|
||||
2. **Drop opens Upload with the file** - dispatch `drop` with `before.png` (image/png, 3.9 MB); assert the Upload tab, a `?hash=` request, no `?limit=24` list request, then the crop step after an empty lookup, and no XHR.
|
||||
1. **Toolbar button, empty library** - `GET /v1/media/?page=1&size=25` resolves `{items: [], total: 0, page: 1, size: 25, counts: {all: 0, unused: 0, duplicates: 0}}`; click "Image"; assert the dialog "Add an image", both tabs, the Upload tab selected, the drop zone copy, footer "Cancel" only, no XHR.
|
||||
2. **Drop opens Upload with the file** - dispatch `drop` with `before.png` (image/png, 3.9 MB); assert the Upload tab, a `?hash=...&page=1&size=100` request, no `?page=1&size=25` list request, then the crop step after an empty lookup, and no XHR.
|
||||
3. **Dragging over shows "Drop to upload"**.
|
||||
4. **Rejected, Examples-driven** - `plan.pdf`, `disguised.png`, `north-wall.tif`; assert no `?hash=` request and no XHR, plus the exact title, text, confirm and cancel.
|
||||
5. **"Choose another file" returns to idle**, with the tabs back.
|
||||
|
||||
@@ -17,13 +17,16 @@ Feature: The editor's Library tab, picking an image from the media library
|
||||
a bottom sheet on mobile
|
||||
And the footer reads "Cancel", and on the Library tab also a primary "Insert image" that is
|
||||
disabled until an image is selected
|
||||
And the Library tab calls GET /v1/media/ through useApi with limit=24, with q only while a
|
||||
search is set and cursor only when loading more (no filter and no sort parameter), and the
|
||||
Upload tab is specified in features/editor_image_upload.feature
|
||||
And the response is { items, next_cursor, counts }, newest first; sizes read "8 KB" and
|
||||
And the Library tab calls GET /v1/media/?q=Q&page=N&size=25 through useApi, with q only while a
|
||||
search is set and page 1, then 2, 3, ... when loading more (no filter and no sort parameter),
|
||||
and the Upload tab is specified in features/editor_image_upload.feature
|
||||
And the response is { items, total, page, size, counts }, newest first; "Load more" shows while
|
||||
the loaded count is below the response total (the matching count, not counts.all); sizes read "8 KB" and
|
||||
"3.9 MB", dimensions read "2400 × 1600" (an SVG reads "Vector"), dates are absolute
|
||||
And the 12-image fixture, ids 1 to 12, is the one in features/media_browse.feature, and the
|
||||
picker mocks use next_cursor "c24" and counts { all: 48, unused: 2, duplicates: 2 }
|
||||
picker mocks use total 48 and counts { all: 48, unused: 2, duplicates: 2 }
|
||||
And this slice is amended by features/media_pagination_api.feature: cursor and limit are gone,
|
||||
LIBRARY_PAGE_LIMIT 24 became the page size 25, and a mock with items equal to its total is the last page
|
||||
And an image is "in this article" when an image node with its id as data-image-id is in
|
||||
the editor's current content, unsaved insertions included, when the dialog opens;
|
||||
in the examples below images 1 (north-wall.jpg) and 4 (logo-renew.svg) are in the article
|
||||
@@ -42,13 +45,13 @@ Feature: The editor's Library tab, picking an image from the media library
|
||||
Rule: Which tab opens
|
||||
|
||||
Scenario: The library has images, so Library opens
|
||||
Given GET /v1/media/?limit=24 resolves 200 with the 12-image fixture
|
||||
Given GET /v1/media/?page=1&size=25 resolves 200 with the 12-image fixture
|
||||
When the user activates the toolbar's "Image" button
|
||||
Then the Library tab is selected and the footer reads "Cancel" and a disabled "Insert image"
|
||||
And nothing is drawn in the grid for the first 150 ms, then 8 skeleton tiles show until the response arrives
|
||||
|
||||
Scenario: The library is empty, so Upload opens
|
||||
Given GET /v1/media/?limit=24 resolves 200 with no item and no search set
|
||||
Given GET /v1/media/?page=1&size=25 resolves 200 with no item and no search set
|
||||
When the dialog opens
|
||||
Then the Upload tab is selected, in its idle state
|
||||
When the user selects the Library tab
|
||||
@@ -56,9 +59,9 @@ Feature: The editor's Library tab, picking an image from the media library
|
||||
|
||||
Scenario: A dropped file opens Upload and sends no list request
|
||||
When the user drops "before.png" onto the editor
|
||||
Then the Upload tab is selected and no GET /v1/media/?limit=24 is sent
|
||||
Then the Upload tab is selected and no GET /v1/media/?page=1&size=25 is sent
|
||||
When the user selects the Library tab
|
||||
Then GET /v1/media/?limit=24 is sent
|
||||
Then GET /v1/media/?page=1&size=25 is sent
|
||||
|
||||
Rule: Browsing
|
||||
|
||||
@@ -75,7 +78,7 @@ Feature: The editor's Library tab, picking an image from the media library
|
||||
Then the line reads "Newest first"
|
||||
|
||||
Scenario: Load more is hidden on the last page
|
||||
Given the response has next_cursor null
|
||||
Given the loaded items equal the response total (for example total 12 with the 12-image fixture)
|
||||
Then "Load more" is not shown
|
||||
|
||||
Scenario Outline: A tile's accessible name
|
||||
@@ -167,16 +170,16 @@ Feature: The editor's Library tab, picking an image from the media library
|
||||
Scenario: Typing searches after a debounce
|
||||
Given the Library tab shows the 12-image fixture
|
||||
When the user types "wall"
|
||||
Then after a 120 ms debounce GET /v1/media/ is sent with q=wall and limit=24 and no cursor
|
||||
Then after a 120 ms debounce GET /v1/media/ is sent with q=wall, page=1 and size=25
|
||||
And skeleton tiles show after 150 ms, and any selection is cleared
|
||||
|
||||
Scenario: The x in the field clears the search
|
||||
Given the search field holds "wall" with results shown
|
||||
When the user activates the x in the field
|
||||
Then the field is empty and GET /v1/media/ is sent with limit=24 and no q
|
||||
Then the field is empty and GET /v1/media/ is sent with page=1 and size=25 and no q
|
||||
|
||||
Scenario: No results
|
||||
Given GET /v1/media/?q=wall&limit=24 resolves 200 with no item
|
||||
Given GET /v1/media/?q=wall&page=1&size=25 resolves 200 with no item
|
||||
Then the search icon shows with the title `No images match "wall"`, the text "Check the spelling, or search for a shorter part of the filename." and a "Clear search" button
|
||||
And the search field still shows "wall" above it, and "Insert image" is disabled
|
||||
When the user activates "Clear search"
|
||||
@@ -185,23 +188,23 @@ Feature: The editor's Library tab, picking an image from the media library
|
||||
Rule: Load more
|
||||
|
||||
Scenario: Loading more shows progress and appends
|
||||
Given the first page resolved with 12 images, next_cursor "c24"
|
||||
Given the first page resolved with 12 images and total 48
|
||||
When the user activates "Load more"
|
||||
Then GET /v1/media/ is sent with limit=24 and cursor=c24
|
||||
Then GET /v1/media/ is sent with page=2 and size=25
|
||||
And the button reads "Loading…" with a loader-circle and is disabled, and one row of skeleton tiles shows at the end
|
||||
And focus stays on the button
|
||||
When the request resolves 200 with 12 more images
|
||||
Then the new tiles are appended after the first 12, and focus moves to the first new tile
|
||||
|
||||
Scenario: The cursor and the search combine
|
||||
Given the search "wall" is active and the page has next_cursor "c24"
|
||||
Scenario: The next page and the search combine
|
||||
Given the search "wall" is active and the response total is above the loaded count
|
||||
When the user activates "Load more"
|
||||
Then GET /v1/media/ is sent with q=wall, limit=24 and cursor=c24
|
||||
Then GET /v1/media/ is sent with q=wall, page=2 and size=25
|
||||
|
||||
Rule: Empty and failed
|
||||
|
||||
Scenario: Empty library
|
||||
Given the Library tab is selected and GET /v1/media/?limit=24 resolved with no item
|
||||
Given the Library tab is selected and GET /v1/media/?page=1&size=25 resolved with no item
|
||||
Then the images icon shows with "Your library is empty", "Images you upload appear here, ready to use in any article." and a primary "Upload an image"
|
||||
And "Insert image" is disabled
|
||||
When the user activates "Upload an image"
|
||||
|
||||
@@ -2,29 +2,29 @@
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `request` for `GET /v1/media/` by parsed query. Use the 12-image fixture from `media_browse.feature` as `items`, with `next_cursor: "c24"` and `counts: {all: 48, unused: 2, duplicates: 2}`. Mount EditorPage at `/article/12/edit` with a body containing `<img data-image-id="1">` and `<img data-image-id="4">`. Use fake timers for the 150 ms and 120 ms steps.
|
||||
Mock `request` for `GET /v1/media/` by parsed query. Use the 12-image fixture from `media_browse.feature` as `items`, with `total: 48`, `page`, `size: 25` and `counts: {all: 48, unused: 2, duplicates: 2}` (amended by `features/media_pagination_api.feature`: `next_cursor`, `cursor` and `limit` are gone). Mount EditorPage at `/article/12/edit` with a body containing `<img data-image-id="1">` and `<img data-image-id="4">`. Use fake timers for the 150 ms and 120 ms steps.
|
||||
|
||||
1. **Opens on Library with images** - first response has items; assert Library selected and skeleton tiles only after 150 ms.
|
||||
2. **Opens on Upload when empty** - empty first response; assert Upload selected; then click Library and assert the empty panel.
|
||||
3. **Drop sends no list request** - drop a file; assert no `?limit=24` request until Library is clicked.
|
||||
3. **Drop sends no list request** - drop a file; assert no `?page=1&size=25` request until Library is clicked.
|
||||
4. **Nothing selected** - assert the line, 12 tiles in order, the "In this article" tag on exactly ids 1 and 4, "Load more", "Insert image" disabled.
|
||||
5. **Active search hides the count** - the line reads "Newest first".
|
||||
6. **Last page** - `next_cursor: null` hides "Load more".
|
||||
6. **Last page** - a total equal to the loaded count (for example 12) hides "Load more".
|
||||
7. **Accessible names** - the three Examples.
|
||||
8. **Selection** - click, Enter and Space each select; check, outline, "Insert image" enabled.
|
||||
9. **Panel copy** - kitchen-after.webp panel, placeholder and help text exact; "Load more" hidden; SVG reads "Vector · 8 KB".
|
||||
10. **Single selection resets alt**; reactivating keeps it selected.
|
||||
11. **Insert** - alt "A kitchen with new oak worktops and white tiles" gives `insertImage("/static/uploadedImages/2.webp", alt, 2)`, SaveStatus "Unsaved changes", the toast, the dialog closed, no non-GET request. Empty alt gives `""`.
|
||||
12. **Cancel, x and Escape** change nothing.
|
||||
13. **Search** - type "wall"; at 119 ms no request, at 120 ms the request has `q=wall&limit=24` and no cursor; the selection is cleared. The field's x and "Clear search" send a request with no `q`.
|
||||
13. **Search** - type "wall"; at 119 ms no request, at 120 ms the request has `q=wall&page=1&size=25`; the selection is cleared. The field's x and "Clear search" send a request with no `q`.
|
||||
14. **No results** and **Empty** copy; "Upload an image" switches to the Upload tab.
|
||||
15. **Load more** - request has `cursor=c24`; button "Loading…" and disabled; skeleton row of 4 (3 below md); focus on the button, then on the first new tile after resolve; combined `q` and `cursor`.
|
||||
15. **Load more** - request has `page=2&size=25`; button "Loading…" and disabled; skeleton row of 4 (3 below md); focus on the button, then on the first new tile after resolve; combined `q` and `page=2`.
|
||||
16. **Failed to load, Examples 500 and network** - alert copy; "Try again" repeats the request; success replaces the alert.
|
||||
|
||||
## Visual: `task visual:check -- media-picker`
|
||||
|
||||
Add a setup in `web/visual/states.ts` for each of the 13 states in `docs/design/screens/media-picker.md`, from the board's script (`RenewCMS Screens Back-office.dc.html` lines 239 to 253). Every capture must be within 10 % at 1440×900 and 390×844, light and dark. The sample data to reuse:
|
||||
- Library mock: the 12-image fixture, `counts.all` 48, `next_cursor` non-null; images 1 and 4 in the article. The dialog is opened on the editor "How we price a bathroom renovation".
|
||||
- Library mock: the 12-image fixture, `counts.all` 48, `total` 48; images 1 and 4 in the article. The dialog is opened on the editor "How we price a bathroom renovation".
|
||||
- "Upload tab": Upload selected, idle drop zone.
|
||||
- "Upload tab: already in your library": a chosen file named `north-wall.jpg`; the hash lookup returns id 1 (12 Sep 2026, Marta Alves); the thumbnail is 72 × 54.
|
||||
- "Library: loading": skeleton tiles.
|
||||
@@ -46,9 +46,9 @@ Manual checks the screenshots can't cover:
|
||||
- [ ] Focus order is tabs, search, tiles, "Load more", footer. Enter and Space select a tile. Focus returns to the toolbar's image button when the dialog closes.
|
||||
- [ ] Hover on a tile and on "Load more", and the focus ring.
|
||||
- [ ] Motion: the dialog follows the standard 240 ms timing.
|
||||
- [ ] A real library of more than 24 images through `task dev`: "Load more" and the cursor.
|
||||
- [ ] A real library of more than 25 images through `task dev`: "Load more" and the page numbers.
|
||||
- [ ] A screen reader reads the tile names and the alt text help.
|
||||
|
||||
## Manual verification
|
||||
|
||||
Real `GET /v1/media/` with `q` and `cursor` against `task dev` with a live SQLite file.
|
||||
Real `GET /v1/media/` with `q`, `page` and `size` against `task dev` with a live SQLite file.
|
||||
@@ -21,7 +21,7 @@ Catalogue spec (pure data, no mount, imports web/src/data/apiCatalogue.ts):
|
||||
- article: id, title, body, body_format, is_online, created_at, updated_at;
|
||||
- media item: id, filename, url, size, content_type, width, height, sha256, uploaded_by,
|
||||
created_at, used_in, duplicate;
|
||||
- media list: items, next_cursor, counts (all, unused, duplicates);
|
||||
- media list: items, total, page, size, counts (all, unused, duplicates), no next_cursor;
|
||||
- merge: articles_updated, merged;
|
||||
- usage: id, title, published;
|
||||
- user list item: id, kind, username, display_name, email, role, status, created_at;
|
||||
|
||||
@@ -20,8 +20,12 @@ Feature: Media page, part 1: browse and manage
|
||||
And useMedia is a composable over useApi. It exposes the list state (items, counts, loading,
|
||||
error), the request functions for list, usage, rename and delete, and the copy helper, and
|
||||
it maps ApiError and NetworkError to the states below
|
||||
And MediaPage calls GET /v1/media/ with the query built from the URL, always with limit=100,
|
||||
and every request carries the jwt cookie through useApi
|
||||
And MediaPage calls GET /v1/media/ with the query built from the URL, always with an explicit
|
||||
page and size (page=1&size=25 by default, or the stored size), and every request carries
|
||||
the jwt cookie through useApi
|
||||
And this slice is amended by features/media_pagination_api.feature and
|
||||
features/media_pagination_screen.feature: limit=100 and next_cursor are gone, the body is
|
||||
{ items, total, page, size, counts }, and the list is paginated
|
||||
And search, tab, sort and view live in the URL query string so the view can be linked and
|
||||
survives a reload: ?q= (search text), ?filter= ("unused" or "duplicates"; absent means
|
||||
All), ?sort= ("oldest", "name" or "largest"; absent means Newest), ?view= ("list"; absent
|
||||
@@ -90,10 +94,10 @@ Feature: Media page, part 1: browse and manage
|
||||
| 10 | bathroom-tiles.png | 860 KB | 1800 × 1200 | PNG | 0 | Marta Alves | 4 Aug 2026 | alpha |
|
||||
| 11 | lime-plaster.jpg | 2.1 MB | 3200 × 2133 | JPEG | 2 | Inès Duarte | 19 Jul 2026 | |
|
||||
| 12 | floorboards.jpg | 2.4 MB | 3600 × 2400 | JPEG | 4 | Inès Duarte | 14 Jun 2026 | duplicate |
|
||||
with counts { all: 12, unused: 2, duplicates: 2 }, and next_cursor null
|
||||
with counts { all: 12, unused: 2, duplicates: 2 }, total 12, page 1, size 25
|
||||
|
||||
Scenario: Default, many images
|
||||
Given GET /v1/media/?filter=all&sort=newest&limit=100 resolves 200 with the 12-image fixture
|
||||
Given GET /v1/media/?filter=all&sort=newest&page=1&size=25 resolves 200 with the 12-image fixture
|
||||
When the user navigates to /media
|
||||
Then the breadcrumb reads "Media" (mobile title "Media")
|
||||
And the tabs read "All 12", "Unused 2", "Duplicates 2", with "All" selected, and the
|
||||
@@ -144,7 +148,7 @@ Feature: Media page, part 1: browse and manage
|
||||
Scenario Outline: The query string is the source of truth for search, tab, sort and view
|
||||
Given GET /v1/media/ is mocked
|
||||
When the user opens /media<url>
|
||||
Then GET /v1/media/ is sent with "<request>" and limit=100
|
||||
Then GET /v1/media/ is sent with "<request>" and page=1&size=25
|
||||
And the control shows "<control>"
|
||||
|
||||
Examples:
|
||||
@@ -161,7 +165,7 @@ Feature: Media page, part 1: browse and manage
|
||||
Scenario: Changing a control writes the query string and reloads the list
|
||||
Given the Default state has loaded
|
||||
When the user types "wall" in the search field
|
||||
Then after a 120 ms debounce the URL gains ?q=wall and GET /v1/media/?q=wall&filter=all&sort=newest&limit=100 is sent
|
||||
Then after a 120 ms debounce the URL gains ?q=wall and GET /v1/media/?q=wall&filter=all&sort=newest&page=1&size=25 is sent
|
||||
When the user selects the "Unused" tab
|
||||
Then the URL reads ?q=wall&filter=unused and the list is requested again
|
||||
When the user picks "Largest" in the sort menu
|
||||
@@ -187,7 +191,7 @@ Feature: Media page, part 1: browse and manage
|
||||
|
||||
Scenario: No results, search and filter
|
||||
Given the Default state has loaded
|
||||
When the user selects "Unused" and types "wall" and GET /v1/media/?q=wall&filter=unused&sort=newest&limit=100 resolves 200 with zero items
|
||||
When the user selects "Unused" and types "wall" and GET /v1/media/?q=wall&filter=unused&sort=newest&page=1&size=25 resolves 200 with zero items
|
||||
Then a panel replaces the images with the search icon, title `No images match "wall"`, text "Nothing matches in Unused. Check the spelling, or clear the filters." and an outline "Clear filters" button
|
||||
When the user activates "Clear filters"
|
||||
Then the search field is emptied, the tab returns to "All", ?q and ?filter leave the URL, and the Default cards reappear
|
||||
|
||||
@@ -147,5 +147,6 @@ Manual checks screenshots cannot cover:
|
||||
- **Toast placement and stacking** on a live app.
|
||||
- **Counts after a delete** against a live server: the background refresh gives the true Unused
|
||||
and Duplicates counts.
|
||||
- **A library above 100 images**: B1 requests `limit=100` and ignores `next_cursor`, so only the
|
||||
first 100 show.
|
||||
- **A library above 25 images**: amended by `features/media_pagination_screen.feature`. B1 used to
|
||||
request `limit=100` and ignore `next_cursor`; the page now requests `page=1&size=25` (or the stored
|
||||
size) and shows a paginator, so every image is reachable.
|
||||
@@ -21,7 +21,7 @@ Package: `test` (black-box). New file `test/media_library_http_test.go`. Fake ty
|
||||
|
||||
## HTTP checks
|
||||
10. Auth table: each method+path from the outline with no cookie -> 401, stale cookie -> 401 with `apperr.UnauthorizedErrorMessage`, unverified cookie -> 403 with `apperr.VerificationRequiredErrorMessage`. Assert no fake method beyond the middleware's user lookup was called.
|
||||
11. List: decode `{items, next_cursor, counts}`. Assert each row of the sort, filter, `q` and `hash` tables, the combined query, the paging walk (limit 2 across three pages ending in `next_cursor == null`), the empty library (`items` is `[]` in the raw JSON, not `null`), SVG `width`/`height` are JSON `null` (assert on the raw body), and `counts` is unchanged by `q`/`filter`. Assert every 400 query row, body equals `apperr.MediaQueryErrorMessage + "\n"`.
|
||||
11. List (amended by `features/media_pagination_api.feature`): decode `{items, total, page, size, counts}`, with no `next_cursor`. Assert each row of the sort, filter, `q` and `hash` tables, the combined query, the paging walk (`size=5` across three pages with `page=1..3`, each reporting the same `total`), the empty library (`items` is `[]` in the raw JSON, not `null`), SVG `width`/`height` are JSON `null` (assert on the raw body), and `counts` is unchanged by `q`/`filter`. Assert every 400 query row, body equals `apperr.MediaQueryErrorMessage + "\n"`.
|
||||
12. Upload: for each allowed type assert 201, the item fields, `sha256` equals the test's own hash, `uploaded_by`, the url regex, and one repo entry with `UploadedByID == 1`. Upload identical bytes twice: both 201, second has `duplicate: true`, list `counts.duplicates == 1`. Assert filename rules (no extension gets `.png`, path stripped). Assert 413/415/400 statuses and bodies, that `Create` is never called on rejection, boundary 10485760 vs 10485761, a missing `file` part -> 400, and an armed `Create` failure -> 500 `"Internal server error\n"`.
|
||||
13. Rename: assert 200 with the new filename, the same `url`, the article body unchanged, and every invalid row -> 400 with `"Enter a filename.\n"` for the filename rows; 404 for 9999; 400 with `apperr.IdUint32ErrorMessage` for `abc`. Assert the repository filename is unchanged after each 400.
|
||||
14. Delete: in-use image -> 204 with an empty body, article bodies byte-identical, `deleteCalls == [1]`; 404, 400, 500 rows. The "file already gone" case is a persistence behaviour and is a manual item.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
Feature: Media page remembers the chosen page size
|
||||
|
||||
As a verified RenewCMS user
|
||||
I want the page size I choose on the Media page to be remembered
|
||||
So that I do not have to pick it again each time I come back or refresh
|
||||
|
||||
Background:
|
||||
Given this slice follows features/articles_page_size_memory.feature and extends features/media_pagination_screen.feature; the URL keeps ?size= as in that spec and no visual or API change is made
|
||||
And the size options stay "5 per page", "25 per page", "50 per page", "100 per page" and the default stays 25
|
||||
And the chosen size is stored in localStorage under the key renewcms-page-size-media as the bare number (for example 5), one key per paginated list; Articles uses renewcms-page-size-articles and Activity renewcms-page-size-activity, and the three do not affect each other
|
||||
And a MEDIA_PAGE_SIZE_KEY constant joins ARTICLES_PAGE_SIZE_KEY and ACTIVITY_PAGE_SIZE_KEY in web/src/lib/pageSizeMemory.ts and the existing readStoredPageSize, storePageSize and resolvePageSize helpers are reused
|
||||
And only choosing a size in the page-size select writes the stored value; loading a URL never writes it
|
||||
And an explicit valid ?size= in the URL is authoritative for that load and does not change the stored value
|
||||
And an invalid ?size= in the URL falls back to 25, never to the stored value, and is replaced in the URL (features/media_pagination_screen.feature)
|
||||
And a stored value that is not 5, 25, 50 or 100 is treated as absent
|
||||
And localStorage that is unavailable or throws on read or write never raises an error or breaks the page: reads act as absent and writes are ignored
|
||||
And logout does not clear the stored size
|
||||
And a size change still resets page to 1 and drops page from the URL
|
||||
And the Duplicates tab ignores the stored size (it always loads with size=100) and never writes it
|
||||
And the picker and hash lookups ignore the stored size (fixed size=25 and size=100)
|
||||
And the requests are GET /v1/media/?filter=F&sort=S&page=P&size=Z as in features/media_pagination_api.feature
|
||||
|
||||
Scenario Outline: Choosing a size stores it
|
||||
Given nothing is stored and the user is on /media
|
||||
When the user chooses "<label>" in the page-size select
|
||||
Then localStorage renewcms-page-size-media is "<stored>"
|
||||
And on success the URL is <url>
|
||||
|
||||
Examples:
|
||||
| label | stored | url |
|
||||
| 5 per page | 5 | /media?size=5 |
|
||||
| 25 per page | 25 | /media |
|
||||
| 50 per page | 50 | /media?size=50 |
|
||||
| 100 per page | 100 | /media?size=100 |
|
||||
|
||||
Scenario: The stored size is restored when the page is opened without a size
|
||||
Given localStorage renewcms-page-size-media is "5"
|
||||
When the user navigates to /media
|
||||
Then GET ...&page=1&size=5 is sent
|
||||
And the page-size select reads "5 per page"
|
||||
And on success the URL is replaced (no history entry) by /media?size=5
|
||||
|
||||
Scenario: The size survives leaving the page and coming back
|
||||
Given the user chose "5 per page" on /media
|
||||
When the user navigates to /, then back to /media
|
||||
Then GET ...&size=5 is sent and the select reads "5 per page"
|
||||
|
||||
Scenario: The size survives a full page refresh
|
||||
Given the user chose "5 per page" on /media
|
||||
When the page is reloaded on /media?size=5, or on /media with the URL typed in without a size
|
||||
Then GET ...&size=5 is sent and the select reads "5 per page"
|
||||
|
||||
Scenario: The stored size is kept across paging, search, tabs, sort and view
|
||||
Given localStorage renewcms-page-size-media is "50" and the user is on /media?size=50
|
||||
When the user activates Next, switches to the "Unused" tab, sorts by "Largest", switches to list view or searches "wall"
|
||||
Then every request carries size=50 and the stored value stays "50"
|
||||
|
||||
Scenario: Changing the size again replaces the stored one
|
||||
Given localStorage renewcms-page-size-media is "5"
|
||||
When the user chooses "100 per page"
|
||||
Then localStorage renewcms-page-size-media is "100"
|
||||
And the next visit to /media without a size sends size=100
|
||||
|
||||
Scenario: Returning to 25 is remembered too
|
||||
Given localStorage renewcms-page-size-media is "5"
|
||||
When the user chooses "25 per page"
|
||||
Then localStorage renewcms-page-size-media is "25"
|
||||
And the next visit to /media sends size=25 and the URL stays /media
|
||||
|
||||
Scenario: An explicit size in the URL wins and is not stored
|
||||
Given localStorage renewcms-page-size-media is "5"
|
||||
When the user navigates to /media?size=100
|
||||
Then GET ...&size=100 is sent and the select reads "100 per page"
|
||||
And localStorage renewcms-page-size-media is still "5"
|
||||
And a later visit to /media sends size=5
|
||||
|
||||
Scenario: An explicit page with no size uses the stored size
|
||||
Given localStorage renewcms-page-size-media is "5"
|
||||
When the user navigates to /media?page=3
|
||||
Then GET ...&page=3&size=5 is sent and on success the URL is replaced by /media?page=3&size=5
|
||||
|
||||
Scenario Outline: An invalid stored value falls back to 25
|
||||
Given localStorage renewcms-page-size-media is "<stored>"
|
||||
When the user navigates to /media
|
||||
Then GET ...&page=1&size=25 is sent, the select reads "25 per page" and the URL is /media
|
||||
|
||||
Examples:
|
||||
| stored |
|
||||
| 10 |
|
||||
| abc |
|
||||
| 0 |
|
||||
| -5 |
|
||||
| 25.5 |
|
||||
| |
|
||||
|
||||
Scenario: An invalid size in the URL ignores the stored one
|
||||
Given localStorage renewcms-page-size-media is "5"
|
||||
When the user navigates to /media?size=10
|
||||
Then GET ... is sent with size=25 and the URL is replaced by /media
|
||||
And localStorage renewcms-page-size-media is still "5"
|
||||
|
||||
Scenario Outline: Unavailable storage never breaks the page
|
||||
Given localStorage <failure>
|
||||
When the user navigates to /media
|
||||
Then GET ...&page=1&size=25 is sent, the page renders and no error is shown
|
||||
When the user chooses "50 per page"
|
||||
Then GET ...&page=1&size=50 is sent and the URL is /media?size=50
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| throws on getItem |
|
||||
| throws on setItem (quota) |
|
||||
| is undefined |
|
||||
|
||||
Scenario: Logging out keeps the stored size
|
||||
Given localStorage renewcms-page-size-media is "5"
|
||||
When the user logs out and logs in again and opens /media
|
||||
Then GET ...&size=5 is sent
|
||||
|
||||
Scenario: The three lists keep separate sizes
|
||||
Given localStorage has renewcms-page-size-articles "50", renewcms-page-size-activity "100" and renewcms-page-size-media "5"
|
||||
When the user opens /article, /activity and /media in turn without a size
|
||||
Then they send size=50, size=100 and size=5 respectively
|
||||
|
||||
Scenario: The Duplicates tab ignores the stored size
|
||||
Given localStorage renewcms-page-size-media is "5"
|
||||
When the user opens /media?filter=duplicates
|
||||
Then the requests carry size=100, the stored value stays "5", and returning to the "All" tab sends size=5
|
||||
@@ -0,0 +1,28 @@
|
||||
# QA: Media page size memory (SPA)
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Add `web/src/views/__tests__/MediaPageSizeMemory.spec.ts`, mirroring `ArticlesPageSizeMemory.spec.ts` and `ActivityPageSizeMemory.spec.ts`: stub `localStorage` per test (a `Storage` fake, plus variants whose `getItem` or `setItem` throws, and `undefined`), use the `/v1/media/` mock of `mediaTestKit.ts` that honours `page` and `size`, and a real router. Extend `web/src/lib/__tests__/pageSizeMemory.spec.ts` for `MEDIA_PAGE_SIZE_KEY`.
|
||||
|
||||
1. Choose a size table (5, 25, 50, 100) with nothing stored: stored value per the table and URLs `/media?size=5`, `/media`, `/media?size=50`, `/media?size=100`.
|
||||
2. Stored 5, open `/media`: request `size=5`, select reads "5 per page", URL replaced (not pushed) by `/media?size=5`.
|
||||
3. Leave and come back (navigate to `/` then `/media`): `size=5`.
|
||||
4. Reload on `/media?size=5` and on `/media` with a fresh mount: `size=5`.
|
||||
5. Stored 50: Next, "Unused" tab, "Largest" sort, list view and search "wall" each carry `size=50`; the stored value stays "50".
|
||||
6. Choose 100 after 5: stored "100", next visit `size=100`. Choose 25 after 5: stored "25", URL stays `/media`.
|
||||
7. Explicit `?size=100` with stored 5: request `size=100`, stored still "5", later visit `size=5`.
|
||||
8. `?page=3` with stored 5: request `page=3&size=5`, URL replaced by `/media?page=3&size=5`.
|
||||
9. Invalid stored table (`10`, `abc`, `0`, `-5`, `25.5`, empty): request `size=25`, URL `/media`.
|
||||
10. Invalid URL `?size=10` with stored 5: request `size=25`, URL `/media`, stored still "5".
|
||||
11. Unavailable storage table (throws on getItem, throws on setItem, undefined): page renders, no error, `size=25`, then choosing 50 sends `size=50` and URL `/media?size=50`.
|
||||
12. Logout and login keeps the key (clear the auth store only; storage untouched), then `/media` sends `size=5`.
|
||||
13. Separate keys: articles 50, activity 100, media 5 -> the three views send 50, 100 and 5.
|
||||
14. Duplicates tab: stored 5 -> requests carry `size=100`, stored unchanged, returning to All sends `size=5`.
|
||||
15. Unit test of the lib: `readStoredPageSize`, `storePageSize` and `resolvePageSize` with the media key and the valid sizes 5/25/50/100.
|
||||
|
||||
No Visual part: no visual or design change.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- In a real browser (`task dev`): choose "5 per page", refresh, close and reopen the tab, confirm the select and the URL; in DevTools confirm only `renewcms-page-size-media` changed.
|
||||
- Block storage (private mode or disabled site data) and confirm the page still works.
|
||||
@@ -0,0 +1,148 @@
|
||||
Feature: Paginated media listing
|
||||
|
||||
As an API consumer, the RenewCMS media page or the editor picker
|
||||
I want GET /v1/media/ to answer one page of images with the total and the library-wide counts
|
||||
So that large libraries load quickly and every consumer pages the same way
|
||||
|
||||
Background:
|
||||
Given GET /v1/media/ is mounted inside the jwtauth.Verifier + Authenticator group of api/router.go, unchanged
|
||||
And BREAKING CHANGE: the body is {"items": [...], "total": N, "page": P, "size": S, "counts": {"all": A, "unused": U, "duplicates": D}} in snake_case; next_cursor is removed and no X-Total-Count header is sent
|
||||
And each item is the existing media item (id, filename, url, size, content_type, width, height, sha256, uploaded_by, created_at, used_in, duplicate, variant_count, variant_size) and items is [] rather than null
|
||||
And paging follows the Activity and Articles convention: page is an integer >= 1 (default 1), size is one of 5, 25, 50, 100 (default 25)
|
||||
And q, hash, filter (all, unused, duplicates), sort (newest, oldest, name, largest, total_size) and variants (with, without) keep their current meaning and validation; the default order is newest
|
||||
And total is the number of images matching q, hash, filter and variants, before paging; page and size echo the values applied
|
||||
And counts stays library-wide: it ignores q, hash, filter, variants and paging
|
||||
And ordering is applied to the whole matching list before slicing, with id as the final tie-break, so pages never overlap or skip (newest and total_size are made deterministic; oldest, name and largest already tie on id)
|
||||
And a page beyond the last answers 200 with items [] and the real total
|
||||
And any invalid page, size, filter, sort, variants or hash answers 400 with the plain-text body "The media query is invalid." (apperr.MediaQueryErrorMessage); page and size are validated with validate tags through validator.Validate.Struct
|
||||
And cursor and limit are no longer parameters: they are ignored, never an error, and have no effect on paging
|
||||
And ListMediaUseCase reaches data only through domain interfaces; the domain ListQuery carries Page and Size instead of Cursor and Limit; the handler never touches a repository
|
||||
And the SPA sends explicit page and size on every list call
|
||||
|
||||
Scenario: A bare request answers the envelope with the defaults
|
||||
Given the library holds 30 images
|
||||
When a verified client sends GET /v1/media/
|
||||
Then the status is 200
|
||||
And the body has page 1, size 25, total 30, 25 items and counts {"all": 30, "unused": U, "duplicates": D}
|
||||
And the body has no next_cursor key
|
||||
|
||||
Scenario Outline: Defaults when only one parameter is given
|
||||
Given the library holds 60 images
|
||||
When a verified client sends GET /v1/media/?<query>
|
||||
Then the body has page <page>, size <size>, total 60 and <count> items
|
||||
|
||||
Examples:
|
||||
| query | page | size | count |
|
||||
| page=2 | 2 | 25 | 25 |
|
||||
| page=3 | 3 | 25 | 10 |
|
||||
| size=5 | 1 | 5 | 5 |
|
||||
| size=50 | 1 | 50 | 50 |
|
||||
| size=100 | 1 | 100 | 60 |
|
||||
|
||||
Scenario: Pages partition the list with no overlap
|
||||
Given the library holds 12 images
|
||||
When a client requests ?page=1&size=5, ?page=2&size=5 and ?page=3&size=5
|
||||
Then the pages hold 5, 5 and 2 items, total is 12 on each, and no id repeats
|
||||
|
||||
Scenario: Newest first with the id tie-break
|
||||
Given images id 1 uploaded 2026-09-01, id 2 and id 3 uploaded 2026-09-03
|
||||
When a client sends GET /v1/media/?size=5
|
||||
Then the item ids are 3, 2, 1
|
||||
|
||||
Scenario Outline: Sort orders the whole library before paging
|
||||
Given 12 images with distinct names, sizes and dates
|
||||
When a client requests ?sort=<sort>&size=5 for pages 1, 2 and 3
|
||||
Then the pages hold the whole ordered list in <order> with no repeat
|
||||
|
||||
Examples:
|
||||
| sort | order |
|
||||
| newest | created_at descending |
|
||||
| oldest | created_at ascending |
|
||||
| name | filename stem, case-insensitive ascending |
|
||||
| largest | size descending |
|
||||
| total_size | original size plus generated variant bytes, descending |
|
||||
|
||||
Scenario: Filter and variants narrow total and items but not the counts
|
||||
Given 40 images of which 6 are unused and 3 hold generated variants, and the library counts are {"all": 40, "unused": 6, "duplicates": 2}
|
||||
When a client sends GET /v1/media/?filter=unused&size=5
|
||||
Then total is 6, the page holds 5 items and counts is {"all": 40, "unused": 6, "duplicates": 2}
|
||||
When a client sends GET /v1/media/?variants=with
|
||||
Then total is 3 and counts is unchanged
|
||||
|
||||
Scenario: Search combines with paging
|
||||
Given 7 images whose filename contains "wall" and 20 others
|
||||
When a client sends GET /v1/media/?q=wall&size=5&page=2
|
||||
Then total is 7, the page holds 2 items and counts.all is 27
|
||||
|
||||
Scenario: Hash lookup returns every copy in one page
|
||||
Given 3 images share the sha256 H
|
||||
When a client sends GET /v1/media/?hash=H&page=1&size=100
|
||||
Then total is 3 and items has the 3 copies
|
||||
|
||||
Scenario: Hash lookup with no paging uses the defaults
|
||||
Given 3 images share the sha256 H
|
||||
When a client sends GET /v1/media/?hash=H
|
||||
Then the body has page 1, size 25, total 3 and 3 items
|
||||
|
||||
Scenario: A page beyond the last is empty with the real total
|
||||
Given the library holds 12 images
|
||||
When a client sends GET /v1/media/?page=99&size=5
|
||||
Then the status is 200, items is [], total is 12, page is 99, size is 5 and counts.all is 12
|
||||
|
||||
Scenario: An empty library
|
||||
Given the library is empty
|
||||
When a client sends GET /v1/media/
|
||||
Then the status is 200, items is [], total is 0 and counts is {"all": 0, "unused": 0, "duplicates": 0}
|
||||
|
||||
Scenario: counts ignores every filter and the page
|
||||
Given the library holds 30 images
|
||||
When a client sends GET /v1/media/?q=zzzz&page=3&size=5
|
||||
Then items is [], total is 0 and counts.all is 30
|
||||
|
||||
Scenario Outline: cursor and limit are ignored
|
||||
Given the library holds 30 images
|
||||
When a client sends GET /v1/media/?<query>
|
||||
Then the status is 200 and the body has page 1, size 25 and 25 items
|
||||
|
||||
Examples:
|
||||
| query |
|
||||
| cursor=abc |
|
||||
| limit=5 |
|
||||
| limit=0 |
|
||||
| limit=abc |
|
||||
| cursor=b2Zmc2V0OjU |
|
||||
|
||||
Scenario Outline: Invalid parameters answer 400
|
||||
When a client sends GET /v1/media/?<query>
|
||||
Then the status is 400
|
||||
And the body is "The media query is invalid."
|
||||
And the repository was not called
|
||||
|
||||
Examples:
|
||||
| query |
|
||||
| page=0 |
|
||||
| page=-1 |
|
||||
| page=abc |
|
||||
| page= |
|
||||
| page=1.5 |
|
||||
| size=0 |
|
||||
| size=10 |
|
||||
| size=200 |
|
||||
| size=abc |
|
||||
| size= |
|
||||
| page=0&size=5 |
|
||||
| page=1&size=1000 |
|
||||
| filter=archived |
|
||||
| sort=biggest |
|
||||
| variants= |
|
||||
| variants=other |
|
||||
| hash=notahash |
|
||||
|
||||
Scenario: Repository failure answers 500
|
||||
Given the repository fails on its next call
|
||||
When a client sends GET /v1/media/
|
||||
Then the status is 500 and the body is "Internal server error"
|
||||
|
||||
Scenario: An unauthenticated caller is refused as before
|
||||
When a client sends GET /v1/media/?page=1&size=5 without a valid jwt cookie
|
||||
Then the response is the unchanged 401 of the auth group and the use case is not called
|
||||
@@ -0,0 +1,36 @@
|
||||
# QA: Paginated media listing (API)
|
||||
|
||||
## HTTP-level (Go, `test/`, `task test`)
|
||||
|
||||
Build `media.NewHandler(...)` over the real `ListMediaUseCase` and in-memory fakes of `image.Repository`, `article.Repository`, `user.Repository` and `image.VariantFileRepository` (extend the existing media fakes, keeping type names unique across files). Mount on a fresh `chi.NewRouter()` mirroring the media router in `api/router.go` (GET / inside `jwtauth.Verifier` + `jwtauth.Authenticator`, test `jwtauth.New("HS256", ...)`, valid `jwt` cookie with a `user_id` claim of a verified user). Call `validator.InitValidator()`. No live server, no SQLite file.
|
||||
|
||||
Helper: decode into {items []map, total, page, size int, counts {all, unused, duplicates}}; also assert the raw JSON contains `"items":[` (never null) and does not contain `next_cursor`.
|
||||
|
||||
1. Bare request: 30 images -> object, page 1, size 25, total 30, 25 items, no `next_cursor`.
|
||||
2. Defaults table: page=2, page=3, size=5, size=50, size=100 on 60 images.
|
||||
3. Partition: 12 images, pages 1-3 of size 5 -> 5, 5, 2; 12 distinct ids.
|
||||
4. Order: equal-timestamp case -> ids 3,2,1. Sort table (newest, oldest, name, largest, total_size): 12 images, three pages, concatenation equals the full ordered list, no repeat. Ties break on id.
|
||||
5. Filter/variants/q: unused -> total 6, counts unchanged; variants=with -> total 3; q=wall&size=5&page=2 -> total 7, 2 items, counts.all 27.
|
||||
6. Hash: hash=H&page=1&size=100 -> 3 copies; hash=H alone -> page 1, size 25, 3 items.
|
||||
7. Out of range: page=99&size=5 -> 200, raw `"items":[]`, total 12.
|
||||
8. Empty library -> items [], total 0, counts 0/0/0.
|
||||
9. counts library-wide: q=zzzz&page=3&size=5 -> items [], total 0, counts.all 30.
|
||||
10. cursor/limit ignored: rows `cursor=abc`, `limit=5`, `limit=0`, `limit=abc`, a valid-looking old cursor -> 200, page 1, size 25, 25 items.
|
||||
11. Invalid table (all rows): each -> 400, body exactly "The media query is invalid.", repository call counters stay 0.
|
||||
12. Repository failure -> 500 "Internal server error".
|
||||
13. Missing or invalid jwt cookie -> the auth group's 401, use case counter 0.
|
||||
14. Use-case tests: `ListMediaUseCase.List` returns items, total, page, size and counts; slices after sorting; propagates a repository error (`errors.Is` on a sentinel); `Describe` is unchanged; `ListQuery.Validate` rejects page < 1 and sizes outside 5/25/50/100 with `ErrInvalidQuery`. `image.ListQuery.Cursor` and `.Limit`, `MediaPage.NextCursor`, `encodeCursor` and `decodeCursor` no longer exist.
|
||||
15. Existing tests that read `next_cursor`, send `limit` or `cursor`, or call `NewListQuery` (media tests in `test/`, `features/media_library.qa.md`, `features/media_browse.qa.md`) are amended to the envelope; list them in the diff.
|
||||
|
||||
## Docs (verified by reading the diff)
|
||||
|
||||
- `docs/api.md` lines 99-103: replace `cursor` and `limit` with `page` (default 1) and `size` (5, 25, 50, 100; default 25); state the envelope (`items`, `total`, `page`, `size`, `counts`), that `counts` stays library-wide, that invalid values answer 400 "The media query is invalid.", and a "Breaking change" note: `next_cursor` is gone, `cursor` and `limit` are ignored.
|
||||
- `docs/api/*.md`: grep for `/v1/media/` and `cursor`; update the media listing section if one exists.
|
||||
- `web/src/data/apiCatalogue.ts` `med-list`: add `page`, `size`, `q`, `filter`, `sort`, `variants`, `hash` query entries, the 400 error, and a paged example (`total`, `page`, `size`, no `next_cursor`). Remove the `next_cursor` rule (line ~129, with its French entry in `web/src/i18n/messages.ts` line ~1606) if it is only used by media. Integration specs asserting the catalogue are amended.
|
||||
- Flag for the user (read-only to agents): `docs/design/screens/integration.md` and `docs/design/source/IntegrationPage.dc.html` (med-list with no params), `docs/design/screens/media.md`, `routes.md`, `change-list.md` item 13b, `README.md` line 92 and `screens/media-picker.md`: all still say "array body" and "X-Total-Count".
|
||||
- A release note under `docs/releases/` is the maintainer's call: flag it.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- Real SQLite file via `task dev`: seed 60 images, check page=2&size=25, ordering by each sort and counts; the same on MySQL/PostgreSQL if available (no live DB in tests).
|
||||
- An authenticated curl returns the envelope with no `next_cursor`.
|
||||
@@ -0,0 +1,185 @@
|
||||
Feature: Media page pagination
|
||||
|
||||
As a verified owner or admin with many images
|
||||
I want the Media library split into pages with a page-size menu and shareable URLs
|
||||
So that I can browse a large library quickly and come back to the same page
|
||||
|
||||
Background:
|
||||
Given /media is the verified Media route (features/media_browse.feature), per docs/design/routes.md "Pagination in the URL (Articles, Media)" and docs/design/screens/media.md states 44-52
|
||||
And the Paginator and PageSizeSelect are the existing components used by Articles (AppPaginator.vue); no new component is created
|
||||
And the page requests GET /v1/media/?q=Q&filter=F&sort=S&variants=V&page=P&size=Z and reads {items, total, page, size, counts} (features/media_pagination_api.feature); q and variants are sent only when set; page and size are always sent explicitly; the tab counts come from counts and do not change while searching
|
||||
And page and size live in the URL: ?page=&size=, next to the existing q, filter, sort, variants and view; the defaults (page 1, size 25) are omitted from the URL
|
||||
And an invalid or missing size falls back to the stored size (features/media_page_size_memory.feature) or 25, and an invalid page (not an integer >= 1) falls back to 1, both silently replaced in the URL with history.replace and never shown as an error
|
||||
And a change to search, tab, sort, view, the variants filter or the page size resets page to 1 (and drops it from the URL); search keeps its 120 ms debounce
|
||||
And the URL changes when a response succeeds, not when the request is sent
|
||||
And the summary reads "Showing {from}–{to} of {total}" with locale-formatted numbers, from total (the filtered count), under the grid or list
|
||||
And the Paginator has First, Previous, page numbers with ellipses marking skipped ranges, Next, Last, and the page-size select "5 per page", "25 per page", "50 per page", "100 per page" (it opens upward)
|
||||
And the current page uses --rc-sand fill, --rc-on-sand text and a --ui-primary border and has aria-current="page"; disabled controls are at 40 % opacity; buttons are 32 px (44 px on mobile)
|
||||
And on mobile the paginator is Previous, "Page N of M" and Next
|
||||
And the paginator is hidden when total fits on one page; the summary and the page-size select stay
|
||||
And the paginator sits under the grid in grid view and under the table (md and up) or card list (below md) in list view; its exact placement is taken from the board frame docs/design/source/MediaFrame.dc.html, not from components.md prose
|
||||
And keyboard: Tab reaches the page-size select, first, previous, each page, next, last; Enter or Space activates; ArrowDown opens the page-size list, Enter selects, Esc closes; every control shows a 2 px --ui-primary focus outline with a 2 px offset
|
||||
And the Duplicates tab (?filter=duplicates) is not paginated: it loads the whole duplicate set with size=100, requesting page 1, 2, ... until the loaded count reaches total, groups it by sha256 as today, shows no paginator, summary or page-size select, and drops page and size from the URL
|
||||
And "Select all" covers the images on the current page only and reads "{n} images" for that page; changing page, size, search, tab, sort or view clears the selection
|
||||
And the silent background refresh after a delete, upload or merge reloads the current page with no skeleton; when the current page has become empty and is past the last page, the list steps back to the last page and replaces the URL
|
||||
And the first load and a change of search, tab or sort keep the existing skeleton after 150 ms and the "Couldn't load your images" alert (media.md states 4 and 42); the page-change error below applies only when images are already shown
|
||||
And the fixture below is total 312, size 25 (13 pages) unless stated, with the board's own sample data
|
||||
|
||||
Scenario: 44 First page
|
||||
Given GET /v1/media/?filter=all&sort=newest&page=1&size=25 resolves 200 with 25 items, total 312 and counts {all 312, unused 2, duplicates 2}
|
||||
When the user navigates to /media
|
||||
Then the summary reads "Showing 1–25 of 312"
|
||||
And First and Previous are disabled, page 1 is current, Next and Last are enabled
|
||||
And the URL has no page or size
|
||||
|
||||
Scenario: 45 Middle page
|
||||
Given GET ...&page=7&size=25 resolves 200 with 25 items and total 312
|
||||
When the user navigates to /media?page=7
|
||||
Then the summary reads "Showing 151–175 of 312"
|
||||
And page 7 is current with --rc-sand / --rc-on-sand and aria-current="page"
|
||||
And ellipses mark the skipped ranges and every control is enabled
|
||||
|
||||
Scenario: 46 Last page
|
||||
Given GET ...&page=13&size=25 resolves 200 with 12 items and total 312
|
||||
When the user navigates to /media?page=13
|
||||
Then the summary reads "Showing 301–312 of 312"
|
||||
And Next and Last are disabled
|
||||
|
||||
Scenario: Single page
|
||||
Given GET /v1/media/ resolves 200 with 12 items, total 12 and counts {all 12, unused 2, duplicates 2}
|
||||
When the user navigates to /media
|
||||
Then the summary reads "Showing 1–12 of 12" and the page-size select stays
|
||||
And the paginator (First ... Last) is not rendered
|
||||
|
||||
Scenario: 47 List view, desktop and mobile
|
||||
Given page 2 of 13 is shown with ?view=list
|
||||
Then at md width or more the paginator sits under the table with the page numbers
|
||||
And below md it sits under the card list as Previous, "Page 2 of 13", Next with 44 px controls and no page numbers
|
||||
And in grid view on mobile the paginator is the same Previous, "Page 2 of 13", Next
|
||||
|
||||
Scenario: 48 Loading next page
|
||||
Given the first page has loaded and GET ...&page=2 has not resolved
|
||||
When the user activates Next
|
||||
Then skeleton cards keep the grid height and the list is aria-busy="true"
|
||||
And every paginator control is disabled and the summary reads "Loading page 2…"
|
||||
When the request resolves 200
|
||||
Then the images and summary of page 2 appear and the URL becomes /media?page=2
|
||||
|
||||
Scenario Outline: 49 Error loading a page keeps the previous images
|
||||
Given page 6 is shown
|
||||
When the user activates Next and GET ...&page=7 <failure>
|
||||
Then the page-6 images stay, the URL stays /media?page=6 and the controls are re-enabled
|
||||
And a notice reads "Couldn't load page 7. Showing page 6." with a "Retry" action
|
||||
When the user activates "Retry"
|
||||
Then GET ...&page=7 is sent again and, on 200, page 7 replaces the images, the URL becomes ?page=7 and the notice goes
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: 50 Page out of range redirects to the last page with a notice
|
||||
Given GET ...&page=99 resolves 200 with items [] and total 312
|
||||
When the user navigates to /media?page=99
|
||||
Then the URL is replaced (no history entry) by /media?page=13 and GET ...&page=13 is sent
|
||||
And the notice reads "That page doesn't exist. Showing the last page."
|
||||
And the last-page state (46) is shown
|
||||
|
||||
Scenario: Out of range with total 0 shows the empty state, no redirect
|
||||
Given GET ...&page=5 resolves 200 with items [] and total 0 and counts.all 0
|
||||
When the user navigates to /media?page=5
|
||||
Then the "No images yet" empty state shows and the URL is /media
|
||||
|
||||
Scenario: Out of range after a search with total 0 shows no results
|
||||
Given GET ...&q=zzzz&page=5 resolves 200 with items [], total 0 and counts.all 312
|
||||
When the user navigates to /media?q=zzzz&page=5
|
||||
Then the no-results state (media.md state 6) shows and the URL is /media?q=zzzz
|
||||
|
||||
Scenario Outline: Search, tab, sort, view and variants changes reset to page 1
|
||||
Given the user is on /media?page=7
|
||||
When the user <action> and the request resolves
|
||||
Then GET ... carries page=1
|
||||
And on success the URL has no page
|
||||
And the tab counts are unchanged by a search
|
||||
|
||||
Examples:
|
||||
| action |
|
||||
| types "wall" in the search field and the debounce elapses |
|
||||
| selects the "Unused" tab |
|
||||
| picks "Largest" in the sort menu |
|
||||
| activates the list icon in the grid toggle |
|
||||
| applies the "With variants" filter |
|
||||
|
||||
Scenario Outline: 51 Page size menu
|
||||
Given the user is on /media?page=3
|
||||
When the user opens the page-size select (it opens upward) and chooses "<label>"
|
||||
Then GET ...&page=1&size=<size> is sent and, on success, the URL is <url>
|
||||
|
||||
Examples:
|
||||
| label | size | url |
|
||||
| 5 per page | 5 | /media?size=5 |
|
||||
| 25 per page | 25 | /media |
|
||||
| 50 per page | 50 | /media?size=50 |
|
||||
| 100 per page | 100 | /media?size=100 |
|
||||
|
||||
Scenario Outline: Invalid URL values fall back silently
|
||||
When the user navigates to /media?<query>
|
||||
Then GET ... is sent with page=1 and size=25 and the URL is replaced by /media with no history entry
|
||||
And no error or notice is shown
|
||||
|
||||
Examples:
|
||||
| query |
|
||||
| size=10&page=abc |
|
||||
| page=0 |
|
||||
| page=-2 |
|
||||
| page=1.5 |
|
||||
| size=0 |
|
||||
| size=200 |
|
||||
|
||||
Scenario: Pagination is kept across the other controls
|
||||
Given the URL is /media?size=50&sort=largest&filter=unused
|
||||
When the user activates Next
|
||||
Then every request carries size=50, sort=largest and filter=unused and the URL keeps them
|
||||
|
||||
Scenario: 52 Focus-visible
|
||||
When the user tabs to Next
|
||||
Then Next shows a 2 px --ui-primary outline with a 2 px offset
|
||||
And the same ring shows on every paginator control and on the page-size select
|
||||
|
||||
Scenario: Keyboard, the page-size list
|
||||
When the user focuses the page-size select and presses ArrowDown
|
||||
Then the list opens; Enter selects the highlighted size; Esc closes it and returns focus to the select
|
||||
|
||||
Scenario: Select all covers the current page only
|
||||
Given page 2 of 13 is shown with 25 images
|
||||
Then the "Select all" row reads "Select all" and "25 images"
|
||||
When the user activates "Select all"
|
||||
Then 25 images are selected and the bulk bar announces "25 selected"
|
||||
When the user activates Next and page 3 loads
|
||||
Then no image is selected and the bulk bar is gone
|
||||
|
||||
Scenario: The Duplicates tab loads the whole set and shows no paginator
|
||||
Given 130 images are duplicates, GET ...&filter=duplicates&sort=newest&page=1&size=100 resolves 200 with 100 items and total 130
|
||||
And GET ...&filter=duplicates&page=2&size=100 resolves 200 with the remaining 30 items
|
||||
When the user navigates to /media?filter=duplicates&page=4&size=5
|
||||
Then page 1 and page 2 are requested with size=100, page 2 only because 100 < 130
|
||||
And the groups are built from the 130 images, with no paginator, summary or page-size select
|
||||
And the URL is replaced by /media?filter=duplicates
|
||||
|
||||
Scenario: Leaving the Duplicates tab returns to the paginated list
|
||||
Given the Duplicates tab is shown
|
||||
When the user selects the "All" tab
|
||||
Then GET ...&filter=all&page=1&size=25 is sent and the paginator is shown
|
||||
|
||||
Scenario: Deleting the last image of the last page
|
||||
Given the user is on the last page with a single image and deletes it (204)
|
||||
Then the list reloads the previous page in the background and the URL is replaced by it
|
||||
And the tab counts are recomputed from the new response
|
||||
|
||||
Scenario: A background refresh keeps the current page
|
||||
Given page 7 is shown and the user renames, uploads or merges
|
||||
Then the list is requested again in the background for page 7 with no skeleton
|
||||
|
||||
Scenario: Hash lookups do not touch the page
|
||||
When the detail panel or the upload duplicate check looks up copies of an image
|
||||
Then GET /v1/media/?hash=H&page=1&size=100 is sent, the URL does not change and the paginator is unaffected
|
||||
@@ -0,0 +1,52 @@
|
||||
# QA: Media page pagination (SPA)
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `/v1/media/` the way `web/src/views/__tests__/mediaTestKit.ts` does, extended so the mock honours `page` and `size`, builds the envelope `{items, total, page, size, counts}` and has no `next_cursor`. Use the 312-image fixture (25 per page) from the board's data in `docs/design/source/MediaFrame.dc.html`. Mount `MediaView` with a real router and Pinia. Reuse the helpers of `ArticlesPagination.spec.ts`.
|
||||
|
||||
One test per state:
|
||||
1. 44 First page: request `?filter=all&sort=newest&page=1&size=25`; "Showing 1–25 of 312"; First/Previous disabled; URL without page or size.
|
||||
2. 45 Middle page: `/media?page=7` -> "Showing 151–175 of 312"; `aria-current="page"` on 7; ellipses.
|
||||
3. 46 Last page: `/media?page=13` -> 12 items, "Showing 301–312 of 312"; Next/Last disabled.
|
||||
4. Single page: total 12 -> summary, page-size select, no First..Last.
|
||||
5. 47 List view: `?view=list&page=2` -> paginator under the table (md) and the card list (below md, the "Page 2 of 13" form).
|
||||
6. 48 Loading next page: pending page 2 -> skeleton cards, `aria-busy`, controls disabled, "Loading page 2…"; resolve -> URL `/media?page=2`.
|
||||
7. 49 Error (500, network): previous images stay, URL unchanged, notice "Couldn't load page 7. Showing page 6." with Retry; Retry succeeds -> `?page=7`, notice gone.
|
||||
8. 50 Out of range: `?page=99` with items [] and total 312 -> URL replaced by `?page=13` (assert `router.replace`, history length unchanged), notice text exact, last-page state.
|
||||
9. Total 0 out of range: empty state, URL `/media`; with `q` -> no-results state.
|
||||
10. Reset to page 1: table of search (120 ms debounce with fake timers on the timer only, never the system date), tab, sort, view, variants filter -> request carries `page=1`, URL has no page.
|
||||
11. 51 Page-size menu table: 5, 25, 50, 100 -> request `page=1&size=N`; URLs per the table.
|
||||
12. Invalid URL table (`size=10&page=abc`, `page=0`, `page=-2`, `page=1.5`, `size=0`, `size=200`): request page=1 size=25, URL replaced, no notice or alert.
|
||||
13. Controls kept: Next with `size=50&sort=largest&filter=unused` keeps every param.
|
||||
14. 52 Focus and keyboard: tab order select, first, previous, pages, next, last; ArrowDown opens, Enter selects, Esc closes and refocuses.
|
||||
15. Select all: "25 images", 25 selected, page change clears the selection and the bulk bar.
|
||||
16. Duplicates tab: with 130 duplicates, assert requests page=1 and page=2 at size=100, groups built from all 130, no paginator, URL drops page and size; selecting All returns to the paginated request.
|
||||
17. Last-image delete: steps back one page and replaces the URL; counts refreshed.
|
||||
18. Background refresh after rename/upload/merge: same page, no skeleton.
|
||||
19. Hash lookups send `hash=H&page=1&size=100` (`findCopies`, `useMediaUpload`, `ImageUploader`).
|
||||
20. Amended existing specs: every spec reading `next_cursor`, `limit=100`, `MEDIA_PAGE_LIMIT` or the old `/v1/media/?` mocks is migrated to the envelope and page/size requests; list them in the diff.
|
||||
|
||||
## Visual (`task visual:check -- media`)
|
||||
|
||||
Hard gate: every capture within 10 % at 1440×900 and 390×844, light and dark. Add `web/visual/states.ts` setups using the board's own sample data (312 images, 25 per page; filenames, sizes, uploaders and dates taken from the frame's script in `docs/design/source/MediaFrame.dc.html`; counts all 312 / unused 2 / duplicates 2):
|
||||
|
||||
| State | Setup |
|
||||
| --- | --- |
|
||||
| 44 Pagination: first page | `/media`, page 1 of 13 |
|
||||
| 45 middle page | `/media?page=7` |
|
||||
| 46 last page | `/media?page=13`, 12 items |
|
||||
| 47 list view | `/media?view=list&page=2` |
|
||||
| 48 loading next page | page 2 pending |
|
||||
| 49 error loading a page | page 7 fails, page 6 shown |
|
||||
| 50 page out of range | `/media?page=99` replaced by `?page=13` with the notice |
|
||||
| 51 page-size menu | select open upward |
|
||||
| 52 focus-visible | Next focused |
|
||||
|
||||
If a frame renders a relative date or greeting, mark that state `skip: clock-sensitive` and write no date-frozen test. The paginator's placement under the grid or list is checked against the board frame's markup, not `components.md` prose.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- Keyboard: tab order and focus ring on every paginator control; ArrowDown/Enter/Esc in the page-size list; grid arrow-key navigation still works after a page change; focus is sensible after Next.
|
||||
- Hover and motion on the paginator and skeleton.
|
||||
- A real library of 60+ images through `task dev`: paging, size change, page past the end replaced with the notice, Duplicates tab loading beyond 100 duplicates.
|
||||
- Screen reader: the summary and "Loading page N…" are announced; selection announcements after a page change.
|
||||
@@ -10,13 +10,16 @@ Feature: Media page, part 2: upload, selection and duplicates
|
||||
DashboardShell, per docs/design/routes.md and docs/design/screens/media.md
|
||||
And this slice is SPA only: the API from slice A is used unchanged, and no Go code, use case
|
||||
or repository changes
|
||||
And this slice is amended by features/media_pagination_screen.feature: "Select all" covers the
|
||||
images of the current page only, the Duplicates tab loads the whole set at size=100 page after
|
||||
page until the loaded count reaches total, and hash lookups send page=1&size=100
|
||||
And this slice supersedes these B1 statements: the Upload button, the mobile pinned button and
|
||||
"Upload an image" are enabled; the Duplicates tab is enabled and ?filter=duplicates is a
|
||||
valid value; "Review duplicates" is enabled; Space on a card toggles selection; the checkbox,
|
||||
the "Select all" text and the partial state exist; the B2 states no longer carry
|
||||
skip 'pending' in web/visual/states.ts
|
||||
And the calls used are POST /v1/media/ (multipart field "file", sent with XMLHttpRequest so
|
||||
that upload progress is reported, with the jwt cookie), GET /v1/media/?hash=<sha256 hex>,
|
||||
that upload progress is reported, with the jwt cookie), GET /v1/media/?hash=<sha256 hex>&page=1&size=100,
|
||||
DELETE /v1/media/{id}, POST /v1/media/merge { keep_id, merge_ids } and the list call from
|
||||
B1 with filter=duplicates; every other /v1 call goes through useApi
|
||||
And 401 and 403 on any of these calls, including the XHR upload, send the user to
|
||||
@@ -130,7 +133,7 @@ Feature: Media page, part 2: upload, selection and duplicates
|
||||
Scenario: A row whose content already exists
|
||||
Given the user chooses "floorboards-2.jpg" (2.4 MB, valid)
|
||||
When the SHA-256 of the file is computed
|
||||
Then GET /v1/media/?hash=<that hex> is sent before any POST
|
||||
Then GET /v1/media/?hash=<that hex>&page=1&size=100 is sent before any POST
|
||||
And when it resolves 200 with the image "floorboards.jpg" (id 12, uploaded 14 Jun 2026 by Inès Duarte)
|
||||
Then the row shows "Already in your library", the text `Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.`, and the buttons "Use existing" and "Upload anyway"
|
||||
And nothing is sent with POST /v1/media/ for it, and its concurrency slot is released
|
||||
@@ -146,7 +149,7 @@ Feature: Media page, part 2: upload, selection and duplicates
|
||||
And the image "floorboards.jpg" is selected, the bulk bar reads "1 selected", and the page announces "floorboards.jpg selected, 1 selected"
|
||||
|
||||
Scenario: Use existing when the image is not in the loaded list
|
||||
Given the existing image is not in the loaded list because of the search, the tab or the 100 limit
|
||||
Given the existing image is not in the loaded list because of the search, the tab or the current page
|
||||
When the user activates "Use existing"
|
||||
Then the search and the tab reset to All and the image is selected
|
||||
And if it is still not loaded the row is dropped without selecting
|
||||
@@ -275,7 +278,7 @@ Feature: Media page, part 2: upload, selection and duplicates
|
||||
When 3 images are selected
|
||||
Then that checkbox shows the partial state
|
||||
When the user activates it while it is partial
|
||||
Then all 12 loaded images are selected and it shows checked
|
||||
Then all 12 images of the current page are selected and it shows checked (never the other pages)
|
||||
When the user activates it while it is checked
|
||||
Then the selection is emptied
|
||||
And in list view at md width or more the table header checkbox does the same; the mobile card list has no "Select all" row and is selected through its 44 px row checkboxes
|
||||
@@ -358,13 +361,13 @@ Feature: Media page, part 2: upload, selection and duplicates
|
||||
Scenario: The Duplicates tab is enabled
|
||||
Given the Default state has loaded
|
||||
When the user selects the "Duplicates 2" tab
|
||||
Then the URL gains ?filter=duplicates and the list is requested with filter=duplicates and limit=100
|
||||
Then the URL gains ?filter=duplicates and the list is requested with filter=duplicates at size=100, page 1, 2, ... until the loaded count reaches total
|
||||
And the tab is selected, and "Duplicates" carries the info-tinted count of groups
|
||||
And opening /media?filter=duplicates lands on the same view, and "Review duplicates" in the detail panel of "floorboards.jpg" is enabled and selects this tab
|
||||
And search applies, the sort menu and the view toggle show as on the other tabs, sort is sent with the request, and the view toggle has no effect on the groups view while the grid or list preference stays in the URL
|
||||
|
||||
Scenario: Duplicates, groups
|
||||
Given GET /v1/media/?filter=duplicates&sort=newest&limit=100 resolves 200 with ids 5, 6, 7, 9 and 12
|
||||
Given GET /v1/media/?filter=duplicates&sort=newest&page=1&size=100 resolves 200 with ids 5, 6, 7, 9 and 12
|
||||
When the user opens the Duplicates tab
|
||||
Then the images are grouped by sha256 into 2 bordered cards stacked with a 16 px gap, the group with the oldest suggested copy first
|
||||
And group 1 has the header "Group 1" over "3 identical images · 2.4 MB each · 3600 × 2400", and group 2 has "Group 2" over "2 identical images · 3.1 MB each · 3600 × 2400"
|
||||
|
||||
@@ -13,7 +13,7 @@ Mount MediaPage in DashboardShell with a verified session and a test router at /
|
||||
3. **Entry points**: Upload (desktop header), the mobile pinned button and "Upload an image" each click the hidden `input[type=file]` (multiple, exact accept list).
|
||||
4. **Drop overlay**: dragenter with Files shows the copy exactly; dragleave hides; drop starts uploads; a drag without Files shows nothing; mobile pinned button hidden while shown.
|
||||
5. **Queue**: 6 valid files give at most 2 XHRs open at once; order kept; header "Uploading 6 images" and "N of 6 done" counts settled rows; "Uploading 1 image" singular; progress 1.1 of 2.6 MB reads "Uploading 1.1 of 2.6 MB" with a 42 % bar; 201 gives "Added to your library"; a silent list reload follows; files added while open are appended.
|
||||
6. **Duplicate check**: GET `?hash=` precedes POST; a match gives the Duplicate row with exact text (oldest match named); slot released; "Use existing" drops the row and selects the id (bulk bar "1 selected", live region text), including the reset-to-All path; "Upload anyway" sends POST; crypto undefined and lookup failure skip straight to POST.
|
||||
6. **Duplicate check**: GET `?hash=...&page=1&size=100` precedes POST; a match gives the Duplicate row with exact text (oldest match named); slot released; "Use existing" drops the row and selects the id (bulk bar "1 selected", live region text), including the reset-to-All path; "Upload anyway" sends POST; crypto undefined and lookup failure skip straight to POST.
|
||||
7. **Server statuses**: 201, 413, 415, 400, 500, network error: row copy and icon exact; "Try again" re-sends only that file with no new GET `?hash=`.
|
||||
8. **Cancel**: row x aborts that XHR and removes the row, the next file starts; "Cancel all" aborts all and removes waiting rows; panel closes when empty.
|
||||
9. **Summary**: Examples-driven summary lines; "Upload finished" / "{n} images" and "Dismiss"; "Nothing was uploaded" with "3 files were rejected" and with "2 files need attention", no summary; live-region text; Dismiss removes every row.
|
||||
@@ -22,7 +22,7 @@ Mount MediaPage in DashboardShell with a verified session and a test router at /
|
||||
12. **Selection**: checkbox visibility (hover, focus, any selection, always on mobile); tick and Space toggle; Enter opens the panel and ticking does not; live-region texts for select, unselect and "Selection cleared"; the checkbox is not a tab stop; select-all states (unchecked, partial, checked) in the grid row and the table header (no row in the mobile card list); Escape closes the panel first and clears the selection next; Clear; selection cleared on q/tab/sort change, kept on view change.
|
||||
13. **Bulk bar**: copy, buttons, desktop and mobile placement classes, replaces the mobile pinned button.
|
||||
14. **Bulk delete**: dialog copy exact (title, description with the summed count, rows "Used in 2", "Used in 3", "Unused"), singular form; no usage request; in progress (one DELETE per id, buttons disabled, not dismissible); done (cards leave, selection empty, focus target, toast id and text, silent reload); partly failed (two removed, failed one stays selected, bar "1 selected", toast with Retry and no auto-dismiss, Retry re-sends only the failed DELETE); several failures title; 404 counted as deleted; all failed gives the inline alert.
|
||||
15. **Duplicates tab**: enabled, URL and request (`filter=duplicates`, limit=100), deep link, "Review duplicates" enabled and switching tab; sort and view toggle shown (sort sent with the request, toggle keeps the preference in the URL); grouping by sha256, group order, headers (including the SVG/unknown uploader case), column order, "Suggested" tag and footer, "Uploaded 14 Jun 2026 by Inès Duarte", "Used in 4 articles" opens the panel; suggested-copy Examples; empty state and "Back to all images"; badge absent at 0.
|
||||
15. **Duplicates tab**: enabled, URL and request (`filter=duplicates`, size=100, page 1, 2, ... until total is reached; amended by `features/media_pagination_screen.feature`), deep link, "Review duplicates" enabled and switching tab; sort and view toggle shown (sort sent with the request, toggle keeps the preference in the URL); grouping by sha256, group order, headers (including the SVG/unknown uploader case), column order, "Suggested" tag and footer, "Uploaded 14 Jun 2026 by Inès Duarte", "Used in 4 articles" opens the panel; suggested-copy Examples; empty state and "Back to all images"; badge absent at 0.
|
||||
16. **Merge**: dialog copy exact for floorboards.jpg and another kept copy, singular form; POST body `{ keep_id: 12, merge_ids: [5, 6] }`; in progress not dismissible; 200 gives group removed, toast id "media-merged-12" and text, live-region text, silent reload, "No duplicates" when last; 500 and network give the alert; 409 gives the alert and a reload; 404 gives the info toast and closes.
|
||||
17. **Global errors**: 401 and 403 from XHR upload, merge, delete and hash lookup call the same redirect handler as useApi; no local copy.
|
||||
18. **B1 regression**: media_browse specs updated for the superseded statements and the rest still passes.
|
||||
|
||||
Reference in new issue
Block a user