diff --git a/.agents/skills/data-client-schema/references/computed-properties.md b/.agents/skills/data-client-schema/references/computed-properties.md index 250eba16c032..a0a2178279d2 100644 --- a/.agents/skills/data-client-schema/references/computed-properties.md +++ b/.agents/skills/data-client-schema/references/computed-properties.md @@ -66,49 +66,292 @@ class User extends Entity { ## Global computations -[Query](./Query.md) can be used for computations of derived data from more than -one entity. We generally call these aggregates. +[Query](./Query.md) derives data from many entities, like a cart's totals. This is known as an +_aggregate_. Define it once beside your resources, then read it anywhere with +[useQuery()](https://dataclient.io/docs/api/useQuery). -```ts title="resources/User" +Files with a `route` are pages mounted at that path, with its params as props; `Link` and `useNavigate()` stand for your router (with react-router, read params with `useParams()`). + +```ts title="resources/Cart" import { Entity, resource } from '@data-client/rest'; -export class User extends Entity { +export class Product extends Entity { id = ''; name = ''; - isAdmin = false; + price = 0; +} +export const ProductResource = resource({ + path: '/products/:id', + schema: Product, +}); + +/** A line in the cart, keyed by its product's id */ +export class CartItem extends Entity { + id = ''; + product = Product.fromJS(); + quantity = 1; + + get total() { + return this.quantity * this.product.price; + } + + static schema = { product: Product }; } -export const UserResource = resource({ - path: '/users/:id', - schema: User, +export const CartResource = resource({ + path: '/cart/items/:id', + schema: CartItem, + optimistic: true, }); + +export const usd = { style: 'currency', currency: 'USD' } as const; +``` + +```ts title="getCartTotals" {6} +import { Query } from '@data-client/rest'; +import { CartResource } from './resources/Cart'; + +export const freeShippingAt = 50; + +export const getCartTotals = new Query(CartResource.getList.schema, items => { + const count = items.reduce((sum, item) => sum + item.quantity, 0); + const subtotal = items.reduce((sum, item) => sum + item.total, 0); + const toFreeShipping = Math.max(0, freeShippingAt - subtotal); + const shipping = subtotal && toFreeShipping ? 5 : 0; + return { + count, + subtotal, + shipping, + total: subtotal + shipping, + toFreeShipping, + }; +}); +``` + +```tsx title="CartBadge" +import { useQuery } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import { getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; + +export default function CartBadge() { + const totals = useQuery(getCartTotals); + if (!totals) return null; + return ( + + {totals.count} {totals.count === 1 ? 'item' : 'items'} ·{' '} + + + ); +} +``` + +```tsx title="FreeShipping" +import { useQuery } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import { freeShippingAt, getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; + +export default function FreeShipping() { + const totals = useQuery(getCartTotals); + if (!totals) return null; + return ( +

+ {totals.toFreeShipping > 0 ? + <> + Add more + for free shipping + + : 'Your order ships free'} + +

+ ); +} +``` + +```tsx title="OrderSummary" +import { useQuery } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import { getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; + +export default function OrderSummary() { + const totals = useQuery(getCartTotals); + if (!totals) return null; + return ( +
+
+
Subtotal
+
+
+
+
Shipping
+
+ {totals.shipping ? + + : 'Free'} +
+
+
+
Total
+
+
+
+ ); +} +``` + +```tsx title="CheckoutButton" +import { useQuery } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import { getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; + +export default function CheckoutButton() { + const totals = useQuery(getCartTotals); + if (!totals) return null; + return ( + + ); +} +``` + +```tsx title="QuantityStepper" +import { useController, useQuery } from '@data-client/react'; +import { CartItem, CartResource, Product } from './resources/Cart'; + +export default function QuantityStepper({ product }: { product: Product }) { + const ctrl = useController(); + const item = useQuery(CartItem, { id: product.id }); + const add = () => + ctrl.fetch(CartResource.getList.push, { + id: product.id, + product, + quantity: 1, + }); + const setQuantity = (quantity: number) => + quantity ? + ctrl.fetch(CartResource.partialUpdate, { id: product.id }, { quantity }) + : ctrl.fetch(CartResource.delete, { id: product.id }); + + return ( + + {item ? + <> + + {item.quantity} + + + : + } + + ); +} ``` -```tsx title="UsersPage" -import { All, Query, schema } from '@data-client/rest'; -import { useQuery, useSuspense } from '@data-client/react'; -import { UserResource, User } from './resources/User'; - -const getUserCount = new Query( - new All(User), - (entries, { isAdmin } = {}) => { - if (isAdmin !== undefined) - return entries.filter(user => user.isAdmin === isAdmin).length; - return entries.length; - }, -); - -function UsersPage() { - useSuspense(UserResource.getList); - const userCount = useQuery(getUserCount); - const adminCount = useQuery(getUserCount, { isAdmin: true }); - // this should never happen since we suspense but typescript does not know that - if (userCount === undefined) return null; +```tsx title="ShopPage" route="/" +import { useSuspense } from '@data-client/react'; +import FreeShipping from './FreeShipping'; +import QuantityStepper from './QuantityStepper'; +import { CartResource, ProductResource, usd } from './resources/Cart'; + +export default function ShopPage() { + const products = useSuspense(ProductResource.getList); + useSuspense(CartResource.getList); + return ( + <> + +
+ {products.map(product => ( +
+
+ {product.name} +
+ {product.price.toLocaleString('en-US', usd)} +
+
+ +
+ ))} +
+ + ); +} +``` + +```tsx title="CartPage" route="/cart" +import { useSuspense } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import CheckoutButton from './CheckoutButton'; +import FreeShipping from './FreeShipping'; +import OrderSummary from './OrderSummary'; +import QuantityStepper from './QuantityStepper'; +import { CartResource, usd } from './resources/Cart'; + +export default function CartPage() { + const items = useSuspense(CartResource.getList); + if (!items.length) return

Your cart is empty

; return ( -
-
Total users: {userCount}
-
Total admins: {adminCount}
-
+ <> +
+ {items.map(item => ( +
+
{item.product.name}
+ + + + +
+ ))} +
+ + + + ); } -render(); ``` + +```tsx title="Store" layout +import { useFetch } from '@data-client/react'; +import CartBadge from './CartBadge'; +import { CartResource } from './resources/Cart'; + +export default function Store({ children }: { children: React.ReactNode }) { + // every page shows the cart's totals, so the cart loads with the app + useFetch(CartResource.getList); + return ( + <> +
+

Daily Grind

+ +
+ + {children} + + ); +} +``` + +Here the cart badge, free-shipping bar, order summary and checkout button all read `getCartTotals`. +It runs once per change, however many components read it, and returns the same object until a +`CartItem` or `Product` it read changes. So every view agrees, even mid +[optimistic update](https://dataclient.io/rest/api/resource#optimistic). + +Do not compute aggregates in components, where each copy can drift. Instead, put them in a `Query`. diff --git a/.agents/skills/data-client-schema/references/computed-properties.vue.md b/.agents/skills/data-client-schema/references/computed-properties.vue.md index 981281d729fb..665acd75580b 100644 --- a/.agents/skills/data-client-schema/references/computed-properties.vue.md +++ b/.agents/skills/data-client-schema/references/computed-properties.vue.md @@ -66,51 +66,295 @@ class User extends Entity { ## Global computations -[Query](./Query.vue.md) can be used for computations of derived data from more than -one entity. We generally call these aggregates. +[Query](./Query.vue.md) derives data from many entities, like a cart's totals. This is known as an +_aggregate_. Define it once beside your resources, then read it anywhere with +[useQuery()](https://dataclient.io/vue/api/useQuery). -```ts title="resources/User" +Files with a `route` are pages mounted at that path, with its params as props (vue-router `props: true`). + +```ts title="resources/Cart" import { Entity, resource } from '@data-client/rest'; -export class User extends Entity { +export class Product extends Entity { id = ''; name = ''; - isAdmin = false; + price = 0; } -export const UserResource = resource({ - path: '/users/:id', - schema: User, +export const ProductResource = resource({ + path: '/products/:id', + schema: Product, +}); + +/** A line in the cart, keyed by its product's id */ +export class CartItem extends Entity { + id = ''; + product = Product.fromJS(); + quantity = 1; + + get total() { + return this.quantity * this.product.price; + } + + static schema = { product: Product }; +} +export const CartResource = resource({ + path: '/cart/items/:id', + schema: CartItem, + optimistic: true, +}); + +export const usd = { style: 'currency', currency: 'USD' } as const; +``` + +```ts title="getCartTotals" {6} +import { Query } from '@data-client/rest'; +import { CartResource } from './resources/Cart'; + +export const freeShippingAt = 50; + +export const getCartTotals = new Query(CartResource.getList.schema, items => { + const count = items.reduce((sum, item) => sum + item.quantity, 0); + const subtotal = items.reduce((sum, item) => sum + item.total, 0); + const toFreeShipping = Math.max(0, freeShippingAt - subtotal); + const shipping = subtotal && toFreeShipping ? 5 : 0; + return { + count, + subtotal, + shipping, + total: subtotal + shipping, + toFreeShipping, + }; }); ``` -```html title="UsersPage.vue" - + + +``` + +```html title="FreeShipping.vue" + + + +``` + +```html title="OrderSummary.vue" + + + +``` + +```html title="CheckoutButton.vue" + + + +``` + +```html title="QuantityStepper.vue" + + +``` + +```html title="ShopPage.vue" route="/" ``` + +```html title="CartPage.vue" route="/cart" + + + +``` + +```html title="Store.vue" layout + + + +``` + +Here the cart badge, free-shipping bar, order summary and checkout button all read `getCartTotals`. +It runs once per change, however many components read it, and returns the same object until a +`CartItem` or `Product` it read changes. So every view agrees, even mid +[optimistic update](https://dataclient.io/rest/api/resource#optimistic). + +Do not compute aggregates in components, where each copy can drift. Instead, put them in a `Query`. diff --git a/docs/rest/guides/computed-properties.md b/docs/rest/guides/computed-properties.md index 7ff7586d2ded..c042f53cfda2 100644 --- a/docs/rest/guides/computed-properties.md +++ b/docs/rest/guides/computed-properties.md @@ -2,8 +2,23 @@ title: Computed Properties --- -import { All, Query, RestEndpoint } from '@data-client/rest'; import FrameworkPlayground from '@site/src/components/FrameworkPlayground'; +import { + cartFixtures, + getInitialInterceptorData, +} from '@site/src/fixtures/cart'; + +export const cartDemo = [ + // $48 to $52: the bar fills, and shipping turns free + { delay: 1200, click: 'button[aria-label="Add one Paper filters"]' }, + { wait: 1800 }, + { click: 'button[aria-label="Add Pour-over dripper"]' }, + { wait: 1800 }, + // the cart's totals are already there + { click: 'nav a[href="/cart"]' }, + { wait: 2500 }, + { click: 'button[aria-label="Remove one Pour-over dripper"]' }, +]; ## Singular computations @@ -75,104 +90,533 @@ class User extends Entity { ## Global computations -[Query](../api/Query.md) can be used for computations of derived data from more than -one entity. We generally call these aggregates. - - - -```ts title="resources/User" collapsed +[Query](../api/Query.md) derives data from many entities, like a cart's totals. This is known as an +_aggregate_. Define it once beside your resources, then read it anywhere with +[useQuery()](/docs/api/useQuery). + + + +```ts title="resources/Cart" collapsed import { Entity, resource } from '@data-client/rest'; -export class User extends Entity { +export class Product extends Entity { id = ''; name = ''; - isAdmin = false; + price = 0; +} +export const ProductResource = resource({ + path: '/products/:id', + schema: Product, +}); + +/** A line in the cart, keyed by its product's id */ +export class CartItem extends Entity { + id = ''; + product = Product.fromJS(); + quantity = 1; + + get total() { + return this.quantity * this.product.price; + } + + static schema = { product: Product }; } -export const UserResource = resource({ - path: '/users/:id', - schema: User, +export const CartResource = resource({ + path: '/cart/items/:id', + schema: CartItem, + optimistic: true, +}); + +export const usd = { style: 'currency', currency: 'USD' } as const; +``` + +```ts title="getCartTotals" {6} +import { Query } from '@data-client/rest'; +import { CartResource } from './resources/Cart'; + +export const freeShippingAt = 50; + +export const getCartTotals = new Query(CartResource.getList.schema, items => { + const count = items.reduce((sum, item) => sum + item.quantity, 0); + const subtotal = items.reduce((sum, item) => sum + item.total, 0); + const toFreeShipping = Math.max(0, freeShippingAt - subtotal); + const shipping = subtotal && toFreeShipping ? 5 : 0; + return { + count, + subtotal, + shipping, + total: subtotal + shipping, + toFreeShipping, + }; }); ``` :::react -```tsx title="UsersPage" -import { All, Query, schema } from '@data-client/rest'; -import { useQuery, useSuspense } from '@data-client/react'; -import { UserResource, User } from './resources/User'; - -const getUserCount = new Query( - new All(User), - (entries, { isAdmin } = {}) => { - if (isAdmin !== undefined) - return entries.filter(user => user.isAdmin === isAdmin).length; - return entries.length; - }, -); - -function UsersPage() { - useSuspense(UserResource.getList); - const userCount = useQuery(getUserCount); - const adminCount = useQuery(getUserCount, { isAdmin: true }); - // this should never happen since we suspense but typescript does not know that - if (userCount === undefined) return null; +```tsx title="CartBadge" collapsed +import { useQuery } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import { getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; + +export default function CartBadge() { + const totals = useQuery(getCartTotals); + if (!totals) return null; return ( -
-
Total users: {userCount}
-
Total admins: {adminCount}
-
+ + {totals.count} {totals.count === 1 ? 'item' : 'items'} ·{' '} + + + ); +} +``` + +```tsx title="FreeShipping" collapsed +import { useQuery } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import { freeShippingAt, getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; + +export default function FreeShipping() { + const totals = useQuery(getCartTotals); + if (!totals) return null; + return ( +

+ {totals.toFreeShipping > 0 ? + <> + Add more + for free shipping + + : 'Your order ships free'} + +

+ ); +} +``` + +```tsx title="OrderSummary" collapsed +import { useQuery } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import { getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; + +export default function OrderSummary() { + const totals = useQuery(getCartTotals); + if (!totals) return null; + return ( +
+
+
Subtotal
+
+
+
+
Shipping
+
+ {totals.shipping ? + + : 'Free'} +
+
+
+
Total
+
+
+
+ ); +} +``` + +```tsx title="CheckoutButton" collapsed +import { useQuery } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import { getCartTotals } from './getCartTotals'; +import { usd } from './resources/Cart'; + +export default function CheckoutButton() { + const totals = useQuery(getCartTotals); + if (!totals) return null; + return ( + + ); +} +``` + +```tsx title="QuantityStepper" collapsed +import { useController, useQuery } from '@data-client/react'; +import { CartItem, CartResource, Product } from './resources/Cart'; + +export default function QuantityStepper({ product }: { product: Product }) { + const ctrl = useController(); + const item = useQuery(CartItem, { id: product.id }); + const add = () => + ctrl.fetch(CartResource.getList.push, { + id: product.id, + product, + quantity: 1, + }); + const setQuantity = (quantity: number) => + quantity ? + ctrl.fetch(CartResource.partialUpdate, { id: product.id }, { quantity }) + : ctrl.fetch(CartResource.delete, { id: product.id }); + + return ( + + {item ? + <> + + {item.quantity} + + + : + } + + ); +} +``` + +```tsx title="ShopPage" route="/" +import { useSuspense } from '@data-client/react'; +import FreeShipping from './FreeShipping'; +import QuantityStepper from './QuantityStepper'; +import { CartResource, ProductResource, usd } from './resources/Cart'; + +export default function ShopPage() { + const products = useSuspense(ProductResource.getList); + useSuspense(CartResource.getList); + return ( + <> + +
+ {products.map(product => ( +
+
+ {product.name} +
+ {product.price.toLocaleString('en-US', usd)} +
+
+ +
+ ))} +
+ + ); +} +``` + +```tsx title="CartPage" route="/cart" collapsed +import { useSuspense } from '@data-client/react'; +import NumberFlow from '@number-flow/react'; +import CheckoutButton from './CheckoutButton'; +import FreeShipping from './FreeShipping'; +import OrderSummary from './OrderSummary'; +import QuantityStepper from './QuantityStepper'; +import { CartResource, usd } from './resources/Cart'; + +export default function CartPage() { + const items = useSuspense(CartResource.getList); + if (!items.length) return

Your cart is empty

; + return ( + <> +
+ {items.map(item => ( +
+
{item.product.name}
+ + + + +
+ ))} +
+ + + + + ); +} +``` + +```tsx title="Store" layout collapsed +import { useFetch } from '@data-client/react'; +import CartBadge from './CartBadge'; +import { CartResource } from './resources/Cart'; + +export default function Store({ children }: { children: React.ReactNode }) { + // every page shows the cart's totals, so the cart loads with the app + useFetch(CartResource.getList); + return ( + <> +
+

Daily Grind

+ +
+ + {children} + ); } -render(); ``` ::: :::vue -```html title="UsersPage.vue" - + +``` + +```html title="FreeShipping.vue" collapsed + + + +``` + +```html title="OrderSummary.vue" collapsed + + + +``` + +```html title="CheckoutButton.vue" collapsed + + + +``` + +```html title="QuantityStepper.vue" collapsed - await useSuspense(UserResource.getList); - const userCount = useQuery(getUserCount); - const adminCount = useQuery(getUserCount, { isAdmin: true }); + +``` + +```html title="ShopPage.vue" route="/" + ``` +```html title="CartPage.vue" route="/cart" collapsed + + + +``` + +```html title="Store.vue" layout collapsed + + + +``` + :::
+ +Here the cart badge, free-shipping bar, order summary and checkout button all read `getCartTotals`. +It runs once per change, however many components read it, and returns the same object until a +`CartItem` or `Product` it read changes. So every view agrees, even mid +[optimistic update](../api/resource.md#optimistic). + +Do not compute aggregates in components, where each copy can drift. Instead, put them in a `Query`. diff --git a/website/src/components/Playground/DesignSystem/design-system.css b/website/src/components/Playground/DesignSystem/design-system.css index 1c48ca0a10ae..d270ea7b1ded 100644 --- a/website/src/components/Playground/DesignSystem/design-system.css +++ b/website/src/components/Playground/DesignSystem/design-system.css @@ -455,12 +455,14 @@ div.playground-preview h1:first-child { clip-path: polygon(50% 0, 100% 100%, 0 100%); } @media (hover: hover) { - .playground-preview button.upvote:hover { + .playground-preview button.upvote:hover, + .playground-preview .stepper > button:hover { background: var(--ds-accent-soft); color: var(--ds-accent); } } -.playground-preview button.upvote:active { +.playground-preview button.upvote:active, +.playground-preview .stepper > button:active { background: color-mix( in srgb, var(--ds-accent) var(--ds-accent-press), @@ -471,13 +473,16 @@ div.playground-preview h1:first-child { /* Figures side by side (`dl.stats`, each a div of dt then dd): the number large over its muted label, so a figure that moves is what catches the eye; one row of columns up to 9rem wide, whose labels wrap on a phone. - A lone figure (a page's one price) has the sheet's width, so a long - number wraps at the sheet's edge rather than a column's */ + The figures share one grid row and the labels another, so a figure in + plain text (Free) sits level with animated ones. A lone figure (a page's + one price) has the sheet's width, so a long number wraps at the sheet's + edge rather than a column's */ .playground-preview .stats { display: grid; + grid-template-rows: auto auto; grid-auto-flow: column; grid-auto-columns: minmax(0, 9rem); - gap: var(--ds-space-4); + gap: 2px var(--ds-space-4); margin: 0; } /* what follows figures (a control) stands a row off them */ @@ -488,9 +493,9 @@ div.playground-preview h1:first-child { grid-auto-columns: minmax(0, 1fr); } .playground-preview .stats > div { - display: flex; - flex-direction: column; - gap: 2px; + display: grid; + grid-row: span 2; + grid-template-rows: subgrid; } /* above its label, so a label that wraps grows down and the figures stay in line */ @@ -500,6 +505,7 @@ div.playground-preview h1:first-child { font-size: var(--ds-text-2xl); font-weight: 600; line-height: 1.1; + align-self: center; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } @@ -509,6 +515,96 @@ div.playground-preview h1:first-child { content: '\2014'; color: var(--ds-faint); } +/* the smallest phones: three prices ($52.00) still fit side by side */ +@media (max-width: 360px) { + .playground-preview .stats dd { + font-size: var(--ds-text-xl); + } +} +/* A quantity stepper (a cart row's): − count + in one edge, the count + between its two buttons; hover and press as an upvote's. With one button + (Add, at none) it keeps the same width, so a row doesn't shift. In a row + the text before it takes the slack, and a trailing `meta` (a line's + price) keeps one width so the rows' prices align */ +.playground-preview .stepper { + --stepper-button: 28px; + --stepper-count: 1.5rem; + flex: none; + /* border-box: min-width includes the 1px border on each side */ + min-width: calc(2 * var(--stepper-button) + var(--stepper-count) + 2px); + display: inline-flex; + align-items: center; + border: 1px solid var(--ds-border-strong); + border-radius: var(--ds-radius-sm); +} +.playground-preview .stepper > button { + width: var(--stepper-button); + height: var(--stepper-button); + margin: 0; + padding: 0; + border: 0; + border-radius: var(--ds-radius-sm); + background: none; + color: var(--ds-text-muted); + font: 500 1rem / 1 var(--ifm-font-family-base); + transition: + background-color var(--motion-snappy), + color var(--motion-snappy); +} +/* a fingertip's target on touch screens */ +@media (pointer: coarse) { + .playground-preview .stepper { + --stepper-button: 40px; + } +} +.playground-preview .stepper > button:only-child { + flex: 1; + color: var(--ds-accent); + font-size: var(--ds-text-sm); + font-weight: 600; +} +/* inside the stepper's edge */ +.playground-preview .stepper > button:focus-visible { + outline-offset: -2px; +} +.playground-preview .stepper > output { + min-width: var(--stepper-count); + font-weight: 600; + text-align: center; + font-variant-numeric: tabular-nums; +} +.playground-preview .listItem:has(> .stepper) > :first-child { + flex: 1; +} +.playground-preview .listItem:has(> .stepper) > .meta:last-child { + min-width: 4rem; + text-align: right; +} +/* A meter (progress toward free shipping): a thin rounded track a step + off the sheet, filled in the accent */ +.playground-preview progress { + display: block; + width: 100%; + height: 6px; + margin-top: var(--ds-space-2); + appearance: none; + border: 0; + border-radius: 999px; + background: var(--ds-surface-2); + overflow: hidden; +} +.playground-preview progress::-webkit-progress-bar { + background: var(--ds-surface-2); +} +.playground-preview progress::-webkit-progress-value { + background: var(--ds-accent); + border-radius: 999px; + transition: inline-size var(--motion-snappy); +} +.playground-preview progress::-moz-progress-bar { + background: var(--ds-accent); + border-radius: 999px; +} /* A board's columns (RestEndpoint's move demo): wells a step off the sheet, each holding its tasks as rows of the sheet's color (radius 8, no edge), 6px apart */ diff --git a/website/src/components/Playground/README.md b/website/src/components/Playground/README.md index f4be7e30fe88..05c408c102bc 100644 --- a/website/src/components/Playground/README.md +++ b/website/src/components/Playground/README.md @@ -225,7 +225,7 @@ controls keep an edge). Before restyling a class, grep `docs/` and style to a new component. Styled elements: `h2` (page title), `button` (`primary` fills one; -`aria-busy` shows pending), text inputs, `nav`, `fieldset`. +`aria-busy` shows pending), text inputs, `nav`, `fieldset`, `progress`. | Class | Use for | | ------------------------------------------ | ------------------------------------------------------------------------------ | @@ -242,6 +242,7 @@ Styled elements: `h2` (page title), `button` (`primary` fills one; | `boardColumn` | a kanban column | | `upvote` (a button) | an upvote with its count; empty, the arrow alone | | `stats` (a `dl`) | figures side by side, each large over its label; a lone one fills the row | +| `stepper` (− `output` +) | a quantity's buttons around its count (one button alone: Add); in a row | ## Auto-run diff --git a/website/src/fixtures/cart.ts b/website/src/fixtures/cart.ts new file mode 100644 index 000000000000..3bde5dfc9223 --- /dev/null +++ b/website/src/fixtures/cart.ts @@ -0,0 +1,86 @@ +import { Entity, resource } from '@data-client/rest'; +import type { Interceptor } from '@data-client/test'; + +class Product extends Entity { + id = ''; + name = ''; + price = 0; +} +const ProductResource = resource({ path: '/products/:id', schema: Product }); +class CartItem extends Entity { + id = ''; + product = Product.fromJS(); + quantity = 1; + + static schema = { product: Product }; +} +const CartResource = resource({ path: '/cart/items/:id', schema: CartItem }); + +const products = { + 'house-blend': { id: 'house-blend', name: 'House blend', price: 16 }, + mug: { id: 'mug', name: 'Ceramic mug', price: 12 }, + filters: { id: 'filters', name: 'Paper filters', price: 4 }, + dripper: { id: 'dripper', name: 'Pour-over dripper', price: 22 }, +}; + +// $48: one more filter pack crosses the $50 free shipping line +const initialItems = { + 'house-blend': { + id: 'house-blend', + product: products['house-blend'], + quantity: 2, + }, + mug: { id: 'mug', product: products.mug, quantity: 1 }, + filters: { id: 'filters', product: products.filters, quantity: 1 }, +}; +type CartItemData = (typeof initialItems)[keyof typeof initialItems]; +type CartInterceptorState = { items: Record }; + +export const getInitialInterceptorData = (): CartInterceptorState => ({ + items: structuredClone(initialItems), +}); + +export const cartFixtures: Interceptor[] = [ + { + endpoint: ProductResource.getList, + response() { + return Object.values(products); + }, + delay: 150, + }, + { + endpoint: CartResource.getList, + response() { + return Object.values(this.items); + }, + delay: 150, + }, + { + endpoint: CartResource.getList.push, + response(body: Parameters[0]) { + const item = body as CartItemData; + this.items[item.id] = item; + return item; + }, + delay: 300, + }, + { + endpoint: CartResource.partialUpdate, + response( + { id }: Parameters[0], + body: Parameters[1], + ) { + this.items[id] = { ...this.items[id], ...body }; + return this.items[id]; + }, + delay: 300, + }, + { + endpoint: CartResource.delete, + response({ id }: Parameters[0]) { + delete this.items[id]; + return { id }; + }, + delay: 300, + }, +];