From 308e4de7a6d104f8645a6853499708bd7405f644 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:38:19 +0200 Subject: [PATCH] =?UTF-8?q?TDD:=20twenty-ui=20render=20coverage=20in=20the?= =?UTF-8?q?=20front-component=20sandbox=20=E2=80=94=20golden=20tests=20cat?= =?UTF-8?q?aloging=20every=20sandbox=20gap=20(fully=20green)=20(#23203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 `` 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 `` 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 ``` --- .../project.json | 1 + .../build-source-examples.ts | 7 +- .../__stories__/TwentyUiGallery.stories.tsx | 257 ++++++++++++++++++ .../WorkerPlatformApis.stories.tsx | 62 +++++ .../host-api-router-link.front-component.tsx | 30 ++ .../host-api/router-link.stories.tsx | 53 ++++ .../front-components/component-gallery.tsx | 128 +++++++++ ...ation-observer-example.front-component.tsx | 64 +++++ ...ui-code-editor-gallery.front-component.tsx | 39 +++ ...i-data-display-gallery.front-component.tsx | 118 ++++++++ ...ty-ui-feedback-gallery.front-component.tsx | 144 ++++++++++ ...twenty-ui-icon-gallery.front-component.tsx | 171 ++++++++++++ ...wenty-ui-input-gallery.front-component.tsx | 256 +++++++++++++++++ ...son-visualizer-gallery.front-component.tsx | 133 +++++++++ ...enty-ui-layout-gallery.front-component.tsx | 101 +++++++ ...-ui-modal-open-gallery.front-component.tsx | 39 +++ ...-ui-navigation-gallery.front-component.tsx | 254 +++++++++++++++++ ...ty-ui-surfaces-gallery.front-component.tsx | 99 +++++++ ...-ui-typography-gallery.front-component.tsx | 87 ++++++ .../vitest.config.ts | 4 + 20 files changed, 2046 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-front-component-renderer/src/__stories__/TwentyUiGallery.stories.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/WorkerPlatformApis.stories.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/host-api/host-api-router-link.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/host-api/router-link.stories.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/shared/front-components/component-gallery.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/showcase/mutation-observer-example.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-code-editor-gallery.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-data-display-gallery.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-feedback-gallery.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-icon-gallery.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-input-gallery.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-json-visualizer-gallery.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-layout-gallery.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-modal-open-gallery.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-navigation-gallery.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-surfaces-gallery.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-typography-gallery.front-component.tsx create mode 100644 packages/twenty-front-component-renderer/vitest.config.ts diff --git a/packages/twenty-front-component-renderer/project.json b/packages/twenty-front-component-renderer/project.json index d433218a50..36e4ca1cdd 100644 --- a/packages/twenty-front-component-renderer/project.json +++ b/packages/twenty-front-component-renderer/project.json @@ -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/**/*" ], diff --git a/packages/twenty-front-component-renderer/scripts/front-component-stories/build-source-examples.ts b/packages/twenty-front-component-renderer/scripts/front-component-stories/build-source-examples.ts index 6ecc9b5692..34d640841f 100644 --- a/packages/twenty-front-component-renderer/scripts/front-component-stories/build-source-examples.ts +++ b/packages/twenty-front-component-renderer/scripts/front-component-stories/build-source-examples.ts @@ -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'); diff --git a/packages/twenty-front-component-renderer/src/__stories__/TwentyUiGallery.stories.tsx b/packages/twenty-front-component-renderer/src/__stories__/TwentyUiGallery.stories.tsx new file mode 100644 index 0000000000..6461240635 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/TwentyUiGallery.stories.tsx @@ -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 = { + title: 'FrontComponent/Twenty UI Gallery', + component: FrontComponentRenderer, + parameters: { + layout: 'centered', + }, + args: FRONT_COMPONENT_STORY_DEFAULT_ARGS, + beforeEach: resetFrontComponentStoryMocks, +}; + +export default meta; +type Story = StoryObj; + +// 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', +); diff --git a/packages/twenty-front-component-renderer/src/__stories__/WorkerPlatformApis.stories.tsx b/packages/twenty-front-component-renderer/src/__stories__/WorkerPlatformApis.stories.tsx new file mode 100644 index 0000000000..900834f1f6 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/WorkerPlatformApis.stories.tsx @@ -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 = { + title: 'FrontComponent/Worker Platform APIs', + component: FrontComponentRenderer, + parameters: { + layout: 'centered', + }, + args: FRONT_COMPONENT_STORY_DEFAULT_ARGS, + beforeEach: resetFrontComponentStoryMocks, +}; + +export default meta; +type Story = StoryObj; + +// 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', +); diff --git a/packages/twenty-front-component-renderer/src/__stories__/host-api/host-api-router-link.front-component.tsx b/packages/twenty-front-component-renderer/src/__stories__/host-api/host-api-router-link.front-component.tsx new file mode 100644 index 0000000000..9bd3044c94 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/host-api/host-api-router-link.front-component.tsx @@ -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 = () => ( + + + + + Go to companies + + + + +); + +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, +}); diff --git a/packages/twenty-front-component-renderer/src/__stories__/host-api/router-link.stories.tsx b/packages/twenty-front-component-renderer/src/__stories__/host-api/router-link.stories.tsx new file mode 100644 index 0000000000..24e37a3350 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/host-api/router-link.stories.tsx @@ -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 = { + title: 'FrontComponent/HostApi/RouterLink', + component: FrontComponentRenderer, + parameters: { layout: 'centered' }, + args: FRONT_COMPONENT_STORY_DEFAULT_ARGS, + beforeEach: resetFrontComponentStoryMocks, +}; + +export default meta; + +type Story = StoryObj; + +// 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(); + }, +}); diff --git a/packages/twenty-front-component-renderer/src/__stories__/shared/front-components/component-gallery.tsx b/packages/twenty-front-component-renderer/src/__stories__/shared/front-components/component-gallery.tsx new file mode 100644 index 0000000000..86ce4bdb07 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/shared/front-components/component-gallery.tsx @@ -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 ( +
+

{title}

+ {isMounted && ( + `${failedEntry.name}: ${failedEntry.message}`) + .join(' | ')} + style={STATUS_STYLE} + > + {failedEntryNames.length === 0 + ? `All ${entries.length} components rendered` + : `Failed to render: ${failedEntryNames.join(', ')}`} + + )} + {entries.map((entry) => ( +
+ {entry.name} + + render failed + + } + onError={(error) => reportFailedEntry(entry.name, error)} + > + {entry.node} + +
+ ))} +
+ ); +}; diff --git a/packages/twenty-front-component-renderer/src/__stories__/showcase/mutation-observer-example.front-component.tsx b/packages/twenty-front-component-renderer/src/__stories__/showcase/mutation-observer-example.front-component.tsx new file mode 100644 index 0000000000..08b63292b4 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/showcase/mutation-observer-example.front-component.tsx @@ -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(null); + const [observedMutationCount, setObservedMutationCount] = useState(0); + const [items, setItems] = useState([]); + + 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 ( +
+ +
+ {items.map((item) => ( + {item} + ))} +
+

+ Mutations observed: {observedMutationCount} +

+
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: 'test-20ui0-0000-0000-0000-000000000110', + name: 'mutation-observer-component', + description: 'Asserts MutationObserver works inside the sandbox worker', + component: MutationObserverComponent, +}); diff --git a/packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-code-editor-gallery.front-component.tsx b/packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-code-editor-gallery.front-component.tsx new file mode 100644 index 0000000000..132bbf0a2f --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-code-editor-gallery.front-component.tsx @@ -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 ( + +
+ setMountState('mounted')} + /> +
+
+ ); +}; + +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, +}); diff --git a/packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-data-display-gallery.front-component.tsx b/packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-data-display-gallery.front-component.tsx new file mode 100644 index 0000000000..85c6828b72 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-data-display-gallery.front-component.tsx @@ -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: , + }, + { + name: 'Avatar', + node: , + }, + { + name: 'AvatarGroup', + node: ( + , + , + ]} + /> + ), + }, + { + name: 'AvatarOrIcon', + node: , + }, + { + name: 'Checkmark', + node: , + }, + { + name: 'Chip', + node: , + }, + { + name: 'ColorSample', + node: , + }, + { + name: 'CommandBlock', + node: , + }, + // 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: , + }, + { + name: 'NotificationCounter', + node: , + }, + { + name: 'Pill', + node: , + }, + { + name: 'Status', + node: , + }, + { + name: 'StyledTintedIconTileContainer', + node: ( + + + + ), + }, + { + name: 'Tag', + node: , + }, + { + name: 'TintedIconTile', + node: , + }, +]; + +const DataDisplayGallery = () => ( + + + +); + +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, +}); diff --git a/packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-feedback-gallery.front-component.tsx b/packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-feedback-gallery.front-component.tsx new file mode 100644 index 0000000000..ec9a02e3a0 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-feedback-gallery.front-component.tsx @@ -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: , + }, + { + name: 'AnimatedPlaceholderEmptyContainer', + node: ( + + Empty + + ), + }, + { + name: 'AnimatedPlaceholderEmptyTextContainer', + node: ( + + Empty text + + ), + }, + { + name: 'AnimatedPlaceholderEmptyTitle', + node: ( + No records + ), + }, + { + name: 'AnimatedPlaceholderEmptySubTitle', + node: ( + + Try adding one + + ), + }, + { + name: 'AnimatedPlaceholderErrorContainer', + node: ( + + Error + + ), + }, + { + name: 'AnimatedPlaceholderErrorTextContainer', + node: ( + + Error text + + ), + }, + { + name: 'AnimatedPlaceholderErrorTitle', + node: ( + Went wrong + ), + }, + { + name: 'AnimatedPlaceholderErrorSubTitle', + node: ( + + Please retry + + ), + }, + { + name: 'Banner', + node: ( + + Heads up + + ), + }, + { + name: 'Callout', + node: ( + + ), + }, + { + name: 'CircularProgressBar', + node: , + }, + { + name: 'Info', + node: , + }, + { + name: 'InlineBanner', + node: , + }, + { + name: 'Loader', + node: , + }, + { + name: 'ProgressBar', + node: , + }, + { + name: 'SidePanelInformationBanner', + node: , + }, +]; + +const FeedbackGallery = () => ( + + + +); + +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, +}); diff --git a/packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-icon-gallery.front-component.tsx b/packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-icon-gallery.front-component.tsx new file mode 100644 index 0000000000..b97049ecfa --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-icon-gallery.front-component.tsx @@ -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 = { + 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 = { + 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 = { + IconCalendar, + IconCheck, + IconChevronDown, + IconHeart, + IconLink, + IconMail, + IconPhone, + IconPlus, + IconSearch, + IconSettings, + IconStar, + IconTrash, + IconUser, + IconX, +}; + +const iconEntries = (icons: Record): GalleryEntry[] => + Object.entries(icons).map(([name, IconComponentToRender]) => ({ + name, + node: , + })); + +const ICON_ENTRIES: GalleryEntry[] = [ + ...iconEntries(CUSTOM_ICONS), + ...iconEntries(ILLUSTRATION_ICONS), + ...iconEntries(TABLER_ICON_SAMPLE), + { + name: 'IllustrationIconWrapper', + node: i, + }, + { + name: 'ThinkingOrbitLoaderIcon', + node: , + }, +]; + +const IconGallery = () => ( + + + +); + +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, +}); diff --git a/packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-input-gallery.front-component.tsx b/packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-input-gallery.front-component.tsx new file mode 100644 index 0000000000..717634ca4d --- /dev/null +++ b/packages/twenty-front-component-renderer/src/__stories__/twenty-ui-gallery/twenty-ui-input-gallery.front-component.tsx @@ -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: ( + {}} + /> + ), + }, + { + name: 'AnimatedButton', + node: ( + } + /> + ), + }, + { + name: 'AnimatedLightIconButton', + node: , + }, + { + name: 'Button', + node: