From 752fec598a6f9b829b2252e4242d1ca17bae34fb Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Wed, 3 Jun 2026 14:32:30 -0600 Subject: [PATCH 01/11] feat: add experience-toolbar starter example [EXT-7365] Add a Create Contentful App example demonstrating the new ExO toolbar App SDK location (LOCATION_EXPERIENCE_TOOLBAR), scaffoldable via `npx create-contentful-app --example experience-toolbar`. Typed with ExperienceEditorToolbarAppSDK (@contentful/app-sdk@4.58.0), it demonstrates location detection, reading sdk.exo.context (experience vs. fragment), subscribing to selection changes, reading the selected node's properties via getNode, and reacting to UI mode changes. React-only; no Data Assembly or save/publish (per ticket scope). Co-Authored-By: Claude Opus 4.8 --- examples/experience-toolbar/.gitignore | 24 ++ examples/experience-toolbar/README.md | 132 ++++++++++ examples/experience-toolbar/index.html | 20 ++ examples/experience-toolbar/package.json | 55 ++++ examples/experience-toolbar/src/App.tsx | 27 ++ .../src/components/LocalhostWarning.tsx | 32 +++ examples/experience-toolbar/src/index.tsx | 23 ++ .../src/locations/ConfigScreen.spec.tsx | 23 ++ .../src/locations/ConfigScreen.tsx | 64 +++++ .../src/locations/ExperienceToolbar.spec.tsx | 66 +++++ .../src/locations/ExperienceToolbar.tsx | 240 ++++++++++++++++++ examples/experience-toolbar/src/setupTests.ts | 10 + .../experience-toolbar/test/mocks/index.ts | 1 + .../experience-toolbar/test/mocks/mockSdk.ts | 53 ++++ examples/experience-toolbar/tsconfig.json | 18 ++ examples/experience-toolbar/vite.config.mts | 19 ++ 16 files changed, 807 insertions(+) create mode 100644 examples/experience-toolbar/.gitignore create mode 100644 examples/experience-toolbar/README.md create mode 100644 examples/experience-toolbar/index.html create mode 100644 examples/experience-toolbar/package.json create mode 100644 examples/experience-toolbar/src/App.tsx create mode 100644 examples/experience-toolbar/src/components/LocalhostWarning.tsx create mode 100644 examples/experience-toolbar/src/index.tsx create mode 100644 examples/experience-toolbar/src/locations/ConfigScreen.spec.tsx create mode 100644 examples/experience-toolbar/src/locations/ConfigScreen.tsx create mode 100644 examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx create mode 100644 examples/experience-toolbar/src/locations/ExperienceToolbar.tsx create mode 100644 examples/experience-toolbar/src/setupTests.ts create mode 100644 examples/experience-toolbar/test/mocks/index.ts create mode 100644 examples/experience-toolbar/test/mocks/mockSdk.ts create mode 100644 examples/experience-toolbar/tsconfig.json create mode 100644 examples/experience-toolbar/vite.config.mts diff --git a/examples/experience-toolbar/.gitignore b/examples/experience-toolbar/.gitignore new file mode 100644 index 000000000..d258ba034 --- /dev/null +++ b/examples/experience-toolbar/.gitignore @@ -0,0 +1,24 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# dotenv environment variables file +.env +.env.* +!.env*.example + +# misc +.DS_Store + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/experience-toolbar/README.md b/examples/experience-toolbar/README.md new file mode 100644 index 000000000..be28175fe --- /dev/null +++ b/examples/experience-toolbar/README.md @@ -0,0 +1,132 @@ +# Experience Toolbar example + +A minimal starter for an app that renders in the **Experience Editor toolbar** — +the new `experience-toolbar` location introduced in +[`@contentful/app-sdk@4.58.0`](https://www.npmjs.com/package/@contentful/app-sdk). +Toolbar apps run alongside the Experience Orchestration (ExO) editor and use the +`sdk.exo` namespace to read and react to the experience the user is editing. + +This example is intentionally small. It demonstrates the core building blocks of +a toolbar app: + +- **Location detection** via `sdk.location.is(locations.LOCATION_EXPERIENCE_TOOLBAR)` +- **Context awareness** — reading `sdk.exo.context` to tell whether the user is + editing an `experience` or a `fragment` +- **UI mode** — reacting to `sdk.exo.onUiModeChanged()` (`form` vs. `visual`) +- **Selection** — subscribing to `sdk.exo.experience.selection.onChange()` +- **Node inspection** — resolving the selected node with + `sdk.exo.experience.getNode(nodeId)` and reading its properties + +It is fully typed with `ExperienceEditorToolbarAppSDK`. + +> **Out of scope (by design).** This starter does not mutate the experience. +> Data Assembly, `save()`/`publish()`, and property writes are deliberately left +> out to keep the template focused. See the SDK reference for the full `sdk.exo` +> surface. + +## How to use + +Execute create-contentful-app with npm, npx or yarn to bootstrap the example: + +```bash +# npx +npx create-contentful-app --example experience-toolbar + +# npm +npm init contentful-app -- --example experience-toolbar + +# Yarn +yarn create contentful-app --example experience-toolbar +``` + +Install it and run: + +```bash +npm install +npm start +# or +yarn +yarn start +``` + +## Registering the toolbar location + +Unlike sidebar or field apps, the toolbar location is **not** assigned per +content type through the configuration screen — there is no `EditorInterface` +target state for it. The app is shown whenever the `experience-toolbar` location +is registered on your app definition. + +To create an app definition that includes it, run: + +```bash +npm run create-app-definition +``` + +and select the **App configuration screen** and **Experience toolbar** locations +when prompted, pointing the app at `http://localhost:3000`. (You can also add the +location later with `npm run add-locations`.) + +## How it works + +`src/App.tsx` routes by location using the standard pattern: + +```ts +const ComponentLocationSettings = { + [locations.LOCATION_APP_CONFIG]: ConfigScreen, + [locations.LOCATION_EXPERIENCE_TOOLBAR]: ExperienceToolbar, +}; +``` + +`src/locations/ExperienceToolbar.tsx` is the toolbar app itself. It mounts once +when the editor opens and stays mounted for the session — selection changes do +**not** remount it, so all live data flows through `on*` subscriptions, each of +which returns an unsubscribe function called on cleanup. + +## A note on verification + +This example is built against the published `@contentful/app-sdk@4.58.0` types, +which are the contract for the toolbar location. At the time of writing, the host +renderer that serves `sdk.exo` at runtime is still rolling out, so the example is +**type-verified and unit-tested against a mocked SDK**, but not yet verified +end-to-end inside a live ExO editor. The API shapes used here match the published +types exactly. + +## Available Scripts + +In the project directory, you can run: + +#### `npm start` + +Runs the app in development mode. Open it in the Experience Editor toolbar to use +it. The page reloads on edits, and lint errors appear in the console. + +#### `npm run build` + +Builds the app for production to the `build` folder. The build is minified and +ready to be deployed. + +#### `npm run upload` + +Uploads the build folder to Contentful and creates an automatically activated +bundle. The command guides you through the deployment process. + +#### `npm run upload-ci` + +Like `npm run upload`, but reads all required arguments from environment +variables (for CI pipelines): + +- `CONTENTFUL_ORG_ID` +- `CONTENTFUL_APP_DEF_ID` +- `CONTENTFUL_ACCESS_TOKEN` + +## Libraries to use + +To make your app look and feel like Contentful, use: + +- [Forma 36](https://f36.contentful.com/) – Contentful's design system +- [App SDK](https://www.contentful.com/developers/docs/extensibility/app-framework/sdk/) – the `sdk.exo` reference + +## Learn More + +[Read more](https://www.contentful.com/developers/docs/extensibility/app-framework/create-contentful-app/) +about the Create Contentful App CLI. diff --git a/examples/experience-toolbar/index.html b/examples/experience-toolbar/index.html new file mode 100644 index 000000000..cf65f5e57 --- /dev/null +++ b/examples/experience-toolbar/index.html @@ -0,0 +1,20 @@ + + + + + + + + +
+ + + + diff --git a/examples/experience-toolbar/package.json b/examples/experience-toolbar/package.json new file mode 100644 index 000000000..e27f3e34d --- /dev/null +++ b/examples/experience-toolbar/package.json @@ -0,0 +1,55 @@ +{ + "name": "experience-toolbar-example", + "version": "0.1.0", + "private": true, + "dependencies": { + "@contentful/app-sdk": "4.58.0", + "@contentful/f36-components": "4.81.1", + "@contentful/f36-tokens": "4.2.0", + "@contentful/react-apps-toolkit": "1.2.16", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest", + "test:ci": "vitest run", + "create-app-definition": "contentful-app-scripts create-app-definition", + "add-locations": "contentful-app-scripts add-locations", + "upload": "contentful-app-scripts upload --bundle-dir ./build", + "upload-ci": "contentful-app-scripts upload --ci --bundle-dir ./build --organization-id $CONTENTFUL_ORG_ID --definition-id $CONTENTFUL_APP_DEF_ID --token $CONTENTFUL_ACCESS_TOKEN" + }, + "eslintConfig": { + "extends": "react-app" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@contentful/app-scripts": "^2.3.0", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^14.3.1", + "@types/node": "^22.13.5", + "@types/react": "18.3.13", + "@types/react-dom": "18.3.1", + "@vitejs/plugin-react": "^4.0.3", + "cross-env": "7.0.3", + "jsdom": "^26.0.0", + "typescript": "4.9.5", + "vite": "^6.2.2", + "vitest": "^3.0.9" + }, + "homepage": "." +} diff --git a/examples/experience-toolbar/src/App.tsx b/examples/experience-toolbar/src/App.tsx new file mode 100644 index 000000000..5db9499a3 --- /dev/null +++ b/examples/experience-toolbar/src/App.tsx @@ -0,0 +1,27 @@ +import React, { useMemo } from 'react'; +import { locations } from '@contentful/app-sdk'; +import { useSDK } from '@contentful/react-apps-toolkit'; + +import ConfigScreen from './locations/ConfigScreen'; +import ExperienceToolbar from './locations/ExperienceToolbar'; + +const ComponentLocationSettings = { + [locations.LOCATION_APP_CONFIG]: ConfigScreen, + [locations.LOCATION_EXPERIENCE_TOOLBAR]: ExperienceToolbar, +}; + +const App = () => { + const sdk = useSDK(); + + const Component = useMemo(() => { + for (const [location, component] of Object.entries(ComponentLocationSettings)) { + if (sdk.location.is(location)) { + return component; + } + } + }, [sdk.location]); + + return Component ? : null; +}; + +export default App; diff --git a/examples/experience-toolbar/src/components/LocalhostWarning.tsx b/examples/experience-toolbar/src/components/LocalhostWarning.tsx new file mode 100644 index 000000000..20be7a2c5 --- /dev/null +++ b/examples/experience-toolbar/src/components/LocalhostWarning.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Paragraph, TextLink, Note, Flex } from '@contentful/f36-components'; + +const LocalhostWarning = () => { + return ( + + + + Contentful Apps need to run inside the Contentful web app to function properly. Install + the app into a space and render your app into one of the{' '} + + available locations + + . + +
+ + + Follow{' '} + + our guide + {' '} + to get started or{' '} + open Contentful{' '} + to manage your app. + +
+
+ ); +}; + +export default LocalhostWarning; diff --git a/examples/experience-toolbar/src/index.tsx b/examples/experience-toolbar/src/index.tsx new file mode 100644 index 000000000..d10662719 --- /dev/null +++ b/examples/experience-toolbar/src/index.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import { GlobalStyles } from '@contentful/f36-components'; +import { SDKProvider } from '@contentful/react-apps-toolkit'; + +import LocalhostWarning from './components/LocalhostWarning'; +import App from './App'; + +const container = document.getElementById('root'); +const root = createRoot(container!); + +if (process.env.NODE_ENV === 'development' && window.self === window.top) { + // You can remove this if block before deploying your app + root.render(); +} else { + root.render( + + + + + ); +} diff --git a/examples/experience-toolbar/src/locations/ConfigScreen.spec.tsx b/examples/experience-toolbar/src/locations/ConfigScreen.spec.tsx new file mode 100644 index 000000000..877d3978e --- /dev/null +++ b/examples/experience-toolbar/src/locations/ConfigScreen.spec.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { vi } from 'vitest'; +import ConfigScreen from './ConfigScreen'; +import { mockSdk } from '../../test/mocks'; + +vi.mock('@contentful/react-apps-toolkit', () => ({ + useSDK: () => mockSdk, +})); + +describe('ConfigScreen', () => { + it('renders the configuration copy', () => { + const { getByText } = render(); + + expect(getByText('Experience Toolbar example')).toBeInTheDocument(); + }); + + it('calls setReady once parameters are loaded', async () => { + render(); + + await waitFor(() => expect(mockSdk.app.setReady).toHaveBeenCalled()); + }); +}); diff --git a/examples/experience-toolbar/src/locations/ConfigScreen.tsx b/examples/experience-toolbar/src/locations/ConfigScreen.tsx new file mode 100644 index 000000000..15367819c --- /dev/null +++ b/examples/experience-toolbar/src/locations/ConfigScreen.tsx @@ -0,0 +1,64 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { ConfigAppSDK } from '@contentful/app-sdk'; +import { Flex, Form, Heading, Paragraph, Note } from '@contentful/f36-components'; +import { useSDK } from '@contentful/react-apps-toolkit'; + +export interface AppInstallationParameters {} + +const ConfigScreen = () => { + const [parameters, setParameters] = useState({}); + const sdk = useSDK(); + + const onConfigure = useCallback(async () => { + // This method is called when a user clicks "Install" or "Save" on the + // configuration screen. The experience-toolbar location is not part of the + // EditorInterface, so there is no `targetState` to assign here — visibility + // is determined solely by whether the location is registered on the app + // definition (see README). We persist installation parameters and keep the + // current state as-is. + const currentState = await sdk.app.getCurrentState(); + + return { + parameters, + targetState: currentState, + }; + }, [parameters, sdk]); + + useEffect(() => { + sdk.app.onConfigure(() => onConfigure()); + }, [sdk, onConfigure]); + + useEffect(() => { + (async () => { + const currentParameters: AppInstallationParameters | null = await sdk.app.getParameters(); + + if (currentParameters) { + setParameters(currentParameters); + } + + // Once preparation has finished, call `setReady` to hide the loading + // screen and present the app to the user. + sdk.app.setReady(); + })(); + }, [sdk]); + + return ( + +
+ Experience Toolbar example + + This app renders in the Experience Editor toolbar. There is nothing to configure here — + once installed, make sure the experience-toolbar location is registered on + your app definition and the toolbar app will appear when editing an experience. + + + The toolbar location is not assigned per content type. Unlike sidebar or field apps, it + has no EditorInterface target state — it is shown whenever the location is + registered. + +
+
+ ); +}; + +export default ConfigScreen; diff --git a/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx b/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx new file mode 100644 index 000000000..a82095316 --- /dev/null +++ b/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { act, render, waitFor } from '@testing-library/react'; +import { vi } from 'vitest'; +import ExperienceToolbar from './ExperienceToolbar'; +import { mockSdk } from '../../test/mocks'; + +vi.mock('@contentful/react-apps-toolkit', () => ({ + useSDK: () => mockSdk, +})); + +describe('ExperienceToolbar', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSdk.exo.context = { type: 'experience', entityId: 'experience-123' }; + mockSdk.exo.getUiMode.mockReturnValue('visual'); + mockSdk.exo.experience.selection.get.mockReturnValue({ nodeId: null }); + }); + + it('renders the editing context and ui mode', () => { + const { getByText, getByTestId } = render(); + + expect(getByText('visual mode')).toBeInTheDocument(); + expect(getByTestId('entity-id')).toHaveTextContent('Editing experience experience-123'); + }); + + it('shows the empty state when nothing is selected', () => { + const { getByTestId } = render(); + + expect(getByTestId('empty-state')).toBeInTheDocument(); + }); + + it('subscribes to context, ui mode, and selection changes', () => { + render(); + + expect(mockSdk.exo.onContextChanged).toHaveBeenCalledOnce(); + expect(mockSdk.exo.onUiModeChanged).toHaveBeenCalledOnce(); + expect(mockSdk.exo.experience.selection.onChange).toHaveBeenCalledOnce(); + }); + + it('warns when in form mode', () => { + mockSdk.exo.getUiMode.mockReturnValue('form'); + + const { getByText } = render(); + + expect(getByText('form mode')).toBeInTheDocument(); + expect(getByText(/Canvas selection and highlighting are disabled/)).toBeInTheDocument(); + }); + + it('resolves the selected node and renders its properties', async () => { + const { getByTestId } = render(); + + // Drive a selection change through the subscription callback. + const onSelectionChange = mockSdk.exo.experience.selection.onChange.mock.calls[0][0]; + act(() => { + onSelectionChange({ nodeId: 'node-1', nodeType: 'Component' }); + }); + + expect(mockSdk.exo.experience.getNode).toHaveBeenCalledWith('node-1'); + + await waitFor(() => { + const table = getByTestId('properties-table'); + expect(table).toHaveTextContent('heading'); + expect(table).toHaveTextContent('backgroundColor'); + }); + }); +}); diff --git a/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx b/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx new file mode 100644 index 000000000..3ddd7d720 --- /dev/null +++ b/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx @@ -0,0 +1,240 @@ +import React, { useEffect, useState } from 'react'; +import type { + ComponentPropertyDescriptor, + ExoContext, + ExoNodeType, + ExperienceEditorToolbarAppSDK, + UiMode, +} from '@contentful/app-sdk'; +import { + Badge, + Box, + Flex, + Heading, + Note, + Paragraph, + Spinner, + Stack, + Subheading, + Table, + Text, +} from '@contentful/f36-components'; +import { useSDK } from '@contentful/react-apps-toolkit'; + +interface Selection { + nodeId: string | null; + nodeType?: ExoNodeType; +} + +/** + * A minimal Experience Editor toolbar app. It demonstrates the core `sdk.exo` + * patterns a toolbar app is built on: + * + * - reading `sdk.exo.context` to tell experience vs. fragment editing apart + * - reacting to `sdk.exo.onUiModeChanged()` (form vs. visual mode) + * - subscribing to `sdk.exo.experience.selection.onChange()` + * - resolving the selected node with `sdk.exo.experience.getNode(nodeId)` and + * reading its properties + * + * The app mounts once when the editor opens and stays mounted for the session; + * selection changes do NOT remount it, so all live data flows through the + * subscriptions below. Each `on*` call returns an unsubscribe function that we + * call on cleanup. + */ +const ExperienceToolbar = () => { + const sdk = useSDK(); + + const [context, setContext] = useState(() => sdk.exo.context); + const [uiMode, setUiMode] = useState(() => sdk.exo.getUiMode()); + const [selection, setSelection] = useState(() => + sdk.exo.experience.selection.get() + ); + const [properties, setProperties] = useState(null); + const [loadingProperties, setLoadingProperties] = useState(false); + + // Keep the editing context (experience vs. fragment) in sync. + useEffect(() => { + return sdk.exo.onContextChanged(setContext); + }, [sdk]); + + // Keep the UI mode (form vs. visual) in sync. In `form` mode, canvas + // affordances like selection highlighting are no-ops, so apps should degrade + // gracefully — here we just surface the current mode. + useEffect(() => { + return sdk.exo.onUiModeChanged(setUiMode); + }, [sdk]); + + // Track the canvas selection. + useEffect(() => { + return sdk.exo.experience.selection.onChange(setSelection); + }, [sdk]); + + // When the selection changes, resolve the node and read its properties. + useEffect(() => { + const { nodeId } = selection; + + if (!nodeId) { + setProperties(null); + setLoadingProperties(false); + return; + } + + const node = sdk.exo.experience.getNode(nodeId); + + if (!node) { + setProperties(null); + setLoadingProperties(false); + return; + } + + let active = true; + setLoadingProperties(true); + + node + .getProperties() + .then((props) => { + if (active) { + setProperties(props); + setLoadingProperties(false); + } + }) + .catch(() => { + if (active) { + setProperties(null); + setLoadingProperties(false); + } + }); + + // Re-read properties whenever this node changes underneath us. + const unsubscribe = node.onChange(() => { + node + .getProperties() + .then((props) => { + if (active) { + setProperties(props); + } + }) + .catch(() => { + /* node may have been removed; ignore */ + }); + }); + + return () => { + active = false; + unsubscribe(); + }; + }, [sdk, selection]); + + return ( + + + + Toolbar example + + {context.type} + + {uiMode} mode + + + + Editing {context.type} {context.entityId} + + + {uiMode === 'form' && ( + + You are in form mode. Canvas selection and highlighting are disabled — + switch to visual mode to select components on the canvas. + + )} + + + Selected component + + + + + ); +}; + +interface SelectedNodeProps { + selection: Selection; + properties: ComponentPropertyDescriptor[] | null; + loading: boolean; +} + +const SelectedNode = ({ selection, properties, loading }: SelectedNodeProps) => { + if (!selection.nodeId) { + return ( + + Select a component on the canvas to inspect its properties. + + ); + } + + return ( + + + {selection.nodeType ?? 'Node'} · {selection.nodeId} + + + {loading && } + + {!loading && properties && properties.length === 0 && ( + This component has no properties. + )} + + {!loading && properties && properties.length > 0 && ( + + + + Property + Area + Value + + + + {properties.map((property) => ( + + + {property.key} + + + + {property.area} + + + {formatPropertyValue(property)} + + ))} + +
+ )} +
+ ); +}; + +/** Renders a property value as a compact, readable string for display. */ +function formatPropertyValue(property: ComponentPropertyDescriptor): string { + if (property.binding) { + const { sourceType, entryId } = property.binding; + return entryId ? `${sourceType} → ${entryId}` : sourceType; + } + + const { value } = property; + + if (value === null || value === undefined) { + return '—'; + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + + return String(value); +} + +export default ExperienceToolbar; diff --git a/examples/experience-toolbar/src/setupTests.ts b/examples/experience-toolbar/src/setupTests.ts new file mode 100644 index 000000000..eb82e0f2f --- /dev/null +++ b/examples/experience-toolbar/src/setupTests.ts @@ -0,0 +1,10 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom'; +import { configure } from '@testing-library/react'; + +configure({ + testIdAttribute: 'data-test-id', +}); diff --git a/examples/experience-toolbar/test/mocks/index.ts b/examples/experience-toolbar/test/mocks/index.ts new file mode 100644 index 000000000..a149e2d7e --- /dev/null +++ b/examples/experience-toolbar/test/mocks/index.ts @@ -0,0 +1 @@ +export { mockSdk, mockNode, noopUnsubscribe } from './mockSdk'; diff --git a/examples/experience-toolbar/test/mocks/mockSdk.ts b/examples/experience-toolbar/test/mocks/mockSdk.ts new file mode 100644 index 000000000..e2a16d63f --- /dev/null +++ b/examples/experience-toolbar/test/mocks/mockSdk.ts @@ -0,0 +1,53 @@ +import { vi } from 'vitest'; + +/** + * A minimal mock of the `ExperienceEditorToolbarAppSDK` surface used by the + * example. Subscription methods record their callback so tests can drive + * changes (selection, ui mode, context) and assert the UI reacts. Each + * subscription returns an unsubscribe spy. + */ +const noopUnsubscribe = vi.fn(); + +const mockNode = { + id: 'node-1', + nodeType: 'Component' as const, + get: vi.fn().mockReturnValue({ id: 'node-1', nodeType: 'Component' }), + onChange: vi.fn().mockReturnValue(noopUnsubscribe), + getProperties: vi.fn().mockResolvedValue([ + { key: 'heading', area: 'content', value: 'Welcome' }, + { key: 'backgroundColor', area: 'design', value: { type: 'ManualDesignValue', value: '#fff' } }, + ]), +}; + +const mockSdk: any = { + location: { + is: vi.fn().mockReturnValue(true), + }, + ids: { + app: 'test-app', + }, + app: { + onConfigure: vi.fn(), + getParameters: vi.fn().mockResolvedValue({}), + setReady: vi.fn(), + getCurrentState: vi.fn().mockResolvedValue(null), + }, + exo: { + context: { type: 'experience', entityId: 'experience-123' }, + onContextChanged: vi.fn().mockReturnValue(noopUnsubscribe), + getUiMode: vi.fn().mockReturnValue('visual'), + onUiModeChanged: vi.fn().mockReturnValue(noopUnsubscribe), + experience: { + getNode: vi.fn().mockReturnValue(mockNode), + getRootNodes: vi.fn().mockReturnValue([mockNode]), + selection: { + get: vi.fn().mockReturnValue({ nodeId: null }), + onChange: vi.fn().mockReturnValue(noopUnsubscribe), + set: vi.fn(), + highlight: vi.fn(), + }, + }, + }, +}; + +export { mockSdk, mockNode, noopUnsubscribe }; diff --git a/examples/experience-toolbar/tsconfig.json b/examples/experience-toolbar/tsconfig.json new file mode 100644 index 000000000..697fd88e9 --- /dev/null +++ b/examples/experience-toolbar/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["actions", "functions"] +} diff --git a/examples/experience-toolbar/vite.config.mts b/examples/experience-toolbar/vite.config.mts new file mode 100644 index 000000000..438203f62 --- /dev/null +++ b/examples/experience-toolbar/vite.config.mts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, // Enables Jest-like global test functions (test, expect) + environment: 'jsdom', // Simulates a browser for component tests + setupFiles: './src/setupTests.ts', // Equivalent to Jest's setup file + }, + base: '', + build: { + outDir: 'build', + }, + server: { + host: 'localhost', + port: 3000, + }, +}); From 9efb31fbc46f9653239dcb3217f436693771fab3 Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Thu, 4 Jun 2026 13:47:35 -0600 Subject: [PATCH 02/11] test: cover spinner, error, and binding paths in toolbar example [EXT-7365] Address code review on the experience-toolbar starter: - add a deferred-promise spec asserting the loading spinner - add a spec for the getProperties rejection path (spinner clears, no table) - cover the formatPropertyValue binding branch ("entry -> entryId") - comment the previously-silent getProperties().catch() state reset Co-Authored-By: Claude Opus 4.8 --- .../src/locations/ExperienceToolbar.spec.tsx | 80 +++++++++++++++++++ .../src/locations/ExperienceToolbar.tsx | 3 + 2 files changed, 83 insertions(+) diff --git a/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx b/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx index a82095316..f336ba5bd 100644 --- a/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx +++ b/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx @@ -63,4 +63,84 @@ describe('ExperienceToolbar', () => { expect(table).toHaveTextContent('backgroundColor'); }); }); + + it('shows a loading spinner while properties resolve', async () => { + // A deferred promise lets us assert the spinner before resolving. + let resolveProps: (props: unknown[]) => void = () => {}; + const pending = new Promise((resolve) => { + resolveProps = resolve; + }); + mockSdk.exo.experience.getNode.mockReturnValue({ + id: 'node-1', + nodeType: 'Component', + onChange: vi.fn().mockReturnValue(vi.fn()), + getProperties: vi.fn().mockReturnValue(pending), + }); + + const { container, getByTestId } = render(); + const onSelectionChange = mockSdk.exo.experience.selection.onChange.mock.calls[0][0]; + act(() => { + onSelectionChange({ nodeId: 'node-1', nodeType: 'Component' }); + }); + + // Spinner is visible while the promise is unresolved. + await waitFor(() => + expect(container.querySelector('[data-test-id="cf-ui-spinner"]')).toBeInTheDocument() + ); + + await act(async () => { + resolveProps([{ key: 'heading', area: 'content', value: 'Welcome' }]); + }); + + await waitFor(() => expect(getByTestId('properties-table')).toHaveTextContent('heading')); + }); + + it('clears the spinner and renders no table when getProperties rejects', async () => { + mockSdk.exo.experience.getNode.mockReturnValue({ + id: 'node-1', + nodeType: 'Component', + onChange: vi.fn().mockReturnValue(vi.fn()), + getProperties: vi.fn().mockRejectedValueOnce(new Error('node removed')), + }); + + const { container, queryByTestId } = render(); + const onSelectionChange = mockSdk.exo.experience.selection.onChange.mock.calls[0][0]; + act(() => { + onSelectionChange({ nodeId: 'node-1', nodeType: 'Component' }); + }); + + // Degrades gracefully: the loading spinner clears and no properties table + // is rendered — the panel does not get stuck on a spinner. + await waitFor(() => + expect(container.querySelector('[data-test-id="cf-ui-spinner"]')).not.toBeInTheDocument() + ); + expect(queryByTestId('properties-table')).not.toBeInTheDocument(); + }); + + it('renders a bound property as "sourceType → entryId"', async () => { + mockSdk.exo.experience.getNode.mockReturnValue({ + id: 'node-1', + nodeType: 'Component', + onChange: vi.fn().mockReturnValue(vi.fn()), + getProperties: vi.fn().mockResolvedValue([ + { + key: 'title', + area: 'content', + value: null, + binding: { sourceType: 'entry', entryId: 'entry-42' }, + }, + ]), + }); + + const { getByTestId } = render(); + const onSelectionChange = mockSdk.exo.experience.selection.onChange.mock.calls[0][0]; + act(() => { + onSelectionChange({ nodeId: 'node-1', nodeType: 'Component' }); + }); + + await waitFor(() => { + const table = getByTestId('properties-table'); + expect(table).toHaveTextContent('entry → entry-42'); + }); + }); }); diff --git a/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx b/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx index 3ddd7d720..f790fa1f1 100644 --- a/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx +++ b/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx @@ -99,6 +99,9 @@ const ExperienceToolbar = () => { } }) .catch(() => { + // The node was likely removed or the host couldn't resolve it; clear + // the panel back to the empty state rather than leaving a stale spinner. + // A real app may want to surface this via sdk.notifier. if (active) { setProperties(null); setLoadingProperties(false); From 19d3ab938752a83fd12778a4654d59217971eac8 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Thu, 4 Jun 2026 19:48:50 -0600 Subject: [PATCH 03/11] feat: add canvas-highlight button to experience-toolbar example [EXT-7365] --- examples/experience-toolbar/README.md | 7 +++ .../src/locations/ExperienceToolbar.spec.tsx | 36 +++++++++++- .../src/locations/ExperienceToolbar.tsx | 57 ++++++++++++++++--- 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/examples/experience-toolbar/README.md b/examples/experience-toolbar/README.md index be28175fe..168ab9711 100644 --- a/examples/experience-toolbar/README.md +++ b/examples/experience-toolbar/README.md @@ -82,6 +82,13 @@ when the editor opens and stays mounted for the session — selection changes do **not** remount it, so all live data flows through `on*` subscriptions, each of which returns an unsubscribe function called on cleanup. +The toolbar can also **drive the canvas**, not just read from it. The "Highlight +on canvas" button calls `sdk.exo.experience.selection.highlight(nodeId, { flash: +true, scrollIntoView: true })` to flash and scroll to the selected component — +the outbound counterpart to the `selection.onChange` subscription the panel reads +from. Canvas affordances like highlighting are no-ops in `form` mode, so the +button is disabled there and enabled only in `visual` mode. + ## A note on verification This example is built against the published `@contentful/app-sdk@4.58.0` types, diff --git a/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx b/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx index f336ba5bd..2f831f55c 100644 --- a/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx +++ b/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { act, render, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; import { vi } from 'vitest'; import ExperienceToolbar from './ExperienceToolbar'; import { mockSdk } from '../../test/mocks'; @@ -143,4 +143,38 @@ describe('ExperienceToolbar', () => { expect(table).toHaveTextContent('entry → entry-42'); }); }); + + it('highlights the selected node on the canvas in visual mode', async () => { + mockSdk.exo.getUiMode.mockReturnValue('visual'); + + const { getByTestId } = render(); + const onSelectionChange = mockSdk.exo.experience.selection.onChange.mock.calls[0][0]; + act(() => { + onSelectionChange({ nodeId: 'node-1', nodeType: 'Component' }); + }); + + const button = await waitFor(() => getByTestId('highlight-button')); + fireEvent.click(button); + + expect(mockSdk.exo.experience.selection.highlight).toHaveBeenCalledWith('node-1', { + flash: true, + scrollIntoView: true, + }); + }); + + it('disables the highlight button in form mode', async () => { + mockSdk.exo.getUiMode.mockReturnValue('form'); + + const { getByTestId } = render(); + const onSelectionChange = mockSdk.exo.experience.selection.onChange.mock.calls[0][0]; + act(() => { + onSelectionChange({ nodeId: 'node-1', nodeType: 'Component' }); + }); + + const button = await waitFor(() => getByTestId('highlight-button')); + expect(button).toBeDisabled(); + + fireEvent.click(button); + expect(mockSdk.exo.experience.selection.highlight).not.toHaveBeenCalled(); + }); }); diff --git a/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx b/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx index f790fa1f1..86193c667 100644 --- a/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx +++ b/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx @@ -9,6 +9,7 @@ import type { import { Badge, Box, + Button, Flex, Heading, Note, @@ -18,6 +19,7 @@ import { Subheading, Table, Text, + Tooltip, } from '@contentful/f36-components'; import { useSDK } from '@contentful/react-apps-toolkit'; @@ -46,9 +48,7 @@ const ExperienceToolbar = () => { const [context, setContext] = useState(() => sdk.exo.context); const [uiMode, setUiMode] = useState(() => sdk.exo.getUiMode()); - const [selection, setSelection] = useState(() => - sdk.exo.experience.selection.get() - ); + const [selection, setSelection] = useState(() => sdk.exo.experience.selection.get()); const [properties, setProperties] = useState(null); const [loadingProperties, setLoadingProperties] = useState(false); @@ -128,6 +128,20 @@ const ExperienceToolbar = () => { }; }, [sdk, selection]); + // Drive the canvas from the toolbar: highlight (and scroll to) the selected + // node. This is the outbound counterpart to the selection.onChange subscription + // above — the app directing the canvas, not just reading from it. In form mode + // the host treats highlight as a no-op, so the button is disabled there. + const handleHighlight = () => { + if (!selection.nodeId) { + return; + } + sdk.exo.experience.selection.highlight(selection.nodeId, { + flash: true, + scrollIntoView: true, + }); + }; + return ( @@ -156,6 +170,8 @@ const ExperienceToolbar = () => { selection={selection} properties={properties} loading={loadingProperties} + uiMode={uiMode} + onHighlight={handleHighlight} /> @@ -167,9 +183,17 @@ interface SelectedNodeProps { selection: Selection; properties: ComponentPropertyDescriptor[] | null; loading: boolean; + uiMode: UiMode; + onHighlight: () => void; } -const SelectedNode = ({ selection, properties, loading }: SelectedNodeProps) => { +const SelectedNode = ({ + selection, + properties, + loading, + uiMode, + onHighlight, +}: SelectedNodeProps) => { if (!selection.nodeId) { return ( @@ -178,11 +202,30 @@ const SelectedNode = ({ selection, properties, loading }: SelectedNodeProps) => ); } + const canHighlight = uiMode === 'visual'; + return ( - - {selection.nodeType ?? 'Node'} · {selection.nodeId} - + + + {selection.nodeType ?? 'Node'} · {selection.nodeId} + + + + + {loading && } From 9e5121e28aa723a7713e038cffacf86c60c66a45 Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Tue, 9 Jun 2026 14:51:10 -0600 Subject: [PATCH 04/11] fix: align experience-toolbar starter with app-sdk 4.58.1 Binding union [EXT-7365] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4.58.1 reshaped ComponentPropertyDescriptor.binding to the typed `Binding` union (EntryBinding | ManualBinding), replacing `{ sourceType, entryId }`. - formatPropertyValue narrows on `binding.type === 'entry'` to read entryId off the EntryBinding arm (was destructuring the removed `sourceType`). - Spec uses the EntryBinding fixture shape `{ type, entryId, fieldId }`. - `area` rendering is unchanged — the discriminator is retained in the SDK. - Bump @contentful/app-sdk 4.58.0 -> 4.58.1. Verified: tsc --noEmit clean, 12 tests passing against 4.58.1. Co-Authored-By: Claude Opus 4.8 --- examples/experience-toolbar/package.json | 2 +- .../src/locations/ExperienceToolbar.spec.tsx | 4 ++-- .../experience-toolbar/src/locations/ExperienceToolbar.tsx | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/experience-toolbar/package.json b/examples/experience-toolbar/package.json index e27f3e34d..04e342f03 100644 --- a/examples/experience-toolbar/package.json +++ b/examples/experience-toolbar/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@contentful/app-sdk": "4.58.0", + "@contentful/app-sdk": "4.58.1", "@contentful/f36-components": "4.81.1", "@contentful/f36-tokens": "4.2.0", "@contentful/react-apps-toolkit": "1.2.16", diff --git a/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx b/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx index 2f831f55c..964629d93 100644 --- a/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx +++ b/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx @@ -117,7 +117,7 @@ describe('ExperienceToolbar', () => { expect(queryByTestId('properties-table')).not.toBeInTheDocument(); }); - it('renders a bound property as "sourceType → entryId"', async () => { + it('renders a bound property as "type → entryId"', async () => { mockSdk.exo.experience.getNode.mockReturnValue({ id: 'node-1', nodeType: 'Component', @@ -127,7 +127,7 @@ describe('ExperienceToolbar', () => { key: 'title', area: 'content', value: null, - binding: { sourceType: 'entry', entryId: 'entry-42' }, + binding: { type: 'entry', entryId: 'entry-42', fieldId: 'title' }, }, ]), }); diff --git a/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx b/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx index 86193c667..f2d122a5f 100644 --- a/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx +++ b/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx @@ -266,8 +266,8 @@ const SelectedNode = ({ /** Renders a property value as a compact, readable string for display. */ function formatPropertyValue(property: ComponentPropertyDescriptor): string { if (property.binding) { - const { sourceType, entryId } = property.binding; - return entryId ? `${sourceType} → ${entryId}` : sourceType; + const { binding } = property; + return binding.type === 'entry' ? `${binding.type} → ${binding.entryId}` : binding.type; } const { value } = property; From 182ebdb778c486fcb4f6a5ac85702d5a31e66aa2 Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Fri, 12 Jun 2026 13:39:03 -0600 Subject: [PATCH 05/11] fix: bump experience-toolbar example to app-sdk 4.58.2 [EXT-7365] Track the latest published ExO surface (DA definition-only trim landed in ui-extensions-sdk#2592, widget-renderer synced in experience-packages#3544). Co-Authored-By: Claude Opus 4.8 --- examples/experience-toolbar/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/experience-toolbar/package.json b/examples/experience-toolbar/package.json index 04e342f03..1969278ed 100644 --- a/examples/experience-toolbar/package.json +++ b/examples/experience-toolbar/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@contentful/app-sdk": "4.58.1", + "@contentful/app-sdk": "4.58.2", "@contentful/f36-components": "4.81.1", "@contentful/f36-tokens": "4.2.0", "@contentful/react-apps-toolkit": "1.2.16", From 1e31b5564ffc193576381daa9832370d9866d5ab Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Wed, 24 Jun 2026 11:36:36 -0600 Subject: [PATCH 06/11] fix: bump experience-toolbar example to app-sdk ^4.59.0 [EXT-7365] --- examples/experience-toolbar/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/experience-toolbar/package.json b/examples/experience-toolbar/package.json index 1969278ed..b1cd5aaea 100644 --- a/examples/experience-toolbar/package.json +++ b/examples/experience-toolbar/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@contentful/app-sdk": "4.58.2", + "@contentful/app-sdk": "^4.59.0", "@contentful/f36-components": "4.81.1", "@contentful/f36-tokens": "4.2.0", "@contentful/react-apps-toolkit": "1.2.16", From 5e9c7cf5c7a5046c01b1a591a2ac88fe45db4fa3 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Wed, 24 Jun 2026 17:00:05 -0600 Subject: [PATCH 07/11] fix: adopt renamed Experience SDK surface in experience-toolbar example [EXT-7497] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump @contentful/app-sdk floor ^4.59.0 → ^4.60.0 and apply the Exo*→Experience* / sdk.exo→sdk.experiences rename across all example source, test, mock, and documentation files. --- examples/experience-toolbar/README.md | 18 ++++---- examples/experience-toolbar/package.json | 2 +- .../src/locations/ExperienceToolbar.spec.tsx | 42 +++++++++---------- .../src/locations/ExperienceToolbar.tsx | 34 ++++++++------- .../experience-toolbar/test/mocks/mockSdk.ts | 2 +- 5 files changed, 50 insertions(+), 48 deletions(-) diff --git a/examples/experience-toolbar/README.md b/examples/experience-toolbar/README.md index 168ab9711..bcaec34f6 100644 --- a/examples/experience-toolbar/README.md +++ b/examples/experience-toolbar/README.md @@ -4,24 +4,24 @@ A minimal starter for an app that renders in the **Experience Editor toolbar** the new `experience-toolbar` location introduced in [`@contentful/app-sdk@4.58.0`](https://www.npmjs.com/package/@contentful/app-sdk). Toolbar apps run alongside the Experience Orchestration (ExO) editor and use the -`sdk.exo` namespace to read and react to the experience the user is editing. +`sdk.experiences` namespace to read and react to the experience the user is editing. This example is intentionally small. It demonstrates the core building blocks of a toolbar app: - **Location detection** via `sdk.location.is(locations.LOCATION_EXPERIENCE_TOOLBAR)` -- **Context awareness** — reading `sdk.exo.context` to tell whether the user is +- **Context awareness** — reading `sdk.experiences.context` to tell whether the user is editing an `experience` or a `fragment` -- **UI mode** — reacting to `sdk.exo.onUiModeChanged()` (`form` vs. `visual`) -- **Selection** — subscribing to `sdk.exo.experience.selection.onChange()` +- **UI mode** — reacting to `sdk.experiences.onUiModeChanged()` (`form` vs. `visual`) +- **Selection** — subscribing to `sdk.experiences.experience.selection.onChange()` - **Node inspection** — resolving the selected node with - `sdk.exo.experience.getNode(nodeId)` and reading its properties + `sdk.experiences.experience.getNode(nodeId)` and reading its properties It is fully typed with `ExperienceEditorToolbarAppSDK`. > **Out of scope (by design).** This starter does not mutate the experience. > Data Assembly, `save()`/`publish()`, and property writes are deliberately left -> out to keep the template focused. See the SDK reference for the full `sdk.exo` +> out to keep the template focused. See the SDK reference for the full `sdk.experiences` > surface. ## How to use @@ -83,7 +83,7 @@ when the editor opens and stays mounted for the session — selection changes do which returns an unsubscribe function called on cleanup. The toolbar can also **drive the canvas**, not just read from it. The "Highlight -on canvas" button calls `sdk.exo.experience.selection.highlight(nodeId, { flash: +on canvas" button calls `sdk.experiences.experience.selection.highlight(nodeId, { flash: true, scrollIntoView: true })` to flash and scroll to the selected component — the outbound counterpart to the `selection.onChange` subscription the panel reads from. Canvas affordances like highlighting are no-ops in `form` mode, so the @@ -93,7 +93,7 @@ button is disabled there and enabled only in `visual` mode. This example is built against the published `@contentful/app-sdk@4.58.0` types, which are the contract for the toolbar location. At the time of writing, the host -renderer that serves `sdk.exo` at runtime is still rolling out, so the example is +renderer that serves `sdk.experiences` at runtime is still rolling out, so the example is **type-verified and unit-tested against a mocked SDK**, but not yet verified end-to-end inside a live ExO editor. The API shapes used here match the published types exactly. @@ -131,7 +131,7 @@ variables (for CI pipelines): To make your app look and feel like Contentful, use: - [Forma 36](https://f36.contentful.com/) – Contentful's design system -- [App SDK](https://www.contentful.com/developers/docs/extensibility/app-framework/sdk/) – the `sdk.exo` reference +- [App SDK](https://www.contentful.com/developers/docs/extensibility/app-framework/sdk/) – the `sdk.experiences` reference ## Learn More diff --git a/examples/experience-toolbar/package.json b/examples/experience-toolbar/package.json index b1cd5aaea..5d4023650 100644 --- a/examples/experience-toolbar/package.json +++ b/examples/experience-toolbar/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@contentful/app-sdk": "^4.59.0", + "@contentful/app-sdk": "^4.60.0", "@contentful/f36-components": "4.81.1", "@contentful/f36-tokens": "4.2.0", "@contentful/react-apps-toolkit": "1.2.16", diff --git a/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx b/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx index 964629d93..ac5cde642 100644 --- a/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx +++ b/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx @@ -11,9 +11,9 @@ vi.mock('@contentful/react-apps-toolkit', () => ({ describe('ExperienceToolbar', () => { beforeEach(() => { vi.clearAllMocks(); - mockSdk.exo.context = { type: 'experience', entityId: 'experience-123' }; - mockSdk.exo.getUiMode.mockReturnValue('visual'); - mockSdk.exo.experience.selection.get.mockReturnValue({ nodeId: null }); + mockSdk.experiences.context = { type: 'experience', entityId: 'experience-123' }; + mockSdk.experiences.getUiMode.mockReturnValue('visual'); + mockSdk.experiences.experience.selection.get.mockReturnValue({ nodeId: null }); }); it('renders the editing context and ui mode', () => { @@ -32,13 +32,13 @@ describe('ExperienceToolbar', () => { it('subscribes to context, ui mode, and selection changes', () => { render(); - expect(mockSdk.exo.onContextChanged).toHaveBeenCalledOnce(); - expect(mockSdk.exo.onUiModeChanged).toHaveBeenCalledOnce(); - expect(mockSdk.exo.experience.selection.onChange).toHaveBeenCalledOnce(); + expect(mockSdk.experiences.onContextChanged).toHaveBeenCalledOnce(); + expect(mockSdk.experiences.onUiModeChanged).toHaveBeenCalledOnce(); + expect(mockSdk.experiences.experience.selection.onChange).toHaveBeenCalledOnce(); }); it('warns when in form mode', () => { - mockSdk.exo.getUiMode.mockReturnValue('form'); + mockSdk.experiences.getUiMode.mockReturnValue('form'); const { getByText } = render(); @@ -50,12 +50,12 @@ describe('ExperienceToolbar', () => { const { getByTestId } = render(); // Drive a selection change through the subscription callback. - const onSelectionChange = mockSdk.exo.experience.selection.onChange.mock.calls[0][0]; + const onSelectionChange = mockSdk.experiences.experience.selection.onChange.mock.calls[0][0]; act(() => { onSelectionChange({ nodeId: 'node-1', nodeType: 'Component' }); }); - expect(mockSdk.exo.experience.getNode).toHaveBeenCalledWith('node-1'); + expect(mockSdk.experiences.experience.getNode).toHaveBeenCalledWith('node-1'); await waitFor(() => { const table = getByTestId('properties-table'); @@ -70,7 +70,7 @@ describe('ExperienceToolbar', () => { const pending = new Promise((resolve) => { resolveProps = resolve; }); - mockSdk.exo.experience.getNode.mockReturnValue({ + mockSdk.experiences.experience.getNode.mockReturnValue({ id: 'node-1', nodeType: 'Component', onChange: vi.fn().mockReturnValue(vi.fn()), @@ -78,7 +78,7 @@ describe('ExperienceToolbar', () => { }); const { container, getByTestId } = render(); - const onSelectionChange = mockSdk.exo.experience.selection.onChange.mock.calls[0][0]; + const onSelectionChange = mockSdk.experiences.experience.selection.onChange.mock.calls[0][0]; act(() => { onSelectionChange({ nodeId: 'node-1', nodeType: 'Component' }); }); @@ -96,7 +96,7 @@ describe('ExperienceToolbar', () => { }); it('clears the spinner and renders no table when getProperties rejects', async () => { - mockSdk.exo.experience.getNode.mockReturnValue({ + mockSdk.experiences.experience.getNode.mockReturnValue({ id: 'node-1', nodeType: 'Component', onChange: vi.fn().mockReturnValue(vi.fn()), @@ -104,7 +104,7 @@ describe('ExperienceToolbar', () => { }); const { container, queryByTestId } = render(); - const onSelectionChange = mockSdk.exo.experience.selection.onChange.mock.calls[0][0]; + const onSelectionChange = mockSdk.experiences.experience.selection.onChange.mock.calls[0][0]; act(() => { onSelectionChange({ nodeId: 'node-1', nodeType: 'Component' }); }); @@ -118,7 +118,7 @@ describe('ExperienceToolbar', () => { }); it('renders a bound property as "type → entryId"', async () => { - mockSdk.exo.experience.getNode.mockReturnValue({ + mockSdk.experiences.experience.getNode.mockReturnValue({ id: 'node-1', nodeType: 'Component', onChange: vi.fn().mockReturnValue(vi.fn()), @@ -133,7 +133,7 @@ describe('ExperienceToolbar', () => { }); const { getByTestId } = render(); - const onSelectionChange = mockSdk.exo.experience.selection.onChange.mock.calls[0][0]; + const onSelectionChange = mockSdk.experiences.experience.selection.onChange.mock.calls[0][0]; act(() => { onSelectionChange({ nodeId: 'node-1', nodeType: 'Component' }); }); @@ -145,10 +145,10 @@ describe('ExperienceToolbar', () => { }); it('highlights the selected node on the canvas in visual mode', async () => { - mockSdk.exo.getUiMode.mockReturnValue('visual'); + mockSdk.experiences.getUiMode.mockReturnValue('visual'); const { getByTestId } = render(); - const onSelectionChange = mockSdk.exo.experience.selection.onChange.mock.calls[0][0]; + const onSelectionChange = mockSdk.experiences.experience.selection.onChange.mock.calls[0][0]; act(() => { onSelectionChange({ nodeId: 'node-1', nodeType: 'Component' }); }); @@ -156,17 +156,17 @@ describe('ExperienceToolbar', () => { const button = await waitFor(() => getByTestId('highlight-button')); fireEvent.click(button); - expect(mockSdk.exo.experience.selection.highlight).toHaveBeenCalledWith('node-1', { + expect(mockSdk.experiences.experience.selection.highlight).toHaveBeenCalledWith('node-1', { flash: true, scrollIntoView: true, }); }); it('disables the highlight button in form mode', async () => { - mockSdk.exo.getUiMode.mockReturnValue('form'); + mockSdk.experiences.getUiMode.mockReturnValue('form'); const { getByTestId } = render(); - const onSelectionChange = mockSdk.exo.experience.selection.onChange.mock.calls[0][0]; + const onSelectionChange = mockSdk.experiences.experience.selection.onChange.mock.calls[0][0]; act(() => { onSelectionChange({ nodeId: 'node-1', nodeType: 'Component' }); }); @@ -175,6 +175,6 @@ describe('ExperienceToolbar', () => { expect(button).toBeDisabled(); fireEvent.click(button); - expect(mockSdk.exo.experience.selection.highlight).not.toHaveBeenCalled(); + expect(mockSdk.experiences.experience.selection.highlight).not.toHaveBeenCalled(); }); }); diff --git a/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx b/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx index f2d122a5f..95dfeabae 100644 --- a/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx +++ b/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useState } from 'react'; import type { ComponentPropertyDescriptor, - ExoContext, - ExoNodeType, + ExperienceContext, + ExperienceNodeType, ExperienceEditorToolbarAppSDK, UiMode, } from '@contentful/app-sdk'; @@ -25,17 +25,17 @@ import { useSDK } from '@contentful/react-apps-toolkit'; interface Selection { nodeId: string | null; - nodeType?: ExoNodeType; + nodeType?: ExperienceNodeType; } /** - * A minimal Experience Editor toolbar app. It demonstrates the core `sdk.exo` + * A minimal Experience Editor toolbar app. It demonstrates the core `sdk.experiences` * patterns a toolbar app is built on: * - * - reading `sdk.exo.context` to tell experience vs. fragment editing apart - * - reacting to `sdk.exo.onUiModeChanged()` (form vs. visual mode) - * - subscribing to `sdk.exo.experience.selection.onChange()` - * - resolving the selected node with `sdk.exo.experience.getNode(nodeId)` and + * - reading `sdk.experiences.context` to tell experience vs. fragment editing apart + * - reacting to `sdk.experiences.onUiModeChanged()` (form vs. visual mode) + * - subscribing to `sdk.experiences.experience.selection.onChange()` + * - resolving the selected node with `sdk.experiences.experience.getNode(nodeId)` and * reading its properties * * The app mounts once when the editor opens and stays mounted for the session; @@ -46,27 +46,29 @@ interface Selection { const ExperienceToolbar = () => { const sdk = useSDK(); - const [context, setContext] = useState(() => sdk.exo.context); - const [uiMode, setUiMode] = useState(() => sdk.exo.getUiMode()); - const [selection, setSelection] = useState(() => sdk.exo.experience.selection.get()); + const [context, setContext] = useState(() => sdk.experiences.context); + const [uiMode, setUiMode] = useState(() => sdk.experiences.getUiMode()); + const [selection, setSelection] = useState(() => + sdk.experiences.experience.selection.get() + ); const [properties, setProperties] = useState(null); const [loadingProperties, setLoadingProperties] = useState(false); // Keep the editing context (experience vs. fragment) in sync. useEffect(() => { - return sdk.exo.onContextChanged(setContext); + return sdk.experiences.onContextChanged(setContext); }, [sdk]); // Keep the UI mode (form vs. visual) in sync. In `form` mode, canvas // affordances like selection highlighting are no-ops, so apps should degrade // gracefully — here we just surface the current mode. useEffect(() => { - return sdk.exo.onUiModeChanged(setUiMode); + return sdk.experiences.onUiModeChanged(setUiMode); }, [sdk]); // Track the canvas selection. useEffect(() => { - return sdk.exo.experience.selection.onChange(setSelection); + return sdk.experiences.experience.selection.onChange(setSelection); }, [sdk]); // When the selection changes, resolve the node and read its properties. @@ -79,7 +81,7 @@ const ExperienceToolbar = () => { return; } - const node = sdk.exo.experience.getNode(nodeId); + const node = sdk.experiences.experience.getNode(nodeId); if (!node) { setProperties(null); @@ -136,7 +138,7 @@ const ExperienceToolbar = () => { if (!selection.nodeId) { return; } - sdk.exo.experience.selection.highlight(selection.nodeId, { + sdk.experiences.experience.selection.highlight(selection.nodeId, { flash: true, scrollIntoView: true, }); diff --git a/examples/experience-toolbar/test/mocks/mockSdk.ts b/examples/experience-toolbar/test/mocks/mockSdk.ts index e2a16d63f..ec072b013 100644 --- a/examples/experience-toolbar/test/mocks/mockSdk.ts +++ b/examples/experience-toolbar/test/mocks/mockSdk.ts @@ -32,7 +32,7 @@ const mockSdk: any = { setReady: vi.fn(), getCurrentState: vi.fn().mockResolvedValue(null), }, - exo: { + experiences: { context: { type: 'experience', entityId: 'experience-123' }, onContextChanged: vi.fn().mockReturnValue(noopUnsubscribe), getUiMode: vi.fn().mockReturnValue('visual'), From 2c5cd83042591aa810261c43149854d052821d44 Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Mon, 6 Jul 2026 10:36:27 -0600 Subject: [PATCH 08/11] docs: correct app-sdk version and drop ExO phrasing in experience-toolbar [EXT-7365] README referenced @contentful/app-sdk@4.58.0 (which shipped the old sdk.exo surface) while package.json pins ^4.60.0 (where sdk.experiences first exists), so a reader checking the version would hit a surface mismatch. Bumps both references to 4.60.0 and drops customer-facing "ExO" phrasing per the approved naming (internal-only abbreviation). Co-Authored-By: Claude Opus 4.8 --- examples/experience-toolbar/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/experience-toolbar/README.md b/examples/experience-toolbar/README.md index bcaec34f6..13862cdf5 100644 --- a/examples/experience-toolbar/README.md +++ b/examples/experience-toolbar/README.md @@ -2,8 +2,8 @@ A minimal starter for an app that renders in the **Experience Editor toolbar** — the new `experience-toolbar` location introduced in -[`@contentful/app-sdk@4.58.0`](https://www.npmjs.com/package/@contentful/app-sdk). -Toolbar apps run alongside the Experience Orchestration (ExO) editor and use the +[`@contentful/app-sdk@4.60.0`](https://www.npmjs.com/package/@contentful/app-sdk). +Toolbar apps run alongside the Experience Editor and use the `sdk.experiences` namespace to read and react to the experience the user is editing. This example is intentionally small. It demonstrates the core building blocks of @@ -91,11 +91,11 @@ button is disabled there and enabled only in `visual` mode. ## A note on verification -This example is built against the published `@contentful/app-sdk@4.58.0` types, +This example is built against the published `@contentful/app-sdk@4.60.0` types, which are the contract for the toolbar location. At the time of writing, the host renderer that serves `sdk.experiences` at runtime is still rolling out, so the example is **type-verified and unit-tested against a mocked SDK**, but not yet verified -end-to-end inside a live ExO editor. The API shapes used here match the published +end-to-end inside a live Experience Editor. The API shapes used here match the published types exactly. ## Available Scripts From 35eb56cb16ba45835edd738f4754aee2760e9774 Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Wed, 8 Jul 2026 11:16:51 -0600 Subject: [PATCH 09/11] fix: drop UI mode usage from experience-toolbar example [EXT-7365] app-sdk 4.63.1 removes the experience getUiMode()/onUiModeChanged() surface, which this starter demonstrated. Remove the mode badge, the form-mode notice, and the mode-gated highlight button; the highlight action now keys off whether a node is selected. Bump the app-sdk pin to ^4.63.1. Co-Authored-By: Claude Opus 4.8 --- examples/experience-toolbar/README.md | 1 - examples/experience-toolbar/package.json | 2 +- .../src/locations/ExperienceToolbar.spec.tsx | 38 ++--------------- .../src/locations/ExperienceToolbar.tsx | 41 ++----------------- .../experience-toolbar/test/mocks/mockSdk.ts | 4 +- 5 files changed, 9 insertions(+), 77 deletions(-) diff --git a/examples/experience-toolbar/README.md b/examples/experience-toolbar/README.md index 13862cdf5..eccf825d7 100644 --- a/examples/experience-toolbar/README.md +++ b/examples/experience-toolbar/README.md @@ -12,7 +12,6 @@ a toolbar app: - **Location detection** via `sdk.location.is(locations.LOCATION_EXPERIENCE_TOOLBAR)` - **Context awareness** — reading `sdk.experiences.context` to tell whether the user is editing an `experience` or a `fragment` -- **UI mode** — reacting to `sdk.experiences.onUiModeChanged()` (`form` vs. `visual`) - **Selection** — subscribing to `sdk.experiences.experience.selection.onChange()` - **Node inspection** — resolving the selected node with `sdk.experiences.experience.getNode(nodeId)` and reading its properties diff --git a/examples/experience-toolbar/package.json b/examples/experience-toolbar/package.json index 5d4023650..6694e9dc5 100644 --- a/examples/experience-toolbar/package.json +++ b/examples/experience-toolbar/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@contentful/app-sdk": "^4.60.0", + "@contentful/app-sdk": "^4.63.1", "@contentful/f36-components": "4.81.1", "@contentful/f36-tokens": "4.2.0", "@contentful/react-apps-toolkit": "1.2.16", diff --git a/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx b/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx index ac5cde642..d370b3027 100644 --- a/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx +++ b/examples/experience-toolbar/src/locations/ExperienceToolbar.spec.tsx @@ -12,14 +12,12 @@ describe('ExperienceToolbar', () => { beforeEach(() => { vi.clearAllMocks(); mockSdk.experiences.context = { type: 'experience', entityId: 'experience-123' }; - mockSdk.experiences.getUiMode.mockReturnValue('visual'); mockSdk.experiences.experience.selection.get.mockReturnValue({ nodeId: null }); }); - it('renders the editing context and ui mode', () => { - const { getByText, getByTestId } = render(); + it('renders the editing context', () => { + const { getByTestId } = render(); - expect(getByText('visual mode')).toBeInTheDocument(); expect(getByTestId('entity-id')).toHaveTextContent('Editing experience experience-123'); }); @@ -29,23 +27,13 @@ describe('ExperienceToolbar', () => { expect(getByTestId('empty-state')).toBeInTheDocument(); }); - it('subscribes to context, ui mode, and selection changes', () => { + it('subscribes to context and selection changes', () => { render(); expect(mockSdk.experiences.onContextChanged).toHaveBeenCalledOnce(); - expect(mockSdk.experiences.onUiModeChanged).toHaveBeenCalledOnce(); expect(mockSdk.experiences.experience.selection.onChange).toHaveBeenCalledOnce(); }); - it('warns when in form mode', () => { - mockSdk.experiences.getUiMode.mockReturnValue('form'); - - const { getByText } = render(); - - expect(getByText('form mode')).toBeInTheDocument(); - expect(getByText(/Canvas selection and highlighting are disabled/)).toBeInTheDocument(); - }); - it('resolves the selected node and renders its properties', async () => { const { getByTestId } = render(); @@ -144,9 +132,7 @@ describe('ExperienceToolbar', () => { }); }); - it('highlights the selected node on the canvas in visual mode', async () => { - mockSdk.experiences.getUiMode.mockReturnValue('visual'); - + it('highlights the selected node on the canvas', async () => { const { getByTestId } = render(); const onSelectionChange = mockSdk.experiences.experience.selection.onChange.mock.calls[0][0]; act(() => { @@ -161,20 +147,4 @@ describe('ExperienceToolbar', () => { scrollIntoView: true, }); }); - - it('disables the highlight button in form mode', async () => { - mockSdk.experiences.getUiMode.mockReturnValue('form'); - - const { getByTestId } = render(); - const onSelectionChange = mockSdk.experiences.experience.selection.onChange.mock.calls[0][0]; - act(() => { - onSelectionChange({ nodeId: 'node-1', nodeType: 'Component' }); - }); - - const button = await waitFor(() => getByTestId('highlight-button')); - expect(button).toBeDisabled(); - - fireEvent.click(button); - expect(mockSdk.experiences.experience.selection.highlight).not.toHaveBeenCalled(); - }); }); diff --git a/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx b/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx index 95dfeabae..39418e30c 100644 --- a/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx +++ b/examples/experience-toolbar/src/locations/ExperienceToolbar.tsx @@ -4,7 +4,6 @@ import type { ExperienceContext, ExperienceNodeType, ExperienceEditorToolbarAppSDK, - UiMode, } from '@contentful/app-sdk'; import { Badge, @@ -33,7 +32,6 @@ interface Selection { * patterns a toolbar app is built on: * * - reading `sdk.experiences.context` to tell experience vs. fragment editing apart - * - reacting to `sdk.experiences.onUiModeChanged()` (form vs. visual mode) * - subscribing to `sdk.experiences.experience.selection.onChange()` * - resolving the selected node with `sdk.experiences.experience.getNode(nodeId)` and * reading its properties @@ -47,7 +45,6 @@ const ExperienceToolbar = () => { const sdk = useSDK(); const [context, setContext] = useState(() => sdk.experiences.context); - const [uiMode, setUiMode] = useState(() => sdk.experiences.getUiMode()); const [selection, setSelection] = useState(() => sdk.experiences.experience.selection.get() ); @@ -59,13 +56,6 @@ const ExperienceToolbar = () => { return sdk.experiences.onContextChanged(setContext); }, [sdk]); - // Keep the UI mode (form vs. visual) in sync. In `form` mode, canvas - // affordances like selection highlighting are no-ops, so apps should degrade - // gracefully — here we just surface the current mode. - useEffect(() => { - return sdk.experiences.onUiModeChanged(setUiMode); - }, [sdk]); - // Track the canvas selection. useEffect(() => { return sdk.experiences.experience.selection.onChange(setSelection); @@ -132,8 +122,7 @@ const ExperienceToolbar = () => { // Drive the canvas from the toolbar: highlight (and scroll to) the selected // node. This is the outbound counterpart to the selection.onChange subscription - // above — the app directing the canvas, not just reading from it. In form mode - // the host treats highlight as a no-op, so the button is disabled there. + // above — the app directing the canvas, not just reading from it. const handleHighlight = () => { if (!selection.nodeId) { return; @@ -152,27 +141,18 @@ const ExperienceToolbar = () => { {context.type} - {uiMode} mode Editing {context.type} {context.entityId} - {uiMode === 'form' && ( - - You are in form mode. Canvas selection and highlighting are disabled — - switch to visual mode to select components on the canvas. - - )} - Selected component @@ -185,17 +165,10 @@ interface SelectedNodeProps { selection: Selection; properties: ComponentPropertyDescriptor[] | null; loading: boolean; - uiMode: UiMode; onHighlight: () => void; } -const SelectedNode = ({ - selection, - properties, - loading, - uiMode, - onHighlight, -}: SelectedNodeProps) => { +const SelectedNode = ({ selection, properties, loading, onHighlight }: SelectedNodeProps) => { if (!selection.nodeId) { return ( @@ -204,24 +177,16 @@ const SelectedNode = ({ ); } - const canHighlight = uiMode === 'visual'; - return ( {selection.nodeType ?? 'Node'} · {selection.nodeId} - + From 5e27e878f51d8c0dc76a27fc6307e871c59f8383 Mon Sep 17 00:00:00 2001 From: Jared Jolton Date: Thu, 9 Jul 2026 14:13:03 -0600 Subject: [PATCH 11/11] docs: remove stale form-mode gating from experience-toolbar README [EXT-7365] UI mode tracking was dropped from the example in 35eb56cb1; the README still claimed the highlight button was disabled in form mode. Co-authored-by: Cursor --- examples/experience-toolbar/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/experience-toolbar/README.md b/examples/experience-toolbar/README.md index eccf825d7..7a03ebc7a 100644 --- a/examples/experience-toolbar/README.md +++ b/examples/experience-toolbar/README.md @@ -85,8 +85,7 @@ The toolbar can also **drive the canvas**, not just read from it. The "Highlight on canvas" button calls `sdk.experiences.experience.selection.highlight(nodeId, { flash: true, scrollIntoView: true })` to flash and scroll to the selected component — the outbound counterpart to the `selection.onChange` subscription the panel reads -from. Canvas affordances like highlighting are no-ops in `form` mode, so the -button is disabled there and enabled only in `visual` mode. +from. ## A note on verification