-
Notifications
You must be signed in to change notification settings - Fork 380
Display warnings and errors in the cart #3400
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@shopify/cli-hydrogen': patch | ||
| 'skeleton': patch | ||
| --- | ||
|
|
||
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,13 +2,10 @@ import type {CartLineUpdateInput} from '@shopify/hydrogen/storefront-api-types'; | |
| import type {CartLayout, LineItemChildrenMap} from '~/components/CartMain'; | ||
| import {CartForm, Image, type OptimisticCartLine} from '@shopify/hydrogen'; | ||
| import {useVariantUrl} from '~/lib/variants'; | ||
| import {Link} from 'react-router'; | ||
| import {href, Link, useFetcher, type FetcherWithComponents} from 'react-router'; | ||
| import {ProductPrice} from './ProductPrice'; | ||
| import {useAside} from './Aside'; | ||
| import type { | ||
| CartApiQueryFragment, | ||
| CartLineFragment, | ||
| } from 'storefrontapi.generated'; | ||
| import type {CartApiQueryFragment} from 'storefrontapi.generated'; | ||
|
|
||
| export type CartLine = OptimisticCartLine<CartApiQueryFragment>; | ||
|
|
||
|
|
@@ -98,13 +95,13 @@ export function CartLineItem({ | |
| */ | ||
| function CartLineQuantity({line}: {line: CartLine}) { | ||
| if (!line || typeof line?.quantity === 'undefined') return null; | ||
|
|
||
| const {id: lineId, quantity, isOptimistic} = line; | ||
| const prevQuantity = Number(Math.max(0, quantity - 1).toFixed(0)); | ||
| const nextQuantity = Number((quantity + 1).toFixed(0)); | ||
|
|
||
| return ( | ||
| <div className="cart-line-quantity"> | ||
| <small>Quantity: {quantity} </small> | ||
| <span>Quantity: </span> | ||
| <CartLineUpdateButton lines={[{id: lineId, quantity: prevQuantity}]}> | ||
| <button | ||
| aria-label="Decrease quantity" | ||
|
|
@@ -116,6 +113,8 @@ function CartLineQuantity({line}: {line: CartLine}) { | |
| </button> | ||
| </CartLineUpdateButton> | ||
| | ||
| <CartLineQuantityInput disabled={!!isOptimistic} line={line} /> | ||
| | ||
| <CartLineUpdateButton lines={[{id: lineId, quantity: nextQuantity}]}> | ||
| <button | ||
| aria-label="Increase quantity" | ||
|
|
@@ -158,6 +157,67 @@ function CartLineRemoveButton({ | |
| ); | ||
| } | ||
|
|
||
| function isKeyboardEvent( | ||
| e: React.ChangeEvent<HTMLInputElement>, | ||
| ): e is typeof e & {nativeEvent: InputEvent} { | ||
| if (e.nativeEvent instanceof InputEvent) { | ||
| if ( | ||
| e.nativeEvent.inputType === 'insertText' || | ||
| e.nativeEvent.inputType === 'deleteContentBackward' || | ||
| e.nativeEvent.inputType === 'deleteContentForward' | ||
| ) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| async function submitQuantity( | ||
| e: React.ChangeEvent<HTMLInputElement>, | ||
| fetcher: FetcherWithComponents<any>, | ||
| line: CartLine, | ||
| ) { | ||
| const value = e.target.valueAsNumber; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing NaN validation
async function submitQuantity(
e: React.ChangeEvent<HTMLInputElement>,
fetcher: FetcherWithComponents<any>,
line: CartLine,
) {
const value = e.target.valueAsNumber;
if (Number.isNaN(value) || value < 1) {
return; // Don't submit invalid quantities
}
// ... rest
} |
||
| const formData = new FormData(); | ||
| formData.set( | ||
| CartForm.INPUT_NAME, | ||
| JSON.stringify({ | ||
| action: CartForm.ACTIONS.LinesUpdate, | ||
| inputs: {lines: [{id: line.id, quantity: value}]}, | ||
| }), | ||
| ); | ||
| await fetcher.submit(formData, {method: 'post', action: href('/cart')}); | ||
| } | ||
|
|
||
| function CartLineQuantityInput({ | ||
| line, | ||
| disabled, | ||
| }: { | ||
| line: CartLine; | ||
| disabled: boolean; | ||
| }) { | ||
| const fetcher = useFetcher({key: getUpdateKey([line.id])}); | ||
|
|
||
| return ( | ||
| <input | ||
| aria-label="Quantity" | ||
| min={1} | ||
| className="cart-line-quantity-input" | ||
| disabled={disabled} | ||
| key={line.quantity} | ||
| type="number" | ||
| defaultValue={line.quantity} | ||
| onChange={(e) => { | ||
| if (isKeyboardEvent(e)) return; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If a user changes quantity via up/down arrows (triggering The fetcher key deduplication mitigates this, but it's still unnecessary network traffic. Consider tracking whether a pending change exists: onChange={(e) => {
if (isKeyboardEvent(e)) return; // Keyboard waits for blur
void submitQuantity(e, fetcher, line);
}}
onBlur={(e) => {
// Only submit if there's a pending keyboard change
// Current implementation submits unconditionally
void submitQuantity(e, fetcher, line);
}} |
||
| void submitQuantity(e, fetcher, line); | ||
| }} | ||
| onBlur={(e) => { | ||
| void submitQuantity(e, fetcher, line); | ||
| }} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| function CartLineUpdateButton({ | ||
| children, | ||
| lines, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| import {InlineFeedback} from './InlineFeedback'; | ||
| import {href, useActionData, useFetchers, type Fetcher} from 'react-router'; | ||
| import type {action as cartAction} from '~/routes/cart'; | ||
| import {useEffect, useState} from 'react'; | ||
|
|
||
| function isCartFetcher( | ||
| fetcher: Fetcher, | ||
| ): fetcher is Fetcher<CartActionData> & {key: string} { | ||
| return fetcher.formAction === href('/cart') && fetcher.formMethod === 'POST'; | ||
| } | ||
|
|
||
| type CartActionData = NonNullable< | ||
| ReturnType<typeof useActionData<typeof cartAction>> | ||
| >; | ||
| /** Returns the errors and warnings from the cart fetchers | ||
| * Normalizes the errors to provide better UX. | ||
| * | ||
| * Errors are normalized by path, warnings are normalized by code. | ||
fredericoo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| */ | ||
| export function useCartFeedback() { | ||
| const [fetcherDataMap, setFetcherDataMap] = useState< | ||
| Map<string, CartActionData> | ||
| >(new Map()); | ||
| const fetchers = useFetchers(); | ||
|
|
||
| useEffect(() => { | ||
fredericoo marked this conversation as resolved.
Show resolved
Hide resolved
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fetcherDataMap is in the dependency array but also being updated inside this effect. In the 'submitting' case, The loop is prevented by the fetcher transitioning to 'loading' state quickly, not by the changed flag. This makes the code fragile — it relies on timing behaviour of React Router rather than explicit loop protection. The 'loading' case is safer because the data comparison Consider using the functional update pattern instead: useEffect(() => {
setFetcherDataMap((prevMap) => {
let changed = false;
const newMap = new Map(prevMap);
for (const fetcher of fetchers) {
if (!isCartFetcher(fetcher)) continue;
switch (fetcher.state) {
case 'submitting':
if (prevMap.size > 0) {
return new Map(); // Clear on new submission
}
break;
case 'loading':
if (fetcher.data && fetcher.data !== prevMap.get(fetcher.key)) {
changed = true;
newMap.set(fetcher.key, fetcher.data);
}
break;
}
}
return changed ? newMap : prevMap;
});
}, [fetchers]); // fetcherDataMap removed from deps |
||
| let changed = false; | ||
| let newFetcherDataMap = new Map(fetcherDataMap); | ||
|
|
||
| for (const fetcher of fetchers) { | ||
| if (!isCartFetcher(fetcher)) continue; | ||
|
|
||
| switch (fetcher.state) { | ||
| case 'submitting': | ||
| newFetcherDataMap = new Map(); | ||
| changed = true; | ||
| break; | ||
| case 'loading': | ||
| if ( | ||
| fetcher.data && | ||
| fetcher.data !== newFetcherDataMap.get(fetcher.key) | ||
| ) { | ||
| changed = true; | ||
| newFetcherDataMap.set(fetcher.key, fetcher.data); | ||
| } | ||
| break; | ||
| default: | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if (changed) { | ||
| setFetcherDataMap(newFetcherDataMap); | ||
| } | ||
| }, [fetchers, fetcherDataMap]); | ||
|
|
||
| const feedback: { | ||
| errors: Map<string, NonNullable<CartActionData['errors']>[number]>; | ||
| warnings: Map<string, NonNullable<CartActionData['warnings']>[number]>; | ||
| userErrors: Map<string, NonNullable<CartActionData['userErrors']>[number]>; | ||
| } = {errors: new Map(), warnings: new Map(), userErrors: new Map()}; | ||
| for (const fetcherData of fetcherDataMap.values()) { | ||
| if (fetcherData.warnings) { | ||
| for (const warning of fetcherData.warnings) { | ||
| feedback.warnings.set(warning.code, warning); | ||
| } | ||
| } | ||
| if (fetcherData.errors) { | ||
| for (const error of fetcherData.errors) { | ||
| feedback.errors.set(error.path?.join('.') ?? '_root', error); | ||
| } | ||
| } | ||
| if (fetcherData.userErrors) { | ||
| for (const userError of fetcherData.userErrors) { | ||
| feedback.userErrors.set(userError.code ?? '_root', userError); | ||
| } | ||
| } | ||
| } | ||
| return { | ||
| errors: Array.from(feedback.errors.values()), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Is this intentional? If errors shouldn't display here, consider removing them from the hook's return value to avoid confusion. If they should display, we're missing that rendering. |
||
| warnings: Array.from(feedback.warnings.values()), | ||
| userErrors: Array.from(feedback.userErrors.values()), | ||
| }; | ||
| } | ||
|
|
||
| /** Renders a list of warnings from the cart if there are any */ | ||
| export function CartWarnings() { | ||
fredericoo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const feedback = useCartFeedback(); | ||
| if (feedback.warnings.length === 0 && feedback.userErrors.length === 0) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <div className="cart-warnings"> | ||
| {feedback.warnings.map((warning) => ( | ||
| <InlineFeedback | ||
| key={warning.code} | ||
| type="warning" | ||
| title={warning.message} | ||
| /> | ||
| ))} | ||
| {feedback.userErrors.map((userError) => ( | ||
| <InlineFeedback | ||
| key={userError.code} | ||
| type="error" | ||
| title={userError.message} | ||
| /> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| interface InlineFeedbackProps { | ||
| type?: 'warning' | 'error'; | ||
| title: string; | ||
| description?: string; | ||
| } | ||
|
|
||
| /** | ||
| * An accessible inline feedback component for warnings and errors. | ||
| * Uses role="alert" to announce changes to assistive technology. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. JSDoc doesn't match implementation The comment says:
But line 19 uses Either update the comment to match, or consider making the role conditional: role={type === 'error' ? 'alert' : 'status'}
Previously I had proposed using `role="status"` but I looked into this more and apparently "alert" is preferred for error messages, whereas "status" is preferred for non-error feedback (eg: cart warnings) |
||
| */ | ||
| export function InlineFeedback({ | ||
| type = 'warning', | ||
| title, | ||
| description, | ||
| }: InlineFeedbackProps) { | ||
| const icon = type === 'error' ? '✕' : '⚠'; | ||
|
|
||
| return ( | ||
| <div className={`inline-feedback inline-feedback--${type}`} role="status"> | ||
| <span className="inline-feedback-icon" aria-hidden="true"> | ||
| {icon} | ||
| </span> | ||
| <div className="inline-feedback-content"> | ||
| <p className="inline-feedback-title">{title}</p> | ||
| {description ? ( | ||
| <p className="inline-feedback-description">{description}</p> | ||
| ) : null} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not strictly enforced right now, but I think we should never add any
anys into the codebaseThis could be properly typed using the cart action type: