The list finally arrived from the reporting added earlier, and confirmed what #181 could only suspect: these are not the five eslint-plugin-sonarjs warnings that issue lists. Those were fixed under #261 and the count staying at five was a coincidence. It is six now, 25 minutes of debt, and one of them was mine. admin.ts imported '../utils' twice — I added readId in #207 without noticing the file already imported from there. One import now. filters.ts had a redundant `as ItemStatus[]`. TypeScript narrows an array through `.every()` with a type predicate from 5.5, and this project is on 5.9, so the assertion stopped telling the compiler anything. Removed, and the build confirms the narrowing holds without it. adminSettings.ts was the only CRITICAL: cognitive complexity 18 against a limit of 15, almost all of it three near-identical loops differing only in how they validated. Each validation is now a small pure reader returning a refusal rather than sending one, and the handler is one loop over a table. Adding a setting type means adding a row. That refactor is deliberately behaviour-preserving. Two things were left alone on purpose: the blanket rejection of empty text, which is wrong for the two settings whose documented default is empty and is filed as #280 rather than folded in where it would be invisible; and the absence of the `count` settings, which no caller submits and which the admin screen has no control for. I had started adding count validation and reverted it — widening behaviour under cover of a complexity fix is how a refactor stops being reviewable. The three S6478s are render props, not components defined during render. ErrorBoundary's `fallback` is typed `(error: Error) => React.ReactNode` and called as `this.props.fallback(...)`, so React only ever sees returned elements and never a new component type — the subtree destruction the rule describes does not happen, and the rule's own message offers `allowAsProps` for this shape, which cannot be set from here. Hoisted rather than suppressed because none of them closes over anything local, so at module level each is one stable function instead of a new closure per render. That is a mild improvement, not a contortion. Verified: 402 backend unit, 358 backend integration, 30 frontend unit, 157 e2e, both lints clean, both builds clean. The e2e run matters most here — storefront-errors.spec.ts exercises all three hoisted fallbacks, and it was run on its own first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
253 lines
11 KiB
TypeScript
253 lines
11 KiB
TypeScript
import type { Category } from './api';
|
||
|
||
export type ItemStatus = 'pending' | 'available' | 'reserved' | 'sold';
|
||
|
||
export interface ItemFilters {
|
||
// Several categories, combined as OR — picking Furniture and Decor means
|
||
// either, not the empty intersection. Deliberately the opposite of tagIds
|
||
// below, which is AND, and both controls label their rule so the difference
|
||
// is stated rather than discovered.
|
||
//
|
||
// The admin's inventory filter is single-select and holds a list of one; the
|
||
// type is shared, and one shape is better than two that drift.
|
||
categoryIds: number[];
|
||
tagIds: number[];
|
||
minPriceCents: number | null;
|
||
maxPriceCents: number | null;
|
||
// Several statuses, because the control this serves is not a status filter:
|
||
// "Not Sold" is available-or-reserved on the storefront and includes pending
|
||
// in the admin, neither of which is one value.
|
||
//
|
||
// Null means "no preference", which each side turns into its own default -
|
||
// Not Sold on the storefront, every status in the admin. Keeping the default
|
||
// as null rather than as an explicit list is what keeps it out of the URL and
|
||
// out of the active-filter count.
|
||
status: ItemStatus[] | null;
|
||
// Storefront only, and only meaningful when signed in. Sold favorites are
|
||
// included: the storefront shows sold items everywhere else, and a favorite
|
||
// that has just sold is often exactly what the customer came to look at.
|
||
favoritesOnly: boolean;
|
||
}
|
||
|
||
// The three-way control both screens offer. It is a preset over the status
|
||
// list rather than a filter of its own, so there is only ever one dimension and
|
||
// no way to express a contradiction like "sold and not sold".
|
||
export type SaleState = 'sold' | 'not-sold' | 'all';
|
||
|
||
// The same three words mean different sets in the two places, which is worth
|
||
// stating twice rather than sharing one table that would be wrong for one of
|
||
// them. Pending is excluded from every public read regardless of filter, so on
|
||
// the storefront "All" cannot and must not include it — a label promising more
|
||
// than it delivers.
|
||
export const STOREFRONT_SALE_STATUSES: Record<SaleState, ItemStatus[]> = {
|
||
'not-sold': ['available', 'reserved'],
|
||
sold: ['sold'],
|
||
all: ['available', 'reserved', 'sold']
|
||
};
|
||
|
||
// The admin had a table of its own here until #132, where the preset was
|
||
// replaced by a multi-select of the statuses themselves. Presets could not
|
||
// express Published or Unpublished and could not isolate a single status, and
|
||
// the admin is where those questions get asked. The storefront keeps its
|
||
// preset: pending never reaches a customer, so the distinction does not exist
|
||
// for them.
|
||
|
||
function isPublicStatus(value: string): value is ItemStatus {
|
||
return value === 'available' || value === 'reserved' || value === 'sold';
|
||
}
|
||
|
||
// Sorted with an explicit comparator rather than a bare .sort(). The default
|
||
// sorts by UTF-16 code unit, which is a perfectly good total order for the
|
||
// ASCII status values passed here — but the rule exists because that stops
|
||
// being true the moment a non-ASCII value appears, and a set comparison that
|
||
// silently depends on its inputs staying ASCII is not worth keeping.
|
||
const sameSet = (a: readonly string[], b: readonly string[]) =>
|
||
a.length === b.length &&
|
||
[...a].sort((x, y) => x.localeCompare(y)).join() ===
|
||
[...b].sort((x, y) => x.localeCompare(y)).join();
|
||
|
||
// Which preset a status list corresponds to, for showing the control's current
|
||
// position. Null means no preference, which each screen renders as its default.
|
||
// A list matching none of the three - only reachable by hand-editing the URL -
|
||
// reports as the default rather than leaving the control blank.
|
||
export function saleStateFromStatuses(
|
||
statuses: ItemStatus[] | null,
|
||
table: Record<SaleState, ItemStatus[]>,
|
||
fallback: SaleState = 'not-sold'
|
||
): SaleState {
|
||
if (statuses === null) return fallback;
|
||
const match = (Object.keys(table) as SaleState[]).find((state) =>
|
||
sameSet(statuses, table[state])
|
||
);
|
||
return match ?? fallback;
|
||
}
|
||
|
||
export const EMPTY_FILTERS: ItemFilters = {
|
||
categoryIds: [],
|
||
tagIds: [],
|
||
minPriceCents: null,
|
||
maxPriceCents: null,
|
||
status: null,
|
||
favoritesOnly: false
|
||
};
|
||
|
||
// Filters live in the URL so a filtered view can be linked, bookmarked, and
|
||
// walked back through with the browser's back button. The param names match
|
||
// what GET /api/items accepts, so the same object serializes for both.
|
||
export function filtersToSearchParams(filters: ItemFilters): URLSearchParams {
|
||
const params = new URLSearchParams();
|
||
// Comma-separated under the singular name it has always had, so a link
|
||
// written before this went multi-valued still means what it meant.
|
||
if (filters.categoryIds.length) params.set('category', filters.categoryIds.join(','));
|
||
if (filters.tagIds.length) params.set('tags', filters.tagIds.join(','));
|
||
if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents));
|
||
if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents));
|
||
if (filters.status !== null) params.set('status', filters.status.join(','));
|
||
if (filters.favoritesOnly) params.set('favorites', '1');
|
||
return params;
|
||
}
|
||
|
||
function readInt(raw: string | null): number | null {
|
||
if (raw === null || raw.trim() === '') return null;
|
||
const parsed = Number(raw);
|
||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||
}
|
||
|
||
export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
|
||
const categories = (params.get('category') || '')
|
||
.split(',')
|
||
.map((part) => readInt(part))
|
||
.filter((id): id is number => id !== null && id > 0);
|
||
|
||
const tags = (params.get('tags') || '')
|
||
.split(',')
|
||
.map((part) => readInt(part))
|
||
.filter((id): id is number => id !== null && id > 0);
|
||
|
||
// Deliberately does NOT accept 'pending', even though it is a valid
|
||
// ItemStatus. This reader exists for the storefront's URL, where filtering by
|
||
// pending is not a thing a customer may ask for — the public API refuses it
|
||
// outright, so parsing it here would only produce a request guaranteed to
|
||
// fail. The admin's status filter holds its value in React state and never
|
||
// round-trips through this function, so it is unaffected. Do not "complete"
|
||
// this list to match the type.
|
||
//
|
||
// A list containing anything unreadable yields null - the default - rather
|
||
// than the readable subset, so a mangled link falls back to a view that is
|
||
// explainable instead of one silently narrower than it looks.
|
||
const rawStatus = params.get('status');
|
||
const parsedStatus = (rawStatus || '')
|
||
.split(',')
|
||
.map((part) => part.trim())
|
||
.filter((part) => part !== '');
|
||
const status =
|
||
parsedStatus.length > 0 && parsedStatus.every(isPublicStatus)
|
||
? parsedStatus
|
||
: null;
|
||
|
||
const favorites = params.get('favorites');
|
||
|
||
return {
|
||
categoryIds: categories,
|
||
tagIds: tags,
|
||
minPriceCents: readInt(params.get('min_price')),
|
||
maxPriceCents: readInt(params.get('max_price')),
|
||
status,
|
||
favoritesOnly: favorites === '1' || favorites === 'true'
|
||
};
|
||
}
|
||
|
||
// Named individually rather than grouped, because grouping is what the preset
|
||
// this replaced did. Pending is listed first: "what is waiting to be published"
|
||
// is the question that prompted #132.
|
||
//
|
||
// Here rather than in the admin screen because the drawer and the active-filter
|
||
// chips both need to turn a status into a label, and a second copy of this list
|
||
// is a second place for a new status to be forgotten.
|
||
export const STATUS_OPTIONS: { value: ItemStatus; label: string }[] = [
|
||
{ value: 'pending', label: 'Pending' },
|
||
{ value: 'available', label: 'Available' },
|
||
{ value: 'reserved', label: 'Reserved' },
|
||
{ value: 'sold', label: 'Sold' }
|
||
];
|
||
|
||
export function statusLabel(status: ItemStatus): string {
|
||
return STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status;
|
||
}
|
||
|
||
export interface CategoryNode extends Category {
|
||
children: CategoryNode[];
|
||
}
|
||
|
||
// The API returns categories flat; the tree is rebuilt here so the drawer and
|
||
// the admin tab share one nesting implementation.
|
||
export function buildCategoryTree(categories: Category[]): CategoryNode[] {
|
||
const byId = new Map<number, CategoryNode>();
|
||
for (const category of categories) {
|
||
byId.set(category.id, { ...category, children: [] });
|
||
}
|
||
|
||
const roots: CategoryNode[] = [];
|
||
for (const node of byId.values()) {
|
||
const parent = node.parent_id === null ? undefined : byId.get(node.parent_id);
|
||
// A node whose parent is missing is treated as a root rather than dropped,
|
||
// so nothing can silently disappear from the tree.
|
||
if (parent) {
|
||
parent.children.push(node);
|
||
} else {
|
||
roots.push(node);
|
||
}
|
||
}
|
||
return roots;
|
||
}
|
||
|
||
/**
|
||
* A category tree in the shape antd's `TreeSelect` reads.
|
||
*
|
||
* Beside `buildCategoryTree` because it has the same property: one meaning, so
|
||
* one implementation. It lived in both `CategoryTreeSelect.tsx` and
|
||
* `FilterDrawer.tsx` verbatim after #139 copied it rather than sharing it, and
|
||
* two copies of a mapping is two places for a field to be renamed.
|
||
*
|
||
* `value` rather than `key`: a `TreeSelect` selects and searches by value,
|
||
* where an antd `Tree` identifies nodes by key. `Categories.tsx` builds a third
|
||
* shape for a real `Tree`, whose title is a React node carrying that screen's
|
||
* own buttons — genuinely different, and deliberately not folded in here.
|
||
*/
|
||
export interface CategoryTreeOption {
|
||
value: number;
|
||
title: string;
|
||
children?: CategoryTreeOption[];
|
||
}
|
||
|
||
export function toCategoryTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
||
return nodes.map((node) => ({
|
||
value: node.id,
|
||
title: node.name,
|
||
children: node.children.length ? toCategoryTreeData(node.children) : undefined
|
||
}));
|
||
}
|
||
|
||
// "Furniture / Tables / Coffee Tables" — used on chips and in the admin form so
|
||
// a leaf name like "Vintage" isn't ambiguous between branches.
|
||
export function categoryPath(categories: Category[], id: number): string {
|
||
const byId = new Map(categories.map((category) => [category.id, category]));
|
||
const parts: string[] = [];
|
||
let current = byId.get(id);
|
||
while (current) {
|
||
parts.unshift(current.name);
|
||
current = current.parent_id === null ? undefined : byId.get(current.parent_id);
|
||
// Guards against a cycle that somehow reached the client.
|
||
if (parts.length > 32) break;
|
||
}
|
||
return parts.join(' / ');
|
||
}
|
||
|
||
export function formatPriceRange(minCents: number | null, maxCents: number | null): string {
|
||
const dollars = (cents: number) => `$${(cents / 100).toFixed(0)}`;
|
||
if (minCents !== null && maxCents !== null) return `${dollars(minCents)}–${dollars(maxCents)}`;
|
||
if (minCents !== null) return `${dollars(minCents)}+`;
|
||
if (maxCents !== null) return `Up to ${dollars(maxCents)}`;
|
||
return '';
|
||
}
|