snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
node_modules
storybook-static
src/__stories__/example-sources-built/*
!src/__stories__/example-sources-built/bundle-sizes.json.d.ts
src/__stories__/example-sources-built-preact
src/remote/sandbox/generated/*
!src/remote/sandbox/generated/frontComponentSandboxDocument.d.ts

View File

@@ -0,0 +1,81 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"jsPlugins": ["../twenty-oxlint-rules/dist/oxlint-plugin.mjs"],
"plugins": ["react", "typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": [
"node_modules",
"dist",
"src/__stories__/example-sources-built",
"src/__stories__/example-sources-built-preact",
"src/remote/sandbox/generated"
],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
],
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/jsx-key": "off",
"react/display-name": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-no-useless-fragment": "off",
"react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"twenty/effect-components": "error",
"twenty/component-props-naming": "error",
"twenty/styled-components-prefixed-with-styled": "error"
},
"overrides": [
{
"files": ["**/constants/*.ts", "**/*.constants.ts"],
"rules": {
"twenty/max-consts-per-file": ["error", { "max": 1 }]
}
},
{
"files": ["src/__stories__/**"],
"rules": {
"twenty/styled-components-prefixed-with-styled": "off"
}
}
]
}

View File

@@ -0,0 +1,73 @@
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',
refs: {
'@chakra-ui/react': { disable: true },
},
staticDirs: [
{
from: '../src/__stories__/example-sources-built',
to: '/built',
},
{
from: '../src/__stories__/example-sources-built-preact',
to: '/built-preact',
},
],
viteFinal: async (viteConfig) => {
return {
...viteConfig,
resolve: {
...viteConfig.resolve,
tsconfigPaths: true,
alias: {
...viteConfig.resolve?.alias,
'@': path.resolve(dirname, '../src'),
// twenty-sdk has React 19 locally (for Ink 6 CLI), but
// twenty-ui's dist uses React 18 internals. Force React 18
// from the workspace root for storybook builds.
react: path.resolve(dirname, '../../../node_modules/react'),
'react-dom': path.resolve(dirname, '../../../node_modules/react-dom'),
'react/jsx-runtime': path.resolve(
dirname,
'../../../node_modules/react/jsx-runtime',
),
'react/jsx-dev-runtime': path.resolve(
dirname,
'../../../node_modules/react/jsx-dev-runtime',
),
},
},
optimizeDeps: {
...viteConfig.optimizeDeps,
include: [
...(viteConfig.optimizeDeps?.include ?? []),
'transliteration',
'@remote-dom/core/polyfill',
'@remote-dom/react/polyfill',
'@remote-dom/core/elements',
'@remote-dom/react',
'react-dom/client',
'react/jsx-runtime',
],
},
};
},
};
export default config;

View File

@@ -0,0 +1,20 @@
import { type Preview } from '@storybook/react-vite';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import 'twenty-ui/theme-light.css';
import 'twenty-ui/theme-dark.css';
const preview: Preview = {
tags: ['autodocs'],
decorators: [
(Story) => {
return (
<ThemeProvider colorScheme="light">
<Story />
</ThemeProvider>
);
},
],
};
export default preview;

View File

@@ -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]);

View File

@@ -0,0 +1,43 @@
import { readFileSync } from 'fs';
import { dirname, resolve } from 'path';
import { pathsToModuleNameMapper } from 'ts-jest';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const tsConfigPath = resolve(__dirname, './tsconfig.json');
const tsConfig = JSON.parse(readFileSync(tsConfigPath, 'utf8'));
const jestConfig = {
displayName: 'twenty-front-component-renderer',
preset: '../../jest.preset.js',
testEnvironment: 'jsdom',
setupFiles: ['<rootDir>/jest.setup.mjs'],
transformIgnorePatterns: ['node_modules/(?!@quilted/)'],
transform: {
'^.+\\.(mjs|[tj]sx?)$': [
'@swc/jest',
{
jsc: {
parser: { syntax: 'typescript', tsx: true },
transform: { react: { runtime: 'automatic' } },
},
},
],
},
moduleNameMapper: {
...pathsToModuleNameMapper(tsConfig.compilerOptions.paths, {
prefix: '<rootDir>/',
}),
'^@quilted/threads$':
'<rootDir>/../../node_modules/@quilted/threads/build/esm/index.mjs',
'^@quilted/events$':
'<rootDir>/../../node_modules/@quilted/events/build/esm/index.mjs',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'mjs'],
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageDirectory: './coverage',
};
export default jestConfig;

View File

@@ -0,0 +1,5 @@
import { deserialize, serialize } from 'node:v8';
if (typeof globalThis.structuredClone !== 'function') {
globalThis.structuredClone = (value) => deserialize(serialize(value));
}

View File

@@ -0,0 +1,60 @@
{
"name": "twenty-front-component-renderer",
"private": true,
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": [
"dist",
"package.json"
],
"scripts": {
"build": "npx rimraf dist && npx vite build"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"license": "AGPL-3.0",
"dependencies": {
"@quilted/threads": "^4.0.1",
"@remote-dom/core": "^1.10.1",
"@remote-dom/react": "^1.2.2",
"@sniptt/guards": "^0.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-error-boundary": "^4.0.11",
"zod": "^4.1.11"
},
"devDependencies": {
"@chakra-ui/react": "^3.33.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/material": "^7.3.8",
"@storybook/addon-vitest": "^10.4.6",
"@storybook/react-vite": "^10.4.6",
"@types/node": "^24.0.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"@vitest/browser-playwright": "^4.1.0",
"playwright": "^1.60.0",
"prettier": "^3.1.1",
"recharts": "^3.9.2",
"storybook": "^10.4.6",
"styled-components": "^6.1.0",
"ts-morph": "^25.0.0",
"tsc-alias": "^1.8.16",
"tsx": "^4.7.0",
"twenty-sdk": "workspace:*",
"twenty-shared": "workspace:*",
"twenty-ui": "workspace:*"
},
"engines": {
"node": "^24.5.0",
"yarn": "^4.0.2"
}
}

View File

@@ -0,0 +1,153 @@
{
"name": "twenty-front-component-renderer",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-front-component-renderer/src",
"projectType": "library",
"tags": ["scope:frontend"],
"targets": {
"build": {
"executor": "nx:run-commands",
"cache": true,
"inputs": ["production", "^production"],
"dependsOn": ["^build", "sandbox:prebuild"],
"outputs": ["{projectRoot}/dist"],
"options": {
"cwd": "{projectRoot}",
"commands": [
"npx rimraf dist && npx vite build -c vite.config.ts",
"tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist"
],
"parallel": false
}
},
"typecheck": {},
"lint": {},
"test": {},
"generate-remote-dom-elements": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": ["^build"],
"inputs": [
"{projectRoot}/scripts/remote-dom/**/*",
"{projectRoot}/src/constants/**/*",
"{projectRoot}/src/types/**/*",
"{workspaceRoot}/packages/twenty-ui/src/**/index.ts",
"{workspaceRoot}/packages/twenty-ui/src/**/*.tsx"
],
"outputs": [
"{projectRoot}/src/host/generated/*",
"{projectRoot}/src/remote/generated/*"
],
"options": {
"cwd": "packages/twenty-front-component-renderer",
"command": "tsx -r tsconfig-paths/register scripts/remote-dom/generate-remote-dom-elements.ts"
},
"configurations": {
"verbose": {
"command": "tsx -r tsconfig-paths/register scripts/remote-dom/generate-remote-dom-elements.ts --verbose"
}
}
},
"sandbox:prebuild": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"generate-remote-dom-elements",
{
"target": "build",
"projects": "twenty-sdk"
},
{
"target": "build:individual",
"projects": "twenty-ui"
},
{
"target": "build:individual",
"projects": "twenty-shared"
}
],
"inputs": [
"{projectRoot}/scripts/front-component-sandbox/**/*",
"{projectRoot}/src/remote/**/*",
"!{projectRoot}/src/remote/sandbox/generated/**/*",
"{projectRoot}/src/constants/**/*",
"{projectRoot}/src/polyfills/**/*",
"{projectRoot}/src/types/**/*",
"{projectRoot}/src/utils/**/*"
],
"outputs": [
"{projectRoot}/src/remote/sandbox/generated/frontComponentSandboxDocument.ts"
],
"options": {
"cwd": "packages/twenty-front-component-renderer",
"command": "tsx -r tsconfig-paths/register scripts/front-component-sandbox/build-sandbox-document.ts"
}
},
"storybook:prebuild": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"generate-remote-dom-elements",
"sandbox:prebuild",
{
"target": "build:individual",
"projects": "twenty-ui"
},
{
"target": "build:individual",
"projects": "twenty-shared"
}
],
"inputs": [
"{projectRoot}/scripts/front-component-stories/**/*",
"{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/**/*"
],
"outputs": [
"{projectRoot}/src/__stories__/example-sources-built/*",
"{projectRoot}/src/__stories__/example-sources-built-preact/*"
],
"options": {
"command": "tsx {projectRoot}/scripts/front-component-stories/build-source-examples.ts"
}
},
"storybook:build": {
"dependsOn": ["storybook:prebuild"],
"configurations": {
"test": {}
}
},
"storybook:serve:dev": {
"executor": "nx:run-commands",
"options": {
"port": 6008
}
},
"storybook:serve:static": {
"options": {
"buildTarget": "twenty-front-component-renderer: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": {}
}
}

View File

@@ -0,0 +1,88 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { build, type Rollup } from 'vite';
const dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(dirname, '../..');
const sandboxBootstrapEntryPath = path.resolve(
projectRoot,
'src/remote/sandbox/sandbox-bootstrap.ts',
);
const generatedFile = path.resolve(
projectRoot,
'src/remote/sandbox/generated/frontComponentSandboxDocument.ts',
);
const replaceProcessEnvReferences = (code: string): string =>
code
.replace(/process\.env\.NODE_ENV/g, JSON.stringify('production'))
.replace(/process\.env/g, '{}');
const escapeClosingScriptTags = (code: string): string =>
code.replace(/<\/script/gi, '<\\/script');
const buildSandboxDocument = async (): Promise<void> => {
const buildResult = await build({
configFile: false,
root: projectRoot,
resolve: {
alias: {
'@/': path.resolve(projectRoot, 'src') + '/',
},
},
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
build: {
write: false,
lib: {
entry: sandboxBootstrapEntryPath,
formats: ['iife'],
name: 'frontComponentSandboxBootstrap',
fileName: () => 'sandbox-bootstrap.js',
},
rollupOptions: {
plugins: [
{
name: 'define-process-env',
transform: replaceProcessEnvReferences,
},
],
},
},
logLevel: 'warn',
});
const rollupOutputs = (
Array.isArray(buildResult) ? buildResult : [buildResult]
).filter((item): item is Rollup.RollupOutput => 'output' in item);
const sandboxBootstrapChunk = rollupOutputs
.flatMap((result) => result.output)
.find((item): item is Rollup.OutputChunk => item.type === 'chunk');
if (sandboxBootstrapChunk === undefined) {
throw new Error(
'Failed to build the front component sandbox bootstrap bundle',
);
}
const sandboxBootstrapScriptTag = `<script>${escapeClosingScriptTags(sandboxBootstrapChunk.code)}</script>`;
const sandboxDocument = `<!doctype html><html><head><meta charset="utf-8" /></head><body>${sandboxBootstrapScriptTag}</body></html>`;
const generatedSource = `export const FRONT_COMPONENT_SANDBOX_DOCUMENT = ${JSON.stringify(
sandboxDocument,
)};\n`;
fs.mkdirSync(path.dirname(generatedFile), { recursive: true });
fs.writeFileSync(generatedFile, generatedSource, 'utf8');
};
buildSandboxDocument().catch((error: unknown) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,244 @@
import * as esbuild from 'esbuild';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getFrontComponentBuildPlugins } from 'twenty-sdk/front-component-renderer/build';
const dirname = path.dirname(fileURLToPath(import.meta.url));
const storiesDir = path.resolve(dirname, '../../src/__stories__');
const exampleSourcesBuiltDir = path.resolve(
dirname,
'../../src/__stories__/example-sources-built',
);
const exampleSourcesBuiltPreactDir = path.resolve(
dirname,
'../../src/__stories__/example-sources-built-preact',
);
const SOURCE_SCAN_ROOTS = [
'html-tag',
'host-api',
'media',
'showcase',
'twenty-ui-gallery',
];
const rootNodeModules = path.resolve(dirname, '../../../../node_modules');
const twentyUiIndividualIndex = path.resolve(
dirname,
'../../../twenty-ui/dist/individual/individual-entry.js',
);
const sdkDefineIndex = path.resolve(
dirname,
'../../../twenty-sdk/dist/define/index.mjs',
);
const sdkFrontComponentIndex = path.resolve(
dirname,
'../../../twenty-sdk/dist/front-component/index.mjs',
);
const twentySharedIndividualDir = path.resolve(
dirname,
'../../../twenty-shared/dist/individual',
);
const TWENTY_SHARED_SUBMODULES = [
'ai',
'application',
'constants',
'database-events',
'metadata',
'testing',
'translations',
'types',
'utils',
'workflow',
'workspace',
];
const twentySharedAliases = Object.fromEntries(
TWENTY_SHARED_SUBMODULES.map((submodule) => [
`twenty-shared/${submodule}`,
path.join(twentySharedIndividualDir, submodule, 'index.js'),
]),
);
const TWENTY_UI_SUBMODULES = [
'components',
'icon',
'primitives',
'primitives/accessibility',
'primitives/data-display',
'primitives/feedback',
'primitives/input',
'primitives/json-visualizer',
'primitives/layout',
'primitives/navigation',
'primitives/surfaces',
'primitives/typography',
'theme-constants',
'utilities',
];
const twentyUiAliases = {
'twenty-ui/style.css': path.join(
path.dirname(twentyUiIndividualIndex),
'twenty-ui.css',
),
'twenty-ui': twentyUiIndividualIndex,
...Object.fromEntries(
TWENTY_UI_SUBMODULES.map((submodule) => [
`twenty-ui/${submodule}`,
twentyUiIndividualIndex,
]),
),
};
const storyAlias = {
react: path.join(rootNodeModules, 'react'),
'react-dom': path.join(rootNodeModules, 'react-dom'),
'twenty-sdk/define': sdkDefineIndex,
'twenty-sdk/front-component': sdkFrontComponentIndex,
...twentyUiAliases,
...twentySharedAliases,
};
const ENTRY_POINT_PATTERN = /\.front-component\.tsx$/;
const findEntryPointFiles = (directory: string): string[] => {
const result: string[] = [];
if (!fs.existsSync(directory)) {
return result;
}
for (const dirent of fs.readdirSync(directory, { withFileTypes: true })) {
const absolutePath = path.join(directory, dirent.name);
if (dirent.isDirectory()) {
if (dirent.name === 'shared') {
continue;
}
result.push(...findEntryPointFiles(absolutePath));
continue;
}
if (!dirent.isFile()) {
continue;
}
if (ENTRY_POINT_PATTERN.test(dirent.name)) {
result.push(absolutePath);
}
}
return result;
};
const resolveEntryPoints = (): Record<string, string> => {
const files = SOURCE_SCAN_ROOTS.flatMap((root) =>
findEntryPointFiles(path.join(storiesDir, root)),
);
const entryPoints: Record<string, string> = {};
for (const filePath of files) {
const basename = path.basename(filePath).replace(/\.tsx$/, '');
if (entryPoints[basename] !== undefined) {
throw new Error(
`Duplicate front-component basename "${basename}" found at ${filePath} and ${entryPoints[basename]}`,
);
}
entryPoints[basename] = filePath;
}
if (Object.keys(entryPoints).length === 0) {
throw new Error(
`No front-component source files found under ${storiesDir} (scanned: ${SOURCE_SCAN_ROOTS.join(', ')})`,
);
}
return entryPoints;
};
const STORY_COMPONENTS = Object.keys(resolveEntryPoints());
type BundleSizeEntry = {
name: string;
reactBytes: number;
preactBytes: number;
};
const collectBundleSizes = (): BundleSizeEntry[] =>
STORY_COMPONENTS.map((name) => {
const reactFile = path.join(exampleSourcesBuiltDir, `${name}.mjs`);
const preactFile = path.join(exampleSourcesBuiltPreactDir, `${name}.mjs`);
return {
name,
reactBytes: fs.existsSync(reactFile) ? fs.statSync(reactFile).size : 0,
preactBytes: fs.existsSync(preactFile) ? fs.statSync(preactFile).size : 0,
};
});
const buildSourceExamples = async (): Promise<void> => {
const entryPoints = resolveEntryPoints();
const tsconfigPath = path.join(dirname, '../../tsconfig.json');
const commonOptions: esbuild.BuildOptions = {
entryPoints,
bundle: true,
splitting: false,
format: 'esm',
outExtension: { '.js': '.mjs' },
tsconfig: tsconfigPath,
jsx: 'automatic',
sourcemap: true,
metafile: true,
logLevel: 'silent',
minify: true,
alias: storyAlias,
};
fs.mkdirSync(exampleSourcesBuiltDir, { recursive: true });
await esbuild.build({
...commonOptions,
outdir: exampleSourcesBuiltDir,
plugins: getFrontComponentBuildPlugins(),
});
console.log(
`Built ${STORY_COMPONENTS.length} React story components to ${exampleSourcesBuiltDir}`,
);
fs.mkdirSync(exampleSourcesBuiltPreactDir, { recursive: true });
await esbuild.build({
...commonOptions,
outdir: exampleSourcesBuiltPreactDir,
plugins: getFrontComponentBuildPlugins({ usePreact: true }),
});
console.log(
`Built ${STORY_COMPONENTS.length} Preact story components to ${exampleSourcesBuiltPreactDir}`,
);
const sizes = collectBundleSizes();
const manifestPath = path.join(exampleSourcesBuiltDir, 'bundle-sizes.json');
fs.writeFileSync(manifestPath, JSON.stringify(sizes, null, 2));
console.log(`Wrote bundle size manifest to ${manifestPath}`);
};
buildSourceExamples().catch((error) => {
console.error('Failed to build mock components:', error);
process.exit(1);
});

