diff --git a/.github/workflows/ci-sdk.yaml b/.github/workflows/ci-sdk.yaml index 42bac00f244..67f2639778f 100644 --- a/.github/workflows/ci-sdk.yaml +++ b/.github/workflows/ci-sdk.yaml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - task: [lint, typecheck, test:unit] + task: [lint, typecheck, test:unit, storybook:build, storybook:test] steps: - name: Cancel Previous Runs uses: styfle/cancel-workflow-action@0.11.0 @@ -39,6 +39,9 @@ jobs: uses: ./.github/actions/yarn-install - name: Build run: npx nx build twenty-sdk + - name: Install Playwright + if: contains(matrix.task, 'storybook') + run: npx playwright install chromium - name: Run ${{ matrix.task }} task uses: ./.github/actions/nx-affected with: diff --git a/packages/twenty-sdk/.gitignore b/packages/twenty-sdk/.gitignore index 084b3711734..b06829d68d8 100644 --- a/packages/twenty-sdk/.gitignore +++ b/packages/twenty-sdk/.gitignore @@ -1,2 +1,4 @@ node_modules .twenty +storybook-static +src/front-component/__stories__/built diff --git a/packages/twenty-sdk/.storybook/main.ts b/packages/twenty-sdk/.storybook/main.ts new file mode 100644 index 00000000000..6251587dc5c --- /dev/null +++ b/packages/twenty-sdk/.storybook/main.ts @@ -0,0 +1,38 @@ +import type { StorybookConfig } from '@storybook/react-vite'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dirname = + typeof __dirname !== 'undefined' + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)); + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], + + addons: ['@storybook/addon-vitest'], + + framework: '@storybook/react-vite', + + staticDirs: [ + { + from: '../src/front-component/__stories__/built', + to: '/built', + }, + ], + + viteFinal: async (viteConfig) => { + return { + ...viteConfig, + resolve: { + ...viteConfig.resolve, + alias: { + ...viteConfig.resolve?.alias, + '@': path.resolve(dirname, '../src'), + }, + }, + }; + }, +}; + +export default config; diff --git a/packages/twenty-sdk/.storybook/preview.tsx b/packages/twenty-sdk/.storybook/preview.tsx new file mode 100644 index 00000000000..33a0c0165c7 --- /dev/null +++ b/packages/twenty-sdk/.storybook/preview.tsx @@ -0,0 +1,25 @@ +import { ThemeProvider } from '@emotion/react'; +import { type Preview } from '@storybook/react-vite'; +import { THEME_LIGHT, ThemeContextProvider } from 'twenty-ui/theme'; + +const preview: Preview = { + tags: ['autodocs'], + decorators: [ + (Story) => { + const theme = THEME_LIGHT; + + return ( + + + + + + ); + }, + ], + args: { + theme: THEME_LIGHT, + }, +}; + +export default preview; diff --git a/packages/twenty-sdk/.storybook/vitest.setup.ts b/packages/twenty-sdk/.storybook/vitest.setup.ts new file mode 100644 index 00000000000..b5c33e4c70b --- /dev/null +++ b/packages/twenty-sdk/.storybook/vitest.setup.ts @@ -0,0 +1,6 @@ +import { setProjectAnnotations } from '@storybook/react-vite'; +import * as projectAnnotations from './preview'; + +// This is an important step to apply the right configuration when testing your stories. +// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations +setProjectAnnotations([projectAnnotations]); diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 93965f66195..dcc68415492 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -81,6 +81,8 @@ }, "devDependencies": { "@prettier/sync": "^0.5.2", + "@storybook/addon-vitest": "^10.1.11", + "@storybook/react-vite": "^10.1.11", "@types/archiver": "^6.0.0", "@types/fs-extra": "^11.0.0", "@types/inquirer": "^9.0.0", @@ -88,6 +90,9 @@ "@types/node": "^24.0.0", "@types/react": "18.2.66", "@types/react-dom": "18.2.22", + "@vitest/browser-playwright": "^4.0.17", + "playwright": "^1.56.1", + "storybook": "^10.1.11", "ts-morph": "^25.0.0", "tsx": "^4.7.0", "twenty-ui": "workspace:*", diff --git a/packages/twenty-sdk/project.json b/packages/twenty-sdk/project.json index ebf092150d2..f12f4a151c3 100644 --- a/packages/twenty-sdk/project.json +++ b/packages/twenty-sdk/project.json @@ -107,6 +107,71 @@ "projects": "twenty-server" } ] - } + }, + "generateRemoteDomElements": { + "executor": "nx:run-commands", + "cache": true, + "inputs": [ + "{projectRoot}/scripts/remote-dom/**/*", + "{projectRoot}/src/front-component-constants/**/*" + ], + "outputs": [ + "{projectRoot}/src/front-component/host/generated/*", + "{projectRoot}/src/front-component/remote/generated/*" + ], + "options": { + "command": "tsx {projectRoot}/scripts/remote-dom/generateRemoteDomElements.ts" + } + }, + "storybook:prebuild": { + "executor": "nx:run-commands", + "cache": true, + "dependsOn": ["generateRemoteDomElements"], + "inputs": [ + "{projectRoot}/src/front-component/__stories__/mocks/**/*", + "{projectRoot}/src/front-component/__stories__/utils/**/*", + "{projectRoot}/src/cli/utilities/build/common/front-component-build/**/*", + "{projectRoot}/src/front-component-constants/**/*", + "{projectRoot}/src/sdk/**/*" + ], + "outputs": ["{projectRoot}/src/front-component/__stories__/built/*"], + "options": { + "command": "tsx {projectRoot}/src/front-component/__stories__/utils/buildMockComponents.ts" + } + }, + "storybook:build": { + "dependsOn": ["storybook:prebuild"], + "configurations": { + "test": {} + } + }, + "storybook:serve:dev": { + "dependsOn": ["storybook:prebuild"], + "options": { + "port": 6008 + } + }, + "storybook:serve:static": { + "options": { + "buildTarget": "twenty-sdk:storybook:build", + "port": 6008 + }, + "configurations": { + "test": {} + } + }, + "storybook:test": { + "dependsOn": ["storybook:prebuild"], + "options": { + "command": "vitest run --coverage --config vitest.storybook.config.ts --shard={args.shard}" + } + }, + "storybook:test:no-coverage": { + "dependsOn": ["storybook:prebuild"], + "options": { + "command": "vitest run --config vitest.storybook.config.ts --shard={args.shard}" + } + }, + "storybook:coverage": {} } } diff --git a/packages/twenty-sdk/scripts/remote-dom/generators/host-registry.generator.ts b/packages/twenty-sdk/scripts/remote-dom/generators/host-registry.generator.ts index 64996538b14..331d0947c46 100644 --- a/packages/twenty-sdk/scripts/remote-dom/generators/host-registry.generator.ts +++ b/packages/twenty-sdk/scripts/remote-dom/generators/host-registry.generator.ts @@ -66,7 +66,33 @@ const filterProps = (props: Record) => { };`; }; +// HTML void elements cannot have children +// https://developer.mozilla.org/en-US/docs/Glossary/Void_element +const VOID_ELEMENTS = new Set([ + 'input', + 'br', + 'hr', + 'img', + 'area', + 'base', + 'col', + 'embed', + 'link', + 'meta', + 'source', + 'track', + 'wbr', +]); + const generateHtmlWrapperComponent = (component: ComponentSchema): string => { + const isVoidElement = VOID_ELEMENTS.has(component.htmlTag ?? ''); + + if (isVoidElement) { + return `const ${component.name}Wrapper = ({ children: _children, ...props }: { children?: React.ReactNode } & Record) => { + return React.createElement('${component.htmlTag}', filterProps(props)); +};`; + } + return `const ${component.name}Wrapper = ({ children, ...props }: { children?: React.ReactNode } & Record) => { return React.createElement('${component.htmlTag}', filterProps(props), children); };`; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts index 5ef299865c5..9ba7019bf8c 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/esbuild-watcher.ts @@ -1,7 +1,7 @@ import { cleanupRemovedFiles } from '@/cli/utilities/build/common/cleanup-removed-files'; import { processEsbuildResult } from '@/cli/utilities/build/common/esbuild-result-processor'; -import { jsxTransformToRemoteDomWorkerFormatPlugin } from '@/cli/utilities/build/common/front-component-build/jsx-transform-to-remote-dom-worker-format-plugin'; -import { reactGlobalsPlugin } from '@/cli/utilities/build/common/front-component-build/react-globals-plugin'; +import { FRONT_COMPONENT_EXTERNAL_MODULES } from '@/cli/utilities/build/common/front-component-build/constants/front-component-external-modules'; +import { getFrontComponentBuildPlugins } from '@/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins'; import { type OnBuildErrorCallback, type OnFileBuiltCallback, @@ -38,14 +38,6 @@ export const LOGIC_FUNCTION_EXTERNAL_MODULES: string[] = [ 'twenty-shared/*', ]; -export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = [ - 'react-dom', - 'twenty-sdk', - 'twenty-sdk/*', - 'twenty-shared', - 'twenty-shared/*', -]; - export type EsbuildWatcherConfig = { externalModules: string[]; fileFolder: FileFolder; @@ -226,9 +218,6 @@ export const createFrontComponentsWatcher = ( externalModules: FRONT_COMPONENT_EXTERNAL_MODULES, fileFolder: FileFolder.BuiltFrontComponent, jsx: 'automatic', - extraPlugins: [ - reactGlobalsPlugin, - jsxTransformToRemoteDomWorkerFormatPlugin, - ], + extraPlugins: getFrontComponentBuildPlugins(), }, }); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/react-globals-plugin.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/react-globals-plugin.spec.ts index 936d7ac5d9e..6e51fd645c5 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/react-globals-plugin.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/__tests__/react-globals-plugin.spec.ts @@ -202,4 +202,65 @@ describe('reactGlobalsPlugin', () => { expect(result).not.toContain('globalThis.React.useEffect'); }); }); + + describe('multiple entry points', () => { + it('should handle multiple files with different React imports', async () => { + const fileA = path.join(tempDir, 'component-a.tsx'); + const fileB = path.join(tempDir, 'component-b.tsx'); + + fs.writeFileSync( + fileA, + ` + import { useState } from 'react'; + export const ComponentA = () => { + const [state] = useState(0); + return state; + }; + `, + 'utf-8', + ); + + fs.writeFileSync( + fileB, + ` + import { useEffect } from 'react'; + export const ComponentB = () => { + useEffect(() => {}, []); + return null; + }; + `, + 'utf-8', + ); + + const result = await esbuild.build({ + entryPoints: [fileA, fileB], + bundle: true, + write: false, + format: 'esm', + jsx: 'automatic', + outdir: tempDir, + plugins: [reactGlobalsPlugin], + }); + + const outputA = result.outputFiles.find( + (f) => path.basename(f.path) === 'component-a.js', + )?.text; + const outputB = result.outputFiles.find( + (f) => path.basename(f.path) === 'component-b.js', + )?.text; + + expect(outputA).toBeDefined(); + expect(outputB).toBeDefined(); + + // Each file should only include the React exports it needs + expect(outputA).toContain('globalThis.React.useState'); + expect(outputB).toContain('globalThis.React.useEffect'); + expect(outputA).not.toContain('globalThis.React.useEffect'); + expect(outputB).not.toContain('globalThis.React.useState'); + + // No raw react imports should remain + expect(outputA).not.toContain('from "react"'); + expect(outputB).not.toContain('from "react"'); + }); + }); }); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/constants/front-component-external-modules.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/constants/front-component-external-modules.ts new file mode 100644 index 00000000000..a124b9d252d --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/constants/front-component-external-modules.ts @@ -0,0 +1,7 @@ +export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = [ + 'react-dom', + 'twenty-sdk', + 'twenty-sdk/*', + 'twenty-shared', + 'twenty-shared/*', +]; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/react-globals-plugin.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/react-globals-plugin.ts index d33022dd515..58b25adb03d 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/react-globals-plugin.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/react-globals-plugin.ts @@ -78,18 +78,20 @@ export const reactGlobalsPlugin: esbuild.Plugin = { if (importer && !reactImportsByFilePath.has(importer)) { try { const sourceFileContent = await fs.readFile(importer, 'utf-8'); - reactImportsByFilePath.set( importer, collectReactImports(sourceFileContent), ); } catch { - reactImportsByFilePath.set(importer, new Set()); + reactImportsByFilePath.set(importer, new Set()); } } return { - path, + path: + path === 'react' && importer + ? `react?importer=${encodeURIComponent(importer)}` + : path, namespace: 'react-globals', pluginData: { importer }, }; @@ -98,11 +100,7 @@ export const reactGlobalsPlugin: esbuild.Plugin = { build.onLoad( { filter: /.*/, namespace: 'react-globals' }, - ({ pluginData, path }) => { - const importerFilePath = pluginData?.importer || ''; - const collectedReactImports = - reactImportsByFilePath.get(importerFilePath) || new Set(); - + ({ path, pluginData }) => { if (path === 'react/jsx-runtime') { return { contents: JSX_RUNTIME_EXPORTS, @@ -110,7 +108,13 @@ export const reactGlobalsPlugin: esbuild.Plugin = { }; } - if (path === 'react') { + if (path === 'react' || path.startsWith('react?importer=')) { + const importerFilePath = + pluginData?.importer || + decodeURIComponent(path.split('react?importer=')[1] || ''); + const collectedReactImports = + reactImportsByFilePath.get(importerFilePath) || new Set(); + return { contents: generateReactExports(collectedReactImports), loader: 'js', diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/create-front-component-build-options.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/create-front-component-build-options.ts new file mode 100644 index 00000000000..63417087c5f --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/create-front-component-build-options.ts @@ -0,0 +1,46 @@ +import type * as esbuild from 'esbuild'; + +import { FRONT_COMPONENT_EXTERNAL_MODULES } from '../constants/front-component-external-modules'; +import { getFrontComponentBuildPlugins } from './get-front-component-build-plugins'; + +type FrontComponentBuildOptions = { + entryPoints: esbuild.BuildOptions['entryPoints']; + outdir: string; + tsconfigPath?: string; + externalModules?: string[]; + logLevel?: esbuild.LogLevel; + platform?: esbuild.Platform; + minify?: boolean; + metafile?: boolean; + sourcemap?: boolean; +}; + +export const createFrontComponentBuildOptions = ({ + entryPoints, + outdir, + tsconfigPath, + externalModules = FRONT_COMPONENT_EXTERNAL_MODULES, + logLevel = 'silent', + platform, + minify, + metafile = true, + sourcemap = true, +}: FrontComponentBuildOptions): esbuild.BuildOptions => { + return { + entryPoints, + bundle: true, + splitting: false, + format: 'esm', + platform, + outdir, + outExtension: { '.js': '.mjs' }, + external: externalModules, + tsconfig: tsconfigPath, + jsx: 'automatic', + sourcemap, + metafile, + logLevel, + minify, + plugins: getFrontComponentBuildPlugins(), + }; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins.ts new file mode 100644 index 00000000000..28f78693be3 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins.ts @@ -0,0 +1,9 @@ +import type * as esbuild from 'esbuild'; + +import { jsxTransformToRemoteDomWorkerFormatPlugin } from '../jsx-transform-to-remote-dom-worker-format-plugin'; +import { reactGlobalsPlugin } from '../react-globals-plugin'; + +export const getFrontComponentBuildPlugins = (): esbuild.Plugin[] => [ + reactGlobalsPlugin, + jsxTransformToRemoteDomWorkerFormatPlugin, +]; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/replace-html-tags-with-remote-components.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/replace-html-tags-with-remote-components.ts index 40d7d26470f..5bbd4183d13 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/replace-html-tags-with-remote-components.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/replace-html-tags-with-remote-components.ts @@ -1,6 +1,6 @@ import { isDefined } from 'twenty-shared/utils'; -import { HTML_TAG_TO_REMOTE_COMPONENT } from '@/front-component-constants'; +import { HTML_TAG_TO_REMOTE_COMPONENT } from '../../../../../../front-component-constants'; const REMOTE_COMPONENTS_GLOBAL_NAMESPACE = 'RemoteComponents'; diff --git a/packages/twenty-sdk/src/front-component/__stories__/FrontComponentRenderer.stories.tsx b/packages/twenty-sdk/src/front-component/__stories__/FrontComponentRenderer.stories.tsx new file mode 100644 index 00000000000..55d3e601dda --- /dev/null +++ b/packages/twenty-sdk/src/front-component/__stories__/FrontComponentRenderer.stories.tsx @@ -0,0 +1,108 @@ +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; + +import { FrontComponentRenderer } from '../host/components/FrontComponentRenderer'; + +import { getBuiltComponentPath } from './utils/loadBuiltComponent'; + +const errorHandler = fn(); + +const meta: Meta = { + title: 'FrontComponent/FrontComponentRenderer', + component: FrontComponentRenderer, + parameters: { + layout: 'centered', + }, + args: { + onError: errorHandler, + }, + beforeEach: () => { + errorHandler.mockClear(); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Static: Story = { + args: { + componentUrl: getBuiltComponentPath('static.front-component'), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const container = await canvas.findByTestId( + 'static-component', + {}, + { timeout: 5000 }, + ); + expect(container).toBeVisible(); + expect(container).toHaveStyle({ + backgroundColor: '#f0f4f8', + borderRadius: '8px', + }); + + const heading = await canvas.findByText('Static Component'); + expect(heading).toBeVisible(); + expect(heading).toHaveStyle({ fontWeight: '700' }); + + const badge = await canvas.findByTestId('styled-badge'); + expect(badge).toBeVisible(); + expect(badge).toHaveStyle({ backgroundColor: '#48bb78' }); + }, +}; + +export const Interactive: Story = { + args: { + componentUrl: getBuiltComponentPath('interactive.front-component'), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByTestId('interactive-component', {}, { timeout: 5000 }); + + expect(await canvas.findByText('Count: 0')).toBeVisible(); + + const button = await canvas.findByTestId('increment-button'); + await userEvent.click(button); + expect(await canvas.findByText('Count: 1')).toBeVisible(); + + await userEvent.click(button); + expect(await canvas.findByText('Count: 2')).toBeVisible(); + }, +}; + +export const Lifecycle: Story = { + args: { + componentUrl: getBuiltComponentPath('lifecycle.front-component'), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByTestId('lifecycle-component', {}, { timeout: 5000 }); + + expect(await canvas.findByText('Mounted')).toBeVisible(); + + await waitFor( + () => { + const tickElement = canvas.getByTestId('tick-count'); + expect(tickElement.textContent).toMatch(/Ticks: [1-9]\d*/); + }, + { timeout: 5000 }, + ); + }, +}; + +export const ErrorHandling: Story = { + args: { + componentUrl: '/built/nonexistent.front-component.mjs', + }, + play: async () => { + await waitFor( + () => { + expect(errorHandler).toHaveBeenCalled(); + }, + { timeout: 5000 }, + ); + }, +}; diff --git a/packages/twenty-sdk/src/front-component/__stories__/mocks/interactive.front-component.tsx b/packages/twenty-sdk/src/front-component/__stories__/mocks/interactive.front-component.tsx new file mode 100644 index 00000000000..686fe753fe0 --- /dev/null +++ b/packages/twenty-sdk/src/front-component/__stories__/mocks/interactive.front-component.tsx @@ -0,0 +1,62 @@ +import { defineFrontComponent } from '@/sdk'; +import { useState } from 'react'; + +const InteractiveComponent = () => { + const [count, setCount] = useState(0); + + return ( +
+

