[FRONT COMPONENT] Stories in twenty-sdk for front component generation and upon render interactivity (#17675)
closes https://github.com/twentyhq/core-team-issues/issues/2179
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
node_modules
|
||||
.twenty
|
||||
storybook-static
|
||||
src/front-component/__stories__/built
|
||||
|
||||
@@ -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;
|
||||
@@ -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 (
|
||||
<ThemeProvider theme={theme}>
|
||||
<ThemeContextProvider theme={theme}>
|
||||
<Story />
|
||||
</ThemeContextProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
},
|
||||
],
|
||||
args: {
|
||||
theme: THEME_LIGHT,
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -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]);
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,33 @@ const filterProps = (props: Record<string, unknown>) => {
|
||||
};`;
|
||||
};
|
||||
|
||||
// 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<string, unknown>) => {
|
||||
return React.createElement('${component.htmlTag}', filterProps(props));
|
||||
};`;
|
||||
}
|
||||
|
||||
return `const ${component.name}Wrapper = ({ children, ...props }: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('${component.htmlTag}', filterProps(props), children);
|
||||
};`;
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
+61
@@ -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"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = [
|
||||
'react-dom',
|
||||
'twenty-sdk',
|
||||
'twenty-sdk/*',
|
||||
'twenty-shared',
|
||||
'twenty-shared/*',
|
||||
];
|
||||
+13
-9
@@ -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<string>());
|
||||
}
|
||||
}
|
||||
|
||||
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<string>();
|
||||
|
||||
({ 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<string>();
|
||||
|
||||
return {
|
||||
contents: generateReactExports(collectedReactImports),
|
||||
loader: 'js',
|
||||
|
||||
+46
@@ -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(),
|
||||
};
|
||||
};
|
||||
+9
@@ -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,
|
||||
];
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
+108
@@ -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<typeof FrontComponentRenderer> = {
|
||||
title: 'FrontComponent/FrontComponentRenderer',
|
||||
component: FrontComponentRenderer,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
args: {
|
||||
onError: errorHandler,
|
||||
},
|
||||
beforeEach: () => {
|
||||
errorHandler.mockClear();
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FrontComponentRenderer>;
|
||||
|
||||
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 },
|
||||
);
|
||||
},
|
||||
};
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { defineFrontComponent } from '@/sdk';
|
||||
import { useState } from 'react';
|
||||
|
||||
const InteractiveComponent = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="interactive-component"
|
||||
style={{
|
||||
padding: 24,
|
||||
backgroundColor: '#faf5ff',
|
||||
border: '2px solid #9f7aea',
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
color: '#553c9a',
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
Interactive Component
|
||||
</h2>
|
||||
<p
|
||||
data-testid="count-display"
|
||||
style={{
|
||||
fontSize: 32,
|
||||
fontWeight: 800,
|
||||
color: '#6b46c1',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
Count: {count}
|
||||
</p>
|
||||
<button
|
||||
data-testid="increment-button"
|
||||
onClick={() => setCount((c) => c + 1)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#805ad5',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Increment
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'test-int0-00000000-0000-0000-0000-000000000002',
|
||||
name: 'interactive-component',
|
||||
description: 'Component with click interactions',
|
||||
component: InteractiveComponent,
|
||||
});
|
||||
+73
@@ -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 (
|
||||
<div
|
||||
data-testid="lifecycle-component"
|
||||
style={{
|
||||
padding: 20,
|
||||
backgroundColor: '#fffaf0',
|
||||
borderLeft: '4px solid #ed8936',
|
||||
borderRadius: '0 8px 8px 0',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
color: '#c05621',
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
Lifecycle Component
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<p
|
||||
data-testid="mounted-status"
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: 20,
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
backgroundColor: mounted ? '#c6f6d5' : '#fed7d7',
|
||||
color: mounted ? '#276749' : '#c53030',
|
||||
}}
|
||||
>
|
||||
{mounted ? 'Mounted' : 'Not mounted'}
|
||||
</p>
|
||||
<p
|
||||
data-testid="tick-count"
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#bee3f8',
|
||||
color: '#2b6cb0',
|
||||
borderRadius: 20,
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Ticks: {ticks}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'test-lif0-00000000-0000-0000-0000-000000000003',
|
||||
name: 'lifecycle-component',
|
||||
description: 'Component with useEffect lifecycle',
|
||||
component: LifecycleComponent,
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineFrontComponent } from '@/sdk';
|
||||
|
||||
const StaticComponent = () => (
|
||||
<div
|
||||
data-testid="static-component"
|
||||
style={{
|
||||
padding: 20,
|
||||
backgroundColor: '#f0f4f8',
|
||||
borderRadius: 8,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ color: '#1a365d', fontWeight: 700, marginBottom: 12 }}>
|
||||
Static Component
|
||||
</h2>
|
||||
<p style={{ color: '#4a5568', fontSize: 14, lineHeight: 1.5 }}>
|
||||
This is a simple static component.
|
||||
</p>
|
||||
<span
|
||||
data-testid="styled-badge"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#48bb78',
|
||||
color: 'white',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Styled Badge
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'test-static-00000000-0000-0000-0000-000000000001',
|
||||
name: 'static-component',
|
||||
description: 'A simple static component for testing',
|
||||
component: StaticComponent,
|
||||
});
|
||||
@@ -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<void> => {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const entryPoints: Record<string, string> = {};
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export const getBuiltComponentPath = (componentName: string): string => {
|
||||
return `/built/${componentName}.mjs`;
|
||||
};
|
||||
@@ -227,10 +227,10 @@ const HtmlAWrapper = ({
|
||||
return React.createElement('a', filterProps(props), children);
|
||||
};
|
||||
const HtmlImgWrapper = ({
|
||||
children,
|
||||
children: _children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
return React.createElement('br', filterProps(props), children);
|
||||
return React.createElement('br', filterProps(props));
|
||||
};
|
||||
const HtmlHrWrapper = ({
|
||||
children,
|
||||
children: _children,
|
||||
...props
|
||||
}: { children?: React.ReactNode } & Record<string, unknown>) => {
|
||||
return React.createElement('hr', filterProps(props), children);
|
||||
return React.createElement('hr', filterProps(props));
|
||||
};
|
||||
const TwentyUiButtonWrapper = ({
|
||||
children,
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.d.ts",
|
||||
".storybook/*.ts",
|
||||
".storybook/*.tsx",
|
||||
"**/__mocks__/**/*",
|
||||
"**/__tests__/**/*",
|
||||
"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',
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -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:*"
|
||||
|
||||
Reference in New Issue
Block a user