Просмотр исходного кода

feat(core): Merge custom fields on updating order lines (#3673)

Michael Bromley 6 месяцев назад
Родитель
Сommit
ead7bfae93

+ 89 - 52
docs/docs/guides/developer-guide/custom-fields/index.md

@@ -18,7 +18,7 @@ Some use-cases for custom fields include:
 * Adding a longitude and latitude to the `StockLocation` for use in selecting the closest location to a customer.
 
 :::note
-Custom fields are not solely restricted to Vendure's native entities though, it's also possible to add support for custom fields to your own custom entities. See: [Supporting custom fields](/guides/developer-guide/database-entity/#supporting-custom-fields) 
+Custom fields are not solely restricted to Vendure's native entities though, it's also possible to add support for custom fields to your own custom entities. See: [Supporting custom fields](/guides/developer-guide/database-entity/#supporting-custom-fields)
 :::
 
 ## Defining custom fields
@@ -61,15 +61,15 @@ mutation {
         id: 1
         // highlight-start
         customFields: {
-            infoUrl: "https://some-url.com",
-            downloadable: true,
+        infoUrl: "https://some-url.com",
+        downloadable: true,
         }
         // highlight-end
         translations: [
-            // highlight-next-line
-            { languageCode: en, customFields: { shortName: "foo" } }
+        // highlight-next-line
+        { languageCode: en, customFields: { shortName: "foo" } }
         ]
-    }) {
+        }) {
         id
         name
         // highlight-start
@@ -88,17 +88,17 @@ mutation {
 
 ```json
 {
-  "data": {
-    "product": {
-      "id": "1",
-      "name": "Laptop",
-      "customFields": {
-          "infoUrl": "https://some-url.com",
-          "downloadable": true,
-          "shortName": "foo"
-      }
+    "data": {
+        "product": {
+            "id": "1",
+            "name": "Laptop",
+            "customFields": {
+                "infoUrl": "https://some-url.com",
+                "downloadable": true,
+                "shortName": "foo"
+            }
+        }
     }
-  }
 }
 ```
 
@@ -111,15 +111,15 @@ The custom fields will also extend the filter and sort options available to the
 query {
     products(options: {
         // highlight-start
-        filter: {
-            infoUrl: { contains: "new" },
-            downloadable: { eq: true }
+    filter: {
+        infoUrl: { contains: "new" },
+        downloadable: { eq: true }
         },
         sort: {
             infoUrl: ASC
         }
         // highlight-end
-    }) {
+        }) {
         items {
             id
             name
@@ -135,7 +135,6 @@ query {
 }
 ```
 
-
 ## Available custom field types
 
 The following types are available for custom fields:
@@ -353,9 +352,9 @@ const config = {
                 type: 'string',
                 // highlight-start
                 label: [
-                    {languageCode: LanguageCode.en, value: 'Info URL'},
-                    {languageCode: LanguageCode.de, value: 'Info-URL'},
-                    {languageCode: LanguageCode.es, value: 'URL de información'},
+                    { languageCode: LanguageCode.en, value: 'Info URL' },
+                    { languageCode: LanguageCode.de, value: 'Info-URL' },
+                    { languageCode: LanguageCode.es, value: 'URL de información' },
                 ],
                 // highlight-end
             },
@@ -382,9 +381,9 @@ const config = {
                 type: 'string',
                 // highlight-start
                 description: [
-                    {languageCode: LanguageCode.en, value: 'A URL to more information about the product'},
-                    {languageCode: LanguageCode.de, value: 'Eine URL zu weiteren Informationen über das Produkt'},
-                    {languageCode: LanguageCode.es, value: 'Una URL con más información sobre el producto'},
+                    { languageCode: LanguageCode.en, value: 'A URL to more information about the product' },
+                    { languageCode: LanguageCode.de, value: 'Eine URL zu weiteren Informationen über das Produkt' },
+                    { languageCode: LanguageCode.es, value: 'Una URL con más información sobre el producto' },
                 ],
                 // highlight-end
             },
@@ -558,9 +557,9 @@ const config = {
 
                         // If a localized error message is required, return an array of LocalizedString objects.
                         return [
-                            {languageCode: LanguageCode.en, value: 'The URL must start with "http"'},
-                            {languageCode: LanguageCode.de, value: 'Die URL muss mit "http" beginnen'},
-                            {languageCode: LanguageCode.es, value: 'La URL debe comenzar con "http"'},
+                            { languageCode: LanguageCode.en, value: 'The URL must start with "http"' },
+                            { languageCode: LanguageCode.de, value: 'Die URL muss mit "http" beginnen' },
+                            { languageCode: LanguageCode.es, value: 'La URL debe comenzar con "http"' },
                         ];
                     }
                 },
@@ -606,7 +605,7 @@ to view or update the field.
 
 In the Admin UI, the custom field will not be displayed if the current administrator lacks the required permission.
 
-In the GraphQL API, if the current user does not have the required permission, then the field will always return `null`. 
+In the GraphQL API, if the current user does not have the required permission, then the field will always return `null`.
 Attempting to set the value of a field for which the user does not have the required permission will cause the mutation to fail
 with an error.
 
@@ -632,7 +631,7 @@ const config = {
                 // and the user must have at least one of the permissions
                 // to access the field.
                 requiresPermission: [
-                    Permission.SuperAdmin, 
+                    Permission.SuperAdmin,
                     Permission.ReadShippingMethod,
                 ],
                 // highlight-end
@@ -700,8 +699,8 @@ const config = {
                 type: 'string',
                 // highlight-start
                 options: [
-                    {value: 'new', label: [{languageCode: LanguageCode.en, value: 'New'}]},
-                    {value: 'used', label: [{languageCode: LanguageCode.en, value: 'Used'}]},
+                    { value: 'new', label: [{ languageCode: LanguageCode.en, value: 'New' }] },
+                    { value: 'used', label: [{ languageCode: LanguageCode.en, value: 'Used' }] },
                 ],
                 // highlight-end
             },
@@ -940,7 +939,7 @@ query {
 }
 ```
 
-Struct fields support many of the same properties as other custom fields, such as `list`, `label`, `description`, `validate`, `ui` and 
+Struct fields support many of the same properties as other custom fields, such as `list`, `label`, `description`, `validate`, `ui` and
 type-specific properties such as `options` and `pattern` for string types.
 
 :::note
@@ -963,8 +962,8 @@ const config = {
                         type: 'string',
                         // highlight-start
                         options: [
-                            {value: 'red', label: [{languageCode: LanguageCode.en, value: 'Red'}]},
-                            {value: 'blue', label: [{languageCode: LanguageCode.en, value: 'Blue'}]},
+                            { value: 'red', label: [{ languageCode: LanguageCode.en, value: 'Red' }] },
+                            { value: 'blue', label: [{ languageCode: LanguageCode.en, value: 'Blue' }] },
                         ],
                         // highlight-end
                     },
@@ -1192,20 +1191,20 @@ const config: VendureConfig = {
     customFields: {
         Product: [
             // Rich text editor
-            {name: 'additionalInfo', type: 'text', ui: {component: 'rich-text-form-input'}},
+            { name: 'additionalInfo', type: 'text', ui: { component: 'rich-text-form-input' } },
             // JSON editor
-            {name: 'specs', type: 'text', ui: {component: 'json-editor-form-input'}},
+            { name: 'specs', type: 'text', ui: { component: 'json-editor-form-input' } },
             // Numeric with suffix
             {
                 name: 'weight',
                 type: 'int',
-                ui: {component: 'number-form-input', suffix: 'g'},
+                ui: { component: 'number-form-input', suffix: 'g' },
             },
             // Currency input
             {
                 name: 'RRP',
                 type: 'int',
-                ui: {component: 'currency-form-input'},
+                ui: { component: 'currency-form-input' },
             },
             // Select with options
             {
@@ -1214,8 +1213,8 @@ const config: VendureConfig = {
                 ui: {
                     component: 'select-form-input',
                     options: [
-                        {value: 'static', label: [{languageCode: LanguageCode.en, value: 'Static'}]},
-                        {value: 'dynamic', label: [{languageCode: LanguageCode.en, value: 'Dynamic'}]},
+                        { value: 'static', label: [{ languageCode: LanguageCode.en, value: 'Static' }] },
+                        { value: 'dynamic', label: [{ languageCode: LanguageCode.en, value: 'Dynamic' }] },
                     ],
                 },
             },
@@ -1247,7 +1246,6 @@ The various configuration options for each of the built-in form input  (e.g. `su
 
 If none of the built-in form input components are suitable, you can create your own. This is a more advanced topic which is covered in detail in the [Custom Form Input Components](/guides/extending-the-admin-ui/custom-form-inputs/) guide.
 
-
 ## Tabbed custom fields
 
 With a large, complex project, it's common for lots of custom fields to be required. This can get visually noisy in the UI, so Vendure supports tabbed custom fields. Just specify the tab name in the `ui` object, and those fields with the same tab name will be grouped in the UI! The tab name can also be a translation token if you need to support multiple languages.
@@ -1263,19 +1261,17 @@ const config = {
     // ...
     customFields: {
         Product: [
-            { name: 'additionalInfo', type: 'text', ui: {component: 'rich-text-form-input'} },
-            { name: 'specs', type: 'text', ui: {component: 'json-editor-form-input'} },
-            { name: 'width', type: 'int', ui: {tab: 'Shipping'} },
-            { name: 'height', type: 'int', ui: {tab: 'Shipping'} },
-            { name: 'depth', type: 'int', ui: {tab: 'Shipping'} },
-            { name: 'weight', type: 'int', ui: {tab: 'Shipping'} },
+            { name: 'additionalInfo', type: 'text', ui: { component: 'rich-text-form-input' } },
+            { name: 'specs', type: 'text', ui: { component: 'json-editor-form-input' } },
+            { name: 'width', type: 'int', ui: { tab: 'Shipping' } },
+            { name: 'height', type: 'int', ui: { tab: 'Shipping' } },
+            { name: 'depth', type: 'int', ui: { tab: 'Shipping' } },
+            { name: 'weight', type: 'int', ui: { tab: 'Shipping' } },
         ],
     },
 }
 ```
 
-
-
 ## TypeScript Typings
 
 Because custom fields are generated at run-time, TypeScript has no way of knowing about them based on your
@@ -1343,3 +1339,44 @@ One way to ensure that your custom field typings always get imported first is to
 For a working example of this setup, see the [real-world-vendure repo](https://github.com/vendure-ecommerce/real-world-vendure/blob/master/src/plugins/reviews/types.ts)
 :::
 
+## Special cases
+
+Beyond adding custom fields to the corresponding GraphQL types, and updating paginated list sort & filter options, there are a few special cases where adding custom fields to certain entities will result in further API changes.
+
+### OrderLine custom fields
+
+When you define custom fields on the `OrderLine` entity, the following API changes are also automatically provided by Vendure:
+
+- Shop API: [addItemToOrder](/reference/graphql-api/shop/mutations#additemtoorder) will have a 3rd input argument, `customFields`, which allows custom field values to be set when adding an item to the order.
+- Shop API: [adjustOrderLine](/reference/graphql-api/shop/mutations#additemtoorder) will have a 3rd input argument, `customFields`, which allows custom field values to be updated.
+- Admin API: the equivalent mutations for manipulating draft orders and for modifying and order will also have inputs to allow custom field values to be set.
+
+:::info
+To see an example of this in practice, see the [Configurable Product guide](/guides/how-to/configurable-products/)
+:::
+
+### Order custom fields
+
+When you define custom fields on the `Order` entity, the following API changes are also automatically provided by Vendure:
+
+- Admin API: [modifyOrder](/reference/graphql-api/admin/mutations#modifyorder) will have a `customFields` field on the input object.
+
+### ShippingMethod custom fields
+
+When you define custom fields on the `ShippingMethod` entity, the following API changes are also automatically provided by Vendure:
+
+- Shop API: [eligibleShippingMethods](/reference/graphql-api/shop/queries#eligibleshippingmethods) will have public custom fields available on the result.
+
+### PaymentMethod custom fields
+
+When you define custom fields on the `PaymentMethod` entity, the following API changes are also automatically provided by Vendure:
+
+- Shop API: [eligiblePaymentMethods](/reference/graphql-api/shop/queries#eligiblepaymentmethods) will have public custom fields available on the result.
+
+### Customer custom fields
+
+When you define custom fields on the `Customer` entity, the following API changes are also automatically provided by Vendure:
+
+- Shop API: [registerCustomerAccount](/reference/graphql-api/shop/mutations#registercustomeraccount) will have have a `customFields` field on the input
+  object.
+

+ 4 - 5
packages/core/e2e/custom-fields.e2e-spec.ts

@@ -4,11 +4,10 @@ import { createTestEnvironment } from '@vendure/testing';
 import { fail } from 'assert';
 import gql from 'graphql-tag';
 import path from 'path';
-import { vi } from 'vitest';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 import { fixPostgresTimezone } from './utils/fix-pg-timezone';
@@ -192,8 +191,8 @@ const customConfig = mergeConfig(testConfig(), {
             {
                 name: 'costPrice',
                 type: 'int',
-            }
-        ],  
+            },
+        ],
         // Single readonly Address custom field to test
         // https://github.com/vendure-ecommerce/vendure/issues/3326
         Address: [

+ 373 - 0
packages/core/e2e/order-line-custom-fields.e2e-spec.ts

@@ -0,0 +1,373 @@
+import { mergeConfig, Product } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import { fixPostgresTimezone } from './utils/fix-pg-timezone';
+
+// Since the predefined mutations don't support custom fields, we'll create our own
+// but still follow the typing pattern from the existing tests
+const ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS = gql`
+    mutation AddItemToOrderWithCustomFields(
+        $productVariantId: ID!
+        $quantity: Int!
+        $customFields: OrderLineCustomFieldsInput
+    ) {
+        addItemToOrder(
+            productVariantId: $productVariantId
+            quantity: $quantity
+            customFields: $customFields
+        ) {
+            ... on Order {
+                id
+                lines {
+                    id
+                    quantity
+                    customFields {
+                        stringField
+                        intField
+                        booleanField
+                        nullableField
+                        relationField {
+                            id
+                            name
+                        }
+                    }
+                }
+            }
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+        }
+    }
+`;
+
+const ADJUST_ORDER_LINE_WITH_CUSTOM_FIELDS = gql`
+    mutation AdjustOrderLineWithCustomFields(
+        $orderLineId: ID!
+        $quantity: Int!
+        $customFields: OrderLineCustomFieldsInput
+    ) {
+        adjustOrderLine(orderLineId: $orderLineId, quantity: $quantity, customFields: $customFields) {
+            ... on Order {
+                id
+                lines {
+                    id
+                    quantity
+                    customFields {
+                        stringField
+                        intField
+                        booleanField
+                        nullableField
+                        relationField {
+                            id
+                            name
+                        }
+                    }
+                }
+            }
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+        }
+    }
+`;
+
+const REMOVE_ALL_ORDER_LINES = gql`
+    mutation RemoveAllOrderLines {
+        removeAllOrderLines {
+            ... on Order {
+                id
+                lines {
+                    id
+                    quantity
+                }
+            }
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+        }
+    }
+`;
+
+fixPostgresTimezone();
+
+const customConfig = mergeConfig(testConfig(), {
+    customFields: {
+        OrderLine: [
+            { name: 'stringField', type: 'string' },
+            { name: 'intField', type: 'int' },
+            { name: 'booleanField', type: 'boolean' },
+            { name: 'nullableField', type: 'string', nullable: true },
+            { name: 'relationField', type: 'relation', entity: Product },
+        ],
+    },
+});
+
+describe('OrderLine Custom Fields', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment(customConfig);
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    beforeEach(async () => {
+        // Clear the shopping cart before each test to ensure test isolation
+        await shopClient.query(REMOVE_ALL_ORDER_LINES);
+    });
+
+    describe('addItemToOrder', () => {
+        it('can add order line with custom fields', async () => {
+            const { addItemToOrder } = await shopClient.query(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS, {
+                productVariantId: 'T_1',
+                quantity: 1,
+                customFields: { stringField: 'test value', intField: 42, booleanField: true },
+            });
+
+            expect(addItemToOrder.lines[0].customFields).toEqual({
+                stringField: 'test value',
+                intField: 42,
+                booleanField: true,
+                nullableField: null,
+                relationField: null,
+            });
+        });
+
+        it('can add order line with relation custom field', async () => {
+            const { addItemToOrder } = await shopClient.query(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS, {
+                productVariantId: 'T_2',
+                quantity: 1,
+                customFields: { relationFieldId: 'T_1' },
+            });
+
+            expect(addItemToOrder.lines[0].customFields.relationField.id).toBe('T_1');
+        });
+    });
+
+    describe('adjustOrderLine - merging behavior', () => {
+        it('should merge custom fields when updating partial fields', async () => {
+            // Create a fresh order line for this test
+            const { addItemToOrder } = await shopClient.query(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS, {
+                productVariantId: 'T_3',
+                quantity: 1,
+                customFields: {
+                    stringField: 'initial value',
+                    intField: 100,
+                    booleanField: false,
+                    nullableField: 'not null',
+                },
+            });
+            const orderLineId = addItemToOrder.lines[0].id;
+
+            const { adjustOrderLine } = await shopClient.query(ADJUST_ORDER_LINE_WITH_CUSTOM_FIELDS, {
+                orderLineId,
+                quantity: 2,
+                customFields: {
+                    stringField: 'updated value',
+                },
+            });
+
+            const updatedLine = adjustOrderLine.lines.find(line => line.id === orderLineId);
+            expect(updatedLine.customFields).toEqual({
+                stringField: 'updated value', // updated
+                intField: 100, // preserved
+                booleanField: false, // preserved
+                nullableField: 'not null', // preserved
+                relationField: null, // preserved
+            });
+        });
+
+        it('should allow updating multiple fields while preserving others', async () => {
+            // Create a fresh order line for this test
+            const { addItemToOrder } = await shopClient.query(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS, {
+                productVariantId: 'T_4',
+                quantity: 1,
+                customFields: {
+                    stringField: 'initial value',
+                    intField: 100,
+                    booleanField: false,
+                    nullableField: 'not null',
+                },
+            });
+            const orderLineId = addItemToOrder.lines[0].id;
+
+            const { adjustOrderLine } = await shopClient.query(ADJUST_ORDER_LINE_WITH_CUSTOM_FIELDS, {
+                orderLineId,
+                quantity: 2,
+                customFields: {
+                    intField: 200,
+                    booleanField: true,
+                },
+            });
+
+            const updatedLine = adjustOrderLine.lines.find(line => line.id === orderLineId);
+            expect(updatedLine.customFields).toEqual({
+                stringField: 'initial value', // preserved
+                intField: 200, // updated
+                booleanField: true, // updated
+                nullableField: 'not null', // preserved
+                relationField: null, // preserved
+            });
+        });
+
+        it('should allow unsetting fields using null', async () => {
+            // Create a fresh order line for this test
+            const { addItemToOrder } = await shopClient.query(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS, {
+                productVariantId: 'T_1',
+                quantity: 1,
+                customFields: {
+                    stringField: 'initial value',
+                    intField: 100,
+                    booleanField: false,
+                    nullableField: 'not null',
+                },
+            });
+            const orderLineId = addItemToOrder.lines[0].id;
+
+            const { adjustOrderLine } = await shopClient.query(ADJUST_ORDER_LINE_WITH_CUSTOM_FIELDS, {
+                orderLineId,
+                quantity: 2,
+                customFields: {
+                    nullableField: null,
+                },
+            });
+
+            const updatedLine = adjustOrderLine.lines.find(line => line.id === orderLineId);
+            expect(updatedLine.customFields).toEqual({
+                stringField: 'initial value', // preserved
+                intField: 100, // preserved
+                booleanField: false, // preserved
+                nullableField: null, // unset using null
+                relationField: null, // preserved
+            });
+        });
+
+        it('should handle relation field updates with merging', async () => {
+            // Create a fresh order line for this test
+            const { addItemToOrder } = await shopClient.query(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS, {
+                productVariantId: 'T_2',
+                quantity: 1,
+                customFields: {
+                    stringField: 'initial value',
+                    intField: 100,
+                    booleanField: false,
+                    nullableField: 'not null',
+                },
+            });
+            const orderLineId = addItemToOrder.lines[0].id;
+
+            const { adjustOrderLine } = await shopClient.query(ADJUST_ORDER_LINE_WITH_CUSTOM_FIELDS, {
+                orderLineId,
+                quantity: 2,
+                customFields: {
+                    relationFieldId: 'T_1',
+                },
+            });
+
+            const updatedLine = adjustOrderLine.lines.find(line => line.id === orderLineId);
+            expect(updatedLine.customFields).toEqual({
+                stringField: 'initial value', // preserved
+                intField: 100, // preserved
+                booleanField: false, // preserved
+                nullableField: 'not null', // preserved
+                relationField: {
+                    id: 'T_1',
+                    name: 'Laptop',
+                },
+            });
+        });
+
+        it('should allow unsetting relation field using null', async () => {
+            // Create a fresh order line for this test
+            const { addItemToOrder } = await shopClient.query(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS, {
+                productVariantId: 'T_3',
+                quantity: 1,
+                customFields: {
+                    stringField: 'initial value',
+                    intField: 100,
+                    booleanField: false,
+                    nullableField: 'not null',
+                    relationFieldId: 'T_1',
+                },
+            });
+            const orderLineId = addItemToOrder.lines[0].id;
+
+            const { adjustOrderLine } = await shopClient.query(ADJUST_ORDER_LINE_WITH_CUSTOM_FIELDS, {
+                orderLineId,
+                quantity: 2,
+                customFields: {
+                    relationFieldId: null,
+                },
+            });
+
+            const updatedLine = adjustOrderLine.lines.find(line => line.id === orderLineId);
+            expect(updatedLine.customFields).toEqual({
+                stringField: 'initial value', // preserved
+                intField: 100, // preserved
+                booleanField: false, // preserved
+                nullableField: 'not null', // preserved
+                relationField: null, // unset using null
+            });
+        });
+    });
+
+    describe('edge cases', () => {
+        it('should handle empty custom fields object', async () => {
+            const { addItemToOrder } = await shopClient.query(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS, {
+                productVariantId: 'T_4',
+                quantity: 1,
+                customFields: {},
+            });
+
+            const newLine = addItemToOrder.lines[0];
+            expect(newLine.customFields).toEqual({
+                stringField: null,
+                intField: null,
+                booleanField: null,
+                nullableField: null,
+                relationField: null,
+            });
+        });
+
+        it('should handle adjustOrderLine with empty custom fields', async () => {
+            const { addItemToOrder } = await shopClient.query(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS, {
+                productVariantId: 'T_1',
+                quantity: 1,
+                customFields: { stringField: 'will be preserved', intField: 999 },
+            });
+
+            const lineId = addItemToOrder.lines[0].id;
+
+            const { adjustOrderLine } = await shopClient.query(ADJUST_ORDER_LINE_WITH_CUSTOM_FIELDS, {
+                orderLineId: lineId,
+                quantity: 2,
+                customFields: {},
+            });
+
+            const updatedLine = adjustOrderLine.lines.find(line => line.id === lineId);
+            expect(updatedLine.customFields).toEqual({
+                stringField: 'will be preserved', // preserved when empty object passed
+                intField: 999, // preserved when empty object passed
+                booleanField: null, // default value for unset fields
+                nullableField: null, // default value for unset fields
+                relationField: null, // default value for unset fields
+            });
+        });
+    });
+});

+ 15 - 2
packages/core/src/service/services/order.service.ts

@@ -781,11 +781,24 @@ export class OrderService {
                 }
             }
             if (customFields != null) {
-                orderLine.customFields = customFields;
+                // Merge custom fields instead of replacing them entirely
+                // This preserves existing values while allowing updates and null-based unsetting
+                const existingCustomFields = orderLine.customFields || {};
+                const mergedCustomFields = { ...existingCustomFields } as any;
+
+                for (const [key, value] of Object.entries(customFields)) {
+                    if (value !== undefined) {
+                        // Update with the new value (including explicit null to unset)
+                        mergedCustomFields[key] = value;
+                    }
+                    // If value is undefined, preserve the existing value (don't set it)
+                }
+
+                orderLine.customFields = mergedCustomFields;
                 await this.customFieldRelationService.updateRelations(
                     ctx,
                     OrderLine,
-                    { customFields },
+                    { customFields: mergedCustomFields },
                     orderLine,
                 );
             }

+ 1 - 0
scripts/codegen/generate-graphql-types.ts

@@ -34,6 +34,7 @@ const specFileToIgnore = [
     'error-handler-strategy.e2e-spec',
     'order-multi-vendor.e2e-spec',
     'auth.e2e-spec',
+    'order-line-custom-fields.e2e-spec',
 ];
 const E2E_ADMIN_QUERY_FILES = path.join(
     __dirname,