Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/quiet-metaobjects-write.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/ui-extensions-tester/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions packages/ui-extensions-tester/src/admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions packages/ui-extensions-tester/src/admin/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,21 @@ function createMockBlockApi<T extends ExtensionTarget>(target: T) {
};
}

function createMetaobjectFormMock<T extends ExtensionTarget>(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<T extends ExtensionTarget>(target: T) {
return {
...createMockStandardRenderingApi(target),
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions packages/ui-extensions/src/surfaces/admin/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import '@shopify/ui-extensions/preact';
import {render} from 'preact';
import {useEffect, useRef, useState} from 'preact/hooks';

export default async () => {
render(<Extension />, 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 (
<s-section>
<s-heading>Metaobject details</s-heading>
<s-grid gridTemplateColumns="1fr 1fr" gap="base">
<s-grid-item>
<s-metaobject-field fieldKey="title" />
</s-grid-item>
<s-grid-item>
<s-text-field
label="Custom summary"
value={customValue}
error={fieldError}
onChange={updateCustomField}
/>
</s-grid-item>
</s-grid>
<s-text>
The native title and custom summary are saved with the metaobject.
</s-text>
</s-section>
);
}
Original file line number Diff line number Diff line change
@@ -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<void>;

/**
* 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<ExtensionTarget extends AnyExtensionTarget>
extends Pick<StandardApi<ExtensionTarget>, 'extension' | 'intents'>,
Pick<BlockExtensionApi<ExtensionTarget>, 'navigation'> {
/** A reactive snapshot of the current metaobject form. */
snapshot: ReadonlySignalLike<MetaobjectFormSnapshot>;
/** Updates a field in the host metaobject form. */
setFieldValue(
input: MetaobjectFormSetFieldValueInput,
): Promise<MetaobjectFormSetFieldValueResult>;
/**
* Registers or removes the callback that runs before the Admin save. Pass
* `null` to remove a previously registered callback.
*/
setSaveHandler(handler: MetaobjectFormSaveHandler | null): void;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/* eslint-disable @typescript-eslint/no-namespace */

// eslint-disable-next-line @typescript-eslint/triple-slash-reference, spaced-comment
/// <reference lib="DOM" />

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<MetaobjectField>;
slot?: Lowercase<string>;
}

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};
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 12 additions & 0 deletions packages/ui-extensions/src/surfaces/admin/extension-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
StandardApi,
IntentRenderApi,
AppHomeApi,
MetaobjectFormApi,
} from './api';
import {
ShouldRenderApi,
Expand All @@ -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.
Expand All @@ -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.
Expand Down
Loading