Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/add-to-cart-parent-line.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@shopify/hydrogen-react': patch
---

Add `parent` prop to `AddToCartButton` for nested cart lines

The `AddToCartButton` component now accepts an optional `parent` prop, allowing you to add items as children of an existing cart line. This enables adding warranties, gift wrapping, or other add-ons that should be associated with a parent product.

### Usage

```tsx
import {AddToCartButton} from '@shopify/hydrogen-react';

// Add a warranty as a child of an existing cart line (by line ID)
<AddToCartButton
variantId="gid://shopify/ProductVariant/warranty-123"
parent={{parentLineId: 'gid://shopify/CartLine/parent-456'}}
>
Add Extended Warranty
</AddToCartButton>

// Add a warranty as a child of a cart line (by merchandise ID)
// Useful when you know the product variant but not the cart line ID
<AddToCartButton
variantId="gid://shopify/ProductVariant/warranty-123"
parent={{merchandiseId: 'gid://shopify/ProductVariant/laptop-456'}}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note for other reviewers: this is using the actual type from the SF API, so the parent line item ID is also accepted here. either one works

>
Add Extended Warranty
</AddToCartButton>
```

### Type

```ts
interface AddToCartButtonPropsBase {
// ... existing props
/** The parent line item of the item being added to the cart. Used for nested cart lines. */
parent?: CartLineParentInput;
}
```
32 changes: 32 additions & 0 deletions .changeset/nested-cart-lines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@shopify/cli-hydrogen': patch
'skeleton': patch
---

Add support for nested cart line items (warranties, gift wrapping, etc.)

Storefront API 2025-10 introduces `parentRelationship` on cart line items, enabling parent-child relationships for add-ons. This update displays nested line items in the cart.

### Changes

- Updates GraphQL fragments to include `parentRelationship` and `lineComponents` fields
- Updates `CartMain` and `CartLineItem` to render child line items with visual hierarchy

### Note

This update focuses on **displaying** nested line items. To add both a product and its child (e.g., warranty) in a single action:

