Skip to content

Commit 2ead87a

Browse files
committed
fix: adjust state
1 parent cd65345 commit 2ead87a

9 files changed

Lines changed: 287 additions & 14 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/cli-hydrogen': patch
3+
'skeleton': patch
4+
---
5+
6+
Add cart warnings component to display feedback to users when there are issues with their cart. Includes new `InlineFeedback` component and a `CartWarnings` component for collecting and displaying cart errors and warnings in an accessible way.

e2e/specs/smoke/cart.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {setTestStore, test, expect} from '../../fixtures';
1+
import {setTestStore, test, expect, Page} from '../../fixtures';
22

33
setTestStore('mockShop');
44

templates/skeleton/app/components/CartLineItem.tsx

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import type {CartLineUpdateInput} from '@shopify/hydrogen/storefront-api-types';
22
import type {CartLayout} from '~/components/CartMain';
33
import {CartForm, Image, type OptimisticCartLine} from '@shopify/hydrogen';
44
import {useVariantUrl} from '~/lib/variants';
5-
import {Link} from 'react-router';
5+
import {href, Link, useFetcher, type FetcherWithComponents} from 'react-router';
66
import {ProductPrice} from './ProductPrice';
77
import {useAside} from './Aside';
88
import type {CartApiQueryFragment} from 'storefrontapi.generated';
9+
import {useId} from 'react';
910

1011
type CartLine = OptimisticCartLine<CartApiQueryFragment>;
1112