View File

@@ -0,0 +1,125 @@
import * as prettier from '@prettier/sync';
import * as fs from 'fs';
import * as path from 'path';
import { IndentationText, Project, QuoteKind } from 'ts-morph';
import { fileURLToPath } from 'url';
import { ALLOWED_HTML_ELEMENTS } from '../../src/constants/AllowedHtmlElements';
import { COMMON_HTML_EVENTS } from '../../src/constants/CommonHtmlEvents';
import { HTML_COMMON_PROPERTIES } from '../../src/constants/HtmlCommonProperties';
import {
type ComponentSchema,
generateHostRegistry,
generateRemoteComponents,
generateRemoteElements,
HtmlElementConfigArrayZ,
OUTPUT_FILES,
} from './generators';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const PACKAGE_PATH = path.resolve(SCRIPT_DIR, '../..');
const SRC_PATH = path.join(PACKAGE_PATH, 'src');
const HOST_GENERATED_DIR = path.join(SRC_PATH, 'host/generated');
const REMOTE_GENERATED_DIR = path.join(SRC_PATH, 'remote/generated');
const extractHtmlTag = (tag: string): string => tag.slice(5);
const getHtmlElementSchemas = (): ComponentSchema[] => {
const result = HtmlElementConfigArrayZ.safeParse(ALLOWED_HTML_ELEMENTS);
if (!result.success) {
const details = result.error.issues
.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
.join('\n');
throw new Error(`Invalid HTML element configuration:\n${details}`);
}
return result.data.map((element) => ({
name: element.name,
customElementName: element.tag,
properties: {
...HTML_COMMON_PROPERTIES,
...element.properties,
},
events: element.events
? [...COMMON_HTML_EVENTS, ...element.events]
: COMMON_HTML_EVENTS,
htmlTag: element.htmlTag ?? extractHtmlTag(element.tag),
}));
};
const getUtilityComponentSchemas = (): ComponentSchema[] => [
{
name: 'RemoteStyle',
customElementName: 'remote-style',
properties: {
cssText: { type: 'string', optional: true },
styleKey: { type: 'string', optional: true },
},
events: [],
customHostRenderer: 'RemoteStyleRenderer',
customHostRendererPath: '@/host/components/RemoteStyleRenderer',
},
];
const writeGeneratedFile = (
dir: string,
filename: string,
content: string,
): void => {
const filePath = path.join(dir, filename);
const formattedContent = prettier.format(content, {
parser: 'typescript',
filepath: filePath,
singleQuote: true,
trailingComma: 'all',
endOfLine: 'lf',
});
fs.writeFileSync(filePath, formattedContent, 'utf-8');
};
const main = (): void => {
const htmlElements = getHtmlElementSchemas();
const utilityComponents = getUtilityComponentSchemas();
const allComponents = [...htmlElements, ...utilityComponents];
fs.mkdirSync(HOST_GENERATED_DIR, { recursive: true });
fs.mkdirSync(REMOTE_GENERATED_DIR, { recursive: true });
const project = new Project({
manipulationSettings: {
indentationText: IndentationText.TwoSpaces,
quoteKind: QuoteKind.Single,
useTrailingCommas: true,
},
});
const hostRegistry = generateHostRegistry(project, allComponents);
writeGeneratedFile(
HOST_GENERATED_DIR,
OUTPUT_FILES.HOST_REGISTRY,
hostRegistry.getFullText(),
);
const remoteElements = generateRemoteElements(
project,
allComponents,
HTML_COMMON_PROPERTIES,
COMMON_HTML_EVENTS,
);
writeGeneratedFile(
REMOTE_GENERATED_DIR,
OUTPUT_FILES.REMOTE_ELEMENTS,
remoteElements.getFullText(),
);
const remoteComponents = generateRemoteComponents(project, allComponents);
writeGeneratedFile(
REMOTE_GENERATED_DIR,
OUTPUT_FILES.REMOTE_COMPONENTS,
remoteComponents.getFullText(),
);
};
main();

View File

@@ -0,0 +1,23 @@
export const CUSTOM_ELEMENT_NAMES = {
ROOT: 'remote-root',
FRAGMENT: 'remote-fragment',
} as const;
export const INTERNAL_ELEMENT_CLASSES = {
ROOT: 'RemoteRootElement',
FRAGMENT: 'RemoteFragmentElement',
} as const;
export const OUTPUT_FILES = {
REMOTE_ELEMENTS: 'remote-elements.ts',
REMOTE_COMPONENTS: 'remote-components.ts',
HOST_REGISTRY: 'host-component-registry.ts',
} as const;
export const TYPE_NAMES = {
COMMON_PROPERTIES: 'HtmlCommonProperties',
COMMON_EVENTS: 'HtmlCommonEvents',
COMMON_PROPERTIES_CONFIG: 'HTML_COMMON_PROPERTIES_CONFIG',
COMMON_EVENTS_ARRAY: 'HTML_COMMON_EVENTS_ARRAY',
EMPTY_RECORD: 'Record<string, never>',
} as const;

View File

@@ -0,0 +1,84 @@
import type { Project, SourceFile } from 'ts-morph';
import { isDefined } from 'twenty-shared/utils';
import { CUSTOM_ELEMENT_NAMES } from './constants';
import { type ComponentSchema } from './schemas';
const getCustomRendererImports = (
components: ComponentSchema[],
): Map<string, string[]> => {
const importsByPath = new Map<string, string[]>();
for (const component of components) {
if (
isDefined(component.customHostRenderer) &&
isDefined(component.customHostRendererPath)
) {
const existing =
importsByPath.get(component.customHostRendererPath) ?? [];
if (!existing.includes(component.customHostRenderer)) {
existing.push(component.customHostRenderer);
}
importsByPath.set(component.customHostRendererPath, existing);
}
}
return importsByPath;
};
const generateRegistryEntries = (components: ComponentSchema[]): string => {
const entries = components
.map((component) => {
if (isDefined(component.customHostRenderer)) {
return ` ['${component.customElementName}', createRemoteComponentRenderer(${component.customHostRenderer})],`;
}
return ` ['${component.customElementName}', createRemoteComponentRenderer(createHtmlHostWrapper('${component.htmlTag}'))],`;
})
.join('\n');
return `type ComponentRegistryValue =
| ReturnType<typeof createRemoteComponentRenderer>
| typeof RemoteFragmentRenderer;
export const componentRegistry: Map<string, ComponentRegistryValue> = new Map([
${entries}
['${CUSTOM_ELEMENT_NAMES.FRAGMENT}', RemoteFragmentRenderer],
]);`;
};
export const generateHostRegistry = (
project: Project,
components: ComponentSchema[],
): SourceFile => {
const sourceFile = project.createSourceFile(
'host-component-registry.ts',
'',
{ overwrite: true },
);
sourceFile.addImportDeclaration({
moduleSpecifier: '@remote-dom/react/host',
namedImports: ['RemoteFragmentRenderer', 'createRemoteComponentRenderer'],
});
sourceFile.addImportDeclaration({
moduleSpecifier: '@/host/elements/utils/createHtmlHostWrapper',
namedImports: ['createHtmlHostWrapper'],
});
const customRendererImports = getCustomRendererImports(components);
for (const [modulePath, namedImports] of customRendererImports) {
sourceFile.addImportDeclaration({
moduleSpecifier: modulePath,
namedImports,
});
}
sourceFile.addStatements(generateRegistryEntries(components));
return sourceFile;
};

View File

@@ -0,0 +1,6 @@
export * from './constants';
export { generateHostRegistry } from './host-registry.generator';
export { generateRemoteComponents } from './remote-components.generator';
export { generateRemoteElements } from './remote-elements.generator';
export * from './schemas';
export * from './utils';

View File

@@ -0,0 +1,64 @@
import type { Project, SourceFile } from 'ts-morph';
import { isDefined } from 'twenty-shared/utils';
import { DOM_EVENT_TYPE_TO_REACT_PROP } from '../../../src/constants/DomEventTypeToReactProp';
import { type ComponentSchema } from './schemas';
import { addExportedConst } from './utils';
const generateComponentDefinition = (
sourceFile: SourceFile,
component: ComponentSchema,
): void => {
const hasEvents = component.events.length > 0;
let initializer: string;
if (hasEvents) {
const eventProps = component.events
.map((event) => {
const propName = DOM_EVENT_TYPE_TO_REACT_PROP[event];
if (!isDefined(propName)) {
throw new Error(
`Missing DOM_EVENT_TYPE_TO_REACT_PROP mapping for '${event}'`,
);
}
return ` ${propName}: { event: '${event}' },`;
})
.join('\n');
initializer = `createRemoteComponent('${component.customElementName}', ${component.name}Element, {
eventProps: {
${eventProps}
},
})`;
} else {
initializer = `createRemoteComponent('${component.customElementName}', ${component.name}Element)`;
}
addExportedConst(sourceFile, component.name, initializer);
};
export const generateRemoteComponents = (
project: Project,
components: ComponentSchema[],
): SourceFile => {
const sourceFile = project.createSourceFile('remote-components.ts', '', {
overwrite: true,
});
sourceFile.addImportDeclaration({
moduleSpecifier: '@remote-dom/react',
namedImports: ['createRemoteComponent'],
});
sourceFile.addImportDeclaration({
moduleSpecifier: './remote-elements',
namedImports: components.map((component) => `${component.name}Element`),
});
for (const component of components) {
generateComponentDefinition(sourceFile, component);
}
return sourceFile;
};

View File

