Skip to content
Merged
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/admin-app-home-intents-request.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/admin-app-home-tools-api.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion packages/ui-extensions-tester/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.

Expand Down
2 changes: 1 addition & 1 deletion packages/ui-extensions-tester/src/admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions packages/ui-extensions-tester/src/admin/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -124,12 +125,30 @@ function createMockLoadingApi(): LoadingApi {
return () => {};
}

function createMockToolsApi(): Tools {
return {
register: () => () => {},
unregister: () => {},
clear: () => {},
};
}

function createAppHomeMock<T extends ExtensionTarget>(target: T) {
const {invoke} = createMockStandardApi(target).intents;

return {
...createMockStandardRenderingApi(target),
toast: createMockToastApi(),
app: createMockAppApi(),
loading: createMockLoadingApi(),
tools: createMockToolsApi(),
intents: {
invoke,
request: {
value: null,
subscribe: () => () => {},
},
},
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Equals<AppHome['intents'], AppHomeIntents>>();
assertType<Equals<AppHome['intents']['request'], AppHomeIntentRequest>>();

// `AppHomeIntents` keeps the standard invocation helper.
assertType<Equals<AppHomeIntents['invoke'], Intents['invoke']>>();

// `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<CreateProductRequest>;
type AppHomeWithGenerated = WithGeneratedIntents<AppHome, GeneratedVariants>;

assertType<
Equals<
AppHomeWithGenerated['intents']['request']['value'],
CreateProductRequest | null
>
>();

// Other `AppHomeApi` properties survive the merge.
assertType<Equals<AppHomeWithGenerated['toast'], AppHome['toast']>>();
assertType<Equals<AppHomeWithGenerated['tools'], AppHome['tools']>>();

// ---------------------------------------------------------------------------
// 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');
});
});
27 changes: 27 additions & 0 deletions packages/ui-extensions-tester/src/tests/admin-factories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
102 changes: 102 additions & 0 deletions packages/ui-extensions-tester/src/tests/admin-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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<UpdateFaqOutput>,
): () => void;
}

type AppHome = AppHomeApi<'admin.app.home.render'>;
type AppHomeWithGenerated = WithGeneratedTools<AppHome, GeneratedTools>;

// AppHomeApi exposes the base `Tools` API (regression test for the missing
// `tools` property on `admin.app.home.render`).
assertType<Equals<AppHome['tools'], Tools>>();

// `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<AppHomeWithGenerated['tools']['unregister'], Tools['unregister']>
>();
assertType<Equals<AppHomeWithGenerated['tools']['clear'], Tools['clear']>>();
assertType<
Equals<AppHomeWithGenerated['tools']['register'], GeneratedTools['register']>
>();

// Other `AppHomeApi` properties are preserved through the merge.
assertType<Equals<AppHomeWithGenerated['toast'], AppHome['toast']>>();
assertType<Equals<AppHomeWithGenerated['app'], AppHome['app']>>();
assertType<Equals<AppHomeWithGenerated['loading'], AppHome['loading']>>();

// When the base API has no `tools`, `WithGeneratedTools` introduces it.
interface NoToolsBase {
extension: {target: 'noop'};
}
type NoToolsMerged = WithGeneratedTools<NoToolsBase, GeneratedTools>;
assertType<Equals<NoToolsMerged['tools'], GeneratedTools>>();

// `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');
});
});
9 changes: 9 additions & 0 deletions packages/ui-extensions-tester/src/tests/type-assertions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends <
T,
>() => T extends B ? 1 : 2
? true
: false;

export function assertType<_T extends true>(): void {
return undefined;
}
7 changes: 6 additions & 1 deletion packages/ui-extensions/src/surfaces/admin/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
71 changes: 71 additions & 0 deletions packages/ui-extensions/src/surfaces/admin/api/app-home/app-home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -24,4 +81,18 @@ export interface AppHomeApi<ExtensionTarget extends AnyExtensionTarget>
* 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;
}
Loading
Loading