+ Interactive Component +

+

+ Count: {count} +

+ +
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: 'test-int0-00000000-0000-0000-0000-000000000002', + name: 'interactive-component', + description: 'Component with click interactions', + component: InteractiveComponent, +}); diff --git a/packages/twenty-sdk/src/front-component/__stories__/mocks/lifecycle.front-component.tsx b/packages/twenty-sdk/src/front-component/__stories__/mocks/lifecycle.front-component.tsx new file mode 100644 index 00000000000..09f09111b5c --- /dev/null +++ b/packages/twenty-sdk/src/front-component/__stories__/mocks/lifecycle.front-component.tsx @@ -0,0 +1,73 @@ +import { defineFrontComponent } from '@/sdk'; +import { useEffect, useState } from 'react'; + +const LifecycleComponent = () => { + const [mounted, setMounted] = useState(false); + const [ticks, setTicks] = useState(0); + + useEffect(() => { + setMounted(true); + const interval = setInterval(() => { + setTicks((t) => t + 1); + }, 1000); + return () => clearInterval(interval); + }, []); + + return ( +
+

+ Lifecycle Component +

+
+

+ {mounted ? 'Mounted' : 'Not mounted'} +

+

+ Ticks: {ticks} +

+
+
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: 'test-lif0-00000000-0000-0000-0000-000000000003', + name: 'lifecycle-component', + description: 'Component with useEffect lifecycle', + component: LifecycleComponent, +}); diff --git a/packages/twenty-sdk/src/front-component/__stories__/mocks/static.front-component.tsx b/packages/twenty-sdk/src/front-component/__stories__/mocks/static.front-component.tsx new file mode 100644 index 00000000000..4861f7b63c8 --- /dev/null +++ b/packages/twenty-sdk/src/front-component/__stories__/mocks/static.front-component.tsx @@ -0,0 +1,41 @@ +import { defineFrontComponent } from '@/sdk'; + +const StaticComponent = () => ( +
+

+ Static Component +

+

+ This is a simple static component. +

+ + Styled Badge + +
+); + +export default defineFrontComponent({ + universalIdentifier: 'test-static-00000000-0000-0000-0000-000000000001', + name: 'static-component', + description: 'A simple static component for testing', + component: StaticComponent, +}); diff --git a/packages/twenty-sdk/src/front-component/__stories__/utils/buildMockComponents.ts b/packages/twenty-sdk/src/front-component/__stories__/utils/buildMockComponents.ts new file mode 100644 index 00000000000..7d9535048fa --- /dev/null +++ b/packages/twenty-sdk/src/front-component/__stories__/utils/buildMockComponents.ts @@ -0,0 +1,51 @@ +import * as esbuild from 'esbuild'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createFrontComponentBuildOptions } from '../../../cli/utilities/build/common/front-component-build/utils/create-front-component-build-options'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const mocksDir = path.resolve(dirname, '../mocks'); +const outputDir = path.resolve(dirname, '../built'); +const sdkRoot = path.resolve(dirname, '../../../..'); + +const STORY_COMPONENTS = [ + 'static.front-component', + 'interactive.front-component', + 'lifecycle.front-component', +]; + +export const buildMockComponents = async (): Promise => { + fs.mkdirSync(outputDir, { recursive: true }); + + const entryPoints: Record = {}; + + for (const name of STORY_COMPONENTS) { + const filePath = path.join(mocksDir, `${name}.tsx`); + if (!fs.existsSync(filePath)) { + throw new Error( + `Story component source file not found: ${filePath}\n` + + `Ensure the file exists in ${mocksDir} and the name in STORY_COMPONENTS is correct.`, + ); + } + entryPoints[name] = filePath; + } + + const buildOptions = createFrontComponentBuildOptions({ + entryPoints, + outdir: outputDir, + tsconfigPath: path.join(sdkRoot, 'tsconfig.json'), + }); + + await esbuild.build(buildOptions); + + console.log( + `Built ${STORY_COMPONENTS.length} story components to ${outputDir}`, + ); +}; + +buildMockComponents().catch((error) => { + console.error('Failed to build mock components:', error); + process.exit(1); +}); diff --git a/packages/twenty-sdk/src/front-component/__stories__/utils/loadBuiltComponent.ts b/packages/twenty-sdk/src/front-component/__stories__/utils/loadBuiltComponent.ts new file mode 100644 index 00000000000..9a84d539e33 --- /dev/null +++ b/packages/twenty-sdk/src/front-component/__stories__/utils/loadBuiltComponent.ts @@ -0,0 +1,3 @@ +export const getBuiltComponentPath = (componentName: string): string => { + return `/built/${componentName}.mjs`; +}; diff --git a/packages/twenty-sdk/src/front-component/host/generated/host-component-registry.ts b/packages/twenty-sdk/src/front-component/host/generated/host-component-registry.ts index d3cdb969d5f..5668035e9e8 100644 --- a/packages/twenty-sdk/src/front-component/host/generated/host-component-registry.ts +++ b/packages/twenty-sdk/src/front-component/host/generated/host-component-registry.ts @@ -227,10 +227,10 @@ const HtmlAWrapper = ({ return React.createElement('a', filterProps(props), children); }; const HtmlImgWrapper = ({ - children, + children: _children, ...props }: { children?: React.ReactNode } & Record) => { - return React.createElement('img', filterProps(props), children); + return React.createElement('img', filterProps(props)); }; const HtmlUlWrapper = ({ children, @@ -263,10 +263,10 @@ const HtmlLabelWrapper = ({ return React.createElement('label', filterProps(props), children); }; const HtmlInputWrapper = ({ - children, + children: _children, ...props }: { children?: React.ReactNode } & Record) => { - return React.createElement('input', filterProps(props), children); + return React.createElement('input', filterProps(props)); }; const HtmlTextareaWrapper = ({ children, @@ -335,16 +335,16 @@ const HtmlTdWrapper = ({ return React.createElement('td', filterProps(props), children); }; const HtmlBrWrapper = ({ - children, + children: _children, ...props }: { children?: React.ReactNode } & Record) => { - return React.createElement('br', filterProps(props), children); + return React.createElement('br', filterProps(props)); }; const HtmlHrWrapper = ({ - children, + children: _children, ...props }: { children?: React.ReactNode } & Record) => { - return React.createElement('hr', filterProps(props), children); + return React.createElement('hr', filterProps(props)); }; const TwentyUiButtonWrapper = ({ children, diff --git a/packages/twenty-sdk/tsconfig.json b/packages/twenty-sdk/tsconfig.json index 8383e42e70e..8405d5d8152 100644 --- a/packages/twenty-sdk/tsconfig.json +++ b/packages/twenty-sdk/tsconfig.json @@ -20,6 +20,8 @@ "src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", + ".storybook/*.ts", + ".storybook/*.tsx", "**/__mocks__/**/*", "**/__tests__/**/*", "vite.config.ts", diff --git a/packages/twenty-sdk/vite.config.ts b/packages/twenty-sdk/vite.config.ts index ae4dbe22190..5d2a6477bec 100644 --- a/packages/twenty-sdk/vite.config.ts +++ b/packages/twenty-sdk/vite.config.ts @@ -156,5 +156,16 @@ export default defineConfig(() => { }, }, logLevel: 'warn', + optimizeDeps: { + include: [ + '@remote-dom/core/polyfill', + '@remote-dom/react/polyfill', + '@remote-dom/core/elements', + '@remote-dom/react', + '@remote-dom/react/host', + 'react-dom/client', + 'react/jsx-runtime', + ], + }, }; }); diff --git a/packages/twenty-sdk/vitest.storybook.config.ts b/packages/twenty-sdk/vitest.storybook.config.ts new file mode 100644 index 00000000000..9e78ca1049b --- /dev/null +++ b/packages/twenty-sdk/vitest.storybook.config.ts @@ -0,0 +1,44 @@ +import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; +import { playwright } from '@vitest/browser-playwright'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const MINUTES_IN_MS = 60 * 1000; + +const dirname = + typeof __dirname !== 'undefined' + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + coverage: { + provider: 'istanbul', + reporter: ['json', 'text'], + reportsDirectory: './coverage/storybook', + }, + projects: [ + { + extends: './vite.config.ts', + plugins: [ + storybookTest({ + configDir: path.join(dirname, '.storybook'), + storybookScript: 'yarn storybook --no-open --port 6008', + }), + ], + test: { + name: 'storybook', + browser: { + enabled: true, + headless: true, + provider: playwright({}), + instances: [{ browser: 'chromium' }], + }, + setupFiles: ['./.storybook/vitest.setup.ts'], + testTimeout: 5 * MINUTES_IN_MS, + }, + }, + ], + }, +}); diff --git a/yarn.lock b/yarn.lock index 76ce587d627..dec4e2b7084 100644 --- a/yarn.lock +++ b/yarn.lock @@ -57682,6 +57682,8 @@ __metadata: "@remote-dom/core": "npm:^1.10.1" "@remote-dom/react": "npm:^1.2.2" "@sniptt/guards": "npm:^0.2.0" + "@storybook/addon-vitest": "npm:^10.1.11" + "@storybook/react-vite": "npm:^10.1.11" "@types/archiver": "npm:^6.0.0" "@types/fs-extra": "npm:^11.0.0" "@types/inquirer": "npm:^9.0.0" @@ -57689,6 +57691,7 @@ __metadata: "@types/node": "npm:^24.0.0" "@types/react": "npm:18.2.66" "@types/react-dom": "npm:18.2.22" + "@vitest/browser-playwright": "npm:^4.0.17" archiver: "npm:^7.0.1" axios: "npm:^1.6.0" chalk: "npm:^5.3.0" @@ -57704,8 +57707,10 @@ __metadata: inquirer: "npm:^10.0.0" jsonc-parser: "npm:^3.2.0" lodash.camelcase: "npm:^4.3.0" + playwright: "npm:^1.56.1" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" + storybook: "npm:^10.1.11" ts-morph: "npm:^25.0.0" tsx: "npm:^4.7.0" twenty-shared: "workspace:*"