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/pos-resolution-target.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 `pos.resolution.action.render` extension target for POS. This target renders a resolution side panel beside the cart when a `beforeCheckout` intercept returns a blocking validation. The target's API includes `ActionTargetApi` (standard API + scanner), a write-capable live Cart API, and read-only navigation (`currentEntry` only — `navigate`/`back` throw host-side). The app identifies which validation to resolve by reading the handle from the seeded navigation URL (`navigation.currentEntry.url`), and re-runs its own interceptor validation function against the live `cart.current` to regenerate the violation details.
31 changes: 31 additions & 0 deletions packages/ui-extensions-tester/src/point-of-sale/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
CashTrackingSessionCompleteData,
CartUpdateEventData,
Money,
ReadonlyNavigationApi,
} from '@shopify/ui-extensions/point-of-sale';

import {createReadonlySignalLike} from '../mocks/signals';
Expand Down Expand Up @@ -256,6 +257,22 @@ function createMockCashDrawerApi(): CashDrawerApi {
return {cashDrawer: {open: async () => {}}};
}

function createMockReadonlyNavigationApi(): ReadonlyNavigationApi {
return {
navigation: {
currentEntry: {
key: 'mock-key',
// The URL is seeded as `/{handle}` by the host. Tests can override
// this by constructing their own mock and replacing `navigation`.
url: '/mock-handle',
getState: () => null,
},
addEventListener: () => {},
removeEventListener: () => {},
},
};
}

// ---------------------------------------------------------------------------
// Group factory functions — each composes the correct API for a target group
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -449,6 +466,17 @@ function createActionTargetCashDrawerMock<T extends RenderExtensionTarget>(
};
}

// Group R: ActionTargetApi + CartApi + ReadonlyNavigationApi
function createResolutionTargetMock<T extends RenderExtensionTarget>(
target: T,
): ActionTargetApi<T> & CartApi & ReadonlyNavigationApi {
return {
...createMockActionTargetApi(target),
...createMockCartApi(),
...createMockReadonlyNavigationApi(),
};
}

