TDD: twenty-ui render coverage in the front-component sandbox — golden tests cataloging every sandbox gap (fully green) (#23203)

## What — TDD coverage layer, no fixes

Renders every twenty-ui component export inside the real front-component
sandbox (opaque-origin iframe + remote-dom worker, real SDK esbuild
pipeline) through the existing Playwright-backed storybook vitest suite,
and expresses **every sandbox gap as a golden known-failure test**. This
PR deliberately ships **zero fixes** and is **fully green (267/267)**:
each known issue has a test asserting the exact current broken behavior.
A golden test fails on regression (an unexpected component starts
failing) AND on fix (the documented failure disappears) — so every
future fix must flip its golden assertion to the strict one, whose
acceptance criteria are spelled out in each story's comment.

- One gallery fixture per submodule under
`src/__stories__/twenty-ui-gallery/` (~150 components)
- `component-gallery.tsx`: each component renders inside its own error
boundary; failures are aggregated with component name and error message
(`data-failed-messages`) so one crash cannot mask the rest
- Behavior-level stories that a fake fix cannot pass (MutationObserver
must actually fire; a link click must reach `hostApi.navigate`)
- `vitest.config.ts` re-export so the storybook in-UI "Run tests" button
works

## Current state: 267/267 green — mergeable; 18 golden tests encode the
catalog below

## Failure catalog (each entry = golden tests asserting today's broken
behavior)

### 1. `MutationObserver.observe is not a function` — 6 failing tests

`@remote-dom/polyfill` ships `MutationObserver` as an empty class:
construction succeeds, the first `.observe()` call crashes.

| Failing test | twenty-ui export(s) | Call site |
|---|---|---|
| Input React + Preact | `Radio`, `RadioGroup`, `CardPicker` |
`@base-ui/react` field internals observe form state |
| Surfaces React + Preact | `AppTooltip` | twenty-ui observes
`document.body` to track anchors |
| Worker Platform APIs: Mutation Observer React + Preact | (behavior
test) | asserts the observer actually fires on a React-driven insertion
— a no-op stub cannot pass it |

Validated fix (branch history, commit 94b96d01e79e): implement real
mutation semantics **locally in the worker** by tapping the same
`@remote-dom/polyfill` hooks
(`insertChild`/`removeChild`/`setAttribute`/`removeAttribute`/`setText`)
remote-dom already uses to mirror mutations to the host. The worker tree
is the source of truth — no host bridge needed.

### 2. `getComputedStyle is not a function` — 4 failing tests

The remote-dom `Window` polyfill does not implement `getComputedStyle`;
`@base-ui/react` Collapsible calls it on open.

| Failing test | twenty-ui export(s) |
|---|---|
| Layout React + Preact | `AnimatedEaseInOut`,
`AnimatedExpandableContainer` |
| Json Visualizer React + Preact | `JsonTree`, `JsonArrayNode`,
`JsonObjectNode`, `JsonNestedNode` (all via `JsonNestedNode`'s
Collapsible) |

Validated fix (commit 7af577054fd9): `getComputedStyle` is synchronous
and layout only exists host-side, so a bridge is impossible — an
inert-but-valid stub (`0s` durations, `none` animation names) is the
honest terminal state.

### 3. No router context in the sandbox — 5 failing tests

react-router's `Link` reads `NavigationContext`, which has no provider
inside the worker: `Cannot destructure property 'basename' of
'useContext(...)' as it is null`.

| Failing test | twenty-ui export(s) |
|---|---|
| Navigation React + Preact | `RawLink`, `UndecoratedLink` |
| Data Display React + Preact | `LinkChip` |
| HostApi: Router Link | acceptance test — the link must render AND its
click must reach `hostApi.navigate` |

Validated fix (commit 221c9c353dc8): SDK build plugin wraps the
component tree in a low-level react-router `<Router>` whose custom
navigator forwards `push`/`replace` to the SDK `navigate` host API (NOT
a MemoryRouter, which would render links but swallow clicks into
in-memory history). Falls back to a pass-through provider when the app
has no react-router-dom dependency.

**Security finding bundled in that commit:** clicking a mirrored `<a>`
performs a native host-page navigation before the async worker
round-trip can `preventDefault` — a front component can escape the
sandbox by rendering a link. Fix: apply the renderer's existing
preventDefault-then-forward guard (already used for form submits) to
anchor clicks, keeping `target="_blank"` native. The Router Link story's
flip-to acceptance assertions (click must reach `hostApi.navigate`) will
hard-crash the vitest browser the moment links render without this guard
— by design.

### 4. Open `Modal` hangs the React runtime — 1 failing test

base-ui Dialog portal with `isOpen` never commits under the React
runtime (no error thrown); works under Preact.

| Failing test | twenty-ui export |
|---|---|
| Modal Open React (Modal Open Preact passes) | `Modal` |

Isolated in its own fixture so the hang cannot mask the rest of the
surfaces gallery (which keeps a closed Modal for mount coverage). Root
cause not yet identified — first experiment: portal into a worker-owned
container instead of the polyfilled `document.body`.

### 5. monaco cannot load in the sandbox — 2 failing tests

`@monaco-editor/react` lazy-loads monaco via script injection,
impossible in the polyfilled worker DOM (opaque-origin CSP, no script
loading). The CodeEditor wrapper mounts; monaco's `onMount` never fires.

| Failing test | twenty-ui export |
|---|---|
| Code Editor React + Preact | `CodeEditor` |

Probably wont-fix in the worker: if front components need a code editor,
the path is a host-rendered privileged component. The failing test is
the documentation.

### Also worth knowing (no failing test possible yet)

