From 40dc0e541782ec996304e5820eefc27f3805a2e3 Mon Sep 17 00:00:00 2001 From: Vito Date: Thu, 29 Oct 2026 01:14:20 +0000 Subject: [PATCH] feat(collections): add to and remove from collections in the selection bar and the inspector (#331, PR 3) (#435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(saved-views): keep a collection in a saved view Images saved views gain a collection key (#331 D140): empty or a collection id of 1 to 9 digits. Writes refuse favorites together with a collection; older rows read an empty collection, and a stored row with both opens the collection, as the listing does for a URL with both. Part of #331 * feat(collections): store collections in the library database * feat(collections): add, remove and rename collections and keep members through duplicate cleanup * feat(collections): add collection routes and the Images collection filter * feat(images): store images' own categories in the library database (#332) One new table, image_categories, and its category index, created empty by a forward-only migration that reads and writes only its own objects. Startup checks the structure; restore also checks the values. The store sets, clears and reads own categories by file key (the deepest output source holding the file and the path below it), and copies a removed duplicate's category to a kept copy that has none. * feat(images): duplicate cleanup keeps images' own categories (#332) The kept copy's own category wins; a kept copy without one receives the removed copy's, keyed by the deepest output source holding each file. The removed copy's row is never deleted, so a give-back or a failed delete loses nothing. The job log reports how many were kept. * feat(images): set, remove and list images' own categories (#332) PUT /prompt_manager/images/category sets the own category of up to 500 listed files, DELETE removes it (listed or not), and GET /prompt_manager/images/categories lists the values whose file is listed. Targets are the Images listing identity of an output source, checked by media_target (shared with Collections). The routes never touch prompts, recipes or links; POST /prompt_manager/bulk/category is unchanged. * fix(images): key symlinked output folders and keep key work off the event loop (#332) Review fixes: a source matches by its configured spelling too, so a detected output folder that is a symlink keys its files (routes and duplicate cleanup). File keys are computed in the executor. A path no source holds is a plain 403, a lone surrogate a 400. The install repairs a table that lacks its category index instead of refusing to open. * fix(collections): address review findings * feat(images): set or remove a category on all N matching images (#332) The category routes also accept the Images listing filter of "Select all N matching" (#392) instead of explicit items, resolved now and capped at 500 images like an explicit selection. * feat(collections): browse a collection from the sidebar The sidebar gains the concept's COLLECTIONS group (D136): one row per collection with its folder icon, name and listed-member count, without the PROPOSED badge; the label shows once a collection exists. A row opens #/collection/, the Images page filtered to that collection (D137): the collection's name as h1, Collection as breadcrumb and title, Edit to rename or delete it through the saved views' Edit dialog with collection nouns, and a note counting members the sources do not list now. An unknown or deleted collection shows a page that says so with a link to Images. A saved view on a deleted collection lists nothing and says so (MC6, D140). The list is read again after a restore and the counts 300 ms after a change. Part of #331 * test(collections): compare the COLLECTIONS group with the concept The concept hides COLLECTIONS at 950 px and below, which would leave a phone no way to open a collection; production shows the rows there in the wrapped layout of every other sidebar nav (D136). The shell comparison now compares the group instead of filtering it out: the concept fixture seeds Landscapes (12) and Experiments (8), the rows compare by name order, and at phone width the nav compares against the concept's own sidebar-nav layout. The collection rows are checked at 1600x1100 and 390x844 in both themes. Part of #331 * test(vendor): count the #332 host case in the execution matrix image-attribution.spec.mjs gains the image category route case, so the execution config lists 92 tests (3 attribution cases per project). * test(browser): install the slideshow clock before wrapping setTimeout The slideshow fixture wrapped window.setTimeout before page.clock.install(), so the viewer's timer ran on real time and clock.runFor() did not drive it; a late WebKit timer failed 'slideshow exposes exact state labels' on CI. * feat(collections): add and remove from the selection bar Add to collection follows Set category in the Images selection bar and opens the concept's simpleInput ("Add selected images to collection", "Collection name", Add) with the existing names as a datalist. Remove from collection follows it while a collection filter is active and reloads the listing in place, keeping the checked files still listed. Every outcome uses the design's strings (section 7.4). The routes take at most 500 explicit files, so an "all N matching" selection is sent only when every match is loaded, checked and at most 500; otherwise the action toasts "Select at most 500 images." (D138, #331). * fix(collections): address review findings - A list read superseded by a newer one (the 300 ms count read, a restore) no longer shows "could not be loaded": the collection and saved-view pages wait for the list to settle. - A listing that finds the open collection gone shows the deleted page once the list no longer holds it; when the list read fails or still holds it, the page keeps the listing's error and Retry instead of staying blank, and each failed read is reported again. - The missing-members note follows every list read and goes away at zero. - A rename that settles after its Edit dialog closed leaves any dialog opened since alone. - The deleted-collection, list-failed and saved-view failure pages focus their title after a route change, as every page does. Part of #331 * feat(collections): add and remove from the inspector and the viewer The shared inspector's footer ends with Collections… (folder icon) after Prompt history for a listed file, in the Images side panel and the viewer. Its dialog reads the file's membership, lists every collection as the concept's checkbox rows, takes an optional new name, and Save sends one request per change (new name, added, removed). A failure keeps the dialog with the membership read again. On a collection page a change to the open collection reloads the listing at once from the side panel and only when the viewer closes from the viewer. The inspector concept comparison draws the button into the concept after Prompt history (D139, #331). * test(collections): compare the collection page and dialog with the concept images-concept compares Add to collection in place in the selection bar (only Remove links… stays omitted) and the collection page's heading, toolbar and filter row against the concept's #collection-landscapes, with D137's Edit drawn into the concept. collections-concept compares the Add to collection dialog with the concept's simpleInput (D138: "Add", an empty field, the datalist of names), in both themes at 1600 and 390 px, with a mutation check. TESTING.md gains the Collections contract (database, keys, missing members, duplicate move, restore, routes, rules, limits, the PR 1 rulings and the all-matching ruling), D136-D140, and amends D28 and D8; docs/application-redesign.md gets the delivery-status line. The coverage baseline adds the two new modules; optional collection callbacks are null rather than no-op defaults so they do not count as uncovered functions. * test(coverage): keep main's floors for files this PR does not touch coverage:update raised nine floors of untouched files from one run; metadata-autocomplete.js branches then measured 84.84 against 85. * fix(collections): address review findings - M1: after a failed Save the Collections dialog shows the membership loading and keeps Save disabled until the re-read lands, so a stale plan cannot be sent. - M2: the changes a Save made reach the page only when the dialog closes, so a collection page's reload cannot remove the dialog before its failure is read. - L4: a New collection name equal to a listed collection ignoring case is that collection's checkbox, checked; never an add then a remove. - L5: a name that cannot be a collection name is refused on the field; nothing is sent, the checkbox edits stay, no re-read. - L6: focus starts on the field and moves to the first checkbox once listed; closing after a re-render replaced the trigger focuses the inspector's current Collections… button or its heading (openContentDialog takes an optional focusTarget). - L3, L7: TESTING.md D138/D139 describe the stale-source and deleted collection outcomes and the above; the viewer spec asserts no listing request while it is open, and a browser test covers M2. * test(legacy): record the image category routes in the legacy contract snapshot (#332) #332 adds PUT/DELETE /prompt_manager/images/category and GET /prompt_manager/images/categories; the legacy pages' routes are unchanged. * test(duplicates): a removed copy with a collection and a category moves both (#331, #332) * test(collections): map the Add to collection suggestions to the shared dialog's datalist (#331) After the merge with #332 PR 3 the input dialog places its datalist after the field with the id workspace-input-suggestions; the concept mapping follows. --- TESTING.md | 245 +++++++++++- docs/application-redesign.md | 1 + tests/browser/collections-concept.spec.mjs | 108 ++++++ tests/browser/full-inspector.spec.mjs | 25 +- tests/browser/images-concept.spec.mjs | 53 ++- tests/browser/inspector-concept.spec.mjs | 36 +- tests/browser/workspace-collections.spec.mjs | 363 ++++++++++++++++++ tests/browser/workspace-images.spec.mjs | 2 +- tests/browser/workspace-tagging-bulk.spec.mjs | 2 +- tests/js/workspace-file-inspector.test.js | 279 ++++++++++++++ tests/js/workspace-images-selection.test.js | 285 ++++++++++++++ tests/js/workspace-toolbar.test.js | 3 + tools/vendor/frontend-coverage-baseline.json | 12 + web/js/workspace/collections.mjs | 3 +- web/js/workspace/file-inspector.mjs | 19 +- web/js/workspace/images-collections.mjs | 76 ++++ web/js/workspace/images-selection.mjs | 40 +- web/js/workspace/images.mjs | 34 +- web/js/workspace/index.mjs | 2 +- web/js/workspace/inspector-collections.mjs | 189 +++++++++ web/js/workspace/media-viewer.mjs | 16 +- web/js/workspace/workspace-dialogs.mjs | 9 +- 22 files changed, 1758 insertions(+), 44 deletions(-) create mode 100644 tests/browser/collections-concept.spec.mjs create mode 100644 tests/js/workspace-images-selection.test.js create mode 100644 web/js/workspace/images-collections.mjs create mode 100644 web/js/workspace/inspector-collections.mjs diff --git a/TESTING.md b/TESTING.md index 63c45abc..3875439f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1087,7 +1087,8 @@ Recorded shell deviations: palette, then the OS. - **D5**: retired by Activity (#249 Activity-1). The concept's Activity button and Activity row are shown; their differences are D52 to D54. -- **D28**: no COLLECTIONS, and no Design review. The bottom nav is +- **D28**: no Design review. (Amended by #331: the COLLECTIONS group ships; its rows, + counts and phone layout are D136.) The bottom nav is the concept's Activity and System, plus the production Overview hub (its own outline home glyph, not the concept's `activity` pulse, and no count). The connection reads "Your library, on your server" with a neutral muted dot. @@ -1429,8 +1430,10 @@ Recorded deviations on this page (spec §8): "Removed from favorites." and a rollback toast on failure. - **D8**: every Images card has the concept's select checkbox before the star; the `#bulkbar` shows "N selected" and only the real actions in the concept's order (Add - tags, then D85's Auto-tag selected, Set category, Export metadata, Clear). Add to - collection waits for collections; "Remove links…" is dropped. (Amended by #249 PR D: + tags, then D85's Auto-tag selected, Set category, Add to collection, Export metadata, + Clear); "Remove links…" is dropped. (Amended by #331: Add to collection ships, and + Remove from collection follows it while a collection filter is active, see D138.) + (Amended by #249 PR D: Add tags ships, see "Bulk tagging from the selection bar"; #392 adds range, keyboard, touch and "all N matching" selection, see "Selecting many images", D135.) The selection survives Load more and bounded re-mounting and is cleared whenever the collection is replaced (search, @@ -1914,8 +1917,9 @@ targets of the longest legal path (1024 code points, even escaped as `\uXXXX`) f Auto-tagging." (the server's own sentence), so no job starts only to fail. Start validates the fields as Generate does (native validity; nothing is sent). The concept comparison (`images-concept.spec.mjs`) uses the concept page itself as - the oracle, with Add to collection and Remove links… removed and a copy of its Add - tags button reading "Auto-tag selected" inserted after it. + the oracle, with Remove links… removed and a copy of its Add tags button reading + "Auto-tag selected" inserted after it (Add to collection compares in place since + #331, D138). - **D90** (plan D67, ruling T3-bulk): the dialog's **How to apply** radio group (`.field[role=radiogroup]`, radios beside their labels): **Apply automatically** (checked; primary "Start auto-tagging") starts the `image-tags` job @@ -2006,7 +2010,7 @@ busy". Units: | Difference | Record | | --- | --- | -| Add tags ships (Add to collection and Remove links… still omitted) | D8 (amended) | +| Add tags ships (Remove links… still omitted; Add to collection ships with #331, D138) | D8 (amended) | | Auto-tag selected and its dialog | D85 (ruling L1) | | How to apply and the Review each image session | D90 (ruling T3-bulk) | | Suggestions count in the bulk dialog | D91 | @@ -2113,6 +2117,235 @@ npm run --prefix tools/vendor test:workflows -- workspace-tagging-lifecycle.spec --project firefox --project webkit ``` +### Collections contract (#331) + +A collection is a named, hand-picked group of images (milestone 7 "Additional +organization"). It never copies, moves or changes a file, a prompt, a tag, a category +or a favorite. Legacy pages have no collections, so the legacy coverage table gains no +row. + +**Database** (`database/image_collections.py`, mixed into `PromptDatabase`): +- `image_collections(id, name, name_key UNIQUE, created_at)` and + `image_collection_items(collection_id → image_collections ON DELETE CASCADE, + root_key, relative_path, added_at, PRIMARY KEY (collection_id, root_key, + relative_path))`, plus `idx_image_collection_items_file(root_key, relative_path)`. + `migrate_image_collections` runs right after the saved-views migration inside its own + savepoint, creates the three objects empty with no backfill, changes no existing + table and reads only `sqlite_master` and its own tables, so its cost does not depend + on library size (checked with an SQLite authorizer on a 23,000-image library; a + second run is a no-op). Startup validates structure only; restore runs the full value + check. +- A name is stored as typed after normalization: trimmed, each run of whitespace one + space, 1 to 100 characters, no control characters (Unicode `Cc`). `name_key` is + `NFC(casefold(NFC(name)))`, so names are unique ignoring case and Unicode form (a + macOS NFD name matches the Windows NFC one). Restore checks `name_key` only as + non-empty bounded text and does not recompute it: Python's Unicode case tables differ + between versions, so a backup made on another Python must still restore. +- A member names a file by location, exactly as a favorite does: the source folder's + real path (`_favorite_root_key`) plus the path below it (`_favorite_relative_key`), + case-folded on Windows, each at most 4,096 characters. A case-only rename keeps a + Windows member; two Linux files that differ only by case are two members; on macOS a + case-only rename hides the member, as it hides a favorite. The Windows folding is + tested on every OS through `ntpath`. +- Each write is one `BEGIN IMMEDIATE` transaction, so two tabs adding the same new + name create one collection. A collection is created only when at least one file is + inserted. + +**Missing members (MC2, MC3):** a member whose file is moved, renamed or deleted +outside PromptManager, or whose source folder is offline, is hidden and its row kept; +it shows again when a file appears at the same path (which may be a different image, +as with favorites). Nothing follows a moved file, and there is no "forget missing" +cleanup; deleting the collection removes those rows. + +**Duplicate cleanup (MC4):** System › Maintenance duplicate files moves the removed +copy's memberships to the kept copy as a union, in the transaction that moves its +favorite and image tags, and the job log reads "Moved N collection membership(s) to the +kept copies". The legacy gallery page's duplicate delete does not move them (as for +favorites). + +**Restore (MC8):** `collections` joins the restore stage's summary keys and +`image_collections`'s `MAX(rowid)` its fingerprint; membership edits are not +fingerprinted, as favorite and tag edits are not. A backup made before collections +restores with no collections (the tables are created empty); the pre-restore safety +backup keeps the old ones. The restore review shows no collections count. + +**Routes** (`py/api/collections.py`; every answer `{"success": bool, ...}`): + +| Route | Request | Success | +| --- | --- | --- | +| `GET /prompt_manager/collections` | none | `{collections: [{id, name, count, missing}]}` in name-key, then id, order | +| `POST /prompt_manager/collections/items` | `{collection_id}` or `{name}`, and `items` | `{collection: {id, name}, created, added, already, not_found}` | +| `DELETE /prompt_manager/collections/items` | `{collection_id, items}` | `{collection: {id, name}, removed}` | +| `GET /prompt_manager/collections/membership` | query `root_index`, `root_scope`, `path`, each once | `{collection_ids: [...]}` | +| `PATCH /prompt_manager/collections/{id}` | `{name}` | `{collection: {id, name}}` | +| `DELETE /prompt_manager/collections/{id}` | none | `{deleted: true}` | + +- Ids are `[1-9][0-9]{0,8}` (path) or that int (body); anything else, or a body with + both or neither of `collection_id` and `name`, is 400. +- `items` holds 1 to 500 targets `{root_index, root_scope, path}`, each checked by the + shared `py/api/media_target.py` (the image-annotation routes use the same check): 400 + for a malformed shape, 403 for a traversal, drive, backslash or non-media path. A + `root_scope` that no longer names its root is 409 `source-changed` ("The sources + changed. Refresh the images and try again."). Any invalid item refuses the request + before a write; two targets that fold to one key count once. +- Add keeps only files the source's listing holds and counts the rest in `not_found`; + a new name with nothing inserted is 404 `files-not-found` and creates nothing. A name + matching an existing collection ignoring case adds to it. Remove accepts any + well-formed target, listed or not. +- An unknown id is 404 `collection-not-found`; a rename to another collection's name is + 409 `name-taken` (a case variant of its own name is allowed). Any other failure is a + logged 500 with "Collections could not be read." or "The collection could not be + changed.", never the exception text. +- Counts: `count` is the members the current listing of every configured source shows + (a folder configured twice counts once), `missing` the stored rows beyond it, + clamped at 0. Both come from one read snapshot of every member and stored count, so a + write between two reads cannot skew them. When any source's scan or read fails, both + are `null` for every collection and the names still return. +- Every database read and every filesystem call (realpath, the scans) runs in the + executor; nothing touches the filesystem on the event loop. +- The Images listing (`/prompt_manager/images/output?mode=workspace`) accepts + `collection` (`""` or an id) among `LISTING_FILTER_KEYS`; the cursor's scope hash + includes it, so a cursor from one collection is refused on another. An unknown + collection is 404 `collection-not-found`, checked before any per-source read; a + failed members read is the listing's 500, never an unfiltered page. A collection and + `favorites=1` are never both applied (the collection wins). + +**Limits (MC12):** names 1 to 100 characters; at most 500 files per add or remove; no +limit on the number of collections or members. + +**Selection bar and "all N matching" (#331 PR 3 ruling):** the collection routes take +explicit files only (no listing filter). With "All N matching selected" (D135), Add to +collection and Remove from collection send the loaded images only when every match is +loaded and checked and there are at most 500; otherwise they toast "Select at most +500 images." and open nothing. Over 500 explicitly checked files toast the same. + +Recorded deviations: +- **D136** (amends D28): the concept's COLLECTIONS group (`#collections-nav`, + index.html 28–31) without the "PROPOSED" badge. One `links()` row per collection + (folder icon, name, `.nav-count`) in name-key order, linking `#/collection/`. The + count follows the nav-count rules of D51 (hidden until loaded, absent when it could + not be read, never a placeholder) and is re-read 300 ms after the last confirmed add, + remove, rename or delete; the list is re-read after a restore. The label is hidden + while no collection exists. At 950 px and below the concept hides + `#collections-nav`, which would leave a phone no way to open a collection: the rows + show in the wrapped nav (`workspace.css`, `/* D136 */`), the label stays hidden as + every sidebar label is, and the counts are hidden as on every concept nav row at that + width. (Open question for the maintainer: CUJ 15 asks for "with counts" at phone + width; production follows the concept's phone rule and hides them.) A failed list + read keeps the last rows. +- **D137**: the collection page `#/collection/` is Images filtered to the + collection: the collection's name is the h1, "Collection" the breadcrumb and title + (concept a1186–1189), with the concept's Images description and every Images control. + Its heading leads with **Edit** ("Edit collection", "Collection name", Delete… + (danger), Cancel, Save; the saved views' D51 dialog with collection nouns): "Collection + renamed.", a taken name stays in the dialog as "A collection named “X” already + exists.", "The collection could not be renamed. Try again." keeps it open. Delete… + asks "Delete “X”? Its N images stay where they are; only the collection is removed." + (N the stored count) and toasts "Collection deleted." (a 404 counts as deleted), + then opens `#/images`; a failure toasts "The collection could not be deleted. Try + again.". A `p.small` after the description reads "N image(s) in this collection + is/are not in your current sources." when members are missing. An unknown id reads + "This collection no longer exists." with an "Open Images" link; a failed list reads + "Collections could not be loaded. Your saved data has not changed." with Retry. The + page neither restores nor replaces the remembered Images state, and its favorites + filter is off (DC10). +- **D138** (amends D8): **Add to collection** follows Set category and opens the + concept's `simpleInput` ("Add selected images to collection", "Collection name") + with the action "Add" (the concept's "Apply in prototype"), an empty field (the + concept pre-fills its sample "Experiments") of at most 100 characters, and a + `datalist` of the existing names (`#action-input-suggestions`; the field is then a + combobox, and Chromium draws its suggestion arrow inside the field, which the + geometry comparison does not see). An empty field keeps the dialog on it. The toasts: "Added N image(s) to + “X”." plus " N was/were already in it." and " N could not be found.", "All selected + images were already in “X”.", "None of the selected images could be found. Refresh + the images and try again." (404 `files-not-found`), "The sources changed. Refresh + the images and try again." (409; the sources and the listing are read again, which + clears the selection), "That collection no longer exists." and "The images could not + be added. Try again.". Otherwise the selection stays and the listing is not reloaded. + **Remove from collection** (beyond the concept) follows Add while a collection filter + is active (a collection page or a saved view on one), with no confirmation: "Removed + N image(s) from “X”.", then the loaded range is read again in place, keeping the + checked files still listed. "That collection no longer exists." (404, a collection + deleted meanwhile) reads the listing again, which then shows the deleted-collection + page; "The sources changed. Refresh the images and try again." (409) reads the + sources and listing again and clears the selection; "The images could not be removed. + Try again." keeps everything. A second press while it runs is ignored. +- **D139** (beyond the concept): the shared inspector's footer ends with + **Collections…** (the folder icon) after Prompt history, for a listed file only + (`rooted-file` with its scope), in the Images side panel and the viewer's details; + Inspect a file and the Prompt library's galleries have no client store and no + button. Its dialog "Collections": "Loading collections…" while the file's membership + is read ("Collections could not be loaded." with Retry on failure; Save waits), one + concept checkbox row (`label.tag-suggestion`) per collection in sidebar order, + checked where the file is a member ("No collections yet. Name one below." without + any), a "New collection" field (at most 100 characters; a name matching an existing + collection adds to it), then Cancel and Save. Focus starts on the field and moves to + the first checkbox once they are listed. A typed name equal to a listed collection + ignoring case is that collection's checkbox, checked (never an add then a remove); a + name that cannot be a collection name is refused on the field with "The collections + could not be updated. Try again.", nothing is sent and the checkbox edits stay. Save + sends one request per change: the new name, each newly checked collection, each + unchecked one; nothing changed sends nothing and closes. All sent: the dialog closes + with "Collections updated.". A failure keeps it open with "The collections could not + be updated. Try again." and the membership read again, Save waiting until that read + lands; × and Escape wait while it saves. The page hears of the changes that went + through only when the dialog closes (a failure included), so a reload cannot remove + the dialog before its message is read. On a collection page a change to the open + collection then reloads the listing at once from the side panel, and from the viewer + only when the viewer closes, so the viewer keeps its place (no listing request while + it is open). Closing returns focus to the trigger, or, when a re-render replaced it, + to the inspector's current Collections… button or its heading. The + inspector comparison draws the button into the concept's panel footer and full + details after Prompt history (a copy of its Prompt history button with the concept's + folder icon), so the browser lays out what follows. +- **D140**: saved views' Images state gains `collection` (`""` or an id, the `source` + encoding); older rows read `""`, and a write with both `favorites: true` and a + collection is 400. Save view on a collection page stores the id; the view reopens + the collection with its other filters under the view's own name (D51), with Remove + from collection and without the missing note. A view whose collection was deleted + (MC6) lists nothing and reads "The collection this view uses was deleted."; it never + falls back to every image, and the view is not changed. + +| Difference | Record | +| --- | --- | +| COLLECTIONS group, rows, counts, phone width | D136 (amends D28) | +| Collection page, Edit, missing note, deleted page | D137 | +| Add to collection dialog and toasts; Remove from collection | D138 (amends D8) | +| Inspector and viewer Collections… and its dialog | D139 | +| Saved views keep a collection; MC6 empty state | D140 | + +Comparisons: `tests/browser/shell-concept.spec.mjs` compares `#collections-nav` with +two seeded collections ("Landscapes" with 12 listed members, "Experiments" with 8), so +the concept's rows and counts compare as they are, mapping out only the badge and, at +390 px, the concept's hidden nav. `tests/browser/images-concept.spec.mjs` compares Add +to collection in place in the selection bar, and the collection page's heading, +breadcrumb, toolbar and filter row against the concept's `#collection-landscapes` +(the grid is not compared: the concept's members come from categories). +`tests/browser/collections-concept.spec.mjs` compares the bulk dialog with the +concept's `simpleInput` (D138 maps "Apply in prototype" to "Add", the sample value to +empty and adds the datalist), at 1600x1100 and 390x844 in dark and light, with a +mutation check. `tests/browser/inspector-concept.spec.mjs` maps D139. + +Tests: Python `tests/test_image_collections_schema.py` (migration, validation, +restore), `tests/test_image_collections.py` (operations, keys, the duplicate move), +`tests/test_api_collections.py` (routes, rules, counts), `tests/test_api_workspace_images.py` +(the `collection` filter), `tests/test_api_duplicate_files.py`, `tests/test_restore_stage.py`, +`tests/test_saved_views.py`; units `tests/js/workspace-collections.test.js` (the client +store and strings), `tests/js/workspace-images-selection.test.js` (the bar's order, the +500 cap, the all-matching rule and every §7.4 string), `tests/js/workspace-file-inspector.test.js` +(D139's button and dialog), `tests/js/workspace-toolbar.test.js` (the datalist); +browser `tests/browser/workspace-collections.spec.mjs` (CUJ 1–15 on the fixture +`tests/browser/workspace_collections_fixture.py`: twelve Landscapes members and one +missing file, eight Experiments members, one loose file and a saved view on +Experiments), `tests/browser/full-inspector.spec.mjs` (the footer with Collections…). + +```sh +python3 -m unittest tests.test_image_collections_schema tests.test_image_collections tests.test_api_collections -q +node --test tests/js/workspace-collections.test.js tests/js/workspace-images-selection.test.js tests/js/workspace-file-inspector.test.js +npm run --prefix tools/vendor test:workflows -- workspace-collections.spec.mjs collections-concept.spec.mjs \ + images-concept.spec.mjs inspector-concept.spec.mjs shell-concept.spec.mjs --project chromium --project touch +``` + ### Prompt library cards and recorded deviations (#249 PR E) The shelf uses the shared concept toolbar and caption (E1) and the concept's diff --git a/docs/application-redesign.md b/docs/application-redesign.md index 536464d0..a47c3997 100644 --- a/docs/application-redesign.md +++ b/docs/application-redesign.md @@ -13,6 +13,7 @@ code. This plan proposes implementation order and acceptance gates. - [Durable history storage](prompt-history-storage.md) records job attempts and outcomes from [#212](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/212), frozen prompt contexts from [#213](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/213), and observed run-owned output locators from [#214](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/214). Those locators do not verify file bytes or establish gallery-image associations. That association contract and the chronological execution timeline remain pending under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209). - [#215](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/215) brings the shell, Images and natural-height Prompt Library presentation into the approved geometry and themes while retaining their existing operations. [#216](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/216) adds the shared current-file inspector with exact generation facts, scoped provenance, raw/download evidence and loaded-collection viewer controls, including a slideshow that pauses at native video. This delivery does not establish whole-application parity, change the default entry point or constitute a release. Recorded chronological run/context browsing continues under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209); image tagging/review, System consolidation and remaining organization capabilities continue under #196. - Local Inspect (#249, PRs L1–L3) replaces the `#/inspect` handoff with the concept's native "Inspect a file" page: one local image or video previews inline and opens the shared viewer and full inspector on a record read entirely in the browser, with nothing uploaded, imported or stored. The legacy metadata page (`web/metadata.html`, served at `/prompt_manager/gallery.html`) stays reachable from Overview; its capabilities map to the shared inspector, including its one-click COPY ALL summary, which is the shared inspector's "Copy settings summary" (D117, LI-4). PR L3 ([#286](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/286)) shipped that button and local roles equal to the server's resolver (`web/js/workspace/local-resolver.mjs`, TESTING.md "Local roles (#249 PR L3, decision LI-1)"). Recorded deviations D60–D66 and D117 are in [TESTING.md](../TESTING.md). +- Collections ([#331](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/331), three PRs) adds named, hand-picked groups of images, stored in the library database by file location as favorites are: a COLLECTIONS sidebar group whose rows open Images filtered to one collection (`#/collection/`) with rename and delete, Add to collection and Remove from collection in the Images selection bar, a Collections… checklist in the shared inspector for the side panel and the viewer, and saved views that keep a collection. Membership survives restarts, source reordering and System › Maintenance duplicate cleanup; no file is copied, moved or changed. ## Scope and compatibility diff --git a/tests/browser/collections-concept.spec.mjs b/tests/browser/collections-concept.spec.mjs new file mode 100644 index 00000000..65986be0 --- /dev/null +++ b/tests/browser/collections-concept.spec.mjs @@ -0,0 +1,108 @@ +import {test,expect} from './fixtures.mjs'; +import {SIZES,THEMES,seedConceptAt,openConcept,openProduction,diffInventory,mutate} from './concept-inventory.mjs'; + +// #331 D138: the Images selection bar's "Add to collection" dialog against the concept's own (`bulk-collection` → +// simpleInput, app.js a1392–1398 and a1138), on the concept-shaped fixture, at 1600x1100 and 390x844 in both themes. +// Paired screenshots are artifacts only; there are no PNG baselines. +test.use({locale:'en-US',timezoneId:'UTC'}); + +const CARDS='.gallery-body > .image-grid > .media-card'; + +/** + * Runs in the page: the open "Add selected images to collection" dialog: its title, the body's tree, the field's + * label, input and suggestions, and the actions, with the concept's geometry and tokens. + */ +function measureAddDialog() { + const dialog=document.querySelector('dialog#modal[open]'); + const css=(node,...keys)=>node?Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]])):null; + const name=node=>[node.localName,...[...node.classList].sort()].join('.'); + const text=node=>node?.textContent.trim()??null; + const body=dialog.querySelector('#modal-body'); + const field=body.querySelector(':scope > .field'); + const label=field?.querySelector(':scope > label'); + const input=field?.querySelector(':scope > input'); + const list=input?.list??null; + const actions=body.querySelector(':scope > .dialog-actions'); + const rect=dialog.getBoundingClientRect(); + return { + title:text(dialog.querySelector('#modal-title')), + tree:[...body.children].map(name), + field:field&&{tree:[...field.children].map(name),height:Math.round(field.getBoundingClientRect().height), + ...css(field,'display','flexDirection','rowGap','marginBottom')}, + label:label&&{text:text(label),for:label.htmlFor,...css(label,'fontSize','fontWeight','color')}, + input:input&&{id:input.id,type:input.type,value:input.value,maxLength:input.maxLength,list:input.getAttribute('list'), + ...css(input,'height','paddingLeft','fontSize','color','backgroundColor','borderTopColor','borderTopLeftRadius')}, + suggestions:list&&{id:list.id,display:getComputedStyle(list).display,options:[...list.options].map(option=>option.value)}, + actions:{tree:[...actions.children].map(name),...css(actions,'display','justifyContent','columnGap','marginTop'), + buttons:[...actions.children].map(button=>({text:text(button),className:button.className, + ...css(button,'backgroundColor','color','fontSize','paddingTop','paddingLeft','borderTopColor')}))}, + width:Math.round(rect.width),height:Math.round(rect.height), + }; +} + +/** + * D138: the action is "Add" (the concept's "Apply in prototype"), the field starts empty (no sample "Experiments") and + * holds at most 100 characters, and a hidden `datalist` of the existing names (the fixture's two) follows the field: + * the shared input dialog's suggestions list, as Set category's (#332 D140). + */ +function mapAddDialogConcept(reference) { + const [cancel,apply]=reference.actions.buttons; + const [field,...rest]=reference.tree; + return {...reference,tree:[field,'datalist',...rest], + input:{...reference.input,value:'',maxLength:100,list:'workspace-input-suggestions'}, + suggestions:{id:'workspace-input-suggestions',display:'none',options:['Experiments','Landscapes']}, + actions:{...reference.actions,buttons:[cancel,{...apply,text:'Add'}]}}; +} + +async function openAddDialog(page,main) { + await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check(); + await page.locator(`${main} #bulkbar`).getByRole('button',{name:'Add to collection',exact:true}).click(); + await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Add selected images to collection'); + await page.mouse.move(0,0); + return page.evaluate(measureAddDialog); +} + +async function conceptAddDialog(page,theme) { + await openConcept(page,'images',theme); + return openAddDialog(page,'#main'); +} + +async function productionAddDialog(page,theme) { + await openProduction(page,'images',theme); + await expect(page.locator(CARDS).first()).toBeVisible(); + return openAddDialog(page,'#workspace-main'); +} + +test.beforeEach(async({page,request},testInfo)=>{testInfo.manifest=await seedConceptAt(page,request);}); + +for(const theme of THEMES)for(const [width,height] of SIZES) { + test(`add to collection dialog matches the concept ${theme} ${width}x${height}`,async({page},testInfo)=>{ + await page.setViewportSize({width,height}); + const concept=await conceptAddDialog(page,theme); + await page.screenshot({path:testInfo.outputPath(`concept-add-to-collection-${theme}-${width}.png`)}); + const production=await productionAddDialog(page,theme); + await page.screenshot({path:testInfo.outputPath(`production-add-to-collection-${theme}-${width}.png`)}); + expect(concept.input.value,'the concept pre-fills its sample').toBe('Experiments'); + expect(diffInventory(mapAddDialogConcept(concept),production)).toEqual([]); + }); +} + +test('add to collection comparison detects mutations',async({page})=>{ + await page.setViewportSize({width:1600,height:1100}); + const concept=await conceptAddDialog(page,'dark'); + const expected=mapAddDialogConcept(concept); + const production=await productionAddDialog(page,'dark'); + expect(diffInventory(expected,production)).toEqual([]); + // Unmapped, the concept differs only where D138 is recorded. + expect(diffInventory(concept,production).map(item=>item.path).sort()).toEqual(expect.arrayContaining( + ['actions.buttons.1.text','tree.1','input.list','input.maxLength','input.value'])); + const paths=async()=>diffInventory(expected,await page.evaluate(measureAddDialog)).map(item=>item.path); + await page.evaluate(()=>{document.querySelector('dialog#modal[open] label[for="action-input"]').textContent='Name';}); + expect(await paths()).toEqual(['label.text']); + await page.evaluate(()=>{document.querySelector('dialog#modal[open] label[for="action-input"]').textContent='Collection name';}); + const undoStyle=await mutate(page,{css:'dialog#modal #action-input { border-color: rgb(255, 0, 0); } dialog#modal .dialog-actions { gap: 31px; }'}); + expect(await paths()).toEqual(expect.arrayContaining(['input.borderTopColor','actions.columnGap'])); + await undoStyle(); + await page.evaluate(()=>document.querySelector('#workspace-input-suggestions').remove()); + expect(await paths()).toEqual(expect.arrayContaining(['suggestions'])); +}); diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 660a23ad..f134e415 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -145,9 +145,10 @@ test('panel matches the concept order and copy',async({page,request})=>{ await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']); await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); const footer=panel.locator('.detail-footer'); - await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history']); + // #331 D139: every listed file's footer ends with Collections… (folder icon). + await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']); await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`); - await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(1); + await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(2); await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/); // Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format. reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';}; @@ -157,10 +158,11 @@ test('panel matches the concept order and copy',async({page,request})=>{ await selectFile(page,'ambiguous.png');await expect(panel.locator('.prompt-section.positive .prompt-text')).toHaveText('embedded positive'); await expect(panel.locator('span.chip.blue')).toHaveText('Embedded parameters'); await expect(panel.locator('.panel-heading .chip')).toHaveText('File extension PNG');await expect(panel.locator('.panel-heading .chip .ca-visually-hidden')).toHaveText('File extension '); - await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI']); - // Unlinked: no badge, category, tags, pair or history. + await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Collections…']); + // Unlinked: no badge, category, tags, pair or history; the footer holds only Collections… (D139). reads.patch=null;await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png'); - expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner'], + await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']); + expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'], inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']}); await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded')); // Ambiguous saved association: the same honest absence. @@ -258,7 +260,7 @@ test('pair send and history appear only with real roles and link',async({page,re await expect(panel.getByRole('button',{name:'Send prompt pair to ComfyUI: explicit-empty.png',exact:true})).toBeVisible(); await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0); await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png'); - await expect(panel.locator('.detail-footer')).toHaveCount(0);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0); + await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0); }); test('no provenance, raw or download controls in the side panel',async({page,request})=>{ @@ -286,8 +288,8 @@ test('viewer details hold provenance, raw records and downloads',async({page,req await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page); await fullFixture(page,request);let details=await viewerDetails(page,'complete.png'); // The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures, - // then the pair send and history buttons (D20). - expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'], + // then the pair send and history buttons (D20) and D139's Collections…. + expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button','button'], inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']}); await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary'); await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal'); @@ -298,7 +300,7 @@ test('viewer details hold provenance, raw records and downloads',async({page,req await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']); await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']); - await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history']); + await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']); await expect(details.locator(':scope > button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`); // File & provenance: the honest facts and the concept's "Prompt evidence" line; no per-fact source lines. const provenance=details.locator('details').first();await provenance.locator('summary').click(); @@ -323,9 +325,10 @@ test('viewer details hold provenance, raw records and downloads',async({page,req await expect(details.getByText('Prompt evidence: embedded parameters',{exact:true})).toBeVisible(); await expect(details.locator('.panel-heading .chip')).toHaveText('File extension PNG'); await expect(details.locator('details dl.meta-grid dd').nth(4)).toHaveText('PNG extension');await expect(details).not.toContainText('.PNG'); - // No badge, no line; no resolved pair or link, no buttons after the details. + // No badge, no line; no resolved pair or link: after the details only D139's Collections…. reads.patch=null;await page.keyboard.press('Escape');details=await viewerDetails(page,'empty.png'); - expect((await viewerStructure(details)).outer).toEqual(['div']); + expect((await viewerStructure(details)).outer).toEqual(['div','button']); + await expect(details.locator(':scope > button')).toHaveText(['Collections…']); await details.getByText('File & provenance',{exact:true}).click();await expect(details).not.toContainText('Prompt evidence'); // The history button leaves the viewer for the prompt's history. await page.keyboard.press('Escape');details=await viewerDetails(page,'complete.png'); diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index 6b41ca70..b2156f52 100644 --- a/tests/browser/images-concept.spec.mjs +++ b/tests/browser/images-concept.spec.mjs @@ -30,9 +30,12 @@ function captionDay(seconds,now=new Date()) { return `${name} · ${date.toLocaleDateString('en-US',options)}`; } -/** D8 (amended by #249 PR D): the selection bar's real actions only; Add to collection and Remove links… are left out. */ -const OMITTED_BULK=Object.freeze(['Add to collection','Remove links…']); -const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`)); +/** + * D8 (amended by #249 PR D and #331): the selection bar's real actions only; Remove links… is left out. Add to + * collection compares in place (D138). + */ +const OMITTED_BULK=Object.freeze(['Remove links…']); +const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`)); /** D85: Auto-tag selected follows the concept's Add tags, a concept button of its own text. */ const AUTO_TAG_CLONE=Object.freeze([{selector:'#bulkbar [data-action="bulk-tags"]',text:'Auto-tag selected'}]); @@ -464,7 +467,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti 'Add to collection','Export metadata','Remove links…','Clear']); expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]); expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Add tags','Auto-tag selected', - 'Set category','Export metadata','Clear']); + 'Set category','Add to collection','Export metadata','Clear']); expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]); expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]); // The bar comparison notices a change to the production bar. @@ -472,6 +475,48 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop'); }); +/** + * #331 D137: the collection page's Edit leads the heading actions, drawn in the concept as a plain copy of its Import + * button reading "Edit" (no icon, no action), so the browser lays out what follows it; returns an undo. + */ +async function drawCollectionEdit(page) { + await page.evaluate(()=>{ + const actions=document.querySelector('#main .heading-actions'); + const edit=actions.querySelector('button').cloneNode(false); + for(const key of Object.keys(edit.dataset))delete edit.dataset[key]; + edit.textContent='Edit';actions.prepend(edit);window.__collectionEdit=edit; + }); + await settle(page); + return ()=>page.evaluate(()=>{window.__collectionEdit.remove();delete window.__collectionEdit;}); +} + +// #331 D137: `#/collection/` against the concept's `#collection-landscapes` (app.js a1152–1157, a1186–1189): the +// collection's name as the h1, "Collection" as the breadcrumb, the Images description, toolbar and filter row. The grid +// is not compared: the concept's members come from its categories, production's from the stored collection. +for(const [width,height] of SIZES)for(const theme of THEMES)test(`collection page heading, toolbar and filter row match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{ + await page.setViewportSize({width,height}); + const manifest=testInfo.manifest; + await openConcept(page,'collection-landscapes',theme);await page.mouse.move(0,0);await settle(page); + await expect(page.locator('#main #page-title')).toHaveText('Landscapes'); + await expect(page.locator('#breadcrumb')).toHaveText('Collection'); + const restore=await drawCollectionEdit(page); + const reference=await inventoryOf(page,'#main'); + await restore(); + await openProduction(page,`collection/${manifest.collections.Landscapes}`,theme);await page.mouse.move(0,0); + await expect(page.locator(CARDS)).toHaveCount(12); + await expect(page.locator('#workspace-main #page-title')).toHaveText('Landscapes'); + await expect(page.locator('#workspace-breadcrumb')).toHaveText('Collection'); + await expect(page).toHaveTitle('Collection · Prompt Manager'); + await settle(page); + const actual=await inventoryOf(page,'#workspace-main'); + expect.soft(actual.heading.actions.buttons.map(item=>item.text),'production heading actions').toEqual(['Edit','Import','New prompt']); + expect.soft(reference.heading.actions.buttons.map(item=>item.text),'mapped concept heading actions').toEqual(['Edit','Import','New prompt']); + expect.soft(compare(reference,actual,manifest,['heading','toolbar','filterRow']),'collection page').toEqual([]); + // The comparison notices a lost Edit. + await mutate(page,{remove:['#workspace-main .heading-actions > button:first-child']}); + expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['heading']).length).toBeGreaterThan(0); +}); + /** #332: an own category on one listed image, set through the real route (PUT /prompt_manager/images/category). */ const IMAGE_CATEGORY='Portraits'; async function setImageCategory(request,path,category) { diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs index 0ca21bcb..3b47cd97 100644 --- a/tests/browser/inspector-concept.spec.mjs +++ b/tests/browser/inspector-concept.spec.mjs @@ -51,10 +51,11 @@ function withCategoryScope(inventory) { } /** - * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117 and D146); everything else must + * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139 and D146); everything else must * equal production. `reference.viewerPlain` is the concept's own full details * with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at - * runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. + * runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. D139's + * "Collections…" is drawn after Prompt history in both the concept's panel footer and its full details. */ export function mapInspectorConcept(reference,{viewport}) { const {panel,viewerPlain,fileControls}=reference; @@ -111,6 +112,22 @@ async function openProductionPanel(page,theme) { await settle(page); } +/** + * #331 D139: production's "Collections…" after Prompt history in `selector`'s buttons, drawn in the concept as its own + * Prompt history button (no action) with the concept's folder icon (its Images filter chip's); returns an undo. + */ +async function withCollectionsButton(page,selector) { + await page.evaluate(selector=>{ + const history=[...document.querySelectorAll(`${selector} > button`)].find(button=>button.textContent.includes('Prompt history')); + const button=history.cloneNode(false); + for(const key of Object.keys(button.dataset))delete button.dataset[key]; + button.append(document.querySelector('.filter-row .chip > svg.icon').cloneNode(true),' Collections…'); + history.after(button);(window.__collectionsButtons??=[]).push(button); + },selector); + await settle(page); + return async()=>{await page.evaluate(()=>{for(const button of window.__collectionsButtons??[])button.remove();delete window.__collectionsButtons;});}; +} + /** * The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy * settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON", @@ -129,8 +146,9 @@ async function plainViewerChips(page) { summary.textContent='Copy settings summary';summary.classList.add('settings-summary'); document.querySelector('#viewer-details > div > dl.meta-grid').after(summary);window.__summaryButton=summary; }); - await settle(page); + const restoreCollections=await withCollectionsButton(page,'#viewer-details'); return async()=>{ + await restoreCollections(); await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips; window.__summaryButton.remove();delete window.__summaryButton;}); await restoreSource();await restoreTargets(); @@ -169,10 +187,12 @@ async function viewerOf(page,concept,mutation=null) { return {viewerDetails,viewerPlain,fileControls}; } async function inventory(page,concept=false) { - // D146: the concept panel's category chip with its visible origin, laid out by the concept page itself. + // D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history. const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null; + const restoreCollections=concept?await withCollectionsButton(page,'#image-inspector > .detail-footer'):null; if(concept)await settle(page); const {panel}=await inspectorOf(page); + await restoreCollections?.(); await restoreSource?.(); const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept); return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls}; @@ -340,7 +360,7 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me await expect(rows.first().locator('.chip .chip-source')).toHaveText(' · prompt'); await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']); await expect(rows.nth(1).locator('span.chip')).toHaveCount(0); - await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']); + await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']); }); // #391: a tag chip replaces the collection; until its listing answers the caption says it is loading, never that the @@ -414,6 +434,12 @@ test('inspector comparison detects mutations',async({page})=>{ expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/); assertInspectorMatch(reference,await viewerMutated(),viewport); + // D139: Collections… is part of the mapped concept, so losing it is a difference. + const restoreButton=await mutate(page,{remove:['#image-inspector > .detail-footer > button:last-child']}); + const withoutCollections=await measure(); + expect(()=>assertInspectorMatch(reference,withoutCollections,viewport)).toThrow(/panel\.footer/); + await restoreButton();await settle(page); + // D146: the visible origin is part of the mapped concept, so losing it is a difference. const restoreSource=await mutate(page,{remove:['#image-inspector .chip-source']}); const sourceMutated=await measure(); diff --git a/tests/browser/workspace-collections.spec.mjs b/tests/browser/workspace-collections.spec.mjs index b87a55b4..a77564ed 100644 --- a/tests/browser/workspace-collections.spec.mjs +++ b/tests/browser/workspace-collections.spec.mjs @@ -491,3 +491,366 @@ test('a page that says a collection or view could not be opened takes focus afte await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.'); await expect(heading(page)).toBeFocused(); }); + +// #331 D138 (design §7): the selection bar's Add to collection and Remove from collection. +const bar = page => page.locator('#bulkbar > .bulkbar'); +const barButton = (page, name) => bar(page).getByRole('button', { name, exact: true }); +const addDialog = page => page.getByRole('dialog', { name: 'Add selected images to collection', exact: true }); +const addField = page => addDialog(page).getByRole('combobox', { name: 'Collection name', exact: true }); +const check = (page, file) => page.getByRole('checkbox', { name: `Select ${file}`, exact: true }).check(); +const card = (page, path) => page.locator(`article[data-image-key$=":${path}"]`); + +/** Add the checked images under `name` through the bar's dialog. */ +async function addChecked(page, name) { + await barButton(page, 'Add to collection').click(); + await expect(addDialog(page)).toBeVisible(); + await addField(page).fill(name); + await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + await expect(addDialog(page)).toHaveCount(0); +} + +test('Add to collection creates a collection and adds to it ignoring case (CUJ 1–3)', async ({ page, request }) => { + await seed(request); + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(21); + for (const file of ['loose.png', 'landscape_01.png', 'experiment_01.png']) await check(page, file); + await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', + 'Export metadata', 'Clear']); + await barButton(page, 'Add to collection').click(); + // The concept's simpleInput with Add (D138) and the existing names as suggestions. + await expect(addDialog(page).locator('label')).toHaveText('Collection name'); + await expect(addField(page)).toHaveValue(''); + await expect(addField(page)).toHaveAttribute('maxlength', '100'); + await expect(addDialog(page).locator('datalist option')).toHaveCount(2); + expect(await addDialog(page).locator('datalist option').evaluateAll(options => options.map(option => option.value))) + .toEqual(['Experiments', 'Landscapes']); + await expect(addDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Add']); + // An empty name keeps the dialog on its field. + await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + await expect(addField(page)).toBeFocused(); + await addField(page).fill(' LoRa testing '); + await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + await expect(toast(page)).toHaveText('Added 3 images to “LoRa testing”.'); + await expect(bar(page).locator('strong')).toHaveText('3 selected'); + await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('3'); + await expect(rows(page)).toHaveCount(3); + + // CUJ 2: one of two already in it. + await bar(page).getByRole('button', { name: 'Clear', exact: true }).click(); + await check(page, 'loose.png'); + await check(page, 'landscape_02.png'); + await addChecked(page, 'LoRa testing'); + await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”. 1 was already in it.'); + await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('4'); + + // CUJ 3: a case variant joins the same collection. + await bar(page).getByRole('button', { name: 'Clear', exact: true }).click(); + await check(page, 'landscape_03.png'); + await addChecked(page, 'lora TESTING'); + await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”.'); + await expect(rows(page)).toHaveCount(3); + await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('5'); + const created = (await listed(request)).find(item => item.name === 'LoRa testing'); + expect(created).toMatchObject({ count: 5, missing: 0 }); + + // Everything already in it. + await addChecked(page, 'LoRa testing'); + await expect(toast(page)).toHaveText('All selected images were already in “LoRa testing”.'); +}); + +test('Remove from collection takes the checked images off the open collection (CUJ 5)', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Landscapes; + await page.goto(`${workspace}#/collection/${id}`); + await expect(imageCards(page)).toHaveCount(12); + await check(page, 'landscape_01.png'); + await check(page, 'landscape_02.png'); + await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', + 'Remove from collection', 'Export metadata', 'Clear']); + await barButton(page, 'Remove from collection').click(); + await expect(toast(page)).toHaveText('Removed 2 images from “Landscapes”.'); + await expect(imageCards(page)).toHaveCount(10); + await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0); + // The removed files left the listing, so the bar has nothing checked. + await expect(bar(page)).toHaveCount(0); + await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('10'); + // A checked file still listed stays checked across the reload. + await check(page, 'landscape_03.png'); + await check(page, 'landscape_04.png'); + await page.route(`**${API}/items`, async route => { + if (route.request().method() !== 'DELETE') return route.fallback(); + // Remove only landscape_04; landscape_03 stays listed and checked. + const body = route.request().postDataJSON(); + await route.continue({ postData: JSON.stringify({ ...body, items: body.items.filter(item => item.path.endsWith('_04.png')) }) }); + }); + await barButton(page, 'Remove from collection').click(); + await expect(toast(page)).toHaveText('Removed 1 image from “Landscapes”.'); + await expect(imageCards(page)).toHaveCount(9); + await expect(bar(page).locator('strong')).toHaveText('1 selected'); + await expect(page.getByRole('checkbox', { name: 'Select landscape_03.png', exact: true })).toBeChecked(); + // No file changed: the plain Images page still lists all of them. + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(21); +}); + +test('Images without a collection filter has no Remove from collection', async ({ page, request }) => { + const manifest = await seed(request); + await page.goto(`${workspace}#/images`); + await check(page, 'loose.png'); + await expect(barButton(page, 'Add to collection')).toBeVisible(); + await expect(barButton(page, 'Remove from collection')).toHaveCount(0); + // A saved view on a collection has its filter active: Remove shows (design §5). + await page.goto(`${workspace}#/saved/${manifest.saved_view.id}`); + await expect(imageCards(page)).toHaveCount(8); + await check(page, 'experiment_01.png'); + await barButton(page, 'Remove from collection').click(); + await expect(toast(page)).toHaveText('Removed 1 image from “Experiments”.'); + await expect(imageCards(page)).toHaveCount(7); +}); + +test('a failed add keeps the selection; a stale source reloads the images', async ({ page, request }) => { + await seed(request); + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(21); + let reply = { status: 500, body: { success: false, error: 'The collection could not be changed.' } }; + await page.route(`**${API}/items`, route => route.fulfill({ status: reply.status, contentType: 'application/json', + body: JSON.stringify(reply.body) })); + await check(page, 'loose.png'); + await check(page, 'landscape_05.png'); + await addChecked(page, 'Grids'); + await expect(toast(page)).toHaveText('The images could not be added. Try again.'); + await expect(bar(page).locator('strong')).toHaveText('2 selected'); + await expect(rows(page)).toHaveCount(2); + + reply = { status: 404, body: { success: false, code: 'files-not-found', error: 'x' } }; + await addChecked(page, 'Grids'); + await expect(toast(page)).toHaveText('None of the selected images could be found. Refresh the images and try again.'); + await expect(bar(page).locator('strong')).toHaveText('2 selected'); + + reply = { status: 409, body: { success: false, code: 'source-changed', error: 'x' } }; + const reloaded = page.waitForRequest(request => request.url().includes('/prompt_manager/gallery/subfolders')); + await addChecked(page, 'Grids'); + await expect(toast(page)).toHaveText('The sources changed. Refresh the images and try again.'); + await reloaded; + await expect(imageCards(page)).toHaveCount(21); + await expect(bar(page)).toHaveCount(0); +}); + +test('more than 500 or an unloaded "all matching" selection is refused with the cap', async ({ page, request }) => { + await seed(request); + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(21); + // Every loaded image checked and the listing says more match: "Select all N matching" (#392, D135). + await page.route('**/prompt_manager/images/output?*', async route => { + // A next page never answers, so the listing stays at its loaded 21 of 900. + if (new URL(route.request().url()).searchParams.has('cursor')) return; + const response = await route.fetch(); + const data = await response.json(); + await route.fulfill({ response, json: { ...data, total: 900, has_more: true, next_cursor: 'more' } }); + }); + await page.reload(); + await expect(imageCards(page)).toHaveCount(21); + await page.locator('#image-grid .image-open').first().focus(); + await page.keyboard.press('ControlOrMeta+a'); + await bar(page).getByRole('button', { name: 'Select all 900 matching', exact: true }).click(); + await expect(bar(page).locator('strong')).toHaveText('All 900 matching selected'); + await barButton(page, 'Add to collection').click(); + await expect(toast(page)).toHaveText('Select at most 500 images.'); + await expect(addDialog(page)).toHaveCount(0); +}); + +for (const theme of ['dark', 'light']) { + test(`the Add to collection dialog fits 390x844 ${theme}`, async ({ page, request }) => { + await seed(request); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${workspace}#/images`); + await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme); + await check(page, 'loose.png'); + await expect(barButton(page, 'Add to collection')).toBeVisible(); + await barButton(page, 'Add to collection').click(); + await expect(addDialog(page)).toBeVisible(); + const fits = await addDialog(page).evaluate(dialog => { + const box = dialog.getBoundingClientRect(); + return box.left >= 0 && box.right <= window.innerWidth && dialog.scrollWidth <= dialog.clientWidth; + }); + expect(fits).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + await addField(page).fill('Phone'); + await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + await expect(toast(page)).toHaveText('Added 1 image to “Phone”.'); + await expect(row(page, 'Phone')).toBeVisible(); + }); +} + +// #331 D139 (design §8): the shared inspector's Collections… in the Images side panel and in the viewer. +const panel = page => page.locator('#image-inspector'); +const collectionsDialog = page => page.getByRole('dialog', { name: 'Collections', exact: true }); +const choice = (page, name) => collectionsDialog(page).getByRole('checkbox', { name, exact: true }); +const newCollection = page => collectionsDialog(page).getByRole('textbox', { name: 'New collection', exact: true }); + +async function membership(request, path) { + const [image] = (await (await request.get(`/prompt_manager/images/output?mode=workspace&q=${encodeURIComponent(path)}`)).json()).images; + const query = new URLSearchParams({ root_index: String(image.root_index), root_scope: image.root_scope, path: image.path }); + const response = await request.get(`${API}/membership?${query}`); + await expect(response).toBeOK(); + return (await response.json()).collection_ids; +} + +test('Collections… in the side panel adds, removes and creates for one image (CUJ 6)', async ({ page, request }) => { + const manifest = await seed(request); + const { Landscapes, Experiments } = manifest.collections; + await page.goto(`${workspace}#/images`); + // The newest image, landscape_01, is the panel's. + await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('landscape_01.png'); + const open = panel(page).getByRole('button', { name: 'Collections…', exact: true }); + await expect(panel(page).locator('.detail-footer > button').last()).toHaveText('Collections…'); + await open.click(); + await expect(collectionsDialog(page)).toBeVisible(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + await expect(choice(page, 'Experiments')).not.toBeChecked(); + await expect(collectionsDialog(page).locator('label.tag-suggestion')).toHaveText(['Experiments', 'Landscapes']); + await expect(collectionsDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Save']); + await choice(page, 'Landscapes').uncheck(); + await choice(page, 'Experiments').check(); + await newCollection(page).fill('Grids'); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(collectionsDialog(page)).toHaveCount(0); + await expect(toast(page)).toHaveText('Collections updated.'); + await expect(open).toBeFocused(); + await expect(row(page, 'Grids').locator('.nav-count')).toHaveText('1'); + await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11'); + await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('9'); + const grids = (await listed(request)).find(item => item.name === 'Grids'); + expect((await membership(request, 'landscape_01')).sort()).toEqual([Experiments.id, grids.id].sort()); + expect(Landscapes.id).not.toBe(grids.id); + // The Images page lists the same files; the dialog opened again shows the new state. + await expect(imageCards(page)).toHaveCount(21); + await open.click(); + await expect(choice(page, 'Grids')).toBeChecked(); + await expect(choice(page, 'Experiments')).toBeChecked(); + await expect(choice(page, 'Landscapes')).not.toBeChecked(); + // Save without a change sends nothing. + let writes = 0; + page.on('request', request => { if (request.url().includes(`${API}/items`)) writes++; }); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(collectionsDialog(page)).toHaveCount(0); + expect(writes).toBe(0); +}); + +test('Collections… in the viewer keeps the viewer until it closes (CUJ 7)', async ({ page, request }) => { + const manifest = await seed(request); + await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`); + await expect(imageCards(page)).toHaveCount(12); + await page.getByRole('button', { name: 'Open landscape_01.png', exact: true }).click(); + const viewer = page.locator('#viewer-dialog[open]'); + await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png'); + // Review L7: no listing request while the viewer is open. + let listings = 0; + const countListing = request => { if (new URL(request.url()).pathname === '/prompt_manager/images/output') listings++; }; + page.on('request', countListing); + await viewer.locator('#viewer-details').getByRole('button', { name: 'Collections…', exact: true }).click(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + // Review L6: focus moves to the first checkbox once listed. + await expect(choice(page, 'Experiments')).toBeFocused(); + await choice(page, 'Landscapes').uncheck(); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('Collections updated.'); + await expect(collectionsDialog(page)).toHaveCount(0); + // The viewer stays on the image; the page keeps listing it while the viewer is open. + await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png'); + await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11'); + await expect(imageCards(page)).toHaveCount(12); + expect(listings, 'no listing request while the viewer is open').toBe(0); + page.off('request', countListing); + await viewer.getByRole('button', { name: 'Close image viewer', exact: true }).click(); + await expect(viewer).toHaveCount(0); + await expect(imageCards(page)).toHaveCount(11); + await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0); +}); + +test('a failed membership read says so and Retry reads it again', async ({ page, request }) => { + await seed(request); + let failing = true; + await page.route(`**${API}/membership?*`, route => failing + ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Collections could not be read."}' }) + : route.fallback()); + await page.goto(`${workspace}#/images`); + await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click(); + await expect(collectionsDialog(page).getByRole('alert').first()).toHaveText('Collections could not be loaded.'); + await expect(collectionsDialog(page).getByRole('checkbox')).toHaveCount(0); + await expect(collectionsDialog(page).getByRole('button', { name: 'Save', exact: true })).toHaveAttribute('aria-disabled', 'true'); + failing = false; + await collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true }).click(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + await expect(collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true })).toBeHidden(); + // A failed write keeps the dialog with the current membership. + await page.route(`**${API}/items`, route => route.fulfill({ status: 500, contentType: 'application/json', + body: '{"success":false,"error":"The collection could not be changed."}' })); + await choice(page, 'Experiments').check(); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(collectionsDialog(page).getByRole('alert')).toHaveText('The collections could not be updated. Try again.'); + await expect(choice(page, 'Experiments')).not.toBeChecked(); + await expect(choice(page, 'Landscapes')).toBeChecked(); +}); + +test('a file in no collection with no collections at all can start one', async ({ page }) => { + await page.goto(`${workspace}#/images`); + await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click(); + await expect(collectionsDialog(page)).toContainText('No collections yet. Name one below.'); + await newCollection(page).fill('First'); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('Collections updated.'); + await expect(rows(page)).toHaveCount(1); + await expect(row(page, 'First').locator('.nav-count')).toHaveText('1'); +}); + +for (const theme of ['dark', 'light']) { + test(`the Collections dialog fits 390x844 ${theme}`, async ({ page, request }) => { + await seed(request); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${workspace}#/images`); + await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme); + await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + const fits = await collectionsDialog(page).evaluate(dialog => { + const box = dialog.getBoundingClientRect(); + return box.left >= 0 && box.right <= window.innerWidth && dialog.scrollWidth <= dialog.clientWidth; + }); + expect(fits).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + }); +} + +// Review M2: on a collection page, a Save that removed the file from the open collection and then failed keeps its +// dialog and message; the listing is read again only once the dialog closes. +test('a partly failed Save from the side panel keeps its dialog until it closes', async ({ page, request }) => { + const manifest = await seed(request); + const { Experiments, Landscapes } = manifest.collections; + // experiment_01 is in both collections; Experiments sorts first, so its remove is sent before Landscapes'. + const [image] = (await (await request.get('/prompt_manager/images/output?mode=workspace&q=experiment_01')).json()).images; + const item = { root_index: image.root_index, root_scope: image.root_scope, path: image.path }; + await expect(await request.post(`${API}/items`, { data: { collection_id: Landscapes.id, items: [item] } })).toBeOK(); + await page.route(`**${API}/items`, route => { + const body = route.request().postDataJSON(); + return route.request().method() === 'DELETE' && body.collection_id === Landscapes.id + ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' }) + : route.fallback(); + }); + await page.goto(`${workspace}#/collection/${Experiments.id}`); + await expect(imageCards(page)).toHaveCount(8); + await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('experiment_01.png'); + await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + await choice(page, 'Experiments').uncheck(); + await choice(page, 'Landscapes').uncheck(); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(collectionsDialog(page).getByRole('alert')).toHaveText('The collections could not be updated. Try again.'); + // The remove from Experiments went through; the dialog shows it and the page has not reloaded under it. + await expect(choice(page, 'Experiments')).not.toBeChecked(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + await expect(imageCards(page)).toHaveCount(8); + await collectionsDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(collectionsDialog(page)).toHaveCount(0); + await expect(imageCards(page)).toHaveCount(7); + await expect(card(page, 'collections/experiment_01.png')).toHaveCount(0); +}); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 78e5baff..0cb08c9a 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -1791,7 +1791,7 @@ test('card checkboxes select images into the concept bar and Clear empties it', await checkbox(first, 'same.png').check(); await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected'); // D85: Add tags (the concept's) and Auto-tag selected lead the real actions. - await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']); + await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']); // Toggling neither opens the viewer, nor inspects, nor moves focus. await expect(page.locator('dialog[open]')).toHaveCount(0); await expect(checkbox(first, 'same.png')).toBeFocused(); diff --git a/tests/browser/workspace-tagging-bulk.spec.mjs b/tests/browser/workspace-tagging-bulk.spec.mjs index 6f3d114d..b63fdcf5 100644 --- a/tests/browser/workspace-tagging-bulk.spec.mjs +++ b/tests/browser/workspace-tagging-bulk.spec.mjs @@ -73,7 +73,7 @@ const jobStart = page => page.waitForRequest(item => item.method() === 'POST' && test('selection bar shows Add tags then Auto-tag selected before Set category', async ({ page }) => { await select(page, FIRST); - await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']); + await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']); }); test('Add tags runs a job and the cards show the tags after it finishes', async ({ page, request }) => { diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index f74621b1..dd593658 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -384,6 +384,285 @@ test('the D117 wrap rule targets only the settings summary button', async () => assert.doesNotMatch(css, /#viewer-details > div > button/); }); +// #331 D139 (design §8): the shared inspector's "Collections…" footer button and its checklist dialog. +const collectionsDialog = await import('../../web/js/workspace/inspector-collections.mjs').catch(() => null); +const dialogModule = () => { assert.ok(collectionsDialog, 'inspector-collections.mjs is not implemented'); return collectionsDialog; }; +const COLLECTION_SCOPE = 'a'.repeat(64); +const FILE = {root_index:2, root_scope:COLLECTION_SCOPE, path:'a.png'}; +function collectionsStore({member = [3], membership, add, remove} = {}) { + const calls = []; + const store = { + calls, + collections:[{id:3, name:'LoRa testing', count:3, missing:0}, {id:5, name:'Portraits', count:1, missing:0}], + membership: async target => { calls.push(['membership', target]); return membership ? membership() : [...member]; }, + add: async (where, items) => { calls.push(['add', where, items]); if (add) return add(where, items); + return {collection:{id:where.collectionId ?? 9, name:where.name ?? 'x'}, created:!!where.name, added:1, already:0, notFound:0}; }, + remove: async (id, items) => { calls.push(['remove', id, items]); if (remove) return remove(id, items); + return {collection:{id, name:'x'}, removed:1}; }, + settled: async () => ({}), + }; + return store; +} +function footerButtons(f) { + return f.walk(f.container, n => n.tagName === 'button').map(n => n.textContent.trim()); +} +test('inspector shows Collections… only for a rooted file with a store', async () => { + for (const variant of ['panel', 'full']) { + const f = mountedDom(), scope = createScope(); + try { + const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, collections:collectionsStore()}); + view.setState({loading:false, error:false, record:{...record(), historyTarget:'#/library/1'}}); + const labels = footerButtons(f); + assert.ok(labels.includes('Collections…'), `${variant}: the button is offered`); + // After Prompt history, as the last footer action. + assert.equal(labels.at(-1), 'Collections…'); + assert.equal(labels.at(-2), 'Prompt history'); + const button = f.walk(f.container, n => n.tagName === 'button' && n.textContent.trim() === 'Collections…')[0]; + assert.equal(button.children[0].tagName, 'svg', 'with the folder icon'); + view.dispose(); + } finally { scope.dispose(); f.restore(); } + } + // No store (Prompt library galleries, Inspect a file) or a file that is not a listed one: no button. + const local = await localRecord(localPng('Fox Render.png')); + for (const [shown, store] of [[record(), null], [local, collectionsStore()], + [{...record(), identity:{...record().identity, root_scope:null}}, collectionsStore()]]) { + const f = mountedDom(), scope = createScope(); + try { + const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant:'full', collections:store}); + view.setState({loading:false, error:false, record:shown}); + assert.ok(!footerButtons(f).includes('Collections…')); + view.dispose(); + } finally { scope.dispose(); f.restore(); } + } +}); + +/** Open the Collections dialog over FILE; returns its nodes by role and helpers. */ +function openCollections(store, extra = {}) { + const {openCollectionsDialog} = dialogModule(); + const f = mountedDom(), scope = createScope(), toasts = [], changes = []; + let dialog = null, closed = 0; + openCollectionsDialog({scope, collections:store, target:FILE, trigger:null, notify:text => toasts.push(text), + onChanged:change => changes.push(change), openDialog:args => { dialog = args; f.container.append(...args.nodes); }, + // As the shell does: closing runs the dialog's onClose once. + closeDialog:() => { closed++; dialog?.onClose?.(); }, ...extra}); + const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + const find = predicate => f.walk(f.container, predicate); + const boxes = () => find(n => n.tagName === 'input' && n.type === 'checkbox'); + const button = text => find(n => n.tagName === 'button' && n.textContent === text)[0]; + const text = () => find(n => n.tagName === 'p').map(n => n.textContent).filter(Boolean); + return {f, scope, toasts, changes, get dialog() { return dialog; }, get closed() { return closed; }, settle, find, boxes, button, text, + done() { scope.dispose(); f.restore(); }}; +} + +test('the Collections dialog loads the membership and lists every collection', async () => { + const store = collectionsStore(); + const view = openCollections(store); + try { + assert.equal(view.dialog.title, 'Collections'); + assert.ok(view.text().includes('Loading collections…')); + assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true'); + await view.settle(); + assert.deepEqual(store.calls, [['membership', FILE]]); + const labels = view.find(n => n.tagName === 'label' && n.className === 'tag-suggestion'); + assert.deepEqual(labels.map(label => label.textContent), ['LoRa testing', 'Portraits']); + assert.deepEqual(view.boxes().map(box => box.checked), [true, false]); + const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0]; + assert.equal(field.maxLength, 100); + assert.equal(view.find(n => n.tagName === 'label' && n.htmlFor === field.id)[0].textContent, 'New collection'); + assert.deepEqual(view.find(n => n.className === 'dialog-actions')[0].children.map(n => n.textContent), ['Cancel', 'Save']); + assert.equal(view.button('Save').getAttribute('aria-disabled'), null); + } finally { view.done(); } +}); + +test('without collections the dialog says to name one', async () => { + const store = collectionsStore({member:[]}); + store.collections = []; + const view = openCollections(store); + try { + await view.settle(); + assert.ok(view.text().includes('No collections yet. Name one below.')); + assert.equal(view.boxes().length, 0); + } finally { view.done(); } +}); + +test('a failed membership read offers Retry', async () => { + let failing = true; + const store = collectionsStore({membership:() => { if (failing) throw new TypeError('offline'); return [5]; }}); + const view = openCollections(store); + try { + await view.settle(); + assert.ok(view.text().includes('Collections could not be loaded.')); + assert.equal(view.button('Retry').hidden, false); + assert.equal(view.boxes().length, 0); + assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true'); + failing = false; + view.button('Retry').fire('click'); + await view.settle(); + assert.deepEqual(view.boxes().map(box => box.checked), [false, true]); + assert.equal(view.button('Retry').hidden, true); + } finally { view.done(); } +}); + +test('Save sends the new name, each added and each removed collection, in that order', async () => { + const store = collectionsStore(); + const view = openCollections(store); + try { + await view.settle(); + const [lora, portraits] = view.boxes(); + lora.checked = false; portraits.checked = true; + view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' Grids '; + view.button('Save').fire('click'); + await view.settle(); await view.settle(); + assert.deepEqual(store.calls.slice(1), [ + ['add', {name:'Grids'}, [FILE]], + ['add', {collectionId:5}, [FILE]], + ['remove', 3, [FILE]], + ]); + assert.equal(view.closed, 1); + assert.deepEqual(view.toasts, ['Collections updated.']); + assert.deepEqual(view.changes, [{added:[9, 5], removed:[3]}]); + } finally { view.done(); } +}); + +test('Save without a change sends nothing and closes', async () => { + const store = collectionsStore(); + const view = openCollections(store); + try { + await view.settle(); + view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' '; + view.button('Save').fire('click'); + await view.settle(); + assert.deepEqual(store.calls.map(call => call[0]), ['membership']); + assert.equal(view.closed, 1); + assert.deepEqual(view.toasts, []); + assert.deepEqual(view.changes, []); + } finally { view.done(); } +}); + +test('a failed Save keeps the dialog, says so and shows the current membership', async () => { + let reads = 0; + const store = collectionsStore({membership:() => (reads++ ? [5] : [3]), + remove:() => { throw Object.assign(new Error('x'), {status:500}); }}); + const view = openCollections(store); + try { + await view.settle(); + const [lora, portraits] = view.boxes(); + lora.checked = false; portraits.checked = true; + view.button('Save').fire('click'); + // While it saves, × and Escape keep the dialog and Save is busy. + assert.equal(view.dialog.canClose(), false); + assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true'); + for (let index = 0; index < 4; index++) await view.settle(); + assert.equal(view.closed, 0); + assert.deepEqual(view.toasts, []); + const alert = view.find(n => n.getAttribute?.('role') === 'alert')[0]; + assert.equal(alert.textContent, 'The collections could not be updated. Try again.'); + // The add went through; the list shows what the server holds now. Review M2: the page hears about it only once + // the dialog closes, so a listing refresh cannot dispose the dialog before its failure is read. + assert.deepEqual(view.changes, []); + assert.deepEqual(view.boxes().map(box => box.checked), [false, true]); + assert.equal(view.dialog.canClose(), true); + assert.equal(view.button('Save').getAttribute('aria-disabled'), null); + view.button('Cancel').fire('click'); + assert.deepEqual(view.changes, [{added:[5], removed:[]}]); + } finally { view.done(); } +}); + +test('a name the store cannot take is refused on the field, keeping the edits (review L5)', async () => { + const store = collectionsStore(); + const view = openCollections(store); + try { + await view.settle(); + const [lora, portraits] = view.boxes(); + lora.checked = false; portraits.checked = true; + const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0]; + const validity = []; + field.setCustomValidity = text => validity.push(text); + field.reportValidity = () => validity.push('reported'); + field.value = 'bad\u0001name'; + view.button('Save').fire('click'); + for (let index = 0; index < 3; index++) await view.settle(); + assert.deepEqual(store.calls.map(call => call[0]), ['membership'], 'nothing sent and no re-read'); + assert.deepEqual(validity, ['The collections could not be updated. Try again.', 'reported']); + assert.deepEqual(view.boxes().map(box => box.checked), [false, true], 'the checkbox edits stay'); + assert.equal(view.closed, 0); + assert.equal(view.button('Save').getAttribute('aria-disabled'), null); + // Editing the field clears its message. + field.fire('input'); + assert.equal(validity.at(-1), ''); + } finally { view.done(); } +}); + + +test('after a failed Save, Save waits for the membership re-read (review M1)', async () => { + let release; + const store = collectionsStore({remove:() => { throw new Error('x'); }}); + store.settled = () => new Promise(resolve => { release = resolve; }); + const view = openCollections(store); + try { + await view.settle(); + view.boxes()[0].checked = false; + view.button('Save').fire('click'); + for (let index = 0; index < 3; index++) await view.settle(); + assert.equal(typeof release, 'function', 'the store is settling'); + assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true'); + assert.ok(view.text().includes('Loading collections…')); + const sent = store.calls.length; + view.button('Save').fire('click'); + await view.settle(); + assert.equal(store.calls.length, sent, 'no stale plan is sent'); + release({}); + for (let index = 0; index < 3; index++) await view.settle(); + assert.equal(view.button('Save').getAttribute('aria-disabled'), null); + } finally { view.done(); } +}); + +test('a typed name of an existing collection is that collection checkbox (review L4)', async () => { + // "portraits" names Portraits (not a member): one add by id, no name request. + let store = collectionsStore(); + let view = openCollections(store); + try { + await view.settle(); + view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' PORTRAITS '; + view.button('Save').fire('click'); + for (let index = 0; index < 3; index++) await view.settle(); + assert.deepEqual(store.calls.slice(1), [['add', {collectionId:5}, [FILE]]]); + } finally { view.done(); } + // Unchecking LoRa testing while typing its name keeps it: nothing to send. + store = collectionsStore(); + view = openCollections(store); + try { + await view.settle(); + view.boxes()[0].checked = false; + view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = 'lora testing'; + view.button('Save').fire('click'); + await view.settle(); + assert.deepEqual(store.calls.map(call => call[0]), ['membership']); + assert.equal(view.closed, 1); + } finally { view.done(); } +}); + +test('focus starts on the field, moves to the first checkbox once listed, and falls back on close (review L6)', async () => { + const trigger = {isConnected:false, focus() { assert.fail('a detached trigger is not focused'); }}; + const fallback = {focused:false, focus() { this.focused = true; }}; + const view = openCollections(collectionsStore(), {trigger, focusFallback:() => fallback}); + try { + const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0]; + assert.equal(view.dialog.focusTarget, field); + await view.settle(); + assert.equal(view.boxes()[0].focused, true); + view.button('Cancel').fire('click'); + assert.equal(fallback.focused, true); + } finally { view.done(); } + const empty = collectionsStore({member:[]}); + empty.collections = []; + const none = openCollections(empty); + try { + await none.settle(); + assert.equal(none.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].focused, true); + } finally { none.done(); } +}); + test('category chip names its origin visibly and shows an own category for any link status (#332 D146)', async () => { const base = await localRecord(localPng('a.png')); const chipTexts = record => { diff --git a/tests/js/workspace-images-selection.test.js b/tests/js/workspace-images-selection.test.js new file mode 100644 index 00000000..4590bc4c --- /dev/null +++ b/tests/js/workspace-images-selection.test.js @@ -0,0 +1,285 @@ +// The Images selection bar's collection actions (#331 D138, design §7): Add to collection after Set category, Remove +// from collection beside it while a collection filter is active, the 500 cap and every toast of §7.4. +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Guarded: before the module exists every test fails on an assertion, not on the import. +const loaded = await import('../../web/js/workspace/images-collections.mjs').catch(() => null); +const module = () => { assert.ok(loaded, 'images-collections.mjs is not implemented'); return loaded; }; +const { mountSelectionBar } = await import('../../web/js/workspace/images-selection.mjs'); +const { createScope } = await import('../../web/js/shared/toolkit/core.js'); +const strings = await import('../../web/js/workspace/collections.mjs'); + +const SCOPE = 'b'.repeat(64); +const image = (path, extra = {}) => ({ root_index: 0, root_scope: SCOPE, path, filename: path.split('/').pop(), ...extra }); +const target = path => ({ root_index: 0, root_scope: SCOPE, path }); +const images = count => Array.from({ length: count }, (_, index) => image(`set/${index}.png`)); +const keyOf = item => `${item.root_index}:${item.path}`; +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); +const coded = (code, status = 400) => Object.assign(new Error(code), { name: 'ApiError', code, status }); + +/** A minimal DOM for the selection bar: elements with order, attributes, listeners, focus and a toast. */ +function fakeDOM() { + const previous = globalThis.document; + let active = null; + class Element extends EventTarget { + constructor(tag) { + super(); + this.tagName = tag.toUpperCase(); + this.children = []; + this.parentNode = null; + this.attributes = {}; + this.dataset = {}; + this.className = ''; + this.id = ''; + this._text = ''; + } + get textContent() { return this._text + this.children.map(child => child.textContent).join(''); } + set textContent(text) { this.children = []; this._text = String(text); } + get nextElementSibling() { + const siblings = this.parentNode?.children ?? []; + return siblings[siblings.indexOf(this) + 1] ?? null; + } + append(...nodes) { + for (const node of nodes) { node.remove(); node.parentNode = this; this.children.push(node); } + } + after(node) { + node.remove(); + node.parentNode = this.parentNode; + this.parentNode.children.splice(this.parentNode.children.indexOf(this) + 1, 0, node); + } + remove() { + if (!this.parentNode) return; + this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1); + this.parentNode = null; + } + setAttribute(name, value) { this.attributes[name] = String(value); } + getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; } + removeAttribute(name) { delete this.attributes[name]; } + querySelector() { return this.children.find(child => child.tagName === 'BUTTON' && child.getAttribute('aria-disabled') !== 'true') ?? null; } + querySelectorAll() { return []; } + focus() { active = this; } + click() { this.dispatchEvent(new Event('click')); } + } + const toast = new Element('div'); + const body = new Element('body'); + globalThis.document = { + body, + get activeElement() { return active ?? body; }, + createElement: tag => new Element(tag), + createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }), + getElementById: id => (id === 'workspace-toast' ? toast : null), + }; + return { Element, body, toast, restore() { globalThis.document = previous; } }; +} + +/** The bar over `items` with `checked` keys; `options` adds the collection props. */ +function mountBar(dom, items, checkedKeys, options = {}) { + const scope = createScope(); + const grid = new dom.Element('div'); + const focusFallback = new dom.Element('h1'); + const bar = mountSelectionBar({ scope, api: {}, grid, getItems: () => items, focusFallback, onCategorySet() {}, + activity: options.activity ?? null, onTagged() {}, getFilter: () => ({}), + getListing: () => ({ total: options.total ?? items.length, hasMore: false, positiveCount: null }), + checked: Object.freeze(new Set(checkedKeys)), ...options.props }); + return { scope, bar, labels: () => bar.element.children[0]?.children.map(child => child.textContent) ?? [], + button: text => bar.element.children[0].children.find(child => child.textContent === text) }; +} + +function store(overrides = {}) { + const calls = []; + return { + calls, + collections: [{ id: 3, name: 'Experiments', count: 8, missing: 0 }, { id: 7, name: 'Landscapes', count: 12, missing: 1 }], + add: async (where, items) => { calls.push(['add', where, items]); return overrides.add?.(where, items); }, + remove: async (id, items) => { calls.push(['remove', id, items]); return overrides.remove?.(id, items); }, + }; +} + +test('selection bar lists Add to collection after Set category', () => { + const dom = fakeDOM(); + try { + const items = images(2); + const collections = store(); + const plain = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections } }); + assert.deepEqual(plain.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', + 'Export metadata', 'Clear']); + plain.scope.dispose(); + // §7.1: Remove from collection beside Add, only while a collection filter is active. + const filtered = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections, collection: '7' } }); + assert.deepEqual(filtered.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', + 'Remove from collection', 'Export metadata', 'Clear']); + filtered.scope.dispose(); + // Without the client store (a page that has none) neither shows. + const none = mountBar(dom, items, [keyOf(items[0])]); + assert.deepEqual(none.labels(), ['1 selected', 'Set category', 'Export metadata', 'Clear']); + none.scope.dispose(); + } finally { dom.restore(); } +}); + +test('more than 500 checked toasts and opens nothing', async () => { + const dom = fakeDOM(); + try { + const items = images(501); + const collections = store(); + const view = mountBar(dom, items, items.map(keyOf), { props: { collections, collection: '7' } }); + view.button('Add to collection').click(); + assert.equal(dom.toast.textContent, 'Select at most 500 images.'); + assert.equal(dom.body.children.length, 0, 'no dialog opened'); + dom.toast.textContent = ''; + view.button('Remove from collection').click(); + await flush(); + assert.equal(dom.toast.textContent, 'Select at most 500 images.'); + assert.deepEqual(collections.calls, []); + view.scope.dispose(); + } finally { dom.restore(); } +}); + +test('Remove from collection removes the checked files and reloads the listing', async () => { + const dom = fakeDOM(); + try { + const items = images(3); + let changed = 0; + const collections = store({ remove: () => ({ collection: { id: 7, name: 'Landscapes' }, removed: 2 }) }); + const view = mountBar(dom, items, [keyOf(items[0]), keyOf(items[2])], { props: { collections, collection: '7', + onCollectionsChanged: () => { changed++; } } }); + const remove = view.button('Remove from collection'); + remove.click(); + // A second press while the first is in flight is ignored. + assert.equal(remove.getAttribute('aria-disabled'), 'true'); + remove.click(); + await flush(); + assert.deepEqual(collections.calls, [['remove', 7, [target('set/0.png'), target('set/2.png')]]]); + assert.equal(dom.toast.textContent, 'Removed 2 images from “Landscapes”.'); + assert.equal(changed, 1); + assert.equal(remove.getAttribute('aria-disabled'), null); + view.scope.dispose(); + } finally { dom.restore(); } +}); + +test('the collection-target rule keeps the all-matching selection to its loaded images', () => { + const { collectionTargets } = module(); + assert.deepEqual(collectionTargets(images(2)), { targets: [target('set/0.png'), target('set/1.png')] }); + assert.deepEqual(collectionTargets(images(501)), { refusal: strings.TOO_MANY_TOAST }); + // All N matching: only when every match is loaded and checked, and at most 500. + assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 2 }), { targets: [target('set/0.png'), target('set/1.png')] }); + assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 3 }), { refusal: strings.TOO_MANY_TOAST }); + assert.deepEqual(collectionTargets(images(501), { allMatching: true, total: 501 }), { refusal: strings.TOO_MANY_TOAST }); + // A file without a listing identity cannot be named; none left is "could not be found". + assert.deepEqual(collectionTargets([{ ...image('x.png'), root_scope: 'nope' }]), { refusal: strings.FILES_NOT_FOUND_TOAST }); +}); + +const pageScope = () => { + const controller = new AbortController(); + return { signal: controller.signal, cleanup() {}, abort: () => controller.abort() }; +}; + +test('Add to collection opens the concept dialog with the existing names as suggestions', () => { + const { openAddToCollection } = module(); + let dialog = null; + const collections = store(); + openAddToCollection({ scope: pageScope(), collections, images: images(1), trigger: 'trigger', notify: assert.fail, + openInput: args => { dialog = args; } }); + assert.equal(dialog.title, 'Add selected images to collection'); + assert.equal(dialog.label, 'Collection name'); + assert.equal(dialog.action, 'Add'); + assert.equal(dialog.maxLength, 100); + assert.equal(dialog.trigger, 'trigger'); + assert.deepEqual(dialog.suggestions, ['Experiments', 'Landscapes']); + assert.equal(dialog.value ?? '', ''); +}); + +/** Apply `name` in the Add dialog over `count` images; resolves with the toasts and the store's calls. */ +async function add(name, answer, { count = 1, extra = {} } = {}) { + const { openAddToCollection } = module(); + const toasts = []; + let dialog = null, sourceChanged = 0; + const collections = store({ add: answer }); + openAddToCollection({ scope: pageScope(), collections, images: images(count), notify: text => toasts.push(text), + openInput: args => { dialog = args; }, onSourceChanged: () => { sourceChanged++; }, ...extra }); + await dialog.onApply(name); + return { toasts, calls: collections.calls, sourceChanged }; +} +const answer = (added, already, notFound, created = false) => () => ({ collection: { id: 9, name: 'LoRa testing' }, created, + added, already, notFound }); + +test('Add sends the normalized name and the checked files', async () => { + const { toasts, calls } = await add(' LoRa testing ', answer(3, 0, 0, true), { count: 3 }); + assert.deepEqual(calls, [['add', { name: 'LoRa testing' }, [target('set/0.png'), target('set/1.png'), target('set/2.png')]]]); + assert.deepEqual(toasts, ['Added 3 images to “LoRa testing”.']); +}); + +test('Add toasts every outcome of §7.4', async () => { + const cases = [ + [answer(1, 0, 0), 'Added 1 image to “LoRa testing”.'], + [answer(1, 1, 0), 'Added 1 image to “LoRa testing”. 1 was already in it.'], + [answer(2, 3, 0), 'Added 2 images to “LoRa testing”. 3 were already in it.'], + [answer(1, 0, 1), 'Added 1 image to “LoRa testing”. 1 could not be found.'], + [answer(1, 2, 4), 'Added 1 image to “LoRa testing”. 2 were already in it. 4 could not be found.'], + [answer(0, 2, 0), 'All selected images were already in “LoRa testing”.'], + [answer(0, 1, 1), 'Added 0 images to “LoRa testing”. 1 was already in it. 1 could not be found.'], + [() => { throw coded('files-not-found', 404); }, 'None of the selected images could be found. Refresh the images and try again.'], + [() => { throw coded('collection-not-found', 404); }, 'That collection no longer exists.'], + [() => { throw new TypeError('offline'); }, 'The images could not be added. Try again.'], + ]; + for (const [reply, text] of cases) assert.deepEqual((await add('LoRa testing', reply)).toasts, [text], text); +}); + +test('a stale source on Add says so and reloads the listing', async () => { + const { toasts, sourceChanged } = await add('Grids', () => { throw coded('source-changed', 409); }); + assert.deepEqual(toasts, ['The sources changed. Refresh the images and try again.']); + assert.equal(sourceChanged, 1); +}); + +test('Add with a name the store cannot take sends nothing and says it failed', async () => { + const { toasts, calls } = await add('bad\u0001name', answer(1, 0, 0)); + assert.deepEqual(calls, []); + assert.deepEqual(toasts, ['The images could not be added. Try again.']); +}); + +test('Add after the page left shows no toast', async () => { + const { openAddToCollection } = module(); + const scope = pageScope(); + const toasts = []; + let dialog = null; + openAddToCollection({ scope, collections: store({ add: answer(1, 0, 0) }), images: images(1), notify: text => toasts.push(text), + openInput: args => { dialog = args; } }); + scope.abort(); + await dialog.onApply('Grids'); + assert.deepEqual(toasts, []); +}); + +test('Add refuses an over-cap or all-matching selection before any dialog', () => { + const { openAddToCollection } = module(); + const toasts = []; + const open = extra => openAddToCollection({ scope: pageScope(), collections: store(), notify: text => toasts.push(text), + openInput: () => assert.fail('no dialog opens'), ...extra }); + open({ images: images(501) }); + open({ images: images(2), allMatching: true, total: 40 }); + assert.deepEqual(toasts, [strings.TOO_MANY_TOAST, strings.TOO_MANY_TOAST]); +}); + +/** Remove `count` images from collection 7 with `reply`; resolves with the toasts and callbacks. */ +async function remove(reply, count = 1) { + const { removeFromCollection } = module(); + const toasts = []; + let changed = 0, sourceChanged = 0; + const collections = store({ remove: reply }); + await removeFromCollection({ scope: pageScope(), collections, collectionId: 7, images: images(count), + notify: text => toasts.push(text), onChanged: () => { changed++; }, onSourceChanged: () => { sourceChanged++; } }); + return { toasts, changed, sourceChanged, calls: collections.calls }; +} + +test('Remove toasts every outcome of §7.4', async () => { + assert.deepEqual(await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 1 })), + { toasts: ['Removed 1 image from “Landscapes”.'], changed: 1, sourceChanged: 0, calls: [['remove', 7, [target('set/0.png')]]] }); + assert.deepEqual((await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 3 }), 3)).toasts, + ['Removed 3 images from “Landscapes”.']); + // A failure keeps the listing; a deleted collection reloads it (the page then says the collection is gone). + const failed = await remove(() => { throw new TypeError('offline'); }); + assert.deepEqual([failed.toasts, failed.changed], [['The images could not be removed. Try again.'], 0]); + const gone = await remove(() => { throw coded('collection-not-found', 404); }); + assert.deepEqual([gone.toasts, gone.changed], [['That collection no longer exists.'], 1]); + const stale = await remove(() => { throw coded('source-changed', 409); }); + assert.deepEqual([stale.toasts, stale.changed, stale.sourceChanged], [['The sources changed. Refresh the images and try again.'], 0, 1]); +}); diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index ad4d8641..a776db41 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -594,6 +594,9 @@ test('an input dialog offers suggestions and a secondary action', async () => { const [plainField, plainActions] = byId(dom, 'modal-body').children; assert.equal(plainField.children[1].getAttribute('list'), null); assert.deepEqual(plainActions.children.map(node => node.textContent), ['Cancel', 'A']); + // An empty list (Add to collection with no collections yet, #331 D138) adds no datalist either. + openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [], onApply() {} }); + assert.equal(dom.all(node => node.tagName === 'DATALIST').length, 0); assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [1], onApply() {} }), TypeError); assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: '' }, onApply() {} }), TypeError); assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: 'X' }, onApply() {} }), TypeError); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index b5292c45..965573d9 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -275,6 +275,12 @@ "functions": 98, "statements": 98 }, + "workspace/images-collections.mjs": { + "lines": 98, + "branches": 89, + "functions": 98, + "statements": 98 + }, "workspace/images-export.mjs": { "lines": 98, "branches": 98, @@ -353,6 +359,12 @@ "functions": 98, "statements": 98 }, + "workspace/inspector-collections.mjs": { + "lines": 98, + "branches": 92, + "functions": 98, + "statements": 98 + }, "workspace/library-editor-state.mjs": { "lines": 96, "branches": 92, diff --git a/web/js/workspace/collections.mjs b/web/js/workspace/collections.mjs index 827b3c16..6491536a 100644 --- a/web/js/workspace/collections.mjs +++ b/web/js/workspace/collections.mjs @@ -78,7 +78,8 @@ export function parseCollections(data) { } /** The sidebar's order while a re-read is pending: an approximation of the server's name key, then id. */ -const nameKey = name => name.normalize('NFC').toLowerCase().normalize('NFC'); +export const collectionNameKey = name => name.normalize('NFC').toLowerCase().normalize('NFC'); +const nameKey = collectionNameKey; const ordered = list => [...list].sort((a, b) => { const left = nameKey(a.name), right = nameKey(b.name); return left < right ? -1 : left > right ? 1 : a.id - b.id; diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 68bfb4f0..2a3ba905 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -8,6 +8,7 @@ import { showWorkspaceToast } from './toast.mjs'; import { openCopyFallback } from './workspace-dialogs.mjs'; import { displayPreferences } from './preferences.mjs'; import { fallBackToOriginal, thumbnailSource } from './thumbnail-support.mjs'; +import { openCollectionsDialog } from './inspector-collections.mjs'; const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs']; const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.'; @@ -220,7 +221,12 @@ function prefixedChip(prefix, text) { /** D124: the inspector's line while "Load metadata on selection" is off. */ export const DEFERRED_TEXT = 'Metadata loads when you ask for it.'; -export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback}) { +/** + * #331 D139: with the client store `collections`, a listed file's footer ends with "Collections…", which opens its + * collections checklist; `onCollectionsChanged({added, removed})` hears the confirmed changes. + */ +export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback, + collections = null, onCollectionsChanged = null}) { if (parentScope.signal.aborted) return {setState() {}, dispose() {}}; const scope = childScope(parentScope); let contentScope; @@ -317,6 +323,17 @@ export function mountFileInspector(container, {api, sender, parentScope, variant }); footer.append(history); } + const member = collections ? revealTarget(record.identity) : null; + if (member) { + const button = createButton(''); + button.dataset.collections = ''; + button.append(createWorkspaceIcon('folder'), ' Collections…'); + // A re-render replaces the button: closing focuses the current one, else the inspector's heading. + const fallback = () => container.querySelector('button[data-collections]') ?? container.querySelector('h2'); + contentScope.listen(button, 'click', () => openCollectionsDialog({scope, collections, target:member, trigger:button, + onChanged:onCollectionsChanged, focusFallback:fallback})); + footer.append(button); + } if (!full && footer.childNodes.length) container.append(footer); } /** diff --git a/web/js/workspace/images-collections.mjs b/web/js/workspace/images-collections.mjs new file mode 100644 index 00000000..69806cae --- /dev/null +++ b/web/js/workspace/images-collections.mjs @@ -0,0 +1,76 @@ +/** + * The Images selection bar's collection actions (#331 D138, design §7.2–7.4): + * + * - **Add to collection** (the concept's `bulk-collection` simpleInput, app.js a1392–1398): "Add selected images to + * collection", "Collection name", Add, with the existing names offered as suggestions. A name that matches a + * collection ignoring case adds to it; a new name creates it. The selection stays and the listing is not reloaded + * (an add never changes it). + * - **Remove from collection** (beyond the concept, only while a collection filter is active): no confirmation; the + * listing then reloads through `onChanged`, keeping the checked files still listed. + * + * The routes take at most 500 explicit files. "All N matching" (#392, D135) is accepted only when every match is + * loaded and checked and there are at most 500 of them; otherwise the action toasts "Select at most 500 images." and + * sends nothing (ruling for #331 PR 3: the collection routes have no filter body). + */ +import { bulkTargets } from './images-tagging-bulk.mjs'; +import { ADD_DIALOG_ACTION, ADD_DIALOG_LABEL, ADD_DIALOG_TITLE, ADD_FAILED_TOAST, FILES_NOT_FOUND_TOAST, MAX_COLLECTION_NAME_LENGTH, + REMOVE_FAILED_TOAST, TOO_MANY_TOAST, addedMessage, isCollectionGone, normalizeCollectionName, removedMessage, + writeFailure } from './collections.mjs'; +import { openInputDialog } from './workspace-dialogs.mjs'; +import { showWorkspaceToast } from './toast.mjs'; + +/** + * The files an action sends for the checked `images` (listing order): `{targets}`, or `{refusal}` with the toast. + * With `allMatching`, `total` is the listing's match count; only a fully loaded match is sent. + */ +export function collectionTargets(images, { allMatching = false, total = images.length } = {}) { + if (allMatching && total !== images.length) return { refusal: TOO_MANY_TOAST }; + const targets = bulkTargets(images); + if (!targets) return { refusal: TOO_MANY_TOAST }; + return targets.length ? { targets } : { refusal: FILES_NOT_FOUND_TOAST }; +} + +/** + * Add to collection over `images` (the checked files, taken now). `collections` is the client store; `openInput` is + * the dialog seam. A stale source (409) calls `onSourceChanged()` after its toast, so the page reloads the listing. + */ +export function openAddToCollection({ scope, collections, images, trigger = null, allMatching = false, total = images.length, + notify = showWorkspaceToast, openInput = openInputDialog, onSourceChanged = null }) { + const { targets, refusal } = collectionTargets(images, { allMatching, total }); + if (refusal) { notify(refusal); return; } + openInput({ scope, trigger, title: ADD_DIALOG_TITLE, label: ADD_DIALOG_LABEL, action: ADD_DIALOG_ACTION, + maxLength: MAX_COLLECTION_NAME_LENGTH, suggestions: collections.collections.map(item => item.name), + onApply: async text => { + const name = normalizeCollectionName(text); + if (name === null) { notify(ADD_FAILED_TOAST); return; } + try { + const result = await collections.add({ name }, targets); + if (!scope.signal.aborted) notify(addedMessage(result)); + } catch (error) { + if (scope.signal.aborted) return; + notify(writeFailure(error, ADD_FAILED_TOAST)); + if (error?.code === 'source-changed') onSourceChanged?.(); + } + } }); +} + +/** + * Remove the checked `images` from the open collection `collectionId`. Resolves once the toast is shown; a confirmed + * remove, or a collection found deleted meanwhile, calls `onChanged()` so the page lists it again. + */ +export async function removeFromCollection({ scope, collections, collectionId, images, allMatching = false, total = images.length, + notify = showWorkspaceToast, onChanged = null, onSourceChanged = null }) { + const { targets, refusal } = collectionTargets(images, { allMatching, total }); + if (refusal) { notify(refusal); return; } + try { + const result = await collections.remove(collectionId, targets); + if (scope.signal.aborted) return; + notify(removedMessage(result.collection.name, result.removed)); + onChanged?.(); + } catch (error) { + if (scope.signal.aborted) return; + notify(writeFailure(error, REMOVE_FAILED_TOAST)); + if (isCollectionGone(error)) onChanged?.(); + else if (error?.code === 'source-changed') onSourceChanged?.(); + } +} diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs index 78957d04..217e32a2 100644 --- a/web/js/workspace/images-selection.mjs +++ b/web/js/workspace/images-selection.mjs @@ -8,6 +8,7 @@ import { closeWorkspaceDialog, openContentDialog, openInputDialog } from './work import { TOO_MANY, bulkTargets, openAddTags, openAutoTag } from './images-tagging-bulk.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { applyKeys, createLongPress, matchingLabel, matchingOffer, offersAllMatching, rangeKeys } from './images-multiselect.mjs'; +import { openAddToCollection, removeFromCollection } from './images-collections.mjs'; const CHECKBOX = '.image-overlays input[type="checkbox"]'; const CHECKBOX_LABEL = '.image-overlays > label'; @@ -42,9 +43,14 @@ function el(tag, text, className) { * `clear` serve the grid's keys (images.mjs). `getListing()` gives `{total, hasMore, positiveCount}` and * `getFilter()` the listing filter: once every loaded image is checked and more match, the bar offers "Select all N * matching", whose actions send that filter to the server. + * + * Collections (#331 D138, design §7): with the client store `collections`, Add to collection follows Set category; + * while a collection filter is active (`collection`, its id) Remove from collection follows it, and a confirmed remove + * calls `onCollectionsChanged()` to reload the listing. A stale source on either calls `onSourceChanged()`. */ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged, - getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()) }) { + getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()), collections = null, + collection = null, onCollectionsChanged = null, onSourceChanged = null }) { let checked = initial; let exporting = false, categorizing = false; // D135: the range anchor, "all N matching" mode, touch selection mode and a pending Shift+click on a checkbox. @@ -75,7 +81,19 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o ...matchingTarget() })); bulk.append(addTags, autoTag); } - bulk.append(setCategory, exportMetadata, clearButton); + bulk.append(setCategory); + if (collections) { + const addToCollection = createButton('Add to collection'); + scope.listen(addToCollection, 'click', () => openAddToCollection({ scope, collections, images: checkedImages(), + trigger: addToCollection, ...collectionSelection(), onSourceChanged })); + bulk.append(addToCollection); + } + if (collections && collection) { + const removeButton = createButton('Remove from collection'); + scope.listen(removeButton, 'click', () => removeChecked(removeButton)); + bulk.append(removeButton); + } + bulk.append(exportMetadata, clearButton); /** The count updates in place: the same bar and `strong`, its text written only when it changes. */ function render() { @@ -143,6 +161,24 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o function matchingTarget() { return allMatching ? { filter: getFilter(), count: getListing().total } : {}; } + /** The collection actions' view of "all N matching" (#331 ruling): the loaded images and the match count. */ + function collectionSelection() { + return { allMatching, total: allMatching ? getListing().total : checked.size }; + } + /** Remove from collection: one write at a time; the selection stays (a reload keeps what is still listed). */ + let removing = false; + async function removeChecked(button) { + if (removing) return; + removing = true; + const release = busy(button); + try { + await removeFromCollection({ scope, collections, collectionId: Number(collection), images: checkedImages(), + ...collectionSelection(), onChanged: onCollectionsChanged, onSourceChanged }); + } finally { + removing = false; + release(); + } + } /** Toggle one image on its own; it becomes the anchor. */ function toggle(key, on = !isChecked(key)) { settleMatching(); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index f35e42a3..bfde7afe 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -116,10 +116,12 @@ export function selectedImage() { * Images last visit. `collection` (an id) is a collection page (#331 D137): only its members are listed, favorites is * off, and it neither restores nor replaces the last visit either. A listing that answers `collection-not-found` calls * `onCollectionMissing()` when given (the shell shows its deleted-collection page); otherwise (a saved view on a - * deleted collection, D140) the page lists nothing and says so. + * deleted collection, D140) the page lists nothing and says so. `collections` (the shell's client store) adds the + * selection bar's Add to collection, and Remove from collection while a collection is open (D138). */ export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null, - favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null} = {}) { + favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null, + collections = null} = {}) { const scope = createScope(); const dispose = () => scope.dispose(); parentScope.signal.addEventListener('abort', dispose, {once:true}); @@ -302,10 +304,20 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage getFilter:() => { const filter = listingFilter(filters), scope = state?.items[0]?.root_scope; return filter.root !== undefined && typeof scope === 'string' ? {...filter, root_scope:scope} : filter; - }}); + }, + // #331 D138: Add to collection, and Remove from collection on an open collection, which then lists it again. + collections, collection:filters.collection || null, onCollectionsChanged:collectionChanged, + onSourceChanged:refreshInspectionCollection}); if (bar) main.append(bar.element); main.append(body); + // #331 D139: a change the inspector or the viewer confirmed that touches the open collection lists it again; the + // viewer reports only once it closes. + const inspectorCollections = embedded ? null : collections; + const collectionsChangedBy = ({added, removed}) => { + if (filters.collection && [...added, ...removed].includes(Number(filters.collection))) collectionChanged(); + }; const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender, + collections:inspectorCollections,onCollectionsChanged:collectionsChangedBy, onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);}, onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onMediaError:image => recovery.recover(image),onRestoreFocus:image => { const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image)); @@ -322,7 +334,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage selectedImage = image; for (const video of details.querySelectorAll('video')) releaseVideo(video); detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel', - onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection}); + onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection, + collections:inspectorCollections, onCollectionsChanged:collectionsChangedBy}); detailInspector.setState(inspection.state); inspection.select(image); for (const [key, card] of rendered) markSelected(card, key === selection); @@ -734,6 +747,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const band = parseFloat(getComputedStyle(optionsStatus).scrollMarginBlockEnd) || 0; if (bottom > innerHeight - band && top < innerHeight) optionsStatus.scrollIntoView({block:'nearest', inline:'nearest'}); } + /** + * A confirmed collection change that may change this listing (a remove from the open collection): the loaded range + * is read again in place, keeping the scroll and the checked files still listed. A failed re-read lists the page + * again from its start, whose failure shows the usual Retry (or the deleted-collection page). + */ + async function collectionChanged() { + if (scope.signal.aborted || !filters.collection || recovering) return; + try { + await stream.refresh(); + } catch { + if (!scope.signal.aborted) reset(); + } + } async function refreshInspectionCollection() { if (scope.signal.aborted) return; recovery.reset(); diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 81d1b132..ad50c0db 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -410,7 +410,7 @@ function mountWorkspace() { if (route.id === 'images' || route.id === 'favorites' || route.id === 'collection') { mountNewPromptAction(main, api, view); images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites', - collection, onCollectionMissing, onSaveView:saveView('images', view), activity}); + collection, onCollectionMissing, onSaveView:saveView('images', view), activity, collections}); return; } if (route.id === 'library') { diff --git a/web/js/workspace/inspector-collections.mjs b/web/js/workspace/inspector-collections.mjs new file mode 100644 index 00000000..87c8598f --- /dev/null +++ b/web/js/workspace/inspector-collections.mjs @@ -0,0 +1,189 @@ +/** + * The shared inspector's "Collections…" dialog (#331 D139, design §8), beyond the concept: one listed file's + * collections as a checklist in the sidebar's order, a New collection field, then Cancel and Save. + * + * - The file's membership is read when the dialog opens ("Loading collections…"); a failed read says "Collections + * could not be loaded." with Retry, and Save waits for a read that worked. + * - Each row is the concept's checkbox row (`label.tag-suggestion`, app.js a1128). With no collection the list says + * "No collections yet. Name one below." + * - Save sends one request per change, in order: the new name, each newly checked collection, each unchecked one. + * Nothing changed sends nothing and closes. All sent: the dialog closes with "Collections updated.". A failure keeps + * it open with "The collections could not be updated. Try again." and the membership read again; the changes that + * went through still reach `onChanged({added, removed})` (collection ids), as a full success does, but only once the + * dialog closes: a page that lists the file again (a collection page) would otherwise remove the dialog before its + * failure is read. Save waits for the membership read again. + * - A New collection name equal to a listed collection ignoring case is that collection's checkbox, checked. A name + * that cannot be a collection name is refused on the field; nothing is sent and the checkbox edits stay. + * - Focus starts on the field and moves to the first checkbox once they are listed; closing returns it to the + * trigger, or to `focusFallback()` when an inspector re-render removed the trigger. + */ +import { createButton } from '../shared/toolkit/core.js'; +import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs'; +import { showWorkspaceToast } from './toast.mjs'; +import { COLLECTIONS_UPDATED_TOAST, MAX_COLLECTION_NAME_LENGTH, MEMBERSHIP_FAILED_TEXT, MEMBERSHIP_LOADING_TEXT, + NO_COLLECTIONS_TEXT, UPDATE_FAILED_TEXT, collectionNameKey, normalizeCollectionName } from './collections.mjs'; + +export const COLLECTIONS_DIALOG_TITLE = 'Collections'; +export const NEW_COLLECTION_LABEL = 'New collection'; + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +/** + * Open the dialog for `target` (`{root_index, root_scope, path}`) with the client store `collections`. `openDialog` + * and `closeDialog` are the dialog seams; `notify` the toast. + */ +export function openCollectionsDialog({ scope, collections, target, trigger = null, notify = showWorkspaceToast, onChanged = null, + focusFallback = null, openDialog = openContentDialog, closeDialog = closeWorkspaceDialog }) { + const local = new AbortController(); + const active = () => !scope.signal.aborted && !local.signal.aborted; + let member = null, saving = false, listed = false; + // The confirmed changes, reported once when the dialog closes (review M2). + const changes = { added: [], removed: [] }; + const status = el('p', '', 'small'); + const retry = createButton('Retry'); + const list = el('div'); + list.setAttribute('role', 'group'); + list.setAttribute('aria-label', COLLECTIONS_DIALOG_TITLE); + const input = el('input'); + input.value = ''; + input.maxLength = MAX_COLLECTION_NAME_LENGTH; + input.autocomplete = 'off'; + input.addEventListener('input', () => input.setCustomValidity?.('')); + const problem = el('p', '', 'small'); + problem.setAttribute('role', 'alert'); + problem.hidden = true; + const actions = el('div', undefined, 'dialog-actions'); + const cancel = createButton('Cancel'); + const save = createButton('Save', 'ca-primary'); + actions.append(cancel, save); + const blocked = () => member === null || saving; + const paintSave = () => { + if (blocked()) save.setAttribute('aria-disabled', 'true'); + else save.removeAttribute('aria-disabled'); + }; + retry.addEventListener('click', () => load()); + cancel.addEventListener('click', () => { if (!saving) closeDialog(); }); + save.addEventListener('click', () => { if (!blocked()) submit(); }); + + /** One checkbox row per collection, checked where the file is a member. */ + function renderList() { + list.replaceChildren(); + if (!collections.collections.length) { + list.append(el('p', NO_COLLECTIONS_TEXT, 'small')); + return; + } + for (const item of collections.collections) { + const row = el('label', undefined, 'tag-suggestion'); + const box = el('input'); + Object.assign(box, { type: 'checkbox', id: `collection-choice-${item.id}`, checked: member.has(item.id) }); + box.dataset.collectionId = String(item.id); + row.htmlFor = box.id; + row.append(box, el('span', item.name)); + list.append(row); + } + } + function showStatus(text, failed) { + status.textContent = text; + status.setAttribute('role', failed ? 'alert' : 'status'); + status.hidden = !text; + retry.hidden = !failed; + } + /** Save waits while the membership is read (again). */ + function loading() { + member = null; + paintSave(); + list.replaceChildren(); + showStatus(MEMBERSHIP_LOADING_TEXT, false); + } + async function load() { + loading(); + try { + const ids = await collections.membership(target); + if (!active()) return; + member = new Set(ids); + showStatus('', false); + renderList(); + // The first listing takes focus from the untouched field to the first checkbox (review L6). + if (!listed && !input.value) firstBox().focus(); + listed = true; + } catch { + if (!active()) return; + showStatus(MEMBERSHIP_FAILED_TEXT, true); + } + paintSave(); + } + const boxesOf = () => [...list.children].map(row => row.children?.[0]).filter(box => box?.dataset?.collectionId); + const firstBox = () => boxesOf()[0] ?? input; + /** + * The requests Save sends, in order; null when the new name cannot be a collection name. A name equal to a listed + * collection's ignoring case is that collection's checkbox, checked (review L4). + */ + function plan() { + const typed = input.value.trim(); + const name = typed ? normalizeCollectionName(typed) : ''; + if (name === null) return null; + const named = name ? collections.collections.find(item => collectionNameKey(item.name) === collectionNameKey(name)) : null; + const steps = name && !named ? [{ add: { name } }] : []; + const boxes = boxesOf().map(box => ({ id: Number(box.dataset.collectionId), checked: box.checked })) + .map(box => (box.id === named?.id ? { ...box, checked: true } : box)); + for (const box of boxes) if (box.checked && !member.has(box.id)) steps.push({ add: { collectionId: box.id } }); + for (const box of boxes) if (!box.checked && member.has(box.id)) steps.push({ remove: box.id }); + return steps; + } + async function submit() { + problem.textContent = ''; + problem.hidden = true; + const steps = plan(); + if (steps === null) { + // Review L5: refused on the field; nothing is sent and the checkbox edits stay. + input.setCustomValidity?.(UPDATE_FAILED_TEXT); + input.reportValidity?.(); + input.focus(); + return; + } + if (steps.length === 0) { closeDialog(); return; } + saving = true; + paintSave(); + let failed = false; + for (const step of steps) { + try { + if (step.add) changes.added.push((await collections.add(step.add, [target])).collection.id); + else changes.removed.push((await collections.remove(step.remove, [target])).collection.id); + } catch { + failed = true; + break; + } + if (!active()) return; + if (step.add?.name) input.value = ''; + } + if (!active()) return; + saving = false; + if (!failed) { + closeDialog(); + notify(COLLECTIONS_UPDATED_TOAST); + return; + } + problem.textContent = UPDATE_FAILED_TEXT; + problem.hidden = false; + // Review M1: Save waits from here until the membership is read again. A collection deleted meanwhile is + // re-read by the store; the list then shows what the server holds. + loading(); + await collections.settled(); + if (active()) await load(); + } + function closed() { + local.abort(); + if (changes.added.length || changes.removed.length) onChanged?.({ added: [...changes.added], removed: [...changes.removed] }); + // The shell focuses a trigger still on the page; one an inspector re-render replaced falls back (review L6). + if (trigger && !trigger.isConnected) focusFallback?.()?.focus(); + } + showStatus('', false); + openDialog({ scope, title: COLLECTIONS_DIALOG_TITLE, trigger, canClose: () => !saving, onClose: closed, focusTarget: input, + nodes: [status, retry, list, field(NEW_COLLECTION_LABEL, input, 'collection-new'), problem, actions] }); + load(); +} diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index 106932af..10645720 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -19,7 +19,12 @@ function element(tag, className, text) { return node; } -export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,onMediaError,adapter = rootedFileAdapter}) { +/** + * #331 D139: with the client store `collections`, the viewer's inspector offers "Collections…"; the changes it confirms + * reach `onCollectionsChanged({added, removed})` once the viewer closes, so the viewer keeps its place while open. + */ +export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,onMediaError,adapter = rootedFileAdapter, + collections = null,onCollectionsChanged = null}) { const scope = createScope(); let active = null, retryVersion = 0; const dispose = () => scope.dispose(); @@ -36,7 +41,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle active?.close(false); const {items,selectedKey,trigger = document.activeElement} = options; let mounted = false, initial, entry, currentKey, viewer = null, withoutViewer = false, mediaScope = null, closed = false; - let originalDownload = null, inspectionState = inspectionController.state; + let originalDownload = null, inspectionState = inspectionController.state, collectionChanges = null; const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => { if (!mounted) { initial = [state,selected]; return; } update(state,selected); @@ -99,7 +104,11 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}}; const inspector = mountFileInspector(details,{api,sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(), onLoad:() => inspectionController.load?.(), - onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined}); + onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined,collections, + onCollectionsChanged:({added,removed}) => { + collectionChanges ??= {added:[],removed:[]}; + collectionChanges.added.push(...added);collectionChanges.removed.push(...removed); + }}); function closeViewer(restore = true) { if (closed) return; closed = true;active = null;local.dispose(); @@ -108,6 +117,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle else onRestoreFocus?.(entry); } if (!scope.signal.aborted) onClose?.(); + if (collectionChanges && !scope.signal.aborted) onCollectionsChanged?.(collectionChanges); } active = {close:closeViewer,stop:() => session.stop()}; local.cleanup(() => { diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 0d6d4643..25a1d5ef 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -332,8 +332,9 @@ export const INPUT_SUGGESTIONS_ID = 'workspace-input-suggestions'; * reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field. * `validate(value)` may refuse a value with a message: the dialog then stays open with the typed text and the field * reports the message (its native validity) until it is edited. `suggestions` (text list) offers values in a - * `` under the field; `secondary` (`{label, onClick}`) adds a button between Cancel and the action that - * closes the dialog and runs `onClick` (#332). + * `` under the field (Set category's categories, #332; Add to collection's names, #331 D138); the field + * still takes any text. `secondary` (`{label, onClick}`) adds a button between Cancel and the action that closes the + * dialog and runs `onClick` (#332). */ export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null, validate = null, suggestions = null, secondary = null }) { @@ -400,13 +401,13 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe * another dialog replaces its body, so the page can dispose what it mounted there. While `canClose()` answers false, * × and Escape leave the dialog open (the page, a route change or a replacing dialog still close it). */ -export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null }) { +export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null, focusTarget = null }) { requireScope(scope); if (typeof title !== 'string' || !title) throw new TypeError('The dialog needs a title'); if (!Array.isArray(nodes) || nodes.some(node => !node || typeof node !== 'object')) throw new TypeError('The dialog needs its nodes'); if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function'); if (canClose !== null && typeof canClose !== 'function') throw new TypeError('The dialog canClose must be a function'); - show(title, nodes, { scope, trigger, onClose, canClose }); + show(title, nodes, { scope, trigger, onClose, canClose, focusTarget }); } export const SCAN_REVIEW_SENTENCE = 'Enrich positive / negative associations, reuse canonical text and report unchanged files. ' +