Files
redefined-designs/frontend/src/filters.ts
T
bermudalamb 61c12fd438
Linting / lint (pull_request) Successful in 1m58s
SonarQube Analysis / sonarqube (pull_request) Failing after 22m40s
refactor: remove the duplicated blocks SonarQube found (#182)
Three of the four candidates were real. The fourth was my mistake in the issue.

**The category tree adapter**, duplicated verbatim between `CategoryTreeSelect.tsx` and `FilterDrawer.tsx`. This one was mine: #139 moved the storefront filter to a `TreeSelect` and copied the admin's adapter rather than sharing it, with a comment saying the shape "matches the admin's CategoryTreeSelect so the two stay comparable" — an argument for one implementation that instead produced two. It now lives in `filters.ts` beside `buildCategoryTree`, which was already shared for exactly the same reason: one meaning, one implementation.

`Categories.tsx` keeps its own. It builds a different shape for a real antd `Tree`, keyed rather than valued, with a title that is a React node carrying that screen's buttons. Genuinely different, and folding it in would mean a parameterised adapter that serves neither case clearly.

**The `item_images` insert loop**, written separately by create and update and differing only in where the id came from and where the sort order started. Both are parameters now, which also means the `/uploads/` prefix is written once — #103 made that the value `uploadUrl` joins an origin onto, so it is a contract rather than a string.

Extracting it turned up two things the inline versions hid. Create indexed `files[i]?.filename ?? ''`, so a missing element would have stored a path pointing at the uploads directory itself; iterating by entry removes the possibility rather than defending against it. And the helper's typed `itemId` surfaced that `req.params.id` is `string | undefined` under `noUncheckedIndexedAccess`, which the old inline `unknown[]` swallowed — now `Number()`, as the `setItemTags` call two lines above already did.

**The optional-field guards**, eight identical lines opening both routes. The distinction worth preserving is that `undefined` means "not submitted", which update reads as "leave as-is", so an unparseable value has to be told apart from an absent one. That is what makes it more than a null check and worth stating once.

**`TAG_COLORS` was not a duplication.** The issue listed four files on the strength of a grep that also matched `STATUS_TAG_COLORS` in `Admin.tsx` — a status-to-colour map for the inventory table, unrelated to the tag palette. What remains is one definition in `backend/src/utils.ts` and one mirror in `frontend/src/admin/Tags.tsx`, already carrying a comment pointing at the other, which is the same treatment `ALLOWED_IMAGE_TYPES` gets and is correct: there is no shared package, and creating one for a colour list would cost more than it saves.

Verified beyond the type checker, since three of these are pure moves that compile either way: 278 unit and 254 integration tests, and the end-to-end specs covering both consumers of the shared adapter — the storefront drawer and the admin item form's category picker, including inline category creation.

Closes #182
2026-08-25 13:08:58 -05:00

271 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
}
const sameSet = (a: readonly string[], b: readonly string[]) =>
a.length === b.length && [...a].sort().join() === [...b].sort().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 as ItemStatus[])
: 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'
};
}
// One count for the "Filters (N)" button. A price range counts once however
// many ends are set, since it reads as a single filter to the user.
//
// Status is deliberately not counted. It has its own always-visible control
// beside this button rather than living in the drawer, so counting it would put
// a number on a button whose drawer shows nothing set — and the control already
// displays its own position.
// 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 function activeFilterCount(filters: ItemFilters): number {
let count = 0;
count += filters.categoryIds.length;
count += filters.tagIds.length;
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
if (filters.favoritesOnly) count++;
return count;
}
// Broader than the count above, and intentionally so: this decides whether an
// empty result reads as "no items match these filters" with a way out, or as an
// empty shop. A status filter that matched nothing is exactly the case where
// that distinction matters, so it counts here even though it is not in the
// drawer's tally.
export function hasActiveFilters(filters: ItemFilters): boolean {
return activeFilterCount(filters) > 0 || filters.status !== null;
}
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 '';
}