refactor(storefront): extract the catalogue machine from App.tsx as useCatalogue (#98) #153
+8
-69
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import Layout from 'antd/es/layout';
|
import Layout from 'antd/es/layout';
|
||||||
import Typography from 'antd/es/typography';
|
import Typography from 'antd/es/typography';
|
||||||
import Switch from 'antd/es/switch';
|
import Switch from 'antd/es/switch';
|
||||||
@@ -13,7 +13,8 @@ import Alert from 'antd/es/alert';
|
|||||||
import Segmented from 'antd/es/segmented';
|
import Segmented from 'antd/es/segmented';
|
||||||
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
|
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
|
||||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
|
import { Item } from './api';
|
||||||
|
import { useCatalogue } from './useCatalogue';
|
||||||
import ItemCard from './components/ItemCard';
|
import ItemCard from './components/ItemCard';
|
||||||
import BrandMark from './components/BrandMark';
|
import BrandMark from './components/BrandMark';
|
||||||
import FilterDrawer from './components/FilterDrawer';
|
import FilterDrawer from './components/FilterDrawer';
|
||||||
@@ -39,10 +40,6 @@ import DevThrow from './components/DevThrow';
|
|||||||
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;
|
|
||||||
|
|
||||||
interface CatalogueProps {
|
interface CatalogueProps {
|
||||||
failed: boolean;
|
failed: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -116,29 +113,22 @@ function Catalogue({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [items, setItems] = useState<Item[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const [options, setOptions] = useState<FilterOptions | null>(null);
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { mode, toggle } = useThemeMode();
|
const { mode, toggle } = useThemeMode();
|
||||||
const { customer, loading: authLoading } = 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
|
// 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.
|
// shared link, and the back button all restore the same view.
|
||||||
const filters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]);
|
const filters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]);
|
||||||
const filterKey = filtersToSearchParams(filters).toString();
|
const openAuthModal = useCallback(() => setAuthModalOpen(true), []);
|
||||||
|
|
||||||
// "Only my favorites" needs to know who is asking. Until the session has
|
const { items, loading, failed, options, needsFavoritesAuth, reload, retry, filterKey } =
|
||||||
// resolved we hold rather than guess: firing the request early would 401 and
|
useCatalogue(filters, openAuthModal);
|
||||||
// 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) => {
|
||||||
@@ -153,57 +143,6 @@ export default function App() {
|
|||||||
setSearchParams(new URLSearchParams(), { replace: true });
|
setSearchParams(new URLSearchParams(), { replace: true });
|
||||||
}, [setSearchParams]);
|
}, [setSearchParams]);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
|
||||||
return fetchItems(filtersFromSearchParams(new URLSearchParams(filterKey)))
|
|
||||||
.then((loaded) => {
|
|
||||||
setItems(loaded);
|
|
||||||
setFailed(false);
|
|
||||||
})
|
|
||||||
// A failed request must never fall through to the empty state: telling a
|
|
||||||
// customer "no items yet" when the server is broken hides the outage and
|
|
||||||
// reads as an empty shop.
|
|
||||||
.catch(() => setFailed(true))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [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(() => void load(), FILTER_DEBOUNCE_MS);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [load, awaitingAuth, needsFavoritesAuth]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchFilterOptions().then(setOptions).catch(() => setOptions(null));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Adding to cart flips an item to reserved, and the filter options' price
|
|
||||||
// bounds shift as inventory changes.
|
|
||||||
const reload = useCallback(() => {
|
|
||||||
void load();
|
|
||||||
fetchFilterOptions().then(setOptions).catch(() => undefined);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const handleRetry = useCallback(() => {
|
|
||||||
setLoading(true);
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const openAuthModal = useCallback(() => setAuthModalOpen(true), []);
|
|
||||||
|
|
||||||
const activeCount = activeFilterCount(filters);
|
const activeCount = activeFilterCount(filters);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -327,7 +266,7 @@ export default function App() {
|
|||||||
items={items}
|
items={items}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
needsFavoritesAuth={needsFavoritesAuth}
|
needsFavoritesAuth={needsFavoritesAuth}
|
||||||
onRetry={handleRetry}
|
onRetry={retry}
|
||||||
onSignIn={openAuthModal}
|
onSignIn={openAuthModal}
|
||||||
onClearFilters={clearFilters}
|
onClearFilters={clearFilters}
|
||||||
onChanged={reload}
|
onChanged={reload}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
|
||||||
|
import { ItemFilters, filtersFromSearchParams, filtersToSearchParams } from './filters';
|
||||||
|
import { useCustomerAuth } from './customer/CustomerAuthContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetching the catalogue for a set of filters.
|
||||||
|
*
|
||||||
|
* This was sixty lines sharing scope with the page that renders a header, a
|
||||||
|
* footer, a filter drawer and an auth modal — six pieces of state, four
|
||||||
|
* callbacks and two effects, none of which are about laying anything out. #81
|
||||||
|
* took the rendering half out by extracting `Catalogue`; this is the state half.
|
||||||
|
*
|
||||||
|
* The page keeps the URL as the source of filter truth, because that genuinely
|
||||||
|
* belongs to it: a reload, a shared link and the back button all have to restore
|
||||||
|
* the same view. What moves here is the request, the debounce, and the session
|
||||||
|
* negotiation the request depends on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Dragging the price slider fires a change per pixel; without this every one
|
||||||
|
// would become its own request.
|
||||||
|
const FILTER_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
export interface Catalogue {
|
||||||
|
items: Item[];
|
||||||
|
loading: boolean;
|
||||||
|
/** A failed request, which must never be rendered as an empty catalogue. */
|
||||||
|
failed: boolean;
|
||||||
|
options: FilterOptions | null;
|
||||||
|
/** The favorites filter is set, the session has resolved, and nobody is signed in. */
|
||||||
|
needsFavoritesAuth: boolean;
|
||||||
|
/** Refetch both the items and the filter bounds — after a cart change, say. */
|
||||||
|
reload: () => void;
|
||||||
|
retry: () => void;
|
||||||
|
/**
|
||||||
|
* A stable identity for the current filter set.
|
||||||
|
*
|
||||||
|
* Returned rather than recomputed by the caller because it is already derived
|
||||||
|
* here, and the page needs exactly this value: it resets the catalogue's error
|
||||||
|
* boundary, so changing the filters gives a crashed grid another chance
|
||||||
|
* without a reload.
|
||||||
|
*/
|
||||||
|
filterKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param onAuthRequired Called when the favorites filter is asked for by someone
|
||||||
|
* signed out. The hook decides the moment; the page decides what to do about it,
|
||||||
|
* because the prompt is a modal the page owns. Must be stable, or it re-triggers
|
||||||
|
* the effect below on every render.
|
||||||
|
*/
|
||||||
|
export function useCatalogue(filters: ItemFilters, onAuthRequired: () => void): Catalogue {
|
||||||
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [options, setOptions] = useState<FilterOptions | null>(null);
|
||||||
|
|
||||||
|
const { customer, loading: authLoading } = useCustomerAuth();
|
||||||
|
|
||||||
|
// "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;
|
||||||
|
|
||||||
|
// Serialised, then parsed back inside `load`. The round trip looks redundant
|
||||||
|
// and is not: depending on a *string* means `load` stays referentially stable
|
||||||
|
// while the filters are value-equal, and `load` is what the debounce effect
|
||||||
|
// below depends on. Depending on the object instead would give a new `load`
|
||||||
|
// every render, restarting the debounce each time and firing a request per
|
||||||
|
// keystroke.
|
||||||
|
const filterKey = filtersToSearchParams(filters).toString();
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
return fetchItems(filtersFromSearchParams(new URLSearchParams(filterKey)))
|
||||||
|
.then((loaded) => {
|
||||||
|
setItems(loaded);
|
||||||
|
setFailed(false);
|
||||||
|
})
|
||||||
|
// A failed request must never fall through to the empty state: telling a
|
||||||
|
// customer "no items yet" when the server is broken hides the outage and
|
||||||
|
// reads as an empty shop.
|
||||||
|
.catch(() => setFailed(true))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [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);
|
||||||
|
onAuthRequired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
const timer = setTimeout(() => void load(), FILTER_DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [load, awaitingAuth, needsFavoritesAuth, onAuthRequired]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFilterOptions().then(setOptions).catch(() => setOptions(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Adding to cart flips an item to reserved, and the filter options' price
|
||||||
|
// bounds shift as inventory changes.
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
void load();
|
||||||
|
fetchFilterOptions().then(setOptions).catch(() => undefined);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { items, loading, failed, options, needsFavoritesAuth, reload, retry, filterKey };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user