@@ -75,13 +76,13 @@ export function CartLineItem({
7576
*/
7677
function CartLineQuantity({line}: {line: CartLine}) {
7778
if (!line || typeof line?.quantity === 'undefined') return null;
79+
7880
const {id: lineId, quantity, isOptimistic} = line;
7981
const prevQuantity = Number(Math.max(0, quantity - 1).toFixed(0));
8082
const nextQuantity = Number((quantity + 1).toFixed(0));
81-
8283
return (
8384
<div className="cart-line-quantity">
84-
<small>Quantity: {quantity} &nbsp;&nbsp;</small>
85+
<span>Quantity: &nbsp;&nbsp;</span>
8586
<CartLineUpdateButton lines={[{id: lineId, quantity: prevQuantity}]}>
8687
<button
8788
aria-label="Decrease quantity"
@@ -93,6 +94,8 @@ function CartLineQuantity({line}: {line: CartLine}) {
9394
</button>
9495
</CartLineUpdateButton>
9596
&nbsp;
97+
<CartLineQuantityInput disabled={!!isOptimistic} line={line} />
98+
&nbsp;
9699
<CartLineUpdateButton lines={[{id: lineId, quantity: nextQuantity}]}>
97100
<button
98101
aria-label="Increase quantity"
@@ -135,6 +138,67 @@ function CartLineRemoveButton({
135138
);
136139
}
137140

141+
function isKeyboardEvent(
142+
e: React.ChangeEvent<HTMLInputElement>,
143+
): e is typeof e & {nativeEvent: InputEvent} {
144+
if (e.nativeEvent instanceof InputEvent) {
145+
if (
146+
e.nativeEvent.inputType === 'insertText' ||
147+
e.nativeEvent.inputType === 'deleteContentBackward' ||
148+
e.nativeEvent.inputType === 'deleteContentForward'
149+
) {
150+
return true;
151+
}
152+
}
153+
return false;
154+
}
155+
156+
async function submitQuantity(
157+
e: React.ChangeEvent<HTMLInputElement>,
158+
fetcher: FetcherWithComponents<any>,
159+
line: CartLine,
160+
) {
161+
const value = e.target.valueAsNumber;
162+
const formData = new FormData();
163+
formData.set(
164+
CartForm.INPUT_NAME,
165+
JSON.stringify({
166+
action: CartForm.ACTIONS.LinesUpdate,
167+
inputs: {lines: [{id: line.id, quantity: value}]},
168+
}),
169+
);
170+
await fetcher.submit(formData, {method: 'post', action: href('/cart')});
171+
}
172+
173+
function CartLineQuantityInput({
174+
line,
175+
disabled,
176+
}: {
177+
line: CartLine;
178+
disabled: boolean;
179+
}) {
180+
const fetcher = useFetcher({key: getUpdateKey([line.id])});
181+
182+
return (
183+
<input
184+
aria-label="Quantity"
185+
min={1}
186+
className="cart-line-quantity-input"
187+
disabled={disabled}
188+
key={line.quantity}
189+
type="number"
190+
defaultValue={line.quantity}
191+
onChange={(e) => {
192+
if (isKeyboardEvent(e)) return;
193+
void submitQuantity(e, fetcher, line);
194+
}}
195+
onBlur={(e) => {
196+
void submitQuantity(e, fetcher, line);
197+
}}
198+
/>
199+
);
200+
}
201+
138202
function CartLineUpdateButton({
139203
children,
140204
lines,

templates/skeleton/app/components/CartMain.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {CartApiQueryFragment} from 'storefrontapi.generated';
44
import {useAside} from '~/components/Aside';
55
import {CartLineItem} from '~/components/CartLineItem';
66
import {CartSummary} from './CartSummary';
7+
import {CartWarnings} from './CartWarnings';
78

89
export type CartLayout = 'page' | 'aside';
910

@@ -31,6 +32,7 @@ export function CartMain({layout, cart: originalCart}: CartMainProps) {
3132
return (
3233
<div className={className}>
3334
<CartEmpty hidden={linesCount} layout={layout} />
35+
<CartWarnings />
3436
<div className="cart-details">
3537
<div aria-labelledby="cart-lines">
3638
<ul>
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import {InlineFeedback} from './InlineFeedback';
2+
import {href, useActionData, useFetchers, type Fetcher} from 'react-router';
3+
import type {action as cartAction} from '~/routes/cart';
4+
import {useEffect, useState} from 'react';
5+
6+
function isCartFetcher(
7+
fetcher: Fetcher,
8+
): fetcher is Fetcher<CartActionData> & {key: string} {
9+
return fetcher.formAction === href('/cart') && fetcher.formMethod === 'POST';
10+
}
11+
12+
type CartActionData = NonNullable<
13+
ReturnType<typeof useActionData<typeof cartAction>>
14+
>;
15+
/** Returns the errors and warnings from the cart fetchers
16+
* Normalizes the errors to provide better UX.
17+
*
18+
* Errors are normalized by path, warnings are normalized by code.
19+
*/
20+
export function useCartFeedback() {
21+
const [fetcherDataMap, setFetcherDataMap] = useState<
22+
Map<string, CartActionData>
23+
>(new Map());
24+
const fetchers = useFetchers();
25+
26+
useEffect(() => {
27+
let changed = false;
28+
let newFetcherDataMap = new Map(fetcherDataMap);
29+
30+
for (const fetcher of fetchers) {
31+
if (!isCartFetcher(fetcher)) continue;
32+
33+
switch (fetcher.state) {
34+
case 'submitting':
35+
newFetcherDataMap = new Map();
36+
changed = true;
37+
break;
38+
case 'loading':
39+
if (
40+
fetcher.data &&
41+
fetcher.data !== newFetcherDataMap.get(fetcher.key)
42+
) {
43+
changed = true;
44+
newFetcherDataMap.set(fetcher.key, fetcher.data);
45+
}
46+
break;
47+
default:
48+
break;
49+
}
50+
}
51+
52+
if (changed) {
53+
setFetcherDataMap(newFetcherDataMap);
54+
}
55+
}, [fetchers, fetcherDataMap]);
56+
57+
const feedback: {
58+
errors: Map<string, NonNullable<CartActionData['errors']>[number]>;
59+
warnings: Map<string, NonNullable<CartActionData['warnings']>[number]>;
60+
userErrors: Map<string, NonNullable<CartActionData['userErrors']>[number]>;
61+
} = {errors: new Map(), warnings: new Map(), userErrors: new Map()};
62+
for (const fetcherData of fetcherDataMap.values()) {
63+
if (fetcherData.warnings) {
64+
for (const warning of fetcherData.warnings) {
65+
feedback.warnings.set(warning.code, warning);
66+
}
67+
}
68+
if (fetcherData.errors) {
69+
for (const error of fetcherData.errors) {
70+
feedback.errors.set(error.path?.join('.') ?? '_root', error);
71+
}
72+
}
73+
if (fetcherData.userErrors) {
74+
for (const userError of fetcherData.userErrors) {
75+
feedback.userErrors.set(userError.code ?? '_root', userError);
76+
}
77+
}
78+
}
79+
return {
80+
errors: Array.from(feedback.errors.values()),
81+
warnings: Array.from(feedback.warnings.values()),
82+
userErrors: Array.from(feedback.userErrors.values()),
83+
};
84+
}
85+
86+
/** Renders a list of warnings from the cart if there are any */
87+
export function CartWarnings() {
88+
const feedback = useCartFeedback();
89+
if (feedback.warnings.length === 0 && feedback.userErrors.length === 0) {
90+
return null;
91+
}
92+
93+
return (
94+
<div className="cart-warnings">
95+
{feedback.warnings.map((warning) => (
96+
<InlineFeedback
97+
key={warning.code}
98+
type="warning"
99+
title={warning.message}
100+
/>
101+
))}
102+
{feedback.userErrors.map((userError) => (
103+
<InlineFeedback
104+
key={userError.code}
105+
type="error"
106+
title={userError.message}
107+
/>
108+
))}
109+
</div>
110+
);
111+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
interface InlineFeedbackProps {
2+
type?: 'warning' | 'error';
3+
title: string;
4+
description?: string;
5+
}
6+
7+
/**
8+
* An accessible inline feedback component for warnings and errors.
9+
* Uses role="alert" to announce changes to assistive technology.
10+
*/
11+
export function InlineFeedback({
12+
type = 'warning',
13+
title,
14+
description,
15+
}: InlineFeedbackProps) {
16+
const icon = type === 'error' ? '✕' : '⚠';
17+
18+
return (
19+
<div
20+
className={`inline-feedback inline-feedback--${type}`}
21+
role="alert"
22+
aria-live="polite"
23+
aria-atomic="true"
24+
>
25+
<span className="inline-feedback-icon" aria-hidden="true">
26+
{icon}
27+
</span>
28+
<div className="inline-feedback-content">
29+
<p className="inline-feedback-title">{title}</p>
30+
{description ? (
31+
<p className="inline-feedback-description">{description}</p>
32+
) : null}
33+
</div>
34+
</div>
35+
);
36+
}

templates/skeleton/app/components/PageLayout.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ import type {
88
import {Aside} from '~/components/Aside';
99
import {Footer} from '~/components/Footer';
1010
import {Header, HeaderMenu} from '~/components/Header';
11-
import {CartMain} from '~/components/CartMain';
1211
import {
1312
SEARCH_ENDPOINT,
1413
SearchFormPredictive,
1514
} from '~/components/SearchFormPredictive';
1615
import {SearchResultsPredictive} from '~/components/SearchResultsPredictive';
16+
import {CartMain} from './CartMain';
1717

1818
interface PageLayoutProps {
1919
cart: Promise<CartApiQueryFragment | null>;
@@ -60,9 +60,7 @@ function CartAside({cart}: {cart: PageLayoutProps['cart']}) {
6060
<Aside type="cart" heading="CART">
6161
<Suspense fallback={<p>Loading cart ...</p>}>
6262
<Await resolve={cart}>
63-
{(cart) => {
64-
return <CartMain cart={cart} layout="aside" />;
65-
}}
63+
{(cart) => <CartMain cart={cart} layout="aside" />}
6664
</Await>
6765
</Suspense>
6866
</Aside>

templates/skeleton/app/routes/cart.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import {
2-
useLoaderData,
3-
data,
4-
type HeadersFunction,
5-
} from 'react-router';
1+
import {useLoaderData, data, type HeadersFunction} from 'react-router';
62
import type {Route} from './+types/cart';
73
import type {CartQueryDataReturn} from '@shopify/hydrogen';
84
import {CartForm} from '@shopify/hydrogen';
@@ -83,7 +79,7 @@ export async function action({request, context}: Route.ActionArgs) {
8379

8480
const cartId = result?.cart?.id;
8581
const headers = cartId ? cart.setCartId(result.cart.id) : new Headers();
86-
const {cart: cartResult, errors, warnings} = result;
82+
const {cart: cartResult, errors, warnings, userErrors} = result;
8783

8884
const redirectTo = formData.get('redirectTo') ?? null;
8985
if (typeof redirectTo === 'string') {
@@ -95,6 +91,7 @@ export async function action({request, context}: Route.ActionArgs) {
9591
{
9692
cart: cartResult,
9793
errors,
94+
userErrors,
9895
warnings,
9996
analytics: {
10097
cartId,

0 commit comments

Comments
 (0)