@@ -0,0 +1,465 @@
import {
type CodeBlockWriter,
type Project,
type SourceFile,
VariableDeclarationKind,
} from 'ts-morph';
import { isDefined } from 'twenty-shared/utils';
import {
CUSTOM_ELEMENT_NAMES,
INTERNAL_ELEMENT_CLASSES,
TYPE_NAMES,
} from './constants';
import { type ComponentSchema, type PropertySchema } from './schemas';
import { schemaTypeToConstructor } from './utils';
const schemaTypeToTs = (type: PropertySchema['type']): string => type;
const generatePropertyEntries = (
properties: Record<string, PropertySchema>,
): string[] =>
Object.entries(properties).map(([name, schema]) => {
const optional = schema.optional ? '?' : '';
return `'${name}'${optional}: ${schemaTypeToTs(schema.type)}`;
});
const writePropertyEntries = (
writer: CodeBlockWriter,
properties: Record<string, PropertySchema>,
): void => {
for (const [name, schema] of Object.entries(properties)) {
writer.writeLine(
`'${name}': { type: ${schemaTypeToConstructor(schema.type)} },`,
);
}
};
const getSpecificProperties = (
component: ComponentSchema,
commonPropertyNames: Set<string>,
): Record<string, PropertySchema> => {
const specific: Record<string, PropertySchema> = {};
for (const [name, schema] of Object.entries(component.properties)) {
if (!commonPropertyNames.has(name)) {
specific[name] = schema;
}
}
return specific;
};
const generateCommonPropertiesType = (
sourceFile: SourceFile,
commonProperties: Record<string, PropertySchema>,
): void => {
const entries = generatePropertyEntries(commonProperties);
sourceFile.addTypeAlias({
isExported: true,
name: TYPE_NAMES.COMMON_PROPERTIES,
type: (writer) => {
writer.block(() => {
for (const entry of entries) {
writer.writeLine(`${entry};`);
}
});
},
});
};
const generateCommonEventsType = (
sourceFile: SourceFile,
events: readonly string[],
): void => {
if (events.length === 0) {
return;
}
sourceFile.addTypeAlias({
isExported: true,
name: TYPE_NAMES.COMMON_EVENTS,
type: (writer) => {
writer.block(() => {
for (const event of events) {
writer.writeLine(
`${event}(event: RemoteEvent<SerializedEventData>): void;`,
);
}
});
},
});
sourceFile.addVariableStatement({
declarationKind: VariableDeclarationKind.Const,
declarations: [
{
name: TYPE_NAMES.COMMON_EVENTS_ARRAY,
initializer: (writer) => {
writer.write('[');
writer.newLine();
writer.indent(() => {
for (const event of events) {
writer.writeLine(`'${event}',`);
}
});
writer.write('] as const');
},
},
],
});
sourceFile.addVariableStatement({
declarationKind: VariableDeclarationKind.Const,
declarations: [
{
name: 'createSerializedEventConfig',
initializer: (writer) => {
writer.write(
'(eventType: string): RemoteElementEventListenerDefinition => (',
);
writer.block(() => {
writer.writeLine(
'dispatchEvent(this: Element, eventData: SerializedEventData) {',
);
writer.indent(() => {
writer.writeLine('applySerializedEventTargetProperties(');
writer.indent(() => {
writer.writeLine('this as unknown as Record<string, unknown>,');
writer.writeLine('eventData,');
});
writer.writeLine(');');
writer.blankLine();
writer.writeLine('const event = new CustomEvent(eventType, {');
writer.indent(() => {
writer.writeLine('detail: eventData,');
});
writer.writeLine('}) as RemoteEvent<SerializedEventData>;');
writer.blankLine();
writer.writeLine('applySerializedEventProperties(');
writer.indent(() => {
writer.writeLine(
'event as unknown as Record<string, unknown>,',
);
writer.writeLine('eventData,');
});
writer.writeLine(');');
writer.blankLine();
writer.writeLine('return event;');
});
writer.writeLine('},');
});
writer.write(')');
},
},
],
});
sourceFile.addVariableStatement({
declarationKind: VariableDeclarationKind.Const,
declarations: [
{
name: 'HTML_COMMON_EVENTS_CONFIG',
initializer: (writer) => {
writer.writeLine('Object.fromEntries(');
writer.indent(() => {
writer.writeLine(
`${TYPE_NAMES.COMMON_EVENTS_ARRAY}.map((eventType) => [`,
);
writer.indent(() => {
writer.writeLine('eventType,');
writer.writeLine('createSerializedEventConfig(eventType),');
});
writer.writeLine(']),');
});
writer.write(
`) as RemoteElementEventListenersDefinition<${TYPE_NAMES.COMMON_EVENTS}>`,
);
},
},
],
});
};
const generateCommonPropertiesConfig = (
sourceFile: SourceFile,
commonProperties: Record<string, PropertySchema>,
): void => {
sourceFile.addVariableStatement({
declarationKind: VariableDeclarationKind.Const,
declarations: [
{
name: TYPE_NAMES.COMMON_PROPERTIES_CONFIG,
initializer: (writer) => {
writer.block(() => {
for (const [name, schema] of Object.entries(commonProperties)) {
writer.writeLine(
`'${name}': { type: ${schemaTypeToConstructor(schema.type)} },`,
);
}
});
},
},
],
});
};
const generateElementPropertyType = (
sourceFile: SourceFile,
component: ComponentSchema,
specificProperties: Record<string, PropertySchema>,
): void => {
const hasSpecificProps = Object.keys(specificProperties).length > 0;
if (hasSpecificProps) {
const entries = generatePropertyEntries(specificProperties);
sourceFile.addTypeAlias({
isExported: true,
name: `${component.name}Properties`,
type: (writer) => {
if (isDefined(component.htmlTag)) {
writer.write(`${TYPE_NAMES.COMMON_PROPERTIES} & `);
}
writer.block(() => {
for (const entry of entries) {
writer.writeLine(`${entry};`);
}
});
},
});
}
};
const generateElementDefinition = (
sourceFile: SourceFile,
component: ComponentSchema,
specificProperties: Record<string, PropertySchema>,
commonEventNames: Set<string>,
shouldUseCommonHtmlPropertiesConfig: boolean,
): void => {
const isHtml = isDefined(component.htmlTag);
const hasCommonHtmlEvents = commonEventNames.size > 0 && isHtml;
const customEvents = component.events.filter(
(event) => !hasCommonHtmlEvents || !commonEventNames.has(event),
);
const hasEvents = hasCommonHtmlEvents || customEvents.length > 0;
const hasSpecificProps = Object.keys(specificProperties).length > 0;
const hasProps = Object.keys(component.properties).length > 0;
const propsType = hasSpecificProps
? `${component.name}Properties`
: hasProps && isHtml
? TYPE_NAMES.COMMON_PROPERTIES
: TYPE_NAMES.EMPTY_RECORD;
const customEventsInline = customEvents
.map((event) => `${event}(event: RemoteEvent<SerializedEventData>): void`)
.join('; ');
let eventsType: string = TYPE_NAMES.EMPTY_RECORD;
if (hasCommonHtmlEvents && customEvents.length > 0) {
eventsType = `${TYPE_NAMES.COMMON_EVENTS} & { ${customEventsInline} }`;
} else if (hasCommonHtmlEvents) {
eventsType = TYPE_NAMES.COMMON_EVENTS;
} else if (customEvents.length > 0) {
eventsType = `{ ${customEventsInline} }`;
}
sourceFile.addVariableStatement({
isExported: true,
declarationKind: VariableDeclarationKind.Const,
declarations: [
{
name: `${component.name}Element`,
initializer: (writer) => {
writer.write('createRemoteElement<');
writer.newLine();
writer.indent(() => {
writer.writeLine(`${propsType},`);
writer.writeLine('Record<string, never>,');
writer.writeLine(`${TYPE_NAMES.EMPTY_RECORD},`);
writer.write(eventsType);
});
writer.newLine();
writer.write('>');
const hasConfig = hasProps || hasEvents;
if (!hasConfig) {
writer.write('({})');
return;
}
writer.write('(');
writer.block(() => {
if (hasProps) {
if (hasSpecificProps && isHtml) {
writer.write('properties: ');
writer.block(() => {
writer.writeLine(
`...${TYPE_NAMES.COMMON_PROPERTIES_CONFIG},`,
);
writePropertyEntries(writer, specificProperties);
});
writer.write(',');
writer.newLine();
} else if (shouldUseCommonHtmlPropertiesConfig && isHtml) {
writer.write(
`properties: ${TYPE_NAMES.COMMON_PROPERTIES_CONFIG},`,
);
writer.newLine();
} else {
writer.write('properties: ');
writer.block(() => {
writePropertyEntries(writer, specificProperties);
});
writer.write(',');
writer.newLine();
}
}
if (hasEvents) {
writer.write('events: ');
writer.block(() => {
if (hasCommonHtmlEvents) {
writer.writeLine('...HTML_COMMON_EVENTS_CONFIG,');
}
for (const event of customEvents) {
writer.writeLine(
`'${event}': createSerializedEventConfig('${event}'),`,
);
}
});
writer.write(',');
writer.newLine();
}
});
writer.write(')');
},
},
],
});
};
const generateCustomElementRegistrations = (
sourceFile: SourceFile,
components: ComponentSchema[],
): void => {
for (const component of components) {
sourceFile.addStatements(
`customElements.define('${component.customElementName}', ${component.name}Element);`,
);
}
sourceFile.addStatements(
`customElements.define('${CUSTOM_ELEMENT_NAMES.ROOT}', ${INTERNAL_ELEMENT_CLASSES.ROOT});`,
);
sourceFile.addStatements(
`customElements.define('${CUSTOM_ELEMENT_NAMES.FRAGMENT}', ${INTERNAL_ELEMENT_CLASSES.FRAGMENT});`,
);
};
const generateTagNameMapDeclaration = (
sourceFile: SourceFile,
components: ComponentSchema[],
): void => {
sourceFile.addStatements((writer) => {
writer.writeLine('declare global {');
writer.indent(() => {
writer.writeLine('interface HTMLElementTagNameMap {');
writer.indent(() => {
for (const component of components) {
writer.writeLine(
`'${component.customElementName}': InstanceType<typeof ${component.name}Element>;`,
);
}
writer.writeLine(
`'${CUSTOM_ELEMENT_NAMES.ROOT}': InstanceType<typeof ${INTERNAL_ELEMENT_CLASSES.ROOT}>;`,
);
writer.writeLine(
`'${CUSTOM_ELEMENT_NAMES.FRAGMENT}': InstanceType<typeof ${INTERNAL_ELEMENT_CLASSES.FRAGMENT}>;`,
);
});
writer.writeLine('}');
});
writer.writeLine('}');
});
};
export const generateRemoteElements = (
project: Project,
components: ComponentSchema[],
commonProperties: Record<string, PropertySchema>,
commonEvents: readonly string[] = [],
): SourceFile => {
const sourceFile = project.createSourceFile('remote-elements.ts', '', {
overwrite: true,
});
const commonEventNames = new Set(commonEvents);
const shouldUseCommonHtmlPropertiesConfig =
Object.keys(commonProperties).length > 0;
sourceFile.addImportDeclaration({
moduleSpecifier: '@remote-dom/core/elements',
namedImports: [
'createRemoteElement',
INTERNAL_ELEMENT_CLASSES.ROOT,
INTERNAL_ELEMENT_CLASSES.FRAGMENT,
{ name: 'RemoteEvent', isTypeOnly: true },
{ name: 'RemoteElementEventListenerDefinition', isTypeOnly: true },
{ name: 'RemoteElementEventListenersDefinition', isTypeOnly: true },
],
});
sourceFile.addImportDeclaration({
moduleSpecifier: '@/remote/elements/utils/applySerializedEventProperties',
namedImports: ['applySerializedEventProperties'],
});
sourceFile.addImportDeclaration({
moduleSpecifier:
'@/remote/elements/utils/applySerializedEventTargetProperties',
namedImports: ['applySerializedEventTargetProperties'],
});
sourceFile.addImportDeclaration({
moduleSpecifier: '@/types/SerializedEventData',
namedImports: [{ name: 'SerializedEventData', isTypeOnly: true }],
});
const commonPropertyNames = new Set(Object.keys(commonProperties));
generateCommonPropertiesType(sourceFile, commonProperties);
if (commonEventNames.size > 0) {
generateCommonEventsType(sourceFile, commonEvents);
}
if (shouldUseCommonHtmlPropertiesConfig) {
generateCommonPropertiesConfig(sourceFile, commonProperties);
}
for (const component of components) {
const specificProperties = isDefined(component.htmlTag)
? getSpecificProperties(component, commonPropertyNames)
: component.properties;
generateElementPropertyType(sourceFile, component, specificProperties);
generateElementDefinition(
sourceFile,
component,
specificProperties,
commonEventNames,
shouldUseCommonHtmlPropertiesConfig,
);
}
generateCustomElementRegistrations(sourceFile, components);
sourceFile.addStatements(
`export { ${INTERNAL_ELEMENT_CLASSES.ROOT}, ${INTERNAL_ELEMENT_CLASSES.FRAGMENT} };`,
);
generateTagNameMapDeclaration(sourceFile, components);
return sourceFile;
};

View File

@@ -0,0 +1,32 @@
import { z } from 'zod';
export const PropertySchemaZ = z.object({
type: z.enum(['string', 'number', 'boolean']),
optional: z.boolean(),
});
export const HtmlElementConfigZ = z.object({
tag: z.string().regex(/^html-[a-z0-9]+$/, 'Tag must start with "html-"'),
name: z
.string()
.regex(/^Html[A-Z]/, 'Name must be PascalCase starting with Html'),
properties: z.record(z.string(), PropertySchemaZ),
events: z.array(z.string()).optional(),
htmlTag: z.string().optional(),
});
export const HtmlElementConfigArrayZ = z.array(HtmlElementConfigZ);
export const ComponentSchemaZ = z.object({
name: z.string().min(1),
customElementName: z.string().min(1),
properties: z.record(z.string(), PropertySchemaZ),
events: z.array(z.string()).readonly(),
htmlTag: z.string().optional(),
customHostRenderer: z.string().optional(),
customHostRendererPath: z.string().optional(),
});
export type PropertySchema = z.infer<typeof PropertySchemaZ>;
export type HtmlElementConfig = z.infer<typeof HtmlElementConfigZ>;
export type ComponentSchema = z.infer<typeof ComponentSchemaZ>;

View File

@@ -0,0 +1,13 @@
import { type SourceFile, VariableDeclarationKind } from 'ts-morph';
export const addExportedConst = (
sourceFile: SourceFile,
name: string,
initializer: string,
): void => {
sourceFile.addVariableStatement({
isExported: true,
declarationKind: VariableDeclarationKind.Const,
declarations: [{ name, initializer }],
});
};

View File

@@ -0,0 +1,2 @@
export { addExportedConst } from './add-exported-const';
export { schemaTypeToConstructor } from './schema-type-to-constructor';

View File

@@ -0,0 +1,10 @@
import { type PropertySchema } from '../schemas';
const SCHEMA_TYPE_TO_CONSTRUCTOR: Record<PropertySchema['type'], string> = {
boolean: 'Boolean',
number: 'Number',
string: 'String',
};
export const schemaTypeToConstructor = (type: PropertySchema['type']): string =>
SCHEMA_TYPE_TO_CONSTRUCTOR[type];

View File

