Feature/favorites filter issue #35 #55
@@ -10,6 +10,10 @@ export interface ItemFilters {
|
||||
minPriceCents: number | null;
|
||||
maxPriceCents: number | 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';
|
||||
@@ -29,6 +33,12 @@ export interface BuiltFilter {
|
||||
// '10.5' are caller mistakes worth surfacing rather than silently coercing.
|
||||
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 {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
@@ -107,12 +117,33 @@ export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
|
||||
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
|
||||
// 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 params: unknown[] = [];
|
||||
let next = startIndex;
|
||||
@@ -164,5 +195,17 @@ export function buildItemFilterSql(filters: ItemFilters, startIndex: number): Bu
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -128,7 +128,13 @@ router.get('/items', asyncRoute(async (req: Request, res: Response) => {
|
||||
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 { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
|
||||
res.json(rows);
|
||||
|
||||
@@ -19,7 +19,16 @@ router.get('/', asyncRoute(async (req: Request, res: Response) => {
|
||||
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 { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
|
||||
res.json(rows);
|
||||
|
||||
@@ -304,3 +304,89 @@ describe('notifying when a favorited item is deleted', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,8 @@ describe('parseItemFilters', () => {
|
||||
tagIds: [],
|
||||
minPriceCents: null,
|
||||
maxPriceCents: null,
|
||||
status: null
|
||||
status: null,
|
||||
favoritesOnly: false
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +86,26 @@ describe('parseItemFilters', () => {
|
||||
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', () => {
|
||||
expect(() => parseItemFilters({ status: 'pending' })).toThrow(FilterError);
|
||||
});
|
||||
@@ -96,19 +117,19 @@ describe('parseItemFilters', () => {
|
||||
|
||||
describe('buildItemFilterSql', () => {
|
||||
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.params).toEqual([]);
|
||||
});
|
||||
|
||||
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.params).toEqual([4]);
|
||||
});
|
||||
|
||||
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 —
|
||||
// an ANY/IN match alone would return items carrying just one of them.
|
||||
expect(built.clauses.join(' ')).toContain('COUNT(*)');
|
||||
@@ -116,20 +137,41 @@ describe('buildItemFilterSql', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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.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', () => {
|
||||
const built = buildItemFilterSql(
|
||||
parseItemFilters({ category: '4', min_price: '100', max_price: '900' }),
|
||||
1
|
||||
1,
|
||||
null
|
||||
);
|
||||
expect(built.params).toEqual([4, 100, 900]);
|
||||
const sql = built.clauses.join(' ');
|
||||
|
||||
+38
-2
@@ -13,6 +13,7 @@ import {
|
||||
filtersToSearchParams,
|
||||
hasActiveFilters
|
||||
} from './filters';
|
||||
import AuthPromptModal from './customer/AuthPromptModal';
|
||||
import { useThemeMode } from './theme/ThemeContext';
|
||||
import { useCustomerAuth } from './customer/CustomerAuthContext';
|
||||
import { useCart } from './cart/CartContext';
|
||||
@@ -30,9 +31,10 @@ export default function App() {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [options, setOptions] = useState<FilterOptions | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { mode, toggle } = useThemeMode();
|
||||
const { customer } = useCustomerAuth();
|
||||
const { customer, loading: authLoading } = useCustomerAuth();
|
||||
const { items: cartItems } = useCart();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
@@ -41,6 +43,12 @@ export default function App() {
|
||||
const filters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]);
|
||||
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(
|
||||
(next: ItemFilters) => {
|
||||
// replace, not push: dragging a slider shouldn't bury the previous page
|
||||
@@ -68,10 +76,24 @@ export default function App() {
|
||||
}, [filterKey]);
|
||||
|
||||
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);
|
||||
const timer = setTimeout(load, FILTER_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [load]);
|
||||
}, [load, awaitingAuth, needsFavoritesAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
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."
|
||||
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 ? (
|
||||
<Empty
|
||||
description={
|
||||
@@ -174,6 +201,15 @@ export default function App() {
|
||||
onClear={clearFilters}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,16 @@ export default function ActiveFilterChips({ options, filters, onChange, onClear
|
||||
|
||||
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) {
|
||||
const path = categoryPath(categories, filters.categoryId);
|
||||
// Falls back to the raw id while /api/filters is still loading, so the chip
|
||||
|
||||
@@ -5,6 +5,7 @@ import Tag from 'antd/es/tag';
|
||||
import Slider from 'antd/es/slider';
|
||||
import InputNumber from 'antd/es/input-number';
|
||||
import Empty from 'antd/es/empty';
|
||||
import Switch from 'antd/es/switch';
|
||||
import Grid from 'antd/es/grid';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import type { FilterOptions } from '../api';
|
||||
@@ -81,6 +82,28 @@ export default function FilterDrawer({
|
||||
</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 }}>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||
Category
|
||||
|
||||
+12
-2
@@ -10,6 +10,10 @@ export interface ItemFilters {
|
||||
// Only the admin Inventory tab sets this; the storefront leaves it null and
|
||||
// shows every status, as it always has.
|
||||
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 = {
|
||||
@@ -17,7 +21,8 @@ export const EMPTY_FILTERS: ItemFilters = {
|
||||
tagIds: [],
|
||||
minPriceCents: null,
|
||||
maxPriceCents: null,
|
||||
status: null
|
||||
status: null,
|
||||
favoritesOnly: false
|
||||
};
|
||||
|
||||
// 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.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents));
|
||||
if (filters.status !== null) params.set('status', filters.status);
|
||||
if (filters.favoritesOnly) params.set('favorites', '1');
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -50,12 +56,15 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
|
||||
? rawStatus
|
||||
: null;
|
||||
|
||||
const favorites = params.get('favorites');
|
||||
|
||||
return {
|
||||
categoryId: readInt(params.get('category')),
|
||||
tagIds: tags,
|
||||
minPriceCents: readInt(params.get('min_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;
|
||||
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
|
||||
if (filters.status !== null) count++;
|
||||
if (filters.favoritesOnly) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user