3 Commits

Author SHA1 Message Date
bblaz f67d2c89be Merge pull request 'Refactor stock API to replace numeric flags with boolean values, add getItemLabel endpoint, and update tests/documentation' (#10) from codex/update-rest-api-integration into main
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #10
2026-05-02 20:52:10 +00:00
bblaz 054a7ad0dd Add category management: list API, UI integration, and search updates
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2026-05-02 22:47:46 +02:00
bblaz 1fe56a232b Refactor stock API to replace numeric flags with boolean values, add getItemLabel endpoint, and update tests/documentation
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-01 23:51:05 +02:00
15 changed files with 517 additions and 58 deletions
+16 -10
View File
@@ -163,36 +163,42 @@ Expected shapes today:
Returns `{ data: [...] }` or `{ kitchens: [...] }`.
- `GET /{database}/kitchen/items?search_name=...`
Returns item definitions for autocomplete.
Item payloads now expose category links via `categories` (array of IDs).
- `GET /{database}/kitchen/items`
Returns the current stock review list. Endpoint is paginated (`limit`/`offset`, backend default `limit=100`); frontend helpers aggregate pages by default unless explicit pagination is passed.
- `GET /{database}/kitchen/items/grouped?expanded=0|1`
Returns grouped stock data; grouped review uses summary-first loading (`expanded=0`) and hydrates item children in background (`expanded=1`).
With `expanded=0`, child `items` can be ID-only stubs (`{ id }`) instead of full item payloads.
- `GET /{database}/kitchen/items/grouped?expanded=true|false`
Returns grouped stock data; grouped review uses summary-first loading (`expanded=false`) and hydrates item children in background (`expanded=true`).
With `expanded=false`, child `items` can be ID-only stubs (`{ id }`) instead of full item payloads.
- `GET /{database}/kitchen/items/{uuid_b64}`
Returns one item detail payload.
Supports `allow_inactive=true|false` query filtering when needed.
- `GET /{database}/kitchen/changes`
Returns `{ since, next_cursor, changes }` feed payload for item/stock updates.
- `POST /{database}/kitchen/items/upsert?mode=preview|apply`
Used by label submit flow for create-or-update behavior and conflict-safe matching.
- `POST /{database}/kitchen/items/lookup`
Identifier lookup response includes source/freshness metadata (`source`, `cache_hit`, `stale_cache`, `payload_fetched_at`, `retry_after_seconds`) used for richer user feedback.
- `POST /{database}/kitchen/items/{uuid_b64}/lookup?update=0|1`
Item-scoped OpenFoodFacts lookup used by stock detail to preview (`update=0`) or apply missing fields (`update=1`).
- `POST /{database}/kitchen/items?label=1`
Used for label image preview rendering.
- `POST /{database}/kitchen/items?label=1&preview=1`
- `POST /{database}/kitchen/items/{uuid_b64}/lookup?update=true|false`
Item-scoped OpenFoodFacts lookup used by stock detail to preview (`update=false`) or apply missing fields (`update=true`).
- `POST /{database}/kitchen/items?label=true&preview=true`
Returns an image blob, `{ imageUrl }`, or `{ imageSvg }` for in-browser preview.
- `GET /{database}/kitchen/items/{uuid_b64}/label`
Returns rendered label PNG for an existing item.
- `POST /{database}/kitchen/items/{uuid_b64}/stock`
Creates a stock event for measured or descriptive updates using `{ quantity }` or `{ level }`.
Creates a stock event for measured or descriptive updates using `{ quantity }` or `{ level }`,
and for non-consumed gone transitions (for example `{ level: "gone", gone_reason: "spoiled" }`).
Response shape is `{ status, stock }`; frontend re-fetches the item detail after successful update.
- `POST /{database}/kitchen/items/{uuid_b64}/use`
Marks an item used up (`gone`) via stock-event semantics.
Marks an item consumed/used up (`gone`) via stock-event semantics.
- `POST /{database}/kitchen/items/{uuid_b64}/print-label`
Prints label for an existing item; called from the save flow when `Print` is enabled.
- `PATCH /{database}/kitchen/items/{uuid_b64}`
Used for item-level edits from stock detail (for example identifier code updates).
- `GET /{database}/kitchen/locations`
Returns a nested location tree.
- `GET /{database}/kitchen/categories`
Returns categories (paged). Frontend now resolves category labels from
`categories_detail` when present, and falls back to this endpoint by ID.
## Notes
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "lonc-web",
"version": "0.2.4",
"version": "0.2.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lonc-web",
"version": "0.2.4",
"version": "0.2.6",
"dependencies": {
"@zxing/browser": "^0.1.5",
"alpinejs": "^3.14.9",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lonc-web",
"version": "0.2.5",
"version": "0.2.6",
"private": true,
"type": "module",
"scripts": {
+90
View File
@@ -0,0 +1,90 @@
import { apiRequest, getPath } from './client.js';
const DEFAULT_LIST_PAGE_LIMIT = 100;
function unwrapListPayload(payload) {
if (Array.isArray(payload)) {
return payload;
}
return payload?.data || payload?.entries || payload?.items || payload?.categories || [];
}
function hasExplicitPagination(filters = {}) {
return (
(filters.limit !== undefined && filters.limit !== null)
|| (filters.offset !== undefined && filters.offset !== null)
);
}
function buildCategoryQuery(filters = {}) {
const query = {};
const searchName = filters.searchName || filters.search_name;
if (searchName) {
query.search_name = searchName;
}
if (filters.active !== undefined && filters.active !== null && filters.active !== '') {
query.active = Boolean(filters.active);
}
if (filters.orderBy || filters.order_by) {
query.order_by = filters.orderBy || filters.order_by;
}
if (filters.orderDir || filters.order_dir) {
query.order_dir = filters.orderDir || filters.order_dir;
}
if (filters.expanded !== undefined && filters.expanded !== null && filters.expanded !== '') {
query.expanded = Boolean(filters.expanded);
}
return query;
}
async function fetchAllCategoryPages(store, baseQuery = {}) {
const items = [];
let offset = 0;
while (true) {
const payload = await apiRequest(store, getPath('categories'), {
query: {
...baseQuery,
limit: DEFAULT_LIST_PAGE_LIMIT,
offset,
},
});
const pageItems = unwrapListPayload(payload);
items.push(...pageItems);
if (pageItems.length < DEFAULT_LIST_PAGE_LIMIT) {
break;
}
offset += DEFAULT_LIST_PAGE_LIMIT;
}
return items;
}
export async function listCategories(store, filters = {}) {
const baseQuery = buildCategoryQuery(filters);
if (hasExplicitPagination(filters)) {
const query = { ...baseQuery };
if (filters.limit !== undefined && filters.limit !== null) {
query.limit = filters.limit;
}
if (filters.offset !== undefined && filters.offset !== null) {
query.offset = filters.offset;
}
const payload = await apiRequest(store, getPath('categories'), {
query,
});
return unwrapListPayload(payload);
}
return fetchAllCategoryPages(store, baseQuery);
}
+14 -1
View File
@@ -42,7 +42,7 @@ export async function previewLabel(store, body) {
method: 'POST',
body,
accept: 'image/svg+xml, image/png, application/json',
query: { label: 1, preview: 1 },
query: { label: true, preview: true },
});
const image = normalizeLabelImagePayload(payload);
@@ -53,6 +53,19 @@ export async function previewLabel(store, body) {
throw new Error('Label preview response did not include an image.');
}
export async function getItemLabel(store, uuidB64) {
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/label`, {
method: 'GET',
accept: 'image/png, application/json',
});
const image = normalizeLabelImagePayload(payload);
if (image) {
return image;
}
throw new Error('Item label response did not include an image.');
}
export async function printItemLabel(store, uuidB64) {
return apiRequest(store, `${getPath('items')}/${uuidB64}/print-label`, {
method: 'POST',
+43 -6
View File
@@ -2,6 +2,35 @@ import { apiRequest, getPath } from './client.js';
const DEFAULT_LIST_PAGE_LIMIT = 100;
function toBooleanFlag(value, defaultValue = false) {
if (value === undefined || value === null || value === '') {
return defaultValue;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (!normalized) {
return defaultValue;
}
if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) {
return true;
}
if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) {
return false;
}
}
return Boolean(value);
}
function unwrapEntryPayload(payload) {
return payload?.data || payload?.entry || payload?.item || payload;
}
@@ -52,7 +81,7 @@ export async function searchItemDefinitions(store, query) {
}
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
query: { search_name: query, expanded: 0 },
query: { search_name: query, expanded: false },
});
if (Array.isArray(payload)) {
@@ -90,7 +119,7 @@ export async function listStockEntries(store, filters = {}) {
export async function listGroupedStockEntries(store, options = {}) {
const baseQuery = {};
const expanded = options.expanded ?? 1;
const expanded = toBooleanFlag(options.expanded, true);
baseQuery.expanded = expanded;
const searchName = options.searchName || options.search_name;
if (searchName) {
@@ -119,7 +148,7 @@ export async function listGroupedStockEntries(store, options = {}) {
export async function getStockEntry(store, stockId, { allowInactive = false } = {}) {
const path = `${getPath('items')}/${stockId}`;
const payload = allowInactive
? await apiRequest(store, path, { query: { allow_inactive: 1 } })
? await apiRequest(store, path, { query: { allow_inactive: true } })
: await apiRequest(store, path);
return unwrapEntryPayload(payload);
}
@@ -128,7 +157,7 @@ export async function createStockEntry(store, body) {
const payload = await apiRequest(store, getPath('items'), {
method: 'POST',
body,
query: { label: 1, print: 1 },
query: { label: true, print: true },
});
return unwrapEntryPayload(payload);
}
@@ -211,7 +240,7 @@ export async function lookupItemByIdentifier(store, identifierCode) {
export async function lookupItemDetails(store, uuidB64, { update = false } = {}) {
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/lookup`, {
method: 'POST',
query: { update: update ? 1 : 0 },
query: { update: toBooleanFlag(update, false) },
});
return normalizeItemLookupResponse(payload);
@@ -246,7 +275,7 @@ export async function createStockEvent(store, uuidB64, body) {
export async function listStockEvents(store, uuidB64, options = {}) {
const query = {};
if (options.allowInactive) {
query.allow_inactive = 1;
query.allow_inactive = true;
}
if (options.limit !== undefined && options.limit !== null) {
query.limit = options.limit;
@@ -269,6 +298,14 @@ export async function listStockEvents(store, uuidB64, options = {}) {
export async function markStockGone(store, uuidB64, reason = 'consumed') {
try {
if (reason === 'consumed') {
const result = await useStockItem(store, uuidB64);
if (result.status === 'already_gone') {
return { status: 'already_gone', reason };
}
return { status: 'gone', reason };
}
await createStockEvent(store, uuidB64, {
level: 'gone',
gone_reason: reason,
+2 -1
View File
@@ -1,5 +1,5 @@
export const APP_NAME = 'Lonc';
export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.5';
export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.6';
export const TRYTON_APPLICATION = 'kitchen';
export const CONNECTION_STATES = {
@@ -27,6 +27,7 @@ export const API_PATHS = {
kitchens: 'kitchen/kitchens',
items: 'kitchen/items',
locations: 'kitchen/locations',
categories: 'kitchen/categories',
changes: 'kitchen/changes',
};
+53 -1
View File
@@ -8,6 +8,7 @@ import {
} from '../../api/stock.js';
import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js';
import { fetchLocations } from '../../api/locations.js';
import { listCategories } from '../../api/categories.js';
import { getRouteContext } from '../../app/router.js';
import { renderScannerModal } from '../shared/scanner-modal.js';
import {
@@ -145,6 +146,21 @@ export function renderStockDetailPage() {
</dd>
<dt class="col-5">Stock type</dt>
<dd class="col-7" x-text="entry.stock_type || 'Stored'"></dd>
<dt class="col-5">Main category</dt>
<dd class="col-7" x-text="mainCategoryLabel(entry) || 'Not set'"></dd>
<dt class="col-5">Categories</dt>
<dd class="col-7">
<template x-if="categoryLabels(entry).length">
<div class="d-flex flex-wrap gap-1">
<template x-for="label in categoryLabels(entry)" :key="label">
<span class="badge text-bg-light border" x-text="label"></span>
</template>
</div>
</template>
<template x-if="!categoryLabels(entry).length">
<span class="text-body-secondary">Uncategorized</span>
</template>
</dd>
</dl>
<div class="mt-4">
@@ -464,6 +480,7 @@ export function stockDetailPageData(store) {
entry: null,
stockEvents: [],
locationPathByUuid: {},
categoriesById: {},
identifierDraft: '',
scannerManualCode: '',
adjustment: {
@@ -484,9 +501,10 @@ export function stockDetailPageData(store) {
const { params } = getRouteContext();
await runAsyncState(this.state, async () => {
const [entry, locations] = await Promise.all([
const [entry, locations, categories] = await Promise.all([
getStockEntry(store, params.id),
fetchLocations(store).catch(() => ({ flat: [] })),
listCategories(store, { expanded: true }).catch(() => []),
]);
this.entry = entry;
this.identifierDraft = normalizeIdentifierCode(entry?.identifier_code);
@@ -495,6 +513,11 @@ export function stockDetailPageData(store) {
.filter((location) => location.uuid_b64)
.map((location) => [location.uuid_b64, location.pathLabel || location.name]),
);
this.categoriesById = Object.fromEntries(
categories
.filter((category) => category?.id !== undefined && category?.id !== null)
.map((category) => [String(category.id), category]),
);
this.adjustment.level = this.entry?.level || 'plenty';
}).catch(() => {});
this.loadStockHistory().catch(() => {});
@@ -966,6 +989,35 @@ export function stockDetailPageData(store) {
return this.locationPathByUuid[locationUuid] || 'Location not resolved';
},
categoryLabel(category) {
if (!category || typeof category !== 'object') {
return '';
}
return String(category.path || category.name || '').trim();
},
mainCategoryLabel(entry) {
const categoryId = entry?.category;
if (categoryId === null || categoryId === undefined || categoryId === '') {
return '';
}
const mapped = this.categoryLabel(this.categoriesById[String(categoryId)]);
if (mapped) {
return mapped;
}
return String(categoryId).trim();
},
categoryLabels(entry) {
const categoryIds = Array.isArray(entry?.categories) ? entry.categories : [];
const labels = categoryIds.map((categoryId) => {
const mapped = this.categoryLabel(this.categoriesById[String(categoryId)]);
return mapped || String(categoryId || '').trim();
});
return [...new Set(labels.filter(Boolean))];
},
nutriScoreLabel(entry) {
const value = entry?.nutriscore_grade;
if (!value) {
+106 -16
View File
@@ -7,6 +7,7 @@ import {
updateStockItem,
} from '../../api/stock.js';
import { fetchLocations } from '../../api/locations.js';
import { listCategories } from '../../api/categories.js';
import { STORAGE_KEYS } from '../../app/config.js';
import { clearStoredValue, loadStoredValue, saveStoredValue } from '../shared/storage.js';
import { createAsyncState } from '../shared/ui-state.js';
@@ -221,13 +222,35 @@ function resolveLocationLabel(entry, locationMap) {
return locationMap[entry.location_initial_uuid_b64] || 'Location not resolved';
}
function searchBlob(entry, locationMap) {
function categoryLabel(category) {
if (!category || typeof category !== 'object') {
return '';
}
return String(category.name || category.path || '').trim();
}
function resolveMainCategoryLabel(entry, categoriesById = {}) {
const categoryId = entry?.category;
if (categoryId === null || categoryId === undefined || categoryId === '') {
return '';
}
const mapped = categoryLabel(categoriesById[String(categoryId)]);
if (mapped) {
return mapped;
}
return String(categoryId).trim();
}
function searchBlob(entry, locationMap, categoriesById = {}) {
return [
entry.name,
entry.description,
entry.level,
entry.stock_type,
resolveLocationLabel(entry, locationMap),
resolveMainCategoryLabel(entry, categoriesById),
entry.uuid_b64,
]
.filter(Boolean)
@@ -235,10 +258,10 @@ function searchBlob(entry, locationMap) {
.toLowerCase();
}
function groupSearchBlob(group, locationMap) {
function groupSearchBlob(group, locationMap, categoriesById = {}) {
return [
searchBlob(group, locationMap),
...(group.items || []).map((item) => searchBlob(item, locationMap)),
searchBlob(group, locationMap, categoriesById),
...(group.items || []).map((item) => searchBlob(item, locationMap, categoriesById)),
]
.filter(Boolean)
.join(' ')
@@ -544,6 +567,9 @@ export function renderStockListPage() {
<td>
<div class="fw-semibold" x-text="entry.name"></div>
<div class="small text-body-secondary" x-text="entry.description || 'No description'"></div>
<div class="small mt-1" x-show="mainCategoryLabel(entry)">
<span class="badge text-bg-light border" x-text="mainCategoryBadgeLabel(entry)"></span>
</div>
<div class="small font-monospace text-body-secondary" x-text="shortId(entry)"></div>
<a
class="small text-decoration-none fw-semibold"
@@ -623,6 +649,9 @@ export function renderStockListPage() {
<div>
<div class="fw-semibold fs-5" x-text="entry.name"></div>
<div class="text-body-secondary small" x-text="entry.description || 'No description'"></div>
<div class="small mt-1" x-show="mainCategoryLabel(entry)">
<span class="badge text-bg-light border" x-text="mainCategoryBadgeLabel(entry)"></span>
</div>
<div class="text-body-secondary small font-monospace" x-text="shortId(entry)"></div>
<a
class="small text-decoration-none fw-semibold"
@@ -728,10 +757,16 @@ export function renderStockListPage() {
<div>
<div class="fw-semibold grouped-stock-summary-title" x-text="group.name"></div>
<div class="text-body-secondary small grouped-stock-summary-description" x-show="group.description" x-text="group.description"></div>
<div class="small mt-1" x-show="mainCategoryLabel(group)">
<span class="badge text-bg-light border" x-text="mainCategoryBadgeLabel(group)"></span>
</div>
<div class="d-flex flex-wrap small grouped-stock-summary-meta">
<span><span class="fw-semibold text-body" x-text="groupItemCount(group)"></span> item(s)</span>
<span><span class="text-body-secondary">Latest location:</span> <span class="fw-semibold text-body" x-text="locationLabel(group)"></span></span>
<span><span class="text-body-secondary">Quantity:</span> <span class="fw-semibold text-body" x-text="quantityLabel(group)"></span></span>
<span>
<span class="text-body-secondary" x-text="groupSummaryMetricLabel(group) + ':'"></span>
<span class="fw-semibold text-body" x-text="groupSummaryMetricValue(group)"></span>
</span>
</div>
</div>
<div class="grouped-stock-summary-status">
@@ -781,6 +816,12 @@ export function renderStockListPage() {
<span x-text="locationLabel(item)"></span>
<span class="grouped-stock-subline-separator" aria-hidden="true">•</span>
<span x-text="shortDescription(item.description)"></span>
<template x-if="mainCategoryLabel(item)">
<span>
<span class="grouped-stock-subline-separator" aria-hidden="true">•</span>
<span class="text-body-tertiary" x-text="'Main: ' + mainCategoryLabel(item)"></span>
</span>
</template>
</div>
</div>
<div class="small text-body-secondary grouped-stock-item-aux">
@@ -876,6 +917,7 @@ export function stockListPageData(store) {
locationMap: {},
locationDescendants: {},
locationLineage: {},
categoriesById: {},
editForms: {},
editErrors: {},
levelOptions: LEVEL_OPTIONS,
@@ -918,13 +960,13 @@ export function stockListPageData(store) {
: false;
if (!restoredFromRuntime) {
const initTasks = [this.loadLocations()];
const initTasks = [this.loadLocations(), this.loadCategories()];
if (this.viewMode === 'items') {
initTasks.push(this.loadEntries());
} else {
initTasks.push(
this.loadGroupedEntries({
expanded: 0,
expanded: false,
resetVisible: !restoredContext,
}),
);
@@ -937,6 +979,9 @@ export function stockListPageData(store) {
if (!restoredFromRuntime && this.viewMode === 'grouped') {
this.hydrateGroupedEntriesInBackground().catch(() => {});
}
if (restoredFromRuntime) {
this.refreshLoadedViewsInBackground().catch(() => {});
}
if (restoredFromRuntime && restoredContext?.focusedItemUuid) {
this.refreshFocusedItemInBackground(restoredContext.focusedItemUuid).catch(() => {});
}
@@ -1047,6 +1092,7 @@ export function stockListPageData(store) {
locationMap: this.locationMap,
locationDescendants: this.locationDescendants,
locationLineage: this.locationLineage,
categoriesById: this.categoriesById,
changeCursor: this.changeCursor,
}),
};
@@ -1106,6 +1152,9 @@ export function stockListPageData(store) {
this.locationLineage = payload.locationLineage && typeof payload.locationLineage === 'object'
? payload.locationLineage
: {};
this.categoriesById = payload.categoriesById && typeof payload.categoriesById === 'object'
? payload.categoriesById
: {};
this.changeCursor = payload.changeCursor || this.changeCursor;
if (this.itemsLoaded) {
@@ -1267,7 +1316,7 @@ export function stockListPageData(store) {
},
indexEntry(entry) {
const indexed = { ...entry };
indexed._searchBlob = searchBlob(indexed, this.locationMap);
indexed._searchBlob = searchBlob(indexed, this.locationMap, this.categoriesById);
return indexed;
},
indexGroup(group) {
@@ -1278,7 +1327,7 @@ export function stockListPageData(store) {
...group,
items: indexedItems,
};
indexed._searchBlob = groupSearchBlob(indexed, this.locationMap);
indexed._searchBlob = groupSearchBlob(indexed, this.locationMap, this.categoriesById);
return indexed;
},
reindexSearchData() {
@@ -1309,7 +1358,7 @@ export function stockListPageData(store) {
if (mode === 'grouped') {
if (!this.groupedLoaded) {
await this.loadGroupedEntries({ expanded: 0, resetVisible: true });
await this.loadGroupedEntries({ expanded: false, resetVisible: true });
}
this.hydrateGroupedEntriesInBackground().catch(() => {});
return;
@@ -1325,7 +1374,7 @@ export function stockListPageData(store) {
: this.itemsLoaded);
if (this.viewMode === 'grouped') {
await this.loadGroupedEntries({ expanded: 0, background: useBackground });
await this.loadGroupedEntries({ expanded: false, background: useBackground });
this.hydrateGroupedEntriesInBackground({ force: true }).catch(() => {});
return;
}
@@ -1417,7 +1466,7 @@ export function stockListPageData(store) {
this.invalidateMemo();
this.persistRuntimeCache();
},
async loadGroupedEntries({ expanded = 1, background = false, resetVisible = false } = {}) {
async loadGroupedEntries({ expanded = true, background = false, resetVisible = false } = {}) {
if (!store.isConnected) {
return;
}
@@ -1432,7 +1481,7 @@ export function stockListPageData(store) {
try {
const loadedGroups = await listGroupedStockEntries(store, { expanded });
if (expanded === 0) {
if (!expanded) {
this.applyGroupedSummary(loadedGroups, { resetVisible });
return;
}
@@ -1461,7 +1510,7 @@ export function stockListPageData(store) {
this.groupedHydrating = true;
try {
await this.loadGroupedEntries({ expanded: 1, background: true });
await this.loadGroupedEntries({ expanded: true, background: true });
} finally {
this.groupedHydrating = false;
}
@@ -1473,7 +1522,7 @@ export function stockListPageData(store) {
}
if (this.groupedLoaded) {
tasks.push(
this.loadGroupedEntries({ expanded: 0, background: true }).then(() =>
this.loadGroupedEntries({ expanded: false, background: true }).then(() =>
this.hydrateGroupedEntriesInBackground({ force: true }),
),
);
@@ -1513,6 +1562,25 @@ export function stockListPageData(store) {
this.persistRuntimeCache();
}
},
async loadCategories() {
if (!store.isConnected) {
return;
}
try {
const categories = await listCategories(store, { expanded: true });
this.categoriesById = Object.fromEntries(
categories
.filter((category) => category?.id !== undefined && category?.id !== null)
.map((category) => [String(category.id), category]),
);
} catch {
this.categoriesById = {};
} finally {
this.reindexSearchData();
this.persistRuntimeCache();
}
},
resetGroupedVisibleLimit() {
this.groupedVisibleLimit = this.groupedPageSize;
},
@@ -1526,6 +1594,28 @@ export function stockListPageData(store) {
return group.items.filter((item) => !isGroupedChildStub(item));
},
mainCategoryLabel(entry) {
return resolveMainCategoryLabel(entry, this.categoriesById);
},
mainCategoryBadgeLabel(entry) {
return `Main category: ${this.mainCategoryLabel(entry)}`;
},
groupSummaryMetricLabel(group) {
if (group?.stock_type === 'measured') {
return 'Quantity';
}
if (group?.stock_type === 'descriptive') {
return 'Stock level';
}
if (group?.stock_type === 'binary') {
return 'Stock state';
}
return 'Stock';
},
groupSummaryMetricValue(group) {
// Use backend-provided grouped fields as the single source of truth.
return quantityLabel(group);
},
hasGroupedChildStubs(group) {
if (!Array.isArray(group?.items)) {
return false;
@@ -2269,7 +2359,7 @@ export function stockListPageData(store) {
? `${item.name} was already out of stock and removed from the group.`
: `${item.name} was ${actionLabel} and removed from the group.`,
});
this.loadGroupedEntries({ expanded: 0, background: true }).catch(() => {});
this.loadGroupedEntries({ expanded: false, background: true }).catch(() => {});
} catch (error) {
this.editErrors[item.id] = error.message || 'Removal failed.';
}
+67
View File
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const apiRequestMock = vi.fn();
vi.mock('../../src/api/client.js', () => ({
getPath(key) {
const paths = {
categories: 'kitchen/categories',
};
return paths[key];
},
apiRequest: (...args) => apiRequestMock(...args),
}));
const { listCategories } = await import('../../src/api/categories.js');
describe('api/categories', () => {
beforeEach(() => {
apiRequestMock.mockReset();
});
it('forwards explicit pagination and filters', async () => {
apiRequestMock.mockResolvedValueOnce([]);
await listCategories(
{ config: { database: 'db' } },
{ searchName: 'dairy', active: true, limit: 10, offset: 20, orderBy: 'name', orderDir: 'asc' },
);
expect(apiRequestMock).toHaveBeenCalledWith(
{ config: { database: 'db' } },
'kitchen/categories',
{
query: {
search_name: 'dairy',
active: true,
order_by: 'name',
order_dir: 'asc',
limit: 10,
offset: 20,
},
},
);
});
it('aggregates category pages by default', async () => {
apiRequestMock
.mockResolvedValueOnce(Array.from({ length: 100 }, (_, id) => ({ id: id + 1 })))
.mockResolvedValueOnce([{ id: 101 }]);
const response = await listCategories({ config: { database: 'db' } }, {});
expect(response).toHaveLength(101);
expect(apiRequestMock).toHaveBeenNthCalledWith(
1,
{ config: { database: 'db' } },
'kitchen/categories',
{ query: { limit: 100, offset: 0 } },
);
expect(apiRequestMock).toHaveBeenNthCalledWith(
2,
{ config: { database: 'db' } },
'kitchen/categories',
{ query: { limit: 100, offset: 100 } },
);
});
});
+4 -4
View File
@@ -61,12 +61,12 @@ describe('api/client', () => {
const url = buildKitchenApiUrl(store, 'kitchen/items/grouped', {
search_name: 'Milk + eggs',
expanded: 1,
expanded: true,
ignored: '',
});
expect(url).toBe(
'https://api.example.com/my%20db/kitchen/items/grouped?search_name=Milk+%2B+eggs&expanded=1',
'https://api.example.com/my%20db/kitchen/items/grouped?search_name=Milk+%2B+eggs&expanded=true',
);
});
@@ -88,14 +88,14 @@ describe('api/client', () => {
name: 'Rice',
},
query: {
label: 1,
label: true,
},
});
expect(payload).toEqual({ ok: true });
const [url, request] = fetchSpy.mock.calls[0];
expect(url).toBe('/kitchen-db/kitchen/items?label=1');
expect(url).toBe('/kitchen-db/kitchen/items?label=true');
expect(request.method).toBe('POST');
expect(request.body).toBe('{"name":"Rice"}');
expect(request.headers.get('Accept')).toBe('application/json');
+68 -2
View File
@@ -1,6 +1,72 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { formatPrintErrorMessage } from '../../src/api/labels.js';
const apiRequestMock = vi.fn();
vi.mock('../../src/api/client.js', () => ({
getPath(key) {
const paths = {
items: 'kitchen/items',
};
return paths[key];
},
apiRequest: (...args) => apiRequestMock(...args),
}));
const {
formatPrintErrorMessage,
getItemLabel,
previewLabel,
} = await import('../../src/api/labels.js');
describe('api/labels', () => {
beforeEach(() => {
apiRequestMock.mockReset();
});
it('previewLabel uses boolean label/preview query flags', async () => {
apiRequestMock.mockResolvedValueOnce({
label: 'YWJj',
});
const response = await previewLabel({ config: { database: 'db' } }, { name: 'Rice' });
expect(apiRequestMock).toHaveBeenCalledWith(
{ config: { database: 'db' } },
'kitchen/items',
{
method: 'POST',
body: { name: 'Rice' },
accept: 'image/svg+xml, image/png, application/json',
query: { label: true, preview: true },
},
);
expect(response).toEqual({
objectUrl: 'data:image/png;base64,YWJj',
contentType: 'image/png',
});
});
it('getItemLabel fetches PNG from /label endpoint', async () => {
apiRequestMock.mockResolvedValueOnce({
label: 'YWJj',
});
const response = await getItemLabel({ config: { database: 'db' } }, 'item-1');
expect(apiRequestMock).toHaveBeenCalledWith(
{ config: { database: 'db' } },
'kitchen/items/item-1/label',
{
method: 'GET',
accept: 'image/png, application/json',
},
);
expect(response).toEqual({
objectUrl: 'data:image/png;base64,YWJj',
contentType: 'image/png',
});
});
});
describe('api/labels formatPrintErrorMessage', () => {
it('maps printer_unavailable payload to user-friendly message', () => {
+34 -7
View File
@@ -20,6 +20,7 @@ const {
listGroupedStockEntries,
listKitchenChanges,
listStockEntries,
markStockGone,
lookupItemByIdentifier,
lookupItemDetails,
patchStockItem,
@@ -84,7 +85,7 @@ describe('api/stock', () => {
await listGroupedStockEntries(
{ config: { database: 'db' } },
{ expanded: 0, searchName: 'Rice', limit: 10, offset: 0 },
{ expanded: false, searchName: 'Rice', limit: 10, offset: 0 },
);
expect(apiRequestMock).toHaveBeenCalledWith(
@@ -92,7 +93,7 @@ describe('api/stock', () => {
'kitchen/items/grouped',
{
query: {
expanded: 0,
expanded: false,
search_name: 'Rice',
limit: 10,
offset: 0,
@@ -108,7 +109,7 @@ describe('api/stock', () => {
const response = await listGroupedStockEntries(
{ config: { database: 'db' } },
{ expanded: 1, searchName: 'Rice' },
{ expanded: true, searchName: 'Rice' },
);
expect(response).toHaveLength(101);
@@ -116,13 +117,13 @@ describe('api/stock', () => {
1,
{ config: { database: 'db' } },
'kitchen/items/grouped',
{ query: { expanded: 1, search_name: 'Rice', limit: 100, offset: 0 } },
{ query: { expanded: true, search_name: 'Rice', limit: 100, offset: 0 } },
);
expect(apiRequestMock).toHaveBeenNthCalledWith(
2,
{ config: { database: 'db' } },
'kitchen/items/grouped',
{ query: { expanded: 1, search_name: 'Rice', limit: 100, offset: 100 } },
{ query: { expanded: true, search_name: 'Rice', limit: 100, offset: 100 } },
);
});
@@ -151,7 +152,7 @@ describe('api/stock', () => {
expect(apiRequestMock).toHaveBeenCalledWith(
{ config: { database: 'db' } },
'kitchen/items/item-2',
{ query: { allow_inactive: 1 } },
{ query: { allow_inactive: true } },
);
});
@@ -297,7 +298,7 @@ describe('api/stock', () => {
expect(apiRequestMock).toHaveBeenCalledWith(
{ config: { database: 'db' } },
'kitchen/items/item-1/lookup',
{ method: 'POST', query: { update: 1 } },
{ method: 'POST', query: { update: true } },
);
expect(response).toEqual({
status: 'ok',
@@ -426,4 +427,30 @@ describe('api/stock', () => {
});
expect(apiRequestMock).toHaveBeenCalledTimes(1);
});
it('markStockGone uses /use endpoint for consumed reason', async () => {
apiRequestMock.mockResolvedValueOnce(null);
const result = await markStockGone({ config: { database: 'db' } }, 'item-1', 'consumed');
expect(result).toEqual({ status: 'gone', reason: 'consumed' });
expect(apiRequestMock).toHaveBeenCalledWith(
{ config: { database: 'db' } },
'kitchen/items/item-1/use',
{ method: 'POST' },
);
});
it('markStockGone uses /stock endpoint for non-consumed reasons', async () => {
apiRequestMock.mockResolvedValueOnce({ status: 'OK', stock: { id: 3 } });
const result = await markStockGone({ config: { database: 'db' } }, 'item-1', 'spoiled');
expect(result).toEqual({ status: 'gone', reason: 'spoiled' });
expect(apiRequestMock).toHaveBeenCalledWith(
{ config: { database: 'db' } },
'kitchen/items/item-1/stock',
{ method: 'POST', body: { level: 'gone', gone_reason: 'spoiled' } },
);
});
});
+1 -1
View File
@@ -130,6 +130,6 @@ describe('stock mark-gone behavior', () => {
message: 'Beans was marked used and removed from the group.',
});
expect(listGroupedStockEntriesMock).toHaveBeenCalledTimes(1);
expect(listGroupedStockEntriesMock).toHaveBeenCalledWith(store, { expanded: 0 });
expect(listGroupedStockEntriesMock).toHaveBeenCalledWith(store, { expanded: false });
});
});
@@ -7,6 +7,7 @@ const getStockEntryMock = vi.fn();
const updateStockItemMock = vi.fn();
const useStockItemMock = vi.fn();
const fetchLocationsMock = vi.fn();
const listCategoriesMock = vi.fn();
vi.mock('../../../src/api/stock.js', () => ({
listStockEntries: (...args) => listStockEntriesMock(...args),
@@ -21,6 +22,10 @@ vi.mock('../../../src/api/locations.js', () => ({
fetchLocations: (...args) => fetchLocationsMock(...args),
}));
vi.mock('../../../src/api/categories.js', () => ({
listCategories: (...args) => listCategoriesMock(...args),
}));
const { stockListPageData } = await import('../../../src/features/stock/stock-list-page.js');
function createGroupedSummary() {
@@ -112,6 +117,8 @@ describe('stock list grouped-first behavior', () => {
updateStockItemMock.mockReset();
useStockItemMock.mockReset();
fetchLocationsMock.mockReset();
listCategoriesMock.mockReset();
listCategoriesMock.mockResolvedValue([]);
globalThis.window = createWindowMock();
globalThis.requestAnimationFrame = (callback) => callback();
@@ -133,6 +140,7 @@ describe('stock list grouped-first behavior', () => {
listStockEntriesMock.mockResolvedValueOnce([]);
listKitchenChangesMock.mockResolvedValue({ since: null, nextCursor: null, changes: [] });
fetchLocationsMock.mockResolvedValue({ flat: [], tree: [] });
listCategoriesMock.mockResolvedValue([]);
const store = { isConnected: true, addAlert: vi.fn() };
const data = stockListPageData(store);
@@ -141,12 +149,12 @@ describe('stock list grouped-first behavior', () => {
await data.init();
expect(data.viewMode).toBe('grouped');
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: 0 });
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: false });
expect(listStockEntriesMock).not.toHaveBeenCalled();
await Promise.resolve();
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: 1 });
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: true });
await data.switchView('items');
expect(listStockEntriesMock).toHaveBeenCalledTimes(1);
@@ -162,7 +170,7 @@ describe('stock list grouped-first behavior', () => {
.mockResolvedValueOnce(createGroupedExpanded());
const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
await data.loadGroupedEntries({ expanded: 0, resetVisible: true });
await data.loadGroupedEntries({ expanded: false, resetVisible: true });
expect(data.groupDisplayItems(data.groupedEntries[0])).toEqual([]);
expect(data.hasGroupedChildStubs(data.groupedEntries[0])).toBe(true);
@@ -226,7 +234,7 @@ describe('stock list grouped-first behavior', () => {
data.groupedLoaded = true;
data.groupedEntries = createGroupedSummary().map((group) => data.indexGroup(group));
const pending = data.loadGroupedEntries({ expanded: 0, background: true });
const pending = data.loadGroupedEntries({ expanded: false, background: true });
expect(data.state.isRefreshing).toBe(true);
expect(data.groupedEntries).toHaveLength(1);
@@ -357,8 +365,10 @@ describe('stock list grouped-first behavior', () => {
await Promise.resolve();
await Promise.resolve();
expect(listGroupedStockEntriesMock).not.toHaveBeenCalled();
expect(listStockEntriesMock).not.toHaveBeenCalled();
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: false });
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: true });
expect(listStockEntriesMock).toHaveBeenCalledTimes(1);
expect(listStockEntriesMock).toHaveBeenCalledWith(store);
expect(fetchLocationsMock).not.toHaveBeenCalled();
expect(getStockEntryMock).toHaveBeenCalledWith(store, 'item-100');
expect(returnVisit.entries[0].quantity).toBe(2);