Feature/categories and tags #24
@@ -9,3 +9,4 @@ coverage/
|
|||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
.env
|
.env
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
parent_id INTEGER REFERENCES categories(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Siblings can't share a name. Two partial indexes rather than one plain
|
||||||
|
-- unique constraint, because a NULL parent_id (a root category) compares
|
||||||
|
-- unequal to every other NULL and would let duplicate roots straight in.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS categories_child_name_uniq
|
||||||
|
ON categories (parent_id, lower(name)) WHERE parent_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS categories_root_name_uniq
|
||||||
|
ON categories (lower(name)) WHERE parent_id IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS categories_parent_id_idx ON categories (parent_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS tags_name_uniq ON tags (lower(name));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_tags (
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (item_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The item_id half is covered by the primary key; filtering by tag needs
|
||||||
|
-- the other direction.
|
||||||
|
CREATE INDEX IF NOT EXISTS item_tags_tag_id_idx ON item_tags (tag_id);
|
||||||
|
|
||||||
|
-- Deleting a category leaves its items in place and merely uncategorized,
|
||||||
|
-- rather than taking inventory down with it.
|
||||||
|
ALTER TABLE items ADD COLUMN IF NOT EXISTS category_id INTEGER
|
||||||
|
REFERENCES categories(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS items_category_id_idx ON items (category_id);
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unlike the baseline migration, this one is safe to reverse: it drops only
|
||||||
|
// what it created. Item rows themselves are untouched — they just lose their
|
||||||
|
// category assignment along with the column.
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
DROP INDEX IF EXISTS items_category_id_idx;
|
||||||
|
ALTER TABLE items DROP COLUMN IF EXISTS category_id;
|
||||||
|
DROP TABLE IF EXISTS item_tags;
|
||||||
|
DROP TABLE IF EXISTS tags;
|
||||||
|
DROP TABLE IF EXISTS categories;
|
||||||
|
`);
|
||||||
|
};
|
||||||
@@ -6,6 +6,9 @@ import { router as cartCheckoutRouter, webhookRouter as cartCheckoutWebhookRoute
|
|||||||
import adminRouter from './routes/admin';
|
import adminRouter from './routes/admin';
|
||||||
import adminCustomersRouter from './routes/adminCustomers';
|
import adminCustomersRouter from './routes/adminCustomers';
|
||||||
import adminSettingsRouter from './routes/adminSettings';
|
import adminSettingsRouter from './routes/adminSettings';
|
||||||
|
import adminCategoriesRouter from './routes/adminCategories';
|
||||||
|
import adminTagsRouter from './routes/adminTags';
|
||||||
|
import filtersRouter from './routes/filters';
|
||||||
import customersRouter from './routes/customers';
|
import customersRouter from './routes/customers';
|
||||||
import publicRouter from './routes/public';
|
import publicRouter from './routes/public';
|
||||||
import cartRouter from './routes/cart';
|
import cartRouter from './routes/cart';
|
||||||
@@ -35,10 +38,13 @@ app.get('/api/config', (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use('/api/items', itemsRouter);
|
app.use('/api/items', itemsRouter);
|
||||||
|
app.use('/api/filters', filtersRouter);
|
||||||
app.use('/api/cart', cartRouter);
|
app.use('/api/cart', cartRouter);
|
||||||
app.use('/api/checkout/cart', cartCheckoutRouter);
|
app.use('/api/checkout/cart', cartCheckoutRouter);
|
||||||
app.use('/api/admin/customers', adminCustomersRouter);
|
app.use('/api/admin/customers', adminCustomersRouter);
|
||||||
app.use('/api/admin/settings', adminSettingsRouter);
|
app.use('/api/admin/settings', adminSettingsRouter);
|
||||||
|
app.use('/api/admin/categories', adminCategoriesRouter);
|
||||||
|
app.use('/api/admin/tags', adminTagsRouter);
|
||||||
app.use('/api/admin', adminRouter);
|
app.use('/api/admin', adminRouter);
|
||||||
app.use('/api/customers/me/addresses', shippingAddressesRouter);
|
app.use('/api/customers/me/addresses', shippingAddressesRouter);
|
||||||
app.use('/api/customers', customersRouter);
|
app.use('/api/customers', customersRouter);
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// Parsing and SQL construction for the storefront's category / tag / price
|
||||||
|
// filters. Kept apart from the route so the rules can be unit-tested without a
|
||||||
|
// database, and so items.ts stays a thin handler.
|
||||||
|
|
||||||
|
export class FilterError extends Error {}
|
||||||
|
|
||||||
|
export interface ItemFilters {
|
||||||
|
categoryId: number | null;
|
||||||
|
tagIds: number[];
|
||||||
|
minPriceCents: number | null;
|
||||||
|
maxPriceCents: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuiltFilter {
|
||||||
|
clauses: string[];
|
||||||
|
params: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliberately excludes a leading sign and any decimal point: every filter
|
||||||
|
// value is a non-negative integer (an id, or a price in cents), so '-1' and
|
||||||
|
// '10.5' are caller mistakes worth surfacing rather than silently coercing.
|
||||||
|
const NON_NEGATIVE_INTEGER = /^\d+$/;
|
||||||
|
|
||||||
|
function singleValue(value: unknown, name: string): string | null {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Express parses `?category=1&category=2` into an array. Picking one silently
|
||||||
|
// would make a malformed link look like it worked, so refuse it instead.
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
throw new FilterError(`${name} may only be given once`);
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new FilterError(`invalid ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNonNegativeInteger(raw: string, name: string): number {
|
||||||
|
if (!NON_NEGATIVE_INTEGER.test(raw)) {
|
||||||
|
throw new FilterError(`invalid ${name}`);
|
||||||
|
}
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (!Number.isSafeInteger(parsed)) {
|
||||||
|
throw new FilterError(`invalid ${name}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseId(raw: string, name: string): number {
|
||||||
|
const parsed = parseNonNegativeInteger(raw, name);
|
||||||
|
if (parsed < 1) {
|
||||||
|
throw new FilterError(`invalid ${name}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePrice(value: unknown, name: string): number | null {
|
||||||
|
const raw = singleValue(value, name);
|
||||||
|
if (raw === null || raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parseNonNegativeInteger(raw, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
|
||||||
|
const categoryRaw = singleValue(query.category, 'category');
|
||||||
|
const categoryId = categoryRaw === null || categoryRaw === '' ? null : parseId(categoryRaw, 'category');
|
||||||
|
|
||||||
|
const tagsRaw = singleValue(query.tags, 'tags');
|
||||||
|
const tagIds: number[] = [];
|
||||||
|
if (tagsRaw) {
|
||||||
|
for (const part of tagsRaw.split(',')) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = parseId(trimmed, 'tags');
|
||||||
|
// Duplicates would inflate the required-match count below and make the
|
||||||
|
// filter match nothing at all.
|
||||||
|
if (!tagIds.includes(id)) {
|
||||||
|
tagIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minPriceCents = parsePrice(query.min_price, 'min_price');
|
||||||
|
const maxPriceCents = parsePrice(query.max_price, 'max_price');
|
||||||
|
if (minPriceCents !== null && maxPriceCents !== null && minPriceCents > maxPriceCents) {
|
||||||
|
throw new FilterError('min_price may not exceed max_price');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { categoryId, tagIds, minPriceCents, maxPriceCents };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns WHERE fragments plus their parameters, with placeholders numbered
|
||||||
|
// from `startIndex` so the caller can splice these in after its own params.
|
||||||
|
export function buildItemFilterSql(filters: ItemFilters, startIndex: number): BuiltFilter {
|
||||||
|
const clauses: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let next = startIndex;
|
||||||
|
|
||||||
|
if (filters.categoryId !== null) {
|
||||||
|
params.push(filters.categoryId);
|
||||||
|
// Selecting a category means "and everything filed beneath it", so walk the
|
||||||
|
// tree down from the chosen node. A recursive CTE keeps the tree
|
||||||
|
// un-denormalized: reparenting stays a single UPDATE with no stored paths
|
||||||
|
// to rewrite.
|
||||||
|
clauses.push(`i.category_id IN (
|
||||||
|
WITH RECURSIVE subtree AS (
|
||||||
|
SELECT id FROM categories WHERE id = $${next}
|
||||||
|
UNION ALL
|
||||||
|
SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id
|
||||||
|
)
|
||||||
|
SELECT id FROM subtree
|
||||||
|
)`);
|
||||||
|
next++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.tagIds.length) {
|
||||||
|
params.push(filters.tagIds, filters.tagIds.length);
|
||||||
|
// AND, not OR: the item must carry every selected tag. Matching with
|
||||||
|
// `tag_id = ANY(...)` alone would return items holding just one of them, so
|
||||||
|
// the count of matched rows has to equal the number requested.
|
||||||
|
clauses.push(
|
||||||
|
`(SELECT COUNT(*) FROM item_tags it
|
||||||
|
WHERE it.item_id = i.id AND it.tag_id = ANY($${next}::int[])) = $${next + 1}`
|
||||||
|
);
|
||||||
|
next += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.minPriceCents !== null) {
|
||||||
|
params.push(filters.minPriceCents);
|
||||||
|
clauses.push(`i.price_cents >= $${next}`);
|
||||||
|
next++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.maxPriceCents !== null) {
|
||||||
|
params.push(filters.maxPriceCents);
|
||||||
|
clauses.push(`i.price_cents <= $${next}`);
|
||||||
|
next++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clauses, params };
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Shared item SELECT shapes for the public and admin routes.
|
||||||
|
//
|
||||||
|
// Images and tags are pulled as scalar subqueries rather than LEFT JOIN +
|
||||||
|
// GROUP BY. Joining two one-to-many relations in the same query multiplies
|
||||||
|
// their rows together — an item with 2 images and 3 tags would aggregate 6
|
||||||
|
// rows, silently repeating every image three times. Subqueries keep each
|
||||||
|
// aggregate independent and drop the GROUP BY entirely.
|
||||||
|
|
||||||
|
const IMAGES_SUBQUERY = `
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order)
|
||||||
|
ORDER BY img.sort_order)
|
||||||
|
FROM item_images img
|
||||||
|
WHERE img.item_id = i.id
|
||||||
|
), '[]') AS images`;
|
||||||
|
|
||||||
|
const TAGS_SUBQUERY = `
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'color', t.color) ORDER BY t.name)
|
||||||
|
FROM item_tags it
|
||||||
|
JOIN tags t ON t.id = it.tag_id
|
||||||
|
WHERE it.item_id = i.id
|
||||||
|
), '[]') AS tags`;
|
||||||
|
|
||||||
|
const FROM_CLAUSE = `
|
||||||
|
FROM items i
|
||||||
|
LEFT JOIN categories c ON c.id = i.category_id`;
|
||||||
|
|
||||||
|
// The storefront gets an explicit column list — it has no business seeing
|
||||||
|
// paypal_order_id or reserved_until.
|
||||||
|
export const PUBLIC_ITEM_SELECT = `
|
||||||
|
SELECT i.id, i.name, i.description, i.price_cents, i.status, i.created_at, i.category_id,
|
||||||
|
c.name AS category_name,
|
||||||
|
${IMAGES_SUBQUERY},
|
||||||
|
${TAGS_SUBQUERY}
|
||||||
|
${FROM_CLAUSE}`;
|
||||||
|
|
||||||
|
export const ADMIN_ITEM_SELECT = `
|
||||||
|
SELECT i.*,
|
||||||
|
c.name AS category_name,
|
||||||
|
${IMAGES_SUBQUERY},
|
||||||
|
${TAGS_SUBQUERY}
|
||||||
|
${FROM_CLAUSE}`;
|
||||||
+94
-15
@@ -2,7 +2,10 @@ import { Router, Request, Response, NextFunction } from 'express';
|
|||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { PoolClient } from 'pg';
|
||||||
import { pool } from '../db';
|
import { pool } from '../db';
|
||||||
|
import { ADMIN_ITEM_SELECT } from '../itemSelect';
|
||||||
|
import { tagColorFor } from '../utils';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -52,31 +55,86 @@ const uploadImages = (req: Request, res: Response, next: NextFunction) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const SELECT_WITH_IMAGES = `
|
// The multipart body carries category_id and tags as text fields. An absent
|
||||||
SELECT i.*,
|
// field means "leave as-is" on update, which is why these return undefined
|
||||||
COALESCE(
|
// rather than null for a missing value.
|
||||||
json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order)
|
function readCategoryId(value: unknown): number | null | undefined {
|
||||||
ORDER BY img.sort_order) FILTER (WHERE img.id IS NOT NULL),
|
if (value === undefined) return undefined;
|
||||||
'[]'
|
if (value === null || value === '') return null;
|
||||||
) AS images
|
const parsed = Number(value);
|
||||||
FROM items i
|
if (!Number.isSafeInteger(parsed) || parsed < 1) return undefined;
|
||||||
LEFT JOIN item_images img ON img.item_id = i.id
|
return parsed;
|
||||||
`;
|
}
|
||||||
|
|
||||||
|
function readTagNames(value: unknown): string[] | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (typeof value !== 'string' || value.trim() === '') return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (!Array.isArray(parsed)) return undefined;
|
||||||
|
return parsed.filter((name): name is string => typeof name === 'string');
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags typed into the item form may not exist yet. Look each name up
|
||||||
|
// case-insensitively — matching the unique index — and create the missing ones
|
||||||
|
// inside the caller's transaction so a later failure rolls them back too.
|
||||||
|
async function resolveTagIds(client: PoolClient, names: string[]): Promise<number[]> {
|
||||||
|
const ids: number[] = [];
|
||||||
|
for (const raw of names) {
|
||||||
|
const name = raw.trim();
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
const existing = await client.query(`SELECT id FROM tags WHERE lower(name) = lower($1)`, [name]);
|
||||||
|
if (existing.rows.length) {
|
||||||
|
if (!ids.includes(existing.rows[0].id)) ids.push(existing.rows[0].id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await client.query(
|
||||||
|
`INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id`,
|
||||||
|
[name, tagColorFor(name)]
|
||||||
|
);
|
||||||
|
ids.push(inserted.rows[0].id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags are replaced wholesale rather than merged — the form submits the full
|
||||||
|
// set it wants, so removing a chip has to actually remove the row.
|
||||||
|
async function setItemTags(client: PoolClient, itemId: number, tagIds: number[]): Promise<void> {
|
||||||
|
await client.query(`DELETE FROM item_tags WHERE item_id = $1`, [itemId]);
|
||||||
|
for (const tagId of tagIds) {
|
||||||
|
await client.query(`INSERT INTO item_tags (item_id, tag_id) VALUES ($1, $2)`, [itemId, tagId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/items', async (_req: Request, res: Response) => {
|
router.get('/items', async (_req: Request, res: Response) => {
|
||||||
const { rows } = await pool.query(`${SELECT_WITH_IMAGES} GROUP BY i.id ORDER BY i.created_at DESC`);
|
const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ORDER BY i.created_at DESC`);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/items', uploadImages, async (req: Request, res: Response) => {
|
router.post('/items', uploadImages, async (req: Request, res: Response) => {
|
||||||
const { name, description, price } = req.body;
|
const { name, description, price } = req.body;
|
||||||
|
|
||||||
|
const categoryId = readCategoryId(req.body.category_id);
|
||||||
|
if (categoryId === undefined && req.body.category_id !== undefined) {
|
||||||
|
return res.status(400).json({ error: 'invalid category_id' });
|
||||||
|
}
|
||||||
|
const tagNames = readTagNames(req.body.tags);
|
||||||
|
if (tagNames === undefined && req.body.tags !== undefined) {
|
||||||
|
return res.status(400).json({ error: 'invalid tags' });
|
||||||
|
}
|
||||||
|
|
||||||
const files = (req.files as Express.Multer.File[]) || [];
|
const files = (req.files as Express.Multer.File[]) || [];
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
const { rows } = await client.query(
|
const { rows } = await client.query(
|
||||||
`INSERT INTO items (name, description, price_cents) VALUES ($1, $2, $3) RETURNING *`,
|
`INSERT INTO items (name, description, price_cents, category_id) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||||
[name, description, Math.round(parseFloat(price) * 100)]
|
[name, description, Math.round(parseFloat(price) * 100), categoryId ?? null]
|
||||||
);
|
);
|
||||||
const item = rows[0];
|
const item = rows[0];
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
@@ -85,8 +143,11 @@ router.post('/items', uploadImages, async (req: Request, res: Response) => {
|
|||||||
[item.id, `/uploads/${files[i].filename}`, i]
|
[item.id, `/uploads/${files[i].filename}`, i]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (tagNames) {
|
||||||
|
await setItemTags(client, item.id, await resolveTagIds(client, tagNames));
|
||||||
|
}
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
const { rows: full } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [item.id]);
|
const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id]);
|
||||||
res.json(full[0]);
|
res.json(full[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
@@ -99,6 +160,16 @@ router.post('/items', uploadImages, async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
router.put('/items/:id', uploadImages, async (req: Request, res: Response) => {
|
router.put('/items/:id', uploadImages, async (req: Request, res: Response) => {
|
||||||
const { name, description, price } = req.body;
|
const { name, description, price } = req.body;
|
||||||
|
|
||||||
|
const categoryId = readCategoryId(req.body.category_id);
|
||||||
|
if (categoryId === undefined && req.body.category_id !== undefined) {
|
||||||
|
return res.status(400).json({ error: 'invalid category_id' });
|
||||||
|
}
|
||||||
|
const tagNames = readTagNames(req.body.tags);
|
||||||
|
if (tagNames === undefined && req.body.tags !== undefined) {
|
||||||
|
return res.status(400).json({ error: 'invalid tags' });
|
||||||
|
}
|
||||||
|
|
||||||
const files = (req.files as Express.Multer.File[]) || [];
|
const files = (req.files as Express.Multer.File[]) || [];
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -107,6 +178,14 @@ router.put('/items/:id', uploadImages, async (req: Request, res: Response) => {
|
|||||||
`UPDATE items SET name=$1, description=$2, price_cents=$3 WHERE id=$4`,
|
`UPDATE items SET name=$1, description=$2, price_cents=$3 WHERE id=$4`,
|
||||||
[name, description, Math.round(parseFloat(price) * 100), req.params.id]
|
[name, description, Math.round(parseFloat(price) * 100), req.params.id]
|
||||||
);
|
);
|
||||||
|
// Only touch the category when the field was actually submitted, so a
|
||||||
|
// caller that omits it doesn't silently uncategorize the item.
|
||||||
|
if (categoryId !== undefined) {
|
||||||
|
await client.query(`UPDATE items SET category_id=$1 WHERE id=$2`, [categoryId, req.params.id]);
|
||||||
|
}
|
||||||
|
if (tagNames) {
|
||||||
|
await setItemTags(client, Number(req.params.id), await resolveTagIds(client, tagNames));
|
||||||
|
}
|
||||||
if (files.length) {
|
if (files.length) {
|
||||||
const { rows: existing } = await client.query(
|
const { rows: existing } = await client.query(
|
||||||
`SELECT COALESCE(MAX(sort_order), -1) AS max_sort FROM item_images WHERE item_id = $1`,
|
`SELECT COALESCE(MAX(sort_order), -1) AS max_sort FROM item_images WHERE item_id = $1`,
|
||||||
@@ -121,7 +200,7 @@ router.put('/items/:id', uploadImages, async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
const { rows: full } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [req.params.id]);
|
const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]);
|
||||||
res.json(full[0]);
|
res.json(full[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { pool } from '../db';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Postgres unique-violation SQLSTATE — raised by the two partial indexes that
|
||||||
|
// stop siblings sharing a name.
|
||||||
|
const UNIQUE_VIOLATION = '23505';
|
||||||
|
|
||||||
|
// Walks down from a node, collecting it and every descendant. Used both for
|
||||||
|
// cycle detection on reparent and for reporting the blast radius of a delete.
|
||||||
|
const SUBTREE_CTE = `
|
||||||
|
WITH RECURSIVE subtree AS (
|
||||||
|
SELECT id FROM categories WHERE id = $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id
|
||||||
|
)`;
|
||||||
|
|
||||||
|
function readName(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed === '' ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinguishes "not supplied" from "explicitly cleared to root".
|
||||||
|
function readParentId(value: unknown): number | null | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (value === null || value === '') return null;
|
||||||
|
const parsed = typeof value === 'number' ? value : Number(value);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < 1) return undefined;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parentExists(id: number): Promise<boolean> {
|
||||||
|
const { rows } = await pool.query(`SELECT 1 FROM categories WHERE id = $1`, [id]);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT c.id, c.name, c.parent_id, c.sort_order,
|
||||||
|
(SELECT COUNT(*)::int FROM items i WHERE i.category_id = c.id) AS item_count
|
||||||
|
FROM categories c
|
||||||
|
ORDER BY c.sort_order, lower(c.name)`
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
|
const name = readName(req.body.name);
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({ error: 'name is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentId = readParentId(req.body.parent_id);
|
||||||
|
if (parentId === undefined && req.body.parent_id !== undefined) {
|
||||||
|
return res.status(400).json({ error: 'invalid parent_id' });
|
||||||
|
}
|
||||||
|
const parent = parentId ?? null;
|
||||||
|
if (parent !== null && !(await parentExists(parent))) {
|
||||||
|
return res.status(400).json({ error: 'parent category does not exist' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOrder = Number.isSafeInteger(req.body.sort_order) ? req.body.sort_order : 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO categories (name, parent_id, sort_order) VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, name, parent_id, sort_order`,
|
||||||
|
[name, parent, sortOrder]
|
||||||
|
);
|
||||||
|
res.status(201).json({ ...rows[0], item_count: 0 });
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { code?: string }).code === UNIQUE_VIOLATION) {
|
||||||
|
return res.status(409).json({ error: 'a category with that name already exists here' });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', async (req: Request, res: Response) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const existing = await pool.query(`SELECT id, name, parent_id, sort_order FROM categories WHERE id = $1`, [id]);
|
||||||
|
if (!existing.rows.length) {
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = existing.rows[0].name;
|
||||||
|
if (req.body.name !== undefined) {
|
||||||
|
const parsed = readName(req.body.name);
|
||||||
|
if (!parsed) {
|
||||||
|
return res.status(400).json({ error: 'name is required' });
|
||||||
|
}
|
||||||
|
name = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent = existing.rows[0].parent_id;
|
||||||
|
if (req.body.parent_id !== undefined) {
|
||||||
|
const parsed = readParentId(req.body.parent_id);
|
||||||
|
if (parsed === undefined) {
|
||||||
|
return res.status(400).json({ error: 'invalid parent_id' });
|
||||||
|
}
|
||||||
|
if (parsed !== null) {
|
||||||
|
if (!(await parentExists(parsed))) {
|
||||||
|
return res.status(400).json({ error: 'parent category does not exist' });
|
||||||
|
}
|
||||||
|
// Moving a node beneath itself or one of its own descendants would
|
||||||
|
// detach that whole branch from the tree into an unreachable cycle.
|
||||||
|
const { rows: cycle } = await pool.query(
|
||||||
|
`${SUBTREE_CTE} SELECT 1 FROM subtree WHERE id = $2`,
|
||||||
|
[id, parsed]
|
||||||
|
);
|
||||||
|
if (cycle.length) {
|
||||||
|
return res.status(400).json({ error: 'a category cannot be moved beneath itself' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parent = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOrder = Number.isSafeInteger(req.body.sort_order)
|
||||||
|
? req.body.sort_order
|
||||||
|
: existing.rows[0].sort_order;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE categories SET name = $1, parent_id = $2, sort_order = $3 WHERE id = $4
|
||||||
|
RETURNING id, name, parent_id, sort_order`,
|
||||||
|
[name, parent, sortOrder, id]
|
||||||
|
);
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { code?: string }).code === UNIQUE_VIOLATION) {
|
||||||
|
return res.status(409).json({ error: 'a category with that name already exists here' });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const { rows: subtree } = await pool.query(`${SUBTREE_CTE} SELECT id FROM subtree`, [id]);
|
||||||
|
if (!subtree.length) {
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = subtree.map((row: { id: number }) => row.id);
|
||||||
|
const { rows: affected } = await pool.query(
|
||||||
|
`SELECT COUNT(*)::int AS n FROM items WHERE category_id = ANY($1::int[])`,
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
|
|
||||||
|
// The FK cascade takes the descendants; items fall back to NULL rather than
|
||||||
|
// being deleted along with their category.
|
||||||
|
await pool.query(`DELETE FROM categories WHERE id = $1`, [id]);
|
||||||
|
|
||||||
|
res.json({ deleted_categories: ids.length, uncategorized_items: affected[0].n });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { pool } from '../db';
|
||||||
|
import { TAG_COLORS, tagColorFor } from '../utils';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const UNIQUE_VIOLATION = '23505';
|
||||||
|
|
||||||
|
function readName(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed === '' ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colours are constrained to the antd preset palette the frontend can actually
|
||||||
|
// render — an arbitrary string would come out as an unstyled grey chip.
|
||||||
|
function readColor(value: unknown): string | null | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (typeof value !== 'string' || !TAG_COLORS.includes(value)) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT t.id, t.name, t.color,
|
||||||
|
(SELECT COUNT(*)::int FROM item_tags it WHERE it.tag_id = t.id) AS item_count
|
||||||
|
FROM tags t
|
||||||
|
ORDER BY lower(t.name)`
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
|
const name = readName(req.body.name);
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({ error: 'name is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedColor = readColor(req.body.color);
|
||||||
|
if (requestedColor === null) {
|
||||||
|
return res.status(400).json({ error: 'invalid color' });
|
||||||
|
}
|
||||||
|
const color = requestedColor ?? tagColorFor(name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id, name, color`,
|
||||||
|
[name, color]
|
||||||
|
);
|
||||||
|
res.status(201).json({ ...rows[0], item_count: 0 });
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { code?: string }).code === UNIQUE_VIOLATION) {
|
||||||
|
return res.status(409).json({ error: 'a tag with that name already exists' });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', async (req: Request, res: Response) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const existing = await pool.query(`SELECT id, name, color FROM tags WHERE id = $1`, [id]);
|
||||||
|
if (!existing.rows.length) {
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = existing.rows[0].name;
|
||||||
|
if (req.body.name !== undefined) {
|
||||||
|
const parsed = readName(req.body.name);
|
||||||
|
if (!parsed) {
|
||||||
|
return res.status(400).json({ error: 'name is required' });
|
||||||
|
}
|
||||||
|
name = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
let color = existing.rows[0].color;
|
||||||
|
if (req.body.color !== undefined) {
|
||||||
|
const parsed = readColor(req.body.color);
|
||||||
|
if (!parsed) {
|
||||||
|
return res.status(400).json({ error: 'invalid color' });
|
||||||
|
}
|
||||||
|
color = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE tags SET name = $1, color = $2 WHERE id = $3 RETURNING id, name, color`,
|
||||||
|
[name, color, id]
|
||||||
|
);
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { code?: string }).code === UNIQUE_VIOLATION) {
|
||||||
|
return res.status(409).json({ error: 'a tag with that name already exists' });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (req: Request, res: Response) => {
|
||||||
|
// item_tags cascades; the items themselves are untouched.
|
||||||
|
await pool.query(`DELETE FROM tags WHERE id = $1`, [req.params.id]);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { pool } from '../db';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Everything the storefront's filter drawer needs, in one request: the whole
|
||||||
|
// category tree (flat — the frontend nests it), every tag with its colour, and
|
||||||
|
// the catalogue's price bounds for the slider.
|
||||||
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
|
const [categories, tags, price] = await Promise.all([
|
||||||
|
pool.query(
|
||||||
|
`SELECT id, name, parent_id, sort_order FROM categories ORDER BY sort_order, lower(name)`
|
||||||
|
),
|
||||||
|
pool.query(
|
||||||
|
`SELECT t.id, t.name, t.color, COUNT(it.item_id)::int AS item_count
|
||||||
|
FROM tags t
|
||||||
|
LEFT JOIN item_tags it ON it.tag_id = t.id
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY lower(t.name)`
|
||||||
|
),
|
||||||
|
// An empty catalogue would otherwise hand the slider a null range.
|
||||||
|
pool.query(
|
||||||
|
`SELECT COALESCE(MIN(price_cents), 0)::int AS min_cents,
|
||||||
|
COALESCE(MAX(price_cents), 0)::int AS max_cents
|
||||||
|
FROM items`
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
categories: categories.rows,
|
||||||
|
tags: tags.rows,
|
||||||
|
priceRange: price.rows[0]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
+18
-13
@@ -1,26 +1,31 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { pool } from '../db';
|
import { pool } from '../db';
|
||||||
|
import { PUBLIC_ITEM_SELECT } from '../itemSelect';
|
||||||
|
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const SELECT_WITH_IMAGES = `
|
router.get('/', async (req: Request, res: Response) => {
|
||||||
SELECT i.id, i.name, i.description, i.price_cents, i.status, i.created_at,
|
let filters;
|
||||||
COALESCE(
|
try {
|
||||||
json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order)
|
filters = parseItemFilters(req.query as Record<string, unknown>);
|
||||||
ORDER BY img.sort_order) FILTER (WHERE img.id IS NOT NULL),
|
} catch (err) {
|
||||||
'[]'
|
// A malformed filter is returned as an error rather than ignored, so a
|
||||||
) AS images
|
// broken link shows itself instead of quietly listing the whole catalogue.
|
||||||
FROM items i
|
if (err instanceof FilterError) {
|
||||||
LEFT JOIN item_images img ON img.item_id = i.id
|
return res.status(400).json({ error: err.message });
|
||||||
`;
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/', async (_req: Request, res: Response) => {
|
const { clauses, params } = buildItemFilterSql(filters, 1);
|
||||||
const { rows } = await pool.query(`${SELECT_WITH_IMAGES} GROUP BY i.id ORDER BY i.created_at DESC`);
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||||
|
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:id', async (req: Request, res: Response) => {
|
router.get('/:id', async (req: Request, res: Response) => {
|
||||||
const { rows } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [req.params.id]);
|
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]);
|
||||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||||
res.json(rows[0]);
|
res.json(rows[0]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,5 +40,29 @@ export function isValidEmail(email: string): boolean {
|
|||||||
return labels.length >= 2 && labels.every((label) => DOMAIN_LABEL_RE.test(label));
|
return labels.length >= 2 && labels.every((label) => DOMAIN_LABEL_RE.test(label));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// antd's preset Tag colours. Kept as the single source of truth for tag
|
||||||
|
// colours so the admin palette picker and the auto-assignment below can never
|
||||||
|
// drift apart — the frontend renders whatever string lands in tags.color.
|
||||||
|
export const TAG_COLORS = [
|
||||||
|
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
|
||||||
|
'green', 'cyan', 'blue', 'geekblue', 'purple'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Tags get a colour the moment they're created inline from the item form, with
|
||||||
|
// no prompt. Deriving it from the name (rather than picking at random or
|
||||||
|
// round-robining on insert order) means the same tag name always lands on the
|
||||||
|
// same colour, so a tag deleted and re-added doesn't silently change colour.
|
||||||
|
// The admin can still override it afterwards.
|
||||||
|
export function tagColorFor(name: string): string {
|
||||||
|
const normalized = name.trim().toLowerCase();
|
||||||
|
// djb2 — cheap, well-spread for short strings, and stable across Node
|
||||||
|
// versions. `| 0` keeps it in int32 range instead of drifting into float.
|
||||||
|
let hash = 5381;
|
||||||
|
for (let i = 0; i < normalized.length; i++) {
|
||||||
|
hash = ((hash << 5) + hash + normalized.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
export const MARKETING_CONSENT_TEXT =
|
export const MARKETING_CONSENT_TEXT =
|
||||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||||
|
|||||||
@@ -0,0 +1,452 @@
|
|||||||
|
import request from 'supertest';
|
||||||
|
import app from '../../src/app';
|
||||||
|
import { pool } from '../../src/db';
|
||||||
|
import { resetDb, closeDb } from './setup/testDb';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await pool.end();
|
||||||
|
await closeDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createCategory(name: string, parentId: number | null = null): Promise<number> {
|
||||||
|
const res = await request(app).post('/api/admin/categories').send({ name, parent_id: parentId });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
return res.body.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTag(name: string): Promise<number> {
|
||||||
|
const res = await request(app).post('/api/admin/tags').send({ name });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
return res.body.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createItem(
|
||||||
|
name: string,
|
||||||
|
priceCents: number,
|
||||||
|
categoryId: number | null = null,
|
||||||
|
tagIds: number[] = []
|
||||||
|
): Promise<number> {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO items (name, price_cents, category_id) VALUES ($1, $2, $3) RETURNING id`,
|
||||||
|
[name, priceCents, categoryId]
|
||||||
|
);
|
||||||
|
const itemId = rows[0].id;
|
||||||
|
for (const tagId of tagIds) {
|
||||||
|
await pool.query(`INSERT INTO item_tags (item_id, tag_id) VALUES ($1, $2)`, [itemId, tagId]);
|
||||||
|
}
|
||||||
|
return itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin categories', () => {
|
||||||
|
it('creates a root category', async () => {
|
||||||
|
const res = await request(app).post('/api/admin/categories').send({ name: 'Furniture' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.name).toBe('Furniture');
|
||||||
|
expect(res.body.parent_id).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a nested category', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
const res = await request(app).post('/api/admin/categories').send({ name: 'Tables', parent_id: furniture });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.parent_id).toBe(furniture);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects two root categories with the same name', async () => {
|
||||||
|
await createCategory('Furniture');
|
||||||
|
const res = await request(app).post('/api/admin/categories').send({ name: 'furniture' });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects two siblings with the same name', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
await createCategory('Tables', furniture);
|
||||||
|
const res = await request(app).post('/api/admin/categories').send({ name: 'Tables', parent_id: furniture });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the same name under different parents', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
const decor = await createCategory('Decor');
|
||||||
|
await createCategory('Vintage', furniture);
|
||||||
|
const res = await request(app).post('/api/admin/categories').send({ name: 'Vintage', parent_id: decor });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a category with a blank name', async () => {
|
||||||
|
const res = await request(app).post('/api/admin/categories').send({ name: ' ' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a parent that does not exist', async () => {
|
||||||
|
const res = await request(app).post('/api/admin/categories').send({ name: 'Orphan', parent_id: 9999 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists categories with the number of items in each', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
await createItem('Chair', 1000, furniture);
|
||||||
|
await createItem('Stool', 2000, furniture);
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/admin/categories');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const found = res.body.find((c: { id: number }) => c.id === furniture);
|
||||||
|
expect(found.item_count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renames a category', async () => {
|
||||||
|
const id = await createCategory('Furnature');
|
||||||
|
const res = await request(app).put(`/api/admin/categories/${id}`).send({ name: 'Furniture' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.name).toBe('Furniture');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reparents a category', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
const tables = await createCategory('Tables');
|
||||||
|
const res = await request(app).put(`/api/admin/categories/${tables}`).send({ parent_id: furniture });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.parent_id).toBe(furniture);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to make a category its own parent', async () => {
|
||||||
|
const id = await createCategory('Furniture');
|
||||||
|
const res = await request(app).put(`/api/admin/categories/${id}`).send({ parent_id: id });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to move a category beneath its own descendant', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
const tables = await createCategory('Tables', furniture);
|
||||||
|
const coffee = await createCategory('Coffee Tables', tables);
|
||||||
|
|
||||||
|
const res = await request(app).put(`/api/admin/categories/${furniture}`).send({ parent_id: coffee });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
|
||||||
|
// The tree must be untouched after a rejected move.
|
||||||
|
const check = await pool.query(`SELECT parent_id FROM categories WHERE id = $1`, [furniture]);
|
||||||
|
expect(check.rows[0].parent_id).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a category and reports what it affected', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
const tables = await createCategory('Tables', furniture);
|
||||||
|
await createItem('Chair', 1000, furniture);
|
||||||
|
await createItem('Coffee table', 2000, tables);
|
||||||
|
|
||||||
|
const res = await request(app).delete(`/api/admin/categories/${furniture}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.deleted_categories).toBe(2);
|
||||||
|
expect(res.body.uncategorized_items).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps items when their category is deleted, merely uncategorizing them', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
const tables = await createCategory('Tables', furniture);
|
||||||
|
const itemId = await createItem('Coffee table', 2000, tables);
|
||||||
|
|
||||||
|
await request(app).delete(`/api/admin/categories/${furniture}`);
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/items/${itemId}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.category_id).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin tags', () => {
|
||||||
|
it('creates a tag with an auto-assigned colour', async () => {
|
||||||
|
const res = await request(app).post('/api/admin/tags').send({ name: 'vintage' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.name).toBe('vintage');
|
||||||
|
expect(typeof res.body.color).toBe('string');
|
||||||
|
expect(res.body.color.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours an explicit colour on create', async () => {
|
||||||
|
const res = await request(app).post('/api/admin/tags').send({ name: 'vintage', color: 'purple' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.color).toBe('purple');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a colour outside the palette', async () => {
|
||||||
|
const res = await request(app).post('/api/admin/tags').send({ name: 'vintage', color: 'chartreuse' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a duplicate tag name regardless of case', async () => {
|
||||||
|
await createTag('vintage');
|
||||||
|
const res = await request(app).post('/api/admin/tags').send({ name: 'VINTAGE' });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a blank tag name', async () => {
|
||||||
|
const res = await request(app).post('/api/admin/tags').send({ name: ' ' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists tags with the number of items carrying each', async () => {
|
||||||
|
const vintage = await createTag('vintage');
|
||||||
|
await createTag('unused');
|
||||||
|
await createItem('Chair', 1000, null, [vintage]);
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/admin/tags');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const found = res.body.find((t: { id: number }) => t.id === vintage);
|
||||||
|
expect(found.item_count).toBe(1);
|
||||||
|
const unused = res.body.find((t: { name: string }) => t.name === 'unused');
|
||||||
|
expect(unused.item_count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overrides a tag colour', async () => {
|
||||||
|
const id = await createTag('vintage');
|
||||||
|
const res = await request(app).put(`/api/admin/tags/${id}`).send({ color: 'geekblue' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.color).toBe('geekblue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renames a tag', async () => {
|
||||||
|
const id = await createTag('vintge');
|
||||||
|
const res = await request(app).put(`/api/admin/tags/${id}`).send({ name: 'vintage' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.name).toBe('vintage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleting a tag detaches it from items without deleting them', async () => {
|
||||||
|
const vintage = await createTag('vintage');
|
||||||
|
const itemId = await createItem('Chair', 1000, null, [vintage]);
|
||||||
|
|
||||||
|
const res = await request(app).delete(`/api/admin/tags/${vintage}`);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
|
||||||
|
const item = await request(app).get(`/api/items/${itemId}`);
|
||||||
|
expect(item.status).toBe(200);
|
||||||
|
expect(item.body.tags).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('admin item form', () => {
|
||||||
|
it('saves a category and creates unknown tags on the fly', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/admin/items')
|
||||||
|
.field('name', 'Oak table')
|
||||||
|
.field('description', '')
|
||||||
|
.field('price', '340')
|
||||||
|
.field('category_id', String(furniture))
|
||||||
|
.field('tags', JSON.stringify(['vintage', 'oak']));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.category_id).toBe(furniture);
|
||||||
|
expect(res.body.tags.map((t: { name: string }) => t.name).sort()).toEqual(['oak', 'vintage']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses an existing tag rather than duplicating it', async () => {
|
||||||
|
await createTag('vintage');
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/admin/items')
|
||||||
|
.field('name', 'Oak table')
|
||||||
|
.field('description', '')
|
||||||
|
.field('price', '340')
|
||||||
|
.field('tags', JSON.stringify(['VINTAGE']));
|
||||||
|
|
||||||
|
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM tags`);
|
||||||
|
expect(rows[0].n).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces an item\'s tags on update rather than appending', async () => {
|
||||||
|
const create = await request(app)
|
||||||
|
.post('/api/admin/items')
|
||||||
|
.field('name', 'Oak table')
|
||||||
|
.field('description', '')
|
||||||
|
.field('price', '340')
|
||||||
|
.field('tags', JSON.stringify(['vintage', 'oak']));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/admin/items/${create.body.id}`)
|
||||||
|
.field('name', 'Oak table')
|
||||||
|
.field('description', '')
|
||||||
|
.field('price', '340')
|
||||||
|
.field('tags', JSON.stringify(['oak']));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.tags.map((t: { name: string }) => t.name)).toEqual(['oak']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the category when an empty category_id is submitted', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
const create = await request(app)
|
||||||
|
.post('/api/admin/items')
|
||||||
|
.field('name', 'Oak table')
|
||||||
|
.field('description', '')
|
||||||
|
.field('price', '340')
|
||||||
|
.field('category_id', String(furniture));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/admin/items/${create.body.id}`)
|
||||||
|
.field('name', 'Oak table')
|
||||||
|
.field('description', '')
|
||||||
|
.field('price', '340')
|
||||||
|
.field('category_id', '');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.category_id).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns each image exactly once for an item that also has tags', async () => {
|
||||||
|
const create = await request(app)
|
||||||
|
.post('/api/admin/items')
|
||||||
|
.field('name', 'Oak table')
|
||||||
|
.field('description', '')
|
||||||
|
.field('price', '340')
|
||||||
|
.field('tags', JSON.stringify(['vintage', 'oak', 'restored']));
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, '/uploads/a.jpg', 0), ($1, '/uploads/b.jpg', 1)`,
|
||||||
|
[create.body.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/items/${create.body.id}`);
|
||||||
|
expect(res.body.images).toHaveLength(2);
|
||||||
|
expect(res.body.tags).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/filters', () => {
|
||||||
|
it('returns the category tree, tags, and the catalogue price range', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
await createCategory('Tables', furniture);
|
||||||
|
await createTag('vintage');
|
||||||
|
await createItem('Cheap', 1200);
|
||||||
|
await createItem('Dear', 80000);
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/filters');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.categories).toHaveLength(2);
|
||||||
|
expect(res.body.tags).toHaveLength(1);
|
||||||
|
expect(res.body.priceRange).toEqual({ min_cents: 1200, max_cents: 80000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a zeroed price range for an empty catalogue', async () => {
|
||||||
|
const res = await request(app).get('/api/filters');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.priceRange).toEqual({ min_cents: 0, max_cents: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/items filtering', () => {
|
||||||
|
it('returns every item when nothing is filtered', async () => {
|
||||||
|
await createItem('A', 1000);
|
||||||
|
await createItem('B', 2000);
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/items');
|
||||||
|
expect(res.body).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a category and all of its descendants', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
const tables = await createCategory('Tables', furniture);
|
||||||
|
const coffee = await createCategory('Coffee Tables', tables);
|
||||||
|
const decor = await createCategory('Decor');
|
||||||
|
|
||||||
|
await createItem('Deep', 1000, coffee);
|
||||||
|
await createItem('Mid', 1000, tables);
|
||||||
|
await createItem('Top', 1000, furniture);
|
||||||
|
await createItem('Elsewhere', 1000, decor);
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/items?category=${furniture}`);
|
||||||
|
expect(res.body.map((i: { name: string }) => i.name).sort()).toEqual(['Deep', 'Mid', 'Top']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes uncategorized items from a category filter', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
await createItem('Filed', 1000, furniture);
|
||||||
|
await createItem('Loose', 1000, null);
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/items?category=${furniture}`);
|
||||||
|
expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Filed']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires every listed tag, not merely one of them', async () => {
|
||||||
|
const vintage = await createTag('vintage');
|
||||||
|
const oak = await createTag('oak');
|
||||||
|
|
||||||
|
await createItem('Both', 1000, null, [vintage, oak]);
|
||||||
|
await createItem('Only vintage', 1000, null, [vintage]);
|
||||||
|
await createItem('Only oak', 1000, null, [oak]);
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/items?tags=${vintage},${oak}`);
|
||||||
|
expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Both']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches items carrying extra tags beyond those selected', async () => {
|
||||||
|
const vintage = await createTag('vintage');
|
||||||
|
const oak = await createTag('oak');
|
||||||
|
const rare = await createTag('rare');
|
||||||
|
await createItem('Three tags', 1000, null, [vintage, oak, rare]);
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/items?tags=${vintage},${oak}`);
|
||||||
|
expect(res.body).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds the price range inclusively', async () => {
|
||||||
|
await createItem('Under', 900);
|
||||||
|
await createItem('Low edge', 1000);
|
||||||
|
await createItem('Middle', 3000);
|
||||||
|
await createItem('High edge', 5000);
|
||||||
|
await createItem('Over', 5100);
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/items?min_price=1000&max_price=5000');
|
||||||
|
expect(res.body.map((i: { name: string }) => i.name).sort()).toEqual(['High edge', 'Low edge', 'Middle']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines category, tags, and price with AND', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
const tables = await createCategory('Tables', furniture);
|
||||||
|
const vintage = await createTag('vintage');
|
||||||
|
|
||||||
|
await createItem('Match', 3000, tables, [vintage]);
|
||||||
|
await createItem('Wrong category', 3000, null, [vintage]);
|
||||||
|
await createItem('Wrong tag', 3000, tables, []);
|
||||||
|
await createItem('Wrong price', 9000, tables, [vintage]);
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/items?category=${furniture}&tags=${vintage}&min_price=1000&max_price=5000`);
|
||||||
|
expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Match']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes each item\'s tags and category name in the response', async () => {
|
||||||
|
const furniture = await createCategory('Furniture');
|
||||||
|
const vintage = await createTag('vintage');
|
||||||
|
await createItem('Chair', 1000, furniture, [vintage]);
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/items');
|
||||||
|
expect(res.body[0].category_name).toBe('Furniture');
|
||||||
|
expect(res.body[0].tags[0].name).toBe('vintage');
|
||||||
|
expect(res.body[0].tags[0].color).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed category rather than silently returning everything', async () => {
|
||||||
|
await createItem('A', 1000);
|
||||||
|
const res = await request(app).get('/api/items?category=furniture');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an inverted price range', async () => {
|
||||||
|
const res = await request(app).get('/api/items?min_price=5000&max_price=1000');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty list for a category that matches nothing', async () => {
|
||||||
|
const empty = await createCategory('Empty');
|
||||||
|
await createItem('A', 1000);
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/items?category=${empty}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,7 +29,8 @@ export async function migrate(): Promise<void> {
|
|||||||
export async function resetDb(): Promise<void> {
|
export async function resetDb(): Promise<void> {
|
||||||
await testPool.query(`
|
await testPool.query(`
|
||||||
TRUNCATE TABLE orders, checkout_items, checkouts, shipping_addresses, cart_items, carts,
|
TRUNCATE TABLE orders, checkout_items, checkouts, shipping_addresses, cart_items, carts,
|
||||||
customer_tokens, customer_sessions, customers, item_images, items
|
customer_tokens, customer_sessions, customers, item_tags, item_images, items,
|
||||||
|
tags, categories
|
||||||
RESTART IDENTITY CASCADE
|
RESTART IDENTITY CASCADE
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { parseItemFilters, FilterError, buildItemFilterSql } from '../../src/itemFilters';
|
||||||
|
|
||||||
|
describe('parseItemFilters', () => {
|
||||||
|
it('returns empty filters for an empty query', () => {
|
||||||
|
expect(parseItemFilters({})).toEqual({
|
||||||
|
categoryId: null,
|
||||||
|
tagIds: [],
|
||||||
|
minPriceCents: null,
|
||||||
|
maxPriceCents: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a category id', () => {
|
||||||
|
expect(parseItemFilters({ category: '7' }).categoryId).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a comma-separated tag list', () => {
|
||||||
|
expect(parseItemFilters({ tags: '3,1,2' }).tagIds).toEqual([3, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses duplicate tag ids', () => {
|
||||||
|
expect(parseItemFilters({ tags: '2,2,5' }).tagIds).toEqual([2, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an empty tag list as no tag filter', () => {
|
||||||
|
expect(parseItemFilters({ tags: '' }).tagIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses an inclusive price range in cents', () => {
|
||||||
|
const filters = parseItemFilters({ min_price: '1000', max_price: '5000' });
|
||||||
|
expect(filters.minPriceCents).toBe(1000);
|
||||||
|
expect(filters.maxPriceCents).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a price range open at one end', () => {
|
||||||
|
expect(parseItemFilters({ min_price: '1000' }).maxPriceCents).toBeNull();
|
||||||
|
expect(parseItemFilters({ max_price: '5000' }).minPriceCents).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a zero minimum price', () => {
|
||||||
|
expect(parseItemFilters({ min_price: '0' }).minPriceCents).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-numeric category', () => {
|
||||||
|
expect(() => parseItemFilters({ category: 'furniture' })).toThrow(FilterError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a category id below 1', () => {
|
||||||
|
expect(() => parseItemFilters({ category: '0' })).toThrow(FilterError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-numeric tag id', () => {
|
||||||
|
expect(() => parseItemFilters({ tags: '1,vintage' })).toThrow(FilterError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a negative price', () => {
|
||||||
|
expect(() => parseItemFilters({ min_price: '-1' })).toThrow(FilterError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a fractional price', () => {
|
||||||
|
expect(() => parseItemFilters({ max_price: '10.5' })).toThrow(FilterError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an inverted price range', () => {
|
||||||
|
expect(() => parseItemFilters({ min_price: '5000', max_price: '1000' })).toThrow(FilterError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a price range where both ends are equal', () => {
|
||||||
|
expect(() => parseItemFilters({ min_price: '1000', max_price: '1000' })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a repeated query param rather than guessing which one to use', () => {
|
||||||
|
expect(() => parseItemFilters({ category: ['1', '2'] })).toThrow(FilterError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildItemFilterSql', () => {
|
||||||
|
it('produces no clauses and no params when nothing is filtered', () => {
|
||||||
|
const built = buildItemFilterSql(parseItemFilters({}), 1);
|
||||||
|
expect(built.clauses).toEqual([]);
|
||||||
|
expect(built.params).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a category and all of its descendants', () => {
|
||||||
|
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 1);
|
||||||
|
expect(built.clauses.join(' ')).toContain('RECURSIVE');
|
||||||
|
expect(built.params).toEqual([4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires every listed tag rather than any of them', () => {
|
||||||
|
const built = buildItemFilterSql(parseItemFilters({ tags: '1,2' }), 1);
|
||||||
|
// The count of matched tag rows must equal the number of tags requested —
|
||||||
|
// an ANY/IN match alone would return items carrying just one of them.
|
||||||
|
expect(built.clauses.join(' ')).toContain('COUNT(*)');
|
||||||
|
expect(built.params).toEqual([[1, 2], 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('numbers placeholders from the given starting index', () => {
|
||||||
|
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 3);
|
||||||
|
expect(built.clauses.join(' ')).toContain('$3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues numbering across multiple filters', () => {
|
||||||
|
const built = buildItemFilterSql(
|
||||||
|
parseItemFilters({ category: '4', min_price: '100', max_price: '900' }),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
expect(built.params).toEqual([4, 100, 900]);
|
||||||
|
const sql = built.clauses.join(' ');
|
||||||
|
expect(sql).toContain('$1');
|
||||||
|
expect(sql).toContain('$2');
|
||||||
|
expect(sql).toContain('$3');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { tagColorFor, TAG_COLORS } from '../../src/utils';
|
||||||
|
|
||||||
|
describe('tagColorFor', () => {
|
||||||
|
it('returns a colour from the palette', () => {
|
||||||
|
expect(TAG_COLORS).toContain(tagColorFor('vintage'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the same colour for the same name every time', () => {
|
||||||
|
expect(tagColorFor('vintage')).toBe(tagColorFor('vintage'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores case and surrounding whitespace, matching how tag names are deduped', () => {
|
||||||
|
expect(tagColorFor(' Vintage ')).toBe(tagColorFor('vintage'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives different names different colours', () => {
|
||||||
|
// Not guaranteed for every possible pair, but a handful of realistic tag
|
||||||
|
// names should spread across the palette rather than collapsing onto one.
|
||||||
|
const names = ['vintage', 'handmade', 'oak', 'restored', 'rare', 'walnut'];
|
||||||
|
const distinct = new Set(names.map(tagColorFor));
|
||||||
|
expect(distinct.size).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles an empty name without throwing', () => {
|
||||||
|
expect(TAG_COLORS).toContain(tagColorFor(''));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a name of non-ASCII characters', () => {
|
||||||
|
expect(TAG_COLORS).toContain(tagColorFor('café'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# Categories and Tags — Design
|
||||||
|
|
||||||
|
**Issue:** [#23 — Categories and tags](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/23)
|
||||||
|
**Date:** 2026-08-17
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Give every item two new organizing dimensions, and let storefront visitors filter on them:
|
||||||
|
|
||||||
|
1. **Category** — an admin-managed tree. Metadata only; no physical storage structure changes.
|
||||||
|
2. **Tags** — flexible, colour-coded labels. An item carries many; new tags are created on the fly.
|
||||||
|
|
||||||
|
The storefront gains filters for category, tags, and price range.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
Every decision below was settled with the issue author and is recorded on the issue
|
||||||
|
([round 1](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/23#issuecomment-183),
|
||||||
|
[round 2](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/23#issuecomment-184),
|
||||||
|
[round 3](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/23#issuecomment-185)).
|
||||||
|
|
||||||
|
| Area | Decision |
|
||||||
|
| --- | --- |
|
||||||
|
| Category assignment | Manual. The admin builds the tree and picks a node per item. **No rule engine.** |
|
||||||
|
| Category depth | Arbitrary, via self-referencing `parent_id` |
|
||||||
|
| Categories per item | Exactly one; `NULL` allowed and means Uncategorized |
|
||||||
|
| Category filtering | Selecting a node matches that node **and all its descendants** |
|
||||||
|
| Tags per item | Many |
|
||||||
|
| Tag registry | Central `tags` table — enables rename, recolour, delete |
|
||||||
|
| Tag creation | On the fly from the item form |
|
||||||
|
| Tag colours | Auto-assigned deterministically from the name, overridable from a palette |
|
||||||
|
| Tag filtering | **AND** — an item must carry every selected tag |
|
||||||
|
| Filter combination | Category AND tags AND price |
|
||||||
|
| Filter location | Server-side, via query params on `/api/items` |
|
||||||
|
| Storefront layout | Drawer + removable active-filter chips; drawer enters from the right at all sizes |
|
||||||
|
| Sold items | Continue to appear on the storefront, unchanged |
|
||||||
|
|
||||||
|
The issue's phrase "rules that dictate how the app automatically organizes items" was explicitly
|
||||||
|
resolved to mean manual tree assignment, matching its own follow-on sentence that categories are
|
||||||
|
"only metadata for organizing the items into a tree-like structure."
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
One new migration, created with `npm run migrate:create -- add-categories-and-tags`.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE categories (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
parent_id INTEGER REFERENCES categories(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Siblings cannot share a name. Two partial indexes rather than one, because
|
||||||
|
-- NULL parent_id would otherwise defeat a plain unique constraint.
|
||||||
|
CREATE UNIQUE INDEX categories_child_name_uniq
|
||||||
|
ON categories (parent_id, lower(name)) WHERE parent_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX categories_root_name_uniq
|
||||||
|
ON categories (lower(name)) WHERE parent_id IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX tags_name_uniq ON tags (lower(name));
|
||||||
|
|
||||||
|
CREATE TABLE item_tags (
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (item_id, tag_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX item_tags_tag_id_idx ON item_tags (tag_id);
|
||||||
|
|
||||||
|
ALTER TABLE items ADD COLUMN category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX items_category_id_idx ON items (category_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Delete semantics.** Deleting a category cascades to its subcategories; items in any deleted node
|
||||||
|
fall back to `NULL` (Uncategorized) rather than being deleted. The admin confirm dialog states the
|
||||||
|
counts before proceeding. Deleting a tag removes its `item_tags` rows and nothing else.
|
||||||
|
|
||||||
|
Unlike the baseline migration, this one has a real `down` that drops the three tables and the
|
||||||
|
`items.category_id` column.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
### Descendant matching
|
||||||
|
|
||||||
|
A recursive CTE walks down from the selected node:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WITH RECURSIVE subtree AS (
|
||||||
|
SELECT id FROM categories WHERE id = $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id
|
||||||
|
)
|
||||||
|
SELECT ... WHERE i.category_id IN (SELECT id FROM subtree)
|
||||||
|
```
|
||||||
|
|
||||||
|
Chosen over a materialized path column because nothing is denormalized: reparenting a subtree stays
|
||||||
|
a single `UPDATE parent_id`, with no descendant paths to rewrite and drift out of sync. The tree
|
||||||
|
will hold dozens of nodes, so the query cost is irrelevant.
|
||||||
|
|
||||||
|
### Fixing the existing item SELECT first
|
||||||
|
|
||||||
|
`SELECT_WITH_IMAGES` in both `routes/items.ts` and `routes/admin.ts` aggregates images through a
|
||||||
|
`LEFT JOIN` plus `GROUP BY`. Adding a second one-to-many join for tags to that shape fans out rows
|
||||||
|
and silently duplicates every image. Both are converted to scalar subqueries, which drops the
|
||||||
|
`GROUP BY` entirely:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT i.id, i.name, i.description, i.price_cents, i.status, i.created_at,
|
||||||
|
i.category_id, c.name AS category_name,
|
||||||
|
COALESCE((SELECT json_agg(json_build_object('id', img.id, 'image_path', img.image_path,
|
||||||
|
'sort_order', img.sort_order) ORDER BY img.sort_order)
|
||||||
|
FROM item_images img WHERE img.item_id = i.id), '[]') AS images,
|
||||||
|
COALESCE((SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'color', t.color)
|
||||||
|
ORDER BY t.name)
|
||||||
|
FROM item_tags it JOIN tags t ON t.id = it.tag_id
|
||||||
|
WHERE it.item_id = i.id), '[]') AS tags
|
||||||
|
FROM items i
|
||||||
|
LEFT JOIN categories c ON c.id = i.category_id
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a prerequisite, not opportunistic refactoring — the feature is incorrect without it.
|
||||||
|
|
||||||
|
### Public endpoints
|
||||||
|
|
||||||
|
**`GET /api/items`** gains query params, all optional and all combined with AND:
|
||||||
|
|
||||||
|
| Param | Type | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `category` | integer | Match this node and all descendants |
|
||||||
|
| `tags` | comma-separated integers | Item must carry **all** of them |
|
||||||
|
| `min_price` / `max_price` | integer cents | Inclusive bounds on `price_cents` |
|
||||||
|
|
||||||
|
Tag AND is enforced with a count check rather than repeated joins:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
AND (SELECT COUNT(*) FROM item_tags it
|
||||||
|
WHERE it.item_id = i.id AND it.tag_id = ANY($1::int[])) = $2
|
||||||
|
```
|
||||||
|
|
||||||
|
Malformed params (non-numeric, negative, inverted price range) return `400` rather than being
|
||||||
|
silently ignored, so a broken filter link is visible instead of quietly returning everything.
|
||||||
|
|
||||||
|
**`GET /api/filters`** returns everything the drawer needs in one request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"categories": [{ "id": 1, "name": "Furniture", "parent_id": null, "sort_order": 0 }],
|
||||||
|
"tags": [{ "id": 1, "name": "vintage", "color": "magenta", "item_count": 4 }],
|
||||||
|
"priceRange": { "min_cents": 1200, "max_cents": 80000 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Categories come back as a flat list; the frontend builds the tree. `priceRange` is computed across
|
||||||
|
all items and gives the slider its bounds. When there are no items, it returns `{min_cents: 0, max_cents: 0}`.
|
||||||
|
|
||||||
|
### Admin endpoints
|
||||||
|
|
||||||
|
Mounted under the existing `/api/admin` prefix, so they inherit the authentik forward-auth boundary
|
||||||
|
described in `.claude/project-context.md` with no nginx change.
|
||||||
|
|
||||||
|
- `GET|POST /api/admin/categories`, `PUT|DELETE /api/admin/categories/:id`
|
||||||
|
- `GET|POST /api/admin/tags`, `PUT|DELETE /api/admin/tags/:id`
|
||||||
|
|
||||||
|
`GET /api/admin/categories` returns each node with an `item_count`, which is what lets the admin UI
|
||||||
|
state the blast radius of a delete before calling it — the tree and the counts are already on the
|
||||||
|
client, so no extra preview endpoint is needed. `PUT /api/admin/categories/:id` accepts `name`,
|
||||||
|
`parent_id`, and `sort_order`; reparenting is validated against cycles server-side — a node may not
|
||||||
|
become its own descendant — returning `400`. `DELETE` reports what it actually did, as
|
||||||
|
`{ deleted_categories, uncategorized_items }`.
|
||||||
|
|
||||||
|
`POST`/`PUT /api/admin/items` gain two multipart fields:
|
||||||
|
|
||||||
|
- `category_id` — integer or empty for Uncategorized
|
||||||
|
- `tags` — JSON array of tag names; unknown names are created inside the same transaction with a
|
||||||
|
hashed colour
|
||||||
|
|
||||||
|
Multer's `fields: 8` cap in `routes/admin.ts` still has headroom: 3 text fields today, 5 after.
|
||||||
|
|
||||||
|
### Tag colours
|
||||||
|
|
||||||
|
A pure function hashes the tag name onto antd's preset palette, so a name always yields the same
|
||||||
|
colour and adjacent tags rarely collide. Lives in `backend/src/utils.ts` beside the existing helpers
|
||||||
|
and is unit-tested for determinism and range. An admin override simply writes `tags.color` directly.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/filters.ts` | Filter state type, URL query-string serialization, category tree building |
|
||||||
|
| `src/components/FilterDrawer.tsx` | The drawer: category tree, tag pills, price slider |
|
||||||
|
| `src/components/ActiveFilterChips.tsx` | Removable chips plus "Clear all" |
|
||||||
|
| `src/admin/Categories.tsx` | Category tree management tab |
|
||||||
|
| `src/admin/Tags.tsx` | Tag list management tab |
|
||||||
|
|
||||||
|
### Storefront
|
||||||
|
|
||||||
|
`App.tsx` holds filter state, syncs it to the URL query string, and refetches items when it changes.
|
||||||
|
Filtered views are therefore shareable and the back button works.
|
||||||
|
|
||||||
|
The closed state is a "Filters (N)" button with active-filter chips beside it — wrapping onto their
|
||||||
|
own line on mobile. The drawer enters from the right at every screen size (`placement="right"`,
|
||||||
|
near-full-width below the `md` breakpoint), with a sticky footer holding "Clear all" and
|
||||||
|
"Show N items".
|
||||||
|
|
||||||
|
The chip row is marked `role="group" aria-label="Active filters"`, which keeps its "Clear all"
|
||||||
|
distinguishable from the identically-labelled control in the drawer.
|
||||||
|
|
||||||
|
The tag section is labelled **"Tags — must have all"** so that selecting a second tag and watching
|
||||||
|
the grid shrink reads as intentional rather than broken.
|
||||||
|
|
||||||
|
`ItemCard` renders its tags as colour-coded antd `Tag` chips.
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
|
||||||
|
Two tabs added beside Inventory, Customers, and Settings:
|
||||||
|
|
||||||
|
- **Categories** — antd `Tree` with drag-to-reparent, inline add/rename/delete, delete confirm
|
||||||
|
naming the affected subcategory and item counts. Expansion is controlled state rather than
|
||||||
|
`defaultExpandAll`: that prop is evaluated once at mount, so a branch created afterwards would
|
||||||
|
render collapsed and its children be unreachable. Creating or moving a node expands its parent.
|
||||||
|
- **Tags** — list with rename, colour override from a palette, and delete showing the item count
|
||||||
|
|
||||||
|
The item modal gains a category `TreeSelect` (with an explicit Uncategorized option) and a tags
|
||||||
|
`Select mode="tags"` for on-the-fly creation.
|
||||||
|
|
||||||
|
### Import convention
|
||||||
|
|
||||||
|
New frontend files use antd deep imports (`import Drawer from 'antd/lib/drawer'`) per the standing
|
||||||
|
user rule. Existing files keep their barrel imports — churning them is out of scope for this issue.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Unit** (`backend/tests/unit/`)
|
||||||
|
- Tag colour hash: deterministic, always within the palette, stable across calls
|
||||||
|
- Filter query-param parsing: valid values accepted, malformed values rejected
|
||||||
|
|
||||||
|
**Integration** (`backend/tests/integration/categoriesTags.integration.test.ts`)
|
||||||
|
- Category CRUD; sibling name collision rejected
|
||||||
|
- Reparent that would create a cycle rejected with `400`
|
||||||
|
- Deleting a parent cascades to subcategories and uncategorizes their items without deleting them
|
||||||
|
- `?category=` matches descendants, not just the exact node
|
||||||
|
- `?tags=` requires **all** listed tags, not any
|
||||||
|
- `?min_price`/`?max_price` bound inclusively; inverted range rejected
|
||||||
|
- All three filters combined
|
||||||
|
- `GET /api/filters` shape, including the empty-catalogue price range
|
||||||
|
- Creating an item with a new tag name creates the tag; reusing a name does not duplicate it
|
||||||
|
|
||||||
|
**E2E** (`frontend/tests/e2e/filters.spec.ts`)
|
||||||
|
- Open the drawer, filter by category, tags, and price; assert the grid narrows
|
||||||
|
- A nested category is selectable, not just the roots
|
||||||
|
- Remove a chip and assert the grid widens; "Clear all" resets everything
|
||||||
|
- Reload a filtered URL and assert the filters are restored
|
||||||
|
- Tags render on the item card
|
||||||
|
|
||||||
|
**E2E** (`frontend/tests/e2e/admin-taxonomy.spec.ts`)
|
||||||
|
- Create a category and a nested child, asserting the child stays visible
|
||||||
|
- Create a tag and confirm a colour was assigned
|
||||||
|
- The item form exposes category and tag fields
|
||||||
|
|
||||||
|
Both e2e specs run against a database that is never reset between runs, so fixture names carry a
|
||||||
|
per-run suffix, and `filters.spec.ts` treats re-entry into its `beforeAll` as a no-op — a worker can
|
||||||
|
be handed the same spec file in more than one batch, and seeding twice would duplicate every item.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Any automatic categorization rule engine (explicitly rejected — see Q1)
|
||||||
|
- Multiple categories per item (explicitly rejected — see Q2)
|
||||||
|
- Customer-facing tag creation; tags are admin-only
|
||||||
|
- Filtering or faceting in the admin inventory table
|
||||||
+100
-9
@@ -1,9 +1,18 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge } from 'antd';
|
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge, Empty } from 'antd';
|
||||||
import { ShoppingCartOutlined } from '@ant-design/icons';
|
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { Item, fetchItems } from './api';
|
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
|
||||||
import ItemCard from './components/ItemCard';
|
import ItemCard from './components/ItemCard';
|
||||||
|
import FilterDrawer from './components/FilterDrawer';
|
||||||
|
import ActiveFilterChips from './components/ActiveFilterChips';
|
||||||
|
import {
|
||||||
|
ItemFilters,
|
||||||
|
activeFilterCount,
|
||||||
|
filtersFromSearchParams,
|
||||||
|
filtersToSearchParams,
|
||||||
|
hasActiveFilters
|
||||||
|
} from './filters';
|
||||||
import { useThemeMode } from './theme/ThemeContext';
|
import { useThemeMode } from './theme/ThemeContext';
|
||||||
import { useCustomerAuth } from './customer/CustomerAuthContext';
|
import { useCustomerAuth } from './customer/CustomerAuthContext';
|
||||||
import { useCart } from './cart/CartContext';
|
import { useCart } from './cart/CartContext';
|
||||||
@@ -11,18 +20,63 @@ import { useCart } from './cart/CartContext';
|
|||||||
const { Header, Content, Footer } = Layout;
|
const { Header, Content, Footer } = Layout;
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
// Dragging the price slider fires a change per pixel; without this every one
|
||||||
|
// would become its own request.
|
||||||
|
const FILTER_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [items, setItems] = useState<Item[]>([]);
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [options, setOptions] = useState<FilterOptions | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { mode, toggle } = useThemeMode();
|
const { mode, toggle } = useThemeMode();
|
||||||
const { customer } = useCustomerAuth();
|
const { customer } = useCustomerAuth();
|
||||||
const { items: cartItems } = useCart();
|
const { items: cartItems } = useCart();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
// The URL is the single source of truth for filter state, so a reload, a
|
||||||
|
// shared link, and the back button all restore the same view.
|
||||||
|
const filters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]);
|
||||||
|
const filterKey = filtersToSearchParams(filters).toString();
|
||||||
|
|
||||||
|
const applyFilters = useCallback(
|
||||||
|
(next: ItemFilters) => {
|
||||||
|
// replace, not push: dragging a slider shouldn't bury the previous page
|
||||||
|
// under dozens of history entries.
|
||||||
|
setSearchParams(filtersToSearchParams(next), { replace: true });
|
||||||
|
},
|
||||||
|
[setSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
|
setSearchParams(new URLSearchParams(), { replace: true });
|
||||||
|
}, [setSearchParams]);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
fetchItems().then(setItems);
|
return fetchItems(filtersFromSearchParams(new URLSearchParams(filterKey)))
|
||||||
|
.then(setItems)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [filterKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
const timer = setTimeout(load, FILTER_DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFilterOptions().then(setOptions).catch(() => setOptions(null));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
// Adding to cart flips an item to reserved, and the filter options' price
|
||||||
|
// bounds shift as inventory changes.
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
load();
|
||||||
|
fetchFilterOptions().then(setOptions).catch(() => undefined);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const activeCount = activeFilterCount(filters);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
@@ -54,11 +108,38 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</Header>
|
</Header>
|
||||||
<Content style={{ padding: 24 }}>
|
<Content style={{ padding: 24 }}>
|
||||||
{!items.length ? <Spin /> : (
|
<div className="filter-bar">
|
||||||
|
<Button
|
||||||
|
icon={<FilterOutlined />}
|
||||||
|
onClick={() => setDrawerOpen(true)}
|
||||||
|
type={activeCount ? 'primary' : 'default'}
|
||||||
|
>
|
||||||
|
Filters{activeCount ? ` (${activeCount})` : ''}
|
||||||
|
</Button>
|
||||||
|
<ActiveFilterChips
|
||||||
|
options={options}
|
||||||
|
filters={filters}
|
||||||
|
onChange={applyFilters}
|
||||||
|
onClear={clearFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !items.length ? <Spin /> : null}
|
||||||
|
{!loading && !items.length ? (
|
||||||
|
<Empty
|
||||||
|
description={
|
||||||
|
hasActiveFilters(filters)
|
||||||
|
? 'No items match these filters'
|
||||||
|
: 'No items yet — check back soon'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasActiveFilters(filters) ? <Button onClick={clearFilters}>Clear filters</Button> : null}
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
<Row gutter={[20, 20]}>
|
<Row gutter={[20, 20]}>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
|
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
|
||||||
<ItemCard item={item} onChanged={load} />
|
<ItemCard item={item} onChanged={reload} />
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
@@ -67,6 +148,16 @@ export default function App() {
|
|||||||
<Footer style={{ textAlign: 'center', background: token.colorBgContainer }}>
|
<Footer style={{ textAlign: 'center', background: token.colorBgContainer }}>
|
||||||
<Link to="/privacy">Privacy Policy</Link>
|
<Link to="/privacy">Privacy Policy</Link>
|
||||||
</Footer>
|
</Footer>
|
||||||
|
|
||||||
|
<FilterDrawer
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
options={options}
|
||||||
|
filters={filters}
|
||||||
|
onChange={applyFilters}
|
||||||
|
onClear={clearFilters}
|
||||||
|
resultCount={items.length}
|
||||||
|
/>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,39 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
|
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
|
||||||
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs
|
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs,
|
||||||
|
TreeSelect, Select
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
|
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload/interface';
|
import type { UploadFile } from 'antd/es/upload/interface';
|
||||||
import MDEditor from '@uiw/react-md-editor';
|
import MDEditor from '@uiw/react-md-editor';
|
||||||
import '@uiw/react-md-editor/markdown-editor.css';
|
import '@uiw/react-md-editor/markdown-editor.css';
|
||||||
import '@uiw/react-markdown-preview/markdown.css';
|
import '@uiw/react-markdown-preview/markdown.css';
|
||||||
import { Item, fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable } from '../api';
|
import {
|
||||||
|
Item, Category, Tag as TagRecord,
|
||||||
|
fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable,
|
||||||
|
fetchAdminCategories, fetchAdminTags
|
||||||
|
} from '../api';
|
||||||
|
import { buildCategoryTree, CategoryNode } from '../filters';
|
||||||
import { useThemeMode } from '../theme/ThemeContext';
|
import { useThemeMode } from '../theme/ThemeContext';
|
||||||
import Customers from './Customers';
|
import Customers from './Customers';
|
||||||
import Settings from './Settings';
|
import Settings from './Settings';
|
||||||
|
import Categories from './Categories';
|
||||||
|
import Tags from './Tags';
|
||||||
|
|
||||||
|
interface CategoryTreeOption {
|
||||||
|
value: number;
|
||||||
|
title: string;
|
||||||
|
children?: CategoryTreeOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCategoryTreeData(nodes: CategoryNode[]): CategoryTreeOption[] {
|
||||||
|
return nodes.map(node => ({
|
||||||
|
value: node.id,
|
||||||
|
title: node.name,
|
||||||
|
children: node.children.length ? toCategoryTreeData(node.children) : undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const { Header, Content } = Layout;
|
const { Header, Content } = Layout;
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
@@ -23,24 +45,41 @@ function Inventory() {
|
|||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||||
const [description, setDescription] = useState<string>('');
|
const [description, setDescription] = useState<string>('');
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [tags, setTags] = useState<TagRecord[]>([]);
|
||||||
const { mode } = useThemeMode();
|
const { mode } = useThemeMode();
|
||||||
|
|
||||||
const load = () => fetchAdminItems().then(setItems);
|
const load = () => fetchAdminItems().then(setItems);
|
||||||
useEffect(() => { load(); }, []);
|
|
||||||
|
// The item form needs the current category tree and tag list; both change
|
||||||
|
// from the sibling tabs, so they're refetched whenever the modal opens.
|
||||||
|
const loadOptions = () => Promise.all([
|
||||||
|
fetchAdminCategories().then(setCategories),
|
||||||
|
fetchAdminTags().then(setTags)
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); loadOptions(); }, []);
|
||||||
|
|
||||||
function openNew() {
|
function openNew() {
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setFileList([]);
|
setFileList([]);
|
||||||
setDescription('');
|
setDescription('');
|
||||||
|
loadOptions();
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(item: Item) {
|
function openEdit(item: Item) {
|
||||||
setEditingItem(item);
|
setEditingItem(item);
|
||||||
form.setFieldsValue({ name: item.name, price: item.price_cents / 100 });
|
form.setFieldsValue({
|
||||||
|
name: item.name,
|
||||||
|
price: item.price_cents / 100,
|
||||||
|
category_id: item.category_id ?? undefined,
|
||||||
|
tags: item.tags.map(tag => tag.name)
|
||||||
|
});
|
||||||
setFileList([]);
|
setFileList([]);
|
||||||
setDescription(item.description || '');
|
setDescription(item.description || '');
|
||||||
|
loadOptions();
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,11 +89,16 @@ function Inventory() {
|
|||||||
fd.append('name', values.name);
|
fd.append('name', values.name);
|
||||||
fd.append('description', description);
|
fd.append('description', description);
|
||||||
fd.append('price', String(values.price));
|
fd.append('price', String(values.price));
|
||||||
|
// An empty string clears the category server-side; undefined would be sent
|
||||||
|
// as the literal text "undefined".
|
||||||
|
fd.append('category_id', values.category_id == null ? '' : String(values.category_id));
|
||||||
|
fd.append('tags', JSON.stringify(values.tags ?? []));
|
||||||
fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); });
|
fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); });
|
||||||
await saveItem(editingItem?.id ?? null, fd);
|
await saveItem(editingItem?.id ?? null, fd);
|
||||||
message.success(editingItem ? 'Item updated' : 'Item added');
|
message.success(editingItem ? 'Item updated' : 'Item added');
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
load();
|
load();
|
||||||
|
loadOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
async function handleDelete(id: number) {
|
||||||
@@ -87,6 +131,19 @@ function Inventory() {
|
|||||||
) : null
|
) : null
|
||||||
},
|
},
|
||||||
{ title: 'Name', dataIndex: 'name' },
|
{ title: 'Name', dataIndex: 'name' },
|
||||||
|
{
|
||||||
|
title: 'Category',
|
||||||
|
dataIndex: 'category_name',
|
||||||
|
render: (name: string | null) => name || <span style={{ opacity: 0.45 }}>Uncategorized</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tags',
|
||||||
|
dataIndex: 'tags',
|
||||||
|
render: (itemTags: Item['tags']) =>
|
||||||
|
itemTags.length
|
||||||
|
? itemTags.map(tag => <Tag key={tag.id} color={tag.color}>{tag.name}</Tag>)
|
||||||
|
: null
|
||||||
|
},
|
||||||
{ title: 'Price', dataIndex: 'price_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
|
{ title: 'Price', dataIndex: 'price_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
|
||||||
{
|
{
|
||||||
title: 'Status',
|
title: 'Status',
|
||||||
@@ -129,6 +186,27 @@ function Inventory() {
|
|||||||
<Form.Item name="price" label="Price (USD)" rules={[{ required: true }]}>
|
<Form.Item name="price" label="Price (USD)" rules={[{ required: true }]}>
|
||||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="category_id" label="Category">
|
||||||
|
<TreeSelect
|
||||||
|
allowClear
|
||||||
|
placeholder="Uncategorized"
|
||||||
|
treeDefaultExpandAll
|
||||||
|
treeData={toCategoryTreeData(buildCategoryTree(categories))}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="tags"
|
||||||
|
label="Tags"
|
||||||
|
extra="Pick existing tags or type a new one and press Enter to create it."
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
placeholder="Add tags"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
options={tags.map(tag => ({ value: tag.name, label: tag.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
{editingItem && editingItem.images.length > 0 && (
|
{editingItem && editingItem.images.length > 0 && (
|
||||||
<Form.Item label="Existing Images (front / back / etc.)">
|
<Form.Item label="Existing Images (front / back / etc.)">
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
@@ -172,6 +250,8 @@ export default function Admin() {
|
|||||||
defaultActiveKey="inventory"
|
defaultActiveKey="inventory"
|
||||||
items={[
|
items={[
|
||||||
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
|
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
|
||||||
|
{ key: 'categories', label: 'Categories', children: <Categories /> },
|
||||||
|
{ key: 'tags', label: 'Tags', children: <Tags /> },
|
||||||
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
||||||
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { Key } from 'react';
|
||||||
|
import Tree from 'antd/lib/tree';
|
||||||
|
import Button from 'antd/lib/button';
|
||||||
|
import Input from 'antd/lib/input';
|
||||||
|
import Modal from 'antd/lib/modal';
|
||||||
|
import Select from 'antd/lib/select';
|
||||||
|
import Space from 'antd/lib/space';
|
||||||
|
import Typography from 'antd/lib/typography';
|
||||||
|
import Empty from 'antd/lib/empty';
|
||||||
|
import Spin from 'antd/lib/spin';
|
||||||
|
import message from 'antd/lib/message';
|
||||||
|
import type { DataNode, TreeProps } from 'antd/es/tree';
|
||||||
|
import {
|
||||||
|
Category,
|
||||||
|
fetchAdminCategories,
|
||||||
|
createCategory,
|
||||||
|
updateCategory,
|
||||||
|
deleteCategory
|
||||||
|
} from '../api';
|
||||||
|
import { buildCategoryTree, categoryPath, CategoryNode } from '../filters';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
// Counts the whole branch, so the delete confirmation can say what a parent
|
||||||
|
// takes down with it rather than only naming itself.
|
||||||
|
function branchTotals(node: CategoryNode): { categories: number; items: number } {
|
||||||
|
return node.children.reduce(
|
||||||
|
(acc, child) => {
|
||||||
|
const nested = branchTotals(child);
|
||||||
|
return { categories: acc.categories + nested.categories, items: acc.items + nested.items };
|
||||||
|
},
|
||||||
|
{ categories: 1, items: node.item_count }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNode(nodes: CategoryNode[], id: number): CategoryNode | null {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.id === id) return node;
|
||||||
|
const nested = findNode(node.children, id);
|
||||||
|
if (nested) return nested;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Categories() {
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Category | null>(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [parentId, setParentId] = useState<number | null>(null);
|
||||||
|
// The tree stays mounted across reloads, so `defaultExpandAll` would only
|
||||||
|
// ever apply to the categories present on first render — a branch added
|
||||||
|
// afterwards would render collapsed and its children be unreachable.
|
||||||
|
const [expandedKeys, setExpandedKeys] = useState<Key[]>([]);
|
||||||
|
const expansionInitialized = useRef(false);
|
||||||
|
|
||||||
|
const tree = buildCategoryTree(categories);
|
||||||
|
|
||||||
|
function expand(id: number) {
|
||||||
|
setExpandedKeys((keys) => (keys.includes(id) ? keys : [...keys, id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoading(true);
|
||||||
|
return fetchAdminCategories()
|
||||||
|
.then((list) => {
|
||||||
|
setCategories(list);
|
||||||
|
// Start fully expanded, then leave expansion to the user — reloading
|
||||||
|
// must not silently re-open branches they collapsed.
|
||||||
|
if (!expansionInitialized.current) {
|
||||||
|
setExpandedKeys(list.map((category) => category.id));
|
||||||
|
expansionInitialized.current = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
function openNew(parent: number | null) {
|
||||||
|
setEditing(null);
|
||||||
|
setName('');
|
||||||
|
setParentId(parent);
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(category: Category) {
|
||||||
|
setEditing(category);
|
||||||
|
setName(category.name);
|
||||||
|
setParentId(category.parent_id);
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
message.error('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await updateCategory(editing.id, { name: trimmed, parent_id: parentId });
|
||||||
|
message.success('Category updated');
|
||||||
|
} else {
|
||||||
|
await createCategory(trimmed, parentId);
|
||||||
|
message.success('Category added');
|
||||||
|
}
|
||||||
|
// Reveal where the category just landed instead of filing it out of sight.
|
||||||
|
if (parentId !== null) expand(parentId);
|
||||||
|
setModalOpen(false);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
message.error((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(category: Category) {
|
||||||
|
const node = findNode(tree, category.id);
|
||||||
|
const totals = node ? branchTotals(node) : { categories: 1, items: category.item_count };
|
||||||
|
const subcategories = totals.categories - 1;
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Delete "${category.name}"?`,
|
||||||
|
content: (
|
||||||
|
<span>
|
||||||
|
This deletes {subcategories === 0 ? 'no subcategories' : `${subcategories} subcategor${subcategories === 1 ? 'y' : 'ies'}`}
|
||||||
|
{' '}and uncategorizes {totals.items} item{totals.items === 1 ? '' : 's'}. The items themselves are kept.
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
okText: 'Delete',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
const result = await deleteCategory(category.id);
|
||||||
|
message.success(
|
||||||
|
`Deleted ${result.deleted_categories} categor${result.deleted_categories === 1 ? 'y' : 'ies'}, ` +
|
||||||
|
`uncategorized ${result.uncategorized_items} item${result.uncategorized_items === 1 ? '' : 's'}`
|
||||||
|
);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dragging a node onto another reparents it. The server rejects a move that
|
||||||
|
// would put a node beneath its own descendant, so a refused drop just
|
||||||
|
// reloads the unchanged tree.
|
||||||
|
const handleDrop: TreeProps['onDrop'] = async (info) => {
|
||||||
|
const dragId = Number(info.dragNode.key);
|
||||||
|
const dropId = Number(info.node.key);
|
||||||
|
const dropToGap = !info.dropToGap ? dropId : findNode(tree, dropId)?.parent_id ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateCategory(dragId, { parent_id: dropToGap });
|
||||||
|
if (dropToGap !== null) expand(dropToGap);
|
||||||
|
message.success('Category moved');
|
||||||
|
} catch (err) {
|
||||||
|
message.error((err as Error).message);
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
function toTreeData(nodes: CategoryNode[]): DataNode[] {
|
||||||
|
return nodes.map((node) => ({
|
||||||
|
key: node.id,
|
||||||
|
title: (
|
||||||
|
<span className="admin-category-node">
|
||||||
|
<span>{node.name}</span>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{node.item_count} item{node.item_count === 1 ? '' : 's'}
|
||||||
|
</Text>
|
||||||
|
<Space size={4} onClick={(event) => event.stopPropagation()}>
|
||||||
|
<Button size="small" type="link" onClick={() => openNew(node.id)}>Add child</Button>
|
||||||
|
<Button size="small" type="link" onClick={() => openEdit(node)}>Rename</Button>
|
||||||
|
<Button size="small" type="link" danger onClick={() => handleDelete(node)}>Delete</Button>
|
||||||
|
</Space>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: node.children.length ? toTreeData(node.children) : undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A category may not be moved beneath itself or its own descendants, so those
|
||||||
|
// options are withheld from the parent picker too.
|
||||||
|
const forbidden = new Set<number>();
|
||||||
|
if (editing) {
|
||||||
|
const collect = (node: CategoryNode) => {
|
||||||
|
forbidden.add(node.id);
|
||||||
|
node.children.forEach(collect);
|
||||||
|
};
|
||||||
|
const node = findNode(tree, editing.id);
|
||||||
|
if (node) collect(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentOptions = [
|
||||||
|
{ value: null as number | null, label: 'No parent (top level)' },
|
||||||
|
...categories
|
||||||
|
.filter((category) => !forbidden.has(category.id))
|
||||||
|
.map((category) => ({ value: category.id as number | null, label: categoryPath(categories, category.id) }))
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>Categories</Title>
|
||||||
|
<Button type="primary" onClick={() => openNew(null)}>Add Category</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !categories.length ? <Spin /> : null}
|
||||||
|
{!loading && !categories.length ? (
|
||||||
|
<Empty description="No categories yet. Add one to start organizing items." />
|
||||||
|
) : (
|
||||||
|
<Tree
|
||||||
|
treeData={toTreeData(tree)}
|
||||||
|
draggable
|
||||||
|
blockNode
|
||||||
|
expandedKeys={expandedKeys}
|
||||||
|
onExpand={setExpandedKeys}
|
||||||
|
selectable={false}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? 'Edit Category' : 'Add Category'}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<label htmlFor="category-name">Name</label>
|
||||||
|
<Input
|
||||||
|
id="category-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
onPressEnter={handleSave}
|
||||||
|
/>
|
||||||
|
<label htmlFor="category-parent">Parent</label>
|
||||||
|
<Select
|
||||||
|
id="category-parent"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={parentId}
|
||||||
|
onChange={setParentId}
|
||||||
|
options={parentOptions}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Table from 'antd/lib/table';
|
||||||
|
import Button from 'antd/lib/button';
|
||||||
|
import Input from 'antd/lib/input';
|
||||||
|
import Modal from 'antd/lib/modal';
|
||||||
|
import Select from 'antd/lib/select';
|
||||||
|
import Space from 'antd/lib/space';
|
||||||
|
import Tag from 'antd/lib/tag';
|
||||||
|
import Typography from 'antd/lib/typography';
|
||||||
|
import message from 'antd/lib/message';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { Tag as TagRecord, fetchAdminTags, createTag, updateTag, deleteTag } from '../api';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
// Mirrors TAG_COLORS in the backend's utils.ts — the server rejects anything
|
||||||
|
// outside this set, so the two lists have to stay aligned.
|
||||||
|
const TAG_COLORS = [
|
||||||
|
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
|
||||||
|
'green', 'cyan', 'blue', 'geekblue', 'purple'
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Tags() {
|
||||||
|
const [tags, setTags] = useState<TagRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<TagRecord | null>(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [color, setColor] = useState<string>(TAG_COLORS[0]);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoading(true);
|
||||||
|
return fetchAdminTags()
|
||||||
|
.then(setTags)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
setEditing(null);
|
||||||
|
setName('');
|
||||||
|
setColor(TAG_COLORS[0]);
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(tag: TagRecord) {
|
||||||
|
setEditing(tag);
|
||||||
|
setName(tag.name);
|
||||||
|
setColor(tag.color);
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
message.error('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await updateTag(editing.id, { name: trimmed, color });
|
||||||
|
message.success('Tag updated');
|
||||||
|
} else {
|
||||||
|
// New tags take the colour the server derives from the name; it can be
|
||||||
|
// overridden straight afterwards by editing.
|
||||||
|
await createTag(trimmed);
|
||||||
|
message.success('Tag added');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
message.error((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(tag: TagRecord) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Delete "${tag.name}"?`,
|
||||||
|
content: `This removes the tag from ${tag.item_count} item${tag.item_count === 1 ? '' : 's'}. The items themselves are kept.`,
|
||||||
|
okText: 'Delete',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
await deleteTag(tag.id);
|
||||||
|
message.success('Tag deleted');
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<TagRecord> = [
|
||||||
|
{
|
||||||
|
title: 'Tag',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (_: string, tag) => <Tag color={tag.color}>{tag.name}</Tag>
|
||||||
|
},
|
||||||
|
{ title: 'Colour', dataIndex: 'color' },
|
||||||
|
{ title: 'Items', dataIndex: 'item_count' },
|
||||||
|
{
|
||||||
|
title: 'Actions',
|
||||||
|
render: (_: unknown, tag) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => openEdit(tag)}>Edit</Button>
|
||||||
|
<Button size="small" danger onClick={() => handleDelete(tag)}>Delete</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>Tags</Title>
|
||||||
|
<Button type="primary" onClick={openNew}>Add Tag</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table rowKey="id" dataSource={tags} columns={columns} loading={loading} scroll={{ x: true }} />
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? 'Edit Tag' : 'Add Tag'}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<label htmlFor="tag-name">Name</label>
|
||||||
|
<Input
|
||||||
|
id="tag-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
onPressEnter={handleSave}
|
||||||
|
/>
|
||||||
|
{editing && (
|
||||||
|
<>
|
||||||
|
<label htmlFor="tag-color">Colour</label>
|
||||||
|
<Select
|
||||||
|
id="tag-color"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={color}
|
||||||
|
onChange={setColor}
|
||||||
|
options={TAG_COLORS.map((option) => ({
|
||||||
|
value: option,
|
||||||
|
label: <Tag color={option}>{option}</Tag>
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+96
-2
@@ -1,3 +1,12 @@
|
|||||||
|
import type { ItemFilters } from './filters';
|
||||||
|
import { filtersToSearchParams } from './filters';
|
||||||
|
|
||||||
|
export interface ItemTag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -5,6 +14,30 @@ export interface Item {
|
|||||||
price_cents: number;
|
price_cents: number;
|
||||||
images: { id: number; image_path: string; sort_order: number }[];
|
images: { id: number; image_path: string; sort_order: number }[];
|
||||||
status: 'available' | 'reserved' | 'sold';
|
status: 'available' | 'reserved' | 'sold';
|
||||||
|
category_id: number | null;
|
||||||
|
category_name: string | null;
|
||||||
|
tags: ItemTag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
parent_id: number | null;
|
||||||
|
sort_order: number;
|
||||||
|
item_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
item_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterOptions {
|
||||||
|
categories: Category[];
|
||||||
|
tags: Tag[];
|
||||||
|
priceRange: { min_cents: number; max_cents: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SiteConfig {
|
export interface SiteConfig {
|
||||||
@@ -18,8 +51,14 @@ export async function fetchConfig(): Promise<SiteConfig> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchItems(): Promise<Item[]> {
|
export async function fetchItems(filters?: ItemFilters): Promise<Item[]> {
|
||||||
const res = await fetch('/api/items');
|
const query = filters ? filtersToSearchParams(filters).toString() : '';
|
||||||
|
const res = await fetch(query ? `/api/items?${query}` : '/api/items');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFilterOptions(): Promise<FilterOptions> {
|
||||||
|
const res = await fetch('/api/filters');
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,3 +90,58 @@ export async function markAvailable(id: number): Promise<Item> {
|
|||||||
const res = await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' });
|
const res = await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' });
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The admin endpoints return a JSON error body on 4xx; surfacing its message
|
||||||
|
// lets the UI say "that name is already used here" instead of a generic
|
||||||
|
// failure.
|
||||||
|
async function sendJson<T>(url: string, method: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.json().catch(() => ({ error: 'request failed' }));
|
||||||
|
throw new Error(detail.error || 'request failed');
|
||||||
|
}
|
||||||
|
return res.status === 204 ? (undefined as T) : res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminCategories(): Promise<Category[]> {
|
||||||
|
const res = await fetch('/api/admin/categories');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCategory(name: string, parentId: number | null): Promise<Category> {
|
||||||
|
return sendJson('/api/admin/categories', 'POST', { name, parent_id: parentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCategory(
|
||||||
|
id: number,
|
||||||
|
changes: { name?: string; parent_id?: number | null }
|
||||||
|
): Promise<Category> {
|
||||||
|
return sendJson(`/api/admin/categories/${id}`, 'PUT', changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCategory(
|
||||||
|
id: number
|
||||||
|
): Promise<{ deleted_categories: number; uncategorized_items: number }> {
|
||||||
|
return sendJson(`/api/admin/categories/${id}`, 'DELETE');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminTags(): Promise<Tag[]> {
|
||||||
|
const res = await fetch('/api/admin/tags');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTag(name: string): Promise<Tag> {
|
||||||
|
return sendJson('/api/admin/tags', 'POST', { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTag(id: number, changes: { name?: string; color?: string }): Promise<Tag> {
|
||||||
|
return sendJson(`/api/admin/tags/${id}`, 'PUT', changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTag(id: number): Promise<void> {
|
||||||
|
return sendJson(`/api/admin/tags/${id}`, 'DELETE');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Tag from 'antd/lib/tag';
|
||||||
|
import Button from 'antd/lib/button';
|
||||||
|
import type { FilterOptions } from '../api';
|
||||||
|
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters } from '../filters';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
options: FilterOptions | null;
|
||||||
|
filters: ItemFilters;
|
||||||
|
onChange: (filters: ItemFilters) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActiveFilterChips({ options, filters, onChange, onClear }: Props) {
|
||||||
|
if (!hasActiveFilters(filters)) return null;
|
||||||
|
|
||||||
|
const categories = options?.categories ?? [];
|
||||||
|
const tags = options?.tags ?? [];
|
||||||
|
|
||||||
|
const chips: { key: string; label: string; onRemove: () => void }[] = [];
|
||||||
|
|
||||||
|
if (filters.categoryId !== null) {
|
||||||
|
const path = categoryPath(categories, filters.categoryId);
|
||||||
|
// Falls back to the raw id while /api/filters is still loading, so the chip
|
||||||
|
// never renders as an empty box.
|
||||||
|
const label = path || `Category ${filters.categoryId}`;
|
||||||
|
chips.push({
|
||||||
|
key: `category-${filters.categoryId}`,
|
||||||
|
// The removable name is the leaf, matching what the user clicked in the
|
||||||
|
// tree, while the chip itself shows the full path for context.
|
||||||
|
label,
|
||||||
|
onRemove: () => onChange({ ...filters, categoryId: null })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tagId of filters.tagIds) {
|
||||||
|
const tag = tags.find((candidate) => candidate.id === tagId);
|
||||||
|
chips.push({
|
||||||
|
key: `tag-${tagId}`,
|
||||||
|
label: tag?.name ?? `Tag ${tagId}`,
|
||||||
|
onRemove: () => onChange({ ...filters, tagIds: filters.tagIds.filter((id) => id !== tagId) })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) {
|
||||||
|
chips.push({
|
||||||
|
key: 'price',
|
||||||
|
label: formatPriceRange(filters.minPriceCents, filters.maxPriceCents),
|
||||||
|
onRemove: () => onChange({ ...filters, minPriceCents: null, maxPriceCents: null })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Named as a group so the chip row's own "Clear all" stays distinguishable
|
||||||
|
// from the identically-labelled one in the filter drawer.
|
||||||
|
<div className="active-filter-chips" role="group" aria-label="Active filters">
|
||||||
|
{chips.map((chip) => (
|
||||||
|
<Tag
|
||||||
|
key={chip.key}
|
||||||
|
closable
|
||||||
|
onClose={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
chip.onRemove();
|
||||||
|
}}
|
||||||
|
// antd renders the close control as an icon with no text, so name it
|
||||||
|
// for screen readers and for anything driving the page by role.
|
||||||
|
closeIcon={
|
||||||
|
<span role="button" aria-label={`Remove filter ${chip.label.split(' / ').pop()}`}>×</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
<Button size="small" type="link" onClick={onClear}>Clear all</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import Drawer from 'antd/lib/drawer';
|
||||||
|
import Button from 'antd/lib/button';
|
||||||
|
import Tree from 'antd/lib/tree';
|
||||||
|
import Tag from 'antd/lib/tag';
|
||||||
|
import Slider from 'antd/lib/slider';
|
||||||
|
import InputNumber from 'antd/lib/input-number';
|
||||||
|
import Empty from 'antd/lib/empty';
|
||||||
|
import Grid from 'antd/lib/grid';
|
||||||
|
import type { DataNode } from 'antd/es/tree';
|
||||||
|
import type { FilterOptions } from '../api';
|
||||||
|
import { ItemFilters, buildCategoryTree, CategoryNode } from '../filters';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
options: FilterOptions | null;
|
||||||
|
filters: ItemFilters;
|
||||||
|
onChange: (filters: ItemFilters) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
resultCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTreeData(nodes: CategoryNode[]): DataNode[] {
|
||||||
|
return nodes.map((node) => ({
|
||||||
|
key: node.id,
|
||||||
|
title: node.name,
|
||||||
|
children: node.children.length ? toTreeData(node.children) : undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const centsToDollars = (cents: number | null): number | null => (cents === null ? null : cents / 100);
|
||||||
|
const dollarsToCents = (dollars: number | null): number | null =>
|
||||||
|
dollars === null || Number.isNaN(dollars) ? null : Math.round(dollars * 100);
|
||||||
|
|
||||||
|
export default function FilterDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
options,
|
||||||
|
filters,
|
||||||
|
onChange,
|
||||||
|
onClear,
|
||||||
|
resultCount
|
||||||
|
}: Props) {
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const categories = options?.categories ?? [];
|
||||||
|
const tags = options?.tags ?? [];
|
||||||
|
const bounds = options?.priceRange ?? { min_cents: 0, max_cents: 0 };
|
||||||
|
|
||||||
|
function toggleTag(tagId: number) {
|
||||||
|
const next = filters.tagIds.includes(tagId)
|
||||||
|
? filters.tagIds.filter((id) => id !== tagId)
|
||||||
|
: [...filters.tagIds, tagId];
|
||||||
|
onChange({ ...filters, tagIds: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selecting the already-selected node clears the filter, so the tree doubles
|
||||||
|
// as its own "all items" control.
|
||||||
|
function selectCategory(keys: React.Key[]) {
|
||||||
|
const picked = keys.length ? Number(keys[0]) : null;
|
||||||
|
onChange({ ...filters, categoryId: picked === filters.categoryId ? null : picked });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliderMax = Math.max(bounds.max_cents, bounds.min_cents + 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title="Filters"
|
||||||
|
placement="right"
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
// Unmounting on close keeps a single copy of controls like "Clear all" in
|
||||||
|
// the document at any time.
|
||||||
|
destroyOnHidden
|
||||||
|
width={screens.md ? 380 : '90%'}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button block onClick={onClear}>Clear all</Button>
|
||||||
|
<Button block type="primary" onClick={onClose}>
|
||||||
|
Show {resultCount} {resultCount === 1 ? 'item' : 'items'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<section style={{ marginBottom: 28 }}>
|
||||||
|
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||||
|
Category
|
||||||
|
</h4>
|
||||||
|
{categories.length ? (
|
||||||
|
<Tree
|
||||||
|
treeData={toTreeData(buildCategoryTree(categories))}
|
||||||
|
selectedKeys={filters.categoryId === null ? [] : [filters.categoryId]}
|
||||||
|
onSelect={selectCategory}
|
||||||
|
defaultExpandAll
|
||||||
|
blockNode
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No categories yet" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: 28 }}>
|
||||||
|
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||||
|
Tags — must have all
|
||||||
|
</h4>
|
||||||
|
{tags.length ? (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const selected = filters.tagIds.includes(tag.id);
|
||||||
|
return (
|
||||||
|
// A real button rather than a styled span, so the pills are
|
||||||
|
// reachable by keyboard and announce their on/off state.
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={() => toggleTag(tag.id)}
|
||||||
|
style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<Tag
|
||||||
|
color={selected ? tag.color : undefined}
|
||||||
|
style={{ margin: 0, opacity: selected ? 1 : 0.75 }}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Tag>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No tags yet" />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||||
|
Price
|
||||||
|
</h4>
|
||||||
|
<Slider
|
||||||
|
range
|
||||||
|
min={bounds.min_cents}
|
||||||
|
max={sliderMax}
|
||||||
|
step={100}
|
||||||
|
value={[filters.minPriceCents ?? bounds.min_cents, filters.maxPriceCents ?? sliderMax]}
|
||||||
|
tooltip={{ formatter: (value) => `$${((value ?? 0) / 100).toFixed(0)}` }}
|
||||||
|
onChange={([min, max]) =>
|
||||||
|
onChange({ ...filters, minPriceCents: min, maxPriceCents: max })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
||||||
|
<InputNumber
|
||||||
|
aria-label="Minimum price"
|
||||||
|
prefix="$"
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={centsToDollars(filters.minPriceCents)}
|
||||||
|
onChange={(value) => onChange({ ...filters, minPriceCents: dollarsToCents(value) })}
|
||||||
|
/>
|
||||||
|
<span style={{ opacity: 0.6 }}>to</span>
|
||||||
|
<InputNumber
|
||||||
|
aria-label="Maximum price"
|
||||||
|
prefix="$"
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={centsToDollars(filters.maxPriceCents)}
|
||||||
|
onChange={(value) => onChange({ ...filters, maxPriceCents: dollarsToCents(value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { Card, Badge, Typography, Carousel, Button, message } from 'antd';
|
import { Card, Badge, Typography, Carousel, Button, message, Tag } from 'antd';
|
||||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
import type { CarouselRef } from 'antd/es/carousel';
|
import type { CarouselRef } from 'antd/es/carousel';
|
||||||
import { Item } from '../api';
|
import { Item } from '../api';
|
||||||
@@ -88,7 +88,15 @@ export default function ItemCard({ item, onChanged }: Props) {
|
|||||||
const card = (
|
const card = (
|
||||||
<Card hoverable cover={cover} className="item-card">
|
<Card hoverable cover={cover} className="item-card">
|
||||||
<Title level={5} style={{ marginBottom: 4 }}>{item.name}</Title>
|
<Title level={5} style={{ marginBottom: 4 }}>{item.name}</Title>
|
||||||
|
{item.category_name && <Text type="secondary" className="item-category">{item.category_name}</Text>}
|
||||||
<MarkdownView content={item.description} />
|
<MarkdownView content={item.description} />
|
||||||
|
{item.tags.length > 0 && (
|
||||||
|
<div className="item-tags">
|
||||||
|
{item.tags.map(tag => (
|
||||||
|
<Tag key={tag.id} color={tag.color} style={{ marginInlineEnd: 4 }}>{tag.name}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="price">${(item.price_cents / 100).toFixed(2)}</div>
|
<div className="price">${(item.price_cents / 100).toFixed(2)}</div>
|
||||||
{actionButton}
|
{actionButton}
|
||||||
<AuthPromptModal
|
<AuthPromptModal
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { Category } from './api';
|
||||||
|
|
||||||
|
export interface ItemFilters {
|
||||||
|
categoryId: number | null;
|
||||||
|
tagIds: number[];
|
||||||
|
minPriceCents: number | null;
|
||||||
|
maxPriceCents: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_FILTERS: ItemFilters = {
|
||||||
|
categoryId: null,
|
||||||
|
tagIds: [],
|
||||||
|
minPriceCents: null,
|
||||||
|
maxPriceCents: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
if (filters.categoryId !== null) params.set('category', String(filters.categoryId));
|
||||||
|
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));
|
||||||
|
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 tags = (params.get('tags') || '')
|
||||||
|
.split(',')
|
||||||
|
.map((part) => readInt(part))
|
||||||
|
.filter((id): id is number => id !== null && id > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
categoryId: readInt(params.get('category')),
|
||||||
|
tagIds: tags,
|
||||||
|
minPriceCents: readInt(params.get('min_price')),
|
||||||
|
maxPriceCents: readInt(params.get('max_price'))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export function activeFilterCount(filters: ItemFilters): number {
|
||||||
|
let count = 0;
|
||||||
|
if (filters.categoryId !== null) count++;
|
||||||
|
count += filters.tagIds.length;
|
||||||
|
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasActiveFilters(filters: ItemFilters): boolean {
|
||||||
|
return activeFilterCount(filters) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "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 '';
|
||||||
|
}
|
||||||
+43
-1
@@ -72,4 +72,46 @@ body { margin: 0; }
|
|||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Filter bar: the Filters button sits inline with the active-filter chips on
|
||||||
|
desktop; on a narrow screen the chips wrap onto their own line beneath it. */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-filter-chips {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-filter-chips .ant-tag {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-category {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 0;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575px) {
|
||||||
|
.filter-bar {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.filter-bar > .ant-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// The e2e database is shared and never reset, so every fixture name carries a
|
||||||
|
// unique suffix and assertions are scoped to the nodes this run created. The
|
||||||
|
// suffix is generated per test rather than per module: a worker can run this
|
||||||
|
// file more than once, and a module-level constant would collide with itself.
|
||||||
|
const suffix = () => `a${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
|
test.describe('Admin taxonomy', () => {
|
||||||
|
test('creates a category and a nested child that stays visible', async ({ page }) => {
|
||||||
|
const RUN = suffix();
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('tab', { name: 'Categories' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Category' }).click();
|
||||||
|
await page.getByLabel('Name').fill(`Furniture ${RUN}`);
|
||||||
|
await page.getByRole('button', { name: 'OK' }).click();
|
||||||
|
await expect(page.getByText(`Furniture ${RUN}`)).toBeVisible();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('treeitem')
|
||||||
|
.filter({ hasText: `Furniture ${RUN}` })
|
||||||
|
.getByRole('button', { name: 'Add child' })
|
||||||
|
.click();
|
||||||
|
await page.getByLabel('Name').fill(`Tables ${RUN}`);
|
||||||
|
await page.getByRole('button', { name: 'OK' }).click();
|
||||||
|
|
||||||
|
// The tree is mounted before this branch exists, so the child is only
|
||||||
|
// visible if expansion follows newly created nodes rather than the state
|
||||||
|
// captured at first render.
|
||||||
|
await expect(page.getByText(`Tables ${RUN}`)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates a tag with an automatically assigned colour', async ({ page }) => {
|
||||||
|
const RUN = suffix();
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('tab', { name: 'Tags' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Tag' }).click();
|
||||||
|
await page.getByLabel('Name').fill(`vintage-${RUN}`);
|
||||||
|
await page.getByRole('button', { name: 'OK' }).click();
|
||||||
|
// Clicking OK only dispatches the request; wait for the confirmation so the
|
||||||
|
// lookup below can't race the create.
|
||||||
|
await expect(page.getByText('Tag added')).toBeVisible();
|
||||||
|
|
||||||
|
// The table paginates and the shared database holds many tags, so the new
|
||||||
|
// row is confirmed through the API rather than hunted for across pages.
|
||||||
|
const tags = await (await page.request.get('/api/admin/tags')).json();
|
||||||
|
const created = tags.find((tag: { name: string }) => tag.name === `vintage-${RUN}`);
|
||||||
|
expect(created).toBeTruthy();
|
||||||
|
expect(created.color).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offers category and tag fields on the item form', async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('button', { name: 'Add Item' }).click();
|
||||||
|
|
||||||
|
const modal = page.getByRole('dialog');
|
||||||
|
await expect(modal.getByText('Category', { exact: true })).toBeVisible();
|
||||||
|
await expect(modal.getByText('Pick existing tags')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { test, expect, APIRequestContext } from '@playwright/test';
|
||||||
|
|
||||||
|
// The storefront shows every item ever seeded, and the e2e database is not
|
||||||
|
// reset between runs. Every fixture below is therefore suffixed with a unique
|
||||||
|
// run id so assertions can name exactly the items this run created.
|
||||||
|
// Playwright runs beforeAll once per worker, so the suffix mixes a timestamp
|
||||||
|
// with randomness — two workers starting in the same millisecond would
|
||||||
|
// otherwise seed colliding category names and 409 against each other.
|
||||||
|
const RUN = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
|
const NAMES = {
|
||||||
|
furniture: `Furniture ${RUN}`,
|
||||||
|
tables: `Tables ${RUN}`,
|
||||||
|
decor: `Decor ${RUN}`,
|
||||||
|
vintage: `vintage-${RUN}`,
|
||||||
|
oak: `oak-${RUN}`,
|
||||||
|
deepItem: `Deep table ${RUN}`,
|
||||||
|
midItem: `Mid chair ${RUN}`,
|
||||||
|
otherItem: `Wall art ${RUN}`,
|
||||||
|
dearItem: `Dear cabinet ${RUN}`
|
||||||
|
};
|
||||||
|
|
||||||
|
async function createCategory(api: APIRequestContext, name: string, parentId: number | null) {
|
||||||
|
const res = await api.post('/api/admin/categories', { data: { name, parent_id: parentId } });
|
||||||
|
expect(res.status()).toBe(201);
|
||||||
|
return (await res.json()).id as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTag(api: APIRequestContext, name: string) {
|
||||||
|
const res = await api.post('/api/admin/tags', { data: { name } });
|
||||||
|
expect(res.status()).toBe(201);
|
||||||
|
return (await res.json()).id as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createItem(
|
||||||
|
api: APIRequestContext,
|
||||||
|
name: string,
|
||||||
|
price: string,
|
||||||
|
categoryId: number | null,
|
||||||
|
tags: string[]
|
||||||
|
) {
|
||||||
|
const res = await api.post('/api/admin/items', {
|
||||||
|
multipart: {
|
||||||
|
name,
|
||||||
|
description: '',
|
||||||
|
price,
|
||||||
|
category_id: categoryId === null ? '' : String(categoryId),
|
||||||
|
tags: JSON.stringify(tags)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(async ({ playwright }) => {
|
||||||
|
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
|
||||||
|
|
||||||
|
// A worker can be handed tests from this file in more than one batch, which
|
||||||
|
// re-runs beforeAll against the module-cached suffix. Seeding twice would
|
||||||
|
// collide on the category names and duplicate every item, so re-entry is a
|
||||||
|
// no-op once the fixtures are in place.
|
||||||
|
const alreadySeeded = ((await (await api.get('/api/admin/categories')).json()) as { name: string }[])
|
||||||
|
.some((category) => category.name === NAMES.furniture);
|
||||||
|
if (alreadySeeded) {
|
||||||
|
await api.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const furniture = await createCategory(api, NAMES.furniture, null);
|
||||||
|
const tables = await createCategory(api, NAMES.tables, furniture);
|
||||||
|
const decor = await createCategory(api, NAMES.decor, null);
|
||||||
|
await createTag(api, NAMES.vintage);
|
||||||
|
await createTag(api, NAMES.oak);
|
||||||
|
|
||||||
|
// Filed one level below the category the tests select, to prove descendant
|
||||||
|
// matching rather than an exact-node match.
|
||||||
|
await createItem(api, NAMES.deepItem, '340', tables, [NAMES.vintage, NAMES.oak]);
|
||||||
|
await createItem(api, NAMES.midItem, '120', furniture, [NAMES.vintage]);
|
||||||
|
await createItem(api, NAMES.otherItem, '90', decor, [NAMES.vintage, NAMES.oak]);
|
||||||
|
await createItem(api, NAMES.dearItem, '5000', tables, [NAMES.vintage, NAMES.oak]);
|
||||||
|
|
||||||
|
await api.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
function card(page: import('@playwright/test').Page, name: string) {
|
||||||
|
return page.getByRole('heading', { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Storefront filters', () => {
|
||||||
|
test('filters by category, including everything filed beneath it', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(card(page, NAMES.otherItem)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
await page.getByRole('treeitem', { name: NAMES.furniture }).click();
|
||||||
|
|
||||||
|
// Both the item filed directly in Furniture and the one nested under
|
||||||
|
// Furniture > Tables must survive.
|
||||||
|
await expect(card(page, NAMES.midItem)).toBeVisible();
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
||||||
|
await expect(card(page, NAMES.otherItem)).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a nested category is reachable in the drawer', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
|
||||||
|
// The tree loads after the drawer mounts, so anything below the roots is
|
||||||
|
// only reachable if expansion tracks the loaded data rather than the state
|
||||||
|
// at mount time.
|
||||||
|
await page.getByRole('treeitem', { name: NAMES.tables }).click();
|
||||||
|
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
||||||
|
await expect(card(page, NAMES.midItem)).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires every selected tag rather than any of them', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: NAMES.vintage }).click();
|
||||||
|
await expect(card(page, NAMES.midItem)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: NAMES.oak }).click();
|
||||||
|
// midItem carries only `vintage`, so adding `oak` must drop it.
|
||||||
|
await expect(card(page, NAMES.midItem)).toBeHidden();
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters by price range', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
|
||||||
|
await page.getByLabel('Minimum price').fill('200');
|
||||||
|
await page.getByLabel('Maximum price').fill('1000');
|
||||||
|
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
||||||
|
await expect(card(page, NAMES.midItem)).toBeHidden();
|
||||||
|
await expect(card(page, NAMES.dearItem)).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing a chip widens the results again', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
await page.getByRole('treeitem', { name: NAMES.decor }).click();
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: `Remove filter ${NAMES.decor}` }).click();
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clear all removes every active filter', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
await page.getByRole('treeitem', { name: NAMES.decor }).click();
|
||||||
|
await page.getByRole('button', { name: NAMES.vintage }).click();
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
|
||||||
|
// Scoped to the chip row: the drawer carries a "Clear all" of its own.
|
||||||
|
await page
|
||||||
|
.getByRole('group', { name: 'Active filters' })
|
||||||
|
.getByRole('button', { name: 'Clear all' })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
||||||
|
await expect(card(page, NAMES.otherItem)).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a filtered view survives a reload', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
await page.getByRole('treeitem', { name: NAMES.furniture }).click();
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/category=\d+/);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(card(page, NAMES.deepItem)).toBeVisible();
|
||||||
|
await expect(card(page, NAMES.otherItem)).toBeHidden();
|
||||||
|
await expect(page.getByRole('button', { name: `Remove filter ${NAMES.furniture}` })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows an item\'s tags on its card', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /Filters/ }).click();
|
||||||
|
await page.getByRole('treeitem', { name: NAMES.decor }).click();
|
||||||
|
await page.getByRole('button', { name: 'Close' }).click();
|
||||||
|
|
||||||
|
const wallArt = page.locator('.item-card').filter({ hasText: NAMES.otherItem });
|
||||||
|
await expect(wallArt.getByText(NAMES.vintage)).toBeVisible();
|
||||||
|
await expect(wallArt.getByText(NAMES.oak)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user