diff --git a/apps/public-docsite-v9-headless/.storybook/preview.js b/apps/public-docsite-v9-headless/.storybook/preview.js index 6264e041abcb63..a3ebd40ef31ecb 100644 --- a/apps/public-docsite-v9-headless/.storybook/preview.js +++ b/apps/public-docsite-v9-headless/.storybook/preview.js @@ -1,6 +1,7 @@ import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill/shadowless'; import * as rootPreview from '../../../.storybook/preview'; +import { tailwindSandboxTemplate } from './tailwind-sandbox-template'; polyfillBodyAndObserve(); @@ -19,6 +20,10 @@ export const parameters = { order: ['Introduction', 'Headless Components'], }, }, + exportToSandbox: { + ...rootPreview.parameters.exportToSandbox, + ...tailwindSandboxTemplate, + }, }; export const tags = ['autodocs']; diff --git a/apps/public-docsite-v9-headless/.storybook/tailwind-sandbox-template.js b/apps/public-docsite-v9-headless/.storybook/tailwind-sandbox-template.js new file mode 100644 index 00000000000000..6204703ca107f8 --- /dev/null +++ b/apps/public-docsite-v9-headless/.storybook/tailwind-sandbox-template.js @@ -0,0 +1,32 @@ +const sandboxApp = `import { Provider } from '@fluentui/react-headless-components-preview'; +import { Example } from './example'; + +const App = () => ( + + + +); + +export default App; +`; + +const tailwindSandboxTemplate = { + devDependencies: { + tailwindcss: '^4.0.0', + '@tailwindcss/vite': '^4.0.0', + }, + transformFiles: (/** @type {Record} */ files) => ({ + ...files, + 'src/index.css': '@import "tailwindcss";\n', + 'src/App.tsx': sandboxApp, + 'src/index.tsx': `import './index.css';\n${files['src/index.tsx']}`, + 'vite.config.ts': files['vite.config.ts'] + .replace( + "import react from '@vitejs/plugin-react'", + "import react from '@vitejs/plugin-react'\nimport tailwindcss from '@tailwindcss/vite'", + ) + .replace('plugins: [react()]', 'plugins: [react(), tailwindcss()]'), + }), +}; + +module.exports = { tailwindSandboxTemplate }; diff --git a/change/@fluentui-react-storybook-addon-export-to-sandbox-73929f6e-c56d-49f5-82cb-3efa58fde4fe.json b/change/@fluentui-react-storybook-addon-export-to-sandbox-73929f6e-c56d-49f5-82cb-3efa58fde4fe.json new file mode 100644 index 00000000000000..856f6cb13608f1 --- /dev/null +++ b/change/@fluentui-react-storybook-addon-export-to-sandbox-73929f6e-c56d-49f5-82cb-3efa58fde4fe.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add template extension params", + "packageName": "@fluentui/react-storybook-addon-export-to-sandbox", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/public-types.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/public-types.ts index 6a8974d09330f5..d9ee74588310c4 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/public-types.ts +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/public-types.ts @@ -4,11 +4,25 @@ * * only pure API definitions of addon are allowed to live here, that are used both internal and for external storybook `Parameter` type extensions */ + +export interface SandboxContext { + provider: 'codesandbox-cloud' | 'codesandbox-browser' | 'stackblitz-cloud'; + bundler: 'vite' | 'cra'; + storyExportToken: string; + storyFile: string; + dependencies: Record; + requiredDependencies: Record; + optionalDependencies: Record; + devDependencies: Record; +} + interface ParametersConfig { optionalDependencies?: Record; requiredDependencies?: Record; + devDependencies?: Record; provider: 'codesandbox-cloud' | 'codesandbox-browser' | 'stackblitz-cloud'; bundler: 'vite' | 'cra'; + transformFiles?: (files: Record, ctx: SandboxContext) => Record; } export interface ParametersExtension { diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.spec.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.spec.ts index ad4bd11f82de0f..4937e8b8cb4a09 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.spec.ts +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.spec.ts @@ -12,6 +12,9 @@ describe(`sabdbox-scaffold`, () => { `, description: 'react sandbox demo', title: 'react sandbox', + requiredDependencies: {}, + optionalDependencies: {}, + devDependencies: {}, }; describe(`cra`, () => { it(`should generate scaffold for codesandbox-browser`, () => { @@ -48,7 +51,7 @@ describe(`sabdbox-scaffold`, () => { }", "public/index.html": "
", "src/App.tsx": "import { FluentProvider, webLightTheme } from '@fluentui/react-components'; - import { DefaultTitle as Example } from './example'; + import { Example } from './example'; const App = () => { return ( @@ -64,7 +67,9 @@ describe(`sabdbox-scaffold`, () => { import { Text } from '@proj/react-components'; export const Default = () => This is an example of the Text component's usage.; - ", + + export { DefaultTitle as Example }; + ", "src/index.tsx": "import * as React from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; @@ -163,7 +168,7 @@ describe(`sabdbox-scaffold`, () => { }", "public/index.html": "
", "src/App.tsx": "import { FluentProvider, webLightTheme } from '@fluentui/react-components'; - import { DefaultTitle as Example } from './example'; + import { Example } from './example'; const App = () => { return ( @@ -179,7 +184,9 @@ describe(`sabdbox-scaffold`, () => { import { Text } from '@proj/react-components'; export const Default = () => This is an example of the Text component's usage.; - ", + + export { DefaultTitle as Example }; + ", "src/index.tsx": "import * as React from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; @@ -244,7 +251,7 @@ describe(`sabdbox-scaffold`, () => { }", "public/index.html": "
", "src/App.tsx": "import { FluentProvider, webLightTheme } from '@fluentui/react-components'; - import { DefaultTitle as Example } from './example'; + import { Example } from './example'; const App = () => { return ( @@ -260,7 +267,9 @@ describe(`sabdbox-scaffold`, () => { import { Text } from '@proj/react-components'; export const Default = () => This is an example of the Text component's usage.; - ", + + export { DefaultTitle as Example }; + ", "src/index.tsx": "import * as React from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; @@ -380,7 +389,7 @@ describe(`sabdbox-scaffold`, () => { } }", "src/App.tsx": "import { FluentProvider, webLightTheme } from '@fluentui/react-components'; - import { DefaultTitle as Example } from './example'; + import { Example } from './example'; const App = () => { return ( @@ -396,7 +405,9 @@ describe(`sabdbox-scaffold`, () => { import { Text } from '@proj/react-components'; export const Default = () => This is an example of the Text component's usage.; - ", + + export { DefaultTitle as Example }; + ", "src/index.tsx": "import * as React from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; @@ -505,7 +516,7 @@ describe(`sabdbox-scaffold`, () => { } }", "src/App.tsx": "import { FluentProvider, webLightTheme } from '@fluentui/react-components'; - import { DefaultTitle as Example } from './example'; + import { Example } from './example'; const App = () => { return ( @@ -521,7 +532,9 @@ describe(`sabdbox-scaffold`, () => { import { Text } from '@proj/react-components'; export const Default = () => This is an example of the Text component's usage.; - ", + + export { DefaultTitle as Example }; + ", "src/index.tsx": "import * as React from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; @@ -587,4 +600,143 @@ describe(`sabdbox-scaffold`, () => { `); }); }); + + describe(`extension params`, () => { + it(`should re-export the story as Example from generated example.tsx`, () => { + const actual = scaffold.vite({ + provider: 'stackblitz-cloud', + bundler: 'vite', + ...config, + }); + + expect(actual['src/example.tsx']).toContain(`export { DefaultTitle as Example };`); + }); + + it(`should keep FluentProvider in App.tsx by default`, () => { + const actual = scaffold.vite({ + provider: 'stackblitz-cloud', + bundler: 'vite', + ...config, + }); + + expect(actual['src/App.tsx']).toContain( + "import { FluentProvider, webLightTheme } from '@fluentui/react-components';", + ); + expect(actual['src/App.tsx']).toContain("import { Example } from './example';"); + }); + + it(`should merge devDependencies into Vite package.json`, () => { + const actual = scaffold.vite({ + provider: 'stackblitz-cloud', + bundler: 'vite', + ...config, + devDependencies: { tailwindcss: '^4.0.0', '@tailwindcss/vite': '^4.0.0' }, + }); + + const pkg = JSON.parse(actual['package.json']); + expect(pkg.devDependencies.tailwindcss).toBe('^4.0.0'); + expect(pkg.devDependencies['@tailwindcss/vite']).toBe('^4.0.0'); + expect(pkg.devDependencies.vite).toBe('^5.0.0'); + expect(pkg.dependencies.tailwindcss).toBeUndefined(); + }); + + it(`should merge devDependencies into CRA package.json`, () => { + const actual = scaffold.cra({ + provider: 'stackblitz-cloud', + bundler: 'cra', + ...config, + devDependencies: { tailwindcss: '^4.0.0' }, + }); + + const pkg = JSON.parse(actual['package.json']); + expect(pkg.devDependencies.tailwindcss).toBe('^4.0.0'); + expect(pkg.devDependencies['react-scripts']).toBe('^5.0.0'); + }); + }); + + describe(`transformFiles`, () => { + it(`should patch an existing generated file`, () => { + const actual = scaffold.vite({ + provider: 'stackblitz-cloud', + bundler: 'vite', + ...config, + transformFiles: files => ({ + ...files, + 'vite.config.ts': files['vite.config.ts'].replace('react()', 'react(), tailwindcss()'), + }), + }); + + expect(actual['vite.config.ts']).toContain('plugins: [react(), tailwindcss()]'); + }); + + it(`should add a new file`, () => { + const actual = scaffold.vite({ + provider: 'stackblitz-cloud', + bundler: 'vite', + ...config, + transformFiles: files => ({ ...files, 'extra.txt': 'hello' }), + }); + + expect(actual['extra.txt']).toBe('hello'); + expect(actual['vite.config.ts']).toBeDefined(); + }); + + it(`should drop a file when the returned map omits its key`, () => { + const actual = scaffold.vite({ + provider: 'stackblitz-cloud', + bundler: 'vite', + ...config, + transformFiles: files => { + const { 'index.html': _dropped, ...rest } = files; + return rest; + }, + }); + + expect(actual['index.html']).toBeUndefined(); + expect(actual['src/App.tsx']).toBeDefined(); + }); + + it(`should expose context to the callback`, () => { + let captured: unknown; + scaffold.vite({ + provider: 'stackblitz-cloud', + bundler: 'vite', + ...config, + requiredDependencies: { 'some-pkg': '^1.0.0' }, + optionalDependencies: { react: '^18' }, + devDependencies: { tailwindcss: '^4.0.0' }, + transformFiles: (files, ctx) => { + captured = ctx; + return files; + }, + }); + + expect(captured).toEqual({ + provider: 'stackblitz-cloud', + bundler: 'vite', + storyExportToken: 'DefaultTitle', + storyFile: config.storyFile, + dependencies: {}, + requiredDependencies: { 'some-pkg': '^1.0.0' }, + optionalDependencies: { react: '^18' }, + devDependencies: { tailwindcss: '^4.0.0' }, + }); + }); + + it(`should see provider-specific extras in the input map (.stackblitzrc)`, () => { + let capturedKeys: string[] = []; + scaffold.vite({ + provider: 'stackblitz-cloud', + bundler: 'vite', + ...config, + transformFiles: files => { + capturedKeys = Object.keys(files); + return files; + }, + }); + + expect(capturedKeys).toContain('.stackblitzrc'); + expect(capturedKeys).toContain('vite.config.ts'); + }); + }); }); diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.ts index 6bacb6f1267990..c58d3201664654 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.ts +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-scaffold.ts @@ -1,5 +1,6 @@ import dedent from 'dedent'; +import type { SandboxContext } from './public-types'; import type { Data } from './sandbox-utils'; import { serializeJson } from './utils'; @@ -11,7 +12,7 @@ export const scaffold = { throw new Error('vite is not supported on codesandbox-browser'); } - const base = { + const base: Record = { 'index.html': Vite.getHTML(), 'src/App.tsx': Vite.getApp(data), 'src/index.tsx': Vite.getRootIndex(), @@ -27,10 +28,10 @@ export const scaffold = { if (data.provider === 'codesandbox-cloud') { Object.assign(base, getCodesandboxConfig('vite')); } - return base; + return applyTransform(base, data); }, cra: (data: Data): Record => { - const base = { + const base: Record = { 'public/index.html': CRA.getHTML(), 'src/App.tsx': CRA.getApp(data), 'src/index.tsx': CRA.getRootIndex(), @@ -45,10 +46,27 @@ export const scaffold = { Object.assign(base, getCodesandboxConfig('cra')); } - return base; + return applyTransform(base, data); }, }; +function applyTransform(base: Record, data: Data): Record { + if (!data.transformFiles) { + return base; + } + const ctx: SandboxContext = { + provider: data.provider, + bundler: data.bundler, + storyExportToken: data.storyExportToken, + storyFile: data.storyFile, + dependencies: data.dependencies, + requiredDependencies: data.requiredDependencies, + optionalDependencies: data.optionalDependencies, + devDependencies: data.devDependencies, + }; + return data.transformFiles(base, ctx); +} + const Vite = { getHTML: () => dedent` @@ -137,6 +155,7 @@ const Vite = { ...commonDevDeps, '@vitejs/plugin-react': '^4.2.0', vite: '^5.0.0', + ...data.devDependencies, }, }); }, @@ -167,6 +186,7 @@ const CRA = { ...commonDevDeps, 'react-scripts': '^5.0.0', '@babel/plugin-proposal-private-property-in-object': 'latest', + ...data.devDependencies, }, scripts: { start: 'react-scripts start', @@ -246,13 +266,13 @@ function getIndex() { } function getExample(demoData: Data) { - return demoData.storyFile; + return `${demoData.storyFile}\nexport { ${demoData.storyExportToken} as Example };\n`; } -function getApp(data: Data) { - const code = dedent` +function getApp(_data: Data) { + return dedent` import { FluentProvider, webLightTheme } from '@fluentui/react-components'; - import { ${data.storyExportToken} as Example } from './example'; + import { Example } from './example'; const App = () => { return ( @@ -264,6 +284,4 @@ function getApp(data: Data) { export default App; `; - - return code; } diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.spec.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.spec.ts index 4f67cc6554eef7..6dd13dfb973312 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.spec.ts +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.spec.ts @@ -61,6 +61,9 @@ describe(`sabdbox-utils`, () => { storyExportToken: 'DefaultTitle', storyFile: context.parameters.fullSource, title: 'FluentUI React v9', + requiredDependencies: {}, + optionalDependencies: {}, + devDependencies: {}, }); }); }); diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts index a0c9093f84a300..8f3076e348c00e 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts @@ -46,7 +46,7 @@ export function prepareSandboxContainers(context: StoryContext) { }); } -const addonConfigDefaults = { requiredDependencies: {}, optionalDependencies: {} }; +const addonConfigDefaults = { requiredDependencies: {}, optionalDependencies: {}, devDependencies: {} }; export type Data = Pick, 'provider' | 'bundler'> & { storyFile: string; // use originalStoryFn because users can override the `storyName` property. @@ -59,6 +59,10 @@ export type Data = Pick, 'provider' | 'bundler'> & { dependencies: Record; title: string; description: string; + requiredDependencies: Record; + optionalDependencies: Record; + devDependencies: Record; + transformFiles?: NonNullable; }; export function prepareData(context: StoryContext): Data | null { @@ -66,7 +70,7 @@ export function prepareData(context: StoryContext): Data | null { throw new Error('exportToSandbox config parameter cannot be empty'); } - const addonConfig: Required = { + const addonConfig: ParametersConfig & typeof addonConfigDefaults = { ...addonConfigDefaults, ...context.parameters.exportToSandbox, }; @@ -97,7 +101,7 @@ export function prepareData(context: StoryContext): Data | null { throw new Error('issues processing story export token'); } - const demoData = { + const demoData: Data = { storyFile, storyExportToken, provider, @@ -105,6 +109,10 @@ export function prepareData(context: StoryContext): Data | null { dependencies, title, description, + requiredDependencies: addonConfig.requiredDependencies, + optionalDependencies: addonConfig.optionalDependencies, + devDependencies: addonConfig.devDependencies, + transformFiles: addonConfig.transformFiles, }; return demoData;