feat(views): Save view, the SAVED VIEWS sidebar and opening a view
The shared toolbar gains the concept's Save view between sort and Filters
on Images and the Prompt library. It opens the concept's simpleInput
("Save this view", "View name", default "Search: <q>" or "My workspace
view", Cancel / Save); an empty name is "Saved view". A save posts the
page's full whitelisted state (toolbar value plus Refine filters) and
toasts "Saved view added to the sidebar.".
The shell adds the concept's SAVED VIEWS label and nav#saved-nav, loaded
with the shell and re-read after every save. Rows use the concept's
links() markup (search icon, name, no count) and link #/saved/<id>.
Opening one mounts its page with the saved state through the page's own
filters, so the toolbar and "Filters · N" (D44) show it; the h1,
breadcrumb and title are the view's name. A removed Source or Folder is
dropped to its default after discovery, so no listing read is rejected;
an unknown id re-reads the list once, then opens Images; a failed list
read offers Retry.
D16 is retired: images-concept and library-concept no longer map Save
view out. New deviations: D49 (no PROPOSED badge) and D50 (no Needs
review row); shell-concept maps Favorites out until Task 3 and compares
a saved row against the concept's. Browser fixtures now wipe
saved_views.
This commit is contained in:
+20
-6
@@ -431,13 +431,26 @@ Recorded shell deviations:
|
||||
- **D4**: a "Theme" select with Dark, Light and System. System follows the ComfyUI
|
||||
palette, then the OS.
|
||||
- **D5**: no Activity button or Activity row until an activity center exists.
|
||||
- **D28**: no SAVED VIEWS or COLLECTIONS, and no Design review. The bottom nav is
|
||||
- **D28**: no COLLECTIONS, and no Design review. The bottom nav is
|
||||
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.
|
||||
- **D49** (saved views, #249 D16): the SAVED VIEWS group label has no "PROPOSED"
|
||||
badge, because saved views are persisted (`/prompt_manager/saved-views`). Its rows are
|
||||
the concept's `links()` rows: the search icon, the view name and no count, linking
|
||||
`#/saved/<id>`. Opening one shows its page with the saved toolbar value and Refine
|
||||
filters, and the view's name as the h1, breadcrumb and title. A value its page no
|
||||
longer accepts (a removed Source or Folder) stays default; an unknown id opens Images.
|
||||
`a saved view row matches the concept's` saves one view in both documents and
|
||||
compares the rows; `tests/browser/workspace-saved-views.spec.mjs` covers saving,
|
||||
reopening, the defaults that replace rejected values, and failed reads and saves.
|
||||
- **D50**: no "Needs review" row in SAVED VIEWS until the tag-review slice (maintainer
|
||||
ruling, 2026-10-21). Favorites arrives with the saved-views plan's Task 3; until then
|
||||
the comparison maps it out with Needs review.
|
||||
- **D29**: no prototype copy. There is no "Interactive concept" badge and no
|
||||
"What changes & why" button. The footer reads "ComfyAssets · Local tools. Your
|
||||
library.", and the title has no " concept" suffix.
|
||||
library.", and the title has no " concept" suffix. Save view's dialog action reads
|
||||
"Save", not "Apply in prototype", and its toast drops the demo's word "Sample".
|
||||
- **D31**: at `max-width: 950px` the overlay caps the one-column shell at
|
||||
`minmax(0, 1fr)`, so extreme zoom cannot widen it to the sidebar's min-content.
|
||||
- **#245 D2**: no "Auto-tag images" on the Tags heading until the image-annotation
|
||||
@@ -464,7 +477,7 @@ zoom, the 2nd card opens the viewer titled "<title> · <filename>", Escape retur
|
||||
to it (production only: the concept re-renders its grid and drops focus), Info on the
|
||||
3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with
|
||||
its toast. `mapImagesConcept` applies D3, D8 (the selection bar's real actions only),
|
||||
D16 (follow-up PR: no Save view), D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
|
||||
D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
|
||||
sorted alphabetically). Removed toolbar and filter-row items flow again from the
|
||||
concept's own boxes (`reflow`), checked against the concept page with those items
|
||||
removed at runtime. Masonry tops and heights are compared unrounded, within one layout
|
||||
@@ -570,7 +583,9 @@ Recorded deviations on this page (spec §8):
|
||||
files in M output folders." (the scan's `total` and `roots`) or "Rescan could not
|
||||
finish. Try again."; while it runs, a reopened Import shows an aria-disabled
|
||||
"Rescanning…".
|
||||
- **D16 (follow-up)**: no Save view in PR C; a follow-up PR adds real saved views.
|
||||
- **D16 (retired)**: Save view is the concept's own toolbar button, between sort and
|
||||
Filters, on Images and the Prompt library (see D49). It saves the visible toolbar value
|
||||
with the Refine filters; the comparisons no longer map it.
|
||||
- **D17**: the caption date is the newest result's day under Recently added (", YYYY"
|
||||
outside the current year), hidden under every other sort.
|
||||
- **D18**: the concept hover zoom, with no sticky zoom on hover-less (touch) devices.
|
||||
@@ -775,8 +790,7 @@ Behaviors, in both documents: the title and Explore history open the prompt's hi
|
||||
(its `h1` is the title), the footer send carries the positive only (a bridge host page
|
||||
records the roles production's preview asks for), copy writes the full positive with
|
||||
"Prompt copied.", and the "neon" search and the Landscapes category leave the same cards
|
||||
and caption. `mapLibraryConcept` applies D14 (Oldest first appended), D16 (no Save
|
||||
view; the toolbar flows again, checked against the concept without it), D23 (the
|
||||
and caption. `mapLibraryConcept` applies D14 (Oldest first appended), D23 (the
|
||||
title button's `min-width: 0`; "− 1 negative variant", measured on the concept with the
|
||||
singular at runtime; the spoken "Rated N out of 5"; the two-line clamp, which the
|
||||
concept's samples exceed at 390px, checked against the concept with the clamp rule
|
||||
|
||||
@@ -85,8 +85,8 @@ const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y
|
||||
|
||||
export function mapImagesConcept(reference,{manifest,now=new Date()}) {
|
||||
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
|
||||
const toolbar=withoutItems(reference.toolbar,item=>item.text==='Save view');
|
||||
// D16 (follow-up PR): no Save view until the saved-views store ships. D14: the sort appends production's two sorts.
|
||||
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
|
||||
const toolbar={...reference.toolbar};
|
||||
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item);
|
||||
// D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category
|
||||
// COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped.
|
||||
@@ -329,9 +329,9 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
|
||||
const conceptLoad=await openConceptLoaded(page,theme,motion);
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
if(capture)await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
|
||||
// The browser as the re-layout's oracle: the concept page itself without Save view, then without the sample tag.
|
||||
// The browser as the re-layout's oracle: the concept page itself without the sample tag.
|
||||
const removed={};
|
||||
for(const [key,selector] of [['toolbar','.toolbar > [data-action="save-view"]'],['filterRow','.filter-row > .sample-tag']]) {
|
||||
for(const [key,selector] of [['filterRow','.filter-row > .sample-tag']]) {
|
||||
const restore=await mutate(page,{remove:[selector]});
|
||||
removed[key]=(await inventoryOf(page,'#main'))[key];
|
||||
await restore();
|
||||
@@ -385,12 +385,10 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
|
||||
expect.soft(reference.cards[0].info.p.text,`${motion} concept subtitle`).toBe(`${first.width} × ${first.height} · PNG · Seed ${first.seed}`);
|
||||
expect.soft(reference.cards[0].info.h3.fontSize,`${motion} concept h3 size`).toBe('11px');
|
||||
expect.soft(referenceImport.rows.map(row=>row.title),`${motion} concept Import rows`).toEqual(['Inspect a local image','Scan an output folder','Import portable recipes']);
|
||||
// The row model reproduces the concept's own rows, and the concept page itself once Save view (D16) or the
|
||||
// sample tag (D29) is removed from it at runtime: the browser is the oracle for the remainders the mapping uses.
|
||||
// The row model reproduces the concept's own rows, and the concept page itself once the sample tag (D29) is
|
||||
// removed from it at runtime: the browser is the oracle for the remainders the mapping uses.
|
||||
expect.soft(diffInventory(stripExact(reflow(reference.toolbar,reference.toolbar.items)),stripExact(reference.toolbar)),`${motion} toolbar model`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(reflow(reference.filterRow,reference.filterRow.items)),stripExact(reference.filterRow)),`${motion} filter row model`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(withoutItems(reference.toolbar,item=>item.text==='Save view')),stripExact(removed.toolbar)),
|
||||
`${motion} toolbar without Save view`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'))),stripExact(removed.filterRow)),
|
||||
`${motion} filter row without the sample tag`).toEqual([]);
|
||||
expect.soft(reference.toolbar.items.find(item=>item.id==='category-filter').options,`${motion} concept categories`).toEqual(CONCEPT_CATEGORIES);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} from './concept-inventory.mjs';
|
||||
|
||||
// Task E3 of #249: the production Prompt library shelf against the immutable concept (app.js renderLibrary a572–586,
|
||||
// style.css s648–708), on the concept-shaped real fixture (the concept's six prompts with their negatives, categories,
|
||||
@@ -18,8 +18,8 @@ const CONCEPT_PROMPTS=6;
|
||||
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
|
||||
export function mapLibraryConcept(reference) {
|
||||
const library=reference.library;
|
||||
// D16 (follow-up PR): no Save view until the saved-views store ships; the remaining items flow again (`reflow`).
|
||||
const toolbar=withoutItems(library.toolbar,item=>item.text==='Save view');
|
||||
// Save view is the concept's own (saved views, #249 D16 retired).
|
||||
const toolbar={...library.toolbar};
|
||||
toolbar.items=toolbar.items.map(item=>{
|
||||
// D14: the Library appends its Oldest first after the concept's three sorts.
|
||||
if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']};
|
||||
@@ -251,11 +251,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
|
||||
await openConceptLibrary(page,theme,motion);
|
||||
const reference=await conceptInventory(page);
|
||||
if(capture)await captureCanonicalState(page,testInfo,`concept-library-${width}-${theme}`);
|
||||
// The browser as the re-layout's oracle: the concept page itself without Save view (D16).
|
||||
const restore=await mutate(page,{remove:['.toolbar > [data-action="save-view"]']});
|
||||
const removed=(await inventoryOf(page,'#main')).library.toolbar;
|
||||
await restore();
|
||||
// ...and with the D23 clamp applied to its cards.
|
||||
// The browser as the re-layout's oracle: the concept page itself with the D23 clamp applied to its cards.
|
||||
const restoreClamp=await mutate(page,{css:CLAMP_CSS});
|
||||
const clamped=(await inventoryOf(page,'#main')).library;
|
||||
await restoreClamp();
|
||||
@@ -269,13 +265,13 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
if(capture)await captureCanonicalState(page,testInfo,`production-library-${width}-${theme}`);
|
||||
const actualBehaviors=capture?await behaviorsOf(page,{back:()=>page.getByRole('button',{name:'← Prompt library',exact:true}).click(),sent:productionSent(host)}):null;
|
||||
result[motion]={reference,removed,sorted,clamped,actual,diff:compare(reference,actual),referenceBehaviors,actualBehaviors};
|
||||
result[motion]={reference,sorted,clamped,actual,diff:compare(reference,actual),referenceBehaviors,actualBehaviors};
|
||||
}
|
||||
const path=testInfo.outputPath('library-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||||
await testInfo.attach('library-geometry',{path,contentType:'application/json'});
|
||||
|
||||
for(const [motion,{reference,removed,sorted,clamped,actual,diff,referenceBehaviors:concept,actualBehaviors:production}] of Object.entries(result)) {
|
||||
const {grid,cards,toolbar}=reference.library,card=cards[0];
|
||||
for(const [motion,{reference,sorted,clamped,actual,diff,referenceBehaviors:concept,actualBehaviors:production}] of Object.entries(result)) {
|
||||
const {grid,cards}=reference.library,card=cards[0];
|
||||
// The concept itself is what this comparison assumes (style.css s648–708, 1424–1428, 1475–1483).
|
||||
expect.soft(grid,`${motion} concept grid`).toMatchObject({columns:narrow?1:2,columnGap:'18px',rowGap:'18px',cardCount:CONCEPT_PROMPTS});
|
||||
expect.soft(card,`${motion} concept card`).toMatchObject({borderTopLeftRadius:'8px',borderTopWidth:'1px',overflow:'hidden'});
|
||||
@@ -291,7 +287,6 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
|
||||
expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']);
|
||||
expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'});
|
||||
expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today');
|
||||
expect.soft(diffInventory(stripExact(withoutItems(toolbar,item=>item.text==='Save view')),stripExact(removed)),`${motion} toolbar without Save view`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]);
|
||||
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
|
||||
|
||||
@@ -181,6 +181,7 @@ def create_app(root: Path) -> web.Application:
|
||||
) -> None:
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
|
||||
@@ -56,13 +56,23 @@ function remainingFooter(footer,spans) {
|
||||
// its `activity` pulse stays reserved for the Activity row D5 omits, so the two entries never share one glyph.
|
||||
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
|
||||
|
||||
/**
|
||||
* The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: the saved views only. D50: no "Needs
|
||||
* review" row until the tag-review slice (maintainer ruling 2026-10-21). Favorites is the saved-views plan's Task 3.
|
||||
*/
|
||||
function savedNav(nav) {
|
||||
const rows=nav.rows.filter(row=>row.text!=='Favorites'&&row.text!=='Needs review');
|
||||
return {...nav,rows:rows.map((row,index)=>index===rows.length-1?{...row,gapAfter:null}:row)};
|
||||
}
|
||||
|
||||
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
|
||||
function mapShellConcept(reference) {
|
||||
const {chrome}=reference;
|
||||
const navs=chrome.navs
|
||||
// D28: SAVED VIEWS and COLLECTIONS are proposals with no persisted store, so their navs are not rendered.
|
||||
.filter(nav=>nav.id!=='saved-nav'&&nav.id!=='collections-nav')
|
||||
// D28: COLLECTIONS is a proposal with no persisted store, so its nav is not rendered.
|
||||
.filter(nav=>nav.id!=='collections-nav')
|
||||
.map(nav=>{
|
||||
if(nav.id==='saved-nav')return savedNav(nav);
|
||||
if(nav.id!=='system-nav')return nav;
|
||||
const system=rowNamed(nav,'System');
|
||||
return {...nav,rows:[
|
||||
@@ -76,8 +86,9 @@ function mapShellConcept(reference) {
|
||||
return {...reference,chrome:{...chrome,
|
||||
// D29: no prototype suffix in the document title.
|
||||
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
|
||||
// D28: only the WORKSPACE group label remains.
|
||||
labels:chrome.labels.filter(label=>label.text==='WORKSPACE'),
|
||||
// D28: no COLLECTIONS label. D49: SAVED VIEWS without its "PROPOSED" badge.
|
||||
labels:chrome.labels.filter(label=>label.text!=='COLLECTIONS PROPOSED')
|
||||
.map(label=>label.text==='SAVED VIEWS PROPOSED'?{...label,text:'SAVED VIEWS'}:label),
|
||||
navs,
|
||||
connection:{...chrome.connection,
|
||||
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
|
||||
@@ -199,6 +210,30 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry
|
||||
}
|
||||
});
|
||||
|
||||
// Saved views (#249 D16): a view saved from the Images toolbar adds the concept's own saved row (search icon, name, no
|
||||
// count) to SAVED VIEWS. The concept's demo action reads "Apply in prototype"; production's is "Save" (D29).
|
||||
for(const [width,height] of SIZES)test(`a saved view row matches the concept's ${width}x${height}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const save=async(action,mainSelector)=>{
|
||||
await page.locator(`${mainSelector} .toolbar`).getByRole('button',{name:'Save view',exact:true}).click();
|
||||
const dialog=page.getByRole('dialog',{name:'Save this view',exact:true});
|
||||
await expect(dialog.getByRole('textbox',{name:'View name',exact:true})).toHaveValue('My workspace view');
|
||||
await dialog.getByRole('textbox',{name:'View name',exact:true}).fill('Golden hour');
|
||||
await dialog.getByRole('button',{name:action,exact:true}).click();
|
||||
await expect(page.locator('#saved-nav').getByRole('link',{name:'Golden hour',exact:true})).toHaveCount(1);
|
||||
await page.mouse.move(0,0);
|
||||
return inventoryOf(page,mainSelector);
|
||||
};
|
||||
await openConcept(page,'images','dark');
|
||||
const reference=await save('Apply in prototype','#main');
|
||||
expect(reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>row.text)).toEqual(['Favorites','Needs review','Golden hour']);
|
||||
await openProduction(page,'images','dark');
|
||||
const actual=await save('Save','#workspace-main');
|
||||
expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]);
|
||||
expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count]))
|
||||
.toEqual([['Golden hour',null]]);
|
||||
});
|
||||
|
||||
test('the inventory reports a missing shell element by its path instead of failing',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
|
||||
|
||||
@@ -808,7 +808,9 @@ test('images toolbar and filter row match the concept controls', async ({ page,
|
||||
await expect(page.getByRole('button', { name: 'Refresh', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: 'Current gallery' })).toHaveCount(0);
|
||||
await expect(main.locator('h2', { hasText: /^All images$/ })).toHaveCount(0);
|
||||
await expect(main.locator('search.toolbar > *')).toHaveCount(4);
|
||||
// app.js toolbar() a347–360: search, category, sort, Save view, Filters.
|
||||
await expect(main.locator('search.toolbar > *')).toHaveCount(5);
|
||||
await expect(main.locator('search.toolbar > button')).toHaveText(['Save view', 'Filters']);
|
||||
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Name Z–A']);
|
||||
const row = main.locator('.filter-row');
|
||||
await expect(row.locator(':scope > *')).toHaveCount(5);
|
||||
|
||||
@@ -151,19 +151,19 @@ test('failed library reads retry while late search responses cannot overwrite ne
|
||||
|
||||
test('library toolbar matches the concept and tags live in Refine this view',async({page})=>{
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Filters.
|
||||
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Save view, Filters.
|
||||
const toolbar=page.locator('#workspace-main search.toolbar');await expect(toolbar).toHaveCount(1);
|
||||
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button']);
|
||||
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button','button']);
|
||||
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
|
||||
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Highest rated','Oldest first']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Filters']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
|
||||
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
|
||||
await expect(page.locator('.library-shelf > search.toolbar:first-child')).toHaveCount(1);
|
||||
// Tags apply from the dialog only; Clear clears them and the toolbar category, as the concept's does.
|
||||
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1);
|
||||
// D44: the active Tags filter shows on the button; the category is the toolbar's own and is not counted.
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Filters · 1']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
|
||||
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
await expect(refine(page).locator('.field label')).toHaveText(['Tags']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
|
||||
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
|
||||
// Saved views (#249 D16, maintainer ruling 2026-10-21): the toolbar's Save view (concept app.js a359, a1330–1346),
|
||||
// the sidebar SAVED VIEWS rows (a331–335) and `#/saved/<id>` (a1158–1162), on Images and the Prompt library.
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const API = '/prompt_manager/saved-views';
|
||||
const main = page => page.locator('#workspace-main');
|
||||
const toolbar = page => main(page).locator('search.toolbar');
|
||||
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
|
||||
const sortBy = page => page.getByRole('combobox', { name: 'Sort results', exact: true });
|
||||
const filtersButton = page => toolbar(page).getByRole('button', { name: /^Filters(?: · \d+)?$/ });
|
||||
const imageCards = page => page.locator('article[data-image-key]');
|
||||
const promptCards = page => page.locator('article[data-prompt-id]');
|
||||
const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true });
|
||||
const saveDialog = page => page.getByRole('dialog', { name: 'Save this view', exact: true });
|
||||
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [] };
|
||||
|
||||
async function refine(page, values) {
|
||||
await filtersButton(page).click();
|
||||
for (const [label, value] of Object.entries(values)) {
|
||||
const control = refineDialog(page).getByLabel(label, { exact: true });
|
||||
if (label === 'Tags') await control.fill(value);
|
||||
else await control.selectOption(value);
|
||||
}
|
||||
await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
|
||||
await expect(refineDialog(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function listed(request) {
|
||||
const response = await request.get(API);
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).views;
|
||||
}
|
||||
|
||||
async function create(request, data) {
|
||||
const response = await request.post(API, { data });
|
||||
expect(response.status()).toBe(201);
|
||||
return (await response.json()).view;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
page.savedErrors = [];
|
||||
page.on('pageerror', error => page.savedErrors.push(error.message));
|
||||
// No listing read may fail on a saved view's values (an unknown Source would be a 400).
|
||||
page.on('response', response => {
|
||||
if (response.status() >= 400 && new URL(response.url()).pathname === '/prompt_manager/images/output') {
|
||||
page.savedErrors.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.savedErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('Save view saves the Images toolbar and Refine filters, and its sidebar row reopens them', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
// The concept's sidebar group: SAVED VIEWS without its PROPOSED badge (D49), empty until a view is saved.
|
||||
await expect(page.locator('aside.sidebar .sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
await expect(savedNav(page).locator('a')).toHaveCount(0);
|
||||
|
||||
await search(page).fill('dogs');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await sortBy(page).selectOption('name');
|
||||
await refine(page, { Source: '1', Media: 'image' });
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
const saveButton = toolbar(page).getByRole('button', { name: 'Save view', exact: true });
|
||||
await saveButton.click();
|
||||
// The concept's simpleInput: "View name", the search's default name, Cancel and the primary action.
|
||||
const dialog = saveDialog(page);
|
||||
await expect(dialog.getByRole('textbox', { name: 'View name', exact: true })).toHaveValue('Search: dogs');
|
||||
await expect(dialog.getByRole('textbox', { name: 'View name', exact: true })).toBeFocused();
|
||||
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Cancel', 'Save']);
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(saveButton).toBeFocused();
|
||||
expect(await listed(request)).toEqual([]);
|
||||
|
||||
await saveButton.click();
|
||||
await dialog.getByRole('textbox', { name: 'View name', exact: true }).fill('Dog shots');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
|
||||
const [view] = await listed(request);
|
||||
expect(view).toMatchObject({ page: 'images', name: 'Dog shots',
|
||||
state: { ...IMAGE_DEFAULTS, q: 'dogs', sort: 'name', source: '1', media: 'image' } });
|
||||
// The concept's links() row: the search icon, then the name, and no count.
|
||||
const row = savedNav(page).getByRole('link', { name: 'Dog shots', exact: true });
|
||||
await expect(row).toHaveAttribute('href', `#/saved/${view.id}`);
|
||||
expect(await row.evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE ? `text:${node.textContent}`
|
||||
: `${node.localName}.${node.getAttribute('class')}`))).toEqual(['svg.icon', 'text:Dog shots']);
|
||||
await expect(row).not.toHaveAttribute('aria-current', 'page');
|
||||
|
||||
// Another page, then the saved row: its page opens with every saved value and the Filters count (D44).
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Prompt library/ }).click();
|
||||
await expect(promptCards(page).first()).toBeVisible();
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`));
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Dog shots');
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toBeFocused();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Dog shots');
|
||||
await expect(page).toHaveTitle('Dog shots · Prompt Manager');
|
||||
await expect(row).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1);
|
||||
await expect(search(page)).toHaveValue('dogs');
|
||||
await expect(sortBy(page)).toHaveValue('name');
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
await filtersButton(page).click();
|
||||
await expect(refineDialog(page).getByLabel('Source', { exact: true })).toHaveValue('1');
|
||||
await expect(refineDialog(page).getByLabel('Media', { exact: true })).toHaveValue('image');
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// A reload opens the same view from its link.
|
||||
await page.reload();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Dog shots');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(savedNav(page).getByRole('link', { name: 'Dog shots', exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('an empty name is Saved view, and a Library view reopens the shelf filters', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
const name = saveDialog(page).getByRole('textbox', { name: 'View name', exact: true });
|
||||
await expect(name).toHaveValue('My workspace view');
|
||||
await name.fill(' ');
|
||||
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(savedNav(page).getByRole('link')).toHaveText(['Saved view']);
|
||||
expect((await listed(request))[0]).toMatchObject({ page: 'images', name: 'Saved view', state: IMAGE_DEFAULTS });
|
||||
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(promptCards(page)).toHaveCount(2);
|
||||
await search(page).fill('dog');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
await refine(page, { Tags: 'dogs' });
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 1');
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
await expect(name).toHaveValue('Search: dog');
|
||||
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(savedNav(page).getByRole('link')).toHaveText(['Saved view', 'Search: dog']);
|
||||
const library = (await listed(request))[1];
|
||||
expect(library).toMatchObject({ page: 'library', name: 'Search: dog', state: { q: 'dog', category: '', sort: 'newest', tags: ['dogs'] } });
|
||||
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await savedNav(page).getByRole('link', { name: 'Search: dog', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Search: dog');
|
||||
await expect(search(page)).toHaveValue('dog');
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 1');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
// Saved views open the page; one saved view to another re-renders with the other's state.
|
||||
await savedNav(page).getByRole('link', { name: 'Saved view', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Saved view');
|
||||
await expect(search(page)).toHaveValue('');
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('a saved value the page no longer accepts stays default while the rest applies', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'images', name: 'Gone source',
|
||||
state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'name-desc', source: '7', folder: 'missing/folder' } });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Gone source');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(sortBy(page)).toHaveValue('name-desc');
|
||||
// The missing Source and Folder are dropped, so no refinement is active.
|
||||
await expect(filtersButton(page)).toHaveText('Filters');
|
||||
await expect(main(page).locator('.filter-row > span.chip')).toHaveText('All output folders');
|
||||
|
||||
// A Source that does exist, with a folder it lists, applies.
|
||||
const kept = await create(request, { page: 'images', name: 'Input shared',
|
||||
state: { ...IMAGE_DEFAULTS, source: '1', folder: 'shared' } });
|
||||
await page.goto(`${workspace}#/saved/${kept.id}`);
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Input shared');
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
});
|
||||
|
||||
test('an unknown saved view opens Images, and a failed listing offers Retry', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/saved/999`);
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Images');
|
||||
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
|
||||
let failing = true;
|
||||
await page.route(`**${API}`, route => failing && route.request().method() === 'GET'
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' })
|
||||
: route.fallback());
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(main(page).getByRole('alert')).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
|
||||
await expect(savedNav(page).locator('a')).toHaveCount(0);
|
||||
failing = false;
|
||||
await main(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Cats');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
await expect(savedNav(page).getByRole('link')).toHaveText(['Cats']);
|
||||
});
|
||||
|
||||
test('a failed save says so and adds nothing', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await page.route(`**${API}`, route => route.request().method() === 'POST'
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' })
|
||||
: route.fallback());
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The view could not be saved. Try again.');
|
||||
await expect(savedNav(page).locator('a')).toHaveCount(0);
|
||||
expect(await listed(request)).toEqual([]);
|
||||
});
|
||||
@@ -95,11 +95,11 @@ test('a tag Browse shows Filters · 1 until its filter is cleared',async({page})
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
const shelf=page.locator('article[data-prompt-id]');await expect(shelf.first()).toBeVisible();const total=await shelf.count();
|
||||
const toolbar=page.locator('#workspace-main search.toolbar');
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Filters']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
|
||||
await page.goto(route);
|
||||
const row=tagRow(page,'cinematic');await row.getByRole('button',{name:'Browse',exact:true}).click();
|
||||
await expectLibrary(page,{tags:'cinematic',count:2});
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Filters · 1']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
|
||||
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
await refine.getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine).toHaveCount(0);
|
||||
@@ -109,7 +109,7 @@ test('a tag Browse shows Filters · 1 until its filter is cleared',async({page})
|
||||
const nav=page.getByRole('navigation',{name:'Workspace',exact:true});
|
||||
await nav.getByRole('link',{name:/^Tags & categories(?:\s*[\d,]+)?$/}).click();await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
|
||||
await nav.getByRole('link',{name:/^Prompt library(?:\s*[\d,]+)?$/}).click();
|
||||
await expect(shelf).toHaveCount(total);await expect(toolbar.getByRole('button')).toHaveText(['Filters']);
|
||||
await expect(shelf).toHaveCount(total);await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
|
||||
});
|
||||
|
||||
test('category Browse sets only the category constraint',async({page})=>{
|
||||
|
||||
@@ -427,10 +427,13 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
|
||||
await expect(page.locator('body > .app-shell > .workspace > #workspace-main')).toHaveCount(1);
|
||||
await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0);
|
||||
const sidebar = page.locator('aside.sidebar');
|
||||
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-bottom']);
|
||||
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-label',
|
||||
'nav#saved-nav', 'div.sidebar-bottom']);
|
||||
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE']);
|
||||
await expect(page.locator('#saved-nav, #collections-nav, .new-label')).toHaveCount(0);
|
||||
// D49: SAVED VIEWS without the concept's PROPOSED badge; D28: still no COLLECTIONS.
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
await expect(page.locator('nav#saved-nav')).toHaveAttribute('aria-label', 'Saved views');
|
||||
await expect(page.locator('#collections-nav, .new-label')).toHaveCount(0);
|
||||
const primary = page.locator('nav#primary-nav');
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Workspace');
|
||||
for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US'));
|
||||
@@ -457,7 +460,7 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
|
||||
// At phone width the concept wraps the links and hides counts and the connection (concept 950px block).
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(navCount(page, 'library')).toBeHidden();
|
||||
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(2);
|
||||
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(3);
|
||||
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
|
||||
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -168,6 +168,7 @@ _TABLES = (
|
||||
"execution_contexts",
|
||||
"execution_prompt_snapshots",
|
||||
"execution_context_texts",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
|
||||
@@ -26,6 +26,7 @@ EDITOR_METADATA = {
|
||||
"notes": "Keep the soft light.",
|
||||
}
|
||||
_TABLES = (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
|
||||
@@ -112,6 +112,7 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
|
||||
"""Replace only the owned browser harness records and media roots."""
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
|
||||
@@ -79,6 +79,7 @@ UNUSED_TAG = "unused_vocabulary"
|
||||
MAX_EXTRA_TAGS = 300
|
||||
MAX_EXTRA_CATEGORIES = 200
|
||||
_TABLES = (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Saved views (#249 D16): the state whitelist, the concept's default name, the listing parser and the shell's store.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const source = import('../../web/js/workspace/saved-views.mjs');
|
||||
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [] };
|
||||
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] };
|
||||
|
||||
test('a saved state always holds every page key and keeps only values its page accepts', async () => {
|
||||
const { normalizeViewState, SAVED_VIEW_PAGES } = await source;
|
||||
assert.deepEqual(SAVED_VIEW_PAGES, ['images', 'library']);
|
||||
assert.deepEqual(normalizeViewState('images', {}), IMAGE_DEFAULTS);
|
||||
assert.deepEqual(normalizeViewState('library', null), LIBRARY_DEFAULTS);
|
||||
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'] };
|
||||
assert.deepEqual(normalizeViewState('images', full), full);
|
||||
// Each rejected value falls back on its own; the rest of the view still applies.
|
||||
assert.deepEqual(normalizeViewState('images', { ...full, sort: 'bogus', source: '-1', media: 'audio', tags: ['a,b'], extra: 1 }),
|
||||
{ ...full, sort: 'newest', source: 'all', media: 'all', tags: [] });
|
||||
assert.deepEqual(normalizeViewState('images', { ...full, q: 'x'.repeat(501), folder: 7, tags: Array(21).fill('t') }),
|
||||
{ ...full, q: '', folder: '', tags: [] });
|
||||
assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] });
|
||||
// Library has no source, folder or media, and no Name Z–A.
|
||||
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'] });
|
||||
assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' });
|
||||
// The returned tags are a copy.
|
||||
const tags = ['one'];
|
||||
const state = normalizeViewState('library', { tags });
|
||||
state.tags.push('two');
|
||||
assert.deepEqual(tags, ['one']);
|
||||
assert.throws(() => normalizeViewState('tags', {}), TypeError);
|
||||
});
|
||||
|
||||
test('the default name is the concept search name, else My workspace view, within the name limit', async () => {
|
||||
const { defaultViewName } = await source;
|
||||
assert.equal(defaultViewName('neon'), 'Search: neon');
|
||||
assert.equal(defaultViewName(' neon city '), 'Search: neon city');
|
||||
assert.equal(defaultViewName(''), 'My workspace view');
|
||||
assert.equal(defaultViewName(' '), 'My workspace view');
|
||||
assert.equal(defaultViewName(undefined), 'My workspace view');
|
||||
assert.equal(defaultViewName('x'.repeat(500)).length, 100);
|
||||
});
|
||||
|
||||
test('the listing keeps usable views in order and normalizes their state', async () => {
|
||||
const { parseSavedViews, parseSavedView, savedViewHash } = await source;
|
||||
const views = parseSavedViews({ success: true, views: [
|
||||
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } },
|
||||
{ id: 0, page: 'images', name: 'Bad id', state: {} },
|
||||
{ id: 4, page: 'tags', name: 'Bad page', state: {} },
|
||||
{ id: 5, page: 'images', name: ' ', state: {} },
|
||||
{ id: 1, page: 'images', name: 'Videos', state: { media: 'video', source: '9' } },
|
||||
] });
|
||||
assert.deepEqual(views, [
|
||||
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } },
|
||||
{ id: 1, page: 'images', name: 'Videos', state: { ...IMAGE_DEFAULTS, media: 'video', source: '9' } },
|
||||
]);
|
||||
assert.equal(parseSavedView(null), null);
|
||||
assert.throws(() => parseSavedViews({ success: true }), TypeError);
|
||||
assert.equal(savedViewHash(12), '#/saved/12');
|
||||
});
|
||||
|
||||
test('page filters and saved states convert both ways', async () => {
|
||||
const { imagesFilters, imagesViewState, libraryFilters, libraryViewState } = await source;
|
||||
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'] };
|
||||
const state = imagesViewState(filters);
|
||||
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'] });
|
||||
assert.deepEqual(imagesFilters(state), filters);
|
||||
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [] });
|
||||
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] };
|
||||
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] });
|
||||
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
|
||||
});
|
||||
|
||||
function fakeApi() {
|
||||
const calls = [];
|
||||
const pending = [];
|
||||
const api = {
|
||||
calls,
|
||||
get(url, options) {
|
||||
calls.push(['GET', url, options.cache]);
|
||||
return new Promise((resolve, reject) => pending.push({ resolve, reject, signal: options.signal }));
|
||||
},
|
||||
post(url, body) {
|
||||
calls.push(['POST', url, body]);
|
||||
return Promise.resolve({ success: true, view: { id: 9, ...body } });
|
||||
},
|
||||
};
|
||||
return { api, pending };
|
||||
}
|
||||
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
test('the store reports loads, keeps the last views after a failed read, and a newer read supersedes', async () => {
|
||||
const { createSavedViews, SAVED_VIEWS_URL } = await source;
|
||||
const { api, pending } = fakeApi();
|
||||
const controller = new AbortController();
|
||||
const store = createSavedViews(api, controller.signal);
|
||||
const seen = [];
|
||||
store.subscribe(next => seen.push([next.status, next.views.map(view => view.id)]));
|
||||
store.load();
|
||||
const settled = store.settled();
|
||||
pending[0].resolve({ success: true, views: [{ id: 2, page: 'images', name: 'A', state: {} }] });
|
||||
assert.equal((await settled).status, 'ready');
|
||||
assert.deepEqual(store.find(2).state, IMAGE_DEFAULTS);
|
||||
assert.equal(store.find(3), null);
|
||||
assert.deepEqual(api.calls[0], ['GET', SAVED_VIEWS_URL, 'no-store']);
|
||||
|
||||
store.load();
|
||||
pending[1].reject(new Error('offline'));
|
||||
await flush();
|
||||
assert.equal(store.status, 'error');
|
||||
assert.deepEqual(store.views.map(view => view.id), [2]);
|
||||
|
||||
// A second read aborts the first; only the newer one lands.
|
||||
store.load();
|
||||
store.load();
|
||||
assert.equal(pending[2].signal.aborted, true);
|
||||
pending[2].resolve({ success: true, views: [] });
|
||||
pending[3].resolve({ success: true, views: [{ id: 5, page: 'library', name: 'B', state: {} }] });
|
||||
await flush();
|
||||
assert.deepEqual(store.views.map(view => view.id), [5]);
|
||||
assert.deepEqual(seen, [['loading', []], ['ready', [2]], ['loading', [2]], ['error', [2]], ['loading', [2]], ['loading', [2]], ['ready', [5]]]);
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
test('creating a view posts the full state and re-reads the list', async () => {
|
||||
const { createSavedViews, SAVED_VIEWS_URL } = await source;
|
||||
const { api, pending } = fakeApi();
|
||||
const store = createSavedViews(api);
|
||||
const created = store.create('library', 'Neon', { q: 'neon' });
|
||||
await flush();
|
||||
assert.deepEqual(api.calls[0], ['POST', SAVED_VIEWS_URL, { page: 'library', name: 'Neon', state: { ...LIBRARY_DEFAULTS, q: 'neon' } }]);
|
||||
pending[0].resolve({ success: true, views: [{ id: 9, page: 'library', name: 'Neon', state: { q: 'neon' } }] });
|
||||
assert.deepEqual(await created, { id: 9, page: 'library', name: 'Neon', state: { ...LIBRARY_DEFAULTS, q: 'neon' } });
|
||||
assert.equal(store.find(9).name, 'Neon');
|
||||
const broken = createSavedViews({ ...api, post: () => Promise.resolve({ success: true, view: null }) });
|
||||
await assert.rejects(broken.create('images', 'X', {}), TypeError);
|
||||
});
|
||||
@@ -160,3 +160,24 @@ test('navigating to the current route re-renders it, as the concept go() does',
|
||||
win.dispatchEvent(new Event('hashchange'));assert.deepEqual(seen.at(-1),['library',13]);
|
||||
dispose();navigateTo('#/library/13',win);assert.equal(seen.length,3);
|
||||
});
|
||||
|
||||
// Saved views (#249 D16): `#/saved/<id>` opens one persisted view; the shell resolves it to its page.
|
||||
test('saved-view links resolve to their id and re-render only when the id changes', async () => {
|
||||
const {resolveRoute,mountRouter,isHandoff,routes}=await source;
|
||||
const route=resolveRoute('#/saved/7');
|
||||
assert.deepEqual([route.id,route.savedId,route.hash],['saved',7,'#/saved/7']);
|
||||
assert.equal(isHandoff(route),false);
|
||||
assert.equal(routes.some(item=>item.id==='saved'),false,'saved views are not an Overview card or a WORKSPACE link');
|
||||
for(const hash of ['#/saved','#/saved/0','#/saved/-2','#/saved/1.5','#/saved/9007199254740992','#/saved/3/x','#/saved/abc']) {
|
||||
assert.equal(resolveRoute(hash).id,'images',hash);
|
||||
}
|
||||
const win=new EventTarget();win.location={hash:'#/saved/3'};
|
||||
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
|
||||
const seen=[];const dispose=mountRouter(win,next=>seen.push([next.id,next.savedId]));
|
||||
assert.equal(win.location.hash,'#/saved/3');
|
||||
win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/saved/4';win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/images';win.dispatchEvent(new Event('hashchange'));
|
||||
assert.deepEqual(seen,[['saved',3],['saved',4],['images',undefined]]);
|
||||
dispose();
|
||||
});
|
||||
|
||||
@@ -116,19 +116,21 @@ test('toolbar emits concept markup and order', async () => {
|
||||
dom.body.append(container);
|
||||
const changes = [];
|
||||
const filters = [];
|
||||
const saves = [];
|
||||
const toolbar = mountToolbar(container, {
|
||||
scope, categories: ['Portraits', 'Landscapes'],
|
||||
sorts: [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated']],
|
||||
value: { q: 'castle', category: 'Landscapes', sort: 'name' },
|
||||
onChange: value => changes.push(value), onFilters: trigger => filters.push(trigger),
|
||||
onSaveView: trigger => saves.push(trigger),
|
||||
});
|
||||
const root = container.children[0];
|
||||
assert.equal(toolbar.element, root);
|
||||
assert.equal(root.tagName, 'SEARCH');
|
||||
assert.equal(root.className, 'toolbar');
|
||||
assert.deepEqual(tags(root), ['label', 'select', 'select', 'button']);
|
||||
assert.deepEqual(tags(root), ['label', 'select', 'select', 'button', 'button']);
|
||||
|
||||
const [label, category, sort, filtersButton] = root.children;
|
||||
const [label, category, sort, saveButton, filtersButton] = root.children;
|
||||
assert.equal(label.className, 'search-field');
|
||||
assert.equal(label.htmlFor, 'search');
|
||||
assert.deepEqual(tags(label), ['svg', 'input']);
|
||||
@@ -151,8 +153,12 @@ test('toolbar emits concept markup and order', async () => {
|
||||
assert.equal(sort.getAttribute('aria-label'), 'Sort results');
|
||||
assert.equal(sort.value, 'name');
|
||||
|
||||
// D16: no Save view until saved views are persisted.
|
||||
assert.equal(button(root, 'Save view'), undefined);
|
||||
// The concept's Save view, between sort and Filters (a359), reports its own button as the trigger.
|
||||
assert.equal(button(root, 'Save view'), saveButton);
|
||||
assert.equal(saveButton.type, 'button');
|
||||
saveButton.click();
|
||||
assert.deepEqual(saves, [saveButton]);
|
||||
assert.deepEqual(filters, []);
|
||||
assert.equal(filtersButton.textContent, 'Filters');
|
||||
assert.equal(filtersButton.type, 'button');
|
||||
filtersButton.click();
|
||||
@@ -184,6 +190,9 @@ test('toolbar emits concept markup and order', async () => {
|
||||
toolbar.setValue({ sort: 'bogus' });
|
||||
assert.equal(sort.value, 'newest');
|
||||
assert.throws(() => mountToolbar(container, { categories: [], sorts: [['newest', 'Recently added']],
|
||||
onChange() {}, onFilters() {}, onSaveView() {} }), TypeError);
|
||||
// Every toolbar page offers Save view, so the callback is required.
|
||||
assert.throws(() => mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
onChange() {}, onFilters() {} }), TypeError);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
@@ -200,7 +209,7 @@ test('toolbar search uses a debounced onChange that stops with its scope', async
|
||||
const container = new dom.Element('div');
|
||||
const changes = [];
|
||||
mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
value: { q: '', category: '', sort: 'newest' }, onChange: value => changes.push(value), onFilters() {} });
|
||||
value: { q: '', category: '', sort: 'newest' }, onChange: value => changes.push(value), onFilters() {}, onSaveView() {} });
|
||||
const input = container.children[0].children[0].children[1];
|
||||
assert.equal(input.id, 'search');
|
||||
for (const text of ['c', 'ca', 'cat']) {
|
||||
@@ -244,7 +253,7 @@ test('sort lists concept options first', async () => {
|
||||
try {
|
||||
const container = new dom.Element('div');
|
||||
mountToolbar(container, { scope, categories: [], sorts: given, value: { q: '', category: '', sort: 'oldest' },
|
||||
onChange() {}, onFilters() {} });
|
||||
onChange() {}, onFilters() {}, onSaveView() {} });
|
||||
const sort = container.children[0].children[2];
|
||||
assert.deepEqual(optionPairs(sort).map(([, label]) => label),
|
||||
['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Filename Z–A']);
|
||||
@@ -469,6 +478,40 @@ test('copy fallback selects the text', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// The concept's Save view dialog (a1330–1346 via simpleInput a1138): an empty name falls back to "Saved view".
|
||||
test('an input dialog with a fallback applies it for an empty value', async () => {
|
||||
const { openInputDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const applied = [];
|
||||
const open = value => openInputDialog({ scope, title: 'Save this view', label: 'View name', action: 'Save',
|
||||
value, maxLength: 100, fallback: 'Saved view', onApply: name => applied.push(name) });
|
||||
open('My workspace view');
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Save this view');
|
||||
const body = byId(dom, 'modal-body');
|
||||
const [field, actions] = body.children;
|
||||
const [label, input] = field.children;
|
||||
assert.equal(label.textContent, 'View name');
|
||||
assert.equal(input.id, 'action-input');
|
||||
assert.equal(input.value, 'My workspace view');
|
||||
assert.equal(input.maxLength, 100);
|
||||
assert.deepEqual(actions.children.map(node => node.textContent), ['Cancel', 'Save']);
|
||||
input.value = ' ';
|
||||
actions.children[1].click();
|
||||
assert.deepEqual(applied, ['Saved view']);
|
||||
open('');
|
||||
byId(dom, 'action-input').value = ' Castles ';
|
||||
byId(dom, 'modal-body').children[1].children[1].click();
|
||||
assert.deepEqual(applied, ['Saved view', 'Castles']);
|
||||
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', fallback: '', onApply() {} }), TypeError);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('dialogs restore focus to the trigger on Escape', async () => {
|
||||
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
@@ -662,8 +705,8 @@ test('the Filters button shows the active refinement count', async () => {
|
||||
try {
|
||||
const container = new dom.Element('div');
|
||||
const toolbar = mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
activeFilters: 2, onChange() {}, onFilters() {} });
|
||||
const filtersButton = container.children[0].children[3];
|
||||
activeFilters: 2, onChange() {}, onFilters() {}, onSaveView() {} });
|
||||
const filtersButton = container.children[0].children[4];
|
||||
// The visible text is the accessible name, so the count is spoken too.
|
||||
assert.equal(filtersButton.textContent, 'Filters · 2');
|
||||
toolbar.setActiveFilters(1);
|
||||
@@ -673,7 +716,7 @@ test('the Filters button shows the active refinement count', async () => {
|
||||
for (const bad of [-1, 1.5, '1', null, Number.NaN]) assert.throws(() => toolbar.setActiveFilters(bad), TypeError);
|
||||
assert.equal(filtersButton.textContent, 'Filters');
|
||||
assert.throws(() => mountToolbar(new dom.Element('div'), { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
activeFilters: -1, onChange() {}, onFilters() {} }), TypeError);
|
||||
activeFilters: -1, onChange() {}, onFilters() {}, onSaveView() {} }), TypeError);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
|
||||
@@ -13,6 +13,7 @@ import { pageHeading } from './heading.mjs';
|
||||
import { mountToolbar } from './toolbar.mjs';
|
||||
import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { imagesFilters, imagesViewState } from './saved-views.mjs';
|
||||
|
||||
/** The concept's panel without an image (app.js updateImages a456–458). */
|
||||
function noSelection() {
|
||||
@@ -99,8 +100,10 @@ function deliverFavorite(key, favorite) {
|
||||
/**
|
||||
* An embedded collection (the history page's shared gallery and its All images and Repeat run dialogs, D47) has no
|
||||
* caption: its page heading or dialog title already names it, and `focusTarget` takes a stranded focus.
|
||||
* `savedState` opens a saved view (#249 D16) instead of the last visit; `onSaveView(trigger, state)` is Save view.
|
||||
*/
|
||||
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null} = {}) {
|
||||
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null,
|
||||
onSaveView = () => {}} = {}) {
|
||||
const scope = createScope();
|
||||
const dispose = () => scope.dispose();
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
@@ -113,7 +116,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next)));
|
||||
scope.cleanup(() => inspection.dispose());
|
||||
scope.cleanup(() => detailInspector?.dispose());
|
||||
const visit = embedded ? null : lastVisit;
|
||||
const saved = embedded || !savedState ? null : imagesFilters(savedState);
|
||||
const visit = embedded || saved ? null : lastVisit;
|
||||
let density = visit?.density || 'comfortable';
|
||||
let metrics = {...INITIAL_METRICS};
|
||||
let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
|
||||
@@ -122,8 +126,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
let roots = [], folders = [];
|
||||
const rendered = new Map();
|
||||
const itemIndexes = new Map();
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters };
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters, ...saved };
|
||||
filters.tags = [...filters.tags];
|
||||
if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';
|
||||
// A saved Source or Folder is only listed once discovery confirms it still exists; until then nothing is fetched.
|
||||
let savedScopePending = Boolean(saved) && (filters.root !== 'all' || filters.subfolder !== '');
|
||||
scope.cleanup(() => {
|
||||
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
|
||||
if (!embedded) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
|
||||
@@ -157,7 +164,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
if (Object.entries(next).every(([key, item]) => filters[key] === item)) return;
|
||||
Object.assign(filters, next);
|
||||
reset();
|
||||
}, onFilters:openFilters});
|
||||
}, onFilters:openFilters, onSaveView:trigger => {
|
||||
const value = toolbar.value();
|
||||
onSaveView(trigger, imagesViewState({...filters, q:value.q.trim(), category:value.category, sort:value.sort}));
|
||||
}});
|
||||
const toolbarControls = toolbar ? [...toolbar.element.querySelectorAll('input, select, button')] : [];
|
||||
const optionsStatus = el('p', '', 'options-status muted');
|
||||
optionsStatus.hidden = true; optionsStatus.setAttribute('role', 'alert');
|
||||
@@ -593,9 +603,17 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// The ruled selection lifetime: a replaced collection may no longer hold the checked files.
|
||||
bar?.clear();
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
settleSavedScope();
|
||||
updateScope();
|
||||
stream.reset(filters, {load:!recovering});
|
||||
}
|
||||
/** A saved view's Source and Folder apply only while the discovered sources still list them (#249 D16). */
|
||||
function settleSavedScope() {
|
||||
if (!savedScopePending) return;
|
||||
savedScopePending = false;
|
||||
if (filters.root !== 'all' && !roots.some(root => String(root.index) === filters.root)) filters.root = 'all';
|
||||
if (!folderOptions(filters.root).some(([path]) => path === filters.subfolder)) filters.subfolder = '';
|
||||
}
|
||||
/** A panel tag chip (D21): the Images Tags filter becomes exactly that tag, as "Refine this view" would set it. */
|
||||
function filterByTag(tag) {
|
||||
if (recovering || !tag.trim() || tag.includes(',')) return;
|
||||
@@ -744,7 +762,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
scope.cleanup(() => { optionsRequest?.abort(); categoryRequest?.abort(); });
|
||||
updateScope();
|
||||
// The page's one discovery alert is written once both first loads settled (#249 final review m6).
|
||||
if (!embedded) Promise.all([loadOptions(), loadCategories()]).then(() => { if (!scope.signal.aborted) renderLoadStatus(); });
|
||||
if (!embedded) Promise.all([loadOptions(), loadCategories()]).then(() => {
|
||||
if (scope.signal.aborted) return;
|
||||
renderLoadStatus();
|
||||
if (savedScopePending) reset();
|
||||
});
|
||||
if (visit) {
|
||||
renderState(stream.state);
|
||||
const selected = stream.state.items.find(image => imageKey(image) === selection);
|
||||
@@ -753,7 +775,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
if (!scope.signal.aborted) window.scrollTo(0, visit.scrollY);
|
||||
});
|
||||
if (visit.state.loading || visit.state.error) stream.loadMore();
|
||||
} else reset();
|
||||
} else if (savedScopePending) updateScope();
|
||||
else reset();
|
||||
/** D6: the selected file's prompt pair, only once its inspection loaded with both roles resolved. */
|
||||
function selectedPair() {
|
||||
const {record, prompts, loading, error, refreshRequired} = inspection.state;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { bindCanvasLink, canvasHref } from './topbar.mjs';
|
||||
import { renderHeading } from './heading.mjs';
|
||||
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
|
||||
import { createSavedViews, openSaveViewDialog, savedViewHash } from './saved-views.mjs';
|
||||
|
||||
const { createApiClient, resolveUrl } = window.ApiClient;
|
||||
|
||||
@@ -31,6 +32,7 @@ function mountWorkspace() {
|
||||
const api = createApiClient();
|
||||
const main = document.getElementById('workspace-main');
|
||||
const primaryNav = document.getElementById('primary-nav');
|
||||
const savedNav = document.getElementById('saved-nav');
|
||||
const systemNav = document.getElementById('system-nav');
|
||||
scope.listen(document.querySelector('.skip'), 'click', event => {
|
||||
event.preventDefault();
|
||||
@@ -61,6 +63,32 @@ function mountWorkspace() {
|
||||
// D28: System, then the production Overview hub, in the concept's tools-and-system group.
|
||||
systemNav.replaceChildren(...navLinks([['system', 'settings'], ['overview', 'overview']]));
|
||||
const sidebar = document.querySelector('aside.sidebar');
|
||||
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`.
|
||||
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` : route.id;
|
||||
let currentKey = null;
|
||||
const markCurrent = () => {
|
||||
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
|
||||
if (link.dataset.route === currentKey) link.setAttribute('aria-current', 'page');
|
||||
else link.removeAttribute('aria-current');
|
||||
}
|
||||
};
|
||||
// SAVED VIEWS (#249 D16): the concept's saved-nav rows (search icon, name, no count), loaded with the shell and
|
||||
// re-read after every change. /* D49 */ no "PROPOSED" badge; /* D50 */ no "Needs review" row.
|
||||
const savedViews = createSavedViews(api, scope.signal);
|
||||
scope.cleanup(savedViews.subscribe(({ views }) => {
|
||||
savedNav.replaceChildren(...views.map(view => {
|
||||
const link = element('a');
|
||||
link.append(createWorkspaceIcon('search'), document.createTextNode(view.name));
|
||||
link.href = savedViewHash(view.id);
|
||||
link.dataset.route = `saved/${view.id}`;
|
||||
return link;
|
||||
}));
|
||||
markCurrent();
|
||||
}));
|
||||
savedViews.load();
|
||||
/** The toolbar's Save view on a page: the concept's name dialog, then the store saves the page's full state. */
|
||||
const saveView = (page, view) => (trigger, state) => openSaveViewDialog({ scope: view, trigger, q: state.q,
|
||||
onSave: name => savedViews.create(page, name, state) });
|
||||
// One read per count, so a write that refreshes Library and Tags never supersedes an Images read in flight.
|
||||
const countRequests = new Map();
|
||||
function refreshCounts(ids) {
|
||||
@@ -92,10 +120,48 @@ function mountWorkspace() {
|
||||
images = null;
|
||||
viewScope = createScope();
|
||||
const view = viewScope;
|
||||
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
|
||||
if (link.dataset.route === route.id) link.setAttribute('aria-current', 'page');
|
||||
else link.removeAttribute('aria-current');
|
||||
currentKey = routeKey(route);
|
||||
markCurrent();
|
||||
if (route.id === 'saved') {
|
||||
openSavedView(route, view);
|
||||
return;
|
||||
}
|
||||
renderPage(route, view);
|
||||
}
|
||||
|
||||
/** A saved view opens its page with its state; the heading, breadcrumb and title are the view's name. */
|
||||
async function openSavedView(route, view) {
|
||||
main.replaceChildren();
|
||||
await savedViews.settled();
|
||||
if (view.signal.aborted) return;
|
||||
// A view saved since the list loaded (another tab) is read once more before the link counts as gone.
|
||||
if (!savedViews.find(route.savedId) && savedViews.status === 'ready') {
|
||||
await savedViews.load();
|
||||
if (view.signal.aborted) return;
|
||||
}
|
||||
const saved = savedViews.find(route.savedId);
|
||||
if (saved) {
|
||||
const page = routes.find(item => item.id === saved.page);
|
||||
renderPage({...page, label:saved.name, title:saved.name}, view, saved);
|
||||
return;
|
||||
}
|
||||
if (savedViews.status === 'ready') {
|
||||
// A removed or unknown view: the shell's default page, without a history entry for the dead link.
|
||||
window.location.replace('#/images');
|
||||
return;
|
||||
}
|
||||
const {description} = renderHeading(main, {title:route.label});
|
||||
description.textContent = 'Saved views could not be loaded. Your saved data has not changed.';
|
||||
description.setAttribute('role', 'alert');
|
||||
const retry = createButton('Retry');
|
||||
view.listen(retry, 'click', () => {
|
||||
savedViews.load();
|
||||
openSavedView(route, view);
|
||||
});
|
||||
main.append(retry);
|
||||
}
|
||||
|
||||
function renderPage(route, view, saved = null) {
|
||||
document.title = `${routeTitle(route)} · Prompt Manager`;
|
||||
// The concept's heading(); each view fills its description and actions.
|
||||
// The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`.
|
||||
@@ -105,11 +171,11 @@ function mountWorkspace() {
|
||||
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
|
||||
if (route.id === 'images') {
|
||||
mountNewPromptAction(main, api, view);
|
||||
images = mountImages(main, api, view);
|
||||
images = mountImages(main, api, view, {savedState:saved?.state, onSaveView:saveView('images', view)});
|
||||
return;
|
||||
}
|
||||
if (route.id === 'library') {
|
||||
mountLibrary(main, api, view, {positiveId:route.positiveId});
|
||||
mountLibrary(main, api, view, {positiveId:route.positiveId, savedState:saved?.state, onSaveView:saveView('library', view)});
|
||||
return;
|
||||
}
|
||||
if (route.id === 'tags') {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {countOf} from './plural.mjs';
|
||||
import {addedTerms} from './history-state.mjs';
|
||||
import {pageHeading} from './heading.mjs';
|
||||
import {mountToolbar} from './toolbar.mjs';
|
||||
import {libraryFilters,libraryViewState} from './saved-views.mjs';
|
||||
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
@@ -133,7 +134,8 @@ export function mountNewPromptAction(main,api,parentScope) {
|
||||
return button;
|
||||
}
|
||||
|
||||
export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
/** `savedState` opens a saved view (#249 D16) instead of the last visit; `onSaveView(trigger, state)` is Save view. */
|
||||
export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,onSaveView=()=>{}}={}) {
|
||||
const scope=createScope();parentScope.cleanup(()=>scope.dispose());
|
||||
main.classList.add('library-main');
|
||||
const sender=createPromptSender({scope});
|
||||
@@ -144,10 +146,11 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
scope.cleanup(()=>main.classList.remove('library-main'));
|
||||
|
||||
function mountShelf() {
|
||||
const visit=shelfVisit, mountedGeneration=writeGeneration;
|
||||
const saved=savedState?libraryFilters(savedState):null;
|
||||
const visit=saved?null:shelfVisit, mountedGeneration=writeGeneration;
|
||||
// An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again.
|
||||
const restorable=visit?.state?visit:null;
|
||||
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters};
|
||||
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters,...saved};
|
||||
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
|
||||
// The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15).
|
||||
// Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY.
|
||||
@@ -155,7 +158,8 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
// D44: Filters reads "Filters · 1" while a Tags filter (from Refine or a Browse) narrows the shelf.
|
||||
const refinements=()=>filters.tags.length?1:0;
|
||||
const toolbar=mountToolbar(section,{scope:toolbarScope,categories:filters.category?[filters.category]:[],sorts:LIBRARY_SORTS,
|
||||
value:{q:filters.text,category:filters.category,sort:filters.sort},activeFilters:refinements(),onChange:applyToolbar,onFilters:openFilters});
|
||||
value:{q:filters.text,category:filters.category,sort:filters.sort},activeFilters:refinements(),onChange:applyToolbar,onFilters:openFilters,
|
||||
onSaveView:trigger=>{const value=toolbar.value();onSaveView(trigger,libraryViewState({...filters,text:value.q.trim(),category:value.category,sort:value.sort}));}});
|
||||
const optionsStatus=el('p','','library-options-status');optionsStatus.hidden=true;optionsStatus.setAttribute('role','alert');
|
||||
// The concept caption (app.js renderLibrary a586): the filtered set's parents and their negative variants.
|
||||
const caption=el('div',undefined,'section-caption');const captionCount=el('span');caption.append(captionCount,el('span','Images are grouped by positive prompt'));caption.hidden=true;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Saved views (#249 D16, maintainer ruling 2026-10-21): a named toolbar value plus its "Refine this view" filters, for
|
||||
* Images or the Prompt library, persisted through `/prompt_manager/saved-views`.
|
||||
*
|
||||
* - The toolbar's Save view opens the concept's dialog (app.js a1330–1346 through `simpleInput` a1138): "Save this
|
||||
* view", "View name", `Search: <q>` or "My workspace view", Cancel / Save; an empty name is "Saved view".
|
||||
* - The sidebar SAVED VIEWS group lists each view (concept `nav()` a331–335) as `#/saved/<id>` (a1158–1162).
|
||||
* - A view's state always holds every key of its page (the API requires them all); a value its page no longer
|
||||
* accepts is dropped to that key's default, so an opened view applies what it can.
|
||||
*/
|
||||
import { openInputDialog } from './workspace-dialogs.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
export const SAVED_VIEWS_URL = '/prompt_manager/saved-views';
|
||||
export const SAVED_TOAST = 'Saved view added to the sidebar.';
|
||||
export const SAVE_FAILED_TOAST = 'The view could not be saved. Try again.';
|
||||
const MAX_NAME_LENGTH = 100;
|
||||
const MAX_TAGS = 20;
|
||||
const MAX_TAG_LENGTH = 50;
|
||||
const CONTROL = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
// The API's whitelist (database/saved_views.py _STATE_FIELDS), with each key's default.
|
||||
const text = (limit, fallback = '') => ({ fallback, accepts: value => typeof value === 'string' && value.length <= limit && !CONTROL.test(value) });
|
||||
const choice = (choices, fallback) => ({ fallback, accepts: value => choices.includes(value) });
|
||||
const tags = { fallback: [], accepts: value => Array.isArray(value) && value.length <= MAX_TAGS && value.every(tag =>
|
||||
typeof tag === 'string' && tag.trim() && tag.length <= MAX_TAG_LENGTH && !tag.includes(',') && !CONTROL.test(tag)) };
|
||||
const FIELDS = Object.freeze({
|
||||
images: Object.freeze({
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc', 'rating'], 'newest'),
|
||||
source: { fallback: 'all', accepts: value => typeof value === 'string' && /^(all|0|[1-9][0-9]{0,8})$/.test(value) },
|
||||
folder: text(1024), media: choice(['all', 'image', 'video'], 'all'), tags,
|
||||
}),
|
||||
library: Object.freeze({
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags,
|
||||
}),
|
||||
});
|
||||
|
||||
export const SAVED_VIEW_PAGES = Object.freeze(Object.keys(FIELDS));
|
||||
|
||||
/** The page's full state: every key, each saved value its page accepts, the default otherwise. */
|
||||
export function normalizeViewState(page, state) {
|
||||
const fields = FIELDS[page];
|
||||
if (!fields) throw new TypeError(`Unknown saved-view page: ${page}`);
|
||||
const source = state && typeof state === 'object' && !Array.isArray(state) ? state : {};
|
||||
return Object.fromEntries(Object.entries(fields).map(([key, field]) => {
|
||||
const value = field.accepts(source[key]) ? source[key] : field.fallback;
|
||||
return [key, Array.isArray(value) ? [...value] : value];
|
||||
}));
|
||||
}
|
||||
|
||||
/** The concept's default name (a1336): `Search: <q>` while there is a search, else "My workspace view". */
|
||||
export function defaultViewName(q) {
|
||||
const search = typeof q === 'string' ? q.trim() : '';
|
||||
return (search ? `Search: ${search}` : 'My workspace view').slice(0, MAX_NAME_LENGTH);
|
||||
}
|
||||
|
||||
/** One listed view, or null when the response row is not a usable view. */
|
||||
export function parseSavedView(row) {
|
||||
if (!Number.isSafeInteger(row?.id) || row.id < 1 || !SAVED_VIEW_PAGES.includes(row.page)) return null;
|
||||
if (typeof row.name !== 'string' || !row.name.trim()) return null;
|
||||
return { id: row.id, page: row.page, name: row.name, state: normalizeViewState(row.page, row.state) };
|
||||
}
|
||||
|
||||
/** The listing's views in the server's order, skipping any row that is not a usable view. */
|
||||
export function parseSavedViews(data) {
|
||||
if (!Array.isArray(data?.views)) throw new TypeError('Invalid saved views');
|
||||
return data.views.map(parseSavedView).filter(Boolean);
|
||||
}
|
||||
|
||||
/** The route of one view. */
|
||||
export const savedViewHash = id => `#/saved/${id}`;
|
||||
|
||||
/** Images filters (images.mjs `filters`) from a saved state, and the state of the current filters. */
|
||||
export function imagesFilters(state) {
|
||||
const view = normalizeViewState('images', state);
|
||||
return { q: view.q, category: view.category, sort: view.sort, root: view.source, subfolder: view.folder, media: view.media, tags: view.tags };
|
||||
}
|
||||
export function imagesViewState(filters) {
|
||||
return normalizeViewState('images', { q: filters.q, category: filters.category, sort: filters.sort, source: filters.root,
|
||||
folder: filters.subfolder, media: filters.media, tags: filters.tags });
|
||||
}
|
||||
|
||||
/** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */
|
||||
export function libraryFilters(state) {
|
||||
const view = normalizeViewState('library', state);
|
||||
return { text: view.q, category: view.category, sort: view.sort, tags: view.tags };
|
||||
}
|
||||
export function libraryViewState(filters) {
|
||||
return normalizeViewState('library', { q: filters.text, category: filters.category, sort: filters.sort, tags: filters.tags });
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell's list of saved views: `load()` re-reads it (a newer read supersedes an older one), `create()` saves one
|
||||
* and re-reads the list, `subscribe(fn)` reports `{views, status}` on every change, and `settled()` resolves once no
|
||||
* read is in flight. `status` is 'loading', 'ready' or 'error'; a failed read keeps the last listed views.
|
||||
*/
|
||||
export function createSavedViews(api, signal) {
|
||||
let views = [], status = 'loading', request = null;
|
||||
const listeners = new Set();
|
||||
let waiting = [];
|
||||
const emit = () => {
|
||||
const snapshot = { views, status };
|
||||
for (const listener of listeners) listener(snapshot);
|
||||
if (status !== 'loading') { const done = waiting; waiting = []; for (const resolve of done) resolve(snapshot); }
|
||||
};
|
||||
function load() {
|
||||
request?.abort();
|
||||
const controller = new AbortController();
|
||||
request = controller;
|
||||
const abort = () => controller.abort();
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
status = 'loading';
|
||||
emit();
|
||||
return api.get(SAVED_VIEWS_URL, { signal: controller.signal, cache: 'no-store' }).then(data => {
|
||||
if (request !== controller) return;
|
||||
views = parseSavedViews(data);
|
||||
status = 'ready';
|
||||
}).catch(() => {
|
||||
if (request !== controller) return;
|
||||
status = 'error';
|
||||
}).finally(() => {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (request !== controller || signal?.aborted) return;
|
||||
request = null;
|
||||
emit();
|
||||
});
|
||||
}
|
||||
async function create(page, name, state) {
|
||||
const data = await api.post(SAVED_VIEWS_URL, { page, name, state: normalizeViewState(page, state) }, { signal });
|
||||
const view = parseSavedView(data?.view);
|
||||
if (!view) throw new TypeError('Invalid saved view');
|
||||
await load();
|
||||
return view;
|
||||
}
|
||||
return {
|
||||
load, create,
|
||||
get views() { return views; },
|
||||
get status() { return status; },
|
||||
find: id => views.find(view => view.id === id) ?? null,
|
||||
settled: () => status === 'loading' ? new Promise(resolve => waiting.push(resolve)) : Promise.resolve({ views, status }),
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The concept's Save view (a1330–1346): the name dialog, then the save. `onSave(name)` returns the save's promise;
|
||||
* the toast reports either outcome.
|
||||
*/
|
||||
export function openSaveViewDialog({ scope, trigger, q, onSave }) {
|
||||
openInputDialog({ scope, trigger, title: 'Save this view', label: 'View name', action: 'Save', value: defaultViewName(q),
|
||||
maxLength: MAX_NAME_LENGTH, fallback: 'Saved view', onApply: async name => {
|
||||
try {
|
||||
await onSave(name);
|
||||
showWorkspaceToast(SAVED_TOAST);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(SAVE_FAILED_TOAST);
|
||||
}
|
||||
} });
|
||||
}
|
||||
@@ -13,7 +13,10 @@ export const routes = [
|
||||
description: 'Open the current dashboard, then choose Settings or Maintenance for configuration, scans, backups and diagnostics.', action: 'Open existing administration' },
|
||||
];
|
||||
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview']);
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview', 'saved']);
|
||||
|
||||
/** A saved view (#249 D16): the shell resolves its id to the view's page, name and state. */
|
||||
const SAVED_ROUTE = Object.freeze({ id: 'saved', label: 'Saved view' });
|
||||
|
||||
/** Breadcrumb and document title: the concept names a few pages differently from their nav label. */
|
||||
export function routeTitle(route) {
|
||||
@@ -26,6 +29,8 @@ export function isHandoff(route) {
|
||||
}
|
||||
|
||||
export function resolveRoute(hash) {
|
||||
const saved = /^#\/saved\/([1-9]\d*)$/.exec(hash);
|
||||
if (saved && Number.isSafeInteger(Number(saved[1]))) return {...SAVED_ROUTE, savedId:Number(saved[1]), hash};
|
||||
const match = /^#\/library\/([1-9]\d*)$/.exec(hash);
|
||||
if (match && Number.isSafeInteger(Number(match[1]))) {
|
||||
return {...routes.find(route => route.id === 'library'), positiveId:Number(match[1]), hash};
|
||||
@@ -96,7 +101,7 @@ export function mountRouter(win, onRoute) {
|
||||
const route = resolveRoute(win.location.hash);
|
||||
const hash = route.hash || `#/${route.id}`;
|
||||
if (win.location.hash !== hash) win.history.replaceState(win.history.state, '', hash);
|
||||
if (current?.id === route.id && current?.positiveId === route.positiveId) return;
|
||||
if (current?.id === route.id && current?.positiveId === route.positiveId && current?.savedId === route.savedId) return;
|
||||
current = route;
|
||||
onRoute(route);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* The concept's shared page toolbar (docs/concepts/workspace/app.js toolbar(), a347–360), used by Images and the
|
||||
* Prompt library: `search.toolbar` > `label.search-field` (search icon + `input#search`), `select#category-filter`,
|
||||
* `select#sort`, then `Filters`. Recorded deviations (#249): the sort lists the concept's three options first and a
|
||||
* page's extra ones after them (D14); there is no `Save view` until saved views are persisted (D16); `Filters` reads
|
||||
* "Filters · N" while N "Refine this view" filters are active (D44).
|
||||
* `select#sort`, `Save view`, then `Filters`. Recorded deviations (#249): the sort lists the concept's three options
|
||||
* first and a page's extra ones after them (D14); `Filters` reads "Filters · N" while N "Refine this view" filters are
|
||||
* active (D44).
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
@@ -55,16 +55,16 @@ function categoryOptions(categories) {
|
||||
|
||||
/**
|
||||
* Mount the toolbar into `container`. `value` is `{q, category, sort}`; `onChange(value)` receives the whole new
|
||||
* value after a select change or, debounced, after typing; `onFilters(trigger)` opens the page's "Refine this view";
|
||||
* `activeFilters` is how many of its filters are set. The toolbar leaves `container` when `scope` is disposed.
|
||||
* value after a select change or, debounced, after typing; `onSaveView(trigger)` opens the page's Save view dialog;
|
||||
* `onFilters(trigger)` opens the page's "Refine this view"; `activeFilters` is how many of its filters are set. The toolbar leaves `container` when `scope` is disposed.
|
||||
* Returns `{element, value, setValue, setCategories, setActiveFilters}`.
|
||||
*/
|
||||
export function mountToolbar(container, { scope, categories = [], sorts, value = {}, activeFilters = 0, onChange, onFilters }) {
|
||||
export function mountToolbar(container, { scope, categories = [], sorts, value = {}, activeFilters = 0, onChange, onFilters, onSaveView }) {
|
||||
if (typeof scope?.listen !== 'function' || typeof scope.debounce !== 'function' || typeof scope.cleanup !== 'function') {
|
||||
throw new TypeError('The toolbar needs the page scope');
|
||||
}
|
||||
if (typeof onChange !== 'function' || typeof onFilters !== 'function') {
|
||||
throw new TypeError('The toolbar needs onChange and onFilters');
|
||||
if (typeof onChange !== 'function' || typeof onFilters !== 'function' || typeof onSaveView !== 'function') {
|
||||
throw new TypeError('The toolbar needs onChange, onFilters and onSaveView');
|
||||
}
|
||||
const root = document.createElement('search');
|
||||
root.className = 'toolbar';
|
||||
@@ -91,8 +91,9 @@ export function mountToolbar(container, { scope, categories = [], sorts, value =
|
||||
sort.setAttribute('aria-label', 'Sort results');
|
||||
sort.replaceChildren(...orderSorts(sorts).map(([id, text]) => option(id, text)));
|
||||
|
||||
const saveView = createButton('Save view');
|
||||
const filters = createButton(filtersLabel(activeFilters));
|
||||
root.append(label, category, sort, filters);
|
||||
root.append(label, category, sort, saveView, filters);
|
||||
|
||||
const read = () => ({ q: search.value, category: category.value, sort: sort.value });
|
||||
function setValue(next) {
|
||||
@@ -114,6 +115,7 @@ export function mountToolbar(container, { scope, categories = [], sorts, value =
|
||||
scope.listen(search, 'input', scope.debounce(report, SEARCH_DELAY));
|
||||
scope.listen(category, 'change', report);
|
||||
scope.listen(sort, 'change', report);
|
||||
scope.listen(saveView, 'click', () => onSaveView(saveView));
|
||||
scope.listen(filters, 'click', () => onFilters(filters));
|
||||
scope.cleanup(() => root.remove());
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* Rescan; no "Import portable recipes" (D15). Rescan shows its running state (aria-disabled "Rescanning…").
|
||||
* - "Copy prompt" (a1223–1241): the select-to-copy fallback when the clipboard is unavailable (D22).
|
||||
* - One-value input (a1138 `simpleInput`): a "Category"-style field (`input#action-input`) and Cancel / a primary
|
||||
* action; used by the Images selection bar's "Set category" (#249 D8).
|
||||
* action; used by the Images selection bar's "Set category" (#249 D8) and the toolbar's Save view.
|
||||
* - Content (a1282–1304 `dialog(title, body)`): a page-built body such as the history's "All images for this
|
||||
* positive" and "Repeat run" media (#249 D26); `onClose` runs once when that body leaves the shell.
|
||||
*
|
||||
@@ -268,15 +268,18 @@ export function openCopyFallback(text, { scope, trigger = null } = {}) {
|
||||
|
||||
/**
|
||||
* The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary
|
||||
* `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field.
|
||||
* A positive `maxLength` limits the field.
|
||||
* `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field, or
|
||||
* reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field.
|
||||
*/
|
||||
export function openInputDialog({ scope, title, label, action, value = '', maxLength, onApply, trigger = null }) {
|
||||
export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null }) {
|
||||
requireScope(scope);
|
||||
requireCallbacks({ onApply });
|
||||
for (const [name, text] of Object.entries({ title, label, action })) {
|
||||
if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`);
|
||||
}
|
||||
if (fallback !== undefined && (typeof fallback !== 'string' || !fallback.trim())) {
|
||||
throw new TypeError('The input dialog fallback must be text');
|
||||
}
|
||||
const input = el('input');
|
||||
input.value = typeof value === 'string' ? value : '';
|
||||
if (Number.isSafeInteger(maxLength) && maxLength > 0) input.maxLength = maxLength;
|
||||
@@ -284,7 +287,7 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe
|
||||
const apply = createButton(action, 'ca-primary');
|
||||
cancel.addEventListener('click', () => finish());
|
||||
apply.addEventListener('click', () => {
|
||||
const entered = input.value.trim();
|
||||
const entered = input.value.trim() || fallback;
|
||||
if (!entered) { input.focus(); return; }
|
||||
finish();
|
||||
onApply(entered);
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<a class="brand" href="#/images"><span class="brand-mark">P<span>±</span></span><span>Prompt Manager<small>COMFYASSETS</small></span></a>
|
||||
<div class="sidebar-label">WORKSPACE</div>
|
||||
<nav id="primary-nav" aria-label="Workspace"></nav>
|
||||
<!-- D49: the concept's SAVED VIEWS group without its "PROPOSED" badge (saved views are persisted, #249 D16). -->
|
||||
<div class="sidebar-label">SAVED VIEWS</div>
|
||||
<nav id="saved-nav" aria-label="Saved views"></nav>
|
||||
<div class="sidebar-bottom">
|
||||
<nav id="system-nav" aria-label="Tools and system"></nav>
|
||||
<div class="connection"><i></i><div>ComfyUI workspace<small>Your library, on your server</small></div></div>
|
||||
|
||||
Reference in New Issue
Block a user