@@ -0,0 +1,213 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import bundleSizes from '@/__stories__/example-sources-built/bundle-sizes.json';
type BundleSizeEntry = {
name: string;
reactBytes: number;
preactBytes: number;
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const kb = bytes / 1024;
if (kb < 1024) {
return `${kb.toFixed(1)} KB`;
}
return `${(kb / 1024).toFixed(2)} MB`;
};
const formatLabel = (name: string): string =>
name
.replace('.front-component', '')
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
const BAR_MAX_WIDTH = 400;
const BundleSizesTable = () => {
const entries = (bundleSizes as BundleSizeEntry[]).sort(
(a, b) => b.reactBytes - a.reactBytes,
);
const maxBytes = Math.max(...entries.map((entry) => entry.reactBytes));
return (
<div style={{ fontFamily: 'Inter, system-ui, sans-serif', padding: 24 }}>
<h2 style={{ margin: '0 0 4px', fontSize: 18, fontWeight: 600 }}>
Front Component Bundle Sizes
</h2>
<p style={{ margin: '0 0 24px', fontSize: 13, color: '#666' }}>
Each bar shows the minified .mjs size (React vs Preact runtime)
</p>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: 13,
}}
>
<thead>
<tr
style={{
textAlign: 'left',
borderBottom: '2px solid #e4e4e7',
}}
>
<th style={{ padding: '8px 12px', width: 180 }}>Component</th>
<th style={{ padding: '8px 12px' }}>Size</th>
<th style={{ padding: '8px 12px', width: 100, textAlign: 'right' }}>
React
</th>
<th style={{ padding: '8px 12px', width: 100, textAlign: 'right' }}>
Preact
</th>
<th style={{ padding: '8px 12px', width: 80, textAlign: 'right' }}>
Saving
</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const reactWidth = (entry.reactBytes / maxBytes) * BAR_MAX_WIDTH;
const preactWidth = (entry.preactBytes / maxBytes) * BAR_MAX_WIDTH;
const saving =
entry.reactBytes > 0
? (
((entry.reactBytes - entry.preactBytes) /
entry.reactBytes) *
100
).toFixed(0)
: '0';
return (
<tr
key={entry.name}
style={{ borderBottom: '1px solid #f0f0f0' }}
>
<td
style={{
padding: '10px 12px',
fontWeight: 500,
whiteSpace: 'nowrap',
}}
>
{formatLabel(entry.name)}
</td>
<td style={{ padding: '10px 12px' }}>
<div
style={{ display: 'flex', flexDirection: 'column', gap: 3 }}
>
<div
style={{
height: 14,
width: reactWidth,
backgroundColor: '#3b82f6',
borderRadius: 3,
minWidth: 4,
}}
/>
<div
style={{
height: 14,
width: preactWidth,
backgroundColor: '#8b5cf6',
borderRadius: 3,
minWidth: 4,
}}
/>
</div>
</td>
<td
style={{
padding: '10px 12px',
textAlign: 'right',
fontVariantNumeric: 'tabular-nums',
color: '#3b82f6',
}}
>
{formatBytes(entry.reactBytes)}
</td>
<td
style={{
padding: '10px 12px',
textAlign: 'right',
fontVariantNumeric: 'tabular-nums',
color: '#8b5cf6',
}}
>
{formatBytes(entry.preactBytes)}
</td>
<td
style={{
padding: '10px 12px',
textAlign: 'right',
fontVariantNumeric: 'tabular-nums',
color: '#16a34a',
}}
>
{saving}%
</td>
</tr>
);
})}
</tbody>
</table>
<div
style={{
display: 'flex',
gap: 16,
marginTop: 16,
fontSize: 12,
color: '#888',
}}
>
<span>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
backgroundColor: '#3b82f6',
borderRadius: 2,
marginRight: 4,
}}
/>
React
</span>
<span>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
backgroundColor: '#8b5cf6',
borderRadius: 2,
marginRight: 4,
}}
/>
Preact
</span>
</div>
</div>
);
};
const meta: Meta = {
title: 'FrontComponent/BundleSizes',
component: BundleSizesTable,
parameters: {
layout: 'padded',
},
};
export default meta;
type Story = StoryObj;
export const Overview: Story = {};

View File

@@ -0,0 +1,152 @@
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 { getBuiltStoryComponentPathForRender } from '@/__stories__/utils/getBuiltStoryComponentPathForRender';
const errorHandler = fn();
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/Feature',
component: FrontComponentRenderer,
parameters: {
layout: 'centered',
},
args: {
onError: errorHandler,
applicationAccessToken: 'fake-token',
executionContext: {
frontComponentId: 'storybook-test',
userId: null,
recordId: null,
selectedRecordIds: [],
timelineActivityId: null,
colorScheme: 'light',
},
},
beforeEach: () => {
errorHandler.mockClear();
},
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
const createComponentStory = (
name: string,
options?: { runtime?: 'preact'; play?: Story['play'] },
): Story => ({
args: {
componentUrl: getBuiltStoryComponentPathForRender(
`${name}.front-component`,
options?.runtime,
),
},
...(options?.play ? { play: options.play } : {}),
});
export const Static: Story = {
...createComponentStory('static'),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const container = await canvas.findByTestId(
'static-component',
{},
{ timeout: 30000 },
);
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 = {
...createComponentStory('interactive'),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId('interactive-component', {}, { timeout: 10000 });
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 = {
...createComponentStory('lifecycle'),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId('lifecycle-component', {}, { timeout: 10000 });
expect(await canvas.findByText('Mounted')).toBeVisible();
expect(
await canvas.findByText(/Ticks: [1-9]\d*/, {}, { timeout: 10000 }),
).toBeVisible();
},
};
export const ErrorHandling: Story = {
...createComponentStory('nonexistent'),
play: async () => {
await waitFor(
() => {
expect(errorHandler).toHaveBeenCalled();
},
{ timeout: 10000 },
);
},
};
export const SdkContext: Story = {
...createComponentStory('sdk-context-example'),
args: {
...createComponentStory('sdk-context-example').args,
executionContext: {
frontComponentId: 'sdk-context-test',
userId: 'test-user-abc-123',
recordId: null,
selectedRecordIds: [],
timelineActivityId: null,
colorScheme: 'light',
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId('sdk-context-component', {}, { timeout: 30000 });
const userIdElement = await canvas.findByTestId('sdk-context-user-id');
expect(userIdElement).toBeVisible();
expect(userIdElement).toHaveTextContent('test-user-abc-123');
const jsonElement = await canvas.findByTestId('sdk-context-json');
expect(jsonElement).toHaveTextContent('"userId": "test-user-abc-123"');
const button = await canvas.findByTestId('sdk-context-button');
await userEvent.click(button);
const renderCount = await canvas.findByTestId('sdk-context-render-count');
expect(renderCount).toHaveTextContent('Renders: 1');
expect(userIdElement).toHaveTextContent('test-user-abc-123');
},
};

View File

@@ -0,0 +1,25 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect } from 'storybook/test';
import { runFrontComponentSandboxIsolationProbe } from '@/__stories__/utils/runFrontComponentSandboxIsolationProbe';
const meta: Meta = {
title: 'FrontComponent/Security',
render: () => <div data-testid="front-component-sandbox-isolation" />,
};
export default meta;
type Story = StoryObj;
export const WorkerRunsInOpaqueOriginWithoutStorageAccess: Story = {
play: async () => {
const report = await runFrontComponentSandboxIsolationProbe();
expect(report.iframeOrigin).toBe('null');
expect(report.workerOrigin).toBe('null');
expect(report.localStorageDenied).toBe(true);
expect(report.cookiesDenied).toBe(true);
expect(report.indexedDbDenied).toBe(true);
expect(report.workerIndexedDbDenied).toBe(true);
},
};

View File

@@ -0,0 +1,95 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, waitFor, within } from 'storybook/test';
import { getBuiltStoryComponentPathForRender } from '@/__stories__/utils/getBuiltStoryComponentPathForRender';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
const GEOMETRY_TIMEOUT = 30000;
const errorHandler = fn();
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/Geometry',
component: FrontComponentRenderer,
parameters: {
layout: 'centered',
},
args: {
onError: errorHandler,
applicationAccessToken: 'fake-token',
executionContext: {
frontComponentId: 'storybook-test',
userId: null,
recordId: null,
selectedRecordIds: [],
timelineActivityId: null,
colorScheme: 'light',
},
},
beforeEach: () => {
errorHandler.mockClear();
},
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
const createGeometryStory = (name: string, play: Story['play']): Story => ({
args: {
componentUrl: getBuiltStoryComponentPathForRender(
`${name}.front-component`,
),
},
play,
});
export const MirrorsElementGeometryIntoTheWorker: Story = createGeometryStory(
'geometry-measure',
async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'geometry-measure-component',
{},
{ timeout: GEOMETRY_TIMEOUT },
);
await waitFor(
() => {
expect(canvas.getByTestId('geometry-measure-width')).toHaveTextContent(
'width: 240',
);
expect(canvas.getByTestId('geometry-measure-height')).toHaveTextContent(
'height: 80',
);
expect(
canvas.getByTestId('geometry-measure-offset-width'),
).toHaveTextContent('offsetWidth: 240');
expect(
canvas.getByTestId('geometry-measure-inner-width'),
).not.toHaveTextContent('innerWidth: 0');
},
{ timeout: GEOMETRY_TIMEOUT },
);
expect(errorHandler).not.toHaveBeenCalled();
},
);
export const RendersRechartsAreaChart: Story = createGeometryStory(
'recharts-example',
async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'recharts-component',
{},
{ timeout: GEOMETRY_TIMEOUT },
);
expect(await canvas.findByText('Jan')).toBeVisible();
expect(await canvas.findByText('Jun')).toBeVisible();
expect(errorHandler).not.toHaveBeenCalled();
},
);

View File

@@ -0,0 +1,437 @@
import { buttonControlsTest } from '@/__stories__/twenty-ui-gallery/utils/buttonControlsTest';
import { type Meta } from '@storybook/react-vite';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { type TwentyUiGalleryStory as Story } from '@/__stories__/twenty-ui-gallery/types/TwentyUiGalleryStory';
import {
createCheckboxTest,
createFieldControlsTest,
createRadioGroupPreactTest,
listItemTest,
sliderTest,
toastTest,
} from '@/__stories__/twenty-ui-gallery/utils/componentInteractionTests';
import {
statusControlsTest,
tagControlsTest,
avatarControlsTest,
chipControlsTest,
} from '@/__stories__/twenty-ui-gallery/utils/displayControlTests';
import { createGalleryStory } from '@/__stories__/twenty-ui-gallery/utils/createGalleryStory';
import { typographyTest } from '@/__stories__/twenty-ui-gallery/utils/typographyTest';
import {
codeEditorTest,
dataDisplayTest,
displayHelpersTest,
galleryRenderTest,
inputPreactTest,
inputReactTest,
modalOpenHangTest,
navigationTest,
themeTokenTest,
} from '@/__stories__/twenty-ui-gallery/utils/galleryRenderTests';
import {
alertDialogTest,
menuTest,
popoverTest,
radioGroupReactTest,
selectTest,
sliderRangeTest,
switchTest,
tabsPreactTest,
tabsReactTest,
} from '@/__stories__/twenty-ui-gallery/utils/sandboxFailureTests';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import { createTooltipSandboxFailureTest } from '@/__stories__/twenty-ui-gallery/utils/createTooltipSandboxFailureTest';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/Twenty UI Gallery',
component: FrontComponentRenderer,
parameters: {
layout: 'centered',
},
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const DataDisplayReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-data-display-gallery',
runtime: 'react',
play: dataDisplayTest,
});
export const DataDisplayPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-data-display-gallery',
runtime: 'preact',
play: dataDisplayTest,
});
export const FeedbackReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-feedback-gallery',
runtime: 'react',
play: galleryRenderTest,
});
export const FeedbackPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-feedback-gallery',
runtime: 'preact',
play: galleryRenderTest,
});
export const IconReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-icon-gallery',
runtime: 'react',
play: galleryRenderTest,
});
export const IconPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-icon-gallery',
runtime: 'preact',
play: galleryRenderTest,
});
export const InputReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-input-gallery',
runtime: 'react',
play: inputReactTest,
});
export const InputPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-input-gallery',
runtime: 'preact',
play: inputPreactTest,
});
export const JsonVisualizerReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-json-visualizer-gallery',
runtime: 'react',
play: galleryRenderTest,
});
export const JsonVisualizerPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-json-visualizer-gallery',
runtime: 'preact',
play: galleryRenderTest,
});
export const LayoutReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-layout-gallery',
runtime: 'react',
play: galleryRenderTest,
});
export const LayoutPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-layout-gallery',
runtime: 'preact',
play: galleryRenderTest,
});
export const NavigationReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-navigation-gallery',
runtime: 'react',
play: navigationTest,
});
export const NavigationPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-navigation-gallery',
runtime: 'preact',
play: navigationTest,
});
export const SurfacesReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-surfaces-gallery',
runtime: 'react',
play: galleryRenderTest,
});
export const SurfacesPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-surfaces-gallery',
runtime: 'preact',
play: galleryRenderTest,
});
export const ModalOpenReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-modal-open-gallery',
runtime: 'react',
play: modalOpenHangTest,
});
export const ModalOpenPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-modal-open-gallery',
runtime: 'preact',
play: galleryRenderTest,
});
export const CodeEditorReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-code-editor-gallery',
runtime: 'react',
play: codeEditorTest,
});
export const CodeEditorPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-code-editor-gallery',
runtime: 'preact',
play: codeEditorTest,
});
export const TypographyReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-typography-gallery',
runtime: 'react',
play: typographyTest,
});
export const TypographyPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-typography-gallery',
runtime: 'preact',
play: typographyTest,
});
export const ThemeTokensReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-theme-tokens',
runtime: 'react',
play: themeTokenTest,
});
export const ThemeTokensPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-theme-tokens',
runtime: 'preact',
play: themeTokenTest,
});
export const FieldControlsReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-field-controls',
runtime: 'react',
// React serializes boolean ARIA as empty strings and loses Textarea's
// change handler.
play: createFieldControlsTest({
expectedAriaInvalid: '',
expectedReportedValues: /^Email: alice; Notes:$/,
}),
});
export const FieldControlsPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-field-controls',
runtime: 'preact',
play: createFieldControlsTest({
expectedAriaInvalid: 'true',
expectedReportedValues: 'Email: alice; Notes: Follow up',
}),
});
export const DisplayHelpersReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-display-helpers',
runtime: 'react',
play: displayHelpersTest,
});
export const DisplayHelpersPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-display-helpers',
runtime: 'preact',
play: displayHelpersTest,
});
export const ListItemReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-list-item',
runtime: 'react',
play: listItemTest,
});
export const ListItemPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-list-item',
runtime: 'preact',
play: listItemTest,
});
export const TabsReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-tabs',
runtime: 'react',
play: tabsReactTest,
});
export const TabsPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-tabs',
runtime: 'preact',
play: tabsPreactTest,
});
export const PopoverReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-popover',
runtime: 'react',
play: popoverTest,
});
export const PopoverPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-popover',
runtime: 'preact',
play: popoverTest,
});
export const TooltipReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-tooltip',
runtime: 'react',
play: createTooltipSandboxFailureTest('react'),
});
export const TooltipPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-tooltip',
runtime: 'preact',
play: createTooltipSandboxFailureTest('preact'),
});
export const MenuReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-menu',
runtime: 'react',
play: menuTest,
});
export const MenuPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-menu',
runtime: 'preact',
play: menuTest,
});
export const SelectReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-select',
runtime: 'react',
play: selectTest,
});
export const SelectPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-select',
runtime: 'preact',
play: selectTest,
});
export const ToastReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-toast',
runtime: 'react',
play: toastTest,
});
export const ToastPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-toast',
runtime: 'preact',
play: toastTest,
});
export const AlertDialogReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-alert-dialog',
runtime: 'react',
play: alertDialogTest,
});
export const AlertDialogPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-alert-dialog',
runtime: 'preact',
play: alertDialogTest,
});
export const SwitchReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-switch',
runtime: 'react',
play: switchTest,
});
export const SwitchPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-switch',
runtime: 'preact',
play: switchTest,
});
export const CheckboxReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-checkbox',
runtime: 'react',
play: createCheckboxTest({ expectedAriaTrue: '' }),
});
export const CheckboxPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-checkbox',
runtime: 'preact',
play: createCheckboxTest({ expectedAriaTrue: 'true' }),
});
export const SliderReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-slider',
runtime: 'react',
play: sliderTest,
});
export const SliderPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-slider',
runtime: 'preact',
play: sliderTest,
});
export const SliderRangeReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-slider-range',
runtime: 'react',
play: sliderRangeTest,
});
export const SliderRangePreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-slider-range',
runtime: 'preact',
play: sliderRangeTest,
});
export const RadioGroupReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-radio-group',
runtime: 'react',
play: radioGroupReactTest,
});
export const RadioGroupPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-radio-group',
runtime: 'preact',
play: createRadioGroupPreactTest({ optionName: 'Daily' }),
});
export const CardPickerReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-radio-group',
runtime: 'react',
play: radioGroupReactTest,
});
export const CardPickerPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-radio-group',
runtime: 'preact',
play: createRadioGroupPreactTest({ optionName: 'Pro plan' }),
});
export const StatusControlsReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-status-controls',
runtime: 'react',
play: statusControlsTest,
});
export const StatusControlsPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-status-controls',
runtime: 'preact',
play: statusControlsTest,
});
export const TagControlsReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-tag-controls',
runtime: 'react',
play: tagControlsTest,
});
export const TagControlsPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-tag-controls',
runtime: 'preact',
play: tagControlsTest,
});
export const ButtonControlsReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-button-controls',
runtime: 'react',
play: buttonControlsTest,
});
export const ButtonControlsPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-button-controls',
runtime: 'preact',
play: buttonControlsTest,
});
export const AvatarControlsReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-avatar-controls',
runtime: 'react',
play: avatarControlsTest,
});
export const AvatarControlsPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-avatar-controls',
runtime: 'preact',
play: avatarControlsTest,
});
export const ChipControlsReact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-chip-controls',
runtime: 'react',
play: chipControlsTest,
});
export const ChipControlsPreact: Story = createGalleryStory({
frontComponentBundleName: 'twenty-ui-chip-controls',
runtime: 'preact',
play: chipControlsTest,
});