- `ResizeObserver` / `IntersectionObserver` / `matchMedia` are absent in
the worker: components mount without them today only because nothing
crashes at mount — behavior like popup auto-resize and `useIsMobile` is
silently wrong. Real fix is a host bridge (async observers on the
component's own mirrored elements only — never the host document).
Behavior tests should land with that fix.
- `Icon`/`IconsProvider`/`useIcons` are excluded from galleries by
choice: they dynamic-import the multi-MB Tabler catalog; direct icon
imports are the supported pattern in bundled front components.

## Iteration plan

Each fix is one commit + one set of stories flipping green, in suggested
order:

1. Worker-local MutationObserver (6 tests) — cherry-pick base:
94b96d01e79e
2. `getComputedStyle` + observer/matchMedia stubs (4 tests) —
cherry-pick base: 7af577054fd9
3. Router provider + anchor click guard (5 tests) — cherry-pick base:
221c9c353dc8
4. Open-Modal React hang investigation (1 test)
5. CodeEditor: decide wont-fix + keep the failing story or convert to a
documented skip (2 tests)

## How to run

```
npx nx run twenty-front-component-renderer:storybook:prebuild
cd packages/twenty-front-component-renderer
npx vitest run --config vitest.storybook.config.ts --project storybook
```
This commit is contained in:
Paul Rastoin
2026-07-24 16:38:19 +02:00
committed by GitHub
parent cbcfba0de2
commit 308e4de7a6
20 changed files with 2046 additions and 1 deletions
@@ -105,6 +105,7 @@
"{projectRoot}/src/__stories__/html-tag/**/*",
"{projectRoot}/src/__stories__/host-api/**/*",
"{projectRoot}/src/__stories__/showcase/**/*",
"{projectRoot}/src/__stories__/twenty-ui-gallery/**/*",
"{projectRoot}/src/__stories__/shared/front-components/**/*",
"{workspaceRoot}/packages/twenty-sdk/src/cli/utilities/build/**/*"
],
@@ -16,7 +16,12 @@ const exampleSourcesBuiltPreactDir = path.resolve(
'../../src/__stories__/example-sources-built-preact',
);
const SOURCE_SCAN_ROOTS = ['html-tag', 'host-api', 'showcase'];
const SOURCE_SCAN_ROOTS = [
'html-tag',
'host-api',
'showcase',
'twenty-ui-gallery',
];
const rootNodeModules = path.resolve(dirname, '../../../../node_modules');
@@ -0,0 +1,257 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, waitFor, within } from 'storybook/test';
import {
errorHandler,
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { getBuiltStoryComponentPathForRender } from '@/__stories__/utils/getBuiltStoryComponentPathForRender';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/Twenty UI Gallery',
component: FrontComponentRenderer,
parameters: {
layout: 'centered',
},
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
// Every gallery fixture wraps each component in an error boundary and reports
// the aggregated result on the gallery-status element, so a single play
// function covers all submodules.
const galleryTest: Story['play'] = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const status = await canvas.findByTestId(
'gallery-status',
{},
{ timeout: 30000 },
);
await waitFor(() => {
expect(status).toHaveAttribute('data-failed-messages', '');
expect(status).toHaveAttribute('data-failed-count', '0');
});
expect(Number(status.getAttribute('data-total-count'))).toBeGreaterThan(0);
expect(errorHandler).not.toHaveBeenCalled();
};
const createGalleryStory = (name: string, runtime?: 'preact'): Story => ({
args: {
componentUrl: getBuiltStoryComponentPathForRender(
`${name}.front-component`,
runtime,
),
},
play: galleryTest,
});
// Golden known-failure test (TDD): PASSES while the documented sandbox gap
// exists — the failing component set matches the expected set EXACTLY. It
// FAILS on regression (an unexpected component starts failing), on fix
// (nothing fails anymore) and on partial fix (only some expected components
// still fail): when your fix lands, flip the story back to the strict
// zero-failure `createGalleryStory` play.
const createKnownFailureGalleryTest =
(expectedFailedComponents: string[]): Story['play'] =>
async ({ canvasElement }) => {
const canvas = within(canvasElement);
const status = await canvas.findByTestId(
'gallery-status',
{},
{ timeout: 30000 },
);
const expectedFailedComponentsSorted = [...expectedFailedComponents].sort();
// Failure reports arrive asynchronously: retry until the failed set
// matches the expected set exactly.
await waitFor(() => {
const failedComponents = (status.getAttribute('data-failed-names') ?? '')
.split(', ')
.filter((failedComponent) => failedComponent.length > 0)
.sort();
expect(failedComponents).toEqual(expectedFailedComponentsSorted);
});
expect(errorHandler).not.toHaveBeenCalled();
};
const createKnownFailureGalleryStory = (
name: string,
expectedFailedComponents: string[],
runtime?: 'preact',
): Story => ({
...createGalleryStory(name, runtime),
play: createKnownFailureGalleryTest(expectedFailedComponents),
});
// KNOWN ISSUE (TDD): LinkChip crashes without a router context in the sandbox.
export const DataDisplayReact: Story = createKnownFailureGalleryStory(
'twenty-ui-data-display-gallery',
['LinkChip'],
);
export const DataDisplayPreact: Story = createKnownFailureGalleryStory(
'twenty-ui-data-display-gallery',
['LinkChip'],
'preact',
);
export const FeedbackReact: Story = createGalleryStory(
'twenty-ui-feedback-gallery',
);
export const FeedbackPreact: Story = createGalleryStory(
'twenty-ui-feedback-gallery',
'preact',
);
export const IconReact: Story = createGalleryStory('twenty-ui-icon-gallery');
export const IconPreact: Story = createGalleryStory(
'twenty-ui-icon-gallery',
'preact',
);
// KNOWN ISSUE (TDD): base-ui radio internals call MutationObserver.observe,
// shipped as an empty stub class by @remote-dom/polyfill.
const INPUT_EXPECTED_FAILURES = ['Radio', 'RadioGroup', 'CardPicker'];
export const InputReact: Story = createKnownFailureGalleryStory(
'twenty-ui-input-gallery',
INPUT_EXPECTED_FAILURES,
);
export const InputPreact: Story = createKnownFailureGalleryStory(
'twenty-ui-input-gallery',
INPUT_EXPECTED_FAILURES,
'preact',
);
// KNOWN ISSUE (TDD): base-ui Collapsible calls getComputedStyle, missing from
// the remote-dom Window polyfill. The runtimes diverge (observed
// deterministically): under Preact only the first Collapsible consumer
// crashes and later siblings render.
export const JsonVisualizerReact: Story = createKnownFailureGalleryStory(
'twenty-ui-json-visualizer-gallery',
['JsonTree', 'JsonArrayNode', 'JsonObjectNode', 'JsonNestedNode'],
);
export const JsonVisualizerPreact: Story = createKnownFailureGalleryStory(
'twenty-ui-json-visualizer-gallery',
['JsonTree'],
'preact',
);
// KNOWN ISSUE (TDD): same getComputedStyle gap through base-ui Collapsible,
// with the same React/Preact divergence.
export const LayoutReact: Story = createKnownFailureGalleryStory(
'twenty-ui-layout-gallery',
['AnimatedEaseInOut', 'AnimatedExpandableContainer'],
);
export const LayoutPreact: Story = createKnownFailureGalleryStory(
'twenty-ui-layout-gallery',
['AnimatedEaseInOut'],
'preact',
);
// KNOWN ISSUE (TDD): react-router Links crash without a router context.
const NAVIGATION_EXPECTED_FAILURES = ['RawLink', 'UndecoratedLink'];
export const NavigationReact: Story = createKnownFailureGalleryStory(
'twenty-ui-navigation-gallery',
NAVIGATION_EXPECTED_FAILURES,
);
export const NavigationPreact: Story = createKnownFailureGalleryStory(
'twenty-ui-navigation-gallery',
NAVIGATION_EXPECTED_FAILURES,
'preact',
);
// KNOWN ISSUE (TDD): AppTooltip observes document.body with the stubbed-out
// MutationObserver.
export const SurfacesReact: Story = createKnownFailureGalleryStory(
'twenty-ui-surfaces-gallery',
['AppTooltip'],
);
export const SurfacesPreact: Story = createKnownFailureGalleryStory(
'twenty-ui-surfaces-gallery',
['AppTooltip'],
'preact',
);
// KNOWN ISSUE (TDD) golden test: an open Modal (base-ui Dialog portal) hangs
// the React-runtime render — the gallery status must never mount. Works under
// Preact (see ModalOpenPreact). When fixed, flip this story to the strict
// zero-failure play used by ModalOpenPreact.
const modalOpenHangTest: Story['play'] = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.findByTestId('gallery-status', {}, { timeout: 10000 }),
).rejects.toThrow();
};
const modalOpenTest: Story['play'] = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const status = await canvas.findByTestId(
'gallery-status',
{},
{ timeout: 15000 },
);
await waitFor(() => {
expect(status).toHaveAttribute('data-failed-messages', '');
expect(status).toHaveAttribute('data-failed-count', '0');
});
expect(errorHandler).not.toHaveBeenCalled();
};
export const ModalOpenReact: Story = {
...createGalleryStory('twenty-ui-modal-open-gallery'),
play: modalOpenHangTest,
};
export const ModalOpenPreact: Story = {
...createGalleryStory('twenty-ui-modal-open-gallery', 'preact'),
play: modalOpenTest,
};
// KNOWN ISSUE (TDD) golden test: monaco cannot load inside the sandbox worker
// (no script loading in the polyfilled DOM, opaque-origin CSP): the CodeEditor
// wrapper mounts but monaco's onMount never fires. If front components ever
// get a supported code editor path, flip the assertion to 'mounted'.
const codeEditorTest: Story['play'] = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const codeEditor = await canvas.findByTestId(
'code-editor-component',
{},
{ timeout: 30000 },
);
await new Promise((resolve) => setTimeout(resolve, 5000));
expect(codeEditor).toHaveAttribute('data-monaco-mount-state', 'pending');
};
export const CodeEditorReact: Story = {
...createGalleryStory('twenty-ui-code-editor-gallery'),
play: codeEditorTest,
};
export const CodeEditorPreact: Story = {
...createGalleryStory('twenty-ui-code-editor-gallery', 'preact'),
play: codeEditorTest,
};
export const TypographyReact: Story = createGalleryStory(
'twenty-ui-typography-gallery',
);
export const TypographyPreact: Story = createGalleryStory(
'twenty-ui-typography-gallery',
'preact',
);
@@ -0,0 +1,62 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
import {
errorHandler,
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { getBuiltStoryComponentPathForRender } from '@/__stories__/utils/getBuiltStoryComponentPathForRender';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/Worker Platform APIs',
component: FrontComponentRenderer,
parameters: {
layout: 'centered',
},
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
// KNOWN ISSUE (TDD) golden test: @remote-dom/polyfill ships MutationObserver
// as an empty class, so observe() throws and crashes the fixture at mount.
// When a real worker-local MutationObserver lands, flip this play to the
// behavior assertions below: click mutation-observer-add, then expect the
// data-observed count on mutation-observer-count to become greater than 0 and
// errorHandler not to have been called — a no-op stub cannot pass that.
const mutationObserverTest: Story['play'] = async () => {
// Matching the message keeps an unrelated worker failure (bundle load,
// import error, ...) from being cataloged as the MutationObserver gap.
await waitFor(
() => {
expect(errorHandler).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('observe is not a function'),
}),
);
},
{ timeout: 30000 },
);
};
const createStory = (name: string, runtime?: 'preact'): Story => ({
args: {
componentUrl: getBuiltStoryComponentPathForRender(
`${name}.front-component`,
runtime,
),
},
play: mutationObserverTest,
});
export const MutationObserverReact: Story = createStory(
'mutation-observer-example',
);
export const MutationObserverPreact: Story = createStory(
'mutation-observer-example',
'preact',
);
@@ -0,0 +1,30 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
// KNOWN ISSUE (TDD): a react-router Link click must route through the host
// navigate API. Today the link cannot even render (no router context in the
// sandbox); once it renders, the click must reach hostApi.navigate — and the
// host must guard native anchor clicks, which otherwise navigate the host
// page before the async worker round-trip can preventDefault.
const HostApiRouterLinkFrontComponent = () => (
<ThemeProvider colorScheme="light">
<FrontComponentCard title="host-api:router-link">
<span data-testid="router-link">
<UndecoratedLink to="/objects/companies">
Go to companies
</UndecoratedLink>
</span>
</FrontComponentCard>
</ThemeProvider>
);
export default defineFrontComponent({
universalIdentifier: 'test-20ui0-0000-0000-0000-000000000113',
name: 'host-api-router-link',
description:
'A front component whose react-router link navigates through the host API',
component: HostApiRouterLinkFrontComponent,
});
@@ -0,0 +1,53 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
import { isDefined } from 'twenty-shared/utils';
import {
errorHandler,
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { HOST_API_TIMEOUT } from '@/__stories__/shared/test-utils/timeouts';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HostApi/RouterLink',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
// KNOWN ISSUE (TDD) golden test: the fixture's react-router Link crashes at
// render (no router context in the sandbox), so the component errors and the
// host navigate API is never reached. When a router fix lands, flip this play
// to the acceptance assertions: find the link by role, click it, and expect
// hostApi.navigate to receive '/objects/companies' — a MemoryRouter cannot
// pass that (it renders the link but swallows the click into in-memory
// history), and clicking also requires the host to guard native anchor clicks,
// which otherwise navigate the host page away before the async worker
// round-trip can preventDefault.
export const RouterLink: Story = runFrontComponentStory({
frontComponentBundleName: 'host-api-router-link',
play: async ({ args }) => {
const api = args.frontComponentHostCommunicationApi;
if (!isDefined(api)) {
throw new Error('frontComponentHostCommunicationApi is required');
}
await waitFor(
() => {
expect(errorHandler).toHaveBeenCalled();
},
{ timeout: HOST_API_TIMEOUT },
);
expect(api.navigate).not.toHaveBeenCalled();
},
});
@@ -0,0 +1,128 @@
import { useEffect, useState, type ReactNode } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
export type GalleryEntry = {
name: string;
node: ReactNode;
};
const GALLERY_STYLE = {
padding: 20,
backgroundColor: '#ffffff',
border: '2px solid #e5e7eb',
borderRadius: 12,
fontFamily: 'system-ui, sans-serif',
display: 'flex',
flexDirection: 'column' as const,
gap: 8,
maxWidth: 640,
};
const HEADING_STYLE = {
color: '#111827',
fontWeight: 700,
fontSize: 16,
margin: 0,
};
const STATUS_STYLE = {
fontSize: 11,
fontFamily: 'monospace',
color: '#6b7280',
};
const ITEM_STYLE = {
display: 'flex',
alignItems: 'center' as const,
gap: 12,
padding: '4px 0',
borderBottom: '1px solid #f3f4f6',
};
const ITEM_LABEL_STYLE = {
fontSize: 11,
fontFamily: 'monospace',
color: '#6b7280',
minWidth: 200,
flexShrink: 0,
};
const ITEM_FAILED_STYLE = {
fontSize: 11,
fontFamily: 'monospace',
color: '#dc2626',
};
type ComponentGalleryProps = {
title: string;
entries: GalleryEntry[];
};
export const ComponentGallery = ({ title, entries }: ComponentGalleryProps) => {
const [failedEntries, setFailedEntries] = useState<
{ name: string; message: string }[]
>([]);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const reportFailedEntry = (entryName: string, thrownValue: unknown) => {
// Error boundaries receive whatever was thrown, not necessarily an Error.
const message =
thrownValue instanceof Error ? thrownValue.message : String(thrownValue);
setFailedEntries((previous) =>
previous.some((failedEntry) => failedEntry.name === entryName)
? previous
: [...previous, { name: entryName, message }],
);
};
const failedEntryNames = failedEntries.map((failedEntry) => failedEntry.name);
return (
<div data-testid="gallery-root" style={GALLERY_STYLE}>
<h2 style={HEADING_STYLE}>{title}</h2>
{isMounted && (
<span
data-testid="gallery-status"
data-total-count={entries.length}
data-failed-count={failedEntryNames.length}
data-failed-names={failedEntryNames.join(', ')}
data-failed-messages={failedEntries
.map((failedEntry) => `${failedEntry.name}: ${failedEntry.message}`)
.join(' | ')}
style={STATUS_STYLE}
>
{failedEntryNames.length === 0
? `All ${entries.length} components rendered`
: `Failed to render: ${failedEntryNames.join(', ')}`}
</span>
)}
{entries.map((entry) => (
<div
key={entry.name}
data-testid={`gallery-item-${entry.name}`}
style={ITEM_STYLE}
>
<span style={ITEM_LABEL_STYLE}>{entry.name}</span>
<ErrorBoundary
fallback={
<span
data-testid={`gallery-item-failed-${entry.name}`}
style={ITEM_FAILED_STYLE}
>
render failed
</span>
}
onError={(error) => reportFailedEntry(entry.name, error)}
>
{entry.node}
</ErrorBoundary>
</div>
))}
</div>
);
};
@@ -0,0 +1,64 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { useEffect, useRef, useState } from 'react';
// Exercises real MutationObserver semantics inside the sandbox worker: the
// observer must fire for React-driven insertions into the observed subtree.
const MutationObserverComponent = () => {
const containerRef = useRef<HTMLDivElement>(null);
const [observedMutationCount, setObservedMutationCount] = useState(0);
const [items, setItems] = useState<string[]>([]);
useEffect(() => {
const container = containerRef.current;
if (container === null) {
return;
}
const observer = new MutationObserver((records) => {
const childListRecordCount = records.filter(
(record) => record.type === 'childList',
).length;
setObservedMutationCount((previous) => previous + childListRecordCount);
});
observer.observe(container, { childList: true, subtree: true });
return () => observer.disconnect();
}, []);
return (
<div
data-testid="mutation-observer-component"
style={{ fontFamily: 'system-ui, sans-serif', padding: 16 }}
>
<button
data-testid="mutation-observer-add"
onClick={() =>
setItems((previous) => [...previous, `item-${previous.length}`])
}
>
Add item
</button>
<div ref={containerRef} data-testid="mutation-observer-container">
{items.map((item) => (
<span key={item}>{item} </span>
))}
</div>
<p
data-testid="mutation-observer-count"
data-observed={observedMutationCount}
>
Mutations observed: {observedMutationCount}
</p>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'test-20ui0-0000-0000-0000-000000000110',
name: 'mutation-observer-component',
description: 'Asserts MutationObserver works inside the sandbox worker',
component: MutationObserverComponent,
});
@@ -0,0 +1,39 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { useState } from 'react';
import { CodeEditor } from 'twenty-ui/input';
import { ThemeProvider } from 'twenty-ui/theme-constants';
// KNOWN ISSUE: CodeEditor wraps @monaco-editor/react, which lazy-loads the
// monaco runtime through script injection at mount. The sandbox worker has no
// script loading (polyfilled DOM, opaque-origin CSP), so monaco can never
// become interactive. This fixture documents that empirically: the dedicated
// story fails until front components get a supported code editor path.
const CodeEditorComponent = () => {
const [mountState, setMountState] = useState<'pending' | 'mounted'>(
'pending',
);
return (
<ThemeProvider colorScheme="light">
<div
data-testid="code-editor-component"
data-monaco-mount-state={mountState}
style={{ fontFamily: 'system-ui, sans-serif', width: 480 }}
>
<CodeEditor
value={'const greeting = "hello";'}
language="typescript"
height={200}
onMount={() => setMountState('mounted')}
/>
</div>
</ThemeProvider>
);
};
export default defineFrontComponent({
universalIdentifier: 'test-20ui0-0000-0000-0000-000000000112',
name: 'twenty-ui-code-editor-gallery',
description: 'Renders the monaco-based twenty-ui CodeEditor in the sandbox',
component: CodeEditorComponent,
});
@@ -0,0 +1,118 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
AnimatedCheckmark,
Avatar,
AvatarGroup,
AvatarOrIcon,
Checkmark,
Chip,
ColorSample,
CommandBlock,
LinkChip,
NotificationCounter,
Pill,
Status,
StyledTintedIconTileContainer,
Tag,
TintedIconTile,
} from 'twenty-ui/data-display';
import { IconStar } from 'twenty-ui/icon';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import {
ComponentGallery,
type GalleryEntry,
} from '../shared/front-components/component-gallery';
const DATA_DISPLAY_ENTRIES: GalleryEntry[] = [
{
name: 'AnimatedCheckmark',
node: <AnimatedCheckmark isAnimating size={28} />,
},
{
name: 'Avatar',
node: <Avatar placeholder="John Doe" size="md" type="rounded" />,
},
{
name: 'AvatarGroup',
node: (
<AvatarGroup
avatars={[
<Avatar key="a" placeholder="Alice" />,
<Avatar key="b" placeholder="Bob" />,
]}
/>
),
},
{
name: 'AvatarOrIcon',
node: <AvatarOrIcon placeholder="Jane" Icon={IconStar} />,
},
{
name: 'Checkmark',
node: <Checkmark />,
},
{
name: 'Chip',
node: <Chip label="Chip label" />,
},
{
name: 'ColorSample',
node: <ColorSample colorName="blue" />,
},
{
name: 'CommandBlock',
node: <CommandBlock commands={['npm install', 'npm run start']} />,
},
// KNOWN ISSUE (TDD): LinkChip renders a react-router Link and crashes
// because the sandbox provides no router context. Expected fix: SDK-injected
// Router whose navigator bridges to the host navigate API.
{
name: 'LinkChip',
node: <LinkChip to="/example" label="Link chip" />,
},
{
name: 'NotificationCounter',
node: <NotificationCounter count={3} />,
},
{
name: 'Pill',
node: <Pill label="Pill" Icon={IconStar} />,
},
{
name: 'Status',
node: <Status color="green" text="Active" />,
},
{
name: 'StyledTintedIconTileContainer',
node: (
<StyledTintedIconTileContainer $dimension="32px">
<IconStar size={16} />
</StyledTintedIconTileContainer>
),
},
{
name: 'Tag',
node: <Tag color="blue" text="Tag" />,
},
{
name: 'TintedIconTile',
node: <TintedIconTile Icon={IconStar} />,
},
];
const DataDisplayGallery = () => (
<ThemeProvider colorScheme="light">
<ComponentGallery
title="twenty-ui/data-display"
entries={DATA_DISPLAY_ENTRIES}
/>
</ThemeProvider>
);
export default defineFrontComponent({
universalIdentifier: 'test-20ui0-0000-0000-0000-000000000101',
name: 'twenty-ui-data-display-gallery',
description: 'Renders every twenty-ui/data-display component in the sandbox',
component: DataDisplayGallery,
});
@@ -0,0 +1,144 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
AnimatedPlaceholder,
AnimatedPlaceholderEmptyContainer,
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
AnimatedPlaceholderErrorContainer,
AnimatedPlaceholderErrorSubTitle,
AnimatedPlaceholderErrorTextContainer,
AnimatedPlaceholderErrorTitle,
Banner,
Callout,
CircularProgressBar,
Info,
InlineBanner,
Loader,
ProgressBar,
SidePanelInformationBanner,
} from 'twenty-ui/feedback';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import {
ComponentGallery,
type GalleryEntry,
} from '../shared/front-components/component-gallery';
const FEEDBACK_ENTRIES: GalleryEntry[] = [
{
name: 'AnimatedPlaceholder',
node: <AnimatedPlaceholder type="error404" />,
},
{
name: 'AnimatedPlaceholderEmptyContainer',
node: (
<AnimatedPlaceholderEmptyContainer>
Empty
</AnimatedPlaceholderEmptyContainer>
),
},
{
name: 'AnimatedPlaceholderEmptyTextContainer',
node: (
<AnimatedPlaceholderEmptyTextContainer>
Empty text
</AnimatedPlaceholderEmptyTextContainer>
),
},
{
name: 'AnimatedPlaceholderEmptyTitle',
node: (
<AnimatedPlaceholderEmptyTitle>No records</AnimatedPlaceholderEmptyTitle>
),
},
{
name: 'AnimatedPlaceholderEmptySubTitle',
node: (
<AnimatedPlaceholderEmptySubTitle>
Try adding one
</AnimatedPlaceholderEmptySubTitle>
),
},
{
name: 'AnimatedPlaceholderErrorContainer',
node: (
<AnimatedPlaceholderErrorContainer>
Error
</AnimatedPlaceholderErrorContainer>
),
},
{
name: 'AnimatedPlaceholderErrorTextContainer',
node: (
<AnimatedPlaceholderErrorTextContainer>
Error text
</AnimatedPlaceholderErrorTextContainer>
),
},
{
name: 'AnimatedPlaceholderErrorTitle',
node: (
<AnimatedPlaceholderErrorTitle>Went wrong</AnimatedPlaceholderErrorTitle>
),
},
{
name: 'AnimatedPlaceholderErrorSubTitle',
node: (
<AnimatedPlaceholderErrorSubTitle>
Please retry
</AnimatedPlaceholderErrorSubTitle>
),
},
{
name: 'Banner',
node: (
<Banner color="blue" variant="primary">
Heads up
</Banner>
),
},
{
name: 'Callout',
node: (
<Callout variant="info" title="Info" description="A short description." />
),
},
{
name: 'CircularProgressBar',
node: <CircularProgressBar size={50} barWidth={5} />,
},
{
name: 'Info',
node: <Info accent="blue" text="Some information" />,
},
{
name: 'InlineBanner',
node: <InlineBanner color="blue" message="Inline message" />,
},
{
name: 'Loader',
node: <Loader color="blue" />,
},
{
name: 'ProgressBar',
node: <ProgressBar value={50} ariaLabel="Progress" />,
},
{
name: 'SidePanelInformationBanner',
node: <SidePanelInformationBanner message="Panel info" variant="default" />,
},
];
const FeedbackGallery = () => (
<ThemeProvider colorScheme="light">
<ComponentGallery title="twenty-ui/feedback" entries={FEEDBACK_ENTRIES} />
</ThemeProvider>
);
export default defineFrontComponent({
universalIdentifier: 'test-20ui0-0000-0000-0000-000000000102',
name: 'twenty-ui-feedback-gallery',
description: 'Renders every twenty-ui/feedback component in the sandbox',
component: FeedbackGallery,
});
@@ -0,0 +1,171 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
IconAddressBook,
IconBrandAnthropic,
IconBrandGemini,
IconBrandGroq,
IconBrandMistral,
IconBrandXai,
IconCalendar,
IconChartBarHorizontal,
IconCheck,
IconChevronDown,
IconGmail,
IconGoogle,
IconGoogleCalendar,
IconHeart,
IconLink,
IconLockCustom,
IconMail,
IconMicrosoft,
IconMicrosoftCalendar,
IconMicrosoftOutlook,
IconModelClaude,
IconPhone,
IconPlus,
IconProviderOpenai,
IconRelationManyToOne,
IconSearch,
IconSettings,
IconSparkle2,
IconStar,
IconTrash,
IconTrashXOff,
IconTwentyStar,
IconTwentyStarFilled,
IconUser,
IconX,
IllustrationIconArray,
IllustrationIconCalendarEvent,
IllustrationIconCalendarTime,
IllustrationIconCurrency,
IllustrationIconFile,
IllustrationIconJson,
IllustrationIconLink,
IllustrationIconMail,
IllustrationIconManyToMany,
IllustrationIconMap,
IllustrationIconNumbers,
IllustrationIconOneToMany,
IllustrationIconOneToOne,
IllustrationIconPhone,
IllustrationIconSetting,
IllustrationIconStar,
IllustrationIconTag,
IllustrationIconTags,
IllustrationIconText,
IllustrationIconToggle,
IllustrationIconUid,
IllustrationIconUser,
IllustrationIconWrapper,
ThinkingOrbitLoaderIcon,
type IconComponent,
} from 'twenty-ui/icon';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import {
ComponentGallery,
type GalleryEntry,
} from '../shared/front-components/component-gallery';
const CUSTOM_ICONS: Record<string, IconComponent> = {
IconAddressBook,
IconBrandAnthropic,
IconBrandGemini,
IconBrandGroq,
IconBrandMistral,
IconBrandXai,
IconChartBarHorizontal,
IconGmail,
IconGoogle,
IconGoogleCalendar,
IconLockCustom,
IconMicrosoft,
IconMicrosoftCalendar,
IconMicrosoftOutlook,
IconModelClaude,
IconProviderOpenai,
IconRelationManyToOne,
IconSparkle2,
IconTrashXOff,
IconTwentyStar,
IconTwentyStarFilled,
};
const ILLUSTRATION_ICONS: Record<string, IconComponent> = {
IllustrationIconArray,
IllustrationIconCalendarEvent,
IllustrationIconCalendarTime,
IllustrationIconCurrency,
IllustrationIconFile,
IllustrationIconJson,
IllustrationIconLink,
IllustrationIconMail,
IllustrationIconManyToMany,
IllustrationIconMap,
IllustrationIconNumbers,
IllustrationIconOneToMany,
IllustrationIconOneToOne,
IllustrationIconPhone,
IllustrationIconSetting,
IllustrationIconStar,
IllustrationIconTag,
IllustrationIconTags,
IllustrationIconText,
IllustrationIconToggle,
IllustrationIconUid,
IllustrationIconUser,
};
// Representative sample of the ~400 re-exported Tabler icons: they all share
// the same implementation, so rendering each one would only slow the suite.
const TABLER_ICON_SAMPLE: Record<string, IconComponent> = {
IconCalendar,
IconCheck,
IconChevronDown,
IconHeart,
IconLink,
IconMail,
IconPhone,
IconPlus,
IconSearch,
IconSettings,
IconStar,
IconTrash,
IconUser,
IconX,
};
const iconEntries = (icons: Record<string, IconComponent>): GalleryEntry[] =>
Object.entries(icons).map(([name, IconComponentToRender]) => ({
name,
node: <IconComponentToRender size={20} />,
}));
const ICON_ENTRIES: GalleryEntry[] = [
...iconEntries(CUSTOM_ICONS),
...iconEntries(ILLUSTRATION_ICONS),
...iconEntries(TABLER_ICON_SAMPLE),
{
name: 'IllustrationIconWrapper',
node: <IllustrationIconWrapper>i</IllustrationIconWrapper>,
},
{
name: 'ThinkingOrbitLoaderIcon',
node: <ThinkingOrbitLoaderIcon size={16} />,
},
];
const IconGallery = () => (
<ThemeProvider colorScheme="light">
<ComponentGallery title="twenty-ui/icon" entries={ICON_ENTRIES} />
</ThemeProvider>
);
export default defineFrontComponent({
universalIdentifier: 'test-20ui0-0000-0000-0000-000000000109',
name: 'twenty-ui-icon-gallery',
description:
'Renders twenty-ui custom and illustration icons plus a Tabler sample in the sandbox',
component: IconGallery,
});
@@ -0,0 +1,256 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { IconPlus, IconSearch, IconStar, IconTrash } from 'twenty-ui/icon';
import {
AdvancedSettingsToggle,
AnimatedButton,
AnimatedLightIconButton,
Button,
ButtonGroup,
CardPicker,
Checkbox,
ColorPickerButton,
ColorSchemeCard,
ColorSchemePicker,
CoreEditorHeader,
FloatingButton,
FloatingButtonGroup,
FloatingIconButton,
FloatingIconButtonGroup,
IconButton,
IconButtonGroup,
IconListViewGrip,
InsideButton,
LightButton,
LightIconButton,
LightIconButtonGroup,
MainButton,
Radio,
RadioGroup,
RoundedIconButton,
SearchInput,
SegmentedControl,
Slider,
StyledTabContainer,
TabButton,
TabContent,
Toggle,
} from 'twenty-ui/input';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import {
ComponentGallery,
type GalleryEntry,
} from '../shared/front-components/component-gallery';
const INPUT_ENTRIES: GalleryEntry[] = [
{
name: 'AdvancedSettingsToggle',
node: (
<AdvancedSettingsToggle
isAdvancedModeEnabled={false}
setIsAdvancedModeEnabled={() => {}}
/>
),
},
{
name: 'AnimatedButton',
node: (
<AnimatedButton
title="Animated"
animatedSvg={<svg width={16} height={16} />}
/>
),
},
{
name: 'AnimatedLightIconButton',
node: <AnimatedLightIconButton Icon={IconStar} />,
},
{
name: 'Button',
node: <Button title="Button" onClick={() => {}} />,
},
{
name: 'ButtonGroup',
node: (
<ButtonGroup>
{[<Button key="a" title="A" />, <Button key="b" title="B" />]}
</ButtonGroup>
),
},
{
name: 'CardPicker',
node: (
<CardPicker checked={false} handleChange={() => {}}>
Card
</CardPicker>
),
},
{
name: 'Checkbox',
node: <Checkbox checked={false} onChange={() => {}} />,
},
{
name: 'CoreEditorHeader',
node: <CoreEditorHeader title="Editor" />,
},
{
name: 'ColorPickerButton',
node: <ColorPickerButton colorName="blue" onClick={() => {}} />,
},
{
name: 'ColorSchemeCard',
node: <ColorSchemeCard variant="Light" />,
},
{
name: 'ColorSchemePicker',
node: (
<ColorSchemePicker
value="Light"
onChange={() => {}}
lightLabel="Light"
darkLabel="Dark"
systemLabel="System"
/>
),
},
{
name: 'FloatingButton',
node: <FloatingButton title="Floating" />,
},
{
name: 'FloatingButtonGroup',
node: (
<FloatingButtonGroup>
{[
<FloatingButton key="a" title="A" />,
<FloatingButton key="b" title="B" />,
]}
</FloatingButtonGroup>
),
},
{
name: 'FloatingIconButton',
node: <FloatingIconButton Icon={IconSearch} ariaLabel="Search" />,
},
{
name: 'FloatingIconButtonGroup',
node: (
<FloatingIconButtonGroup
iconButtons={[{ Icon: IconSearch, ariaLabel: 'Search' }]}
/>
),
},
{
name: 'IconButton',
node: <IconButton Icon={IconPlus} ariaLabel="Add" onClick={() => {}} />,
},
{
name: 'IconButtonGroup',
node: (
<IconButtonGroup
iconButtons={[{ Icon: IconTrash, ariaLabel: 'Delete' }]}
/>
),
},
{
name: 'IconListViewGrip',
node: <IconListViewGrip />,
},
{
name: 'InsideButton',
node: <InsideButton Icon={IconPlus} ariaLabel="Add" />,
},
{
name: 'LightButton',
node: <LightButton title="Light" />,
},
{
name: 'LightIconButton',
node: <LightIconButton Icon={IconStar} aria-label="Star" />,
},
{
name: 'LightIconButtonGroup',
node: (
<LightIconButtonGroup
iconButtons={[{ Icon: IconStar, ariaLabel: 'Star', onClick: () => {} }]}
/>
),
},
{
name: 'MainButton',
node: <MainButton title="Main" />,
},
{
name: 'Radio',
node: <Radio checked={false} label="Radio" />,
},
{
name: 'RadioGroup',
node: (
<RadioGroup value="a">
<Radio value="a" label="A" />
<Radio value="b" label="B" />
</RadioGroup>
),
},
{
name: 'RoundedIconButton',
node: <RoundedIconButton Icon={IconPlus} aria-label="Add" />,
},
{
name: 'SearchInput',
node: <SearchInput value="" onChange={() => {}} placeholder="Search" />,
},
{
name: 'SegmentedControl',
node: (
<SegmentedControl
ariaLabel="Choose"
value="left"
onChange={() => {}}
options={[
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' },
]}
/>
),
},
{
name: 'Slider',
node: <Slider max={100} value={50} onChange={() => {}} />,
},
{
name: 'StyledTabContainer',
node: (
<StyledTabContainer>
<TabButton id="t1" title="Tab" />
</StyledTabContainer>
),
},
{
name: 'TabButton',
node: <TabButton id="tab1" title="Tab" />,
},
{
name: 'TabContent',
node: <TabContent id="tc1" title="Content" />,
},
{
name: 'Toggle',
node: <Toggle value={false} onChange={() => {}} />,
},
];
const InputGallery = () => (
<ThemeProvider colorScheme="light">
<ComponentGallery title="twenty-ui/input" entries={INPUT_ENTRIES} />
</ThemeProvider>
);
export default defineFrontComponent({
universalIdentifier: 'test-20ui0-0000-0000-0000-000000000107',
name: 'twenty-ui-input-gallery',
description:
'Renders every twenty-ui/input component (except monaco CodeEditor) in the sandbox',
component: InputGallery,
});
@@ -0,0 +1,133 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { IconCube } from 'twenty-ui/icon';
import {
JsonArrayNode,
JsonNestedNode,
JsonNode,
JsonObjectNode,
JsonTree,
JsonTreeContextProvider,
type JsonTreeContextType,
JsonValueNode,
} from 'twenty-ui/json-visualizer';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import {
ComponentGallery,
type GalleryEntry,
} from '../shared/front-components/component-gallery';
const JSON_TREE_CONTEXT_VALUE: JsonTreeContextType = {
shouldExpandNodeInitially: () => true,
emptyStringLabel: '[empty string]',
emptyArrayLabel: '[empty array]',
emptyObjectLabel: '[empty object]',
arrowButtonCollapsedLabel: 'Expand',
arrowButtonExpandedLabel: 'Collapse',
};
const JSON_VISUALIZER_ENTRIES: GalleryEntry[] = [
{
name: 'JsonTree',
node: (
<JsonTree
value={{ id: 1, name: 'Twenty', tags: ['a', 'b'], active: true }}
shouldExpandNodeInitially={() => true}
emptyArrayLabel="[empty array]"
emptyObjectLabel="[empty object]"
emptyStringLabel="[empty string]"
arrowButtonCollapsedLabel="Expand"
arrowButtonExpandedLabel="Collapse"
/>
),
},
{
name: 'JsonNode',
node: (
<JsonTreeContextProvider value={JSON_TREE_CONTEXT_VALUE}>
<JsonNode value={{ id: 1, name: 'Twenty' }} depth={0} keyPath="" />
</JsonTreeContextProvider>
),
},
{
name: 'JsonArrayNode',
node: (
<JsonTreeContextProvider value={JSON_TREE_CONTEXT_VALUE}>
<JsonArrayNode
label="items"
value={[1, 'two', true]}
depth={0}
keyPath="items"
highlighting={undefined}
/>
</JsonTreeContextProvider>
),
},
{
name: 'JsonObjectNode',
node: (
<JsonTreeContextProvider value={JSON_TREE_CONTEXT_VALUE}>
<JsonObjectNode
label="object"
value={{ id: 1, name: 'Twenty' }}
depth={0}
keyPath="object"
highlighting={undefined}
/>
</JsonTreeContextProvider>
),
},
{
name: 'JsonNestedNode',
node: (
<JsonTreeContextProvider value={JSON_TREE_CONTEXT_VALUE}>
<JsonNestedNode
label="nested"
Icon={IconCube}
elements={[
{ id: 'id', label: 'id', value: 1 },
{ id: 'name', label: 'name', value: 'Twenty' },
]}
renderElementsCount={(count) => `{${count}}`}
emptyElementsText="[empty object]"
depth={0}
keyPath="nested"
highlighting={undefined}
/>
</JsonTreeContextProvider>
),
},
{
name: 'JsonValueNode',
node: (
<JsonTreeContextProvider value={JSON_TREE_CONTEXT_VALUE}>
<JsonValueNode valueAsString="Twenty" highlighting={undefined} />
</JsonTreeContextProvider>
),
},
{
name: 'JsonTreeContextProvider',
node: (
<JsonTreeContextProvider value={JSON_TREE_CONTEXT_VALUE}>
<JsonNode value="leaf value" depth={0} keyPath="" />
</JsonTreeContextProvider>
),
},
];
const JsonVisualizerGallery = () => (
<ThemeProvider colorScheme="light">
<ComponentGallery
title="twenty-ui/json-visualizer"
entries={JSON_VISUALIZER_ENTRIES}
/>
</ThemeProvider>
);
export default defineFrontComponent({
universalIdentifier: 'test-20ui0-0000-0000-0000-000000000106',
name: 'twenty-ui-json-visualizer-gallery',
description:
'Renders every twenty-ui/json-visualizer component in the sandbox',
component: JsonVisualizerGallery,
});
@@ -0,0 +1,101 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { IconHeart, IconStar } from 'twenty-ui/icon';
import {
AnimatedCircleLoading,
AnimatedContainer,
AnimatedEaseIn,
AnimatedEaseInOut,
AnimatedExpandableContainer,
AnimatedIconCrossfade,
AnimatedRotate,
AutogrowWrapper,
HorizontalSeparator,
ResizeHandle,
Section,
SectionAlignment,
SectionFontColor,
} from 'twenty-ui/layout';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import {
ComponentGallery,
type GalleryEntry,
} from '../shared/front-components/component-gallery';
const LAYOUT_ENTRIES: GalleryEntry[] = [
{
name: 'AnimatedCircleLoading',
node: <AnimatedCircleLoading>Loading</AnimatedCircleLoading>,
},
{
name: 'AnimatedContainer',
node: <AnimatedContainer>Content</AnimatedContainer>,
},
{
name: 'AnimatedEaseIn',
node: <AnimatedEaseIn>Fades in</AnimatedEaseIn>,
},
{
name: 'AnimatedEaseInOut',
node: <AnimatedEaseInOut isOpen={true}>Panel</AnimatedEaseInOut>,
},
{
name: 'AnimatedExpandableContainer',
node: (
<AnimatedExpandableContainer isExpanded={true}>
Expandable
</AnimatedExpandableContainer>
),
},
{
name: 'AnimatedIconCrossfade',
node: (
<AnimatedIconCrossfade
isActive={true}
ActiveIcon={IconStar}
InactiveIcon={IconHeart}
size={16}
/>
),
},
{
name: 'AnimatedRotate',
node: <AnimatedRotate>Rotate</AnimatedRotate>,
},
{
name: 'AutogrowWrapper',
node: <AutogrowWrapper>Grows</AutogrowWrapper>,
},
{
name: 'HorizontalSeparator',
node: <HorizontalSeparator text="or" />,
},
{
name: 'ResizeHandle',
node: <ResizeHandle />,
},
{
name: 'Section',
node: (
<Section
alignment={SectionAlignment.Left}
fontColor={SectionFontColor.Primary}
>
Section
</Section>
),
},
];
const LayoutGallery = () => (
<ThemeProvider colorScheme="light">
<ComponentGallery title="twenty-ui/layout" entries={LAYOUT_ENTRIES} />
</ThemeProvider>
);
export default defineFrontComponent({
universalIdentifier: 'test-20ui0-0000-0000-0000-000000000103',
name: 'twenty-ui-layout-gallery',
description: 'Renders every twenty-ui/layout component in the sandbox',
component: LayoutGallery,
});
@@ -0,0 +1,39 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { Modal } from 'twenty-ui/surfaces';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import {
ComponentGallery,
type GalleryEntry,
} from '../shared/front-components/component-gallery';
// KNOWN ISSUE: an open Modal (base-ui Dialog portal) hangs the React-runtime
// sandbox render entirely (no error, the tree never commits); it works under
// Preact. Isolated in its own fixture so the hang cannot mask the rest of the
// surfaces gallery.
const MODAL_OPEN_ENTRIES: GalleryEntry[] = [
{
name: 'Modal',
node: (
<Modal isOpen={true} ariaLabel="Open gallery modal">
Modal body
</Modal>
),
},
];
const ModalOpenGallery = () => (
<ThemeProvider colorScheme="light">
<ComponentGallery
title="twenty-ui/surfaces Modal (open)"
entries={MODAL_OPEN_ENTRIES}
/>
</ThemeProvider>
);
export default defineFrontComponent({
universalIdentifier: 'test-20ui0-0000-0000-0000-000000000111',
name: 'twenty-ui-modal-open-gallery',
description: 'Renders an open twenty-ui Modal in the sandbox',
component: ModalOpenGallery,
});
@@ -0,0 +1,254 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { IconHome, IconUser } from 'twenty-ui/icon';
import {
ClickToActionLink,
ContactLink,
GithubVersionLink,
LinkType,
MenuItem,
MenuItemAvatar,
MenuItemDraggable,
MenuItemHotKeys,
MenuItemLeftContent,
MenuItemMultiSelect,
MenuItemMultiSelectAvatar,
MenuItemMultiSelectTag,
MenuItemNavigate,
MenuItemSelect,
MenuItemSelectAvatar,
MenuItemSelectColor,
MenuItemSelectTag,
MenuItemSuggestion,
MenuItemToggle,
MenuPicker,
NavigationBar,
NavigationBarItem,
RawLink,
RoundedLink,
SocialLink,
StyledHoverableMenuItemBase,
StyledMenuItemIconCheck,
StyledMenuItemLabel,
StyledMenuItemLeftContent,
StyledMenuItemSelect,
UndecoratedLink,
} from 'twenty-ui/navigation';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import {
ComponentGallery,
type GalleryEntry,
} from '../shared/front-components/component-gallery';
const NAVIGATION_ENTRIES: GalleryEntry[] = [
{
name: 'ClickToActionLink',
node: <ClickToActionLink href="#">Click me</ClickToActionLink>,
},
{
name: 'ContactLink',
node: <ContactLink href="https://twenty.com">Contact</ContactLink>,
},
{
name: 'GithubVersionLink',
node: <GithubVersionLink version="v1.0.0" />,
},
{
name: 'MenuItem',
node: <MenuItem text="Menu item" LeftIcon={IconUser} />,
},
{
name: 'MenuItemLeftContent',
node: <MenuItemLeftContent text="Left content" LeftIcon={IconUser} />,
},
{
name: 'StyledHoverableMenuItemBase',
node: (
<StyledHoverableMenuItemBase>Hoverable base</StyledHoverableMenuItemBase>
),
},
{
name: 'StyledMenuItemIconCheck',
node: <StyledMenuItemIconCheck size={16} />,
},
{
name: 'StyledMenuItemLabel',
node: <StyledMenuItemLabel>Label</StyledMenuItemLabel>,
},
{
name: 'StyledMenuItemLeftContent',
node: (
<StyledMenuItemLeftContent>
Left content wrapper
</StyledMenuItemLeftContent>
),
},
{
name: 'MenuItemAvatar',
node: <MenuItemAvatar text="Avatar item" />,
},
{
name: 'MenuItemDraggable',
node: (
<MenuItemDraggable
text="Draggable"
LeftIcon={IconUser}
gripMode="always"
/>
),
},
{
name: 'MenuItemHotKeys',
node: <MenuItemHotKeys hotKeys={['⌘', 'K']} />,
},
{
name: 'MenuItemMultiSelect',
node: (
<MenuItemMultiSelect
text="Multi select"
selected={false}
className=""
onSelectChange={() => {}}
/>
),
},
{
name: 'MenuItemMultiSelectAvatar',
node: (
<MenuItemMultiSelectAvatar
text="Multi avatar"
selected={true}
onSelectChange={() => {}}
/>
),
},
{
name: 'MenuItemMultiSelectTag',
node: (
<MenuItemMultiSelectTag
text="Tag"
color="blue"
selected={false}
onClick={() => {}}
/>
),
},
{
name: 'MenuItemNavigate',
node: (
<MenuItemNavigate
text="Navigate"
LeftIcon={IconUser}
onClick={() => {}}
/>
),
},
{
name: 'StyledMenuItemSelect',
node: <StyledMenuItemSelect>Select base</StyledMenuItemSelect>,
},
{
name: 'MenuItemSelect',
node: <MenuItemSelect text="Select" selected={true} onClick={() => {}} />,
},
{
name: 'MenuItemSelectAvatar',
node: (
<MenuItemSelectAvatar
text="Select avatar"
selected={true}
onClick={() => {}}
/>
),
},
{
name: 'MenuItemSelectColor',
node: (
<MenuItemSelectColor color="blue" selected={true} onClick={() => {}} />
),
},
{
name: 'MenuItemSelectTag',
node: (
<MenuItemSelectTag
color="blue"
text="Select tag"
selected={true}
onClick={() => {}}
/>
),
},
{
name: 'MenuItemSuggestion',
node: <MenuItemSuggestion text="Suggestion" onClick={() => {}} />,
},
{
name: 'MenuItemToggle',
node: (
<MenuItemToggle text="Toggle" toggled={true} onToggleChange={() => {}} />
),
},
{
name: 'MenuPicker',
node: <MenuPicker id="picker-1" icon={IconHome} label="Picker" />,
},
{
name: 'NavigationBar',
node: (
<NavigationBar
activeItemName="home"
items={[
{ name: 'home', label: 'Home', Icon: IconHome, onClick: () => {} },
]}
/>
),
},
{
name: 'NavigationBarItem',
node: (
<NavigationBarItem
Icon={IconHome}
isActive={true}
ariaLabel="Home"
onClick={() => {}}
/>
),
},
// KNOWN ISSUE (TDD): RawLink and UndecoratedLink render a react-router Link
// and crash because the sandbox provides no router context. Expected fix:
// SDK-injected Router whose navigator bridges to the host navigate API.
{
name: 'RawLink',
node: <RawLink href="/path">Raw link</RawLink>,
},
{
name: 'RoundedLink',
node: <RoundedLink href="https://twenty.com" label="Rounded link" />,
},
{
name: 'SocialLink',
node: (
<SocialLink href="https://twitter.com/twenty" type={LinkType.Twitter} />
),
},
{
name: 'UndecoratedLink',
node: <UndecoratedLink to="/path">Undecorated link</UndecoratedLink>,
},
];
const NavigationGallery = () => (
<ThemeProvider colorScheme="light">
<ComponentGallery
title="twenty-ui/navigation"
entries={NAVIGATION_ENTRIES}
/>
</ThemeProvider>
);
export default defineFrontComponent({
universalIdentifier: 'test-20ui0-0000-0000-0000-000000000108',
name: 'twenty-ui-navigation-gallery',
description: 'Renders every twenty-ui/navigation component in the sandbox',
component: NavigationGallery,
});
@@ -0,0 +1,99 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
AppTooltip,
Card,
CardContent,
CardFooter,
CardHeader,
Modal,
ModalBackdrop,
ModalContent,
ModalFooter,
ModalHeader,
OverflowingTextWithTooltip,
} from 'twenty-ui/surfaces';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import {
ComponentGallery,
type GalleryEntry,
} from '../shared/front-components/component-gallery';
const SURFACES_ENTRIES: GalleryEntry[] = [
{
name: 'AppTooltip',
node: (
<>
<span id="gallery-tooltip-anchor">Tooltip anchor</span>
<AppTooltip
anchorSelect="#gallery-tooltip-anchor"
content="Tooltip content"
isOpen={true}
/>
</>
),
},
{
name: 'Card',
node: <Card>Card body</Card>,
},
{
name: 'CardContent',
node: <CardContent>Card content</CardContent>,
},
{
name: 'CardFooter',
node: <CardFooter>Card footer</CardFooter>,
},
{
name: 'CardHeader',
node: <CardHeader>Card header</CardHeader>,
},
// Rendered closed here so a hang cannot mask the rest of this gallery; the
// open-Modal known issue is exposed by twenty-ui-modal-open-gallery.
{
name: 'Modal',
node: (
<Modal isOpen={false} ariaLabel="Gallery modal">
Modal body
</Modal>
),
},
{
name: 'ModalBackdrop',
node: (
<ModalBackdrop overlay="dark" backdropZIndex={39}>
Backdrop child
</ModalBackdrop>
),
},
{
name: 'ModalContent',
node: <ModalContent>Modal content</ModalContent>,
},
{
name: 'ModalFooter',
node: <ModalFooter>Modal footer</ModalFooter>,
},
{
name: 'ModalHeader',
node: <ModalHeader>Modal header</ModalHeader>,
},
{
name: 'OverflowingTextWithTooltip',
node: <OverflowingTextWithTooltip text="Some overflowing text" />,
},
];
const SurfacesGallery = () => (
<ThemeProvider colorScheme="light">
<ComponentGallery title="twenty-ui/surfaces" entries={SURFACES_ENTRIES} />
</ThemeProvider>
);
export default defineFrontComponent({
universalIdentifier: 'test-20ui0-0000-0000-0000-000000000105',
name: 'twenty-ui-surfaces-gallery',
description: 'Renders every twenty-ui/surfaces component in the sandbox',
component: SurfacesGallery,
});
@@ -0,0 +1,87 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
VisibilityHidden,
VisibilityHiddenInput,
} from 'twenty-ui/accessibility';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import {
H1Title,
H1TitleFontColor,
H2Title,
H3Title,
Label,
LinkifiedText,
SeparatorLineText,
StyledText,
StyledTextContent,
StyledTextWrapper,
} from 'twenty-ui/typography';
import {
ComponentGallery,
type GalleryEntry,
} from '../shared/front-components/component-gallery';
const TYPOGRAPHY_ENTRIES: GalleryEntry[] = [
{
name: 'H1Title',
node: <H1Title title="Heading 1" fontColor={H1TitleFontColor.Primary} />,
},
{
name: 'H2Title',
node: <H2Title title="Heading 2" />,
},
{
name: 'H3Title',
node: <H3Title title="Heading 3" />,
},
{
name: 'Label',
node: <Label variant="default">Label</Label>,
},
{
name: 'LinkifiedText',
node: <LinkifiedText text="Visit https://twenty.com now" />,
},
{
name: 'SeparatorLineText',
node: <SeparatorLineText>or</SeparatorLineText>,
},
{
name: 'StyledText',
node: <StyledText text="Styled text" />,
},
{
name: 'StyledTextContent',
node: <StyledTextContent>Content</StyledTextContent>,
},
{
name: 'StyledTextWrapper',
node: <StyledTextWrapper>Wrapper</StyledTextWrapper>,
},
{
name: 'VisibilityHidden',
node: <VisibilityHidden>Screen-reader only</VisibilityHidden>,
},
{
name: 'VisibilityHiddenInput',
node: <VisibilityHiddenInput readOnly value="" />,
},
];
const TypographyGallery = () => (
<ThemeProvider colorScheme="light">
<ComponentGallery
title="twenty-ui/typography + accessibility"
entries={TYPOGRAPHY_ENTRIES}
/>
</ThemeProvider>
);
export default defineFrontComponent({
universalIdentifier: 'test-20ui0-0000-0000-0000-000000000104',
name: 'twenty-ui-typography-gallery',
description:
'Renders every twenty-ui/typography and accessibility component in the sandbox',
component: TypographyGallery,
});
@@ -0,0 +1,4 @@
// Re-exported so @storybook/addon-vitest's in-UI "Run tests" button (which
// only discovers vitest.config.* / vite.config.* files) finds the storybookTest
// project. CLI runs keep passing --config vitest.storybook.config.ts.
export { default } from './vitest.storybook.config';