// Data target factories
function createDataTargetMock<T extends ExtensionTarget>(
target: T,
Expand Down Expand Up @@ -618,6 +646,9 @@ const posMockFactories: PosMockFactory = {
// Group Q: ActionTargetApi + CashDrawerApi
'pos.register-details.action.render': createActionTargetCashDrawerMock,

// Group R: ActionTargetApi + CartApi + ReadonlyNavigationApi
'pos.resolution.action.render': createResolutionTargetMock,

// Data targets
'pos.app.ready.data': createDataTargetMock,

Expand Down
8 changes: 8 additions & 0 deletions packages/ui-extensions/src/surfaces/point-of-sale/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export type {
ExtensionApiContent,
} from './api/extension-api/extension-api';
export type {ActionTargetApi} from './api/action-target-api/action-target-api';
export type {
ReadonlyNavigation,
ReadonlyNavigationApi,
Navigation,
NavigationNavigateOptions,
NavigationHistoryEntry,
NavigationCurrentEntryChangeEvent,
} from './api/navigation-api/navigation-api';
export type {DataTargetApi} from './api/data-target-api/data-target-api';
export type {
TransactionCompleteEvent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,36 @@ export interface Navigation {
}

/**
* The global `window` object provides control over the extension modal lifecycle. Access these properties and methods directly through the global `window` object to manage the modal interface programmatically.
* Read-only view of the `Navigation` object, for extension targets that must not
* navigate programmatically (e.g. `pos.resolution.action.render`).
*
* This is a `Pick` of `Navigation` rather than a redeclaration, so it stays in
* sync automatically as `Navigation` evolves: any member added to `Navigation`
* is excluded here by default, which is the safe direction for a read-only view.
*
* `navigate` and `back` are omitted. The host rejects navigation writes at
* runtime regardless of the type (see Shopify/extensibility#1586); this type
* exists so the omission is visible to app developers in autocomplete.
*
* Note: `currentEntry` intentionally keeps the same type it has on `Navigation`.
* The RPC bridge already makes signals read-only guest-side, so no separate
* read-only entry type is needed.
*
* @publicDocs
*/
export interface Window {
/**
* Closes the extension screen and dismisses the modal interface. Use to programmatically close the modal after completing a workflow, canceling an operation, or when user action is no longer required. This provides the same behavior as the user dismissing the modal through the UI.
*/
close(): void;
export type ReadonlyNavigation = Pick<
Navigation,
'currentEntry' | 'addEventListener' | 'removeEventListener'
>;

/**
* Provides read-only navigation for targets that cannot navigate
* programmatically. Follows the same shape convention as `CartApi` /
* `ReadonlyCartApi`.
* @publicDocs
*/
export interface ReadonlyNavigationApi {
navigation: ReadonlyNavigation;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
OrderApi,
StorageApi,
CashDrawerApi,
ReadonlyNavigationApi,
} from './api';
import type {ActionExtensionComponents} from './components/targets/ActionExtensionComponents';
import type {BlockExtensionComponents} from './components/targets/BlockExtensionComponents';
Expand Down Expand Up @@ -122,6 +123,69 @@ export interface RenderExtensionTargets {
ActionTargetApi<'pos.home.modal.render'> & CartApi,
BasicComponents
>;
/**
* Renders a resolution side panel beside the POS cart when a merchant app's
* `beforeCheckout` intercept returns a blocking validation. POS launches this
* target automatically — it is not triggered by a tile or menu item — and
* revalidates the cart when the side panel is closed.
*
* **Which validation are you resolving?** POS seeds the navigation URL with
* `/{handle}`, where `handle` is the one your app supplied on the blocking
* validation. Read it from `navigation.currentEntry`:
*
* ```ts
* const handle = navigation.currentEntry.url?.slice(1) ?? '';
* ```
*
* **Getting the validation details.** There is no API that hands you the
* violation — this is deliberate, so there is one source of truth for
* validation logic. Re-run the same validation function you use in your
* `beforeCheckout` interceptor against `shopify.cart.current`, then match on
* the handle to find the violation this screen is for:
*
* ```ts
* const violations = myValidations(shopify.cart.current);
* const violation = violations.find((v) => v.handle === handle);
* ```
*
* **`cart.current` is live.** The cart is the same live cart every other
* target sees — read and write. Mutate it to resolve the problem (remove a
* restricted item, apply a required discount, etc.), observe the change
* reflected in `cart.current`, then re-run your validation function to
* confirm it passes and render your own success UI. POS also revalidates
* the live cart when the resolution flow closes.
*
* **Navigation is read-only.** `currentEntry` and the `currententrychange`
* event listener work normally, but `navigate()`, `back()`, `push()`, and
* `pop()` throw — the host rejects all navigation writes.
*
* **Static per-event API table** (API exposure is static per intercept event
* name, documented here — there is no runtime capability introspection):
*
* | Event | Cart | Navigation | Standard | Scanner |
* | --- | --- | --- | --- | --- |
* | `beforeCheckout` | read + write | read-only | yes | yes |
* | `paymentType` *(future, not implemented)* | read-only | read-only | yes | yes |
*
* For the current `beforeCheckout` event the extension gets: the full
* `StandardApi` (including `ScannerApi` — scanning a driver's licence or ID
* to clear an age-restriction block is a first-class use of this target), a
* write-capable `CartApi`, and read-only navigation (`currentEntry` only —
* `navigate`/`back` throw host-side). A future `paymentType` event would
* receive read-only cart instead, documented here for forward compatibility.
*
* **API version requirement.** The handle travels in `navigation.currentEntry`,
* which the host only exposes on remote-dom api versions. This target therefore
* requires a remote-dom `minimum_api_version`; version enforcement happens in
* the shop/world server registration (`pos_ui.rb`), not in this package. Because
* the target is brand new, nothing is grandfathered.
*/
'pos.resolution.action.render': RenderExtension<
ActionTargetApi<'pos.resolution.action.render'> &
CartApi &
ReadonlyNavigationApi,
BasicComponents
>;
/**
* Renders a single interactive button component as a menu item in the post-return action menu. Use this target for post-return operations like generating return receipts, processing restocking workflows, or collecting return feedback.
*
Expand Down
12 changes: 12 additions & 0 deletions packages/ui-extensions/src/surfaces/point-of-sale/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ import type {
ShopifyInterceptor,
} from './events';

/* eslint-disable-next-line no-warning-comments -- deliberate prototype marker; tracks a known gap in this draft */
// TODO(prototype): The `navigation` global is declared process-wide for all POS
// targets. For `pos.resolution.action.render` we want read-only navigation
// (currentEntry only, no navigate/back). This is currently expressed in the
// per-target API intersection via `ReadonlyNavigationApi`, but the `navigation`
// *global* still advertises the full `Navigation` type. Per-target global
// narrowing (following the BackgroundShopifyGlobal precedent + buildTargetDts
// isDataTarget branch) is deferred — navigation writes are rejected host-side
// in Shopify/extensibility, not at the type level. A future PR can add a
// `ResolutionShopifyGlobal` / narrowed navigation global if type-level
// enforcement is needed.

/**
* The `shopify` global provides APIs that are available to all POS extensions
* without needing to access them through the target's `api` argument.
Expand Down
Loading