View File

@@ -0,0 +1,151 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import { getBuiltStoryComponentPathForRender } from '@/__stories__/utils/getBuiltStoryComponentPathForRender';
const errorHandler = fn();
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/UI Libraries',
component: FrontComponentRenderer,
parameters: {
layout: 'centered',
},
args: {
onError: errorHandler,
applicationAccessToken: 'fake-token',
executionContext: {
frontComponentId: 'storybook-test',
userId: null,
recordId: null,
selectedRecordIds: [],
timelineActivityId: null,
colorScheme: 'light',
},
},
beforeEach: () => {
errorHandler.mockClear();
},
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
const createComponentStory = (
name: string,
options?: { runtime?: 'preact'; play?: Story['play'] },
): Story => ({
args: {
componentUrl: getBuiltStoryComponentPathForRender(
`${name}.front-component`,
options?.runtime,
),
},
...(options?.play ? { play: options.play } : {}),
});
const createCounterTest =
(testIdPrefix: string): Story['play'] =>
async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
`${testIdPrefix}-component`,
{},
{ timeout: 30000 },
);
expect(await canvas.findByText('Count: 0')).toBeVisible();
const button = await canvas.findByTestId(`${testIdPrefix}-button`);
await userEvent.click(button);
expect(await canvas.findByText('Count: 1')).toBeVisible();
await userEvent.click(button);
expect(await canvas.findByText('Count: 2')).toBeVisible();
};
const chakraTest = createCounterTest('chakra');
export const ChakraReact: Story = createComponentStory('chakra-example', {
play: chakraTest,
});
export const ChakraPreact: Story = createComponentStory('chakra-example', {
runtime: 'preact',
play: chakraTest,
});
const tailwindTest = createCounterTest('tailwind');
export const TailwindReact: Story = createComponentStory('tailwind-example', {
play: tailwindTest,
});
export const TailwindPreact: Story = createComponentStory('tailwind-example', {
runtime: 'preact',
play: tailwindTest,
});
const emotionTest = createCounterTest('emotion');
export const EmotionReact: Story = createComponentStory('emotion-example', {
play: emotionTest,
});
export const EmotionPreact: Story = createComponentStory('emotion-example', {
runtime: 'preact',
play: emotionTest,
});
const styledComponentsTest = createCounterTest('styled-components');
export const StyledComponentsReact: Story = createComponentStory(
'styled-components-example',
{ play: styledComponentsTest },
);
export const StyledComponentsPreact: Story = createComponentStory(
'styled-components-example',
{ runtime: 'preact', play: styledComponentsTest },
);
const shadcnTest = createCounterTest('shadcn');
export const ShadcnReact: Story = createComponentStory('shadcn-example', {
play: shadcnTest,
});
export const ShadcnPreact: Story = createComponentStory('shadcn-example', {
runtime: 'preact',
play: shadcnTest,
});
const muiTest = createCounterTest('mui');
export const MuiReact: Story = createComponentStory('mui-example', {
play: muiTest,
});
export const MuiPreact: Story = createComponentStory('mui-example', {
runtime: 'preact',
play: muiTest,
});
const twentyUiTest: Story['play'] = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId('twenty-ui-component', {}, { timeout: 30000 });
expect(await canvas.findByText('Count: 0')).toBeVisible();
const button = await canvas.findByText('Increment');
await userEvent.click(button);
expect(await canvas.findByText('Count: 1')).toBeVisible();
await userEvent.click(button);
expect(await canvas.findByText('Count: 2')).toBeVisible();
};
export const TwentyUiReact: Story = createComponentStory('twenty-ui-example', {
play: twentyUiTest,
});
export const TwentyUiPreact: Story = createComponentStory('twenty-ui-example', {
runtime: 'preact',
play: twentyUiTest,
});

View File

@@ -0,0 +1,139 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import {
errorHandler,
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectJsonDataAttribute } from '@/__stories__/shared/test-utils/matchers/expectJsonDataAttribute';
import {
INTERACTION_TIMEOUT,
MOUNT_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
import { getBuiltStoryComponentPathForRender } from '@/__stories__/utils/getBuiltStoryComponentPathForRender';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/Worker Platform APIs',
component: FrontComponentRenderer,
parameters: {
layout: 'centered',
},
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
const EXPECTED_OBSERVED_MUTATIONS = [
{
type: 'childList',
addedItems: ['item-0'],
removedItems: [],
hasPreviousSibling: false,
hasNextSibling: false,
},
{
type: 'childList',
addedItems: ['item-1'],
removedItems: [],
hasPreviousSibling: true,
hasNextSibling: false,
},
];
const mutationObserverTest: Story['play'] = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const addItemButton = await canvas.findByTestId(
'mutation-observer-add',
{},
{ timeout: MOUNT_TIMEOUT },
);
await userEvent.click(addItemButton);
await userEvent.click(addItemButton);
await expectJsonDataAttribute({
canvas,
testId: 'mutation-observer-status',
attributeName: 'data-observed-records',
expectedValue: EXPECTED_OBSERVED_MUTATIONS,
});
expect(errorHandler).not.toHaveBeenCalled();
};
const EXPECTED_CLASS_LIST_REPORT = {
tokens: ['initial-class', 'mapboxgl-map', 'replaced', 'toggled-on'],
value: 'initial-class mapboxgl-map replaced toggled-on',
};
const classListTest: Story['play'] = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const runButton = await canvas.findByTestId(
'class-list-run',
{},
{ timeout: MOUNT_TIMEOUT },
);
await userEvent.click(runButton);
await expectJsonDataAttribute({
canvas,
testId: 'class-list-status',
attributeName: 'data-class-list-report',
expectedValue: EXPECTED_CLASS_LIST_REPORT,
});
await waitFor(
() => {
expect(canvas.getByTestId('class-list-container').className).toBe(
EXPECTED_CLASS_LIST_REPORT.value,
);
},
{ timeout: INTERACTION_TIMEOUT },
);
expect(errorHandler).not.toHaveBeenCalled();
};
const createStory = ({
name,
play,
runtime,
}: {
name: string;
play: Story['play'];
runtime?: 'preact';
}): Story => ({
args: {
componentUrl: getBuiltStoryComponentPathForRender(
`${name}.front-component`,
runtime,
),
},
play,
});
export const MutationObserverReact: Story = createStory({
name: 'mutation-observer-example',
play: mutationObserverTest,
});
export const MutationObserverPreact: Story = createStory({
name: 'mutation-observer-example',
play: mutationObserverTest,
runtime: 'preact',
});
export const ClassListReact: Story = createStory({
name: 'class-list-example',
play: classListTest,
});
export const ClassListPreact: Story = createStory({
name: 'class-list-example',
play: classListTest,
runtime: 'preact',
});

View File

@@ -0,0 +1,7 @@
declare const bundleSizes: {
name: string;
reactBytes: number;
preactBytes: number;
}[];
export default bundleSizes;

View File

@@ -0,0 +1,60 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { isDefined } from 'twenty-shared/utils';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import {
HOST_API_TIMEOUT,
INTERACTION_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HostApi/ClosePanel',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const ClosePanel: Story = runFrontComponentStory({
frontComponentBundleName: 'host-api-side-panel-close',
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi;
if (!isDefined(api)) {
throw new Error('frontComponentHostCommunicationApi is required');
}
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await waitFor(
() => {
expect(api.closeSidePanel).toHaveBeenCalled();
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'closePanel:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
});

View File

@@ -0,0 +1,60 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { isDefined } from 'twenty-shared/utils';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import {
HOST_API_TIMEOUT,
INTERACTION_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HostApi/CopyToClipboard',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const CopyToClipboard: Story = runFrontComponentStory({
frontComponentBundleName: 'host-api-copy-to-clipboard',
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi;
if (!isDefined(api)) {
throw new Error('frontComponentHostCommunicationApi is required');
}
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await waitFor(
() => {
expect(api.copyToClipboard).toHaveBeenCalledWith('Hello clipboard');
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'clipboard:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
});

View File

@@ -0,0 +1,47 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { copyToClipboard } from 'twenty-sdk/front-component';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
BUTTON_STYLE,
STATUS_STYLE,
} from '@/__stories__/shared/front-components/styles';
const HostApiCopyToClipboardFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
await copyToClipboard('Hello clipboard');
setStatus('clipboard:success');
} catch (error) {
setStatus(
`clipboard:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:copy-to-clipboard">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Copy
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-clipboard-00000000-0000-0000-0000-000000000020',
name: 'host-api-copy-to-clipboard-front-component',
description: 'Front component covering copyToClipboard host API',
component: HostApiCopyToClipboardFrontComponent,
});

View File

@@ -0,0 +1,49 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { AppPath, navigate } from 'twenty-sdk/front-component';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
BUTTON_STYLE,
STATUS_STYLE,
} from '@/__stories__/shared/front-components/styles';
const HostApiNavigateFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
await navigate(AppPath.RecordIndexPage, {
objectNamePlural: 'companies',
});
setStatus('navigate:success');
} catch (error) {
setStatus(
`navigate:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:navigate">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Navigate
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-navigate-00000000-0000-0000-0000-000000000020',
name: 'host-api-navigate-front-component',
description: 'Front component covering navigate host API',
component: HostApiNavigateFrontComponent,
});

View File

@@ -0,0 +1,55 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
BUTTON_STYLE,
STATUS_STYLE,
} from '@/__stories__/shared/front-components/styles';
// Deliberately uses the standard web API rather than the SDK helper: this is
// what a third-party library calls, and it only works if the worker polyfill
// installed navigator.clipboard in the real sandbox realm.
const HostApiNavigatorClipboardFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
if (typeof navigator.clipboard?.writeText !== 'function') {
setStatus('clipboard:missing');
return;
}
await navigator.clipboard.writeText('Hello standard clipboard');
setStatus('clipboard:success');
} catch (error) {
setStatus(
`clipboard:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:navigator-clipboard">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Copy
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-clipboard-00000000-0000-0000-0000-000000000021',
name: 'host-api-navigator-clipboard-front-component',
description: 'Front component covering the navigator.clipboard polyfill',
component: HostApiNavigatorClipboardFrontComponent,
});

View File

@@ -0,0 +1,47 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { updateProgress } from 'twenty-sdk/front-component';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
BUTTON_STYLE,
STATUS_STYLE,
} from '@/__stories__/shared/front-components/styles';
const HostApiProgressFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
await updateProgress(50);
setStatus('progress:success');
} catch (error) {
setStatus(
`progress:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:progress">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Update Progress
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-progress-00000000-0000-0000-0000-000000000020',
name: 'host-api-progress-front-component',
description: 'Front component covering updateProgress host API',
component: HostApiProgressFrontComponent,
});

View File

@@ -0,0 +1,30 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { UndecoratedLink } from 'twenty-ui/primitives/navigation';
import { ThemeProvider } from 'twenty-ui/theme-constants';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
// KNOWN ISSUE (TDD): a react-router Link click must route through the host
// navigate API. Today the link cannot even render (no router context in the
// sandbox); once it renders, the click must reach hostApi.navigate — and the
// host must guard native anchor clicks, which otherwise navigate the host
// page before the async worker round-trip can preventDefault.
const HostApiRouterLinkFrontComponent = () => (
<ThemeProvider colorScheme="light">
<FrontComponentCard title="host-api:router-link">
<span data-testid="router-link">
<UndecoratedLink to="/objects/companies">
Go to companies
</UndecoratedLink>
</span>
</FrontComponentCard>
</ThemeProvider>
);
export default defineFrontComponent({
universalIdentifier: 'test-20ui0-0000-0000-0000-000000000113',
name: 'host-api-router-link',
description:
'A front component whose react-router link navigates through the host API',
component: HostApiRouterLinkFrontComponent,
});

View File

