feat(views): Edit rename/delete and the Favorites saved view
- Favorites is the first SAVED VIEWS row (star icon, name, real count). The count is the Favorites listing's own limit=1 total, follows the nav-count rules and is re-read after every confirmed star or unstar. - #/favorites opens Images filtered to favorites. The workspace listing takes favorites=1: each selected root's favorites are read through the image_favorites primary key and matched against the cached scan with the toggle's case folding; a failed read is an error, never an unfiltered page. The cursor scope includes the flag. - Saved Images state gains a required favorites boolean, so Save view on Favorites reopens the favorites. - An open saved view's heading leads with Edit: "Edit saved view" with View name, Delete... (danger), Cancel and Save. Delete... confirms in the same dialog (never window.confirm). Toasts "Saved view renamed." and "Saved view deleted."; a delete returns to the view's plain page. - shell-concept no longer maps Favorites out (Needs review stays, D50). Recorded as D51 in TESTING.md.
This commit is contained in:
+23
-2
@@ -445,8 +445,29 @@ Recorded shell deviations:
|
||||
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.
|
||||
ruling, 2026-10-21). The comparison maps only that row out.
|
||||
- **D51** (maintainer ruling, 2026-10-21, beyond the concept): SAVED VIEWS starts with
|
||||
the concept's Favorites row (star icon, name, count). Its count is the Favorites
|
||||
listing's own `limit=1` total (`/images/output?mode=workspace&favorites=1`), follows
|
||||
the nav-count rules (hidden until loaded, absent when its read fails, never a
|
||||
placeholder) and is re-read after every confirmed star or unstar, on any page.
|
||||
`#/favorites` is Images filtered to favorites, named "Favorites" (concept
|
||||
`renderImages`); `favorites=1` keeps only the selected roots' favorites, read per root
|
||||
from `image_favorites` and matched against the scan with the toggle's case folding,
|
||||
and a failed read is an error, never an unfiltered page. Unlike the concept, an
|
||||
unstarred card stays on Favorites (showing ☆) until the next load, so the star can be
|
||||
undone in place. Favorites neither restores nor replaces the Images return state.
|
||||
Save view there saves `favorites: true`, so the view reopens the favorites. An open
|
||||
saved view's heading leads with **Edit**: "Edit saved view" with "View name" and
|
||||
Delete… (danger), Cancel and Save. Save re-reads the list, retitles the page and
|
||||
toasts "Saved view renamed."; an empty name keeps the dialog open. Delete… swaps the
|
||||
same dialog to "Delete saved view" (Cancel / Delete; never `window.confirm`); Delete
|
||||
removes the view, replaces the dead route with the view's plain page and toasts
|
||||
"Saved view deleted.". Failures toast "The view could not be renamed. Try again." or
|
||||
"The view could not be deleted. Try again."; a view another tab already deleted counts
|
||||
as deleted. `tests/browser/workspace-saved-views.spec.mjs` covers the row, count,
|
||||
route, star updates, Save view on Favorites, Edit, and the failure paths;
|
||||
`tests/test_image_favorites.py` covers `favorites=1`.
|
||||
- **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. Save view's dialog action reads
|
||||
|
||||
@@ -137,3 +137,16 @@ class ImageFavoriteOperations:
|
||||
)
|
||||
found.update(path for (path,) in rows)
|
||||
return found
|
||||
|
||||
def get_root_favorites(self, root_key: str) -> set[str]:
|
||||
"""Every relative key favorited under *root_key* (a primary-key range).
|
||||
|
||||
The Favorites listing (``favorites=1``) intersects these with the
|
||||
root's scan, so its size follows the root's favorites, not its files.
|
||||
"""
|
||||
conn = self.model.get_connection()
|
||||
rows = conn.execute(
|
||||
"SELECT relative_path FROM image_favorites WHERE root_key=?",
|
||||
(root_key,),
|
||||
)
|
||||
return {path for (path,) in rows}
|
||||
|
||||
@@ -35,6 +35,8 @@ _STATE_FIELDS = {
|
||||
"folder": ("text", 1024),
|
||||
"media": ("choice", _MEDIA),
|
||||
"tags": ("tags", None),
|
||||
# The Favorites page's filter (#249 D51): a view saved there reopens it.
|
||||
"favorites": ("bool", None),
|
||||
},
|
||||
"library": {
|
||||
"q": ("text", 500),
|
||||
@@ -92,6 +94,10 @@ def _validate_value(key: str, kind: str, limit, value: object) -> None:
|
||||
):
|
||||
raise ValueError(f"State '{key}' holds an invalid tag")
|
||||
return
|
||||
if kind == "bool":
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"State '{key}' must be true or false")
|
||||
return
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"State '{key}' must be a string")
|
||||
if kind == "choice" and value not in limit:
|
||||
|
||||
+40
-4
@@ -696,7 +696,9 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
``q`` (at most 500 characters) matches the relative path, case-folded, and
|
||||
the linked positive's text, title and tag names. ``category`` is exact,
|
||||
``tags`` is a comma list that must all be present, ``sort=rating`` ranks by
|
||||
the linked positive. Raises ValueError for any unknown or oversized value.
|
||||
the linked positive, and ``favorites=1`` keeps only favorites (the
|
||||
Favorites page, #249 D51). Raises ValueError for any unknown or oversized
|
||||
value.
|
||||
"""
|
||||
text = query.get("q", "").strip()
|
||||
folded = text.casefold()
|
||||
@@ -707,8 +709,10 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
tags = sorted(
|
||||
{tag.strip() for tag in query.get("tags", "").split(",") if tag.strip()}
|
||||
)
|
||||
favorites = query.get("favorites", "0")
|
||||
if (
|
||||
len(text) > 500
|
||||
favorites not in ("0", "1")
|
||||
or len(text) > 500
|
||||
or len(folded) > 500
|
||||
or media not in ("all", "image", "video")
|
||||
or order not in WORKSPACE_ORDERS
|
||||
@@ -727,6 +731,7 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
"folder": folder,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"favorites": favorites == "1",
|
||||
}
|
||||
|
||||
|
||||
@@ -736,7 +741,12 @@ _COUNT_OMITTED = object()
|
||||
|
||||
|
||||
def _filtered_page(
|
||||
entries: list, start: int, size: int, filters: dict, links: dict | None
|
||||
entries: list,
|
||||
start: int,
|
||||
size: int,
|
||||
filters: dict,
|
||||
links: dict | None,
|
||||
favorites: frozenset | None = None,
|
||||
) -> tuple[int, list, int | None]:
|
||||
"""Count the *entries* the filters admit and take *size* from *start* on.
|
||||
|
||||
@@ -745,6 +755,7 @@ def _filtered_page(
|
||||
category or tag filter admits only those, and ``q`` admits a filename match
|
||||
or a linked-positive hit. Returns the total, the page and the number of
|
||||
distinct linked positives among all admitted entries (None without links).
|
||||
*favorites*, when given, admits only those listed paths (``favorites=1``).
|
||||
"""
|
||||
query, media, folder = filters["query"], filters["media"], filters["folder"]
|
||||
members_only = filters["category"] is not None or bool(filters["tags"])
|
||||
@@ -758,6 +769,8 @@ def _filtered_page(
|
||||
continue
|
||||
if media != "all" and (media == "video") != entry.video:
|
||||
continue
|
||||
if favorites is not None and entry.path not in favorites:
|
||||
continue
|
||||
match = None if links is None else links.get(entry.normalized)
|
||||
if members_only and match is None:
|
||||
continue
|
||||
@@ -1146,6 +1159,7 @@ class ImageRoutesMixin:
|
||||
[str(root) for root in output_dirs],
|
||||
filters["category"],
|
||||
filters["tags"],
|
||||
filters["favorites"],
|
||||
]
|
||||
).encode()
|
||||
).hexdigest()
|
||||
@@ -1333,8 +1347,11 @@ class ImageRoutesMixin:
|
||||
start = len(entries) - bisect_left(ascending, after, key=key)
|
||||
else:
|
||||
start = bisect_right(entries, after, key=key)
|
||||
favorites = (
|
||||
self._workspace_favorite_paths(listings) if filters["favorites"] else None
|
||||
)
|
||||
total, page, positive_count = _filtered_page(
|
||||
entries, start, limit + 1, filters, links
|
||||
entries, start, limit + 1, filters, links, favorites
|
||||
)
|
||||
if not needed and after is not None:
|
||||
positive_count = _COUNT_OMITTED
|
||||
@@ -2347,6 +2364,25 @@ class ImageRoutesMixin:
|
||||
self._favorite_listing_cache[cache_key] = (files, listing)
|
||||
return listing
|
||||
|
||||
def _workspace_favorite_paths(self, listings: list[tuple]) -> frozenset:
|
||||
"""The listed paths of every favorite in the selected roots (blocking).
|
||||
|
||||
One primary-key read per root (get_root_favorites), matched against the
|
||||
root's cached ``{relative key: listing spelling}``, so the work follows
|
||||
the favorites rather than the files, and a key folds case exactly as
|
||||
the toggle stored it (Windows). A favorite whose file is gone matches
|
||||
nothing. A failed read raises: an unfiltered page must never pass for
|
||||
the favorites.
|
||||
"""
|
||||
paths = set()
|
||||
for _index, root, files in listings:
|
||||
listing = self._favorite_listing(root, files)
|
||||
for key in self.db.get_root_favorites(_favorite_root_key(root)):
|
||||
listed = listing.get(key)
|
||||
if listed is not None:
|
||||
paths.add(root / listed)
|
||||
return frozenset(paths)
|
||||
|
||||
def _workspace_favorites(self, page: list[tuple]) -> set[tuple[int, str]]:
|
||||
"""``(root index, listing path)`` of the favorites among *page* entries."""
|
||||
by_root: dict[tuple[int, Path], dict[str, str]] = {}
|
||||
|
||||
@@ -97,6 +97,8 @@ export async function openProduction(page,route,theme,motion='no-preference') {
|
||||
await selectTheme(page,theme);
|
||||
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
|
||||
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
|
||||
// SAVED VIEWS' Favorites count (D51), the concept's starred images in the concept fixture.
|
||||
await expect(page.locator('#saved-nav a[data-route="favorites"] .nav-count')).toHaveCount(1);
|
||||
}
|
||||
|
||||
/** Computed outline of the focused element, after keyboard focus so `:focus-visible` applies. */
|
||||
|
||||
@@ -57,11 +57,11 @@ function remainingFooter(footer,spans) {
|
||||
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.
|
||||
* The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: Favorites with its count (D51), then the
|
||||
* saved views. D50: no "Needs review" row until the tag-review slice (maintainer ruling 2026-10-21).
|
||||
*/
|
||||
function savedNav(nav) {
|
||||
const rows=nav.rows.filter(row=>row.text!=='Favorites'&&row.text!=='Needs review');
|
||||
const rows=nav.rows.filter(row=>row.text!=='Needs review');
|
||||
return {...nav,rows:rows.map((row,index)=>index===rows.length-1?{...row,gapAfter:null}:row)};
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ for(const [width,height] of SIZES)test(`a saved view row matches the concept's $
|
||||
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]]);
|
||||
.toEqual([['Favorites',reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows[0].count],['Golden hour',null]]);
|
||||
});
|
||||
|
||||
test('the inventory reports a missing shell element by its path instead of failing',async({page})=>{
|
||||
|
||||
@@ -13,10 +13,17 @@ const filtersButton = page => toolbar(page).getByRole('button', { name: /^Filter
|
||||
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 });
|
||||
// The saved views' own rows, after the Favorites row (D51).
|
||||
const viewRows = page => savedNav(page).locator('a[data-route^="saved/"]');
|
||||
const favoritesRow = page => savedNav(page).locator('a[data-route="favorites"]');
|
||||
const favoritesCount = page => favoritesRow(page).locator('.nav-count');
|
||||
const editDialog = page => page.getByRole('dialog', { name: 'Edit saved view', exact: true });
|
||||
const deleteDialog = page => page.getByRole('dialog', { name: 'Delete saved view', exact: true });
|
||||
const heading = page => main(page).getByRole('heading', { level: 1 });
|
||||
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: [] };
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
|
||||
|
||||
async function refine(page, values) {
|
||||
await filtersButton(page).click();
|
||||
@@ -46,6 +53,8 @@ test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
page.savedErrors = [];
|
||||
page.on('pageerror', error => page.savedErrors.push(error.message));
|
||||
// D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open.
|
||||
page.on('dialog', dialog => { page.savedErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
|
||||
// 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') {
|
||||
@@ -65,7 +74,7 @@ test('Save view saves the Images toolbar and Refine filters, and its sidebar row
|
||||
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 expect(viewRows(page)).toHaveCount(0);
|
||||
|
||||
await search(page).fill('dogs');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
@@ -134,7 +143,7 @@ test('an empty name is Saved view, and a Library view reopens the shelf filters'
|
||||
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']);
|
||||
await expect(viewRows(page)).toHaveText(['Saved view']);
|
||||
expect((await listed(request))[0]).toMatchObject({ page: 'images', name: 'Saved view', state: IMAGE_DEFAULTS });
|
||||
|
||||
await page.goto(`${workspace}#/library`);
|
||||
@@ -146,7 +155,7 @@ test('an empty name is Saved view, and a Library view reopens the shelf filters'
|
||||
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']);
|
||||
await expect(viewRows(page)).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'] } });
|
||||
|
||||
@@ -199,13 +208,13 @@ test('an unknown saved view opens Images, and a failed listing offers Retry', as
|
||||
: 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);
|
||||
await expect(viewRows(page)).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']);
|
||||
await expect(viewRows(page)).toHaveText(['Cats']);
|
||||
});
|
||||
|
||||
test('a failed save says so and adds nothing', async ({ page, request }) => {
|
||||
@@ -217,6 +226,200 @@ test('a failed save says so and adds nothing', async ({ page, request }) => {
|
||||
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);
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
expect(await listed(request)).toEqual([]);
|
||||
});
|
||||
|
||||
async function star(page, key) {
|
||||
await page.locator(`article[data-image-key="${key}"] .star-button`).click();
|
||||
}
|
||||
|
||||
test('Favorites is the first SAVED VIEWS row, with a real count that follows every star', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
// Hidden until its read settles: never a placeholder "0".
|
||||
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
|
||||
&& url.searchParams.get('limit') === '1', async route => { await held; await route.fallback(); }, { times: 1 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await expect(favoritesRow(page)).toHaveAttribute('href', '#/favorites');
|
||||
await expect(favoritesCount(page)).toHaveCount(0);
|
||||
release();
|
||||
// The concept's row (app.js a332): the star icon, "Favorites", then the count.
|
||||
await expect(favoritesCount(page)).toHaveText('0');
|
||||
expect(await favoritesRow(page).evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE
|
||||
? `text:${node.textContent}` : `${node.localName}.${node.getAttribute('class')}`)))
|
||||
.toEqual(['svg.icon', 'text:Favorites', 'span.nav-count']);
|
||||
expect(await savedNav(page).locator('a').evaluateAll(links => links.map(link => link.dataset.route))).toEqual(['favorites']);
|
||||
|
||||
await star(page, '1:shared/same.png');
|
||||
await expect(toast(page)).toHaveText('Added to favorites.');
|
||||
await expect(favoritesCount(page)).toHaveText('1');
|
||||
|
||||
// #/favorites: Images filtered to favorites, named Favorites (concept renderImages a417).
|
||||
const listings = [];
|
||||
page.on('request', item => {
|
||||
const url = new URL(item.url());
|
||||
if (url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('limit') !== '1') listings.push(url.searchParams);
|
||||
});
|
||||
await favoritesRow(page).click();
|
||||
await expect(page).toHaveURL(/#\/favorites$/);
|
||||
await expect(heading(page)).toHaveText('Favorites');
|
||||
await expect(heading(page)).toBeFocused();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Favorites');
|
||||
await expect(page).toHaveTitle('Favorites · Prompt Manager');
|
||||
await expect(favoritesRow(page)).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
await expect(main(page).locator('#result-count')).toHaveText(/^1 of 1 image/);
|
||||
expect(listings.at(-1).get('favorites')).toBe('1');
|
||||
|
||||
// Unstarring on Favorites keeps the card (with ☆) so it can be undone in place; the count follows.
|
||||
await star(page, '1:shared/same.png');
|
||||
await expect(toast(page)).toHaveText('Removed from favorites.');
|
||||
await expect(imageCards(page).locator('.star-button')).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(favoritesCount(page)).toHaveText('0');
|
||||
await star(page, '1:shared/same.png');
|
||||
await expect(favoritesCount(page)).toHaveText('1');
|
||||
|
||||
// Favorites never replaces the Images return state: Images still lists everything.
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Images/ }).click();
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await page.goto(`${workspace}#/favorites`);
|
||||
await expect(heading(page)).toHaveText('Favorites');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('a failed Favorites count shows no badge', async ({ page }) => {
|
||||
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
|
||||
&& url.searchParams.get('limit') === '1', route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }));
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(page.locator('#primary-nav a[data-route="images"] .nav-count')).toHaveText('2');
|
||||
await expect(favoritesRow(page)).toBeVisible();
|
||||
await expect(favoritesCount(page)).toHaveCount(0);
|
||||
// The refused count read is this test's own; nothing else failed.
|
||||
page.savedErrors = page.savedErrors.filter(error => !/^503 .*favorites=1/.test(error));
|
||||
});
|
||||
|
||||
test('Save view on Favorites reopens the favorites', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await star(page, '0:shared/same.png');
|
||||
await expect(favoritesCount(page)).toHaveText('1');
|
||||
await page.goto(`${workspace}#/favorites`);
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
await saveDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Best');
|
||||
await saveDialog(page).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: 'Best', state: { ...IMAGE_DEFAULTS, favorites: true } });
|
||||
await expect(viewRows(page)).toHaveText(['Best']);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await viewRows(page).click();
|
||||
await expect(heading(page)).toHaveText('Best');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
|
||||
});
|
||||
|
||||
test('Edit renames an open view, and deletes it only after confirming inside the dialog', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const view = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
// Plain pages have no Edit.
|
||||
await expect(main(page).locator('.heading-actions').getByRole('button', { name: 'Edit', exact: true })).toHaveCount(0);
|
||||
await viewRows(page).click();
|
||||
await expect(heading(page)).toHaveText('Dogs');
|
||||
// D51: Edit leads the heading's actions of an open view.
|
||||
const edit = main(page).locator('.heading-actions > button').first();
|
||||
await expect(edit).toHaveText('Edit');
|
||||
await edit.click();
|
||||
const dialog = editDialog(page);
|
||||
const name = dialog.getByRole('textbox', { name: 'View name', exact: true });
|
||||
await expect(name).toHaveValue('Dogs');
|
||||
await expect(name).toBeFocused();
|
||||
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']);
|
||||
await expect(dialog.getByRole('button', { name: 'Delete…', exact: true })).toHaveClass(/\bdanger\b/);
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(edit).toBeFocused();
|
||||
|
||||
// An empty name keeps the dialog open on its field.
|
||||
await edit.click();
|
||||
await name.fill(' ');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(name).toBeFocused();
|
||||
await name.fill('Good dogs');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view renamed.');
|
||||
await expect(viewRows(page)).toHaveText(['Good dogs']);
|
||||
await expect(heading(page)).toHaveText('Good dogs');
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Good dogs');
|
||||
await expect(page).toHaveTitle('Good dogs · Prompt Manager');
|
||||
await expect(edit).toBeFocused();
|
||||
expect(await listed(request)).toEqual([{ ...view, name: 'Good dogs' }]);
|
||||
// The rename kept the view's page state.
|
||||
await expect(search(page)).toHaveValue('dogs');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
|
||||
// Delete… asks first; its Cancel deletes nothing.
|
||||
await edit.click();
|
||||
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
const confirm = deleteDialog(page);
|
||||
await expect(confirm).toContainText('Delete “Good dogs”? Only the saved view is removed; its images and prompts stay.');
|
||||
await expect(confirm.locator('.dialog-actions button')).toHaveText(['Cancel', 'Delete']);
|
||||
await expect(confirm.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused();
|
||||
await confirm.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(confirm).toHaveCount(0);
|
||||
expect(await listed(request)).toHaveLength(1);
|
||||
|
||||
await edit.click();
|
||||
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await confirm.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view deleted.');
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
expect(await listed(request)).toEqual([]);
|
||||
// The dead link left no history entry.
|
||||
await page.goBack();
|
||||
await expect(page).not.toHaveURL(new RegExp(`#/saved/${view.id}$`));
|
||||
});
|
||||
|
||||
test('a deleted Library view returns to the Library, and failed edits say so', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
const edit = main(page).locator('.heading-actions > button').first();
|
||||
await expect(edit).toHaveText('Edit');
|
||||
let failing = true;
|
||||
await page.route(`**${API}/${view.id}`, route => failing
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' })
|
||||
: route.fallback());
|
||||
await edit.click();
|
||||
await editDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Kittens');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The view could not be renamed. Try again.');
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
await expect(viewRows(page)).toHaveText(['Cats']);
|
||||
await edit.click();
|
||||
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The view could not be deleted. Try again.');
|
||||
await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`));
|
||||
expect(await listed(request)).toHaveLength(1);
|
||||
|
||||
failing = false;
|
||||
await edit.click();
|
||||
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view deleted.');
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await expect(heading(page)).toHaveText('Prompt library');
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ const routes = [
|
||||
['tags', 'Tags & categories', null],
|
||||
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
|
||||
['system', 'System', '/prompt_manager/admin'],
|
||||
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
|
||||
['favorites', 'Favorites', null],
|
||||
];
|
||||
|
||||
async function countThemeWrites(page) {
|
||||
|
||||
@@ -128,6 +128,17 @@ test('all-source defaults are omitted and descending name sort remains supported
|
||||
await stream.reset({root:'all',media:'all',sort:'name-desc'});
|
||||
assert.equal(query.has('root'),false);assert.equal(query.has('media'),false);
|
||||
assert.equal(query.get('sort'),'name-desc');assert.equal(query.get('limit'),'40');
|
||||
assert.equal(query.has('favorites'),false);
|
||||
});
|
||||
|
||||
test('the Favorites page asks the listing for favorites only (#249 D51)', async () => {
|
||||
const {createImageStream}=await source;let query;
|
||||
const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}});
|
||||
await stream.reset({favorites:true,q:'fog'});
|
||||
assert.equal(query.get('favorites'),'1');assert.equal(query.get('q'),'fog');
|
||||
await stream.reset({favorites:false});
|
||||
assert.equal(query.has('favorites'),false);
|
||||
for(const favorites of ['1',1,'true'])await assert.rejects(stream.reset({favorites}),/Invalid favorites filter/);
|
||||
});
|
||||
|
||||
test('restored browsing state resumes its cursor and filters without an initial request', async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
|
||||
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 () => {
|
||||
@@ -12,8 +12,10 @@ test('a saved state always holds every page key and keeps only values its page a
|
||||
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'] };
|
||||
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true };
|
||||
assert.deepEqual(normalizeViewState('images', full), full);
|
||||
// D51: favorites is a boolean; anything else is the whole listing.
|
||||
for (const favorites of ['true', 1, null]) assert.equal(normalizeViewState('images', { ...full, favorites }).favorites, false);
|
||||
// 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: [] });
|
||||
@@ -61,11 +63,12 @@ test('the listing keeps usable views in order and normalizes their state', async
|
||||
|
||||
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 filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true };
|
||||
const state = imagesViewState(filters);
|
||||
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'] });
|
||||
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true });
|
||||
assert.deepEqual(imagesFilters(state), filters);
|
||||
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [] });
|
||||
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false });
|
||||
assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false);
|
||||
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);
|
||||
@@ -84,6 +87,14 @@ function fakeApi() {
|
||||
calls.push(['POST', url, body]);
|
||||
return Promise.resolve({ success: true, view: { id: 9, ...body } });
|
||||
},
|
||||
patch(url, body) {
|
||||
calls.push(['PATCH', url, body]);
|
||||
return api.patchResult ?? Promise.resolve({ success: true, view: { id: 9, page: 'images', name: body.name, state: {} } });
|
||||
},
|
||||
del(url) {
|
||||
calls.push(['DELETE', url]);
|
||||
return api.deleteResult ?? Promise.resolve({ success: true, id: 9 });
|
||||
},
|
||||
};
|
||||
return { api, pending };
|
||||
}
|
||||
@@ -136,3 +147,43 @@ test('creating a view posts the full state and re-reads the list', async () => {
|
||||
const broken = createSavedViews({ ...api, post: () => Promise.resolve({ success: true, view: null }) });
|
||||
await assert.rejects(broken.create('images', 'X', {}), TypeError);
|
||||
});
|
||||
|
||||
test('renaming and deleting a view re-read the list, whatever the outcome', async () => {
|
||||
const { createSavedViews, SAVED_VIEWS_URL } = await source;
|
||||
const { api, pending } = fakeApi();
|
||||
const store = createSavedViews(api);
|
||||
const renamed = store.rename(9, 'Dusk');
|
||||
await flush();
|
||||
assert.deepEqual(api.calls[0], ['PATCH', `${SAVED_VIEWS_URL}/9`, { name: 'Dusk' }]);
|
||||
assert.deepEqual(api.calls[1], ['GET', SAVED_VIEWS_URL, 'no-store']);
|
||||
pending[0].resolve({ success: true, views: [{ id: 9, page: 'images', name: 'Dusk', state: {} }] });
|
||||
assert.deepEqual(await renamed, { id: 9, page: 'images', name: 'Dusk', state: IMAGE_DEFAULTS });
|
||||
assert.equal(store.find(9).name, 'Dusk');
|
||||
|
||||
const removed = store.remove(9);
|
||||
await flush();
|
||||
assert.deepEqual(api.calls[2], ['DELETE', `${SAVED_VIEWS_URL}/9`]);
|
||||
pending[1].resolve({ success: true, views: [] });
|
||||
assert.equal(await removed, 9);
|
||||
assert.equal(store.find(9), null);
|
||||
|
||||
// A view another tab already deleted is the outcome asked for; any other failure rejects, after a re-read.
|
||||
api.deleteResult = Promise.reject(Object.assign(new Error('Not found'), { status: 404 }));
|
||||
const gone = store.remove(4);
|
||||
await flush();
|
||||
pending[2].resolve({ success: true, views: [] });
|
||||
assert.equal(await gone, 4);
|
||||
api.deleteResult = Promise.reject(Object.assign(new Error('Locked'), { status: 500 }));
|
||||
const failed = store.remove(4);
|
||||
await flush();
|
||||
pending[3].resolve({ success: true, views: [] });
|
||||
await assert.rejects(failed, /Locked/);
|
||||
api.patchResult = Promise.resolve({ success: true, view: null });
|
||||
const invalid = store.rename(4, 'X');
|
||||
await flush();
|
||||
pending[4].resolve({ success: true, views: [] });
|
||||
await assert.rejects(invalid, TypeError);
|
||||
assert.equal(pending.length, 5);
|
||||
assert.throws(() => store.rename(0, 'X'), TypeError);
|
||||
assert.throws(() => store.remove('9'), TypeError);
|
||||
});
|
||||
|
||||
@@ -79,18 +79,19 @@ test('positive library deep links preserve the selected identity without duplica
|
||||
|
||||
test('nav counts reject negative, non-integer and missing totals', async () => {
|
||||
const { summarizeNavCounts } = await source;
|
||||
const counts = summarizeNavCounts({images: {total: 24}, library: {total: 0}, tags: {total: 7}});
|
||||
const counts = summarizeNavCounts({images: {total: 24}, favorites: {total: 3}, library: {total: 0}, tags: {total: 7}});
|
||||
assert.ok(counts instanceof Map);
|
||||
assert.deepEqual([...counts], [['images', 24], ['library', 0], ['tags', 7]]);
|
||||
assert.deepEqual([...counts], [['images', 24], ['favorites', 3], ['library', 0], ['tags', 7]]);
|
||||
assert.equal(counts.has('inspect'), false, 'Inspect a file has no count');
|
||||
for (const invalid of [undefined, null, {}, {total: -1}, {total: 1.5}, {total: '3'}, {total: NaN},
|
||||
{total: Infinity}, {total: Number.MAX_SAFE_INTEGER + 1}, {total: null}, [], 'x']) {
|
||||
const result = summarizeNavCounts({images: invalid, library: {total: 2}, tags: invalid});
|
||||
const result = summarizeNavCounts({images: invalid, favorites: invalid, library: {total: 2}, tags: invalid});
|
||||
assert.equal(result.get('images'), null, JSON.stringify(invalid));
|
||||
assert.equal(result.get('favorites'), null);
|
||||
assert.equal(result.get('library'), 2);
|
||||
assert.equal(result.get('tags'), null);
|
||||
}
|
||||
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['library', null], ['tags', null]]);
|
||||
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['favorites', null], ['library', null], ['tags', null]]);
|
||||
});
|
||||
|
||||
test('nav counts settle independently so a slow images read never delays the others', async () => {
|
||||
@@ -98,6 +99,7 @@ test('nav counts settle independently so a slow images read never delays the oth
|
||||
let releaseImages;
|
||||
const images = new Promise(resolve => { releaseImages = resolve; });
|
||||
const api = {get: async url => {
|
||||
if (url.includes('favorites=1')) return {success: true, total: 2};
|
||||
if (url.startsWith('/prompt_manager/images')) return images;
|
||||
if (url.startsWith('/prompt_manager/library')) return {success: true, total: 3};
|
||||
throw new Error('Unavailable');
|
||||
@@ -105,14 +107,14 @@ test('nav counts settle independently so a slow images read never delays the oth
|
||||
const settled = [];
|
||||
const done = loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.deepEqual(settled.sort(), [['library', 3], ['tags', null]]);
|
||||
assert.deepEqual(settled.sort(), [['favorites', 2], ['library', 3], ['tags', null]]);
|
||||
releaseImages({success: true, total: 40});
|
||||
const counts = await done;
|
||||
assert.deepEqual(settled.at(-1), ['images', 40]);
|
||||
assert.deepEqual([...counts], [['images', 40], ['library', 3], ['tags', null]]);
|
||||
assert.deepEqual([...counts], [['images', 40], ['favorites', 2], ['library', 3], ['tags', null]]);
|
||||
});
|
||||
|
||||
test('nav counts issue three bounded requests and drop only the failed count', async () => {
|
||||
test('nav counts issue four bounded requests and drop only the failed count', async () => {
|
||||
const { loadNavCounts } = await source;
|
||||
const calls = [];
|
||||
const signal = new AbortController().signal;
|
||||
@@ -123,15 +125,42 @@ test('nav counts issue three bounded requests and drop only the failed count', a
|
||||
return {success: true, total: 12};
|
||||
}};
|
||||
const counts = await loadNavCounts(api, signal);
|
||||
assert.deepEqual([...counts], [['images', 12], ['library', null], ['tags', 5]]);
|
||||
assert.deepEqual([...counts], [['images', 12], ['favorites', 12], ['library', null], ['tags', 5]]);
|
||||
const urls = calls.map(([url]) => new URL(url, 'http://host'));
|
||||
assert.deepEqual(urls.map(url => url.pathname),
|
||||
['/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
|
||||
['/prompt_manager/images/output', '/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
|
||||
for (const url of urls) assert.equal(url.searchParams.get('limit'), '1');
|
||||
assert.equal(urls[0].searchParams.get('mode'), 'workspace');
|
||||
assert.equal(urls[0].searchParams.has('favorites'), false);
|
||||
// D51: the Favorites count is the Favorites page's own listing.
|
||||
assert.equal(urls[1].searchParams.get('mode'), 'workspace');
|
||||
assert.equal(urls[1].searchParams.get('favorites'), '1');
|
||||
assert.ok(calls.every(([, used]) => used === signal));
|
||||
});
|
||||
|
||||
test('a favorite change re-reads only the Favorites count', async () => {
|
||||
const { loadNavCounts, FAVORITE_WRITE_COUNTS } = await source;
|
||||
assert.deepEqual([...FAVORITE_WRITE_COUNTS], ['favorites']);
|
||||
assert.ok(Object.isFrozen(FAVORITE_WRITE_COUNTS));
|
||||
const calls = [];
|
||||
const api = {get: async url => { calls.push(url); return {success: true, total: 0}; }};
|
||||
const counts = await loadNavCounts(api, new AbortController().signal, () => {}, FAVORITE_WRITE_COUNTS);
|
||||
assert.deepEqual(calls, ['/prompt_manager/images/output?mode=workspace&favorites=1&limit=1']);
|
||||
// A real zero is a count, unlike an outstanding read.
|
||||
assert.deepEqual([...counts], [['favorites', 0]]);
|
||||
});
|
||||
|
||||
test('the Favorites route is native and resolves by its hash', async () => {
|
||||
const { resolveRoute, isHandoff, routeTitle, routes } = await source;
|
||||
const route = resolveRoute('#/favorites');
|
||||
assert.equal(route.id, 'favorites');
|
||||
assert.equal(route.label, 'Favorites');
|
||||
assert.equal(routeTitle(route), 'Favorites');
|
||||
assert.equal(isHandoff(route), false);
|
||||
// No href: Overview lists no legacy card for it.
|
||||
assert.equal(routes.find(item => item.id === 'favorites').href, undefined);
|
||||
});
|
||||
|
||||
test('a Library or vocabulary write re-reads only the Library and Tags counts', async () => {
|
||||
const { loadNavCounts, LIBRARY_WRITE_COUNTS } = await source;
|
||||
assert.deepEqual([...LIBRARY_WRITE_COUNTS], ['library', 'tags']);
|
||||
|
||||
@@ -292,6 +292,134 @@ class TestImageFavoriteApi(ImageAPITestCase):
|
||||
self.assertEqual(data, {"success": False, "error": "Favorite update failed"})
|
||||
|
||||
|
||||
class TestWorkspaceFavoritesFilter(ImageAPITestCase):
|
||||
"""``favorites=1`` on the workspace listing: the Favorites page (#249 D51)."""
|
||||
|
||||
async def tearDownAsync(self):
|
||||
self.api.db.close_all()
|
||||
assert_closed(self, self._temp_db.name, self._output_tmp.name)
|
||||
await super().tearDownAsync()
|
||||
|
||||
async def page(self, status=200, **params):
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/output", params={"mode": "workspace", **params}
|
||||
)
|
||||
data = await response.json()
|
||||
self.assertEqual(response.status, status, data)
|
||||
return data
|
||||
|
||||
async def star(self, path, root_index=0, method="PUT"):
|
||||
response = await self.client.request(
|
||||
method, FAVORITE, json={"root_index": root_index, "path": path}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
|
||||
def make(self, *names):
|
||||
for offset, name in enumerate(names):
|
||||
target = make_png(self.output_dir / name)
|
||||
os.utime(target, (1_700_000_000 + offset, 1_700_000_000 + offset))
|
||||
|
||||
async def test_lists_only_favorites_and_counts_them(self):
|
||||
self.make("a.png", "sub/b.png", "c.png", "sub/d.png")
|
||||
await self.star("a.png")
|
||||
await self.star("sub/d.png")
|
||||
data = await self.page(favorites="1")
|
||||
self.assertEqual([i["path"] for i in data["images"]], ["sub/d.png", "a.png"])
|
||||
self.assertEqual(data["total"], 2)
|
||||
self.assertTrue(all(i["favorite"] is True for i in data["images"]))
|
||||
# The nav count's read: limit=1 still reports every favorite's total.
|
||||
self.assertEqual((await self.page(favorites="1", limit="1"))["total"], 2)
|
||||
# Other filters intersect with it.
|
||||
sub = await self.page(favorites="1", subfolder="sub")
|
||||
self.assertEqual([i["path"] for i in sub["images"]], ["sub/d.png"])
|
||||
self.assertEqual((await self.page(favorites="1", q="a.png"))["total"], 1)
|
||||
self.assertEqual((await self.page(favorites="1", media="video"))["total"], 0)
|
||||
# favorites=0 (and no parameter) is the whole listing.
|
||||
self.assertEqual((await self.page(favorites="0"))["total"], 4)
|
||||
self.assertEqual((await self.page())["total"], 4)
|
||||
await self.star("a.png", method="DELETE")
|
||||
self.assertEqual((await self.page(favorites="1"))["total"], 1)
|
||||
|
||||
async def test_cursor_pages_walk_only_favorites_in_every_order(self):
|
||||
self.make("a.png", "b.png", "c.png", "d.png", "e.png")
|
||||
for name in ("a.png", "c.png", "e.png"):
|
||||
await self.star(name)
|
||||
expected = {
|
||||
"newest": ["e.png", "c.png", "a.png"],
|
||||
"oldest": ["a.png", "c.png", "e.png"],
|
||||
"name": ["a.png", "c.png", "e.png"],
|
||||
"name-desc": ["e.png", "c.png", "a.png"],
|
||||
"rating": ["e.png", "c.png", "a.png"],
|
||||
}
|
||||
for sort, names in expected.items():
|
||||
seen, cursor = [], None
|
||||
while True:
|
||||
params = {"favorites": "1", "sort": sort, "limit": "1"}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
data = await self.page(**params)
|
||||
self.assertEqual(data["total"], 3, sort)
|
||||
seen += [i["path"] for i in data["images"]]
|
||||
cursor = data["next_cursor"]
|
||||
if not data["has_more"]:
|
||||
break
|
||||
self.assertEqual(seen, names, sort)
|
||||
# A cursor is bound to its filters: a whole-listing cursor is refused.
|
||||
whole = await self.page(limit="1")
|
||||
await self.page(400, favorites="1", cursor=whole["next_cursor"])
|
||||
|
||||
async def test_favorites_follow_their_own_root(self):
|
||||
second = self.comfy_dir / "second"
|
||||
make_png(second / "a.png")
|
||||
make_png(self.output_dir / "a.png")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
await self.star("a.png", root_index=1)
|
||||
data = await self.page(favorites="1")
|
||||
self.assertEqual(
|
||||
[(i["root_index"], i["path"]) for i in data["images"]], [(1, "a.png")]
|
||||
)
|
||||
self.assertEqual((await self.page(favorites="1", root="0"))["total"], 0)
|
||||
self.assertEqual((await self.page(favorites="1", root="1"))["total"], 1)
|
||||
|
||||
async def test_windows_case_folded_favorite_is_listed(self):
|
||||
make_png(self.output_dir / "Sub" / "A.png")
|
||||
with patch("py.api.images._path_flavor", ntpath):
|
||||
await self.star("Sub/A.png")
|
||||
(self.output_dir / "Sub" / "A.png").rename(
|
||||
self.output_dir / "Sub" / "a.png"
|
||||
)
|
||||
(self.output_dir / "Sub").rename(self.output_dir / "sub")
|
||||
self.api.invalidate_gallery_cache()
|
||||
data = await self.page(favorites="1")
|
||||
self.assertEqual([i["path"] for i in data["images"]], ["sub/a.png"])
|
||||
|
||||
async def test_a_favorite_whose_file_is_gone_is_not_listed(self):
|
||||
self.make("a.png", "b.png")
|
||||
await self.star("a.png")
|
||||
await self.star("b.png")
|
||||
(self.output_dir / "b.png").unlink()
|
||||
self.api.invalidate_gallery_cache()
|
||||
data = await self.page(favorites="1")
|
||||
self.assertEqual([i["path"] for i in data["images"]], ["a.png"])
|
||||
self.assertEqual(data["total"], 1)
|
||||
|
||||
async def test_rejects_other_values(self):
|
||||
for value in ("true", "2", "", "yes"):
|
||||
data = await self.page(400, favorites=value)
|
||||
self.assertFalse(data["success"])
|
||||
|
||||
async def test_store_failure_is_an_error_not_an_unfiltered_page(self):
|
||||
self.make("a.png")
|
||||
|
||||
def broken(*_args, **_kwargs):
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
self.api.db.get_root_favorites = broken
|
||||
data = await self.page(500, favorites="1")
|
||||
self.assertFalse(data["success"])
|
||||
self.assertNotIn("images", data)
|
||||
|
||||
|
||||
class TestImageFavoriteSchema(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
@@ -314,6 +442,10 @@ class TestImageFavoriteSchema(unittest.TestCase):
|
||||
db.get_image_favorites("output", ["a.png", "b.png"]), {"a.png"}
|
||||
)
|
||||
self.assertEqual(db.get_image_favorites("output", []), set())
|
||||
# One root's favorites, read through the primary key.
|
||||
self.assertEqual(db.get_root_favorites("output"), {"a.png"})
|
||||
self.assertEqual(db.get_root_favorites("other"), {"a.png"})
|
||||
self.assertEqual(db.get_root_favorites("missing"), set())
|
||||
many = [f"{n}.png" for n in range(2000)] + ["a.png"]
|
||||
self.assertEqual(db.get_image_favorites("output", many), {"a.png"})
|
||||
self.assertTrue(db.clear_image_favorite("output", "a.png"))
|
||||
|
||||
@@ -35,6 +35,7 @@ IMAGES_STATE = {
|
||||
"folder": "",
|
||||
"media": "all",
|
||||
"tags": ["fluffy"],
|
||||
"favorites": False,
|
||||
}
|
||||
LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []}
|
||||
|
||||
@@ -145,6 +146,14 @@ class TestSavedViewsApi(ImageAPITestCase):
|
||||
{**good, "state": {**IMAGES_STATE, "tags": ["a,b"]}},
|
||||
{**good, "state": {**IMAGES_STATE, "tags": ["x" * 51]}},
|
||||
{**good, "state": {**IMAGES_STATE, "tags": [f"t{n}" for n in range(21)]}},
|
||||
# Favorites (the Favorites page's filter) is a JSON boolean.
|
||||
{**good, "state": {**IMAGES_STATE, "favorites": "true"}},
|
||||
{**good, "state": {**IMAGES_STATE, "favorites": 1}},
|
||||
{**good, "state": {**IMAGES_STATE, "favorites": None}},
|
||||
{
|
||||
**good,
|
||||
"state": {k: v for k, v in IMAGES_STATE.items() if k != "favorites"},
|
||||
},
|
||||
# Library has no source, folder or media, and its own sorts.
|
||||
{"page": "library", "name": "L", "state": IMAGES_STATE},
|
||||
{
|
||||
@@ -185,10 +194,12 @@ class TestSavedViewsApi(ImageAPITestCase):
|
||||
for sort in ("newest", "oldest", "name", "name-desc", "rating"):
|
||||
for media in ("all", "image", "video"):
|
||||
await self.create(state={"sort": sort, "media": media, "source": "3"})
|
||||
favorite = await self.create(state={"favorites": True})
|
||||
self.assertIs(favorite["state"]["favorites"], True)
|
||||
for sort in ("newest", "oldest", "name", "rating"):
|
||||
await self.create(page="library", state={"sort": sort, "tags": ["a b"]})
|
||||
_, listed = await self.send("GET")
|
||||
self.assertEqual(len(listed["views"]), 19)
|
||||
self.assertEqual(len(listed["views"]), 20)
|
||||
|
||||
async def test_rename_and_delete_validate_id_and_body(self):
|
||||
view = await self.create()
|
||||
|
||||
@@ -36,6 +36,10 @@ function queryFor(filters, batchSize) {
|
||||
const sort = filters.sort ?? 'newest';
|
||||
if (!IMAGE_SORTS.includes(sort)) throw new TypeError('Invalid sort filter');
|
||||
query.set('sort', sort);
|
||||
// The Favorites page (#249 D51): the listing keeps only favorites.
|
||||
const favorites = filters.favorites ?? false;
|
||||
if (typeof favorites !== 'boolean') throw new TypeError('Invalid favorites filter');
|
||||
if (favorites) query.set('favorites', '1');
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -96,14 +96,23 @@ function deliverFavorite(key, favorite) {
|
||||
else if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
|
||||
for (const target of embeddedFavoriteTargets) target(key, favorite);
|
||||
}
|
||||
// Told after every confirmed favorite change, wherever it was made (the shell's Favorites count, #249 D51).
|
||||
const favoriteListeners = new Set();
|
||||
/** Run `listener()` after each confirmed favorite change; returns the unsubscribe. */
|
||||
export function onFavoriteChange(listener) {
|
||||
favoriteListeners.add(listener);
|
||||
return () => favoriteListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `favorites` is the Favorites page (#249 D51): only favorites are listed, and it neither restores nor replaces the
|
||||
* Images last visit.
|
||||
*/
|
||||
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null,
|
||||
onSaveView = () => {}} = {}) {
|
||||
favorites = false, onSaveView = () => {}} = {}) {
|
||||
const scope = createScope();
|
||||
const dispose = () => scope.dispose();
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
@@ -117,7 +126,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
scope.cleanup(() => inspection.dispose());
|
||||
scope.cleanup(() => detailInspector?.dispose());
|
||||
const saved = embedded || !savedState ? null : imagesFilters(savedState);
|
||||
const visit = embedded || saved ? null : lastVisit;
|
||||
const visit = embedded || saved || favorites ? 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;
|
||||
@@ -126,14 +135,16 @@ 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, ...saved };
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters, ...saved,
|
||||
favorites: favorites || saved?.favorites === true };
|
||||
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;
|
||||
// A favorites-only collection is not the Images collection, so it never becomes the Images return state.
|
||||
if (!embedded && !filters.favorites) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
|
||||
cancelAnimationFrame(restoreFrame);
|
||||
});
|
||||
main.classList.add('images-main');
|
||||
@@ -370,6 +381,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
favoritePending.delete(key);
|
||||
deliverFavorite(key, saved ? favorite : !favorite);
|
||||
showWorkspaceToast(saved ? FAVORITE_DONE[favorite] : FAVORITE_FAILED[favorite]);
|
||||
// On Favorites an unstarred card stays (with ☆) until the next load, so the star can be undone in place (D51).
|
||||
if (saved) for (const listener of favoriteListeners) listener();
|
||||
}
|
||||
scope.listen(grid, 'click', event => {
|
||||
const card = event.target.closest('article[data-image-key]');
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import '../api-client.js';
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts,
|
||||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES } from './state.mjs';
|
||||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS } from './state.mjs';
|
||||
import { mountWorkspaceTheme } from './theme.mjs';
|
||||
import { mountImages } from './images.mjs';
|
||||
import { mountImages, onFavoriteChange } from './images.mjs';
|
||||
import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
|
||||
import { mountVocabulary } from './vocabulary.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { bindCanvasLink, canvasHref } from './topbar.mjs';
|
||||
import { renderHeading } from './heading.mjs';
|
||||
import { renderHeading, pageHeading } from './heading.mjs';
|
||||
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
|
||||
import { createSavedViews, openSaveViewDialog, savedViewHash } from './saved-views.mjs';
|
||||
import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs';
|
||||
|
||||
const { createApiClient, resolveUrl } = window.ApiClient;
|
||||
|
||||
@@ -73,10 +73,13 @@ function mountWorkspace() {
|
||||
}
|
||||
};
|
||||
// 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.
|
||||
// re-read after every change. /* D49 */ no "PROPOSED" badge; /* D50 */ no "Needs review" row. The first row is
|
||||
// the concept's Favorites (star icon, count), kept across every re-read so its count stays (D51).
|
||||
const [favoritesLink] = navLinks([['favorites', 'star']]);
|
||||
savedNav.replaceChildren(favoritesLink);
|
||||
const savedViews = createSavedViews(api, scope.signal);
|
||||
scope.cleanup(savedViews.subscribe(({ views }) => {
|
||||
savedNav.replaceChildren(...views.map(view => {
|
||||
savedNav.replaceChildren(favoritesLink, ...views.map(view => {
|
||||
const link = element('a');
|
||||
link.append(createWorkspaceIcon('search'), document.createTextNode(view.name));
|
||||
link.href = savedViewHash(view.id);
|
||||
@@ -99,7 +102,7 @@ function mountWorkspace() {
|
||||
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
|
||||
loadNavCounts(api, request.signal, (_, count) => {
|
||||
if (request.signal.aborted) return;
|
||||
const link = primaryNav.querySelector(`[data-route="${id}"]`);
|
||||
const link = sidebar.querySelector(`nav [data-route="${id}"]`);
|
||||
link?.querySelector('.nav-count')?.remove();
|
||||
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
|
||||
}, [id]);
|
||||
@@ -108,6 +111,8 @@ function mountWorkspace() {
|
||||
scope.cleanup(() => { for (const request of countRequests.values()) request.abort(); });
|
||||
// A confirmed Library or vocabulary write cannot change the Images count, so only Library and Tags are re-read.
|
||||
scope.cleanup(onLibraryWrite(() => refreshCounts(LIBRARY_WRITE_COUNTS)));
|
||||
// Starring or unstarring anywhere (Images, Favorites, Library media) re-reads the Favorites count (D51).
|
||||
scope.cleanup(onFavoriteChange(() => refreshCounts(FAVORITE_WRITE_COUNTS)));
|
||||
refreshCounts(NAV_COUNT_ROUTES);
|
||||
let viewScope;
|
||||
let firstRender = true;
|
||||
@@ -143,6 +148,7 @@ function mountWorkspace() {
|
||||
if (saved) {
|
||||
const page = routes.find(item => item.id === saved.page);
|
||||
renderPage({...page, label:saved.name, title:saved.name}, view, saved);
|
||||
mountEditAction(saved, view);
|
||||
return;
|
||||
}
|
||||
if (savedViews.status === 'ready') {
|
||||
@@ -161,6 +167,33 @@ function mountWorkspace() {
|
||||
main.append(retry);
|
||||
}
|
||||
|
||||
// /* D51 */ The open view's Edit (maintainer ruling): first in the heading's actions. A rename re-reads the list and
|
||||
// retitles the open page; a delete re-reads it and replaces the dead route with the view's plain page.
|
||||
function mountEditAction(saved, view) {
|
||||
const heading = pageHeading(main);
|
||||
if (!heading) return;
|
||||
const edit = createButton('Edit');
|
||||
heading.actions.prepend(edit);
|
||||
view.listen(edit, 'click', () => openEditViewDialog({ scope: view, trigger: edit, view: saved,
|
||||
onRename: async name => {
|
||||
const renamed = await savedViews.rename(saved.id, name);
|
||||
if (view.signal.aborted) return;
|
||||
saved = renamed;
|
||||
retitle(renamed.name);
|
||||
},
|
||||
onDelete: async () => {
|
||||
await savedViews.remove(saved.id);
|
||||
window.location.replace(`#/${saved.page}`);
|
||||
} }));
|
||||
}
|
||||
|
||||
function retitle(name) {
|
||||
const { h1 } = pageHeading(main) ?? {};
|
||||
if (h1) h1.textContent = name;
|
||||
document.getElementById('workspace-breadcrumb').textContent = name;
|
||||
document.title = `${name} · Prompt Manager`;
|
||||
}
|
||||
|
||||
function renderPage(route, view, saved = null) {
|
||||
document.title = `${routeTitle(route)} · Prompt Manager`;
|
||||
// The concept's heading(); each view fills its description and actions.
|
||||
@@ -169,9 +202,10 @@ function mountWorkspace() {
|
||||
if (!firstRender) h1.focus();
|
||||
firstRender = false;
|
||||
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
|
||||
if (route.id === 'images') {
|
||||
if (route.id === 'images' || route.id === 'favorites') {
|
||||
mountNewPromptAction(main, api, view);
|
||||
images = mountImages(main, api, view, {savedState:saved?.state, onSaveView:saveView('images', view)});
|
||||
images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites',
|
||||
onSaveView:saveView('images', view)});
|
||||
return;
|
||||
}
|
||||
if (route.id === 'library') {
|
||||
|
||||
@@ -7,13 +7,20 @@
|
||||
* - 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.
|
||||
* - D51: an open view's heading Edit (maintainer ruling, beyond the concept): "Edit saved view" with "View name",
|
||||
* Delete… (danger), Cancel and Save; Delete… asks inside the same dialog, never through `window.confirm`.
|
||||
*/
|
||||
import { openInputDialog } from './workspace-dialogs.mjs';
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { openInputDialog, openContentDialog, closeWorkspaceDialog } 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.';
|
||||
export const RENAMED_TOAST = 'Saved view renamed.';
|
||||
export const DELETED_TOAST = 'Saved view deleted.';
|
||||
export const RENAME_FAILED_TOAST = 'The view could not be renamed. Try again.';
|
||||
export const DELETE_FAILED_TOAST = 'The view could not be deleted. Try again.';
|
||||
const MAX_NAME_LENGTH = 100;
|
||||
const MAX_TAGS = 20;
|
||||
const MAX_TAG_LENGTH = 50;
|
||||
@@ -29,6 +36,8 @@ const FIELDS = 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,
|
||||
// A view saved on Favorites (#249 D51) reopens Images filtered to favorites.
|
||||
favorites: { fallback: false, accepts: value => typeof value === 'boolean' },
|
||||
}),
|
||||
library: Object.freeze({
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags,
|
||||
@@ -73,11 +82,12 @@ 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 };
|
||||
return { q: view.q, category: view.category, sort: view.sort, root: view.source, subfolder: view.folder, media: view.media,
|
||||
tags: view.tags, favorites: view.favorites };
|
||||
}
|
||||
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 });
|
||||
folder: filters.subfolder, media: filters.media, tags: filters.tags, favorites: filters.favorites === true });
|
||||
}
|
||||
|
||||
/** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */
|
||||
@@ -91,7 +101,7 @@ export function libraryViewState(filters) {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* and re-reads the list, `rename(id, name)` and `remove(id)` change one and re-read it (a failure re-reads it too), `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) {
|
||||
@@ -132,8 +142,36 @@ export function createSavedViews(api, signal) {
|
||||
await load();
|
||||
return view;
|
||||
}
|
||||
/** Run one change of a listed view, then re-read the list whatever its outcome (another tab may have changed it). */
|
||||
async function change(send) {
|
||||
try { return await send(); } finally { await load(); }
|
||||
}
|
||||
const viewUrl = id => {
|
||||
if (!Number.isSafeInteger(id) || id < 1) throw new TypeError('Invalid saved view id');
|
||||
return `${SAVED_VIEWS_URL}/${id}`;
|
||||
};
|
||||
function rename(id, name) {
|
||||
const url = viewUrl(id);
|
||||
return change(async () => {
|
||||
const view = parseSavedView((await api.patch(url, { name }, { signal }))?.view);
|
||||
if (!view) throw new TypeError('Invalid saved view');
|
||||
return view;
|
||||
});
|
||||
}
|
||||
function remove(id) {
|
||||
const url = viewUrl(id);
|
||||
return change(async () => {
|
||||
try {
|
||||
await api.del(url, { signal });
|
||||
} catch (error) {
|
||||
// Already gone (another tab deleted it): the outcome the user asked for.
|
||||
if (error?.status !== 404) throw error;
|
||||
}
|
||||
return id;
|
||||
});
|
||||
}
|
||||
return {
|
||||
load, create,
|
||||
load, create, rename, remove,
|
||||
get views() { return views; },
|
||||
get status() { return status; },
|
||||
find: id => views.find(view => view.id === id) ?? null,
|
||||
@@ -160,3 +198,66 @@ export function openSaveViewDialog({ scope, trigger, q, onSave }) {
|
||||
}
|
||||
} });
|
||||
}
|
||||
|
||||
/**
|
||||
* D51: the open view's Edit dialog. "View name" (Save keeps the dialog open on an empty name; an unchanged one closes it), then Delete… (danger), Cancel
|
||||
* and Save. Delete… swaps the same dialog to its confirmation, Cancel / Delete, so nothing is removed by one press and
|
||||
* no browser `confirm()` appears. `onRename(name)` and `onDelete()` return the store's promise; the toast reports either
|
||||
* outcome, and the shell navigates after a delete.
|
||||
*/
|
||||
export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) {
|
||||
const input = document.createElement('input');
|
||||
input.value = view.name;
|
||||
input.maxLength = MAX_NAME_LENGTH;
|
||||
const remove = createButton('Delete…', 'danger');
|
||||
const cancel = createButton('Cancel');
|
||||
const save = createButton('Save', 'ca-primary');
|
||||
const field = document.createElement('div');
|
||||
field.className = 'field';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = 'View name';
|
||||
label.htmlFor = input.id = 'action-input';
|
||||
field.append(label, input);
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'dialog-actions';
|
||||
actions.append(remove, cancel, save);
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
save.addEventListener('click', async () => {
|
||||
const name = input.value.trim();
|
||||
if (!name) { input.focus(); return; }
|
||||
closeWorkspaceDialog();
|
||||
if (name === view.name) return;
|
||||
try {
|
||||
await onRename(name);
|
||||
showWorkspaceToast(RENAMED_TOAST);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(RENAME_FAILED_TOAST);
|
||||
}
|
||||
});
|
||||
remove.addEventListener('click', () => confirmDelete({ scope, trigger, view, onDelete }));
|
||||
openContentDialog({ scope, trigger, title: 'Edit saved view', nodes: [field, actions] });
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
function confirmDelete({ scope, trigger, view, onDelete }) {
|
||||
const text = document.createElement('p');
|
||||
text.textContent = `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`;
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'dialog-actions';
|
||||
const cancel = createButton('Cancel');
|
||||
const confirmButton = createButton('Delete', 'danger');
|
||||
actions.append(cancel, confirmButton);
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
confirmButton.addEventListener('click', async () => {
|
||||
closeWorkspaceDialog();
|
||||
try {
|
||||
await onDelete();
|
||||
showWorkspaceToast(DELETED_TOAST);
|
||||
} catch {
|
||||
showWorkspaceToast(DELETE_FAILED_TOAST);
|
||||
}
|
||||
});
|
||||
openContentDialog({ scope, trigger, title: 'Delete saved view', nodes: [text, actions] });
|
||||
cancel.focus();
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ export const routes = [
|
||||
{ id: 'inspect', label: 'Inspect a file', href: '/prompt_manager/gallery.html',
|
||||
description: 'Inspect generation metadata from an image or video file.', action: 'Open existing metadata inspector' },
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
// SAVED VIEWS' first row (#249 D51): Images filtered to favorites. No href, so Overview lists no card for it.
|
||||
{ id: 'favorites', label: 'Favorites' },
|
||||
{ id: 'system', label: 'System', href: '/prompt_manager/admin',
|
||||
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', 'saved']);
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview', 'saved', 'favorites']);
|
||||
|
||||
/** 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' });
|
||||
@@ -47,14 +49,16 @@ export function summarizeStats(stats) {
|
||||
});
|
||||
}
|
||||
|
||||
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count.
|
||||
// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count. Favorites (D51) is the
|
||||
// Favorites page's own listing, so its count is what that page lists.
|
||||
const NAV_COUNT_SOURCES = Object.freeze([
|
||||
['images', '/prompt_manager/images/output?mode=workspace&limit=1'],
|
||||
['favorites', '/prompt_manager/images/output?mode=workspace&favorites=1&limit=1'],
|
||||
['library', '/prompt_manager/library?limit=1'],
|
||||
['tags', '/prompt_manager/vocabulary/tags?limit=1'],
|
||||
]);
|
||||
|
||||
/** Route id -> count for the WORKSPACE links; null when a page is missing or its total is not a count. */
|
||||
/** Route id -> count for the sidebar links; null when a page is missing or its total is not a count. */
|
||||
export function summarizeNavCounts(pages = {}) {
|
||||
return new Map(NAV_COUNT_SOURCES.map(([id]) => {
|
||||
const total = pages?.[id]?.total;
|
||||
@@ -63,16 +67,19 @@ export function summarizeNavCounts(pages = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the three counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read
|
||||
* settles, so a slow listing (the Images output scan) never holds back the cheap Library and Tags counts.
|
||||
* Read the counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read settles, so a
|
||||
* slow listing (the Images output scan) never holds back the cheap Library and Tags counts.
|
||||
*/
|
||||
/** The WORKSPACE routes that carry a count. */
|
||||
/** The sidebar routes that carry a count (WORKSPACE, plus SAVED VIEWS' Favorites). */
|
||||
export const NAV_COUNT_ROUTES = Object.freeze(NAV_COUNT_SOURCES.map(([id]) => id));
|
||||
|
||||
/** The counts a confirmed Library or vocabulary write can change; the Images listing cannot. */
|
||||
export const LIBRARY_WRITE_COUNTS = Object.freeze(['library', 'tags']);
|
||||
|
||||
/** Read the given WORKSPACE counts (all by default), reporting each as its own read settles. */
|
||||
/** The count a confirmed favorite change can change. */
|
||||
export const FAVORITE_WRITE_COUNTS = Object.freeze(['favorites']);
|
||||
|
||||
/** Read the given sidebar counts (all by default), reporting each as its own read settles. */
|
||||
export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_COUNT_ROUTES) {
|
||||
const pages = {};
|
||||
await Promise.all(NAV_COUNT_SOURCES.filter(([id]) => ids.includes(id)).map(async ([id, url]) => {
|
||||
|
||||
Reference in New Issue
Block a user