diff --git a/.changeset/metafield-dynamic-options-target.md b/.changeset/metafield-dynamic-options-target.md new file mode 100644 index 0000000000..2b91b3a9ff --- /dev/null +++ b/.changeset/metafield-dynamic-options-target.md @@ -0,0 +1,5 @@ +--- +'@shopify/ui-extensions': minor +--- + +Add the `admin.metafields.options.data` runnable extension target, with its `MetafieldOptionsApi` input and `MetafieldOptionsOutput` output types. An app can supply presentation-only options for its own metafield definitions in the admin's native metafield editors, without writing a `choices` validation. The target is `@private` while it is gated to Shopify-owned apps in Core. diff --git a/packages/ui-extensions-tester/src/admin/factories.ts b/packages/ui-extensions-tester/src/admin/factories.ts index aa157d3022..b8b5679a97 100644 --- a/packages/ui-extensions-tester/src/admin/factories.ts +++ b/packages/ui-extensions-tester/src/admin/factories.ts @@ -310,6 +310,16 @@ function createCustomerSegmentTemplateMock( }; } +function createMetafieldOptionsMock(target: T) { + return { + ...createMockStandardApi(target), + data: { + owner: {id: 'gid://shopify/Product/1', type: 'PRODUCT'}, + metafields: [], + }, + }; +} + // --------------------------------------------------------------------------- // Factory map — TypeScript verifies each entry against ApiForTarget // --------------------------------------------------------------------------- @@ -322,6 +332,7 @@ const adminMockFactories: AdminMockFactory = { // Runnable targets 'admin.customers.segmentation-templates.data': createCustomerSegmentTemplateMock, + 'admin.metafields.options.data': createMetafieldOptionsMock, 'admin.app.tools.data': createMockStandardApi, // App render targets diff --git a/packages/ui-extensions-tester/src/tests/admin-factories.test.ts b/packages/ui-extensions-tester/src/tests/admin-factories.test.ts index 7064916c7c..d08d9fc8a3 100644 --- a/packages/ui-extensions-tester/src/tests/admin-factories.test.ts +++ b/packages/ui-extensions-tester/src/tests/admin-factories.test.ts @@ -40,6 +40,17 @@ describe('createMockAdminTargetApi', () => { expect(api).not.toHaveProperty('resourcePicker'); }); + it('creates a metafield options api with owner and definitions data', () => { + const api = createMockAdminTargetApi('admin.metafields.options.data'); + + expect(api.extension.target).toBe('admin.metafields.options.data'); + expect(api.data.owner).toStrictEqual({ + id: 'gid://shopify/Product/1', + type: 'PRODUCT', + }); + expect(api.data.metafields).toStrictEqual([]); + }); + it('creates an app home api with loading controls', () => { const api = createMockAdminTargetApi('admin.app.home.render'); diff --git a/packages/ui-extensions/src/surfaces/admin/api.ts b/packages/ui-extensions/src/surfaces/admin/api.ts index 80c382f349..65103b182f 100644 --- a/packages/ui-extensions/src/surfaces/admin/api.ts +++ b/packages/ui-extensions/src/surfaces/admin/api.ts @@ -25,6 +25,15 @@ export type { CustomerSegmentTemplateApi, CustomerSegmentTemplate, } from './api/customer-segment-template/customer-segment-template'; +export type { + MetafieldOptionsApi, + MetafieldOptions, + MetafieldOptionsData, + MetafieldOptionsDefinition, + MetafieldOptionsOutput, + MetafieldOptionsOwner, + MetafieldOptionsSupportedType, +} from './api/metafield-options/metafield-options'; export type {ActionExtensionApi} from './api/action/action'; export type {BlockExtensionApi} from './api/block/block'; export type {PrintActionExtensionApi} from './api/print-action/print-action'; diff --git a/packages/ui-extensions/src/surfaces/admin/api/metafield-options/metafield-options.ts b/packages/ui-extensions/src/surfaces/admin/api/metafield-options/metafield-options.ts new file mode 100644 index 0000000000..3f0454aaa7 --- /dev/null +++ b/packages/ui-extensions/src/surfaces/admin/api/metafield-options/metafield-options.ts @@ -0,0 +1,90 @@ +import type {StandardApi} from '../standard/standard'; +import type {ExtensionTarget as AnyExtensionTarget} from '../../extension-targets'; + +/** + * The [metafield definition types](/docs/apps/build/metafields/list-of-data-types) that support app-provided options. + * @publicDocs + */ +export type MetafieldOptionsSupportedType = + | 'single_line_text_field' + | 'list.single_line_text_field'; + +/** + * The resource whose metafields the admin is currently editing. + * @publicDocs + */ +export interface MetafieldOptionsOwner { + /** The resource's global ID, or `null` when the resource has not been created yet. */ + id: string | null; + /** The [metafield owner type](/docs/api/admin-graphql/latest/enums/MetafieldOwnerType) of the resource, for example `ARTICLE` or `PRODUCT`. */ + type: string; +} + +/** + * A metafield definition owned by your app that the admin is currently rendering. + * @publicDocs + */ +export interface MetafieldOptionsDefinition { + /** The definition's reserved namespace. */ + namespace: string; + /** The definition's key. */ + key: string; + /** The merchant-facing name of the definition. */ + name: string; + /** The definition's type. */ + type: MetafieldOptionsSupportedType; + /** The value currently held in the editor, or an empty string when unset. */ + value: string; +} + +/** + * The data passed to a metafield options extension. Only definitions owned by your app are included. + * @publicDocs + */ +export interface MetafieldOptionsData { + /** The resource whose metafields are being edited. */ + owner: MetafieldOptionsOwner; + /** The app-owned definitions the admin is asking your extension to supply options for. */ + metafields: MetafieldOptionsDefinition[]; +} + +/** + * Presentation-only options for one of your app's metafield definitions. + * @publicDocs + */ +export interface MetafieldOptions { + /** The namespace of the definition these options belong to. Must match one of the definitions in `data.metafields`. */ + namespace: string; + /** The key of the definition these options belong to. Must match one of the definitions in `data.metafields`. */ + key: string; + /** The values the merchant can choose from. Options beyond the first 250 are ignored, and duplicates are removed. */ + options: string[]; +} + +/** + * The output returned by a metafield options extension. + * + * These options are presentation guidance only. They are not persisted as `choices` validations, they do not change + * save-time enforcement, and a value already saved on the metafield stays visible even if you stop returning it. + * Entries for definitions that are not in `data.metafields` are ignored. + * @publicDocs + */ +export interface MetafieldOptionsOutput { + /** One entry per definition you want to supply options for. Omit a definition to leave it as a free-text field. */ + metafields: MetafieldOptions[]; +} + +/** + * The `MetafieldOptionsApi` object provides the context a metafield options extension needs to supply choices for its + * own metafield definitions. Access the following properties to read the resource being edited and the app-owned + * definitions the admin is rendering. + * @publicDocs + */ +export interface MetafieldOptionsApi + extends StandardApi { + /** + * The resource being edited, and the metafield definitions owned by your app that the admin is rendering. The admin + * never includes definitions owned by the merchant or by another app. + */ + data: MetafieldOptionsData; +} diff --git a/packages/ui-extensions/src/surfaces/admin/extension-targets.ts b/packages/ui-extensions/src/surfaces/admin/extension-targets.ts index f5c29ee181..5cac0b46ac 100644 --- a/packages/ui-extensions/src/surfaces/admin/extension-targets.ts +++ b/packages/ui-extensions/src/surfaces/admin/extension-targets.ts @@ -12,6 +12,8 @@ import type { DiscountFunctionSettingsApi, CustomerSegmentTemplateApi, CustomerSegmentTemplate, + MetafieldOptionsApi, + MetafieldOptionsOutput, StandardApi, IntentRenderApi, AppHomeApi, @@ -40,6 +42,15 @@ export interface ExtensionTargets { {templates: CustomerSegmentTemplate[]} >; + /** + * A runnable target that supplies presentation-only options for your app's own [metafield](/docs/apps/build/custom-data) definitions in the admin's native metafield editors. Use this target when the values a merchant should choose from are dynamic or come from an external system, so they don't belong in a persisted `choices` validation. The admin only sends you definitions your app owns, it renders and saves the field itself, and the options you return never change save-time validation. + * @private + */ + 'admin.metafields.options.data': RunnableExtension< + MetafieldOptionsApi<'admin.metafields.options.data'>, + MetafieldOptionsOutput + >; + // Blocks /** * A block target that displays inline content within the product details page. Use this to show product-specific information, tools, or actions directly on the product page.