@@ -0,0 +1,47 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { closeSidePanel } from 'twenty-sdk/front-component';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
BUTTON_STYLE,
STATUS_STYLE,
} from '@/__stories__/shared/front-components/styles';
const HostApiSidePanelCloseFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
await closeSidePanel();
setStatus('closePanel:success');
} catch (error) {
setStatus(
`closePanel:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:side-panel:close">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Close Side Panel
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-spc-00000000-0000-0000-0000-000000000020',
name: 'host-api-side-panel-close-front-component',
description: 'Front component covering closeSidePanel host API',
component: HostApiSidePanelCloseFrontComponent,
});

View File

@@ -0,0 +1,53 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { AppPath, openSidePanelPage } from 'twenty-sdk/front-component';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
BUTTON_STYLE,
STATUS_STYLE,
} from '@/__stories__/shared/front-components/styles';
const HostApiSidePanelOpenFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
await openSidePanelPage({
to: AppPath.RecordShowPage,
params: {
objectNameSingular: 'company',
objectRecordId: 'test-record-id',
},
});
setStatus('sidePanel:success');
} catch (error) {
setStatus(
`sidePanel:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:side-panel:open">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Open Side Panel
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-spo-00000000-0000-0000-0000-000000000020',
name: 'host-api-side-panel-open-front-component',
description: 'Front component covering openSidePanelPage host API',
component: HostApiSidePanelOpenFrontComponent,
});

View File

@@ -0,0 +1,50 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
BUTTON_STYLE,
STATUS_STYLE,
} from '@/__stories__/shared/front-components/styles';
const HostApiSnackbarFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
await enqueueSnackbar({
message: 'Test notification',
variant: 'success',
});
setStatus('snackbar:success');
} catch (error) {
setStatus(
`snackbar:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:snackbar">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Snackbar
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-snackbar-00000000-0000-0000-0000-000000000020',
name: 'host-api-snackbar-front-component',
description: 'Front component covering enqueueSnackbar host API',
component: HostApiSnackbarFrontComponent,
});

View File

@@ -0,0 +1,112 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
BUTTON_STYLE,
STATUS_STYLE,
} from '@/__stories__/shared/front-components/styles';
const OVERSIZED_VALUE = 'x'.repeat(300 * 1024);
const getErrorName = (error: unknown): string =>
error instanceof Error ? error.name : String(error);
const HostApiStorageFrontComponent = () => {
const [status, setStatus] = useState('idle');
const runLocalStorageRoundTrip = () => {
try {
window.localStorage.setItem('greeting', 'hello');
const storedValue = window.localStorage.getItem('greeting');
const storedKey = window.localStorage.key(0);
const storedLength = window.localStorage.length;
window.localStorage.removeItem('greeting');
setStatus(
`storage:local:${storedValue}:${storedKey}:${storedLength}:${window.localStorage.length}`,
);
} catch (error) {
setStatus(`storage:error:${getErrorName(error)}`);
}
};
const runLocalStorageWrite = () => {
try {
window.localStorage.setItem('theme', 'dark');
setStatus(`storage:local-write:${window.localStorage.getItem('theme')}`);
} catch (error) {
setStatus(`storage:error:${getErrorName(error)}`);
}
};
const runSessionStorageRoundTrip = () => {
try {
window.sessionStorage.setItem('visits', '2');
setStatus(`storage:session:${window.sessionStorage.getItem('visits')}`);
} catch (error) {
setStatus(`storage:error:${getErrorName(error)}`);
}
};
const runOversizedWrite = () => {
try {
window.localStorage.setItem('oversized', OVERSIZED_VALUE);
setStatus('storage:local:oversized');
} catch (error) {
setStatus(`storage:error:${getErrorName(error)}`);
}
};
return (
<FrontComponentCard title="host-api:storage">
<button
data-testid="subject"
type="button"
onClick={runLocalStorageRoundTrip}
style={BUTTON_STYLE}
>
Local storage round trip
</button>
<button
data-testid="local-storage-write"
type="button"
onClick={runLocalStorageWrite}
style={BUTTON_STYLE}
>
Local storage write
</button>
<button
data-testid="session-storage"
type="button"
onClick={runSessionStorageRoundTrip}
style={BUTTON_STYLE}
>
Session storage round trip
</button>
<button
data-testid="oversized"
type="button"
onClick={runOversizedWrite}
style={BUTTON_STYLE}
>
Write oversized value
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-storage-00000000-0000-0000-0000-000000000021',
name: 'host-api-storage-front-component',
description: 'Front component covering the window storage shims',
component: HostApiStorageFrontComponent,
});

View File

@@ -0,0 +1,47 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { unmountFrontComponent } from 'twenty-sdk/front-component';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
BUTTON_STYLE,
STATUS_STYLE,
} from '@/__stories__/shared/front-components/styles';
const HostApiUnmountFrontComponent = () => {
const [status, setStatus] = useState('idle');
const handleClick = async () => {
try {
await unmountFrontComponent();
setStatus('unmount:success');
} catch (error) {
setStatus(
`unmount:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<FrontComponentCard title="host-api:unmount">
<button
data-testid="subject"
type="button"
onClick={handleClick}
style={BUTTON_STYLE}
>
Unmount
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{status}
</span>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-host-unmount-00000000-0000-0000-0000-000000000020',
name: 'host-api-unmount-front-component',
description: 'Front component covering unmountFrontComponent host API',
component: HostApiUnmountFrontComponent,
});

View File

@@ -0,0 +1,60 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { isDefined } from 'twenty-shared/utils';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import {
HOST_API_TIMEOUT,
INTERACTION_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HostApi/Navigate',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const Navigate: Story = runFrontComponentStory({
frontComponentBundleName: 'host-api-navigate',
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi;
if (!isDefined(api)) {
throw new Error('frontComponentHostCommunicationApi is required');
}
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await waitFor(
() => {
expect(api.navigate).toHaveBeenCalled();
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'navigate:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
});

View File

@@ -0,0 +1,65 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { isDefined } from 'twenty-shared/utils';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import {
HOST_API_TIMEOUT,
INTERACTION_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HostApi/NavigatorClipboard',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
// Runs the real sandbox: iframe, worker, and bridge in an actual browser, so
// it fails if the polyfill never reaches the worker realm — which the unit
// tests of the install function cannot detect.
export const NavigatorClipboard: Story = runFrontComponentStory({
frontComponentBundleName: 'host-api-navigator-clipboard',
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi;
if (!isDefined(api)) {
throw new Error('frontComponentHostCommunicationApi is required');
}
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await waitFor(
() => {
expect(api.copyToClipboard).toHaveBeenCalledWith(
'Hello standard clipboard',
);
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'clipboard:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
});

View File

@@ -0,0 +1,60 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { isDefined } from 'twenty-shared/utils';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import {
HOST_API_TIMEOUT,
INTERACTION_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HostApi/Progress',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const Progress: Story = runFrontComponentStory({
frontComponentBundleName: 'host-api-progress',
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi;
if (!isDefined(api)) {
throw new Error('frontComponentHostCommunicationApi is required');
}
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await waitFor(
() => {
expect(api.updateProgress).toHaveBeenCalledWith(50);
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'progress:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
});

View File

@@ -0,0 +1,53 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
import { isDefined } from 'twenty-shared/utils';
import {
errorHandler,
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { HOST_API_TIMEOUT } from '@/__stories__/shared/test-utils/timeouts';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HostApi/RouterLink',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
// KNOWN ISSUE (TDD) golden test: the fixture's react-router Link crashes at
// render (no router context in the sandbox), so the component errors and the
// host navigate API is never reached. When a router fix lands, flip this play
// to the acceptance assertions: find the link by role, click it, and expect
// hostApi.navigate to receive '/objects/companies' — a MemoryRouter cannot
// pass that (it renders the link but swallows the click into in-memory
// history), and clicking also requires the host to guard native anchor clicks,
// which otherwise navigate the host page away before the async worker
// round-trip can preventDefault.
export const RouterLink: Story = runFrontComponentStory({
frontComponentBundleName: 'host-api-router-link',
play: async ({ args }) => {
const api = args.frontComponentHostCommunicationApi;
if (!isDefined(api)) {
throw new Error('frontComponentHostCommunicationApi is required');
}
await waitFor(
() => {
expect(errorHandler).toHaveBeenCalled();
},
{ timeout: HOST_API_TIMEOUT },
);
expect(api.navigate).not.toHaveBeenCalled();
},
});

View File

@@ -0,0 +1,63 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { isDefined } from 'twenty-shared/utils';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import {
HOST_API_TIMEOUT,
INTERACTION_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HostApi/Snackbar',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const Snackbar: Story = runFrontComponentStory({
frontComponentBundleName: 'host-api-snackbar',
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi;
if (!isDefined(api)) {
throw new Error('frontComponentHostCommunicationApi is required');
}
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await waitFor(
() => {
expect(api.enqueueSnackbar).toHaveBeenCalledWith({
message: 'Test notification',
variant: 'success',
});
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'snackbar:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
});

View File

@@ -0,0 +1,138 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import { buildFrontComponentStorageNamespace } from '@/host/storage/utils/buildFrontComponentStorageNamespace';
import { clearFrontComponentStorage } from '@/host/storage/utils/clearFrontComponentStorage';
import { deleteFrontComponentStorageItem } from '@/host/storage/utils/deleteFrontComponentStorageItem';
import { setFrontComponentStorageItem } from '@/host/storage/utils/setFrontComponentStorageItem';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
hostApiMocks,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import {
HOST_API_TIMEOUT,
INTERACTION_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
const PERSISTED_STORAGE_NAMESPACE = buildFrontComponentStorageNamespace({
applicationId: 'story-application-id',
userId: 'story-user-id',
});
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HostApi/Storage',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
const roundTripStory = runFrontComponentStory({
frontComponentBundleName: 'host-api-storage',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
await userEvent.click(await canvas.findByTestId('subject'));
expect(
await canvas.findByText(
'storage:local:hello:greeting:1:0',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
await userEvent.click(await canvas.findByTestId('local-storage-write'));
await userEvent.click(await canvas.findByTestId('session-storage'));
await waitFor(
() => {
expect(
window.localStorage.getItem(`${PERSISTED_STORAGE_NAMESPACE}greeting`),
).toBeNull();
expect(
window.localStorage.getItem(`${PERSISTED_STORAGE_NAMESPACE}theme`),
).toBe('dark');
expect(
window.sessionStorage.getItem(`${PERSISTED_STORAGE_NAMESPACE}visits`),
).toBe('2');
},
{ timeout: HOST_API_TIMEOUT },
);
},
});
const clearPersistedStorageNamespace = () => {
for (const storageType of ['localStorage', 'sessionStorage'] as const) {
clearFrontComponentStorage({
namespace: PERSISTED_STORAGE_NAMESPACE,
storageType,
});
}
};
export const StorageRoundTrip: Story = {
...roundTripStory,
beforeEach: () => {
clearPersistedStorageNamespace();
return clearPersistedStorageNamespace;
},
args: {
...roundTripStory.args,
storageNamespace: PERSISTED_STORAGE_NAMESPACE,
frontComponentHostCommunicationApi: {
...hostApiMocks,
storageSet: async ({ storageType, key, serializedValue }) => {
setFrontComponentStorageItem({
namespace: PERSISTED_STORAGE_NAMESPACE,
storageType,
key,
serializedValue,
});
},
storageDelete: async ({ storageType, key }) => {
deleteFrontComponentStorageItem({
namespace: PERSISTED_STORAGE_NAMESPACE,
storageType,
key,
});
},
storageClear: async ({ storageType }) => {
clearFrontComponentStorage({
namespace: PERSISTED_STORAGE_NAMESPACE,
storageType,
});
},
},
},
};
export const StorageValueTooLarge: Story = runFrontComponentStory({
frontComponentBundleName: 'host-api-storage',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
await userEvent.click(await canvas.findByTestId('oversized'));
expect(
await canvas.findByText(
'storage:error:QuotaExceededError',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
});

View File

@@ -0,0 +1,37 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const IframeClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="iframe:click">
<iframe
data-testid="subject"
title="probe"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
tabIndex={0}
style={{ ...FILL_RECT_STYLE, height: 80 }}
/>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-iframe-c-click-00000000-0000-0000-0000-000000000020',
name: 'iframe-click-front-component',
description: 'Front component covering click on <iframe>',
component: IframeClickFrontComponent,
});

View File

@@ -0,0 +1,29 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import {
createHtmlTagClickStory,
createHtmlTagFocusStory,
} from '@/__stories__/shared/test-utils/createHtmlElementStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Embedded/Iframe/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'iframe-click',
});
export const FocusBlur = createHtmlTagFocusStory({
frontComponentBundleName: 'iframe-focus-blur',
});

View File

@@ -0,0 +1,38 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const IframeFocusBlurFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="iframe:focus-blur">
<iframe
data-testid="subject"
title="probe"
onFocus={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
onBlur={(event) => pushEvent(event)}
tabIndex={0}
style={{ ...FILL_RECT_STYLE, height: 80 }}
/>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-iframe-fb-fb-00000000-0000-0000-0000-000000000020',
name: 'iframe-focus-blur-front-component',
description: 'Front component covering focus-blur on <iframe>',
component: IframeFocusBlurFrontComponent,
});

View File

@@ -0,0 +1,21 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const IframeSandboxDangerousFrontComponent = () => (
<FrontComponentCard title="iframe:sandbox-dangerous">
<iframe
data-testid="subject"
title="probe"
sandbox="allow-scripts allow-same-origin allow-top-navigation allow-popups-to-escape-sandbox"
style={{ ...FILL_RECT_STYLE, height: 80 }}
/>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-iframe-sb-dgr-00000000-0000-0000-0000-000000000020',
name: 'iframe-sandbox-dangerous-front-component',
description: 'Front component declaring an <iframe> with a dangerous sandbox',
component: IframeSandboxDangerousFrontComponent,
});

View File

@@ -0,0 +1,20 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const IframeSandboxDefaultFrontComponent = () => (
<FrontComponentCard title="iframe:sandbox-default">
<iframe
data-testid="subject"
title="probe"
style={{ ...FILL_RECT_STYLE, height: 80 }}
/>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-iframe-sb-def-00000000-0000-0000-0000-000000000020',
name: 'iframe-sandbox-default-front-component',
description: 'Front component declaring an <iframe> without a sandbox value',
component: IframeSandboxDefaultFrontComponent,
});

View File

@@ -0,0 +1,43 @@
import { type Meta } from '@storybook/react-vite';
import { within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { expectStorybookIframeSandboxSanitized } from '@/__stories__/shared/test-utils/matchers/expectStorybookIframeSandboxSanitized';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Embedded/Iframe/Sandbox',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Dangerous = runFrontComponentStory({
frontComponentBundleName: 'iframe-sandbox-dangerous',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
await expectStorybookIframeSandboxSanitized({ canvas });
},
});
export const Default = runFrontComponentStory({
frontComponentBundleName: 'iframe-sandbox-default',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
await expectStorybookIframeSandboxSanitized({ canvas });
},
});

View File

@@ -0,0 +1,38 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const ImgClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="img:click">
<img
data-testid="subject"
src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='40'/>"
alt="probe"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
tabIndex={0}
style={{ ...FILL_RECT_STYLE, height: 40 }}
/>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-img-c-click-00000000-0000-0000-0000-000000000020',
name: 'img-click-front-component',
description: 'Front component covering click on <img>',
component: ImgClickFrontComponent,
});

