diff --git a/.changeset/media-library-folder-ui.md b/.changeset/media-library-folder-ui.md new file mode 100644 index 0000000000..5f32e43401 --- /dev/null +++ b/.changeset/media-library-folder-ui.md @@ -0,0 +1,8 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Adds flat-folder organization to the local Media Library. Editors can create, rename, and delete folders, while authors can move their own media and editors can move any local media through Media Details. + +Uploads continue to enter the Main library. Deleting a folder returns its media to the Main library without deleting files or changing their URLs. diff --git a/docs/src/content/docs/guides/media-library.mdx b/docs/src/content/docs/guides/media-library.mdx index 80d5a01440..349fb50c3d 100644 --- a/docs/src/content/docs/guides/media-library.mdx +++ b/docs/src/content/docs/guides/media-library.mdx @@ -157,6 +157,20 @@ Use the search box to find files by name. Search matches partial filenames. Use the type menu to show all files, images, video, audio, or documents. +## Organizing media in folders + +Editors can select **Add new folder** from the Main library. Open a folder by selecting its name. +Without a search term, folder pages show only the media assigned to that folder. Filename searches +cover the whole library, including other folders and the Main library. + +To move a file, open its **Media Details**, choose a **Location**, and select **Save**. Authors can +move files they uploaded. Editors can move any local file. + +Uploads enter the Main library. Move an uploaded file into a folder from **Media Details**. + +Deleting a folder returns its media to the Main library. The media files, URLs, and content +references remain unchanged. + ## Using Media in Content ### In the Rich Text Editor diff --git a/docs/technical-specs/media-library-folders-pr2.md b/docs/technical-specs/media-library-folders-pr2.md new file mode 100644 index 0000000000..cc38992cf7 --- /dev/null +++ b/docs/technical-specs/media-library-folders-pr2.md @@ -0,0 +1,501 @@ +# Strapi-style flat media folders in the admin + +Status: Proposed +Dependency: Draft PR [#2584](https://github.com/emdash-cms/emdash/pull/2584) at `feat/media-folders-api` commit `b5b28210`, stacked on pagination PR [#2582](https://github.com/emdash-cms/emdash/pull/2582) at `5e0df073` +Intended position: PR 2 of the media-folders sequence; target `feat/media-folders-api`, then retarget to `main` after its dependencies merge +Reference implementation: Strapi Upload `5.49.0`, stable Media Library on Strapi `develop` commit `e8b156d3`; exclude the `future/` implementation behind `unstableMediaLibrary` + +## Approval and authority + +GitHub has no folder-specific maintainer-approved Discussion. Discussion [#990](https://github.com/emdash-cms/emdash/discussions/990) is a broad media-workflow roadmap whose maintainer feedback asks for separate discussions before implementation. Discussion [#1655](https://github.com/emdash-cms/emdash/discussions/1655) covers media usage, not folders. + +The design may be approved in this thread, but implementation and a ready-for-review feature PR remain blocked until maintainers confirm that one of those Discussions covers folders or approve a folder-specific breakout Discussion. + +This specification authorizes only its own creation and revision. It does not authorize source changes, commits, pushes, PR2 creation, or GitHub mutations. Use `$feat-implement` after the product decisions and Discussion gate are approved. + +## Summary + +Add flat-folder browsing and management to the main local Media Library. Reproduce Strapi's stable folder workflow where PR1's flat model supports it: + +- a Back action and breadcrumbs inside a folder; +- a secondary **Add new folder** action beside the primary upload action at the root; +- folders before separately paginated media on page 1; +- folder cards in grid view and folder rows before media in list view; +- a labeled pencil action that opens the shared create/edit dialog; +- folder deletion from that dialog; and +- a **Location** field in Media Details for moving one local media item. + +PR2 does not pretend PR1 is hierarchical. It removes Strapi's parent selector, ancestor menu, child counts, bulk selection, bulk move, drag-to-folder, and recursive delete. Uploads continue to enter the Main library until duplicate-upload placement has an approved product rule. + +## User outcome + +An editor can create a folder, open it, move one existing media item into or out of it, rename it, and delete it without deleting any media. Readers can browse folders. Browser Back and direct folder URLs recover reliably. Existing providers and media pickers continue to see all media and do not gain folder controls. + +## Goals + +- Make PR1's flat folders usable from the main local Media Library. +- Match the stable Strapi placement, labels, navigation, dialog actions, and folder-first result ordering where the flat model permits. +- Preserve numbered pagination's stable grid, scroll, focus, page recovery, and 35/70/90 page sizes. +- Keep folder work authenticated, bounded, localized, RTL-safe, keyboard accessible, and provider-local. +- Keep media IDs, storage keys, URLs, usage records, and content references unchanged. +- Add only the smallest server reads needed for reliable direct folder URLs and bounded global folder search. + +## Non-goals + +- No nested folders, parent IDs, paths, ancestor menus, or folder-to-folder moves. +- No folder child counts, per-folder media counts, or count queries. +- No bulk selection, bulk move, bulk delete, mixed folder/media actions, or drag-to-folder. +- No upload-to-current-folder, upload dialog Location field, or direct/signed upload contract changes. +- No folder controls in `MediaPickerModal`, content fields, Portable Text, providers, CLI, MCP, plugins, or imports. +- No All-media sidebar, folder tree, or general navigation framework. +- No change to media deletion, replacement, usage, search indexes, storage objects, or deduplication. +- No adoption of Strapi's experimental `future/` Media Library. +- No rewrite of the existing asset grid, numbered paginator, provider tabs, upload queue, or detail-dialog layout. + +## Verified current behavior + +### PR1 contracts + +PR1 provides: + +- flat globally unique folders; +- `media.folder_id` with `ON DELETE SET NULL`; +- folder list/create/update/delete routes; +- media list filtering where omitted `folderId` means All media, `unfiled` means Main library, and an ID means one folder; +- single-media assignment through `PUT /_emdash/api/media/:id`; +- editor-only folder management and ownership-aware media assignment; and +- typed core-client support. + +Deleting a folder preserves media identity and returns its media to the Main library. Uploads create media with `folderId: null`. + +### EmDash admin + +`MediaPage` in `packages/admin/src/router.tsx` owns local filename search, MIME filter, numbered page, page size, retained total, empty-page recovery, upload mutation, and the `['media', ...]` query. `keepPreviousData` and `MediaLibrary`'s inert pending state prevent layout jumps. + +`MediaLibrary` owns provider selection, search input, type selector, grid/list view, upload dialog, detail dialog, focus restoration, and Kumo pagination. Folder UI must be local-only and must not change provider queries. + +`MediaDetailPanel` already combines local image metadata in one update mutation, surfaces mutation errors with `DialogError`, confirms destructive actions with `ConfirmDialog`, and closes with focus recovery. Folder assignment belongs in this update, not in a second detail dialog. + +The admin media client lacks folder types and functions. It already supplies the CSRF header, envelope parsing, and server-message propagation through `apiFetch`, `parseApiResponse`, and `throwResponseError`. + +### Stable Strapi behavior to reproduce + +Stable Strapi uses `folder` in URL state, fetches the current folder separately, shows Back and breadcrumbs inside a folder, renders folders before assets only on asset page 1, and resets selection when the folder changes. + +Its grid shows four, three, two, then one folder card across breakpoints. Folder cards show a folder icon, linked name, and labeled pencil edit action revealed on hover or focus. List view puts folder rows before asset rows and provides explicit keyboard-accessible open/edit controls. + +The header places secondary **Add new folder** and primary **Add new assets** actions together. Create/edit uses one dialog with **Cancel**, **Create** or **Save**, and **Delete folder** while editing. Asset editing includes a **Location** selector. + +Strapi search is library-wide: when a search term exists, asset and folder queries ignore the current folder. Folder links clear search. MIME filtering suppresses folders. Successful folder creation returns the asset paginator to page 1. + +## Deliberate differences from Strapi + +These are product boundaries, not incomplete implementation: + +| Strapi stable behavior | EmDash PR2 behavior | Reason | +| --- | --- | --- | +| Nested folders and Location tree | One flat root and one folder level | PR1 is flat by design | +| Child-folder and asset counts | No subtitle counts | PR1 intentionally adds no folder counts; avoid N+1 queries | +| All folders fetched unbounded | 100 per request with **Load more folders** | EmDash list work stays bounded | +| Recursive destructive folder delete | Media returns to Main library | Preserve content references and stored files | +| Upload into current folder | Upload is available only at the root | Duplicate-upload placement remains undecided | +| Bulk move and folder drag/drop | One media item moves through Media Details | PR1 exposes only single-media assignment | +| Physical left/right styling and unmirrored Back arrow | Logical spacing and mirrored direction icon | EmDash supports RTL | + +## Information architecture and visual contract + +The root is the Main library, matching Strapi's unfiled root. An absent `folder` URL parameter maps to API `folderId: null`. `?folder=` opens one flat folder. PR2 does not expose the API's compatibility-oriented All media view in the admin. + +```text +Root + +Media Library [Add new folder] [Upload Files] +[Search by filename...] [All types] [Grid] [List] + +Folders +[ Folder icon Product photos Edit ] +[ Folder icon Press Edit ] +[ Load more folders ] +------------------------------------------------------------ +[ existing media grid or table ] +[ existing numbered pagination ] + +Folder + +< Back +Media Library / Product photos +[Search by filename...] [All types] [Grid] [List] + +[ media assigned to Product photos ] +[ existing numbered pagination ] +``` + +### Header + +- Root: current Media Library title, secondary **Add new folder**, primary **Upload Files**. +- Folder: **Back**, Kumo `Breadcrumbs` with linked **Media Library** and current folder text, no create-folder or upload action. +- On narrow screens, actions become full-width and stack below the title. Back stays before the title in reading order. +- Folder management actions render only for editor-level users. Folder browsing renders for every existing Media page reader. + +### Folder grid and list + +- Render folders only for the local provider, asset page 1, and no MIME filter. Outside the root, render them only while a filename search is active, matching Strapi's library-wide search result mode. +- Send the filename term to the bounded folder list API and make asset search library-wide, matching Strapi. A visible **Load more folders** remains when later matching folder pages exist. +- Grid order: localized **Folders** heading, 4/3/2/1 folder columns, divider when media also exists, then the unchanged asset grid. +- List order: folder rows before asset rows. Folder rows contain icon, linked name, and labeled edit action; MIME, size, and date cells use an em dash and accessible context rather than fake values. +- Do not display a numeric folder total because the API does not return one. + +Use Kumo `LayerCard`, `Button`, `Link` or router composition, `Breadcrumbs`, `Dialog`, `Input`, `Combobox`, `Loader`, and Toast APIs. One folder card exposes one navigation link and one edit button; do not nest interactive controls. The edit button stays reachable on touch and keyboard, even if its visual emphasis is reduced until hover/focus on pointer devices. + +## URL and navigation state + +Add `folder?: string` to the TanStack media route search schema. + +- Absence means Main library. +- A non-empty string of at most 64 characters means one folder ID. +- Folder-card navigation pushes history, clears the filename search, resets page to 1, clears retained totals, closes Media Details, and preserves page size, type filter, view mode, and provider state. +- Back pushes the root state, preserves the current search and type filter like stable Strapi, and resets page to 1. +- A stale or deleted folder replaces the URL with root and shows one localized informational toast. It must not add a broken history entry or retry forever. +- Folder navigation must not scroll the document to the top. Existing item/paginator focus preservation remains; when the triggering folder or media disappears, focus falls back to the Media Library heading with `preventScroll` where supported. + +Search remains component state in PR2. Browser history restores folder selection but does not attempt to reconstruct an earlier search term. Moving all Media page query state into the URL is out of scope. + +## Minimal server additions + +Add authenticated `GET /_emdash/api/media/folders/:id` using the existing repository `findById`. + +- Permission: `media:read`; bearer scope remains `media:read` through the existing media-prefix rule. +- Response: `{ item: MediaFolder }`. +- Unknown or invalid ID: `NOT_FOUND` or `VALIDATION_ERROR` through existing schemas/status mapping. +- Add the GET operation to OpenAPI and the typed core client. +- No migration, runtime method, new permission, new token scope, count, or new repository query is required. + +This separate current-folder read matches stable Strapi, makes direct URLs reliable, and avoids fetching every folder page merely to resolve one name. + +Add optional `q` to `GET /_emdash/api/media/folders`. + +- Trim and cap it at 200 UTF-16 code units, matching media filename search. +- Normalize it with the same NFKC-plus-lowercase rule as `name_key` and escape SQL `LIKE` wildcards. +- Match a substring of `name_key` before cursor pagination. +- Preserve the existing 1–100 limit, `name_key ASC, id ASC` cursor order, and omitted-query behavior. +- Add the query to schemas, OpenAPI, typed clients, and SQLite/PostgreSQL repository tests. + +This is the smallest way to reproduce Strapi's library-wide folder search without automatically draining every folder cursor or returning incomplete client-filtered results. + +## Admin data flow + +### Admin client contracts + +Keep the shared admin `MediaItem` compatible with provider and external-URL picker items. Add a `LocalMediaItem extends MediaItem` subtype with required `folderId: string | null`, `authorId: string | null`, and local storage fields. Local media list/get/upload/update calls return `LocalMediaItem`; provider conversion and picker-created external items remain `MediaItem` and do not gain fake local fields. + +Add `MediaFolder`, its cursor-list response, `folderId?: string | null` on local `fetchMediaList`, and `folderId?: string | null` on the existing media update input. Type the main local Media page and its detail path with `LocalMediaItem`, while shared picker/provider component boundaries retain the wider `MediaItem` type. + +Add admin functions for bounded folder list/search, current-folder get, create, rename, and delete. Encode every path ID with `encodeURIComponent`, use `apiFetch`, and parse the normal `{ success, data }` envelope. + +Add an `ApiResponseError extends Error` in the shared admin client with additive `status`, `code`, and `details` fields. `throwResponseError` keeps its current localized message behavior but throws this subtype. Existing callers remain compatible because it is still an `Error`; the folder dialog uses `VALIDATION_ERROR` and `CONFLICT` to place field errors under Name and sends unclassified failures to `DialogError`. + +### Folder queries + +Use two query families: + +- `['media-folders', 'page', { search }]`: Media page `useInfiniteQuery`, `limit: 100`, optional normalized `q`, and cursor from `nextCursor`; flatten only fetched pages. +- `['media-folders', 'location', { search }]`: independent Location `useInfiniteQuery` with the same request contract but its own pages. +- `['media-folder', folderId]`: one-folder GET, enabled for a named route folder or a selected local media item's non-null folder. + +Enable the page folder-list query only at the root or while filename search is active, and only when the local provider and asset page 1 are active with no MIME filter. A named folder with no search does not fetch the 100-row folder list. The folder grid calls the list query with the Media page's debounced filename search. + +The Location Combobox owns a separate debounced `locationSearch` and enables its folder-list query only after the Combobox opens for a movable local item. It starts with an empty term. Opening Media Details may fetch only the selected item's one current folder so the closed control has an accurate label; it must not fetch the 100-row Location list until the user opens the control. A Media page search must never restrict the locations offered in Media Details. + +Folder-grid and Location-combobox **Load more folders** actions fetch one bounded page at a time from their separate query keys. Identical search strings must not share pages between the two surfaces. Folder mutations invalidate the shared `['media-folders']` prefix. Do not automatically drain every cursor. + +If a selected media item's current folder is not in the loaded Location list, fetch that item's folder through the same `['media-folder', id]` query family and inject it into the Combobox options without duplicating it. The route folder and selected item's folder may be different during library-wide search. + +### Media query + +Extend the existing key to: + +```ts +['media', { search, mime: mimeKey, folder: folderId ?? 'main', page, perPage }] +``` + +The media request uses: + +- `folderId: null` at the root; +- the current folder ID inside a folder; and +- omitted `folderId` while filename search is non-empty, reproducing Strapi's library-wide search. + +Search takes precedence over folder scope exactly as in stable Strapi. With search plus MIME, both filters apply library-wide and folders stay hidden. With MIME and no search, media remains folder-scoped and folders stay hidden. Folder or filter changes reset the asset page and retained total before the next query. Keep `placeholderData: keepPreviousData`, page recovery, inert loading content, pagination focus, and stable layout. + +### Mutations and invalidation + +| Action | Request | Success behavior | +| --- | --- | --- | +| Create folder | `POST /media/folders` | Invalidate `['media-folders']`, go to page 1, toast success, close, restore trigger focus | +| Rename folder | `PUT /media/folders/:id` | Invalidate folder list and current folder, update breadcrumb/card, toast success, close, restore edit focus | +| Delete folder | `DELETE /media/folders/:id` | Invalidate folders, media, and `['media-folder', deletedId]`; if current, replace URL with root; toast success; focus heading | +| Move media | existing `PUT /media/:id` with `folderId` | Invalidate media; close detail; focus original card or heading if it left the result set | + +Do not optimistically remove folder or media rows. Server foreign keys and permission checks determine the committed state; invalidation reads it back. + +## Folder create/edit dialog + +Use one focused `MediaFolderDialog` for create and edit. + +Create: + +- title **Add new folder**; +- one **Name** input, autofocus on open; +- **Cancel** and **Create**; +- Enter submits from the Name input. + +Edit: + +- title **Edit folder**; +- prefilled **Name** input; +- **Cancel**, destructive-light **Delete folder**, and primary **Save**; +- no Location, creation date, child count, or asset count. + +Behavior: + +- Trim and validate 1–200 UTF-16 code units before mutation, while the repository remains authoritative. +- Duplicate and validation messages render inline through the Kumo `Input` error surface. Unexpected server errors use `DialogError`. +- Keep the dialog open on error. Disable close-by-submit and all conflicting actions while pending. Ignore duplicate submit attempts. +- Success closes, toasts, and restores focus. +- Delete opens the existing `ConfirmDialog` with explicit localized copy: **Delete “{name}”? Media in this folder will return to Main library. No files will be deleted.** +- Canceling delete leaves the edit dialog open and restores focus to **Delete folder**. + +## Media Details Location field + +Add a local-only Kumo `Combobox` labeled **Location** for every local MIME type. + +Options are synthetic **Main library** (`null`) followed by loaded folders in name order. The dropdown contains **Load more folders** while a cursor remains. The current folder fetched by ID is present even if its list page has not loaded. + +Permission: + +- editors can move any local media; +- authors can move media whose `authorId` matches the current user; +- other users see the current location as read-only text; +- provider items receive no folder field or requests. + +Folder and image metadata changes submit in one existing `updateMedia` call. A folder-only change works for images, videos, audio, and documents. The dirty-state and discard confirmation include Location. The server preserves atomicity when a folder disappears during save; the dialog shows the server error and remains open. + +## Upload behavior + +At the Main library root, uploads and whole-page file drop behave exactly as they do now. New and deduplicated media retain the PR1 upload behavior. + +Inside a folder on the local Library tab: + +- hide **Upload Files** and disable the page-level drop overlay; +- the empty state says **This folder is empty** and explains that media can be moved here from Media Details; +- provide **Back to Main library**, not an upload action. + +Do not upload then issue a second assignment request. That fails badly when a duplicate upload returns an asset already assigned elsewhere and creates a retry state where the bytes succeeded but placement failed. + +External-provider upload controls remain unchanged even when the URL retains a local folder selection. + +## Loading, empty, error, and race states + +- Initial root load: render the existing Media Library shell; folder section gets an inline loader while media can render independently. +- Folder-list error: keep media usable, show a localized inline folder error with **Retry**. +- Current-folder load: breadcrumb current item uses Kumo loading state; retain the prior media shape until the folder read resolves. +- Missing/deleted direct folder: replace to root once and toast. +- Root with folders but no media: show folders and no whole-library empty state. +- Root with neither: retain the existing upload empty state. +- Named folder with no results and no active search/filter: folder-empty state and Back action. +- Search/type no results: retain **No matching media** and clear controls. +- Delete races with move: server returns `NOT_FOUND`; keep the detail dialog open and refetch folder/media state. +- If the selected item's one-folder read returns `NOT_FOUND`, fetch that media item by ID and replace `MediaLibrary`'s open `detailItem` through an explicit `onItemRefreshed(LocalMediaItem)` callback. Also invalidate the media-list prefix. Keep Location in a loading state until the refreshed item arrives; its `folderId: null` disables the deleted-folder query and renders **Main library** without treating the whole dialog as failed. Refetching only the list cache is insufficient because the current component stores `detailItem` independently. +- Rename/delete conflict: mutation error remains in its dialog; no optimistic URL or breadcrumb change. +- Provider tab active: folder queries may stay cached, but folder UI, current-folder empty states, and folder mutations are hidden. Returning to Library restores the URL-selected folder. + +The local toolbar remains visible when folders exist even if the current media count is zero, so editors can search folder names, change view, and manage folders. + +## Authorization, privacy, and direct access + +- Browsing and current-folder reads use existing `media:read`. +- Create, rename, and delete controls require editor level and the server's `media:edit_any` check remains authoritative. +- Single-media Location honors `media:edit_own`/`media:edit_any` ownership rules. +- All writes use `apiFetch`, retaining CSRF protection. +- Folder names are shared media metadata; no user-private folder state is introduced. +- Direct URL access cannot reveal folder data without `media:read`. + +## Accessibility, localization, RTL, and responsive behavior + +- Every visible string, toast, error, empty state, title, label, and aria label uses Lingui. +- Do not commit `messages.po` changes. +- Use logical Tailwind classes only. Back chevrons use `rtl:-scale-x-100` or a bidi-aware icon. +- Kumo Breadcrumbs has a navigation label. Current folder is text, root is a router-aware link. +- Folder cards have one descriptive link and one **Edit folder** button; no nested anchors/buttons or click-only containers. +- Dialog focus is trapped by Kumo. Create autofocuses Name. Close restores the invoking control. Delete confirmation returns focus to Delete on cancel. +- Pending folder/media content uses `aria-busy` and inert controls consistently with the current library. Folder load completion and result changes receive a polite announcement. +- Folder cards use 4/3/2/1 columns. At 320 CSS pixels, no horizontal overflow is allowed; header actions, toolbar, Location field, dialogs, and pagination remain reachable. +- Verify Arabic direction with Back, breadcrumbs, folder cards, dialog footer, Combobox, list rows, and paginator together. +- Respect reduced motion; do not add custom folder enter/exit animation. + +## Compatibility and cost + +- Existing media requests that omit `folderId`, media picker flows, providers, plugins, CLI, and MCP remain All media. +- The main Media page changes its root request from omitted `folderId` to `folderId=unfiled`; all existing media is initially unfiled, so the visible result is unchanged until users assign folders. +- Named-folder navigation adds one current-folder query. The bounded folder-list query runs only at root, during global filename search, or while the Location Combobox is open. +- Each folder-list request returns at most 100 rows. Additional work occurs only after explicit **Load more folders**. +- No folder count, N+1 media count, automatic cursor drain, logged-out query, storage operation, or usage reindex is added. +- PR2 adds an additive core GET route and admin UI. It requires an `emdash` minor and `@emdash-cms/admin` minor changeset unless maintainers coordinate the two stacked PRs into one release entry. + +## Test plan + +### Core and admin API + +- Current-folder GET returns the folder, requires `media:read`, validates IDs, returns 404 after deletion, appears in OpenAPI, and encodes typed-client IDs. +- Admin folder list pagination preserves cursors, serializes independent bounded page and Location searches, maps Main library to `unfiled`, encodes path IDs, sends exact bodies, and surfaces server messages. +- No picker/provider/upload request gains `folderId`. + +### Router and data state + +- Root, named folder, global search, MIME filter, page reset, page-size preservation, and query keys map to the documented API options. +- Folder-card navigation clears search; Back preserves it. +- Direct missing folder replaces root once. +- Re-entering a just-deleted folder through browser Forward or a direct URL cannot render its cached name and recovers to root once. +- Folder create, rename, delete, and media move invalidate only the required query prefixes. +- Equal Media-page and Location search terms keep independent cursor pages; loading more in one surface does not extend the other. +- Existing keep-previous-data, page recovery, paginator focus, scroll position, and no-layout-shift tests remain green. + +### Components + +- Grid and list render folders before media on page 1. They hide for MIME filters, later asset pages, providers, and named folder pages without search; a searched named-folder page renders global matching folders before global matching media. +- Folder card link/edit semantics, permission visibility, Unicode names, long-name truncation, touch access, and focus restoration work. +- Create/edit dialog covers autofocus, Enter, trim/length validation, duplicate inline error, unknown error, loading, duplicate submit, rename, delete cancel, safe-delete copy, success toast, and focus return. +- Location covers Main library, independent search and pages, named folder, load-more, current option injection, ownership, every local MIME family, provider exclusion, combined metadata/folder save, concurrent selected-folder deletion, replacement of the open detail item, stale folder error, discard confirmation, and disappearing-card focus. +- Tests assert behavior and accessible names, not Tailwind class literals. + +### Browser and visual verification + +Add a main Media Library E2E flow: + +1. Create two folders. +2. Move an existing media item into one folder. +3. Enter the folder, reload, and use browser Back. +4. Search globally, then open a folder and confirm search clears. +5. Rename the current folder. +6. Delete it and confirm the media remains reachable in Main library with the same ID and URL. +7. Confirm authors can move their own media but cannot manage folders. + +Run accessibility scans on the populated root, folder page, create/edit/delete dialogs, and open Location Combobox. Add 320-pixel mobile interaction coverage and update the main Media Library visual baseline in English and Arabic after maintainer review. + +## Expected files and line budget + +Expected production files: + +- `packages/core/src/api/handlers/media-folders.ts` +- `packages/core/src/api/openapi/document.ts` +- `packages/core/src/api/schemas/media.ts` +- `packages/core/src/database/repositories/media-folders.ts` +- `packages/core/src/astro/routes/api/media/folders/index.ts` +- `packages/core/src/astro/routes/api/media/folders/[id].ts` +- `packages/core/src/client/index.ts` +- `packages/admin/src/lib/api/media.ts` +- `packages/admin/src/lib/api/client.ts` +- `packages/admin/src/lib/api/index.ts` +- `packages/admin/src/router.tsx` +- `packages/admin/src/components/MediaLibrary.tsx` +- `packages/admin/src/components/MediaFolderDialog.tsx` +- `packages/admin/src/components/MediaDetailPanel.tsx` +- narrow test helpers or one folder-card component only if the main component becomes harder to read + +Expected tests: + +- focused core route/OpenAPI/client tests; +- admin media API tests; +- `MediaLibrary`, folder dialog, Media Details, and router tests; +- main media E2E and accessibility coverage; +- English/Arabic visual baselines when accepted by a maintainer. + +Expected documentation and release files: + +- `docs/src/content/docs/guides/media-library.mdx` +- one changeset for `emdash` and `@emdash-cms/admin` +- no locale catalogs + +Projected size: + +| Area | Production lines | Test lines | Docs/changeset lines | +| --- | ---: | ---: | ---: | +| Current-folder GET, folder search, and clients | 115–165 | 120–180 | 0 | +| Router state and folder queries/mutations | 130–190 | 110–170 | 0 | +| Folder header/grid/list/dialog UI | 240–330 | 200–300 | 0 | +| Media Details Location | 90–130 | 100–160 | 0 | +| E2E, accessibility, docs, release | 0 | 80–140 | 15–30 | +| Total | 575–815 | 610–950 | 15–30 | + +Treat 815 production lines as a warning threshold. Stop for scope review above 875. Block implementation above 975 production lines, or if it adds nesting, counts, automatic all-folder loading, bulk mutation, drag/drop, upload placement, picker/provider behavior, or a folder navigation framework. + +## Implementation sequence + +Every commit follows: + +`plan → meaningful failing tests → implementation → adversarial review → patch → re-review → checks → scope audit → local commit` + +### Commit 1: Add direct folder reads and admin data orchestration + +Responsibility: Add current-folder GET and bounded folder-search contracts, admin folder API functions/types, folder URL state, bounded folder queries, and folder-aware media query mapping. + +Acceptance criteria: + +- Direct folder URLs resolve one folder or recover to root. +- Root, folder, global search, and MIME filters send the documented media request. +- Folder query state exposes `hasNextPage` and `fetchNextPage` for the visible load-more UI added in Commit 2. +- Existing pagination, upload, picker, provider, and cache behavior remains unchanged. + +Expected size: 230–320 production lines and 210–320 test lines. + +Explicit exclusions: no folder cards, dialogs, Location control, upload placement, nesting, or bulk behavior. + +### Commit 2: Add Strapi-style folder browsing and management + +Responsibility: Add Back/breadcrumb/header actions, folder-first grid/list rendering, create/edit/delete dialogs, permissions, focus, and empty/error states. + +Acceptance criteria: + +- Stable Strapi's compatible labels, action placement, ordering, dialog footer, and responsive card progression are reproduced. +- Folder grid and list expose **Load more folders** only when their bounded query has another page. +- Safe-delete wording accurately describes Main library behavior. +- Folder controls stay local-only, localized, RTL-safe, keyboard accessible, and editor-gated. +- Root/folder navigation and folder mutations compose with numbered pagination without refresh feel or layout jumps. + +Expected size: 250–350 production lines and 230–350 test lines. + +Explicit exclusions: no counts, nested Location tree, folder selection, bulk actions, drag/drop, upload placement, picker, or providers. + +### Commit 3: Add single-media Location and release documentation + +Responsibility: Add ownership-aware Location editing for local media, complete browser/accessibility/RTL coverage, update the guide, and add the changeset. + +Acceptance criteria: + +- Authors can move their own local media; editors can move any local media. +- Folder assignment composes atomically with image metadata and works for non-images. +- The Location Combobox starts its independent bounded query only when opened and exposes **Load more folders** only when another page exists. +- Moving an item out of the current view closes cleanly and restores focus. +- Main library, stale-folder, provider, pending, and discard states are correct. +- Docs describe only shipped folder workflows and state that uploads enter Main library. + +Expected size: 100–160 production lines, 150–260 test lines, and 15–30 docs/changeset lines. + +Explicit exclusions: no upload placement, bulk move, folder tree, picker, CLI, MCP, or media/storage schema changes. + +## Acceptance criteria + +PR2 is complete when a reader can browse Main library folders, an editor can create/rename/delete a folder, an authorized user can move one local media item through Media Details, direct folder URLs and Back work, and safe deletion returns media to Main library. The UI matches stable Strapi's compatible folder workflow without adopting hierarchy or destructive semantics. Pagination remains stable, providers and pickers remain unchanged, and all UI is localized, RTL-safe, responsive, and accessible. + +All affected unit, browser, E2E, OpenAPI, typecheck, lint, formatting, build, docs, and changeset checks must pass. PostgreSQL is relevant to the additive core folder-read and folder-search changes and may be reported unavailable locally when `EMDASH_TEST_PG` is unset. + +## Decisions to approve + +This specification recommends: + +1. The admin root becomes Main library, matching Strapi; the API's All media mode remains available to existing callers but is not exposed in PR2 UI. +2. PR2 adds `GET /media/folders/:id` for direct URLs and `q` on the bounded folder list for complete search, instead of automatically loading every folder page. +3. Search is library-wide like stable Strapi. With search plus MIME, both filters are library-wide; MIME without search remains folder-scoped. Any MIME filter hides folder cards. +4. Upload controls are available only at the Main library root; upload-to-folder waits for a deduplication placement rule. +5. Folder pages use explicit bounded **Load more folders** rather than hidden or unbounded folder loading. +6. Stable Strapi's hierarchy, counts, bulk operations, drag/drop, and destructive delete are deliberate non-goals. +7. Implementation remains blocked until maintainers confirm Discussion coverage for the folder feature. diff --git a/docs/technical-specs/media-library-folders-visual-polish.md b/docs/technical-specs/media-library-folders-visual-polish.md new file mode 100644 index 0000000000..c87582bda8 --- /dev/null +++ b/docs/technical-specs/media-library-folders-visual-polish.md @@ -0,0 +1,431 @@ +# Media Library folder visual polish + +Status: Approved for local implementation by the current thread +Dependency: `feat/media-folders-ui` commit `c2d451f5`, stacked on `feat/media-folders-api` commit `b5b28210` +Intended position: focused follow-up commits on PR2 before it is pushed or opened +Reference: Strapi Upload stable Media Library at `e8b156d3a629`; exclude `src/future` + +## Authority + +This document defines a visual-polish follow-up to the implemented flat-folder feature. It does not +authorize source edits, commits, pushes, pull requests, merges, releases, or deployments by itself. +The current `$feat-implement` invocation separately authorizes the local source changes and commits +listed here. It does not authorize GitHub mutation. + +The folder-specific maintainer Discussion gate recorded in the PR2 specification still applies to a +ready-for-review pull request. + +## Purpose + +Bring the implemented Media Library folder UI to the compact, predictable baseline of Strapi's +stable Media Library while retaining EmDash's Kumo components, flat-folder model, bounded queries, +safe deletion, and explicit link semantics. + +After this follow-up: + +- folder cards read as lightweight navigation rows rather than stacked content cards; +- root actions, media cards, and folder dialogs have deliberate narrow-screen geometry; +- Back and breadcrumbs use consistent navigation semantics and typography; +- folder names remain identifiable across long strings and mixed LTR/RTL content; +- list view integrates folder state into the table instead of leaving a detached folder surface; and +- every commit has reproducible interaction checks plus reviewed screenshots before the next commit + begins. + +## Scope + +### Included + +- Compact horizontal folder cards using Kumo `LayerCard`, a router-aware link, and a separate edit + button. +- Strapi-style 4/3/2/1 folder progression using the existing responsive grid. +- Router-link Back behavior and compact, single-scale Kumo breadcrumbs. +- Direction-aware rendering for user-provided folder names. +- Equal mobile root action widths and concise local upload wording. +- Deliberate mobile create/edit footer stacking. +- Media cards filling sparse one-column mobile tracks without changing desktop column calculation. +- List-view folder loading, error, load-more, navigation, and edit controls within the mixed table. +- Behavioral and geometry checks at desktop, mobile, LTR, RTL, dark, and light states. + +### Excluded + +- Folder counts, asset counts, subtitles, nesting, parent selection, folder moves, bulk selection, + bulk actions, drag and drop, or upload placement. +- Changes to media queries, folder queries, permissions, routes, schemas, storage, usage, or database + state. +- Changes to Media Picker, Portable Text, providers, CLI, MCP, plugins, or imports. +- Reworking numbered pagination, search semantics, Location data flow, upload queue behavior, or + Media Details layout. +- Strapi's row-click-only navigation, recursive folder deletion, unbounded folder fetch, eagerly + loaded Location tree, or mixed-direction omissions. +- General shell, sidebar, typography, media hover animation, or unrelated table redesign. +- Committed image snapshots before maintainers accept environment-specific visual baselines. + +## Verified current state + +### Branch and runtime + +The authoritative implementation is `/private/tmp/emdash-media-folders-ui` at `c2d451f5`. The +browser audit ran an isolated fixture on port 4554. The server process cwd and the rendered footer +both resolved to this worktree and commit after rebuilding ignored package artifacts. + +The audit created two folders and 40 local images, then exercised: + +- page sizes and numbered page 1 to page 2 with a forced 1.2-second response delay; +- grid and list views; +- global filename/folder search; +- folder open, Back, browser history, rename, safe delete, and media return to Main library; +- Media Details with closed and open Location controls; +- create, edit, and delete confirmation dialogs; +- 1512×982 and 320×800 viewports; +- dark, light, English LTR, and Arabic RTL rendering. + +No page error or horizontal document overflow occurred. Search/filter and media-card gaps both +resolve to 12px. At 1512px the media grid resolves to seven 161.7px tracks and the folder grid to +four 292px tracks. Pending numbered pagination retains the previous grid and paginator bounding +boxes while making the grid inert. + +### Confirmed visual gaps + +1. Folder cards are two-tier 126px cards. Strapi's stable card is one compact horizontal row. +2. At 320px, **Add new folder** fills 274px while **Upload to Library** stays approximately 161px. +3. A 272px one-column mobile media track contains a 200px card, leaving 72px unused. +4. The edit-folder footer wraps as Cancel on one row and Delete/Save on another by accident. +5. Back is a button even though it changes the URL; the breadcrumb root/current items use different + type scales. +6. Long Latin folder names under Arabic truncate from the identifying prefix because the text + inherits RTL direction. +7. List view renders a detached **Folders** heading and load-more surface above a table whose folder + rows already appear first; the edit button floats at the far edge of the wide Filename cell. + +### Behavior that already passes + +- Folder names are real links with a separate labeled edit button. +- Opening a search-result folder clears search. +- Root, named-folder, global search, MIME filters, and bounded folder pages map to the approved API + behavior. +- Pending numbered pagination keeps its rendered geometry and does not reset document scroll. +- Create, rename, safe delete, and single-media Location updates persist. +- Deleting a folder preserves the media URL and returns the item to Main library. +- Dialog focus, save races, stale-folder recovery, permissions, and provider boundaries are covered + by the existing PR2 tests. + +## Strapi baseline to retain + +Stable Strapi provides the reference hierarchy, not a component-by-component copy. + +### Header + +- Back precedes the title only inside a folder. +- The title stays **Media Library** and breadcrumbs sit below it. +- Add-folder and add-assets actions form one group with an 8px gap and equal full widths when + stacked. +- The page header moves actions below the title on narrow screens. + +Source: `packages/core/upload/admin/src/pages/App/components/Header.tsx:54-112` and +`packages/core/admin/admin/src/components/Layouts/HeaderLayout.tsx:128-210` in Strapi. + +### Folder card and grid + +- One horizontal card row contains the icon, linked name/body, and trailing edit action. +- The card uses compact padding and a single surface. +- The grid progresses 4/3/2/1 columns and cards stretch to their tracks. +- Folders precede media; a quiet divider separates the two groups. + +Source: Strapi `FolderCard.tsx:30-115`, `FolderGridList.tsx:10-22`, and stable +`MediaLibrary.tsx:385-499`. + +### List and dialog + +- List view is one mixed table with folders first and explicit navigation/edit actions. +- Create/edit is one dialog. Cancel leads; Delete and Save remain one related group. +- Enter submits from the name field. + +Source: Strapi `TableList/TableRows.tsx:45-149` and `EditFolderDialog.tsx:183-329`. + +## Deliberate EmDash differences + +- Do not add Strapi's count subtitle; the flat API intentionally has no count query. +- Keep the edit button visible enough for touch. Pointer hover may increase emphasis, but it must not + be the only way to discover the action. +- Keep explicit folder-name link semantics. Do not make the entire card or table row an unlabeled + click target. +- Keep **Back to Main library** in the empty-folder call to action. Use concise **Back** only for the + header navigation link. +- Keep root-only uploads and use **Upload Files** for the local provider. External-provider upload + labels remain provider-specific. +- Keep bounded **Load more folders**, safe-delete copy, lazy Location search, logical Tailwind + classes, and mirrored directional icons. +- Improve on Strapi by applying `dir="auto"` to user folder names. + +## Visual and interaction contract + +### Compact folder cards + +Render each grid folder as one Kumo `LayerCard` surface containing one row: + +1. A router-aware link occupies the icon and name region and grows to available width. +2. The folder icon remains in the existing 40px semantic Kumo tint chip. +3. The folder name is semibold, single-line, truncated, and `dir="auto"`. +4. A separate Kumo square ghost edit button trails the link. + +The card has one border/ring, one radius, 12px internal padding, and 12px between primary groups. +The edit icon can use lower resting opacity on hover-capable pointers but remains visible on touch +and when the card contains focus. No custom motion is added. + +Use the existing grid gap. Change the final four-column threshold from `xl` to `lg`; keep the +standard `sm` and `md` breakpoints. At 1512×982 and a collapsed or expanded sidebar, four cards fit. +At 320×800, cards use one full-width track. + +### Header and breadcrumbs + +- Remove the existing top-level Back button and render `RouterLinkButton` before the title inside the + folder header. Its route search removes only `search.folder`; preserve the filename search, MIME + filter, provider, view mode, and page size. Reset only the asset page and retained total. Keep + `resetScroll: false`. Intercept only an unmodified primary click to run the existing focus/page + reset callback; preserve modified-click and context-menu link behavior. +- Label the header link **Back**. Retain the mirrored arrow. +- Use Kumo `Breadcrumbs size="sm"` and a 14px router-aware root crumb so root and current folder share + one optical scale and line height. +- Wrap the current folder name in `dir="auto"`. + +### Narrow root actions and media cards + +- Root header actions stack in one full-width column below `sm`. +- Both action buttons fill that column and share height, radius, and leading/trailing edges. +- The local primary action reads **Upload Files**. External-provider wording is unchanged. +- Local media cards fill the available grid track below `sm`. Existing desktop auto-fill tracks, + seven-column MacBook layout, aspect ratio, provider cards, and 200px cap remain unchanged. + +### Folder dialog footer + +Remove the nonessential create/edit description paragraph. Kumo `Dialog.Title`, Name, and the action +labels provide the complete task context. + +Below `sm`, render actions as a deliberate full-width vertical stack in DOM order: + +1. Cancel +2. Delete folder when editing +3. Save or Create + +At `sm` and above, preserve Strapi's hierarchy: Cancel at the start, Delete and Save/Create grouped +at the end with an 8px gap. Pending states disable every conflicting action. Confirmation dialog +copy and focus behavior do not change. + +### Mixed-direction names + +Apply `dir="auto"` to the text node that renders a user folder name in: + +- grid cards; +- list rows; +- current breadcrumb; +- Location selected value; and +- Location options; and +- read-only Location text for users without move permission. + +Layout direction remains inherited from the admin locale. Only the user-provided string chooses its +own inline direction. Accessible labels retain the complete folder name. + +### List view + +List mode remains one table with folder rows before media. + +- Do not render a standalone **Folders** heading or divider above the table. +- Once the media table shell exists, order list folder states as: a full-span folder loader or + error/retry row; loaded folder rows; a full-span post-row load-more error/retry when a later page + fails; then the full-span **Load more folders** row while another cursor remains. Existing media + rows follow these folder rows and states. The existing whole-media initial loader remains unchanged + while the media request itself has no renderable items. +- Keep the folder name link and edit button in the Filename cell, but group them with `justify-start` + and a compact gap so the edit action does not float hundreds of pixels away. +- Preserve the current five-column media table and accessible em-dash context. Do not add a general + media actions column in this follow-up. + +## Responsive state matrix + +Every implementation commit must be checked at these states before review: + +| Viewport/state | Required evidence | +| --- | --- | +| 1512×982 root grid, dark and light | Header grouping, at least four fixture folders proving four tracks, seven media tracks, 12px gaps, no overflow | +| 1512×982 root list | One mixed table, folder-first order, aligned edit action, no detached folder surface | +| 1512×982 named folder | Back/link/breadcrumb hierarchy, sparse media, paginator alignment | +| 320×800 root grid | Equal full-width actions, full-track cards, reachable toolbar/paginator | +| 320×800 create/edit/delete | Deliberate action stack, visible Name/error, no clipped buttons | +| 320×800 Media Details/Location | Existing reachable control and popup geometry remain unchanged | +| 320×800 Arabic root/folder | Logical ordering, mirrored Back/pagination, prefix-preserving Latin folder names | +| Delayed page 1 → 2 request | Before/pending grid and paginator boxes change by at most 1px; content is inert | + +Screenshots are generated into a temporary audit directory for human review after each commit. Do +not commit environment-specific baselines in this sequence. Geometry assertions and interaction +tests are committed and determine pass/fail; screenshots are supplementary evidence. + +## Test plan + +### Component behavior + +- Folder cards expose one link whose accessible name contains the complete folder name and one edit + button. +- The Back control has link semantics and invokes the reset/focus callback only for unmodified + primary activation. +- Mobile action and dialog behavior is tested through rendered geometry, not Tailwind class-string + assertions. +- Grid/list/current/Location name surfaces preserve the full accessible name and carry `dir="auto"`. +- List mode does not render the grid-only Folders heading; folder loading, retry, and load-more remain + reachable in the table. +- Existing folder permission, empty, error, focus, and mutation tests remain green. + +### Browser interaction and geometry + +Extend the existing Media Library Playwright flow with a bounded reusable fixture: + +- create at least four folders and enough unique media to produce a second numbered page; +- verify action widths differ by at most 1px at 320px; +- verify the local media-card width equals its one-column grid track within 1px at 320px; +- verify each mobile folder-dialog action occupies its own row, shares the available inner width + within 1px, and preserves Cancel → Delete → Save tab order; +- verify folder row positions prove one column at 639px, two at 640px, three at 768px, and four at + 1024px; repeat just below each transition at 767px and 1023px; +- verify root grid/list, named folder, folder search, create/edit/delete, and Location closed/open; +- verify rename and safe delete preserve the media URL; +- verify browser Back/Forward, direct folder URL, focus restoration, and search clearing; +- separately enter a folder, set filename search and MIME filter, activate the header **Back** link, + and assert the root URL, preserved filename/MIME/page-size/provider/view state, asset page reset, + preserved scroll position clamped only when the destination has a smaller maximum scroll, and + Media Library heading focus; +- retain a separate search-result-folder assertion proving folder navigation clears filename search; +- delay page 2 and compare before/pending geometry; +- repeat the folder/name surfaces under Arabic and assert document width equals viewport width; +- for an overflowing Latin folder name under Arabic, assert computed `direction: ltr`, + `scrollWidth > clientWidth`, and the complete accessible name retains the identifying prefix; +- verify the read-only Location value uses the same automatic direction for a non-owner local item. + +Use behavioral assertions for committed tests. Use screenshot inspection as a per-commit gate rather +than accepting snapshots automatically. + +## Expected files and line budget + +Expected production files: + +- `packages/admin/src/components/MediaLibrary.tsx` +- `packages/admin/src/components/MediaFolderDialog.tsx` +- `packages/admin/src/components/MediaDetailPanel.tsx` + +Expected tests: + +- `packages/admin/tests/components/MediaLibrary.test.tsx` +- `packages/admin/tests/components/MediaFolderDialog.test.tsx` +- `packages/admin/tests/components/MediaDetailPanel.test.tsx` +- `e2e/tests/media-library.spec.ts` +- `e2e/tests/accessibility.spec.ts` for the changed populated list table and its post-media-shell + folder loader/error/retry rows + +Expected documentation: + +- this technical specification only; +- no public guide, changeset, locale catalog, lockfile, query-count snapshot, or visual baseline. + +Projected totals: + +| Area | Production lines | Test lines | Spec lines | +| --- | ---: | ---: | ---: | +| Compact cards, Back, breadcrumbs, bidi | 40–80 | 50–100 | 0 | +| Responsive actions, cards, dialog footer | 20–45 | 40–90 | 0 | +| List composition and bounded states | 30–65 | 50–100 | 0 | +| Technical specification | 0 | 0 | 390–470 | +| Total | 90–190 | 140–290 | 390–470 | + +Treat 190 production lines as a warning threshold. Stop for scope review above 230. Block above 280 +production lines or if the work adds counts, nesting, bulk actions, drag/drop, upload placement, +provider behavior, picker behavior, new API contracts, or a general table/navigation framework. + +## Implementation sequence + +Every commit follows: + +`plan → failing behavior test → implementation → browser geometry/screenshots → Terra X-High review → patch → re-review → checks → scope audit → local commit` + +Do not start the next commit until the current commit's interaction flow and screenshots meet its +acceptance criteria. + +### Commit 1: Match compact folder navigation hierarchy + +Responsibility: Replace stacked folder cards with compact horizontal folder navigation, use real +Back-link semantics, align breadcrumb typography, advance the four-column threshold, and make folder +names direction-aware. + +Acceptance criteria: + +- Grid cards are one horizontal Kumo surface with one icon/name link and one edit button. +- The existing top Back button is removed. Back is a router-aware link before the title, labeled + **Back**; it removes only the folder route state, preserves search/filter/provider/view/page-size + state, resets the asset page, retains `resetScroll: false`, and keeps modified clicks native. +- Breadcrumb root/current items share the compact 14px scale. +- Folder names preserve their prefix in Arabic for long Latin values. +- Four folder columns render at `lg`; one full-width folder card renders at 320px. +- Existing navigation, focus, search clearing, permission, and mutation behavior is unchanged. + +Expected size: 40–80 production lines and 50–100 test lines. + +Explicit exclusions: header action sizing, media-card sizing, dialog footer, list loading/load-more, +counts, selection, and folder data flow. + +### Commit 2: Balance narrow-screen actions and sparse media + +Responsibility: Make local header actions, sparse media cards, and folder-dialog actions deliberate at +small widths. + +Acceptance criteria: + +- Local Add/Upload actions share width and edges below `sm`; desktop hierarchy remains secondary then + primary. +- Local label is **Upload Files**; external providers retain their names. +- Local cards fill one-column tracks at 320px and retain desktop grid math. Provider cards are + unchanged. +- Create/edit actions form a full-width mobile stack and the existing desktop footer grouping. +- Create/edit/delete dialogs remain keyboard accessible, focus-safe, and untranslated strings remain + routed through Lingui. + +Expected size: 20–45 production lines and 40–90 test lines. + +Explicit exclusions: folder-card anatomy, pagination, Media Details layout, upload behavior, and +provider contracts. + +### Commit 3: Integrate folder states into list view + +Responsibility: Remove the detached grid-only folder surface from list mode and put bounded folder +states where the folder rows render. + +Acceptance criteria: + +- One table renders folder rows before media with no standalone Folders heading. +- Folder edit stays adjacent to the folder link. +- After media is renderable, folder loading, retry, and explicit Load more remain accessible + full-span rows. The existing whole-media initial loader is unchanged. +- A failed later folder page renders retry after existing folder rows and before Load more. +- Grid mode retains its heading, loader/error, load-more, divider, and media ordering. +- Browser coverage delays or fails the folder request after media has rendered, proving the table + loader/error row. A later-page failure proves post-row retry ordering. +- The accessibility audit covers populated list mode plus post-media-shell folder loading and + later-page failure rows. +- No folder query, cursor, permission, provider, or pagination behavior changes. + +Expected size: 30–65 production lines and 50–100 test lines. + +Explicit exclusions: new columns, general table abstractions, whole-row clicks, media-row actions, +bulk behavior, and API changes. + +## Final acceptance criteria + +The follow-up is complete when all three commits are locally committed, independently reviewed, and +the final branch satisfies the responsive state matrix without material visual or interaction +findings. + +The final tree must retain: + +- flat folders and safe delete; +- bounded folder queries; +- root-only local uploads; +- provider and picker compatibility; +- stable numbered pagination and focus; +- Kumo controls, Lingui strings, logical RTL classes, and keyboard navigation; and +- a clean worktree with no pushed branch or GitHub mutation. diff --git a/e2e/tests/accessibility.spec.ts b/e2e/tests/accessibility.spec.ts index 0f0b81f233..ace2caba88 100644 --- a/e2e/tests/accessibility.spec.ts +++ b/e2e/tests/accessibility.spec.ts @@ -178,12 +178,120 @@ test.describe("Accessibility Audit", () => { await admin.waitForLoading(); await expect(admin.page).toHaveURL(MEDIA_URL); - const results = await new AxeBuilder({ page: admin.page }) - .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) - .disableRules(KNOWN_A11Y_EXCLUSIONS) - .analyze(); + const analyze = () => + new AxeBuilder({ page: admin.page }) + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .disableRules(KNOWN_A11Y_EXCLUSIONS) + .analyze(); + expect((await analyze()).violations).toEqual([]); + + const folderName = `Accessibility ${Date.now()}`; + await admin.page.getByRole("button", { name: "Add new folder" }).click(); + const folderDialog = admin.page.getByRole("dialog", { name: "Add new folder" }); + expect((await analyze()).violations).toEqual([]); + await folderDialog.getByLabel("Name").fill(folderName); + await folderDialog.getByRole("button", { name: "Create" }).click(); + + await admin.page.getByRole("button", { name: `Edit folder ${folderName}` }).click(); + expect((await analyze()).violations).toEqual([]); + const editDialog = admin.page.getByRole("dialog", { name: "Edit folder" }); + await editDialog.getByRole("button", { name: "Delete folder" }).click(); + expect((await analyze()).violations).toEqual([]); + await admin.page.getByRole("button", { name: "Cancel" }).last().click(); + await editDialog.getByRole("button", { name: "Cancel" }).click(); + + await admin.page.getByRole("link", { name: `Open folder ${folderName}` }).click(); + expect((await analyze()).violations).toEqual([]); + await admin.page.getByRole("button", { name: "Back to Main library" }).first().click(); + + await admin.page.locator("[data-media-grid] button").first().click(); + const mediaDetails = admin.page.getByRole("dialog", { name: "Media Details" }); + await mediaDetails.getByRole("combobox", { name: "Location" }).click(); + expect((await analyze()).violations).toEqual([]); + await admin.page.keyboard.press("Escape"); + await mediaDetails.getByRole("button", { name: "Close" }).click(); + + await admin.page.getByRole("button", { name: `Edit folder ${folderName}` }).click(); + await editDialog.getByRole("button", { name: "Delete folder" }).click(); + await admin.page + .getByRole("dialog", { name: `Delete “${folderName}”?` }) + .getByRole("button", { name: "Delete folder" }) + .click(); + }); - expect(results.violations).toEqual([]); + test("media list folder states should have no WCAG 2.x AA violations", async ({ admin }) => { + test.setTimeout(60_000); + const page = admin.page; + const folderPattern = "**/_emdash/api/media/folders?**"; + let releaseFolders: () => void = () => {}; + const folderGate = new Promise((resolve) => { + releaseFolders = resolve; + }); + await page.route(folderPattern, async (route) => { + if (route.request().method() !== "GET") return route.continue(); + await folderGate; + await route.continue(); + }); + + await admin.goToMedia(); + await expect(page.getByRole("heading", { name: "Media Library" })).toBeVisible(); + await page.getByRole("tab", { name: "List view" }).click(); + const table = page.getByRole("table"); + await expect(table.getByText("Loading folders")).toBeVisible(); + const analyze = () => + new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21aa"]) + .disableRules(KNOWN_A11Y_EXCLUSIONS) + .analyze(); + expect((await analyze()).violations).toEqual([]); + + releaseFolders(); + await expect(table.getByText("Loading folders")).not.toBeVisible(); + await page.unroute(folderPattern); + const folderName = `List accessibility ${Date.now()}`; + await page.getByRole("button", { name: "Add new folder" }).click(); + const folderDialog = page.getByRole("dialog", { name: "Add new folder" }); + await folderDialog.getByLabel("Name").fill(folderName); + await folderDialog.getByRole("button", { name: "Create" }).click(); + await expect(page.getByRole("link", { name: `Open folder ${folderName}` })).toBeVisible(); + expect((await analyze()).violations).toEqual([]); + + await page.route(folderPattern, async (route) => { + if (route.request().method() !== "GET") return route.continue(); + const url = new URL(route.request().url()); + if (url.searchParams.has("cursor")) { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ + success: false, + error: { code: "TEST_ERROR", message: "Folder list failed" }, + }), + }); + return; + } + const response = await route.fetch(); + const body = (await response.json()) as { data: { nextCursor?: string } }; + body.data.nextCursor = "forced-accessibility-page"; + await route.fulfill({ response, json: body }); + }); + await page.reload(); + const listTab = page.getByRole("tab", { name: "List view" }); + if ((await listTab.getAttribute("aria-selected")) !== "true") await listTab.click(); + await page.getByRole("button", { name: "Load more folders" }).click(); + await expect(table.getByRole("alert")).toHaveText("Folders could not be loaded."); + await expect(table.getByRole("button", { name: "Retry" })).toBeVisible(); + expect((await analyze()).violations).toEqual([]); + + await page.unroute(folderPattern); + await page.reload(); + await page.getByRole("button", { name: `Edit folder ${folderName}` }).click(); + const editDialog = page.getByRole("dialog", { name: "Edit folder" }); + await editDialog.getByRole("button", { name: "Delete folder" }).click(); + await page + .getByRole("dialog", { name: `Delete “${folderName}”?` }) + .getByRole("button", { name: "Delete folder" }) + .click(); }); test("users page should have no WCAG 2.x AA violations", async ({ admin }) => { diff --git a/e2e/tests/media-library.spec.ts b/e2e/tests/media-library.spec.ts index d9e169f37c..3d38042a90 100644 --- a/e2e/tests/media-library.spec.ts +++ b/e2e/tests/media-library.spec.ts @@ -62,6 +62,26 @@ async function uploadTestImage(page: Page) { await expect(dialog).not.toBeVisible(); } +async function createFolder(page: Page, name: string) { + await page.getByRole("button", { name: "Add new folder" }).click(); + const dialog = page.getByRole("dialog", { name: "Add new folder" }); + await dialog.getByLabel("Name").fill(name); + await dialog.getByRole("button", { name: "Create" }).click(); + await expect(dialog).not.toBeVisible(); +} + +async function expectFolderColumns(page: Page, expectedColumns: number) { + const cards = page.locator("[data-media-folder-card]"); + await expect(cards).toHaveCount(4); + const firstRowCount = await cards.evaluateAll((elements) => { + const firstTop = Math.round(elements[0]!.getBoundingClientRect().top); + return elements.filter( + (element) => Math.abs(Math.round(element.getBoundingClientRect().top) - firstTop) <= 1, + ).length; + }); + expect(firstRowCount).toBe(expectedColumns); +} + test.describe("Media Library", () => { test.beforeAll(() => { ensureTestAssets(); @@ -143,5 +163,360 @@ test.describe("Media Library", () => { await expect(page.locator("th:has-text('Type')")).toBeVisible(); await expect(page.locator("th:has-text('Size')")).toBeVisible(); }); + + test("keeps bounded folder states inside the mixed table", async ({ admin, page }) => { + test.setTimeout(60_000); + let releaseFolders: () => void = () => {}; + const folderGate = new Promise((resolve) => { + releaseFolders = resolve; + }); + const folderPattern = "**/_emdash/api/media/folders?**"; + await page.route(folderPattern, async (route) => { + if (route.request().method() !== "GET") return route.continue(); + await folderGate; + await route.continue(); + }); + + await admin.goToMedia(); + await expect(page.getByRole("heading", { name: "Media Library" })).toBeVisible(); + await page.getByRole("tab", { name: "List view" }).click(); + const table = page.getByRole("table"); + const loadingRow = table.getByRole("row").filter({ hasText: "Loading folders" }); + await expect(loadingRow).toBeVisible(); + await expect(loadingRow.locator("td")).toHaveAttribute("colspan", "5"); + + releaseFolders(); + await expect(loadingRow).not.toBeVisible(); + await page.unroute(folderPattern); + const folderName = `List folder ${Date.now()}`; + await createFolder(page, folderName); + + await page.route(folderPattern, async (route) => { + if (route.request().method() !== "GET") return route.continue(); + const url = new URL(route.request().url()); + if (url.searchParams.has("cursor")) { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ + success: false, + error: { code: "TEST_ERROR", message: "Later folder page failed" }, + }), + }); + return; + } + const response = await route.fetch(); + const body = (await response.json()) as { + data: { nextCursor?: string }; + }; + body.data.nextCursor = "forced-next-page"; + await route.fulfill({ response, json: body }); + }); + await page.reload(); + const listTab = page.getByRole("tab", { name: "List view" }); + if ((await listTab.getAttribute("aria-selected")) !== "true") await listTab.click(); + await expect(page.getByRole("heading", { name: "Folders" })).toHaveCount(0); + const folderLink = page.getByRole("link", { name: `Open folder ${folderName}` }); + const editFolder = page.getByRole("button", { name: `Edit folder ${folderName}` }); + await expect(folderLink).toBeVisible(); + const folderLinkBox = await folderLink.boundingBox(); + const editFolderBox = await editFolder.boundingBox(); + expect(folderLinkBox).not.toBeNull(); + expect(editFolderBox).not.toBeNull(); + expect(editFolderBox!.x - (folderLinkBox!.x + folderLinkBox!.width)).toBeLessThanOrEqual(8); + + await page.getByRole("button", { name: "Load more folders" }).click(); + const rows = table.locator("tbody > tr"); + await expect(table.getByRole("alert")).toHaveText("Folders could not be loaded."); + await page.setViewportSize({ width: 320, height: 800 }); + const retryBox = await table.getByRole("button", { name: "Retry" }).boundingBox(); + expect(retryBox).not.toBeNull(); + expect(retryBox!.x + retryBox!.width).toBeLessThanOrEqual(320); + const rowText = await rows.allTextContents(); + const folderIndex = rowText.findIndex((text) => text.includes(folderName)); + const errorIndex = rowText.findIndex((text) => text.includes("Folders could not be loaded.")); + const loadMoreIndex = rowText.findIndex((text) => text.includes("Load more folders")); + expect(folderIndex).toBeGreaterThanOrEqual(0); + expect(errorIndex).toBeGreaterThan(folderIndex); + expect(loadMoreIndex).toBeGreaterThan(errorIndex); + expect(loadMoreIndex).toBeLessThan(rowText.length - 1); + await page.unroute(folderPattern); + await page.reload(); + await page.getByRole("button", { name: `Edit folder ${folderName}` }).click(); + const editDialog = page.getByRole("dialog", { name: "Edit folder" }); + await editDialog.getByRole("button", { name: "Delete folder" }).click(); + const confirmDelete = page.getByRole("dialog", { name: `Delete “${folderName}”?` }); + await confirmDelete.getByRole("button", { name: "Delete folder" }).click(); + await expect(confirmDelete).not.toBeVisible(); + await expect(page.getByRole("link", { name: `Open folder ${folderName}` })).toHaveCount(0); + }); + }); + + test("matches the compact responsive folder layout and mixed-direction names", async ({ + admin, + page, + }) => { + test.setTimeout(60_000); + const longFolderName = `Campaign assets with a deliberately long folder name ${Date.now()}`; + await admin.goToMedia(); + await admin.waitForLoading(); + await createFolder(page, `Archive ${Date.now()}`); + await createFolder(page, longFolderName); + await createFolder(page, `Events ${Date.now()}`); + await createFolder(page, `Press ${Date.now()}`); + + for (const [width, columns] of [ + [639, 1], + [640, 2], + [767, 2], + [768, 3], + [1023, 3], + [1024, 4], + ] as const) { + await page.setViewportSize({ width, height: 900 }); + await expectFolderColumns(page, columns); + } + + await page.setViewportSize({ width: 1512, height: 982 }); + expect( + await page + .locator("[data-media-folder-card]") + .first() + .evaluate((element) => element.getBoundingClientRect().height), + ).toBeLessThanOrEqual(72); + + await page.setViewportSize({ width: 320, height: 800 }); + const addFolderBox = await page.getByRole("button", { name: "Add new folder" }).boundingBox(); + const uploadFilesBox = await page.getByRole("button", { name: "Upload Files" }).boundingBox(); + expect(addFolderBox).not.toBeNull(); + expect(uploadFilesBox).not.toBeNull(); + expect(Math.abs(addFolderBox!.width - uploadFilesBox!.width)).toBeLessThanOrEqual(1); + const mediaGridBox = await page.locator("[data-media-grid]").boundingBox(); + const mediaCardBox = await page.locator("[data-media-grid] > button").first().boundingBox(); + expect(mediaGridBox).not.toBeNull(); + expect(mediaCardBox).not.toBeNull(); + expect(Math.abs(mediaGridBox!.width - mediaCardBox!.width)).toBeLessThanOrEqual(1); + + await page.getByRole("button", { name: "Add new folder" }).click(); + const createDialog = page.getByRole("dialog", { name: "Add new folder" }); + const createCancelBox = await createDialog + .getByRole("button", { name: "Cancel" }) + .boundingBox(); + const createSubmitBox = await createDialog + .getByRole("button", { name: "Create" }) + .boundingBox(); + expect(createCancelBox).not.toBeNull(); + expect(createSubmitBox).not.toBeNull(); + expect(createCancelBox!.y).not.toBe(createSubmitBox!.y); + expect(Math.abs(createCancelBox!.width - createSubmitBox!.width)).toBeLessThanOrEqual(1); + await createDialog.getByRole("button", { name: "Cancel" }).click(); + + await page + .context() + .addCookies([{ name: "emdash-locale", value: "ar", domain: "localhost", path: "/_emdash" }]); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("dir", "rtl"); + const rtlSearch = page.locator('input[type="search"]'); + await rtlSearch.fill(longFolderName); + const longFolderText = page + .locator('[data-media-folder-card] [dir="auto"]') + .filter({ hasText: longFolderName }); + await expect(longFolderText).toBeVisible(); + const bidiMetrics = await longFolderText.evaluate((element) => ({ + direction: getComputedStyle(element).direction, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + text: element.textContent ?? "", + })); + expect(bidiMetrics.direction).toBe("ltr"); + expect(bidiMetrics.scrollWidth).toBeGreaterThan(bidiMetrics.clientWidth); + expect(bidiMetrics.text.startsWith("Campaign assets")).toBe(true); + + await longFolderText.locator("xpath=ancestor::a[1]").click(); + await expect(page).toHaveURL(/\/media\?folder=/); + const currentBidiName = page.locator('[aria-current="page"] [dir="auto"]').first(); + await expect(currentBidiName).toBeVisible(); + const currentBidiMetrics = await currentBidiName.evaluate((element) => ({ + direction: getComputedStyle(element).direction, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + text: element.textContent ?? "", + })); + expect(currentBidiMetrics.direction).toBe("ltr"); + expect(currentBidiMetrics.scrollWidth).toBeGreaterThan(currentBidiMetrics.clientWidth); + expect(currentBidiMetrics.text.startsWith("Campaign assets")).toBe(true); + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + + await page + .context() + .addCookies([{ name: "emdash-locale", value: "en", domain: "localhost", path: "/_emdash" }]); + }); + + test("keeps media intact while organizing it in folders", async ({ admin, page }) => { + test.setTimeout(90_000); + const folderName = `Product photos ${Date.now()}`; + const renamedFolder = `${folderName} archive`; + await admin.goToMedia(); + await admin.waitForLoading(); + await uploadTestImage(page); + await createFolder(page, folderName); + await createFolder(page, `Press ${Date.now()}`); + + const mediaGrid = page.locator("[data-media-grid]"); + const originalImage = mediaGrid.locator("img").first(); + await expect(originalImage).toBeVisible(); + const originalSrc = await originalImage.getAttribute("src"); + await mediaGrid.locator("button").first().click(); + + const details = page.getByRole("dialog", { name: "Media Details" }); + await details.getByRole("combobox", { name: "Location" }).click(); + await page.getByRole("option", { name: folderName }).click(); + await details.getByRole("button", { name: "Save" }).click(); + await expect(details).not.toBeVisible(); + await expect(page.getByRole("heading", { name: "Media Library" })).toBeFocused(); + + await page.getByRole("link", { name: `Open folder ${folderName}` }).click(); + await expect(page).toHaveURL(/\/media\?folder=/); + await expect(mediaGrid.locator("img").first()).toHaveAttribute("src", originalSrc!); + + const folderSearch = page.getByRole("searchbox", { name: "Search media" }); + await folderSearch.fill("test-image"); + const filteredMediaResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname.endsWith("/_emdash/api/media") && url.searchParams.get("mimeType") === "image/" + ); + }); + await page.getByRole("combobox", { name: "Filter by type" }).click(); + await page.getByRole("option", { name: "Images" }).click(); + await filteredMediaResponse; + await expect(page.locator("[data-media-library]")).not.toHaveAttribute("aria-busy", "true"); + await page.getByRole("tab", { name: "List view" }).click(); + await page.getByRole("combobox", { name: "Page size" }).click(); + await page.getByRole("option", { name: "70" }).click(); + const main = page.locator("main"); + const headerBack = page.getByRole("link", { name: "Back" }); + await headerBack.focus(); + const scrollFixture = await page.addStyleTag({ + content: "main { padding-bottom: 1200px !important; }", + }); + const scrollBeforeBack = await main.evaluate((element) => { + element.scrollTop = 400; + return element.scrollTop; + }); + expect(scrollBeforeBack).toBeGreaterThan(0); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/media\/?$/); + await expect(folderSearch).toHaveValue("test-image"); + await expect(page.getByRole("combobox", { name: "Filter by type" })).toContainText("Images"); + await expect(page.getByRole("combobox", { name: "Page size" })).toContainText("70"); + await expect(page.getByRole("tab", { name: "List view" })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page.getByRole("heading", { name: "Media Library" })).toBeFocused(); + const expectedScrollAfterBack = await main.evaluate((element, previousScroll) => { + return Math.min(previousScroll, element.scrollHeight - element.clientHeight); + }, scrollBeforeBack); + expect(expectedScrollAfterBack).toBeGreaterThan(0); + await expect + .poll(() => main.evaluate((element) => element.scrollTop)) + .toBe(expectedScrollAfterBack); + await scrollFixture.evaluate((element) => element.remove()); + + await folderSearch.fill(""); + await page.getByRole("combobox", { name: "Filter by type" }).click(); + await page.getByRole("option", { name: "All types" }).click(); + await page.getByRole("tab", { name: "Grid view" }).click(); + await page.getByRole("link", { name: `Open folder ${folderName}` }).click(); + await expect(page).toHaveURL(/\/media\?folder=/); + await page.setViewportSize({ width: 320, height: 800 }); + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + await page.reload(); + await expect(mediaGrid.locator("img").first()).toHaveAttribute("src", originalSrc!); + await page.route("**/_emdash/api/media/folders?**", async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1200)); + await route.continue(); + }); + const rootFoldersResponse = page.waitForResponse((response) => + new URL(response.url()).pathname.endsWith("/_emdash/api/media/folders"), + ); + const delayedBack = page.getByRole("link", { name: "Back" }); + await delayedBack.focus(); + const delayedScrollFixture = await page.addStyleTag({ + content: "main { padding-bottom: 1200px !important; }", + }); + const delayedScrollBeforeBack = await main.evaluate((element) => { + element.scrollTop = 400; + return element.scrollTop; + }); + expect(delayedScrollBeforeBack).toBeGreaterThan(0); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/media\/?$/); + await rootFoldersResponse; + const expectedDelayedScroll = await main.evaluate((element, previousScroll) => { + return Math.min(previousScroll, element.scrollHeight - element.clientHeight); + }, delayedScrollBeforeBack); + expect(expectedDelayedScroll).toBeGreaterThan(0); + await expect + .poll(() => main.evaluate((element) => element.scrollTop)) + .toBe(expectedDelayedScroll); + await delayedScrollFixture.evaluate((element) => element.remove()); + await page.unroute("**/_emdash/api/media/folders?**"); + await page.goBack(); + await expect(page).toHaveURL(/\/media\?folder=/); + await page.goForward(); + await expect(page).toHaveURL(/\/media\/?$/); + + const search = page.getByRole("searchbox", { name: "Search media" }); + await search.fill(folderName); + await page.getByRole("link", { name: `Open folder ${folderName}` }).click(); + await expect(search).toHaveValue(""); + await search.fill(folderName); + await page.getByRole("button", { name: `Edit folder ${folderName}` }).click(); + + const editDialog = page.getByRole("dialog", { name: "Edit folder" }); + const editActionBoxes = await Promise.all( + ["Cancel", "Delete folder", "Save"].map((name) => + editDialog.getByRole("button", { name }).boundingBox(), + ), + ); + expect(editActionBoxes.every((box) => box !== null)).toBe(true); + expect(new Set(editActionBoxes.map((box) => box!.y)).size).toBe(3); + expect( + Math.max(...editActionBoxes.map((box) => box!.width)) - + Math.min(...editActionBoxes.map((box) => box!.width)), + ).toBeLessThanOrEqual(1); + await editDialog.getByLabel("Name").fill(renamedFolder); + await editDialog.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText(renamedFolder).first()).toBeVisible(); + + await page + .context() + .addCookies([{ name: "emdash-locale", value: "ar", domain: "localhost", path: "/_emdash" }]); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("dir", "rtl"); + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + await page + .context() + .addCookies([{ name: "emdash-locale", value: "en", domain: "localhost", path: "/_emdash" }]); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("dir", "ltr"); + + await search.fill(renamedFolder); + await page.getByRole("button", { name: `Edit folder ${renamedFolder}` }).click(); + await editDialog.getByRole("button", { name: "Delete folder" }).click(); + const confirm = page.getByRole("dialog", { name: `Delete “${renamedFolder}”?` }); + await confirm.getByRole("button", { name: "Delete folder" }).click(); + + await expect(page).toHaveURL(/\/media\/?$/); + await page.getByRole("button", { name: "Clear search" }).click(); + await expect(mediaGrid.locator("img").first()).toHaveAttribute("src", originalSrc!); }); }); diff --git a/packages/admin/src/components/MediaDetailPanel.tsx b/packages/admin/src/components/MediaDetailPanel.tsx index 522078d07e..44a45633ea 100644 --- a/packages/admin/src/components/MediaDetailPanel.tsx +++ b/packages/admin/src/components/MediaDetailPanel.tsx @@ -5,29 +5,66 @@ * Opens when clicking an item in the MediaLibrary. */ -import { Button, ClipboardText, Dialog, Input, InputArea, Tooltip } from "@cloudflare/kumo"; +import { + Button, + ClipboardText, + Combobox, + Dialog, + Input, + InputArea, + Tooltip, + inputVariants, +} from "@cloudflare/kumo"; +import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; -import { X, Trash, Calendar, HardDrive, LinkSimple, Ruler, Info } from "@phosphor-icons/react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + X, + Trash, + Calendar, + CaretDown, + HardDrive, + LinkSimple, + Ruler, + Info, +} from "@phosphor-icons/react"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; -import { updateMedia, deleteMedia, deleteFromProvider, type MediaItem } from "../lib/api"; -import { useStableCallback } from "../lib/hooks"; +import { + ApiResponseError, + updateMedia, + deleteMedia, + deleteFromProvider, + fetchMediaFolder, + fetchMediaFolders, + fetchMediaItem, + type LocalMediaItem, + type MediaFolder, + type MediaItem, +} from "../lib/api"; +import { useDebouncedValue, useStableCallback } from "../lib/hooks"; import { getFileIcon, formatFileSize } from "../lib/media-utils"; import { ConfirmDialog } from "./ConfirmDialog"; import { DialogError, getMutationError } from "./DialogError.js"; const CLOSE_FALLBACK_MS = 500; +interface MediaLocationOption { + id: string | null; + name: string; +} + export interface MediaDetailPanelProps { open: boolean; item: MediaItem; providerName?: string; canDelete?: boolean; + canMoveLocation?: boolean; restoreFocusTargetRef?: React.RefObject; onClose: () => void; onClosed?: () => void; onUpdated?: () => void; + onItemRefreshed?: (item: LocalMediaItem) => void; onDeleted?: () => void; } @@ -39,15 +76,18 @@ export function MediaDetailPanel({ item, providerName, canDelete: canDeleteProp, + canMoveLocation: canMoveLocationProp, restoreFocusTargetRef, onClose, onClosed, onUpdated, + onItemRefreshed, onDeleted, }: MediaDetailPanelProps) { const { t } = useLingui(); const queryClient = useQueryClient(); const restoreFocusAfterDeleteRef = React.useRef(false); + const savePendingRef = React.useRef(false); const closeFallbackTimerRef = React.useRef(null); const closeFinishedRef = React.useRef(false); @@ -57,10 +97,16 @@ export function MediaDetailPanel({ const isAudio = item.mimeType.startsWith("audio/"); const canEditMetadata = !isProviderAsset && isImage; const canDelete = !isProviderAsset || Boolean(canDeleteProp); + const localItem = isLocalMediaItem(item) ? item : null; + const canMoveLocation = Boolean(localItem && canMoveLocationProp); const [filename, setFilename] = React.useState(item.filename); const [alt, setAlt] = React.useState(item.alt ?? ""); const [caption, setCaption] = React.useState(item.caption ?? ""); + const [folderId, setFolderId] = React.useState(localItem?.folderId ?? null); + const [selectedFolder, setSelectedFolder] = React.useState(null); + const [locationOpen, setLocationOpen] = React.useState(false); + const [locationSearch, setLocationSearch] = React.useState(""); const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); const [showDiscardConfirm, setShowDiscardConfirm] = React.useState(false); @@ -72,12 +118,17 @@ export function MediaDetailPanel({ } closeFinishedRef.current = false; restoreFocusAfterDeleteRef.current = false; + savePendingRef.current = false; setFilename(item.filename); setAlt(item.alt ?? ""); setCaption(item.caption ?? ""); + setFolderId(localItem?.folderId ?? null); + setSelectedFolder(null); + setLocationOpen(false); + setLocationSearch(""); setShowDeleteConfirm(false); setShowDiscardConfirm(false); - }, [item.id, open]); + }, [item.id, localItem?.folderId, open]); React.useEffect(() => { return () => { @@ -99,7 +150,7 @@ export function MediaDetailPanel({ onClosed?.(); if (shouldRestoreFocus) { window.setTimeout(() => { - restoreFocusTargetRef?.current?.focus(); + restoreFocusTargetRef?.current?.focus({ preventScroll: true }); }, 0); } }, [onClosed, restoreFocusTargetRef]); @@ -112,8 +163,11 @@ export function MediaDetailPanel({ closeFallbackTimerRef.current = window.setTimeout(finishClose, CLOSE_FALLBACK_MS); }, [finishClose, onClose]); - const hasChanges = + const metadataChanged = canEditMetadata && (alt !== (item.alt ?? "") || caption !== (item.caption ?? "")); + const locationChanged = canMoveLocation && folderId !== localItem?.folderId; + const canEdit = canEditMetadata || canMoveLocation; + const hasChanges = metadataChanged || locationChanged; const isConfirmOpen = showDeleteConfirm || showDiscardConfirm; const publicFileUrl = !isProviderAsset && item.url ? new URL(item.url, window.location.origin).href : ""; @@ -121,14 +175,135 @@ export function MediaDetailPanel({ const filenameHelpLabel = t`Why can't this be changed?`; const altTextHelp = t`Used by screen readers and when image fails to load`; const altTextHelpLabel = t`Why is this important?`; + const debouncedLocationSearch = useDebouncedValue(locationSearch, 300); + const currentFolderQuery = useQuery({ + queryKey: ["media-folder", localItem?.folderId], + queryFn: () => fetchMediaFolder(localItem!.folderId!), + enabled: open && Boolean(localItem?.folderId), + retry: (failureCount, error) => + !(error instanceof ApiResponseError && error.code === "NOT_FOUND") && failureCount < 2, + }); + const currentFolderMissing = + currentFolderQuery.error instanceof ApiResponseError && + currentFolderQuery.error.code === "NOT_FOUND"; + const locationListQuery = useInfiniteQuery({ + queryKey: ["media-folders", "location", { search: debouncedLocationSearch.trim() }], + queryFn: ({ pageParam }) => + fetchMediaFolders({ + limit: 100, + cursor: pageParam, + search: debouncedLocationSearch.trim() || undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor, + enabled: open && canMoveLocation && locationOpen, + }); + const locationFolders = React.useMemo( + () => locationListQuery.data?.pages.flatMap((page) => page.items) ?? [], + [locationListQuery.data?.pages], + ); + const mainLocation = React.useMemo( + () => ({ id: null, name: t`Main library` }), + [t], + ); + const locationOptions = React.useMemo(() => { + const foldersById = new Map(); + for (const folder of locationFolders) foldersById.set(folder.id, folder); + if (currentFolderQuery.data && !currentFolderMissing) + foldersById.set(currentFolderQuery.data.id, currentFolderQuery.data); + if (selectedFolder) foldersById.set(selectedFolder.id, selectedFolder); + return [ + mainLocation, + ...[...foldersById.values()] + .toSorted( + (left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id), + ) + .map((folder) => ({ id: folder.id, name: folder.name })), + ]; + }, [ + currentFolderMissing, + currentFolderQuery.data, + locationFolders, + mainLocation, + selectedFolder, + ]); + const selectedLocation = React.useMemo(() => { + if (folderId === null) return mainLocation; + return ( + locationOptions.find((option) => option.id === folderId) ?? { + id: folderId, + name: + currentFolderQuery.isLoading || currentFolderMissing + ? t`Loading...` + : t`Location unavailable`, + } + ); + }, [ + currentFolderMissing, + currentFolderQuery.isLoading, + folderId, + locationOptions, + mainLocation, + t, + ]); + const currentLocationName = + localItem?.folderId === null + ? mainLocation.name + : currentFolderMissing + ? t`Loading...` + : (currentFolderQuery.data?.name ?? + (currentFolderQuery.isLoading ? t`Loading...` : t`Location unavailable`)); + const recoveryPendingRef = React.useRef(false); + const recoveredFolderRef = React.useRef(null); + const recoverMediaMutation = useMutation({ + mutationFn: () => fetchMediaItem(item.id), + onSuccess: (refreshed) => { + onItemRefreshed?.(refreshed); + void queryClient.invalidateQueries({ queryKey: ["media"] }); + }, + onError: () => { + void queryClient.invalidateQueries({ queryKey: ["media"] }); + }, + onSettled: () => { + recoveryPendingRef.current = false; + }, + }); + const recoverMediaItem = useStableCallback(() => { + if (!localItem || recoveryPendingRef.current) return; + recoveryPendingRef.current = true; + recoverMediaMutation.mutate(); + }); + React.useEffect(() => { + recoveryPendingRef.current = false; + recoveredFolderRef.current = null; + recoverMediaMutation.reset(); + }, [item.id, localItem?.folderId]); + React.useEffect(() => { + if (!currentFolderMissing || !localItem?.folderId) return; + const recoveryKey = `${localItem.id}:${localItem.folderId}`; + if (recoveredFolderRef.current === recoveryKey) return; + recoveredFolderRef.current = recoveryKey; + recoverMediaItem(); + }, [currentFolderMissing, localItem?.folderId, localItem?.id, recoverMediaItem]); + React.useEffect(() => { + if (!open) recoveredFolderRef.current = null; + }, [open]); const updateMutation = useMutation({ - mutationFn: (data: { alt?: string; caption?: string }) => updateMedia(item.id, data), + mutationFn: (data: { alt?: string; caption?: string; folderId?: string | null }) => + updateMedia(item.id, data), onSuccess: () => { + if (locationChanged) restoreFocusAfterDeleteRef.current = true; void queryClient.invalidateQueries({ queryKey: ["media"] }); onUpdated?.(); closeDialog(); }, + onError: (error) => { + if (error instanceof ApiResponseError && error.code === "NOT_FOUND") recoverMediaItem(); + }, + onSettled: () => { + savePendingRef.current = false; + }, }); const deleteMutation = useMutation({ @@ -148,7 +323,20 @@ export function MediaDetailPanel({ }); const isSaving = updateMutation.isPending; const isDeleting = deleteMutation.isPending; - const isBusy = isSaving || isDeleting; + const isRecovering = recoverMediaMutation.isPending; + const mediaUnavailable = + recoverMediaMutation.error instanceof ApiResponseError && + recoverMediaMutation.error.code === "NOT_FOUND"; + const isBusy = isSaving || isDeleting || isRecovering; + const updateNotFound = + updateMutation.error instanceof ApiResponseError && updateMutation.error.code === "NOT_FOUND"; + const updateErrorMessage = mediaUnavailable + ? t`This media item no longer exists.` + : updateNotFound + ? isRecovering + ? null + : t`The selected folder no longer exists. Choose another location and save again.` + : getMutationError(updateMutation.error) || getMutationError(recoverMediaMutation.error); const requestClose = React.useCallback(() => { if (isBusy) return; @@ -161,10 +349,11 @@ export function MediaDetailPanel({ }, [closeDialog, hasChanges, isBusy, isConfirmOpen]); const handleSave = () => { - if (!canEditMetadata || !hasChanges || isSaving) return; + if (!canEdit || !hasChanges || isBusy || mediaUnavailable || savePendingRef.current) return; + savePendingRef.current = true; updateMutation.mutate({ - alt, - caption, + ...(canEditMetadata ? { alt, caption } : {}), + ...(locationChanged ? { folderId } : {}), }); }; @@ -185,7 +374,7 @@ export function MediaDetailPanel({ const handleKeyDown = (event: KeyboardEvent) => { if (isConfirmOpen) return; if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { - if (!canEditMetadata || !hasChanges || isSaving) return; + if (!canEdit || !hasChanges || isBusy || mediaUnavailable) return; event.preventDefault(); stableHandleSave(); } @@ -193,7 +382,7 @@ export function MediaDetailPanel({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [canEditMetadata, hasChanges, isConfirmOpen, isSaving, open, stableHandleSave]); + }, [canEdit, hasChanges, isBusy, isConfirmOpen, mediaUnavailable, open, stableHandleSave]); return ( <> @@ -209,7 +398,7 @@ export function MediaDetailPanel({ >
+ {localItem && + (canMoveLocation ? ( + + label={t`Location`} + items={locationOptions} + filter={null} + value={selectedLocation} + inputValue={locationSearch} + isItemEqualToValue={(option, value) => option.id === value.id} + itemToStringLabel={(option) => option.name} + itemToStringValue={(option) => option.id ?? "main"} + disabled={isBusy || mediaUnavailable} + onOpenChange={(nextOpen) => { + setLocationOpen(nextOpen); + if (!nextOpen) setLocationSearch(""); + }} + onInputValueChange={(value, eventDetails) => { + if ( + eventDetails.reason === "input-change" || + eventDetails.reason === "input-clear" || + eventDetails.reason === "clear-press" + ) { + setLocationSearch(value); + } + }} + onValueChange={(option) => { + setFolderId(option?.id ?? null); + setSelectedFolder(option?.id ? { id: option.id, name: option.name } : null); + }} + > + + + {(option) => ( + {option?.name ?? t`Select a location`} + )} + + + + + + +
+ {locationListQuery.isFetching + ? t`Loading folders...` + : locationListQuery.data + ? plural(locationFolders.length, { + one: "# folder loaded", + other: "# folders loaded", + }) + : ""} +
+ {t`No folders found`} + + {(option) => ( + + {option.name} + + )} + + {locationListQuery.error && ( +
+

{t`Folders could not be loaded.`}

+ +
+ )} + {locationListQuery.hasNextPage && ( +
+ +
+ )} +
+ + ) : ( +
+

{t`Location`}

+

+ {currentLocationName} +

+
+ ))} + {canEditMetadata && ( <>
@@ -372,7 +676,7 @@ export function MediaDetailPanel({ value={alt} onChange={(event) => setAlt(event.target.value)} placeholder={t`Describe this image for accessibility`} - disabled={isSaving} + disabled={isBusy || mediaUnavailable} className="w-full" />
@@ -383,13 +687,13 @@ export function MediaDetailPanel({ onChange={(event) => setCaption(event.target.value)} placeholder={t`Optional caption for display`} rows={2} - disabled={isSaving} + disabled={isBusy || mediaUnavailable} /> )} - + @@ -405,7 +709,7 @@ export function MediaDetailPanel({ size="sm" icon={} onClick={handleDelete} - disabled={isBusy} + disabled={isBusy || mediaUnavailable} > {isDeleting ? t`Deleting...` : t`Delete`} @@ -413,14 +717,14 @@ export function MediaDetailPanel({
- {canEditMetadata && ( + {canEdit && ( @@ -470,4 +774,13 @@ function formatDate(isoString: string): string { }); } +function isLocalMediaItem(item: MediaItem): item is LocalMediaItem { + return ( + !item.provider && + "folderId" in item && + "authorId" in item && + typeof item.storageKey === "string" + ); +} + export default MediaDetailPanel; diff --git a/packages/admin/src/components/MediaFolderDialog.tsx b/packages/admin/src/components/MediaFolderDialog.tsx new file mode 100644 index 0000000000..3248ad866a --- /dev/null +++ b/packages/admin/src/components/MediaFolderDialog.tsx @@ -0,0 +1,193 @@ +import { Button, Dialog, Input, Toast } from "@cloudflare/kumo"; +import { useLingui } from "@lingui/react/macro"; +import { useMutation } from "@tanstack/react-query"; +import * as React from "react"; + +import { ApiResponseError, type MediaFolder } from "../lib/api"; +import { ConfirmDialog } from "./ConfirmDialog"; +import { DialogError, getMutationError } from "./DialogError.js"; + +export interface MediaFolderDialogProps { + open: boolean; + folder?: MediaFolder | null; + onClose: () => void; + onCreate: (name: string) => Promise; + onRename: (folder: MediaFolder, name: string) => Promise; + onDelete: (folder: MediaFolder) => Promise; +} + +export function MediaFolderDialog({ + open, + folder, + onClose, + onCreate, + onRename, + onDelete, +}: MediaFolderDialogProps) { + const { t } = useLingui(); + const toastManager = Toast.useToastManager(); + const [name, setName] = React.useState(""); + const [validationError, setValidationError] = React.useState(null); + const [deleteOpen, setDeleteOpen] = React.useState(false); + const deleteButtonRef = React.useRef(null); + const savePendingRef = React.useRef(false); + const deletePendingRef = React.useRef(false); + const isEditing = folder !== null && folder !== undefined; + + React.useEffect(() => { + if (!open) return; + setName(folder?.name ?? ""); + setValidationError(null); + setDeleteOpen(false); + savePendingRef.current = false; + deletePendingRef.current = false; + }, [folder?.id, folder?.name, open]); + + const saveMutation = useMutation({ + mutationFn: (nextName: string) => (folder ? onRename(folder, nextName) : onCreate(nextName)), + onSuccess: () => { + toastManager.add({ + title: isEditing ? t`Folder successfully edited` : t`Folder successfully created`, + type: "success", + timeout: 3000, + }); + onClose(); + }, + onSettled: () => { + savePendingRef.current = false; + }, + }); + const deleteMutation = useMutation({ + mutationFn: () => { + if (!folder) throw new Error(t`Folder unavailable`); + return onDelete(folder); + }, + onSuccess: () => { + setDeleteOpen(false); + toastManager.add({ title: t`Folder deleted`, type: "success", timeout: 3000 }); + onClose(); + }, + onSettled: () => { + deletePendingRef.current = false; + }, + }); + React.useEffect(() => { + if (!open) return; + saveMutation.reset(); + deleteMutation.reset(); + }, [folder?.id, open]); + const isPending = saveMutation.isPending || deleteMutation.isPending; + const mutationError = saveMutation.error; + const fieldError = + mutationError instanceof ApiResponseError + ? mutationError.code === "VALIDATION_ERROR" + ? t`Folder name must be between 1 and 200 characters` + : mutationError.code === "CONFLICT" + ? t`A media folder with this name already exists` + : null + : null; + const dialogError = fieldError ? null : getMutationError(mutationError); + + const submit = (event: React.FormEvent) => { + event.preventDefault(); + if (isPending || savePendingRef.current) return; + const trimmed = name.trim(); + if (trimmed.length < 1 || trimmed.length > 200) { + setValidationError(t`Folder name must be between 1 and 200 characters`); + return; + } + setValidationError(null); + savePendingRef.current = true; + saveMutation.mutate(trimmed); + }; + const closeDelete = () => { + if (deleteMutation.isPending) return; + setDeleteOpen(false); + deleteMutation.reset(); + window.requestAnimationFrame(() => deleteButtonRef.current?.focus()); + }; + const confirmDelete = () => { + if (deletePendingRef.current || deleteMutation.isPending) return; + deletePendingRef.current = true; + deleteMutation.mutate(); + }; + + return ( + <> + { + if (!nextOpen && !isPending && !deleteOpen) onClose(); + }} + disablePointerDismissal={isPending} + > + +
+ + {isEditing ? t`Edit folder` : t`Add new folder`} + +
+ { + setName(event.target.value); + setValidationError(null); + saveMutation.reset(); + }} + error={validationError ?? fieldError ?? undefined} + autoFocus + disabled={isPending} + /> +
+ +
+ +
+ {folder && ( + + )} + +
+
+ +
+
+ {folder && ( + + )} + + ); +} diff --git a/packages/admin/src/components/MediaLibrary.tsx b/packages/admin/src/components/MediaLibrary.tsx index 40809b14c1..4768a6529a 100644 --- a/packages/admin/src/components/MediaLibrary.tsx +++ b/packages/admin/src/components/MediaLibrary.tsx @@ -1,12 +1,34 @@ -import { Button, Input, Loader, Pagination, Select, Tabs } from "@cloudflare/kumo"; +import { + Breadcrumbs, + Button, + Input, + LayerCard, + Loader, + Pagination, + Select, + Tabs, +} from "@cloudflare/kumo"; +import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; -import { Upload, Images, SquaresFour, List, MagnifyingGlass } from "@phosphor-icons/react"; +import { + ArrowLeft, + Folder, + Images, + List, + MagnifyingGlass, + PencilSimple, + Plus, + SquaresFour, + Upload, +} from "@phosphor-icons/react"; import type { Icon } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import * as React from "react"; import { + type LocalMediaItem, type MediaItem, + type MediaFolder, type MediaUploadOptions, type MediaProviderItem, MEDIA_SEARCH_MAX_LENGTH, @@ -25,7 +47,9 @@ import { } from "../lib/media-utils"; import { cn } from "../lib/utils"; import { MediaDetailPanel } from "./MediaDetailPanel"; +import { MediaFolderDialog } from "./MediaFolderDialog.js"; import { LOCAL_MEDIA_UPLOAD_ACCEPT, MediaUploadDialog } from "./MediaUploadDialog.js"; +import { RouterLinkButton } from "./RouterLinkButton.js"; /** Maps a coarse type-filter choice to the media list's `mimeType` filter. */ function mimeForTypeFilter(value: string): string | string[] | undefined { @@ -58,6 +82,25 @@ export interface MediaLibraryProps { onLocalSearchChange?: (q: string) => void; /** Called with the MIME filter for the local library (undefined = all types). */ onLocalMimeFilterChange?: (mimeType: string | string[] | undefined) => void; + /** Bounded folder pages owned by the main local Media route. */ + folders?: MediaFolder[]; + foldersLoading?: boolean; + foldersError?: Error | null; + hasMoreFolders?: boolean; + isLoadingMoreFolders?: boolean; + onLoadMoreFolders?: () => void; + onActiveProviderChange?: (providerId: string) => void; + folderId?: string; + currentFolder?: MediaFolder | null; + currentFolderLoading?: boolean; + canManageFolders?: boolean; + onOpenFolder?: (folder: MediaFolder) => void; + onBackToMain?: () => void; + onRetryFolders?: () => void; + onCreateFolder?: (name: string) => Promise; + onRenameFolder?: (folder: MediaFolder, name: string) => Promise; + onDeleteFolder?: (folder: MediaFolder) => Promise; + canMoveMedia?: (item: LocalMediaItem) => boolean; } export interface MediaLibraryPagination { @@ -71,6 +114,7 @@ export interface MediaLibraryPagination { const MEDIA_PAGE_SIZE_OPTIONS = [35, 70, 90]; const MAX_DROPDOWN_PAGE_COUNT = 100; +let pendingMediaLibraryScrollTop: number | null = null; /** * Media library component with upload, provider tabs, and grid view @@ -85,6 +129,24 @@ export function MediaLibrary({ pagination, onLocalSearchChange, onLocalMimeFilterChange, + onActiveProviderChange, + folders = [], + foldersLoading, + foldersError, + hasMoreFolders, + isLoadingMoreFolders, + onLoadMoreFolders, + folderId, + currentFolder, + currentFolderLoading, + canManageFolders, + onOpenFolder, + onBackToMain, + onRetryFolders, + onCreateFolder, + onRenameFolder, + onDeleteFolder, + canMoveMedia, }: MediaLibraryProps) { const { t } = useLingui(); const [viewMode, setViewMode] = React.useState<"grid" | "list">("grid"); @@ -117,6 +179,9 @@ export function MediaLibrary({ const enqueueIdRef = React.useRef(0); const dragDepthRef = React.useRef(0); const returnFocusRef = React.useRef(null); + const [folderDialogOpen, setFolderDialogOpen] = React.useState(false); + const [editingFolder, setEditingFolder] = React.useState(null); + const folderDialogReturnFocusRef = React.useRef(null); // Track loaded image dimensions for providers that don't return them (e.g., CF Images) const [loadedDimensions, setLoadedDimensions] = React.useState< Record @@ -157,6 +222,7 @@ export function MediaLibrary({ }, [activeProvider, providers, t]); const canUpload = activeProviderInfo?.capabilities.upload ?? false; const canSearch = activeProviderInfo?.capabilities.search ?? false; + const canUploadHere = canUpload && (activeProvider !== "local" || !folderId); const cancelPendingDetailOpen = React.useCallback(() => { if (detailOpenFrameRef.current === null) return; @@ -165,7 +231,6 @@ export function MediaLibrary({ }, []); React.useEffect(() => cancelPendingDetailOpen, [cancelPendingDetailOpen]); - const requestPage = React.useCallback( (nextPage: number) => { if (!pagination || pagination.isPending) return; @@ -237,20 +302,23 @@ export function MediaLibrary({ const handleDetailClosed = React.useCallback(() => { setDetailItem(null); }, []); + const handleDetailItemRefreshed = React.useCallback((refreshed: LocalMediaItem) => { + setDetailItem((current) => (current?.id === refreshed.id ? refreshed : current)); + }, []); const enqueueFiles = React.useCallback( (files: readonly File[], returnFocus?: HTMLElement | null) => { - if (!canUpload || !activeProviderInfo || files.length === 0) return; + if (!canUploadHere || !activeProviderInfo || files.length === 0) return; if (returnFocus) returnFocusRef.current = returnFocus; setUploadTarget({ id: activeProviderInfo.id, name: activeProviderInfo.name }); setEnqueueRequest({ id: (enqueueIdRef.current += 1), files }); setUploadDialogOpen(true); }, - [activeProviderInfo, canUpload], + [activeProviderInfo, canUploadHere], ); const openUploadDialog = (event: React.MouseEvent) => { - if (!canUpload || !activeProviderInfo) return; + if (!canUploadHere || !activeProviderInfo) return; returnFocusRef.current = event.currentTarget; setUploadTarget({ id: activeProviderInfo.id, name: activeProviderInfo.name }); setEnqueueRequest(null); @@ -266,7 +334,7 @@ export function MediaLibrary({ const handleDragEnter = (event: DragEvent) => { if (!hasFiles(event)) return; event.preventDefault(); - if (uploadDialogOpen || !canUpload) return; + if (uploadDialogOpen || !canUploadHere) return; dragDepthRef.current += 1; setIsFileDragActive(true); }; @@ -274,7 +342,7 @@ export function MediaLibrary({ if (hasFiles(event)) event.preventDefault(); }; const handleDragLeave = (event: DragEvent) => { - if (dragDepthRef.current === 0 || uploadDialogOpen || !canUpload) return; + if (dragDepthRef.current === 0 || uploadDialogOpen || !canUploadHere) return; if (event.relatedTarget === null) { resetDrag(); return; @@ -286,7 +354,7 @@ export function MediaLibrary({ if (!hasFiles(event)) return; event.preventDefault(); resetDrag(); - if (uploadDialogOpen || !canUpload) return; + if (uploadDialogOpen || !canUploadHere) return; enqueueFiles([...(event.dataTransfer?.files ?? [])], mediaHeadingRef.current); }; @@ -300,7 +368,7 @@ export function MediaLibrary({ window.removeEventListener("dragleave", handleDragLeave); window.removeEventListener("drop", handleDrop); }; - }, [canUpload, enqueueFiles, uploadDialogOpen]); + }, [canUploadHere, enqueueFiles, uploadDialogOpen]); // Build provider tabs const providerTabs = React.useMemo(() => { @@ -321,6 +389,29 @@ export function MediaLibrary({ const currentItems = activeProvider === "local" ? items : []; const currentProviderItems = activeProvider !== "local" ? providerData?.items || [] : []; const currentLoading = activeProvider === "local" ? isLoading : providerLoading; + React.useEffect(() => { + if ( + pendingMediaLibraryScrollTop === null || + currentLoading || + foldersLoading || + currentFolderLoading + ) + return; + let secondFrame: number | undefined; + const firstFrame = window.requestAnimationFrame(() => { + secondFrame = window.requestAnimationFrame(() => { + const scrollContainer = document.querySelector("main"); + if (scrollContainer && pendingMediaLibraryScrollTop !== null) { + scrollContainer.scrollTop = pendingMediaLibraryScrollTop; + } + pendingMediaLibraryScrollTop = null; + }); + }); + return () => { + window.cancelAnimationFrame(firstFrame); + if (secondFrame !== undefined) window.cancelAnimationFrame(secondFrame); + }; + }, [currentFolderLoading, currentLoading, folderId, foldersLoading]); const resultCount = activeProvider === "local" @@ -341,7 +432,33 @@ export function MediaLibrary({ onLocalSearchChange?.(""); } }; - const showToolbar = resultCount > 0 || hasActiveQuery; + const showToolbar = + resultCount > 0 || + hasActiveQuery || + (activeProvider === "local" && + (folders.length > 0 || Boolean(foldersLoading) || Boolean(foldersError))); + const assetPage = pagination?.page ?? 1; + const showFolderResults = + activeProvider === "local" && + assetPage === 1 && + localTypeFilter === "all" && + (!folderId || searchQuery.trim() !== ""); + const visibleFolders = showFolderResults ? folders : []; + const hasFolderSurface = + showFolderResults && + (Boolean(foldersLoading) || + Boolean(foldersError) || + visibleFolders.length > 0 || + hasMoreFolders); + const folderResultsMayFillView = + Boolean(foldersLoading) || + visibleFolders.length > 0 || + (viewMode === "list" && hasFolderSurface); + const folderActionsAvailable = + Boolean(canManageFolders) && + Boolean(onCreateFolder) && + Boolean(onRenameFolder) && + Boolean(onDeleteFolder); const uploadFile = React.useCallback( async (file: File, options: { signal: AbortSignal }) => { if (!uploadTarget) throw new Error("Upload target unavailable"); @@ -366,10 +483,49 @@ export function MediaLibrary({ const handleUploadQueueIdle = React.useCallback(() => { if (uploadTarget?.id !== "local") void refetchProviderMedia(); }, [refetchProviderMedia, uploadTarget?.id]); + const openCreateFolder = (event: React.MouseEvent) => { + folderDialogReturnFocusRef.current = event.currentTarget; + setEditingFolder(null); + setFolderDialogOpen(true); + }; + const openEditFolder = (folder: MediaFolder, trigger: HTMLElement) => { + folderDialogReturnFocusRef.current = trigger; + setEditingFolder(folder); + setFolderDialogOpen(true); + }; + const closeFolderDialog = () => { + setFolderDialogOpen(false); + const returnTarget = folderDialogReturnFocusRef.current; + folderDialogReturnFocusRef.current = null; + window.requestAnimationFrame(() => { + (returnTarget?.isConnected ? returnTarget : mediaHeadingRef.current)?.focus({ + preventScroll: true, + }); + }); + }; + const focusMediaHeading = () => mediaHeadingRef.current?.focus({ preventScroll: true }); + const rememberScrollPosition = () => { + pendingMediaLibraryScrollTop = mediaHeadingRef.current?.closest("main")?.scrollTop ?? null; + }; + const backToMain = () => { + rememberScrollPosition(); + focusMediaHeading(); + onBackToMain?.(); + }; + const openFolder = (folder: MediaFolder) => { + setSearchQuery(""); + onLocalSearchChange?.(""); + cancelPendingDetailOpen(); + setIsDetailOpen(false); + setDetailItem(null); + rememberScrollPosition(); + focusMediaHeading(); + onOpenFolder?.(folder); + }; return (
- {isFileDragActive && ( + {isFileDragActive && canUploadHere && ( )} - {/* Header: page title (start) + primary upload action (end) */} + {/* Header: page title (start) + primary actions (end) */}
-

- {t`Media Library`} -

-
- {canUpload && ( - + )} + {canUploadHere && ( + )}
@@ -405,6 +614,7 @@ export function MediaLibrary({ if (!v) return; cancelPendingDetailOpen(); setActiveProvider(v); + onActiveProviderChange?.(v); setIsDetailOpen(false); setDetailItem(null); setSearchQuery(""); @@ -501,12 +711,81 @@ export function MediaLibrary({
)} + {activeProvider === "local" && ( + + {!hasFolderSurface || foldersError + ? "" + : foldersLoading || isLoadingMoreFolders + ? t`Loading folders` + : plural(visibleFolders.length, { + one: "# folder loaded", + other: "# folders loaded", + })} + + )} + + {hasFolderSurface && viewMode === "grid" && ( +
+
+

+ {t`Folders`} +

+ {foldersError && onRetryFolders && ( + + )} +
+ {foldersError && ( +
+ {t`Folders could not be loaded.`} +
+ )} + {foldersLoading && visibleFolders.length === 0 ? ( +
+ +
+ ) : ( +
+ {visibleFolders.map((folder) => ( + openFolder(folder) : undefined} + onEdit={(trigger) => openEditFolder(folder, trigger)} + /> + ))} +
+ )} + {hasMoreFolders && onLoadMoreFolders && ( +
+ +
+ )} + {visibleFolders.length > 0 && currentItems.length > 0 && ( +
+ )} +
+ )} + {/* Content */} {currentLoading && currentItems.length === 0 && currentProviderItems.length === 0 ? (
- ) : activeProvider === "local" && currentItems.length === 0 ? ( + ) : activeProvider === "local" && currentItems.length === 0 && !folderResultsMayFillView ? ( hasActiveQuery ? ( } /> + ) : folderId ? ( + + {t`Back to Main library`} + + } + /> ) : ( - +
@@ -621,6 +916,46 @@ export function MediaLibrary({ + {showFolderResults && foldersLoading && visibleFolders.length === 0 && ( + + + + )} + {showFolderResults && foldersError && visibleFolders.length === 0 && ( + + )} + {activeProvider === "local" && + visibleFolders.map((folder) => ( + openFolder(folder) : undefined} + onEdit={(trigger) => openEditFolder(folder, trigger)} + /> + ))} + {showFolderResults && foldersError && visibleFolders.length > 0 && ( + + )} + {showFolderResults && hasMoreFolders && onLoadMoreFolders && ( + + + + )} {activeProvider === "local" ? currentItems.map((item) => ( )} + + {folderActionsAvailable && onCreateFolder && onRenameFolder && onDeleteFolder && ( + + )} ); } +function MediaFolderCard({ + folder, + canEdit, + onOpen, + onEdit, +}: { + folder: MediaFolder; + canEdit: boolean; + onOpen?: () => void; + onEdit: (trigger: HTMLElement) => void; +}) { + const { t } = useLingui(); + return ( + + handleNavigationClick(event, onOpen)} + > +
+
+ + {folder.name} + +
+ {canEdit && ( + + )} +
+ ); +} + +function MediaFolderListItem({ + folder, + canEdit, + onOpen, + onEdit, +}: { + folder: MediaFolder; + canEdit: boolean; + onOpen?: () => void; + onEdit: (trigger: HTMLElement) => void; +}) { + const { t } = useLingui(); + return ( +
+ + + + + + + ); +} + +function MediaFolderErrorRow({ onRetry }: { onRetry?: () => void }) { + const { t } = useLingui(); + return ( + + + + ); +} + +function handleNavigationClick( + event: React.MouseEvent, + navigate: (() => void) | undefined, +) { + if ( + !navigate || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return; + event.preventDefault(); + navigate(); +} + +function isLocalMediaItem(item: MediaItem): item is LocalMediaItem { + return ( + !item.provider && + "folderId" in item && + "authorId" in item && + typeof item.storageKey === "string" + ); +} + /** Single-chip illustration: solid tinted circle + darker icon, decorative. */ function MediaEmptyIllustration({ hero: Hero }: { hero: Icon }) { return ( @@ -801,7 +1300,7 @@ function MediaGridItem({ item, selected, onClick }: MediaGridItemProps) { type="button" onClick={onClick} className={cn( - "group relative overflow-hidden rounded-lg border bg-kumo-base text-start transition-all max-w-[200px]", + "group relative w-full max-w-[200px] overflow-hidden rounded-lg border bg-kumo-base text-start transition-all max-sm:max-w-none", selected ? "ring-2 ring-kumo-brand border-kumo-brand" : "hover:border-kumo-brand/50", )} > diff --git a/packages/admin/src/components/RouterLinkButton.tsx b/packages/admin/src/components/RouterLinkButton.tsx index 3c1b8bcc09..3f75abb8c1 100644 --- a/packages/admin/src/components/RouterLinkButton.tsx +++ b/packages/admin/src/components/RouterLinkButton.tsx @@ -36,6 +36,7 @@ export type RouterLinkButtonProps = Omit & ButtonStyleProps & { className?: string; children?: React.ReactNode; + onClick?: React.MouseEventHandler; }; export function RouterLinkButton({ diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts index c8acad2727..b928e297c8 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -22,6 +22,18 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +export class ApiResponseError extends Error { + constructor( + public status: number, + public code: string, + message: string, + public details?: Record, + ) { + super(message); + this.name = "ApiResponseError"; + } +} + /** * Extract per-field validation issue messages from a `VALIDATION_ERROR` * response's `error.details.issues` array (see `packages/core/src/api/parse.ts`). @@ -56,12 +68,21 @@ function formatValidationIssues(error: Record): string | undefi export async function throwResponseError(res: Response, fallback: string): Promise { const body: unknown = await res.json().catch(() => ({})); let message: string | undefined; + let code = "UNKNOWN_ERROR"; + let details: Record | undefined; if (isRecord(body) && isRecord(body.error)) { const { error } = body; message = formatValidationIssues(error); if (!message && typeof error.message === "string") message = error.message; + if (typeof error.code === "string") code = error.code; + if (isRecord(error.details)) details = error.details; } - throw new Error(message || `${fallback}: ${res.statusText}`); + throw new ApiResponseError( + res.status, + code, + message || `${fallback}: ${res.statusText}`, + details, + ); } /** diff --git a/packages/admin/src/lib/api/index.ts b/packages/admin/src/lib/api/index.ts index f8a4482692..298d67f819 100644 --- a/packages/admin/src/lib/api/index.ts +++ b/packages/admin/src/lib/api/index.ts @@ -7,6 +7,7 @@ // Base client and shared types export { API_BASE, + ApiResponseError, apiFetch, parseApiResponse, throwResponseError, @@ -58,6 +59,9 @@ export { // Media export { type MediaItem, + type LocalMediaItem, + type MediaFolder, + type MediaFolderListResult, type MediaUploadOptions, type MediaProviderCapabilities, type MediaProviderInfo, @@ -66,6 +70,11 @@ export { MEDIA_SEARCH_MAX_LENGTH, fetchMediaList, fetchMediaItem, + fetchMediaFolders, + fetchMediaFolder, + createMediaFolder, + renameMediaFolder, + deleteMediaFolder, uploadMedia, deleteMedia, updateMedia, diff --git a/packages/admin/src/lib/api/media.ts b/packages/admin/src/lib/api/media.ts index 77a1d94e56..05c4993781 100644 --- a/packages/admin/src/lib/api/media.ts +++ b/packages/admin/src/lib/api/media.ts @@ -51,10 +51,24 @@ export interface MediaItem { meta?: Record; } -export interface MediaListResult extends FindManyResult { +export interface LocalMediaItem extends MediaItem { + provider?: undefined; + storageKey: string; + authorId: string | null; + folderId: string | null; +} + +export interface MediaFolder { + id: string; + name: string; +} + +export interface MediaListResult extends FindManyResult { totalCount?: number; } +export interface MediaFolderListResult extends FindManyResult {} + /** * Fetch media list */ @@ -63,6 +77,7 @@ export async function fetchMediaList(options?: { page?: number; limit?: number; mimeType?: string | string[]; + folderId?: string | null; /** Case-insensitive filename substring search (also matches extensions). */ search?: string; }): Promise { @@ -74,6 +89,11 @@ export async function fetchMediaList(options?: { const value = Array.isArray(options.mimeType) ? options.mimeType.join(",") : options.mimeType; if (value) params.set("mimeType", value); } + if (options?.folderId === null) { + params.set("folderId", "unfiled"); + } else if (options?.folderId !== undefined) { + params.set("folderId", options.folderId); + } if (options?.search) { // Trim and clamp to the server's accepted range so a long or // whitespace-only term can't trigger an avoidable 400. @@ -92,15 +112,78 @@ export async function fetchMediaList(options?: { * Used to resolve an id-only reference (e.g. a byline's `avatarMediaId`) * back into a full media item for display. */ -export async function fetchMediaItem(id: string, options?: MediaUploadOptions): Promise { - const response = await apiFetch(`${API_BASE}/media/${id}`, { signal: options?.signal }); - const data = await parseApiResponse<{ item: MediaItem }>( +export async function fetchMediaItem( + id: string, + options?: MediaUploadOptions, +): Promise { + const response = await apiFetch(`${API_BASE}/media/${encodeURIComponent(id)}`, { + signal: options?.signal, + }); + const data = await parseApiResponse<{ item: LocalMediaItem }>( response, i18n._(msg`Failed to fetch media item`), ); return data.item; } +export async function fetchMediaFolders( + options: { limit?: number; cursor?: string; search?: string } = {}, +): Promise { + const params = new URLSearchParams(); + if (options.limit !== undefined) params.set("limit", String(options.limit)); + if (options.cursor !== undefined) params.set("cursor", options.cursor); + const search = normalizeMediaSearch(options.search); + if (search) params.set("q", search); + const query = params.toString(); + const response = await apiFetch(`${API_BASE}/media/folders${query ? `?${query}` : ""}`); + return parseApiResponse( + response, + i18n._(msg`Failed to fetch media folders`), + ); +} + +export async function fetchMediaFolder(id: string): Promise { + const response = await apiFetch(`${API_BASE}/media/folders/${encodeURIComponent(id)}`); + const data = await parseApiResponse<{ item: MediaFolder }>( + response, + i18n._(msg`Failed to fetch media folder`), + ); + return data.item; +} + +export async function createMediaFolder(name: string): Promise { + const response = await apiFetch(`${API_BASE}/media/folders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + const data = await parseApiResponse<{ item: MediaFolder }>( + response, + i18n._(msg`Failed to create media folder`), + ); + return data.item; +} + +export async function renameMediaFolder(id: string, name: string): Promise { + const response = await apiFetch(`${API_BASE}/media/folders/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + const data = await parseApiResponse<{ item: MediaFolder }>( + response, + i18n._(msg`Failed to rename media folder`), + ); + return data.item; +} + +export async function deleteMediaFolder(id: string): Promise { + const response = await apiFetch(`${API_BASE}/media/folders/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to delete media folder`)); +} + /** * Upload URL response from the API */ @@ -346,14 +429,20 @@ export async function deleteMedia(id: string): Promise { */ export async function updateMedia( id: string, - input: { alt?: string; caption?: string; width?: number; height?: number }, -): Promise { - const response = await apiFetch(`${API_BASE}/media/${id}`, { + input: { + alt?: string; + caption?: string; + width?: number; + height?: number; + folderId?: string | null; + }, +): Promise { + const response = await apiFetch(`${API_BASE}/media/${encodeURIComponent(id)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), }); - const data = await parseApiResponse<{ item: MediaItem }>( + const data = await parseApiResponse<{ item: LocalMediaItem }>( response, i18n._(msg`Failed to update media`), ); diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 1ad1103811..00efeb8950 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -113,6 +113,12 @@ import { unpublishContent, discardDraft, fetchRevision, + fetchMediaFolder, + fetchMediaFolders, + createMediaFolder, + renameMediaFolder, + deleteMediaFolder, + ApiResponseError, useCurrentUser, type CreateCollectionInput, type UpdateCollectionInput, @@ -821,7 +827,8 @@ const contentEditRoute = createRoute({ }), }); -// Editor role level from @emdash-cms/auth +// Role levels from @emdash-cms/auth +const ROLE_AUTHOR = 30; const ROLE_EDITOR = 40; function ContentEditPage() { @@ -1371,26 +1378,91 @@ const mediaRoute = createRoute({ getParentRoute: () => adminLayoutRoute, path: "/media", component: MediaPage, + validateSearch: (search: Record) => ({ + folder: + typeof search.folder === "string" && search.folder.length > 0 && search.folder.length <= 64 + ? search.folder + : undefined, + }), }); function MediaPage() { + const { t } = useLingui(); const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { folder } = useSearch({ from: "/_admin/media" }); + const toastManager = Toast.useToastManager(); + const { data: currentUser } = useCurrentUser(); const [search, setSearch] = React.useState(""); const [mimeFilter, setMimeFilter] = React.useState(undefined); const [page, setPage] = React.useState(1); const [perPage, setPerPage] = React.useState(35); const [retainedTotalCount, setRetainedTotalCount] = React.useState(0); + const [activeProvider, setActiveProvider] = React.useState("local"); const mimeKey = Array.isArray(mimeFilter) ? mimeFilter.join(",") : (mimeFilter ?? ""); + const currentFolderQuery = useQuery({ + queryKey: ["media-folder", folder], + queryFn: () => fetchMediaFolder(folder!), + enabled: folder !== undefined, + retry: (failureCount, queryError) => + !(queryError instanceof ApiResponseError && queryError.code === "NOT_FOUND") && + failureCount < 2, + }); + const missingFolder = + currentFolderQuery.error instanceof ApiResponseError && + currentFolderQuery.error.code === "NOT_FOUND"; + const recoveredFolderRef = React.useRef(null); + React.useEffect(() => { + if (!folder || !missingFolder || recoveredFolderRef.current === folder) return; + recoveredFolderRef.current = folder; + void navigate({ to: "/media", search: { folder: undefined }, replace: true }); + toastManager.add({ + title: t`Folder no longer exists`, + type: "warning", + timeout: 4000, + }); + }, [folder, missingFolder, navigate, t, toastManager]); + React.useEffect(() => { + if (folder !== recoveredFolderRef.current) recoveredFolderRef.current = null; + }, [folder]); + const previousFolderRef = React.useRef(folder); + const folderChanged = previousFolderRef.current !== folder; + const requestedPage = folderChanged ? 1 : page; + const folderListEnabled = + activeProvider === "local" && + requestedPage === 1 && + mimeFilter === undefined && + (folder === undefined || search !== ""); + const folderListQuery = useInfiniteQuery({ + queryKey: ["media-folders", "page", { search }], + queryFn: ({ pageParam }) => + fetchMediaFolders({ + limit: 100, + cursor: pageParam, + search: search || undefined, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor, + enabled: folderListEnabled, + }); + const folders = React.useMemo( + () => folderListQuery.data?.pages.flatMap((folderPage) => folderPage.items) ?? [], + [folderListQuery.data?.pages], + ); const { data, isLoading, isFetching, error } = useQuery({ - queryKey: ["media", { search, mime: mimeKey, page, perPage }], + queryKey: [ + "media", + { search, mime: mimeKey, folder: folder ?? "main", page: requestedPage, perPage }, + ], queryFn: () => fetchMediaList({ - page, + page: requestedPage, limit: perPage, search: search || undefined, mimeType: mimeFilter, + folderId: search ? undefined : (folder ?? null), }), placeholderData: keepPreviousData, }); @@ -1398,10 +1470,16 @@ function MediaPage() { React.useEffect(() => { if (data?.totalCount !== undefined) setRetainedTotalCount(data.totalCount); }, [data?.totalCount]); + React.useEffect(() => { + if (previousFolderRef.current === folder) return; + previousFolderRef.current = folder; + setPage(1); + setRetainedTotalCount(0); + }, [folder]); const totalCount = data?.totalCount ?? retainedTotalCount; const lastPage = Math.max(1, Math.ceil((data?.totalCount ?? 0) / perPage)); - const isRecoveringPage = data?.totalCount !== undefined && page > lastPage; + const isRecoveringPage = data?.totalCount !== undefined && requestedPage > lastPage; React.useEffect(() => { if (isRecoveringPage) setPage(lastPage); }, [isRecoveringPage, lastPage]); @@ -1450,6 +1528,76 @@ function MediaPage() { void queryClient.invalidateQueries({ queryKey: ["media"] }); }, }); + const resetMediaPage = React.useCallback(() => { + setPage(1); + setRetainedTotalCount(0); + }, []); + const handleOpenFolder = React.useCallback( + (nextFolder: { id: string }) => { + resetMediaPage(); + void navigate({ to: "/media", search: { folder: nextFolder.id }, resetScroll: false }); + }, + [navigate, resetMediaPage], + ); + const handleBackToMain = React.useCallback(() => { + resetMediaPage(); + void navigate({ to: "/media", search: { folder: undefined }, resetScroll: false }); + }, [navigate, resetMediaPage]); + const handleCreateFolder = React.useCallback( + async (name: string) => { + const created = await createMediaFolder(name); + resetMediaPage(); + await queryClient.invalidateQueries({ queryKey: ["media-folders"] }); + return created; + }, + [queryClient, resetMediaPage], + ); + const handleRenameFolder = React.useCallback( + async (targetFolder: { id: string }, name: string) => { + const renamed = await renameMediaFolder(targetFolder.id, name); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["media-folders"] }), + queryClient.invalidateQueries({ queryKey: ["media-folder", targetFolder.id] }), + ]); + return renamed; + }, + [queryClient], + ); + const handleDeleteFolder = React.useCallback( + async (targetFolder: { id: string }) => { + await deleteMediaFolder(targetFolder.id); + const deletingCurrentFolder = folder === targetFolder.id; + if (deletingCurrentFolder) { + resetMediaPage(); + await navigate({ + to: "/media", + search: { folder: undefined }, + replace: true, + resetScroll: false, + }); + } + queryClient.removeQueries({ queryKey: ["media-folder", targetFolder.id], exact: true }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["media-folders"] }), + queryClient.invalidateQueries({ queryKey: ["media"] }), + ]); + if (!deletingCurrentFolder) resetMediaPage(); + }, + [folder, navigate, queryClient, resetMediaPage], + ); + const canMoveMedia = React.useCallback( + (item: { authorId: string | null }) => + Boolean( + currentUser && + (currentUser.role >= ROLE_EDITOR || + (currentUser.role >= ROLE_AUTHOR && item.authorId === currentUser.id)), + ), + [currentUser], + ); + + if (currentFolderQuery.error && !missingFolder) { + return ; + } if (error) { return ; @@ -1460,7 +1608,7 @@ function MediaPage() { items={isRecoveringPage ? [] : (data?.items ?? [])} isLoading={paginationPending} pagination={{ - page: isRecoveringPage ? lastPage : page, + page: isRecoveringPage ? lastPage : requestedPage, perPage, totalCount, isPending: paginationPending, @@ -1472,6 +1620,24 @@ function MediaPage() { }} onLocalSearchChange={handleSearchChange} onLocalMimeFilterChange={handleMimeFilterChange} + folders={folders} + foldersLoading={folderListQuery.isLoading} + foldersError={folderListQuery.error} + hasMoreFolders={folderListQuery.hasNextPage} + isLoadingMoreFolders={folderListQuery.isFetchingNextPage} + onLoadMoreFolders={() => void folderListQuery.fetchNextPage()} + onActiveProviderChange={setActiveProvider} + folderId={folder} + currentFolder={currentFolderQuery.data ?? null} + currentFolderLoading={currentFolderQuery.isLoading} + canManageFolders={(currentUser?.role ?? 0) >= ROLE_EDITOR} + onOpenFolder={handleOpenFolder} + onBackToMain={handleBackToMain} + onRetryFolders={() => void folderListQuery.refetch()} + onCreateFolder={handleCreateFolder} + onRenameFolder={handleRenameFolder} + onDeleteFolder={handleDeleteFolder} + canMoveMedia={canMoveMedia} /> ); } diff --git a/packages/admin/tests/components/MediaDetailPanel.test.tsx b/packages/admin/tests/components/MediaDetailPanel.test.tsx index 4a98fed62c..0683439f33 100644 --- a/packages/admin/tests/components/MediaDetailPanel.test.tsx +++ b/packages/admin/tests/components/MediaDetailPanel.test.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { MediaDetailPanel } from "../../src/components/MediaDetailPanel"; -import type { MediaItem } from "../../src/lib/api"; +import { ApiResponseError, type LocalMediaItem, type MediaItem } from "../../src/lib/api"; import { render } from "../utils/render.tsx"; vi.mock("../../src/lib/api", async () => { @@ -13,11 +13,21 @@ vi.mock("../../src/lib/api", async () => { updateMedia: vi.fn().mockResolvedValue({}), deleteMedia: vi.fn().mockResolvedValue({}), deleteFromProvider: vi.fn().mockResolvedValue({}), + fetchMediaFolders: vi.fn().mockResolvedValue({ items: [{ id: "folder-2", name: "Press" }] }), + fetchMediaFolder: vi.fn().mockResolvedValue({ id: "folder-1", name: "Product photos" }), + fetchMediaItem: vi.fn().mockResolvedValue({}), }; }); // Import the mocked functions for assertions -import { updateMedia, deleteMedia, deleteFromProvider } from "../../src/lib/api"; +import { + updateMedia, + deleteMedia, + deleteFromProvider, + fetchMediaFolders, + fetchMediaFolder, + fetchMediaItem, +} from "../../src/lib/api"; function QueryWrapper({ children }: { children: React.ReactNode }) { const qc = new QueryClient({ @@ -54,6 +64,16 @@ function makePdfItem(overrides: Partial = {}): MediaItem { }; } +function makeLocalItem(overrides: Partial = {}): LocalMediaItem { + return { + ...makeImageItem(), + storageKey: "media-1.jpg", + authorId: "user-1", + folderId: "folder-1", + ...overrides, + }; +} + function renderPanel(props: Partial> = {}) { const defaultProps: React.ComponentProps = { open: true, @@ -290,6 +310,272 @@ describe("MediaDetailPanel", () => { }); }); + it("loads bounded Location options only after the control opens", async () => { + const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: true }); + + expect(fetchMediaFolders).not.toHaveBeenCalled(); + const location = screen.getByRole("combobox", { name: "Location" }); + await expect.element(location).toHaveTextContent("Product photos"); + expect(location.element().querySelector('[dir="auto"]')).toHaveTextContent("Product photos"); + + location.element().click(); + + await vi.waitFor(() => { + expect(fetchMediaFolders).toHaveBeenCalledWith({ + limit: 100, + cursor: undefined, + search: undefined, + }); + }); + await expect.element(screen.getByRole("option", { name: "Main library" })).toBeInTheDocument(); + await expect.element(screen.getByRole("option", { name: "Press" })).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Press" }).element().querySelector('[dir="auto"]'), + ).toHaveTextContent("Press"); + await expect.element(screen.getByText("1 folder loaded")).toBeInTheDocument(); + }); + + it("saves image metadata and Location in one update", async () => { + const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: true }); + + screen.getByRole("combobox", { name: "Location" }).element().click(); + await expect.element(screen.getByRole("option", { name: "Press" })).toBeInTheDocument(); + screen.getByRole("option", { name: "Press" }).element().click(); + await screen.getByLabelText("Alt Text").fill("Updated alt"); + screen.getByRole("button", { name: "Save" }).element().click(); + + await vi.waitFor(() => { + expect(updateMedia).toHaveBeenCalledWith("media-1", { + alt: "Updated alt", + caption: "Photo caption", + folderId: "folder-2", + }); + }); + }); + + it("does not overwrite Location during a metadata-only save", async () => { + const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: true }); + + await screen.getByLabelText("Alt Text").fill("Metadata only"); + screen.getByRole("button", { name: "Save" }).element().click(); + + await vi.waitFor(() => { + expect(updateMedia).toHaveBeenCalledWith("media-1", { + alt: "Metadata only", + caption: "Photo caption", + }); + }); + }); + + it("searches Location independently and resets the search after selection", async () => { + const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: true }); + const locationTrigger = screen + .getByTestId("media-detail-dialog-details-column") + .getByRole("combobox", { name: "Location" }); + + locationTrigger.element().click(); + await screen.getByPlaceholder("Search folders").fill("press"); + await vi.waitFor(() => { + expect(fetchMediaFolders).toHaveBeenLastCalledWith({ + limit: 100, + cursor: undefined, + search: "press", + }); + }); + await expect.element(screen.getByRole("option", { name: "Press" })).toBeInTheDocument(); + screen.getByRole("option", { name: "Press" }).element().click(); + await expect.element(screen.getByRole("option", { name: "Press" })).not.toBeInTheDocument(); + locationTrigger.element().click(); + + await expect.element(screen.getByPlaceholder("Search folders")).toHaveValue(""); + }); + + it("ignores duplicate Location saves while the first update is pending", async () => { + let resolveUpdate!: (item: LocalMediaItem) => void; + vi.mocked(updateMedia).mockImplementationOnce( + () => new Promise((resolve) => (resolveUpdate = resolve)), + ); + const item = makeLocalItem(); + const screen = await renderPanel({ item, canMoveLocation: true }); + + screen.getByRole("combobox", { name: "Location" }).element().click(); + await expect.element(screen.getByRole("option", { name: "Press" })).toBeInTheDocument(); + screen.getByRole("option", { name: "Press" }).element().click(); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + const save = screen.getByRole("button", { name: "Save" }).element(); + save.click(); + save.click(); + + await vi.waitFor(() => expect(updateMedia).toHaveBeenCalledTimes(1)); + resolveUpdate({ ...item, folderId: "folder-2" }); + }); + + it.each([ + ["video", "video/mp4"], + ["audio", "audio/mpeg"], + ["document", "application/pdf"], + ])("moves a local %s without image metadata", async (_kind, mimeType) => { + const screen = await renderPanel({ + item: makeLocalItem({ mimeType, alt: undefined, caption: undefined }), + canMoveLocation: true, + }); + + screen.getByRole("combobox", { name: "Location" }).element().click(); + await expect.element(screen.getByRole("option", { name: "Main library" })).toBeInTheDocument(); + screen.getByRole("option", { name: "Main library" }).element().click(); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + screen.getByRole("button", { name: "Save" }).element().click(); + + await vi.waitFor(() => { + expect(updateMedia).toHaveBeenCalledWith("media-1", { folderId: null }); + }); + }); + + it("loads one additional bounded Location page on request", async () => { + vi.mocked(fetchMediaFolders).mockImplementation(async ({ cursor }) => + cursor === "next-folder" + ? { items: [{ id: "folder-3", name: "Archive" }] } + : { items: [{ id: "folder-2", name: "Press" }], nextCursor: "next-folder" }, + ); + const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: true }); + + screen.getByRole("combobox", { name: "Location" }).element().click(); + await expect + .element(screen.getByRole("button", { name: "Load more folders" })) + .toBeInTheDocument(); + screen.getByRole("button", { name: "Load more folders" }).element().click(); + + await expect.element(screen.getByRole("option", { name: "Archive" })).toBeInTheDocument(); + expect(fetchMediaFolders).toHaveBeenLastCalledWith({ + limit: 100, + cursor: "next-folder", + search: undefined, + }); + }); + + it("shows a read-only Location when the user cannot move the item", async () => { + const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: false }); + + await expect.element(screen.getByText("Location")).toBeInTheDocument(); + const currentLocation = screen.getByText("Product photos"); + await expect.element(currentLocation).toBeInTheDocument(); + expect(currentLocation.element()).toHaveAttribute("dir", "auto"); + expect(screen.getByRole("combobox", { name: "Location" }).query()).toBeNull(); + expect(fetchMediaFolders).not.toHaveBeenCalled(); + }); + + it("refreshes the open item when its saved folder no longer exists", async () => { + const refreshed = makeLocalItem({ folderId: null }); + let resolveRefresh!: (item: LocalMediaItem) => void; + vi.mocked(fetchMediaFolder).mockRejectedValueOnce( + new ApiResponseError(404, "NOT_FOUND", "Media folder not found"), + ); + vi.mocked(fetchMediaItem).mockImplementationOnce( + () => new Promise((resolve) => (resolveRefresh = resolve)), + ); + const onItemRefreshed = vi.fn(); + + const screen = await renderPanel({ + item: makeLocalItem(), + canMoveLocation: true, + onItemRefreshed, + }); + + await vi.waitFor(() => expect(fetchMediaItem).toHaveBeenCalledWith("media-1")); + await expect + .element(screen.getByRole("combobox", { name: "Location" })) + .toHaveTextContent("Loading..."); + resolveRefresh(refreshed); + await vi.waitFor(() => { + expect(onItemRefreshed).toHaveBeenCalledWith(refreshed); + }); + }); + + it("refreshes the open item when a selected folder disappears during save", async () => { + const refreshed = makeLocalItem({ folderId: null }); + vi.mocked(updateMedia).mockRejectedValueOnce( + new ApiResponseError(404, "NOT_FOUND", "Media folder not found"), + ); + vi.mocked(fetchMediaItem).mockResolvedValueOnce(refreshed); + const onItemRefreshed = vi.fn(); + const screen = await renderPanel({ + item: makeLocalItem(), + canMoveLocation: true, + onItemRefreshed, + }); + + screen.getByRole("combobox", { name: "Location" }).element().click(); + await expect.element(screen.getByRole("option", { name: "Main library" })).toBeInTheDocument(); + screen.getByRole("option", { name: "Main library" }).element().click(); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + screen.getByRole("button", { name: "Save" }).element().click(); + + await vi.waitFor(() => { + expect(fetchMediaItem).toHaveBeenCalledWith("media-1"); + expect(onItemRefreshed).toHaveBeenCalledWith(refreshed); + }); + await expect + .element( + screen.getByText( + "The selected folder no longer exists. Choose another location and save again.", + ), + ) + .toBeInTheDocument(); + }); + + it("blocks stale save retries while missing-folder recovery is pending", async () => { + let resolveRefresh!: (item: LocalMediaItem) => void; + vi.mocked(updateMedia).mockRejectedValueOnce( + new ApiResponseError(404, "NOT_FOUND", "Media folder not found"), + ); + vi.mocked(fetchMediaItem).mockImplementationOnce( + () => new Promise((resolve) => (resolveRefresh = resolve)), + ); + const item = makeLocalItem(); + const screen = await renderPanel({ item, canMoveLocation: true }); + + screen.getByRole("combobox", { name: "Location" }).element().click(); + await expect.element(screen.getByRole("option", { name: "Main library" })).toBeInTheDocument(); + screen.getByRole("option", { name: "Main library" }).element().click(); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + const save = screen.getByRole("button", { name: "Save" }).element(); + save.click(); + + await vi.waitFor(() => expect(fetchMediaItem).toHaveBeenCalledWith("media-1")); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + const shortcut = new KeyboardEvent("keydown", { key: "s", ctrlKey: true, cancelable: true }); + window.dispatchEvent(shortcut); + expect(shortcut.defaultPrevented).toBe(false); + save.click(); + expect(updateMedia).toHaveBeenCalledTimes(1); + resolveRefresh({ ...item, folderId: null }); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + }); + + it("reports when the media itself was deleted during a save", async () => { + vi.mocked(updateMedia).mockRejectedValueOnce( + new ApiResponseError(404, "NOT_FOUND", "Media item not found"), + ); + vi.mocked(fetchMediaItem).mockRejectedValueOnce( + new ApiResponseError(404, "NOT_FOUND", "Media item not found"), + ); + const screen = await renderPanel({ item: makeLocalItem(), canMoveLocation: true }); + + screen.getByRole("combobox", { name: "Location" }).element().click(); + await expect.element(screen.getByRole("option", { name: "Main library" })).toBeInTheDocument(); + screen.getByRole("option", { name: "Main library" }).element().click(); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + screen.getByRole("button", { name: "Save" }).element().click(); + + await expect.element(screen.getByText("This media item no longer exists.")).toBeInTheDocument(); + expect( + screen + .getByText("The selected folder no longer exists. Choose another location and save again.") + .query(), + ).toBeNull(); + await expect.element(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + it("does not consume the keyboard save shortcut when nothing can be saved", async () => { await renderPanel({ item: makeImageItem({ provider: "cloudflare-images" }), @@ -496,5 +782,8 @@ describe("MediaDetailPanel file URL", () => { .element(screen.getByLabelText("Alt Text"), { timeout: 100 }) .not.toBeInTheDocument(); await expect.element(screen.getByText("Uploaded:"), { timeout: 100 }).not.toBeInTheDocument(); + await expect.element(screen.getByText("Location"), { timeout: 100 }).not.toBeInTheDocument(); + expect(fetchMediaFolder).not.toHaveBeenCalled(); + expect(fetchMediaFolders).not.toHaveBeenCalled(); }); }); diff --git a/packages/admin/tests/components/MediaFolderDialog.test.tsx b/packages/admin/tests/components/MediaFolderDialog.test.tsx new file mode 100644 index 0000000000..75d1edd235 --- /dev/null +++ b/packages/admin/tests/components/MediaFolderDialog.test.tsx @@ -0,0 +1,155 @@ +import { Toasty } from "@cloudflare/kumo"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; + +import { MediaFolderDialog } from "../../src/components/MediaFolderDialog"; +import { ApiResponseError } from "../../src/lib/api"; +import { render } from "../utils/render.tsx"; + +async function renderDialog(props: Partial> = {}) { + const defaults: React.ComponentProps = { + open: true, + onClose: vi.fn(), + onCreate: vi.fn().mockResolvedValue({ id: "folder-1", name: "Created" }), + onRename: vi.fn().mockResolvedValue({ id: "folder-1", name: "Renamed" }), + onDelete: vi.fn().mockResolvedValue(undefined), + ...props, + }; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + function Harness() { + const [open, setOpen] = React.useState(true); + return ( + { + setOpen(false); + defaults.onClose(); + }} + /> + ); + } + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + } + const screen = await render(, { wrapper: Wrapper }); + return { screen, props: defaults }; +} + +describe("MediaFolderDialog", () => { + beforeEach(() => vi.clearAllMocks()); + + it("autofocuses Name and creates a trimmed folder on Enter", async () => { + const onCreate = vi.fn().mockResolvedValue({ id: "folder-1", name: "Created" }); + const onClose = vi.fn(); + const { screen } = await renderDialog({ onCreate, onClose }); + const name = screen.getByLabelText("Name"); + + await expect.element(name).toHaveFocus(); + expect(screen.getByText("Create a folder in the Main library.").query()).toBeNull(); + await name.fill(" Created "); + await userEvent.keyboard("{Enter}"); + + await vi.waitFor(() => { + expect(onCreate).toHaveBeenCalledWith("Created"); + expect(onClose).toHaveBeenCalledTimes(1); + }); + await vi.waitFor(() => + expect(screen.getByRole("heading", { name: "Add new folder" }).query()).toBeNull(), + ); + }); + + it("keeps validation and conflict errors inline", async () => { + const onCreate = vi + .fn() + .mockRejectedValue( + new ApiResponseError(409, "CONFLICT", "Database unique constraint failed"), + ); + const { screen } = await renderDialog({ onCreate }); + + screen.getByRole("button", { name: "Create" }).element().click(); + await expect + .element(screen.getByText("Folder name must be between 1 and 200 characters")) + .toBeInTheDocument(); + + await screen.getByLabelText("Name").fill("Duplicate"); + screen.getByRole("button", { name: "Create" }).element().click(); + await expect + .element(screen.getByText("A media folder with this name already exists")) + .toBeInTheDocument(); + screen.getByRole("button", { name: "Cancel" }).element().click(); + await vi.waitFor(() => + expect(screen.getByRole("heading", { name: "Add new folder" }).query()).toBeNull(), + ); + }); + + it("ignores duplicate submits while a folder save is pending", async () => { + let resolveCreate: ((folder: { id: string; name: string }) => void) | undefined; + const onCreate = vi.fn( + () => + new Promise<{ id: string; name: string }>((resolve) => { + resolveCreate = resolve; + }), + ); + const { screen } = await renderDialog({ onCreate }); + await screen.getByLabelText("Name").fill("Created"); + const create = screen.getByRole("button", { name: "Create" }).element(); + + create.click(); + create.click(); + + await vi.waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1)); + resolveCreate?.({ id: "folder-1", name: "Created" }); + await vi.waitFor(() => + expect(screen.getByRole("heading", { name: "Add new folder" }).query()).toBeNull(), + ); + }); + + it("renames a folder from the edit dialog", async () => { + const folder = { id: "folder-1", name: "Drafts" }; + const onRename = vi.fn().mockResolvedValue({ ...folder, name: "Published" }); + const { screen } = await renderDialog({ folder, onRename }); + + await screen.getByLabelText("Name").fill("Published"); + screen.getByRole("button", { name: "Save" }).element().click(); + + await vi.waitFor(() => expect(onRename).toHaveBeenCalledWith(folder, "Published")); + await vi.waitFor(() => + expect(screen.getByRole("heading", { name: "Edit folder" }).query()).toBeNull(), + ); + }); + + it("explains safe deletion and leaves edit open when confirmation is canceled", async () => { + const folder = { id: "folder-1", name: "Drafts" }; + const onDelete = vi.fn().mockResolvedValue(undefined); + const { screen } = await renderDialog({ folder, onDelete }); + const deleteButton = screen.getByRole("button", { name: "Delete folder" }); + + deleteButton.element().click(); + await expect.element(screen.getByText("Delete “Drafts”?")).toBeInTheDocument(); + await expect + .element( + screen.getByText( + "Media in this folder will return to Main library. No files will be deleted.", + ), + ) + .toBeInTheDocument(); + screen.getByRole("button", { name: "Cancel" }).last().element().click(); + + expect(onDelete).not.toHaveBeenCalled(); + await expect.element(screen.getByRole("heading", { name: "Edit folder" })).toBeInTheDocument(); + await vi.waitFor(() => expect(document.activeElement).toBe(deleteButton.element())); + screen.getByRole("button", { name: "Cancel" }).element().click(); + await vi.waitFor(() => + expect(screen.getByRole("heading", { name: "Edit folder" }).query()).toBeNull(), + ); + }); +}); diff --git a/packages/admin/tests/components/MediaLibrary.test.tsx b/packages/admin/tests/components/MediaLibrary.test.tsx index 9f409ef5b7..0135b198ec 100644 --- a/packages/admin/tests/components/MediaLibrary.test.tsx +++ b/packages/admin/tests/components/MediaLibrary.test.tsx @@ -1,12 +1,37 @@ +import { Toasty } from "@cloudflare/kumo"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { MediaLibrary } from "../../src/components/MediaLibrary"; -import type { MediaItem } from "../../src/lib/api"; +import type { MediaFolder, MediaItem } from "../../src/lib/api"; import { deleteMedia } from "../../src/lib/api"; import { render } from "../utils/render.tsx"; +vi.mock("../../src/components/RouterLinkButton.js", () => ({ + RouterLinkButton: ({ + to, + search, + variant: _variant, + size: _size, + shape: _shape, + icon: _icon, + ...props + }: React.ComponentProps<"a"> & { + to: string; + search?: { folder?: string }; + variant?: string; + size?: string; + shape?: string; + icon?: React.ReactNode; + }) => ( + + ), +})); + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -45,7 +70,11 @@ function QueryWrapper({ children }: { children: React.ReactNode }) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); - return {children}; + return ( + + {children} + + ); } function renderLibrary(props: Partial> = {}) { @@ -78,6 +107,10 @@ function makeMediaItem(overrides: Partial = {}): MediaItem { }; } +function makeFolder(overrides: Partial = {}): MediaFolder { + return { id: "folder-1", name: "Product photos", ...overrides }; +} + function makePagination( overrides: Partial["pagination"]>> = {}, ): NonNullable["pagination"]> { @@ -98,6 +131,345 @@ describe("MediaLibrary", () => { }); describe("rendering items", () => { + it("uses the concise local upload action without changing the dialog title", async () => { + const screen = await renderLibrary({ items: [makeMediaItem()] }); + + expect(screen.getByRole("button", { name: UPLOAD_TO_LIBRARY_PATTERN }).query()).toBeNull(); + screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).element().click(); + await expect + .element(screen.getByRole("heading", { name: "Upload to Library" })) + .toBeInTheDocument(); + }); + + it("renders folders before media with navigation, edit, and load-more actions", async () => { + const onOpenFolder = vi.fn(); + const onCreateFolder = vi.fn().mockResolvedValue(makeFolder()); + const onRenameFolder = vi.fn().mockResolvedValue(makeFolder()); + const onDeleteFolder = vi.fn().mockResolvedValue(undefined); + const onLoadMoreFolders = vi.fn(); + const folder = makeFolder(); + const screen = await renderLibrary({ + folders: [folder], + items: [makeMediaItem()], + pagination: makePagination(), + canManageFolders: true, + hasMoreFolders: true, + onOpenFolder, + onCreateFolder, + onRenameFolder, + onDeleteFolder, + onLoadMoreFolders, + }); + + await expect.element(screen.getByRole("heading", { name: "Folders" })).toBeInTheDocument(); + await expect.element(screen.getByText("1 folder loaded")).toBeInTheDocument(); + const folderLink = screen.getByRole("link", { name: "Open folder Product photos" }); + const folderCard = folderLink.element().closest("[data-media-folder-card]"); + expect(folderCard).not.toBeNull(); + expect(folderCard!.getBoundingClientRect().height).toBeLessThanOrEqual(72); + expect(folderLink.element().querySelector('[dir="auto"]')).toHaveTextContent( + "Product photos", + ); + await folderLink.click(); + expect(onOpenFolder).toHaveBeenCalledWith(folder); + await expect.element(screen.getByRole("heading", { name: "Media Library" })).toHaveFocus(); + const editFolder = screen.getByRole("button", { name: "Edit folder Product photos" }); + await editFolder.click(); + await expect + .element(screen.getByRole("heading", { name: "Edit folder" })) + .toBeInTheDocument(); + screen.getByRole("button", { name: "Cancel" }).element().click(); + await vi.waitFor(() => expect(document.activeElement).toBe(editFolder.element())); + await screen.getByRole("button", { name: "Add new folder" }).click(); + await expect + .element(screen.getByRole("heading", { name: "Add new folder" })) + .toBeInTheDocument(); + screen.getByRole("button", { name: "Cancel" }).element().click(); + await screen.getByRole("button", { name: "Load more folders" }).click(); + expect(onLoadMoreFolders).toHaveBeenCalledTimes(1); + }); + + it("keeps folder rows before media in list view", async () => { + const screen = await renderLibrary({ + folders: [makeFolder()], + items: [makeMediaItem({ filename: "photo.jpg" })], + pagination: makePagination(), + canManageFolders: true, + onCreateFolder: vi.fn().mockResolvedValue(makeFolder()), + onRenameFolder: vi.fn().mockResolvedValue(makeFolder()), + onDeleteFolder: vi.fn().mockResolvedValue(undefined), + }); + + await screen.getByRole("tab", { name: "List view" }).click(); + expect(screen.getByRole("heading", { name: "Folders" }).query()).toBeNull(); + const rows = screen.getByRole("row").all(); + const folderRow = rows[1]?.element(); + expect(folderRow).toHaveTextContent("Product photos"); + const folderCells = folderRow?.querySelectorAll("td"); + const folderLink = screen.getByRole("link", { name: "Open folder Product photos" }); + const editFolder = screen.getByRole("button", { name: "Edit folder Product photos" }); + expect(folderCells?.[1]).toContainElement(editFolder.element()); + const folderLinkBox = folderLink.element().getBoundingClientRect(); + const editFolderBox = editFolder.element().getBoundingClientRect(); + expect(editFolderBox.left - folderLinkBox.right).toBeLessThanOrEqual(8); + expect(folderLink.element().querySelector('[dir="auto"]')).toHaveTextContent( + "Product photos", + ); + expect(folderCells?.[2]).toHaveTextContent("Type: Folder"); + expect(folderCells?.[3]).toHaveTextContent("Size is not applicable to folders"); + expect(folderCells?.[4]).toHaveTextContent("Alt text is not applicable to folders"); + expect(rows[2]?.element()).toHaveTextContent("photo.jpg"); + }); + + it("renders the initial folder loader and error inside the list table", async () => { + const onRetryFolders = vi.fn(); + const screen = await renderLibrary({ + foldersLoading: true, + items: [makeMediaItem({ filename: "photo.jpg" })], + pagination: makePagination(), + onRetryFolders, + }); + + await screen.getByRole("tab", { name: "List view" }).click(); + let rows = screen.getByRole("row").all(); + expect(rows[1]?.element()).toHaveTextContent("Loading folders"); + expect(rows[1]?.element().querySelector("td")).toHaveAttribute("colspan", "5"); + expect(rows[2]?.element()).toHaveTextContent("photo.jpg"); + + await screen.rerender( + + + , + ); + rows = screen.getByRole("row").all(); + expect(rows[1]?.element()).toHaveTextContent("Folders could not be loaded."); + expect(rows[1]?.element().querySelector("td")).toHaveAttribute("colspan", "5"); + await screen.getByRole("button", { name: "Retry" }).click(); + expect(onRetryFolders).toHaveBeenCalledTimes(1); + expect(rows[2]?.element()).toHaveTextContent("photo.jpg"); + }); + + it("orders later folder-page errors and load more before media rows", async () => { + const onRetryFolders = vi.fn(); + const onLoadMoreFolders = vi.fn(); + const screen = await renderLibrary({ + folders: [makeFolder()], + foldersError: new Error("offline"), + hasMoreFolders: true, + onRetryFolders, + onLoadMoreFolders, + items: [makeMediaItem({ filename: "photo.jpg" })], + pagination: makePagination(), + }); + + await screen.getByRole("tab", { name: "List view" }).click(); + const rows = screen.getByRole("row").all(); + expect(rows[1]?.element()).toHaveTextContent("Product photos"); + expect(rows[2]?.element()).toHaveTextContent("Folders could not be loaded."); + expect(rows[3]?.element()).toHaveTextContent("Load more folders"); + expect(rows[4]?.element()).toHaveTextContent("photo.jpg"); + await screen.getByRole("button", { name: "Retry" }).click(); + await screen.getByRole("button", { name: "Load more folders" }).click(); + expect(onRetryFolders).toHaveBeenCalledTimes(1); + expect(onLoadMoreFolders).toHaveBeenCalledTimes(1); + }); + + it("shows folders instead of the whole-library empty state", async () => { + const screen = await renderLibrary({ folders: [makeFolder()], items: [] }); + + await expect.element(screen.getByText("Product photos").first()).toBeInTheDocument(); + expect(screen.getByText("Your media library is empty").query()).toBeNull(); + }); + + it("marks folder results busy while loading another bounded page", async () => { + const screen = await renderLibrary({ + folders: [makeFolder()], + isLoadingMoreFolders: true, + hasMoreFolders: true, + onLoadMoreFolders: vi.fn(), + }); + + const folderSection = screen + .getByRole("heading", { name: "Folders" }) + .element() + .closest("section"); + expect(folderSection).toHaveAttribute("aria-busy", "true"); + await expect.element(screen.getByText("Loading folders")).toBeInTheDocument(); + }); + + it("shows Back and breadcrumbs inside a folder and hides local creation actions", async () => { + const onBackToMain = vi.fn(); + const screen = await renderLibrary({ + folderId: "folder-1", + currentFolder: makeFolder(), + canManageFolders: true, + onBackToMain, + }); + + const back = screen.getByRole("link", { name: "Back" }); + const modifiedClick = new MouseEvent("click", { + bubbles: true, + cancelable: true, + metaKey: true, + }); + back.element().dispatchEvent(modifiedClick); + expect(modifiedClick.defaultPrevented).toBe(false); + expect(onBackToMain).not.toHaveBeenCalled(); + back.element().click(); + expect(onBackToMain).toHaveBeenCalledTimes(1); + await expect.element(screen.getByRole("heading", { name: "Media Library" })).toHaveFocus(); + const currentFolder = screen.getByText("Product photos").first(); + await expect.element(currentFolder).toBeInTheDocument(); + expect(currentFolder.element()).toHaveAttribute("dir", "auto"); + const rootCrumb = screen.getByRole("link", { name: "Media Library" }).first(); + expect(getComputedStyle(rootCrumb.element()).fontSize).toBe( + getComputedStyle(currentFolder.element()).fontSize, + ); + expect(screen.getByRole("button", { name: "Add new folder" }).query()).toBeNull(); + expect(screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).query()).toBeNull(); + }); + + it("keeps browsing available without folder-management permission", async () => { + const folder = makeFolder(); + const onOpenFolder = vi.fn(); + const screen = await renderLibrary({ + folders: [folder], + pagination: makePagination(), + canManageFolders: false, + onOpenFolder, + onCreateFolder: vi.fn(), + onRenameFolder: vi.fn(), + onDeleteFolder: vi.fn(), + }); + + expect(screen.getByRole("button", { name: "Add new folder" }).query()).toBeNull(); + expect(screen.getByRole("button", { name: "Edit folder Product photos" }).query()).toBeNull(); + await screen.getByRole("link", { name: "Open folder Product photos" }).click(); + expect(onOpenFolder).toHaveBeenCalledWith(folder); + }); + + it("hides folders on later pages and while a MIME filter is active", async () => { + const screen = await renderLibrary({ + folders: [makeFolder()], + items: [makeMediaItem()], + pagination: makePagination({ page: 2, totalCount: 70 }), + }); + + expect(screen.getByRole("heading", { name: "Folders" }).query()).toBeNull(); + await screen.rerender( + + + , + ); + await screen.getByRole("combobox", { name: "Filter by type" }).click(); + await screen.getByRole("option", { name: "Images" }).click(); + expect(screen.getByRole("heading", { name: "Folders" }).query()).toBeNull(); + }); + + it("hides retained folder query state from a filtered list", async () => { + const screen = await renderLibrary({ + folders: [makeFolder()], + foldersLoading: true, + foldersError: new Error("offline"), + hasMoreFolders: true, + isLoadingMoreFolders: true, + onLoadMoreFolders: vi.fn(), + onRetryFolders: vi.fn(), + items: [makeMediaItem()], + pagination: makePagination(), + }); + + await screen.getByRole("tab", { name: "List view" }).click(); + await screen.getByRole("combobox", { name: "Filter by type" }).click(); + await screen.getByRole("option", { name: "Images" }).click(); + + expect(screen.getByRole("link", { name: "Open folder Product photos" }).query()).toBeNull(); + expect(screen.getByText("Loading folders").query()).toBeNull(); + expect(screen.getByText("Folders could not be loaded.").query()).toBeNull(); + expect(screen.getByRole("button", { name: "Retry" }).query()).toBeNull(); + expect(screen.getByRole("button", { name: "Load more folders" }).query()).toBeNull(); + expect(screen.getByRole("table").element()).not.toHaveAttribute("aria-busy"); + }); + + it("shows global folder results while searching from a named folder", async () => { + const onLocalSearchChange = vi.fn(); + const onOpenFolder = vi.fn(); + const screen = await renderLibrary({ + folderId: "folder-current", + currentFolder: makeFolder({ id: "folder-current", name: "Current" }), + folders: [makeFolder({ id: "folder-result", name: "Product photos" })], + items: [makeMediaItem()], + pagination: makePagination(), + onLocalSearchChange, + onOpenFolder, + }); + + await screen.getByRole("searchbox", { name: "Search media" }).fill("product"); + await expect.element(screen.getByRole("heading", { name: "Folders" })).toBeInTheDocument(); + await expect + .element(screen.getByRole("link", { name: "Open folder Product photos" })) + .toBeInTheDocument(); + await screen.getByRole("link", { name: "Open folder Product photos" }).click(); + expect(onLocalSearchChange).toHaveBeenLastCalledWith(""); + expect(onOpenFolder).toHaveBeenCalledWith(expect.objectContaining({ id: "folder-result" })); + }); + + it("disables local page-drop upload while inside a folder", async () => { + const onUpload = vi.fn(); + const screen = await renderLibrary({ + folderId: "folder-1", + currentFolder: makeFolder(), + onUpload, + }); + + dropFiles(window, [new File(["image"], "dropped.jpg", { type: "image/jpeg" })]); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(onUpload).not.toHaveBeenCalled(); + expect(screen.getByText("Drop files to upload").query()).toBeNull(); + }); + + it("keeps media usable when folders fail and offers retry", async () => { + const onRetryFolders = vi.fn(); + const screen = await renderLibrary({ + foldersError: new Error("offline"), + onRetryFolders, + items: [makeMediaItem()], + pagination: makePagination(), + }); + + await expect + .element(screen.getByRole("alert")) + .toHaveTextContent("Folders could not be loaded."); + await screen.getByRole("button", { name: "Retry" }).click(); + expect(onRetryFolders).toHaveBeenCalledTimes(1); + await expect.element(screen.getByAltText("photo.jpg")).toBeInTheDocument(); + }); + + it("keeps the media search empty state when folder search fails", async () => { + const screen = await renderLibrary({ + foldersError: new Error("offline"), + onRetryFolders: vi.fn(), + }); + + await screen.getByRole("searchbox", { name: "Search media" }).fill("missing"); + + await expect.element(screen.getByText("Folders could not be loaded.")).toBeInTheDocument(); + await expect.element(screen.getByText("No matching media")).toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: "Clear search" })) + .toBeInTheDocument(); + }); + it("displays media items in grid view by default", async () => { const items = [ makeMediaItem({ id: "1", filename: "image1.jpg" }), @@ -143,7 +515,7 @@ describe("MediaLibrary", () => { const onUpload = vi.fn(); const screen = await renderLibrary({ onUpload }); - screen.getByRole("button", { name: UPLOAD_TO_LIBRARY_PATTERN }).element().click(); + screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).first().element().click(); await expect .element(screen.getByRole("heading", { name: "Upload to Library" })) @@ -157,7 +529,7 @@ describe("MediaLibrary", () => { it("opens the same empty dialog from the empty-state action", async () => { const screen = await renderLibrary(); - screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).element().click(); + screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).last().element().click(); await expect .element(screen.getByRole("heading", { name: "Upload to Library" })) @@ -177,7 +549,7 @@ describe("MediaLibrary", () => { (name) => new File([name], name, { type: "image/jpeg" }), ); - screen.getByRole("button", { name: UPLOAD_TO_LIBRARY_PATTERN }).element().click(); + screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).first().element().click(); await expect .element(screen.getByRole("heading", { name: "Upload to Library" })) .toBeInTheDocument(); @@ -234,7 +606,7 @@ describe("MediaLibrary", () => { .mockResolvedValue(undefined); const screen = await renderLibrary({ onUpload }); - screen.getByRole("button", { name: UPLOAD_TO_LIBRARY_PATTERN }).element().click(); + screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).first().element().click(); await expect .element(screen.getByRole("heading", { name: "Upload to Library" })) .toBeInTheDocument(); @@ -363,7 +735,7 @@ describe("MediaLibrary", () => { await expect.element(screen.getByText("Your media library is empty")).toBeInTheDocument(); await expect.element(screen.getByText(UPLOAD_CTA_PATTERN)).toBeInTheDocument(); await expect - .element(screen.getByRole("button", { name: UPLOAD_FILES_PATTERN })) + .element(screen.getByRole("button", { name: UPLOAD_FILES_PATTERN }).last()) .toBeInTheDocument(); }); }); @@ -587,6 +959,7 @@ describe("MediaLibrary", () => { const screen = await renderLibrary({ items: [makeMediaItem({ id: "1", filename: "a.jpg" })], + folders: [makeFolder()], pagination: makePagination(), }); @@ -598,6 +971,7 @@ describe("MediaLibrary", () => { expect(screen.getByRole("navigation", { name: "Media pagination" }).query()).toBeNull(); expect(screen.getByRole("tab", { name: "Grid view" }).query()).toBeNull(); expect(screen.getByRole("tab", { name: "List view" }).query()).toBeNull(); + expect(screen.getByRole("heading", { name: "Folders" }).query()).toBeNull(); }); }); }); diff --git a/packages/admin/tests/lib/api-client.test.ts b/packages/admin/tests/lib/api-client.test.ts index 14e4af9b31..0c80e0fe5c 100644 --- a/packages/admin/tests/lib/api-client.test.ts +++ b/packages/admin/tests/lib/api-client.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { apiFetch, fetchManifest, throwResponseError } from "../../src/lib/api/client"; +import { + ApiResponseError, + apiFetch, + fetchManifest, + throwResponseError, +} from "../../src/lib/api/client"; describe("apiFetch", () => { let fetchSpy: ReturnType; @@ -132,6 +137,29 @@ describe("throwResponseError", () => { await expect(throwResponseError(response, "fallback")).rejects.toThrow("Not found"); }); + it("preserves status, code, and details on API response errors", async () => { + const response = new Response( + JSON.stringify({ + error: { + code: "CONFLICT", + message: "Already exists", + details: { field: "name" }, + }, + }), + { status: 409 }, + ); + + const error = await throwResponseError(response, "fallback").catch((value: unknown) => value); + + expect(error).toBeInstanceOf(ApiResponseError); + expect(error).toMatchObject({ + status: 409, + code: "CONFLICT", + message: "Already exists", + details: { field: "name" }, + }); + }); + it("falls back to the generic fallback when the body has no error", async () => { const response = new Response("", { status: 500, statusText: "Internal Server Error" }); await expect(throwResponseError(response, "fallback")).rejects.toThrow( diff --git a/packages/admin/tests/lib/media-folders.test.ts b/packages/admin/tests/lib/media-folders.test.ts new file mode 100644 index 0000000000..3ed25ac641 --- /dev/null +++ b/packages/admin/tests/lib/media-folders.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ApiResponseError } from "../../src/lib/api/client"; +import { + createMediaFolder, + deleteMediaFolder, + fetchMediaFolder, + fetchMediaFolders, + renameMediaFolder, +} from "../../src/lib/api/media"; + +describe("media folder API client", () => { + const originalFetch = globalThis.fetch; + let requests: Request[]; + + beforeEach(() => { + requests = []; + globalThis.fetch = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init); + requests.push(request.clone()); + const url = new URL(request.url, "http://localhost"); + if (request.method === "GET" && url.pathname.endsWith("/folder%2Fone")) { + return Response.json({ data: { item: { id: "folder/one", name: "One" } } }); + } + if (request.method === "GET") { + return Response.json({ + data: { items: [{ id: "folder/one", name: "One" }], nextCursor: "next" }, + }); + } + if (request.method === "DELETE") return Response.json({ data: { deleted: true } }); + return Response.json({ data: { item: { id: "folder/one", name: "Saved" } } }); + }) as typeof globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("lists and gets folders with bounded search and encoded IDs", async () => { + const list = await fetchMediaFolders({ + limit: 100, + cursor: "after / folder", + search: " résumé ", + }); + const folder = await fetchMediaFolder("folder/one"); + + expect(list).toEqual({ items: [{ id: "folder/one", name: "One" }], nextCursor: "next" }); + expect(folder).toEqual({ id: "folder/one", name: "One" }); + const listUrl = new URL(requests[0]!.url); + expect(Object.fromEntries(listUrl.searchParams)).toEqual({ + limit: "100", + cursor: "after / folder", + q: "résumé", + }); + expect(new URL(requests[1]!.url).pathname).toBe("/_emdash/api/media/folders/folder%2Fone"); + }); + + it("creates, renames, and deletes folders with exact request bodies", async () => { + await createMediaFolder("Created"); + await renameMediaFolder("folder/one", "Renamed"); + await deleteMediaFolder("folder/one"); + + expect(requests.map((request) => request.method)).toEqual(["POST", "PUT", "DELETE"]); + expect(await requests[0]!.json()).toEqual({ name: "Created" }); + expect(await requests[1]!.json()).toEqual({ name: "Renamed" }); + expect(new URL(requests[1]!.url).pathname).toBe("/_emdash/api/media/folders/folder%2Fone"); + expect(new URL(requests[2]!.url).pathname).toBe("/_emdash/api/media/folders/folder%2Fone"); + }); + + it("surfaces the server error code and message", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue( + Response.json( + { error: { code: "CONFLICT", message: "A media folder with this name already exists" } }, + { status: 409 }, + ), + ); + + const error = await createMediaFolder("Duplicate").catch((value: unknown) => value); + + expect(error).toBeInstanceOf(ApiResponseError); + expect(error).toMatchObject({ + status: 409, + code: "CONFLICT", + message: "A media folder with this name already exists", + }); + }); +}); diff --git a/packages/admin/tests/lib/media-pagination.test.ts b/packages/admin/tests/lib/media-pagination.test.ts index 73ee3c2483..a0b0cc51ed 100644 --- a/packages/admin/tests/lib/media-pagination.test.ts +++ b/packages/admin/tests/lib/media-pagination.test.ts @@ -9,8 +9,9 @@ describe("media page API client", () => { beforeEach(() => { fetchSpy = vi .fn() - .mockResolvedValue( - new Response(JSON.stringify({ data: { items: [], totalCount: 37 } }), { status: 200 }), + .mockImplementation( + () => + new Response(JSON.stringify({ data: { items: [], totalCount: 37 } }), { status: 200 }), ); globalThis.fetch = fetchSpy as typeof globalThis.fetch; }); @@ -27,4 +28,13 @@ describe("media page API client", () => { expect(Object.fromEntries(requestUrl.searchParams)).toEqual({ page: "1", limit: "35" }); expect(result).toEqual({ items: [], totalCount: 37 }); }); + + it("serializes Main library and named-folder filters", async () => { + const mainOptions = { page: 1, limit: 35, folderId: null }; + await fetchMediaList(mainOptions); + await fetchMediaList({ page: 1, limit: 35, folderId: "folder/one" }); + + const urls = fetchSpy.mock.calls.map(([url]) => new URL(url, "http://localhost")); + expect(urls.map((url) => url.searchParams.get("folderId"))).toEqual(["unfiled", "folder/one"]); + }); }); diff --git a/packages/admin/tests/router.test.tsx b/packages/admin/tests/router.test.tsx index 73587a26be..f3b3e7cb7e 100644 --- a/packages/admin/tests/router.test.tsx +++ b/packages/admin/tests/router.test.tsx @@ -115,12 +115,36 @@ vi.mock("../src/components/MediaLibrary", () => ({ isLoading, onUpload, onLocalSearchChange, + folders, + hasMoreFolders, + onLoadMoreFolders, + folderId, + currentFolder, + canManageFolders, + onOpenFolder, + onBackToMain, + onCreateFolder, + onRenameFolder, + onDeleteFolder, + canMoveMedia, pagination, }: { items?: Array<{ id?: string }>; isLoading?: boolean; onUpload?: (file: File) => Promise | void; onLocalSearchChange?: (search: string) => void; + folders?: Array<{ id: string }>; + hasMoreFolders?: boolean; + onLoadMoreFolders?: () => void; + folderId?: string; + currentFolder?: { id: string; name: string } | null; + canManageFolders?: boolean; + onOpenFolder?: (folder: { id: string; name: string }) => void; + onBackToMain?: () => void; + onCreateFolder?: (name: string) => Promise; + onRenameFolder?: (folder: { id: string; name: string }, name: string) => Promise; + onDeleteFolder?: (folder: { id: string; name: string }) => Promise; + canMoveMedia?: (item: { authorId: string | null }) => boolean; pagination?: { page: number; perPage: number; @@ -149,6 +173,42 @@ vi.mock("../src/components/MediaLibrary", () => ({ {items?.length ?? 0} {items?.[0]?.id ?? ""} {isLoading ? "loading" : "ready"} + {folders?.length ?? 0} + + + + {canManageFolders && ( + <> + + + + + )} + {folderId ?? "main"} + + {canMoveMedia?.({ authorId: "user_01" }) ? "yes" : "no"} + + + {canMoveMedia?.({ authorId: "other-user" }) ? "yes" : "no"} + {pagination && ( <> {pagination.page} @@ -247,6 +307,21 @@ describe("MediaPage – upload completion", () => { .on("GET", "/_emdash/api/auth/me", { data: { id: "user_01", role: 60 }, }) + .on("GET", "/_emdash/api/media/folders/folder-one", { + data: { item: { id: "folder-one", name: "Folder One" } }, + }) + .on("GET", "/_emdash/api/media/folders", { + data: { items: [{ id: "folder-one", name: "Folder One" }] }, + }) + .on("POST", "/_emdash/api/media/folders", { + data: { item: { id: "folder-created", name: "Created" } }, + }) + .on("PUT", "/_emdash/api/media/folders/folder-one", { + data: { item: { id: "folder-one", name: "Renamed" } }, + }) + .on("DELETE", "/_emdash/api/media/folders/folder-one", { + data: { deleted: true }, + }) .on("GET", "/_emdash/api/media", { data: { items: [], totalCount: 60 }, }); @@ -333,6 +408,231 @@ describe("MediaPage – upload completion", () => { }); }); + it("maps root and direct folder URL state to media filters with global search precedence", async () => { + const requests: string[] = []; + const mockedFetch = globalThis.fetch; + globalThis.fetch = (input, init) => { + requests.push( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + ); + return mockedFetch(input, init); + }; + + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media" }); + const screen = await render(); + + await vi.waitFor(() => { + expect( + requests.some( + (url) => + url.includes("/_emdash/api/media?") && + new URL(url, "http://localhost").searchParams.get("folderId") === "unfiled", + ), + ).toBe(true); + }); + + await router.navigate({ to: "/media", search: { folder: "folder-one" } }); + await vi.waitFor(() => { + expect(requests.some((url) => url.includes("/media/folders/folder-one"))).toBe(true); + expect( + requests.some( + (url) => new URL(url, "http://localhost").searchParams.get("folderId") === "folder-one", + ), + ).toBe(true); + }); + + await screen.getByRole("button", { name: "Search media" }).click(); + await vi.waitFor(() => { + expect( + requests.some((rawUrl) => { + const url = new URL(rawUrl, "http://localhost"); + return url.searchParams.get("q") === "photo" && !url.searchParams.has("folderId"); + }), + ).toBe(true); + }); + }); + + it("does not request the previous page when direct folder state changes", async () => { + const requests: string[] = []; + const mockedFetch = globalThis.fetch; + globalThis.fetch = (input, init) => { + requests.push( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + ); + return mockedFetch(input, init); + }; + + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media" }); + const screen = await render(); + await screen.getByRole("button", { name: "Open page 2" }).click(); + await expect.element(screen.getByTestId("media-page")).toHaveTextContent("2"); + + requests.length = 0; + await router.navigate({ to: "/media", search: { folder: "folder-one" } }); + await vi.waitFor(() => { + expect( + requests.some((rawUrl) => { + const url = new URL(rawUrl, "http://localhost"); + return ( + url.searchParams.get("folderId") === "folder-one" && + url.searchParams.get("page") === "1" + ); + }), + ).toBe(true); + }); + expect( + requests.some((rawUrl) => { + const url = new URL(rawUrl, "http://localhost"); + return ( + url.searchParams.get("folderId") === "folder-one" && url.searchParams.get("page") === "2" + ); + }), + ).toBe(false); + }); + + it("loads bounded folder pages and exposes explicit load-more state", async () => { + const mockedFetch = globalThis.fetch; + const folderRequests: URL[] = []; + globalThis.fetch = (input, init) => { + const rawUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const url = new URL(rawUrl, "http://localhost"); + if (url.pathname === "/_emdash/api/media/folders") { + folderRequests.push(url); + const cursor = url.searchParams.get("cursor"); + return Promise.resolve( + Response.json({ + data: + cursor === "next-folder" + ? { items: [{ id: "folder-two", name: "Folder Two" }] } + : { + items: [{ id: "folder-one", name: "Folder One" }], + nextCursor: "next-folder", + }, + }), + ); + } + return mockedFetch(input, init); + }; + + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media" }); + const screen = await render(); + + await expect.element(screen.getByTestId("folder-count")).toHaveTextContent("1"); + await screen.getByRole("button", { name: "Load more folders" }).click(); + await expect.element(screen.getByTestId("folder-count")).toHaveTextContent("2"); + expect(folderRequests).toHaveLength(2); + expect(folderRequests[0]?.searchParams.get("limit")).toBe("100"); + expect(folderRequests[1]?.searchParams.get("cursor")).toBe("next-folder"); + }); + + it("orchestrates create, open, rename, and current-folder delete", async () => { + const calls: Array<{ url: string; method: string; body?: string }> = []; + const mockedFetch = globalThis.fetch; + globalThis.fetch = (input, init) => { + calls.push({ + url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + method: init?.method ?? "GET", + body: typeof init?.body === "string" ? init.body : undefined, + }); + return mockedFetch(input, init); + }; + + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media" }); + const navigateSpy = vi.spyOn(router, "navigate"); + const screen = await render(); + await expect.element(screen.getByTestId("folder-count")).toHaveTextContent("1"); + + await screen.getByRole("button", { name: "Create mock folder" }).click(); + await vi.waitFor(() => { + const request = calls.find((call) => call.method === "POST" && call.url.endsWith("/folders")); + expect(request?.body && JSON.parse(request.body)).toEqual({ name: "Created" }); + }); + + await screen.getByRole("button", { name: "Open mock folder" }).click(); + await vi.waitFor(() => { + expect(router.state.location.search).toEqual({ folder: "folder-one" }); + expect(screen.getByTestId("current-folder-id").element()).toHaveTextContent("folder-one"); + expect(navigateSpy).toHaveBeenCalledWith(expect.objectContaining({ resetScroll: false })); + }); + + await screen.getByRole("button", { name: "Back to Main" }).click(); + await vi.waitFor(() => { + expect(router.state.location.search).toEqual({}); + expect(navigateSpy).toHaveBeenCalledWith( + expect.objectContaining({ search: { folder: undefined }, resetScroll: false }), + ); + }); + await screen.getByRole("button", { name: "Open mock folder" }).click(); + + await screen.getByRole("button", { name: "Rename current folder" }).click(); + await vi.waitFor(() => { + const request = calls.find( + (call) => call.method === "PUT" && call.url.endsWith("/folders/folder-one"), + ); + expect(request?.body && JSON.parse(request.body)).toEqual({ name: "Renamed" }); + }); + + await screen.getByRole("button", { name: "Delete current folder" }).click(); + await vi.waitFor(() => { + expect( + calls.some((call) => call.method === "DELETE" && call.url.endsWith("/folders/folder-one")), + ).toBe(true); + expect(router.state.location.search).toEqual({}); + expect(navigateSpy).toHaveBeenCalledWith( + expect.objectContaining({ replace: true, resetScroll: false }), + ); + }); + }); + + it("allows authors to move their own media but not another user's media", async () => { + mockFetch.on("GET", "/_emdash/api/auth/me", { + data: { id: "user_01", role: 30 }, + }); + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media" }); + const screen = await render(); + + await expect.element(screen.getByTestId("can-move-own-media")).toHaveTextContent("yes"); + await expect.element(screen.getByTestId("can-move-other-media")).toHaveTextContent("no"); + }); + + it("replaces a missing direct folder URL with Main library once", async () => { + mockFetch.on( + "GET", + "/_emdash/api/media/folders/missing-folder", + { error: { code: "NOT_FOUND", message: "Media folder not found" } }, + 404, + ); + const requests: string[] = []; + const mockedFetch = globalThis.fetch; + globalThis.fetch = (input, init) => { + requests.push( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + ); + return mockedFetch(input, init); + }; + + const { router, TestApp } = buildRouter(); + await router.navigate({ to: "/media", search: { folder: "missing-folder" } }); + const screen = await render(); + + await vi.waitFor(() => { + expect(router.state.location.search).toEqual({}); + expect( + requests.some( + (rawUrl) => + new URL(rawUrl, "http://localhost").searchParams.get("folderId") === "unfiled", + ), + ).toBe(true); + }); + await expect.element(screen.getByText("Folder no longer exists")).toBeInTheDocument(); + }); + it("recovers an emptied later page without exposing an invalid page number", async () => { const mockedFetch = globalThis.fetch; let requestedSecondPage = false; diff --git a/packages/core/src/api/errors.ts b/packages/core/src/api/errors.ts index 660521dc26..70e61e6dbe 100644 --- a/packages/core/src/api/errors.ts +++ b/packages/core/src/api/errors.ts @@ -87,6 +87,7 @@ export const ErrorCode = { MEDIA_UPDATE_ERROR: "MEDIA_UPDATE_ERROR", MEDIA_DELETE_ERROR: "MEDIA_DELETE_ERROR", MEDIA_FOLDER_LIST_ERROR: "MEDIA_FOLDER_LIST_ERROR", + MEDIA_FOLDER_GET_ERROR: "MEDIA_FOLDER_GET_ERROR", MEDIA_FOLDER_CREATE_ERROR: "MEDIA_FOLDER_CREATE_ERROR", MEDIA_FOLDER_UPDATE_ERROR: "MEDIA_FOLDER_UPDATE_ERROR", MEDIA_FOLDER_DELETE_ERROR: "MEDIA_FOLDER_DELETE_ERROR", diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index 15e9833ba8..be8049b287 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -62,6 +62,7 @@ export { } from "./media.js"; export { handleMediaFolderList, + handleMediaFolderGet, handleMediaFolderCreate, handleMediaFolderUpdate, handleMediaFolderDelete, diff --git a/packages/core/src/api/handlers/media-folders.ts b/packages/core/src/api/handlers/media-folders.ts index 0d34167ff1..7374479590 100644 --- a/packages/core/src/api/handlers/media-folders.ts +++ b/packages/core/src/api/handlers/media-folders.ts @@ -13,7 +13,7 @@ const UNIQUE_VIOLATION_RE = export async function handleMediaFolderList( db: Kysely, - options: { limit?: number; cursor?: string } = {}, + options: { limit?: number; cursor?: string; q?: string } = {}, ): Promise> { try { const result = await new MediaFolderRepository(db).findMany(options); @@ -29,6 +29,24 @@ export async function handleMediaFolderList( } } +export async function handleMediaFolderGet( + db: Kysely, + id: string, +): Promise> { + try { + const item = await new MediaFolderRepository(db).findById(id); + if (!item) { + return { success: false, error: { code: "NOT_FOUND", message: "Media folder not found" } }; + } + return { success: true, data: { item } }; + } catch { + return { + success: false, + error: { code: "MEDIA_FOLDER_GET_ERROR", message: "Failed to get media folder" }, + }; + } +} + export async function handleMediaFolderCreate( db: Kysely, input: { name: string }, diff --git a/packages/core/src/api/openapi/document.ts b/packages/core/src/api/openapi/document.ts index e76a23ce47..992d877e91 100644 --- a/packages/core/src/api/openapi/document.ts +++ b/packages/core/src/api/openapi/document.ts @@ -742,6 +742,24 @@ function buildMediaPaths(maxUploadSize: number) { }, }, "/_emdash/api/media/folders/{id}": { + get: { + operationId: "getMediaFolder", + summary: "Get a media folder", + tags: ["Media"], + requestParams: { + path: z.object({ id: mediaFolderIdSchema.meta({ description: "Media folder ID" }) }), + }, + responses: { + "200": { + description: "Media folder", + content: { + [JSON_CONTENT]: { schema: successEnvelope(mediaFolderResponseSchema) }, + }, + }, + ...authErrors, + ...standardErrors(400, 404, 500), + }, + }, put: { operationId: "updateMediaFolder", summary: "Update a media folder", diff --git a/packages/core/src/api/schemas/media.ts b/packages/core/src/api/schemas/media.ts index 4d3ecdc399..b6da530958 100644 --- a/packages/core/src/api/schemas/media.ts +++ b/packages/core/src/api/schemas/media.ts @@ -64,7 +64,9 @@ export const mediaUpdateBody = z export const mediaFolderIdSchema = z.string().min(1).max(64); -export const mediaFolderListQuery = cursorPaginationQuery.meta({ id: "MediaFolderListQuery" }); +export const mediaFolderListQuery = cursorPaginationQuery + .extend({ q: z.string().trim().min(1).max(200).optional() }) + .meta({ id: "MediaFolderListQuery" }); const mediaFolderNameSchema = z.string().refine( (value) => { diff --git a/packages/core/src/astro/routes/api/media/folders/[id].ts b/packages/core/src/astro/routes/api/media/folders/[id].ts index 7fa355135d..a0e230ec5d 100644 --- a/packages/core/src/astro/routes/api/media/folders/[id].ts +++ b/packages/core/src/astro/routes/api/media/folders/[id].ts @@ -2,7 +2,11 @@ import type { APIRoute } from "astro"; import { requirePerm } from "#api/authorize.js"; import { apiError, unwrapResult } from "#api/error.js"; -import { handleMediaFolderDelete, handleMediaFolderUpdate } from "#api/handlers/media-folders.js"; +import { + handleMediaFolderDelete, + handleMediaFolderGet, + handleMediaFolderUpdate, +} from "#api/handlers/media-folders.js"; import { isParseError, parseBody } from "#api/parse.js"; import { mediaFolderBody, mediaFolderIdSchema } from "#api/schemas.js"; @@ -15,6 +19,17 @@ function parseFolderId(id: string | undefined): string | Response { : apiError("VALIDATION_ERROR", "Invalid media folder ID", 400); } +export const GET: APIRoute = async ({ params, locals }) => { + const { emdash, user } = locals; + const denied = requirePerm(user, "media:read"); + if (denied) return denied; + if (!emdash) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + + const id = parseFolderId(params.id); + if (id instanceof Response) return id; + return unwrapResult(await handleMediaFolderGet(emdash.db, id)); +}; + export const PUT: APIRoute = async ({ params, request, locals }) => { const { emdash, user } = locals; const denied = requirePerm(user, "media:edit_any"); diff --git a/packages/core/src/astro/routes/api/media/folders/index.ts b/packages/core/src/astro/routes/api/media/folders/index.ts index c464d34c96..ef1e496910 100644 --- a/packages/core/src/astro/routes/api/media/folders/index.ts +++ b/packages/core/src/astro/routes/api/media/folders/index.ts @@ -17,7 +17,11 @@ export const GET: APIRoute = async ({ request, locals }) => { const query = parseQuery(new URL(request.url), mediaFolderListQuery); if (isParseError(query)) return query; return unwrapResult( - await handleMediaFolderList(emdash.db, { limit: query.limit, cursor: query.cursor }), + await handleMediaFolderList(emdash.db, { + limit: query.limit, + cursor: query.cursor, + q: query.q, + }), ); }; diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index c2efa423e0..8290a3aa51 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -876,15 +876,25 @@ export class EmDashClient { /** List media folders */ async mediaFolderList( - options: { limit?: number; cursor?: string } = {}, + options: { limit?: number; cursor?: string; q?: string } = {}, ): Promise> { const params = new URLSearchParams(); if (options.limit !== undefined) params.set("limit", String(options.limit)); if (options.cursor !== undefined) params.set("cursor", options.cursor); + if (options.q !== undefined) params.set("q", options.q); const qs = params.toString(); return this.request>("GET", `/media/folders${qs ? `?${qs}` : ""}`); } + /** Get one media folder */ + async mediaFolderGet(id: string): Promise { + const data = await this.request<{ item: MediaFolder }>( + "GET", + `/media/folders/${encodeURIComponent(id)}`, + ); + return data.item; + } + /** Create a media folder */ async mediaFolderCreate(name: string): Promise { const data = await this.request<{ item: MediaFolder }>("POST", "/media/folders", { name }); diff --git a/packages/core/src/database/repositories/media-folders.ts b/packages/core/src/database/repositories/media-folders.ts index 6b927269b5..3b72439c05 100644 --- a/packages/core/src/database/repositories/media-folders.ts +++ b/packages/core/src/database/repositories/media-folders.ts @@ -1,4 +1,4 @@ -import type { Kysely } from "kysely"; +import { sql, type Kysely } from "kysely"; import { ulid } from "ulidx"; import type { Database } from "../types.js"; @@ -12,6 +12,15 @@ export interface MediaFolder { export interface FindManyMediaFoldersOptions { limit?: number; cursor?: string; + q?: string; +} + +function escapeLike(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_"); +} + +function normalizeFolderSearch(value: string): string { + return value.trim().normalize("NFKC").toLowerCase(); } function normalizeFolderName(name: string): { name: string; nameKey: string } { @@ -37,6 +46,12 @@ export class MediaFolderRepository { .orderBy("id", "asc") .limit(limit + 1); + const term = normalizeFolderSearch(options.q ?? ""); + if (term) { + const pattern = `%${escapeLike(term)}%`; + query = query.where("name_key", "like", sql`${pattern} escape '\\'`); + } + if (options.cursor !== undefined) { const { orderValue: nameKey, id } = decodeCursor(options.cursor); query = query.where((eb) => diff --git a/packages/core/tests/unit/api/media-folders-handlers.test.ts b/packages/core/tests/unit/api/media-folders-handlers.test.ts index 1c59911728..090e6f8f59 100644 --- a/packages/core/tests/unit/api/media-folders-handlers.test.ts +++ b/packages/core/tests/unit/api/media-folders-handlers.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, expect, it } from "vitest"; import { handleMediaFolderCreate, handleMediaFolderDelete, + handleMediaFolderGet, handleMediaFolderList, handleMediaFolderUpdate, } from "../../../src/api/handlers/media-folders.js"; @@ -48,6 +49,42 @@ describeEachDialect("media folder handlers", (dialect) => { }); }); + it("normalizes and applies folder-name search before pagination", async () => { + await handleMediaFolderCreate(ctx.db, { name: "Archive" }); + await handleMediaFolderCreate(ctx.db, { name: "Résumé" }); + const options = { q: " re\u0301su " }; + + const result = await handleMediaFolderList(ctx.db, options); + + expect(result).toMatchObject({ + success: true, + data: { items: [{ name: "Résumé" }] }, + }); + }); + + it("treats folder-search wildcards literally", async () => { + await handleMediaFolderCreate(ctx.db, { name: "100% Real" }); + await handleMediaFolderCreate(ctx.db, { name: "100 Percent" }); + + const result = await handleMediaFolderList(ctx.db, { q: "%" }); + + expect(result).toMatchObject({ + success: true, + data: { items: [{ name: "100% Real" }] }, + }); + }); + + it("gets one folder and returns not found for an unknown ID", async () => { + const created = await handleMediaFolderCreate(ctx.db, { name: "Current" }); + if (!created.success) throw new Error("expected folder create success"); + + expect(await handleMediaFolderGet(ctx.db, created.data.item.id)).toEqual(created); + expect(await handleMediaFolderGet(ctx.db, "missing-folder")).toMatchObject({ + success: false, + error: { code: "NOT_FOUND" }, + }); + }); + it("normalizes names and maps create or rename collisions to conflicts", async () => { const created = await handleMediaFolderCreate(ctx.db, { name: " Photos " }); expect(created).toMatchObject({ success: true, data: { item: { name: "Photos" } } }); diff --git a/packages/core/tests/unit/api/media-folders-routes.test.ts b/packages/core/tests/unit/api/media-folders-routes.test.ts index fcac50f692..a446b5c4af 100644 --- a/packages/core/tests/unit/api/media-folders-routes.test.ts +++ b/packages/core/tests/unit/api/media-folders-routes.test.ts @@ -5,6 +5,7 @@ import { GET as listMedia } from "../../../src/astro/routes/api/media.js"; import { PUT as updateMedia } from "../../../src/astro/routes/api/media/[id].js"; import { DELETE as deleteFolder, + GET as getFolder, PUT as updateFolder, } from "../../../src/astro/routes/api/media/folders/[id].js"; import { @@ -151,6 +152,30 @@ describe("media folder routes", () => { ).toMatchObject({ status: 200 }); }); + it("allows readers to get one folder and validates direct folder IDs", async () => { + const folder = await new MediaFolderRepository(ctx.db).create("Direct"); + const request = new Request(`http://localhost/_emdash/api/media/folders/${folder.id}`); + + const response = await getFolder( + routeContext(request, Role.SUBSCRIBER, { id: folder.id }) as Parameters[0], + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ data: { item: folder } }); + + const missing = await getFolder( + routeContext(request, Role.SUBSCRIBER, { id: "missing-folder" }) as Parameters< + typeof getFolder + >[0], + ); + expect(missing.status).toBe(404); + + const invalidId = "x".repeat(65); + const invalid = await getFolder( + routeContext(request, Role.SUBSCRIBER, { id: invalidId }) as Parameters[0], + ); + expect(invalid.status).toBe(400); + }); + it("maps unfiled list requests to Main library", async () => { const handleMediaList = vi.fn().mockResolvedValue({ success: true, data: { items: [] } }); const request = new Request("http://localhost/_emdash/api/media?folderId=unfiled"); diff --git a/packages/core/tests/unit/api/openapi.test.ts b/packages/core/tests/unit/api/openapi.test.ts index c9d565aa5a..232729adf2 100644 --- a/packages/core/tests/unit/api/openapi.test.ts +++ b/packages/core/tests/unit/api/openapi.test.ts @@ -65,6 +65,16 @@ describe("OpenAPI document generation", () => { "500": expect.any(Object), }), ); + expect(folders?.get?.parameters).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "q", in: "query" })]), + ); + expect(folder?.get?.responses).toEqual( + expect.objectContaining({ + "200": expect.any(Object), + "400": expect.any(Object), + "404": expect.any(Object), + }), + ); expect(folders?.post?.responses).toEqual( expect.objectContaining({ "201": expect.any(Object), diff --git a/packages/core/tests/unit/client/client.test.ts b/packages/core/tests/unit/client/client.test.ts index cbe189754b..fb666f25c2 100644 --- a/packages/core/tests/unit/client/client.test.ts +++ b/packages/core/tests/unit/client/client.test.ts @@ -751,6 +751,9 @@ describe("EmDashClient", () => { const url = new URL(req.url); const text = await req.text(); requests.push({ method: req.method, url, body: text ? JSON.parse(text) : undefined }); + if (req.method === "GET" && url.pathname.endsWith("/media/folders/folder%2Fone")) { + return jsonResponse({ item: { id: "folder/one", name: "One" } }); + } if (req.method === "GET") { return jsonResponse({ items: [{ id: "folder/one", name: "One" }], nextCursor: "next" }); } @@ -766,7 +769,12 @@ describe("EmDashClient", () => { interceptors: [backend], }); - const list = await client.mediaFolderList({ limit: 25, cursor: "after / folder" }); + const list = await client.mediaFolderList({ + limit: 25, + cursor: "after / folder", + q: "photo set", + }); + const fetched = await client.mediaFolderGet("folder/one"); const created = await client.mediaFolderCreate("Created"); const updated = await client.mediaFolderUpdate("folder/one", "Updated"); await client.mediaFolderDelete("folder/one"); @@ -775,14 +783,21 @@ describe("EmDashClient", () => { expect(list).toEqual({ items: [{ id: "folder/one", name: "One" }], nextCursor: "next" }); expect(created).toEqual({ id: "folder/one", name: "Updated" }); expect(updated).toEqual({ id: "folder/one", name: "Updated" }); + expect(fetched).toEqual({ id: "folder/one", name: "One" }); expect(media).toEqual({ id: "media/one", folderId: null }); expect(Object.fromEntries(requests[0]?.url.searchParams ?? [])).toEqual({ limit: "25", cursor: "after / folder", + q: "photo set", }); expect( - requests.slice(1).map(({ method, url, body }) => ({ method, path: url.pathname, body })), + requests.map(({ method, url, body }) => ({ method, path: url.pathname, body })).slice(1), ).toEqual([ + { + method: "GET", + path: "/_emdash/api/media/folders/folder%2Fone", + body: undefined, + }, { method: "POST", path: "/_emdash/api/media/folders", body: { name: "Created" } }, { method: "PUT",
{t`Preview`}
+
+ + {t`Loading folders`} +
+
+ +
+
+
+
+
+ handleNavigationClick(event, onOpen)} + > + + {folder.name} + + + {canEdit && ( + + )} +
+
+ + {t`Type: Folder`} + + + {t`Size is not applicable to folders`} + + + {t`Alt text is not applicable to folders`} +
+
+ {t`Folders could not be loaded.`} + {onRetry && ( + + )} +
+