diff --git a/.changeset/admin-app-home-intents-request.md b/.changeset/admin-app-home-intents-request.md new file mode 100644 index 0000000000..a9c313d0bc --- /dev/null +++ b/.changeset/admin-app-home-intents-request.md @@ -0,0 +1,6 @@ +--- +'@shopify/ui-extensions': patch +'@shopify/ui-extensions-tester': patch +--- + +Narrow `AppHomeApi.intents` to a new `AppHomeIntents` shape that exposes a signal-like `request` so the runtime can stream link intents into a long-lived `admin.app.home.render` extension. `WithGeneratedIntents` continues to narrow `request.value` to the CLI-generated variants. diff --git a/.changeset/admin-app-home-tools-api.md b/.changeset/admin-app-home-tools-api.md new file mode 100644 index 0000000000..0e08d9af83 --- /dev/null +++ b/.changeset/admin-app-home-tools-api.md @@ -0,0 +1,6 @@ +--- +'@shopify/ui-extensions': patch +'@shopify/ui-extensions-tester': patch +--- + +Expose the `Tools` API on `AppHomeApi` so `admin.app.home.render` extensions can call `shopify.tools.register(...)` even before the CLI generates typed overloads. Adds a matching mock to `@shopify/ui-extensions-tester`. diff --git a/packages/ui-extensions-tester/README.md b/packages/ui-extensions-tester/README.md index b1427accdc..f24ae79116 100644 --- a/packages/ui-extensions-tester/README.md +++ b/packages/ui-extensions-tester/README.md @@ -401,7 +401,7 @@ Imports and executes the extension module's default export, rendering the extens A mock `shopify` global, typed correctly for the target under test. You can mutate any property. -When testing `admin.app.home.render`, the mock `shopify` object also includes `toast`, `app`, and `loading()`. +When testing `admin.app.home.render`, the mock `shopify` object also includes `toast`, `app`, `loading()`, `tools`, and `intents.request`. When testing `admin.app.intent.render`, the mock `shopify.intents` object also includes `response.ok()`, `response.error()`, and `response.closed()`. diff --git a/packages/ui-extensions-tester/src/admin/README.md b/packages/ui-extensions-tester/src/admin/README.md index d147e9372b..c6ed1300c4 100644 --- a/packages/ui-extensions-tester/src/admin/README.md +++ b/packages/ui-extensions-tester/src/admin/README.md @@ -25,7 +25,7 @@ extension.shopify.storage = createStorage({ ## 🏠 App home mocks -The `admin.app.home.render` target mock includes `shopify.toast`, `shopify.app`, and `shopify.loading()`. +The `admin.app.home.render` target mock includes `shopify.toast`, `shopify.app`, `shopify.loading()`, `shopify.tools`, and `shopify.intents.request`. ## 🧭 App intent mocks diff --git a/packages/ui-extensions-tester/src/admin/factories.ts b/packages/ui-extensions-tester/src/admin/factories.ts index 4f4a82b0fb..c09624c01c 100644 --- a/packages/ui-extensions-tester/src/admin/factories.ts +++ b/packages/ui-extensions-tester/src/admin/factories.ts @@ -9,6 +9,7 @@ import type { ToastApi, AppApi, LoadingApi, + Tools, } from '@shopify/ui-extensions/admin'; import {createReadonlySignalLike} from '../mocks/signals'; import {createMockI18n} from '../mocks/i18n'; @@ -124,12 +125,30 @@ function createMockLoadingApi(): LoadingApi { return () => {}; } +function createMockToolsApi(): Tools { + return { + register: () => () => {}, + unregister: () => {}, + clear: () => {}, + }; +} + function createAppHomeMock(target: T) { + const {invoke} = createMockStandardApi(target).intents; + return { ...createMockStandardRenderingApi(target), toast: createMockToastApi(), app: createMockAppApi(), loading: createMockLoadingApi(), + tools: createMockToolsApi(), + intents: { + invoke, + request: { + value: null, + subscribe: () => () => {}, + }, + }, }; } diff --git a/packages/ui-extensions-tester/src/tests/admin-app-home-intents.test.ts b/packages/ui-extensions-tester/src/tests/admin-app-home-intents.test.ts new file mode 100644 index 0000000000..e12cace0e3 --- /dev/null +++ b/packages/ui-extensions-tester/src/tests/admin-app-home-intents.test.ts @@ -0,0 +1,73 @@ +import type { + AppHomeApi, + AppHomeIntents, + AppHomeIntentRequest, + Intents, + ShopifyGeneratedIntentVariant, + WithGeneratedIntents, +} from '@shopify/ui-extensions/admin'; + +import {createMockAdminTargetApi} from '../admin/factories'; +import {assertType, type Equals} from './type-assertions'; + +// --------------------------------------------------------------------------- +// Compile-time assertions +// --------------------------------------------------------------------------- + +type AppHome = AppHomeApi<'admin.app.home.render'>; + +// AppHomeApi exposes a richer `Intents` shape than `StandardApi`: a signal- +// like `request` is always present so the runtime can stream link intents +// into the long-lived extension instance. +assertType>(); +assertType>(); + +// `AppHomeIntents` keeps the standard invocation helper. +assertType>(); + +// `WithGeneratedIntents` narrows `request.value` to the generated payload +// union and preserves the signal-like shape. +interface CreateProductRequest { + action: 'create'; + type: 'shopify/Product'; + data?: {title?: string}; +} +type GeneratedVariants = ShopifyGeneratedIntentVariant; +type AppHomeWithGenerated = WithGeneratedIntents; + +assertType< + Equals< + AppHomeWithGenerated['intents']['request']['value'], + CreateProductRequest | null + > +>(); + +// Other `AppHomeApi` properties survive the merge. +assertType>(); +assertType>(); + +// --------------------------------------------------------------------------- +// Runtime behaviour of the mock factory +// --------------------------------------------------------------------------- + +describe('app home intents api', () => { + it('starts with a null request and a no-op subscribe', () => { + const api = createMockAdminTargetApi('admin.app.home.render'); + + expect(api.intents.request.value).toBeNull(); + + const callback = jest.fn(); + const unsubscribe = api.intents.request.subscribe(callback); + + // The mock subscriber is never invoked because the value is static. + expect(callback).not.toHaveBeenCalled(); + expect(typeof unsubscribe).toBe('function'); + expect(() => unsubscribe()).not.toThrow(); + }); + + it('exposes the standard `invoke` helper alongside `request`', () => { + const api = createMockAdminTargetApi('admin.app.home.render'); + + expect(typeof api.intents.invoke).toBe('function'); + }); +}); 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 33316b17f1..7064916c7c 100644 --- a/packages/ui-extensions-tester/src/tests/admin-factories.test.ts +++ b/packages/ui-extensions-tester/src/tests/admin-factories.test.ts @@ -49,6 +49,33 @@ describe('createMockAdminTargetApi', () => { expect(typeof api.loading).toBe('function'); }); + it('exposes a tools registration api on app home', () => { + const api = createMockAdminTargetApi('admin.app.home.render'); + + expect(typeof api.tools.register).toBe('function'); + expect(typeof api.tools.unregister).toBe('function'); + expect(typeof api.tools.clear).toBe('function'); + + const unregister = api.tools.register('faq.update', async () => ({ + ok: true, + })); + expect(typeof unregister).toBe('function'); + expect(() => unregister()).not.toThrow(); + expect(() => api.tools.unregister('faq.update')).not.toThrow(); + expect(() => api.tools.clear()).not.toThrow(); + }); + + it('exposes a signal-like intents.request on app home', () => { + const api = createMockAdminTargetApi('admin.app.home.render'); + + expect(api.intents.request.value).toBeNull(); + expect(typeof api.intents.request.subscribe).toBe('function'); + + const unsubscribe = api.intents.request.subscribe(() => {}); + expect(typeof unsubscribe).toBe('function'); + expect(() => unsubscribe()).not.toThrow(); + }); + it('creates a standard rendering api for app intent targets', () => { const api = createMockAdminTargetApi('admin.app.intent.render'); diff --git a/packages/ui-extensions-tester/src/tests/admin-tools.test.ts b/packages/ui-extensions-tester/src/tests/admin-tools.test.ts new file mode 100644 index 0000000000..114a08f209 --- /dev/null +++ b/packages/ui-extensions-tester/src/tests/admin-tools.test.ts @@ -0,0 +1,102 @@ +import type { + AppHomeApi, + Tools, + ToolHandler, + WithGeneratedTools, +} from '@shopify/ui-extensions/admin'; + +import {createMockAdminTargetApi} from '../admin/factories'; +import {assertType, type Equals} from './type-assertions'; + +// --------------------------------------------------------------------------- +// Compile-time assertions +// --------------------------------------------------------------------------- + +interface UpdateFaqInput { + id: string; + body: string; +} + +interface UpdateFaqOutput { + ok: boolean; +} + +interface GeneratedTools { + register( + name: 'update_faq', + handler: (input: UpdateFaqInput) => Promise, + ): () => void; +} + +type AppHome = AppHomeApi<'admin.app.home.render'>; +type AppHomeWithGenerated = WithGeneratedTools; + +// AppHomeApi exposes the base `Tools` API (regression test for the missing +// `tools` property on `admin.app.home.render`). +assertType>(); + +// `WithGeneratedTools` keeps non-`register` members from the base `Tools` API +// (so `unregister` and `clear` stay available) while replacing `register` +// with the generated overloads. +assertType< + Equals +>(); +assertType>(); +assertType< + Equals +>(); + +// Other `AppHomeApi` properties are preserved through the merge. +assertType>(); +assertType>(); +assertType>(); + +// When the base API has no `tools`, `WithGeneratedTools` introduces it. +interface NoToolsBase { + extension: {target: 'noop'}; +} +type NoToolsMerged = WithGeneratedTools; +assertType>(); + +// `ToolHandler` is the generic fallback signature (Record in / Record out). +const handler: ToolHandler = async (input) => { + return {received: Object.keys(input).length}; +}; + +// --------------------------------------------------------------------------- +// Runtime behaviour of the mock factory +// --------------------------------------------------------------------------- + +describe('admin tools api', () => { + it('mocks register/unregister/clear with no-op functions', async () => { + const api = createMockAdminTargetApi('admin.app.home.render'); + + expect(typeof api.tools.register).toBe('function'); + const cleanup = api.tools.register('update_faq', handler); + expect(typeof cleanup).toBe('function'); + + // The generic handler is invokable and returns a plain record. + const result = await handler({id: '1', body: 'hi'}); + expect(result).toStrictEqual({received: 2}); + + expect(() => cleanup()).not.toThrow(); + expect(() => api.tools.unregister('update_faq')).not.toThrow(); + expect(() => api.tools.clear()).not.toThrow(); + }); + + it('narrows the mock api type to the generated overloads when wrapped', () => { + const baseApi = createMockAdminTargetApi('admin.app.home.render'); + // Cast at the boundary the way generated typings would augment it. + const api = baseApi as unknown as AppHomeWithGenerated; + + // Generated overload: name + typed handler must match. + const cleanup = api.tools.register('update_faq', async (input) => { + // `input` is narrowed to `UpdateFaqInput`, so `id` is a string. + return {ok: input.id.length > 0}; + }); + + expect(typeof cleanup).toBe('function'); + expect(typeof api.tools.unregister).toBe('function'); + expect(typeof api.tools.clear).toBe('function'); + }); +}); diff --git a/packages/ui-extensions-tester/src/tests/type-assertions.ts b/packages/ui-extensions-tester/src/tests/type-assertions.ts new file mode 100644 index 0000000000..e84d1ccf89 --- /dev/null +++ b/packages/ui-extensions-tester/src/tests/type-assertions.ts @@ -0,0 +1,9 @@ +export type Equals = (() => T extends A ? 1 : 2) extends < + T, +>() => T extends B ? 1 : 2 + ? true + : false; + +export function assertType<_T extends true>(): void { + return undefined; +} diff --git a/packages/ui-extensions/src/surfaces/admin/api.ts b/packages/ui-extensions/src/surfaces/admin/api.ts index 09e370a922..80c382f349 100644 --- a/packages/ui-extensions/src/surfaces/admin/api.ts +++ b/packages/ui-extensions/src/surfaces/admin/api.ts @@ -2,6 +2,7 @@ export type {I18n, I18nTranslate} from '../../api'; export type {StandardApi, Intents} from './api/standard/standard'; export type {ToastApi, ToastOptions} from './api/toast/toast'; export type {LoadingApi, LoadingOptions} from './api/loading/loading'; +export type {Tools, ToolHandler} from './api/tools/tools'; export type { AppApi, ExtensionInfo, @@ -13,7 +14,11 @@ export type { ThemeAppBlockTarget, ThemeAppEmbedTarget, } from './api/app/app'; -export type {AppHomeApi} from './api/app-home/app-home'; +export type { + AppHomeApi, + AppHomeIntents, + AppHomeIntentRequest, +} from './api/app-home/app-home'; export type {StandardRenderingExtensionApi} from './api/standard/standard-rendering'; export type {Navigation} from './api/block/block'; export type { diff --git a/packages/ui-extensions/src/surfaces/admin/api/app-home/app-home.ts b/packages/ui-extensions/src/surfaces/admin/api/app-home/app-home.ts index ceae59e531..5f38eccc03 100644 --- a/packages/ui-extensions/src/surfaces/admin/api/app-home/app-home.ts +++ b/packages/ui-extensions/src/surfaces/admin/api/app-home/app-home.ts @@ -3,6 +3,63 @@ import type {ExtensionTarget as AnyExtensionTarget} from '../../extension-target import type {ToastApi} from '../toast/toast'; import type {AppApi} from '../app/app'; import type {LoadingApi} from '../loading/loading'; +import type {Tools} from '../tools/tools'; +import type { + IntentInvokeApi, + IntentQueryOptions, + IntentResponseApi, +} from '../intents/intents'; + +/** + * The current link intent delivered to an app home extension. The admin + * pushes a new value into `request` whenever a link intent is dispatched to + * your app, and resets it to `null` when no intent is active. + * + * The shape is signal-like (`value` + `subscribe`) so the running extension + * can observe a stream of intents over its lifetime, mirroring the behaviour + * the CLI's generated typings already expect via `WithGeneratedIntents`. + * + * @publicDocs + */ +export interface AppHomeIntentRequest { + /** + * The intent that's currently being delivered to the extension, or `null` + * when no intent is active. + */ + readonly value: IntentQueryOptions | null; + /** + * Subscribes to changes in the active intent. The callback is invoked with + * the new value whenever the admin dispatches a new intent or clears it. + * Returns a function that unsubscribes the callback. + */ + subscribe: ( + callback: (value: IntentQueryOptions | null) => void, + ) => () => void; +} + +/** + * The intents API available to app home extensions. It exposes a signal-like + * `request` that streams link intents into the long-running extension. Inspect + * `request.value` for information about the active intent. + * + * @publicDocs + */ +export interface AppHomeIntents { + /** + * The current link intent delivered to the extension. Subscribe to receive + * new intents over the lifetime of the extension. + */ + request: AppHomeIntentRequest; + /** + * Resolves the current intent. Available only while `request.value` is + * non-null; the runtime swaps in a fresh handle for each new intent. + */ + response?: IntentResponseApi; + /** + * Launches an intent workflow for creating or editing Shopify resources. + */ + invoke?: IntentInvokeApi; +} /** * The `AppHomeApi` object provides methods for app home extensions. Access the following properties on the `AppHomeApi` object to authenticate users, query the [GraphQL Admin API](/docs/api/admin-graphql), translate content, handle intents, persist data, display toast notifications, control the Admin page-level loading indicator, and access app-level data. @@ -24,4 +81,18 @@ export interface AppHomeApi * Sets the Admin page-level loading indicator. Call `loading(true)` to show the loading indicator while your app home extension performs an asynchronous task. Call `loading(false)`, or call `loading()` without an argument, to hide it when the task completes. */ loading: LoadingApi; + + /** + * Receives [link intents](/docs/apps/build/sidekick/build-app-data) dispatched to the app home target and resolves them when complete. Subscribe to `intents.request` to be notified each time the admin navigates the merchant to your app via a link intent, and call `intents.response.ok()` / `error()` / `closed()` to resolve the active intent. + * + * When you run `dev`, the CLI generates typed variants for every intent declared in `shopify.extension.toml`. Use the `WithGeneratedIntents` helper type to narrow `request.value` and `response.ok` to the generated payloads in your app code. + */ + intents: AppHomeIntents; + + /** + * Registers runtime handlers for app tools declared in your extension configuration. Use this to expose app data lookups and app actions to [Sidekick](/docs/apps/build/sidekick/build-app-data), including link intents that drive the navigate modality from the app home target. + * + * When you run `dev`, the CLI generates typed `register` overloads for every tool declared in `shopify.extension.toml`. Use the `WithGeneratedTools` helper type to narrow this property to the generated overloads in your app code. + */ + tools: Tools; } diff --git a/packages/ui-extensions/src/surfaces/admin/api/tools/tools.ts b/packages/ui-extensions/src/surfaces/admin/api/tools/tools.ts new file mode 100644 index 0000000000..0ded0a23ef --- /dev/null +++ b/packages/ui-extensions/src/surfaces/admin/api/tools/tools.ts @@ -0,0 +1,57 @@ +/** + * A handler function invoked by the platform when a registered tool is called. + * The handler receives an `input` object that matches the tool's declared input + * JSON schema and must return (synchronously or asynchronously) an object that + * matches the declared output JSON schema. + * + * Generated tool typings narrow the `input` and return value of this handler + * for each registered tool name. This base signature is the generic fallback + * used before code generation has run, or for tools that aren't yet declared + * in your extension configuration. + * + * @publicDocs + */ +export type ToolHandler = ( + input: Record, +) => Record | Promise>; + +/** + * The `Tools` object provides methods for registering and removing runtime + * handlers for app tools declared in your extension configuration. Use this + * API to expose app data lookups and app actions that + * [Sidekick](/docs/apps/build/sidekick/build-app-data) can invoke on behalf of + * merchants. + * + * The base `register` signature accepts any tool name and a generic handler. + * When you run `dev`, the CLI generates a typed `register` overload for every + * tool declared in `shopify.extension.toml`, narrowing both the accepted name + * and the handler's `input`/return types. Use the `WithGeneratedTools` helper + * to merge the generated typings into your target's API. + * + * @publicDocs + */ +export interface Tools { + /** + * Registers a tool handler for your app. If a handler with the same name is + * already registered, it is replaced. Returns a cleanup function that + * unregisters the handler. + * + * @param name - The tool name as declared in your extension configuration. + * @param handler - The function invoked when the tool is called. + * @returns A function that unregisters the handler when invoked. + */ + register: (name: string, handler: ToolHandler) => () => void; + + /** + * Unregisters a previously registered tool handler. Calling this for a tool + * that isn't registered is a no-op. + * + * @param name - The tool name to unregister. + */ + unregister: (name: string) => void; + + /** + * Unregisters every tool handler currently registered by your extension. + */ + clear: () => void; +}