View File

@@ -0,0 +1,29 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import {
createHtmlTagClickStory,
createHtmlTagFocusStory,
} from '@/__stories__/shared/test-utils/createHtmlElementStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Embedded/Img/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'img-click',
});
export const FocusBlur = createHtmlTagFocusStory({
frontComponentBundleName: 'img-focus-blur',
});

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const ImgFocusBlurFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="img:focus-blur">
<img
data-testid="subject"
src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='40'/>"
alt="probe"
onFocus={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
onBlur={(event) => pushEvent(event)}
tabIndex={0}
style={{ ...FILL_RECT_STYLE, height: 40 }}
/>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-img-fb-fb-00000000-0000-0000-0000-000000000020',
name: 'img-focus-blur-front-component',
description: 'Front component covering focus-blur on <img>',
component: ImgFocusBlurFrontComponent,
});

View File

@@ -0,0 +1,27 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { PROPERTY_FIXTURE } from '@/__stories__/shared/front-components/property-fixture';
const ImgPropertiesFrontComponent = () => (
<FrontComponentCard title="img:properties">
<img
data-testid="subject"
id={PROPERTY_FIXTURE.id}
className={PROPERTY_FIXTURE.className}
title={PROPERTY_FIXTURE.title}
role={PROPERTY_FIXTURE.role}
aria-label={PROPERTY_FIXTURE.ariaLabel}
tabIndex={PROPERTY_FIXTURE.tabIndex}
src={PROPERTY_FIXTURE.src}
alt={PROPERTY_FIXTURE.alt}
/>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-img-props-00000000-0000-0000-0000-000000000020',
name: 'img-properties-front-component',
description: 'Front component covering property reflection on <img>',
component: ImgPropertiesFrontComponent,
});

View File

@@ -0,0 +1,26 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { createPropertyReflectionStory } from '@/__stories__/shared/test-utils/createPropertyReflectionStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Embedded/Img/Properties',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Properties = createPropertyReflectionStory({
frontComponentBundleName: 'img-properties',
extraAttributes: {
src: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="80" height="40"/>',
alt: 'subject-alt',
},
});

View File

@@ -0,0 +1,42 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const PictureClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="picture:click">
<picture
data-testid="subject"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
tabIndex={0}
style={FILL_RECT_STYLE}
>
<img
src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='40'/>"
alt="probe"
/>
</picture>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-picture-c-click-00000000-0000-0000-0000-000000000020',
name: 'picture-click-front-component',
description: 'Front component covering click on <picture>',
component: PictureClickFrontComponent,
});

View File

@@ -0,0 +1,29 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import {
createHtmlTagClickStory,
createHtmlTagFocusStory,
} from '@/__stories__/shared/test-utils/createHtmlElementStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Embedded/Picture/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'picture-click',
});
export const FocusBlur = createHtmlTagFocusStory({
frontComponentBundleName: 'picture-focus-blur',
});

View File

@@ -0,0 +1,42 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const PictureFocusBlurFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="picture:focus-blur">
<picture
data-testid="subject"
onFocus={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
onBlur={(event) => pushEvent(event)}
tabIndex={0}
style={FILL_RECT_STYLE}
>
<img
src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='40'/>"
alt="probe"
/>
</picture>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-picture-fb-fb-00000000-0000-0000-0000-000000000020',
name: 'picture-focus-blur-front-component',
description: 'Front component covering focus-blur on <picture>',
component: PictureFocusBlurFrontComponent,
});

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
const ButtonClickFrontComponent = () => {
const [clickCount, setClickCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="button:click">
<button
data-testid="subject"
type="button"
onClick={(event) => {
setClickCount((previous) => previous + 1);
pushEvent(event);
}}
style={BUTTON_STYLE}
>
Click me
</button>
<span data-testid="front-component-value">{clickCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-btn-click-00000000-0000-0000-0000-000000000020',
name: 'button-click-front-component',
description: 'Front component covering click event on <button>',
component: ButtonClickFrontComponent,
});

View File

@@ -0,0 +1,43 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectEventLogged } from '@/__stories__/shared/test-utils/matchers/expectEventLogged';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { expectFrontComponentValue } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentValue';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Button/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const ClickEvent: Story = runFrontComponentStory({
frontComponentBundleName: 'button-click',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await expectFrontComponentValue({ canvas, expected: '1' });
await userEvent.click(subject);
await expectFrontComponentValue({ canvas, expected: '2' });
await expectEventLogged({ canvas, matcher: { type: 'click' } });
},
});

View File

@@ -0,0 +1,28 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { PROPERTY_FIXTURE } from '@/__stories__/shared/front-components/property-fixture';
const ButtonPropertiesFrontComponent = () => (
<FrontComponentCard title="button:properties">
<button
data-testid="subject"
type="button"
id={PROPERTY_FIXTURE.id}
className={PROPERTY_FIXTURE.className}
title={PROPERTY_FIXTURE.title}
role={PROPERTY_FIXTURE.role}
aria-label={PROPERTY_FIXTURE.ariaLabel}
tabIndex={PROPERTY_FIXTURE.tabIndex}
>
button
</button>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-btn-props-00000000-0000-0000-0000-000000000020',
name: 'button-properties-front-component',
description: 'Front component covering property reflection on <button>',
component: ButtonPropertiesFrontComponent,
});

View File

@@ -0,0 +1,22 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { createPropertyReflectionStory } from '@/__stories__/shared/test-utils/createPropertyReflectionStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Button/Properties',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Properties = createPropertyReflectionStory({
frontComponentBundleName: 'button-properties',
});

View File

@@ -0,0 +1,41 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
const DatalistClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="datalist:click">
<>
<input list="probe-list" />
<datalist
id="probe-list"
data-testid="subject"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
>
<option value="alpha" />
<option value="beta" />
</datalist>
</>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-datalist-c-click-00000000-0000-0000-0000-000000000020',
name: 'datalist-click-front-component',
description: 'Front component covering click on <datalist>',
component: DatalistClickFrontComponent,
});

View File

@@ -0,0 +1,22 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { createHtmlTagClickStory } from '@/__stories__/shared/test-utils/createHtmlElementStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Datalist/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'datalist-click',
});

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const FieldsetClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="fieldset:click">
<fieldset
data-testid="subject"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
tabIndex={0}
style={FILL_RECT_STYLE}
>
fieldset
</fieldset>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-fieldset-c-click-00000000-0000-0000-0000-000000000020',
name: 'fieldset-click-front-component',
description: 'Front component covering click on <fieldset>',
component: FieldsetClickFrontComponent,
});

View File

@@ -0,0 +1,29 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import {
createHtmlTagClickStory,
createHtmlTagFocusStory,
} from '@/__stories__/shared/test-utils/createHtmlElementStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Fieldset/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'fieldset-click',
});
export const FocusBlur = createHtmlTagFocusStory({
frontComponentBundleName: 'fieldset-focus-blur',
});

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const FieldsetFocusBlurFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="fieldset:focus-blur">
<fieldset
data-testid="subject"
onFocus={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
onBlur={(event) => pushEvent(event)}
tabIndex={0}
style={FILL_RECT_STYLE}
>
fieldset
</fieldset>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-fieldset-fb-fb-00000000-0000-0000-0000-000000000020',
name: 'fieldset-focus-blur-front-component',
description: 'Front component covering focus-blur on <fieldset>',
component: FieldsetFocusBlurFrontComponent,
});

View File

@@ -0,0 +1,27 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { PROPERTY_FIXTURE } from '@/__stories__/shared/front-components/property-fixture';
const FieldsetPropertiesFrontComponent = () => (
<FrontComponentCard title="fieldset:properties">
<fieldset
data-testid="subject"
id={PROPERTY_FIXTURE.id}
className={PROPERTY_FIXTURE.className}
title={PROPERTY_FIXTURE.title}
role={PROPERTY_FIXTURE.role}
aria-label={PROPERTY_FIXTURE.ariaLabel}
tabIndex={PROPERTY_FIXTURE.tabIndex}
>
field
</fieldset>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-fieldset-props-00000000-0000-0000-0000-000000000020',
name: 'fieldset-properties-front-component',
description: 'Front component covering property reflection on <fieldset>',
component: FieldsetPropertiesFrontComponent,
});

View File

@@ -0,0 +1,22 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { createPropertyReflectionStory } from '@/__stories__/shared/test-utils/createPropertyReflectionStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Fieldset/Properties',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Properties = createPropertyReflectionStory({
frontComponentBundleName: 'fieldset-properties',
});

View File

@@ -0,0 +1,40 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectEventLogged } from '@/__stories__/shared/test-utils/matchers/expectEventLogged';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { expectFrontComponentValue } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentValue';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Form/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const SubmitEvent: Story = runFrontComponentStory({
frontComponentBundleName: 'form-submit',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const submitButton = await canvas.findByTestId('submit-button');
await userEvent.click(submitButton);
await expectFrontComponentValue({ canvas, expected: 'value-from-form' });
await expectEventLogged({ canvas, matcher: { type: 'submit' } });
},
});

View File

@@ -0,0 +1,27 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { PROPERTY_FIXTURE } from '@/__stories__/shared/front-components/property-fixture';
const FormPropertiesFrontComponent = () => (
<FrontComponentCard title="form:properties">
<form
data-testid="subject"
id={PROPERTY_FIXTURE.id}
className={PROPERTY_FIXTURE.className}
title={PROPERTY_FIXTURE.title}
role={PROPERTY_FIXTURE.role}
aria-label={PROPERTY_FIXTURE.ariaLabel}
tabIndex={PROPERTY_FIXTURE.tabIndex}
>
form
</form>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-form-props-00000000-0000-0000-0000-000000000020',
name: 'form-properties-front-component',
description: 'Front component covering property reflection on <form>',
component: FormPropertiesFrontComponent,
});

View File

@@ -0,0 +1,22 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { createPropertyReflectionStory } from '@/__stories__/shared/test-utils/createPropertyReflectionStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Form/Properties',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Properties = createPropertyReflectionStory({
frontComponentBundleName: 'form-properties',
});

View File

@@ -0,0 +1,53 @@
import { type FormEvent, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
BUTTON_STYLE,
INPUT_STYLE,
} from '@/__stories__/shared/front-components/styles';
const FormSubmitFrontComponent = () => {
const [fieldValue, setFieldValue] = useState('value-from-form');
const [submittedValue, setSubmittedValue] = useState<string | null>(null);
const { entries, pushEvent } = useEventLog();
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmittedValue(fieldValue);
pushEvent(event);
};
return (
<FrontComponentCard title="form:submit">
<form data-testid="subject" onSubmit={handleSubmit}>
<input
data-testid="form-field"
type="text"
name="field"
value={fieldValue}
onChange={(event) => setFieldValue(event.target.value)}
style={INPUT_STYLE}
/>
<button data-testid="submit-button" type="submit" style={BUTTON_STYLE}>
Submit
</button>
</form>
<span data-testid="front-component-value">
{submittedValue ?? 'none'}
</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-form-submit-00000000-0000-0000-0000-000000000020',
name: 'form-submit-front-component',
description: 'Front component covering submit event on <form>',
component: FormSubmitFrontComponent,
});

View File

@@ -0,0 +1,38 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
INPUT_STYLE,
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const CARET_INITIAL_VALUE = 'Hello world';
const InputCaretFrontComponent = () => {
const [value, setValue] = useState(CARET_INITIAL_VALUE);
return (
<FrontComponentCard title="input:text:caret">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Text input (pre-filled)</label>
<input
data-testid="subject"
type="text"
value={value}
onChange={(event) => setValue(event.target.value)}
style={INPUT_STYLE}
/>
<span data-testid="front-component-value">{value}</span>
</div>
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-input-caret-00000000-0000-0000-0000-000000000020',
name: 'input-caret-front-component',
description: 'Front component covering caret behavior on <input type="text">',
component: InputCaretFrontComponent,
});

View File

@@ -0,0 +1,61 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import {
INTERACTION_TIMEOUT,
TYPING_TIMEOUT,
} from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Input/Caret',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const CaretPreservedMidString: Story = runFrontComponentStory({
frontComponentBundleName: 'input-caret',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = (await canvas.findByTestId('subject')) as HTMLInputElement;
await waitFor(
() => {
expect(subject.value).toBe('Hello world');
},
{ timeout: INTERACTION_TIMEOUT },
);
subject.focus();
subject.setSelectionRange(4, 4);
await userEvent.keyboard('X');
await waitFor(
() => {
expect(subject.value).toBe('HellXo world');
expect(canvas.getByTestId('front-component-value').textContent).toBe(
'HellXo world',
);
expect(subject.selectionStart).toBe(5);
expect(subject.selectionEnd).toBe(5);
},
{ timeout: TYPING_TIMEOUT },
);
},
});

View File

@@ -0,0 +1,43 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
LABEL_STYLE,
ROW_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputCheckboxFrontComponent = () => {
const [checked, setChecked] = useState(false);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:checkbox:checked">
<div style={ROW_STYLE}>
<input
data-testid="subject"
type="checkbox"
checked={checked}
onChange={(event) => {
setChecked(event.target.checked);
pushEvent(event);
}}
/>
<label style={LABEL_STYLE}>Check me</label>
<span data-testid="front-component-value">{String(checked)}</span>
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-input-checkbox-00000000-0000-0000-0000-000000000020',
name: 'input-checkbox-front-component',
description: 'Front component covering <input type="checkbox">',
component: InputCheckboxFrontComponent,
});

View File

@@ -0,0 +1,51 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectEventLogged } from '@/__stories__/shared/test-utils/matchers/expectEventLogged';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { expectFrontComponentValue } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentValue';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Input/Checkbox',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const CheckedRoundTrip: Story = runFrontComponentStory({
frontComponentBundleName: 'input-checkbox',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await expectFrontComponentValue({ canvas, expected: 'true' });
await expectEventLogged({
canvas,
matcher: { type: 'change', checked: true },
});
await userEvent.click(subject);
await expectFrontComponentValue({ canvas, expected: 'false' });
await expectEventLogged({
canvas,
matcher: { type: 'change', checked: false },
});
},
});

View File

@@ -0,0 +1,41 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
INPUT_STYLE,
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputClipboardFrontComponent = () => {
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:text:clipboard">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Clipboard input</label>
<input
data-testid="subject"
type="text"
onPaste={pushEvent}
onCopy={pushEvent}
onCut={pushEvent}
style={INPUT_STYLE}
/>
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-input-clipboard-00000000-0000-0000-0000-000000000020',
name: 'input-clipboard-front-component',
description: 'Front component covering clipboard events on <input>',
component: InputClipboardFrontComponent,
});

View File

