diff --git a/.changeset/calm-ui-metafield-owners.md b/.changeset/calm-ui-metafield-owners.md new file mode 100644 index 00000000000..ac6fd116b19 --- /dev/null +++ b/.changeset/calm-ui-metafield-owners.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Support owner types in UI extension metafield configuration. diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index f402aff0bb2..09503ba1461 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -1345,7 +1345,7 @@ describe('load', () => { await writeConfig(appConfiguration) const blockConfiguration = ` - api_version = "unstable" + api_version = "2026-10" [[extensions]] name = "my-admin-action" handle = "admin-action-handle" @@ -1356,6 +1356,11 @@ describe('load', () => { [[extensions.metafields]] namespace = "my-namespace" key = "my-key" + owner_type = "PRODUCT" + + [[extensions.metafields]] + namespace = "my-namespace" + key = "my-key-without-owner-type" # extra fields not included in the schema should be ignored [[extensions.invalid_field]] @@ -1384,7 +1389,7 @@ describe('load', () => { expect(extension).not.toBeUndefined() if (extension) { expect(extension.configuration).toMatchObject({ - api_version: 'unstable', + api_version: '2026-10', name: 'my-admin-action', handle: 'admin-action-handle', type: 'ui_extension', @@ -1392,6 +1397,11 @@ describe('load', () => { { namespace: 'my-namespace', key: 'my-key', + owner_type: 'PRODUCT', + }, + { + namespace: 'my-namespace', + key: 'my-key-without-owner-type', }, ], extension_points: [ @@ -1400,6 +1410,11 @@ describe('load', () => { { namespace: 'my-namespace', key: 'my-key', + owner_type: 'PRODUCT', + }, + { + namespace: 'my-namespace', + key: 'my-key-without-owner-type', }, ], module: './src/ActionExtension.js', @@ -1421,7 +1436,7 @@ describe('load', () => { await writeConfig(appConfiguration) const blockConfiguration = ` - api_version = "2023-07" + api_version = "2026-10" [[extensions]] name = "My checkout extension" @@ -1465,6 +1480,11 @@ describe('load', () => { target = "purchase.checkout.block.render" module = "./CheckoutDynamicRender.jsx" + [[extensions.targeting.metafields]] + namespace = "target-namespace" + key = "target-key" + owner_type = "CART" + # extra fields not included in the schema should be ignored [[extensions.invalid_field]] namespace = "my-namespace" @@ -1502,7 +1522,7 @@ describe('load', () => { expect(extension).not.toBeUndefined() if (extension) { expect(extension.configuration).toMatchObject({ - api_version: '2023-07', + api_version: '2026-10', name: 'My checkout extension', handle: 'checkout-ui', type: 'ui_extension', @@ -1559,12 +1579,9 @@ describe('load', () => { { metafields: [ { - key: 'my-key', - namespace: 'my-namespace', - }, - { - key: 'my-other-key', - namespace: 'my-namespace', + key: 'target-key', + namespace: 'target-namespace', + owner_type: 'CART', }, ], module: './CheckoutDynamicRender.jsx', @@ -1575,6 +1592,13 @@ describe('load', () => { { target: 'purchase.checkout.block.render', module: './CheckoutDynamicRender.jsx', + metafields: [ + { + key: 'target-key', + namespace: 'target-namespace', + owner_type: 'CART', + }, + ], }, ], }) diff --git a/packages/app/src/cli/models/extensions/schemas.test.ts b/packages/app/src/cli/models/extensions/schemas.test.ts index d149d24b791..9ba7b152c2d 100644 --- a/packages/app/src/cli/models/extensions/schemas.test.ts +++ b/packages/app/src/cli/models/extensions/schemas.test.ts @@ -1,4 +1,4 @@ -import {BaseSchema, MAX_UID_LENGTH, NewExtensionPointsSchema} from './schemas.js' +import {BaseSchema, MAX_UID_LENGTH, MetafieldSchema, NewExtensionPointsSchema} from './schemas.js' import {describe, expect, test} from 'vitest' const validUIDTestCases = [ @@ -56,6 +56,23 @@ describe('UIDSchema', () => { }) }) +describe('MetafieldSchema', () => { + test('accepts an owner type for API validation', () => { + const result = MetafieldSchema.safeParse({namespace: 'custom', key: 'value', owner_type: 'PRODUCT'}) + + expect(result).toEqual({ + success: true, + data: {namespace: 'custom', key: 'value', owner_type: 'PRODUCT'}, + }) + }) + + test('accepts a metafield without an owner type', () => { + const result = MetafieldSchema.safeParse({namespace: 'custom', key: 'value'}) + + expect(result.success).toBe(true) + }) +}) + describe('NewExtensionPointsSchema', () => { test.each([ [ diff --git a/packages/app/src/cli/models/extensions/schemas.ts b/packages/app/src/cli/models/extensions/schemas.ts index 11d6f2fdc7e..4c32cf3eb7c 100644 --- a/packages/app/src/cli/models/extensions/schemas.ts +++ b/packages/app/src/cli/models/extensions/schemas.ts @@ -9,6 +9,7 @@ export type ZodSchemaType = zod.ZodType export const MetafieldSchema = zod.object({ namespace: zod.string(), key: zod.string(), + owner_type: zod.string().optional(), }) const CollectBuyerConsentCapabilitySchema = zod.object({ diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts index 14aa354b05c..ce3a8a62ca7 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts @@ -117,12 +117,12 @@ describe('ui_extension', async () => { }, }, ], - api_version: '2023-01' as const, + api_version: '2026-10' as const, handle: 'test-ui-extension', name: 'UI Extension', description: 'This is an ordinary test extension', type: 'ui_extension', - metafields: [{namespace: 'test', key: 'test'}], + metafields: [{namespace: 'test', key: 'test', owner_type: 'PRODUCT' as const}], capabilities: { block_progress: false, network_access: false, @@ -155,7 +155,7 @@ describe('ui_extension', async () => { intents: undefined, assets: undefined, module: './src/ExtensionPointA.js', - metafields: [{namespace: 'test', key: 'test'}], + metafields: [{namespace: 'test', key: 'test', owner_type: 'PRODUCT'}], default_placement_reference: undefined, capabilities: undefined, preloads: {}, @@ -176,6 +176,34 @@ describe('ui_extension', async () => { ]) }) + test('target-level metafields override extension-level metafields', async () => { + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! + const configuration = { + targeting: [ + { + target: 'EXTENSION::POINT::A', + module: './src/ExtensionPointA.js', + metafields: [{namespace: 'target', key: 'value', owner_type: 'COMPANY_LOCATION' as const}], + }, + ], + api_version: '2026-10' as const, + handle: 'test-ui-extension', + name: 'UI Extension', + type: 'ui_extension', + metafields: [{namespace: 'extension', key: 'value', owner_type: 'SHOP' as const}], + } + + const parsed = specification.parseConfigurationObject(configuration) + + expect(parsed.state).toBe('ok') + if (parsed.state === 'ok') { + expect(parsed.data.extension_points[0]?.metafields).toStrictEqual([ + {namespace: 'target', key: 'value', owner_type: 'COMPANY_LOCATION'}, + ]) + } + }) + test('targeting object accepts a default_placement', async () => { const allSpecs = await loadLocalExtensionsSpecifications() const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')!