Explorar o código

fix(dashboard): Include OrderLine custom fields in detail view (#3958)

Michael Bromley hai 2 meses
pai
achega
d314a6ca03

+ 4 - 1
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-detail-shared.tsx

@@ -2,6 +2,7 @@ import { CustomFieldsForm } from '@/vdb/components/shared/custom-fields-form.js'
 import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
 import { Button } from '@/vdb/components/ui/button.js';
 import { DropdownMenuItem } from '@/vdb/components/ui/dropdown-menu.js';
+import { addCustomFields } from '@/vdb/framework/document-introspection/add-custom-fields.js';
 import {
     Page,
     PageActionBar,
@@ -72,7 +73,9 @@ export function OrderDetailShared({
 
     const { form, submitHandler, entity, refreshEntity } = useDetailPage({
         pageId,
-        queryDocument: orderDetailDocument,
+        queryDocument: addCustomFields(orderDetailDocument, {
+            includeNestedFragments: ['OrderLine'],
+        }),
         updateDocument: setOrderCustomFieldsDocument,
         setValuesForUpdate: (entity: any) => {
             return {

+ 4 - 1
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-table.tsx

@@ -147,7 +147,10 @@ export function OrderTable({ order, pageId }: Readonly<OrderTableProps>) {
     ];
     const currencyCode = order.currencyCode;
 
-    const fields = getFieldsFromDocumentNode(addCustomFields(orderDetailDocument), ['order', 'lines']);
+    const fields = getFieldsFromDocumentNode(
+        addCustomFields(orderDetailDocument, { includeNestedFragments: ['OrderLine'] }),
+        ['order', 'lines'],
+    );
 
     const customizeColumns = useMemo(() => createCustomizeColumns(currencyCode), [currencyCode]);
 

+ 907 - 1
packages/dashboard/src/lib/framework/document-introspection/add-custom-fields.spec.ts

@@ -3,7 +3,7 @@ import { graphql } from 'gql.tada';
 import { DocumentNode, FieldNode, FragmentDefinitionNode, Kind, print } from 'graphql';
 import { beforeEach, describe, expect, it } from 'vitest';
 
-import { addCustomFields } from './add-custom-fields.js';
+import { addCustomFields, addCustomFieldsToFragment } from './add-custom-fields.js';
 
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
 describe('addCustomFields()', () => {
@@ -549,4 +549,910 @@ describe('addCustomFields()', () => {
             expect(printed).not.toMatch(/product\s*\{[^}]*customFields/s);
         });
     });
+
+    describe('includeNestedFragments option', () => {
+        it('Should add custom fields to nested fragments when explicitly included', () => {
+            const assetFragment = graphql(`
+                fragment Asset on Asset {
+                    id
+                    preview
+                }
+            `);
+
+            const documentNode = graphql(
+                `
+                    query ProductList($options: ProductListOptions) {
+                        products(options: $options) {
+                            items {
+                                id
+                                name
+                                featuredAsset {
+                                    ...Asset
+                                }
+                            }
+                        }
+                    }
+                `,
+                [assetFragment],
+            );
+
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [{ name: 'productCustomField', type: 'string', list: false }]);
+            customFieldsConfig.set('Asset', [{ name: 'assetCustomField', type: 'string', list: false }]);
+
+            const result = addCustomFields(documentNode, {
+                customFieldsMap: customFieldsConfig,
+                includeNestedFragments: ['Asset'], // Explicitly include the nested Asset fragment
+            });
+            const printed = print(result);
+
+            // Should add customFields to Product (top-level)
+            expect(printed).toContain('productCustomField');
+
+            // Should ALSO add customFields to Asset (nested, but explicitly included)
+            expect(printed).toContain('fragment Asset on Asset');
+            expect(printed).toContain('assetCustomField');
+        });
+
+        it('Should handle multiple nested fragments in includeNestedFragments', () => {
+            const assetFragment = graphql(`
+                fragment Asset on Asset {
+                    id
+                    preview
+                }
+            `);
+
+            const orderLineFragment = graphql(`
+                fragment OrderLine on OrderLine {
+                    id
+                    quantity
+                }
+            `);
+
+            const documentNode = graphql(
+                `
+                    query GetOrder($id: ID!) {
+                        order(id: $id) {
+                            id
+                            code
+                            lines {
+                                ...OrderLine
+                            }
+                            featuredAsset {
+                                ...Asset
+                            }
+                        }
+                    }
+                `,
+                [orderLineFragment, assetFragment],
+            );
+
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Order', [{ name: 'orderCustomField', type: 'string', list: false }]);
+            customFieldsConfig.set('OrderLine', [
+                { name: 'orderLineCustomField', type: 'string', list: false },
+            ]);
+            customFieldsConfig.set('Asset', [{ name: 'assetCustomField', type: 'string', list: false }]);
+
+            const result = addCustomFields(documentNode, {
+                customFieldsMap: customFieldsConfig,
+                includeNestedFragments: ['OrderLine', 'Asset'], // Include both nested fragments
+            });
+            const printed = print(result);
+
+            // Should add customFields to all three
+            expect(printed).toContain('orderCustomField');
+            expect(printed).toContain('orderLineCustomField');
+            expect(printed).toContain('assetCustomField');
+        });
+
+        it('Should only add custom fields to specified nested fragments, not all nested fragments', () => {
+            const assetFragment = graphql(`
+                fragment Asset on Asset {
+                    id
+                    preview
+                }
+            `);
+
+            const orderLineFragment = graphql(`
+                fragment OrderLine on OrderLine {
+                    id
+                    quantity
+                }
+            `);
+
+            const documentNode = graphql(
+                `
+                    query GetOrder($id: ID!) {
+                        order(id: $id) {
+                            id
+                            lines {
+                                ...OrderLine
+                            }
+                            featuredAsset {
+                                ...Asset
+                            }
+                        }
+                    }
+                `,
+                [orderLineFragment, assetFragment],
+            );
+
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Order', [{ name: 'orderCustomField', type: 'string', list: false }]);
+            customFieldsConfig.set('OrderLine', [
+                { name: 'orderLineCustomField', type: 'string', list: false },
+            ]);
+            customFieldsConfig.set('Asset', [{ name: 'assetCustomField', type: 'string', list: false }]);
+
+            const result = addCustomFields(documentNode, {
+                customFieldsMap: customFieldsConfig,
+                includeNestedFragments: ['OrderLine'], // Only include OrderLine, not Asset
+            });
+            const printed = print(result);
+
+            // Should add customFields to Order (top-level) and OrderLine (explicitly included)
+            expect(printed).toContain('orderCustomField');
+            expect(printed).toContain('orderLineCustomField');
+
+            // Should NOT add customFields to Asset (nested and not included)
+            expect(printed).not.toContain('assetCustomField');
+        });
+
+        it('Works with the timing issue - called later when globalCustomFieldsMap is populated', () => {
+            const orderLineFragment = graphql(`
+                fragment OrderLine on OrderLine {
+                    id
+                    quantity
+                }
+            `);
+
+            const orderDetailFragment = graphql(
+                `
+                    fragment OrderDetail on Order {
+                        id
+                        code
+                        lines {
+                            ...OrderLine
+                        }
+                    }
+                `,
+                [orderLineFragment],
+            );
+
+            const orderDetailDocument = graphql(
+                `
+                    query GetOrder($id: ID!) {
+                        order(id: $id) {
+                            ...OrderDetail
+                        }
+                    }
+                `,
+                [orderDetailFragment],
+            );
+
+            // Initially, globalCustomFieldsMap is empty (simulating module load time)
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            // Documents are created...
+
+            // Later, when server config is loaded and custom fields are available
+            customFieldsConfig.set('Order', [{ name: 'orderCustomField', type: 'string', list: false }]);
+            customFieldsConfig.set('OrderLine', [
+                { name: 'orderLineCustomField', type: 'string', list: false },
+            ]);
+
+            // Now when addCustomFields is called (e.g., in a component), it has access to custom fields
+            const result = addCustomFields(orderDetailDocument, {
+                customFieldsMap: customFieldsConfig,
+                includeNestedFragments: ['OrderLine'], // Explicitly include nested OrderLine fragment
+            });
+            const printed = print(result);
+
+            // Should add customFields to both Order and OrderLine
+            expect(printed).toContain('orderCustomField');
+            expect(printed).toContain('orderLineCustomField');
+        });
+    });
+});
+
+describe('addCustomFieldsToFragment()', () => {
+    /**
+     * Normalizes the indentation of a string to make it easier to compare with the expected output
+     */
+    function normalizeIndentation(str: string): string {
+        const lines = str.replace(/    /g, '  ').split('\n');
+        const indentLength = lines[1].search(/\S|$/); // Find the first non-whitespace character
+        return lines
+            .map(line => line.slice(indentLength))
+            .join('\n')
+            .trim()
+            .replace(/"/g, '');
+    }
+
+    describe('Basic functionality', () => {
+        it('Adds customFields to a simple fragment', () => {
+            const fragmentDocument = graphql(`
+                fragment Product on Product {
+                    id
+                    name
+                }
+            `);
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [
+                { name: 'custom1', type: 'string', list: false },
+                { name: 'custom2', type: 'boolean', list: false },
+            ]);
+
+            const result = addCustomFieldsToFragment(fragmentDocument, {
+                customFieldsMap: customFieldsConfig,
+            });
+
+            expect(print(result)).toBe(
+                normalizeIndentation(`
+                fragment Product on Product {
+                    id
+                    name
+                    customFields {
+                        custom1
+                        custom2
+                    }
+                }
+            `),
+            );
+        });
+
+        it('Adds customFields with includeCustomFields filter', () => {
+            const fragmentDocument = graphql(`
+                fragment Product on Product {
+                    id
+                    name
+                }
+            `);
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [
+                { name: 'custom1', type: 'string', list: false },
+                { name: 'custom2', type: 'boolean', list: false },
+                { name: 'custom3', type: 'int', list: false },
+            ]);
+
+            const result = addCustomFieldsToFragment(fragmentDocument, {
+                customFieldsMap: customFieldsConfig,
+                includeCustomFields: ['custom1', 'custom3'],
+            });
+
+            expect(print(result)).toBe(
+                normalizeIndentation(`
+                fragment Product on Product {
+                    id
+                    name
+                    customFields {
+                        custom1
+                        custom3
+                    }
+                }
+            `),
+            );
+        });
+
+        it('Handles fragment with no custom fields configured', () => {
+            const fragmentDocument = graphql(`
+                fragment Product on Product {
+                    id
+                    name
+                }
+            `);
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+
+            const result = addCustomFieldsToFragment(fragmentDocument, {
+                customFieldsMap: customFieldsConfig,
+            });
+
+            // Should return the fragment unchanged
+            expect(print(result)).toBe(
+                normalizeIndentation(`
+                fragment Product on Product {
+                    id
+                    name
+                }
+            `),
+            );
+        });
+    });
+
+    describe('Validation', () => {
+        it('Throws error when given a query document', () => {
+            const documentNode = graphql(`
+                query GetProduct {
+                    product {
+                        id
+                        name
+                    }
+                }
+            `);
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [{ name: 'custom', type: 'string', list: false }]);
+
+            expect(() =>
+                addCustomFieldsToFragment(documentNode, { customFieldsMap: customFieldsConfig }),
+            ).toThrow('expects a fragment-only document');
+        });
+
+        it('Only modifies the first fragment when multiple fragments are present', () => {
+            const productFragment = graphql(`
+                fragment Product on Product {
+                    id
+                }
+            `);
+            const variantFragment = graphql(`
+                fragment Variant on ProductVariant {
+                    id
+                }
+            `);
+
+            // Create a document with both fragments (Product first, then Variant)
+            const multiFragmentDoc = {
+                kind: Kind.DOCUMENT,
+                definitions: [...productFragment.definitions, ...variantFragment.definitions],
+            } as DocumentNode;
+
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [{ name: 'productCustom', type: 'string', list: false }]);
+            customFieldsConfig.set('ProductVariant', [
+                { name: 'variantCustom', type: 'string', list: false },
+            ]);
+
+            const result = addCustomFieldsToFragment(multiFragmentDoc, {
+                customFieldsMap: customFieldsConfig,
+            });
+            const printed = print(result);
+
+            // Should add customFields to Product (first fragment)
+            expect(printed).toContain('fragment Product on Product');
+            expect(printed).toContain('productCustom');
+
+            // Should NOT add customFields to Variant (dependency fragment)
+            expect(printed).toContain('fragment Variant on ProductVariant');
+            expect(printed).not.toContain('variantCustom');
+        });
+
+        it('Throws error when given an empty document', () => {
+            const emptyDoc = {
+                kind: Kind.DOCUMENT,
+                definitions: [],
+            } as DocumentNode;
+
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+
+            expect(() =>
+                addCustomFieldsToFragment(emptyDoc, { customFieldsMap: customFieldsConfig }),
+            ).toThrow('expects a document with at least one fragment definition');
+        });
+    });
+
+    describe('Advanced field types', () => {
+        it('Handles relation custom fields', () => {
+            const fragmentDocument = graphql(`
+                fragment Product on Product {
+                    id
+                    name
+                }
+            `);
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [
+                {
+                    name: 'relatedProduct',
+                    type: 'relation',
+                    list: false,
+                    scalarFields: ['id', 'name', 'slug'],
+                },
+            ]);
+
+            const result = addCustomFieldsToFragment(fragmentDocument, {
+                customFieldsMap: customFieldsConfig,
+            });
+
+            expect(print(result)).toBe(
+                normalizeIndentation(`
+                fragment Product on Product {
+                    id
+                    name
+                    customFields {
+                        relatedProduct {
+                            id
+                            name
+                            slug
+                        }
+                    }
+                }
+            `),
+            );
+        });
+
+        it('Handles struct custom fields', () => {
+            const fragmentDocument = graphql(`
+                fragment Product on Product {
+                    id
+                    name
+                }
+            `);
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [
+                {
+                    name: 'dimensions',
+                    type: 'struct',
+                    list: false,
+                    fields: [
+                        { name: 'width', type: 'int' },
+                        { name: 'height', type: 'int' },
+                        { name: 'depth', type: 'int' },
+                    ],
+                },
+            ]);
+
+            const result = addCustomFieldsToFragment(fragmentDocument, {
+                customFieldsMap: customFieldsConfig,
+            });
+
+            expect(print(result)).toBe(
+                normalizeIndentation(`
+                fragment Product on Product {
+                    id
+                    name
+                    customFields {
+                        dimensions {
+                            width
+                            height
+                            depth
+                        }
+                    }
+                }
+            `),
+            );
+        });
+
+        it('Handles localized custom fields in translations', () => {
+            const fragmentDocument = graphql(`
+                fragment Product on Product {
+                    id
+                    translations {
+                        languageCode
+                        name
+                    }
+                }
+            `);
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [
+                { name: 'customDescription', type: 'localeString', list: false },
+                { name: 'customSeoTitle', type: 'localeText', list: false },
+            ]);
+
+            const result = addCustomFieldsToFragment(fragmentDocument, {
+                customFieldsMap: customFieldsConfig,
+            });
+
+            const printed = print(result);
+            // Should add localized fields to translations
+            expect(printed).toContain('translations {');
+            expect(printed).toMatch(/translations\s*\{[^}]*customFields/s);
+
+            const fragmentDef = result.definitions[0] as FragmentDefinitionNode;
+            const translationsField = fragmentDef.selectionSet.selections.find(
+                s => s.kind === Kind.FIELD && s.name.value === 'translations',
+            ) as FieldNode;
+            const customFieldsInTranslations = translationsField.selectionSet!.selections.find(
+                s => s.kind === Kind.FIELD && s.name.value === 'customFields',
+            ) as FieldNode;
+
+            expect(customFieldsInTranslations).toBeTruthy();
+            expect(customFieldsInTranslations.selectionSet!.selections.length).toBe(2);
+        });
+    });
+
+    describe('Special type handling', () => {
+        it('Handles OrderAddress as alias of Address', () => {
+            const fragmentDocument = graphql(`
+                fragment OrderAddress on OrderAddress {
+                    id
+                    streetLine1
+                }
+            `);
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            // Custom fields are configured for Address, not OrderAddress
+            customFieldsConfig.set('Address', [{ name: 'buildingNumber', type: 'string', list: false }]);
+
+            const result = addCustomFieldsToFragment(fragmentDocument, {
+                customFieldsMap: customFieldsConfig,
+            });
+
+            // Should still add custom fields because OrderAddress is aliased to Address
+            expect(print(result)).toContain('customFields {');
+            expect(print(result)).toContain('buildingNumber');
+        });
+
+        it('Handles Country as alias of Region', () => {
+            const fragmentDocument = graphql(`
+                fragment Country on Country {
+                    id
+                    name
+                }
+            `);
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            // Custom fields are configured for Region, not Country
+            customFieldsConfig.set('Region', [{ name: 'regionCode', type: 'string', list: false }]);
+
+            const result = addCustomFieldsToFragment(fragmentDocument, {
+                customFieldsMap: customFieldsConfig,
+            });
+
+            // Should still add custom fields because Country is aliased to Region
+            expect(print(result)).toContain('customFields {');
+            expect(print(result)).toContain('regionCode');
+        });
+    });
+
+    describe('Memoization', () => {
+        it('Returns the same instance for the same inputs', () => {
+            const fragmentDocument = graphql(`
+                fragment Product on Product {
+                    id
+                    name
+                }
+            `);
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [{ name: 'custom', type: 'string', list: false }]);
+
+            const result1 = addCustomFieldsToFragment(fragmentDocument, {
+                customFieldsMap: customFieldsConfig,
+            });
+            const result2 = addCustomFieldsToFragment(fragmentDocument, {
+                customFieldsMap: customFieldsConfig,
+            });
+
+            // Should return the exact same instance (identity equality)
+            expect(result1).toBe(result2);
+        });
+
+        it('Returns different instances for different options', () => {
+            const fragmentDocument = graphql(`
+                fragment Product on Product {
+                    id
+                    name
+                }
+            `);
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [
+                { name: 'custom1', type: 'string', list: false },
+                { name: 'custom2', type: 'boolean', list: false },
+            ]);
+
+            const result1 = addCustomFieldsToFragment(fragmentDocument, {
+                customFieldsMap: customFieldsConfig,
+                includeCustomFields: ['custom1'],
+            });
+            const result2 = addCustomFieldsToFragment(fragmentDocument, {
+                customFieldsMap: customFieldsConfig,
+                includeCustomFields: ['custom2'],
+            });
+
+            // Should return different instances for different options
+            expect(result1).not.toBe(result2);
+            expect(print(result1)).toContain('custom1');
+            expect(print(result1)).not.toContain('custom2');
+            expect(print(result2)).toContain('custom2');
+            expect(print(result2)).not.toContain('custom1');
+        });
+    });
+
+    describe('Fragment spreads handling', () => {
+        it('Should only add custom fields to the top-level fragment, not to referenced fragments', () => {
+            const orderLineFragment = graphql(`
+                fragment OrderLine on OrderLine {
+                    id
+                    quantity
+                }
+            `);
+
+            const orderDetailFragment = graphql(
+                `
+                    fragment OrderDetail on Order {
+                        id
+                        code
+                        lines {
+                            ...OrderLine
+                        }
+                    }
+                `,
+                [orderLineFragment],
+            );
+
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Order', [{ name: 'orderCustomField', type: 'string', list: false }]);
+            customFieldsConfig.set('OrderLine', [
+                { name: 'orderLineCustomField', type: 'string', list: false },
+            ]);
+
+            // Apply to the OrderDetail fragment only
+            const result = addCustomFieldsToFragment(orderDetailFragment, {
+                customFieldsMap: customFieldsConfig,
+            });
+
+            const printed = print(result);
+
+            // Should add customFields to OrderDetail (top-level fragment)
+            expect(printed).toContain('fragment OrderDetail on Order');
+            expect(printed).toContain('orderCustomField');
+
+            // Should include the OrderLine fragment definition (dependency) but NOT add customFields to it
+            expect(printed).toContain('...OrderLine');
+            expect(printed).toContain('fragment OrderLine on OrderLine');
+            expect(printed).not.toContain('orderLineCustomField');
+        });
+
+        it('Should work with deeply nested fragment spreads', () => {
+            const assetFragment = graphql(`
+                fragment Asset on Asset {
+                    id
+                    preview
+                }
+            `);
+
+            const orderLineFragment = graphql(
+                `
+                    fragment OrderLine on OrderLine {
+                        id
+                        quantity
+                        featuredAsset {
+                            ...Asset
+                        }
+                    }
+                `,
+                [assetFragment],
+            );
+
+            const orderDetailFragment = graphql(
+                `
+                    fragment OrderDetail on Order {
+                        id
+                        code
+                        lines {
+                            ...OrderLine
+                        }
+                    }
+                `,
+                [orderLineFragment],
+            );
+
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Order', [{ name: 'orderCustomField', type: 'string', list: false }]);
+            customFieldsConfig.set('OrderLine', [
+                { name: 'orderLineCustomField', type: 'string', list: false },
+            ]);
+            customFieldsConfig.set('Asset', [{ name: 'assetCustomField', type: 'string', list: false }]);
+
+            const result = addCustomFieldsToFragment(orderDetailFragment, {
+                customFieldsMap: customFieldsConfig,
+            });
+
+            const printed = print(result);
+
+            // Should ONLY add customFields to OrderDetail
+            expect(printed).toContain('orderCustomField');
+            expect(printed).not.toContain('orderLineCustomField');
+            expect(printed).not.toContain('assetCustomField');
+
+            // Should still contain the fragment definitions (dependencies) but without custom fields
+            expect(printed).toContain('...OrderLine');
+            expect(printed).toContain('fragment OrderLine on OrderLine');
+            expect(printed).toContain('fragment Asset on Asset');
+        });
+    });
+
+    describe('Composability with addCustomFields()', () => {
+        it('Can be used inline in graphql() dependency array (like the original pattern)', () => {
+            const productFragment = graphql(`
+                fragment Product on Product {
+                    id
+                    name
+                }
+            `);
+
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [
+                { name: 'custom1', type: 'string', list: false },
+                { name: 'custom2', type: 'boolean', list: false },
+            ]);
+
+            // Use addCustomFieldsToFragment directly in the array - this is the pattern from orders.graphql.ts
+            const queryDocument = graphql(
+                `
+                    query GetProduct {
+                        product {
+                            ...Product
+                        }
+                    }
+                `,
+                [addCustomFieldsToFragment(productFragment, { customFieldsMap: customFieldsConfig })],
+            );
+
+            // The query should include the modified fragment with custom fields
+            const printed = print(queryDocument);
+            expect(printed).toContain('customFields {');
+            expect(printed).toContain('custom1');
+            expect(printed).toContain('custom2');
+        });
+
+        it('addCustomFieldsToFragment produces same result as addCustomFields for single fragments', () => {
+            const productFragment = graphql(`
+                fragment Product on Product {
+                    id
+                    name
+                }
+            `);
+
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [
+                { name: 'custom1', type: 'string', list: false },
+                { name: 'custom2', type: 'boolean', list: false },
+            ]);
+
+            const resultFromFragment = addCustomFieldsToFragment(productFragment, {
+                customFieldsMap: customFieldsConfig,
+            });
+            const resultFromFull = addCustomFields(productFragment, { customFieldsMap: customFieldsConfig });
+
+            // Both should produce the same output
+            expect(print(resultFromFragment)).toBe(print(resultFromFull));
+        });
+
+        it('Works with fragments that have dependencies when used inline', () => {
+            const orderLineFragment = graphql(`
+                fragment OrderLine on OrderLine {
+                    id
+                    quantity
+                }
+            `);
+
+            const orderDetailFragment = graphql(
+                `
+                    fragment OrderDetail on Order {
+                        id
+                        code
+                        lines {
+                            ...OrderLine
+                        }
+                    }
+                `,
+                [orderLineFragment],
+            );
+
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Order', [{ name: 'orderCustomField', type: 'string', list: false }]);
+            customFieldsConfig.set('OrderLine', [
+                { name: 'orderLineCustomField', type: 'string', list: false },
+            ]);
+
+            // This is exactly the pattern used in orders.graphql.ts
+            const queryDocument = graphql(
+                `
+                    query GetOrder($id: ID!) {
+                        order(id: $id) {
+                            ...OrderDetail
+                        }
+                    }
+                `,
+                [addCustomFieldsToFragment(orderDetailFragment, { customFieldsMap: customFieldsConfig })],
+            );
+
+            const printed = print(queryDocument);
+
+            // Should add custom fields to OrderDetail
+            expect(printed).toContain('fragment OrderDetail on Order');
+            expect(printed).toContain('orderCustomField');
+
+            // Should NOT add custom fields to OrderLine (dependency)
+            expect(printed).toContain('fragment OrderLine on OrderLine');
+            expect(printed).not.toContain('orderLineCustomField');
+
+            // Verify the query structure is correct
+            expect(printed).toContain('query GetOrder');
+            expect(printed).toContain('order(id: $id)');
+            expect(printed).toContain('...OrderDetail');
+        });
+
+        it('Can be used to compose fragments in query documents', () => {
+            const productFragment = graphql(`
+                fragment Product on Product {
+                    id
+                    name
+                }
+            `);
+
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [
+                { name: 'custom1', type: 'string', list: false },
+                { name: 'custom2', type: 'boolean', list: false },
+            ]);
+
+            // Use addCustomFieldsToFragment to modify the fragment
+            const modifiedFragment = addCustomFieldsToFragment(productFragment, {
+                customFieldsMap: customFieldsConfig,
+            });
+
+            // Then compose it into a query
+            const queryDocument = graphql(
+                `
+                    query GetProduct {
+                        product {
+                            ...Product
+                        }
+                    }
+                `,
+                [modifiedFragment],
+            );
+
+            // The query should include the modified fragment with custom fields
+            const printed = print(queryDocument);
+            expect(printed).toContain('customFields {');
+            expect(printed).toContain('custom1');
+            expect(printed).toContain('custom2');
+        });
+
+        it('Can selectively modify different fragments with different custom fields', () => {
+            const productFragment = graphql(`
+                fragment Product on Product {
+                    id
+                    name
+                }
+            `);
+
+            const variantFragment = graphql(`
+                fragment Variant on ProductVariant {
+                    id
+                    sku
+                }
+            `);
+
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [
+                { name: 'productCustom1', type: 'string', list: false },
+                { name: 'productCustom2', type: 'boolean', list: false },
+            ]);
+            customFieldsConfig.set('ProductVariant', [
+                { name: 'variantCustom1', type: 'string', list: false },
+            ]);
+
+            // Selectively modify each fragment with different custom fields
+            const modifiedProductFragment = addCustomFieldsToFragment(productFragment, {
+                customFieldsMap: customFieldsConfig,
+                includeCustomFields: ['productCustom1'], // Only include productCustom1
+            });
+
+            const modifiedVariantFragment = addCustomFieldsToFragment(variantFragment, {
+                customFieldsMap: customFieldsConfig,
+                includeCustomFields: ['variantCustom1'],
+            });
+
+            // Compose into a query
+            const queryDocument = graphql(
+                `
+                    query GetProductWithVariants {
+                        product {
+                            ...Product
+                            variants {
+                                ...Variant
+                            }
+                        }
+                    }
+                `,
+                [modifiedProductFragment, modifiedVariantFragment],
+            );
+
+            const printed = print(queryDocument);
+            // Product fragment should have only productCustom1
+            expect(printed).toContain('productCustom1');
+            expect(printed).not.toContain('productCustom2');
+            // Variant fragment should have variantCustom1
+            expect(printed).toContain('variantCustom1');
+        });
+    });
 });

