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. ' +