From 136424acb6fc6a1d2b622f0585e73b1714ce7bcc Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Fri, 15 Aug 2025 11:49:53 +0200 Subject: [PATCH 01/17] Add page context API with item editor support - Add getPageContext() function for retrieving current page information - Implement PageContext type with item editor and generic page support - Add page context request/response schema validation - Export new types and functions in public API --- src/customAppSdk.ts | 78 ++++++++++++++++++++++++++++ src/iframeSchema.ts | 120 ++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 4 +- src/utilityTypes.ts | 1 + 4 files changed, 201 insertions(+), 2 deletions(-) diff --git a/src/customAppSdk.ts b/src/customAppSdk.ts index 9bb5e56..3d0cf62 100644 --- a/src/customAppSdk.ts +++ b/src/customAppSdk.ts @@ -47,3 +47,81 @@ export const getCustomAppContext = (): Promise => reject(error); } }); + +export type PageContext = + | { + readonly isError: false; + readonly pageContext: { + readonly route: { + readonly path: string; + readonly params: Readonly>; + readonly query: Readonly>; + }; + readonly page: { + readonly title: string; + readonly breadcrumbs: ReadonlyArray<{ + readonly label: string; + readonly path?: string; + }>; + }; + } & ( + | { + readonly pageType: "item-editor"; + readonly contentItem: { + readonly id: string; + readonly codename: string; + readonly name: string; + readonly type: { + readonly id: string; + readonly codename: string; + }; + readonly language: { + readonly id: string; + readonly codename: string; + }; + readonly workflowStep?: { + readonly id: string; + readonly codename: string; + }; + }; + readonly editor: { + readonly validationErrors: Readonly>>; + readonly elements: ReadonlyArray<{ + readonly id: string; + readonly type: string; + readonly value: string; + }>; + }; + } + | { + readonly pageType: "other"; + } + ) | null; + } + | { + readonly isError: true; + readonly code: ErrorCode; + readonly description: string; + }; + +export const getPageContext = (): Promise => + new Promise((resolve, reject) => { + try { + sendMessage<"get-page-context@1.0.0">( + { + type: "get-page-context-request", + version: "1.0.0", + payload: null, + }, + (response) => { + if (matchesSchema(ErrorMessage, response)) { + resolve({ isError: true, code: response.code, description: response.description }); + } else { + resolve({ ...response.payload, isError: false }); + } + }, + ); + } catch (error) { + reject(error); + } + }); diff --git a/src/iframeSchema.ts b/src/iframeSchema.ts index 6baba17..0c37015 100644 --- a/src/iframeSchema.ts +++ b/src/iframeSchema.ts @@ -25,6 +25,7 @@ const ClientGetContextV1Request = z const ClientGetContextV1Response = z .object({ type: z.literal("get-context-response"), + isError: z.boolean(), payload: z .object({ context: z @@ -53,11 +54,130 @@ const ClientGetContextV1Response = z .or(ErrorMessage) .readonly(); +const ClientGetPageContextV1Request = z + .object({ + type: z.literal("get-page-context-request"), + requestId: z.string().uuid(), + version: z.literal("1.0.0"), + payload: z.null(), + }) + .readonly(); + +const basePageContextProperties = { + route: z + .object({ + path: z.string(), + params: z.record(z.string()), + query: z.record(z.string()), + }) + .readonly(), + page: z + .object({ + title: z.string(), + breadcrumbs: z + .array( + z + .object({ + label: z.string(), + path: z.string().optional(), + }) + .readonly() + ) + .readonly(), + }) + .readonly(), +}; + +const ItemEditorPageContextSchema = z + .object({ + ...basePageContextProperties, + pageType: z.literal("item-editor"), + contentItem: z + .object({ + id: z.string().uuid(), + codename: z.string(), + name: z.string(), + type: z + .object({ + id: z.string().uuid(), + codename: z.string(), + }) + .readonly(), + language: z + .object({ + id: z.string().uuid(), + codename: z.string(), + }) + .readonly(), + workflowStep: z + .object({ + id: z.string().uuid(), + codename: z.string(), + }) + .readonly() + .optional(), + }) + .readonly(), + editor: z + .object({ + validationErrors: z.record(z.string(), z.array(z.string()).readonly()).readonly(), + elements: z + .array( + z + .object({ + id: z.string(), + type: z.string(), + value: z.string(), + }) + .readonly() + ) + .readonly(), + }) + .readonly(), + }) + .readonly(); + +const OtherPageContextSchema = z + .object({ + ...basePageContextProperties, + pageType: z.literal("other"), + }) + .readonly(); + +const PageContextSchema = z.union([ + ItemEditorPageContextSchema, + OtherPageContextSchema, +]); + +const ClientGetPageContextV1Response = z + .object({ + type: z.literal("get-page-context-response"), + isError: z.boolean(), + payload: z + .object({ + pageContext: PageContextSchema.nullable(), + }) + .readonly(), + requestId: z.string().uuid(), + version: z.literal("1.0.0"), + }) + .or(ErrorMessage) + .readonly(); + +export const AllClientRequestMessages = z.union([ + ClientGetContextV1Request, + ClientGetPageContextV1Request, +]); + export type Schema = { client: { "get-context@1.0.0": { request: z.infer; response: z.infer; }; + "get-page-context@1.0.0": { + request: z.infer; + response: z.infer; + }; }; }; diff --git a/src/index.ts b/src/index.ts index 46e1ba3..7b6d18b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ -export { getCustomAppContext } from "./customAppSdk"; -export { CustomAppContext } from "./customAppSdk"; +export { getCustomAppContext, getPageContext } from "./customAppSdk"; +export { CustomAppContext, PageContext } from "./customAppSdk"; diff --git a/src/utilityTypes.ts b/src/utilityTypes.ts index 2a8e822..4671c37 100644 --- a/src/utilityTypes.ts +++ b/src/utilityTypes.ts @@ -1,3 +1,4 @@ import type { Schema } from "./iframeSchema"; export type AllClientResponses = Schema["client"][keyof Schema["client"]]["response"]; +export type AllClientRequests = Schema["client"][keyof Schema["client"]]["request"]; From 16280ef215da2076bea4a11a142c667de9b4dd2a Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Tue, 26 Aug 2025 12:05:58 +0200 Subject: [PATCH 02/17] Add support for resinzing the custom app popup --- src/customAppSdk.ts | 49 ++++++++++++++++++++++++++++++++++++ src/iframeSchema.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 4 +-- 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/customAppSdk.ts b/src/customAppSdk.ts index 3d0cf62..c17a09d 100644 --- a/src/customAppSdk.ts +++ b/src/customAppSdk.ts @@ -125,3 +125,52 @@ export const getPageContext = (): Promise => reject(error); } }); + +export type SetPopupSizeResult = + | { + readonly isError: false; + readonly success: boolean; + } + | { + readonly isError: true; + readonly code: ErrorCode; + readonly description: string; + }; + +export type PopupSizeDimension = + | { + readonly unit: "px"; + readonly value: number; + } + | { + readonly unit: "%"; + readonly value: number; + }; + +export const setPopupSize = ( + width: PopupSizeDimension, + height: PopupSizeDimension, +): Promise => + new Promise((resolve, reject) => { + try { + sendMessage<"set-popup-size@1.0.0">( + { + type: "set-popup-size-request", + version: "1.0.0", + payload: { + width, + height, + }, + }, + (response) => { + if (matchesSchema(ErrorMessage, response)) { + resolve({ isError: true, code: response.code, description: response.description }); + } else { + resolve({ ...response.payload, isError: false }); + } + }, + ); + } catch (error) { + reject(error); + } + }); diff --git a/src/iframeSchema.ts b/src/iframeSchema.ts index 0c37015..a7b7f3e 100644 --- a/src/iframeSchema.ts +++ b/src/iframeSchema.ts @@ -164,9 +164,65 @@ const ClientGetPageContextV1Response = z .or(ErrorMessage) .readonly(); +const ClientSetPopupSizeV1Request = z + .object({ + type: z.literal("set-popup-size-request"), + requestId: z.string().uuid(), + version: z.literal("1.0.0"), + payload: z + .object({ + width: z.union([ + z + .object({ + unit: z.literal("px"), + value: z.number().min(200).max(2000), + }) + .readonly(), + z + .object({ + unit: z.literal("%"), + value: z.number().min(10).max(100), + }) + .readonly(), + ]), + height: z.union([ + z + .object({ + unit: z.literal("px"), + value: z.number().min(150).max(1500), + }) + .readonly(), + z + .object({ + unit: z.literal("%"), + value: z.number().min(10).max(100), + }) + .readonly(), + ]), + }) + .readonly(), + }) + .readonly(); + +const ClientSetPopupSizeV1Response = z + .object({ + type: z.literal("set-popup-size-response"), + isError: z.boolean(), + payload: z + .object({ + success: z.boolean(), + }) + .readonly(), + requestId: z.string().uuid(), + version: z.literal("1.0.0"), + }) + .or(ErrorMessage) + .readonly(); + export const AllClientRequestMessages = z.union([ ClientGetContextV1Request, ClientGetPageContextV1Request, + ClientSetPopupSizeV1Request, ]); export type Schema = { @@ -179,5 +235,9 @@ export type Schema = { request: z.infer; response: z.infer; }; + "set-popup-size@1.0.0": { + request: z.infer; + response: z.infer; + }; }; }; diff --git a/src/index.ts b/src/index.ts index 7b6d18b..281c2bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ -export { getCustomAppContext, getPageContext } from "./customAppSdk"; -export { CustomAppContext, PageContext } from "./customAppSdk"; +export { getCustomAppContext, getPageContext, setPopupSize } from "./customAppSdk"; +export { CustomAppContext, PageContext, SetPopupSizeResult, PopupSizeDimension } from "./customAppSdk"; From 8f39b3198dc999ed7da779150663320ad88e9d06 Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Tue, 16 Sep 2025 19:33:48 +0200 Subject: [PATCH 03/17] Adjust get page context response shape --- src/customAppSdk.ts | 69 +++++++++++++------------------------ src/iframeSchema.ts | 83 ++++++++++----------------------------------- src/index.ts | 7 +++- 3 files changed, 46 insertions(+), 113 deletions(-) diff --git a/src/customAppSdk.ts b/src/customAppSdk.ts index c17a09d..b17f0fd 100644 --- a/src/customAppSdk.ts +++ b/src/customAppSdk.ts @@ -51,52 +51,29 @@ export const getCustomAppContext = (): Promise => export type PageContext = | { readonly isError: false; - readonly pageContext: { - readonly route: { - readonly path: string; - readonly params: Readonly>; - readonly query: Readonly>; - }; - readonly page: { - readonly title: string; - readonly breadcrumbs: ReadonlyArray<{ - readonly label: string; - readonly path?: string; - }>; - }; - } & ( - | { - readonly pageType: "item-editor"; - readonly contentItem: { - readonly id: string; - readonly codename: string; - readonly name: string; - readonly type: { - readonly id: string; - readonly codename: string; - }; - readonly language: { - readonly id: string; - readonly codename: string; - }; - readonly workflowStep?: { - readonly id: string; - readonly codename: string; - }; - }; - readonly editor: { - readonly validationErrors: Readonly>>; - readonly elements: ReadonlyArray<{ - readonly id: string; - readonly type: string; - readonly value: string; - }>; - }; - } - | { - readonly pageType: "other"; - } - ) | null; + readonly pageContext: + | ( + | { + readonly path: string; + readonly pageTitle: string; + readonly pageType: "item-editor"; + readonly contentItem: { + readonly id: string; + readonly codename: string; + readonly language: { + readonly id: string; + readonly codename: string; + }; + }; + readonly validationErrors: Readonly>>; + } + | { + readonly path: string; + readonly pageTitle: string; + readonly pageType: "other"; + } + ) + | null; } | { readonly isError: true; diff --git a/src/iframeSchema.ts b/src/iframeSchema.ts index a7b7f3e..b0a93d8 100644 --- a/src/iframeSchema.ts +++ b/src/iframeSchema.ts @@ -54,7 +54,7 @@ const ClientGetContextV1Response = z .or(ErrorMessage) .readonly(); -const ClientGetPageContextV1Request = z +const ClientGetCustomAppPageContextV1Request = z .object({ type: z.literal("get-page-context-request"), requestId: z.string().uuid(), @@ -63,99 +63,50 @@ const ClientGetPageContextV1Request = z }) .readonly(); -const basePageContextProperties = { - route: z - .object({ - path: z.string(), - params: z.record(z.string()), - query: z.record(z.string()), - }) - .readonly(), - page: z - .object({ - title: z.string(), - breadcrumbs: z - .array( - z - .object({ - label: z.string(), - path: z.string().optional(), - }) - .readonly() - ) - .readonly(), - }) - .readonly(), +const baseCustomAppPageContextProperties = { + path: z.string(), + pageTitle: z.string(), }; -const ItemEditorPageContextSchema = z +const ItemEditorCustomAppPageContextSchema = z .object({ - ...basePageContextProperties, + ...baseCustomAppPageContextProperties, pageType: z.literal("item-editor"), contentItem: z .object({ id: z.string().uuid(), codename: z.string(), - name: z.string(), - type: z - .object({ - id: z.string().uuid(), - codename: z.string(), - }) - .readonly(), language: z .object({ id: z.string().uuid(), codename: z.string(), }) .readonly(), - workflowStep: z - .object({ - id: z.string().uuid(), - codename: z.string(), - }) - .readonly() - .optional(), - }) - .readonly(), - editor: z - .object({ - validationErrors: z.record(z.string(), z.array(z.string()).readonly()).readonly(), - elements: z - .array( - z - .object({ - id: z.string(), - type: z.string(), - value: z.string(), - }) - .readonly() - ) - .readonly(), }) .readonly(), + validationErrors: z.record(z.string(), z.array(z.string()).readonly()).readonly(), }) .readonly(); -const OtherPageContextSchema = z +const CustomAppOtherPageContextSchema = z .object({ - ...basePageContextProperties, + ...baseCustomAppPageContextProperties, pageType: z.literal("other"), }) .readonly(); -const PageContextSchema = z.union([ - ItemEditorPageContextSchema, - OtherPageContextSchema, +const CustomAppPageContextSchema = z.union([ + ItemEditorCustomAppPageContextSchema, + CustomAppOtherPageContextSchema, ]); -const ClientGetPageContextV1Response = z +const ClientGetCustomAppPageContextV1Response = z .object({ type: z.literal("get-page-context-response"), isError: z.boolean(), payload: z .object({ - pageContext: PageContextSchema.nullable(), + pageContext: CustomAppPageContextSchema.nullable(), }) .readonly(), requestId: z.string().uuid(), @@ -221,7 +172,7 @@ const ClientSetPopupSizeV1Response = z export const AllClientRequestMessages = z.union([ ClientGetContextV1Request, - ClientGetPageContextV1Request, + ClientGetCustomAppPageContextV1Request, ClientSetPopupSizeV1Request, ]); @@ -232,8 +183,8 @@ export type Schema = { response: z.infer; }; "get-page-context@1.0.0": { - request: z.infer; - response: z.infer; + request: z.infer; + response: z.infer; }; "set-popup-size@1.0.0": { request: z.infer; diff --git a/src/index.ts b/src/index.ts index 281c2bc..a8b6941 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,7 @@ export { getCustomAppContext, getPageContext, setPopupSize } from "./customAppSdk"; -export { CustomAppContext, PageContext, SetPopupSizeResult, PopupSizeDimension } from "./customAppSdk"; +export { + CustomAppContext, + PageContext, + SetPopupSizeResult, + PopupSizeDimension, +} from "./customAppSdk"; From 76aa38f6d6be9fe666179d2b2f9d54dd65dc43d9 Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Mon, 29 Sep 2025 13:23:41 +0200 Subject: [PATCH 04/17] Adjust custom app page schema to the new format --- package-lock.json | 89 +++++++++++++++++++-------------------- package.json | 6 +-- src/customAppSdk.ts | 43 +++++++------------ src/iframeSchema.ts | 70 +++++++++++++++--------------- src/index.ts | 3 +- src/types/object.fix.d.ts | 32 ++++++++++++++ 6 files changed, 131 insertions(+), 112 deletions(-) create mode 100644 src/types/object.fix.d.ts diff --git a/package-lock.json b/package-lock.json index 986fc5c..4d1e42f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,20 +9,19 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "zod": "^3.23.8" + "zod": "^4.1.11" }, "devDependencies": { - "@biomejs/biome": "^1.9.4", + "@biomejs/biome": "^2.2.4", "@types/node": "^22.9.0", - "typescript": "^5.6.3" + "typescript": "^5.9.2" } }, "node_modules/@biomejs/biome": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", - "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.4.tgz", + "integrity": "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==", "dev": true, - "hasInstallScript": true, "license": "MIT OR Apache-2.0", "bin": { "biome": "bin/biome" @@ -35,20 +34,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "1.9.4", - "@biomejs/cli-darwin-x64": "1.9.4", - "@biomejs/cli-linux-arm64": "1.9.4", - "@biomejs/cli-linux-arm64-musl": "1.9.4", - "@biomejs/cli-linux-x64": "1.9.4", - "@biomejs/cli-linux-x64-musl": "1.9.4", - "@biomejs/cli-win32-arm64": "1.9.4", - "@biomejs/cli-win32-x64": "1.9.4" + "@biomejs/cli-darwin-arm64": "2.2.4", + "@biomejs/cli-darwin-x64": "2.2.4", + "@biomejs/cli-linux-arm64": "2.2.4", + "@biomejs/cli-linux-arm64-musl": "2.2.4", + "@biomejs/cli-linux-x64": "2.2.4", + "@biomejs/cli-linux-x64-musl": "2.2.4", + "@biomejs/cli-win32-arm64": "2.2.4", + "@biomejs/cli-win32-x64": "2.2.4" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", - "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.4.tgz", + "integrity": "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==", "cpu": [ "arm64" ], @@ -63,9 +62,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", - "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.4.tgz", + "integrity": "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==", "cpu": [ "x64" ], @@ -80,9 +79,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", - "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.4.tgz", + "integrity": "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==", "cpu": [ "arm64" ], @@ -97,9 +96,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", - "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.4.tgz", + "integrity": "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==", "cpu": [ "arm64" ], @@ -114,9 +113,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", - "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.4.tgz", + "integrity": "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==", "cpu": [ "x64" ], @@ -131,9 +130,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", - "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.4.tgz", + "integrity": "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==", "cpu": [ "x64" ], @@ -148,9 +147,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", - "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.4.tgz", + "integrity": "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==", "cpu": [ "arm64" ], @@ -165,9 +164,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", - "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.4.tgz", + "integrity": "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==", "cpu": [ "x64" ], @@ -192,9 +191,9 @@ } }, "node_modules/typescript": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", - "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", "bin": { @@ -213,9 +212,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index edfdec3..1424307 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,11 @@ "format": "npx @biomejs/biome check --write ./src" }, "dependencies": { - "zod": "^3.23.8" + "zod": "^4.1.11" }, "devDependencies": { - "@biomejs/biome": "^1.9.4", + "@biomejs/biome": "^2.2.4", "@types/node": "^22.9.0", - "typescript": "^5.6.3" + "typescript": "^5.9.2" } } diff --git a/src/customAppSdk.ts b/src/customAppSdk.ts index b17f0fd..a0bc3a6 100644 --- a/src/customAppSdk.ts +++ b/src/customAppSdk.ts @@ -1,5 +1,5 @@ import { sendMessage } from "./iframeMessenger"; -import { ErrorMessage } from "./iframeSchema"; +import { type CustomAppPageContextProperties, ErrorMessage } from "./iframeSchema"; import { matchesSchema } from "./matchesSchema"; export enum ErrorCode { @@ -48,32 +48,10 @@ export const getCustomAppContext = (): Promise => } }); -export type PageContext = +export type PageContextResult> = | { readonly isError: false; - readonly pageContext: - | ( - | { - readonly path: string; - readonly pageTitle: string; - readonly pageType: "item-editor"; - readonly contentItem: { - readonly id: string; - readonly codename: string; - readonly language: { - readonly id: string; - readonly codename: string; - }; - }; - readonly validationErrors: Readonly>>; - } - | { - readonly path: string; - readonly pageTitle: string; - readonly pageType: "other"; - } - ) - | null; + readonly properties: { [K in T[number]]: CustomAppPageContextProperties[K] }; } | { readonly isError: true; @@ -81,20 +59,29 @@ export type PageContext = readonly description: string; }; -export const getPageContext = (): Promise => +export const getPageContext = >( + properties: T, +): Promise> => new Promise((resolve, reject) => { try { sendMessage<"get-page-context@1.0.0">( { type: "get-page-context-request", version: "1.0.0", - payload: null, + payload: { + properties: properties as T, + }, }, (response) => { if (matchesSchema(ErrorMessage, response)) { resolve({ isError: true, code: response.code, description: response.description }); } else { - resolve({ ...response.payload, isError: false }); + resolve({ + isError: false, + properties: response.payload.properties as { + [K in T[number]]: CustomAppPageContextProperties[K]; + }, + }); } }, ); diff --git a/src/iframeSchema.ts b/src/iframeSchema.ts index b0a93d8..60bed6e 100644 --- a/src/iframeSchema.ts +++ b/src/iframeSchema.ts @@ -54,59 +54,59 @@ const ClientGetContextV1Response = z .or(ErrorMessage) .readonly(); -const ClientGetCustomAppPageContextV1Request = z +export type CustomAppPageContextProperties = { + readonly projectEnvironmentId?: string; + readonly contentItemId?: string; + readonly languageId?: string; + readonly path?: string; + readonly pageTitle?: string; + readonly validationErrors?: Readonly>>; +}; + +export const allCustomAppPageContextPropertyKeys = Object.keys({ + projectEnvironmentId: "", + contentItemId: "", + languageId: "", + path: "", + pageTitle: "", + validationErrors: "", +} as const satisfies Record); + +export const ClientGetCustomAppPageContextV1Request = z .object({ type: z.literal("get-page-context-request"), requestId: z.string().uuid(), version: z.literal("1.0.0"), - payload: z.null(), - }) - .readonly(); - -const baseCustomAppPageContextProperties = { - path: z.string(), - pageTitle: z.string(), -}; - -const ItemEditorCustomAppPageContextSchema = z - .object({ - ...baseCustomAppPageContextProperties, - pageType: z.literal("item-editor"), - contentItem: z + payload: z .object({ - id: z.string().uuid(), - codename: z.string(), - language: z - .object({ - id: z.string().uuid(), - codename: z.string(), - }) - .readonly(), + properties: z.array(z.enum(allCustomAppPageContextPropertyKeys)).readonly(), }) .readonly(), - validationErrors: z.record(z.string(), z.array(z.string()).readonly()).readonly(), }) .readonly(); -const CustomAppOtherPageContextSchema = z +const CustomAppPageContextPropertiesSchema = z .object({ - ...baseCustomAppPageContextProperties, - pageType: z.literal("other"), - }) + projectEnvironmentId: z.string().optional(), + contentItemId: z.string().uuid().optional(), + languageId: z.string().uuid().optional(), + path: z.string().optional(), + pageTitle: z.string().optional(), + validationErrors: z.record(z.string(), z.array(z.string()).readonly()).readonly().optional(), + } as const satisfies Required<{ + readonly [K in keyof CustomAppPageContextProperties]: z.ZodType< + CustomAppPageContextProperties[K] + >; + }>) .readonly(); -const CustomAppPageContextSchema = z.union([ - ItemEditorCustomAppPageContextSchema, - CustomAppOtherPageContextSchema, -]); - -const ClientGetCustomAppPageContextV1Response = z +export const ClientGetCustomAppPageContextV1Response = z .object({ type: z.literal("get-page-context-response"), isError: z.boolean(), payload: z .object({ - pageContext: CustomAppPageContextSchema.nullable(), + properties: CustomAppPageContextPropertiesSchema, }) .readonly(), requestId: z.string().uuid(), diff --git a/src/index.ts b/src/index.ts index a8b6941..1522e56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ export { getCustomAppContext, getPageContext, setPopupSize } from "./customAppSdk"; export { CustomAppContext, - PageContext, + PageContextResult, SetPopupSizeResult, PopupSizeDimension, } from "./customAppSdk"; +export { CustomAppPageContextProperties } from "./iframeSchema"; diff --git a/src/types/object.fix.d.ts b/src/types/object.fix.d.ts new file mode 100644 index 0000000..0cf148a --- /dev/null +++ b/src/types/object.fix.d.ts @@ -0,0 +1,32 @@ +type FindValueByKeyInEntries< + SearchedKey, + // biome-ignore lint/suspicious/noExplicitAny: TypeScript utility types require any for flexibility + TEntries extends ReadonlyArray, +> = TEntries[number] extends infer EntriesUnion + ? EntriesUnion extends readonly [infer Key extends SearchedKey, infer Value] + ? SearchedKey extends Key + ? Value + : never + : never + : never; + +// biome-ignore lint/suspicious/noExplicitAny: TypeScript utility types require any for flexibility +type Entries = TObject extends any + ? { [TKey in keyof TObject]: readonly [TKey, TObject[TKey]] }[keyof TObject] + : never; + +interface ObjectConstructor { + // https://github.com/microsoft/TypeScript/issues/35745 + // biome-ignore lint/suspicious/noExplicitAny: TypeScript utility types require any for flexibility + entries>>( + o: TObject, + ): ReadonlyArray, undefined>>; + fromEntries( + entries: Iterable, + ): ReadonlyRecord; + // biome-ignore lint/suspicious/noExplicitAny: TypeScript utility types require any for flexibility + fromEntries>( + entries: TEntries, + ): Readonly<{ [k in TEntries[number][0]]: FindValueByKeyInEntries }>; + keys(o: Readonly>): ReadonlyArray; +} From eeb43174ea8d14936b5c127e4bbe314ba3f3a6a4 Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Mon, 29 Sep 2025 13:33:28 +0200 Subject: [PATCH 05/17] Document getPageContext in readme --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/README.md b/README.md index 562eb45..06d8bc0 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,59 @@ The `context` object contains data provided by the Kontent.ai application that y | `id` | UUID | The role's ID | | `codename` | string | The role's codename - applicable only for the _Project manager_ role | +### getPageContext + +Use the `getPageContext` function to retrieve contextual information about the current page within the Kontent.ai application. The function takes an array of property names as an argument and returns a promise with a value of an object of type `PageContextResult`. + +#### Parameters + +| Parameter | Type | Description | +|--------------|----------------------------------------------|------------------------------------------------------| +| `properties` | Array | An array of property names to retrieve from the page context | + +#### PageContextResult + +| Property | Type | Description | +|---------------|------------------------|------------------------------------------------------------------------------| +| `isError` | boolean | Determines if there was an error while getting the page context | +| `code` | ErrorCode enum \| null | The code of the error message | +| `description` | string \| null | The description of the error message | +| `properties` | object \| null | Contains the requested page context properties | + +#### CustomAppPageContextProperties + +The following properties can be requested through the `getPageContext` function: + +| Property | Type | Description | +|-------------------------|---------------------------------------|----------------------------------------------------------------------| +| `projectEnvironmentId` | string \| undefined | The current environment's ID | +| `contentItemId` | string \| undefined | The ID of the content item being viewed or edited | +| `languageId` | string \| undefined | The ID of the current language | +| `path` | string \| undefined | The current path within the Kontent.ai application | +| `pageTitle` | string \| undefined | The title of the current page | +| `validationErrors` | Record \| undefined | A record of validation errors for content item fields | + +#### Usage Example + +```typescript +import { getPageContext, PageContextResult, CustomAppPageContextProperties } from "@kontent-ai/custom-app-sdk"; + +// Request specific properties +const response: PageContextResult<["contentItemId", "languageId"]> = await getPageContext([ + "contentItemId", + "languageId" +]); + +if (response.isError) { + console.error({ errorCode: response.code, description: response.description }); +} else { + console.log({ + contentItemId: response.properties.contentItemId, + languageId: response.properties.languageId + }); +} +``` + ## Contributing For Contributing please see `CONTRIBUTING.md` for more information. From c5ec86979acb4e11ff4ad04e59180fd32cca9845 Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Mon, 29 Sep 2025 14:24:19 +0200 Subject: [PATCH 06/17] Formulate better responses in readme --- README.md | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 06d8bc0..63b5057 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,23 @@ Use the `getCustomAppContext` function to retrieve the context of the custom app #### CustomAppContext +`CustomAppContext` is a discriminated union type that can be in one of two states: + +##### Success Response (`isError: false`) + +| Property | Type | Description | +|---------------|------------------------|------------------------------------------------------------------------------| +| `isError` | `false` | Indicates the request was successful | +| `context` | object | Contains data provided by the Kontent.ai application | +| `config` | unknown \| undefined | Contains JSON object specified in the custom app configuration | + +##### Error Response (`isError: true`) + | Property | Type | Description | |---------------|------------------------|------------------------------------------------------------------------------| -| `isError` | boolean | Determines if there was an error while getting the context of the custom app | -| `code` | ErrorCode enum \| null | The code of the error message | -| `description` | string \| null | The description of the error message | -| `context` | object \| null | Contains data provided by the Kontent.ai application | -| `config` | object \| null | Contains JSON object specified in the custom app configuration | +| `isError` | `true` | Indicates an error occurred | +| `code` | ErrorCode enum | The code of the error message | +| `description` | string | The description of the error message | #### Config The `config` object is a JSON object that can be defined within the Custom App configuration under Environment settings in the Kontent.ai app. @@ -86,12 +96,22 @@ Use the `getPageContext` function to retrieve contextual information about the c #### PageContextResult +`PageContextResult` is a discriminated union type that can be in one of two states: + +##### Success Response (`isError: false`) + +| Property | Type | Description | +|---------------|------------------------|------------------------------------------------------------------------------| +| `isError` | `false` | Indicates the request was successful | +| `properties` | object | Contains the requested page context properties | + +##### Error Response (`isError: true`) + | Property | Type | Description | |---------------|------------------------|------------------------------------------------------------------------------| -| `isError` | boolean | Determines if there was an error while getting the page context | -| `code` | ErrorCode enum \| null | The code of the error message | -| `description` | string \| null | The description of the error message | -| `properties` | object \| null | Contains the requested page context properties | +| `isError` | `true` | Indicates an error occurred | +| `code` | ErrorCode enum | The code of the error message | +| `description` | string | The description of the error message | #### CustomAppPageContextProperties From fccc8b38e4a87cada794e89134e6f6e2a0043f97 Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Mon, 29 Sep 2025 16:17:26 +0200 Subject: [PATCH 07/17] Remove projectEnvironmentId from page context --- README.md | 1 - src/iframeSchema.ts | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 63b5057..0c3ec46 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,6 @@ The following properties can be requested through the `getPageContext` function: | Property | Type | Description | |-------------------------|---------------------------------------|----------------------------------------------------------------------| -| `projectEnvironmentId` | string \| undefined | The current environment's ID | | `contentItemId` | string \| undefined | The ID of the content item being viewed or edited | | `languageId` | string \| undefined | The ID of the current language | | `path` | string \| undefined | The current path within the Kontent.ai application | diff --git a/src/iframeSchema.ts b/src/iframeSchema.ts index 60bed6e..153c836 100644 --- a/src/iframeSchema.ts +++ b/src/iframeSchema.ts @@ -55,21 +55,21 @@ const ClientGetContextV1Response = z .readonly(); export type CustomAppPageContextProperties = { - readonly projectEnvironmentId?: string; readonly contentItemId?: string; readonly languageId?: string; readonly path?: string; readonly pageTitle?: string; readonly validationErrors?: Readonly>>; + readonly currentPage: "itemEditor" | "other"; }; export const allCustomAppPageContextPropertyKeys = Object.keys({ - projectEnvironmentId: "", contentItemId: "", languageId: "", path: "", pageTitle: "", validationErrors: "", + currentPage: "", } as const satisfies Record); export const ClientGetCustomAppPageContextV1Request = z @@ -87,12 +87,12 @@ export const ClientGetCustomAppPageContextV1Request = z const CustomAppPageContextPropertiesSchema = z .object({ - projectEnvironmentId: z.string().optional(), contentItemId: z.string().uuid().optional(), languageId: z.string().uuid().optional(), path: z.string().optional(), pageTitle: z.string().optional(), validationErrors: z.record(z.string(), z.array(z.string()).readonly()).readonly().optional(), + currentPage: z.union([z.literal('itemEditor'), z.literal('other')]), } as const satisfies Required<{ readonly [K in keyof CustomAppPageContextProperties]: z.ZodType< CustomAppPageContextProperties[K] From 66ce9b46d39572135e9068eaa984bccb87ff1f61 Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Mon, 29 Sep 2025 20:50:35 +0200 Subject: [PATCH 08/17] Change getPageContext to return predefined objects for each page --- README.md | 67 ++++++++++++------- src/customAppSdk.ts | 153 ++++++++++++++++++++++++-------------------- src/index.ts | 1 + src/pageContexts.ts | 39 +++++++++++ 4 files changed, 168 insertions(+), 92 deletions(-) create mode 100644 src/pageContexts.ts diff --git a/README.md b/README.md index 0c3ec46..dcf8b6b 100644 --- a/README.md +++ b/README.md @@ -86,24 +86,22 @@ The `context` object contains data provided by the Kontent.ai application that y ### getPageContext -Use the `getPageContext` function to retrieve contextual information about the current page within the Kontent.ai application. The function takes an array of property names as an argument and returns a promise with a value of an object of type `PageContextResult`. +Use the `getPageContext` function to retrieve contextual information about the current page within the Kontent.ai application. The function automatically detects the current page type and returns the appropriate context with all relevant properties for that page type. #### Parameters -| Parameter | Type | Description | -|--------------|----------------------------------------------|------------------------------------------------------| -| `properties` | Array | An array of property names to retrieve from the page context | +None - the function takes no parameters and automatically determines what properties to fetch based on the current page. -#### PageContextResult +#### Return Type -`PageContextResult` is a discriminated union type that can be in one of two states: +The function returns a promise that resolves to a discriminated union type with two possible states: ##### Success Response (`isError: false`) | Property | Type | Description | |---------------|------------------------|------------------------------------------------------------------------------| | `isError` | `false` | Indicates the request was successful | -| `properties` | object | Contains the requested page context properties | +| `context` | `PageContext` | A discriminated union of page-specific context objects | ##### Error Response (`isError: true`) @@ -113,36 +111,57 @@ Use the `getPageContext` function to retrieve contextual information about the c | `code` | ErrorCode enum | The code of the error message | | `description` | string | The description of the error message | -#### CustomAppPageContextProperties +#### PageContext -The following properties can be requested through the `getPageContext` function: +`PageContext` is a discriminated union type based on the `currentPage` property. Each page type includes only the relevant properties for that specific page: + +##### Item Editor Page Context + +When `currentPage` is `"itemEditor"`, the context includes: + +| Property | Type | Description | +|-------------------------|---------------------------------------|----------------------------------------------------------------------| +| `currentPage` | `"itemEditor"` | Identifies this as an item editor page | +| `contentItemId` | UUID | The ID of the content item being edited | +| `languageId` | UUID | The ID of the current language | +| `path` | string | The current path within the Kontent.ai application | +| `pageTitle` | string | The title of the current page | +| `validationErrors` | Record | A record of validation errors for content item fields | + +##### Other Page Context + +When `currentPage` is `"other"`, the context includes: | Property | Type | Description | |-------------------------|---------------------------------------|----------------------------------------------------------------------| -| `contentItemId` | string \| undefined | The ID of the content item being viewed or edited | -| `languageId` | string \| undefined | The ID of the current language | -| `path` | string \| undefined | The current path within the Kontent.ai application | -| `pageTitle` | string \| undefined | The title of the current page | -| `validationErrors` | Record \| undefined | A record of validation errors for content item fields | +| `currentPage` | `"other"` | Identifies this as any other page type | +| `path` | string | The current path within the Kontent.ai application | +| `pageTitle` | string | The title of the current page | #### Usage Example ```typescript -import { getPageContext, PageContextResult, CustomAppPageContextProperties } from "@kontent-ai/custom-app-sdk"; +import { getPageContext, PageContext } from "@kontent-ai/custom-app-sdk"; -// Request specific properties -const response: PageContextResult<["contentItemId", "languageId"]> = await getPageContext([ - "contentItemId", - "languageId" -]); +// Get page context - automatically fetches appropriate properties based on current page +const response = await getPageContext(); if (response.isError) { console.error({ errorCode: response.code, description: response.description }); } else { - console.log({ - contentItemId: response.properties.contentItemId, - languageId: response.properties.languageId - }); + // TypeScript will narrow the type based on currentPage + if (response.context.currentPage === "itemEditor") { + console.log({ + contentItemId: response.context.contentItemId, + languageId: response.context.languageId, + validationErrors: response.context.validationErrors + }); + } else { + console.log({ + path: response.context.path, + pageTitle: response.context.pageTitle + }); + } } ``` diff --git a/src/customAppSdk.ts b/src/customAppSdk.ts index a0bc3a6..87b551a 100644 --- a/src/customAppSdk.ts +++ b/src/customAppSdk.ts @@ -1,9 +1,12 @@ import { sendMessage } from "./iframeMessenger"; import { type CustomAppPageContextProperties, ErrorMessage } from "./iframeSchema"; import { matchesSchema } from "./matchesSchema"; +import { type PageContext, isItemEditorPageContext, isOtherPageContext, itemEditorPageProperties, otherPageProperties } from "./pageContexts"; +import type { Schema } from "./iframeSchema"; export enum ErrorCode { UnknownMessage = "unknown-message", + OutdatedPageContext = "outdated-page-context", } export type CustomAppContext = @@ -26,27 +29,19 @@ export type CustomAppContext = readonly description: string; }; -export const getCustomAppContext = (): Promise => - new Promise((resolve, reject) => { - try { - sendMessage<"get-context@1.0.0">( - { - type: "get-context-request", - version: "1.0.0", - payload: null, - }, - (response) => { - if (matchesSchema(ErrorMessage, response)) { - resolve({ isError: true, code: response.code, description: response.description }); - } else { - resolve({ ...response.payload, isError: false }); - } - }, - ); - } catch (error) { - reject(error); - } +export const getCustomAppContext = async (): Promise => { + const response = await sendMessagePromise<"get-context@1.0.0">({ + type: "get-context-request", + version: "1.0.0", + payload: null, }); + + if (matchesSchema(ErrorMessage, response)) { + return { isError: true, code: response.code, description: response.description }; + } + + return { ...response.payload, isError: false }; +}; export type PageContextResult> = | { @@ -59,37 +54,54 @@ export type PageContextResult>( - properties: T, -): Promise> => - new Promise((resolve, reject) => { - try { - sendMessage<"get-page-context@1.0.0">( - { - type: "get-page-context-request", - version: "1.0.0", - payload: { - properties: properties as T, - }, - }, - (response) => { - if (matchesSchema(ErrorMessage, response)) { - resolve({ isError: true, code: response.code, description: response.description }); - } else { - resolve({ - isError: false, - properties: response.payload.properties as { - [K in T[number]]: CustomAppPageContextProperties[K]; - }, - }); - } - }, - ); - } catch (error) { - reject(error); - } +export const getPageContext = async (): Promise< + | { readonly isError: false; readonly context: PageContext } + | { readonly isError: true; readonly code: ErrorCode; readonly description: string } +> => { + // First, get current page to determine which properties to fetch + const currentPageResponse = await sendMessagePromise<"get-page-context@1.0.0">({ + type: "get-page-context-request", + version: "1.0.0", + payload: { + properties: ["currentPage"], + }, }); + if (matchesSchema(ErrorMessage, currentPageResponse)) { + return { isError: true, code: currentPageResponse.code, description: currentPageResponse.description }; + } + + const currentPage = currentPageResponse.payload.properties.currentPage; + const propertiesToFetch = currentPage === "itemEditor" ? itemEditorPageProperties : otherPageProperties; + + // Fetch all properties for the specific page type + const response = await sendMessagePromise<"get-page-context@1.0.0">({ + type: "get-page-context-request", + version: "1.0.0", + payload: { + properties: propertiesToFetch, + }, + }); + + if (matchesSchema(ErrorMessage, response)) { + return { isError: true, code: response.code, description: response.description }; + } + + switch (currentPage) { + case "itemEditor": { + return isItemEditorPageContext(response.payload.properties) + ? { isError: false, context: response.payload.properties } + : outdatedPageContextError; + } + case "other": + return isOtherPageContext(response.payload.properties) + ? { isError: false, context: response.payload.properties } + : outdatedPageContextError; + default: + return outdatedPageContextError; + } +}; + export type SetPopupSizeResult = | { readonly isError: false; @@ -111,30 +123,35 @@ export type PopupSizeDimension = readonly value: number; }; -export const setPopupSize = ( +export const setPopupSize = async ( width: PopupSizeDimension, height: PopupSizeDimension, -): Promise => +): Promise => { + const response = await sendMessagePromise<"set-popup-size@1.0.0">({ + type: "set-popup-size-request", + version: "1.0.0", + payload: { + width, + height, + }, + }); + + if (matchesSchema(ErrorMessage, response)) { + return { isError: true, code: response.code, description: response.description }; + } + + return { isError: false, success: response.payload.success }; +}; + +const sendMessagePromise = ( + message: Omit, +): Promise => new Promise((resolve, reject) => { try { - sendMessage<"set-popup-size@1.0.0">( - { - type: "set-popup-size-request", - version: "1.0.0", - payload: { - width, - height, - }, - }, - (response) => { - if (matchesSchema(ErrorMessage, response)) { - resolve({ isError: true, code: response.code, description: response.description }); - } else { - resolve({ ...response.payload, isError: false }); - } - }, - ); + sendMessage(message, resolve); } catch (error) { reject(error); } }); + +const outdatedPageContextError = { isError: true, code: ErrorCode.OutdatedPageContext, description: "The page context we received is outdated, please try to get the page context again." } as const; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 1522e56..79fe81d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,3 +6,4 @@ export { PopupSizeDimension, } from "./customAppSdk"; export { CustomAppPageContextProperties } from "./iframeSchema"; +export { PageContext } from "./pageContexts"; diff --git a/src/pageContexts.ts b/src/pageContexts.ts new file mode 100644 index 0000000..29c306f --- /dev/null +++ b/src/pageContexts.ts @@ -0,0 +1,39 @@ +import { type CustomAppPageContextProperties } from "./iframeSchema"; + +const sharedPageProperties = [ + "path", + "pageTitle", +] as const satisfies ReadonlyArray; + +export const itemEditorPageProperties = [ + ...sharedPageProperties, + "contentItemId", + "languageId", + "validationErrors", + "currentPage" +] as const satisfies ReadonlyArray; + +type RawItemEditorPageContext = Pick; + +type ItemEditorPageContext = Required, typeof itemEditorPageProperties[number]>>; + +export const isItemEditorPageContext = (context: RawItemEditorPageContext): context is ItemEditorPageContext => { + return context.currentPage === "itemEditor" && itemEditorPageProperties.every((property) => property in context && context[property] !== undefined); +}; + +export const otherPageProperties = [ + ...sharedPageProperties, + "currentPage" +] as const satisfies ReadonlyArray; + +type RawOtherPageContext = Pick; + +type OtherPageContext = Required, typeof otherPageProperties[number]>>; + +export const isOtherPageContext = (context: RawOtherPageContext): context is OtherPageContext => { + return context.currentPage === "other" && otherPageProperties.every((property) => property in context && context[property] !== undefined); +}; + +export type PageContext = ItemEditorPageContext | OtherPageContext; + +type WithSpecificPage = T & { readonly currentPage: Page }; From 2ab6205dd9bf12ca5361794ca0c7b321085ba478 Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Thu, 2 Oct 2025 13:48:35 +0200 Subject: [PATCH 09/17] Add support for observerPageContext --- src/customAppSdk.ts | 122 +++++++++++++++++++++++++++++++++++------ src/iframeMessenger.ts | 38 +++++++++++-- src/iframeSchema.ts | 79 ++++++++++++++++++++++++++ src/index.ts | 4 +- src/utilityTypes.ts | 10 +++- tsconfig.json | 1 + 6 files changed, 230 insertions(+), 24 deletions(-) diff --git a/src/customAppSdk.ts b/src/customAppSdk.ts index 87b551a..1d4bf12 100644 --- a/src/customAppSdk.ts +++ b/src/customAppSdk.ts @@ -1,8 +1,9 @@ -import { sendMessage } from "./iframeMessenger"; -import { type CustomAppPageContextProperties, ErrorMessage } from "./iframeSchema"; +import { sendMessage, addNotificationCallback, removeNotificationCallback } from "./iframeMessenger"; +import { type CustomAppPageContextProperties, ErrorMessage, ClientPageContextChangedV1Notification } from "./iframeSchema"; import { matchesSchema } from "./matchesSchema"; import { type PageContext, isItemEditorPageContext, isOtherPageContext, itemEditorPageProperties, otherPageProperties } from "./pageContexts"; import type { Schema } from "./iframeSchema"; +import { z } from "zod"; export enum ErrorCode { UnknownMessage = "unknown-message", @@ -87,19 +88,7 @@ export const getPageContext = async (): Promise< return { isError: true, code: response.code, description: response.description }; } - switch (currentPage) { - case "itemEditor": { - return isItemEditorPageContext(response.payload.properties) - ? { isError: false, context: response.payload.properties } - : outdatedPageContextError; - } - case "other": - return isOtherPageContext(response.payload.properties) - ? { isError: false, context: response.payload.properties } - : outdatedPageContextError; - default: - return outdatedPageContextError; - } + return getPageContextFromProperties(response.payload.properties); }; export type SetPopupSizeResult = @@ -154,4 +143,105 @@ const sendMessagePromise = ( } }); -const outdatedPageContextError = { isError: true, code: ErrorCode.OutdatedPageContext, description: "The page context we received is outdated, please try to get the page context again." } as const; \ No newline at end of file +const outdatedPageContextError = { isError: true, code: ErrorCode.OutdatedPageContext, description: "The page context we received is outdated, please try to get the page context again." } as const; + +export type ObservePageContextCallback = (context: PageContext) => void; + +export type ObservePageContextResult = + | { + readonly isError: false; + readonly context: PageContext; + readonly unsubscribe: () => Promise; + } + | { + readonly isError: true; + readonly code: ErrorCode; + readonly description: string; + }; + +export const observePageContext = async ( + callback: ObservePageContextCallback +): Promise => { + // First, get current page to determine which properties to observe + const currentPageResponse = await sendMessagePromise<"get-page-context@1.0.0">({ + type: "get-page-context-request", + version: "1.0.0", + payload: { + properties: ["currentPage"], + }, + }); + + if (matchesSchema(ErrorMessage, currentPageResponse)) { + return { isError: true, code: currentPageResponse.code, description: currentPageResponse.description }; + } + + const currentPage = currentPageResponse.payload.properties.currentPage; + const propertiesToObserve = currentPage === "itemEditor" ? itemEditorPageProperties : otherPageProperties; + + // Start observing with the appropriate properties + const observeResponse = await sendMessagePromise<"observe-page-context@1.0.0">({ + type: "observe-page-context-request", + version: "1.0.0", + payload: { + properties: propertiesToObserve, + }, + }); + + if (matchesSchema(ErrorMessage, observeResponse)) { + return { isError: true, code: observeResponse.code, description: observeResponse.description }; + } + + // Process the initial context + const initialContext = getPageContextFromProperties(observeResponse.payload.properties); + if (initialContext.isError) { + return initialContext; + } + + // Set up notification handler + const notificationHandler = (notification: z.infer) => { + const contextResult = getPageContextFromProperties(notification.payload.properties); + if (!contextResult.isError) { + callback(contextResult.context); + } + }; + + addNotificationCallback(observeResponse.payload.subscriptionId, notificationHandler); + + // Create unsubscribe function + const unsubscribe = async (): Promise => { + removeNotificationCallback(observeResponse.payload.subscriptionId); + + await sendMessagePromise<"unsubscribe-page-context@1.0.0">({ + type: "unsubscribe-page-context-request", + version: "1.0.0", + payload: { + subscriptionId: observeResponse.payload.subscriptionId, + }, + }); + }; + + return { + isError: false, + context: initialContext.context, + unsubscribe, + }; +}; + +const getPageContextFromProperties = ( + properties: CustomAppPageContextProperties +): { isError: false; context: PageContext } | { isError: true; code: ErrorCode; description: string } => { + const currentPage = properties.currentPage; + + switch (currentPage) { + case "itemEditor": + return isItemEditorPageContext(properties) + ? { isError: false, context: properties } + : outdatedPageContextError; + case "other": + return isOtherPageContext(properties) + ? { isError: false, context: properties } + : outdatedPageContextError; + default: + return outdatedPageContextError; + } +}; \ No newline at end of file diff --git a/src/iframeMessenger.ts b/src/iframeMessenger.ts index d6962ea..4711913 100644 --- a/src/iframeMessenger.ts +++ b/src/iframeMessenger.ts @@ -1,8 +1,11 @@ import { createUuid } from "./createUuid"; import type { Schema } from "./iframeSchema"; -import type { AllClientResponses } from "./utilityTypes"; +import { ClientPageContextChangedV1Notification } from "./iframeSchema"; +import type { AllClientNotifications, AllClientResponses } from "./utilityTypes"; +import { z } from "zod"; let callbacks: Readonly void>> = {}; +let notificationCallbacks: Readonly void>> = {}; export const sendMessage = ( message: Omit, @@ -13,13 +16,38 @@ export const sendMessage = ( window.parent.postMessage({ ...message, requestId }, "*"); }; -const processMessage = (event: MessageEvent): void => { +export const addNotificationCallback = ( + subscriptionId: string, + callback: (notification: z.infer) => void, +): void => { + notificationCallbacks = { ...notificationCallbacks, [subscriptionId]: callback } as typeof notificationCallbacks; +}; + +export const removeNotificationCallback = ( + subscriptionId: string, +): void => { + notificationCallbacks = Object.fromEntries( + Object.entries(notificationCallbacks).filter(([subscriptionId]) => subscriptionId !== subscriptionId), + ); +}; + +const processMessage = (event: MessageEvent): void => { const message = event.data; - const callback = callbacks[message.requestId]; + + // Check if it's a notification + if ('subscriptionId' in message) { + const notification = message as AllClientNotifications; + notificationCallbacks[message.subscriptionId]?.(notification); + return; + } + + // Otherwise, it's a response + const response = message as AllClientResponses; + const callback = callbacks[response.requestId]; callbacks = Object.fromEntries( - Object.entries(callbacks).filter(([requestId]) => requestId !== message.requestId), + Object.entries(callbacks).filter(([requestId]) => requestId !== response.requestId), ); - callback?.(message); + callback?.(response); }; if (window.self === window.top) { diff --git a/src/iframeSchema.ts b/src/iframeSchema.ts index 153c836..6a416ae 100644 --- a/src/iframeSchema.ts +++ b/src/iframeSchema.ts @@ -170,10 +170,80 @@ const ClientSetPopupSizeV1Response = z .or(ErrorMessage) .readonly(); +export const ClientObservePageContextV1Request = z + .object({ + type: z.literal('observe-page-context-request'), + requestId: z.string().uuid(), + version: z.literal('1.0.0'), + payload: z + .object({ + properties: z.array(z.enum(allCustomAppPageContextPropertyKeys)).readonly(), + }) + .readonly(), + }) + .readonly(); + +export const ClientObservePageContextV1Response = z + .object({ + type: z.literal('observe-page-context-response'), + isError: z.boolean(), + payload: z + .object({ + properties: CustomAppPageContextPropertiesSchema.readonly(), + subscriptionId: z.string().uuid(), + }) + .readonly(), + requestId: z.string().uuid(), + version: z.literal('1.0.0'), + }) + .or(ErrorMessage) + .readonly(); + +export const ClientUnsubscribePageContextV1Request = z + .object({ + type: z.literal('unsubscribe-page-context-request'), + requestId: z.string().uuid(), + version: z.literal('1.0.0'), + payload: z.object({ + subscriptionId: z.string().uuid(), + }).readonly(), + }) + .readonly(); + +export const ClientUnsubscribePageContextV1Response = z + .object({ + type: z.literal('unsubscribe-page-context-response'), + isError: z.boolean(), + payload: z + .object({ + success: z.boolean(), + }) + .readonly(), + requestId: z.string().uuid(), + version: z.literal('1.0.0'), + }) + .or(ErrorMessage) + .readonly(); + +export const ClientPageContextChangedV1Notification = z + .object({ + type: z.literal('page-context-changed-notification'), + subscriptionId: z.string().uuid(), + version: z.literal('1.0.0'), + payload: z + .object({ + properties: CustomAppPageContextPropertiesSchema.readonly(), + }) + .readonly(), + }) + .readonly(); + export const AllClientRequestMessages = z.union([ ClientGetContextV1Request, ClientGetCustomAppPageContextV1Request, ClientSetPopupSizeV1Request, + ClientObservePageContextV1Request, + ClientUnsubscribePageContextV1Request, ]); export type Schema = { @@ -190,5 +260,14 @@ export type Schema = { request: z.infer; response: z.infer; }; + "observe-page-context@1.0.0": { + request: z.infer; + response: z.infer; + notification: z.infer; + }; + "unsubscribe-page-context@1.0.0": { + request: z.infer; + response: z.infer; + }; }; }; diff --git a/src/index.ts b/src/index.ts index 79fe81d..629a63e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,11 @@ -export { getCustomAppContext, getPageContext, setPopupSize } from "./customAppSdk"; +export { getCustomAppContext, getPageContext, setPopupSize, observePageContext } from "./customAppSdk"; export { CustomAppContext, PageContextResult, SetPopupSizeResult, PopupSizeDimension, + ObservePageContextCallback, + ObservePageContextResult, } from "./customAppSdk"; export { CustomAppPageContextProperties } from "./iframeSchema"; export { PageContext } from "./pageContexts"; diff --git a/src/utilityTypes.ts b/src/utilityTypes.ts index 4671c37..b3f5a3f 100644 --- a/src/utilityTypes.ts +++ b/src/utilityTypes.ts @@ -1,4 +1,10 @@ import type { Schema } from "./iframeSchema"; -export type AllClientResponses = Schema["client"][keyof Schema["client"]]["response"]; -export type AllClientRequests = Schema["client"][keyof Schema["client"]]["request"]; +export type AllClientResponses = AllClientMessages["response"]; +export type AllClientRequests = AllClientMessages["request"]; + +export type AllClientNotifications = ExtractNotification; + +type AllClientMessages = Schema["client"][keyof Schema["client"]]; + +type ExtractNotification = T extends { notification: infer N } ? N : never; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index f426ea1..70c4f46 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "alwaysStrict": true, "noUnusedParameters": false, "noUnusedLocals": true, + "noUncheckedIndexedAccess": true, "strictFunctionTypes": true, "noImplicitAny": true, "strictNullChecks": true, From 2df75b7de74e791aad7f4c50bc5772b496dbd2d6 Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Thu, 2 Oct 2025 16:05:33 +0200 Subject: [PATCH 10/17] Update biome configuration and fix errors --- biome.json | 14 ++------ src/customAppSdk.ts | 77 ++++++++++++++++++++++++++++++------------ src/iframeMessenger.ts | 22 ++++++------ src/iframeSchema.ts | 30 ++++++++-------- src/index.ts | 11 +++--- src/pageContexts.ts | 60 +++++++++++++++++++++++--------- src/utilityTypes.ts | 4 ++- 7 files changed, 139 insertions(+), 79 deletions(-) diff --git a/biome.json b/biome.json index e0dff08..69d8a82 100644 --- a/biome.json +++ b/biome.json @@ -1,8 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "organizeImports": { - "enabled": true - }, + "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", "formatter": { "indentStyle": "space", "lineWidth": 100 @@ -20,13 +17,8 @@ } }, "files": { - "ignore": [ - "**/*-lock.json", - "**/.DS_Store", - ".idea/**", - ".vscode/**", - ".history/**", - "node_modules/**" + "includes": [ + "src/**/*" ] } } diff --git a/src/customAppSdk.ts b/src/customAppSdk.ts index 1d4bf12..ca2e73f 100644 --- a/src/customAppSdk.ts +++ b/src/customAppSdk.ts @@ -1,9 +1,23 @@ -import { sendMessage, addNotificationCallback, removeNotificationCallback } from "./iframeMessenger"; -import { type CustomAppPageContextProperties, ErrorMessage, ClientPageContextChangedV1Notification } from "./iframeSchema"; -import { matchesSchema } from "./matchesSchema"; -import { type PageContext, isItemEditorPageContext, isOtherPageContext, itemEditorPageProperties, otherPageProperties } from "./pageContexts"; +import type { z } from "zod"; +import { + addNotificationCallback, + removeNotificationCallback, + sendMessage, +} from "./iframeMessenger"; import type { Schema } from "./iframeSchema"; -import { z } from "zod"; +import { + type ClientPageContextChangedV1Notification, + type CustomAppPageContextProperties, + ErrorMessage, +} from "./iframeSchema"; +import { matchesSchema } from "./matchesSchema"; +import { + isItemEditorPageContext, + isOtherPageContext, + itemEditorPageProperties, + otherPageProperties, + type PageContext, +} from "./pageContexts"; export enum ErrorCode { UnknownMessage = "unknown-message", @@ -36,10 +50,10 @@ export const getCustomAppContext = async (): Promise => { version: "1.0.0", payload: null, }); - + if (matchesSchema(ErrorMessage, response)) { return { isError: true, code: response.code, description: response.description }; - } + } return { ...response.payload, isError: false }; }; @@ -69,11 +83,16 @@ export const getPageContext = async (): Promise< }); if (matchesSchema(ErrorMessage, currentPageResponse)) { - return { isError: true, code: currentPageResponse.code, description: currentPageResponse.description }; + return { + isError: true, + code: currentPageResponse.code, + description: currentPageResponse.description, + }; } const currentPage = currentPageResponse.payload.properties.currentPage; - const propertiesToFetch = currentPage === "itemEditor" ? itemEditorPageProperties : otherPageProperties; + const propertiesToFetch = + currentPage === "itemEditor" ? itemEditorPageProperties : otherPageProperties; // Fetch all properties for the specific page type const response = await sendMessagePromise<"get-page-context@1.0.0">({ @@ -86,7 +105,7 @@ export const getPageContext = async (): Promise< if (matchesSchema(ErrorMessage, response)) { return { isError: true, code: response.code, description: response.description }; - } + } return getPageContextFromProperties(response.payload.properties); }; @@ -127,7 +146,7 @@ export const setPopupSize = async ( if (matchesSchema(ErrorMessage, response)) { return { isError: true, code: response.code, description: response.description }; - } + } return { isError: false, success: response.payload.success }; }; @@ -143,11 +162,16 @@ const sendMessagePromise = ( } }); -const outdatedPageContextError = { isError: true, code: ErrorCode.OutdatedPageContext, description: "The page context we received is outdated, please try to get the page context again." } as const; +const outdatedPageContextError = { + isError: true, + code: ErrorCode.OutdatedPageContext, + description: + "The page context we received is outdated, please try to get the page context again.", +} as const; export type ObservePageContextCallback = (context: PageContext) => void; -export type ObservePageContextResult = +export type ObservePageContextResult = | { readonly isError: false; readonly context: PageContext; @@ -160,7 +184,7 @@ export type ObservePageContextResult = }; export const observePageContext = async ( - callback: ObservePageContextCallback + callback: ObservePageContextCallback, ): Promise => { // First, get current page to determine which properties to observe const currentPageResponse = await sendMessagePromise<"get-page-context@1.0.0">({ @@ -172,11 +196,16 @@ export const observePageContext = async ( }); if (matchesSchema(ErrorMessage, currentPageResponse)) { - return { isError: true, code: currentPageResponse.code, description: currentPageResponse.description }; + return { + isError: true, + code: currentPageResponse.code, + description: currentPageResponse.description, + }; } const currentPage = currentPageResponse.payload.properties.currentPage; - const propertiesToObserve = currentPage === "itemEditor" ? itemEditorPageProperties : otherPageProperties; + const propertiesToObserve = + currentPage === "itemEditor" ? itemEditorPageProperties : otherPageProperties; // Start observing with the appropriate properties const observeResponse = await sendMessagePromise<"observe-page-context@1.0.0">({ @@ -198,7 +227,9 @@ export const observePageContext = async ( } // Set up notification handler - const notificationHandler = (notification: z.infer) => { + const notificationHandler = ( + notification: z.infer, + ) => { const contextResult = getPageContextFromProperties(notification.payload.properties); if (!contextResult.isError) { callback(contextResult.context); @@ -210,7 +241,7 @@ export const observePageContext = async ( // Create unsubscribe function const unsubscribe = async (): Promise => { removeNotificationCallback(observeResponse.payload.subscriptionId); - + await sendMessagePromise<"unsubscribe-page-context@1.0.0">({ type: "unsubscribe-page-context-request", version: "1.0.0", @@ -228,10 +259,12 @@ export const observePageContext = async ( }; const getPageContextFromProperties = ( - properties: CustomAppPageContextProperties -): { isError: false; context: PageContext } | { isError: true; code: ErrorCode; description: string } => { + properties: CustomAppPageContextProperties, +): + | { isError: false; context: PageContext } + | { isError: true; code: ErrorCode; description: string } => { const currentPage = properties.currentPage; - + switch (currentPage) { case "itemEditor": return isItemEditorPageContext(properties) @@ -244,4 +277,4 @@ const getPageContextFromProperties = ( default: return outdatedPageContextError; } -}; \ No newline at end of file +}; diff --git a/src/iframeMessenger.ts b/src/iframeMessenger.ts index 4711913..9c7bb94 100644 --- a/src/iframeMessenger.ts +++ b/src/iframeMessenger.ts @@ -1,8 +1,7 @@ +import type { z } from "zod"; import { createUuid } from "./createUuid"; -import type { Schema } from "./iframeSchema"; -import { ClientPageContextChangedV1Notification } from "./iframeSchema"; +import type { ClientPageContextChangedV1Notification, Schema } from "./iframeSchema"; import type { AllClientNotifications, AllClientResponses } from "./utilityTypes"; -import { z } from "zod"; let callbacks: Readonly void>> = {}; let notificationCallbacks: Readonly void>> = {}; @@ -20,27 +19,28 @@ export const addNotificationCallback = ( subscriptionId: string, callback: (notification: z.infer) => void, ): void => { - notificationCallbacks = { ...notificationCallbacks, [subscriptionId]: callback } as typeof notificationCallbacks; + notificationCallbacks = { + ...notificationCallbacks, + [subscriptionId]: callback, + } as typeof notificationCallbacks; }; -export const removeNotificationCallback = ( - subscriptionId: string, -): void => { +export const removeNotificationCallback = (subscriptionId: string): void => { notificationCallbacks = Object.fromEntries( - Object.entries(notificationCallbacks).filter(([subscriptionId]) => subscriptionId !== subscriptionId), + Object.entries(notificationCallbacks).filter(([subId]) => subId !== subscriptionId), ); }; const processMessage = (event: MessageEvent): void => { const message = event.data; - + // Check if it's a notification - if ('subscriptionId' in message) { + if ("subscriptionId" in message) { const notification = message as AllClientNotifications; notificationCallbacks[message.subscriptionId]?.(notification); return; } - + // Otherwise, it's a response const response = message as AllClientResponses; const callback = callbacks[response.requestId]; diff --git a/src/iframeSchema.ts b/src/iframeSchema.ts index 6a416ae..2788264 100644 --- a/src/iframeSchema.ts +++ b/src/iframeSchema.ts @@ -92,7 +92,7 @@ const CustomAppPageContextPropertiesSchema = z path: z.string().optional(), pageTitle: z.string().optional(), validationErrors: z.record(z.string(), z.array(z.string()).readonly()).readonly().optional(), - currentPage: z.union([z.literal('itemEditor'), z.literal('other')]), + currentPage: z.union([z.literal("itemEditor"), z.literal("other")]), } as const satisfies Required<{ readonly [K in keyof CustomAppPageContextProperties]: z.ZodType< CustomAppPageContextProperties[K] @@ -172,9 +172,9 @@ const ClientSetPopupSizeV1Response = z export const ClientObservePageContextV1Request = z .object({ - type: z.literal('observe-page-context-request'), + type: z.literal("observe-page-context-request"), requestId: z.string().uuid(), - version: z.literal('1.0.0'), + version: z.literal("1.0.0"), payload: z .object({ properties: z.array(z.enum(allCustomAppPageContextPropertyKeys)).readonly(), @@ -185,7 +185,7 @@ export const ClientObservePageContextV1Request = z export const ClientObservePageContextV1Response = z .object({ - type: z.literal('observe-page-context-response'), + type: z.literal("observe-page-context-response"), isError: z.boolean(), payload: z .object({ @@ -194,25 +194,27 @@ export const ClientObservePageContextV1Response = z }) .readonly(), requestId: z.string().uuid(), - version: z.literal('1.0.0'), + version: z.literal("1.0.0"), }) .or(ErrorMessage) .readonly(); export const ClientUnsubscribePageContextV1Request = z .object({ - type: z.literal('unsubscribe-page-context-request'), + type: z.literal("unsubscribe-page-context-request"), requestId: z.string().uuid(), - version: z.literal('1.0.0'), - payload: z.object({ - subscriptionId: z.string().uuid(), - }).readonly(), + version: z.literal("1.0.0"), + payload: z + .object({ + subscriptionId: z.string().uuid(), + }) + .readonly(), }) .readonly(); export const ClientUnsubscribePageContextV1Response = z .object({ - type: z.literal('unsubscribe-page-context-response'), + type: z.literal("unsubscribe-page-context-response"), isError: z.boolean(), payload: z .object({ @@ -220,16 +222,16 @@ export const ClientUnsubscribePageContextV1Response = z }) .readonly(), requestId: z.string().uuid(), - version: z.literal('1.0.0'), + version: z.literal("1.0.0"), }) .or(ErrorMessage) .readonly(); export const ClientPageContextChangedV1Notification = z .object({ - type: z.literal('page-context-changed-notification'), + type: z.literal("page-context-changed-notification"), subscriptionId: z.string().uuid(), - version: z.literal('1.0.0'), + version: z.literal("1.0.0"), payload: z .object({ properties: CustomAppPageContextPropertiesSchema.readonly(), diff --git a/src/index.ts b/src/index.ts index 629a63e..09e5fee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,14 @@ -export { getCustomAppContext, getPageContext, setPopupSize, observePageContext } from "./customAppSdk"; export { CustomAppContext, - PageContextResult, - SetPopupSizeResult, - PopupSizeDimension, + getCustomAppContext, + getPageContext, ObservePageContextCallback, ObservePageContextResult, + observePageContext, + PageContextResult, + PopupSizeDimension, + SetPopupSizeResult, + setPopupSize, } from "./customAppSdk"; export { CustomAppPageContextProperties } from "./iframeSchema"; export { PageContext } from "./pageContexts"; diff --git a/src/pageContexts.ts b/src/pageContexts.ts index 29c306f..dda693b 100644 --- a/src/pageContexts.ts +++ b/src/pageContexts.ts @@ -1,39 +1,67 @@ -import { type CustomAppPageContextProperties } from "./iframeSchema"; +import type { CustomAppPageContextProperties } from "./iframeSchema"; -const sharedPageProperties = [ - "path", - "pageTitle", -] as const satisfies ReadonlyArray; +const sharedPageProperties = ["path", "pageTitle"] as const satisfies ReadonlyArray< + keyof CustomAppPageContextProperties +>; export const itemEditorPageProperties = [ ...sharedPageProperties, "contentItemId", - "languageId", + "languageId", "validationErrors", - "currentPage" + "currentPage", ] as const satisfies ReadonlyArray; -type RawItemEditorPageContext = Pick; +type RawItemEditorPageContext = Pick< + CustomAppPageContextProperties, + (typeof itemEditorPageProperties)[number] +>; -type ItemEditorPageContext = Required, typeof itemEditorPageProperties[number]>>; +type ItemEditorPageContext = Required< + Pick< + WithSpecificPage, + (typeof itemEditorPageProperties)[number] + > +>; -export const isItemEditorPageContext = (context: RawItemEditorPageContext): context is ItemEditorPageContext => { - return context.currentPage === "itemEditor" && itemEditorPageProperties.every((property) => property in context && context[property] !== undefined); +export const isItemEditorPageContext = ( + context: RawItemEditorPageContext, +): context is ItemEditorPageContext => { + return ( + context.currentPage === "itemEditor" && + itemEditorPageProperties.every( + (property) => property in context && context[property] !== undefined, + ) + ); }; export const otherPageProperties = [ ...sharedPageProperties, - "currentPage" + "currentPage", ] as const satisfies ReadonlyArray; -type RawOtherPageContext = Pick; +type RawOtherPageContext = Pick< + CustomAppPageContextProperties, + (typeof otherPageProperties)[number] +>; -type OtherPageContext = Required, typeof otherPageProperties[number]>>; +type OtherPageContext = Required< + Pick< + WithSpecificPage, + (typeof otherPageProperties)[number] + > +>; export const isOtherPageContext = (context: RawOtherPageContext): context is OtherPageContext => { - return context.currentPage === "other" && otherPageProperties.every((property) => property in context && context[property] !== undefined); + return ( + context.currentPage === "other" && + otherPageProperties.every((property) => property in context && context[property] !== undefined) + ); }; export type PageContext = ItemEditorPageContext | OtherPageContext; -type WithSpecificPage = T & { readonly currentPage: Page }; +type WithSpecificPage< + T extends CustomAppPageContextProperties, + Page extends PageContext["currentPage"], +> = T & { readonly currentPage: Page }; diff --git a/src/utilityTypes.ts b/src/utilityTypes.ts index b3f5a3f..06073cc 100644 --- a/src/utilityTypes.ts +++ b/src/utilityTypes.ts @@ -7,4 +7,6 @@ export type AllClientNotifications = ExtractNotification; type AllClientMessages = Schema["client"][keyof Schema["client"]]; -type ExtractNotification = T extends { notification: infer N } ? N : never; \ No newline at end of file +type ExtractNotification = T extends { notification: infer N } + ? N + : never; From 38b38844036bdf29069e602d4552ae31a4ddf1cb Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Mon, 13 Oct 2025 19:48:16 +0200 Subject: [PATCH 11/17] Merge get-context and get-page-context together into a new version of get-context message --- src/contexts.ts | 62 +++++++++++ src/customAppSdk.ts | 206 +++++++++++----------------------- src/iframeMessenger.ts | 21 ++-- src/iframeSchema.ts | 246 ++++++++++++++++++++++------------------- src/index.ts | 13 +-- src/pageContexts.ts | 67 ----------- 6 files changed, 276 insertions(+), 339 deletions(-) create mode 100644 src/contexts.ts delete mode 100644 src/pageContexts.ts diff --git a/src/contexts.ts b/src/contexts.ts new file mode 100644 index 0000000..26b0064 --- /dev/null +++ b/src/contexts.ts @@ -0,0 +1,62 @@ +import type { CustomAppContextProperties } from "./iframeSchema"; + +const sharedContextProperties = [ + "path", + "pageTitle", + "environmentId", + "userId", + "userEmail", + "userRoles", + "appConfig", +] as const satisfies ReadonlyArray; + +export const itemEditorContextProperties = [ + ...sharedContextProperties, + "contentItemId", + "languageId", + "validationErrors", + "currentPage", +] as const satisfies ReadonlyArray; + +type RawItemEditorContext = Pick< + CustomAppContextProperties, + (typeof itemEditorContextProperties)[number] +>; + +type ItemEditorContext = Required>; + +export const isItemEditorContext = ( + context: RawItemEditorContext, +): context is ItemEditorContext => { + return ( + context.currentPage === "itemEditor" && + itemEditorContextProperties.every( + (property) => property in context && context[property] !== undefined, + ) + ); +}; + +export const otherContextProperties = [ + ...sharedContextProperties, + "currentPage", +] as const satisfies ReadonlyArray; + +type RawOtherContext = Pick; + +type OtherContext = Required>; + +export const isOtherContext = (context: RawOtherContext): context is OtherContext => { + return ( + context.currentPage === "other" && + otherContextProperties.every( + (property) => property in context && context[property] !== undefined, + ) + ); +}; + +export type Context = ItemEditorContext | OtherContext; + +type WithSpecificPage< + T extends CustomAppContextProperties, + Page extends Context["currentPage"], +> = T & { readonly currentPage: Page }; diff --git a/src/customAppSdk.ts b/src/customAppSdk.ts index ca2e73f..647b4b1 100644 --- a/src/customAppSdk.ts +++ b/src/customAppSdk.ts @@ -1,82 +1,33 @@ import type { z } from "zod"; +import { + type Context, + isItemEditorContext, + isOtherContext, + itemEditorContextProperties, + otherContextProperties, +} from "./contexts"; import { addNotificationCallback, removeNotificationCallback, sendMessage, } from "./iframeMessenger"; -import type { Schema } from "./iframeSchema"; import { - type ClientPageContextChangedV1Notification, - type CustomAppPageContextProperties, + type ClientContextChangedV1Notification, + type CustomAppContextProperties, ErrorMessage, } from "./iframeSchema"; import { matchesSchema } from "./matchesSchema"; -import { - isItemEditorPageContext, - isOtherPageContext, - itemEditorPageProperties, - otherPageProperties, - type PageContext, -} from "./pageContexts"; export enum ErrorCode { UnknownMessage = "unknown-message", - OutdatedPageContext = "outdated-page-context", + NotSupported = "not-supported", + OutdatedContext = "outdated-context", } -export type CustomAppContext = - | { - readonly isError: false; - readonly context: { - readonly environmentId: string; - readonly userId: string; - readonly userEmail: string; - readonly userRoles: ReadonlyArray<{ - readonly id: string; - readonly codename: string | null; - }>; - }; - readonly config?: unknown; - } - | { - readonly isError: true; - readonly code: ErrorCode; - readonly description: string; - }; - -export const getCustomAppContext = async (): Promise => { - const response = await sendMessagePromise<"get-context@1.0.0">({ +export const getCustomAppContext = async (): Promise> => { + const currentPageResponse = await sendMessage<"get-context@2.0.0">({ type: "get-context-request", - version: "1.0.0", - payload: null, - }); - - if (matchesSchema(ErrorMessage, response)) { - return { isError: true, code: response.code, description: response.description }; - } - - return { ...response.payload, isError: false }; -}; - -export type PageContextResult> = - | { - readonly isError: false; - readonly properties: { [K in T[number]]: CustomAppPageContextProperties[K] }; - } - | { - readonly isError: true; - readonly code: ErrorCode; - readonly description: string; - }; - -export const getPageContext = async (): Promise< - | { readonly isError: false; readonly context: PageContext } - | { readonly isError: true; readonly code: ErrorCode; readonly description: string } -> => { - // First, get current page to determine which properties to fetch - const currentPageResponse = await sendMessagePromise<"get-page-context@1.0.0">({ - type: "get-page-context-request", - version: "1.0.0", + version: "2.0.0", payload: { properties: ["currentPage"], }, @@ -92,12 +43,11 @@ export const getPageContext = async (): Promise< const currentPage = currentPageResponse.payload.properties.currentPage; const propertiesToFetch = - currentPage === "itemEditor" ? itemEditorPageProperties : otherPageProperties; + currentPage === "itemEditor" ? itemEditorContextProperties : otherContextProperties; - // Fetch all properties for the specific page type - const response = await sendMessagePromise<"get-page-context@1.0.0">({ - type: "get-page-context-request", - version: "1.0.0", + const response = await sendMessage<"get-context@2.0.0">({ + type: "get-context-request", + version: "2.0.0", payload: { properties: propertiesToFetch, }, @@ -107,20 +57,9 @@ export const getPageContext = async (): Promise< return { isError: true, code: response.code, description: response.description }; } - return getPageContextFromProperties(response.payload.properties); + return getContextFromProperties(response.payload.properties); }; -export type SetPopupSizeResult = - | { - readonly isError: false; - readonly success: boolean; - } - | { - readonly isError: true; - readonly code: ErrorCode; - readonly description: string; - }; - export type PopupSizeDimension = | { readonly unit: "px"; @@ -134,8 +73,8 @@ export type PopupSizeDimension = export const setPopupSize = async ( width: PopupSizeDimension, height: PopupSizeDimension, -): Promise => { - const response = await sendMessagePromise<"set-popup-size@1.0.0">({ +): Promise> => { + const response = await sendMessage<"set-popup-size@1.0.0">({ type: "set-popup-size-request", version: "1.0.0", payload: { @@ -148,48 +87,21 @@ export const setPopupSize = async ( return { isError: true, code: response.code, description: response.description }; } - return { isError: false, success: response.payload.success }; + return { isError: false }; }; -const sendMessagePromise = ( - message: Omit, -): Promise => - new Promise((resolve, reject) => { - try { - sendMessage(message, resolve); - } catch (error) { - reject(error); - } - }); - -const outdatedPageContextError = { +const outdatedContextError = { isError: true, - code: ErrorCode.OutdatedPageContext, - description: - "The page context we received is outdated, please try to get the page context again.", + code: ErrorCode.OutdatedContext, + description: "The context we received is outdated, please try to get the context again.", } as const; -export type ObservePageContextCallback = (context: PageContext) => void; - -export type ObservePageContextResult = - | { - readonly isError: false; - readonly context: PageContext; - readonly unsubscribe: () => Promise; - } - | { - readonly isError: true; - readonly code: ErrorCode; - readonly description: string; - }; - -export const observePageContext = async ( - callback: ObservePageContextCallback, -): Promise => { - // First, get current page to determine which properties to observe - const currentPageResponse = await sendMessagePromise<"get-page-context@1.0.0">({ - type: "get-page-context-request", - version: "1.0.0", +export const observeContext = async ( + callback: (context: Context) => void, +): Promise Promise }>> => { + const currentPageResponse = await sendMessage<"get-context@2.0.0">({ + type: "get-context-request", + version: "2.0.0", payload: { properties: ["currentPage"], }, @@ -205,11 +117,10 @@ export const observePageContext = async ( const currentPage = currentPageResponse.payload.properties.currentPage; const propertiesToObserve = - currentPage === "itemEditor" ? itemEditorPageProperties : otherPageProperties; + currentPage === "itemEditor" ? itemEditorContextProperties : otherContextProperties; - // Start observing with the appropriate properties - const observeResponse = await sendMessagePromise<"observe-page-context@1.0.0">({ - type: "observe-page-context-request", + const observeResponse = await sendMessage<"observe-context@1.0.0">({ + type: "observe-context-request", version: "1.0.0", payload: { properties: propertiesToObserve, @@ -220,17 +131,31 @@ export const observePageContext = async ( return { isError: true, code: observeResponse.code, description: observeResponse.description }; } - // Process the initial context - const initialContext = getPageContextFromProperties(observeResponse.payload.properties); + const initialContextResponse = await sendMessage<"get-context@2.0.0">({ + type: "get-context-request", + version: "2.0.0", + payload: { + properties: propertiesToObserve, + }, + }); + + if (matchesSchema(ErrorMessage, initialContextResponse)) { + return { + isError: true, + code: initialContextResponse.code, + description: initialContextResponse.description, + }; + } + + const initialContext = getContextFromProperties(initialContextResponse.payload.properties); if (initialContext.isError) { return initialContext; } - // Set up notification handler const notificationHandler = ( - notification: z.infer, + notification: z.infer, ) => { - const contextResult = getPageContextFromProperties(notification.payload.properties); + const contextResult = getContextFromProperties(notification.payload.properties); if (!contextResult.isError) { callback(contextResult.context); } @@ -238,12 +163,11 @@ export const observePageContext = async ( addNotificationCallback(observeResponse.payload.subscriptionId, notificationHandler); - // Create unsubscribe function const unsubscribe = async (): Promise => { removeNotificationCallback(observeResponse.payload.subscriptionId); - await sendMessagePromise<"unsubscribe-page-context@1.0.0">({ - type: "unsubscribe-page-context-request", + await sendMessage<"unsubscribe-context@1.0.0">({ + type: "unsubscribe-context-request", version: "1.0.0", payload: { subscriptionId: observeResponse.payload.subscriptionId, @@ -258,23 +182,23 @@ export const observePageContext = async ( }; }; -const getPageContextFromProperties = ( - properties: CustomAppPageContextProperties, -): - | { isError: false; context: PageContext } - | { isError: true; code: ErrorCode; description: string } => { +const getContextFromProperties = ( + properties: CustomAppContextProperties, +): Result<{ readonly context: Context }> => { const currentPage = properties.currentPage; switch (currentPage) { case "itemEditor": - return isItemEditorPageContext(properties) + return isItemEditorContext(properties) ? { isError: false, context: properties } - : outdatedPageContextError; + : outdatedContextError; case "other": - return isOtherPageContext(properties) + return isOtherContext(properties) ? { isError: false, context: properties } - : outdatedPageContextError; + : outdatedContextError; default: - return outdatedPageContextError; + return outdatedContextError; } }; + +type Result = ({ isError: false } & T) | { isError: true; code: ErrorCode; description: string }; diff --git a/src/iframeMessenger.ts b/src/iframeMessenger.ts index 9c7bb94..0b8fbdd 100644 --- a/src/iframeMessenger.ts +++ b/src/iframeMessenger.ts @@ -1,6 +1,6 @@ import type { z } from "zod"; import { createUuid } from "./createUuid"; -import type { ClientPageContextChangedV1Notification, Schema } from "./iframeSchema"; +import type { ClientContextChangedV1Notification, Schema } from "./iframeSchema"; import type { AllClientNotifications, AllClientResponses } from "./utilityTypes"; let callbacks: Readonly void>> = {}; @@ -8,16 +8,21 @@ let notificationCallbacks: Readonly void>> = { export const sendMessage = ( message: Omit, - callback: (data: Schema["client"][TMessageType]["response"]) => void, -): void => { - const requestId = createUuid(); - callbacks = { ...callbacks, [requestId]: callback } as typeof callbacks; - window.parent.postMessage({ ...message, requestId }, "*"); +): Promise => { + return new Promise((resolve, reject) => { + try { + const requestId = createUuid(); + callbacks = { ...callbacks, [requestId]: resolve } as typeof callbacks; + window.parent.postMessage({ ...message, requestId }, "*"); + } catch (error) { + reject(error); + } + }); }; export const addNotificationCallback = ( subscriptionId: string, - callback: (notification: z.infer) => void, + callback: (notification: z.infer) => void, ): void => { notificationCallbacks = { ...notificationCallbacks, @@ -34,14 +39,12 @@ export const removeNotificationCallback = (subscriptionId: string): void => { const processMessage = (event: MessageEvent): void => { const message = event.data; - // Check if it's a notification if ("subscriptionId" in message) { const notification = message as AllClientNotifications; notificationCallbacks[message.subscriptionId]?.(notification); return; } - // Otherwise, it's a response const response = message as AllClientResponses; const callback = callbacks[response.requestId]; callbacks = Object.fromEntries( diff --git a/src/iframeSchema.ts b/src/iframeSchema.ts index 2788264..fbe59d8 100644 --- a/src/iframeSchema.ts +++ b/src/iframeSchema.ts @@ -1,13 +1,14 @@ import { z } from "zod"; -enum ErrorCode { +export enum ErrorCode { UnknownMessage = "unknown-message", + NotSupported = "not-supported", } export const ErrorMessage = z .object({ - requestId: z.string().uuid(), - isError: z.boolean(), + requestId: z.uuid(), + isError: z.literal(true), code: z.nativeEnum(ErrorCode), description: z.string(), }) @@ -16,225 +17,240 @@ export const ErrorMessage = z const ClientGetContextV1Request = z .object({ type: z.literal("get-context-request"), - requestId: z.string().uuid(), + requestId: z.uuid(), version: z.literal("1.0.0"), payload: z.null(), }) .readonly(); -const ClientGetContextV1Response = z +const CustomAppContextV1Schema = z .object({ - type: z.literal("get-context-response"), - isError: z.boolean(), - payload: z + context: z .object({ - context: z - .object({ - environmentId: z.string().uuid(), - userId: z.string(), - userEmail: z.string().email(), - userRoles: z - .array( - z - .object({ - id: z.string().uuid(), - codename: z.string().or(z.null()), - }) - .readonly(), - ) + environmentId: z.string().uuid(), + userId: z.string(), + userEmail: z.string().email(), + userRoles: z + .array( + z + .object({ + id: z.string().uuid(), + codename: z.string().or(z.null()), + }) .readonly(), - }) + ) .readonly(), - config: z.unknown(), }) .readonly(), + config: z.unknown(), + }) + .readonly(); + +export type CustomAppContextV1 = z.infer; + +const ClientGetContextV1Response = z + .object({ + type: z.literal("get-context-response"), + isError: z.literal(false), + payload: CustomAppContextV1Schema.readonly(), requestId: z.string().uuid(), version: z.literal("1.0.0"), }) .or(ErrorMessage) .readonly(); -export type CustomAppPageContextProperties = { +export type CustomAppContextProperties = { + readonly environmentId?: string; + readonly userId?: string; + readonly userEmail?: string; + readonly userRoles?: ReadonlyArray<{ + readonly id: string; + readonly codename: string | null; + }>; + readonly appConfig?: unknown; readonly contentItemId?: string; readonly languageId?: string; readonly path?: string; readonly pageTitle?: string; readonly validationErrors?: Readonly>>; - readonly currentPage: "itemEditor" | "other"; + readonly currentPage?: "itemEditor" | "other"; }; -export const allCustomAppPageContextPropertyKeys = Object.keys({ +export const allCustomAppContextPropertyKeys = Object.keys({ + environmentId: "", + userId: "", + userEmail: "", + userRoles: "", + appConfig: "", contentItemId: "", languageId: "", path: "", pageTitle: "", validationErrors: "", currentPage: "", -} as const satisfies Record); +} as const satisfies Record); -export const ClientGetCustomAppPageContextV1Request = z +const ClientGetContextV2Request = z .object({ - type: z.literal("get-page-context-request"), + type: z.literal("get-context-request"), requestId: z.string().uuid(), - version: z.literal("1.0.0"), + version: z.literal("2.0.0"), payload: z .object({ - properties: z.array(z.enum(allCustomAppPageContextPropertyKeys)).readonly(), + properties: z.array(z.enum(allCustomAppContextPropertyKeys)).readonly(), }) .readonly(), }) .readonly(); -const CustomAppPageContextPropertiesSchema = z +const CustomAppContextPropertiesSchema = z .object({ + environmentId: z.string().uuid().optional(), + userId: z.string().optional(), + userEmail: z.string().email().optional(), + userRoles: z + .array( + z + .object({ + id: z.string().uuid(), + codename: z.string().or(z.null()), + }) + .readonly(), + ) + .readonly() + .optional(), + appConfig: z.unknown().optional(), contentItemId: z.string().uuid().optional(), languageId: z.string().uuid().optional(), path: z.string().optional(), pageTitle: z.string().optional(), validationErrors: z.record(z.string(), z.array(z.string()).readonly()).readonly().optional(), - currentPage: z.union([z.literal("itemEditor"), z.literal("other")]), + currentPage: z.union([z.literal("itemEditor"), z.literal("other")]).optional(), } as const satisfies Required<{ - readonly [K in keyof CustomAppPageContextProperties]: z.ZodType< - CustomAppPageContextProperties[K] + readonly [K in keyof CustomAppContextProperties]: z.ZodType< + Partial[K] >; }>) .readonly(); -export const ClientGetCustomAppPageContextV1Response = z +export type CustomAppContext = z.infer; + +const ClientGetContextV2Response = z .object({ - type: z.literal("get-page-context-response"), - isError: z.boolean(), + type: z.literal("get-context-response"), + isError: z.literal(false), payload: z .object({ - properties: CustomAppPageContextPropertiesSchema, + properties: CustomAppContextPropertiesSchema.readonly(), }) .readonly(), requestId: z.string().uuid(), - version: z.literal("1.0.0"), + version: z.literal("2.0.0"), }) .or(ErrorMessage) .readonly(); -const ClientSetPopupSizeV1Request = z +const PopupSizeDimensionSchema = z + .union([ + z.object({ + unit: z.literal("px"), + value: z.number().min(200).max(2000), + }), + z.object({ + unit: z.literal("%"), + value: z.number().min(10).max(100), + }), + ]) + .readonly(); + +export const ClientSetPopupSizeV1Request = z .object({ type: z.literal("set-popup-size-request"), - requestId: z.string().uuid(), + requestId: z.uuid(), version: z.literal("1.0.0"), payload: z .object({ - width: z.union([ - z - .object({ - unit: z.literal("px"), - value: z.number().min(200).max(2000), - }) - .readonly(), - z - .object({ - unit: z.literal("%"), - value: z.number().min(10).max(100), - }) - .readonly(), - ]), - height: z.union([ - z - .object({ - unit: z.literal("px"), - value: z.number().min(150).max(1500), - }) - .readonly(), - z - .object({ - unit: z.literal("%"), - value: z.number().min(10).max(100), - }) - .readonly(), - ]), + width: PopupSizeDimensionSchema, + height: PopupSizeDimensionSchema, }) .readonly(), }) .readonly(); -const ClientSetPopupSizeV1Response = z +export const ClientSetPopupSizeV1Response = z .object({ type: z.literal("set-popup-size-response"), - isError: z.boolean(), - payload: z - .object({ - success: z.boolean(), - }) - .readonly(), - requestId: z.string().uuid(), + isError: z.literal(false), + requestId: z.uuid(), version: z.literal("1.0.0"), }) .or(ErrorMessage) .readonly(); -export const ClientObservePageContextV1Request = z +export const ClientObserveContextV1Request = z .object({ - type: z.literal("observe-page-context-request"), - requestId: z.string().uuid(), + type: z.literal("observe-context-request"), + requestId: z.uuid(), version: z.literal("1.0.0"), payload: z .object({ - properties: z.array(z.enum(allCustomAppPageContextPropertyKeys)).readonly(), + properties: z.array(z.enum(allCustomAppContextPropertyKeys)).readonly(), }) .readonly(), }) .readonly(); -export const ClientObservePageContextV1Response = z +export const ClientObserveContextV1Response = z .object({ - type: z.literal("observe-page-context-response"), - isError: z.boolean(), + type: z.literal("observe-context-response"), + isError: z.literal(false), payload: z .object({ - properties: CustomAppPageContextPropertiesSchema.readonly(), subscriptionId: z.string().uuid(), }) .readonly(), - requestId: z.string().uuid(), + requestId: z.uuid(), version: z.literal("1.0.0"), }) .or(ErrorMessage) .readonly(); -export const ClientUnsubscribePageContextV1Request = z +export const ClientUnsubscribeContextV1Request = z .object({ - type: z.literal("unsubscribe-page-context-request"), - requestId: z.string().uuid(), + type: z.literal("unsubscribe-context-request"), + requestId: z.uuid(), version: z.literal("1.0.0"), payload: z .object({ - subscriptionId: z.string().uuid(), + subscriptionId: z.uuid(), }) .readonly(), }) .readonly(); -export const ClientUnsubscribePageContextV1Response = z +export const ClientUnsubscribeContextV1Response = z .object({ - type: z.literal("unsubscribe-page-context-response"), - isError: z.boolean(), + type: z.literal("unsubscribe-context-response"), + isError: z.literal(false), payload: z .object({ success: z.boolean(), }) .readonly(), - requestId: z.string().uuid(), + requestId: z.uuid(), version: z.literal("1.0.0"), }) .or(ErrorMessage) .readonly(); -export const ClientPageContextChangedV1Notification = z +export const ClientContextChangedV1Notification = z .object({ - type: z.literal("page-context-changed-notification"), - subscriptionId: z.string().uuid(), + type: z.literal("context-changed-notification"), + subscriptionId: z.uuid(), version: z.literal("1.0.0"), payload: z .object({ - properties: CustomAppPageContextPropertiesSchema.readonly(), + properties: CustomAppContextPropertiesSchema.readonly(), }) .readonly(), }) @@ -242,10 +258,10 @@ export const ClientPageContextChangedV1Notification = z export const AllClientRequestMessages = z.union([ ClientGetContextV1Request, - ClientGetCustomAppPageContextV1Request, + ClientGetContextV2Request, ClientSetPopupSizeV1Request, - ClientObservePageContextV1Request, - ClientUnsubscribePageContextV1Request, + ClientObserveContextV1Request, + ClientUnsubscribeContextV1Request, ]); export type Schema = { @@ -254,22 +270,26 @@ export type Schema = { request: z.infer; response: z.infer; }; - "get-page-context@1.0.0": { - request: z.infer; - response: z.infer; + "get-context@2.0.0": { + request: z.infer; + response: z.infer; }; "set-popup-size@1.0.0": { request: z.infer; response: z.infer; }; - "observe-page-context@1.0.0": { - request: z.infer; - response: z.infer; - notification: z.infer; + "set-popup-size@1.2.0": { + request: z.infer; + response: z.infer; + }; + "observe-context@1.0.0": { + request: z.infer; + response: z.infer; + notification: z.infer; }; - "unsubscribe-page-context@1.0.0": { - request: z.infer; - response: z.infer; + "unsubscribe-context@1.0.0": { + request: z.infer; + response: z.infer; }; }; }; diff --git a/src/index.ts b/src/index.ts index 09e5fee..e477014 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,9 @@ +export { Context } from "./contexts"; export { - CustomAppContext, + ErrorCode, getCustomAppContext, - getPageContext, - ObservePageContextCallback, - ObservePageContextResult, - observePageContext, - PageContextResult, + observeContext, PopupSizeDimension, - SetPopupSizeResult, setPopupSize, } from "./customAppSdk"; -export { CustomAppPageContextProperties } from "./iframeSchema"; -export { PageContext } from "./pageContexts"; +export { CustomAppContextProperties } from "./iframeSchema"; diff --git a/src/pageContexts.ts b/src/pageContexts.ts deleted file mode 100644 index dda693b..0000000 --- a/src/pageContexts.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { CustomAppPageContextProperties } from "./iframeSchema"; - -const sharedPageProperties = ["path", "pageTitle"] as const satisfies ReadonlyArray< - keyof CustomAppPageContextProperties ->; - -export const itemEditorPageProperties = [ - ...sharedPageProperties, - "contentItemId", - "languageId", - "validationErrors", - "currentPage", -] as const satisfies ReadonlyArray; - -type RawItemEditorPageContext = Pick< - CustomAppPageContextProperties, - (typeof itemEditorPageProperties)[number] ->; - -type ItemEditorPageContext = Required< - Pick< - WithSpecificPage, - (typeof itemEditorPageProperties)[number] - > ->; - -export const isItemEditorPageContext = ( - context: RawItemEditorPageContext, -): context is ItemEditorPageContext => { - return ( - context.currentPage === "itemEditor" && - itemEditorPageProperties.every( - (property) => property in context && context[property] !== undefined, - ) - ); -}; - -export const otherPageProperties = [ - ...sharedPageProperties, - "currentPage", -] as const satisfies ReadonlyArray; - -type RawOtherPageContext = Pick< - CustomAppPageContextProperties, - (typeof otherPageProperties)[number] ->; - -type OtherPageContext = Required< - Pick< - WithSpecificPage, - (typeof otherPageProperties)[number] - > ->; - -export const isOtherPageContext = (context: RawOtherPageContext): context is OtherPageContext => { - return ( - context.currentPage === "other" && - otherPageProperties.every((property) => property in context && context[property] !== undefined) - ); -}; - -export type PageContext = ItemEditorPageContext | OtherPageContext; - -type WithSpecificPage< - T extends CustomAppPageContextProperties, - Page extends PageContext["currentPage"], -> = T & { readonly currentPage: Page }; From 67b7bcab9aa1c081d9b5cf73546e222665385fe4 Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Fri, 17 Oct 2025 20:14:45 +0200 Subject: [PATCH 12/17] Mark some context properties as optional --- src/contexts.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/contexts.ts b/src/contexts.ts index 26b0064..413739b 100644 --- a/src/contexts.ts +++ b/src/contexts.ts @@ -18,6 +18,10 @@ export const itemEditorContextProperties = [ "currentPage", ] as const satisfies ReadonlyArray; +const optionalProperties: ReadonlyArray = [ + "appConfig", +] as const satisfies ReadonlyArray; + type RawItemEditorContext = Pick< CustomAppContextProperties, (typeof itemEditorContextProperties)[number] @@ -30,9 +34,9 @@ export const isItemEditorContext = ( ): context is ItemEditorContext => { return ( context.currentPage === "itemEditor" && - itemEditorContextProperties.every( - (property) => property in context && context[property] !== undefined, - ) + itemEditorContextProperties + .filter((property) => !optionalProperties.includes(property)) + .every((property) => property in context && context[property] !== undefined) ); }; @@ -48,9 +52,9 @@ type OtherContext = Required { return ( context.currentPage === "other" && - otherContextProperties.every( - (property) => property in context && context[property] !== undefined, - ) + otherContextProperties + .filter((property) => !optionalProperties.includes(property)) + .every((property) => property in context && context[property] !== undefined) ); }; From 4077bad2b3fb88c65af3761d4c30ed6ad46f1236 Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Thu, 23 Oct 2025 12:59:52 +0200 Subject: [PATCH 13/17] Rename observeContext to observeCustomAppContext for consistency and adjust exports --- src/contexts.ts | 6 ++++-- src/customAppSdk.ts | 2 +- src/index.ts | 9 ++++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/contexts.ts b/src/contexts.ts index 413739b..f4b51f1 100644 --- a/src/contexts.ts +++ b/src/contexts.ts @@ -27,7 +27,9 @@ type RawItemEditorContext = Pick< (typeof itemEditorContextProperties)[number] >; -type ItemEditorContext = Required>; +export type ItemEditorContext = Required< + WithSpecificPage +>; export const isItemEditorContext = ( context: RawItemEditorContext, @@ -47,7 +49,7 @@ export const otherContextProperties = [ type RawOtherContext = Pick; -type OtherContext = Required>; +export type OtherContext = Required>; export const isOtherContext = (context: RawOtherContext): context is OtherContext => { return ( diff --git a/src/customAppSdk.ts b/src/customAppSdk.ts index 647b4b1..5c71b5a 100644 --- a/src/customAppSdk.ts +++ b/src/customAppSdk.ts @@ -96,7 +96,7 @@ const outdatedContextError = { description: "The context we received is outdated, please try to get the context again.", } as const; -export const observeContext = async ( +export const observeCustomAppContext = async ( callback: (context: Context) => void, ): Promise Promise }>> => { const currentPageResponse = await sendMessage<"get-context@2.0.0">({ diff --git a/src/index.ts b/src/index.ts index e477014..48f1918 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,12 @@ -export { Context } from "./contexts"; +export { + Context as CustomAppContext, + ItemEditorContext as CustomAppItemEditorContext, + OtherContext as CustomAppOtherContext, +} from "./contexts"; export { ErrorCode, getCustomAppContext, - observeContext, + observeCustomAppContext, PopupSizeDimension, setPopupSize, } from "./customAppSdk"; -export { CustomAppContextProperties } from "./iframeSchema"; From 92ec44041183f5cd0f42fba03e546e21179d1b5b Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Fri, 24 Oct 2025 17:58:16 +0200 Subject: [PATCH 14/17] Update API reference in readme --- README.md | 172 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 112 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index dcf8b6b..fa8310c 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,19 @@ npm install @kontent-ai/custom-app-sdk ```typescript import { getCustomAppContext, CustomAppContext } from "@kontent-ai/custom-app-sdk"; -const response: CustomAppContext = await getCustomAppContext(); +const response = await getCustomAppContext(); if (response.isError) { - console.error({ errorCode: response.code, description: response.description}); + console.error({ errorCode: response.code, description: response.description }); } else { - console.log({ config: response.config, context: response.context }); + // TypeScript will narrow the type based on currentPage + if (response.context.currentPage === "itemEditor") { + console.log({ + contentItemId: response.context.contentItemId, + languageId: response.context.languageId, + validationErrors: response.context.validationErrors + }); + } } ``` @@ -42,19 +49,18 @@ if (response.isError) { ### getCustomAppContext -Use the `getCustomAppContext` function to retrieve the context of the custom app. The function takes no arguments and returns a promise with a value of an object of type `CustomAppContext`. +Retrieves the context of the custom app. The function takes no arguments and automatically detects the current page type, returning the appropriate context with all relevant properties for that page. -#### CustomAppContext +#### Return Type -`CustomAppContext` is a discriminated union type that can be in one of two states: +Returns a promise that resolves to a discriminated union type with two possible states: ##### Success Response (`isError: false`) | Property | Type | Description | |---------------|------------------------|------------------------------------------------------------------------------| | `isError` | `false` | Indicates the request was successful | -| `context` | object | Contains data provided by the Kontent.ai application | -| `config` | unknown \| undefined | Contains JSON object specified in the custom app configuration | +| `context` | `CustomAppContext` | A discriminated union of page-specific context objects | ##### Error Response (`isError: true`) @@ -64,44 +70,71 @@ Use the `getCustomAppContext` function to retrieve the context of the custom app | `code` | ErrorCode enum | The code of the error message | | `description` | string | The description of the error message | -#### Config -The `config` object is a JSON object that can be defined within the Custom App configuration under Environment settings in the Kontent.ai app. +#### CustomAppContext -#### Context -The `context` object contains data provided by the Kontent.ai application that you can leverage in your custom app. +`CustomAppContext` is a discriminated union type based on the `currentPage` property. Each page type includes only the relevant properties for that specific page: -| Property | Type | Description | -|-----------------|-------------------|--------------------------------------------------------------------------| -| `environmentId` | UUID | The environment's ID | -| `userId` | string | The current user's ID | -| `userEmail` | string | The current user's email | -| `userRoles` | Array of UserRole | An array containing all the roles of the current user in the environment | +##### Item Editor Page Context + +When `currentPage` is `"itemEditor"`, the context includes: -#### UserRole +| Property | Type | Description | +|-------------------------|---------------------------------------|----------------------------------------------------------------------| +| `currentPage` | `"itemEditor"` | Identifies this as an item editor page | +| `environmentId` | UUID | The environment's ID | +| `userId` | string | The current user's ID | +| `userEmail` | string | The current user's email | +| `userRoles` | Array of UserRole | An array containing all the roles of the current user in the environment | +| `path` | string | The current path within the Kontent.ai application | +| `pageTitle` | string | The title of the current page | +| `appConfig` | unknown \| undefined | JSON object specified in the custom app configuration | +| `contentItemId` | UUID | The ID of the content item being edited | +| `languageId` | UUID | The ID of the current language | +| `validationErrors` | Record | A record of validation errors for content item fields | + +##### Other Page Context + +When `currentPage` is `"other"`, the context includes: + +| Property | Type | Description | +|-------------------------|---------------------------------------|----------------------------------------------------------------------| +| `currentPage` | `"other"` | Identifies this as any other page type | +| `environmentId` | UUID | The environment's ID | +| `userId` | string | The current user's ID | +| `userEmail` | string | The current user's email | +| `userRoles` | Array of UserRole | An array containing all the roles of the current user in the environment | +| `path` | string | The current path within the Kontent.ai application | +| `pageTitle` | string | The title of the current page | +| `appConfig` | unknown \| undefined | JSON object specified in the custom app configuration | + +#### UserRole | Property | Type | Description | |------------|--------|----------------------------------------------------------------------| | `id` | UUID | The role's ID | | `codename` | string | The role's codename - applicable only for the _Project manager_ role | -### getPageContext +### observeCustomAppContext -Use the `getPageContext` function to retrieve contextual information about the current page within the Kontent.ai application. The function automatically detects the current page type and returns the appropriate context with all relevant properties for that page type. +Subscribes to context changes and receives notifications when the context is updated. The function takes a callback that will be invoked whenever the context changes. #### Parameters -None - the function takes no parameters and automatically determines what properties to fetch based on the current page. +| Parameter | Type | Description | +|------------|-----------------------------|-------------------------------------------------------| +| `callback` | `(context: CustomAppContext) => void` | Function to be called when the context changes | #### Return Type -The function returns a promise that resolves to a discriminated union type with two possible states: +Returns a promise that resolves to a discriminated union type with two possible states: ##### Success Response (`isError: false`) | Property | Type | Description | |---------------|------------------------|------------------------------------------------------------------------------| | `isError` | `false` | Indicates the request was successful | -| `context` | `PageContext` | A discriminated union of page-specific context objects | +| `context` | `CustomAppContext` | The initial context value | +| `unsubscribe` | `() => Promise` | Function to call to stop receiving context updates | ##### Error Response (`isError: true`) @@ -111,57 +144,76 @@ The function returns a promise that resolves to a discriminated union type with | `code` | ErrorCode enum | The code of the error message | | `description` | string | The description of the error message | -#### PageContext +#### Usage Example -`PageContext` is a discriminated union type based on the `currentPage` property. Each page type includes only the relevant properties for that specific page: +```typescript +import { observeCustomAppContext } from "@kontent-ai/custom-app-sdk"; -##### Item Editor Page Context +const response = await observeCustomAppContext((context) => { + console.log("Context updated:", context); +}); -When `currentPage` is `"itemEditor"`, the context includes: +if (response.isError) { + console.error({ errorCode: response.code, description: response.description }); +} else { + console.log("Initial context:", response.context); -| Property | Type | Description | -|-------------------------|---------------------------------------|----------------------------------------------------------------------| -| `currentPage` | `"itemEditor"` | Identifies this as an item editor page | -| `contentItemId` | UUID | The ID of the content item being edited | -| `languageId` | UUID | The ID of the current language | -| `path` | string | The current path within the Kontent.ai application | -| `pageTitle` | string | The title of the current page | -| `validationErrors` | Record | A record of validation errors for content item fields | + // Later, when you want to stop observing + await response.unsubscribe(); +} +``` -##### Other Page Context +### setPopupSize -When `currentPage` is `"other"`, the context includes: +Sets the size of the popup window when the custom app is displayed in a popup. -| Property | Type | Description | -|-------------------------|---------------------------------------|----------------------------------------------------------------------| -| `currentPage` | `"other"` | Identifies this as any other page type | -| `path` | string | The current path within the Kontent.ai application | -| `pageTitle` | string | The title of the current page | +#### Parameters + +| Parameter | Type | Description | +|-----------|----------------------|--------------------------------------------------| +| `width` | `PopupSizeDimension` | The desired width of the popup | +| `height` | `PopupSizeDimension` | The desired height of the popup | + +#### PopupSizeDimension + +A discriminated union type for specifying dimensions in either pixels or percentages: + +```typescript +type PopupSizeDimension = + | { unit: "px"; value: number } + | { unit: "%"; value: number }; +``` + +#### Return Type + +Returns a promise that resolves to a discriminated union type with two possible states: + +##### Success Response (`isError: false`) + +| Property | Type | Description | +|---------------|------------------------|------------------------------------------------------------------------------| +| `isError` | `false` | Indicates the request was successful | + +##### Error Response (`isError: true`) + +| Property | Type | Description | +|---------------|------------------------|------------------------------------------------------------------------------| +| `isError` | `true` | Indicates an error occurred | +| `code` | ErrorCode enum | The code of the error message | +| `description` | string | The description of the error message | #### Usage Example ```typescript -import { getPageContext, PageContext } from "@kontent-ai/custom-app-sdk"; +import { setPopupSize } from "@kontent-ai/custom-app-sdk"; -// Get page context - automatically fetches appropriate properties based on current page -const response = await getPageContext(); +const response = await setPopupSize( + { unit: "px", value: 800 }, + { unit: "%", value: 90 } +); if (response.isError) { console.error({ errorCode: response.code, description: response.description }); -} else { - // TypeScript will narrow the type based on currentPage - if (response.context.currentPage === "itemEditor") { - console.log({ - contentItemId: response.context.contentItemId, - languageId: response.context.languageId, - validationErrors: response.context.validationErrors - }); - } else { - console.log({ - path: response.context.path, - pageTitle: response.context.pageTitle - }); - } } ``` From 1f52daa33053b7c2d6911507884aa402f2acc01d Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Thu, 30 Oct 2025 10:39:45 +0100 Subject: [PATCH 15/17] Remove unnecessary casting in iframeMessenger --- src/iframeMessenger.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/iframeMessenger.ts b/src/iframeMessenger.ts index 0b8fbdd..a42417a 100644 --- a/src/iframeMessenger.ts +++ b/src/iframeMessenger.ts @@ -40,17 +40,15 @@ const processMessage = (event: MessageEvent requestId !== response.requestId), + Object.entries(callbacks).filter(([requestId]) => requestId !== message.requestId), ); - callback?.(response); + callback?.(message); }; if (window.self === window.top) { From 758161bf2267f67e6a06509d19029b4c4e3d6b3c Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Thu, 30 Oct 2025 10:49:26 +0100 Subject: [PATCH 16/17] Remove fake set-pup-size@1.2.0 message in iframeSchema --- src/iframeSchema.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/iframeSchema.ts b/src/iframeSchema.ts index fbe59d8..6ee8c06 100644 --- a/src/iframeSchema.ts +++ b/src/iframeSchema.ts @@ -278,10 +278,6 @@ export type Schema = { request: z.infer; response: z.infer; }; - "set-popup-size@1.2.0": { - request: z.infer; - response: z.infer; - }; "observe-context@1.0.0": { request: z.infer; response: z.infer; From fb2b43eeb7fb35e0687c8e127ec04ba2348e8b6b Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Thu, 30 Oct 2025 10:53:04 +0100 Subject: [PATCH 17/17] 2.0.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4d1e42f..b1e1f7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@kontent-ai/custom-app-sdk", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@kontent-ai/custom-app-sdk", - "version": "1.0.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "zod": "^4.1.11" diff --git a/package.json b/package.json index 1424307..2d11d08 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kontent-ai/custom-app-sdk", - "version": "1.0.0", + "version": "2.0.0", "description": "The Kontent.ai Custom App SDK enhances the integration of your custom app with the Kontent.ai platform.", "license": "MIT", "author": "Kontent.ai",