Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 144 additions & 7 deletions assets/toolbox/sprites/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ const elements = {
add: document.querySelector("[data-sprites-add]"),
apiStatus: document.querySelector("[data-sprites-api-status]"),
count: document.querySelector("[data-sprites-count]"),
categoryFilter: document.querySelector("[data-sprites-category-filter]"),
clearFilters: document.querySelector("[data-sprites-clear-filters]"),
emptyState: document.querySelector("[data-sprites-empty-state]"),
errorState: document.querySelector("[data-sprites-error-state]"),
filterStatus: document.querySelector("[data-sprites-filter-status]"),
libraryStatus: document.querySelector("[data-sprites-library-status]"),
metadata: document.querySelector("[data-sprites-metadata]"),
outputStatus: document.querySelector("[data-sprites-output-status]"),
Expand All @@ -19,8 +22,11 @@ const elements = {
refresh: document.querySelector("[data-sprites-refresh]"),
replace: document.querySelector("[data-sprites-replace]"),
replaceStatus: document.querySelector("[data-sprites-replace-status]"),
search: document.querySelector("[data-sprites-search]"),
storageStatus: document.querySelector("[data-sprites-storage-status]"),
statusFilter: document.querySelector("[data-sprites-status-filter]"),
tableBody: document.querySelector("[data-sprites-table-body]"),
tagFilter: document.querySelector("[data-sprites-tag-filter]"),
updated: document.querySelector("[data-sprites-updated]"),
validation: document.querySelector("[data-sprites-validation]"),
duplicate: document.querySelector("[data-sprites-duplicate]"),
Expand Down Expand Up @@ -172,6 +178,16 @@ function paletteKeysFor(sprite) {
return [];
}

function tagKeysFor(sprite) {
if (Array.isArray(sprite?.tagKeys)) {
return sprite.tagKeys.map((key) => String(key || "").trim()).filter(Boolean);
}
if (Array.isArray(sprite?.tag_keys)) {
return sprite.tag_keys.map((key) => String(key || "").trim()).filter(Boolean);
}
return [];
}

function usageCountFor(sprite) {
const count = Number(sprite?.usageCount ?? sprite?.usage_count ?? sprite?.references?.length);
return Number.isFinite(count) && count >= 0 ? String(count) : "0";
Expand All @@ -192,12 +208,93 @@ function spriteRowsFromPayload(payload) {
return [];
}

function uniqueSorted(values) {
return [...new Set(values.map((value) => String(value || "").trim()).filter(Boolean))]
.sort((left, right) => left.localeCompare(right, undefined, { sensitivity: "base" }));
}

function setSelectOptions(select, values, allLabel) {
if (!select) {
return;
}
const current = select.value;
const options = [""].concat(values);
select.replaceChildren(...options.map((value) => {
const option = document.createElement("option");
option.value = value;
option.textContent = value || allLabel;
return option;
}));
select.value = options.includes(current) ? current : "";
}

function renderFilterOptions(sprites) {
setSelectOptions(elements.statusFilter, SPRITE_STATUSES, "All statuses");
setSelectOptions(elements.categoryFilter, uniqueSorted(sprites.map((sprite) => sprite.category)), "All categories");
setSelectOptions(elements.tagFilter, uniqueSorted(sprites.flatMap(tagKeysFor)), "All tag keys");
}

function filterValues() {
return {
category: String(elements.categoryFilter?.value || "").trim(),
search: String(elements.search?.value || "").trim().toLowerCase(),
status: String(elements.statusFilter?.value || "").trim(),
tagKey: String(elements.tagFilter?.value || "").trim(),
};
}

function spriteMatchesFilters(sprite, filters) {
if (filters.status && sprite.status !== filters.status) {
return false;
}
if (filters.category && sprite.category !== filters.category) {
return false;
}
if (filters.tagKey && !tagKeysFor(sprite).includes(filters.tagKey)) {
return false;
}
if (!filters.search) {
return true;
}
const haystack = [
sprite.name,
sprite.status,
sprite.category,
sprite.source,
sprite.storagePath,
...tagKeysFor(sprite),
...paletteKeysFor(sprite),
].map((value) => String(value || "").toLowerCase()).join(" ");
return haystack.includes(filters.search);
}

function filteredSprites() {
const filters = filterValues();
return currentSprites.filter((sprite) => spriteMatchesFilters(sprite, filters));
}

function renderFilterStatus(visibleCount, totalCount) {
if (totalCount === 0) {
setText(elements.filterStatus, "No API-backed Sprites records are available to filter.");
return;
}
const filters = filterValues();
const activeFilters = Object.values(filters).filter(Boolean).length;
setText(
elements.filterStatus,
activeFilters > 0
? `${visibleCount} of ${totalCount} Sprites records match current filters.`
: `${totalCount} Sprites records available.`
);
}

function renderLoading() {
setText(elements.apiStatus, "Loading");
setText(elements.libraryStatus, "Loading");
setText(elements.outputStatus, "Loading");
setText(elements.outputSummary, "Waiting for Sprites API response.");
setActionStatus("Loading Sprites records.");
setText(elements.filterStatus, "Filters load with API-backed Sprites records.");
setText(elements.storageStatus, "Storage import is checking API capabilities.");
setText(elements.replaceStatus, "Select a sprite to update source metadata through the API.");
setText(elements.emptyState, "Loading Sprites records.");
Expand Down Expand Up @@ -226,6 +323,7 @@ function renderUnavailable(message) {
setText(elements.paletteStatus, "Palette/Colors references unavailable until Sprites records load from the API.");
setText(elements.paletteSelectionStatus, "Palette/Colors selection unavailable until API-backed key records are available.");
setText(elements.storageStatus, "Storage import unavailable because the Sprites API is not responding.");
setText(elements.filterStatus, "Filters unavailable until Sprites records load from the API.");
setText(elements.replaceStatus, "Replace metadata unavailable until the Sprites API responds.");
renderPreviewPanel(null);
setText(elements.updated, new Date().toLocaleTimeString());
Expand Down Expand Up @@ -316,13 +414,13 @@ function selectSprite(sprite) {
renderPreviewPanel(sprite);
}

function renderRows(sprites) {
function renderRows(sprites, emptyMessage = "No Sprites records returned by the API.") {
if (!elements.tableBody) {
return;
}
if (sprites.length === 0) {
const row = document.createElement("tr");
const cell = createCell("No Sprites records returned by the API.");
const cell = createCell(emptyMessage);
cell.colSpan = 9;
row.append(cell);
elements.tableBody.replaceChildren(...(editingKey === "__new__" ? [createEditRow(), row] : [row]));
Expand Down Expand Up @@ -425,11 +523,13 @@ function renderSprites(payload) {
const sprites = spriteRowsFromPayload(payload);
currentSprites = sprites;
const count = sprites.length;
renderFilterOptions(sprites);
const visibleSprites = filteredSprites();
setText(elements.apiStatus, "Ready");
setText(elements.libraryStatus, count > 0 ? "Ready" : "Empty");
setText(elements.count, String(count));
setText(elements.outputStatus, count > 0 ? "Ready" : "Empty");
setText(elements.outputSummary, count > 0 ? `${count} sprite record${count === 1 ? "" : "s"} loaded from the API.` : "Sprites API responded with no records.");
setText(elements.outputSummary, count > 0 ? `${visibleSprites.length} of ${count} sprite record${count === 1 ? "" : "s"} displayed from the API.` : "Sprites API responded with no records.");
setText(elements.emptyState, count > 0 ? "" : "No Sprites records returned by the API.");
setText(elements.updated, new Date().toLocaleTimeString());
setText(elements.metadata, count > 0 ? "Select a sprite row to review its metadata." : "No sprite metadata available yet.");
Expand All @@ -440,7 +540,11 @@ function renderSprites(payload) {
setHidden(elements.emptyState, count > 0);
setHidden(elements.errorState, true);
renderPaletteStatus(sprites);
renderRows(sprites);
renderFilterStatus(visibleSprites.length, count);
renderRows(
visibleSprites,
count > 0 ? "No Sprites records match current filters." : "No Sprites records returned by the API."
);
}

function bodyFromSprite(sprite, overrides = {}) {
Expand Down Expand Up @@ -648,7 +752,7 @@ elements.refresh?.addEventListener("click", () => {

elements.add?.addEventListener("click", () => {
editingKey = "__new__";
renderRows(currentSprites);
renderRows(filteredSprites());
setActionStatus("New sprite row ready. Name and status are required.");
});

Expand All @@ -665,13 +769,13 @@ elements.tableBody?.addEventListener("click", (event) => {
const duplicateKey = target.dataset.spritesDuplicateRow;
if (editKey !== undefined) {
editingKey = editKey;
renderRows(currentSprites);
renderRows(filteredSprites());
setActionStatus("Editing sprite row. Name and status are required.");
return;
}
if (cancelKey !== undefined) {
editingKey = "";
renderRows(currentSprites);
renderRows(filteredSprites());
setActionStatus("Sprite edit cancelled.");
return;
}
Expand Down Expand Up @@ -703,4 +807,37 @@ elements.replace?.addEventListener("click", () => {
void replaceSpriteMetadata(selectedSpriteKey);
});

[elements.search, elements.statusFilter, elements.categoryFilter, elements.tagFilter].forEach((control) => {
control?.addEventListener("input", () => {
editingKey = "";
const visibleSprites = filteredSprites();
renderFilterStatus(visibleSprites.length, currentSprites.length);
renderRows(visibleSprites, "No Sprites records match current filters.");
});
control?.addEventListener("change", () => {
editingKey = "";
const visibleSprites = filteredSprites();
renderFilterStatus(visibleSprites.length, currentSprites.length);
renderRows(visibleSprites, "No Sprites records match current filters.");
});
});

elements.clearFilters?.addEventListener("click", () => {
if (elements.search) {
elements.search.value = "";
}
if (elements.statusFilter) {
elements.statusFilter.value = "";
}
if (elements.categoryFilter) {
elements.categoryFilter.value = "";
}
if (elements.tagFilter) {
elements.tagFilter.value = "";
}
const visibleSprites = filteredSprites();
renderFilterStatus(visibleSprites.length, currentSprites.length);
renderRows(visibleSprites);
});

void loadSprites();
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# PR_26177_CHARLIE_014 Branch Validation

Status: PASS

## Checks

- PASS: PR014 was created as a stacked branch from `PR_26177_CHARLIE_013-sprites-import-preview-metadata-palette`.
- PASS: Stacking is required because search/filter controls build on the PR013 Sprites table and metadata shell.
- PASS: Current work branch is `PR_26177_CHARLIE_014-sprites-tags-categories-search`.
- PASS: Branch contains only the Sprites tags/categories/search PR scope relative to PR013.
- PASS: No merge was performed.
- PASS: No `start_of_day` path is changed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# PR_26177_CHARLIE_014 Manual Validation Notes

Status: PASS

## Manual Review

- Verified Search filters by API-returned sprite fields and tag/palette key text.
- Verified category options are derived from current API records.
- Verified tag key options are derived from current API records.
- Verified status filtering uses the Sprites status contract.
- Verified clear filters restores the unfiltered API-backed table.
- Verified no static category or tag product-data list was introduced.
- Verified Sprites does not duplicate Tags ownership.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# PR_26177_CHARLIE_014 Requirements Checklist

Status: PASS

- PASS: Added search for Sprites.
- PASS: Added status filter.
- PASS: Added categories filter.
- PASS: Added tag key filter.
- PASS: Search and filters use API/database-backed sprite data.
- PASS: Categories are derived from API-backed records.
- PASS: Tag keys are derived from API-backed records.
- PASS: Did not create Sprite-owned Tags data.
- PASS: Did not use page-local product arrays for categories or tags.
- PASS: Added table filtering UX consistent with GFS patterns.
- PASS: Did not add browser storage product-data source of truth.
- PASS: Did not introduce MEM DB, local-mem, fake-login, or silent fallback.
- PASS: Targeted Playwright coverage passed.
- PASS: Required report artifacts were created.
- PASS: Repo-structured ZIP artifact was created under `tmp/`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# PR_26177_CHARLIE_014 Validation Lane

Status: PASS

## Commands

```powershell
rg -n "<style|style=|onclick=|onchange=|oninput=|onsubmit=|<script>" toolbox/sprites/index.html assets/toolbox/sprites/js/index.js tests/playwright/tools/SpritesToolShell.spec.mjs
```

Result: PASS, no matches.

```powershell
rg -n "localStorage|sessionStorage|indexedDB|imageDataUrl|MEM DB|local-mem|fake-login|silent fallback" toolbox/sprites/index.html assets/toolbox/sprites/js/index.js tests/playwright/tools/SpritesToolShell.spec.mjs
```

Result: PASS, no matches.

```powershell
git diff --check
```

Result: PASS. Git reported only repository line-ending warnings for changed HTML/test files.

```powershell
node ./node_modules/@playwright/test/cli.js test tests/playwright/tools/SpritesToolShell.spec.mjs --project=playwright --workers=1 --reporter=list
```

Result: PASS, 9 passed.

## Playwright Coverage

Targeted Playwright coverage updated `docs_build/dev/reports/playwright_v8_coverage_report.txt` for the Sprites browser module.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# PR_26177_CHARLIE_014-sprites-tags-categories-search

Team: Charlie

Status: PASS

## Scope

Added API-backed search and filters for Sprites records. Categories and tag keys are derived from the API response; Sprites does not create a separate tag authority or page-local product data list.

## Changed Files

- `toolbox/sprites/index.html`
- `assets/toolbox/sprites/js/index.js`
- `tests/playwright/tools/SpritesToolShell.spec.mjs`
- `docs_build/dev/reports/playwright_v8_coverage_report.txt`
- `docs_build/dev/reports/codex_review.diff`
- `docs_build/dev/reports/codex_changed_files.txt`
- `docs_build/dev/reports/PR_26177_CHARLIE_014-sprites-tags-categories-search.md`
- `docs_build/dev/reports/PR_26177_CHARLIE_014-sprites-tags-categories-search-branch-validation.md`
- `docs_build/dev/reports/PR_26177_CHARLIE_014-sprites-tags-categories-search-requirements-checklist.md`
- `docs_build/dev/reports/PR_26177_CHARLIE_014-sprites-tags-categories-search-validation-lane.md`
- `docs_build/dev/reports/PR_26177_CHARLIE_014-sprites-tags-categories-search-manual-validation-notes.md`

## Implementation Notes

- Added search control.
- Added status filter using the Sprites API status contract.
- Added category filter derived from API-backed sprite records.
- Added tag key filter derived from API-backed sprite records.
- Added clear filters action.
- Added filter status summary for visible records versus total API-backed records.
- Did not duplicate Tags ownership or add page-local reusable product arrays.

## Validation

- PASS: `git diff --check`
- PASS: inline CSS/script/handler scan for Sprites files found no matches.
- PASS: browser storage and forbidden local data pattern scan found no matches.
- PASS: no `start_of_day` files changed.
- PASS: `node ./node_modules/@playwright/test/cli.js test tests/playwright/tools/SpritesToolShell.spec.mjs --project=playwright --workers=1 --reporter=list`

## ZIP Artifact

- `tmp/PR_26177_CHARLIE_014-sprites-tags-categories-search_delta.zip`
Loading
Loading