Skip to content

Commit

Permalink
Cart Optimistic UI helpers (#1366)
Browse files Browse the repository at this point in the history
  • Loading branch information
wizardlyhel authored Sep 28, 2023
1 parent 00210fa commit 8772903
Show file tree
Hide file tree
Showing 10 changed files with 386 additions and 13 deletions.
6 changes: 6 additions & 0 deletions .changeset/khaki-brooms-draw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'demo-store': patch
'@shopify/hydrogen': patch
---

Cart Optimistic UI helpers
117 changes: 115 additions & 2 deletions packages/hydrogen/docs/generated/generated_docs_data.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions packages/hydrogen/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export type {
export {createContentSecurityPolicy, useNonce} from './csp/csp';
export {Script} from './csp/Script';

export {
useOptimisticData,
OptimisticInput,
} from './optimistic-ui/optimistic-ui';

export {
AnalyticsEventName,
AnalyticsPageType,
Expand Down
38 changes: 38 additions & 0 deletions packages/hydrogen/src/optimistic-ui/OptimisticInput.doc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs';

const data: ReferenceEntityTemplateSchema = {
name: 'OptimisticInput',
category: 'components',
isVisualComponent: false,
related: [],
description:
'Creates a form input for optimistic UI updates. Use `useOptimisticData` to update the UI with the latest optimistic data.',
type: 'component',
defaultExample: {
description: 'This is the default example',
codeblock: {
tabs: [
{
title: 'JavaScript',
code: './OptimisticInput.example.jsx',
language: 'js',
},
{
title: 'TypeScript',
code: './OptimisticInput.example.tsx',
language: 'ts',
},
],
title: 'example',
},
},
definitions: [
{
title: 'Props',
type: 'OptimisticInputProps',
description: '',
},
],
};

export default data;
27 changes: 27 additions & 0 deletions packages/hydrogen/src/optimistic-ui/OptimisticInput.example.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {CartForm, OptimisticInput, useOptimisticData} from '@shopify/hydrogen';

export default function Cart({line}) {
const optimisticId = line.id;
const optimisticData = useOptimisticData(optimisticId);

return (
<div
style={{
// Hide the line item if the optimistic data action is remove
// Do not remove the form from the DOM
display: optimisticData?.action === 'remove' ? 'none' : 'block',
}}
>
<CartForm
route="/cart"
action={CartForm.ACTIONS.LinesRemove}
inputs={{
lineIds: [line.id],
}}
>
<button type="submit">Remove</button>
<OptimisticInput id={optimisticId} data={{action: 'remove'}} />
</CartForm>
</div>
);
}
32 changes: 32 additions & 0 deletions packages/hydrogen/src/optimistic-ui/OptimisticInput.example.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {CartForm, OptimisticInput, useOptimisticData} from '@shopify/hydrogen';
import {CartLine} from '@shopify/hydrogen-react/storefront-api-types';

type OptimisticData = {
action: string;
};

export default function Cart({line}: {line: CartLine}) {
const optimisticId = line.id;
const optimisticData = useOptimisticData<OptimisticData>(optimisticId);

return (
<div
style={{
// Hide the line item if the optimistic data action is remove
// Do not remove the form from the DOM
display: optimisticData?.action === 'remove' ? 'none' : 'block',
}}
>
<CartForm
route="/cart"
action={CartForm.ACTIONS.LinesRemove}
inputs={{
lineIds: [line.id],
}}
>
<button type="submit">Remove</button>
<OptimisticInput id={optimisticId} data={{action: 'remove'}} />
</CartForm>
</div>
);
}
28 changes: 28 additions & 0 deletions packages/hydrogen/src/optimistic-ui/optimistic-ui.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {describe, expect, it} from 'vitest';
import {render} from '@testing-library/react';
import {OptimisticInput} from './optimistic-ui';

function getOptimisticIdentifier(container: HTMLElement) {
return container
.querySelector('input[name="optimistic-identifier"]')
?.getAttribute('value');
}

function getOptimisticData(container: HTMLElement) {
return container
.querySelector('input[name="optimistic-data"]')
?.getAttribute('value');
}

describe('<OptimisticInput />', () => {
it('renders a form with children', () => {
const {container} = render(
<form>
<OptimisticInput id="test" data={{action: 'remove'}} />
</form>,
);

expect(getOptimisticIdentifier(container)).toBe('test');
expect(getOptimisticData(container)).toBe('{"action":"remove"}');
});
});
49 changes: 49 additions & 0 deletions packages/hydrogen/src/optimistic-ui/optimistic-ui.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {useFetchers} from '@remix-run/react';

export function useOptimisticData<T>(identifier: string) {
const fetchers = useFetchers();
const data: Record<string, unknown> = {};

for (const fetcher of fetchers) {
const formData = fetcher.submission?.formData;
if (formData && formData.get('optimistic-identifier') === identifier) {
try {
if (formData.has('optimistic-data')) {
const dataInForm: unknown = JSON.parse(
String(formData.get('optimistic-data')),
);
Object.assign(data, dataInForm);
}
} catch {
// do nothing
}
}
}
return data as T;
}

export type OptimisticInputProps = {
/**
* A unique identifier for the optimistic input. Use the same identifier in `useOptimisticData`
* to retrieve the optimistic data from actions.
*/
id: string;
/**
* The data to be stored in the optimistic input. Use for creating an optimistic successful state
* of this form action.
*/
data: Record<string, unknown>;
};

export function OptimisticInput({id, data}: OptimisticInputProps) {
return (
<>
<input type="hidden" name="optimistic-identifier" value={id} />
<input
type="hidden"
name="optimistic-data"
value={JSON.stringify(data)}
/>
</>
);
}
38 changes: 38 additions & 0 deletions packages/hydrogen/src/optimistic-ui/useOptimisticData.doc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs';

const data: ReferenceEntityTemplateSchema = {
name: 'useOptimisticData',
category: 'hooks',
isVisualComponent: false,
related: [],
description:
'Gets the latest optimistic data with matching optimistic id from actions. Use `OptimisticInput` to accept optimistic data in forms.',
type: 'component',
defaultExample: {
description: 'This is the default example',
codeblock: {
tabs: [
{
title: 'JavaScript',
code: './OptimisticInput.example.jsx',
language: 'js',
},
{
title: 'TypeScript',
code: './OptimisticInput.example.tsx',
language: 'ts',
},
],
title: 'example',
},
},
definitions: [
{
title: 'Props',
type: 'UseOptimisticDataGeneratedType',
description: '',
},
],
};

export default data;
59 changes: 48 additions & 11 deletions templates/demo-store/app/components/Cart.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import clsx from 'clsx';
import {useRef} from 'react';
import {useScroll} from 'react-use';
import {flattenConnection, CartForm, Image, Money} from '@shopify/hydrogen';
import {
flattenConnection,
CartForm,
Image,
Money,
useOptimisticData,
OptimisticInput,
} from '@shopify/hydrogen';
import type {
Cart as CartType,
CartCost,
Expand Down Expand Up @@ -229,15 +236,30 @@ function CartSummary({
);
}

type OptimisticData = {
action?: string;
quantity?: number;
};

function CartLineItem({line}: {line: CartLine}) {
const optimisticData = useOptimisticData<OptimisticData>(line?.id);

if (!line?.id) return null;

const {id, quantity, merchandise} = line;

if (typeof quantity === 'undefined' || !merchandise?.product) return null;

return (
<li key={id} className="flex gap-4">
<li
key={id}
className="flex gap-4"
style={{
// Hide the line item if the optimistic data action is remove
// Do not remove the form from the DOM
display: optimisticData?.action === 'remove' ? 'none' : 'flex',
}}
>
<div className="flex-shrink">
{merchandise.image && (
<Image
Expand Down Expand Up @@ -274,7 +296,7 @@ function CartLineItem({line}: {line: CartLine}) {
<div className="flex justify-start text-copy">
<CartLineQuantityAdjust line={line} />
</div>
<ItemRemoveButton lineIds={[id]} />
<ItemRemoveButton lineId={id} />
</div>
</div>
<Text>
Expand All @@ -285,13 +307,13 @@ function CartLineItem({line}: {line: CartLine}) {
);
}

function ItemRemoveButton({lineIds}: {lineIds: CartLine['id'][]}) {
function ItemRemoveButton({lineId}: {lineId: CartLine['id']}) {
return (
<CartForm
route="/cart"
action={CartForm.ACTIONS.LinesRemove}
inputs={{
lineIds,
lineIds: [lineId],
}}
>
<button
Expand All @@ -301,20 +323,27 @@ function ItemRemoveButton({lineIds}: {lineIds: CartLine['id'][]}) {
<span className="sr-only">Remove</span>
<IconRemove aria-hidden="true" />
</button>
<OptimisticInput id={lineId} data={{action: 'remove'}} />
</CartForm>
);
}

function CartLineQuantityAdjust({line}: {line: CartLine}) {
const optimisticId = line?.id;
const optimisticData = useOptimisticData<OptimisticData>(optimisticId);

if (!line || typeof line?.quantity === 'undefined') return null;
const {id: lineId, quantity} = line;
const prevQuantity = Number(Math.max(0, quantity - 1).toFixed(0));
const nextQuantity = Number((quantity + 1).toFixed(0));

const optimisticQuantity = optimisticData?.quantity || line.quantity;

const {id: lineId} = line;
const prevQuantity = Number(Math.max(0, optimisticQuantity - 1).toFixed(0));
const nextQuantity = Number((optimisticQuantity + 1).toFixed(0));

return (
<>
<label htmlFor={`quantity-${lineId}`} className="sr-only">
Quantity, {quantity}
Quantity, {optimisticQuantity}
</label>
<div className="flex items-center border rounded">
<UpdateCartButton lines={[{id: lineId, quantity: prevQuantity}]}>
Expand All @@ -323,14 +352,18 @@ function CartLineQuantityAdjust({line}: {line: CartLine}) {
aria-label="Decrease quantity"
className="w-10 h-10 transition text-primary/50 hover:text-primary disabled:text-primary/10"
value={prevQuantity}
disabled={quantity <= 1}
disabled={optimisticQuantity <= 1}
>
<span>&#8722;</span>
<OptimisticInput
id={optimisticId}
data={{quantity: prevQuantity}}
/>
</button>
</UpdateCartButton>

<div className="px-2 text-center" data-test="item-quantity">
{quantity}
{optimisticQuantity}
</div>

<UpdateCartButton lines={[{id: lineId, quantity: nextQuantity}]}>
Expand All @@ -341,6 +374,10 @@ function CartLineQuantityAdjust({line}: {line: CartLine}) {
aria-label="Increase quantity"
>
<span>&#43;</span>
<OptimisticInput
id={optimisticId}
data={{quantity: nextQuantity}}
/>
</button>
</UpdateCartButton>
</div>
Expand Down

0 comments on commit 8772903

Please sign in to comment.