Feature/favorites filter issue #35 #55

Merged
bermudalamb merged 2 commits from feature/favorites-filter into main 2026-08-18 15:17:34 -05:00
10 changed files with 451 additions and 15 deletions
Showing only changes of commit d4e2abe743 - Show all commits
+45 -2
View File
@@ -10,6 +10,10 @@ export interface ItemFilters {
minPriceCents: number | null; minPriceCents: number | null;
maxPriceCents: number | null; maxPriceCents: number | null;
status: ItemStatus | null; status: ItemStatus | null;
// Storefront only: "just the items I have favorited". Which customer that
// means is not part of the parsed filter — it comes from the session at build
// time, so a query string can never name someone else's favorites.
favoritesOnly: boolean;
} }
export type ItemStatus = 'available' | 'reserved' | 'sold'; export type ItemStatus = 'available' | 'reserved' | 'sold';
@@ -29,6 +33,12 @@ export interface BuiltFilter {
// '10.5' are caller mistakes worth surfacing rather than silently coercing. // '10.5' are caller mistakes worth surfacing rather than silently coercing.
const NON_NEGATIVE_INTEGER = /^\d+$/; const NON_NEGATIVE_INTEGER = /^\d+$/;
// Accepts both spellings because these URLs get hand-edited and shared, but
// nothing else: '?favorites=yes' is a mistake worth reporting rather than
// treating as either on or off.
const TRUE_VALUES: readonly string[] = ['1', 'true'];
const FALSE_VALUES: readonly string[] = ['0', 'false'];
function singleValue(value: unknown, name: string): string | null { function singleValue(value: unknown, name: string): string | null {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return null; return null;
@@ -107,12 +117,33 @@ export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
status = statusRaw as ItemStatus; status = statusRaw as ItemStatus;
} }
return { categoryId, tagIds, minPriceCents, maxPriceCents, status }; const favoritesRaw = singleValue(query.favorites, 'favorites');
let favoritesOnly = false;
if (favoritesRaw !== null && favoritesRaw !== '') {
if (TRUE_VALUES.includes(favoritesRaw)) {
favoritesOnly = true;
} else if (!FALSE_VALUES.includes(favoritesRaw)) {
throw new FilterError('invalid favorites');
}
}
return { categoryId, tagIds, minPriceCents, maxPriceCents, status, favoritesOnly };
} }
// Returns WHERE fragments plus their parameters, with placeholders numbered // Returns WHERE fragments plus their parameters, with placeholders numbered
// from `startIndex` so the caller can splice these in after its own params. // from `startIndex` so the caller can splice these in after its own params.
export function buildItemFilterSql(filters: ItemFilters, startIndex: number): BuiltFilter { //
// `favoritesCustomerId` is required rather than optional so a caller has to say
// whose favorites it means, even when it means nobody's. Both routes already
// reject a favorites filter they cannot satisfy, so reaching the throw below is
// a programming error — but it is here so that a future caller which forgets
// the guard fails loudly instead of quietly ignoring the filter and listing the
// whole catalogue.
export function buildItemFilterSql(
filters: ItemFilters,
startIndex: number,
favoritesCustomerId: number | null
): BuiltFilter {
const clauses: string[] = []; const clauses: string[] = [];
const params: unknown[] = []; const params: unknown[] = [];
let next = startIndex; let next = startIndex;
@@ -164,5 +195,17 @@ export function buildItemFilterSql(filters: ItemFilters, startIndex: number): Bu
next++; next++;
} }
if (filters.favoritesOnly) {
if (favoritesCustomerId === null) {
throw new Error('favorites filter requires a customer id');
}
params.push(favoritesCustomerId);
// EXISTS rather than a join: an item is favorited by a customer at most
// once, but joining would still risk multiplying rows if that ever changed,
// and this reads as the membership test it is.
clauses.push(`EXISTS (SELECT 1 FROM favorites f WHERE f.item_id = i.id AND f.customer_id = $${next})`);
next++;
}
return { clauses, params }; return { clauses, params };
} }
+7 -1
View File
@@ -128,7 +128,13 @@ router.get('/items', asyncRoute(async (req: Request, res: Response) => {
throw err; throw err;
} }
const { clauses, params } = buildItemFilterSql(filters, 1); // Favorites belong to a customer, and the admin inventory view is not
// browsing as one. Refused rather than ignored so the mistake is visible.
if (filters.favoritesOnly) {
return res.status(400).json({ error: 'favorites is not a valid inventory filter' });
}
const { clauses, params } = buildItemFilterSql(filters, 1, null);
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params); const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
res.json(rows); res.json(rows);
+10 -1
View File
@@ -19,7 +19,16 @@ router.get('/', asyncRoute(async (req: Request, res: Response) => {
throw err; throw err;
} }
const { clauses, params } = buildItemFilterSql(filters, 1); // 401 rather than an empty list: a signed-out visitor asking for "my
// favorites" has no favorites to be empty of, and answering with [] would
// render as "no items match these filters" — a plausible-looking lie. The
// storefront prompts for sign-in instead of sending this, so reaching here
// means a bookmarked link outlived its session.
if (filters.favoritesOnly && !req.customerId) {
return res.status(401).json({ error: 'sign in to filter by favorites' });
}
const { clauses, params } = buildItemFilterSql(filters, 1, req.customerId ?? null);
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params); const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
res.json(rows); res.json(rows);
@@ -304,3 +304,89 @@ describe('notifying when a favorited item is deleted', () => {
expect(rows[0].n).toBe(0); expect(rows[0].n).toBe(0);
}); });
}); });
describe('filtering the storefront by favorites', () => {
it('returns only the favorited items', async () => {
const favorited = await createItem('Oak table');
await createItem('Elm bench');
const { agent } = await register('filter@example.com');
await agent.post(`/api/customers/me/favorites/${favorited}`);
const res = await agent.get('/api/items?favorites=1');
expect(res.status).toBe(200);
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
});
it('keeps one customer out of another customer\'s favorites', async () => {
const mine = await createItem('Oak table');
const theirs = await createItem('Elm bench');
const { agent: me } = await register('mine@example.com');
const { agent: them } = await register('theirs@example.com');
await me.post(`/api/customers/me/favorites/${mine}`);
await them.post(`/api/customers/me/favorites/${theirs}`);
const res = await me.get('/api/items?favorites=1');
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
});
it('still shows a favorite that has sold', async () => {
const itemId = await createItem('Oak table');
const { agent } = await register('sold@example.com');
await agent.post(`/api/customers/me/favorites/${itemId}`);
await pool.query(`UPDATE items SET status = 'sold' WHERE id = $1`, [itemId]);
const res = await agent.get('/api/items?favorites=1');
// The storefront shows sold items everywhere else, and a favorite that has
// just sold is often exactly what the customer came back to look at.
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
});
it('combines with the other filters rather than replacing them', async () => {
const cheap = await createItem('Oak table');
const dear = await createItem('Elm bench');
await pool.query(`UPDATE items SET price_cents = 90000 WHERE id = $1`, [dear]);
const { agent } = await register('combined@example.com');
await agent.post(`/api/customers/me/favorites/${cheap}`);
await agent.post(`/api/customers/me/favorites/${dear}`);
const res = await agent.get('/api/items?favorites=1&max_price=50000');
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
});
it('answers 401 rather than an empty list when nobody is signed in', async () => {
await createItem('Oak table');
const res = await request(app).get('/api/items?favorites=1');
// An empty array would render as "no items match these filters", telling a
// signed-out visitor they have no favorites instead of that we do not know
// who they are.
expect(res.status).toBe(401);
});
it('rejects a favorites value that is neither on nor off', async () => {
const { agent } = await register('bogus@example.com');
expect((await agent.get('/api/items?favorites=yes')).status).toBe(400);
});
it('leaves the catalogue alone when the flag is off', async () => {
await createItem('Oak table');
await createItem('Elm bench');
const { agent } = await register('off@example.com');
const res = await agent.get('/api/items?favorites=0');
expect(res.body).toHaveLength(2);
});
it('refuses the favorites filter on the admin inventory', async () => {
const res = await request(app).get('/api/admin/items?favorites=1');
expect(res.status).toBe(400);
});
});
+49 -7
View File
@@ -7,7 +7,8 @@ describe('parseItemFilters', () => {
tagIds: [], tagIds: [],
minPriceCents: null, minPriceCents: null,
maxPriceCents: null, maxPriceCents: null,
status: null status: null,
favoritesOnly: false
}); });
}); });
@@ -85,6 +86,26 @@ describe('parseItemFilters', () => {
expect(parseItemFilters({ status: '' }).status).toBeNull(); expect(parseItemFilters({ status: '' }).status).toBeNull();
}); });
it('parses the favorites flag in both spellings', () => {
expect(parseItemFilters({ favorites: '1' }).favoritesOnly).toBe(true);
expect(parseItemFilters({ favorites: 'true' }).favoritesOnly).toBe(true);
});
it('treats an explicit off value as off', () => {
expect(parseItemFilters({ favorites: '0' }).favoritesOnly).toBe(false);
expect(parseItemFilters({ favorites: 'false' }).favoritesOnly).toBe(false);
expect(parseItemFilters({ favorites: '' }).favoritesOnly).toBe(false);
});
it('defaults favorites to off', () => {
expect(parseItemFilters({}).favoritesOnly).toBe(false);
});
it('rejects a favorites value that is neither on nor off', () => {
expect(() => parseItemFilters({ favorites: 'yes' })).toThrow(FilterError);
expect(() => parseItemFilters({ favorites: 'mine' })).toThrow(FilterError);
});
it('rejects a status outside the known set', () => { it('rejects a status outside the known set', () => {
expect(() => parseItemFilters({ status: 'pending' })).toThrow(FilterError); expect(() => parseItemFilters({ status: 'pending' })).toThrow(FilterError);
}); });
@@ -96,19 +117,19 @@ describe('parseItemFilters', () => {
describe('buildItemFilterSql', () => { describe('buildItemFilterSql', () => {
it('produces no clauses and no params when nothing is filtered', () => { it('produces no clauses and no params when nothing is filtered', () => {
const built = buildItemFilterSql(parseItemFilters({}), 1); const built = buildItemFilterSql(parseItemFilters({}), 1, null);
expect(built.clauses).toEqual([]); expect(built.clauses).toEqual([]);
expect(built.params).toEqual([]); expect(built.params).toEqual([]);
}); });
it('matches a category and all of its descendants', () => { it('matches a category and all of its descendants', () => {
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 1); const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 1, null);
expect(built.clauses.join(' ')).toContain('RECURSIVE'); expect(built.clauses.join(' ')).toContain('RECURSIVE');
expect(built.params).toEqual([4]); expect(built.params).toEqual([4]);
}); });
it('requires every listed tag rather than any of them', () => { it('requires every listed tag rather than any of them', () => {
const built = buildItemFilterSql(parseItemFilters({ tags: '1,2' }), 1); const built = buildItemFilterSql(parseItemFilters({ tags: '1,2' }), 1, null);
// The count of matched tag rows must equal the number of tags requested — // 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. // an ANY/IN match alone would return items carrying just one of them.
expect(built.clauses.join(' ')).toContain('COUNT(*)'); expect(built.clauses.join(' ')).toContain('COUNT(*)');
@@ -116,20 +137,41 @@ describe('buildItemFilterSql', () => {
}); });
it('numbers placeholders from the given starting index', () => { it('numbers placeholders from the given starting index', () => {
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 3); const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 3, null);
expect(built.clauses.join(' ')).toContain('$3'); expect(built.clauses.join(' ')).toContain('$3');
}); });
it('filters on status', () => { it('filters on status', () => {
const built = buildItemFilterSql(parseItemFilters({ status: 'reserved' }), 1); const built = buildItemFilterSql(parseItemFilters({ status: 'reserved' }), 1, null);
expect(built.clauses.join(' ')).toContain('i.status'); expect(built.clauses.join(' ')).toContain('i.status');
expect(built.params).toEqual(['reserved']); expect(built.params).toEqual(['reserved']);
}); });
it('restricts to the favorites of the given customer', () => {
const built = buildItemFilterSql(parseItemFilters({ favorites: '1' }), 1, 42);
expect(built.clauses.join(' ')).toContain('EXISTS');
expect(built.clauses.join(' ')).toContain('favorites f');
expect(built.params).toEqual([42]);
});
it('does not restrict to favorites when the flag is off, even given a customer', () => {
const built = buildItemFilterSql(parseItemFilters({}), 1, 42);
expect(built.clauses).toEqual([]);
expect(built.params).toEqual([]);
});
// Both routes reject this before reaching the builder, so it can only happen
// through a new caller that forgot to. Failing loudly beats dropping the
// clause and returning the whole catalogue as if it were someone's favorites.
it('throws rather than ignore a favorites filter with no customer', () => {
expect(() => buildItemFilterSql(parseItemFilters({ favorites: '1' }), 1, null)).toThrow();
});
it('continues numbering across multiple filters', () => { it('continues numbering across multiple filters', () => {
const built = buildItemFilterSql( const built = buildItemFilterSql(
parseItemFilters({ category: '4', min_price: '100', max_price: '900' }), parseItemFilters({ category: '4', min_price: '100', max_price: '900' }),
1 1,
null
); );
expect(built.params).toEqual([4, 100, 900]); expect(built.params).toEqual([4, 100, 900]);
const sql = built.clauses.join(' '); const sql = built.clauses.join(' ');
+38 -2
View File
@@ -13,6 +13,7 @@ import {
filtersToSearchParams, filtersToSearchParams,
hasActiveFilters hasActiveFilters
} from './filters'; } from './filters';
import AuthPromptModal from './customer/AuthPromptModal';
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';
@@ -30,9 +31,10 @@ export default function App() {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const [options, setOptions] = useState<FilterOptions | null>(null); const [options, setOptions] = useState<FilterOptions | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { mode, toggle } = useThemeMode(); const { mode, toggle } = useThemeMode();
const { customer } = useCustomerAuth(); const { customer, loading: authLoading } = useCustomerAuth();
const { items: cartItems } = useCart(); const { items: cartItems } = useCart();
const { token } = theme.useToken(); const { token } = theme.useToken();
@@ -41,6 +43,12 @@ export default function App() {
const filters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]); const filters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]);
const filterKey = filtersToSearchParams(filters).toString(); const filterKey = filtersToSearchParams(filters).toString();
// "Only my favorites" needs to know who is asking. Until the session has
// resolved we hold rather than guess: firing the request early would 401 and
// show the outage banner to someone who is in fact signed in.
const awaitingAuth = filters.favoritesOnly && authLoading;
const needsFavoritesAuth = filters.favoritesOnly && !authLoading && !customer;
const applyFilters = useCallback( const applyFilters = useCallback(
(next: ItemFilters) => { (next: ItemFilters) => {
// replace, not push: dragging a slider shouldn't bury the previous page // replace, not push: dragging a slider shouldn't bury the previous page
@@ -68,10 +76,24 @@ export default function App() {
}, [filterKey]); }, [filterKey]);
useEffect(() => { useEffect(() => {
if (awaitingAuth) {
setLoading(true);
return;
}
// Prompt instead of requesting. The server would answer 401, and rendering
// that as "no items match these filters" would tell a signed-out visitor
// they have no favorites rather than that we do not know who they are.
if (needsFavoritesAuth) {
setItems([]);
setFailed(false);
setLoading(false);
setAuthModalOpen(true);
return;
}
setLoading(true); setLoading(true);
const timer = setTimeout(load, FILTER_DEBOUNCE_MS); const timer = setTimeout(load, FILTER_DEBOUNCE_MS);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [load]); }, [load, awaitingAuth, needsFavoritesAuth]);
useEffect(() => { useEffect(() => {
fetchFilterOptions().then(setOptions).catch(() => setOptions(null)); fetchFilterOptions().then(setOptions).catch(() => setOptions(null));
@@ -141,6 +163,11 @@ export default function App() {
description="The server didn't return the catalogue. This is usually temporary." description="The server didn't return the catalogue. This is usually temporary."
action={<Button size="small" onClick={() => { setLoading(true); load(); }}>Retry</Button>} action={<Button size="small" onClick={() => { setLoading(true); load(); }}>Retry</Button>}
/> />
) : needsFavoritesAuth ? (
<Empty description="Sign in to see the items you have favorited">
<Button type="primary" onClick={() => setAuthModalOpen(true)}>Sign in</Button>
<Button style={{ marginInlineStart: 8 }} onClick={clearFilters}>Browse everything</Button>
</Empty>
) : !loading && !items.length ? ( ) : !loading && !items.length ? (
<Empty <Empty
description={ description={
@@ -174,6 +201,15 @@ export default function App() {
onClear={clearFilters} onClear={clearFilters}
resultCount={items.length} resultCount={items.length}
/> />
{/* The same prompt the heart button and Add to Cart use. Signing in
resolves the gate above, and the filter then applies on its own — the
customer never has to set it a second time. */}
<AuthPromptModal
open={authModalOpen}
onClose={() => setAuthModalOpen(false)}
onSuccess={() => setAuthModalOpen(false)}
/>
</Layout> </Layout>
); );
} }
@@ -18,6 +18,16 @@ export default function ActiveFilterChips({ options, filters, onChange, onClear
const chips: { key: string; label: string; onRemove: () => void }[] = []; const chips: { key: string; label: string; onRemove: () => void }[] = [];
// Listed first so it matches the drawer's ordering, and because it is the
// chip most worth noticing when a customer wonders why the grid looks short.
if (filters.favoritesOnly) {
chips.push({
key: 'favorites',
label: 'My favorites',
onRemove: () => onChange({ ...filters, favoritesOnly: false })
});
}
if (filters.categoryId !== null) { if (filters.categoryId !== null) {
const path = categoryPath(categories, filters.categoryId); const path = categoryPath(categories, filters.categoryId);
// Falls back to the raw id while /api/filters is still loading, so the chip // Falls back to the raw id while /api/filters is still loading, so the chip
+23
View File
@@ -5,6 +5,7 @@ import Tag from 'antd/es/tag';
import Slider from 'antd/es/slider'; import Slider from 'antd/es/slider';
import InputNumber from 'antd/es/input-number'; import InputNumber from 'antd/es/input-number';
import Empty from 'antd/es/empty'; import Empty from 'antd/es/empty';
import Switch from 'antd/es/switch';
import Grid from 'antd/es/grid'; import Grid from 'antd/es/grid';
import type { DataNode } from 'antd/es/tree'; import type { DataNode } from 'antd/es/tree';
import type { FilterOptions } from '../api'; import type { FilterOptions } from '../api';
@@ -81,6 +82,28 @@ export default function FilterDrawer({
</div> </div>
} }
> >
{/* First because it is the broadest cut, and because a customer who came
here for their favorites should not have to scroll past the catalogue
controls to find it. Shown to signed-out visitors too: switching it on
prompts them to sign in, which is how they learn favorites exist. */}
<section style={{ marginBottom: 28 }}>
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
Favorites
</h4>
{/* Deliberately not wrapped in a <label>: antd renders the switch as a
button, which is labelable, so a wrapping label can forward a click
the switch already handled and toggle it twice. The accessible name
comes from aria-label instead. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Switch
checked={filters.favoritesOnly}
onChange={(checked) => onChange({ ...filters, favoritesOnly: checked })}
aria-label="Only my favorites"
/>
<span>Only my favorites</span>
</div>
</section>
<section style={{ marginBottom: 28 }}> <section style={{ marginBottom: 28 }}>
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}> <h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
Category Category
+12 -2
View File
@@ -10,6 +10,10 @@ export interface ItemFilters {
// Only the admin Inventory tab sets this; the storefront leaves it null and // Only the admin Inventory tab sets this; the storefront leaves it null and
// shows every status, as it always has. // shows every status, as it always has.
status: ItemStatus | null; status: ItemStatus | null;
// Storefront only, and only meaningful when signed in. Sold favorites are
// included: the storefront shows sold items everywhere else, and a favorite
// that has just sold is often exactly what the customer came to look at.
favoritesOnly: boolean;
} }
export const EMPTY_FILTERS: ItemFilters = { export const EMPTY_FILTERS: ItemFilters = {
@@ -17,7 +21,8 @@ export const EMPTY_FILTERS: ItemFilters = {
tagIds: [], tagIds: [],
minPriceCents: null, minPriceCents: null,
maxPriceCents: null, maxPriceCents: null,
status: null status: null,
favoritesOnly: false
}; };
// Filters live in the URL so a filtered view can be linked, bookmarked, and // Filters live in the URL so a filtered view can be linked, bookmarked, and
@@ -30,6 +35,7 @@ export function filtersToSearchParams(filters: ItemFilters): URLSearchParams {
if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents)); if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents));
if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents)); if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents));
if (filters.status !== null) params.set('status', filters.status); if (filters.status !== null) params.set('status', filters.status);
if (filters.favoritesOnly) params.set('favorites', '1');
return params; return params;
} }
@@ -50,12 +56,15 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
? rawStatus ? rawStatus
: null; : null;
const favorites = params.get('favorites');
return { return {
categoryId: readInt(params.get('category')), categoryId: readInt(params.get('category')),
tagIds: tags, tagIds: tags,
minPriceCents: readInt(params.get('min_price')), minPriceCents: readInt(params.get('min_price')),
maxPriceCents: readInt(params.get('max_price')), maxPriceCents: readInt(params.get('max_price')),
status status,
favoritesOnly: favorites === '1' || favorites === 'true'
}; };
} }
@@ -67,6 +76,7 @@ export function activeFilterCount(filters: ItemFilters): number {
count += filters.tagIds.length; count += filters.tagIds.length;
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++; if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
if (filters.status !== null) count++; if (filters.status !== null) count++;
if (filters.favoritesOnly) count++;
return count; return count;
} }
+171
View File
@@ -0,0 +1,171 @@
import { test, expect, Page } from '@playwright/test';
const PASSWORD = 'supersecret123';
// The storefront runs against a shared database that is never reset, so every
// name has to be unique to this run or a rerun would match the last one's rows.
const RUN = `ff${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const KEPT = `Kept ${RUN}`;
const OTHER = `Other ${RUN}`;
// Its own item because this run marks it sold, and the suite is fullyParallel:
// mutating an item the other tests read would make them race.
const SELLS = `Sells ${RUN}`;
const uniqueEmail = () => `favfilter-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
test.beforeAll(async ({ playwright }) => {
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
// Two items, priced apart so one test can prove favorites combines with the
// price filter rather than replacing it.
for (const [name, price] of [[KEPT, '60'], [OTHER, '900'], [SELLS, '70']] as const) {
const res = await api.post('/api/admin/items', {
multipart: { name, description: '', price, category_id: '', tags: '[]' }
});
expect(res.ok()).toBeTruthy();
}
await api.dispose();
});
async function register(page: Page) {
await page.goto('/register');
await page.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail());
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page).toHaveURL(/\/account/);
}
// The session is still resolving for a moment after a remount, and the
// favorites filter deliberately waits it out rather than guessing. Waiting for
// the account link is what a real customer sees settle.
async function gotoStorefrontSignedIn(page: Page) {
await page.goto('/');
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible();
}
async function favorite(page: Page, itemName: string) {
await page.getByRole('button', { name: `Add ${itemName} to favorites` }).click();
// The alert opt-in is offered on every favorite until it is accepted, so it
// is always there to decline. Clicked rather than probed with isVisible():
// that check does not wait, so it loses the race with the modal appearing and
// leaves it open to block everything the test does next.
const decline = page.getByRole('dialog').getByRole('button', { name: 'No thanks' });
await decline.click();
// Its wrapper goes on intercepting pointer events while it fades out.
await expect(decline).toBeHidden();
await expect(page.getByRole('button', { name: `Remove ${itemName} from favorites` })).toBeVisible();
}
async function openFilters(page: Page) {
await page.getByRole('button', { name: /Filters/ }).click();
await expect(favoritesSwitch(page)).toBeVisible();
}
const favoritesSwitch = (page: Page) => page.getByRole('switch', { name: 'Only my favorites' });
test.describe('Filtering the storefront by favorites', () => {
test('narrows the grid to favorited items and puts it in the URL', async ({ page }) => {
await register(page);
await gotoStorefrontSignedIn(page);
await favorite(page, KEPT);
await openFilters(page);
await favoritesSwitch(page).click();
// Close the drawer before reading the grid behind it, as the other filter
// tests do.
await page.getByRole('button', { name: 'Close' }).click();
await expect(page.getByRole('button', { name: `Remove ${KEPT} from favorites` })).toBeVisible();
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeHidden();
// In the URL so the view can be linked, bookmarked, and reloaded.
await expect(page).toHaveURL(/favorites=1/);
});
test('survives a reload, since the URL is the source of truth', async ({ page }) => {
await register(page);
await gotoStorefrontSignedIn(page);
await favorite(page, KEPT);
await page.goto('/?favorites=1');
await expect(page.getByRole('button', { name: `Remove ${KEPT} from favorites` })).toBeVisible();
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeHidden();
});
test('shows a removable chip that restores the full catalogue', async ({ page }) => {
await register(page);
await gotoStorefrontSignedIn(page);
await favorite(page, KEPT);
await page.goto('/?favorites=1');
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeHidden();
const chips = page.getByRole('group', { name: 'Active filters' });
await expect(chips).toContainText('My favorites');
await chips.getByRole('button', { name: 'Remove filter My favorites' }).click();
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeVisible();
await expect(page).not.toHaveURL(/favorites/);
});
test('combines with the price filter rather than replacing it', async ({ page }) => {
await register(page);
await gotoStorefrontSignedIn(page);
await favorite(page, KEPT);
await favorite(page, OTHER);
// Both are favorited; only one is under the price cap.
await page.goto('/?favorites=1&max_price=50000');
await expect(page.getByRole('button', { name: `Remove ${KEPT} from favorites` })).toBeVisible();
await expect(page.getByRole('button', { name: `Remove ${OTHER} from favorites` })).toBeHidden();
});
test('prompts a signed-out visitor to sign in, then applies the filter', async ({ page }) => {
await page.goto('/');
await openFilters(page);
await favoritesSwitch(page).click();
// The same inline prompt the heart and Add to Cart use, rather than an
// empty grid implying the visitor has no favorites.
const prompt = page.getByRole('dialog', { name: /Create an account/ });
await expect(prompt).toBeVisible();
await prompt.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail());
await prompt.getByLabel('Password').fill(PASSWORD);
await prompt.getByRole('button', { name: 'Create account' }).click();
// Signing in resolves the gate and the filter applies on its own — the
// customer never sets it twice. A brand-new account has no favorites yet.
await expect(page.getByText('No items match these filters')).toBeVisible();
await expect(page).toHaveURL(/favorites=1/);
});
test('explains itself when a favorites link is opened without a session', async ({ page }) => {
// A bookmarked filtered view whose session has since expired. The grid must
// not claim there are no matching items, which would read as "you have no
// favorites" rather than "we do not know who you are".
await page.goto('/?favorites=1');
await expect(page.getByText('Sign in to see the items you have favorited')).toBeVisible();
await expect(page.getByText('No items match these filters')).toBeHidden();
});
test('keeps showing a favorite after it sells', async ({ page, playwright }) => {
await register(page);
await gotoStorefrontSignedIn(page);
await favorite(page, SELLS);
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
const items = await (await api.get('/api/items')).json();
const sells = items.find((item: { name: string }) => item.name === SELLS);
expect(await (await api.post(`/api/admin/items/${sells.id}/mark-sold`)).ok()).toBeTruthy();
await api.dispose();
await page.goto('/?favorites=1');
// Hiding it would make an item the customer curated vanish without
// explanation, right after they were emailed to say it had sold.
await expect(page.getByRole('button', { name: `Remove ${SELLS} from favorites` })).toBeVisible();
// Scoped to this item's cell: the ribbon sits outside the card, and other
// sold items from earlier runs are on the same page.
await expect(page.locator('.ant-col').filter({ hasText: SELLS })).toContainText('SOLD');
});
});