Align stock API with paginated backend and bump to v0.2.2
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -110,17 +110,29 @@ Reason:
|
|||||||
- grouped result is a better template for “new item from existing definition”
|
- grouped result is a better template for “new item from existing definition”
|
||||||
- not expanding children keeps the query lighter
|
- not expanding children keeps the query lighter
|
||||||
|
|
||||||
|
### List pagination
|
||||||
|
|
||||||
|
- `GET /{database}/kitchen/items` and `GET /{database}/kitchen/items/grouped` are paginated (`limit`/`offset`, backend default `limit=100`)
|
||||||
|
- frontend API helpers aggregate pages when no explicit `limit`/`offset` is requested
|
||||||
|
|
||||||
### Grouped stock view
|
### Grouped stock view
|
||||||
|
|
||||||
Grouped stock view uses:
|
Grouped stock view uses:
|
||||||
|
|
||||||
- `GET /{database}/kitchen/items/grouped?expanded=1`
|
- `GET /{database}/kitchen/items/grouped?expanded=0` for summary
|
||||||
|
- `GET /{database}/kitchen/items/grouped?expanded=1` for hydrated child details
|
||||||
|
|
||||||
Important:
|
Important:
|
||||||
|
|
||||||
- group-level fields are meaningful and should be used
|
- group-level fields are meaningful and should be used
|
||||||
- group expiration status should follow the backend-provided “first item expires” semantics
|
- group expiration status should follow the backend-provided “first item expires” semantics
|
||||||
- do not assume grouped child records are always returned unless `expanded=1`
|
- with `expanded=0`, grouped child `items` may be ID-only stubs (`{ id }`)
|
||||||
|
- do not assume grouped child records are fully returned unless `expanded=1`
|
||||||
|
|
||||||
|
### Stock updates
|
||||||
|
|
||||||
|
- `POST /{database}/kitchen/items/{uuid_b64}/stock` creates a stock event and returns `{ status, stock }`
|
||||||
|
- frontend refreshes item details with `GET /{database}/kitchen/items/{uuid_b64}` after stock updates
|
||||||
|
|
||||||
## UX rules that should be preserved
|
## UX rules that should be preserved
|
||||||
|
|
||||||
|
|||||||
@@ -164,9 +164,10 @@ Expected shapes today:
|
|||||||
- `GET /{database}/kitchen/items?search_name=...`
|
- `GET /{database}/kitchen/items?search_name=...`
|
||||||
Returns item definitions for autocomplete.
|
Returns item definitions for autocomplete.
|
||||||
- `GET /{database}/kitchen/items`
|
- `GET /{database}/kitchen/items`
|
||||||
Returns the current stock review list.
|
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`
|
- `GET /{database}/kitchen/items/grouped?expanded=0|1`
|
||||||
Returns grouped stock data; grouped review uses summary-first loading and hydrates item children in background.
|
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/{uuid_b64}`
|
- `GET /{database}/kitchen/items/{uuid_b64}`
|
||||||
Returns one item detail payload.
|
Returns one item detail payload.
|
||||||
- `GET /{database}/kitchen/changes`
|
- `GET /{database}/kitchen/changes`
|
||||||
@@ -182,13 +183,12 @@ Expected shapes today:
|
|||||||
- `POST /{database}/kitchen/items?label=1&preview=1`
|
- `POST /{database}/kitchen/items?label=1&preview=1`
|
||||||
Returns an image blob, `{ imageUrl }`, or `{ imageSvg }` for in-browser preview.
|
Returns an image blob, `{ imageUrl }`, or `{ imageSvg }` for in-browser preview.
|
||||||
- `POST /{database}/kitchen/items/{uuid_b64}/stock`
|
- `POST /{database}/kitchen/items/{uuid_b64}/stock`
|
||||||
Updates measured or descriptive stock state using `{ quantity }` or `{ level }`.
|
Creates a stock event for measured or descriptive updates using `{ quantity }` or `{ level }`.
|
||||||
|
Response shape is `{ status, stock }`; frontend re-fetches the item detail after successful update.
|
||||||
- `POST /{database}/kitchen/items/{uuid_b64}/use`
|
- `POST /{database}/kitchen/items/{uuid_b64}/use`
|
||||||
Marks an item used up (`gone`) via stock-event semantics.
|
Marks an item used up (`gone`) via stock-event semantics.
|
||||||
- `POST /{database}/kitchen/items/{uuid_b64}/print-label`
|
- `POST /{database}/kitchen/items/{uuid_b64}/print-label`
|
||||||
Prints label for an existing item; called from the save flow when `Print` is enabled.
|
Prints label for an existing item; called from the save flow when `Print` is enabled.
|
||||||
- `DELETE /{database}/kitchen/items/{uuid_b64}`
|
|
||||||
Compatibility fallback when `/use` is not available on the backend.
|
|
||||||
- `PATCH /{database}/kitchen/items/{uuid_b64}`
|
- `PATCH /{database}/kitchen/items/{uuid_b64}`
|
||||||
Used for item-level edits from stock detail (for example identifier code updates).
|
Used for item-level edits from stock detail (for example identifier code updates).
|
||||||
- `GET /{database}/kitchen/locations`
|
- `GET /{database}/kitchen/locations`
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lonc-web",
|
"name": "lonc-web",
|
||||||
"version": "0.2.0",
|
"version": "0.2.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lonc-web",
|
"name": "lonc-web",
|
||||||
"version": "0.2.0",
|
"version": "0.2.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zxing/browser": "^0.1.5",
|
"@zxing/browser": "^0.1.5",
|
||||||
"alpinejs": "^3.14.9",
|
"alpinejs": "^3.14.9",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lonc-web",
|
"name": "lonc-web",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+80
-45
@@ -1,9 +1,51 @@
|
|||||||
import { apiRequest, getPath } from './client.js';
|
import { apiRequest, getPath } from './client.js';
|
||||||
|
|
||||||
|
const DEFAULT_LIST_PAGE_LIMIT = 100;
|
||||||
|
|
||||||
function unwrapEntryPayload(payload) {
|
function unwrapEntryPayload(payload) {
|
||||||
return payload?.data || payload?.entry || payload?.item || payload;
|
return payload?.data || payload?.entry || payload?.item || payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unwrapListPayload(payload) {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload?.data || payload?.entries || payload?.items || payload?.groups || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExplicitPagination(filters = {}) {
|
||||||
|
return (
|
||||||
|
(filters.limit !== undefined && filters.limit !== null)
|
||||||
|
|| (filters.offset !== undefined && filters.offset !== null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllListPages(store, path, baseQuery = {}) {
|
||||||
|
const items = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const payload = await apiRequest(store, path, {
|
||||||
|
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 searchItemDefinitions(store, query) {
|
export async function searchItemDefinitions(store, query) {
|
||||||
if (query.trim().length <= 2) {
|
if (query.trim().length <= 2) {
|
||||||
return [];
|
return [];
|
||||||
@@ -21,59 +63,57 @@ export async function searchItemDefinitions(store, query) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listStockEntries(store, filters = {}) {
|
export async function listStockEntries(store, filters = {}) {
|
||||||
const query = {};
|
const baseQuery = {};
|
||||||
const searchName = filters.searchName || filters.search_name;
|
const searchName = filters.searchName || filters.search_name;
|
||||||
if (searchName) {
|
if (searchName) {
|
||||||
query.search_name = searchName;
|
baseQuery.search_name = searchName;
|
||||||
}
|
|
||||||
if (filters.limit !== undefined && filters.limit !== null) {
|
|
||||||
query.limit = filters.limit;
|
|
||||||
}
|
|
||||||
if (filters.offset !== undefined && filters.offset !== null) {
|
|
||||||
query.offset = filters.offset;
|
|
||||||
}
|
|
||||||
if (filters.cursor) {
|
|
||||||
query.cursor = filters.cursor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await apiRequest(store, getPath('items'), {
|
if (hasExplicitPagination(filters)) {
|
||||||
query,
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(payload)) {
|
const payload = await apiRequest(store, getPath('items'), {
|
||||||
return payload;
|
query,
|
||||||
|
});
|
||||||
|
|
||||||
|
return unwrapListPayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload?.data || payload?.entries || payload?.items || [];
|
return fetchAllListPages(store, getPath('items'), baseQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listGroupedStockEntries(store, options = {}) {
|
export async function listGroupedStockEntries(store, options = {}) {
|
||||||
const query = {};
|
const baseQuery = {};
|
||||||
const expanded = options.expanded ?? 1;
|
const expanded = options.expanded ?? 1;
|
||||||
query.expanded = expanded;
|
baseQuery.expanded = expanded;
|
||||||
const searchName = options.searchName || options.search_name;
|
const searchName = options.searchName || options.search_name;
|
||||||
if (searchName) {
|
if (searchName) {
|
||||||
query.search_name = searchName;
|
baseQuery.search_name = searchName;
|
||||||
}
|
|
||||||
if (options.limit !== undefined && options.limit !== null) {
|
|
||||||
query.limit = options.limit;
|
|
||||||
}
|
|
||||||
if (options.offset !== undefined && options.offset !== null) {
|
|
||||||
query.offset = options.offset;
|
|
||||||
}
|
|
||||||
if (options.cursor) {
|
|
||||||
query.cursor = options.cursor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
|
if (hasExplicitPagination(options)) {
|
||||||
query,
|
const query = { ...baseQuery };
|
||||||
});
|
if (options.limit !== undefined && options.limit !== null) {
|
||||||
|
query.limit = options.limit;
|
||||||
|
}
|
||||||
|
if (options.offset !== undefined && options.offset !== null) {
|
||||||
|
query.offset = options.offset;
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(payload)) {
|
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
|
||||||
return payload;
|
query,
|
||||||
|
});
|
||||||
|
|
||||||
|
return unwrapListPayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload?.data || payload?.entries || payload?.items || payload?.groups || [];
|
return fetchAllListPages(store, `${getPath('items')}/grouped`, baseQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStockEntry(store, stockId) {
|
export async function getStockEntry(store, stockId) {
|
||||||
@@ -183,11 +223,11 @@ export async function patchStockItem(store, uuidB64, body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateStockItem(store, uuidB64, body) {
|
export async function updateStockItem(store, uuidB64, body) {
|
||||||
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, {
|
await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
return unwrapEntryPayload(payload);
|
return getStockEntry(store, uuidB64);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteStockItem(store, uuidB64) {
|
export async function deleteStockItem(store, uuidB64) {
|
||||||
@@ -205,25 +245,20 @@ export async function useStockItem(store, uuidB64) {
|
|||||||
return { status: 'used' };
|
return { status: 'used' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error?.status || error?.cause?.status;
|
const status = error?.status || error?.cause?.status;
|
||||||
if (status === 409) {
|
if (status === 409 || status === 404) {
|
||||||
return { status: 'already_gone' };
|
return { status: 'already_gone' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 404 || status === 405) {
|
|
||||||
await deleteStockItem(store, uuidB64);
|
|
||||||
return { status: 'fallback_delete' };
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function adjustStockEntry(store, stockId, body) {
|
export async function adjustStockEntry(store, stockId, body) {
|
||||||
const payload = await apiRequest(store, `${getPath('items')}/${stockId}/stock`, {
|
await apiRequest(store, `${getPath('items')}/${stockId}/stock`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
return unwrapEntryPayload(payload);
|
return getStockEntry(store, stockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listKitchenChanges(store, { since, limit = 10 } = {}) {
|
export async function listKitchenChanges(store, { since, limit = 10 } = {}) {
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
export const APP_NAME = 'Lonc';
|
export const APP_NAME = 'Lonc';
|
||||||
export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.1';
|
export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.2';
|
||||||
export const TRYTON_APPLICATION = 'kitchen';
|
export const TRYTON_APPLICATION = 'kitchen';
|
||||||
|
|
||||||
export const CONNECTION_STATES = {
|
export const CONNECTION_STATES = {
|
||||||
|
|||||||
@@ -257,6 +257,23 @@ function groupedFirstExpireDate(group) {
|
|||||||
return group.first_expire_date || group.expire_date;
|
return group.first_expire_date || group.expire_date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGroupedChildStub(item) {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !(
|
||||||
|
'uuid_b64' in item
|
||||||
|
|| 'name' in item
|
||||||
|
|| 'stock_type' in item
|
||||||
|
|| 'date' in item
|
||||||
|
|| 'expire_date' in item
|
||||||
|
|| 'location_initial_uuid_b64' in item
|
||||||
|
|| 'quantity' in item
|
||||||
|
|| 'level' in item
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function shortDescription(value, maxLength = 24) {
|
function shortDescription(value, maxLength = 24) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return 'No description';
|
return 'No description';
|
||||||
@@ -744,9 +761,9 @@ export function renderStockListPage() {
|
|||||||
|
|
||||||
<template x-if="isGroupedCardOpen(group.id)">
|
<template x-if="isGroupedCardOpen(group.id)">
|
||||||
<div>
|
<div>
|
||||||
<template x-if="group.items?.length">
|
<template x-if="groupDisplayItems(group).length">
|
||||||
<div class="grouped-stock-items">
|
<div class="grouped-stock-items">
|
||||||
<template x-for="item in group.items" :key="item.id">
|
<template x-for="item in groupDisplayItems(group)" :key="item.id">
|
||||||
<div class="grouped-stock-item" :class="[groupedItemClass(item), isItemRefreshing(item) ? 'stock-item-refreshing' : '']">
|
<div class="grouped-stock-item" :class="[groupedItemClass(item), isItemRefreshing(item) ? 'stock-item-refreshing' : '']">
|
||||||
<div class="grouped-stock-item-row">
|
<div class="grouped-stock-item-row">
|
||||||
<div class="grouped-stock-item-main">
|
<div class="grouped-stock-item-main">
|
||||||
@@ -792,10 +809,10 @@ export function renderStockListPage() {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!group.items?.length && groupedHydrating">
|
<template x-if="!groupDisplayItems(group).length && (groupedHydrating || hasGroupedChildStubs(group))">
|
||||||
<div class="small text-body-secondary py-2">Loading grouped items...</div>
|
<div class="small text-body-secondary py-2">Loading grouped items...</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!group.items?.length && !groupedHydrating">
|
<template x-if="!groupDisplayItems(group).length && !groupedHydrating && !hasGroupedChildStubs(group)">
|
||||||
<div class="small text-body-secondary py-2">No items currently available in this group.</div>
|
<div class="small text-body-secondary py-2">No items currently available in this group.</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -1345,7 +1362,11 @@ export function stockListPageData(store) {
|
|||||||
const existingById = new Map(this.groupedEntries.map((group) => [group.id, group]));
|
const existingById = new Map(this.groupedEntries.map((group) => [group.id, group]));
|
||||||
const nextGroups = loadedGroups.map((group) => {
|
const nextGroups = loadedGroups.map((group) => {
|
||||||
const existing = existingById.get(group.id);
|
const existing = existingById.get(group.id);
|
||||||
const preservedItems = Array.isArray(group.items) ? group.items : existing?.items || [];
|
const incomingItems = Array.isArray(group.items) ? group.items : [];
|
||||||
|
const hasDetailedIncomingItems = incomingItems.some((item) => !isGroupedChildStub(item));
|
||||||
|
const preservedItems = hasDetailedIncomingItems
|
||||||
|
? incomingItems
|
||||||
|
: existing?.items || incomingItems;
|
||||||
return this.indexGroup({
|
return this.indexGroup({
|
||||||
...existing,
|
...existing,
|
||||||
...group,
|
...group,
|
||||||
@@ -1369,7 +1390,11 @@ export function stockListPageData(store) {
|
|||||||
const existingById = new Map(this.groupedEntries.map((group) => [group.id, group]));
|
const existingById = new Map(this.groupedEntries.map((group) => [group.id, group]));
|
||||||
const nextGroups = loadedGroups.map((group) => {
|
const nextGroups = loadedGroups.map((group) => {
|
||||||
const existing = existingById.get(group.id);
|
const existing = existingById.get(group.id);
|
||||||
const mergedItems = Array.isArray(group.items) ? group.items : existing?.items || [];
|
const incomingItems = Array.isArray(group.items) ? group.items : [];
|
||||||
|
const hasDetailedIncomingItems = incomingItems.some((item) => !isGroupedChildStub(item));
|
||||||
|
const mergedItems = hasDetailedIncomingItems
|
||||||
|
? incomingItems
|
||||||
|
: existing?.items || incomingItems;
|
||||||
return this.indexGroup({
|
return this.indexGroup({
|
||||||
...existing,
|
...existing,
|
||||||
...group,
|
...group,
|
||||||
@@ -1487,6 +1512,20 @@ export function stockListPageData(store) {
|
|||||||
showMoreGroups() {
|
showMoreGroups() {
|
||||||
this.groupedVisibleLimit += this.groupedPageSize;
|
this.groupedVisibleLimit += this.groupedPageSize;
|
||||||
},
|
},
|
||||||
|
groupDisplayItems(group) {
|
||||||
|
if (!Array.isArray(group?.items)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return group.items.filter((item) => !isGroupedChildStub(item));
|
||||||
|
},
|
||||||
|
hasGroupedChildStubs(group) {
|
||||||
|
if (!Array.isArray(group?.items)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return group.items.some((item) => isGroupedChildStub(item));
|
||||||
|
},
|
||||||
groupItemCount(group) {
|
groupItemCount(group) {
|
||||||
if (Number.isFinite(group.items_count)) {
|
if (Number.isFinite(group.items_count)) {
|
||||||
return group.items_count;
|
return group.items_count;
|
||||||
|
|||||||
+111
-18
@@ -14,14 +14,16 @@ vi.mock('../../src/api/client.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
adjustStockEntry,
|
||||||
applyItemUpsert,
|
applyItemUpsert,
|
||||||
listGroupedStockEntries,
|
listGroupedStockEntries,
|
||||||
|
listKitchenChanges,
|
||||||
listStockEntries,
|
listStockEntries,
|
||||||
lookupItemByIdentifier,
|
lookupItemByIdentifier,
|
||||||
lookupItemDetails,
|
lookupItemDetails,
|
||||||
listKitchenChanges,
|
|
||||||
patchStockItem,
|
patchStockItem,
|
||||||
previewItemUpsert,
|
previewItemUpsert,
|
||||||
|
updateStockItem,
|
||||||
useStockItem,
|
useStockItem,
|
||||||
} = await import('../../src/api/stock.js');
|
} = await import('../../src/api/stock.js');
|
||||||
|
|
||||||
@@ -30,12 +32,12 @@ describe('api/stock', () => {
|
|||||||
apiRequestMock.mockReset();
|
apiRequestMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('listStockEntries forwards optional query filters', async () => {
|
it('listStockEntries forwards explicit pagination query filters', async () => {
|
||||||
apiRequestMock.mockResolvedValueOnce([]);
|
apiRequestMock.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
await listStockEntries(
|
await listStockEntries(
|
||||||
{ config: { database: 'db' } },
|
{ config: { database: 'db' } },
|
||||||
{ searchName: 'Milk', limit: 20, offset: 40, cursor: 'cursor-1' },
|
{ searchName: 'Milk', limit: 20, offset: 40 },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
@@ -46,18 +48,42 @@ describe('api/stock', () => {
|
|||||||
search_name: 'Milk',
|
search_name: 'Milk',
|
||||||
limit: 20,
|
limit: 20,
|
||||||
offset: 40,
|
offset: 40,
|
||||||
cursor: 'cursor-1',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('listGroupedStockEntries defaults to expanded=1 and forwards options', async () => {
|
it('listStockEntries aggregates all pages by default', async () => {
|
||||||
|
apiRequestMock
|
||||||
|
.mockResolvedValueOnce(Array.from({ length: 100 }, (_, id) => ({ id: id + 1 })))
|
||||||
|
.mockResolvedValueOnce([{ id: 101 }]);
|
||||||
|
|
||||||
|
const response = await listStockEntries(
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
{ searchName: 'Milk' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response).toHaveLength(101);
|
||||||
|
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/items',
|
||||||
|
{ query: { search_name: 'Milk', limit: 100, offset: 0 } },
|
||||||
|
);
|
||||||
|
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/items',
|
||||||
|
{ query: { search_name: 'Milk', limit: 100, offset: 100 } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listGroupedStockEntries forwards explicit pagination options', async () => {
|
||||||
apiRequestMock.mockResolvedValueOnce([]);
|
apiRequestMock.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
await listGroupedStockEntries(
|
await listGroupedStockEntries(
|
||||||
{ config: { database: 'db' } },
|
{ config: { database: 'db' } },
|
||||||
{ expanded: 0, searchName: 'Rice', limit: 10, offset: 0, cursor: 'cursor-2' },
|
{ expanded: 0, searchName: 'Rice', limit: 10, offset: 0 },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
@@ -69,12 +95,36 @@ describe('api/stock', () => {
|
|||||||
search_name: 'Rice',
|
search_name: 'Rice',
|
||||||
limit: 10,
|
limit: 10,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
cursor: 'cursor-2',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('listGroupedStockEntries aggregates all pages by default', async () => {
|
||||||
|
apiRequestMock
|
||||||
|
.mockResolvedValueOnce(Array.from({ length: 100 }, (_, id) => ({ id: id + 1 })))
|
||||||
|
.mockResolvedValueOnce([{ id: 101 }]);
|
||||||
|
|
||||||
|
const response = await listGroupedStockEntries(
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
{ expanded: 1, searchName: 'Rice' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response).toHaveLength(101);
|
||||||
|
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/items/grouped',
|
||||||
|
{ query: { expanded: 1, 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 } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('listKitchenChanges returns normalized changes payload', async () => {
|
it('listKitchenChanges returns normalized changes payload', async () => {
|
||||||
apiRequestMock.mockResolvedValueOnce({
|
apiRequestMock.mockResolvedValueOnce({
|
||||||
since: 'cursor-1',
|
since: 'cursor-1',
|
||||||
@@ -257,6 +307,56 @@ describe('api/stock', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updateStockItem posts stock event and re-fetches updated item', async () => {
|
||||||
|
apiRequestMock
|
||||||
|
.mockResolvedValueOnce({ status: 'OK', stock: { id: 1 } })
|
||||||
|
.mockResolvedValueOnce({ uuid_b64: 'item-1', quantity: 2 });
|
||||||
|
|
||||||
|
const response = await updateStockItem(
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'item-1',
|
||||||
|
{ quantity: 2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/items/item-1/stock',
|
||||||
|
{ method: 'POST', body: { quantity: 2 } },
|
||||||
|
);
|
||||||
|
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/items/item-1',
|
||||||
|
);
|
||||||
|
expect(response).toEqual({ uuid_b64: 'item-1', quantity: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjustStockEntry posts stock event and re-fetches updated item', async () => {
|
||||||
|
apiRequestMock
|
||||||
|
.mockResolvedValueOnce({ status: 'OK', stock: { id: 2 } })
|
||||||
|
.mockResolvedValueOnce({ uuid_b64: 'item-1', level: 'good' });
|
||||||
|
|
||||||
|
const response = await adjustStockEntry(
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'item-1',
|
||||||
|
{ level: 'good' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/items/item-1/stock',
|
||||||
|
{ method: 'POST', body: { level: 'good' } },
|
||||||
|
);
|
||||||
|
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/items/item-1',
|
||||||
|
);
|
||||||
|
expect(response).toEqual({ uuid_b64: 'item-1', level: 'good' });
|
||||||
|
});
|
||||||
|
|
||||||
it('useStockItem returns used on 204', async () => {
|
it('useStockItem returns used on 204', async () => {
|
||||||
apiRequestMock.mockResolvedValueOnce(null);
|
apiRequestMock.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
@@ -278,20 +378,13 @@ describe('api/stock', () => {
|
|||||||
expect(result).toEqual({ status: 'already_gone' });
|
expect(result).toEqual({ status: 'already_gone' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('useStockItem falls back to delete on 404/405', async () => {
|
it('useStockItem returns already_gone on 404', async () => {
|
||||||
apiRequestMock
|
apiRequestMock.mockRejectedValueOnce({ status: 404 });
|
||||||
.mockRejectedValueOnce({ status: 404 })
|
|
||||||
.mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
const result = await useStockItem({ config: { database: 'db' } }, 'item-1');
|
const result = await useStockItem({ config: { database: 'db' } }, 'item-1');
|
||||||
|
|
||||||
expect(result).toEqual({ status: 'fallback_delete' });
|
expect(result).toEqual({ status: 'already_gone' });
|
||||||
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
expect(apiRequestMock).toHaveBeenCalledTimes(1);
|
||||||
2,
|
|
||||||
{ config: { database: 'db' } },
|
|
||||||
'kitchen/items/item-1',
|
|
||||||
{ method: 'DELETE' },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('useStockItem does not fallback on unrelated client errors', async () => {
|
it('useStockItem does not fallback on unrelated client errors', async () => {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function createGroupedSummary() {
|
|||||||
first_expire_date: '2026-04-25',
|
first_expire_date: '2026-04-25',
|
||||||
first_production_date: '2026-04-10',
|
first_production_date: '2026-04-10',
|
||||||
items_count: 1,
|
items_count: 1,
|
||||||
items: [],
|
items: [{ id: 100 }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -164,12 +164,27 @@ describe('stock list grouped-first behavior', () => {
|
|||||||
const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
|
const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
|
||||||
await data.loadGroupedEntries({ expanded: 0, resetVisible: true });
|
await data.loadGroupedEntries({ expanded: 0, resetVisible: true });
|
||||||
|
|
||||||
expect(data.groupedEntries[0].items).toEqual([]);
|
expect(data.groupDisplayItems(data.groupedEntries[0])).toEqual([]);
|
||||||
|
expect(data.hasGroupedChildStubs(data.groupedEntries[0])).toBe(true);
|
||||||
|
|
||||||
await data.hydrateGroupedEntriesInBackground();
|
await data.hydrateGroupedEntriesInBackground();
|
||||||
|
|
||||||
expect(data.groupedHydrated).toBe(true);
|
expect(data.groupedHydrated).toBe(true);
|
||||||
expect(data.groupedEntries[0].items).toHaveLength(1);
|
expect(data.groupDisplayItems(data.groupedEntries[0])).toHaveLength(1);
|
||||||
|
expect(data.hasGroupedChildStubs(data.groupedEntries[0])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves hydrated child details when summary refresh returns id stubs', async () => {
|
||||||
|
const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
|
||||||
|
|
||||||
|
data.applyGroupedSummary(createGroupedSummary());
|
||||||
|
data.applyGroupedHydration(createGroupedExpanded());
|
||||||
|
|
||||||
|
expect(data.groupDisplayItems(data.groupedEntries[0])).toHaveLength(1);
|
||||||
|
|
||||||
|
data.applyGroupedSummary(createGroupedSummary());
|
||||||
|
|
||||||
|
expect(data.groupDisplayItems(data.groupedEntries[0])).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('memoizes filtered results and invalidates when filters change', () => {
|
it('memoizes filtered results and invalidates when filters change', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user