diff --git a/.changeset/quiet-metaobjects-write.md b/.changeset/quiet-metaobjects-write.md new file mode 100644 index 0000000000..f17727210d --- /dev/null +++ b/.changeset/quiet-metaobjects-write.md @@ -0,0 +1,6 @@ +--- +'@shopify/ui-extensions': minor +'@shopify/ui-extensions-tester': minor +--- + +Add the `admin.metaobject-details.form.render` target, its custom form API and contextual components, and a matching Admin tester factory. diff --git a/packages/ui-extensions-tester/README.md b/packages/ui-extensions-tester/README.md index f24ae79116..ccf02f535b 100644 --- a/packages/ui-extensions-tester/README.md +++ b/packages/ui-extensions-tester/README.md @@ -227,6 +227,8 @@ test('it handles an empty order', async () => { }); ``` +For `admin.metaobject-details.form.render`, the default mock provides `shopify.intents` as `{launchUrl: undefined}` without a fake `invoke()`, an empty `shopify.snapshot`, a successful `shopify.setFieldValue()`, and a no-op `shopify.setSaveHandler()`. It omits the unsupported incidental `data` object. See the [Admin helpers guide](./src/admin/README.md#-metaobject-form-mocks) for details. + ### 🖱️ Triggering events To simulate how a user would interact with your UI extension, you can call [`dispatchEvent()`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent) or use `fireEvent` from `@testing-library/preact`. When an event triggers an async state change (like a Preact re-render), wrap follow-up assertions in `await waitFor()` to wait for the DOM to settle: diff --git a/packages/ui-extensions-tester/src/admin/README.md b/packages/ui-extensions-tester/src/admin/README.md index f826ea8dbb..647e0537b0 100644 --- a/packages/ui-extensions-tester/src/admin/README.md +++ b/packages/ui-extensions-tester/src/admin/README.md @@ -31,6 +31,18 @@ The `admin.app.home.render` target mock includes `shopify.toast`, `shopify.app`, The `admin.app.intent.render` target mock includes `shopify.intents.response.ok()`, `.error()`, and `.closed()`. +## 🧩 Metaobject form mocks + +The `admin.metaobject-details.form.render` target mock matches the target's narrow supported runtime API. Its `intents` is `{launchUrl: undefined}` with no fake `invoke()`, its `snapshot` starts with no fields, `setFieldValue()` resolves to `{status: 'SUCCESS'}`, and `setSaveHandler()` is a no-op that you can replace with a spy when testing pending writes. The mock intentionally omits `data`. A generic Admin Host might expose an incidental empty `data: {}`, but it isn't part of the supported target contract. + +```ts +const setFieldValue = vi.fn().mockResolvedValue({ + status: 'ERROR', + code: 'INVALID_VALUE', +}); +extension.shopify.setFieldValue = setFieldValue; +``` + ## 🔒 Mocking mutation return values Replace mutation functions with `vi.fn()` and use `createResult()` to build typed return values. The first argument is the mutation name; the second is an optional result override. diff --git a/packages/ui-extensions-tester/src/admin/factories.ts b/packages/ui-extensions-tester/src/admin/factories.ts index 11d49a2963..76c1d632b7 100644 --- a/packages/ui-extensions-tester/src/admin/factories.ts +++ b/packages/ui-extensions-tester/src/admin/factories.ts @@ -170,6 +170,21 @@ function createMockBlockApi(target: T) { }; } +function createMetaobjectFormMock(target: T) { + const {extension} = createMockStandardApi(target); + + // A generic Admin Host might expose an incidental empty `data`, but the + // supported target contract and its public mock intentionally omit it. + return { + extension, + intents: {launchUrl: undefined}, + navigation: createNavigation(), + snapshot: createReadonlySignalLike({fields: []}), + setFieldValue: async () => ({status: 'SUCCESS' as const}), + setSaveHandler: () => {}, + }; +} + function createMockActionApi(target: T) { return { ...createMockStandardRenderingApi(target), @@ -328,6 +343,9 @@ const adminMockFactories: AdminMockFactory = { 'admin.app.home.render': createAppHomeMock, 'admin.app.intent.render': createAppIntentRenderMock, + // Form targets + 'admin.metaobject-details.form.render': createMetaobjectFormMock, + // Block targets 'admin.product-details.block.render': createMockBlockApi, 'admin.order-details.block.render': createMockBlockApi, diff --git a/packages/ui-extensions/src/surfaces/admin/api.ts b/packages/ui-extensions/src/surfaces/admin/api.ts index 80c382f349..fb6f7b3c6d 100644 --- a/packages/ui-extensions/src/surfaces/admin/api.ts +++ b/packages/ui-extensions/src/surfaces/admin/api.ts @@ -27,6 +27,15 @@ export type { } from './api/customer-segment-template/customer-segment-template'; export type {ActionExtensionApi} from './api/action/action'; export type {BlockExtensionApi} from './api/block/block'; +export type { + MetaobjectFormApi, + MetaobjectFormSnapshotField, + MetaobjectFormSnapshot, + MetaobjectFormSetFieldValueInput, + MetaobjectFormSetFieldValueErrorCode, + MetaobjectFormSetFieldValueResult, + MetaobjectFormSaveHandler, +} from './api/metaobject-form/metaobject-form'; export type {PrintActionExtensionApi} from './api/print-action/print-action'; export type {ProductDetailsConfigurationApi} from './api/product-configuration/product-details-configuration'; export type {ProductVariantDetailsConfigurationApi} from './api/product-configuration/product-variant-details-configuration'; diff --git a/packages/ui-extensions/src/surfaces/admin/api/metaobject-form/examples/customize-metaobject-form.jsx b/packages/ui-extensions/src/surfaces/admin/api/metaobject-form/examples/customize-metaobject-form.jsx new file mode 100644 index 0000000000..822a51c5ec --- /dev/null +++ b/packages/ui-extensions/src/surfaces/admin/api/metaobject-form/examples/customize-metaobject-form.jsx @@ -0,0 +1,94 @@ +import '@shopify/ui-extensions/preact'; +import {render} from 'preact'; +import {useEffect, useRef, useState} from 'preact/hooks'; + +export default async () => { + render(, document.body); + + let unmounted = false; + return () => { + if (unmounted) { + return; + } + + unmounted = true; + render(null, document.body); + }; +}; + +function Extension() { + const snapshot = shopify.snapshot.value; + const customField = snapshot.fields.find(({key}) => key === 'custom_summary'); + const hostValue = customField?.value ?? ''; + const [customValue, setCustomValue] = useState(hostValue); + const [rejectionCode, setRejectionCode] = useState(null); + const [transportFailed, setTransportFailed] = useState(false); + const pendingWrite = useRef(Promise.resolve()); + + useEffect(() => { + setCustomValue(hostValue); + }, [hostValue]); + + useEffect(() => { + shopify.setSaveHandler(() => pendingWrite.current); + + return () => shopify.setSaveHandler(null); + }, []); + + const writeCustomField = async (value) => { + const result = await shopify.setFieldValue({ + key: 'custom_summary', + value, + }); + + if (result.status === 'ERROR') { + setRejectionCode(result.code); + const currentField = shopify.snapshot.value.fields.find( + ({key}) => key === 'custom_summary', + ); + setCustomValue(currentField?.value ?? ''); + return; + } + + setRejectionCode(null); + setTransportFailed(false); + }; + + const updateCustomField = (event) => { + const value = event.currentTarget.value; + setCustomValue(value); + + const write = pendingWrite.current + .catch(() => undefined) + .then(() => writeCustomField(value)); + pendingWrite.current = write; + void write.catch(() => setTransportFailed(true)); + }; + + const fieldError = + customField?.errors[0] ?? + (rejectionCode ? `Field update rejected: ${rejectionCode}` : undefined) ?? + (transportFailed ? 'The field update could not be sent.' : undefined); + + return ( + + Metaobject details + + + + + + + + + + The native title and custom summary are saved with the metaobject. + + + ); +} diff --git a/packages/ui-extensions/src/surfaces/admin/api/metaobject-form/metaobject-form.ts b/packages/ui-extensions/src/surfaces/admin/api/metaobject-form/metaobject-form.ts new file mode 100644 index 0000000000..a7955be4ae --- /dev/null +++ b/packages/ui-extensions/src/surfaces/admin/api/metaobject-form/metaobject-form.ts @@ -0,0 +1,100 @@ +import type {ReadonlySignalLike} from '../../../../shared'; +import type {ExtensionTarget as AnyExtensionTarget} from '../../extension-targets'; +import type {BlockExtensionApi} from '../block/block'; +import type {StandardApi} from '../standard/standard'; + +/** + * A metaobject field exposed to a custom form extension. + * @publicDocs + */ +export interface MetaobjectFormSnapshotField { + /** The field key from the metaobject definition. */ + key: string; + /** The metaobject field definition type. */ + type: string; + /** The field's current serialized value. */ + value: string; + /** Whether the field can be updated by the extension. */ + editable: boolean; + /** Validation errors currently associated with the field. */ + errors: string[]; +} + +/** + * The current state of the metaobject form. + * @publicDocs + */ +export interface MetaobjectFormSnapshot { + /** + * The complete set of fields in the metaobject form. Admin launches this + * target only for definitions with at most 40 trusted form fields, so this + * array is complete whenever the target runs. + */ + fields: MetaobjectFormSnapshotField[]; +} + +/** + * The input used to update a metaobject field. + * @publicDocs + */ +export interface MetaobjectFormSetFieldValueInput { + /** The field key from the metaobject definition. */ + key: string; + /** The new serialized field value. */ + value: string; +} + +/** + * A machine-readable reason why a metaobject field update failed. + * @publicDocs + */ +export type MetaobjectFormSetFieldValueErrorCode = + | 'UNKNOWN_FIELD' + | 'READ_ONLY' + | 'COMPUTED' + | 'UNSUPPORTED_FIELD_TYPE' + | 'INVALID_VALUE' + | 'VALUE_TOO_LARGE' + | 'NOT_ACTIVE'; + +/** + * The result returned after attempting to update a metaobject field. + * @publicDocs + */ +export type MetaobjectFormSetFieldValueResult = + | {status: 'SUCCESS'} + | { + status: 'ERROR'; + code: MetaobjectFormSetFieldValueErrorCode; + }; + +/** + * A callback that finishes pending extension writes before Admin saves the + * metaobject form. + * @publicDocs + */ +export type MetaobjectFormSaveHandler = () => void | Promise; + +/** + * The API available to extensions that customize a metaobject details form. + * It intentionally exposes only the baseline APIs provided by this target's + * Admin runtime in addition to the metaobject form contract. A generic Admin + * Host might expose an incidental empty `data: {}`, but `data` is not part of + * this target's supported contract. + * @publicDocs + */ +export interface MetaobjectFormApi + extends Pick, 'extension' | 'intents'>, + Pick, 'navigation'> { + /** A reactive snapshot of the current metaobject form. */ + snapshot: ReadonlySignalLike; + /** Updates a field in the host metaobject form. */ + setFieldValue( + input: MetaobjectFormSetFieldValueInput, + ): Promise; + /** + * Registers or removes the callback that runs before the Admin save. Pass + * `null` to remove a previously registered callback. + */ + setSaveHandler(handler: MetaobjectFormSaveHandler | null): void; +} diff --git a/packages/ui-extensions/src/surfaces/admin/components/MetaobjectField.d.ts b/packages/ui-extensions/src/surfaces/admin/components/MetaobjectField.d.ts new file mode 100644 index 0000000000..db85128657 --- /dev/null +++ b/packages/ui-extensions/src/surfaces/admin/components/MetaobjectField.d.ts @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/no-namespace */ + +// eslint-disable-next-line @typescript-eslint/triple-slash-reference, spaced-comment +/// + +import type {Key, Ref} from 'preact'; + +/** + * The properties for the contextual metaobject field component. + * @publicDocs + */ +export interface MetaobjectFieldProps { + /** + * The key of the metaobject field to render using Admin's native field + * editor. + */ + fieldKey: string; +} + +/** + * The JSX properties for the contextual metaobject field component. + * @publicDocs + */ +export interface MetaobjectFieldJSXProps extends MetaobjectFieldProps { + key?: Key; + ref?: Ref; + slot?: Lowercase; +} + +declare const tagName = 's-metaobject-field'; + +/** + * Renders Admin's native editor for a field in the current metaobject form. + * This contextual component does not accept children. + */ +declare class MetaobjectField + extends HTMLElement + implements MetaobjectFieldProps +{ + fieldKey: string; +} + +declare global { + interface HTMLElementTagNameMap { + [tagName]: MetaobjectField; + } +} + +declare module 'preact' { + namespace createElement.JSX { + interface IntrinsicElements { + [tagName]: MetaobjectFieldJSXProps; + } + } +} + +export {MetaobjectField}; diff --git a/packages/ui-extensions/src/surfaces/admin/components/MetaobjectFormComponents.ts b/packages/ui-extensions/src/surfaces/admin/components/MetaobjectFormComponents.ts new file mode 100644 index 0000000000..b211550863 --- /dev/null +++ b/packages/ui-extensions/src/surfaces/admin/components/MetaobjectFormComponents.ts @@ -0,0 +1,14 @@ +/** + * The UI components available to extensions that customize a metaobject form. + * @publicDocs + */ +export type MetaobjectFormComponents = + | 'Grid' + | 'GridItem' + | 'Heading' + | 'Section' + | 'Text' + | 'TextField' + | 'MetaobjectField'; + +export default MetaobjectFormComponents; diff --git a/packages/ui-extensions/src/surfaces/admin/extension-targets.ts b/packages/ui-extensions/src/surfaces/admin/extension-targets.ts index e48f9d0341..a3f1dbfcf9 100644 --- a/packages/ui-extensions/src/surfaces/admin/extension-targets.ts +++ b/packages/ui-extensions/src/surfaces/admin/extension-targets.ts @@ -15,6 +15,7 @@ import type { StandardApi, IntentRenderApi, AppHomeApi, + MetaobjectFormApi, } from './api'; import { ShouldRenderApi, @@ -25,6 +26,7 @@ import type {ActionExtensionComponents} from './components/ActionExtensionCompon import type {PrintActionExtensionComponents} from './components/PrintActionExtensionComponents'; import type {FunctionSettingsComponents} from './components/FunctionSettingsComponents'; import type {FormExtensionComponents} from './components/FormExtensionComponents'; +import type {MetaobjectFormComponents} from './components/MetaobjectFormComponents'; /** * Maps extension target identifiers to their corresponding extension types. Each target represents a specific location or context in the Shopify admin where extensions can render or execute. Use these targets to define where your extension appears and what capabilities it has access to. @@ -40,6 +42,16 @@ export interface ExtensionTargets { {templates: CustomerSegmentTemplate[]} >; + /** + * A form target that replaces the default layout of a metaobject details form. Use this target to arrange native metaobject fields and provide custom field editors while preserving Admin's existing save flow. + * + * Admin launches this custom form only for definitions with at most 40 trusted form fields, so `snapshot.fields` is complete whenever the target launches. + */ + 'admin.metaobject-details.form.render': RenderExtension< + MetaobjectFormApi<'admin.metaobject-details.form.render'>, + MetaobjectFormComponents + >; + // 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.