Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions examples/experience-toolbar/.gitignore
Original file line number Diff line number Diff line change
@@ -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*
138 changes: 138 additions & 0 deletions examples/experience-toolbar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# 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.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
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`
- **Selection** — subscribing to `sdk.experiences.experience.selection.onChange()`
- **Node inspection** — resolving the selected node with
`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.experiences`
> 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.

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.

## A note on verification

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 Experience 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.experiences` reference

## Learn More

[Read more](https://www.contentful.com/developers/docs/extensibility/app-framework/create-contentful-app/)
about the Create Contentful App CLI.
20 changes: 20 additions & 0 deletions examples/experience-toolbar/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start`.
To create a production bundle, use `npm run build`.
-->
</body>
</html>
55 changes: 55 additions & 0 deletions examples/experience-toolbar/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"name": "experience-toolbar-example",
"version": "0.1.0",
"private": true,
"dependencies": {
"@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",
"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": "."
}
27 changes: 27 additions & 0 deletions examples/experience-toolbar/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 ? <Component /> : null;
};

export default App;
32 changes: 32 additions & 0 deletions examples/experience-toolbar/src/components/LocalhostWarning.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import { Paragraph, TextLink, Note, Flex } from '@contentful/f36-components';

const LocalhostWarning = () => {
return (
<Flex marginTop="spacingXl" justifyContent="center">
<Note title="App running outside of Contentful" style={{ maxWidth: '800px' }}>
<Paragraph>
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{' '}
<TextLink href="https://www.contentful.com/developers/docs/extensibility/ui-extensions/sdk-reference/#locations">
available locations
</TextLink>
.
</Paragraph>
<br />

<Paragraph>
Follow{' '}
<TextLink href="https://www.contentful.com/developers/docs/extensibility/app-framework/tutorial/#embed-your-app-in-the-contentful-web-app">
our guide
</TextLink>{' '}
to get started or{' '}
<TextLink href="https://app.contentful.com/deeplink?link=apps">open Contentful</TextLink>{' '}
to manage your app.
</Paragraph>
</Note>
</Flex>
);
};

export default LocalhostWarning;
23 changes: 23 additions & 0 deletions examples/experience-toolbar/src/index.tsx
Original file line number Diff line number Diff line change
@@ -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(<LocalhostWarning />);
} else {
root.render(
<SDKProvider>
<GlobalStyles />
<App />
</SDKProvider>
);
}
23 changes: 23 additions & 0 deletions examples/experience-toolbar/src/locations/ConfigScreen.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<ConfigScreen />);

expect(getByText('Experience Toolbar example')).toBeInTheDocument();
});

it('calls setReady once parameters are loaded', async () => {
render(<ConfigScreen />);

await waitFor(() => expect(mockSdk.app.setReady).toHaveBeenCalled());
});
});
Loading
Loading