+ 248 - 119
packages/dashboard/src/lib/framework/document-introspection/add-custom-fields.ts

@@ -27,6 +27,7 @@ let globalCustomFieldsMap: Map<string, CustomFieldConfig[]> = new Map();
 
 // Memoization cache using WeakMap to avoid memory leaks
 const memoizationCache = new WeakMap<DocumentNode, Map<string, TypedDocumentNode<any, any>>>();
+const fragmentMemoizationCache = new WeakMap<DocumentNode, Map<string, TypedDocumentNode<any, any>>>();
 
 /**
  * Creates a cache key for the options object
@@ -34,6 +35,7 @@ const memoizationCache = new WeakMap<DocumentNode, Map<string, TypedDocumentNode
 function createOptionsKey(options?: {
     customFieldsMap?: Map<string, CustomFieldConfig[]>;
     includeCustomFields?: string[];
+    includeNestedFragments?: string[];
 }): string {
     if (!options) return 'default';
 
@@ -51,6 +53,10 @@ function createOptionsKey(options?: {
         parts.push(`include:${options.includeCustomFields.sort().join(',')}`);
     }
 
+    if (options.includeNestedFragments) {
+        parts.push(`nested:${options.includeNestedFragments.sort().join(',')}`);
+    }
+
     return parts.join('|') || 'default';
 }
 
@@ -82,10 +88,232 @@ export function getCustomFieldsMap() {
     return globalCustomFieldsMap;
 }
 
+/**
+ * @description
+ * Internal helper function that applies custom fields to a selection set for a given entity type.
+ * This is the core logic extracted for reuse.
+ */
+function applyCustomFieldsToSelection(
+    typeName: string,
+    selectionSet: SelectionSetNode,
+    customFields: Map<string, CustomFieldConfig[]>,
+    options?: {
+        includeCustomFields?: string[];
+    },
+): void {
+    let entityType = typeName;
+
+    if (entityType === ('OrderAddress' as any)) {
+        // OrderAddress is a special case of the Address entity, and shares its custom fields
+        // so we treat it as an alias
+        entityType = 'Address';
+    }
+
+    if (entityType === ('Country' as any)) {
+        // Country is an alias of Region
+        entityType = 'Region';
+    }
+
+    const customFieldsForType = customFields.get(entityType);
+    if (customFieldsForType && customFieldsForType.length) {
+        // Check if there is already a customFields field in the fragment
+        // to avoid duplication
+        const existingCustomFieldsField = selectionSet.selections.find(
+            selection => isFieldNode(selection) && selection.name.value === 'customFields',
+        ) as FieldNode | undefined;
+        const selectionNodes: SelectionNode[] = customFieldsForType
+            .filter(
+                field => !options?.includeCustomFields || options?.includeCustomFields.includes(field.name),
+            )
+            .map(
+                customField =>
+                    ({
+                        kind: Kind.FIELD,
+                        name: {
+                            kind: Kind.NAME,
+                            value: customField.name,
+                        },
+                        // For "relation" custom fields, we need to also select
+                        // all the scalar fields of the related type
+                        ...(customField.type === 'relation'
+                            ? {
+                                  selectionSet: {
+                                      kind: Kind.SELECTION_SET,
+                                      selections: (
+                                          customField as RelationCustomFieldFragment
+                                      ).scalarFields.map(f => ({
+                                          kind: Kind.FIELD,
+                                          name: { kind: Kind.NAME, value: f },
+                                      })),
+                                  },
+                              }
+                            : {}),
+                        ...(customField.type === 'struct'
+                            ? {
+                                  selectionSet: {
+                                      kind: Kind.SELECTION_SET,
+                                      selections: (customField as StructCustomFieldFragment).fields.map(
+                                          f => ({
+                                              kind: Kind.FIELD,
+                                              name: { kind: Kind.NAME, value: f.name },
+                                          }),
+                                      ),
+                                  },
+                              }
+                            : {}),
+                    }) as FieldNode,
+            );
+        if (!existingCustomFieldsField) {
+            // If no customFields field exists, add one
+            (selectionSet.selections as SelectionNode[]).push({
+                kind: Kind.FIELD,
+                name: {
+                    kind: Kind.NAME,
+                    value: 'customFields',
+                },
+                selectionSet: {
+                    kind: Kind.SELECTION_SET,
+                    selections: selectionNodes,
+                },
+            });
+        } else {
+            // If a customFields field already exists, add the custom fields
+            // to the existing selection set
+            (existingCustomFieldsField.selectionSet as any) = {
+                kind: Kind.SELECTION_SET,
+                selections: selectionNodes,
+            };
+        }
+
+        const localizedFields = customFieldsForType.filter(
+            field => field.type === 'localeString' || field.type === 'localeText',
+        );
+
+        const translationsField = selectionSet.selections
+            .filter(isFieldNode)
+            .find(field => field.name.value === 'translations');
+
+        if (localizedFields.length && translationsField && translationsField.selectionSet) {
+            (translationsField.selectionSet.selections as SelectionNode[]).push({
+                name: {
+                    kind: Kind.NAME,
+                    value: 'customFields',
+                },
+                kind: Kind.FIELD,
+                selectionSet: {
+                    kind: Kind.SELECTION_SET,
+                    selections: localizedFields.map(
+                        customField =>
+                            ({
+                                kind: Kind.FIELD,
+                                name: {
+                                    kind: Kind.NAME,
+                                    value: customField.name,
+                                },
+                            }) as FieldNode,
+                    ),
+                },
+            });
+        }
+    }
+}
+
+/**
+ * @description
+ * Adds custom fields to a single fragment document. This is a more granular version of `addCustomFields()`
+ * that operates on individual fragments, allowing for better composability.
+ *
+ * **Important behavior with fragment dependencies:**
+ * - When a document contains multiple fragments (e.g., a main fragment with dependencies passed to `graphql()`),
+ *   only the **first fragment** is modified with custom fields.
+ * - Any additional fragments (dependencies) are left untouched in the output document.
+ * - This allows you to selectively control which fragments get custom fields.
+ *
+ * This function is memoized to return a stable identity for given inputs.
+ *
+ * @example
+ * ```typescript
+ * // Basic usage
+ * const modifiedFragment = addCustomFieldsToFragment(orderDetailFragment, {
+ *     includeCustomFields: ['reviewCount', 'priority']
+ * });
+ *
+ * // With fragment dependencies (only OrderDetail gets custom fields, OrderLine doesn't)
+ * const orderDetailFragment = graphql(
+ *     `fragment OrderDetail on Order {
+ *         id
+ *         lines { ...OrderLine }
+ *     }`,
+ *     [orderLineFragment]  // This dependency won't get custom fields
+ * );
+ * const modified = addCustomFieldsToFragment(orderDetailFragment);
+ * ```
+ */
+export function addCustomFieldsToFragment<T, V extends Variables = Variables>(
+    fragmentDocument: DocumentNode | TypedDocumentNode<T, V>,
+    options?: {
+        customFieldsMap?: Map<string, CustomFieldConfig[]>;
+        includeCustomFields?: string[];
+    },
+): TypedDocumentNode<T, V> {
+    const optionsKey = createOptionsKey(options);
+
+    // Check if we have a cached result for this fragment and options
+    let documentCache = fragmentMemoizationCache.get(fragmentDocument);
+    if (!documentCache) {
+        documentCache = new Map();
+        fragmentMemoizationCache.set(fragmentDocument, documentCache);
+    }
+
+    const cachedResult = documentCache.get(optionsKey);
+    if (cachedResult) {
+        return cachedResult as TypedDocumentNode<T, V>;
+    }
+
+    // Validate that this is a fragment-only document
+    const fragmentDefs = fragmentDocument.definitions.filter(isFragmentDefinition);
+    const queryDefs = fragmentDocument.definitions.filter(isOperationDefinition);
+
+    if (queryDefs.length > 0) {
+        throw new Error(
+            'addCustomFieldsToFragment() expects a fragment-only document. Use addCustomFields() for documents with queries.',
+        );
+    }
+
+    if (fragmentDefs.length === 0) {
+        throw new Error(
+            'addCustomFieldsToFragment() expects a document with at least one fragment definition.',
+        );
+    }
+
+    // Clone the document
+    const clone = JSON.parse(JSON.stringify(fragmentDocument)) as DocumentNode;
+    const customFields = options?.customFieldsMap || globalCustomFieldsMap;
+
+    // Only modify the first fragment (the main one)
+    // Any additional fragments are dependencies and should be left untouched
+    const fragmentDef = clone.definitions.find(isFragmentDefinition) as FragmentDefinitionNode;
+
+    // Apply custom fields only to the first/main fragment
+    applyCustomFieldsToSelection(
+        fragmentDef.typeCondition.name.value,
+        fragmentDef.selectionSet,
+        customFields,
+        options,
+    );
+
+    // Cache the result before returning
+    documentCache.set(optionsKey, clone);
+    return clone;
+}
+
 /**
  * Given a GraphQL AST (DocumentNode), this function looks for fragment definitions and adds and configured
  * custom fields to those fragments.
  *
+ * By default, only adds custom fields to top-level fragments (those used directly in the query result).
+ * Use `includeNestedFragments` to also add custom fields to specific nested fragments.
+ *
  * This function is memoized to return a stable identity for given inputs.
  */
 export function addCustomFields<T, V extends Variables = Variables>(
@@ -93,6 +321,18 @@ export function addCustomFields<T, V extends Variables = Variables>(
     options?: {
         customFieldsMap?: Map<string, CustomFieldConfig[]>;
         includeCustomFields?: string[];
+        /**
+         * Names of nested fragments that should also get custom fields.
+         * By default, only top-level fragments get custom fields.
+         *
+         * @example
+         * ```typescript
+         * addCustomFields(orderDetailDocument, {
+         *     includeNestedFragments: ['OrderLine', 'Asset']
+         * })
+         * ```
+         */
+        includeNestedFragments?: string[];
     },
 ): TypedDocumentNode<T, V> {
     const optionsKey = createOptionsKey(options);
@@ -168,9 +408,13 @@ export function addCustomFields<T, V extends Variables = Variables>(
 
     for (const fragmentDef of fragmentDefs) {
         if (hasQueries) {
-            // If we have queries, only add custom fields to fragments used at the top level
-            // Skip fragments that are only used in nested contexts
-            if (topLevelFragments.has(fragmentDef.name.value)) {
+            // If we have queries, add custom fields to:
+            // 1. Fragments used at the top level (in the main query result)
+            // 2. Fragments explicitly listed in includeNestedFragments option
+            const isTopLevel = topLevelFragments.has(fragmentDef.name.value);
+            const isExplicitlyIncluded = options?.includeNestedFragments?.includes(fragmentDef.name.value);
+
+            if (isTopLevel || isExplicitlyIncluded) {
                 targetNodes.push({
                     typeName: fragmentDef.typeCondition.name.value,
                     selectionSet: fragmentDef.selectionSet,
@@ -187,122 +431,7 @@ export function addCustomFields<T, V extends Variables = Variables>(
     }
 
     for (const target of targetNodes) {
-        let entityType = target.typeName;
-
-        if (entityType === ('OrderAddress' as any)) {
-            // OrderAddress is a special case of the Address entity, and shares its custom fields
-            // so we treat it as an alias
-            entityType = 'Address';
-        }
-
-        if (entityType === ('Country' as any)) {
-            // Country is an alias of Region
-            entityType = 'Region';
-        }
-
-        const customFieldsForType = customFields.get(entityType);
-        if (customFieldsForType && customFieldsForType.length) {
-            // Check if there is already a customFields field in the fragment
-            // to avoid duplication
-            const existingCustomFieldsField = target.selectionSet.selections.find(
-                selection => isFieldNode(selection) && selection.name.value === 'customFields',
-            ) as FieldNode | undefined;
-            const selectionNodes: SelectionNode[] = customFieldsForType
-                .filter(
-                    field =>
-                        !options?.includeCustomFields || options?.includeCustomFields.includes(field.name),
-                )
-                .map(
-                    customField =>
-                        ({
-                            kind: Kind.FIELD,
-                            name: {
-                                kind: Kind.NAME,
-                                value: customField.name,
-                            },
-                            // For "relation" custom fields, we need to also select
-                            // all the scalar fields of the related type
-                            ...(customField.type === 'relation'
-                                ? {
-                                      selectionSet: {
-                                          kind: Kind.SELECTION_SET,
-                                          selections: (
-                                              customField as RelationCustomFieldFragment
-                                          ).scalarFields.map(f => ({
-                                              kind: Kind.FIELD,
-                                              name: { kind: Kind.NAME, value: f },
-                                          })),
-                                      },
-                                  }
-                                : {}),
-                            ...(customField.type === 'struct'
-                                ? {
-                                      selectionSet: {
-                                          kind: Kind.SELECTION_SET,
-                                          selections: (customField as StructCustomFieldFragment).fields.map(
-                                              f => ({
-                                                  kind: Kind.FIELD,
-                                                  name: { kind: Kind.NAME, value: f.name },
-                                              }),
-                                          ),
-                                      },
-                                  }
-                                : {}),
-                        }) as FieldNode,
-                );
-            if (!existingCustomFieldsField) {
-                // If no customFields field exists, add one
-                (target.selectionSet.selections as SelectionNode[]).push({
-                    kind: Kind.FIELD,
-                    name: {
-                        kind: Kind.NAME,
-                        value: 'customFields',
-                    },
-                    selectionSet: {
-                        kind: Kind.SELECTION_SET,
-                        selections: selectionNodes,
-                    },
-                });
-            } else {
-                // If a customFields field already exists, add the custom fields
-                // to the existing selection set
-                (existingCustomFieldsField.selectionSet as any) = {
-                    kind: Kind.SELECTION_SET,
-                    selections: selectionNodes,
-                };
-            }
-
-            const localizedFields = customFieldsForType.filter(
-                field => field.type === 'localeString' || field.type === 'localeText',
-            );
-
-            const translationsField = target.selectionSet.selections
-                .filter(isFieldNode)
-                .find(field => field.name.value === 'translations');
-
-            if (localizedFields.length && translationsField && translationsField.selectionSet) {
-                (translationsField.selectionSet.selections as SelectionNode[]).push({
-                    name: {
-                        kind: Kind.NAME,
-                        value: 'customFields',
-                    },
-                    kind: Kind.FIELD,
-                    selectionSet: {
-                        kind: Kind.SELECTION_SET,
-                        selections: localizedFields.map(
-                            customField =>
-                                ({
-                                    kind: Kind.FIELD,
-                                    name: {
-                                        kind: Kind.NAME,
-                                        value: customField.name,
-                                    },
-                                }) as FieldNode,
-                        ),
-                    },
-                });
-            }
-        }
+        applyCustomFieldsToSelection(target.typeName, target.selectionSet, customFields, options);
     }
 
     // Cache the result before returning