```tsx
<AddToCartButton
lines={[
{merchandiseId: 'gid://shopify/ProductVariant/laptop-456', quantity: 1},
{
merchandiseId: 'gid://shopify/ProductVariant/warranty-123',
quantity: 1,
parent: {merchandiseId: 'gid://shopify/ProductVariant/laptop-456'},
},
]}
>
Add to Cart with Warranty
</AddToCartButton>
```
63 changes: 41 additions & 22 deletions cookbook/src/lib/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ export function applyRecipe(params: {

// apply the patches to the template directory
console.log(`- 🥣 Applying steps…`);
const conflictFiles: {orig: string[], rej: string[]} = {orig: [], rej: []};
const conflictFiles: {orig: string[]; rej: string[]} = {orig: [], rej: []};

for (let i = 0; i < recipe.steps.length; i++) {
const step = recipe.steps[i];
if (step.diffs == null || step.diffs.length === 0) {
Expand All @@ -122,66 +122,85 @@ export function applyRecipe(params: {
console.log(` - 🩹 Patching ${diff.file} with ${diff.patchFile}…`);
const patchPath = path.join(recipeDir, 'patches', diff.patchFile);
const destPath = path.join(TEMPLATE_PATH, diff.file);

try {
execSync(`patch '${destPath}' '${patchPath}'`, {stdio: 'inherit'});
} catch (error) {
console.error(` ⚠️ Patch command failed or returned non-zero exit code`);
console.error(
` ⚠️ Patch command failed or returned non-zero exit code`,
);
}

// Check for conflict files
const origPath = `${destPath}.orig`;
const rejPath = `${destPath}.rej`;

if (fs.existsSync(origPath)) {
console.error(` ⚠️ Backup file created: ${path.basename(origPath)}`);
conflictFiles.orig.push(origPath);
}

if (fs.existsSync(rejPath)) {
console.error(` ❌ Patch rejected: ${path.basename(rejPath)}`);
conflictFiles.rej.push(rejPath);

// Show the contents of the .rej file to help with debugging
try {
const rejContent = fs.readFileSync(rejPath, 'utf-8');
console.error(`\n === Rejected hunks from ${path.basename(diff.file)} ===`);
console.error(rejContent.split('\n').map(line => ` | ${line}`).join('\n'));
console.error(
`\n === Rejected hunks from ${path.basename(diff.file)} ===`,
);
console.error(
rejContent
.split('\n')
.map((line) => ` | ${line}`)
.join('\n'),
);
console.error(` === End rejected hunks ===\n`);
} catch (e) {
// Ignore if we can't read the file
}
}
}
}

if (conflictFiles.orig.length > 0 || conflictFiles.rej.length > 0) {
console.error(`\n❌ PATCH CONFLICTS DETECTED!\n`);

if (conflictFiles.orig.length > 0) {
console.error(`📝 Backup files created (.orig):`);
console.error(` These indicate patches that applied with offset or fuzz.`);
conflictFiles.orig.forEach(file => {
console.error(
` These indicate patches that applied with offset or fuzz.`,
);
conflictFiles.orig.forEach((file) => {
console.error(` - ${file}`);
});
console.error('');
}

if (conflictFiles.rej.length > 0) {
console.error(`🚫 Rejected patches (.rej):`);
console.error(` These patches could not be applied to the current code.`);
conflictFiles.rej.forEach(file => {
console.error(
` These patches could not be applied to the current code.`,
);
conflictFiles.rej.forEach((file) => {
console.error(` - ${file}`);
});
console.error('');
}

console.error(`📋 To resolve:`);
console.error(` 1. Review the .orig and .rej files to understand the conflicts`);
console.error(
` 1. Review the .orig and .rej files to understand the conflicts`,
);
console.error(` 2. Manually apply the necessary changes`);
console.error(` 3. Delete the .orig and .rej files when resolved`);
console.error(` 4. Consider regenerating the recipe patches if the codebase has changed significantly`);

throw new Error('Patch conflicts detected. Please resolve conflicts before proceeding.');
console.error(
` 4. Consider regenerating the recipe patches if the codebase has changed significantly`,
);

throw new Error(
'Patch conflicts detected. Please resolve conflicts before proceeding.',
);
}
}
88 changes: 51 additions & 37 deletions cookbook/src/lib/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,7 @@ export type ValidationResult = {
errors: ValidationError[];
};

function getYamlNode(
yamlPath: string,
errorPath: (string | number)[],
): any {
function getYamlNode(yamlPath: string, errorPath: (string | number)[]): any {
try {
const content = fs.readFileSync(yamlPath, 'utf8');
const doc = YAML.parseDocument(content, {keepSourceTokens: true});
Expand All @@ -41,7 +38,10 @@ function getYamlNode(

if (typeof segment === 'number' && Array.isArray(node?.items)) {
node = node.items[segment];
} else if (typeof segment === 'string' && typeof node?.get === 'function') {
} else if (
typeof segment === 'string' &&
typeof node?.get === 'function'
) {
node = node.get(segment, true);
} else {
return null;
Expand All @@ -63,8 +63,7 @@ export function getYamlLineNumber(
const node = getYamlNode(yamlPath, errorPath);

if (node?.range?.[0] != null) {
const lineNumber =
content.substring(0, node.range[0]).split('\n').length;
const lineNumber = content.substring(0, node.range[0]).split('\n').length;
return lineNumber;
}

Expand All @@ -74,7 +73,10 @@ export function getYamlLineNumber(
}
}

function getYamlValue(yamlPath: string, errorPath: (string | number)[]): string | null {
function getYamlValue(
yamlPath: string,
errorPath: (string | number)[],
): string | null {
try {
const node = getYamlNode(yamlPath, errorPath);
if (node == null) return null;
Expand Down Expand Up @@ -385,55 +387,67 @@ export function validateRecipe(params: {
}

const preFlightResults = [
...(recipe ? [
validateStepNames(recipe),
validateStepDescriptions(recipe),
validatePatchFiles(recipeTitle, recipe),
validateIngredientFiles(recipeTitle, recipe),
] : []),
...(recipe
? [
validateStepNames(recipe),
validateStepDescriptions(recipe),
validatePatchFiles(recipeTitle, recipe),
validateIngredientFiles(recipeTitle, recipe),
]
: []),
validateReadmeExists(recipeTitle),
validateLlmPromptExists(recipeTitle),
];

allErrors.push(...preFlightResults.flatMap((r) => r.errors));

if (allErrors.length > 0) {
const errorsWithLines = allErrors.map((err) => {
if (err.lineNumber) return err;
if (!err.location) return err;
if (allErrors.length > 0) {
const errorsWithLines = allErrors.map((err) => {
if (err.lineNumber) return err;
if (!err.location) return err;

const pathSegments = err.location
.replace(/\[/g, '.')
.replace(/\]/g, '')
.split('.')
.filter(Boolean);
const pathSegments = err.location
.replace(/\[/g, '.')
.replace(/\]/g, '')
.split('.')
.filter(Boolean);

const lineNumber = getYamlLineNumber(recipeYamlPath, pathSegments);
return {...err, lineNumber: lineNumber ?? undefined};
});
const lineNumber = getYamlLineNumber(recipeYamlPath, pathSegments);
return {...err, lineNumber: lineNumber ?? undefined};
});

printValidationErrors(recipeTitle, errorsWithLines);
return false;
}
printValidationErrors(recipeTitle, errorsWithLines);
return false;
}

console.log(`- 🧑‍🍳 Applying recipe '${recipeTitle}'`);
console.log(`- 🧑‍🍳 Applying recipe '${recipeTitle}'`);
applyRecipe({
recipeTitle,
});

try {
const conflictFiles = execSync(`find ${TEMPLATE_PATH} -name "*.orig" -o -name "*.rej"`, {
encoding: 'utf-8',
stdio: 'pipe'
}).trim().split('\n').filter(Boolean);
const conflictFiles = execSync(
`find ${TEMPLATE_PATH} -name "*.orig" -o -name "*.rej"`,
{
encoding: 'utf-8',
stdio: 'pipe',
},
)
.trim()
.split('\n')
.filter(Boolean);

if (conflictFiles.length > 0) {
console.error(`\n❌ Conflict files detected in template directory:`);
conflictFiles.forEach(file => {
conflictFiles.forEach((file) => {
console.error(` - ${file}`);
});
console.error(`\nThese files will cause TypeScript errors during validation.`);
console.error(`Please resolve patch conflicts before running validation.`);
console.error(
`\nThese files will cause TypeScript errors during validation.`,
);
console.error(
`Please resolve patch conflicts before running validation.`,
);
return false;
}
} catch (e) {
Expand Down
27 changes: 27 additions & 0 deletions packages/hydrogen-react/src/AddToCartButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {AddToCartButton} from './AddToCartButton.js';
import {getProduct, getVariant} from './ProductProvider.test.helpers.js';
import {getCartMock} from './CartProvider.test.helpers.js';
import userEvent from '@testing-library/user-event';
import {CartLineParentInput} from './storefront-api-types.js';

const mockLinesAdd = vi.fn();

Expand Down Expand Up @@ -92,6 +93,32 @@ describe('<AddToCartButton/>', () => {
}),
]);
});

it('calls linesAdd with parent when adding a child line item', async () => {
const id = '123';
const parent: CartLineParentInput = {
lineId: 'gid://shopify/CartLine/parent-456',
};
const user = userEvent.setup();

render(
<MockWrapper>
<AddToCartButton variantId={id} parent={parent}>
Add warranty
</AddToCartButton>
</MockWrapper>,
);

await act(async () => await user.click(screen.getByRole('button')));

expect(mockLinesAdd).toHaveBeenCalledTimes(1);
expect(mockLinesAdd).toHaveBeenCalledWith([
expect.objectContaining({
merchandiseId: id,
parent,
}),
]);
});
});

describe('when inside a ProductProvider', () => {
Expand Down
Loading
Loading