Skip to content

Commit 8c919fb

Browse files
lkostrowskiclaude
andauthored
add openPopup App Bridge action for widgets (#6667)
* feat(extensions): add openPopup App Bridge action for widgets Lets a WIDGET extension open one of its own app's POPUP extensions ("full mode") that is co-located on the same page. The action carries a per-app-unique extension `identifier` and an arbitrary JSON payload that is serialized into a single `appParams` query param on the popup URL. - New `openPopup` action: gated to WIDGET frames, resolved against the page's loaded extensions filtered by app id + identifier + POPUP target (same-app enforced by construction), reusing the existing popup dialog. - Failures (wrong source / not found / wrong target / oversized payload) return ok:false and console.error on the dashboard. - Adds an extension registry so the global popup context can resolve against extensions the current page already fetched (no extra query). The `identifier` field is faked into the GraphQL schema until Saleor Core ships it; the action type is declared locally until @saleor/app-sdk does. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: sync GraphQL schema and regenerate types Core now ships AppExtension.identifier (previously faked locally); fetch brings in the real field and regenerates. Adds fromCache to the openPopup test fixture after the main merge. * refactor(extensions): forward app-serialized appParams verbatim openPopup now takes a pre-serialized (base64) appParams string from the app and forwards it into the popup URL unchanged; the Dashboard only length-validates it instead of JSON-serializing the payload itself. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(extensions): keep openPopup JWT fresh on long-open dashboard Widgets never refreshed their access token: AppWidgetExtensionItem mounted AppFrame without a refetch, so useTokenRefresh was a no-op and the ExtensionList query token was fetched once and left to expire. The openPopup path read that same token from the registry and only rejected an empty token, never an expired one -- so a long-open dashboard could open a popup with a dead JWT and fail the handshake. - add isTokenFresh (jwt-decode, 5s margin; lenient on undecodable tokens) - thread the query refetch onto Extension + into the widget AppFrame so useTokenRefresh keeps the widget JWT current (also keeps the registry, and any co-located popup, fresh) - gate openPopupByIdentifier on token freshness: on stale, kick a refetch and reject with console.error so the retry gets a fresh token Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * iframe height * use real sdk * changeset --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 91bd387 commit 8c919fb

26 files changed

Lines changed: 642 additions & 100 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"saleor-dashboard": patch
3+
---
4+
5+
Widgets can now open a full-mode popup. A WIDGET extension can dispatch the new `openPopup` App Bridge action to open one of its app's POPUP extensions declared on the same page, passing an arbitrary JSON payload to it. This lets an app render a compact widget inline and let the user expand it into a larger popup view on demand. The popup always opens with a fresh access token and fills the full popup height.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@
166166
"@graphql-eslint/eslint-plugin": "^4.4.0",
167167
"@mizdra/graphql-codegen-typescript-fabbrica": "^0.6.1",
168168
"@playwright/test": "1.58.2",
169-
"@saleor/app-sdk": "1.11.0",
169+
"@saleor/app-sdk": "1.12.0",
170170
"@sentry/cli": "^2.58.2",
171171
"@storybook/addon-mcp": "^0.4.1",
172172
"@storybook/addon-vitest": "10.2.19",

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/Sidebar/menu/utils.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ describe("mapToExtensionsItems", () => {
3232

3333
const mockExtension: Extension = {
3434
id: "test-extension",
35+
identifier: null,
3536
label: "Test Extension",
3637
app: mockApp,
3738
url: "/test",
@@ -282,6 +283,7 @@ describe("getMenuItemExtension", () => {
282283

283284
const baseMockExtension: Extension = {
284285
id: "base-id",
286+
identifier: null,
285287
label: "Base Label",
286288
app: mockAppDefinition,
287289
url: "/base-url",
@@ -298,6 +300,7 @@ describe("getMenuItemExtension", () => {
298300
const mockExtension: Extension = {
299301
...baseMockExtension,
300302
id: "test-extension",
303+
identifier: null,
301304
label: "Test Extension",
302305
app: mockAppDefinition,
303306
url: "/test",

src/extensions/components/AppExtensionContext/AppExtensionContextProvider.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { APP_VERSION } from "@dashboard/config";
22
import { useExtensionFormPayloadUpdate } from "@dashboard/extensions/app-extension-form-payload-update";
3+
import { useRegisteredExtensions } from "@dashboard/extensions/extension-registry";
4+
import { isTokenFresh } from "@dashboard/extensions/isTokenFresh";
5+
import { isUrlAbsolute } from "@dashboard/extensions/isUrlAbsolute";
6+
import { findOpenPopupExtension, validateOpenPopupParams } from "@dashboard/extensions/open-popup";
37
import { ExtensionsUrls } from "@dashboard/extensions/urls";
48
import { AppDialog } from "@dashboard/extensions/views/ViewManifestExtension/components/AppDialog/AppDialog";
59
import { AppFrame } from "@dashboard/extensions/views/ViewManifestExtension/components/AppFrame/AppFrame";
@@ -44,11 +48,18 @@ export const AppExtensionPopupProvider = ({ children }: PropsWithChildren) => {
4448
);
4549
};
4650

51+
export interface OpenPopupByIdentifierResult {
52+
ok: boolean;
53+
/** Failure description, set when `ok` is false. */
54+
reason?: string;
55+
}
56+
4757
// todo extract modal from non-modal
4858
export const useActiveAppExtension = () => {
4959
const { state, setActive, setInactive, attachFormState } = useAppExtensionPopup();
5060
const navigate = useNavigator();
5161
const { framesByFormType, attachFormResponseFrame } = useExtensionFormPayloadUpdate();
62+
const registeredExtensions = useRegisteredExtensions();
5263

5364
const activate = (appData: AppExtensionActiveParams) => {
5465
if (appData.targetName === "POPUP") {
@@ -61,10 +72,90 @@ export const useActiveAppExtension = () => {
6172
};
6273
const deactivate = setInactive;
6374

75+
/**
76+
* Resolve and open a POPUP extension by its `identifier`, scoped to the
77+
* requesting app and the extensions loaded on the current page. Used by the
78+
* `openPopup` App Bridge action so a widget can open its "full mode" popup.
79+
*/
80+
const openPopupByIdentifier = ({
81+
requestingAppId,
82+
extensionIdentifier,
83+
appParams,
84+
}: {
85+
requestingAppId: string;
86+
extensionIdentifier: string;
87+
appParams?: string;
88+
}): OpenPopupByIdentifierResult => {
89+
const extension = findOpenPopupExtension(registeredExtensions, {
90+
requestingAppId,
91+
extensionIdentifier,
92+
});
93+
94+
if (!extension) {
95+
return {
96+
ok: false,
97+
reason: `No POPUP extension with identifier "${extensionIdentifier}" found for the requesting app on this page`,
98+
};
99+
}
100+
101+
const absoluteUrl = isUrlAbsolute(extension.url)
102+
? extension.url
103+
: `${extension.app.appUrl ?? ""}${extension.url}`;
104+
105+
if (!isUrlAbsolute(absoluteUrl)) {
106+
return {
107+
ok: false,
108+
reason: `Extension "${extensionIdentifier}" has no resolvable absolute URL`,
109+
};
110+
}
111+
112+
const validation = validateOpenPopupParams(appParams);
113+
114+
if (!validation.ok) {
115+
return { ok: false, reason: validation.reason };
116+
}
117+
118+
// The popup handshake requires a valid JWT - never open with an empty token
119+
// (e.g. extensions still painting from cache before the network response).
120+
if (!extension.accessToken) {
121+
return {
122+
ok: false,
123+
reason: `Extension "${extensionIdentifier}" has no access token yet`,
124+
};
125+
}
126+
127+
// On a long-open dashboard the JWT can expire before the widget's refresh
128+
// timer fires (e.g. after the machine wakes from sleep). Never open a popup
129+
// with a stale token - kick a refetch and reject so the retry gets a fresh
130+
// one (the widget's own refresh loop also keeps this list current).
131+
if (!isTokenFresh(extension.accessToken)) {
132+
extension.refetch?.();
133+
134+
return {
135+
ok: false,
136+
reason: `Extension "${extensionIdentifier}" access token is stale; refreshing, retry shortly`,
137+
};
138+
}
139+
140+
setActive({
141+
id: extension.app.id,
142+
appToken: extension.accessToken,
143+
src: absoluteUrl,
144+
label: extension.label,
145+
targetName: "POPUP",
146+
// Forward the app's already-serialized payload verbatim.
147+
params: appParams === undefined ? {} : { appParams },
148+
formState: {},
149+
});
150+
151+
return { ok: true };
152+
};
153+
64154
return {
65155
active: state.active,
66156
activate,
67157
deactivate,
158+
openPopupByIdentifier,
68159
attachFormState,
69160
attachFormResponseFrame,
70161
framesByFormType,

src/extensions/components/AppWidgets/AppWidgetExtensionItem.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ export const AppWidgetExtensionItem = ({
103103
appId={extension.app.id}
104104
dashboardVersion={APP_VERSION}
105105
params={params}
106+
// Keeps the widget's JWT fresh on a long-open dashboard, which also
107+
// keeps the token a co-located `openPopup` popup will use current.
108+
refetch={extension.refetch}
106109
/>
107110
)}
108111
</AppWidgetCard>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { atom, useAtomValue, useSetAtom } from "jotai";
2+
import { useEffect } from "react";
3+
4+
import { type PopupCandidate } from "./open-popup";
5+
6+
/**
7+
* Registry of the extensions currently mounted on the page, keyed per
8+
* `useExtensions` caller.
9+
*
10+
* The popup state lives in a single global provider, but the `openPopup` action
11+
* must resolve a target extension against the extensions the current page
12+
* already fetched (a widget may only open a popup co-located on the same page).
13+
* Each `useExtensions` call publishes its extensions here so the resolver can
14+
* read the union without re-fetching.
15+
*/
16+
const registryAtom = atom<Record<string, PopupCandidate[]>>({});
17+
18+
export const useRegisterExtensions = (key: string, extensions: PopupCandidate[]) => {
19+
const setRegistry = useSetAtom(registryAtom);
20+
21+
// The extensions array is rebuilt on every render, so depend on a stable
22+
// signature instead of the array identity to avoid an update loop.
23+
//
24+
// `accessToken` is part of the signature on purpose: extensions first paint
25+
// from cache with an empty token (`cache-and-network`), then the network
26+
// response fills in the real JWT. Without it the registry would keep the
27+
// cached empty token and open popups with an invalid JWT.
28+
const signature = extensions
29+
.map(
30+
({ app, identifier, targetName, url, accessToken }) =>
31+
`${app.id}:${identifier ?? ""}:${targetName}:${url}:${accessToken}`,
32+
)
33+
.join("|");
34+
35+
useEffect(() => {
36+
setRegistry(prev => ({ ...prev, [key]: extensions }));
37+
38+
return () => {
39+
setRegistry(prev => {
40+
const next = { ...prev };
41+
42+
delete next[key];
43+
44+
return next;
45+
});
46+
};
47+
// eslint-disable-next-line react-hooks/exhaustive-deps
48+
}, [key, signature, setRegistry]);
49+
};
50+
51+
export const useRegisteredExtensions = (): PopupCandidate[] => {
52+
const registry = useAtomValue(registryAtom);
53+
54+
return Object.values(registry).flat();
55+
};

src/extensions/getExtensionsItems.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { type ExtensionWithParams } from "./types";
3232
const mockedExtension: ExtensionWithParams = {
3333
id: "ext-1",
3434
label: "Extension 1",
35+
identifier: null,
3536
app: {
3637
__typename: "App",
3738
id: "app-id",

src/extensions/hooks/useExtensions.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe("Extensions / hooks / useExtensions", () => {
5252
permissions: [{ code: PermissionEnum.MANAGE_ORDERS, __typename: "Permission" }],
5353
url: "https://example.com/ext1",
5454
label: "Extension 1",
55+
identifier: null,
5556
mountName: "PRODUCT_OVERVIEW_CREATE",
5657
targetName: "POPUP",
5758
settings: {},
@@ -72,6 +73,7 @@ describe("Extensions / hooks / useExtensions", () => {
7273
permissions: [{ code: PermissionEnum.MANAGE_PRODUCTS, __typename: "Permission" }],
7374
url: "https://example.com/ext2",
7475
label: "Extension 2",
76+
identifier: null,
7577
mountName: "PRODUCT_DETAILS_MORE_ACTIONS",
7678
targetName: "APP_PAGE",
7779
app: {
@@ -94,6 +96,7 @@ describe("Extensions / hooks / useExtensions", () => {
9496
permissions: [{ code: PermissionEnum.MANAGE_CHANNELS, __typename: "Permission" }],
9597
url: "https://example.com/ext3",
9698
label: "Extension 3",
99+
identifier: null,
97100
mountName: "PRODUCT_OVERVIEW_CREATE",
98101
targetName: "POPUP",
99102
app: {
@@ -115,6 +118,7 @@ describe("Extensions / hooks / useExtensions", () => {
115118
permissions: [{ code: PermissionEnum.MANAGE_PRODUCTS, __typename: "Permission" }],
116119
url: "https://example.com/ext4",
117120
label: "Extension 4",
121+
identifier: null,
118122
mountName: "PRODUCT_OVERVIEW_CREATE",
119123
targetName: "NEW_TAB",
120124
app: {
@@ -136,6 +140,7 @@ describe("Extensions / hooks / useExtensions", () => {
136140
permissions: [{ code: PermissionEnum.MANAGE_PRODUCTS, __typename: "Permission" }],
137141
url: "https://example.com/ext5",
138142
label: "Extension 5",
143+
identifier: null,
139144
mountName: "PRODUCT_OVERVIEW_CREATE",
140145
targetName: "NEW_TAB",
141146
app: {
@@ -159,6 +164,7 @@ describe("Extensions / hooks / useExtensions", () => {
159164
permissions: [{ code: PermissionEnum.MANAGE_PRODUCTS, __typename: "Permission" }],
160165
url: "/ext6",
161166
label: "Extension 6",
167+
identifier: null,
162168
mountName: "PRODUCT_OVERVIEW_CREATE",
163169
targetName: "NEW_TAB",
164170
app: {
@@ -182,6 +188,7 @@ describe("Extensions / hooks / useExtensions", () => {
182188
permissions: [{ code: PermissionEnum.MANAGE_PRODUCTS, __typename: "Permission" }],
183189
url: "/ext7",
184190
label: "Extension 7",
191+
identifier: null,
185192
mountName: "PRODUCT_OVERVIEW_CREATE",
186193
targetName: "NEW_TAB",
187194
app: {
@@ -239,6 +246,7 @@ describe("Extensions / hooks / useExtensions", () => {
239246
permissions: [PermissionEnum.MANAGE_ORDERS],
240247
url: "https://example.com/ext1",
241248
label: "Extension 1",
249+
identifier: null,
242250
mountName: "PRODUCT_OVERVIEW_CREATE",
243251
targetName: "POPUP",
244252
settings: {},
@@ -254,6 +262,7 @@ describe("Extensions / hooks / useExtensions", () => {
254262
permissions: [PermissionEnum.MANAGE_CHANNELS],
255263
url: "https://example.com/ext3",
256264
label: "Extension 3",
265+
identifier: null,
257266
mountName: "PRODUCT_OVERVIEW_CREATE",
258267
targetName: "POPUP",
259268
settings: {},
@@ -269,6 +278,7 @@ describe("Extensions / hooks / useExtensions", () => {
269278
permissions: [PermissionEnum.MANAGE_PRODUCTS],
270279
url: "https://example.com/ext4",
271280
label: "Extension 4",
281+
identifier: null,
272282
mountName: "PRODUCT_OVERVIEW_CREATE",
273283
targetName: "NEW_TAB",
274284
settings: {},
@@ -284,6 +294,7 @@ describe("Extensions / hooks / useExtensions", () => {
284294
permissions: [PermissionEnum.MANAGE_PRODUCTS],
285295
url: "https://example.com/ext5",
286296
label: "Extension 5",
297+
identifier: null,
287298
mountName: "PRODUCT_OVERVIEW_CREATE",
288299
targetName: "NEW_TAB",
289300
settings: { newTabTarget: { method: "POST" } },
@@ -299,6 +310,7 @@ describe("Extensions / hooks / useExtensions", () => {
299310
permissions: [PermissionEnum.MANAGE_PRODUCTS],
300311
url: "/ext6",
301312
label: "Extension 6",
313+
identifier: null,
302314
mountName: "PRODUCT_OVERVIEW_CREATE",
303315
targetName: "NEW_TAB",
304316
settings: { newTabTarget: { method: "GET" } },
@@ -315,6 +327,7 @@ describe("Extensions / hooks / useExtensions", () => {
315327
permissions: [PermissionEnum.MANAGE_PRODUCTS],
316328
url: "/ext7",
317329
label: "Extension 7",
330+
identifier: null,
318331
mountName: "PRODUCT_OVERVIEW_CREATE",
319332
targetName: "NEW_TAB",
320333
settings: { newTabTarget: { method: "POST" } },
@@ -333,6 +346,7 @@ describe("Extensions / hooks / useExtensions", () => {
333346
permissions: [PermissionEnum.MANAGE_PRODUCTS],
334347
url: "https://example.com/ext2",
335348
label: "Extension 2",
349+
identifier: null,
336350
mountName: "PRODUCT_DETAILS_MORE_ACTIONS",
337351
targetName: "APP_PAGE",
338352
settings: {},

0 commit comments

Comments
 (0)