@@ -0,0 +1,69 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectEventLogged } from '@/__stories__/shared/test-utils/matchers/expectEventLogged';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Input/Clipboard',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const Paste: Story = runFrontComponentStory({
frontComponentBundleName: 'input-clipboard',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await userEvent.paste('hello-clipboard');
await expectEventLogged({
canvas,
matcher: { type: 'paste', clipboardText: 'hello-clipboard' },
});
},
});
export const CopyAndCutStayPrivate: Story = runFrontComponentStory({
frontComponentBundleName: 'input-clipboard',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await userEvent.type(subject, 'secret');
(subject as HTMLInputElement).select();
await userEvent.copy();
await expectEventLogged({
canvas,
matcher: { type: 'copy', clipboardText: undefined },
});
await userEvent.cut();
await expectEventLogged({
canvas,
matcher: { type: 'cut', clipboardText: undefined },
});
},
});

View File

@@ -0,0 +1,42 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
INPUT_STYLE,
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputEditingFrontComponent = () => {
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:text:editing">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Editing input</label>
<input
data-testid="subject"
type="text"
onBeforeInput={pushEvent}
onCompositionStart={pushEvent}
onCompositionUpdate={pushEvent}
onCompositionEnd={pushEvent}
style={INPUT_STYLE}
/>
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-input-editing-00000000-0000-0000-0000-000000000020',
name: 'input-editing-front-component',
description:
'Front component covering beforeinput and composition events on <input>',
component: InputEditingFrontComponent,
});

View File

@@ -0,0 +1,90 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectEventLogged } from '@/__stories__/shared/test-utils/matchers/expectEventLogged';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Input/Editing',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const BeforeInputInsert: Story = runFrontComponentStory({
frontComponentBundleName: 'input-editing',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.type(subject, 'a');
await expectEventLogged({
canvas,
matcher: { type: 'beforeinput', inputType: 'insertText', data: 'a' },
});
},
});
export const BeforeInputDelete: Story = runFrontComponentStory({
frontComponentBundleName: 'input-editing',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.type(subject, 'ab{backspace}');
await expectEventLogged({
canvas,
matcher: { type: 'beforeinput', inputType: 'deleteContentBackward' },
});
},
});
export const Composition: Story = runFrontComponentStory({
frontComponentBundleName: 'input-editing',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
subject.dispatchEvent(
new CompositionEvent('compositionstart', { bubbles: true }),
);
subject.dispatchEvent(
new CompositionEvent('compositionupdate', { data: 'か', bubbles: true }),
);
subject.dispatchEvent(
new CompositionEvent('compositionend', { data: 'か', bubbles: true }),
);
await expectEventLogged({ canvas, matcher: { type: 'compositionstart' } });
await expectEventLogged({
canvas,
matcher: { type: 'compositionupdate', data: 'か' },
});
await expectEventLogged({
canvas,
matcher: { type: 'compositionend', data: 'か' },
});
},
});

View File

@@ -0,0 +1,63 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectEventLogged } from '@/__stories__/shared/test-utils/matchers/expectEventLogged';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { expectFrontComponentValue } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentValue';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
import { TYPING_DELAY } from '@/__stories__/shared/test-utils/timeouts';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Input/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const TextValueRoundTrip: Story = runFrontComponentStory({
frontComponentBundleName: 'input-text-value',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.type(subject, 'hello', { delay: TYPING_DELAY });
await expectFrontComponentValue({ canvas, expected: 'hello' });
await expectEventLogged({
canvas,
matcher: { type: 'change', value: 'hello' },
});
},
});
export const FocusBlurEvents: Story = runFrontComponentStory({
frontComponentBundleName: 'input-text-focus-blur',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await expectEventLogged({ canvas, matcher: { type: 'focus' } });
const blurTarget = await canvas.findByTestId('blur-target');
await userEvent.click(blurTarget);
await expectEventLogged({ canvas, matcher: { type: 'blur' } });
},
});

View File

@@ -0,0 +1,39 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputFileMultipleFrontComponent = () => {
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:file:multiple">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Multiple files</label>
<input
data-testid="subject"
type="file"
multiple
accept="image/*"
onChange={(event) => pushEvent(event)}
/>
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-input-file-multiple-00000000-0000-0000-0000-000000000020',
name: 'input-file-multiple-front-component',
description: 'Front component covering multi-file <input type="file">',
component: InputFileMultipleFrontComponent,
});

View File

@@ -0,0 +1,37 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputFileSingleFrontComponent = () => {
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:file:single">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Single file</label>
<input
data-testid="subject"
type="file"
onChange={(event) => pushEvent(event)}
/>
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-input-file-single-00000000-0000-0000-0000-000000000020',
name: 'input-file-single-front-component',
description: 'Front component covering single-file <input type="file">',
component: InputFileSingleFrontComponent,
});

View File

@@ -0,0 +1,76 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectEventLogged } from '@/__stories__/shared/test-utils/matchers/expectEventLogged';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Input/File',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const SingleFile: Story = runFrontComponentStory({
frontComponentBundleName: 'input-file-single',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = (await canvas.findByTestId('subject')) as HTMLInputElement;
const file = new File(['hello world'], 'hello.txt', {
type: 'text/plain',
lastModified: 1700000000000,
});
await userEvent.upload(subject, file);
await expectEventLogged({
canvas,
matcher: {
type: 'change',
files: [{ name: 'hello.txt', type: 'text/plain' }],
},
});
},
});
export const MultipleFiles: Story = runFrontComponentStory({
frontComponentBundleName: 'input-file-multiple',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = (await canvas.findByTestId('subject')) as HTMLInputElement;
const first = new File(['a'], 'one.png', { type: 'image/png' });
const second = new File(['bb'], 'two.png', { type: 'image/png' });
await userEvent.upload(subject, [first, second]);
await expectEventLogged({
canvas,
matcher: {
type: 'change',
files: [
{ name: 'one.png', type: 'image/png' },
{ name: 'two.png', type: 'image/png' },
],
},
});
},
});

View File

@@ -0,0 +1,45 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
INPUT_STYLE,
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputKeyboardFrontComponent = () => {
const [lastKey, setLastKey] = useState('');
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:text:keyboard">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Keyboard input</label>
<input
data-testid="subject"
type="text"
onKeyDown={(event) => {
setLastKey(event.key);
pushEvent(event);
}}
onKeyUp={(event) => pushEvent(event)}
style={INPUT_STYLE}
/>
<span data-testid="front-component-value">{lastKey}</span>
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-input-keyboard-00000000-0000-0000-0000-000000000020',
name: 'input-keyboard-front-component',
description: 'Front component covering keyboard events on <input>',
component: InputKeyboardFrontComponent,
});

View File

@@ -0,0 +1,65 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { expectEventLogged } from '@/__stories__/shared/test-utils/matchers/expectEventLogged';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Input/Keyboard',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
export const BasicKey: Story = runFrontComponentStory({
frontComponentBundleName: 'input-keyboard',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await userEvent.keyboard('a');
await expectEventLogged({
canvas,
matcher: { type: 'keydown', key: 'a', code: 'KeyA' },
});
await expectEventLogged({
canvas,
matcher: { type: 'keyup', key: 'a', code: 'KeyA' },
});
},
});
export const ShiftModifier: Story = runFrontComponentStory({
frontComponentBundleName: 'input-keyboard',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await userEvent.keyboard('{Shift>}b{/Shift}');
await expectEventLogged({
canvas,
matcher: { type: 'keydown', shiftKey: true },
});
},
});

View File

@@ -0,0 +1,33 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { PROPERTY_FIXTURE } from '@/__stories__/shared/front-components/property-fixture';
const noop = () => undefined;
const InputNumberPropertiesFrontComponent = () => (
<FrontComponentCard title="input:number:properties">
<input
data-testid="subject"
id={PROPERTY_FIXTURE.id}
className={PROPERTY_FIXTURE.className}
title={PROPERTY_FIXTURE.title}
role={PROPERTY_FIXTURE.role}
aria-label={PROPERTY_FIXTURE.ariaLabel}
tabIndex={PROPERTY_FIXTURE.tabIndex}
type="number"
name={PROPERTY_FIXTURE.name}
value={String(PROPERTY_FIXTURE.numberValue)}
onChange={noop}
/>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier:
'fc-input-num-props-00000000-0000-0000-0000-000000000020',
name: 'input-number-properties-front-component',
description:
'Front component covering property reflection on <input type="number">',
component: InputNumberPropertiesFrontComponent,
});

View File

@@ -0,0 +1,38 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import { PROPERTY_FIXTURE } from '@/__stories__/shared/front-components/property-fixture';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { createPropertyReflectionStory } from '@/__stories__/shared/test-utils/createPropertyReflectionStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Input/Properties',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Text = createPropertyReflectionStory({
frontComponentBundleName: 'input-text-properties',
extraAttributes: {
type: PROPERTY_FIXTURE.type,
name: PROPERTY_FIXTURE.name,
placeholder: PROPERTY_FIXTURE.placeholder,
},
extraProperties: { value: PROPERTY_FIXTURE.textValue },
});
export const Number = createPropertyReflectionStory({
frontComponentBundleName: 'input-number-properties',
extraAttributes: {
type: 'number',
name: PROPERTY_FIXTURE.name,
},
extraProperties: { value: String(PROPERTY_FIXTURE.numberValue) },
});

View File

@@ -0,0 +1,40 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
INPUT_STYLE,
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputTextFocusBlurFrontComponent = () => {
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:text:focus-blur">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Focus / Blur</label>
<input
data-testid="subject"
type="text"
onFocus={(event) => pushEvent(event)}
onBlur={(event) => pushEvent(event)}
style={INPUT_STYLE}
/>
</div>
<input data-testid="blur-target" type="text" style={INPUT_STYLE} />
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-input-text-fb-00000000-0000-0000-0000-000000000020',
name: 'input-text-focus-blur-front-component',
description: 'Front component covering focus/blur on text <input>',
component: InputTextFocusBlurFrontComponent,
});

View File

@@ -0,0 +1,34 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { PROPERTY_FIXTURE } from '@/__stories__/shared/front-components/property-fixture';
const noop = () => undefined;
const InputTextPropertiesFrontComponent = () => (
<FrontComponentCard title="input:text:properties">
<input
data-testid="subject"
id={PROPERTY_FIXTURE.id}
className={PROPERTY_FIXTURE.className}
title={PROPERTY_FIXTURE.title}
role={PROPERTY_FIXTURE.role}
aria-label={PROPERTY_FIXTURE.ariaLabel}
tabIndex={PROPERTY_FIXTURE.tabIndex}
type={PROPERTY_FIXTURE.type}
name={PROPERTY_FIXTURE.name}
placeholder={PROPERTY_FIXTURE.placeholder}
value={PROPERTY_FIXTURE.textValue}
onChange={noop}
/>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier:
'fc-input-text-props-00000000-0000-0000-0000-000000000020',
name: 'input-text-properties-front-component',
description:
'Front component covering property reflection on <input type="text">',
component: InputTextPropertiesFrontComponent,
});

View File

@@ -0,0 +1,47 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import {
INPUT_STYLE,
LABEL_STYLE,
SUBJECT_WRAPPER_STYLE,
} from '@/__stories__/shared/front-components/styles';
const InputTextValueFrontComponent = () => {
const [value, setValue] = useState('');
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="input:text:value">
<div style={SUBJECT_WRAPPER_STYLE}>
<label style={LABEL_STYLE}>Text input</label>
<input
data-testid="subject"
type="text"
placeholder="Type here..."
value={value}
onChange={(event) => {
setValue(event.target.value);
pushEvent(event);
}}
style={INPUT_STYLE}
/>
<span data-testid="front-component-value">{value}</span>
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-input-text-value-00000000-0000-0000-0000-000000000020',
name: 'input-text-value-front-component',
description: 'Front component covering <input type="text"> value round-trip',
component: InputTextValueFrontComponent,
});

View File

@@ -0,0 +1,38 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_INLINE_STYLE } from '@/__stories__/shared/front-components/styles';
const LabelClickFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="label:click">
<label
data-testid="subject"
onClick={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
tabIndex={0}
style={FILL_INLINE_STYLE}
>
label
</label>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-label-c-click-00000000-0000-0000-0000-000000000020',
name: 'label-click-front-component',
description: 'Front component covering click on <label>',
component: LabelClickFrontComponent,
});

View File

@@ -0,0 +1,29 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import {
createHtmlTagClickStory,
createHtmlTagFocusStory,
} from '@/__stories__/shared/test-utils/createHtmlElementStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Label/Events',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Click = createHtmlTagClickStory({
frontComponentBundleName: 'label-click',
});
export const FocusBlur = createHtmlTagFocusStory({
frontComponentBundleName: 'label-focus-blur',
});

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_INLINE_STYLE } from '@/__stories__/shared/front-components/styles';
const LabelFocusBlurFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="label:focus-blur">
<label
data-testid="subject"
onFocus={(event) => {
setInteractionCount((previous) => previous + 1);
pushEvent(event);
}}
onBlur={(event) => pushEvent(event)}
tabIndex={0}
style={FILL_INLINE_STYLE}
>
label
</label>
<span data-testid="front-component-value">{interactionCount}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-label-fb-fb-00000000-0000-0000-0000-000000000020',
name: 'label-focus-blur-front-component',
description: 'Front component covering focus-blur on <label>',
component: LabelFocusBlurFrontComponent,
});

View File

@@ -0,0 +1,27 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { PROPERTY_FIXTURE } from '@/__stories__/shared/front-components/property-fixture';
const LabelPropertiesFrontComponent = () => (
<FrontComponentCard title="label:properties">
<label
data-testid="subject"
id={PROPERTY_FIXTURE.id}
className={PROPERTY_FIXTURE.className}
title={PROPERTY_FIXTURE.title}
role={PROPERTY_FIXTURE.role}
aria-label={PROPERTY_FIXTURE.ariaLabel}
tabIndex={PROPERTY_FIXTURE.tabIndex}
>
content
</label>
</FrontComponentCard>
);
export default defineFrontComponent({
universalIdentifier: 'fc-label-props-00000000-0000-0000-0000-000000000020',
name: 'label-properties-front-component',
description: 'Front component covering property reflection on <label>',
component: LabelPropertiesFrontComponent,
});

View File

@@ -0,0 +1,22 @@
import { type Meta } from '@storybook/react-vite';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
resetFrontComponentStoryMocks,
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
import { createPropertyReflectionStory } from '@/__stories__/shared/test-utils/createPropertyReflectionStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Form/Label/Properties',
component: FrontComponentRenderer,
parameters: { layout: 'centered' },
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
beforeEach: resetFrontComponentStoryMocks,
};
export default meta;
export const Properties = createPropertyReflectionStory({
frontComponentBundleName: 'label-properties',
});

Some files were not shown because too many files have changed in this diff Show More