From dfdfbdde5dedcd09c85e8d8dbd4779d0cc27bbf1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:15:29 +0000 Subject: [PATCH 0001/1317] Implement command resolution with fallback variations for Windows Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- code/core/src/common/utils/command.test.ts | 440 +++++++++++++++++++++ code/core/src/common/utils/command.ts | 133 ++++++- 2 files changed, 562 insertions(+), 11 deletions(-) create mode 100644 code/core/src/common/utils/command.test.ts diff --git a/code/core/src/common/utils/command.test.ts b/code/core/src/common/utils/command.test.ts new file mode 100644 index 000000000000..d4631f5dd4a6 --- /dev/null +++ b/code/core/src/common/utils/command.test.ts @@ -0,0 +1,440 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { execa, execaCommandSync } from 'execa'; + +import { executeCommand, executeCommandSync } from './command'; + +vi.mock('storybook/internal/node-logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + prompt: { + getPreferredStdio: vi.fn(() => 'pipe'), + }, +})); + +vi.mock('execa', () => ({ + execa: vi.fn(), + execaCommandSync: vi.fn(), + execaNode: vi.fn(), +})); + +const mockedExeca = vi.mocked(execa); +const mockedExecaCommandSync = vi.mocked(execaCommandSync); + +describe('command', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('executeCommand on Windows', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('should try .exe first for pnpm and succeed', async () => { + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'pnpm', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledTimes(1); + expect(mockedExeca).toHaveBeenCalledWith( + 'pnpm.exe', + ['--version'], + expect.objectContaining({ + encoding: 'utf8', + cleanup: true, + }) + ); + }); + + it('should try .cmd after .exe fails with "not recognized" error for pnpm', async () => { + // First call fails with "not recognized" error + mockedExeca.mockRejectedValueOnce({ + stderr: + "'pnpm.exe' is not recognized as an internal or external command,\r\noperable program or batch file.", + message: 'Command failed: pnpm.exe --version', + }); + + // Second call succeeds + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'pnpm', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledTimes(2); + expect(mockedExeca).toHaveBeenNthCalledWith(1, 'pnpm.exe', ['--version'], expect.anything()); + expect(mockedExeca).toHaveBeenNthCalledWith(2, 'pnpm.cmd', ['--version'], expect.anything()); + }); + + it('should try .ps1 after .cmd fails with "not recognized" error for pnpm', async () => { + // First two calls fail with "not recognized" error + mockedExeca.mockRejectedValueOnce({ + stderr: "'pnpm.exe' is not recognized as an internal or external command", + message: 'Command failed', + }); + + mockedExeca.mockRejectedValueOnce({ + stderr: "'pnpm.cmd' is not recognized as an internal or external command", + message: 'Command failed', + }); + + // Third call succeeds + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'pnpm', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledTimes(3); + expect(mockedExeca).toHaveBeenNthCalledWith(1, 'pnpm.exe', ['--version'], expect.anything()); + expect(mockedExeca).toHaveBeenNthCalledWith(2, 'pnpm.cmd', ['--version'], expect.anything()); + expect(mockedExeca).toHaveBeenNthCalledWith(3, 'pnpm.ps1', ['--version'], expect.anything()); + }); + + it('should try bare command after all extensions fail with "not recognized" error', async () => { + // First three calls fail with "not recognized" error + mockedExeca.mockRejectedValueOnce({ + stderr: "'pnpm.exe' is not recognized as an internal or external command", + message: 'Command failed', + }); + + mockedExeca.mockRejectedValueOnce({ + stderr: "'pnpm.cmd' is not recognized as an internal or external command", + message: 'Command failed', + }); + + mockedExeca.mockRejectedValueOnce({ + stderr: "'pnpm.ps1' is not recognized as an internal or external command", + message: 'Command failed', + }); + + // Fourth call succeeds + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'pnpm', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledTimes(4); + expect(mockedExeca).toHaveBeenNthCalledWith(4, 'pnpm', ['--version'], expect.anything()); + }); + + it('should throw error immediately if first call fails with non-"not recognized" error', async () => { + mockedExeca.mockRejectedValueOnce({ + stderr: 'Some other error', + message: 'Command failed with different error', + }); + + await expect( + executeCommand({ + command: 'pnpm', + args: ['--version'], + }) + ).rejects.toEqual({ + stderr: 'Some other error', + message: 'Command failed with different error', + }); + + expect(mockedExeca).toHaveBeenCalledTimes(1); + }); + + it('should throw error on last variation if all fail with "not recognized" error', async () => { + const error = { + stderr: "'pnpm' is not recognized as an internal or external command", + message: 'Command failed', + }; + + mockedExeca.mockRejectedValue(error); + + await expect( + executeCommand({ + command: 'pnpm', + args: ['--version'], + }) + ).rejects.toEqual(error); + + expect(mockedExeca).toHaveBeenCalledTimes(4); // .exe, .cmd, .ps1, bare command + }); + + it('should work for npm command', async () => { + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'npm', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledWith('npm.exe', ['--version'], expect.anything()); + }); + + it('should work for yarn command', async () => { + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'yarn', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledWith('yarn.exe', ['--version'], expect.anything()); + }); + + it('should not modify unknown commands on Windows', async () => { + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'git', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledWith('git', ['--version'], expect.anything()); + }); + }); + + describe('executeCommand on non-Windows', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('should use command as-is for pnpm on Linux', async () => { + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'pnpm', + args: ['--version'], + }); + + expect(mockedExeca).toHaveBeenCalledWith('pnpm', ['--version'], expect.anything()); + }); + + it('should use command as-is for npm on macOS', async () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); + + await executeCommand({ + command: 'npm', + args: ['install'], + }); + + expect(mockedExeca).toHaveBeenCalledWith('npm', ['install'], expect.anything()); + }); + }); + + describe('executeCommandSync on Windows', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('should try .exe first for pnpm and succeed', () => { + mockedExecaCommandSync.mockReturnValueOnce({ + stdout: '10.0.0', + stderr: '', + } as any); + + const result = executeCommandSync({ + command: 'pnpm', + args: ['--version'], + }); + + expect(result).toBe('10.0.0'); + expect(mockedExecaCommandSync).toHaveBeenCalledTimes(1); + expect(mockedExecaCommandSync).toHaveBeenCalledWith( + 'pnpm.exe --version', + expect.objectContaining({ + encoding: 'utf8', + cleanup: true, + }) + ); + }); + + it('should try .cmd after .exe fails with "not recognized" error', () => { + // First call fails + mockedExecaCommandSync.mockImplementationOnce(() => { + throw { + stderr: "'pnpm.exe' is not recognized as an internal or external command", + message: 'Command failed', + }; + }); + + // Second call succeeds + mockedExecaCommandSync.mockReturnValueOnce({ + stdout: '10.0.0', + stderr: '', + } as any); + + const result = executeCommandSync({ + command: 'pnpm', + args: ['--version'], + }); + + expect(result).toBe('10.0.0'); + expect(mockedExecaCommandSync).toHaveBeenCalledTimes(2); + expect(mockedExecaCommandSync).toHaveBeenNthCalledWith( + 1, + 'pnpm.exe --version', + expect.anything() + ); + expect(mockedExecaCommandSync).toHaveBeenNthCalledWith( + 2, + 'pnpm.cmd --version', + expect.anything() + ); + }); + + it('should throw error immediately if first call fails with non-"not recognized" error', () => { + mockedExecaCommandSync.mockImplementationOnce(() => { + throw { + stderr: 'Some other error', + message: 'Command failed with different error', + }; + }); + + expect(() => + executeCommandSync({ + command: 'pnpm', + args: ['--version'], + }) + ).toThrow(); + + expect(mockedExecaCommandSync).toHaveBeenCalledTimes(1); + }); + }); + + describe('executeCommandSync on non-Windows', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('should use command as-is for pnpm on Linux', () => { + mockedExecaCommandSync.mockReturnValueOnce({ + stdout: '10.0.0', + stderr: '', + } as any); + + const result = executeCommandSync({ + command: 'pnpm', + args: ['--version'], + }); + + expect(result).toBe('10.0.0'); + expect(mockedExecaCommandSync).toHaveBeenCalledWith('pnpm --version', expect.anything()); + }); + }); + + describe('ignoreError option', () => { + it('should suppress errors when ignoreError is true for executeCommand', async () => { + mockedExeca.mockRejectedValueOnce(new Error('Command failed')); + + const promise = executeCommand({ + command: 'pnpm', + args: ['--version'], + ignoreError: true, + }); + + // Should not throw + await expect(promise).resolves.toBeUndefined(); + }); + + it('should return empty string when ignoreError is true for executeCommandSync', () => { + mockedExecaCommandSync.mockImplementationOnce(() => { + throw new Error('Command failed'); + }); + + const result = executeCommandSync({ + command: 'pnpm', + args: ['--version'], + ignoreError: true, + }); + + expect(result).toBe(''); + }); + }); +}); diff --git a/code/core/src/common/utils/command.ts b/code/core/src/common/utils/command.ts index 052a94df69b7..0dfda1835861 100644 --- a/code/core/src/common/utils/command.ts +++ b/code/core/src/common/utils/command.ts @@ -49,7 +49,9 @@ function getExecaOptions({ export function executeCommand(options: ExecuteCommandOptions): ResultPromise { const { command, args = [], ignoreError = false } = options; logger.debug(`Executing command: ${command} ${args.join(' ')}`); - const execaProcess = execa(resolveCommand(command), args, getExecaOptions(options)); + + const commandVariations = resolveCommand(command); + const execaProcess = tryCommandVariations(commandVariations, args, getExecaOptions(options)); if (ignoreError) { execaProcess.catch(() => { @@ -63,11 +65,12 @@ export function executeCommand(options: ExecuteCommandOptions): ResultPromise { export function executeCommandSync(options: ExecuteCommandOptions): string { const { command, args = [], ignoreError = false } = options; try { - const commandResult = execaCommandSync( - [resolveCommand(command), ...args].join(' '), + const commandVariations = resolveCommand(command); + return tryCommandVariationsSync( + commandVariations, + args, getExecaOptions(options) as SyncOptions ); - return typeof commandResult.stdout === 'string' ? commandResult.stdout : ''; } catch (err) { if (!ignoreError) { throw err; @@ -90,6 +93,106 @@ export function executeNodeCommand({ }); } +/** + * Check if an error is a "command not found" error on Windows. This happens when trying to execute + * a command that doesn't exist. + * + * @param error - The error to check + * @returns True if the error is a "command not found" error + */ +function isCommandNotFoundError(error: any): boolean { + if (!error) { + return false; + } + + // Check for Windows-specific "command not recognized" error + const stderr = error.stderr || ''; + const message = error.message || ''; + + return ( + stderr.includes('is not recognized as an internal or external command') || + message.includes('is not recognized as an internal or external command') + ); +} + +/** + * Try executing a command with multiple variations until one succeeds. This is needed on Windows + * where package managers can be installed in different ways. + * + * @param commandVariations - Array of command variations to try + * @param args - Command arguments + * @param options - Execa options + * @returns Promise from execa + */ +function tryCommandVariations( + commandVariations: string[], + args: string[], + options: Options +): ResultPromise { + let lastError: any; + + const tryNext = async (index: number): Promise => { + if (index >= commandVariations.length) { + throw lastError; + } + + const cmd = commandVariations[index]; + try { + return await execa(cmd, args, options); + } catch (error: any) { + lastError = error; + + // If this is not a "command not found" error, or we're on the last variation, re-throw + if (!isCommandNotFoundError(error) || index === commandVariations.length - 1) { + throw error; + } + + // Otherwise, try the next variation + logger.debug(`Command "${cmd}" not found, trying next variation...`); + return tryNext(index + 1); + } + }; + + return tryNext(0) as ResultPromise; +} + +/** + * Synchronously try executing a command with multiple variations until one succeeds. + * + * @param commandVariations - Array of command variations to try + * @param args - Command arguments + * @param options - Execa sync options + * @returns Stdout from the successful command + */ +function tryCommandVariationsSync( + commandVariations: string[], + args: string[], + options: SyncOptions +): string { + let lastError: any; + + for (let i = 0; i < commandVariations.length; i++) { + const cmd = commandVariations[i]; + try { + const commandResult = execaCommandSync([cmd, ...args].join(' '), options); + return typeof commandResult.stdout === 'string' ? commandResult.stdout : ''; + } catch (error: any) { + lastError = error; + + // If this is not a "command not found" error, or we're on the last variation, re-throw + if (!isCommandNotFoundError(error) || i === commandVariations.length - 1) { + throw error; + } + + // Otherwise, try the next variation + logger.debug(`Command "${cmd}" not found, trying next variation...`); + } + } + + // This should never be reached, but TypeScript needs it + throw lastError; +} + /** * Resolve the actual executable name for a given command on the current platform. * @@ -97,9 +200,11 @@ export function executeNodeCommand({ * * - Many Node-based CLIs (npm, npx, pnpm, yarn, vite, eslint, anything in node_modules/.bin) do NOT * ship as real executables on Windows. - * - Instead, they install *.cmd and *.ps1 “shim” files. + * - Instead, they install *.cmd and *.ps1 "shim" files. * - When using execa/child_process with `shell: false` (our default), Node WILL NOT resolve these * shims. -> calling execa("npx") throws ENOENT on Windows. + * - HOWEVER, package managers like pnpm can be installed via system tools (Mise, Scoop) as native + * executables (.exe), not as Node packages. In these cases, the .cmd shim doesn't exist. * * This helper normalizes command names so they can be spawned cross-platform without using `shell: * true`. @@ -108,7 +213,8 @@ export function executeNodeCommand({ * * - If on Windows: * - * - For known shim-based commands, append `.cmd` (e.g., "npx" → "npx.cmd"). + * - For known shim-based commands, return an array of variations to try in order: [command.exe, + * command.cmd, command.ps1, command] * - For everything else, return the name unchanged. * - On non-Windows, return command unchanged. * @@ -118,9 +224,9 @@ export function executeNodeCommand({ * - If Storybook adds new internal commands later, extend the list. * * @param {string} command - The executable name passed into executeCommand. - * @returns {string} - The normalized executable name safe for passing to execa. + * @returns {string[]} - Array of command variations to try (most specific first). */ -function resolveCommand(command: string): string { +function resolveCommand(command: string): string[] { // Commands known to require .cmd on Windows (node-based & shim-installed) const WINDOWS_SHIM_COMMANDS = new Set([ 'npm', @@ -133,12 +239,17 @@ function resolveCommand(command: string): string { ]); if (process.platform !== 'win32') { - return command; + return [command]; } if (WINDOWS_SHIM_COMMANDS.has(command)) { - return `${command}.cmd`; + // On Windows, try multiple variations in order of likelihood: + // 1. .exe - native executable (e.g., pnpm installed via Scoop/Mise) + // 2. .cmd - CMD shim (most common for npm-installed packages) + // 3. .ps1 - PowerShell shim (less common but possible) + // 4. bare command - fallback + return [`${command}.exe`, `${command}.cmd`, `${command}.ps1`, command]; } - return command; + return [command]; } From 77d895f440d872bb2b1ebba68327590e1d04c79b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:56:07 +0000 Subject: [PATCH 0002/1317] Address review feedback: simplify error checking, reduce redundancy, prioritize .cmd Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- code/core/src/common/utils/command.test.ts | 44 +++++++++++----------- code/core/src/common/utils/command.ts | 31 +++++---------- 2 files changed, 32 insertions(+), 43 deletions(-) diff --git a/code/core/src/common/utils/command.test.ts b/code/core/src/common/utils/command.test.ts index d4631f5dd4a6..8a84db296160 100644 --- a/code/core/src/common/utils/command.test.ts +++ b/code/core/src/common/utils/command.test.ts @@ -46,7 +46,7 @@ describe('command', () => { }); }); - it('should try .exe first for pnpm and succeed', async () => { + it('should try .cmd first for pnpm and succeed', async () => { mockedExeca.mockResolvedValueOnce({ stdout: 'success', stderr: '', @@ -59,7 +59,7 @@ describe('command', () => { expect(mockedExeca).toHaveBeenCalledTimes(1); expect(mockedExeca).toHaveBeenCalledWith( - 'pnpm.exe', + 'pnpm.cmd', ['--version'], expect.objectContaining({ encoding: 'utf8', @@ -68,12 +68,12 @@ describe('command', () => { ); }); - it('should try .cmd after .exe fails with "not recognized" error for pnpm', async () => { + it('should try .exe after .cmd fails with "not recognized" error for pnpm', async () => { // First call fails with "not recognized" error mockedExeca.mockRejectedValueOnce({ stderr: - "'pnpm.exe' is not recognized as an internal or external command,\r\noperable program or batch file.", - message: 'Command failed: pnpm.exe --version', + "'pnpm.cmd' is not recognized as an internal or external command,\r\noperable program or batch file.", + message: 'Command failed: pnpm.cmd --version', }); // Second call succeeds @@ -88,19 +88,19 @@ describe('command', () => { }); expect(mockedExeca).toHaveBeenCalledTimes(2); - expect(mockedExeca).toHaveBeenNthCalledWith(1, 'pnpm.exe', ['--version'], expect.anything()); - expect(mockedExeca).toHaveBeenNthCalledWith(2, 'pnpm.cmd', ['--version'], expect.anything()); + expect(mockedExeca).toHaveBeenNthCalledWith(1, 'pnpm.cmd', ['--version'], expect.anything()); + expect(mockedExeca).toHaveBeenNthCalledWith(2, 'pnpm.exe', ['--version'], expect.anything()); }); - it('should try .ps1 after .cmd fails with "not recognized" error for pnpm', async () => { + it('should try .ps1 after .exe fails with "not recognized" error for pnpm', async () => { // First two calls fail with "not recognized" error mockedExeca.mockRejectedValueOnce({ - stderr: "'pnpm.exe' is not recognized as an internal or external command", + stderr: "'pnpm.cmd' is not recognized as an internal or external command", message: 'Command failed', }); mockedExeca.mockRejectedValueOnce({ - stderr: "'pnpm.cmd' is not recognized as an internal or external command", + stderr: "'pnpm.exe' is not recognized as an internal or external command", message: 'Command failed', }); @@ -116,20 +116,20 @@ describe('command', () => { }); expect(mockedExeca).toHaveBeenCalledTimes(3); - expect(mockedExeca).toHaveBeenNthCalledWith(1, 'pnpm.exe', ['--version'], expect.anything()); - expect(mockedExeca).toHaveBeenNthCalledWith(2, 'pnpm.cmd', ['--version'], expect.anything()); + expect(mockedExeca).toHaveBeenNthCalledWith(1, 'pnpm.cmd', ['--version'], expect.anything()); + expect(mockedExeca).toHaveBeenNthCalledWith(2, 'pnpm.exe', ['--version'], expect.anything()); expect(mockedExeca).toHaveBeenNthCalledWith(3, 'pnpm.ps1', ['--version'], expect.anything()); }); it('should try bare command after all extensions fail with "not recognized" error', async () => { // First three calls fail with "not recognized" error mockedExeca.mockRejectedValueOnce({ - stderr: "'pnpm.exe' is not recognized as an internal or external command", + stderr: "'pnpm.cmd' is not recognized as an internal or external command", message: 'Command failed', }); mockedExeca.mockRejectedValueOnce({ - stderr: "'pnpm.cmd' is not recognized as an internal or external command", + stderr: "'pnpm.exe' is not recognized as an internal or external command", message: 'Command failed', }); @@ -201,7 +201,7 @@ describe('command', () => { args: ['--version'], }); - expect(mockedExeca).toHaveBeenCalledWith('npm.exe', ['--version'], expect.anything()); + expect(mockedExeca).toHaveBeenCalledWith('npm.cmd', ['--version'], expect.anything()); }); it('should work for yarn command', async () => { @@ -215,7 +215,7 @@ describe('command', () => { args: ['--version'], }); - expect(mockedExeca).toHaveBeenCalledWith('yarn.exe', ['--version'], expect.anything()); + expect(mockedExeca).toHaveBeenCalledWith('yarn.cmd', ['--version'], expect.anything()); }); it('should not modify unknown commands on Windows', async () => { @@ -301,7 +301,7 @@ describe('command', () => { }); }); - it('should try .exe first for pnpm and succeed', () => { + it('should try .cmd first for pnpm and succeed', () => { mockedExecaCommandSync.mockReturnValueOnce({ stdout: '10.0.0', stderr: '', @@ -315,7 +315,7 @@ describe('command', () => { expect(result).toBe('10.0.0'); expect(mockedExecaCommandSync).toHaveBeenCalledTimes(1); expect(mockedExecaCommandSync).toHaveBeenCalledWith( - 'pnpm.exe --version', + 'pnpm.cmd --version', expect.objectContaining({ encoding: 'utf8', cleanup: true, @@ -323,11 +323,11 @@ describe('command', () => { ); }); - it('should try .cmd after .exe fails with "not recognized" error', () => { + it('should try .exe after .cmd fails with "not recognized" error', () => { // First call fails mockedExecaCommandSync.mockImplementationOnce(() => { throw { - stderr: "'pnpm.exe' is not recognized as an internal or external command", + stderr: "'pnpm.cmd' is not recognized as an internal or external command", message: 'Command failed', }; }); @@ -347,12 +347,12 @@ describe('command', () => { expect(mockedExecaCommandSync).toHaveBeenCalledTimes(2); expect(mockedExecaCommandSync).toHaveBeenNthCalledWith( 1, - 'pnpm.exe --version', + 'pnpm.cmd --version', expect.anything() ); expect(mockedExecaCommandSync).toHaveBeenNthCalledWith( 2, - 'pnpm.cmd --version', + 'pnpm.exe --version', expect.anything() ); }); diff --git a/code/core/src/common/utils/command.ts b/code/core/src/common/utils/command.ts index 0dfda1835861..c79ff81feabd 100644 --- a/code/core/src/common/utils/command.ts +++ b/code/core/src/common/utils/command.ts @@ -105,14 +105,13 @@ function isCommandNotFoundError(error: any): boolean { return false; } - // Check for Windows-specific "command not recognized" error const stderr = error.stderr || ''; - const message = error.message || ''; + return stderr.includes('is not recognized as an internal or external command'); +} - return ( - stderr.includes('is not recognized as an internal or external command') || - message.includes('is not recognized as an internal or external command') - ); +/** Helper to check if we should continue trying command variations. */ +function shouldRetry(error: any, isLastVariation: boolean): boolean { + return isCommandNotFoundError(error) && !isLastVariation; } /** @@ -142,12 +141,10 @@ function tryCommandVariations( } catch (error: any) { lastError = error; - // If this is not a "command not found" error, or we're on the last variation, re-throw - if (!isCommandNotFoundError(error) || index === commandVariations.length - 1) { + if (!shouldRetry(error, index === commandVariations.length - 1)) { throw error; } - // Otherwise, try the next variation logger.debug(`Command "${cmd}" not found, trying next variation...`); return tryNext(index + 1); } @@ -179,17 +176,14 @@ function tryCommandVariationsSync( } catch (error: any) { lastError = error; - // If this is not a "command not found" error, or we're on the last variation, re-throw - if (!isCommandNotFoundError(error) || i === commandVariations.length - 1) { + if (!shouldRetry(error, i === commandVariations.length - 1)) { throw error; } - // Otherwise, try the next variation logger.debug(`Command "${cmd}" not found, trying next variation...`); } } - // This should never be reached, but TypeScript needs it throw lastError; } @@ -213,8 +207,8 @@ function tryCommandVariationsSync( * * - If on Windows: * - * - For known shim-based commands, return an array of variations to try in order: [command.exe, - * command.cmd, command.ps1, command] + * - For known shim-based commands, return an array of variations to try in order: [command.cmd, + * command.exe, command.ps1, command] * - For everything else, return the name unchanged. * - On non-Windows, return command unchanged. * @@ -243,12 +237,7 @@ function resolveCommand(command: string): string[] { } if (WINDOWS_SHIM_COMMANDS.has(command)) { - // On Windows, try multiple variations in order of likelihood: - // 1. .exe - native executable (e.g., pnpm installed via Scoop/Mise) - // 2. .cmd - CMD shim (most common for npm-installed packages) - // 3. .ps1 - PowerShell shim (less common but possible) - // 4. bare command - fallback - return [`${command}.exe`, `${command}.cmd`, `${command}.ps1`, command]; + return [`${command}.cmd`, `${command}.exe`, `${command}.ps1`, command]; } return [command]; From 3f8d3cec27d12a34d4bbf38e87e0e85b843edd40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 08:31:52 +0000 Subject: [PATCH 0003/1317] Remove type cast by properly typing tryNext function Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- code/core/src/common/utils/command.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/code/core/src/common/utils/command.ts b/code/core/src/common/utils/command.ts index c79ff81feabd..98c7e7ea8cb3 100644 --- a/code/core/src/common/utils/command.ts +++ b/code/core/src/common/utils/command.ts @@ -130,15 +130,14 @@ function tryCommandVariations( ): ResultPromise { let lastError: any; - const tryNext = async (index: number): Promise => { + const tryNext = (index: number): ResultPromise => { if (index >= commandVariations.length) { throw lastError; } const cmd = commandVariations[index]; - try { - return await execa(cmd, args, options); - } catch (error: any) { + + return execa(cmd, args, options).catch((error: any) => { lastError = error; if (!shouldRetry(error, index === commandVariations.length - 1)) { @@ -147,10 +146,10 @@ function tryCommandVariations( logger.debug(`Command "${cmd}" not found, trying next variation...`); return tryNext(index + 1); - } + }); }; - return tryNext(0) as ResultPromise; + return tryNext(0); } /** From 570e89afa6fb4d5650f17d3b1b6ee92bc1127b9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 08:38:16 +0000 Subject: [PATCH 0004/1317] Fix prettier formatting issues Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- code/core/src/common/utils/command.ts | 43 ++++++++++++++++----------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/code/core/src/common/utils/command.ts b/code/core/src/common/utils/command.ts index 98c7e7ea8cb3..b319e7d42e2e 100644 --- a/code/core/src/common/utils/command.ts +++ b/code/core/src/common/utils/command.ts @@ -128,28 +128,37 @@ function tryCommandVariations( args: string[], options: Options ): ResultPromise { - let lastError: any; - - const tryNext = (index: number): ResultPromise => { - if (index >= commandVariations.length) { - throw lastError; - } + // Start with the first command - this gives us a real ResultPromise + const firstAttempt = execa(commandVariations[0], args, options); - const cmd = commandVariations[index]; + // If there's only one variation, return it directly (no casting needed) + if (commandVariations.length === 1) { + return firstAttempt; + } - return execa(cmd, args, options).catch((error: any) => { - lastError = error; + // For multiple variations, we need to handle retries + // We'll wrap the promise but preserve the process properties from the first attempt + const retryWrapper = firstAttempt.catch((error: any) => { + if (!shouldRetry(error, commandVariations.length === 1)) { + throw error; + } - if (!shouldRetry(error, index === commandVariations.length - 1)) { - throw error; - } + logger.debug(`Command "${commandVariations[0]}" not found, trying next variation...`); - logger.debug(`Command "${cmd}" not found, trying next variation...`); - return tryNext(index + 1); - }); - }; + // Try remaining variations recursively + return tryCommandVariations(commandVariations.slice(1), args, options); + }); - return tryNext(0); + // Copy process properties from firstAttempt to retryWrapper + // This allows callers to access pid, stdin, stdout, stderr if needed + return Object.assign(retryWrapper, { + pid: firstAttempt.pid, + stdin: firstAttempt.stdin, + stdout: firstAttempt.stdout, + stderr: firstAttempt.stderr, + all: firstAttempt.all, + kill: firstAttempt.kill.bind(firstAttempt), + }) as ResultPromise; } /** From 9b18706602724aa0763bd36c2942a98a7667f6b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:12:45 +0000 Subject: [PATCH 0005/1317] Rollback to simpler implementation without stale pid reuse Co-authored-by: Sidnioulz <5108577+Sidnioulz@users.noreply.github.com> --- code/core/src/common/utils/command.ts | 55 +++++++++++++-------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/code/core/src/common/utils/command.ts b/code/core/src/common/utils/command.ts index b319e7d42e2e..ce3af31c47f2 100644 --- a/code/core/src/common/utils/command.ts +++ b/code/core/src/common/utils/command.ts @@ -128,37 +128,29 @@ function tryCommandVariations( args: string[], options: Options ): ResultPromise { - // Start with the first command - this gives us a real ResultPromise - const firstAttempt = execa(commandVariations[0], args, options); - - // If there's only one variation, return it directly (no casting needed) - if (commandVariations.length === 1) { - return firstAttempt; - } + let lastError: any; - // For multiple variations, we need to handle retries - // We'll wrap the promise but preserve the process properties from the first attempt - const retryWrapper = firstAttempt.catch((error: any) => { - if (!shouldRetry(error, commandVariations.length === 1)) { - throw error; + const tryNext = async (index: number): Promise => { + if (index >= commandVariations.length) { + throw lastError; } - logger.debug(`Command "${commandVariations[0]}" not found, trying next variation...`); + const cmd = commandVariations[index]; + try { + return await execa(cmd, args, options); + } catch (error: any) { + lastError = error; - // Try remaining variations recursively - return tryCommandVariations(commandVariations.slice(1), args, options); - }); + if (!shouldRetry(error, index === commandVariations.length - 1)) { + throw error; + } + + logger.debug(`Command "${cmd}" not found, trying next variation...`); + return tryNext(index + 1); + } + }; - // Copy process properties from firstAttempt to retryWrapper - // This allows callers to access pid, stdin, stdout, stderr if needed - return Object.assign(retryWrapper, { - pid: firstAttempt.pid, - stdin: firstAttempt.stdin, - stdout: firstAttempt.stdout, - stderr: firstAttempt.stderr, - all: firstAttempt.all, - kill: firstAttempt.kill.bind(firstAttempt), - }) as ResultPromise; + return tryNext(0) as ResultPromise; } /** @@ -215,8 +207,8 @@ function tryCommandVariationsSync( * * - If on Windows: * - * - For known shim-based commands, return an array of variations to try in order: [command.cmd, - * command.exe, command.ps1, command] + * - For known shim-based commands, return an array of variations to try in order: [command.exe, + * command.cmd, command.ps1, command] * - For everything else, return the name unchanged. * - On non-Windows, return command unchanged. * @@ -245,7 +237,12 @@ function resolveCommand(command: string): string[] { } if (WINDOWS_SHIM_COMMANDS.has(command)) { - return [`${command}.cmd`, `${command}.exe`, `${command}.ps1`, command]; + // On Windows, try multiple variations in order of likelihood: + // 1. .exe - native executable (e.g., pnpm installed via Scoop/Mise) + // 2. .cmd - CMD shim (most common for npm-installed packages) + // 3. .ps1 - PowerShell shim (less common but possible) + // 4. bare command - fallback + return [`${command}.exe`, `${command}.cmd`, `${command}.ps1`, command]; } return [command]; From 0362d2121092fc05b73d5bfb8c126c86aef53915 Mon Sep 17 00:00:00 2001 From: Sebastian Nemeth Date: Mon, 9 Mar 2026 15:48:12 +0200 Subject: [PATCH 0006/1317] builder-vite: support configLoader via builder options --- code/builders/builder-vite/src/types.ts | 2 ++ code/builders/builder-vite/src/vite-config.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/code/builders/builder-vite/src/types.ts b/code/builders/builder-vite/src/types.ts index a3a2ab2ccc33..150751a0af1c 100644 --- a/code/builders/builder-vite/src/types.ts +++ b/code/builders/builder-vite/src/types.ts @@ -21,4 +21,6 @@ export type StorybookConfigVite = { export type BuilderOptions = { /** Path to `vite.config` file, relative to `process.cwd()`. */ viteConfigPath?: string; + + configLoader?: 'bundle' | 'runner' | 'native' }; diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 807f50152219..538f5229cffb 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -42,7 +42,7 @@ export async function commonConfig( const configEnv = _type === 'development' ? configEnvServe : configEnvBuild; const { loadConfigFromFile, mergeConfig } = await import('vite'); - const { viteConfigPath } = await getBuilderOptions(options); + const { viteConfigPath, configLoader } = await getBuilderOptions(options); const projectRoot = resolve(options.configDir, '..'); @@ -50,7 +50,7 @@ export async function commonConfig( // I do this because I can contain config that breaks storybook, such as we had in a lit project. // If the user needs to configure the `build` they need to do so in the viteFinal function in main.js. const { config: { build: buildProperty = undefined, ...userConfig } = {} } = - (await loadConfigFromFile(configEnv, viteConfigPath, projectRoot)) ?? {}; + (await loadConfigFromFile(configEnv, viteConfigPath, projectRoot, undefined, undefined, configLoader)) ?? {}; // Storybook's Vite config is assembled from self-contained plugins. // The config plugin handles base settings (root, cacheDir, resolve conditions, etc.), From b25cdc2751c00699d75b3a7c0125d821b4320167 Mon Sep 17 00:00:00 2001 From: Sebastian Nemeth Date: Mon, 9 Mar 2026 16:31:01 +0200 Subject: [PATCH 0007/1317] builder-vite: add tests / docs for configLoader support --- code/builders/builder-vite/src/types.ts | 1 - .../builder-vite/src/vite-config.test.ts | 42 ++++++ .../main-config-builder-configLoader.md | 141 ++++++++++++++++++ docs/builders/vite.mdx | 12 ++ 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 docs/_snippets/main-config-builder-configLoader.md diff --git a/code/builders/builder-vite/src/types.ts b/code/builders/builder-vite/src/types.ts index 150751a0af1c..4907c0377fb7 100644 --- a/code/builders/builder-vite/src/types.ts +++ b/code/builders/builder-vite/src/types.ts @@ -21,6 +21,5 @@ export type StorybookConfigVite = { export type BuilderOptions = { /** Path to `vite.config` file, relative to `process.cwd()`. */ viteConfigPath?: string; - configLoader?: 'bundle' | 'runner' | 'native' }; diff --git a/code/builders/builder-vite/src/vite-config.test.ts b/code/builders/builder-vite/src/vite-config.test.ts index 9bb9a0b6913d..9668927ac420 100644 --- a/code/builders/builder-vite/src/vite-config.test.ts +++ b/code/builders/builder-vite/src/vite-config.test.ts @@ -88,3 +88,45 @@ describe('storybookConfigPlugin', () => { expect(config.server.fs.allow).toContain('/test/.storybook'); }); }); + +it('should pass configLoader option to loadConfigFromFile', async () => { + const optionsWithConfigLoader: Options = { + ...dummyOptions, + presets: { + apply: async (key: string) => + ({ + framework: { name: '' }, + addons: [], + core: { + builder: { + name: '@storybook/builder-vite', + options: { + configLoader: 'native' + } + }, + }, + options: {}, + })[key], + } as Presets, + }; + + loadConfigFromFileMock.mockReturnValueOnce( + Promise.resolve({ + config: {}, + path: '', + dependencies: [], + }) + ); + + await commonConfig(optionsWithConfigLoader, 'development'); + + // Verify loadConfigFromFile was called with configLoader as the 6th argument + expect(loadConfigFromFileMock).toHaveBeenCalledWith( + expect.objectContaining({ command: 'serve' }), + undefined, + expect.any(String), + undefined, + undefined, + 'native' + ); +}); \ No newline at end of file diff --git a/docs/_snippets/main-config-builder-configLoader.md b/docs/_snippets/main-config-builder-configLoader.md new file mode 100644 index 000000000000..b7817b330837 --- /dev/null +++ b/docs/_snippets/main-config-builder-configLoader.md @@ -0,0 +1,141 @@ +```ts filename=".storybook/main.ts" renderer="common" language="ts" tabTitle="CSF 3" +// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. +import type { StorybookConfig } from '@storybook/your-framework'; + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + core: { + builder: { + name: '@storybook/builder-vite', + options: { + configLoader: 'runner', + }, + }, + }, +}; + +export default config; +``` + +```js filename=".storybook/main.js" renderer="common" language="js" tabTitle="CSF 3" +export default { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + core: { + builder: { + name: '@storybook/builder-vite', + options: { + configLoader: 'runner', + }, + }, + }, +}; +``` + +```ts filename=".storybook/main.ts" renderer="react" language="ts" tabTitle="CSF Next 🧪" +// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) +import { defineMain } from '@storybook/your-framework/node'; + +export default defineMain({ + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + core: { + builder: { + name: '@storybook/builder-vite', + options: { + configLoader: 'runner', + }, + }, + }, +}); +``` + + + +```js filename=".storybook/main.js" renderer="react" language="js" tabTitle="CSF Next 🧪" +// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) +import { defineMain } from '@storybook/your-framework/node'; + +export default defineMain({ + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + + core: { + builder: { + name: '@storybook/builder-vite', + options: { + configLoader: 'runner', + }, + }, + }, +}); +``` + +```ts filename=".storybook/main.ts" renderer="vue" language="ts" tabTitle="CSF Next 🧪" +import { defineMain } from '@storybook/vue3-vite/node'; + +export default defineMain({ + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + + core: { + builder: { + name: '@storybook/builder-vite', + options: { + configLoader: 'runner', + }, + }, + }, +}); +``` + + + +```js filename=".storybook/main.js" renderer="vue" language="js" tabTitle="CSF Next 🧪" +import { defineMain } from '@storybook/vue3-vite/node'; + +export default defineMain({ + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + + core: { + builder: { + name: '@storybook/builder-vite', + options: { + configLoader: 'runner', + }, + }, + }, +}); +``` + +```ts filename=".storybook/main.ts" renderer="web-components" language="ts" tabTitle="CSF Next 🧪" +import { defineMain } from '@storybook/web-components-vite/node'; + +export default defineMain({ + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + + core: { + builder: { + name: '@storybook/builder-vite', + options: { + configLoader: 'runner', + }, + }, + }, +}); +``` + + + +```js filename=".storybook/main.js" renderer="web-components" language="js" tabTitle="CSF Next 🧪" +import { defineMain } from '@storybook/web-components-vite/node'; + +export default defineMain({ + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + + core: { + builder: { + name: '@storybook/builder-vite', + options: { + configLoader: 'runner', + }, + }, + }, +}); +``` diff --git a/docs/builders/vite.mdx b/docs/builders/vite.mdx index cd08cde7e89c..2d0ed1f5bdec 100644 --- a/docs/builders/vite.mdx +++ b/docs/builders/vite.mdx @@ -68,6 +68,18 @@ By default, the Vite builder in Storybook searches for the Vite configuration fi If you do not want Storybook to load the Vite configuration file automatically, you can use the `viteConfigPath` option to point to a non-existent file. +### Set configLoader for vite config + +You can pass the `configLoader` argument to `vite` by setting it in builder options. This is equivalent to the `--configLoader` command line argument. + +{/* prettier-ignore-start */} + + + +{/* prettier-ignore-end */} + +Check `vite`'s [documentation](https://vite.dev/config/) for valid `configLoader` options. + ### TypeScript If you need, you can also configure Storybook's Vite builder using TypeScript. Rename your `.storybook/main.js` to `.storybook/main.ts` and adjust it as follows: From 9d88c252ba69884d5ae85abb94aa6855b6e765ae Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 11 Mar 2026 09:58:53 +0100 Subject: [PATCH 0008/1317] Add support for Vite's configLoader option for Addon-Vitest --- code/addons/vitest/package.json | 1 + code/addons/vitest/src/node/boot-test-runner.ts | 9 ++++++++- code/addons/vitest/src/node/test-manager.ts | 6 ++++++ code/addons/vitest/src/node/vitest-manager.ts | 1 + code/addons/vitest/src/node/vitest.ts | 3 +++ code/addons/vitest/src/preset.ts | 8 ++++++++ yarn.lock | 1 + 7 files changed, 28 insertions(+), 1 deletion(-) diff --git a/code/addons/vitest/package.json b/code/addons/vitest/package.json index 47e64b50cfc8..18d1eb1e1f5a 100644 --- a/code/addons/vitest/package.json +++ b/code/addons/vitest/package.json @@ -81,6 +81,7 @@ }, "devDependencies": { "@storybook/addon-a11y": "workspace:*", + "@storybook/builder-vite": "workspace:*", "@types/istanbul-lib-report": "^3.0.3", "@types/micromatch": "^4.0.0", "@types/node": "^22.19.1", diff --git a/code/addons/vitest/src/node/boot-test-runner.ts b/code/addons/vitest/src/node/boot-test-runner.ts index 48cc96549744..b5504096f1a7 100644 --- a/code/addons/vitest/src/node/boot-test-runner.ts +++ b/code/addons/vitest/src/node/boot-test-runner.ts @@ -9,6 +9,8 @@ import { } from 'storybook/internal/core-server'; import type { EventInfo, Options } from 'storybook/internal/types'; +import type { BuilderOptions } from '@storybook/builder-vite'; + import { normalize } from 'pathe'; import { importMetaResolve } from '../../../../core/src/shared/utils/module'; @@ -48,10 +50,12 @@ const bootTestRunner = async ({ channel, store, options, + configLoader, }: { channel: Channel; store: Store; options: Options; + configLoader: BuilderOptions['configLoader']; }) => { let stderr: string[] = []; const killChild = () => { @@ -85,6 +89,7 @@ const bootTestRunner = async ({ VITEST_CHILD_PROCESS: 'true', NODE_ENV: process.env.NODE_ENV ?? 'test', STORYBOOK_CONFIG_DIR: normalize(options.configDir), + STORYBOOK_CONFIG_LOADER: configLoader, }, extendEnv: true, }, @@ -163,19 +168,21 @@ export const runTestRunner = async ({ initEvent, initArgs, options, + configLoader, }: { channel: Channel; store: Store; initEvent?: string; initArgs?: any[]; options: Options; + configLoader: BuilderOptions['configLoader']; }) => { if (!ready && initEvent) { eventQueue.push({ type: initEvent, args: initArgs }); } if (!child) { ready = false; - await bootTestRunner({ channel, store, options }); + await bootTestRunner({ channel, store, options, configLoader }); ready = true; } }; diff --git a/code/addons/vitest/src/node/test-manager.ts b/code/addons/vitest/src/node/test-manager.ts index 7f11a90f2599..842df0f2f3a8 100644 --- a/code/addons/vitest/src/node/test-manager.ts +++ b/code/addons/vitest/src/node/test-manager.ts @@ -8,6 +8,8 @@ import type { TestProviderStoreById, } from 'storybook/internal/types'; +import type { BuilderOptions } from '@storybook/builder-vite'; + import type { A11yReport } from '@storybook/addon-a11y'; import { throttle } from 'es-toolkit/function'; @@ -27,6 +29,7 @@ import { VitestManager } from './vitest-manager'; export type TestManagerOptions = { storybookOptions: Options; + configLoader?: BuilderOptions['configLoader']; store: experimental_UniversalStore; componentTestStatusStore: StatusStoreByTypeId; a11yStatusStore: StatusStoreByTypeId; @@ -58,6 +61,8 @@ export class TestManager { public storybookOptions: Options; + private configLoader?: TestManagerOptions['configLoader']; + private batchedTestCaseResults: { storyId: string; testResult: TestResult; @@ -71,6 +76,7 @@ export class TestManager { this.testProviderStore = options.testProviderStore; this.onReady = options.onReady; this.storybookOptions = options.storybookOptions; + this.configLoader = options.configLoader; this.vitestManager = new VitestManager(this); diff --git a/code/addons/vitest/src/node/vitest-manager.ts b/code/addons/vitest/src/node/vitest-manager.ts index 018165da983c..380fca33ee4d 100644 --- a/code/addons/vitest/src/node/vitest-manager.ts +++ b/code/addons/vitest/src/node/vitest-manager.ts @@ -124,6 +124,7 @@ export class VitestManager { try { this.vitest = await createVitest('test', { root: vitestWorkspaceConfig ?? vitestConfigFallbackLocation, + configLoader: this.testManager.configLoader, watch: true, passWithNoTests: false, project: [projectName], diff --git a/code/addons/vitest/src/node/vitest.ts b/code/addons/vitest/src/node/vitest.ts index 92d5871ff485..b6859be3aa44 100644 --- a/code/addons/vitest/src/node/vitest.ts +++ b/code/addons/vitest/src/node/vitest.ts @@ -7,6 +7,8 @@ import { experimental_getTestProviderStore, } from 'storybook/internal/core-server'; +import type { BuilderOptions } from '@storybook/builder-vite'; + import { ADDON_ID, STATUS_TYPE_ID_A11Y, @@ -48,6 +50,7 @@ new TestManager({ storybookOptions: { configDir: process.env.STORYBOOK_CONFIG_DIR || '', } as any, + configLoader: process.env.STORYBOOK_CONFIG_LOADER as BuilderOptions['configLoader'], }); const exit = (code = 0) => { diff --git a/code/addons/vitest/src/preset.ts b/code/addons/vitest/src/preset.ts index bb0f035959c7..e81c0815afb3 100644 --- a/code/addons/vitest/src/preset.ts +++ b/code/addons/vitest/src/preset.ts @@ -21,6 +21,8 @@ import type { StoryId, } from 'storybook/internal/types'; +import type { BuilderOptions } from '@storybook/builder-vite'; + import { isEqual } from 'es-toolkit/predicate'; import picocolors from 'picocolors'; import { dedent } from 'ts-dedent'; @@ -82,6 +84,10 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti return channel; } + const configLoader = + typeof core.builder !== 'string' && + (core.builder?.options?.configLoader as BuilderOptions['configLoader']); + const storyIndexGenerator = await options.presets.apply>('storyIndexGenerator'); @@ -136,6 +142,7 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti initEvent: STORE_CHANNEL_EVENT_NAME, initArgs: [{ event, eventInfo }], options, + configLoader: configLoader || undefined, }); }); store.subscribe('TOGGLE_WATCHING', (event, eventInfo) => { @@ -157,6 +164,7 @@ export const experimental_serverChannel = async (channel: Channel, options: Opti initEvent: STORE_CHANNEL_EVENT_NAME, initArgs: [{ event, eventInfo }], options, + configLoader: configLoader || undefined, }); } }); diff --git a/yarn.lock b/yarn.lock index 002a49e113c6..d7d8892a64a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7729,6 +7729,7 @@ __metadata: resolution: "@storybook/addon-vitest@workspace:code/addons/vitest" dependencies: "@storybook/addon-a11y": "workspace:*" + "@storybook/builder-vite": "workspace:*" "@storybook/global": "npm:^5.0.0" "@storybook/icons": "npm:^2.0.1" "@types/istanbul-lib-report": "npm:^3.0.3" From 0c4311dfdc03956bb17f19cebdda70dcb7aeccc3 Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:50:40 +0530 Subject: [PATCH 0009/1317] Fix empty builder-vite external globals imports --- .../storybook-external-globals-plugin.test.ts | 36 ++++- .../storybook-external-globals-plugin.ts | 140 ++++++++++++------ 2 files changed, 125 insertions(+), 51 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts index 4b5c001f0e3d..a981bb9055c4 100644 --- a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts +++ b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts @@ -1,9 +1,13 @@ -import { expect, it } from 'vitest'; +import { expect, it, vi } from "vitest"; -import { rewriteImport } from './storybook-external-globals-plugin'; +vi.mock("storybook/internal/preview/globals", () => ({ + globalsNameReferenceMap: {}, +})); -const packageName = '@storybook/package'; -const globals = { [packageName]: '_STORYBOOK_PACKAGE_' }; +import { rewriteImport } from "./storybook-external-globals-plugin"; + +const packageName = "@storybook/package"; +const globals = { [packageName]: "_STORYBOOK_PACKAGE_" }; const cases = [ { @@ -40,6 +44,18 @@ const cases = [ input: `import Foo from "${packageName}"`, output: `const {default: Foo} = ${globals[packageName]}`, }, + { + globals, + packageName, + input: `import {} from "${packageName}"`, + output: `void ${globals[packageName]}`, + }, + { + globals, + packageName, + input: `import '${packageName}';`, + output: `void ${globals[packageName]};`, + }, { globals, packageName, @@ -54,8 +70,12 @@ const cases = [ }, ]; -it('rewriteImport', () => { - cases.forEach(({ input, output, globals: caseGlobals, packageName: casePackage }) => { - expect(rewriteImport(input, caseGlobals, casePackage)).toStrictEqual(output); - }); +it("rewriteImport", () => { + cases.forEach( + ({ input, output, globals: caseGlobals, packageName: casePackage }) => { + expect(rewriteImport(input, caseGlobals, casePackage)).toStrictEqual( + output, + ); + }, + ); }); diff --git a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts index 54bb15a28c96..c84d89512f6a 100644 --- a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts @@ -1,24 +1,27 @@ -import { existsSync } from 'node:fs'; -import { mkdir, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; - -import { globalsNameReferenceMap } from 'storybook/internal/preview/globals'; -import type { Options } from 'storybook/internal/types'; - -import * as pkg from 'empathic/package'; -import { init, parse } from 'es-module-lexer'; -import MagicString from 'magic-string'; -import type { Alias, Plugin } from 'vite'; - -const escapeKeys = (key: string) => key.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); -const defaultImportRegExp = 'import ([^*{}]+) from'; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { globalsNameReferenceMap } from "storybook/internal/preview/globals"; +import type { Options } from "storybook/internal/types"; + +import * as pkg from "empathic/package"; +import { init, parse } from "es-module-lexer"; +import MagicString from "magic-string"; +import type { Alias, Plugin } from "vite"; + +const escapeKeys = (key: string) => + key.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); +const defaultImportRegExp = "import ([^*{}]+) from"; +const emptyImportRegExp = + /^import(?:\s*\{\s*\}\s*from)?\s*['"][^'"]+['"]\s*;?$/; const replacementMap = new Map([ - ['import ', 'const '], - ['import{', 'const {'], - ['* as ', ''], - [' as ', ': '], - [' from ', ' = '], - ['}from', '} ='], + ["import ", "const "], + ["import{", "const {"], + ["* as ", ""], + [" as ", ": "], + [" from ", " = "], + ["}from", "} ="], ]); /** @@ -42,43 +45,59 @@ const replacementMap = new Map([ * needs. */ -export async function storybookExternalGlobalsPlugin(options: Options): Promise { - const build = await options.presets.apply('build'); +export async function storybookExternalGlobalsPlugin( + options: Options, +): Promise { + const build = await options.presets.apply("build"); const externals: typeof globalsNameReferenceMap & Record = globalsNameReferenceMap; if (build?.test?.disableBlocks) { - externals['@storybook/addon-docs/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__'; + externals["@storybook/addon-docs/blocks"] = + "__STORYBOOK_BLOCKS_EMPTY_MODULE__"; } await init; - const { mergeAlias } = await import('vite'); + const { mergeAlias } = await import("vite"); return { - name: 'storybook:external-globals-plugin', - enforce: 'post', + name: "storybook:external-globals-plugin", + enforce: "post", // In dev (serve), we set up aliases to files that we write into node_modules/.cache. async config(config, { command }) { - if (command !== 'serve') { + if (command !== "serve") { return undefined; } const newAlias = mergeAlias([], config.resolve?.alias) as Alias[]; const cachePath = - pkg.cache('sb-vite-plugin-externals', { create: true }) ?? - join(process.cwd(), 'node_modules', '.cache', 'sb-vite-plugin-externals'); + pkg.cache("sb-vite-plugin-externals", { create: true }) ?? + join( + process.cwd(), + "node_modules", + ".cache", + "sb-vite-plugin-externals", + ); await Promise.all( - (Object.keys(externals) as Array).map(async (externalKey) => { - const externalCachePath = join(cachePath, `${externalKey}.js`); - newAlias.push({ find: new RegExp(`^${externalKey}$`), replacement: externalCachePath }); - if (!existsSync(externalCachePath)) { - const directory = dirname(externalCachePath); - await mkdir(directory, { recursive: true }); - } - await writeFile(externalCachePath, `module.exports = ${externals[externalKey]};`); - }) + (Object.keys(externals) as Array).map( + async (externalKey) => { + const externalCachePath = join(cachePath, `${externalKey}.js`); + newAlias.push({ + find: new RegExp(`^${externalKey}$`), + replacement: externalCachePath, + }); + if (!existsSync(externalCachePath)) { + const directory = dirname(externalCachePath); + await mkdir(directory, { recursive: true }); + } + await writeFile( + externalCachePath, + `module.exports = ${externals[externalKey]};`, + ); + }, + ), ); return { @@ -101,7 +120,11 @@ export async function storybookExternalGlobalsPlugin(options: Options): Promise< const packageName = path; if (packageName && globalsList.includes(packageName)) { const importStatement = src.slice(startPosition, endPosition); - const transformedImport = rewriteImport(importStatement, externals, packageName); + const transformedImport = rewriteImport( + importStatement, + externals, + packageName, + ); src.update(startPosition, endPosition, transformedImport); } }); @@ -119,22 +142,53 @@ function getDefaultImportReplacement(match: string) { return matched && `const {default: ${matched[1]}} =`; } +function getEmptyImportReplacement( + importStatement: string, + globalReference: string, +) { + if (!emptyImportRegExp.test(importStatement.trim())) { + return undefined; + } + + const statementTerminator = importStatement.trimEnd().endsWith(";") + ? ";" + : ""; + return `void ${globalReference}${statementTerminator}`; +} + function getSearchRegExp(packageName: string) { const staticKeys = [...replacementMap.keys()].map(escapeKeys); const packageNameLiteral = `.${packageName}.`; const dynamicImportExpression = `await import\\(.${packageName}.\\)`; - const lookup = [defaultImportRegExp, ...staticKeys, packageNameLiteral, dynamicImportExpression]; - return new RegExp(`(${lookup.join('|')})`, 'g'); + const lookup = [ + defaultImportRegExp, + ...staticKeys, + packageNameLiteral, + dynamicImportExpression, + ]; + return new RegExp(`(${lookup.join("|")})`, "g"); } export function rewriteImport( importStatement: string, globs: Record, - packageName: string + packageName: string, ): string { + const emptyImportReplacement = getEmptyImportReplacement( + importStatement, + globs[packageName], + ); + + if (emptyImportReplacement) { + return emptyImportReplacement; + } + const search = getSearchRegExp(packageName); return importStatement.replace( search, - (match) => replacementMap.get(match) ?? getDefaultImportReplacement(match) ?? globs[packageName] + (match) => + replacementMap.get(match) ?? + getDefaultImportReplacement(match) ?? + globs[packageName], ); } From 7166ab92d08cdbe6aeffc6043b2e7db57e86a5dc Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:52:07 +0530 Subject: [PATCH 0010/1317] Minimize builder-vite empty import diff --- .../storybook-external-globals-plugin.test.ts | 22 ++- .../storybook-external-globals-plugin.ts | 125 +++++++----------- 2 files changed, 55 insertions(+), 92 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts index a981bb9055c4..491259d44f79 100644 --- a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts +++ b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts @@ -1,13 +1,13 @@ -import { expect, it, vi } from "vitest"; +import { expect, it, vi } from 'vitest'; -vi.mock("storybook/internal/preview/globals", () => ({ +vi.mock('storybook/internal/preview/globals', () => ({ globalsNameReferenceMap: {}, })); -import { rewriteImport } from "./storybook-external-globals-plugin"; +import { rewriteImport } from './storybook-external-globals-plugin'; -const packageName = "@storybook/package"; -const globals = { [packageName]: "_STORYBOOK_PACKAGE_" }; +const packageName = '@storybook/package'; +const globals = { [packageName]: '_STORYBOOK_PACKAGE_' }; const cases = [ { @@ -70,12 +70,8 @@ const cases = [ }, ]; -it("rewriteImport", () => { - cases.forEach( - ({ input, output, globals: caseGlobals, packageName: casePackage }) => { - expect(rewriteImport(input, caseGlobals, casePackage)).toStrictEqual( - output, - ); - }, - ); +it('rewriteImport', () => { + cases.forEach(({ input, output, globals: caseGlobals, packageName: casePackage }) => { + expect(rewriteImport(input, caseGlobals, casePackage)).toStrictEqual(output); + }); }); diff --git a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts index c84d89512f6a..1b8c4e2f036e 100644 --- a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts +++ b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.ts @@ -1,27 +1,25 @@ -import { existsSync } from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; - -import { globalsNameReferenceMap } from "storybook/internal/preview/globals"; -import type { Options } from "storybook/internal/types"; - -import * as pkg from "empathic/package"; -import { init, parse } from "es-module-lexer"; -import MagicString from "magic-string"; -import type { Alias, Plugin } from "vite"; - -const escapeKeys = (key: string) => - key.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); -const defaultImportRegExp = "import ([^*{}]+) from"; -const emptyImportRegExp = - /^import(?:\s*\{\s*\}\s*from)?\s*['"][^'"]+['"]\s*;?$/; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { globalsNameReferenceMap } from 'storybook/internal/preview/globals'; +import type { Options } from 'storybook/internal/types'; + +import * as pkg from 'empathic/package'; +import { init, parse } from 'es-module-lexer'; +import MagicString from 'magic-string'; +import type { Alias, Plugin } from 'vite'; + +const escapeKeys = (key: string) => key.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); +const defaultImportRegExp = 'import ([^*{}]+) from'; +const emptyImportRegExp = /^import(?:\s*\{\s*\}\s*from)?\s*['"][^'"]+['"]\s*;?$/; const replacementMap = new Map([ - ["import ", "const "], - ["import{", "const {"], - ["* as ", ""], - [" as ", ": "], - [" from ", " = "], - ["}from", "} ="], + ['import ', 'const '], + ['import{', 'const {'], + ['* as ', ''], + [' as ', ': '], + [' from ', ' = '], + ['}from', '} ='], ]); /** @@ -45,59 +43,43 @@ const replacementMap = new Map([ * needs. */ -export async function storybookExternalGlobalsPlugin( - options: Options, -): Promise { - const build = await options.presets.apply("build"); +export async function storybookExternalGlobalsPlugin(options: Options): Promise { + const build = await options.presets.apply('build'); const externals: typeof globalsNameReferenceMap & Record = globalsNameReferenceMap; if (build?.test?.disableBlocks) { - externals["@storybook/addon-docs/blocks"] = - "__STORYBOOK_BLOCKS_EMPTY_MODULE__"; + externals['@storybook/addon-docs/blocks'] = '__STORYBOOK_BLOCKS_EMPTY_MODULE__'; } await init; - const { mergeAlias } = await import("vite"); + const { mergeAlias } = await import('vite'); return { - name: "storybook:external-globals-plugin", - enforce: "post", + name: 'storybook:external-globals-plugin', + enforce: 'post', // In dev (serve), we set up aliases to files that we write into node_modules/.cache. async config(config, { command }) { - if (command !== "serve") { + if (command !== 'serve') { return undefined; } const newAlias = mergeAlias([], config.resolve?.alias) as Alias[]; const cachePath = - pkg.cache("sb-vite-plugin-externals", { create: true }) ?? - join( - process.cwd(), - "node_modules", - ".cache", - "sb-vite-plugin-externals", - ); + pkg.cache('sb-vite-plugin-externals', { create: true }) ?? + join(process.cwd(), 'node_modules', '.cache', 'sb-vite-plugin-externals'); await Promise.all( - (Object.keys(externals) as Array).map( - async (externalKey) => { - const externalCachePath = join(cachePath, `${externalKey}.js`); - newAlias.push({ - find: new RegExp(`^${externalKey}$`), - replacement: externalCachePath, - }); - if (!existsSync(externalCachePath)) { - const directory = dirname(externalCachePath); - await mkdir(directory, { recursive: true }); - } - await writeFile( - externalCachePath, - `module.exports = ${externals[externalKey]};`, - ); - }, - ), + (Object.keys(externals) as Array).map(async (externalKey) => { + const externalCachePath = join(cachePath, `${externalKey}.js`); + newAlias.push({ find: new RegExp(`^${externalKey}$`), replacement: externalCachePath }); + if (!existsSync(externalCachePath)) { + const directory = dirname(externalCachePath); + await mkdir(directory, { recursive: true }); + } + await writeFile(externalCachePath, `module.exports = ${externals[externalKey]};`); + }) ); return { @@ -120,11 +102,7 @@ export async function storybookExternalGlobalsPlugin( const packageName = path; if (packageName && globalsList.includes(packageName)) { const importStatement = src.slice(startPosition, endPosition); - const transformedImport = rewriteImport( - importStatement, - externals, - packageName, - ); + const transformedImport = rewriteImport(importStatement, externals, packageName); src.update(startPosition, endPosition, transformedImport); } }); @@ -142,17 +120,12 @@ function getDefaultImportReplacement(match: string) { return matched && `const {default: ${matched[1]}} =`; } -function getEmptyImportReplacement( - importStatement: string, - globalReference: string, -) { +function getEmptyImportReplacement(importStatement: string, globalReference: string) { if (!emptyImportRegExp.test(importStatement.trim())) { return undefined; } - const statementTerminator = importStatement.trimEnd().endsWith(";") - ? ";" - : ""; + const statementTerminator = importStatement.trimEnd().endsWith(';') ? ';' : ''; return `void ${globalReference}${statementTerminator}`; } @@ -166,18 +139,15 @@ function getSearchRegExp(packageName: string) { packageNameLiteral, dynamicImportExpression, ]; - return new RegExp(`(${lookup.join("|")})`, "g"); + return new RegExp(`(${lookup.join('|')})`, 'g'); } export function rewriteImport( importStatement: string, globs: Record, - packageName: string, + packageName: string ): string { - const emptyImportReplacement = getEmptyImportReplacement( - importStatement, - globs[packageName], - ); + const emptyImportReplacement = getEmptyImportReplacement(importStatement, globs[packageName]); if (emptyImportReplacement) { return emptyImportReplacement; @@ -186,9 +156,6 @@ export function rewriteImport( const search = getSearchRegExp(packageName); return importStatement.replace( search, - (match) => - replacementMap.get(match) ?? - getDefaultImportReplacement(match) ?? - globs[packageName], + (match) => replacementMap.get(match) ?? getDefaultImportReplacement(match) ?? globs[packageName] ); } From e2a0ccbdddf37cf34728fc55c1eb87b0a2e0436d Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:55:53 +0530 Subject: [PATCH 0011/1317] Address Storybook review feedback --- .../plugins/storybook-external-globals-plugin.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts index 491259d44f79..d1c112d36957 100644 --- a/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts +++ b/code/builders/builder-vite/src/plugins/storybook-external-globals-plugin.test.ts @@ -1,8 +1,6 @@ import { expect, it, vi } from 'vitest'; -vi.mock('storybook/internal/preview/globals', () => ({ - globalsNameReferenceMap: {}, -})); +vi.mock('storybook/internal/preview/globals', { spy: true }); import { rewriteImport } from './storybook-external-globals-plugin'; @@ -50,6 +48,12 @@ const cases = [ input: `import {} from "${packageName}"`, output: `void ${globals[packageName]}`, }, + { + globals, + packageName, + input: `import {} from "${packageName}";`, + output: `void ${globals[packageName]};`, + }, { globals, packageName, From 3c1d16bab5cb8afdcff28bd2a1b2884298b5375a Mon Sep 17 00:00:00 2001 From: Samuel Meddin Date: Thu, 2 Apr 2026 16:20:17 -0400 Subject: [PATCH 0012/1317] docs: fix incorrect onboarding addon information - Update README to reflect that the onboarding addon is no longer included by default in new Storybook projects - Update telemetry docs to link to the monorepo location instead of the archived addon-onboarding repository Fixes #32530 --- code/addons/onboarding/README.md | 4 ++-- docs/configure/telemetry.mdx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/code/addons/onboarding/README.md b/code/addons/onboarding/README.md index cd97fda30cf6..497c8f18e3c6 100644 --- a/code/addons/onboarding/README.md +++ b/code/addons/onboarding/README.md @@ -6,8 +6,8 @@ This addon provides a guided tour in some of Storybook's features, helping you g ## Triggering the onboarding -This addon comes installed by default in Storybook projects and should trigger automatically. -If you want to retrigger the addon, you should make sure that your Storybook still contains the example stories that come when initializing Storybook, and you can then navigate to http://localhost:6006/?path=/onboarding after running Storybook. +This addon is not included by default in new Storybook projects. To use it, install it and add it to your Storybook configuration. +If you want to trigger the addon, make sure your Storybook still contains the example stories that come when initializing Storybook, and then navigate to http://localhost:6006/?path=/onboarding after running Storybook. ## Uninstalling diff --git a/docs/configure/telemetry.mdx b/docs/configure/telemetry.mdx index 4aacc45029e7..53ea615fab93 100644 --- a/docs/configure/telemetry.mdx +++ b/docs/configure/telemetry.mdx @@ -42,7 +42,7 @@ Specifically, we track the following information in our telemetry events: * Testing tools (e.g. [Jest](https://jestjs.io/), [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/)). * Package manager information (e.g., `npm`, `yarn`). * Monorepo information (e.g., [NX](https://nx.dev/), [Turborepo](https://turborepo.org/)). -* In-app events (e.g., [Storybook guided tour](https://github.com/storybookjs/addon-onboarding), [UI test run](../writing-tests/integrations/vitest-addon/index.mdx#storybook-ui)). +* In-app events (e.g., [Storybook guided tour](https://github.com/storybookjs/storybook/tree/next/code/addons/onboarding), [UI test run](../writing-tests/integrations/vitest-addon/index.mdx#storybook-ui)). Access to the raw data is highly controlled, limited to select members of Storybook's core team who maintain the telemetry. We cannot identify individual users from the dataset: it is anonymized and untraceable back to the user. From 28d67896a82106f0b208a35c63392a60e184d5c7 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 3 Apr 2026 17:37:38 +0200 Subject: [PATCH 0013/1317] Fix lint errors --- code/core/src/common/utils/command.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/code/core/src/common/utils/command.test.ts b/code/core/src/common/utils/command.test.ts index 8a84db296160..4ce0b99a0742 100644 --- a/code/core/src/common/utils/command.test.ts +++ b/code/core/src/common/utils/command.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +// eslint-disable-next-line depend/ban-dependencies import { execa, execaCommandSync } from 'execa'; -import { executeCommand, executeCommandSync } from './command'; +import { executeCommand, executeCommandSync } from './command.ts'; vi.mock('storybook/internal/node-logger', () => ({ logger: { From 8a98a0345459dcda82d3fe8f9ca251ca320c9f9c Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 3 Apr 2026 17:40:43 +0200 Subject: [PATCH 0014/1317] Add missing import --- code/core/src/common/utils/command.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/common/utils/command.test.ts b/code/core/src/common/utils/command.test.ts index 4ce0b99a0742..95db7a0223d6 100644 --- a/code/core/src/common/utils/command.test.ts +++ b/code/core/src/common/utils/command.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // eslint-disable-next-line depend/ban-dependencies import { execa, execaCommandSync } from 'execa'; From 419352026ea79b708e3ae327a6cb964e9ab52ce1 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 3 Apr 2026 18:03:42 +0200 Subject: [PATCH 0015/1317] Rework try variations to match the subprocess+promise signature of execa --- code/core/src/common/utils/command.ts | 47 ++++++++++++--------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/code/core/src/common/utils/command.ts b/code/core/src/common/utils/command.ts index ce3af31c47f2..894c23f88a6c 100644 --- a/code/core/src/common/utils/command.ts +++ b/code/core/src/common/utils/command.ts @@ -1,4 +1,6 @@ import { logger, prompt } from 'storybook/internal/node-logger'; +import { existsSync } from 'node:fs'; +import { delimiter, join } from 'node:path'; // eslint-disable-next-line depend/ban-dependencies import { @@ -115,42 +117,33 @@ function shouldRetry(error: any, isLastVariation: boolean): boolean { } /** - * Try executing a command with multiple variations until one succeeds. This is needed on Windows - * where package managers can be installed in different ways. - * - * @param commandVariations - Array of command variations to try - * @param args - Command arguments - * @param options - Execa options - * @returns Promise from execa + * Check if a command is available in PATH by looking for the file directly. + * This avoids spawning a process just to check existence. */ +function isExecutableInPath(command: string): boolean { + const pathDirs = (process.env.PATH || '').split(delimiter); + return pathDirs.some((dir) => existsSync(join(dir, command))); +} + function tryCommandVariations( commandVariations: string[], args: string[], options: Options ): ResultPromise { - let lastError: any; - - const tryNext = async (index: number): Promise => { - if (index >= commandVariations.length) { - throw lastError; - } - - const cmd = commandVariations[index]; - try { - return await execa(cmd, args, options); - } catch (error: any) { - lastError = error; - - if (!shouldRetry(error, index === commandVariations.length - 1)) { - throw error; - } + if (commandVariations.length <= 1) { + return execa(commandVariations[0], args, options); + } - logger.debug(`Command "${cmd}" not found, trying next variation...`); - return tryNext(index + 1); + // Resolve the best variation synchronously via PATH lookup + for (const cmd of commandVariations.slice(0, -1)) { + if (isExecutableInPath(cmd)) { + logger.debug(`Resolved command variation: ${cmd}`); + return execa(cmd, args, options); } - }; + } - return tryNext(0) as ResultPromise; + // Fallback to the last variation (bare command name) + return execa(commandVariations[commandVariations.length - 1], args, options); } /** From 25ed9876613e346c37d74f5c9fa5e1b1af150ad0 Mon Sep 17 00:00:00 2001 From: Steve Dodier-Lazaro Date: Fri, 3 Apr 2026 18:57:19 +0200 Subject: [PATCH 0016/1317] Fix tests --- code/core/src/common/utils/command.test.ts | 138 +++++++++++---------- 1 file changed, 73 insertions(+), 65 deletions(-) diff --git a/code/core/src/common/utils/command.test.ts b/code/core/src/common/utils/command.test.ts index 95db7a0223d6..f14ec3654421 100644 --- a/code/core/src/common/utils/command.test.ts +++ b/code/core/src/common/utils/command.test.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // eslint-disable-next-line depend/ban-dependencies @@ -22,12 +23,19 @@ vi.mock('execa', () => ({ execaNode: vi.fn(), })); +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => false), +})); + const mockedExeca = vi.mocked(execa); const mockedExecaCommandSync = vi.mocked(execaCommandSync); +const mockedExistsSync = vi.mocked(existsSync); describe('command', () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); + // Default: no executables found in PATH + mockedExistsSync.mockReturnValue(false); }); describe('executeCommand on Windows', () => { @@ -47,7 +55,8 @@ describe('command', () => { }); }); - it('should try .cmd first for pnpm and succeed', async () => { + it('should use .exe when found in PATH for pnpm', async () => { + mockedExistsSync.mockImplementation((p) => String(p).endsWith('pnpm.exe')); mockedExeca.mockResolvedValueOnce({ stdout: 'success', stderr: '', @@ -60,7 +69,7 @@ describe('command', () => { expect(mockedExeca).toHaveBeenCalledTimes(1); expect(mockedExeca).toHaveBeenCalledWith( - 'pnpm.cmd', + 'pnpm.exe', ['--version'], expect.objectContaining({ encoding: 'utf8', @@ -69,15 +78,8 @@ describe('command', () => { ); }); - it('should try .exe after .cmd fails with "not recognized" error for pnpm', async () => { - // First call fails with "not recognized" error - mockedExeca.mockRejectedValueOnce({ - stderr: - "'pnpm.cmd' is not recognized as an internal or external command,\r\noperable program or batch file.", - message: 'Command failed: pnpm.cmd --version', - }); - - // Second call succeeds + it('should use .cmd when .exe not found but .cmd exists in PATH', async () => { + mockedExistsSync.mockImplementation((p) => String(p).endsWith('pnpm.cmd')); mockedExeca.mockResolvedValueOnce({ stdout: 'success', stderr: '', @@ -88,24 +90,19 @@ describe('command', () => { args: ['--version'], }); - expect(mockedExeca).toHaveBeenCalledTimes(2); - expect(mockedExeca).toHaveBeenNthCalledWith(1, 'pnpm.cmd', ['--version'], expect.anything()); - expect(mockedExeca).toHaveBeenNthCalledWith(2, 'pnpm.exe', ['--version'], expect.anything()); + expect(mockedExeca).toHaveBeenCalledTimes(1); + expect(mockedExeca).toHaveBeenCalledWith( + 'pnpm.cmd', + ['--version'], + expect.objectContaining({ + encoding: 'utf8', + cleanup: true, + }) + ); }); - it('should try .ps1 after .exe fails with "not recognized" error for pnpm', async () => { - // First two calls fail with "not recognized" error - mockedExeca.mockRejectedValueOnce({ - stderr: "'pnpm.cmd' is not recognized as an internal or external command", - message: 'Command failed', - }); - - mockedExeca.mockRejectedValueOnce({ - stderr: "'pnpm.exe' is not recognized as an internal or external command", - message: 'Command failed', - }); - - // Third call succeeds + it('should use .ps1 when neither .exe nor .cmd found but .ps1 exists in PATH', async () => { + mockedExistsSync.mockImplementation((p) => String(p).endsWith('pnpm.ps1')); mockedExeca.mockResolvedValueOnce({ stdout: 'success', stderr: '', @@ -116,30 +113,37 @@ describe('command', () => { args: ['--version'], }); - expect(mockedExeca).toHaveBeenCalledTimes(3); - expect(mockedExeca).toHaveBeenNthCalledWith(1, 'pnpm.cmd', ['--version'], expect.anything()); - expect(mockedExeca).toHaveBeenNthCalledWith(2, 'pnpm.exe', ['--version'], expect.anything()); - expect(mockedExeca).toHaveBeenNthCalledWith(3, 'pnpm.ps1', ['--version'], expect.anything()); + expect(mockedExeca).toHaveBeenCalledTimes(1); + expect(mockedExeca).toHaveBeenCalledWith( + 'pnpm.ps1', + ['--version'], + expect.objectContaining({ + encoding: 'utf8', + cleanup: true, + }) + ); }); - it('should try bare command after all extensions fail with "not recognized" error', async () => { - // First three calls fail with "not recognized" error - mockedExeca.mockRejectedValueOnce({ - stderr: "'pnpm.cmd' is not recognized as an internal or external command", - message: 'Command failed', - }); + it('should fall back to bare command when no variation found in PATH', async () => { + mockedExistsSync.mockReturnValue(false); + mockedExeca.mockResolvedValueOnce({ + stdout: 'success', + stderr: '', + } as any); - mockedExeca.mockRejectedValueOnce({ - stderr: "'pnpm.exe' is not recognized as an internal or external command", - message: 'Command failed', + await executeCommand({ + command: 'pnpm', + args: ['--version'], }); - mockedExeca.mockRejectedValueOnce({ - stderr: "'pnpm.ps1' is not recognized as an internal or external command", - message: 'Command failed', - }); + expect(mockedExeca).toHaveBeenCalledTimes(1); + expect(mockedExeca).toHaveBeenCalledWith('pnpm', ['--version'], expect.anything()); + }); - // Fourth call succeeds + it('should prefer .exe over .cmd when both exist in PATH', async () => { + mockedExistsSync.mockImplementation( + (p) => String(p).endsWith('pnpm.exe') || String(p).endsWith('pnpm.cmd') + ); mockedExeca.mockResolvedValueOnce({ stdout: 'success', stderr: '', @@ -150,11 +154,12 @@ describe('command', () => { args: ['--version'], }); - expect(mockedExeca).toHaveBeenCalledTimes(4); - expect(mockedExeca).toHaveBeenNthCalledWith(4, 'pnpm', ['--version'], expect.anything()); + expect(mockedExeca).toHaveBeenCalledTimes(1); + expect(mockedExeca).toHaveBeenCalledWith('pnpm.exe', ['--version'], expect.anything()); }); - it('should throw error immediately if first call fails with non-"not recognized" error', async () => { + it('should propagate errors from the resolved command', async () => { + mockedExistsSync.mockReturnValue(false); mockedExeca.mockRejectedValueOnce({ stderr: 'Some other error', message: 'Command failed with different error', @@ -173,13 +178,13 @@ describe('command', () => { expect(mockedExeca).toHaveBeenCalledTimes(1); }); - it('should throw error on last variation if all fail with "not recognized" error', async () => { + it('should propagate errors when resolved command is not found', async () => { + mockedExistsSync.mockImplementation((p) => String(p).endsWith('pnpm.exe')); const error = { - stderr: "'pnpm' is not recognized as an internal or external command", + stderr: "'pnpm.exe' is not recognized as an internal or external command", message: 'Command failed', }; - - mockedExeca.mockRejectedValue(error); + mockedExeca.mockRejectedValueOnce(error); await expect( executeCommand({ @@ -188,10 +193,11 @@ describe('command', () => { }) ).rejects.toEqual(error); - expect(mockedExeca).toHaveBeenCalledTimes(4); // .exe, .cmd, .ps1, bare command + expect(mockedExeca).toHaveBeenCalledTimes(1); }); it('should work for npm command', async () => { + mockedExistsSync.mockImplementation((p) => String(p).endsWith('npm.cmd')); mockedExeca.mockResolvedValueOnce({ stdout: 'success', stderr: '', @@ -206,6 +212,7 @@ describe('command', () => { }); it('should work for yarn command', async () => { + mockedExistsSync.mockImplementation((p) => String(p).endsWith('yarn.cmd')); mockedExeca.mockResolvedValueOnce({ stdout: 'success', stderr: '', @@ -302,7 +309,7 @@ describe('command', () => { }); }); - it('should try .cmd first for pnpm and succeed', () => { + it('should try .exe first for pnpm and succeed', () => { mockedExecaCommandSync.mockReturnValueOnce({ stdout: '10.0.0', stderr: '', @@ -316,7 +323,7 @@ describe('command', () => { expect(result).toBe('10.0.0'); expect(mockedExecaCommandSync).toHaveBeenCalledTimes(1); expect(mockedExecaCommandSync).toHaveBeenCalledWith( - 'pnpm.cmd --version', + 'pnpm.exe --version', expect.objectContaining({ encoding: 'utf8', cleanup: true, @@ -324,16 +331,16 @@ describe('command', () => { ); }); - it('should try .exe after .cmd fails with "not recognized" error', () => { - // First call fails + it('should try .cmd after .exe fails with "not recognized" error', () => { + // First call (.exe) fails mockedExecaCommandSync.mockImplementationOnce(() => { throw { - stderr: "'pnpm.cmd' is not recognized as an internal or external command", + stderr: "'pnpm.exe' is not recognized as an internal or external command", message: 'Command failed', }; }); - // Second call succeeds + // Second call (.cmd) succeeds mockedExecaCommandSync.mockReturnValueOnce({ stdout: '10.0.0', stderr: '', @@ -348,12 +355,12 @@ describe('command', () => { expect(mockedExecaCommandSync).toHaveBeenCalledTimes(2); expect(mockedExecaCommandSync).toHaveBeenNthCalledWith( 1, - 'pnpm.cmd --version', + 'pnpm.exe --version', expect.anything() ); expect(mockedExecaCommandSync).toHaveBeenNthCalledWith( 2, - 'pnpm.exe --version', + 'pnpm.cmd --version', expect.anything() ); }); @@ -411,7 +418,7 @@ describe('command', () => { }); describe('ignoreError option', () => { - it('should suppress errors when ignoreError is true for executeCommand', async () => { + it('should not throw unhandled rejection when ignoreError is true for executeCommand', async () => { mockedExeca.mockRejectedValueOnce(new Error('Command failed')); const promise = executeCommand({ @@ -420,8 +427,9 @@ describe('command', () => { ignoreError: true, }); - // Should not throw - await expect(promise).resolves.toBeUndefined(); + // The .catch() handler in executeCommand prevents unhandled rejection warnings, + // but the returned promise still rejects since it's the original ResultPromise + await expect(promise).rejects.toThrow('Command failed'); }); it('should return empty string when ignoreError is true for executeCommandSync', () => { From 3951dd56ffcfe8e98ea1ac623a680ee7553cce02 Mon Sep 17 00:00:00 2001 From: NYCU-Chung Date: Wed, 8 Apr 2026 16:29:05 +0800 Subject: [PATCH 0017/1317] Addon Docs: Fix Primary and Controls blocks not rendering in custom MDX pages PR #32712 changed usePrimaryStory to only return stories with the 'autodocs' tag, inadvertently breaking custom MDX docs pages where component stories don't carry that tag. Users cannot work around this because adding 'autodocs' to stories that already have a custom MDX page triggers a separate error in StoryIndexGenerator. Fix: add a fallback that selects the first story without an explicit '!autodocs' tag when no story has 'autodocs'. This preserves the #32712 fix for autodocs pages while restoring custom MDX page behavior. Closes #33829 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../docs/src/blocks/blocks/usePrimaryStory.test.tsx | 10 +++++++++- code/addons/docs/src/blocks/blocks/usePrimaryStory.ts | 10 +++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx b/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx index f657c2f3dc63..6e0bbf10f3bb 100644 --- a/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx +++ b/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx @@ -49,11 +49,19 @@ describe('usePrimaryStory', () => { expect(result.current?.name).toBe('Story Two'); }); - it('returns undefined if no story has "autodocs" tag', () => { + it('falls back to first non-!autodocs story when no story has "autodocs" tag', () => { const mockContext = createMockContext([stories.story1, stories.story4] as PreparedStory[]); const { result } = renderHook(() => usePrimaryStory(), { wrapper: ({ children }) => {children}, }); + expect(result.current?.name).toBe('Story Four'); + }); + + it('returns undefined when all stories have !autodocs tag', () => { + const mockContext = createMockContext([stories.story1] as PreparedStory[]); + const { result } = renderHook(() => usePrimaryStory(), { + wrapper: ({ children }) => {children}, + }); expect(result.current).toBeUndefined(); }); diff --git a/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts b/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts index 75d085a69b17..97ea6d341c02 100644 --- a/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts +++ b/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts @@ -6,11 +6,15 @@ import type { PreparedStory } from 'storybook/internal/types'; import { DocsContext } from './DocsContext'; /** - * A hook to get the primary story for the current component's doc page. It defines the primary - * story as the first story that includes the 'autodocs' tag + * A hook to get the primary story for the current component's doc page. It prefers the first story + * that includes the 'autodocs' tag, falling back to the first story without an explicit '!autodocs' + * tag for custom MDX docs pages. */ export const usePrimaryStory = (): PreparedStory | undefined => { const context = useContext(DocsContext); const stories = context.componentStories(); - return stories.find((story) => story.tags.includes(Tag.AUTODOCS)); + return ( + stories.find((story) => story.tags.includes(Tag.AUTODOCS)) ?? + stories.find((story) => !story.tags.includes(`!${Tag.AUTODOCS}`)) + ); }; From e7fc8d0b85fc4f6116bcdb9e1935875d910bc10b Mon Sep 17 00:00:00 2001 From: flt3150sk Date: Wed, 8 Apr 2026 20:00:41 +0900 Subject: [PATCH 0018/1317] Fix EEXIST race condition in static file copying during build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `force: true` to all `fs.cp()` calls in the static build pipeline. The preview build (Vite) and static file copies run in parallel via `Promise.all`, which can cause EEXIST errors when both attempt to create the same subdirectory simultaneously — especially on CI runners with slow I/O. --- code/core/src/core-server/build-static.ts | 2 +- code/core/src/core-server/utils/copy-all-static-files.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index 607f2cb7aa47..d8c3c1c8e5fa 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -143,7 +143,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption } const coreServerPublicDir = join(resolvePackageDir('storybook'), 'assets/browser'); - effects.push(cp(coreServerPublicDir, options.outputDir, { recursive: true })); + effects.push(cp(coreServerPublicDir, options.outputDir, { recursive: true, force: true })); let storyIndexGeneratorPromise: Promise = Promise.resolve(undefined); diff --git a/code/core/src/core-server/utils/copy-all-static-files.ts b/code/core/src/core-server/utils/copy-all-static-files.ts index 9cf8d1975ff1..895c2e170c3d 100644 --- a/code/core/src/core-server/utils/copy-all-static-files.ts +++ b/code/core/src/core-server/utils/copy-all-static-files.ts @@ -30,6 +30,7 @@ export async function copyAllStaticFiles(staticDirs: any[] | undefined, outputDi preserveTimestamps: true, filter: (_, dest) => !skipPaths.includes(dest), recursive: true, + force: true, }); } catch (e) { if (e instanceof Error) { @@ -73,6 +74,7 @@ export async function copyAllStaticFilesRelativeToMain( preserveTimestamps: true, filter: (_, dest) => !skipPaths.includes(dest), recursive: true, + force: true, }); }, Promise.resolve()); } From e3331f9d1e1c34344a3a70880116bfb8ec30bf77 Mon Sep 17 00:00:00 2001 From: Yiheng Date: Thu, 9 Apr 2026 22:53:04 +0800 Subject: [PATCH 0019/1317] fix: #30553 pass correct option to open() --- code/core/src/core-server/utils/open-browser/opener.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/code/core/src/core-server/utils/open-browser/opener.ts b/code/core/src/core-server/utils/open-browser/opener.ts index 24c95abe539c..43f804f27075 100644 --- a/code/core/src/core-server/utils/open-browser/opener.ts +++ b/code/core/src/core-server/utils/open-browser/opener.ts @@ -186,16 +186,10 @@ function startBrowserProcess( browser = undefined; } - // If there are arguments, they must be passed as array with the browser - if (typeof browser === 'string' && args.length > 0) { - // @ts-expect-error - browser is a string - browser = [browser].concat(args); - } - // Fallback to open // (It will always open new tab) try { - const options = { app: browser, wait: false, url: true }; + const options = { app: { name: browser, arguments: args }, wait: false, url: true }; open(url, options).catch(() => {}); // Prevent `unhandledRejection` error. return true; } catch { From f82ea0b8838caf4dcfd4de2c214a929127c16c10 Mon Sep 17 00:00:00 2001 From: Yiheng Date: Thu, 9 Apr 2026 23:15:59 +0800 Subject: [PATCH 0020/1317] Refactor options object for open function --- code/core/src/core-server/utils/open-browser/opener.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/code/core/src/core-server/utils/open-browser/opener.ts b/code/core/src/core-server/utils/open-browser/opener.ts index 43f804f27075..b3523aedc0d7 100644 --- a/code/core/src/core-server/utils/open-browser/opener.ts +++ b/code/core/src/core-server/utils/open-browser/opener.ts @@ -189,7 +189,11 @@ function startBrowserProcess( // Fallback to open // (It will always open new tab) try { - const options = { app: { name: browser, arguments: args }, wait: false, url: true }; + const options = { + app: browser ? { name: browser, arguments: args } : undefined, + wait: false, + url: true + }; open(url, options).catch(() => {}); // Prevent `unhandledRejection` error. return true; } catch { From 37a1197efcc9e2e7739ee14380e63d9547dd08b2 Mon Sep 17 00:00:00 2001 From: Yiheng Date: Fri, 10 Apr 2026 01:08:58 +0800 Subject: [PATCH 0021/1317] test: Add incognito mode support for browser opener test --- code/core/src/core-server/utils/open-browser/opener.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/code/core/src/core-server/utils/open-browser/opener.test.ts b/code/core/src/core-server/utils/open-browser/opener.test.ts index b204f0d3b7c0..9cfa2ededf9a 100644 --- a/code/core/src/core-server/utils/open-browser/opener.test.ts +++ b/code/core/src/core-server/utils/open-browser/opener.test.ts @@ -96,13 +96,14 @@ describe('openBrowser BROWSER script handling', () => { it('starts browser process on Linux when BROWSER is not a shell script', () => { process.env.BROWSER = 'google chrome'; + process.env.BROWSER_ARGS = '--incognito'; platformSpy.mockReturnValue('linux'); openBrowser('http://localhost:6006/'); expect(vi.mocked(spawn)).not.toHaveBeenCalled(); expect(vi.mocked(open)).toHaveBeenCalledWith('http://localhost:6006/', { - app: 'google chrome', + app: { name: 'google chrome', arguments: ['--incognito'] }, wait: false, url: true, }); From 37e8acbb60824784d028d9fdabbf90b0668a5e08 Mon Sep 17 00:00:00 2001 From: Lino Le Van <11367844+lino-levan@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:28:20 -0700 Subject: [PATCH 0022/1317] fix: incorrect package json handling --- code/core/src/common/utils/get-storybook-refs.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/code/core/src/common/utils/get-storybook-refs.ts b/code/core/src/common/utils/get-storybook-refs.ts index b12f8aabbd4b..106c44b8e8e7 100644 --- a/code/core/src/common/utils/get-storybook-refs.ts +++ b/code/core/src/common/utils/get-storybook-refs.ts @@ -5,7 +5,6 @@ import { logger } from 'storybook/internal/node-logger'; import type { Options, Ref } from 'storybook/internal/types'; import * as pkg from 'empathic/package'; -import * as resolve from 'empathic/resolve'; import { getProjectRoot } from './paths.ts'; @@ -23,7 +22,7 @@ export const getAutoRefs = async (options: Options): Promise const list = await Promise.all( deps.map(async (d) => { try { - const l = resolve.from(directory, join(d, 'package.json')); + const l = join(directory, 'node_modules', d, 'package.json'); const { storybook, name, version } = JSON.parse(await readFile(l, { encoding: 'utf8' })) || {}; @@ -32,9 +31,7 @@ export const getAutoRefs = async (options: Options): Promise return { id: name, ...storybook, version }; } } catch (error) { - if ((error as any).code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') { - // silent warning because user can't do anything about it - // "package.json" is not part of the package's "exports" field in its package.json + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return undefined; } logger.warn(`unable to find package.json for ${d}`); From 55611eaee3da1600082c7108b7710bd74c034f43 Mon Sep 17 00:00:00 2001 From: zhyd1997 Date: Fri, 10 Apr 2026 11:07:07 +0800 Subject: [PATCH 0023/1317] CLI: Support `peerDependencies` in framework detection for component libraries --- .../src/services/ProjectTypeService.test.ts | 69 +++++++++++++++++++ .../src/services/ProjectTypeService.ts | 10 +-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/code/lib/create-storybook/src/services/ProjectTypeService.test.ts b/code/lib/create-storybook/src/services/ProjectTypeService.test.ts index b9d3e1aa0773..45997fb472ae 100644 --- a/code/lib/create-storybook/src/services/ProjectTypeService.test.ts +++ b/code/lib/create-storybook/src/services/ProjectTypeService.test.ts @@ -189,6 +189,28 @@ describe('ProjectTypeService', () => { expect(result).toBe(ProjectType.REACT_NATIVE); }); + it('detects REACT_NATIVE via peerDependencies', async () => { + (pm as any).primaryPackageJson.packageJson = { + peerDependencies: { react: '^18.0.0', 'react-native': '^0.76.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.REACT_NATIVE); + }); + + it('detects REACT_NATIVE via peerDependencies react-native-scripts', async () => { + (pm as any).primaryPackageJson.packageJson = { + peerDependencies: { react: '^18.0.0', 'react-native-scripts': '^5.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.REACT_NATIVE); + }); + it('detects NUXT via nuxt', async () => { (pm as any).primaryPackageJson.packageJson = { dependencies: { nuxt: '^3.0.0' }, @@ -199,6 +221,53 @@ describe('ProjectTypeService', () => { const result = await service.autoDetectProjectType({ html: false } as CommandOptions); expect(result).toBe(ProjectType.NUXT); }); + + it('detects REACT via peerDependencies', async () => { + (pm as any).primaryPackageJson.packageJson = { + peerDependencies: { react: '^18.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.REACT); + }); + + it('detects REACT via devDependencies', async () => { + (pm as any).primaryPackageJson.packageJson = { + devDependencies: { react: '^18.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.REACT); + }); + + it('prefers REACT_NATIVE over REACT when markers are split across dependency sections', async () => { + (pm as any).primaryPackageJson.packageJson = { + dependencies: { 'react-native': '^0.76.0' }, + peerDependencies: { react: '^18.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.REACT_NATIVE); + }); + + it('does not detect REACT from react-dom alone', async () => { + (pm as any).primaryPackageJson.packageJson = { + peerDependencies: { 'react-dom': '^18.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + + await expect( + service.autoDetectProjectType({ html: false } as CommandOptions) + ).rejects.toThrowError('Storybook failed to detect your project type'); + }); }); describe('validateProvidedType', () => { diff --git a/code/lib/create-storybook/src/services/ProjectTypeService.ts b/code/lib/create-storybook/src/services/ProjectTypeService.ts index 39e2699bcb6d..80a9a2beb40a 100644 --- a/code/lib/create-storybook/src/services/ProjectTypeService.ts +++ b/code/lib/create-storybook/src/services/ProjectTypeService.ts @@ -77,8 +77,9 @@ export class ProjectTypeService { { preset: ProjectType.REACT_NATIVE, dependencies: ['react-native', 'react-native-scripts'], - matcherFunction: ({ dependencies }) => { - return dependencies?.some(Boolean) ?? false; + peerDependencies: ['react-native', 'react-native-scripts'], + matcherFunction: ({ dependencies, peerDependencies }) => { + return (dependencies?.some(Boolean) || peerDependencies?.some(Boolean)) ?? false; }, }, { @@ -139,8 +140,9 @@ export class ProjectTypeService { { preset: ProjectType.REACT, dependencies: ['react'], - matcherFunction: ({ dependencies }) => { - return dependencies?.every(Boolean) ?? true; + peerDependencies: ['react'], + matcherFunction: ({ dependencies, peerDependencies }) => { + return (dependencies?.some(Boolean) || peerDependencies?.some(Boolean)) ?? false; }, }, ]; From c7d163d1a1729928929a176caa8bd0ab83a1a610 Mon Sep 17 00:00:00 2001 From: Bill Collins Date: Wed, 15 Apr 2026 12:34:37 +0100 Subject: [PATCH 0024/1317] Add what should be a failing test for parsing custom stats.json paths --- .../builders/build-storybook/index.spec.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/code/frameworks/angular/src/builders/build-storybook/index.spec.ts b/code/frameworks/angular/src/builders/build-storybook/index.spec.ts index 4a8a37ac9080..854baa3f547d 100644 --- a/code/frameworks/angular/src/builders/build-storybook/index.spec.ts +++ b/code/frameworks/angular/src/builders/build-storybook/index.spec.ts @@ -133,7 +133,7 @@ describe.skip('Build Storybook Builder', () => { ); }); - it('should build storybook with webpack stats.json', async () => { + it('should build storybook with stats.json', async () => { const run = await architect.scheduleBuilder('@storybook/angular:build-storybook', { tsConfig: 'path/to/tsConfig.json', compodoc: false, @@ -162,6 +162,35 @@ describe.skip('Build Storybook Builder', () => { ); }); + it('should build storybook with custom stats.json path', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:build-storybook', { + tsConfig: 'path/to/tsConfig.json', + compodoc: false, + statsJson: './custom-stats.json', + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: null, + angularBuilderContext: expect.any(Object), + configDir: '.storybook', + loglevel: undefined, + quiet: false, + outputDir: 'storybook-static', + packageJson: expect.any(Object), + mode: 'static', + tsConfig: 'path/to/tsConfig.json', + statsJson: './custom-stats.json', + }) + ); + }); + it('should throw error', async () => { buildStaticStandaloneMock.mockRejectedValue(true); From e22712fde9e6a1a0d245d7abf862e64cad638263 Mon Sep 17 00:00:00 2001 From: Bill Collins Date: Wed, 15 Apr 2026 12:35:18 +0100 Subject: [PATCH 0025/1317] Add what I _think_ is a fix --- code/frameworks/angular/build-schema.json | 4 ++-- code/frameworks/angular/start-schema.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/code/frameworks/angular/build-schema.json b/code/frameworks/angular/build-schema.json index aa8da2b6f738..034cd0b42a41 100644 --- a/code/frameworks/angular/build-schema.json +++ b/code/frameworks/angular/build-schema.json @@ -77,12 +77,12 @@ } }, "webpackStatsJson": { - "type": ["boolean", "string"], + "type": ["string", "boolean"], "description": "Write Webpack Stats JSON to disk", "default": false }, "statsJson": { - "type": ["boolean", "string"], + "type": ["string", "boolean"], "description": "Write stats JSON to disk", "default": false }, diff --git a/code/frameworks/angular/start-schema.json b/code/frameworks/angular/start-schema.json index 7b67578ac332..bab2a0e33cb9 100644 --- a/code/frameworks/angular/start-schema.json +++ b/code/frameworks/angular/start-schema.json @@ -132,12 +132,12 @@ "description": "URL path to be appended when visiting Storybook for the first time" }, "webpackStatsJson": { - "type": ["boolean", "string"], + "type": ["string", "boolean"], "description": "Write Webpack Stats JSON to disk", "default": false }, "statsJson": { - "type": ["boolean", "string"], + "type": ["string", "boolean"], "description": "Write stats JSON to disk", "default": false }, From 36dcd514c97a170103947a55b97b836b3006e1b2 Mon Sep 17 00:00:00 2001 From: nitzan-treg Date: Thu, 16 Apr 2026 07:14:34 +0300 Subject: [PATCH 0026/1317] docs: clarify webpack process.env requires variables to be set `added a warning callout to the angular/ember section of the env vars docs explaining that webpack 5 only injects process.env when the variable is actually defined at build time -- if it's not set, you get a "can't find variable: process" runtime error. also added a troubleshooting entry for that specific error with steps to fix it` Signed-off-by: nitzan-treg --- docs/configure/environment-variables.mdx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/configure/environment-variables.mdx b/docs/configure/environment-variables.mdx index 894c5b60e20a..827c1ce5d744 100644 --- a/docs/configure/environment-variables.mdx +++ b/docs/configure/environment-variables.mdx @@ -12,7 +12,7 @@ If you supply an environment variable prefixed with `STORYBOOK_`, it will be ava STORYBOOK_THEME=red STORYBOOK_DATA_KEY=12345 npm run storybook ``` - + Do not store any secrets (e.g., private API keys) or other types of sensitive information in your Storybook. Environment variables are embedded into the build, meaning anyone can view them by inspecting your files. @@ -21,7 +21,13 @@ Then we can access these environment variables anywhere inside our preview JavaS {/* prettier-ignore-start */} - + + + + With Webpack 5, `process.env` is only injected into the browser bundle for environment variables that are **actually defined** at build time. If a `STORYBOOK_*` variable is not set in your environment or `.env` file, referencing `process.env` will throw a `Can't find variable: process` runtime error. + + To avoid this, make sure all `STORYBOOK_*` variables you reference are always defined — either in a `.env` file, on the command line, or by providing defaults in your `.storybook/main.ts` using the [`env` configuration field](#using-storybook-configuration). + {/* prettier-ignore-end */} @@ -112,6 +118,16 @@ The table below lists the available options: ## Troubleshooting +### Can't find variable: process (Webpack) + +When using a Webpack-based framework (e.g., Angular with Webpack 5), you may see a `Can't find variable: process` runtime error if you reference `process.env.STORYBOOK_*` for a variable that is not actually set at build time. Unlike Vite, Webpack only injects `process.env` into the browser bundle when at least one matching environment variable is defined. If the variable is missing, `process` itself is not available in the browser and any reference to it will throw. + +To fix this, make sure all `STORYBOOK_*` variables you reference are always defined. You can: + +- Set them in a `.env` file at the root of your project +- Pass them on the command line when running Storybook +- Provide defaults in your `.storybook/main.ts` using the [`env` configuration field](#using-storybook-configuration) + ### Environment variables are not working If you're trying to use framework-specific environment variables (e.g.,`VUE_APP_`), you may run into issues primarily due to the fact that Storybook and your framework may have specific configurations and may not be able to recognize and use those environment variables. If you run into a similar situation, you may need to adjust your framework configuration to make sure that it can recognize and use those environment variables. For example, if you're working with a Vite-based framework, you can extend the configuration file and enable the [`envPrefix`](https://vitejs.dev/config/shared-options.html#envprefix) option. Other frameworks may require a similar approach. From 006e099efa712a9972bc5e63d62f6c4cee174ff2 Mon Sep 17 00:00:00 2001 From: Nitzan Tregerman <96335151+nitzan-treg@users.noreply.github.com> Date: Thu, 16 Apr 2026 07:22:13 +0300 Subject: [PATCH 0027/1317] fix: replace smart quotes with ASCII quotes in JSX attributes CodeRabbit correctly identified that typographic quotes break MDX parsing. Signed-off-by: nitzan-treg --- docs/configure/environment-variables.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/configure/environment-variables.mdx b/docs/configure/environment-variables.mdx index 827c1ce5d744..1c9266dba594 100644 --- a/docs/configure/environment-variables.mdx +++ b/docs/configure/environment-variables.mdx @@ -5,14 +5,14 @@ sidebar: title: Environment variables --- -You can use environment variables in Storybook to change its behavior in different “modes”. +You can use environment variables in Storybook to change its behavior in different "modes". If you supply an environment variable prefixed with `STORYBOOK_`, it will be available in `process.env` when using Webpack, or `import.meta.env` when using the Vite builder: ```shell STORYBOOK_THEME=red STORYBOOK_DATA_KEY=12345 npm run storybook ``` - + Do not store any secrets (e.g., private API keys) or other types of sensitive information in your Storybook. Environment variables are embedded into the build, meaning anyone can view them by inspecting your files. @@ -21,9 +21,9 @@ Then we can access these environment variables anywhere inside our preview JavaS {/* prettier-ignore-start */} - + - + With Webpack 5, `process.env` is only injected into the browser bundle for environment variables that are **actually defined** at build time. If a `STORYBOOK_*` variable is not set in your environment or `.env` file, referencing `process.env` will throw a `Can't find variable: process` runtime error. To avoid this, make sure all `STORYBOOK_*` variables you reference are always defined — either in a `.env` file, on the command line, or by providing defaults in your `.storybook/main.ts` using the [`env` configuration field](#using-storybook-configuration). From 242839e9b6f9ad7a1e022495f0a6b0e932f38ed4 Mon Sep 17 00:00:00 2001 From: Bill Collins Date: Thu, 16 Apr 2026 08:47:02 +0100 Subject: [PATCH 0028/1317] Fix mocking so tests run --- .../builders/build-storybook/index.spec.ts | 54 +++++++++++-------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/code/frameworks/angular/src/builders/build-storybook/index.spec.ts b/code/frameworks/angular/src/builders/build-storybook/index.spec.ts index 854baa3f547d..2b86247614ac 100644 --- a/code/frameworks/angular/src/builders/build-storybook/index.spec.ts +++ b/code/frameworks/angular/src/builders/build-storybook/index.spec.ts @@ -1,20 +1,22 @@ import { Architect, createBuilder } from '@angular-devkit/architect'; import { TestingArchitectHost } from '@angular-devkit/architect/testing'; import { schema } from '@angular-devkit/core'; -import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildStaticStandalone, +} from 'storybook/internal/core-server'; -const buildDevStandaloneMock = vi.fn(); -const buildStaticStandaloneMock = vi.fn(); +import handler from './index.ts'; +import buildSchema from '../../../build-schema.json' -const buildMock = { - buildDevStandalone: buildDevStandaloneMock, - buildStaticStandalone: buildStaticStandaloneMock, +vi.mock('storybook/internal/core-server', () => ({ + buildDevStandalone: vi.fn(), + buildStaticStandalone: vi.fn(), withTelemetry: (name: string, options: any, fn: any) => fn(), -}; +})); -vi.doMock('storybook/internal/core-server', () => buildMock); -vi.doMock('storybook/internal/common', () => ({ +vi.mock('storybook/internal/common', async (importOriginal) => ({ + ...(await importOriginal()), JsPackageManagerFactory: { getPackageManager: () => ({ runPackageCommand: mockRunScript, @@ -25,12 +27,12 @@ vi.doMock('storybook/internal/common', () => ({ storybook: 'x.x.x', }, })); -vi.doMock('empathic/find', () => ({ up: () => './storybook/tsconfig.ts' })); + +vi.mock('empathic/find', () => ({ up: () => './storybook/tsconfig.ts' })); const mockRunScript = vi.fn(); -// Randomly fails on CI. TODO: investigate why -describe.skip('Build Storybook Builder', () => { +describe('Build Storybook Builder', () => { let architect: Architect; let architectHost: TestingArchitectHost; @@ -62,13 +64,19 @@ describe.skip('Build Storybook Builder', () => { } ); - // This will either take a Node package name, or a path to the directory - // for the package.json file. - await architectHost.addBuilderFromPackage(path.join(__dirname, '../../..')); + // Manually add the builder, as angular uses `require` calls in addBuilderFromPackage which bypass mocking + architectHost.addBuilder( + "@storybook/angular:build-storybook", + handler, + "", + buildSchema, + ); }); beforeEach(() => { - buildStaticStandaloneMock.mockImplementation((_options: unknown) => Promise.resolve(_options)); + vi.mocked(buildStaticStandalone).mockImplementation( + () => Promise.resolve(), + ); }); afterEach(() => { @@ -87,7 +95,7 @@ describe.skip('Build Storybook Builder', () => { expect(output.success).toBeTruthy(); expect(mockRunScript).not.toHaveBeenCalledWith(); - expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect(buildStaticStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: 'angular-cli:build-2', angularBuilderContext: expect.any(Object), @@ -116,7 +124,7 @@ describe.skip('Build Storybook Builder', () => { expect(output.success).toBeTruthy(); expect(mockRunScript).not.toHaveBeenCalledWith(); - expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect(buildStaticStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: null, angularBuilderContext: expect.any(Object), @@ -146,7 +154,7 @@ describe.skip('Build Storybook Builder', () => { expect(output.success).toBeTruthy(); expect(mockRunScript).not.toHaveBeenCalledWith(); - expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect(buildStaticStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: null, angularBuilderContext: expect.any(Object), @@ -175,7 +183,7 @@ describe.skip('Build Storybook Builder', () => { expect(output.success).toBeTruthy(); expect(mockRunScript).not.toHaveBeenCalledWith(); - expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect(buildStaticStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: null, angularBuilderContext: expect.any(Object), @@ -192,7 +200,7 @@ describe.skip('Build Storybook Builder', () => { }); it('should throw error', async () => { - buildStaticStandaloneMock.mockRejectedValue(true); + vi.mocked(buildStaticStandalone).mockRejectedValue(true); const run = await architect.scheduleBuilder('@storybook/angular:build-storybook', { browserTarget: 'angular-cli:build-2', @@ -225,7 +233,7 @@ describe.skip('Build Storybook Builder', () => { ['-p', './storybook/tsconfig.ts', '-d', '.', '-e', 'json'], '' ); - expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect(buildStaticStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: 'angular-cli:build-2', angularBuilderContext: expect.any(Object), @@ -254,7 +262,7 @@ describe.skip('Build Storybook Builder', () => { expect(output.success).toBeTruthy(); expect(mockRunScript).not.toHaveBeenCalledWith(); - expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect(buildStaticStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: null, angularBuilderContext: expect.any(Object), From 31ddb2fb8e59626de5939f27e4da04206bd1a73e Mon Sep 17 00:00:00 2001 From: Bill Collins Date: Thu, 16 Apr 2026 10:00:28 +0100 Subject: [PATCH 0029/1317] Fix remaining tests --- .../builders/build-storybook/index.spec.ts | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/code/frameworks/angular/src/builders/build-storybook/index.spec.ts b/code/frameworks/angular/src/builders/build-storybook/index.spec.ts index 2b86247614ac..bfc26fdfb696 100644 --- a/code/frameworks/angular/src/builders/build-storybook/index.spec.ts +++ b/code/frameworks/angular/src/builders/build-storybook/index.spec.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildStaticStandalone, } from 'storybook/internal/core-server'; +import {JsPackageManagerFactory} from "storybook/internal/common"; import handler from './index.ts'; import buildSchema from '../../../build-schema.json' @@ -15,12 +16,20 @@ vi.mock('storybook/internal/core-server', () => ({ withTelemetry: (name: string, options: any, fn: any) => fn(), })); +vi.mock('storybook/internal/node-logger', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + prompt: { + executeTaskWithSpinner: (fn) => fn() + } + } +}); + vi.mock('storybook/internal/common', async (importOriginal) => ({ ...(await importOriginal()), JsPackageManagerFactory: { - getPackageManager: () => ({ - runPackageCommand: mockRunScript, - }), + getPackageManager: vi.fn(), }, getEnvConfig: (options: any) => options, versions: { @@ -77,6 +86,9 @@ describe('Build Storybook Builder', () => { vi.mocked(buildStaticStandalone).mockImplementation( () => Promise.resolve(), ); + vi.mocked(JsPackageManagerFactory.getPackageManager).mockImplementation(() => ({ + runPackageCommand: mockRunScript, + })) }); afterEach(() => { @@ -207,15 +219,9 @@ describe('Build Storybook Builder', () => { compodoc: false, }); - try { - await run.result; - - expect(false).toEqual('Throw expected'); - } catch (error) { - expect(error).toEqual( - 'Broken build, fix the error above.\nYou may need to refresh the browser.' - ); - } + await expect(run.result).rejects.toThrow( + "Broken build, fix the error above.\nYou may need to refresh the browser.", + ); }); it('should run compodoc', async () => { @@ -229,9 +235,18 @@ describe('Build Storybook Builder', () => { expect(output.success).toBeTruthy(); expect(mockRunScript).toHaveBeenCalledWith( - 'compodoc', - ['-p', './storybook/tsconfig.ts', '-d', '.', '-e', 'json'], - '' + { + args: [ + "compodoc", + "-p", + "./storybook/tsconfig.ts", + "-d", + ".", + "-e", + "json", + ], + cwd: '' + }, ); expect(buildStaticStandalone).toHaveBeenCalledWith( expect.objectContaining({ @@ -266,7 +281,7 @@ describe('Build Storybook Builder', () => { expect.objectContaining({ angularBrowserTarget: null, angularBuilderContext: expect.any(Object), - angularBuilderOptions: { assets: [], styles: ['style.scss'] }, + angularBuilderOptions: expect.objectContaining({ assets: [], styles: ['style.scss'] }), configDir: '.storybook', loglevel: undefined, quiet: false, From 095e1256c42879fc922aafa855ee0b253b6ad8aa Mon Sep 17 00:00:00 2001 From: Bill Collins Date: Thu, 16 Apr 2026 10:01:12 +0100 Subject: [PATCH 0030/1317] fmt --- .../builders/build-storybook/index.spec.ts | 48 ++++++------------- 1 file changed, 15 insertions(+), 33 deletions(-) diff --git a/code/frameworks/angular/src/builders/build-storybook/index.spec.ts b/code/frameworks/angular/src/builders/build-storybook/index.spec.ts index bfc26fdfb696..69df7b3e1477 100644 --- a/code/frameworks/angular/src/builders/build-storybook/index.spec.ts +++ b/code/frameworks/angular/src/builders/build-storybook/index.spec.ts @@ -2,13 +2,11 @@ import { Architect, createBuilder } from '@angular-devkit/architect'; import { TestingArchitectHost } from '@angular-devkit/architect/testing'; import { schema } from '@angular-devkit/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - buildStaticStandalone, -} from 'storybook/internal/core-server'; -import {JsPackageManagerFactory} from "storybook/internal/common"; +import { buildStaticStandalone } from 'storybook/internal/core-server'; +import { JsPackageManagerFactory } from 'storybook/internal/common'; import handler from './index.ts'; -import buildSchema from '../../../build-schema.json' +import buildSchema from '../../../build-schema.json'; vi.mock('storybook/internal/core-server', () => ({ buildDevStandalone: vi.fn(), @@ -21,9 +19,9 @@ vi.mock('storybook/internal/node-logger', async (importOriginal) => { return { ...original, prompt: { - executeTaskWithSpinner: (fn) => fn() - } - } + executeTaskWithSpinner: (fn) => fn(), + }, + }; }); vi.mock('storybook/internal/common', async (importOriginal) => ({ @@ -74,21 +72,15 @@ describe('Build Storybook Builder', () => { ); // Manually add the builder, as angular uses `require` calls in addBuilderFromPackage which bypass mocking - architectHost.addBuilder( - "@storybook/angular:build-storybook", - handler, - "", - buildSchema, - ); + architectHost.addBuilder('@storybook/angular:build-storybook', handler, '', buildSchema); }); beforeEach(() => { - vi.mocked(buildStaticStandalone).mockImplementation( - () => Promise.resolve(), - ); + vi.mocked(buildStaticStandalone).mockImplementation(() => Promise.resolve()); + // @ts-expect-error mocked module vi.mocked(JsPackageManagerFactory.getPackageManager).mockImplementation(() => ({ runPackageCommand: mockRunScript, - })) + })); }); afterEach(() => { @@ -220,7 +212,7 @@ describe('Build Storybook Builder', () => { }); await expect(run.result).rejects.toThrow( - "Broken build, fix the error above.\nYou may need to refresh the browser.", + 'Broken build, fix the error above.\nYou may need to refresh the browser.' ); }); @@ -234,20 +226,10 @@ describe('Build Storybook Builder', () => { await run.stop(); expect(output.success).toBeTruthy(); - expect(mockRunScript).toHaveBeenCalledWith( - { - args: [ - "compodoc", - "-p", - "./storybook/tsconfig.ts", - "-d", - ".", - "-e", - "json", - ], - cwd: '' - }, - ); + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-p', './storybook/tsconfig.ts', '-d', '.', '-e', 'json'], + cwd: '', + }); expect(buildStaticStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: 'angular-cli:build-2', From c57311e584df9669c34fb71c46a0dc7afd1d52ee Mon Sep 17 00:00:00 2001 From: Bill Collins Date: Thu, 16 Apr 2026 10:13:09 +0100 Subject: [PATCH 0031/1317] Fix start-storybook tests too --- .../builders/start-storybook/index.spec.ts | 132 ++++++++++-------- 1 file changed, 72 insertions(+), 60 deletions(-) diff --git a/code/frameworks/angular/src/builders/start-storybook/index.spec.ts b/code/frameworks/angular/src/builders/start-storybook/index.spec.ts index ace692f2758a..0bc10170f64f 100644 --- a/code/frameworks/angular/src/builders/start-storybook/index.spec.ts +++ b/code/frameworks/angular/src/builders/start-storybook/index.spec.ts @@ -1,35 +1,46 @@ import { Architect, createBuilder } from '@angular-devkit/architect'; import { TestingArchitectHost } from '@angular-devkit/architect/testing'; import { schema } from '@angular-devkit/core'; -import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const buildDevStandaloneMock = vi.fn(); -const buildStaticStandaloneMock = vi.fn(); -const buildMock = { - buildDevStandalone: buildDevStandaloneMock, - buildStaticStandalone: buildStaticStandaloneMock, - withTelemetry: (_: string, __: any, fn: any) => fn(), -}; -vi.doMock('storybook/internal/core-server', () => buildMock); -vi.doMock('empathic/find', () => ({ up: () => './storybook/tsconfig.ts' })); +import { buildDevStandalone } from 'storybook/internal/core-server'; -const mockRunScript = vi.fn(); +import handler from './index.ts'; +import startSchema from '../../../start-schema.json'; +import { JsPackageManagerFactory } from 'storybook/internal/common'; + +vi.mock('storybook/internal/core-server', () => ({ + buildDevStandalone: vi.fn(), + buildStaticStandalone: vi.fn(), + withTelemetry: (name: string, options: any, fn: any) => fn(), +})); + +vi.mock('storybook/internal/node-logger', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + prompt: { + executeTaskWithSpinner: (fn) => fn(), + }, + }; +}); -vi.mock('storybook/internal/common', () => ({ +vi.mock('storybook/internal/common', async (importOriginal) => ({ + ...(await importOriginal()), + JsPackageManagerFactory: { + getPackageManager: vi.fn(), + }, getEnvConfig: (options: any) => options, versions: { storybook: 'x.x.x', }, - JsPackageManagerFactory: { - getPackageManager: () => ({ - runPackageCommand: mockRunScript, - }), - }, })); -// Randomly fails on CI. TODO: investigate why -describe.skip('Start Storybook Builder', () => { +vi.mock('empathic/find', () => ({ up: () => './storybook/tsconfig.ts' })); + +const mockRunScript = vi.fn(); + +describe('Start Storybook Builder', () => { let architect: Architect; let architectHost: TestingArchitectHost; @@ -60,13 +71,19 @@ describe.skip('Start Storybook Builder', () => { scripts: [], } ); - // This will either take a Node package name, or a path to the directory - // for the package.json file. - await architectHost.addBuilderFromPackage(join(__dirname, '../../..')); + + // Manually add the builder, as angular uses `require` calls in addBuilderFromPackage which bypass mocking + architectHost.addBuilder('@storybook/angular:start-storybook', handler, '', startSchema); }); beforeEach(() => { - buildDevStandaloneMock.mockImplementation((_options: unknown) => Promise.resolve(_options)); + vi.mocked(buildDevStandalone).mockImplementation(() => + Promise.resolve({ port: 0, address: '', networkAddress: '' }) + ); + // @ts-expect-error mocked module + vi.mocked(JsPackageManagerFactory.getPackageManager).mockImplementation(() => ({ + runPackageCommand: mockRunScript, + })); }); afterEach(() => { @@ -86,7 +103,7 @@ describe.skip('Start Storybook Builder', () => { expect(output.success).toBeTruthy(); expect(mockRunScript).not.toHaveBeenCalledWith(); - expect(buildDevStandaloneMock).toHaveBeenCalledWith( + expect(buildDevStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: 'angular-cli:build-2', angularBuilderContext: expect.any(Object), @@ -120,9 +137,9 @@ describe.skip('Start Storybook Builder', () => { expect(output.success).toBeTruthy(); expect(mockRunScript).not.toHaveBeenCalledWith(); - expect(buildDevStandaloneMock).toHaveBeenCalledWith( + expect(buildDevStandalone).toHaveBeenCalledWith( expect.objectContaining({ - angularBrowserTarget: null, + angularBrowserTarget: undefined, angularBuilderContext: expect.any(Object), ci: false, configDir: '.storybook', @@ -142,7 +159,7 @@ describe.skip('Start Storybook Builder', () => { }); it('should throw error', async () => { - buildDevStandaloneMock.mockRejectedValue(true); + vi.mocked(buildDevStandalone).mockRejectedValue(true); const run = await architect.scheduleBuilder('@storybook/angular:start-storybook', { browserTarget: 'angular-cli:build-2', @@ -150,15 +167,9 @@ describe.skip('Start Storybook Builder', () => { compodoc: false, }); - try { - await run.result; - - expect(false).toEqual('Throw expected'); - } catch (error) { - expect(error).toEqual( - 'Broken build, fix the error above.\nYou may need to refresh the browser.' - ); - } + await expect(run.result).rejects.toThrow( + 'Broken build, fix the error above.\nYou may need to refresh the browser.' + ); }); it('should run compodoc', async () => { @@ -171,12 +182,11 @@ describe.skip('Start Storybook Builder', () => { await run.stop(); expect(output.success).toBeTruthy(); - expect(mockRunScript).toHaveBeenCalledWith( - 'compodoc', - ['-p', './storybook/tsconfig.ts', '-d', '.', '-e', 'json'], - '' - ); - expect(buildDevStandaloneMock).toHaveBeenCalledWith( + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-p', './storybook/tsconfig.ts', '-d', '.', '-e', 'json'], + cwd: '', + }); + expect(buildDevStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: 'angular-cli:build-2', angularBuilderContext: expect.any(Object), @@ -211,23 +221,25 @@ describe.skip('Start Storybook Builder', () => { expect(output.success).toBeTruthy(); expect(mockRunScript).not.toHaveBeenCalledWith(); - expect(buildDevStandaloneMock).toHaveBeenCalledWith({ - angularBrowserTarget: null, - angularBuilderContext: expect.any(Object), - angularBuilderOptions: { assets: [], styles: ['src/styles.css'] }, - disableTelemetry: undefined, - ci: false, - configDir: '.storybook', - host: 'localhost', - https: false, - port: 4400, - packageJson: expect.any(Object), - quiet: false, - smokeTest: false, - sslCa: undefined, - sslCert: undefined, - sslKey: undefined, - tsConfig: 'path/to/tsConfig.json', - }); + expect(buildDevStandalone).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: undefined, + angularBuilderContext: expect.any(Object), + angularBuilderOptions: expect.objectContaining({ assets: [], styles: ['src/styles.css'] }), + disableTelemetry: undefined, + ci: false, + configDir: '.storybook', + host: 'localhost', + https: false, + port: 4400, + packageJson: expect.any(Object), + quiet: false, + smokeTest: false, + sslCa: undefined, + sslCert: undefined, + sslKey: undefined, + tsConfig: 'path/to/tsConfig.json', + }) + ); }); }); From 67659cedc1e304ba7868e795e044b45b3def9509 Mon Sep 17 00:00:00 2001 From: Bill Collins Date: Thu, 16 Apr 2026 10:14:49 +0100 Subject: [PATCH 0032/1317] Improve assertions --- .../angular/src/builders/build-storybook/index.spec.ts | 10 +++++----- .../angular/src/builders/start-storybook/index.spec.ts | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/code/frameworks/angular/src/builders/build-storybook/index.spec.ts b/code/frameworks/angular/src/builders/build-storybook/index.spec.ts index 69df7b3e1477..9b0defbd717a 100644 --- a/code/frameworks/angular/src/builders/build-storybook/index.spec.ts +++ b/code/frameworks/angular/src/builders/build-storybook/index.spec.ts @@ -98,7 +98,7 @@ describe('Build Storybook Builder', () => { await run.stop(); expect(output.success).toBeTruthy(); - expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(mockRunScript).not.toHaveBeenCalled(); expect(buildStaticStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: 'angular-cli:build-2', @@ -127,7 +127,7 @@ describe('Build Storybook Builder', () => { await run.stop(); expect(output.success).toBeTruthy(); - expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(mockRunScript).not.toHaveBeenCalled(); expect(buildStaticStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: null, @@ -157,7 +157,7 @@ describe('Build Storybook Builder', () => { await run.stop(); expect(output.success).toBeTruthy(); - expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(mockRunScript).not.toHaveBeenCalled(); expect(buildStaticStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: null, @@ -186,7 +186,7 @@ describe('Build Storybook Builder', () => { await run.stop(); expect(output.success).toBeTruthy(); - expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(mockRunScript).not.toHaveBeenCalled(); expect(buildStaticStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: null, @@ -258,7 +258,7 @@ describe('Build Storybook Builder', () => { await run.stop(); expect(output.success).toBeTruthy(); - expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(mockRunScript).not.toHaveBeenCalled(); expect(buildStaticStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: null, diff --git a/code/frameworks/angular/src/builders/start-storybook/index.spec.ts b/code/frameworks/angular/src/builders/start-storybook/index.spec.ts index 0bc10170f64f..03d5060982db 100644 --- a/code/frameworks/angular/src/builders/start-storybook/index.spec.ts +++ b/code/frameworks/angular/src/builders/start-storybook/index.spec.ts @@ -102,7 +102,7 @@ describe('Start Storybook Builder', () => { await run.stop(); expect(output.success).toBeTruthy(); - expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(mockRunScript).not.toHaveBeenCalled(); expect(buildDevStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: 'angular-cli:build-2', @@ -136,7 +136,7 @@ describe('Start Storybook Builder', () => { await run.stop(); expect(output.success).toBeTruthy(); - expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(mockRunScript).not.toHaveBeenCalled(); expect(buildDevStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: undefined, @@ -220,7 +220,7 @@ describe('Start Storybook Builder', () => { await run.stop(); expect(output.success).toBeTruthy(); - expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(mockRunScript).not.toHaveBeenCalled(); expect(buildDevStandalone).toHaveBeenCalledWith( expect.objectContaining({ angularBrowserTarget: undefined, From 41cd7e3c3dae713cf0039c9dcf71f564243ad428 Mon Sep 17 00:00:00 2001 From: Ismail Memon Date: Sun, 19 Apr 2026 15:58:48 +0500 Subject: [PATCH 0033/1317] fix: categorize UniversalStore follower timeout error --- code/core/src/manager-errors.ts | 12 ++++++++++++ code/core/src/shared/universal-store/index.test.ts | 5 +++-- code/core/src/shared/universal-store/index.ts | 9 ++------- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/code/core/src/manager-errors.ts b/code/core/src/manager-errors.ts index b33843655ddd..8393b6ea0dcc 100644 --- a/code/core/src/manager-errors.ts +++ b/code/core/src/manager-errors.ts @@ -18,6 +18,7 @@ export enum Category { MANAGER_CORE_EVENTS = 'MANAGER_CORE-EVENTS', MANAGER_ROUTER = 'MANAGER_ROUTER', MANAGER_THEMING = 'MANAGER_THEMING', + MANAGER_UNIVERSAL_STORE = 'MANAGER_UNIVERSAL-STORE', } export class ProviderDoesNotExtendBaseProviderError extends StorybookError { @@ -66,3 +67,14 @@ export class StatusTypeIdMismatchError extends StorybookError { }); } } + +export class UniversalStoreFollowerTimeoutError extends StorybookError { + constructor(followerId: string) { + super({ + name: 'UniversalStoreFollowerTimeoutError', + category: Category.MANAGER_UNIVERSAL_STORE, + code: 1, + message: `No existing state found for follower with id: '${followerId}'. Make sure a leader with the same id exists before creating a follower.`, + }); + } +} \ No newline at end of file diff --git a/code/core/src/shared/universal-store/index.test.ts b/code/core/src/shared/universal-store/index.test.ts index c02c6aa6dbac..3a9ecd44e21d 100644 --- a/code/core/src/shared/universal-store/index.test.ts +++ b/code/core/src/shared/universal-store/index.test.ts @@ -6,6 +6,7 @@ import { UniversalStore } from './index.ts'; import { instances as mockedInstances } from './__mocks__/instances.ts'; import { MockUniversalStore } from './mock.ts'; import type { ChannelEvent } from './types.ts'; +import { UniversalStoreFollowerTimeoutError } from '../../manager-errors.ts'; vi.mock('./instances'); @@ -690,8 +691,8 @@ You should reuse the existing instance instead of trying to create a new one.`); // Assert - eventually the follower.untilReady() promise should throw an error when the timeout is reached vi.advanceTimersToNextTimer(); - await expect(follower.untilReady()).rejects.toThrowErrorMatchingInlineSnapshot( - `[TypeError: No existing state found for follower with id: 'env1:test'. Make sure a leader with the same id exists before creating a follower.]` + await expect(follower.untilReady()).rejects.toBeInstanceOf( + UniversalStoreFollowerTimeoutError ); expect(follower.status).toBe(UniversalStore.Status.ERROR); }); diff --git a/code/core/src/shared/universal-store/index.ts b/code/core/src/shared/universal-store/index.ts index f11ca36bc851..6882903485d0 100644 --- a/code/core/src/shared/universal-store/index.ts +++ b/code/core/src/shared/universal-store/index.ts @@ -16,6 +16,7 @@ import type { StatusType, StoreOptions, } from './types.ts'; +import { UniversalStoreFollowerTimeoutError } from '../../manager-errors.ts'; const CHANNEL_EVENT_PREFIX = 'UNIVERSAL_STORE:' as const; @@ -542,13 +543,7 @@ export class UniversalStore< ); // 2. Wait 1 sec for a response, then reject the syncing promise if not already resolved setTimeout(() => { - // if the state is already resolved by a response before this timeout, - // rejecting it doesn't do anything, it will be ignored - this.syncing!.reject!( - new TypeError( - `No existing state found for follower with id: '${this.id}'. Make sure a leader with the same id exists before creating a follower.` - ) - ); + this.syncing!.reject!(new UniversalStoreFollowerTimeoutError(this.id)); }, 1000); } } From 8d714eeb9324d643ab4673189ca67f37803b0e9f Mon Sep 17 00:00:00 2001 From: Ismail Memon Date: Sun, 19 Apr 2026 17:28:33 +0500 Subject: [PATCH 0034/1317] fix: clarify UniversalStore follower timeout error message --- code/core/src/manager-errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/manager-errors.ts b/code/core/src/manager-errors.ts index 8393b6ea0dcc..fc4295d6089f 100644 --- a/code/core/src/manager-errors.ts +++ b/code/core/src/manager-errors.ts @@ -74,7 +74,7 @@ export class UniversalStoreFollowerTimeoutError extends StorybookError { name: 'UniversalStoreFollowerTimeoutError', category: Category.MANAGER_UNIVERSAL_STORE, code: 1, - message: `No existing state found for follower with id: '${followerId}'. Make sure a leader with the same id exists before creating a follower.`, + message: `Timed out waiting for leader state for UniversalStore follower with id: '${followerId}'. Ensure a leader with the same id exists and is reachable before creating a follower.`, }); } } \ No newline at end of file From ccafd8afa8c0fe4a7d4b41058eb464c71468b55d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 13:31:10 +0000 Subject: [PATCH 0035/1317] feat(nextjs): add useLinkStatus mock to next/link export mock Adds the `useLinkStatus` hook (introduced in Next.js 15.3) to the `@storybook/nextjs/link.mock` module. Without this export, Storybook fails at build and runtime when a component imports `useLinkStatus` from `next/link`. Also exports the `LinkStatus` type to match the Next.js public API. https://claude.ai/code/session_01Hw1UdSNqtAb34tbqFFw14S --- code/frameworks/nextjs/src/export-mocks/link/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/code/frameworks/nextjs/src/export-mocks/link/index.tsx b/code/frameworks/nextjs/src/export-mocks/link/index.tsx index 56ef92af0438..8b6baf1337d3 100644 --- a/code/frameworks/nextjs/src/export-mocks/link/index.tsx +++ b/code/frameworks/nextjs/src/export-mocks/link/index.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { fn } from 'storybook/test'; +export type LinkStatus = { pending: boolean }; + const linkAction = fn().mockName('next/link::Link'); const MockLink = React.forwardRef(function MockLink( @@ -64,3 +66,7 @@ MockLink.displayName = 'NextLink'; export default MockLink; export { MockLink as Link }; + +export const useLinkStatus = fn((): LinkStatus => ({ pending: false })).mockName( + 'next/link::useLinkStatus' +); From 71f696998fa7542e4b315450d56523a0d36a473f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 13:32:51 +0000 Subject: [PATCH 0036/1317] chore(docs): auto-format eslint-plugin.mdx quote style Formatter normalised single quotes to double quotes in code blocks. https://claude.ai/code/session_01Hw1UdSNqtAb34tbqFFw14S --- docs/configure/integration/eslint-plugin.mdx | 30 ++++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/configure/integration/eslint-plugin.mdx b/docs/configure/integration/eslint-plugin.mdx index 1e20b0ce2714..9990fe924ecc 100644 --- a/docs/configure/integration/eslint-plugin.mdx +++ b/docs/configure/integration/eslint-plugin.mdx @@ -39,10 +39,10 @@ For more details on why this line is required in the `.eslintignore` file, refer If you are using [flat config style](https://eslint.org/docs/latest/use/configure/configuration-files-new), add this to your configuration file: ```js title="eslint.config.js" -import { defineConfig, globalIgnores } from 'eslint/config'; +import { defineConfig, globalIgnores } from "eslint/config"; export default defineConfig([ - globalIgnores(['!.storybook'], 'Include Storybook Directory'), + globalIgnores(["!.storybook"], "Include Storybook Directory"), // ... ]); ``` @@ -91,12 +91,12 @@ Optionally, you can override, add to, or disable individual rules. You likely do Use the `eslint.config.js` file to configure rules using the [flat config style](https://eslint.org/docs/latest/use/configure/configuration-files-new). This is the default in ESLint v9, but can be used starting from ESLint v8.57.0. [ESLint docs](https://eslint.org/docs/latest/use/configure/configuration-files-new). ```js title="eslint.config.js" -import storybook from 'eslint-plugin-storybook'; +import storybook from "eslint-plugin-storybook"; // Replace the eslint/config package with @eslint/config-helpers if you're using an older version of ESLint. -import { defineConfig } from 'eslint/config'; +import { defineConfig } from "eslint/config"; export default defineConfig([ - ...storybook.configs['flat/recommended'], + ...storybook.configs["flat/recommended"], // Add more configuration options and generic rulesets here, such as js.configs.recommended ]); ``` @@ -104,13 +104,13 @@ export default defineConfig([ In case you are using utility functions from tools like `tseslint`, you might need to register the plugin a little differently: ```ts title="eslint.config.ts" -import storybook from 'eslint-plugin-storybook'; -import somePlugin from 'some-plugin'; -import tseslint from 'typescript-eslint'; +import storybook from "eslint-plugin-storybook"; +import somePlugin from "some-plugin"; +import tseslint from "typescript-eslint"; export default tseslint.config( somePlugin, - storybook.configs['flat/recommended'], // notice that it is not destructured + storybook.configs["flat/recommended"], // notice that it is not destructured ); ``` @@ -119,19 +119,19 @@ export default tseslint.config( Optionally, you can override, add, or disable individual rules. You likely don't want these settings to be applied to every file, so ensure that you add a flat config section in your `eslint.config.js` file that applies the overrides only to your story files. ```js title="eslint.config.js" -import storybook from 'eslint-plugin-storybook'; -import { defineConfig } from 'eslint/config'; +import storybook from "eslint-plugin-storybook"; +import { defineConfig } from "eslint/config"; export default defineConfig([ - ...storybook.configs['flat/recommended'], + ...storybook.configs["flat/recommended"], { // 👇 This should match the `stories` property in .storybook/main.js|ts - files: ['**/*.stories.@(ts|tsx|js|jsx|mjs|cjs)'], + files: ["**/*.stories.@(ts|tsx|js|jsx|mjs|cjs)"], rules: { // 👇 Enable this rule - 'storybook/csf-component': 'error', + "storybook/csf-component": "error", // 👇 Disable this rule - 'storybook/default-exports': 'off', + "storybook/default-exports": "off", }, }, ]); From b9569c8282cefc57a065c68e98d97b9288375f30 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 13:42:28 +0000 Subject: [PATCH 0037/1317] test(nextjs): add useLinkStatus stories to verify mock behaviour Adds two template stories to Link.stories.tsx: - DefaultLinkStatus: asserts the mock returns { pending: false } by default - PendingLinkStatus: demonstrates overriding via beforeEach + mockReturnValue Follows the same pattern used in Navigation.stories.tsx and ServerActions.stories.tsx. https://claude.ai/code/session_01Hw1UdSNqtAb34tbqFFw14S --- .../nextjs/template/stories/Link.stories.tsx | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/code/frameworks/nextjs/template/stories/Link.stories.tsx b/code/frameworks/nextjs/template/stories/Link.stories.tsx index 23fd174dd010..17c8203980e6 100644 --- a/code/frameworks/nextjs/template/stories/Link.stories.tsx +++ b/code/frameworks/nextjs/template/stories/Link.stories.tsx @@ -1,8 +1,10 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/nextjs'; +import { useLinkStatus as mockUseLinkStatus } from '@storybook/nextjs/link.mock'; -import Link from 'next/link'; +import Link, { useLinkStatus } from 'next/link'; +import { expect, within } from 'storybook/test'; import style from './Link.stories.module.css'; @@ -77,3 +79,33 @@ export const InAppDir: Story = { }, }, }; + +function LinkStatusComponent() { + const { pending } = useLinkStatus(); + return
{pending ? 'Pending' : 'Idle'}
; +} + +export const DefaultLinkStatus: Story = { + render: () => , + parameters: { + nextjs: { appDirectory: true }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Idle')).toBeInTheDocument(); + }, +}; + +export const PendingLinkStatus: Story = { + render: () => , + parameters: { + nextjs: { appDirectory: true }, + }, + beforeEach() { + mockUseLinkStatus.mockReturnValue({ pending: true }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Pending')).toBeInTheDocument(); + }, +}; From bce2e6984fe0f0906cf59c2f988754a304f727b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 13:46:56 +0000 Subject: [PATCH 0038/1317] revert(docs): restore eslint-plugin.mdx to next branch version Undoes auto-formatter quote changes that were inadvertently committed. https://claude.ai/code/session_01Hw1UdSNqtAb34tbqFFw14S --- docs/configure/integration/eslint-plugin.mdx | 30 ++++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/configure/integration/eslint-plugin.mdx b/docs/configure/integration/eslint-plugin.mdx index 9990fe924ecc..1e20b0ce2714 100644 --- a/docs/configure/integration/eslint-plugin.mdx +++ b/docs/configure/integration/eslint-plugin.mdx @@ -39,10 +39,10 @@ For more details on why this line is required in the `.eslintignore` file, refer If you are using [flat config style](https://eslint.org/docs/latest/use/configure/configuration-files-new), add this to your configuration file: ```js title="eslint.config.js" -import { defineConfig, globalIgnores } from "eslint/config"; +import { defineConfig, globalIgnores } from 'eslint/config'; export default defineConfig([ - globalIgnores(["!.storybook"], "Include Storybook Directory"), + globalIgnores(['!.storybook'], 'Include Storybook Directory'), // ... ]); ``` @@ -91,12 +91,12 @@ Optionally, you can override, add to, or disable individual rules. You likely do Use the `eslint.config.js` file to configure rules using the [flat config style](https://eslint.org/docs/latest/use/configure/configuration-files-new). This is the default in ESLint v9, but can be used starting from ESLint v8.57.0. [ESLint docs](https://eslint.org/docs/latest/use/configure/configuration-files-new). ```js title="eslint.config.js" -import storybook from "eslint-plugin-storybook"; +import storybook from 'eslint-plugin-storybook'; // Replace the eslint/config package with @eslint/config-helpers if you're using an older version of ESLint. -import { defineConfig } from "eslint/config"; +import { defineConfig } from 'eslint/config'; export default defineConfig([ - ...storybook.configs["flat/recommended"], + ...storybook.configs['flat/recommended'], // Add more configuration options and generic rulesets here, such as js.configs.recommended ]); ``` @@ -104,13 +104,13 @@ export default defineConfig([ In case you are using utility functions from tools like `tseslint`, you might need to register the plugin a little differently: ```ts title="eslint.config.ts" -import storybook from "eslint-plugin-storybook"; -import somePlugin from "some-plugin"; -import tseslint from "typescript-eslint"; +import storybook from 'eslint-plugin-storybook'; +import somePlugin from 'some-plugin'; +import tseslint from 'typescript-eslint'; export default tseslint.config( somePlugin, - storybook.configs["flat/recommended"], // notice that it is not destructured + storybook.configs['flat/recommended'], // notice that it is not destructured ); ``` @@ -119,19 +119,19 @@ export default tseslint.config( Optionally, you can override, add, or disable individual rules. You likely don't want these settings to be applied to every file, so ensure that you add a flat config section in your `eslint.config.js` file that applies the overrides only to your story files. ```js title="eslint.config.js" -import storybook from "eslint-plugin-storybook"; -import { defineConfig } from "eslint/config"; +import storybook from 'eslint-plugin-storybook'; +import { defineConfig } from 'eslint/config'; export default defineConfig([ - ...storybook.configs["flat/recommended"], + ...storybook.configs['flat/recommended'], { // 👇 This should match the `stories` property in .storybook/main.js|ts - files: ["**/*.stories.@(ts|tsx|js|jsx|mjs|cjs)"], + files: ['**/*.stories.@(ts|tsx|js|jsx|mjs|cjs)'], rules: { // 👇 Enable this rule - "storybook/csf-component": "error", + 'storybook/csf-component': 'error', // 👇 Disable this rule - "storybook/default-exports": "off", + 'storybook/default-exports': 'off', }, }, ]); From 3ada6df028122a657a0e18ec6782b13719b70c98 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 12:35:17 +0000 Subject: [PATCH 0039/1317] refactor(nextjs): remove manual LinkStatus type export from link mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No other mock manually exports types — they rely on re-exporting from the real Next.js dist modules. Keeping this consistent; the inline return type on useLinkStatus is sufficient for type inference. https://claude.ai/code/session_01Hw1UdSNqtAb34tbqFFw14S --- code/frameworks/nextjs/src/export-mocks/link/index.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/code/frameworks/nextjs/src/export-mocks/link/index.tsx b/code/frameworks/nextjs/src/export-mocks/link/index.tsx index 8b6baf1337d3..0b0cfef2760d 100644 --- a/code/frameworks/nextjs/src/export-mocks/link/index.tsx +++ b/code/frameworks/nextjs/src/export-mocks/link/index.tsx @@ -2,8 +2,6 @@ import React from 'react'; import { fn } from 'storybook/test'; -export type LinkStatus = { pending: boolean }; - const linkAction = fn().mockName('next/link::Link'); const MockLink = React.forwardRef(function MockLink( @@ -67,6 +65,6 @@ MockLink.displayName = 'NextLink'; export default MockLink; export { MockLink as Link }; -export const useLinkStatus = fn((): LinkStatus => ({ pending: false })).mockName( +export const useLinkStatus = fn((): { pending: boolean } => ({ pending: false })).mockName( 'next/link::useLinkStatus' ); From c6abd293063aa54532fa044b49932103f8695707 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Sat, 25 Apr 2026 00:53:36 +0200 Subject: [PATCH 0040/1317] Add optional TypeScript peer to react-vite --- code/frameworks/react-vite/package.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/code/frameworks/react-vite/package.json b/code/frameworks/react-vite/package.json index 117b87362e32..949902c89529 100644 --- a/code/frameworks/react-vite/package.json +++ b/code/frameworks/react-vite/package.json @@ -71,8 +71,14 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "workspace:^", + "typescript": ">=4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, "publishConfig": { "access": "public" }, From 8d688dfc81cacc4034332aa4c60500f90ed7ef8c Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Mon, 27 Apr 2026 22:26:51 +0200 Subject: [PATCH 0041/1317] Update docs to include get-changed-stories tool --- docs/ai/mcp/api.mdx | 2 +- docs/ai/mcp/overview.mdx | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/ai/mcp/api.mdx b/docs/ai/mcp/api.mdx index dc94a2efaf0e..7dc1567324c3 100644 --- a/docs/ai/mcp/api.mdx +++ b/docs/ai/mcp/api.mdx @@ -51,7 +51,7 @@ Type: `boolean` Default: `true` -The development toolset includes the [`get-storybook-story-instructions`](./overview.mdx#get-storybook-story-instructions) and [`preview-stories`](./overview.mdx#preview-stories) tools. +The development toolset includes the [`get-storybook-story-instructions`](./overview.mdx#get-storybook-story-instructions), [`get-changed-stories`](./overview.mdx#get-changed-stories) and [`preview-stories`](./overview.mdx#preview-stories) tools. #### `docs` diff --git a/docs/ai/mcp/overview.mdx b/docs/ai/mcp/overview.mdx index f487fc94e7d4..4ead7b9428bc 100644 --- a/docs/ai/mcp/overview.mdx +++ b/docs/ai/mcp/overview.mdx @@ -120,6 +120,10 @@ The development toolset includes the following tools: Returns instructions on how to write useful stories, including which props to capture and how to write interaction tests for the story (if applicable). +#### `get-changed-stories` + +Returns a list of stories that are changed or possibly affected by local file changes (requires change detection to be enabled). + #### `preview-stories` Returns story previews rendered in the agent's chat interface, if the agent supports [MCP Apps](https://modelcontextprotocol.io/extensions/apps/overview#client-support). Otherwise, it returns links to relevant stories in Storybook. From 69da71e638405887706626d48978b09717aaf769 Mon Sep 17 00:00:00 2001 From: cyphercodes Date: Wed, 29 Apr 2026 02:14:22 +0300 Subject: [PATCH 0042/1317] fix: preserve disabled a11y rules with runOnly --- code/addons/a11y/src/a11yRunner.test.ts | 108 +++++++++++++++++++++++- code/addons/a11y/src/a11yRunner.ts | 44 +++++++++- 2 files changed, 148 insertions(+), 4 deletions(-) diff --git a/code/addons/a11y/src/a11yRunner.test.ts b/code/addons/a11y/src/a11yRunner.test.ts index 9018e7417c6e..3edeaa1977e7 100644 --- a/code/addons/a11y/src/a11yRunner.test.ts +++ b/code/addons/a11y/src/a11yRunner.test.ts @@ -1,3 +1,4 @@ +import type { AxeResults } from 'axe-core'; import type { Mock } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -5,17 +6,57 @@ import { addons } from 'storybook/preview-api'; import { EVENTS } from './constants.ts'; +const { axeMock, documentMock } = vi.hoisted(() => { + const documentMock = { + body: {}, + getElementById: vi.fn(), + location: { pathname: '/iframe.html' }, + }; + + return { + documentMock, + axeMock: { + reset: vi.fn(), + configure: vi.fn(), + run: vi.fn(), + }, + }; +}); + +vi.mock('@storybook/global', () => ({ + global: { + document: documentMock, + }, +})); + +vi.mock('axe-core', () => ({ + default: axeMock, +})); + vi.mock('storybook/preview-api'); const mockedAddons = vi.mocked(addons); +const axeResults = { + violations: [], + passes: [], + incomplete: [], + inapplicable: [], +} as Partial as AxeResults; + describe('a11yRunner', () => { let mockChannel: { on: Mock; emit?: Mock }; beforeEach(() => { - mockedAddons.getChannel.mockReset(); + vi.resetModules(); + vi.clearAllMocks(); + + documentMock.getElementById.mockReturnValue(null); + axeMock.run.mockResolvedValue(axeResults); mockChannel = { on: vi.fn(), emit: vi.fn() }; - mockedAddons.getChannel.mockReturnValue(mockChannel as any); + mockedAddons.getChannel.mockReturnValue( + mockChannel as unknown as ReturnType + ); }); it('should listen to events', async () => { @@ -24,4 +65,67 @@ describe('a11yRunner', () => { expect(mockedAddons.getChannel).toHaveBeenCalled(); expect(mockChannel.on).toHaveBeenCalledWith(EVENTS.MANUAL, expect.any(Function)); }); + + it('passes disabled configured rules to axe.run when runOnly is present', async () => { + const { run } = await import('./a11yRunner.ts'); + const input = { + config: { + rules: [ + { id: 'target-size', enabled: false }, + { id: 'color-contrast', enabled: true }, + ], + }, + options: { + runOnly: ['wcag2a'], + rules: { + 'button-name': { enabled: false }, + }, + }, + }; + + await run(input, 'example-story'); + + expect(axeMock.configure).toHaveBeenCalledWith({ + rules: [ + { id: 'region', enabled: false }, + { id: 'target-size', enabled: false }, + { id: 'color-contrast', enabled: true }, + ], + }); + expect(axeMock.run).toHaveBeenCalledWith(expect.any(Object), { + runOnly: ['wcag2a'], + rules: { + region: { enabled: false }, + 'target-size': { enabled: false }, + 'button-name': { enabled: false }, + }, + }); + expect(axeMock.run.mock.calls[0][1]).not.toBe(input.options); + expect(input.options).toEqual({ + runOnly: ['wcag2a'], + rules: { + 'button-name': { enabled: false }, + }, + }); + }); + + it('respects configured rule overrides when collecting disabled rules', async () => { + const { run } = await import('./a11yRunner.ts'); + + await run( + { + config: { + rules: [{ id: 'region', enabled: true }], + }, + options: { + runOnly: ['wcag2a'], + }, + }, + 'example-story' + ); + + expect(axeMock.run).toHaveBeenCalledWith(expect.any(Object), { + runOnly: ['wcag2a'], + }); + }); }); diff --git a/code/addons/a11y/src/a11yRunner.ts b/code/addons/a11y/src/a11yRunner.ts index 00fb4540f8a7..fa44ba1b4101 100644 --- a/code/addons/a11y/src/a11yRunner.ts +++ b/code/addons/a11y/src/a11yRunner.ts @@ -2,7 +2,7 @@ import { ElementA11yParameterError } from 'storybook/internal/preview-errors'; import { global } from '@storybook/global'; -import type { AxeResults, ContextProp, ContextSpec } from 'axe-core'; +import type { AxeResults, ContextProp, ContextSpec, RunOptions, Spec } from 'axe-core'; import { addons, waitForAnimations } from 'storybook/preview-api'; import { withLinkPaths } from './a11yRunnerUtils.ts'; @@ -21,6 +21,44 @@ const DISABLED_RULES = [ 'region', ] as const; +const getDisabledRules = (rules: Spec['rules'] = []) => { + const ruleStates = new Map(); + + rules.forEach(({ id, enabled }) => { + if (id && typeof enabled === 'boolean') { + ruleStates.set(id, enabled); + } + }); + + return Object.fromEntries( + Array.from(ruleStates.entries()) + .filter(([, enabled]) => !enabled) + .map(([id]) => [id, { enabled: false }]) + ) as NonNullable; +}; + +const mergeDisabledRulesIntoRunOptions = (options: RunOptions, config: Spec): RunOptions => { + // axe.run({ runOnly }) can re-enable tagged rules, so mirror configured disables into + // the same run options object without mutating the user's parameters. + if (!options.runOnly) { + return options; + } + + const disabledRules = getDisabledRules(config.rules); + + if (Object.keys(disabledRules).length === 0) { + return options; + } + + return { + ...options, + rules: { + ...disabledRules, + ...options.rules, + }, + }; +}; + // A simple queue to run axe-core in sequence // This is necessary because axe-core is not designed to run in parallel const queue: (() => Promise)[] = []; @@ -92,6 +130,8 @@ export const run = async (input: A11yParameters = DEFAULT_PARAMETERS, storyId: s axe.configure(configWithDefault); + const optionsWithDisabledRules = mergeDisabledRulesIntoRunOptions(options, configWithDefault); + return new Promise((resolve, reject) => { const highlightsRoot = document?.getElementById('storybook-highlights-root'); if (highlightsRoot) { @@ -100,7 +140,7 @@ export const run = async (input: A11yParameters = DEFAULT_PARAMETERS, storyId: s const task = async () => { try { - const result = await axe.run(context, options); + const result = await axe.run(context, optionsWithDisabledRules); const resultWithLinks = withLinkPaths(result, storyId); resolve(resultWithLinks); } catch (error) { From 03dfc4eab717f053edd02709a5649b98e6304786 Mon Sep 17 00:00:00 2001 From: Kyle Gach Date: Wed, 29 Apr 2026 11:56:40 -0600 Subject: [PATCH 0043/1317] Tweaks --- docs/ai/mcp/api.mdx | 2 +- docs/ai/mcp/overview.mdx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/ai/mcp/api.mdx b/docs/ai/mcp/api.mdx index 7dc1567324c3..5f8b8a9d4f95 100644 --- a/docs/ai/mcp/api.mdx +++ b/docs/ai/mcp/api.mdx @@ -51,7 +51,7 @@ Type: `boolean` Default: `true` -The development toolset includes the [`get-storybook-story-instructions`](./overview.mdx#get-storybook-story-instructions), [`get-changed-stories`](./overview.mdx#get-changed-stories) and [`preview-stories`](./overview.mdx#preview-stories) tools. +The development toolset includes the [`get-changed-stories`](./overview.mdx#get-changed-stories), [`get-storybook-story-instructions`](./overview.mdx#get-storybook-story-instructions), and [`preview-stories`](./overview.mdx#preview-stories) tools. #### `docs` diff --git a/docs/ai/mcp/overview.mdx b/docs/ai/mcp/overview.mdx index 4ead7b9428bc..29b619009af8 100644 --- a/docs/ai/mcp/overview.mdx +++ b/docs/ai/mcp/overview.mdx @@ -116,13 +116,13 @@ The development toolset provides tools for authoring well-considered stories (in The development toolset includes the following tools: -#### `get-storybook-story-instructions` +#### `get-changed-stories` -Returns instructions on how to write useful stories, including which props to capture and how to write interaction tests for the story (if applicable). +Returns a list of stories that are changed or possibly affected by local file changes (requires [change detection](../../configure/user-interface/change-detection.mdx) to be enabled). -#### `get-changed-stories` +#### `get-storybook-story-instructions` -Returns a list of stories that are changed or possibly affected by local file changes (requires change detection to be enabled). +Returns instructions on how to write useful stories, including which props to capture and how to write interaction tests for the story (if applicable). #### `preview-stories` From 3b109d08d8ad16583e24d3744b20392b76b5d4ff Mon Sep 17 00:00:00 2001 From: Rohit Sharma Date: Thu, 30 Apr 2026 01:38:40 -0700 Subject: [PATCH 0044/1317] fix(docs): guard normalizeOptions against array labels and prototype lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Svelte's docgen incorrectly emits an array as the `labels` prop, `labels['reverse']` resolves to `Array.prototype.reverse` via the prototype chain, causing the control to display `function reverse() { [native code] }` instead of the option value. Add an `Array.isArray` guard and use `Object.prototype.hasOwnProperty.call` so only own-property matches on a plain-object labels map are used; everything else falls back to `String(item)`. Fixes #30142 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude Sonnet 4.6 --- .../blocks/controls/options/Options.test.ts | 62 +++++++++++++++++++ .../src/blocks/controls/options/Options.tsx | 10 ++- 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 code/addons/docs/src/blocks/controls/options/Options.test.ts diff --git a/code/addons/docs/src/blocks/controls/options/Options.test.ts b/code/addons/docs/src/blocks/controls/options/Options.test.ts new file mode 100644 index 000000000000..3f728314b136 --- /dev/null +++ b/code/addons/docs/src/blocks/controls/options/Options.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +// Access the private normalizeOptions via a test-only re-export workaround: +// We test the behaviour through its observable effects by reconstructing the logic inline. +// The actual fix is in Options.tsx; these tests document the bug and verify the fix. + +// Inline the fixed normalizeOptions so the tests are self-contained and don't require +// exporting an internal helper. +const normalizeOptions = (options: any[], labels?: Record) => { + if (Array.isArray(options)) { + return options.reduce((acc: Record, item: any) => { + const label = + labels != null && + !Array.isArray(labels) && + Object.prototype.hasOwnProperty.call(labels, item) + ? labels[item] + : String(item); + acc[label] = item; + return acc; + }, {}); + } + return options; +}; + +describe('normalizeOptions', () => { + it('uses String(item) as label when no labels map is provided', () => { + expect(normalizeOptions(['a', 'b', 'c'])).toEqual({ a: 'a', b: 'b', c: 'c' }); + }); + + it('uses labels map when provided with matching keys', () => { + const labels = { a: 'Option A', b: 'Option B' }; + expect(normalizeOptions(['a', 'b'], labels)).toEqual({ + 'Option A': 'a', + 'Option B': 'b', + }); + }); + + it('falls back to String(item) for items missing from labels map', () => { + const labels = { a: 'Option A' }; + expect(normalizeOptions(['a', 'b'], labels)).toEqual({ + 'Option A': 'a', + b: 'b', + }); + }); + + it('does not resolve Array.prototype methods when labels is an array (issue #30142)', () => { + // When Svelte docgen incorrectly passes an array as `labels`, items whose name + // matches an Array prototype method (e.g. 'reverse') must NOT resolve to the + // native function — they should fall back to String(item). + const labelsAsArray: any = ['first', 'second', 'third']; + const options = ['reverse', 'map', 'filter']; + const result = normalizeOptions(options, labelsAsArray); + // Each key must be the plain string value, not a native function + expect(result).toEqual({ reverse: 'reverse', map: 'map', filter: 'filter' }); + expect(Object.keys(result)).not.toContain('function reverse() { [native code] }'); + }); + + it('returns the options object unchanged when options is not an array', () => { + const obj = { 'Option A': 'a', 'Option B': 'b' }; + expect(normalizeOptions(obj as any)).toBe(obj); + }); +}); diff --git a/code/addons/docs/src/blocks/controls/options/Options.tsx b/code/addons/docs/src/blocks/controls/options/Options.tsx index cb0eb2ce79dc..d2741603fb8c 100644 --- a/code/addons/docs/src/blocks/controls/options/Options.tsx +++ b/code/addons/docs/src/blocks/controls/options/Options.tsx @@ -21,7 +21,15 @@ import { SelectControl } from './Select'; const normalizeOptions = (options: Options, labels?: Record) => { if (Array.isArray(options)) { return options.reduce((acc, item) => { - acc[labels?.[item] || String(item)] = item; + // Guard against `labels` being an array (e.g. from Svelte docgen) and against + // prototype-chain lookups (e.g. 'reverse' resolving to Array.prototype.reverse). + const label = + labels != null && + !Array.isArray(labels) && + Object.prototype.hasOwnProperty.call(labels, item) + ? labels[item] + : String(item); + acc[label] = item; return acc; }, {}); } From 0b34853189c8dfc36fd077db2101dc2e3e8f362e Mon Sep 17 00:00:00 2001 From: Rohit Sharma Date: Thu, 30 Apr 2026 09:58:17 -0700 Subject: [PATCH 0045/1317] fix(docs): export normalizeOptions and test real implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export normalizeOptions from Options.tsx so tests exercise the production function directly rather than a reimplemented copy. 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude Sonnet 4.6 --- .../blocks/controls/options/Options.test.ts | 27 +++---------------- .../src/blocks/controls/options/Options.tsx | 2 +- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/code/addons/docs/src/blocks/controls/options/Options.test.ts b/code/addons/docs/src/blocks/controls/options/Options.test.ts index 3f728314b136..e30f4efeb9cc 100644 --- a/code/addons/docs/src/blocks/controls/options/Options.test.ts +++ b/code/addons/docs/src/blocks/controls/options/Options.test.ts @@ -1,26 +1,6 @@ import { describe, expect, it } from 'vitest'; -// Access the private normalizeOptions via a test-only re-export workaround: -// We test the behaviour through its observable effects by reconstructing the logic inline. -// The actual fix is in Options.tsx; these tests document the bug and verify the fix. - -// Inline the fixed normalizeOptions so the tests are self-contained and don't require -// exporting an internal helper. -const normalizeOptions = (options: any[], labels?: Record) => { - if (Array.isArray(options)) { - return options.reduce((acc: Record, item: any) => { - const label = - labels != null && - !Array.isArray(labels) && - Object.prototype.hasOwnProperty.call(labels, item) - ? labels[item] - : String(item); - acc[label] = item; - return acc; - }, {}); - } - return options; -}; +import { normalizeOptions } from './Options'; describe('normalizeOptions', () => { it('uses String(item) as label when no labels map is provided', () => { @@ -50,9 +30,10 @@ describe('normalizeOptions', () => { const labelsAsArray: any = ['first', 'second', 'third']; const options = ['reverse', 'map', 'filter']; const result = normalizeOptions(options, labelsAsArray); - // Each key must be the plain string value, not a native function expect(result).toEqual({ reverse: 'reverse', map: 'map', filter: 'filter' }); - expect(Object.keys(result)).not.toContain('function reverse() { [native code] }'); + expect(Object.keys(result as object)).not.toContain( + 'function reverse() { [native code] }' + ); }); it('returns the options object unchanged when options is not an array', () => { diff --git a/code/addons/docs/src/blocks/controls/options/Options.tsx b/code/addons/docs/src/blocks/controls/options/Options.tsx index d2741603fb8c..da4f680ae255 100644 --- a/code/addons/docs/src/blocks/controls/options/Options.tsx +++ b/code/addons/docs/src/blocks/controls/options/Options.tsx @@ -18,7 +18,7 @@ import { SelectControl } from './Select'; * While non-primitive values are deprecated, they might still not be valid object keys, so the * resulting object is a Label -> Value mapping. */ -const normalizeOptions = (options: Options, labels?: Record) => { +export const normalizeOptions = (options: Options, labels?: Record) => { if (Array.isArray(options)) { return options.reduce((acc, item) => { // Guard against `labels` being an array (e.g. from Svelte docgen) and against From e41be8c2132b0a7811c3490641f2cf65605ef642 Mon Sep 17 00:00:00 2001 From: Yiheng Date: Fri, 1 May 2026 04:36:55 +0800 Subject: [PATCH 0046/1317] Update code/core/src/core-server/utils/open-browser/opener.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- code/core/src/core-server/utils/open-browser/opener.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/core-server/utils/open-browser/opener.ts b/code/core/src/core-server/utils/open-browser/opener.ts index b3523aedc0d7..6f2c5ee500fc 100644 --- a/code/core/src/core-server/utils/open-browser/opener.ts +++ b/code/core/src/core-server/utils/open-browser/opener.ts @@ -192,7 +192,7 @@ function startBrowserProcess( const options = { app: browser ? { name: browser, arguments: args } : undefined, wait: false, - url: true + url: true, }; open(url, options).catch(() => {}); // Prevent `unhandledRejection` error. return true; From 72e68930c1d987838f11d81d7115d0c8c24cc6ba Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Sun, 3 May 2026 20:57:23 +0200 Subject: [PATCH 0047/1317] maintenance: replace `resolve` and `resolve.exports` with `oxc-resolver` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that `oxc-resolver` is a runtime dependency of `code/core` (added in the change-detection PR), the two remaining call sites of the legacy `resolve` and `resolve.exports` packages can be migrated: - `common/utils/interpret-files.ts`: `resolve.sync` + `packageFilter` (prefer `module` over `main`) → `ResolverFactory.sync` with `mainFields: ['module', 'main']`. - `mocking-utils/resolve.ts`: `resolve.exports({ browser: true })` on a manually-fetched `package.json` → `ResolverFactory.sync` with `conditionNames: ['browser', 'import', 'module', 'default']`, `mainFields: ['browser', 'module', 'main']`, and `aliasFields: [['browser']]`. The `require.resolve` fallback for legacy package layouts is preserved. `resolve` and `resolve.exports` are removed from `code/core`'s dependencies. The public `resolveImport(id, options)` signature changes from `resolve.SyncOpts` to `{ basedir: string }`. Both internal callers (`StoryIndexGenerator.resolveComponentPath`, `cachedResolveImport` in the React renderer's component manifest) only pass `basedir`, so this is a safe narrowing. Co-Authored-By: Claude Sonnet 4.6 --- code/core/package.json | 2 - code/core/src/common/utils/interpret-files.ts | 40 ++++++++----- code/core/src/mocking-utils/resolve.ts | 58 +++++++------------ yarn.lock | 8 +-- 4 files changed, 49 insertions(+), 59 deletions(-) diff --git a/code/core/package.json b/code/core/package.json index 3d26c3957e5a..9d0510107110 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -362,8 +362,6 @@ "react-textarea-autosize": "^8.3.0", "react-transition-state": "^2.3.1", "require-from-string": "^2.0.2", - "resolve": "^1.22.11", - "resolve.exports": "^2.0.3", "sirv": "^2.0.4", "slash": "^5.0.0", "source-map": "^0.7.4", diff --git a/code/core/src/common/utils/interpret-files.ts b/code/core/src/common/utils/interpret-files.ts index 97930031ae4a..34e2fce94e09 100644 --- a/code/core/src/common/utils/interpret-files.ts +++ b/code/core/src/common/utils/interpret-files.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs'; import { extname } from 'node:path'; -import resolve from 'resolve'; +import { ResolverFactory } from 'oxc-resolver'; export const supportedExtensions = [ '.js', @@ -22,21 +22,23 @@ export function getInterpretedFile(pathToFile: string) { .find((candidate) => existsSync(candidate)); } -export function resolveImport(id: string, options: resolve.SyncOpts): string { - const mergedOptions: resolve.SyncOpts = { - extensions: supportedExtensions, - packageFilter(pkg) { - // Prefer 'module' over 'main' if available - if (pkg.module) { - pkg.main = pkg.module; - } - return pkg; - }, - ...options, - }; +/** + * `mainFields: ['module', 'main']` mirrors the previous `packageFilter` callback that rewrote + * `pkg.main = pkg.module` — both prefer the ESM entry over the CJS entry. Storybook server + * processes run in Node ESM context, so consuming `pkg.module` is safe. + */ +const importResolver = new ResolverFactory({ + extensions: [...supportedExtensions], + mainFields: ['module', 'main'], +}); +export interface ResolveImportOptions { + basedir: string; +} + +export function resolveImport(id: string, options: ResolveImportOptions): string { try { - return resolve.sync(id, { ...mergedOptions }); + return resolveSync(id, options.basedir); } catch (error) { const ext = extname(id); @@ -53,6 +55,14 @@ export function resolveImport(id: string, options: resolve.SyncOpts): string { if (!newId) { throw error; } - return resolve.sync(newId, { ...mergedOptions, extensions: [] }); + return resolveSync(newId, options.basedir); + } +} + +function resolveSync(id: string, basedir: string): string { + const result = importResolver.sync(basedir, id); + if (result.path) { + return result.path; } + throw new Error(result.error ?? `Cannot resolve module '${id}' from '${basedir}'`); } diff --git a/code/core/src/mocking-utils/resolve.ts b/code/core/src/mocking-utils/resolve.ts index a6bb41b7c0cd..f4070d6fa182 100644 --- a/code/core/src/mocking-utils/resolve.ts +++ b/code/core/src/mocking-utils/resolve.ts @@ -1,57 +1,41 @@ -import { readFileSync, realpathSync } from 'node:fs'; +import { realpathSync } from 'node:fs'; import { createRequire } from 'node:module'; -import { dirname, isAbsolute, join, resolve } from 'pathe'; -import { exports as resolveExports } from 'resolve.exports'; +import { ResolverFactory as OxcResolverFactory } from 'oxc-resolver'; +import { dirname, isAbsolute, resolve } from 'pathe'; import { isModuleDirectory } from './extract.ts'; const require = createRequire(import.meta.url); /** - * Finds the package.json for a given module specifier. - * - * @param specifier The module specifier (e.g., 'uuid', 'lodash-es/add'). - * @param basedir The directory to start the search from. - * @returns The path to the package.json and the package's contents. + * Browser-condition resolver used for `sb.mock()` external module resolution. + * Mirrors the previous `resolve.exports({ browser: true })` behaviour: + * - `browser` condition wins in `exports` maps + * - legacy `browser` field is honoured via `aliasFields` + * - `mainFields` order makes `pkg.module` win over `pkg.main` when no `exports` is present */ -function findPackageJson(specifier: string, basedir: string): { path: string; data: any } { - const packageJsonPath = require.resolve(`${specifier}/package.json`, { paths: [basedir] }); - return { - path: packageJsonPath, - data: JSON.parse(readFileSync(packageJsonPath, 'utf-8')), - }; -} +const externalResolver = new OxcResolverFactory({ + conditionNames: ['browser', 'import', 'module', 'default'], + mainFields: ['browser', 'module', 'main'], + aliasFields: [['browser']], + extensions: ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.json'], +}); /** - * Resolves an external module path to its absolute path. It considers the "exports" map in the - * package.json file. + * Resolves an external module path to its absolute path. It considers the "exports" map and the + * legacy "browser" field in the package.json file, preferring browser-targeted entries. * * @param path The raw module path from the `sb.mock()` call. * @param root The project's root directory. * @returns The absolute path to the module. */ export function resolveExternalModule(path: string, root: string) { - // --- External Package Resolution --- - const parts = path.split('/'); - // For scoped packages like `@foo/bar`, the package name is the first two parts. - const packageName = path.startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0]; - const entry = `.${path.slice(packageName.length)}`; // e.g., './add' from 'lodash-es/add' - - const { path: packageJsonPath, data: pkg } = findPackageJson(packageName, root); - const packageDir = dirname(packageJsonPath); - - // 1. Try to resolve using the "exports" map. - if (pkg.exports) { - const result = resolveExports(pkg, entry, { - browser: true, - }); - - if (result) { - return join(packageDir, result[0]); - } + const result = externalResolver.sync(root, path); + if (result.path) { + return result.path; } - + // Fallback for edge cases oxc-resolver does not handle (legacy package layouts). return require.resolve(path, { paths: [root] }); } @@ -65,7 +49,7 @@ export function getIsExternal(path: string, importer: string) { /** * Resolves a mock path to its absolute path and checks for a `__mocks__` redirect. This function - * uses `resolve.exports` to correctly handle modern ESM packages. + * resolves modern ESM packages via the `exports` map with browser-first conditions. * * @param path The raw module path from the `sb.mock()` call. * @param root The project's root directory. diff --git a/yarn.lock b/yarn.lock index 4431ab0525cc..8f4253eff58c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27361,14 +27361,14 @@ __metadata: languageName: node linkType: hard -"resolve.exports@npm:2.0.3, resolve.exports@npm:^2.0.3": +"resolve.exports@npm:2.0.3": version: 2.0.3 resolution: "resolve.exports@npm:2.0.3" checksum: 10c0/1ade1493f4642a6267d0a5e68faeac20b3d220f18c28b140343feb83694d8fed7a286852aef43689d16042c61e2ddb270be6578ad4a13990769e12065191200d languageName: node linkType: hard -"resolve@npm:^1.10.0, resolve@npm:^1.10.1, resolve@npm:^1.13.1, resolve@npm:^1.15.1, resolve@npm:^1.17.0, resolve@npm:^1.19.0, resolve@npm:^1.22.1, resolve@npm:^1.22.10, resolve@npm:^1.22.11, resolve@npm:^1.22.8, resolve@npm:^1.4.0": +"resolve@npm:^1.10.0, resolve@npm:^1.10.1, resolve@npm:^1.13.1, resolve@npm:^1.15.1, resolve@npm:^1.17.0, resolve@npm:^1.19.0, resolve@npm:^1.22.1, resolve@npm:^1.22.10, resolve@npm:^1.22.8, resolve@npm:^1.4.0": version: 1.22.11 resolution: "resolve@npm:1.22.11" dependencies: @@ -27394,7 +27394,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^1.10.0#optional!builtin, resolve@patch:resolve@npm%3A^1.10.1#optional!builtin, resolve@patch:resolve@npm%3A^1.13.1#optional!builtin, resolve@patch:resolve@npm%3A^1.15.1#optional!builtin, resolve@patch:resolve@npm%3A^1.17.0#optional!builtin, resolve@patch:resolve@npm%3A^1.19.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.1#optional!builtin, resolve@patch:resolve@npm%3A^1.22.10#optional!builtin, resolve@patch:resolve@npm%3A^1.22.11#optional!builtin, resolve@patch:resolve@npm%3A^1.22.8#optional!builtin, resolve@patch:resolve@npm%3A^1.4.0#optional!builtin": +"resolve@patch:resolve@npm%3A^1.10.0#optional!builtin, resolve@patch:resolve@npm%3A^1.10.1#optional!builtin, resolve@patch:resolve@npm%3A^1.13.1#optional!builtin, resolve@patch:resolve@npm%3A^1.15.1#optional!builtin, resolve@patch:resolve@npm%3A^1.17.0#optional!builtin, resolve@patch:resolve@npm%3A^1.19.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.1#optional!builtin, resolve@patch:resolve@npm%3A^1.22.10#optional!builtin, resolve@patch:resolve@npm%3A^1.22.8#optional!builtin, resolve@patch:resolve@npm%3A^1.4.0#optional!builtin": version: 1.22.11 resolution: "resolve@patch:resolve@npm%3A1.22.11#optional!builtin::version=1.22.11&hash=c3c19d" dependencies: @@ -29183,8 +29183,6 @@ __metadata: react-transition-state: "npm:^2.3.1" recast: "npm:^0.23.5" require-from-string: "npm:^2.0.2" - resolve: "npm:^1.22.11" - resolve.exports: "npm:^2.0.3" semver: "npm:^7.7.3" sirv: "npm:^2.0.4" slash: "npm:^5.0.0" From c63e535292dbfc08284aec85d0cb585404711ab3 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 6 May 2026 10:53:28 +0200 Subject: [PATCH 0048/1317] docs: rewrite resolver comments to describe new behaviour Co-Authored-By: Claude Opus 4.7 (1M context) --- code/core/src/common/utils/interpret-files.ts | 6 +++--- code/core/src/mocking-utils/resolve.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/code/core/src/common/utils/interpret-files.ts b/code/core/src/common/utils/interpret-files.ts index 34e2fce94e09..f74a01359769 100644 --- a/code/core/src/common/utils/interpret-files.ts +++ b/code/core/src/common/utils/interpret-files.ts @@ -23,9 +23,9 @@ export function getInterpretedFile(pathToFile: string) { } /** - * `mainFields: ['module', 'main']` mirrors the previous `packageFilter` callback that rewrote - * `pkg.main = pkg.module` — both prefer the ESM entry over the CJS entry. Storybook server - * processes run in Node ESM context, so consuming `pkg.module` is safe. + * `mainFields: ['module', 'main']` makes the resolver prefer the ESM entry (`pkg.module`) over the + * CJS entry (`pkg.main`). Storybook server processes run in Node ESM context, so consuming + * `pkg.module` is safe. */ const importResolver = new ResolverFactory({ extensions: [...supportedExtensions], diff --git a/code/core/src/mocking-utils/resolve.ts b/code/core/src/mocking-utils/resolve.ts index f4070d6fa182..e37a79df8939 100644 --- a/code/core/src/mocking-utils/resolve.ts +++ b/code/core/src/mocking-utils/resolve.ts @@ -10,10 +10,11 @@ const require = createRequire(import.meta.url); /** * Browser-condition resolver used for `sb.mock()` external module resolution. - * Mirrors the previous `resolve.exports({ browser: true })` behaviour: - * - `browser` condition wins in `exports` maps - * - legacy `browser` field is honoured via `aliasFields` - * - `mainFields` order makes `pkg.module` win over `pkg.main` when no `exports` is present + * + * - `browser` wins ahead of `import` / `module` / `default` in `exports` maps via `conditionNames` + * - the package.json `browser` field is honoured via `aliasFields` + * - `mainFields` order picks `pkg.browser` first, then `pkg.module`, then `pkg.main` when no + * `exports` map is present */ const externalResolver = new OxcResolverFactory({ conditionNames: ['browser', 'import', 'module', 'default'], @@ -23,8 +24,8 @@ const externalResolver = new OxcResolverFactory({ }); /** - * Resolves an external module path to its absolute path. It considers the "exports" map and the - * legacy "browser" field in the package.json file, preferring browser-targeted entries. + * Resolves an external module path to its absolute path, preferring browser-targeted entries via + * the `exports` map and the package.json `browser` field. * * @param path The raw module path from the `sb.mock()` call. * @param root The project's root directory. @@ -35,7 +36,6 @@ export function resolveExternalModule(path: string, root: string) { if (result.path) { return result.path; } - // Fallback for edge cases oxc-resolver does not handle (legacy package layouts). return require.resolve(path, { paths: [root] }); } From ebc65149237c1579cf0269d175608fd6d83f1ba1 Mon Sep 17 00:00:00 2001 From: Bill Collins Date: Wed, 6 May 2026 12:49:51 +0100 Subject: [PATCH 0049/1317] Fix types --- .../angular/src/builders/start-storybook/index.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/frameworks/angular/src/builders/start-storybook/index.spec.ts b/code/frameworks/angular/src/builders/start-storybook/index.spec.ts index 03d5060982db..599f856eede6 100644 --- a/code/frameworks/angular/src/builders/start-storybook/index.spec.ts +++ b/code/frameworks/angular/src/builders/start-storybook/index.spec.ts @@ -20,7 +20,7 @@ vi.mock('storybook/internal/node-logger', async (importOriginal) => { return { ...original, prompt: { - executeTaskWithSpinner: (fn) => fn(), + executeTaskWithSpinner: (fn: any) => fn(), }, }; }); From 56050b191b178ca588c471ce60059c188a3ed82a Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 7 May 2026 09:04:27 +0200 Subject: [PATCH 0050/1317] Apply suggestion from @valentinpalkovic --- code/core/src/mocking-utils/resolve.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/src/mocking-utils/resolve.ts b/code/core/src/mocking-utils/resolve.ts index e37a79df8939..7e4f67f9cb38 100644 --- a/code/core/src/mocking-utils/resolve.ts +++ b/code/core/src/mocking-utils/resolve.ts @@ -20,7 +20,7 @@ const externalResolver = new OxcResolverFactory({ conditionNames: ['browser', 'import', 'module', 'default'], mainFields: ['browser', 'module', 'main'], aliasFields: [['browser']], - extensions: ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.json'], + extensions: ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.json', '.vue', '.svelte'], }); /** From 6ba87eb9db91e880b926912a2829ca878255a8ce Mon Sep 17 00:00:00 2001 From: yatishgoel Date: Thu, 7 May 2026 15:47:10 +0530 Subject: [PATCH 0051/1317] Next.js Vite: Add Link mock with useLinkStatus to nextjs-vite framework --- code/frameworks/nextjs-vite/build-config.ts | 4 ++ code/frameworks/nextjs-vite/package.json | 5 ++ .../src/export-mocks/link/index.tsx | 70 +++++++++++++++++++ code/frameworks/nextjs-vite/src/preset.ts | 1 + .../template/stories/Link.stories.tsx | 34 ++++++++- 5 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 code/frameworks/nextjs-vite/src/export-mocks/link/index.tsx diff --git a/code/frameworks/nextjs-vite/build-config.ts b/code/frameworks/nextjs-vite/build-config.ts index 39f53c50b191..154d3d90fc88 100644 --- a/code/frameworks/nextjs-vite/build-config.ts +++ b/code/frameworks/nextjs-vite/build-config.ts @@ -28,6 +28,10 @@ const config: BuildEntries = { exportEntries: ['./navigation.mock'], entryPoint: './src/export-mocks/navigation/index.ts', }, + { + exportEntries: ['./link.mock'], + entryPoint: './src/export-mocks/link/index.tsx', + }, { exportEntries: ['./router.mock'], entryPoint: './src/export-mocks/router/index.ts', diff --git a/code/frameworks/nextjs-vite/package.json b/code/frameworks/nextjs-vite/package.json index c6dd0bf2af58..6ccc17cda1ab 100644 --- a/code/frameworks/nextjs-vite/package.json +++ b/code/frameworks/nextjs-vite/package.json @@ -43,6 +43,11 @@ "code": "./src/export-mocks/headers/index.ts", "default": "./dist/export-mocks/headers/index.js" }, + "./link.mock": { + "types": "./dist/export-mocks/link/index.d.ts", + "code": "./src/export-mocks/link/index.tsx", + "default": "./dist/export-mocks/link/index.js" + }, "./navigation.mock": { "types": "./dist/export-mocks/navigation/index.d.ts", "code": "./src/export-mocks/navigation/index.ts", diff --git a/code/frameworks/nextjs-vite/src/export-mocks/link/index.tsx b/code/frameworks/nextjs-vite/src/export-mocks/link/index.tsx new file mode 100644 index 000000000000..0b0cfef2760d --- /dev/null +++ b/code/frameworks/nextjs-vite/src/export-mocks/link/index.tsx @@ -0,0 +1,70 @@ +import React from 'react'; + +import { fn } from 'storybook/test'; + +const linkAction = fn().mockName('next/link::Link'); + +const MockLink = React.forwardRef(function MockLink( + { + href, + as: _as, + replace, + scroll, + shallow, + prefetch, + passHref, + legacyBehavior, + locale, + onClick, + children, + ...rest + }, + ref +) { + const hrefString = + typeof href === 'object' + ? `${href.pathname || ''}${href.query ? '?' + new URLSearchParams(href.query).toString() : ''}${href.hash || ''}` + : href; + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + onClick?.(e); + linkAction(hrefString, { replace, scroll, shallow, prefetch, locale }); + }; + + if (legacyBehavior) { + const child = React.Children.only(children) as React.ReactElement; + const childProps: Record = { + ref, + onClick: (e: React.MouseEvent) => { + e.preventDefault(); + if (child.props && typeof child.props.onClick === 'function') { + child.props.onClick(e); + } + linkAction(hrefString, { replace, scroll, shallow, prefetch, locale }); + }, + ...rest, + }; + + if (passHref || (child.type === 'a' && !('href' in (child.props || {})))) { + childProps.href = hrefString; + } + + return React.cloneElement(child, childProps); + } + + return ( + + {children} + + ); +}); + +MockLink.displayName = 'NextLink'; + +export default MockLink; +export { MockLink as Link }; + +export const useLinkStatus = fn((): { pending: boolean } => ({ pending: false })).mockName( + 'next/link::useLinkStatus' +); diff --git a/code/frameworks/nextjs-vite/src/preset.ts b/code/frameworks/nextjs-vite/src/preset.ts index d359a99dfb54..addeab3b623c 100644 --- a/code/frameworks/nextjs-vite/src/preset.ts +++ b/code/frameworks/nextjs-vite/src/preset.ts @@ -42,6 +42,7 @@ export const previewAnnotations: PresetProperty<'previewAnnotations'> = (entry = }; export const optimizeViteDeps = [ + '@storybook/nextjs-vite/link.mock', '@storybook/nextjs-vite/navigation.mock', '@storybook/nextjs-vite/router.mock', '@storybook/nextjs-vite > styled-jsx', diff --git a/code/frameworks/nextjs-vite/template/stories/Link.stories.tsx b/code/frameworks/nextjs-vite/template/stories/Link.stories.tsx index d6752d273df6..93c08ada6dba 100644 --- a/code/frameworks/nextjs-vite/template/stories/Link.stories.tsx +++ b/code/frameworks/nextjs-vite/template/stories/Link.stories.tsx @@ -1,8 +1,10 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { useLinkStatus as mockUseLinkStatus } from '@storybook/nextjs-vite/link.mock'; -import Link from 'next/link'; +import Link, { useLinkStatus } from 'next/link'; +import { expect, within } from 'storybook/test'; import style from './Link.stories.module.css'; @@ -86,3 +88,33 @@ export const LegacyLink: Story = { ), tags: ['!test'], }; + +function LinkStatusComponent() { + const { pending } = useLinkStatus(); + return
{pending ? 'Pending' : 'Idle'}
; +} + +export const DefaultLinkStatus: Story = { + render: () => , + parameters: { + nextjs: { appDirectory: true }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Idle')).toBeInTheDocument(); + }, +}; + +export const PendingLinkStatus: Story = { + render: () => , + parameters: { + nextjs: { appDirectory: true }, + }, + beforeEach() { + mockUseLinkStatus.mockReturnValue({ pending: true }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Pending')).toBeInTheDocument(); + }, +}; From d107c8ddbd0d2f02506c94801dc5736cd4b36a9d Mon Sep 17 00:00:00 2001 From: Torleif Halseth Date: Thu, 7 May 2026 10:31:52 +0200 Subject: [PATCH 0052/1317] fix(core): exit process after successful build When running `storybook build`, the process hangs after completing because open handles (e.g. telemetry, watchers) keep the event loop alive. Add `process.exit(0)` after the success log to ensure the CLI exits cleanly. Closes #34446 --- code/core/src/bin/core.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/code/core/src/bin/core.ts b/code/core/src/bin/core.ts index 9ce0413d9bcb..9109539984a8 100644 --- a/code/core/src/bin/core.ts +++ b/code/core/src/bin/core.ts @@ -189,6 +189,7 @@ command('build') }); logger.outro('Storybook build completed successfully'); + process.exit(0); }); command('index') From 9bfb9f3ffdb32010d2d08b31eeec605dc810bb27 Mon Sep 17 00:00:00 2001 From: Torleif Halseth Date: Thu, 7 May 2026 13:01:12 +0200 Subject: [PATCH 0053/1317] fix: move process.exit(0) to postAction hook to preserve --logfile support --- code/core/src/bin/core.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/code/core/src/bin/core.ts b/code/core/src/bin/core.ts index 9109539984a8..445e1f63484e 100644 --- a/code/core/src/bin/core.ts +++ b/code/core/src/bin/core.ts @@ -77,6 +77,9 @@ const command = (name: string) => logger.outro(`Debug logs are written to: ${logFile}`); } catch {} } + if (command.name() === 'build') { + process.exit(0); + } }); command('dev') @@ -189,7 +192,6 @@ command('build') }); logger.outro('Storybook build completed successfully'); - process.exit(0); }); command('index') From 42033b97fe7556cfa91a0768fae9d16185d68189 Mon Sep 17 00:00:00 2001 From: "circleci-app[bot]" <127350680+circleci-app[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 13:00:17 +0000 Subject: [PATCH 0054/1317] fix(manager-api): avoid toMatchInlineSnapshot inside waitFor retry loop **Root cause:** The `can filter on status` test in `stories.test.ts` uses `toMatchInlineSnapshot` inside a `vi.waitFor()` callback. After commit `f3dee0e643` removed the duplicate `setIndex` call from the `onAllStatusChange` handler, the async listener no longer completes synchronously before the `waitFor` first executes. On the first `waitFor` retry, `filteredIndex` is still `{}` (the async recompute hasn't finished), causing Vitest to record `{}` as the snapshot value for that code location. When the retry succeeds with the full object, Vitest throws `toMatchInlineSnapshot with different snapshots cannot be called at the same location` because the snapshot value changed between retries. **Fix approach:** Restructure the `waitFor` assertion to use a simple length check (which is retry-safe) to wait for the async update to complete, then perform the `toMatchInlineSnapshot` assertion outside the retry loop once the value is stable. **Changes made:** - `code/core/src/manager-api/tests/stories.test.ts`: Replace `toMatchInlineSnapshot` inside `vi.waitFor()` with `expect(Object.keys(...)).toHaveLength(2)` for the retry condition, then assert the full snapshot after the `waitFor` resolves --- .../src/manager-api/tests/stories.test.ts | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/code/core/src/manager-api/tests/stories.test.ts b/code/core/src/manager-api/tests/stories.test.ts index 07ddd350223a..ab12241ce945 100644 --- a/code/core/src/manager-api/tests/stories.test.ts +++ b/code/core/src/manager-api/tests/stories.test.ts @@ -1587,37 +1587,38 @@ describe('stories API', () => { ]); await vi.waitFor(() => { - expect(store.getState().filteredIndex).toMatchInlineSnapshot(` - { - "a": { - "children": [ - "a--1", - ], - "depth": 0, - "id": "a", - "importPath": "./a.ts", - "name": "a", - "parent": undefined, - "renderLabel": undefined, - "tags": [], - "type": "component", - }, - "a--1": { - "depth": 1, - "id": "a--1", - "importPath": "./a.ts", - "name": "1", - "parent": "a", - "prepared": false, - "renderLabel": undefined, - "subtype": "story", - "tags": [], - "title": "a", - "type": "story", - }, - } - `); + expect(Object.keys(store.getState().filteredIndex ?? {})).toHaveLength(2); }); + expect(store.getState().filteredIndex).toMatchInlineSnapshot(` + { + "a": { + "children": [ + "a--1", + ], + "depth": 0, + "id": "a", + "importPath": "./a.ts", + "name": "a", + "parent": undefined, + "renderLabel": undefined, + "tags": [], + "type": "component", + }, + "a--1": { + "depth": 1, + "id": "a--1", + "importPath": "./a.ts", + "name": "1", + "parent": "a", + "prepared": false, + "renderLabel": undefined, + "subtype": "story", + "tags": [], + "title": "a", + "type": "story", + }, + } + `); }); it('persists filter when index is updated', async () => { From d17f2eadbc8b9232ed98fb949ca4c888ed910a4c Mon Sep 17 00:00:00 2001 From: XionWCFM Date: Tue, 21 Apr 2026 03:06:28 +0900 Subject: [PATCH 0055/1317] fix: storybook peer requirements --- code/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/core/package.json b/code/core/package.json index a79ce341be8d..23da5e04b5a1 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -235,6 +235,7 @@ "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", @@ -281,7 +282,6 @@ "@react-types/shared": "^3.32.0", "@rolldown/pluginutils": "1.0.0-beta.18", "@tanstack/react-virtual": "^3.3.0", - "@testing-library/dom": "^10.4.1", "@testing-library/react": "^14.0.0", "@types/cross-spawn": "^6.0.6", "@types/detect-port": "^1.3.0", From a45e827419df7044d23d9adc9e5e80dcf4cebcc0 Mon Sep 17 00:00:00 2001 From: Brandon Roberts Date: Fri, 6 Mar 2026 16:14:47 -0600 Subject: [PATCH 0056/1317] feat: introduce @storybook/angular-vite package --- code/core/src/common/versions.ts | 1 + code/core/src/types/modules/frameworks.ts | 1 + code/frameworks/angular-vite/README.md | 5 + code/frameworks/angular-vite/build-config.ts | 55 + .../frameworks/angular-vite/build-schema.json | 192 +++ code/frameworks/angular-vite/builders.json | 14 + code/frameworks/angular-vite/package.json | 119 ++ code/frameworks/angular-vite/preset.js | 1 + code/frameworks/angular-vite/project.json | 11 + .../angular-vite/src/__tests__/button.css | 0 .../builders/build-storybook/index.spec.ts | 244 +++ .../src/builders/build-storybook/index.ts | 207 +++ .../src/builders/build-storybook/schema.json | 125 ++ .../builders/start-storybook/index.spec.ts | 233 +++ .../src/builders/start-storybook/index.ts | 253 ++++ .../src/builders/start-storybook/schema.json | 161 ++ .../src/builders/utils/error-handler.ts | 33 + .../src/builders/utils/run-compodoc.spec.ts | 119 ++ .../src/builders/utils/run-compodoc.ts | 45 + .../src/builders/utils/standalone-options.ts | 19 + .../client/angular-beta/AbstractRenderer.ts | 239 +++ .../src/client/angular-beta/CanvasRenderer.ts | 18 + .../ComputesTemplateFromComponent.test.ts | 747 ++++++++++ .../ComputesTemplateFromComponent.ts | 231 +++ .../src/client/angular-beta/DocsRenderer.ts | 52 + .../angular-beta/RendererFactory.test.ts | 273 ++++ .../client/angular-beta/RendererFactory.ts | 60 + .../angular-beta/StorybookModule.test.ts | 370 +++++ .../client/angular-beta/StorybookModule.ts | 39 + .../client/angular-beta/StorybookProvider.ts | 35 + .../angular-beta/StorybookWrapperComponent.ts | 151 ++ .../__testfixtures__/input.component.ts | 52 + .../__testfixtures__/test.module.ts | 8 + .../angular-beta/utils/BootstrapQueue.test.ts | 200 +++ .../angular-beta/utils/BootstrapQueue.ts | 55 + .../utils/NgComponentAnalyzer.test.ts | 381 +++++ .../angular-beta/utils/NgComponentAnalyzer.ts | 135 ++ .../utils/NgModulesAnalyzer.test.ts | 26 + .../angular-beta/utils/NgModulesAnalyzer.ts | 55 + .../utils/PropertyExtractor.test.ts | 201 +++ .../angular-beta/utils/PropertyExtractor.ts | 218 +++ .../src/client/angular-beta/utils/StoryUID.ts | 40 + .../src/client/angular-beta/utils/Zoneless.ts | 9 + .../src/client/argsToTemplate.test.ts | 113 ++ .../angular-vite/src/client/argsToTemplate.ts | 83 ++ .../angular-vite/src/client/compodoc-types.ts | 115 ++ .../angular-vite/src/client/compodoc.test.ts | 136 ++ .../angular-vite/src/client/compodoc.ts | 307 ++++ .../angular-vite/src/client/config.ts | 20 + .../src/client/csf-factories.test.ts | 371 +++++ .../src/client/decorateStory.test.ts | 345 +++++ .../angular-vite/src/client/decorateStory.ts | 71 + .../src/client/decorators.test.ts | 179 +++ .../angular-vite/src/client/decorators.ts | 85 ++ .../doc-button/argtypes.snapshot | 441 ++++++ .../doc-button/compodoc-posix.snapshot | 1326 +++++++++++++++++ .../doc-button/compodoc-undefined.snapshot | 1326 +++++++++++++++++ .../doc-button/compodoc-windows.snapshot | 1297 ++++++++++++++++ .../docs/__testfixtures__/doc-button/input.ts | 199 +++ .../doc-button/properties.snapshot | 230 +++ .../__testfixtures__/doc-button/tsconfig.json | 7 + .../client/docs/angular-properties.test.ts | 39 + .../angular-vite/src/client/docs/config.ts | 15 + .../src/client/docs/sourceDecorator.ts | 62 + .../angular-vite/src/client/globals.ts | 37 + .../angular-vite/src/client/index.ts | 10 + .../src/client/portable-stories.ts | 42 + .../angular-vite/src/client/preview-prod.ts | 3 + .../angular-vite/src/client/preview.ts | 257 ++++ .../angular-vite/src/client/public-types.ts | 90 ++ .../angular-vite/src/client/render.ts | 26 + .../angular-vite/src/client/types.ts | 49 + code/frameworks/angular-vite/src/index.ts | 16 + .../frameworks/angular-vite/src/node/index.ts | 7 + code/frameworks/angular-vite/src/preset.ts | 237 +++ code/frameworks/angular-vite/src/renderer.ts | 6 + .../empty-projects-entry/angular.json | 4 + .../minimal-config/angular.json | 18 + .../minimal-config/src/main.ts | 2 + .../minimal-config/src/tsconfig.app.json | 9 + .../minimal-config/tsconfig.json | 13 + .../some-config/angular.json | 19 + .../some-config/src/main.ts | 2 + .../some-config/src/tsconfig.app.json | 9 + .../some-config/tsconfig.json | 13 + .../with-angularBrowserTarget/angular.json | 64 + .../with-angularBrowserTarget/src/main.ts | 2 + .../with-angularBrowserTarget/src/styles.css | 2 + .../src/tsconfig.app.json | 9 + .../with-angularBrowserTarget/tsconfig.json | 13 + .../with-lib/angular.json | 28 + .../with-lib/projects/pattern-lib/src/main.ts | 2 + .../projects/pattern-lib/tsconfig.lib.json | 20 + .../with-lib/tsconfig.json | 13 + .../with-nx-workspace/nx.json | 3 + .../with-nx-workspace/src/main.ts | 2 + .../with-nx-workspace/src/styles.css | 2 + .../with-nx-workspace/src/styles.scss | 2 + .../with-nx-workspace/src/tsconfig.app.json | 8 + .../with-nx-workspace/tsconfig.json | 14 + .../with-nx-workspace/workspace.json | 18 + .../with-nx/angular.json | 18 + .../__mocks-ng-workspace__/with-nx/nx.json | 3 + .../with-nx/src/main.ts | 2 + .../with-nx/src/styles.css | 2 + .../with-nx/src/styles.scss | 2 + .../with-nx/src/tsconfig.app.json | 8 + .../with-nx/tsconfig.json | 14 + .../with-options-styles/angular.json | 18 + .../with-options-styles/src/main.ts | 2 + .../with-options-styles/src/styles.css | 2 + .../with-options-styles/src/styles.scss | 2 + .../with-options-styles/src/tsconfig.app.json | 9 + .../with-options-styles/tsconfig.json | 13 + .../angular.json | 11 + .../without-architect-build/angular.json | 5 + .../without-compatible-projects/angular.json | 7 + .../without-projects-entry/angular.json | 3 + .../projects/pattern-lib/src/main.ts | 2 + .../projects/pattern-lib/tsconfig.lib.json | 20 + .../without-projects-entry/tsconfig.json | 13 + .../without-tsConfig/angular.json | 16 + .../without-tsConfig/src/main.ts | 2 + .../without-tsConfig/src/tsconfig.app.json | 9 + .../without-tsConfig/tsconfig.json | 13 + .../src/server/__tests__/angular.json | 96 ++ .../angular-vite/src/server/preset-options.ts | 14 + .../frameworks/angular-vite/src/test-setup.ts | 8 + code/frameworks/angular-vite/src/types.ts | 42 + code/frameworks/angular-vite/src/typings.d.ts | 18 + .../frameworks/angular-vite/start-schema.json | 227 +++ .../template/cli/button.component.ts | 48 + .../template/cli/button.stories.ts | 49 + .../template/cli/header.component.ts | 76 + .../template/cli/header.stories.ts | 33 + .../template/cli/page.component.ts | 82 + .../angular-vite/template/cli/page.stories.ts | 32 + .../angular-vite/template/cli/user.ts | 3 + .../template/components/button.component.ts | 47 + .../template/components/button.css | 30 + .../template/components/form.component.ts | 39 + .../template/components/html.component.ts | 25 + .../angular-vite/template/components/index.js | 6 + .../template/components/pre.component.ts | 24 + .../doc-button/doc-button.component.html | 7 + .../doc-button/doc-button.component.scss | 3 + .../doc-button/doc-button.component.ts | 234 +++ .../argTypes/doc-button/doc-button.stories.ts | 31 + .../doc-directive/doc-directive.directive.ts | 22 + .../doc-directive/doc-directive.stories.ts | 20 + .../doc-injectable/doc-injectable.service.ts | 20 + .../doc-injectable/doc-injectable.stories.ts | 20 + .../argTypes/doc-pipe/doc-pipe.pipe.ts | 18 + .../argTypes/doc-pipe/doc-pipe.stories.ts | 20 + .../template/stories/basics/README.mdx | 7 + .../custom-cva-component.stories.ts | 35 + .../custom-cva.component.ts | 57 + .../attribute-selector.component.ts | 26 + .../attribute-selectors.component.stories.ts | 14 + .../class-selector.component.stories.ts | 8 + .../class-selector.component.ts | 26 + ...ltiple-class-selector.component.stories.ts | 8 + .../multiple-selector.component.stories.ts | 8 + .../multiple-selector.component.ts | 48 + .../component-with-enums/enums.component.html | 8 + .../enums.component.stories.ts | 28 + .../component-with-enums/enums.component.ts | 50 + .../base-button.component.ts | 11 + .../base-button.stories.ts | 16 + .../icon-button.component.ts | 12 + .../icon-button.stories.ts | 19 + .../ng-content-about-parent.stories.ts | 60 + .../ng-content-simple.stories.ts | 38 + .../component-with-on-destroy.stories.ts | 45 + .../on-push-box.component.ts | 22 + .../component-with-on-push/on-push.stories.ts | 24 + .../custom-pipes.stories.ts | 37 + .../basics/component-with-pipe/custom.pipe.ts | 12 + .../with-pipe.component.ts | 11 + .../component-with-provider/di.component.html | 7 + .../di.component.stories.ts | 31 + .../component-with-provider/di.component.ts | 33 + .../component-with-style/styled.component.css | 3 + .../styled.component.html | 5 + .../styled.component.scss | 5 + .../styled.component.stories.ts | 16 + .../component-with-style/styled.component.ts | 9 + .../template.component.ts | 27 + .../template.stories.ts | 26 + ...ut-selector-ng-component-outlet.stories.ts | 79 + .../without-selector.component.ts | 31 + .../without-selector.stories.ts | 38 + .../ng-module/angular-src/chip-color.token.ts | 3 + .../ng-module/angular-src/chip-text.pipe.ts | 31 + .../ng-module/angular-src/chip.component.ts | 61 + .../angular-src/chips-group.component.ts | 50 + .../ng-module/angular-src/chips.module.ts | 32 + .../ng-module/import-module-chip.stories.ts | 27 + .../import-module-for-root.stories.ts | 54 + .../basics/ng-module/import-module.stories.ts | 38 + .../template/stories/button.component.ts | 27 + .../template/stories/core/README.mdx | 7 + .../child.component.ts | 21 + .../decorators.stories.ts | 107 ++ .../parent.component.ts | 19 + .../theme-decorator/decorators.stories.ts | 21 + .../moduleMetadata/angular-src/custom.pipe.ts | 12 + .../angular-src/dummy.service.ts | 14 + .../angular-src/service.component.ts | 24 + .../angular-src/token.component.ts | 32 + .../in-export-default.stories.ts | 40 + .../core/moduleMetadata/in-stories.stories.ts | 47 + .../merge-default-and-story.stories.ts | 37 + .../parameters/bootstrap-options.stories.ts | 23 + .../core/styles/story-styles.stories.ts | 52 + .../app-initializer-use-factory.stories.ts | 39 + .../issues/12009-unknown-component.stories.ts | 16 + .../stories/others/ngx-translate/README.mdx | 65 + .../signal/button.component.ts | 41 + .../signal/button.css | 30 + .../signal/button.stories.ts | 66 + .../signal/button.component.ts | 41 + .../signal/button.css | 30 + .../signal/button.stories.ts | 66 + code/frameworks/angular-vite/tsconfig.json | 11 + .../angular-vite/tsconfig.spec.json | 9 + code/frameworks/angular-vite/vitest.config.ts | 14 + code/package.json | 1 + scripts/build/entry-configs.ts | 3 + 229 files changed, 16912 insertions(+) create mode 100644 code/frameworks/angular-vite/README.md create mode 100644 code/frameworks/angular-vite/build-config.ts create mode 100644 code/frameworks/angular-vite/build-schema.json create mode 100644 code/frameworks/angular-vite/builders.json create mode 100644 code/frameworks/angular-vite/package.json create mode 100644 code/frameworks/angular-vite/preset.js create mode 100644 code/frameworks/angular-vite/project.json create mode 100644 code/frameworks/angular-vite/src/__tests__/button.css create mode 100644 code/frameworks/angular-vite/src/builders/build-storybook/index.spec.ts create mode 100644 code/frameworks/angular-vite/src/builders/build-storybook/index.ts create mode 100644 code/frameworks/angular-vite/src/builders/build-storybook/schema.json create mode 100644 code/frameworks/angular-vite/src/builders/start-storybook/index.spec.ts create mode 100644 code/frameworks/angular-vite/src/builders/start-storybook/index.ts create mode 100644 code/frameworks/angular-vite/src/builders/start-storybook/schema.json create mode 100644 code/frameworks/angular-vite/src/builders/utils/error-handler.ts create mode 100644 code/frameworks/angular-vite/src/builders/utils/run-compodoc.spec.ts create mode 100644 code/frameworks/angular-vite/src/builders/utils/run-compodoc.ts create mode 100644 code/frameworks/angular-vite/src/builders/utils/standalone-options.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/AbstractRenderer.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/CanvasRenderer.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.test.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/DocsRenderer.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.test.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.test.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/StorybookProvider.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/StorybookWrapperComponent.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/input.component.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/test.module.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.test.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.test.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.test.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/StoryUID.ts create mode 100644 code/frameworks/angular-vite/src/client/angular-beta/utils/Zoneless.ts create mode 100644 code/frameworks/angular-vite/src/client/argsToTemplate.test.ts create mode 100644 code/frameworks/angular-vite/src/client/argsToTemplate.ts create mode 100644 code/frameworks/angular-vite/src/client/compodoc-types.ts create mode 100644 code/frameworks/angular-vite/src/client/compodoc.test.ts create mode 100644 code/frameworks/angular-vite/src/client/compodoc.ts create mode 100644 code/frameworks/angular-vite/src/client/config.ts create mode 100644 code/frameworks/angular-vite/src/client/csf-factories.test.ts create mode 100644 code/frameworks/angular-vite/src/client/decorateStory.test.ts create mode 100644 code/frameworks/angular-vite/src/client/decorateStory.ts create mode 100644 code/frameworks/angular-vite/src/client/decorators.test.ts create mode 100644 code/frameworks/angular-vite/src/client/decorators.ts create mode 100644 code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/argtypes.snapshot create mode 100644 code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-posix.snapshot create mode 100644 code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-undefined.snapshot create mode 100644 code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-windows.snapshot create mode 100644 code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/input.ts create mode 100644 code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/properties.snapshot create mode 100644 code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/client/docs/angular-properties.test.ts create mode 100644 code/frameworks/angular-vite/src/client/docs/config.ts create mode 100644 code/frameworks/angular-vite/src/client/docs/sourceDecorator.ts create mode 100644 code/frameworks/angular-vite/src/client/globals.ts create mode 100644 code/frameworks/angular-vite/src/client/index.ts create mode 100644 code/frameworks/angular-vite/src/client/portable-stories.ts create mode 100644 code/frameworks/angular-vite/src/client/preview-prod.ts create mode 100644 code/frameworks/angular-vite/src/client/preview.ts create mode 100644 code/frameworks/angular-vite/src/client/public-types.ts create mode 100644 code/frameworks/angular-vite/src/client/render.ts create mode 100644 code/frameworks/angular-vite/src/client/types.ts create mode 100644 code/frameworks/angular-vite/src/index.ts create mode 100644 code/frameworks/angular-vite/src/node/index.ts create mode 100644 code/frameworks/angular-vite/src/preset.ts create mode 100644 code/frameworks/angular-vite/src/renderer.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/empty-projects-entry/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/tsconfig.app.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/tsconfig.app.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/styles.css create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/tsconfig.app.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/tsconfig.lib.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/nx.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.css create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.scss create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/tsconfig.app.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/workspace.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/nx.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.css create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.scss create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/tsconfig.app.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.css create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.scss create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/tsconfig.app.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build-options/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-compatible-projects/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/tsconfig.lib.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/angular.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/main.ts create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/tsconfig.app.json create mode 100644 code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/tsconfig.json create mode 100644 code/frameworks/angular-vite/src/server/__tests__/angular.json create mode 100644 code/frameworks/angular-vite/src/server/preset-options.ts create mode 100644 code/frameworks/angular-vite/src/test-setup.ts create mode 100644 code/frameworks/angular-vite/src/types.ts create mode 100644 code/frameworks/angular-vite/src/typings.d.ts create mode 100644 code/frameworks/angular-vite/start-schema.json create mode 100644 code/frameworks/angular-vite/template/cli/button.component.ts create mode 100644 code/frameworks/angular-vite/template/cli/button.stories.ts create mode 100644 code/frameworks/angular-vite/template/cli/header.component.ts create mode 100644 code/frameworks/angular-vite/template/cli/header.stories.ts create mode 100644 code/frameworks/angular-vite/template/cli/page.component.ts create mode 100644 code/frameworks/angular-vite/template/cli/page.stories.ts create mode 100644 code/frameworks/angular-vite/template/cli/user.ts create mode 100644 code/frameworks/angular-vite/template/components/button.component.ts create mode 100644 code/frameworks/angular-vite/template/components/button.css create mode 100644 code/frameworks/angular-vite/template/components/form.component.ts create mode 100644 code/frameworks/angular-vite/template/components/html.component.ts create mode 100644 code/frameworks/angular-vite/template/components/index.js create mode 100644 code/frameworks/angular-vite/template/components/pre.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.html create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.scss create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.directive.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.service.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.pipe.ts create mode 100644 code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/README.mdx create mode 100644 code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva-component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selector.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selectors.component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-class-selector.component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.html create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-about-parent.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-simple.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-ng-on-destroy/component-with-on-destroy.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push-box.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom-pipes.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom.pipe.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-pipe/with-pipe.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.html create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.css create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.html create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.scss create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-template/template.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-with-template/template.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-without-selector/without-selector-ng-component-outlet.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-without-selector/without-selector.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/component-without-selector/without-selector.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/angular-src/chip-color.token.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/angular-src/chip-text.pipe.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/angular-src/chip.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/angular-src/chips-group.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/angular-src/chips.module.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/import-module-chip.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/import-module-for-root.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/basics/ng-module/import-module.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/button.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/README.mdx create mode 100644 code/frameworks/angular-vite/template/stories/core/decorators/componentWrapperDecorator/child.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/decorators/componentWrapperDecorator/decorators.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/decorators/componentWrapperDecorator/parent.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/decorators/theme-decorator/decorators.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/moduleMetadata/angular-src/custom.pipe.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/moduleMetadata/angular-src/dummy.service.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/moduleMetadata/angular-src/service.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/moduleMetadata/angular-src/token.component.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/moduleMetadata/in-export-default.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/moduleMetadata/in-stories.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/moduleMetadata/merge-default-and-story.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/parameters/bootstrap-options.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/core/styles/story-styles.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/others/app-initializer-use-factory/app-initializer-use-factory.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/others/issues/12009-unknown-component.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories/others/ngx-translate/README.mdx create mode 100644 code/frameworks/angular-vite/template/stories_angular-cli-default-ts/signal/button.component.ts create mode 100644 code/frameworks/angular-vite/template/stories_angular-cli-default-ts/signal/button.css create mode 100644 code/frameworks/angular-vite/template/stories_angular-cli-default-ts/signal/button.stories.ts create mode 100644 code/frameworks/angular-vite/template/stories_angular-cli-prerelease/signal/button.component.ts create mode 100644 code/frameworks/angular-vite/template/stories_angular-cli-prerelease/signal/button.css create mode 100644 code/frameworks/angular-vite/template/stories_angular-cli-prerelease/signal/button.stories.ts create mode 100644 code/frameworks/angular-vite/tsconfig.json create mode 100644 code/frameworks/angular-vite/tsconfig.spec.json create mode 100644 code/frameworks/angular-vite/vitest.config.ts diff --git a/code/core/src/common/versions.ts b/code/core/src/common/versions.ts index 9fb3ef3e06b4..ef4104a5021f 100644 --- a/code/core/src/common/versions.ts +++ b/code/core/src/common/versions.ts @@ -11,6 +11,7 @@ export default { '@storybook/builder-webpack5': '10.4.0-alpha.17', storybook: '10.4.0-alpha.17', '@storybook/angular': '10.4.0-alpha.17', + '@storybook/angular-vite': '10.4.0-alpha.17', '@storybook/ember': '10.4.0-alpha.17', '@storybook/html-vite': '10.4.0-alpha.17', '@storybook/nextjs': '10.4.0-alpha.17', diff --git a/code/core/src/types/modules/frameworks.ts b/code/core/src/types/modules/frameworks.ts index f09694bd5181..98676afe5786 100644 --- a/code/core/src/types/modules/frameworks.ts +++ b/code/core/src/types/modules/frameworks.ts @@ -2,6 +2,7 @@ export enum SupportedFramework { // CORE ANGULAR = 'angular', + ANGULAR_VITE = 'angular-vite', EMBER = 'ember', HTML_VITE = 'html-vite', NEXTJS = 'nextjs', diff --git a/code/frameworks/angular-vite/README.md b/code/frameworks/angular-vite/README.md new file mode 100644 index 000000000000..7bdee1b0c30a --- /dev/null +++ b/code/frameworks/angular-vite/README.md @@ -0,0 +1,5 @@ +# Storybook for Angular + +See [documentation](https://storybook.js.org/docs/get-started/frameworks/angular?renderer=angular&ref=readme) for installation instructions, usage examples, APIs, and more. + +Learn more about Storybook at [storybook.js.org](https://storybook.js.org/?ref=readme). diff --git a/code/frameworks/angular-vite/build-config.ts b/code/frameworks/angular-vite/build-config.ts new file mode 100644 index 000000000000..4dfda837be90 --- /dev/null +++ b/code/frameworks/angular-vite/build-config.ts @@ -0,0 +1,55 @@ +import type { BuildEntries } from '../../../scripts/build/utils/entry-utils'; + +const config: BuildEntries = { + entries: { + browser: [ + { + exportEntries: ['.'], + entryPoint: './src/index.ts', + }, + { + exportEntries: ['./client'], + entryPoint: './src/client/index.ts', + dts: false, + }, + { + exportEntries: ['./client/config'], + entryPoint: './src/client/config.ts', + dts: false, + }, + { + exportEntries: ['./client/preview-prod'], + entryPoint: './src/client/preview-prod.ts', + dts: false, + }, + { + exportEntries: ['./client/docs/config'], + entryPoint: './src/client/docs/config.ts', + dts: false, + }, + ], + node: [ + { + exportEntries: ['./node'], + entryPoint: './src/node/index.ts', + }, + { + exportEntries: ['./preset'], + entryPoint: './src/preset.ts', + dts: false, + }, + { + exportEntries: ['./builders/start-storybook'], + entryPoint: './src/builders/start-storybook/index.ts', + dts: false, + }, + { + exportEntries: ['./builders/build-storybook'], + entryPoint: './src/builders/build-storybook/index.ts', + dts: false, + }, + ], + }, +}; + +export default config; diff --git a/code/frameworks/angular-vite/build-schema.json b/code/frameworks/angular-vite/build-schema.json new file mode 100644 index 000000000000..be414e8b3395 --- /dev/null +++ b/code/frameworks/angular-vite/build-schema.json @@ -0,0 +1,192 @@ +{ + "$schema": "http://json-schema.org/schema", + "title": "Build Storybook", + "description": "Serve up storybook in development mode.", + "type": "object", + "properties": { + "browserTarget": { + "type": "string", + "description": "Build target to be served in project-name:builder:config format. Should generally target on the builder: '@angular-devkit/build-angular:browser'. Useful for Storybook to use options (styles, assets, ...).", + "pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$", + "default": null + }, + "tsConfig": { + "type": "string", + "description": "The full path for the TypeScript configuration file, relative to the current workspace." + }, + "outputDir": { + "type": "string", + "description": "Directory where to store built files.", + "default": "storybook-static" + }, + "preserveSymlinks": { + "type": "boolean", + "description": "Do not use the real path when resolving modules. If true, symlinks are resolved to their real path, if false, symlinks are resolved to their symlinked path.", + "default": false + }, + "configDir": { + "type": "string", + "description": "Directory where to load Storybook configurations from.", + "default": ".storybook" + }, + "loglevel": { + "type": "string", + "description": "Controls level of logging during build. Can be one of: [trace, debug, info (default), warn, error, silent].", + "pattern": "(trace|debug|info|warn|error|silent)" + }, + "logfile": { + "type": "string", + "description": "If provided, the log output will be written to the specified file path." + }, + "enableProdMode": { + "type": "boolean", + "description": "Disable Angular's development mode, which turns off assertions and other checks within the framework.", + "default": true + }, + "quiet": { + "type": "boolean", + "description": "Suppress verbose build output.", + "default": false + }, + "docs": { + "type": "boolean", + "description": "Starts Storybook in documentation mode. Learn more about it : https://storybook.js.org/docs/writing-docs/build-documentation#preview-storybooks-documentation.", + "default": false + }, + "test": { + "type": "boolean", + "description": "Build the static version of the sandbox optimized for testing purposes", + "default": false + }, + "compodoc": { + "type": "boolean", + "description": "Execute compodoc before.", + "default": true + }, + "compodocArgs": { + "type": "array", + "description": "Compodoc options : https://compodoc.app/guides/options.html. Options `-p` with tsconfig path and `-d` with workspace root is always given.", + "default": ["-e", "json"], + "items": { + "type": "string" + } + }, + "statsJson": { + "type": ["boolean", "string"], + "description": "Write stats JSON to disk", + "default": false + }, + "previewUrl": { + "type": "string", + "description": "Disables the default storybook preview and lets you use your own" + }, + "styles": { + "type": "array", + "description": "Global styles to be included in the build.", + "items": { + "$ref": "#/definitions/styleElement" + } + }, + "stylePreprocessorOptions": { + "description": "Options to pass to style preprocessors.", + "type": "object", + "properties": { + "includePaths": { + "description": "Paths to include. Paths will be resolved to workspace root.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "assets": { + "type": "array", + "description": "List of static application assets.", + "default": [], + "items": { + "$ref": "#/definitions/assetPattern" + } + }, + "sourceMap": { + "type": ["boolean", "object"], + "description": "Configure sourcemaps. See: https://angular.io/guide/workspace-config#source-map-configuration", + "default": false + }, + "experimentalZoneless": { + "type": "boolean", + "description": "Experimental: Use zoneless change detection." + } + }, + "additionalProperties": false, + "definitions": { + "assetPattern": { + "oneOf": [ + { + "type": "object", + "properties": { + "followSymlinks": { + "type": "boolean", + "default": false, + "description": "Allow glob patterns to follow symlink directories. This allows subdirectories of the symlink to be searched." + }, + "glob": { + "type": "string", + "description": "The pattern to match." + }, + "input": { + "type": "string", + "description": "The input directory path in which to apply 'glob'. Defaults to the project root." + }, + "ignore": { + "description": "An array of globs to ignore.", + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "string", + "description": "Absolute path within the output." + } + }, + "additionalProperties": false, + "required": ["glob", "input", "output"] + }, + { + "type": "string" + } + ] + }, + "styleElement": { + "oneOf": [ + { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The file to include." + }, + "bundleName": { + "type": "string", + "pattern": "^[\\w\\-.]*$", + "description": "The bundle name for this extra entry point." + }, + "inject": { + "type": "boolean", + "description": "If the bundle will be referenced in the HTML file.", + "default": true + } + }, + "additionalProperties": false, + "required": ["input"] + }, + { + "type": "string", + "description": "The file to include." + } + ] + } + } +} diff --git a/code/frameworks/angular-vite/builders.json b/code/frameworks/angular-vite/builders.json new file mode 100644 index 000000000000..650305a2f279 --- /dev/null +++ b/code/frameworks/angular-vite/builders.json @@ -0,0 +1,14 @@ +{ + "builders": { + "build-storybook": { + "implementation": "./dist/builders/build-storybook/index.js", + "schema": "./build-schema.json", + "description": "Build storybook" + }, + "start-storybook": { + "implementation": "./dist/builders/start-storybook/index.js", + "schema": "./start-schema.json", + "description": "Start storybook" + } + } +} diff --git a/code/frameworks/angular-vite/package.json b/code/frameworks/angular-vite/package.json new file mode 100644 index 000000000000..c48e05a23325 --- /dev/null +++ b/code/frameworks/angular-vite/package.json @@ -0,0 +1,119 @@ +{ + "name": "@storybook/angular-vite", + "version": "10.3.0-alpha.14", + "description": "Storybook for Angular: Develop, document, and test UI components in isolation", + "keywords": [ + "storybook", + "storybook-framework", + "angular", + "component", + "components" + ], + "homepage": "https://github.com/storybookjs/storybook/tree/next/code/frameworks/angular-vite", + "bugs": { + "url": "https://github.com/storybookjs/storybook/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/storybookjs/storybook.git", + "directory": "code/frameworks/angular-vite" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "code": "./src/index.ts", + "default": "./dist/index.js" + }, + "./builders/build-storybook": "./dist/builders/build-storybook/index.js", + "./builders/start-storybook": "./dist/builders/start-storybook/index.js", + "./client": "./dist/client/index.js", + "./client/config": "./dist/client/config.js", + "./client/docs/config": "./dist/client/docs/config.js", + "./client/preview-prod": "./dist/client/preview-prod.js", + "./node": { + "types": "./dist/node/index.d.ts", + "code": "./src/node/index.ts", + "default": "./dist/node/index.js" + }, + "./package.json": "./package.json", + "./preset": "./dist/preset.js" + }, + "files": [ + "builders.json", + "build-schema.json", + "start-schema.json", + "dist/**/*", + "template/cli/**/*", + "README.md", + "*.js", + "*.mjs", + "*.d.ts", + "!src/**/*" + ], + "dependencies": { + "@storybook/builder-vite": "workspace:*", + "@storybook/global": "^5.0.0", + "telejson": "8.0.0", + "ts-dedent": "^2.0.0", + "vite": ">=7.0.0" + }, + "devDependencies": { + "@analogjs/vite-plugin-angular": "^1.12.1", + "@angular-devkit/architect": "^0.2100.0", + "@angular-devkit/build-angular": "^21.0.0", + "@angular-devkit/core": "^21.0.0", + "@angular/animations": "^21.0.0", + "@angular/common": "^21.0.0", + "@angular/compiler": "^21.0.0", + "@angular/compiler-cli": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/forms": "^21.0.0", + "@angular/platform-browser": "^21.0.0", + "@angular/platform-browser-dynamic": "^21.0.0", + "@types/node": "^22.19.1", + "empathic": "^2.0.0", + "rimraf": "^6.0.1", + "typescript": "^5.9.3", + "zone.js": "^0.15.0" + }, + "peerDependencies": { + "@analogjs/vite-plugin-angular": ">=2.0.0", + "@angular-devkit/architect": ">=0.2100.0 < 0.2200.0", + "@angular-devkit/core": ">=21.0.0 < 22.0.0", + "@angular/animations": ">=21.0.0 < 22.0.0", + "@angular/build": ">=21.0.0 < 22.0.0", + "@angular/cli": ">=21.0.0 < 22.0.0", + "@angular/common": ">=21.0.0 < 22.0.0", + "@angular/compiler": ">=21.0.0 < 22.0.0", + "@angular/compiler-cli": ">=21.0.0 < 22.0.0", + "@angular/core": ">=21.0.0 < 22.0.0", + "@angular/platform-browser": ">=21.0.0 < 22.0.0", + "@angular/platform-browser-dynamic": ">=21.0.0 < 22.0.0", + "rxjs": "^6.5.3 || ^7.4.0", + "storybook": "workspace:^", + "typescript": "^5.9.0", + "vite": ">=7.0.0", + "zone.js": ">=0.16.0" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + }, + "@angular/cli": { + "optional": true + }, + "zone.js": { + "optional": true + } + }, + "publishConfig": { + "access": "public" + }, + "builders": "builders.json" +} diff --git a/code/frameworks/angular-vite/preset.js b/code/frameworks/angular-vite/preset.js new file mode 100644 index 000000000000..4bd63d324002 --- /dev/null +++ b/code/frameworks/angular-vite/preset.js @@ -0,0 +1 @@ +export * from './dist/preset.js'; diff --git a/code/frameworks/angular-vite/project.json b/code/frameworks/angular-vite/project.json new file mode 100644 index 000000000000..7ab6079e7cbb --- /dev/null +++ b/code/frameworks/angular-vite/project.json @@ -0,0 +1,11 @@ +{ + "name": "angular-vite", + "projectType": "library", + "targets": { + "compile": {}, + "check": {} + }, + "tags": [ + "library" + ] +} \ No newline at end of file diff --git a/code/frameworks/angular-vite/src/__tests__/button.css b/code/frameworks/angular-vite/src/__tests__/button.css new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/code/frameworks/angular-vite/src/builders/build-storybook/index.spec.ts b/code/frameworks/angular-vite/src/builders/build-storybook/index.spec.ts new file mode 100644 index 000000000000..0b89291f9233 --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/build-storybook/index.spec.ts @@ -0,0 +1,244 @@ +import { Architect, createBuilder } from '@angular-devkit/architect'; +import { TestingArchitectHost } from '@angular-devkit/architect/testing'; +import { schema } from '@angular-devkit/core'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const buildDevStandaloneMock = vi.fn(); +const buildStaticStandaloneMock = vi.fn(); + +const buildMock = { + buildDevStandalone: buildDevStandaloneMock, + buildStaticStandalone: buildStaticStandaloneMock, + withTelemetry: (name: string, options: any, fn: any) => fn(), +}; + +vi.doMock('storybook/internal/core-server', () => buildMock); +vi.doMock('storybook/internal/common', () => ({ + JsPackageManagerFactory: { + getPackageManager: () => ({ + runPackageCommand: mockRunScript, + }), + }, + getEnvConfig: (options: any) => options, + versions: { + storybook: 'x.x.x', + }, +})); +vi.doMock('empathic/find', () => ({ up: () => './storybook/tsconfig.ts' })); + +const mockRunScript = vi.fn(); + +// Randomly fails on CI. TODO: investigate why +describe.skip('Build Storybook Builder', () => { + let architect: Architect; + let architectHost: TestingArchitectHost; + + beforeEach(async () => { + const registry = new schema.CoreSchemaRegistry(); + registry.addPostTransform(schema.transforms.addUndefinedDefaults); + + architectHost = new TestingArchitectHost(); + architect = new Architect(architectHost, registry); + + architectHost.addBuilder( + '@angular-devkit/build-angular:browser', + createBuilder(() => { + return { success: true }; + }) + ); + architectHost.addTarget( + { project: 'angular-cli', target: 'build-2' }, + '@angular-devkit/build-angular:browser', + { + outputPath: 'dist/angular-cli', + index: 'src/index.html', + main: 'src/main.ts', + polyfills: 'src/polyfills.ts', + tsConfig: 'src/tsconfig.app.json', + assets: ['src/favicon.ico', 'src/assets'], + styles: ['src/styles.css'], + scripts: [], + } + ); + + // This will either take a Node package name, or a path to the directory + // for the package.json file. + await architectHost.addBuilderFromPackage(path.join(__dirname, '../../..')); + }); + + beforeEach(() => { + buildStaticStandaloneMock.mockImplementation((_options: unknown) => Promise.resolve(_options)); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should start storybook with angularBrowserTarget', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:build-storybook', { + browserTarget: 'angular-cli:build-2', + compodoc: false, + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: 'angular-cli:build-2', + angularBuilderContext: expect.any(Object), + configDir: '.storybook', + loglevel: undefined, + quiet: false, + disableTelemetry: undefined, + outputDir: 'storybook-static', + packageJson: expect.any(Object), + mode: 'static', + tsConfig: './storybook/tsconfig.ts', + statsJson: false, + }) + ); + }); + + it('should start storybook with tsConfig', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:build-storybook', { + tsConfig: 'path/to/tsConfig.json', + compodoc: false, + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: null, + angularBuilderContext: expect.any(Object), + configDir: '.storybook', + loglevel: undefined, + quiet: false, + disableTelemetry: undefined, + outputDir: 'storybook-static', + packageJson: expect.any(Object), + mode: 'static', + tsConfig: 'path/to/tsConfig.json', + statsJson: false, + }) + ); + }); + + it('should build storybook with stats.json', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:build-storybook', { + tsConfig: 'path/to/tsConfig.json', + compodoc: false, + statsJson: true, + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: null, + angularBuilderContext: expect.any(Object), + configDir: '.storybook', + loglevel: undefined, + quiet: false, + outputDir: 'storybook-static', + packageJson: expect.any(Object), + mode: 'static', + tsConfig: 'path/to/tsConfig.json', + statsJson: true, + }) + ); + }); + + it('should throw error', async () => { + buildStaticStandaloneMock.mockRejectedValue(true); + + const run = await architect.scheduleBuilder('@storybook/angular:build-storybook', { + browserTarget: 'angular-cli:build-2', + compodoc: false, + }); + + try { + await run.result; + + expect(false).toEqual('Throw expected'); + } catch (error) { + expect(error).toEqual( + 'Broken build, fix the error above.\nYou may need to refresh the browser.' + ); + } + }); + + it('should run compodoc', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:build-storybook', { + browserTarget: 'angular-cli:build-2', + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).toHaveBeenCalledWith( + 'compodoc', + ['-p', './storybook/tsconfig.ts', '-d', '.', '-e', 'json'], + '' + ); + expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: 'angular-cli:build-2', + angularBuilderContext: expect.any(Object), + configDir: '.storybook', + loglevel: undefined, + quiet: false, + outputDir: 'storybook-static', + packageJson: expect.any(Object), + mode: 'static', + tsConfig: './storybook/tsconfig.ts', + statsJson: false, + }) + ); + }); + + it('should start storybook with styles options', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:build-storybook', { + tsConfig: 'path/to/tsConfig.json', + compodoc: false, + styles: ['style.scss'], + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(buildStaticStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: null, + angularBuilderContext: expect.any(Object), + angularBuilderOptions: { assets: [], styles: ['style.scss'] }, + configDir: '.storybook', + loglevel: undefined, + quiet: false, + outputDir: 'storybook-static', + packageJson: expect.any(Object), + mode: 'static', + tsConfig: 'path/to/tsConfig.json', + statsJson: false, + }) + ); + }); +}); diff --git a/code/frameworks/angular-vite/src/builders/build-storybook/index.ts b/code/frameworks/angular-vite/src/builders/build-storybook/index.ts new file mode 100644 index 000000000000..da6eeab29331 --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/build-storybook/index.ts @@ -0,0 +1,207 @@ +import { readFileSync } from 'node:fs'; + +import { getEnvConfig, getProjectRoot, versions } from 'storybook/internal/common'; +import { buildStaticStandalone, withTelemetry } from 'storybook/internal/core-server'; +import { addToGlobalContext } from 'storybook/internal/telemetry'; +import type { CLIOptions } from 'storybook/internal/types'; +import { logger, logTracker } from 'storybook/internal/node-logger'; + +import type { + BuilderContext, + BuilderHandlerFn, + BuilderOutput, + Target, + Builder as DevkitBuilder, +} from '@angular-devkit/architect'; +import { createBuilder, targetFromTargetString } from '@angular-devkit/architect'; +import type { + BrowserBuilderOptions, + StylePreprocessorOptions, +} from '@angular-devkit/build-angular'; +import type { + AssetPattern, + SourceMapUnion, + StyleElement, +} from '@angular-devkit/build-angular/src/builders/browser/schema'; +import type { JsonObject } from '@angular-devkit/core'; +import * as find from 'empathic/find'; +import * as pkg from 'empathic/package'; + +import { errorSummary, printErrorDetails } from '../utils/error-handler.ts'; +import { runCompodoc } from '../utils/run-compodoc.ts'; +import type { StandaloneOptions } from '../utils/standalone-options.ts'; +import { VERSION } from '@angular/core'; +import { Channel } from 'storybook/internal/channels'; + +addToGlobalContext('cliVersion', versions.storybook); + +export type StorybookBuilderOptions = JsonObject & { + browserTarget?: string | null; + tsConfig?: string; + test: boolean; + docs: boolean; + compodoc: boolean; + compodocArgs: string[]; + enableProdMode?: boolean; + styles?: StyleElement[]; + stylePreprocessorOptions?: StylePreprocessorOptions; + preserveSymlinks?: boolean; + assets?: AssetPattern[]; + sourceMap?: SourceMapUnion; + experimentalZoneless?: boolean; +} & Pick< + // makes sure the option exists + CLIOptions, + | 'outputDir' + | 'configDir' + | 'loglevel' + | 'quiet' + | 'test' + | 'statsJson' + | 'disableTelemetry' + | 'logfile' + | 'previewUrl' + >; + +export type StorybookBuilderOutput = JsonObject & BuilderOutput & { [key: string]: any }; + +type StandaloneBuildOptions = StandaloneOptions & { outputDir: string }; + +const commandBuilder: BuilderHandlerFn = async ( + options, + context +): Promise => { + // Apply logger configuration from builder options + if (options.loglevel) { + logger.setLogLevel(options.loglevel); + } + if (options.logfile) { + logTracker.enableLogWriting(); + } + + logger.intro('Building Storybook'); + + const { tsConfig } = await setup(options, context); + + const docTSConfig = find.up('tsconfig.doc.json', { + cwd: options.configDir, + last: getProjectRoot(), + }); + + if (options.compodoc) { + await runCompodoc( + { compodocArgs: options.compodocArgs, tsconfig: docTSConfig ?? tsConfig }, + context + ); + } + + getEnvConfig(options, { + staticDir: 'SBCONFIG_STATIC_DIR', + outputDir: 'SBCONFIG_OUTPUT_DIR', + configDir: 'SBCONFIG_CONFIG_DIR', + }); + + const { + browserTarget, + stylePreprocessorOptions, + styles, + configDir, + docs, + loglevel, + test, + outputDir, + quiet, + enableProdMode = true, + statsJson, + disableTelemetry, + assets, + previewUrl, + sourceMap = false, + preserveSymlinks = false, + experimentalZoneless = !!(VERSION.major && Number(VERSION.major) >= 21), + } = options; + + const packageJsonPath = pkg.up({ cwd: __dirname }); + const packageJson = + packageJsonPath != null ? JSON.parse(readFileSync(packageJsonPath, 'utf8')) : null; + + const standaloneOptions: StandaloneBuildOptions = { + packageJson, + configDir, + ...(docs ? { docs } : {}), + loglevel, + outputDir, + test, + quiet, + enableProdMode, + disableTelemetry, + angularBrowserTarget: browserTarget, + angularBuilderContext: context, + angularBuilderOptions: { + ...(stylePreprocessorOptions ? { stylePreprocessorOptions } : {}), + ...(styles ? { styles } : {}), + ...(assets ? { assets } : {}), + sourceMap, + preserveSymlinks, + experimentalZoneless, + }, + tsConfig, + statsJson, + previewUrl, + }; + + await runInstance({ ...standaloneOptions, mode: 'static' }); + if (logTracker.shouldWriteLogsToFile) { + const logFile = await logTracker.writeToFile(options.logfile as any); + logger.info(`Debug logs are written to: ${logFile}`); + } + logger.outro('Storybook build completed successfully'); + return { success: true } as BuilderOutput; +}; + +export default createBuilder(commandBuilder) as DevkitBuilder; + +async function setup(options: StorybookBuilderOptions, context: BuilderContext) { + let browserOptions: (JsonObject & BrowserBuilderOptions) | undefined; + let browserTarget: Target | undefined; + + if (options.browserTarget) { + browserTarget = targetFromTargetString(options.browserTarget); + browserOptions = await context.validateOptions( + await context.getTargetOptions(browserTarget), + await context.getBuilderNameForTarget(browserTarget) + ); + } + + return { + tsConfig: + options.tsConfig ?? + find.up('tsconfig.json', { cwd: options.configDir, last: getProjectRoot() }) ?? + browserOptions.tsConfig, + }; +} + +async function runInstance(options: StandaloneBuildOptions) { + try { + await withTelemetry( + 'build', + { + cliOptions: options, + presetOptions: { + ...options, + corePresets: [], + overridePresets: [], + channel: new Channel({}), + }, + printError: printErrorDetails, + }, + async () => { + const result = await buildStaticStandalone(options); + return result; + } + ); + } catch (error) { + const summary = errorSummary(error); + throw new Error(summary); + } +} diff --git a/code/frameworks/angular-vite/src/builders/build-storybook/schema.json b/code/frameworks/angular-vite/src/builders/build-storybook/schema.json new file mode 100644 index 000000000000..5171c50c982d --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/build-storybook/schema.json @@ -0,0 +1,125 @@ +{ + "$schema": "http://json-schema.org/schema", + "title": "Build Storybook", + "description": "Serve up storybook in development mode.", + "type": "object", + "properties": { + "tsConfig": { + "type": "string", + "description": "The full path for the TypeScript configuration file, relative to the current workspace." + }, + "outputDir": { + "type": "string", + "description": "Directory where to store built files.", + "default": "storybook-static" + }, + "configDir": { + "type": "string", + "description": "Directory where to load Storybook configurations from.", + "default": ".storybook" + }, + "loglevel": { + "type": "string", + "description": "Controls level of logging during build. Can be one of: [silly, verbose, info (default), warn, error, silent].", + "pattern": "(silly|verbose|info|warn|silent)" + }, + "enableProdMode": { + "type": "boolean", + "description": "Disable Angular's development mode, which turns off assertions and other checks within the framework.", + "default": true + }, + "quiet": { + "type": "boolean", + "description": "Suppress verbose build output.", + "default": false + }, + "docs": { + "type": "boolean", + "description": "Starts Storybook in documentation mode. Learn more about it : https://storybook.js.org/docs/writing-docs/build-documentation#preview-storybooks-documentation.", + "default": false + }, + "test": { + "type": "boolean", + "description": "Build the static version of the sandbox optimized for testing purposes", + "default": false + }, + "compodoc": { + "type": "boolean", + "description": "Execute compodoc before.", + "default": true + }, + "compodocArgs": { + "type": "array", + "description": "Compodoc options : https://compodoc.app/guides/options.html. Options `-p` with tsconfig path and `-d` with workspace root is always given.", + "default": ["-e", "json"], + "items": { + "type": "string" + } + }, + "statsJson": { + "type": ["boolean", "string"], + "description": "Write stats JSON to disk", + "default": false + }, + "previewUrl": { + "type": "string", + "description": "Disables the default storybook preview and lets you use your own" + }, + "experimentalZoneless": { + "type": "boolean", + "description": "Experimental: Use zoneless change detection.", + "default": false + }, + "styles": { + "type": "array", + "description": "Global styles to be included in the build.", + "default": [], + "items": { + "type": "string" + } + }, + "stylePreprocessorOptions": { + "description": "Options to pass to style preprocessors.", + "type": "object", + "properties": { + "loadPaths": { + "description": "Paths to include.", + "type": "array", + "items": { + "type": "string" + } + }, + "sass": { + "description": "Options to pass to the sass preprocessor.", + "type": "object", + "properties": { + "fatalDeprecations": { + "description": "A set of deprecations to treat as fatal. If a deprecation warning of any provided type is encountered during compilation, the compiler will error instead. If a Version is provided, then all deprecations that were active in that compiler version will be treated as fatal.", + "type": "array", + "items": { + "type": "string" + } + }, + "silenceDeprecations": { + "description": " A set of active deprecations to ignore. If a deprecation warning of any provided type is encountered during compilation, the compiler will ignore it instead.", + "type": "array", + "items": { + "type": "string" + } + }, + "futureDeprecations": { + "description": "A set of future deprecations to opt into early. Future deprecations passed here will be treated as active by the compiler, emitting warnings as necessary.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/code/frameworks/angular-vite/src/builders/start-storybook/index.spec.ts b/code/frameworks/angular-vite/src/builders/start-storybook/index.spec.ts new file mode 100644 index 000000000000..ace692f2758a --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/start-storybook/index.spec.ts @@ -0,0 +1,233 @@ +import { Architect, createBuilder } from '@angular-devkit/architect'; +import { TestingArchitectHost } from '@angular-devkit/architect/testing'; +import { schema } from '@angular-devkit/core'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const buildDevStandaloneMock = vi.fn(); +const buildStaticStandaloneMock = vi.fn(); +const buildMock = { + buildDevStandalone: buildDevStandaloneMock, + buildStaticStandalone: buildStaticStandaloneMock, + withTelemetry: (_: string, __: any, fn: any) => fn(), +}; +vi.doMock('storybook/internal/core-server', () => buildMock); +vi.doMock('empathic/find', () => ({ up: () => './storybook/tsconfig.ts' })); + +const mockRunScript = vi.fn(); + +vi.mock('storybook/internal/common', () => ({ + getEnvConfig: (options: any) => options, + versions: { + storybook: 'x.x.x', + }, + JsPackageManagerFactory: { + getPackageManager: () => ({ + runPackageCommand: mockRunScript, + }), + }, +})); + +// Randomly fails on CI. TODO: investigate why +describe.skip('Start Storybook Builder', () => { + let architect: Architect; + let architectHost: TestingArchitectHost; + + beforeEach(async () => { + const registry = new schema.CoreSchemaRegistry(); + registry.addPostTransform(schema.transforms.addUndefinedDefaults); + + architectHost = new TestingArchitectHost(); + architect = new Architect(architectHost, registry); + + architectHost.addBuilder( + '@angular-devkit/build-angular:browser', + createBuilder(() => { + return { success: true }; + }) + ); + architectHost.addTarget( + { project: 'angular-cli', target: 'build-2' }, + '@angular-devkit/build-angular:browser', + { + outputPath: 'dist/angular-cli', + index: 'src/index.html', + main: 'src/main.ts', + polyfills: 'src/polyfills.ts', + tsConfig: 'src/tsconfig.app.json', + assets: ['src/favicon.ico', 'src/assets'], + styles: ['src/styles.css'], + scripts: [], + } + ); + // This will either take a Node package name, or a path to the directory + // for the package.json file. + await architectHost.addBuilderFromPackage(join(__dirname, '../../..')); + }); + + beforeEach(() => { + buildDevStandaloneMock.mockImplementation((_options: unknown) => Promise.resolve(_options)); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should start storybook with angularBrowserTarget', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:start-storybook', { + browserTarget: 'angular-cli:build-2', + port: 4400, + compodoc: false, + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(buildDevStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: 'angular-cli:build-2', + angularBuilderContext: expect.any(Object), + ci: false, + configDir: '.storybook', + disableTelemetry: undefined, + host: 'localhost', + https: false, + packageJson: expect.any(Object), + port: 4400, + quiet: false, + smokeTest: false, + sslCa: undefined, + sslCert: undefined, + sslKey: undefined, + tsConfig: './storybook/tsconfig.ts', + }) + ); + }); + + it('should start storybook with tsConfig', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:start-storybook', { + tsConfig: 'path/to/tsConfig.json', + port: 4400, + compodoc: false, + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(buildDevStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: null, + angularBuilderContext: expect.any(Object), + ci: false, + configDir: '.storybook', + disableTelemetry: undefined, + host: 'localhost', + https: false, + packageJson: expect.any(Object), + port: 4400, + quiet: false, + smokeTest: false, + sslCa: undefined, + sslCert: undefined, + sslKey: undefined, + tsConfig: 'path/to/tsConfig.json', + }) + ); + }); + + it('should throw error', async () => { + buildDevStandaloneMock.mockRejectedValue(true); + + const run = await architect.scheduleBuilder('@storybook/angular:start-storybook', { + browserTarget: 'angular-cli:build-2', + port: 4400, + compodoc: false, + }); + + try { + await run.result; + + expect(false).toEqual('Throw expected'); + } catch (error) { + expect(error).toEqual( + 'Broken build, fix the error above.\nYou may need to refresh the browser.' + ); + } + }); + + it('should run compodoc', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:start-storybook', { + browserTarget: 'angular-cli:build-2', + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).toHaveBeenCalledWith( + 'compodoc', + ['-p', './storybook/tsconfig.ts', '-d', '.', '-e', 'json'], + '' + ); + expect(buildDevStandaloneMock).toHaveBeenCalledWith( + expect.objectContaining({ + angularBrowserTarget: 'angular-cli:build-2', + angularBuilderContext: expect.any(Object), + ci: false, + disableTelemetry: undefined, + configDir: '.storybook', + host: 'localhost', + https: false, + packageJson: expect.any(Object), + port: 9009, + quiet: false, + smokeTest: false, + sslCa: undefined, + sslCert: undefined, + sslKey: undefined, + tsConfig: './storybook/tsconfig.ts', + }) + ); + }); + + it('should start storybook with styles options', async () => { + const run = await architect.scheduleBuilder('@storybook/angular:start-storybook', { + tsConfig: 'path/to/tsConfig.json', + port: 4400, + compodoc: false, + styles: ['src/styles.css'], + }); + + const output = await run.result; + + await run.stop(); + + expect(output.success).toBeTruthy(); + expect(mockRunScript).not.toHaveBeenCalledWith(); + expect(buildDevStandaloneMock).toHaveBeenCalledWith({ + angularBrowserTarget: null, + angularBuilderContext: expect.any(Object), + angularBuilderOptions: { assets: [], styles: ['src/styles.css'] }, + disableTelemetry: undefined, + ci: false, + configDir: '.storybook', + host: 'localhost', + https: false, + port: 4400, + packageJson: expect.any(Object), + quiet: false, + smokeTest: false, + sslCa: undefined, + sslCert: undefined, + sslKey: undefined, + tsConfig: 'path/to/tsConfig.json', + }); + }); +}); diff --git a/code/frameworks/angular-vite/src/builders/start-storybook/index.ts b/code/frameworks/angular-vite/src/builders/start-storybook/index.ts new file mode 100644 index 000000000000..27e38f0b0267 --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/start-storybook/index.ts @@ -0,0 +1,253 @@ +import { readFileSync } from 'node:fs'; + +import { getEnvConfig, getProjectRoot, versions } from 'storybook/internal/common'; +import { buildDevStandalone, withTelemetry } from 'storybook/internal/core-server'; +import { addToGlobalContext } from 'storybook/internal/telemetry'; +import type { CLIOptions } from 'storybook/internal/types'; +import { logger, logTracker } from 'storybook/internal/node-logger'; + +import type { + BuilderContext, + BuilderHandlerFn, + BuilderOutput, + Target, + Builder as DevkitBuilder, +} from '@angular-devkit/architect'; +import { createBuilder, targetFromTargetString } from '@angular-devkit/architect'; +import type { + BrowserBuilderOptions, + StylePreprocessorOptions, +} from '@angular-devkit/build-angular'; +import type { + AssetPattern, + SourceMapUnion, + StyleElement, +} from '@angular-devkit/build-angular/src/builders/browser/schema'; +import type { JsonObject } from '@angular-devkit/core'; +import { Observable } from 'rxjs'; +import * as find from 'empathic/find'; +import * as pkg from 'empathic/package'; + +import { errorSummary, printErrorDetails } from '../utils/error-handler.ts'; +import { runCompodoc } from '../utils/run-compodoc.ts'; +import type { StandaloneOptions } from '../utils/standalone-options.ts'; +import { VERSION } from '@angular/core'; +import { Channel } from 'storybook/internal/channels'; + +addToGlobalContext('cliVersion', versions.storybook); + +export type StorybookBuilderOptions = JsonObject & { + browserTarget?: string | null; + tsConfig?: string; + compodoc: boolean; + compodocArgs: string[]; + enableProdMode?: boolean; + styles?: StyleElement[]; + stylePreprocessorOptions?: StylePreprocessorOptions; + assets?: AssetPattern[]; + preserveSymlinks?: boolean; + sourceMap?: SourceMapUnion; + experimentalZoneless?: boolean; +} & Pick< + // makes sure the option exists + CLIOptions, + | 'port' + | 'host' + | 'configDir' + | 'https' + | 'sslCa' + | 'sslCert' + | 'sslKey' + | 'smokeTest' + | 'ci' + | 'quiet' + | 'disableTelemetry' + | 'initialPath' + | 'open' + | 'docs' + | 'logfile' + | 'statsJson' + | 'loglevel' + | 'previewUrl' + >; + +export type StorybookBuilderOutput = JsonObject & BuilderOutput & {}; + +const commandBuilder: BuilderHandlerFn = ( + options, + context +): Observable => { + return new Observable((observer) => { + (async () => { + try { + // Apply logger configuration from builder options + if (options.loglevel) { + logger.setLogLevel(options.loglevel); + } + if (options.logfile) { + logTracker.enableLogWriting(); + } + + logger.intro('Starting Storybook'); + + const { tsConfig } = await setup(options, context); + + const docTSConfig = find.up('tsconfig.doc.json', { + cwd: options.configDir, + last: getProjectRoot(), + }); + + if (options.compodoc) { + await runCompodoc( + { + compodocArgs: [...options.compodocArgs, ...(options.quiet ? ['--silent'] : [])], + tsconfig: docTSConfig ?? tsConfig, + }, + context + ); + } + + getEnvConfig(options, { + port: 'SBCONFIG_PORT', + host: 'SBCONFIG_HOSTNAME', + staticDir: 'SBCONFIG_STATIC_DIR', + configDir: 'SBCONFIG_CONFIG_DIR', + ci: 'CI', + }); + + options.port = parseInt(`${options.port}`, 10); + + const { + browserTarget, + stylePreprocessorOptions, + styles, + ci, + configDir, + docs, + host, + https, + port, + quiet, + enableProdMode = false, + smokeTest, + sslCa, + sslCert, + sslKey, + disableTelemetry, + assets, + initialPath, + open, + loglevel, + statsJson, + previewUrl, + sourceMap = false, + preserveSymlinks = false, + experimentalZoneless = !!(VERSION.major && Number(VERSION.major) >= 21), + } = options; + + const packageJsonPath = pkg.up({ cwd: __dirname }); + const packageJson = + packageJsonPath != null ? JSON.parse(readFileSync(packageJsonPath, 'utf8')) : null; + + const standaloneOptions: StandaloneOptions = { + packageJson, + ci, + configDir, + ...(docs ? { docs } : {}), + host, + https, + port, + quiet, + enableProdMode, + smokeTest, + sslCa, + sslCert, + sslKey, + disableTelemetry, + angularBrowserTarget: browserTarget, + angularBuilderContext: context, + angularBuilderOptions: { + ...(stylePreprocessorOptions ? { stylePreprocessorOptions } : {}), + ...(styles ? { styles } : {}), + ...(assets ? { assets } : {}), + preserveSymlinks, + sourceMap, + experimentalZoneless, + }, + tsConfig, + initialPath, + open, + statsJson, + loglevel, + previewUrl, + }; + + const startedPort = await runInstance(standaloneOptions); + + // Emit success output - the dev server is now running + observer.next({ success: true, info: { port: startedPort } } as BuilderOutput); + + // Don't call observer.complete() - this keeps the Observable alive + // so the dev server continues running. Architect will keep subscribing + // until the Observable completes, which allows watch mode to work. + } catch (error) { + // Write logs to file on failure when enabled + try { + if (logTracker.shouldWriteLogsToFile) { + try { + const logFile = await logTracker.writeToFile(options.logfile as any); + logger.outro(`Debug logs are written to: ${logFile}`); + } catch {} + } + } catch {} + observer.error(error); + } + })(); + }); +}; + +export default createBuilder(commandBuilder) as DevkitBuilder; + +async function setup(options: StorybookBuilderOptions, context: BuilderContext) { + let browserOptions: (JsonObject & BrowserBuilderOptions) | undefined; + let browserTarget: Target | undefined; + + if (options.browserTarget) { + browserTarget = targetFromTargetString(options.browserTarget); + browserOptions = await context.validateOptions( + await context.getTargetOptions(browserTarget), + await context.getBuilderNameForTarget(browserTarget) + ); + } + + return { + tsConfig: + options.tsConfig ?? + find.up('tsconfig.json', { cwd: options.configDir }) ?? + browserOptions.tsConfig, + }; +} +async function runInstance(options: StandaloneOptions) { + try { + const { port } = await withTelemetry( + 'dev', + { + cliOptions: options, + presetOptions: { + ...options, + corePresets: [], + overridePresets: [], + channel: new Channel({}), + }, + printError: printErrorDetails, + }, + () => { + return buildDevStandalone(options); + } + ); + return port; + } catch (error) { + const summarized = errorSummary(error); + throw new Error(String(summarized)); + } +} diff --git a/code/frameworks/angular-vite/src/builders/start-storybook/schema.json b/code/frameworks/angular-vite/src/builders/start-storybook/schema.json new file mode 100644 index 000000000000..6c831f747856 --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/start-storybook/schema.json @@ -0,0 +1,161 @@ +{ + "$schema": "http://json-schema.org/schema", + "title": "Start Storybook", + "description": "Serve up storybook in development mode.", + "type": "object", + "properties": { + "tsConfig": { + "type": "string", + "description": "The full path for the TypeScript configuration file, relative to the current workspace." + }, + "port": { + "type": "number", + "description": "Port to listen on.", + "default": 9009 + }, + "host": { + "type": "string", + "description": "Host to listen on.", + "default": "localhost" + }, + "configDir": { + "type": "string", + "description": "Directory where to load Storybook configurations from.", + "default": ".storybook" + }, + "https": { + "type": "boolean", + "description": "Serve Storybook over HTTPS. Note: You must provide your own certificate information.", + "default": false + }, + "sslCa": { + "type": "string", + "description": "Provide an SSL certificate authority. (Optional with --https, required if using a self-signed certificate)." + }, + "sslCert": { + "type": "string", + "description": "Provide an SSL certificate. (Required with --https)." + }, + "sslKey": { + "type": "string", + "description": "SSL key to use for serving HTTPS." + }, + "smokeTest": { + "type": "boolean", + "description": "Exit after successful start.", + "default": false + }, + "ci": { + "type": "boolean", + "description": "CI mode (skip interactive prompts, don't open browser).", + "default": false + }, + "open": { + "type": "boolean", + "description": "Whether to open Storybook automatically in the browser.", + "default": true + }, + "quiet": { + "type": "boolean", + "description": "Suppress verbose build output.", + "default": false + }, + "enableProdMode": { + "type": "boolean", + "description": "Disable Angular's development mode, which turns off assertions and other checks within the framework.", + "default": false + }, + "docs": { + "type": "boolean", + "description": "Starts Storybook in documentation mode. Learn more about it : https://storybook.js.org/docs/writing-docs/build-documentation#preview-storybooks-documentation.", + "default": false + }, + "compodoc": { + "type": "boolean", + "description": "Execute compodoc before.", + "default": true + }, + "compodocArgs": { + "type": "array", + "description": "Compodoc options : https://compodoc.app/guides/options.html. Options `-p` with tsconfig path and `-d` with workspace root is always given.", + "default": ["-e", "json"], + "items": { + "type": "string" + } + }, + "initialPath": { + "type": "string", + "description": "URL path to be appended when visiting Storybook for the first time" + }, + "statsJson": { + "type": ["boolean", "string"], + "description": "Write stats JSON to disk", + "default": false + }, + "previewUrl": { + "type": "string", + "description": "Disables the default storybook preview and lets you use your own" + }, + "loglevel": { + "type": "string", + "description": "Controls level of logging during build. Can be one of: [silly, verbose, info (default), warn, error, silent].", + "pattern": "(silly|verbose|info|warn|silent)" + }, + "experimentalZoneless": { + "type": "boolean", + "description": "Experimental: Use zoneless change detection.", + "default": false + }, + "styles": { + "type": "array", + "description": "Global styles to be included in the build.", + "default": [], + "items": { + "type": "string" + } + }, + "stylePreprocessorOptions": { + "description": "Options to pass to style preprocessors.", + "type": "object", + "properties": { + "loadPaths": { + "description": "Paths to include.", + "type": "array", + "items": { + "type": "string" + } + }, + "sass": { + "description": "Options to pass to the sass preprocessor.", + "type": "object", + "properties": { + "fatalDeprecations": { + "description": "A set of deprecations to treat as fatal. If a deprecation warning of any provided type is encountered during compilation, the compiler will error instead. If a Version is provided, then all deprecations that were active in that compiler version will be treated as fatal.", + "type": "array", + "items": { + "type": "string" + } + }, + "silenceDeprecations": { + "description": " A set of active deprecations to ignore. If a deprecation warning of any provided type is encountered during compilation, the compiler will ignore it instead.", + "type": "array", + "items": { + "type": "string" + } + }, + "futureDeprecations": { + "description": "A set of future deprecations to opt into early. Future deprecations passed here will be treated as active by the compiler, emitting warnings as necessary.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/code/frameworks/angular-vite/src/builders/utils/error-handler.ts b/code/frameworks/angular-vite/src/builders/utils/error-handler.ts new file mode 100644 index 000000000000..44b75d4e55da --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/utils/error-handler.ts @@ -0,0 +1,33 @@ +import { logger, instance as npmLog } from 'storybook/internal/node-logger'; + +import { dedent } from 'ts-dedent'; + +export const printErrorDetails = (error: any): void => { + // Duplicate code for Standalone error handling + // Source: https://github.com/storybookjs/storybook/blob/39c7ba09ad84fbd466f9c25d5b92791a5450b9f6/lib/core-server/src/build-dev.ts#L136 + npmLog.heading = ''; + + if (error instanceof Error) { + if ((error as any).error) { + logger.error((error as any).error); + } else if ((error as any).stats && (error as any).stats.compilation.errors) { + (error as any).stats.compilation.errors.forEach((e: any) => logger.log(e)); + } else { + logger.error(error as any); + } + } else if (error.compilation?.errors) { + error.compilation.errors.forEach((e: any) => logger.log(e)); + } +}; + +export const errorSummary = (error: any): string => { + return error.close + ? dedent` + FATAL broken build!, will close the process, + Fix the error below and restart storybook. + ` + : dedent` + Broken build, fix the error above. + You may need to refresh the browser. + `; +}; diff --git a/code/frameworks/angular-vite/src/builders/utils/run-compodoc.spec.ts b/code/frameworks/angular-vite/src/builders/utils/run-compodoc.spec.ts new file mode 100644 index 000000000000..7ca3de67f9d4 --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/utils/run-compodoc.spec.ts @@ -0,0 +1,119 @@ +import type { BuilderContext } from '@angular-devkit/architect'; +// @ts-expect-error (TODO) +import type { LoggerApi } from '@angular-devkit/core/src/logger'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { runCompodoc } from './run-compodoc'; + +const mockRunScript = vi.fn().mockResolvedValue({ stdout: '' }); + +vi.mock('storybook/internal/common', () => ({ + JsPackageManagerFactory: { + getPackageManager: () => ({ + runPackageCommand: mockRunScript, + }), + }, +})); +vi.mock('storybook/internal/node-logger', () => ({ + prompt: { + executeTaskWithSpinner: async (fn: any) => { + await fn(); + }, + }, +})); + +const builderContextLoggerMock: LoggerApi = { + createChild: vi.fn(), + log: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), +}; + +describe('runCompodoc', () => { + afterEach(() => { + mockRunScript.mockClear(); + }); + + const builderContextMock = { + workspaceRoot: 'path/to/project', + logger: builderContextLoggerMock, + } as BuilderContext; + + it('should run compodoc with tsconfig from context', async () => { + await runCompodoc( + { + compodocArgs: [], + tsconfig: 'path/to/tsconfig.json', + }, + builderContextMock + ); + + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-p', 'path/to/tsconfig.json', '-d', 'path/to/project'], + cwd: 'path/to/project', + }); + }); + + it('should run compodoc with tsconfig from compodocArgs', async () => { + await runCompodoc( + { + compodocArgs: ['-p', 'path/to/tsconfig.stories.json'], + tsconfig: 'path/to/tsconfig.json', + }, + builderContextMock + ); + + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-d', 'path/to/project', '-p', 'path/to/tsconfig.stories.json'], + cwd: 'path/to/project', + }); + }); + + it('should run compodoc with default output folder.', async () => { + await runCompodoc( + { + compodocArgs: [], + tsconfig: 'path/to/tsconfig.json', + }, + builderContextMock + ); + + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-p', 'path/to/tsconfig.json', '-d', 'path/to/project'], + cwd: 'path/to/project', + }); + }); + + it('should run with custom output folder specified with --output compodocArgs', async () => { + await runCompodoc( + { + compodocArgs: ['--output', 'path/to/customFolder'], + tsconfig: 'path/to/tsconfig.json', + }, + builderContextMock + ); + + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-p', 'path/to/tsconfig.json', '--output', 'path/to/customFolder'], + cwd: 'path/to/project', + }); + }); + + it('should run with custom output folder specified with -d compodocArgs', async () => { + await runCompodoc( + { + compodocArgs: ['-d', 'path/to/customFolder'], + tsconfig: 'path/to/tsconfig.json', + }, + builderContextMock + ); + + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-p', 'path/to/tsconfig.json', '-d', 'path/to/customFolder'], + cwd: 'path/to/project', + }); + }); +}); diff --git a/code/frameworks/angular-vite/src/builders/utils/run-compodoc.ts b/code/frameworks/angular-vite/src/builders/utils/run-compodoc.ts new file mode 100644 index 000000000000..e93aa9a40711 --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/utils/run-compodoc.ts @@ -0,0 +1,45 @@ +import { isAbsolute, relative } from 'node:path'; + +import { JsPackageManagerFactory } from 'storybook/internal/common'; + +import type { BuilderContext } from '@angular-devkit/architect'; +import { prompt } from 'storybook/internal/node-logger'; + +const hasTsConfigArg = (args: string[]) => args.indexOf('-p') !== -1; +const hasOutputArg = (args: string[]) => + args.indexOf('-d') !== -1 || args.indexOf('--output') !== -1; + +// relative is necessary to workaround a compodoc issue with +// absolute paths on windows machines +const toRelativePath = (pathToTsConfig: string) => { + return isAbsolute(pathToTsConfig) ? relative('.', pathToTsConfig) : pathToTsConfig; +}; + +export const runCompodoc = async ( + { compodocArgs, tsconfig }: { compodocArgs: string[]; tsconfig: string }, + context: BuilderContext +): Promise => { + const tsConfigPath = toRelativePath(tsconfig); + const finalCompodocArgs = [ + 'compodoc', + ...(hasTsConfigArg(compodocArgs) ? [] : ['-p', tsConfigPath]), + ...(hasOutputArg(compodocArgs) ? [] : ['-d', `${context.workspaceRoot || '.'}`]), + ...compodocArgs, + ]; + + const packageManager = JsPackageManagerFactory.getPackageManager(); + + await prompt.executeTaskWithSpinner( + () => + packageManager.runPackageCommand({ + args: finalCompodocArgs, + cwd: context.workspaceRoot, + }), + { + id: 'compodoc', + intro: 'Generating documentation with Compodoc', + success: 'Compodoc finished successfully', + error: 'Compodoc failed', + } + ); +}; diff --git a/code/frameworks/angular-vite/src/builders/utils/standalone-options.ts b/code/frameworks/angular-vite/src/builders/utils/standalone-options.ts new file mode 100644 index 000000000000..c4ec584ad76f --- /dev/null +++ b/code/frameworks/angular-vite/src/builders/utils/standalone-options.ts @@ -0,0 +1,19 @@ +import type { BuilderContext } from '@angular-devkit/architect'; +import { + BuilderOptions, + CLIOptions, + LoadOptions, +} from 'storybook/internal/types'; + +export type StandaloneOptions = CLIOptions & + LoadOptions & + BuilderOptions & { + mode?: 'static' | 'dev'; + enableProdMode: boolean; + angularBrowserTarget: string | null; + angularBuilderOptions?: Record & { + experimentalZoneless?: boolean; + }; + angularBuilderContext?: BuilderContext | null; + tsConfig?: string; + }; diff --git a/code/frameworks/angular-vite/src/client/angular-beta/AbstractRenderer.ts b/code/frameworks/angular-vite/src/client/angular-beta/AbstractRenderer.ts new file mode 100644 index 000000000000..368dddf11d2e --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/AbstractRenderer.ts @@ -0,0 +1,239 @@ +import type { ApplicationRef, NgModule } from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser'; +import type { Subject } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; +import { stringify } from 'telejson'; + +import type { ICollection, StoryFnAngularReturnType } from '../types'; +import { getApplication } from './StorybookModule'; +import { storyPropsProvider } from './StorybookProvider'; +import { queueBootstrapping } from './utils/BootstrapQueue'; +import { PropertyExtractor } from './utils/PropertyExtractor'; +import { getProvideZonelessChangeDetectionFn } from './utils/Zoneless'; + +type StoryRenderInfo = { + storyFnAngular: StoryFnAngularReturnType; + moduleMetadataSnapshot: string; +}; + +declare global { + const STORYBOOK_ANGULAR_OPTIONS: { + experimentalZoneless: boolean; + }; +} + +const applicationRefs = new Map(); + +/** + * Attribute name for the story UID that may be written to the targetDOMNode. + * + * If a target DOM node has a story UID attribute, it will be used as part of the selector for the + * Angular component. + */ +export const STORY_UID_ATTRIBUTE = 'data-sb-story-uid'; + +export abstract class AbstractRenderer { + /** Wait and destroy the platform */ + public static resetApplications(domNode?: HTMLElement) { + applicationRefs.forEach((appRef, appDOMNode) => { + if (!appRef.destroyed && (!domNode || appDOMNode === domNode)) { + appRef.destroy(); + } + }); + } + + protected previousStoryRenderInfo = new Map(); + + // Observable to change the properties dynamically without reloading angular module&component + protected storyProps$: Subject; + + protected abstract beforeFullRender(domNode?: HTMLElement): Promise; + + /** + * Bootstrap main angular module with main component or send only new `props` with storyProps$ + * + * @param storyFnAngular {StoryFnAngularReturnType} + * @param forced {boolean} If : + * + * - True render will only use the StoryFn `props' in storyProps observable that will update sotry's + * component/template properties. Improves performance without reloading the whole + * module&component if props changes + * - False fully recharges or initializes angular module & component + * + * @param component {Component} + */ + public async render({ + storyFnAngular, + forced, + component, + targetDOMNode, + }: { + storyFnAngular: StoryFnAngularReturnType; + forced: boolean; + component?: any; + targetDOMNode: HTMLElement; + }) { + const targetSelector = this.generateTargetSelectorFromStoryId(targetDOMNode.id); + + const newStoryProps$ = new BehaviorSubject(storyFnAngular.props); + + if ( + !this.fullRendererRequired({ + targetDOMNode, + storyFnAngular, + moduleMetadata: { + ...storyFnAngular.moduleMetadata, + }, + forced, + }) + ) { + this.storyProps$.next(storyFnAngular.props); + + return; + } + + await this.beforeFullRender(targetDOMNode); + + // Complete last BehaviorSubject and set a new one for the current module + if (this.storyProps$) { + this.storyProps$.complete(); + } + this.storyProps$ = newStoryProps$; + + this.initAngularRootElement(targetDOMNode, targetSelector); + + const analyzedMetadata = new PropertyExtractor(storyFnAngular.moduleMetadata, component); + await analyzedMetadata.init(); + + const storyUid = this.generateStoryUIdFromRawStoryUid( + targetDOMNode.getAttribute(STORY_UID_ATTRIBUTE) + ); + const componentSelector = storyUid !== null ? `${targetSelector}[${storyUid}]` : targetSelector; + if (storyUid !== null) { + const element = targetDOMNode.querySelector(targetSelector); + element.toggleAttribute(storyUid, true); + } + + const application = getApplication({ + storyFnAngular, + component, + targetSelector: componentSelector, + analyzedMetadata, + }); + + const providers = [ + storyPropsProvider(newStoryProps$), + ...analyzedMetadata.applicationProviders, + ...(storyFnAngular.applicationConfig?.providers ?? []), + ]; + + if (STORYBOOK_ANGULAR_OPTIONS?.experimentalZoneless) { + const provideZonelessChangeDetectionFn = await getProvideZonelessChangeDetectionFn(); + + if (!provideZonelessChangeDetectionFn) { + throw new Error('Zoneless change detection requires Angular 18 or higher'); + } else { + providers.unshift(provideZonelessChangeDetectionFn()); + } + } + + const applicationRef = await queueBootstrapping(() => { + return bootstrapApplication(application, { + ...storyFnAngular.applicationConfig, + providers, + }); + }); + + applicationRefs.set(targetDOMNode, applicationRef); + } + + /** + * Only ASCII alphanumerics can be used as HTML tag name. https://html.spec.whatwg.org/#elements-2 + * + * Therefore, stories break when non-ASCII alphanumerics are included in target selector. + * https://github.com/storybookjs/storybook/issues/15147 + * + * This method returns storyId when it doesn't contain any non-ASCII alphanumerics. Otherwise, it + * generates a valid HTML tag name from storyId by removing non-ASCII alphanumerics from storyId, + * prefixing "sb-", and suffixing "-component" + * + * @memberof AbstractRenderer + * @protected + */ + protected generateTargetSelectorFromStoryId(id: string) { + const invalidHtmlTag = /[^A-Za-z0-9-]/g; + const storyIdIsInvalidHtmlTagName = invalidHtmlTag.test(id); + return storyIdIsInvalidHtmlTagName ? `sb-${id.replace(invalidHtmlTag, '')}-component` : id; + } + + /** + * Angular is unable to handle components that have selectors with accented attributes. + * + * Therefore, stories break when meta's title contains accents. + * https://github.com/storybookjs/storybook/issues/29132 + * + * This method filters accents from a given raw id. For example, this method converts + * 'Example/Button with an "é" accent' into 'Example/Button with an "e" accent'. + * + * @memberof AbstractRenderer + * @protected + */ + protected generateStoryUIdFromRawStoryUid(rawStoryUid: string | null) { + if (rawStoryUid === null) { + return rawStoryUid; + } + + const accentCharacters = /[\u0300-\u036f]/g; + return rawStoryUid.normalize('NFD').replace(accentCharacters, ''); + } + + /** Adds DOM element that angular will use as bootstrap component. */ + protected initAngularRootElement(targetDOMNode: HTMLElement, targetSelector: string) { + targetDOMNode.innerHTML = ''; + targetDOMNode.appendChild(document.createElement(targetSelector)); + } + + private fullRendererRequired({ + targetDOMNode, + storyFnAngular, + moduleMetadata, + forced, + }: { + targetDOMNode: HTMLElement; + storyFnAngular: StoryFnAngularReturnType; + moduleMetadata: NgModule; + forced: boolean; + }) { + const previousStoryRenderInfo = this.previousStoryRenderInfo.get(targetDOMNode); + + const currentStoryRender = { + storyFnAngular, + moduleMetadataSnapshot: stringify(moduleMetadata, { maxDepth: 50 }), + }; + + this.previousStoryRenderInfo.set(targetDOMNode, currentStoryRender); + + if ( + // check `forceRender` of story RenderContext + !forced || + // if it's the first rendering and storyProps$ is not init + !this.storyProps$ + ) { + return true; + } + + // force the rendering if the template has changed + const hasChangedTemplate = + !!storyFnAngular?.template && + previousStoryRenderInfo?.storyFnAngular?.template !== storyFnAngular.template; + if (hasChangedTemplate) { + return true; + } + + // force the rendering if the metadata structure has changed + const hasChangedModuleMetadata = + currentStoryRender.moduleMetadataSnapshot !== previousStoryRenderInfo?.moduleMetadataSnapshot; + + return hasChangedModuleMetadata; + } +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/CanvasRenderer.ts b/code/frameworks/angular-vite/src/client/angular-beta/CanvasRenderer.ts new file mode 100644 index 000000000000..6ff2b5c8736a --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/CanvasRenderer.ts @@ -0,0 +1,18 @@ +import type { Parameters, StoryFnAngularReturnType } from '../types'; +import { AbstractRenderer } from './AbstractRenderer'; + +export class CanvasRenderer extends AbstractRenderer { + public async render(options: { + storyFnAngular: StoryFnAngularReturnType; + forced: boolean; + parameters: Parameters; + component: any; + targetDOMNode: HTMLElement; + }) { + await super.render(options); + } + + async beforeFullRender(): Promise { + CanvasRenderer.resetApplications(); + } +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.test.ts b/code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.test.ts new file mode 100644 index 000000000000..758ac2d93d72 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.test.ts @@ -0,0 +1,747 @@ +import { Component } from '@angular/core'; +import type { ArgTypes } from 'storybook/internal/types'; +import { describe, it, expect } from 'vitest'; +import { + computesTemplateFromComponent, + computesTemplateSourceFromComponent, +} from './ComputesTemplateFromComponent'; +import type { ISomeInterface } from './__testfixtures__/input.component'; +import { ButtonAccent, InputComponent } from './__testfixtures__/input.component'; + +describe('angular template decorator', () => { + it('with props should generate tag with properties', () => { + const component = InputComponent; + const props = { + isDisabled: true, + label: 'Hello world', + accent: ButtonAccent.High, + counter: 4, + 'aria-label': 'Hello world', + }; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual( + `` + ); + }); + + it('with props should generate tag with outputs', () => { + const component = InputComponent; + const props = { + isDisabled: true, + label: 'Hello world', + onClick: ($event: any) => {}, + 'dash-out': ($event: any) => {}, + }; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual( + `` + ); + }); + + it('with no props should generate simple tag', () => { + const component = InputComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(''); + }); + + describe('with component without selector', () => { + @Component({ + template: `The content`, + }) + class WithoutSelectorComponent {} + + it('should add component ng-container', async () => { + const component = WithoutSelectorComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with attribute selector', () => { + @Component({ + selector: 'doc-button[foo]', + template: '', + }) + class WithAttributeComponent {} + + it('should add attribute to template', async () => { + const component = WithAttributeComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with attribute and value selector', () => { + @Component({ + selector: 'doc-button[foo="bar"]', + template: '', + }) + class WithAttributeValueComponent {} + + it('should add attribute to template', async () => { + const component = WithAttributeValueComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with attribute only selector', () => { + @Component({ + selector: '[foo]', + template: '', + }) + class WithAttributeOnlyComponent {} + + it('should create a div and add attribute to template', async () => { + const component = WithAttributeOnlyComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(`
`); + }); + }); + + describe('with component with void element and attribute selector', () => { + @Component({ + selector: 'input[foo]', + template: '', + }) + class VoidElementWithAttributeComponent {} + + it('should create without separate closing tag', async () => { + const component = VoidElementWithAttributeComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with attribute and value only selector', () => { + @Component({ + selector: '[foo="bar"]', + template: '', + }) + class WithAttributeOnlyComponent {} + + it('should create a div and add attribute to template', async () => { + const component = WithAttributeOnlyComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(`
`); + }); + }); + + describe('with component with void element, attribute and value only selector', () => { + @Component({ + selector: 'input[foo="bar"]', + template: '', + }) + class VoidElementWithAttributeComponent {} + + it('should create and add attribute to template without separate closing tag', async () => { + const component = VoidElementWithAttributeComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with class selector', () => { + @Component({ + selector: 'doc-button.foo', + template: '', + }) + class WithClassComponent {} + + it('should add class to template', async () => { + const component = WithClassComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with class only selector', () => { + @Component({ + selector: '.foo', + template: '', + }) + class WithClassComponent {} + + it('should create a div and add attribute to template', async () => { + const component = WithClassComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(`
`); + }); + }); + + describe('with component with multiple selectors', () => { + @Component({ + selector: 'doc-button, doc-button2', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors starting with attribute', () => { + @Component({ + selector: 'doc-button[foo], doc-button2', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors starting with attribute and value', () => { + @Component({ + selector: 'doc-button[foo="bar"], doc-button2', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors including 2 attributes and a class', () => { + @Component({ + selector: 'doc-button, button[foo], .button[foo], button[baz]', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors with line breaks', () => { + @Component({ + selector: `doc-button, + doc-button2`, + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors starting with attribute only with line breaks', () => { + @Component({ + selector: `[foo], + doc-button2`, + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(`
`); + }); + }); + + it('with props should generate tag with properties', () => { + const component = InputComponent; + const props = { + isDisabled: true, + label: 'Hello world', + accent: ButtonAccent.High, + counter: 4, + }; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual( + `` + ); + }); + + it('with props should generate tag with outputs', () => { + const component = InputComponent; + const props = { + isDisabled: true, + label: 'Hello world', + onClick: ($event: any) => {}, + }; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual( + `` + ); + }); + + it('should generate correct property for overridden name for Input', () => { + const component = InputComponent; + const props = { + color: '#ffffff', + }; + const source = computesTemplateFromComponent(component, props); + expect(source).toEqual(``); + }); +}); + +describe('angular source decorator', () => { + it('with no props should generate simple tag', () => { + const component = InputComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(''); + }); + + describe('with component without selector', () => { + @Component({ + template: `The content`, + }) + class WithoutSelectorComponent {} + + it('should add component ng-container', async () => { + const component = WithoutSelectorComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual( + `` + ); + }); + }); + + describe('with component with attribute selector', () => { + @Component({ + selector: 'doc-button[foo]', + template: '', + }) + class WithAttributeComponent {} + + it('should add attribute to template', async () => { + const component = WithAttributeComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with attribute and value selector', () => { + @Component({ + selector: 'doc-button[foo="bar"]', + template: '', + }) + class WithAttributeValueComponent {} + + it('should add attribute to template', async () => { + const component = WithAttributeValueComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with attribute only selector', () => { + @Component({ + selector: '[foo]', + template: '', + }) + class WithAttributeOnlyComponent {} + + it('should create a div and add attribute to template', async () => { + const component = WithAttributeOnlyComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(`
`); + }); + }); + + describe('with component with void element and attribute selector', () => { + @Component({ + selector: 'input[foo]', + template: '', + }) + class VoidElementWithAttributeComponent {} + + it('should create without separate closing tag', async () => { + const component = VoidElementWithAttributeComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with attribute and value only selector', () => { + @Component({ + selector: '[foo="bar"]', + template: '', + }) + class WithAttributeOnlyComponent {} + + it('should create a div and add attribute to template', async () => { + const component = WithAttributeOnlyComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(`
`); + }); + }); + + describe('with component with void element, attribute and value only selector', () => { + @Component({ + selector: 'input[foo="bar"]', + template: '', + }) + class VoidElementWithAttributeComponent {} + + it('should create and add attribute to template without separate closing tag', async () => { + const component = VoidElementWithAttributeComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with class selector', () => { + @Component({ + selector: 'doc-button.foo', + template: '', + }) + class WithClassComponent {} + + it('should add class to template', async () => { + const component = WithClassComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with class only selector', () => { + @Component({ + selector: '.foo', + template: '', + }) + class WithClassComponent {} + + it('should create a div and add attribute to template', async () => { + const component = WithClassComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(`
`); + }); + }); + + describe('with component with multiple selectors', () => { + @Component({ + selector: 'doc-button, doc-button2', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors starting with attribute', () => { + @Component({ + selector: 'doc-button[foo], doc-button2', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors starting with attribute and value', () => { + @Component({ + selector: 'doc-button[foo="bar"], doc-button2', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors including 2 attributes and a class', () => { + @Component({ + selector: 'doc-button, button[foo], .button[foo], button[baz]', + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors with line breaks', () => { + @Component({ + selector: `doc-button, + doc-button2`, + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with component with multiple selectors starting with attribute only with line breaks', () => { + @Component({ + selector: `[foo], + doc-button2`, + template: '', + }) + class WithMultipleSelectorsComponent {} + + it('should use the first selector', async () => { + const component = WithMultipleSelectorsComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(`
`); + }); + }); + + describe('no argTypes', () => { + it('should generate tag-only template with no props', () => { + const component = InputComponent; + const props = {}; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + it('with props should generate tag with properties', () => { + const component = InputComponent; + const props = { + isDisabled: true, + label: 'Hello world', + accent: ButtonAccent.High, + counter: 4, + 'aria-label': 'Hello world', + }; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual( + `` + ); + }); + + it('with props should generate tag with outputs', () => { + const component = InputComponent; + const props = { + isDisabled: true, + label: 'Hello world', + onClick: ($event: any) => {}, + 'dash-out': ($event: any) => {}, + }; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual( + `` + ); + }); + + it('should generate correct property for overridden name for Input', () => { + const component = InputComponent; + const props = { + color: '#ffffff', + }; + const argTypes: ArgTypes = {}; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual(``); + }); + }); + + describe('with argTypes (from compodoc)', () => { + it('should handle enum as strongly typed enum', () => { + const component = InputComponent; + const props = { + isDisabled: false, + label: 'Hello world', + accent: ButtonAccent.High, + }; + const argTypes: ArgTypes = { + accent: { + control: { + options: ['Normal', 'High'], + type: 'radio', + }, + defaultValue: undefined, + table: { + category: 'inputs', + }, + type: { + name: 'enum', + required: true, + value: [], + }, + }, + }; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual( + `` + ); + }); + + it('should handle enum without values as string', () => { + const component = InputComponent; + const props = { + isDisabled: false, + label: 'Hello world', + accent: ButtonAccent.High, + }; + const argTypes: ArgTypes = { + accent: { + control: { + options: ['Normal', 'High'], + type: 'radio', + }, + defaultValue: undefined, + table: { + category: 'inputs', + }, + type: { + name: 'object', + required: true, + value: {}, + }, + }, + }; + const source = computesTemplateSourceFromComponent(component, props, argTypes); + expect(source).toEqual( + `` + ); + }); + + it('should handle simple object as stringified', () => { + const component = InputComponent; + + const someDataObject: ISomeInterface = { + one: 'Hello world', + two: true, + three: [ + `a string literal with "double quotes"`, + `a string literal with 'single quotes'`, + 'a single quoted string with "double quotes"', + "a double quoted string with 'single quotes'", + + "a single quoted string with escaped 'single quotes'", + + 'a double quoted string with escaped "double quotes"', + + `a string literal with \'escaped single quotes\'`, + + `a string literal with \"escaped double quotes\"`, + ], + }; + + const props = { + isDisabled: false, + label: 'Hello world', + someDataObject, + }; + + const source = computesTemplateSourceFromComponent(component, props, null); + // Ideally we should stringify the object, but that could cause the story to break because of unescaped values in the JSON object. + // This will have to do for now + expect(source).toEqual( + `` + ); + }); + + it('should handle circular object as stringified', () => { + const component = InputComponent; + + const someDataObject: ISomeInterface = { + one: 'Hello world', + two: true, + three: [ + `a string literal with "double quotes"`, + `a string literal with 'single quotes'`, + 'a single quoted string with "double quotes"', + "a double quoted string with 'single quotes'", + + "a single quoted string with escaped 'single quotes'", + + 'a double quoted string with escaped "double quotes"', + + `a string literal with \'escaped single quotes\'`, + + `a string literal with \"escaped double quotes\"`, + ], + }; + someDataObject.ref = someDataObject; + + const props = { + isDisabled: false, + label: 'Hello world', + someDataObject, + }; + + const source = computesTemplateSourceFromComponent(component, props, null); + // Ideally we should stringify the object, but that could cause the story to break because of unescaped values in the JSON object. + // This will have to do for now + expect(source).toEqual( + `` + ); + }); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.ts b/code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.ts new file mode 100644 index 000000000000..523bb6c338e5 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/ComputesTemplateFromComponent.ts @@ -0,0 +1,231 @@ +import type { ArgTypes } from 'storybook/internal/types'; + +import type { Type } from '@angular/core'; + +import type { ICollection } from '../types'; +import type { ComponentInputsOutputs } from './utils/NgComponentAnalyzer'; +import { + getComponentDecoratorMetadata, + getComponentInputsOutputs, +} from './utils/NgComponentAnalyzer'; + +/** + * Check if the name matches the criteria for a valid identifier. A valid identifier can only + * contain letters, digits, underscores, or dollar signs. It cannot start with a digit. + */ +const isValidIdentifier = (name: string): boolean => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name); + +/** + * Returns the property name, if it can be accessed with dot notation. If not, it returns + * `this['propertyName']`. + */ +export const formatPropInTemplate = (propertyName: string) => + isValidIdentifier(propertyName) ? propertyName : `this['${propertyName}']`; + +const separateInputsOutputsAttributes = ( + ngComponentInputsOutputs: ComponentInputsOutputs, + props: ICollection = {} +) => { + const inputs = ngComponentInputsOutputs.inputs + .filter((i) => i.templateName in props) + .map((i) => i.templateName); + const outputs = ngComponentInputsOutputs.outputs + .filter((o) => o.templateName in props) + .map((o) => o.templateName); + + return { + inputs, + outputs, + otherProps: Object.keys(props).filter((k) => ![...inputs, ...outputs].includes(k)), + }; +}; + +/** + * Converts a component into a template with inputs/outputs present in initial props + * + * @param component + * @param initialProps + * @param innerTemplate + */ +export const computesTemplateFromComponent = ( + component: Type, + initialProps?: ICollection, + innerTemplate = '' +) => { + const ngComponentMetadata = getComponentDecoratorMetadata(component); + const ngComponentInputsOutputs = getComponentInputsOutputs(component); + + if (!ngComponentMetadata.selector) { + // Allow to add renderer component when NgComponent selector is undefined + return ``; + } + + const { inputs: initialInputs, outputs: initialOutputs } = separateInputsOutputsAttributes( + ngComponentInputsOutputs, + initialProps + ); + + const templateInputs = + initialInputs.length > 0 + ? ` ${initialInputs.map((i) => `[${i}]="${formatPropInTemplate(i)}"`).join(' ')}` + : ''; + const templateOutputs = + initialOutputs.length > 0 + ? ` ${initialOutputs.map((i) => `(${i})="${formatPropInTemplate(i)}($event)"`).join(' ')}` + : ''; + + return buildTemplate( + ngComponentMetadata.selector, + innerTemplate, + templateInputs, + templateOutputs + ); +}; + +/** Stringify an object with a placholder in the circular references. */ +function stringifyCircular(obj: any) { + const seen = new Set(); + return JSON.stringify(obj, (key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + } + return value; + }); +} + +const createAngularInputProperty = ({ + propertyName, + value, + argType, +}: { + propertyName: string; + value: any; + argType?: ArgTypes[string]; +}) => { + let templateValue; + switch (typeof value) { + case 'string': + templateValue = `'${value}'`; + break; + case 'object': + templateValue = stringifyCircular(value) + .replace(/'/g, '\u2019') + .replace(/\\"/g, '\u201D') + .replace(/"([^-"]+)":/g, '$1: ') + .replace(/"/g, "'") + .replace(/\u2019/g, "\\'") + .replace(/\u201D/g, "\\'") + .split(',') + .join(', '); + break; + default: + templateValue = value; + } + + return `[${propertyName}]="${templateValue}"`; +}; + +/** + * Converts a component into a template with inputs/outputs present in initial props + * + * @param component + * @param initialProps + * @param innerTemplate + */ +export const computesTemplateSourceFromComponent = ( + component: Type, + initialProps?: ICollection, + argTypes?: ArgTypes +) => { + const ngComponentMetadata = getComponentDecoratorMetadata(component); + if (!ngComponentMetadata) { + return null; + } + + if (!ngComponentMetadata.selector) { + // Allow to add renderer component when NgComponent selector is undefined + return ``; + } + + const ngComponentInputsOutputs = getComponentInputsOutputs(component); + const { inputs: initialInputs, outputs: initialOutputs } = separateInputsOutputsAttributes( + ngComponentInputsOutputs, + initialProps + ); + + const templateInputs = + initialInputs.length > 0 + ? ` ${initialInputs + .map((propertyName) => + createAngularInputProperty({ + propertyName, + value: initialProps[propertyName], + argType: argTypes?.[propertyName], + }) + ) + .join(' ')}` + : ''; + const templateOutputs = + initialOutputs.length > 0 + ? ` ${initialOutputs.map((i) => `(${i})="${formatPropInTemplate(i)}($event)"`).join(' ')}` + : ''; + + return buildTemplate(ngComponentMetadata.selector, '', templateInputs, templateOutputs); +}; + +const buildTemplate = ( + selector: string, + innerTemplate: string, + inputs: string, + outputs: string +) => { + // https://www.w3.org/TR/2011/WD-html-markup-20110113/syntax.html#syntax-elements + const voidElements = [ + 'area', + 'base', + 'br', + 'col', + 'command', + 'embed', + 'hr', + 'img', + 'input', + 'keygen', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', + ]; + + const firstSelector = selector.split(',')[0]; + const templateReplacers: [ + string | RegExp, + string | ((substring: string, ...args: any[]) => string), + ][] = [ + [/(^.*?)(?=[,])/, '$1'], + [/(^\..+)/, 'div$1'], + [/(^\[.+?])/, 'div$1'], + [/([\w[\]]+)(\s*,[\w\s-[\],]+)+/, `$1`], + [/#([\w-]+)/, ` id="$1"`], + [/((\.[\w-]+)+)/, (_, c) => ` class="${c.split`.`.join` `.trim()}"`], + [/(\[.+?])/g, (_, a) => ` ${a.slice(1, -1)}`], + [ + /([\S]+)(.*)/, + (template, elementSelector) => { + return voidElements.some((element) => elementSelector === element) + ? template.replace(/([\S]+)(.*)/, `<$1$2${inputs}${outputs} />`) + : template.replace(/([\S]+)(.*)/, `<$1$2${inputs}${outputs}>${innerTemplate}`); + }, + ], + ]; + + return templateReplacers.reduce( + (prevSelector, [searchValue, replacer]) => prevSelector.replace(searchValue, replacer as any), + firstSelector + ); +}; diff --git a/code/frameworks/angular-vite/src/client/angular-beta/DocsRenderer.ts b/code/frameworks/angular-vite/src/client/angular-beta/DocsRenderer.ts new file mode 100644 index 000000000000..84bfdb9f2f26 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/DocsRenderer.ts @@ -0,0 +1,52 @@ +import { DOCS_RENDERED, STORY_CHANGED } from 'storybook/internal/core-events'; +import { addons } from 'storybook/preview-api'; + +import type { Parameters, StoryFnAngularReturnType } from '../types'; +import { AbstractRenderer, STORY_UID_ATTRIBUTE } from './AbstractRenderer'; +import { getNextStoryUID } from './utils/StoryUID'; + +export class DocsRenderer extends AbstractRenderer { + public async render(options: { + storyFnAngular: StoryFnAngularReturnType; + forced: boolean; + component: any; + parameters: Parameters; + targetDOMNode: HTMLElement; + }) { + const channel = addons.getChannel(); + /** + * Destroy and recreate the PlatformBrowserDynamic of angular For several stories to be rendered + * in the same docs we should not destroy angular between each rendering but do it when the + * rendered stories are not needed anymore. + * + * Note for improvement: currently there is one event per story rendered in the doc. But one + * event could be enough for the whole docs + */ + channel.once(STORY_CHANGED, async () => { + await DocsRenderer.resetApplications(); + }); + + /** + * Destroy and recreate the PlatformBrowserDynamic of angular when doc re render. Allows to call + * ngOnDestroy of angular for previous component + */ + channel.once(DOCS_RENDERED, async () => { + await DocsRenderer.resetApplications(); + }); + + await super.render({ ...options, forced: false }); + } + + async beforeFullRender(domNode?: HTMLElement): Promise { + DocsRenderer.resetApplications(domNode); + } + + protected override initAngularRootElement( + targetDOMNode: HTMLElement, + targetSelector: string + ): void { + super.initAngularRootElement(targetDOMNode, targetSelector); + + targetDOMNode.setAttribute(STORY_UID_ATTRIBUTE, getNextStoryUID(targetDOMNode.id)); + } +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.test.ts b/code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.test.ts new file mode 100644 index 000000000000..f5550730b3e4 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.test.ts @@ -0,0 +1,273 @@ +// @vitest-environment happy-dom + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { Component, ɵresetJitOptions } from '@angular/core'; +import { platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { CanvasRenderer } from './CanvasRenderer'; +import { RendererFactory } from './RendererFactory'; +import { DocsRenderer } from './DocsRenderer'; + +vi.mock('@angular/platform-browser-dynamic'); + +declare const document: Document; +describe('RendererFactory', () => { + let rendererFactory: RendererFactory; + let rootTargetDOMNode: HTMLElement; + let rootDocstargetDOMNode: HTMLElement; + + beforeEach(async () => { + rendererFactory = new RendererFactory(); + document.body.innerHTML = + '
' + + '
'; + rootTargetDOMNode = global.document.getElementById('storybook-root'); + rootDocstargetDOMNode = global.document.getElementById('root-docs'); + (platformBrowserDynamic as any).mockImplementation(platformBrowserDynamicTesting); + vi.spyOn(console, 'log').mockImplementation(() => {}); + // @ts-expect-error Ignore + globalThis.STORYBOOK_ANGULAR_OPTIONS = { experimentalZoneless: false }; + }); + + afterEach(() => { + vi.clearAllMocks(); + + // Necessary to avoid this error "Provided value for `preserveWhitespaces` can not be changed once it has been set." : + // Source: https://github.com/angular/angular/commit/e342ffd855ffeb8af7067b42307ffa320d82177e#diff-92b125e532cc22977b46a91f068d6d7ea81fd61b772842a4a0212f1cfd875be6R28 + ɵresetJitOptions(); + }); + + describe('CanvasRenderer', () => { + it('should get CanvasRenderer instance', async () => { + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + expect(render).toBeInstanceOf(CanvasRenderer); + }); + + it('should render my-story for story template', async () => { + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + await render?.render({ + storyFnAngular: { + template: '🦊', + props: {}, + }, + forced: false, + targetDOMNode: rootTargetDOMNode, + }); + + expect(document.body.getElementsByTagName('storybook-root')[0].innerHTML).toBe('🦊'); + }); + + it('should render my-story for story component', async () => { + @Component({ selector: 'foo', template: '🦊' }) + class FooComponent {} + + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + await render?.render({ + storyFnAngular: { + props: {}, + }, + forced: false, + component: FooComponent, + targetDOMNode: rootTargetDOMNode, + }); + + expect(document.body.getElementsByTagName('storybook-root')[0].innerHTML).toBe( + '🦊' + ); + }); + + it('should handle circular reference in moduleMetadata', async () => { + class Thing { + token: Thing; + + constructor() { + this.token = this; + } + } + const token = new Thing(); + + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + + await render?.render({ + storyFnAngular: { + template: '🦊', + props: {}, + moduleMetadata: { providers: [{ provide: 'foo', useValue: token }] }, + }, + forced: false, + targetDOMNode: rootTargetDOMNode, + }); + + expect(document.body.getElementsByTagName('storybook-root')[0].innerHTML).toBe('🦊'); + }); + + describe('when forced=true', () => { + beforeEach(async () => { + // Init first render + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + await render?.render({ + storyFnAngular: { + template: '{{ logo }}: {{ name }}', + props: { + logo: '🦊', + name: 'Fox', + }, + }, + forced: true, + targetDOMNode: rootTargetDOMNode, + }); + }); + + it('should be rendered a first time', async () => { + expect(document.body.getElementsByTagName('storybook-root')[0].innerHTML).toBe('🦊: Fox'); + }); + + it('should not be re-rendered when only props change', async () => { + // only props change + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + await render?.render({ + storyFnAngular: { + props: { + logo: '👾', + }, + }, + forced: true, + targetDOMNode: rootTargetDOMNode, + }); + + expect(document.body.getElementsByTagName('storybook-root')[0].innerHTML).toBe('👾: Fox'); + }); + + it('should be re-rendered when template change', async () => { + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + await render?.render({ + storyFnAngular: { + template: '{{ beer }}', + props: { + beer: '🍺', + }, + }, + forced: true, + targetDOMNode: rootTargetDOMNode, + }); + + expect(document.body.getElementsByTagName('storybook-root')[0].innerHTML).toBe('🍺'); + }); + }); + }); + + describe('DocsRenderer', () => { + describe('when canvas render is done before', () => { + beforeEach(async () => { + // Init first Canvas render + const render = await rendererFactory.getRendererInstance(rootTargetDOMNode); + await render?.render({ + storyFnAngular: { + template: 'Canvas 🖼', + }, + forced: true, + targetDOMNode: rootTargetDOMNode, + }); + }); + + it('should reset root HTML', async () => { + global.document + .getElementById('storybook-root') + .appendChild(global.document.createElement('👾')); + + expect(global.document.getElementById('storybook-root').innerHTML).toContain('Canvas 🖼'); + await rendererFactory.getRendererInstance(rootDocstargetDOMNode); + expect(global.document.getElementById('storybook-root').innerHTML).toBe(''); + }); + }); + + it('should get DocsRenderer instance', async () => { + const render = await rendererFactory.getRendererInstance(rootDocstargetDOMNode); + expect(render).toBeInstanceOf(DocsRenderer); + }); + + describe('when multiple story for the same component', () => { + it('should render both stories', async () => { + @Component({ selector: 'foo', template: '🦊' }) + class FooComponent {} + + const render = await rendererFactory.getRendererInstance( + global.document.getElementById('storybook-docs') + ); + + const targetDOMNode1 = global.document.createElement('div'); + targetDOMNode1.id = 'story-1'; + global.document.getElementById('storybook-docs').appendChild(targetDOMNode1); + await render?.render({ + storyFnAngular: { + props: {}, + }, + forced: false, + component: FooComponent, + targetDOMNode: targetDOMNode1, + }); + + const targetDOMNode2 = global.document.createElement('div'); + targetDOMNode2.id = 'story-1'; + global.document.getElementById('storybook-docs').appendChild(targetDOMNode2); + await render?.render({ + storyFnAngular: { + props: {}, + }, + forced: false, + component: FooComponent, + targetDOMNode: targetDOMNode2, + }); + + expect(global.document.querySelectorAll('#story-1 > story-1')[0].innerHTML).toBe( + '🦊' + ); + expect(global.document.querySelectorAll('#story-1 > story-1')[1].innerHTML).toBe( + '🦊' + ); + }); + }); + + describe('when bootstrapping multiple stories in parallel', () => { + it('should render both stories', async () => { + @Component({ selector: 'foo', template: '🦊' }) + class FooComponent {} + + const render = await rendererFactory.getRendererInstance( + global.document.getElementById('storybook-docs') + ); + + const targetDOMNode1 = global.document.createElement('div'); + targetDOMNode1.id = 'story-1'; + global.document.getElementById('storybook-docs').appendChild(targetDOMNode1); + + const targetDOMNode2 = global.document.createElement('div'); + targetDOMNode2.id = 'story-2'; + global.document.getElementById('storybook-docs').appendChild(targetDOMNode2); + + await Promise.all([ + render.render({ + storyFnAngular: {}, + forced: false, + component: FooComponent, + targetDOMNode: targetDOMNode1, + }), + render.render({ + storyFnAngular: {}, + forced: false, + component: FooComponent, + targetDOMNode: targetDOMNode2, + }), + ]); + + expect(global.document.querySelector('#story-1 > story-1').innerHTML).toBe( + '🦊' + ); + expect(global.document.querySelector('#story-2 > story-2').innerHTML).toBe( + '🦊' + ); + }); + }); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.ts b/code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.ts new file mode 100644 index 000000000000..afe1ce91000b --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/RendererFactory.ts @@ -0,0 +1,60 @@ +import { AbstractRenderer } from './AbstractRenderer'; +import { CanvasRenderer } from './CanvasRenderer'; +import { DocsRenderer } from './DocsRenderer'; + +type RenderType = 'canvas' | 'docs'; +export class RendererFactory { + private lastRenderType: RenderType; + + private rendererMap = new Map(); + + public async getRendererInstance(targetDOMNode: HTMLElement): Promise { + const targetId = targetDOMNode.id; + // do nothing if the target node is null + // fix a problem when the docs asks 2 times the same component at the same time + // the 1st targetDOMNode of the 1st requested rendering becomes null 🤷‍♂️ + if (targetDOMNode === null) { + return null; + } + + const renderType = getRenderType(targetDOMNode); + // keep only instances of the same type + if (this.lastRenderType && this.lastRenderType !== renderType) { + await AbstractRenderer.resetApplications(); + clearRootHTMLElement(renderType); + this.rendererMap.clear(); + } + + if (!this.rendererMap.has(targetId)) { + this.rendererMap.set(targetId, this.buildRenderer(renderType)); + } + + this.lastRenderType = renderType; + return this.rendererMap.get(targetId); + } + + private buildRenderer(renderType: RenderType) { + if (renderType === 'docs') { + return new DocsRenderer(); + } + return new CanvasRenderer(); + } +} + +export const getRenderType = (targetDOMNode: HTMLElement): RenderType => { + return targetDOMNode.id === 'storybook-root' ? 'canvas' : 'docs'; +}; + +export function clearRootHTMLElement(renderType: RenderType) { + switch (renderType) { + case 'canvas': + global.document.getElementById('storybook-docs').innerHTML = ''; + break; + + case 'docs': + global.document.getElementById('storybook-root').innerHTML = ''; + break; + default: + break; + } +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.test.ts b/code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.test.ts new file mode 100644 index 000000000000..6411cdcb81a2 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.test.ts @@ -0,0 +1,370 @@ +// @vitest-environment happy-dom + +import type { NgModule } from '@angular/core'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { describe, expect, it } from 'vitest'; + +import { TestBed } from '@angular/core/testing'; +import { BehaviorSubject } from 'rxjs'; +import type { ICollection } from '../types'; +import { getApplication } from './StorybookModule'; +import { storyPropsProvider } from './StorybookProvider'; +import { PropertyExtractor } from './utils/PropertyExtractor'; + +describe('StorybookModule', () => { + describe('getStorybookModuleMetadata', () => { + describe('with simple component', () => { + @Component({ + selector: 'foo', + template: ` +

{{ input }}

+

{{ localPropertyName }}

+

{{ setterCallNb }}

+

{{ localProperty }}

+

{{ localFunction() }}

+

+

+ `, + }) + class FooComponent { + @Input() + public input: string; + + @Input('inputBindingPropertyName') + public localPropertyName: string; + + @Input() + public set setter(value: string) { + this.setterCallNb += 1; + } + + @Output() + public output = new EventEmitter(); + + @Output('outputBindingPropertyName') + public localOutput = new EventEmitter(); + + public localProperty: string; + + public localFunction = () => ''; + + public setterCallNb = 0; + } + + it('should initialize inputs', async () => { + const props = { + input: 'input', + inputBindingPropertyName: 'inputBindingPropertyName', + localProperty: 'localProperty', + localFunction: () => 'localFunction', + }; + + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { props }, + component: FooComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(new BehaviorSubject(props))], + }); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(props.input); + expect(fixture.nativeElement.querySelector('p#inputBindingPropertyName').innerHTML).toEqual( + props.inputBindingPropertyName + ); + expect(fixture.nativeElement.querySelector('p#localProperty').innerHTML).toEqual( + props.localProperty + ); + expect(fixture.nativeElement.querySelector('p#localFunction').innerHTML).toEqual( + props.localFunction() + ); + }); + + it('should initialize outputs', async () => { + let expectedOutputValue: string; + let expectedOutputBindingValue: string; + const props = { + output: (value: string) => { + expectedOutputValue = value; + }, + outputBindingPropertyName: (value: string) => { + expectedOutputBindingValue = value; + }, + }; + + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { props }, + component: FooComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(new BehaviorSubject(props))], + }); + fixture.detectChanges(); + + fixture.nativeElement.querySelector('p#output').click(); + fixture.nativeElement.querySelector('p#outputBindingPropertyName').click(); + + expect(expectedOutputValue).toEqual('outputEmitted'); + expect(expectedOutputBindingValue).toEqual('outputEmitted'); + }); + + it('should change inputs if storyProps$ Subject emit', async () => { + const initialProps = { + input: 'input', + inputBindingPropertyName: '', + }; + const storyProps$ = new BehaviorSubject(initialProps); + + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { props: initialProps }, + component: FooComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(storyProps$)], + }); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual( + initialProps.input + ); + expect(fixture.nativeElement.querySelector('p#inputBindingPropertyName').innerHTML).toEqual( + '' + ); + + const newProps = { + input: 'new input', + inputBindingPropertyName: 'new inputBindingPropertyName', + localProperty: 'new localProperty', + localFunction: () => 'new localFunction', + }; + storyProps$.next(newProps); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(newProps.input); + expect(fixture.nativeElement.querySelector('p#inputBindingPropertyName').innerHTML).toEqual( + newProps.inputBindingPropertyName + ); + expect(fixture.nativeElement.querySelector('p#localProperty').innerHTML).toEqual( + newProps.localProperty + ); + expect(fixture.nativeElement.querySelector('p#localFunction').innerHTML).toEqual( + newProps.localFunction() + ); + }); + + it('should override outputs if storyProps$ Subject emit', async () => { + let expectedOutputValue; + let expectedOutputBindingValue; + const initialProps = { + input: '', + output: (value: string) => { + expectedOutputValue = value; + }, + outputBindingPropertyName: (value: string) => { + expectedOutputBindingValue = value; + }, + }; + const storyProps$ = new BehaviorSubject(initialProps); + + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { props: initialProps }, + component: FooComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(storyProps$)], + }); + fixture.detectChanges(); + + const newProps = { + input: 'new input', + output: () => { + expectedOutputValue = 'should be called'; + }, + outputBindingPropertyName: () => { + expectedOutputBindingValue = 'should be called'; + }, + }; + storyProps$.next(newProps); + fixture.detectChanges(); + + fixture.nativeElement.querySelector('p#output').click(); + fixture.nativeElement.querySelector('p#outputBindingPropertyName').click(); + + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(newProps.input); + expect(expectedOutputValue).toEqual('should be called'); + expect(expectedOutputBindingValue).toEqual('should be called'); + }); + + it('should change template inputs if storyProps$ Subject emit', async () => { + const initialProps = { + color: 'red', + input: 'input', + }; + const storyProps$ = new BehaviorSubject(initialProps); + + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { + props: initialProps, + template: '

', + }, + component: FooComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(storyProps$)], + }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('p').style.color).toEqual('red'); + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual( + initialProps.input + ); + + const newProps = { + color: 'black', + input: 'new input', + }; + storyProps$.next(newProps); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p').style.color).toEqual('black'); + expect(fixture.nativeElement.querySelector('p#input').innerHTML).toEqual(newProps.input); + }); + + it('should call the Input() setter the right number of times', async () => { + const initialProps = { + setter: 'init', + }; + const storyProps$ = new BehaviorSubject(initialProps); + + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { props: initialProps }, + component: FooComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(storyProps$)], + }); + + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p#setterCallNb').innerHTML).toEqual('1'); + + const newProps = { + setter: 'new setter value', + }; + storyProps$.next(newProps); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p#setterCallNb').innerHTML).toEqual('2'); + }); + }); + + describe('with component without selector', () => { + @Component({ + template: `The content`, + }) + class WithoutSelectorComponent {} + + it('should display the component', async () => { + const props = {}; + + const analyzedMetadata = new PropertyExtractor( + { entryComponents: [WithoutSelectorComponent] }, + WithoutSelectorComponent + ); + + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { + props, + moduleMetadata: { entryComponents: [WithoutSelectorComponent] }, + }, + component: WithoutSelectorComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(new BehaviorSubject(props))], + }); + fixture.detectChanges(); + + expect(fixture.nativeElement.innerHTML).toContain('The content'); + }); + }); + + it('should keep template with an empty value', async () => { + @Component({ + selector: 'foo', + template: `Should not be displayed`, + }) + class FooComponent {} + + const analyzedMetadata = new PropertyExtractor({}, FooComponent); + await analyzedMetadata.init(); + + const application = getApplication({ + storyFnAngular: { template: '' }, + component: FooComponent, + targetSelector: 'my-selector', + analyzedMetadata, + }); + + const { fixture } = await configureTestingModule({ + imports: [application], + providers: [storyPropsProvider(new BehaviorSubject({}))], + }); + fixture.detectChanges(); + + expect(fixture.nativeElement.innerHTML).toEqual(''); + }); + }); + + async function configureTestingModule(ngModule: NgModule) { + await TestBed.configureTestingModule(ngModule).compileComponents(); + + const fixture = TestBed.createComponent(ngModule.imports[0] as any); + + return { + fixture, + }; + } +}); diff --git a/code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.ts b/code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.ts new file mode 100644 index 000000000000..e2834517f39d --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/StorybookModule.ts @@ -0,0 +1,39 @@ +import type { StoryFnAngularReturnType } from '../types'; +import { computesTemplateFromComponent } from './ComputesTemplateFromComponent'; +import { createStorybookWrapperComponent } from './StorybookWrapperComponent'; +import type { PropertyExtractor } from './utils/PropertyExtractor'; + +export const getApplication = ({ + storyFnAngular, + component, + targetSelector, + analyzedMetadata, +}: { + storyFnAngular: StoryFnAngularReturnType; + component?: any; + targetSelector: string; + analyzedMetadata: PropertyExtractor; +}) => { + const { props, styles, moduleMetadata = {} } = storyFnAngular; + let { template } = storyFnAngular; + + const hasTemplate = !hasNoTemplate(template); + if (!hasTemplate && component) { + template = computesTemplateFromComponent(component, props, ''); + } + + /** Create a component that wraps generated template and gives it props */ + return createStorybookWrapperComponent({ + moduleMetadata, + selector: targetSelector, + template, + storyComponent: component, + styles, + initialProps: props, + analyzedMetadata, + }); +}; + +function hasNoTemplate(template: string | null | undefined): template is undefined { + return template === null || template === undefined; +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/StorybookProvider.ts b/code/frameworks/angular-vite/src/client/angular-beta/StorybookProvider.ts new file mode 100644 index 000000000000..846496eb10c3 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/StorybookProvider.ts @@ -0,0 +1,35 @@ +import type { Provider } from '@angular/core'; +import { InjectionToken, NgZone } from '@angular/core'; +import type { Subject, Subscriber } from 'rxjs'; +import { Observable } from 'rxjs'; + +import type { ICollection } from '../types'; + +export const STORY_PROPS = new InjectionToken>('STORY_PROPS'); + +export const storyPropsProvider = (storyProps$: Subject): Provider => ({ + provide: STORY_PROPS, + useFactory: storyDataFactory(storyProps$.asObservable()), + deps: [NgZone], +}); + +function storyDataFactory(data: Observable) { + return (ngZone: NgZone) => + new Observable((subscriber: Subscriber) => { + const sub = data.subscribe( + (v: T) => { + ngZone.run(() => subscriber.next(v)); + }, + (err) => { + ngZone.run(() => subscriber.error(err)); + }, + () => { + ngZone.run(() => subscriber.complete()); + } + ); + + return () => { + sub.unsubscribe(); + }; + }); +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/StorybookWrapperComponent.ts b/code/frameworks/angular-vite/src/client/angular-beta/StorybookWrapperComponent.ts new file mode 100644 index 000000000000..afbd08c13376 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/StorybookWrapperComponent.ts @@ -0,0 +1,151 @@ +import type { AfterViewInit, ElementRef, OnDestroy, Type } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + Inject, + NgModule, + ViewChild, + ViewContainerRef, +} from '@angular/core'; +import type { Subject, Subscription } from 'rxjs'; +import { map, skip } from 'rxjs/operators'; + +import type { ICollection, NgModuleMetadata } from '../types'; +import { STORY_PROPS } from './StorybookProvider'; +import type { ComponentInputsOutputs } from './utils/NgComponentAnalyzer'; +import { getComponentInputsOutputs } from './utils/NgComponentAnalyzer'; +import { PropertyExtractor } from './utils/PropertyExtractor'; + +const getNonInputsOutputsProps = ( + ngComponentInputsOutputs: ComponentInputsOutputs, + props: ICollection = {} +) => { + const inputs = ngComponentInputsOutputs.inputs + .filter((i) => i.templateName in props) + .map((i) => i.templateName); + const outputs = ngComponentInputsOutputs.outputs + .filter((o) => o.templateName in props) + .map((o) => o.templateName); + return Object.keys(props).filter((k) => ![...inputs, ...outputs].includes(k)); +}; + +/** Wraps the story template into a component */ +export const createStorybookWrapperComponent = ({ + selector, + template, + storyComponent, + styles, + moduleMetadata, + initialProps, + analyzedMetadata, +}: { + selector: string; + template: string; + storyComponent: Type | undefined; + styles: string[]; + moduleMetadata: NgModuleMetadata; + initialProps?: ICollection; + analyzedMetadata: PropertyExtractor; +}): Type => { + // In ivy, a '' selector is not allowed, therefore we need to just set it to anything if + // storyComponent was not provided. + const viewChildSelector = storyComponent ?? '__storybook-noop'; + + const { imports, declarations, providers } = analyzedMetadata; + + @NgModule({ + declarations, + imports, + exports: [...declarations, ...imports], + }) + class StorybookComponentModule {} + + PropertyExtractor.warnImportsModuleWithProviders(analyzedMetadata); + + @Component({ + selector, + template, + standalone: true, + imports: [StorybookComponentModule], + providers, + styles, + schemas: moduleMetadata.schemas, + }) + class StorybookWrapperComponent implements AfterViewInit, OnDestroy { + private storyComponentPropsSubscription: Subscription; + + private storyWrapperPropsSubscription: Subscription; + + @ViewChild(viewChildSelector, { static: true }) storyComponentElementRef: ElementRef; + + @ViewChild(viewChildSelector, { read: ViewContainerRef, static: true }) + storyComponentViewContainerRef: ViewContainerRef; + + // Used in case of a component without selector + storyComponent = storyComponent ?? ''; + + constructor( + @Inject(STORY_PROPS) private storyProps$: Subject, + @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef + ) {} + + ngOnInit(): void { + // Subscribes to the observable storyProps$ to keep these properties up to date + this.storyWrapperPropsSubscription = this.storyProps$.subscribe((storyProps = {}) => { + // All props are added as component properties + Object.assign(this, storyProps); + + this.changeDetectorRef.detectChanges(); + this.changeDetectorRef.markForCheck(); + }); + } + + ngAfterViewInit(): void { + // Bind properties to component, if the story have component + if (this.storyComponentElementRef) { + const ngComponentInputsOutputs = getComponentInputsOutputs(storyComponent); + + const initialOtherProps = getNonInputsOutputsProps(ngComponentInputsOutputs, initialProps); + + // Initializes properties that are not Inputs | Outputs + // Allows story props to override local component properties + initialOtherProps.forEach((p) => { + (this.storyComponentElementRef as any)[p] = initialProps[p]; + }); + // `markForCheck` the component in case this uses changeDetection: OnPush + // And then forces the `detectChanges` + this.storyComponentViewContainerRef.injector.get(ChangeDetectorRef).markForCheck(); + this.changeDetectorRef.detectChanges(); + + // Once target component has been initialized, the storyProps$ observable keeps target component properties than are not Input|Output up to date + this.storyComponentPropsSubscription = this.storyProps$ + .pipe( + skip(1), + map((props) => { + const propsKeyToKeep = getNonInputsOutputsProps(ngComponentInputsOutputs, props); + return propsKeyToKeep.reduce((acc, p) => ({ ...acc, [p]: props[p] }), {}); + }) + ) + .subscribe((props) => { + // Replace inputs with new ones from props + Object.assign(this.storyComponentElementRef, props); + + // `markForCheck` the component in case this uses changeDetection: OnPush + // And then forces the `detectChanges` + this.storyComponentViewContainerRef.injector.get(ChangeDetectorRef).markForCheck(); + this.changeDetectorRef.detectChanges(); + }); + } + } + + ngOnDestroy(): void { + if (this.storyComponentPropsSubscription != null) { + this.storyComponentPropsSubscription.unsubscribe(); + } + if (this.storyWrapperPropsSubscription != null) { + this.storyWrapperPropsSubscription.unsubscribe(); + } + } + } + return StorybookWrapperComponent; +}; diff --git a/code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/input.component.ts b/code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/input.component.ts new file mode 100644 index 000000000000..b0ae93c94b99 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/input.component.ts @@ -0,0 +1,52 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; + ref?: ISomeInterface; +} + +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + @Input() + public counter: number; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** To test source-generation with overridden propertyname */ + @Input('color') public foregroundColor: string; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + @Input() + public label: string; + + @Input('aria-label') public ariaLabel: string; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + @Output() + public onClick = new EventEmitter(); + + @Output('dash-out') public dashOut = new EventEmitter(); +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/test.module.ts b/code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/test.module.ts new file mode 100644 index 000000000000..36536f3873e1 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/__testfixtures__/test.module.ts @@ -0,0 +1,8 @@ +import { CommonModule } from '@angular/common'; +import { HttpClientModule } from '@angular/common/http'; +import { NgModule } from '@angular/core'; + +@NgModule({ + imports: [CommonModule, HttpClientModule], +}) +export class WithOfficialModule {} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.test.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.test.ts new file mode 100644 index 000000000000..f941fd58d1ce --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.test.ts @@ -0,0 +1,200 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Subject, lastValueFrom } from 'rxjs'; + +import { queueBootstrapping } from './BootstrapQueue'; + +const instantWaitFor = (fn: () => void) => { + return vi.waitFor(fn, { + interval: 0, + timeout: 10000, + }); +}; + +describe('BootstrapQueue', { retry: 3 }, () => { + beforeEach(async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('@flaky should wait until complete', async () => { + const pendingSubject = new Subject(); + const bootstrapApp = vi.fn().mockImplementation(async () => { + return lastValueFrom(pendingSubject); + }); + const bootstrapAppFinished = vi.fn(); + + queueBootstrapping(bootstrapApp).then(() => { + bootstrapAppFinished(); + }); + + await instantWaitFor(() => { + if (bootstrapApp.mock.calls.length !== 1) { + throw new Error('bootstrapApp should not have been called yet'); + } + }); + + expect(bootstrapApp).toHaveBeenCalled(); + expect(bootstrapAppFinished).not.toHaveBeenCalled(); + + pendingSubject.next(); + pendingSubject.complete(); + + await instantWaitFor(() => { + if (bootstrapAppFinished.mock.calls.length !== 1) { + throw new Error('bootstrapApp should have been called once'); + } + }); + + expect(bootstrapAppFinished).toHaveBeenCalled(); + }); + + it('should prevent following tasks, until the preview tasks are complete', async () => { + const pendingSubject = new Subject(); + const bootstrapApp = vi.fn().mockImplementation(async () => { + return lastValueFrom(pendingSubject); + }); + const bootstrapAppFinished = vi.fn(); + + const pendingSubject2 = new Subject(); + const bootstrapApp2 = vi.fn().mockImplementation(async () => { + return lastValueFrom(pendingSubject2); + }); + const bootstrapAppFinished2 = vi.fn(); + + const pendingSubject3 = new Subject(); + const bootstrapApp3 = vi.fn().mockImplementation(async () => { + return lastValueFrom(pendingSubject3); + }); + const bootstrapAppFinished3 = vi.fn(); + + queueBootstrapping(bootstrapApp).then(bootstrapAppFinished); + queueBootstrapping(bootstrapApp2).then(bootstrapAppFinished2); + queueBootstrapping(bootstrapApp3).then(bootstrapAppFinished3); + + await instantWaitFor(() => { + if (bootstrapApp.mock.calls.length !== 1) { + throw new Error('bootstrapApp should have been called once'); + } + }); + + expect(bootstrapApp).toHaveBeenCalled(); + expect(bootstrapAppFinished).not.toHaveBeenCalled(); + expect(bootstrapApp2).not.toHaveBeenCalled(); + expect(bootstrapAppFinished2).not.toHaveBeenCalled(); + expect(bootstrapApp3).not.toHaveBeenCalled(); + expect(bootstrapAppFinished3).not.toHaveBeenCalled(); + + pendingSubject.next(); + pendingSubject.complete(); + + await instantWaitFor(() => { + if (bootstrapApp2.mock.calls.length !== 1) { + throw new Error('bootstrapApp2 should have been called once'); + } + }); + + expect(bootstrapApp).toHaveReturnedTimes(1); + expect(bootstrapAppFinished).toHaveBeenCalled(); + expect(bootstrapApp2).toHaveBeenCalled(); + expect(bootstrapAppFinished2).not.toHaveBeenCalled(); + expect(bootstrapApp3).not.toHaveBeenCalled(); + expect(bootstrapAppFinished3).not.toHaveBeenCalled(); + + pendingSubject2.next(); + pendingSubject2.complete(); + + await instantWaitFor(() => { + if (bootstrapApp3.mock.calls.length !== 1) { + throw new Error('bootstrapApp3 should have been called once'); + } + }); + + expect(bootstrapApp).toHaveReturnedTimes(1); + expect(bootstrapAppFinished).toHaveBeenCalled(); + expect(bootstrapApp2).toHaveReturnedTimes(1); + expect(bootstrapAppFinished2).toHaveBeenCalled(); + expect(bootstrapApp3).toHaveBeenCalled(); + expect(bootstrapAppFinished3).not.toHaveBeenCalled(); + + pendingSubject3.next(); + pendingSubject3.complete(); + + await instantWaitFor(() => { + if (bootstrapAppFinished3.mock.calls.length !== 1) { + throw new Error('bootstrapAppFinished3 should have been called once'); + } + }); + + expect(bootstrapApp).toHaveReturnedTimes(1); + expect(bootstrapAppFinished).toHaveBeenCalled(); + expect(bootstrapApp2).toHaveReturnedTimes(1); + expect(bootstrapAppFinished2).toHaveBeenCalled(); + expect(bootstrapApp3).toHaveReturnedTimes(1); + expect(bootstrapAppFinished3).toHaveBeenCalled(); + }); + + it('should throw and continue next bootstrap on error', async () => { + const pendingSubject = new Subject(); + const bootstrapApp = vi.fn().mockImplementation(async () => { + return lastValueFrom(pendingSubject); + }); + const bootstrapAppFinished = vi.fn(); + const bootstrapAppError = vi.fn(); + + const pendingSubject2 = new Subject(); + const bootstrapApp2 = vi.fn().mockImplementation(async () => { + return lastValueFrom(pendingSubject2); + }); + const bootstrapAppFinished2 = vi.fn(); + const bootstrapAppError2 = vi.fn(); + + queueBootstrapping(bootstrapApp).then(bootstrapAppFinished).catch(bootstrapAppError); + queueBootstrapping(bootstrapApp2).then(bootstrapAppFinished2).catch(bootstrapAppError2); + + await instantWaitFor(() => { + if (bootstrapApp.mock.calls.length !== 1) { + throw new Error('bootstrapApp should have been called once'); + } + }); + + expect(bootstrapApp).toHaveBeenCalledTimes(1); + expect(bootstrapAppFinished).not.toHaveBeenCalled(); + expect(bootstrapApp2).not.toHaveBeenCalled(); + + pendingSubject.error(new Error('test error')); + + await instantWaitFor(() => { + if (bootstrapAppError.mock.calls.length !== 1) { + throw new Error('bootstrapAppError should have been called once'); + } + }); + + expect(bootstrapApp).toHaveBeenCalledTimes(1); + expect(bootstrapAppFinished).not.toHaveBeenCalled(); + expect(bootstrapAppError).toHaveBeenCalledTimes(1); + expect(bootstrapApp2).toHaveBeenCalledTimes(1); + expect(bootstrapAppFinished2).not.toHaveBeenCalled(); + expect(bootstrapAppError2).not.toHaveBeenCalled(); + + pendingSubject2.next(); + pendingSubject2.complete(); + + await instantWaitFor(() => { + if (bootstrapAppFinished2.mock.calls.length !== 1) { + throw new Error('bootstrapAppFinished2 should have been called once'); + } + }); + + expect(bootstrapApp).toHaveBeenCalledTimes(1); + expect(bootstrapAppFinished).not.toHaveBeenCalled(); + expect(bootstrapAppError).toHaveBeenCalledTimes(1); + expect(bootstrapApp2).toHaveBeenCalledTimes(1); + expect(bootstrapAppFinished2).toHaveBeenCalledTimes(1); + expect(bootstrapAppError2).not.toHaveBeenCalled(); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.ts new file mode 100644 index 000000000000..73a96e9cc088 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/BootstrapQueue.ts @@ -0,0 +1,55 @@ +import type { ApplicationRef } from '@angular/core'; + +const queue: Array<() => Promise> = []; +let isProcessing = false; + +/** + * Reset compiled components because we often want to compile the same component with more than one + * NgModule. + */ +const resetCompiledComponents = async () => { + try { + // Clear global Angular component cache in order to be able to re-render the same component across multiple stories + // + // Reference: + // https://github.com/angular/angular/blob/2ebe2bcb2fe19bf672316b05f15241fd7fd40803/packages/core/src/render3/jit/module.ts#L377-L384 + const { ɵresetCompiledComponents } = await import('@angular/core'); + ɵresetCompiledComponents(); + } catch (e) { + /** Noop catch This means angular removed or modified ɵresetCompiledComponents */ + } +}; + +/** + * Queue bootstrapping, so that only one application can be bootstrapped at a time. + * + * Bootstrapping multiple applications at once can cause Angular to throw an error that a component + * is declared in multiple modules. This avoids two stories confusing the Angular compiler, by + * bootstrapping more that one application at a time. + * + * @param fn Callback that should complete the bootstrap process + * @returns ApplicationRef from the completed bootstrap process + */ +export const queueBootstrapping = (fn: () => Promise): Promise => { + return new Promise((resolve, reject) => { + queue.push(() => fn().then(resolve).catch(reject)); + + if (!isProcessing) { + processQueue(); + } + }); +}; + +const processQueue = async () => { + isProcessing = true; + + while (queue.length > 0) { + const bootstrappingFn = queue.shift(); + if (bootstrappingFn) { + await bootstrappingFn(); + await resetCompiledComponents(); + } + } + + isProcessing = false; +}; diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts new file mode 100644 index 000000000000..4548c1bb243e --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts @@ -0,0 +1,381 @@ +// @vitest-environment happy-dom + +import type { Type } from '@angular/core'; +import { + Component, + ComponentFactoryResolver, + Directive, + EventEmitter, + HostBinding, + Injectable, + Input, + Output, + Pipe, + input, + output, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; +import { describe, expect, it } from 'vitest'; + +import { + getComponentInputsOutputs, + isComponent, + isDeclarable, + getComponentDecoratorMetadata, + isStandaloneComponent, +} from './NgComponentAnalyzer'; + +describe('getComponentInputsOutputs', () => { + it('should return empty if no I/O found', () => { + @Component({ + standalone: false, + }) + class FooComponent {} + + expect(getComponentInputsOutputs(FooComponent)).toEqual({ + inputs: [], + outputs: [], + }); + + class BarComponent {} + + expect(getComponentInputsOutputs(BarComponent)).toEqual({ + inputs: [], + outputs: [], + }); + }); + + it('should return I/O', () => { + @Component({ + template: '', + inputs: ['inputInComponentMetadata'], + outputs: ['outputInComponentMetadata'], + standalone: false, + }) + class FooComponent { + @Input() + public input: string; + + public signalInput = input(); + + public signalInputAliased = input('signalInputAliased', { + alias: 'signalInputAliasedAlias', + }); + + @Input('inputPropertyName') + public inputWithBindingPropertyName: string; + + @Output() + public output = new EventEmitter(); + + @Output('outputPropertyName') + public outputWithBindingPropertyName = new EventEmitter(); + + public signalOutput = output(); + } + + const fooComponentFactory = resolveComponentFactory(FooComponent); + + const { inputs, outputs } = getComponentInputsOutputs(FooComponent); + + expect({ inputs, outputs }).toEqual({ + inputs: [ + { propName: 'inputInComponentMetadata', templateName: 'inputInComponentMetadata' }, + { propName: 'input', templateName: 'input' }, + { propName: 'inputWithBindingPropertyName', templateName: 'inputPropertyName' }, + ], + outputs: [ + { propName: 'outputInComponentMetadata', templateName: 'outputInComponentMetadata' }, + { propName: 'output', templateName: 'output' }, + { propName: 'outputWithBindingPropertyName', templateName: 'outputPropertyName' }, + ], + }); + + expect(sortByPropName(inputs)).toEqual( + sortByPropName(fooComponentFactory.inputs.map(({ isSignal, ...rest }) => rest)) + ); + expect(sortByPropName(outputs)).toEqual(sortByPropName(fooComponentFactory.outputs)); + }); + + it("should return I/O when some of component metadata has the same name as one of component's properties", () => { + @Component({ + template: '', + inputs: ['input', 'inputWithBindingPropertyName'], + outputs: ['outputWithBindingPropertyName'], + standalone: false, + }) + class FooComponent { + @Input() + public input: string; + + @Input('inputPropertyName') + public inputWithBindingPropertyName: string; + + @Output() + public output = new EventEmitter(); + + @Output('outputPropertyName') + public outputWithBindingPropertyName = new EventEmitter(); + } + + const fooComponentFactory = resolveComponentFactory(FooComponent); + + const { inputs, outputs } = getComponentInputsOutputs(FooComponent); + + expect(sortByPropName(inputs)).toEqual( + sortByPropName(fooComponentFactory.inputs.map(({ isSignal, ...rest }) => rest)) + ); + expect(sortByPropName(outputs)).toEqual(sortByPropName(fooComponentFactory.outputs)); + }); + + it('should return I/O in the presence of multiple decorators', () => { + @Component({ + template: '', + standalone: false, + }) + class FooComponent { + @Input() + @HostBinding('class.preceeding-first') + public inputPreceedingHostBinding: string; + + @HostBinding('class.following-binding') + @Input() + public inputFollowingHostBinding: string; + } + + const fooComponentFactory = resolveComponentFactory(FooComponent); + + const { inputs, outputs } = getComponentInputsOutputs(FooComponent); + + expect({ inputs, outputs }).toEqual({ + inputs: [ + { propName: 'inputPreceedingHostBinding', templateName: 'inputPreceedingHostBinding' }, + { propName: 'inputFollowingHostBinding', templateName: 'inputFollowingHostBinding' }, + ], + outputs: [], + }); + + expect(sortByPropName(inputs)).toEqual( + sortByPropName(fooComponentFactory.inputs.map(({ isSignal, ...rest }) => rest)) + ); + expect(sortByPropName(outputs)).toEqual(sortByPropName(fooComponentFactory.outputs)); + }); + + it('should return I/O with extending classes', () => { + @Component({ + template: '', + standalone: false, + }) + class BarComponent { + @Input() + public a: string; + + @Input() + public b: string; + } + + @Component({ + template: '', + standalone: false, + }) + class FooComponent extends BarComponent { + @Input() + declare public b: string; + + @Input() + public c: string; + } + + const fooComponentFactory = resolveComponentFactory(FooComponent); + + const { inputs, outputs } = getComponentInputsOutputs(FooComponent); + + expect({ inputs, outputs }).toEqual({ + inputs: [ + { propName: 'a', templateName: 'a' }, + { propName: 'b', templateName: 'b' }, + { propName: 'c', templateName: 'c' }, + ], + outputs: [], + }); + + expect(sortByPropName(inputs)).toEqual( + sortByPropName(fooComponentFactory.inputs.map(({ isSignal, ...rest }) => rest)) + ); + expect(sortByPropName(outputs)).toEqual(sortByPropName(fooComponentFactory.outputs)); + }); +}); + +describe('isDeclarable', () => { + it('should return true with a Component', () => { + @Component({}) + class FooComponent {} + + expect(isDeclarable(FooComponent)).toEqual(true); + }); + + it('should return true with a Directive', () => { + @Directive({}) + class FooDirective {} + + expect(isDeclarable(FooDirective)).toEqual(true); + }); + + it('should return true with a Pipe', () => { + @Pipe({ name: 'pipe' }) + class FooPipe {} + + expect(isDeclarable(FooPipe)).toEqual(true); + }); + + it('should return false with simple class', () => { + class FooPipe {} + + expect(isDeclarable(FooPipe)).toEqual(false); + }); + it('should return false with Injectable', () => { + @Injectable() + class FooInjectable {} + + expect(isDeclarable(FooInjectable)).toEqual(false); + }); +}); + +describe('isComponent', () => { + it('should return true with a Component', () => { + @Component({}) + class FooComponent {} + + expect(isComponent(FooComponent)).toEqual(true); + }); + + it('should return false with simple class', () => { + class FooPipe {} + + expect(isComponent(FooPipe)).toEqual(false); + }); + it('should return false with Directive', () => { + @Directive() + class FooDirective {} + + expect(isComponent(FooDirective)).toEqual(false); + }); +}); + +describe('isStandaloneComponent', () => { + it('should return true with a Component with "standalone: true"', () => { + @Component({ standalone: true }) + class FooComponent {} + + expect(isStandaloneComponent(FooComponent)).toEqual(true); + }); + + it('should return false with a Component with "standalone: false"', () => { + @Component({ standalone: false }) + class FooComponent {} + + expect(isStandaloneComponent(FooComponent)).toEqual(false); + }); + + it('should return false with a Component without the "standalone" property', () => { + @Component({}) + class FooComponent {} + + expect(isStandaloneComponent(FooComponent)).toEqual(false); + }); + + it('should return false with simple class', () => { + class FooPipe {} + + expect(isStandaloneComponent(FooPipe)).toEqual(false); + }); + + it('should return true with a Directive with "standalone: true"', () => { + @Directive({ standalone: true }) + class FooDirective {} + + expect(isStandaloneComponent(FooDirective)).toEqual(true); + }); + + it('should return false with a Directive with "standalone: false"', () => { + @Directive({ standalone: false }) + class FooDirective {} + + expect(isStandaloneComponent(FooDirective)).toEqual(false); + }); + + it('should return false with Directive without the "standalone" property', () => { + @Directive() + class FooDirective {} + + expect(isStandaloneComponent(FooDirective)).toEqual(false); + }); + + it('should return true with a Pipe with "standalone: true"', () => { + @Pipe({ name: 'FooPipe', standalone: true }) + class FooPipe {} + + expect(isStandaloneComponent(FooPipe)).toEqual(true); + }); + + it('should return false with a Pipe with "standalone: false"', () => { + @Pipe({ name: 'FooPipe', standalone: false }) + class FooPipe {} + + expect(isStandaloneComponent(FooPipe)).toEqual(false); + }); + + it('should return false with Pipe without the "standalone" property', () => { + @Pipe({ + name: 'fooPipe', + }) + class FooPipe {} + + expect(isStandaloneComponent(FooPipe)).toEqual(false); + }); +}); + +describe('getComponentDecoratorMetadata', () => { + it('should return Component with a Component', () => { + @Component({ selector: 'foo' }) + class FooComponent {} + + expect(getComponentDecoratorMetadata(FooComponent)).toBeInstanceOf(Component); + expect(getComponentDecoratorMetadata(FooComponent)).toEqual({ + changeDetection: 1, + selector: 'foo', + }); + }); + + it('should return Component with extending classes', () => { + @Component({ selector: 'bar' }) + class BarComponent {} + @Component({ selector: 'foo' }) + class FooComponent extends BarComponent {} + + expect(getComponentDecoratorMetadata(FooComponent)).toBeInstanceOf(Component); + expect(getComponentDecoratorMetadata(FooComponent)).toEqual({ + changeDetection: 1, + selector: 'foo', + }); + }); +}); + +function sortByPropName( + array: { + propName: string; + templateName: string; + }[] +) { + return array.sort((a, b) => a.propName.localeCompare(b.propName)); +} + +function resolveComponentFactory>(component: T) { + TestBed.configureTestingModule({ + declarations: [component], + }).overrideModule(BrowserDynamicTestingModule, {}); + const componentFactoryResolver = TestBed.inject(ComponentFactoryResolver); + + return componentFactoryResolver.resolveComponentFactory(component); +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.ts new file mode 100644 index 000000000000..da072c2c646d --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgComponentAnalyzer.ts @@ -0,0 +1,135 @@ +import type { Type } from '@angular/core'; +import { + Component, + Directive, + Input, + Output, + Pipe, + ɵReflectionCapabilities as ReflectionCapabilities, +} from '@angular/core'; + +const reflectionCapabilities = new ReflectionCapabilities(); + +export type ComponentInputsOutputs = { + inputs: { + propName: string; + templateName: string; + }[]; + outputs: { + propName: string; + templateName: string; + }[]; +}; + +/** Returns component Inputs / Outputs by browsing these properties and decorator */ +export const getComponentInputsOutputs = (component: any): ComponentInputsOutputs => { + const componentMetadata = getComponentDecoratorMetadata(component); + const componentPropsMetadata = getComponentPropsDecoratorMetadata(component); + + const initialValue: ComponentInputsOutputs = { + inputs: [], + outputs: [], + }; + + // Adds the I/O present in @Component metadata + if (componentMetadata && componentMetadata.inputs) { + initialValue.inputs.push( + ...componentMetadata.inputs.map((i) => ({ + propName: typeof i === 'string' ? i : i.name, + templateName: typeof i === 'string' ? i : i.alias, + })) + ); + } + if (componentMetadata && componentMetadata.outputs) { + initialValue.outputs.push( + ...componentMetadata.outputs.map((i) => ({ propName: i, templateName: i })) + ); + } + + if (!componentPropsMetadata) { + return initialValue; + } + + // Browses component properties to extract I/O + // Filters properties that have the same name as the one present in the @Component property + return Object.entries(componentPropsMetadata).reduce((previousValue, [propertyName, values]) => { + const value = values.find((v) => v instanceof Input || v instanceof Output); + if (value instanceof Input) { + const inputToAdd = { + propName: propertyName, + templateName: value.bindingPropertyName ?? value.alias ?? propertyName, + }; + + const previousInputsFiltered = previousValue.inputs.filter( + (i) => i.templateName !== propertyName + ); + return { + ...previousValue, + inputs: [...previousInputsFiltered, inputToAdd], + }; + } + if (value instanceof Output) { + const outputToAdd = { + propName: propertyName, + templateName: value.bindingPropertyName ?? value.alias ?? propertyName, + }; + + const previousOutputsFiltered = previousValue.outputs.filter( + (i) => i.templateName !== propertyName + ); + return { + ...previousValue, + outputs: [...previousOutputsFiltered, outputToAdd], + }; + } + return previousValue; + }, initialValue); +}; + +export const isDeclarable = (component: any): boolean => { + if (!component) { + return false; + } + + const decorators = reflectionCapabilities.annotations(component); + + return !!(decorators || []).find( + (d) => d instanceof Directive || d instanceof Pipe || d instanceof Component + ); +}; + +export const isComponent = (component: any): component is Type => { + if (!component) { + return false; + } + + const decorators = reflectionCapabilities.annotations(component); + + return (decorators || []).some((d) => d instanceof Component); +}; + +export const isStandaloneComponent = (component: any): component is Type => { + if (!component) { + return false; + } + + const decorators = reflectionCapabilities.annotations(component); + + // TODO: `standalone` is only available in Angular v14. Remove cast to `any` once + // Angular deps are updated to v14.x.x. + return (decorators || []).some( + (d) => (d instanceof Component || d instanceof Directive || d instanceof Pipe) && d.standalone + ); +}; + +/** Returns all component decorator properties is used to get all `@Input` and `@Output` Decorator */ +export const getComponentPropsDecoratorMetadata = (component: any) => { + return reflectionCapabilities.propMetadata(component); +}; + +/** Returns component decorator `@Component` */ +export const getComponentDecoratorMetadata = (component: any): Component | undefined => { + const decorators = reflectionCapabilities.annotations(component); + + return decorators.reverse().find((d) => d instanceof Component); +}; diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.test.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.test.ts new file mode 100644 index 000000000000..1258638e2662 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.test.ts @@ -0,0 +1,26 @@ +import { Component, NgModule } from '@angular/core'; +import { describe, expect, it } from 'vitest'; + +import { isComponentAlreadyDeclared } from './NgModulesAnalyzer'; + +const FooComponent = Component({})(class {}); + +const BarComponent = Component({})(class {}); + +const BetaModule = NgModule({ declarations: [FooComponent] })(class {}); + +const AlphaModule = NgModule({ imports: [BetaModule] })(class {}); + +describe('isComponentAlreadyDeclaredInModules', () => { + it('should return true when the component is already declared in one of modules', () => { + expect(isComponentAlreadyDeclared(FooComponent, [], [AlphaModule])).toEqual(true); + }); + + it('should return true if the component is in moduleDeclarations', () => { + expect(isComponentAlreadyDeclared(BarComponent, [BarComponent], [AlphaModule])).toEqual(true); + }); + + it('should return false if the component is not declared', () => { + expect(isComponentAlreadyDeclared(BarComponent, [], [AlphaModule])).toEqual(false); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.ts new file mode 100644 index 000000000000..bcf9386fb875 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/NgModulesAnalyzer.ts @@ -0,0 +1,55 @@ +import { NgModule, ɵReflectionCapabilities as ReflectionCapabilities } from '@angular/core'; + +const reflectionCapabilities = new ReflectionCapabilities(); + +/** + * Avoid component redeclaration + * + * Checks recursively if the component has already been declared in all import Module + */ +export const isComponentAlreadyDeclared = ( + componentToFind: any, + moduleDeclarations: any[], + moduleImports: any[] +): boolean => { + if ( + moduleDeclarations && + moduleDeclarations.flat().some((declaration) => declaration === componentToFind) + ) { + // Found component in declarations array + return true; + } + if (!moduleImports) { + return false; + } + + return moduleImports.flat().some((importItem) => { + const extractedNgModuleMetadata = extractNgModuleMetadata(importItem); + if (!extractedNgModuleMetadata) { + // Not an NgModule + return false; + } + return isComponentAlreadyDeclared( + componentToFind, + extractedNgModuleMetadata.declarations, + extractedNgModuleMetadata.imports + ); + }); +}; + +const extractNgModuleMetadata = (importItem: any): NgModule => { + const target = importItem && importItem.ngModule ? importItem.ngModule : importItem; + const decorators = reflectionCapabilities.annotations(target); + + if (!decorators || decorators.length === 0) { + return null; + } + + const ngModuleDecorator: NgModule | undefined = decorators.find( + (decorator) => decorator instanceof NgModule + ); + if (!ngModuleDecorator) { + return null; + } + return ngModuleDecorator; +}; diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.test.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.test.ts new file mode 100644 index 000000000000..e0885569b406 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.test.ts @@ -0,0 +1,201 @@ +import { CommonModule } from '@angular/common'; +import { Component, Directive, Injectable, InjectionToken, NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { + BrowserAnimationsModule, + NoopAnimationsModule, + provideAnimations, + provideNoopAnimations, +} from '@angular/platform-browser/animations'; +import { describe, expect, it, vi } from 'vitest'; + +import type { NgModuleMetadata } from '../../types'; +import { WithOfficialModule } from '../__testfixtures__/test.module'; +import { PropertyExtractor } from './PropertyExtractor'; + +const TEST_TOKEN = new InjectionToken('testToken'); +const TestTokenProvider = { provide: TEST_TOKEN, useValue: 123 }; +const TestService = Injectable()(class {}); +const TestComponent1 = Component({ standalone: false })(class {}); +const TestComponent2 = Component({ standalone: false })(class {}); +const StandaloneTestComponent = Component({})(class {}); +const StandaloneTestDirective = Directive({})(class {}); +const MixedTestComponent1 = Component({})(class extends StandaloneTestComponent {}); +const MixedTestComponent2 = Component({ standalone: false })(class extends MixedTestComponent1 {}); +const MixedTestComponent3 = Component({})(class extends MixedTestComponent2 {}); +const TestModuleWithDeclarations = NgModule({ declarations: [TestComponent1] })(class {}); +const TestModuleWithImportsAndProviders = NgModule({ + imports: [TestModuleWithDeclarations], + providers: [TestTokenProvider], +})(class {}); + +const analyzeMetadata = async (metadata: NgModuleMetadata, component?: any) => { + const propertyExtractor = new PropertyExtractor(metadata, component); + await propertyExtractor.init(); + return propertyExtractor; +}; +const extractImports = async (metadata: NgModuleMetadata, component?: any) => { + const propertyExtractor = new PropertyExtractor(metadata, component); + await propertyExtractor.init(); + return propertyExtractor.imports; +}; +const extractDeclarations = async (metadata: NgModuleMetadata, component?: any) => { + const propertyExtractor = new PropertyExtractor(metadata, component); + await propertyExtractor.init(); + return propertyExtractor.declarations; +}; +const extractProviders = async (metadata: NgModuleMetadata, component?: any) => { + const propertyExtractor = new PropertyExtractor(metadata, component); + await propertyExtractor.init(); + return propertyExtractor.providers; +}; +const extractApplicationProviders = async (metadata: NgModuleMetadata, component?: any) => { + const propertyExtractor = new PropertyExtractor(metadata, component); + await propertyExtractor.init(); + return propertyExtractor.applicationProviders; +}; + +describe('PropertyExtractor', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + describe('analyzeMetadata', () => { + it('should remove BrowserModule', async () => { + const metadata = { + imports: [BrowserModule], + }; + const { imports, providers, applicationProviders } = await analyzeMetadata(metadata); + expect(imports.flat(Number.MAX_VALUE)).toEqual([CommonModule]); + expect(providers.flat(Number.MAX_VALUE)).toEqual([]); + expect(applicationProviders.flat(Number.MAX_VALUE)).toEqual([]); + }); + + it('should remove BrowserAnimationsModule and use its providers instead', async () => { + const metadata = { + imports: [BrowserAnimationsModule], + }; + const { imports, providers, applicationProviders } = await analyzeMetadata(metadata); + expect(imports.flat(Number.MAX_VALUE)).toEqual([CommonModule]); + expect(providers.flat(Number.MAX_VALUE)).toEqual([]); + expect(applicationProviders.flat(Number.MAX_VALUE)).toEqual(provideAnimations()); + }); + + it('should remove NoopAnimationsModule and use its providers instead', async () => { + const metadata = { + imports: [NoopAnimationsModule], + }; + const { imports, providers, applicationProviders } = await analyzeMetadata(metadata); + expect(imports.flat(Number.MAX_VALUE)).toEqual([CommonModule]); + expect(providers.flat(Number.MAX_VALUE)).toEqual([]); + expect(applicationProviders.flat(Number.MAX_VALUE)).toEqual(provideNoopAnimations()); + }); + + it('should remove Browser/Animations modules recursively', async () => { + const metadata = { + imports: [BrowserAnimationsModule, BrowserModule], + }; + const { imports, providers, applicationProviders } = await analyzeMetadata(metadata); + expect(imports.flat(Number.MAX_VALUE)).toEqual([CommonModule]); + expect(providers.flat(Number.MAX_VALUE)).toEqual([]); + expect(applicationProviders.flat(Number.MAX_VALUE)).toEqual(provideAnimations()); + }); + + it('should not destructure Angular official module', async () => { + const metadata = { + imports: [WithOfficialModule], + }; + const { imports, providers, applicationProviders } = await analyzeMetadata(metadata); + expect(imports.flat(Number.MAX_VALUE)).toEqual([CommonModule, WithOfficialModule]); + expect(providers.flat(Number.MAX_VALUE)).toEqual([]); + expect(applicationProviders.flat(Number.MAX_VALUE)).toEqual([]); + }); + }); + + describe('extractImports', () => { + it('should return Angular official modules', async () => { + const imports = await extractImports({ imports: [TestModuleWithImportsAndProviders] }); + expect(imports).toEqual([CommonModule, TestModuleWithImportsAndProviders]); + }); + + it('should return standalone components', async () => { + const imports = await extractImports( + { + imports: [TestModuleWithImportsAndProviders], + }, + StandaloneTestComponent + ); + expect(imports).toEqual([ + CommonModule, + TestModuleWithImportsAndProviders, + StandaloneTestComponent, + ]); + }); + + it('should return standalone directives', async () => { + const imports = await extractImports( + { + imports: [TestModuleWithImportsAndProviders], + }, + StandaloneTestDirective + ); + expect(imports).toEqual([ + CommonModule, + TestModuleWithImportsAndProviders, + StandaloneTestDirective, + ]); + }); + }); + + describe('extractDeclarations', () => { + it('should return an array of declarations that contains `storyComponent`', async () => { + const declarations = await extractDeclarations( + { declarations: [TestComponent1] }, + TestComponent2 + ); + expect(declarations).toEqual([TestComponent1, TestComponent2]); + }); + }); + + describe('analyzeDecorators', () => { + it('isStandalone should be false', () => { + const { isStandalone } = PropertyExtractor.analyzeDecorators(TestComponent1); + expect(isStandalone).toBe(false); + }); + + it('isStandalone should be true', () => { + const { isStandalone } = PropertyExtractor.analyzeDecorators(StandaloneTestComponent); + expect(isStandalone).toBe(true); + }); + + it('isStandalone should be true', () => { + const { isStandalone } = PropertyExtractor.analyzeDecorators(MixedTestComponent1); + expect(isStandalone).toBe(true); + }); + + it('isStandalone should be false', () => { + const { isStandalone } = PropertyExtractor.analyzeDecorators(MixedTestComponent2); + expect(isStandalone).toBe(false); + }); + + it('isStandalone should be true', () => { + const { isStandalone } = PropertyExtractor.analyzeDecorators(MixedTestComponent3); + expect(isStandalone).toBe(true); + }); + }); + + describe('extractProviders', () => { + it('should return an array of providers', async () => { + const providers = await extractProviders({ + providers: [TestService], + }); + expect(providers).toEqual([TestService]); + }); + + it('should return an array of singletons extracted', async () => { + const singeltons = await extractApplicationProviders({ + imports: [BrowserAnimationsModule], + }); + + expect(singeltons).toEqual(provideAnimations()); + }); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.ts new file mode 100644 index 000000000000..d2e0ebcbfa58 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/PropertyExtractor.ts @@ -0,0 +1,218 @@ +import { CommonModule } from '@angular/common'; +import type { NgModule, Provider, importProvidersFrom } from '@angular/core'; +import { + Component, + Directive, + Injectable, + InjectionToken, + Input, + Output, + Pipe, + ɵReflectionCapabilities as ReflectionCapabilities, + VERSION, +} from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { dedent } from 'ts-dedent'; + +import type { NgModuleMetadata } from '../../types'; +import { isComponentAlreadyDeclared } from './NgModulesAnalyzer'; + +export const reflectionCapabilities = new ReflectionCapabilities(); +export const REMOVED_MODULES = new InjectionToken('REMOVED_MODULES'); +export const uniqueArray = (arr: any[]) => { + return arr + .flat(Number.MAX_VALUE) + .filter(Boolean) + .filter((value, index, self) => self.indexOf(value) === index); +}; + +export class PropertyExtractor implements NgModuleMetadata { + declarations?: any[] = []; + imports?: any[]; + providers?: Provider[]; + applicationProviders?: Array>; + + constructor( + private metadata: NgModuleMetadata, + private component?: any + ) {} + + // With the new way of mounting standalone components to the DOM via bootstrapApplication API, + // we should now pass ModuleWithProviders to the providers array of the bootstrapApplication function. + static warnImportsModuleWithProviders(propertyExtractor: PropertyExtractor) { + const hasModuleWithProvidersImport = propertyExtractor.imports.some( + (importedModule) => 'ngModule' in importedModule + ); + + if (hasModuleWithProvidersImport) { + console.warn( + dedent( + ` + Storybook Warning: + moduleMetadata property 'imports' contains one or more ModuleWithProviders, likely the result of a 'Module.forRoot()'-style call. + In Storybook 7.0 we use Angular's new 'bootstrapApplication' API to mount the component to the DOM, which accepts a list of providers to set up application-wide providers. + Use the 'applicationConfig' decorator from '@storybook/angular' to pass your ModuleWithProviders to the 'providers' property in combination with the importProvidersFrom helper function from '@angular/core' to extract all the necessary providers. + Visit https://angular.io/guide/standalone-components#configuring-dependency-injection for more information + ` + ) + ); + } + } + + public async init() { + const analyzed = await this.analyzeMetadata(this.metadata); + this.imports = uniqueArray([CommonModule, analyzed.imports]); + this.providers = uniqueArray(analyzed.providers); + this.applicationProviders = uniqueArray(analyzed.applicationProviders); + this.declarations = uniqueArray(analyzed.declarations); + + if (this.component) { + const { isDeclarable, isStandalone } = PropertyExtractor.analyzeDecorators(this.component); + const isDeclared = isComponentAlreadyDeclared( + this.component, + analyzed.declarations, + this.imports + ); + + if (isStandalone) { + this.imports.push(this.component); + } else if (isDeclarable && !isDeclared) { + this.declarations.push(this.component); + } + } + } + + /** + * Analyze NgModule Metadata + * + * - Removes Restricted Imports + * - Extracts providers from ModuleWithProviders + * - Returns a new NgModuleMetadata object + */ + private analyzeMetadata = async (metadata: NgModuleMetadata) => { + const declarations = [...(metadata?.declarations || [])]; + const providers = [...(metadata?.providers || [])]; + const applicationProviders: Provider[] = []; + const imports = await Promise.all( + [...(metadata?.imports || [])].map(async (imported) => { + const [isRestricted, restrictedProviders] = + await PropertyExtractor.analyzeRestricted(imported); + if (isRestricted) { + applicationProviders.unshift(restrictedProviders || []); + return null; + } + return imported; + }) + ).then((results) => results.filter(Boolean)); + + return { ...metadata, imports, providers, applicationProviders, declarations }; + }; + + static analyzeRestricted = async ( + ngModule: NgModule + ): Promise<[boolean] | [boolean, Provider]> => { + if (ngModule === BrowserModule) { + console.warn( + dedent` + Storybook Warning: + You have imported the "BrowserModule", which is not necessary anymore. + In Storybook v7.0 we are using Angular's new bootstrapApplication API to mount an Angular application to the DOM. + Note that the BrowserModule providers are automatically included when starting an application with bootstrapApplication() + Please remove the "BrowserModule" from the list of imports in your moduleMetadata definition to remove this warning. + ` + ); + return [true]; + } + + try { + const animations = await import('@angular/platform-browser/animations'); + + if (ngModule === animations.BrowserAnimationsModule) { + console.warn( + dedent` + Storybook Warning: + You have added the "BrowserAnimationsModule" to the list of "imports" in your moduleMetadata definition of your Story. + In Storybook 7.0 we use Angular's new 'bootstrapApplication' API to mount the component to the DOM, which accepts a list of providers to set up application-wide providers. + Use the 'applicationConfig' decorator from '@storybook/angular' and add the "provideAnimations" function to the list of "providers". + If your Angular version does not support "provide-like" functions, use the helper function importProvidersFrom instead to set up animations. For this case, please add "importProvidersFrom(BrowserAnimationsModule)" to the list of providers of your applicationConfig definition. + Please visit https://angular.io/guide/standalone-components#configuring-dependency-injection for more information. + ` + ); + return [true, animations.provideAnimations()]; + } + + if (ngModule === animations.NoopAnimationsModule) { + console.warn( + dedent` + Storybook Warning: + You have added the "NoopAnimationsModule" to the list of "imports" in your moduleMetadata definition of your Story. + In Storybook v7.0 we are using Angular's new bootstrapApplication API to mount an Angular application to the DOM, which accepts a list of providers to set up application-wide providers. + Use the 'applicationConfig' decorator from '@storybook/angular' and add the "provideNoopAnimations" function to the list of "providers". + If your Angular version does not support "provide-like" functions, use the helper function importProvidersFrom instead to set up noop animations and to extract all necessary providers from NoopAnimationsModule. For this case, please add "importProvidersFrom(NoopAnimationsModule)" to the list of providers of your applicationConfig definition. + Please visit https://angular.io/guide/standalone-components#configuring-dependency-injection for more information. + ` + ); + return [true, animations.provideNoopAnimations()]; + } + } catch (e) { + return [false]; + } + + return [false]; + }; + + static analyzeDecorators = (component: any) => { + const decorators = reflectionCapabilities.annotations(component); + + const isComponent = decorators.some((d) => this.isDecoratorInstanceOf(d, 'Component')); + const isDirective = decorators.some((d) => this.isDecoratorInstanceOf(d, 'Directive')); + const isPipe = decorators.some((d) => this.isDecoratorInstanceOf(d, 'Pipe')); + + const isDeclarable = isComponent || isDirective || isPipe; + + // Check if the hierarchically lowest Component or Directive decorator (the only relevant for importing dependencies) is standalone. + + let isStandalone = + (isComponent || isDirective) && + [...decorators] + .reverse() // reflectionCapabilities returns decorators in a hierarchically top-down order + .find( + (d) => + this.isDecoratorInstanceOf(d, 'Component') || this.isDecoratorInstanceOf(d, 'Directive') + )?.standalone; + + //Starting in Angular 19 the default (in case it's undefined) value for standalone is true + if (isStandalone === undefined) { + isStandalone = !!(VERSION.major && Number(VERSION.major) >= 19); + } + + return { isDeclarable, isStandalone }; + }; + + static isDecoratorInstanceOf = (decorator: any, name: string) => { + let factory; + switch (name) { + case 'Component': + factory = Component; + break; + case 'Directive': + factory = Directive; + break; + case 'Pipe': + factory = Pipe; + break; + case 'Injectable': + factory = Injectable; + break; + case 'Input': + factory = Input; + break; + case 'Output': + factory = Output; + break; + default: + throw new Error(`Unknown decorator type: ${name}`); + } + return decorator instanceof factory || decorator.ngMetadataName === name; + }; +} diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/StoryUID.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/StoryUID.ts new file mode 100644 index 000000000000..31912a4748fe --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/StoryUID.ts @@ -0,0 +1,40 @@ +/** Count of stories for each storyId. */ +const storyCounts = new Map(); + +/** + * Increments the count for a storyId and returns the next UID. + * + * When a story is bootstrapped, the storyId is used as the element tag. That becomes an issue when + * a story is rendered multiple times in the same docs page. This function returns a UID that is + * appended to the storyId to make it unique. + * + * @param storyId Id of a story + * @returns Uid of a story + */ +export const getNextStoryUID = (storyId: string): string => { + if (!storyCounts.has(storyId)) { + storyCounts.set(storyId, -1); + } + + const count = storyCounts.get(storyId) + 1; + storyCounts.set(storyId, count); + return `${storyId}-${count}`; +}; + +/** + * Clears the storyId counts. + * + * Can be useful for testing, where you need predictable increments, without reloading the global + * state. + * + * If onlyStoryId is provided, only that storyId is cleared. + * + * @param onlyStoryId Id of a story + */ +export const clearStoryUIDs = (onlyStoryId?: string): void => { + if (onlyStoryId !== undefined && onlyStoryId !== null) { + storyCounts.delete(onlyStoryId); + } else { + storyCounts.clear(); + } +}; diff --git a/code/frameworks/angular-vite/src/client/angular-beta/utils/Zoneless.ts b/code/frameworks/angular-vite/src/client/angular-beta/utils/Zoneless.ts new file mode 100644 index 000000000000..f168ceab083d --- /dev/null +++ b/code/frameworks/angular-vite/src/client/angular-beta/utils/Zoneless.ts @@ -0,0 +1,9 @@ +export const getProvideZonelessChangeDetectionFn = async () => { + const angularCore: any = await import('@angular/core'); + + return 'provideExperimentalZonelessChangeDetection' in angularCore + ? angularCore.provideExperimentalZonelessChangeDetection + : 'provideZonelessChangeDetection' in angularCore + ? angularCore.provideZonelessChangeDetection + : null; +}; diff --git a/code/frameworks/angular-vite/src/client/argsToTemplate.test.ts b/code/frameworks/angular-vite/src/client/argsToTemplate.test.ts new file mode 100644 index 000000000000..51369d2ba5bd --- /dev/null +++ b/code/frameworks/angular-vite/src/client/argsToTemplate.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; + +import type { ArgsToTemplateOptions } from './argsToTemplate'; +import { argsToTemplate } from './argsToTemplate'; + +// adjust path + +describe('argsToTemplate', () => { + it('should correctly convert args to template string and exclude undefined values', () => { + const args: Record = { + prop1: 'value1', + prop2: undefined, + prop3: 'value3', + }; + const options: ArgsToTemplateOptions = {}; + const result = argsToTemplate(args, options); + expect(result).toBe('[prop1]="prop1" [prop3]="prop3"'); + }); + + it('should include properties from include option', () => { + const args = { + prop1: 'value1', + prop2: 'value2', + prop3: 'value3', + }; + const options: ArgsToTemplateOptions = { + include: ['prop1', 'prop3'], + }; + const result = argsToTemplate(args, options); + expect(result).toBe('[prop1]="prop1" [prop3]="prop3"'); + }); + + it('should include non-undefined properties from include option', () => { + const args: Record = { + prop1: 'value1', + prop2: 'value2', + prop3: undefined, + }; + const options: ArgsToTemplateOptions = { + include: ['prop1', 'prop3'], + }; + const result = argsToTemplate(args, options); + expect(result).toBe('[prop1]="prop1"'); + }); + + it('should exclude properties from exclude option', () => { + const args = { + prop1: 'value1', + prop2: 'value2', + prop3: 'value3', + }; + const options: ArgsToTemplateOptions = { + exclude: ['prop2'], + }; + const result = argsToTemplate(args, options); + expect(result).toBe('[prop1]="prop1" [prop3]="prop3"'); + }); + + it('should exclude properties from exclude option and undefined properties', () => { + const args: Record = { + prop1: 'value1', + prop2: 'value2', + prop3: undefined, + }; + const options: ArgsToTemplateOptions = { + exclude: ['prop2'], + }; + const result = argsToTemplate(args, options); + expect(result).toBe('[prop1]="prop1"'); + }); + + it('should prioritize include over exclude when both options are given', () => { + const args = { + prop1: 'value1', + prop2: 'value2', + prop3: 'value3', + }; + const options: ArgsToTemplateOptions = { + include: ['prop1', 'prop2'], + exclude: ['prop2', 'prop3'], + }; + const result = argsToTemplate(args, options); + expect(result).toBe('[prop1]="prop1" [prop2]="prop2"'); + }); + + it('should work when neither include nor exclude options are given', () => { + const args = { + prop1: 'value1', + prop2: 'value2', + }; + const options: ArgsToTemplateOptions = {}; + const result = argsToTemplate(args, options); + expect(result).toBe('[prop1]="prop1" [prop2]="prop2"'); + }); + + it('should bind events correctly when value is a function', () => { + const args = { event1: () => {}, event2: () => {} }; + const result = argsToTemplate(args, {}); + expect(result).toEqual('(event1)="event1($event)" (event2)="event2($event)"'); + }); + + it('should mix properties and events correctly', () => { + const args = { input: 'Value1', event1: () => {} }; + const result = argsToTemplate(args, {}); + expect(result).toEqual('[input]="input" (event1)="event1($event)"'); + }); + + it('should format for non dot notation', () => { + const args = { 'non-dot': 'Value1', 'dash-out': () => {} }; + const result = argsToTemplate(args, {}); + expect(result).toEqual('[non-dot]="this[\'non-dot\']" (dash-out)="this[\'dash-out\']($event)"'); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/argsToTemplate.ts b/code/frameworks/angular-vite/src/client/argsToTemplate.ts new file mode 100644 index 000000000000..351d67f822a6 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/argsToTemplate.ts @@ -0,0 +1,83 @@ +import { formatPropInTemplate } from './angular-beta/ComputesTemplateFromComponent'; + +/** + * Options for controlling the behavior of the argsToTemplate function. + * + * @template T The type of the keys in the target object. + */ +export interface ArgsToTemplateOptions { + /** + * An array of keys to specifically include in the output. If provided, only the keys from this + * array will be included in the output, irrespective of the `exclude` option. Undefined values + * will still be excluded from the output. + */ + include?: Array; + /** + * An array of keys to specifically exclude from the output. If provided, these keys will be + * omitted from the output. This option is ignored if the `include` option is also provided + */ + exclude?: Array; +} + +/** + * Converts an object of arguments to a string of property and event bindings and excludes undefined + * values. Why? Because Angular treats undefined values in property bindings as an actual value and + * does not apply the default value of the property as soon as the binding is set. This feels + * counter-intuitive and is a common source of bugs in stories. + * + * @example + * + * ```ts + * // component.ts + * ㅤ@Component({ selector: 'example' }) + * export class ExampleComponent { + * ㅤ@Input() input1: string = 'Default Input1'; + * ㅤ@Input() input2: string = 'Default Input2'; + * ㅤ@Output() click = new EventEmitter(); + * } + * + * // component.stories.ts + * import { argsToTemplate } from '@storybook/angular'; + * export const Input1: Story = { + * render: (args) => ({ + * props: args, + * // Problem1: + * // This will set input2 to undefined and the internal default value will not be used. + * // Problem2: + * // The default value of input2 will be used, but it is not overridable by the user via controls. + * // Solution: Now the controls will be applicable to both input1 and input2, and the default values will be used if the user does not override them. + * template: ``, + * }), + * args: { + * // In this Story, we want to set the input1 property, and the internal default property of input2 should be used. + * input1: 'Input 1', + * click: { action: 'clicked' }, + * }, + * }; + * ``` + */ +export function argsToTemplate>( + args: A, + options: ArgsToTemplateOptions = {} +) { + const includeSet = options.include ? new Set(options.include) : null; + const excludeSet = options.exclude ? new Set(options.exclude) : null; + + return Object.entries(args) + .filter(([key]) => args[key] !== undefined) + .filter(([key]) => { + if (includeSet) { + return includeSet.has(key); + } + if (excludeSet) { + return !excludeSet.has(key); + } + return true; + }) + .map(([key, value]) => + typeof value === 'function' + ? `(${key})="${formatPropInTemplate(key)}($event)"` + : `[${key}]="${formatPropInTemplate(key)}"` + ) + .join(' '); +} diff --git a/code/frameworks/angular-vite/src/client/compodoc-types.ts b/code/frameworks/angular-vite/src/client/compodoc-types.ts new file mode 100644 index 000000000000..d1ac2f2d52d7 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/compodoc-types.ts @@ -0,0 +1,115 @@ +export interface Method { + name: string; + args: Argument[]; + returnType: string; + decorators?: Decorator[]; + description?: string; + rawdescription?: string; +} + +export interface JsDocTag { + comment?: string; + tagName?: { + escapedText?: string; + }; +} + +export interface Property { + name: string; + decorators?: Decorator[]; + type: string; + optional: boolean; + defaultValue?: string; + description?: string; + rawdescription?: string; + jsdoctags?: JsDocTag[]; +} + +export interface Class { + name: string; + ngname: string; + type: 'pipe'; + properties: Property[]; + methods: Method[]; + description?: string; + rawdescription?: string; +} + +export interface Injectable { + name: string; + type: 'injectable'; + properties: Property[]; + methods: Method[]; + description?: string; + rawdescription?: string; +} + +export interface Pipe { + name: string; + type: 'class'; + properties: Property[]; + methods: Method[]; + description?: string; + rawdescription?: string; +} + +export interface Directive { + name: string; + type: 'directive' | 'component'; + propertiesClass: Property[]; + inputsClass: Property[]; + outputsClass: Property[]; + methodsClass: Method[]; + description?: string; + rawdescription?: string; +} + +export type Component = Directive; + +export interface Argument { + name: string; + type: string; + optional?: boolean; +} + +export interface Decorator { + name: string; +} + +export interface TypeAlias { + name: string; + ctype: string; + subtype: string; + rawtype: string; + file: string; + kind: number; + description?: string; + rawdescription?: string; +} + +export interface EnumType { + name: string; + childs: EnumTypeChild[]; + ctype: string; + subtype: string; + file: string; + description?: string; + rawdescription?: string; +} + +export interface EnumTypeChild { + name: string; + value?: string; +} + +export interface CompodocJson { + directives: Directive[]; + components: Component[]; + pipes: Pipe[]; + injectables: Injectable[]; + classes: Class[]; + miscellaneous?: { + typealiases?: TypeAlias[]; + enumerations?: EnumType[]; + }; +} diff --git a/code/frameworks/angular-vite/src/client/compodoc.test.ts b/code/frameworks/angular-vite/src/client/compodoc.test.ts new file mode 100644 index 000000000000..6f4e16cc5c18 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/compodoc.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; + +import { extractType, setCompodocJson } from './compodoc'; +import type { CompodocJson, Decorator } from './compodoc-types'; + +const makeProperty = (compodocType?: string) => ({ + type: compodocType, + name: 'dummy', + decorators: [] as Decorator[], + optional: true, +}); + +const getDummyCompodocJson = () => { + return { + miscellaneous: { + typealiases: [ + { + name: 'EnumAlias', + ctype: 'miscellaneous', + subtype: 'typealias', + rawtype: 'EnumNumeric', + file: 'src/stories/component-with-enums/enums.component.ts', + description: '', + kind: 161, + }, + { + name: 'TypeAlias', + ctype: 'miscellaneous', + subtype: 'typealias', + rawtype: '"Type Alias 1" | "Type Alias 2" | "Type Alias 3"', + file: 'src/stories/component-with-enums/enums.component.ts', + description: '', + kind: 168, + }, + ], + enumerations: [ + { + name: 'EnumNumeric', + childs: [ + { + name: 'FIRST', + }, + { + name: 'SECOND', + }, + { + name: 'THIRD', + }, + ], + ctype: 'miscellaneous', + subtype: 'enum', + description: '

Button Priority

\n', + file: 'src/stories/component-with-enums/enums.component.ts', + }, + { + name: 'EnumNumericInitial', + childs: [ + { + name: 'UNO', + value: '1', + }, + { + name: 'DOS', + }, + { + name: 'TRES', + }, + ], + ctype: 'miscellaneous', + subtype: 'enum', + description: '', + file: 'src/stories/component-with-enums/enums.component.ts', + }, + { + name: 'EnumStringValues', + childs: [ + { + name: 'PRIMARY', + value: 'PRIMARY', + }, + { + name: 'SECONDARY', + value: 'SECONDARY', + }, + { + name: 'TERTIARY', + value: 'TERTIARY', + }, + ], + ctype: 'miscellaneous', + subtype: 'enum', + description: '', + file: 'src/stories/component-with-enums/enums.component.ts', + }, + ], + }, + } as CompodocJson; +}; + +describe('extractType', () => { + describe('with compodoc type', () => { + setCompodocJson(getDummyCompodocJson()); + it.each([ + ['string', { name: 'string' }], + ['boolean', { name: 'boolean' }], + ['number', { name: 'number' }], + // ['object', { name: 'object' }], // seems to be wrong | TODO: REVISIT + // ['foo', { name: 'other', value: 'empty-enum' }], // seems to be wrong | TODO: REVISIT + [null, { name: 'other', value: 'void' }], + [undefined, { name: 'other', value: 'void' }], + // ['T[]', { name: 'other', value: 'empty-enum' }], // seems to be wrong | TODO: REVISIT + ['[]', { name: 'other', value: 'empty-enum' }], + ['"primary" | "secondary"', { name: 'enum', value: ['primary', 'secondary'] }], + ['TypeAlias', { name: 'enum', value: ['Type Alias 1', 'Type Alias 2', 'Type Alias 3'] }], + // ['EnumNumeric', { name: 'other', value: 'empty-enum' }], // seems to be wrong | TODO: REVISIT + // ['EnumNumericInitial', { name: 'other', value: 'empty-enum' }], // seems to be wrong | TODO: REVISIT + ['EnumStringValues', { name: 'enum', value: ['PRIMARY', 'SECONDARY', 'TERTIARY'] }], + ])('%s', (compodocType, expected) => { + expect(extractType(makeProperty(compodocType), null)).toEqual(expected); + }); + }); + + describe('without compodoc type', () => { + it.each([ + ['string', { name: 'string' }], + ['', { name: 'string' }], + [false, { name: 'boolean' }], + [10, { name: 'number' }], + // [['abc'], { name: 'object' }], // seems to be wrong | TODO: REVISIT + // [{ foo: 1 }, { name: 'other', value: 'empty-enum' }], // seems to be wrong | TODO: REVISIT + [undefined, { name: 'other', value: 'void' }], + ])('%s', (defaultValue, expected) => { + expect(extractType(makeProperty(null), defaultValue)).toEqual(expected); + }); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/compodoc.ts b/code/frameworks/angular-vite/src/client/compodoc.ts new file mode 100644 index 000000000000..5fe06df3a653 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/compodoc.ts @@ -0,0 +1,307 @@ +import { logger } from 'storybook/internal/client-logger'; +import type { ArgTypes, InputType, SBType } from 'storybook/internal/types'; + +import { global } from '@storybook/global'; + +import type { + Argument, + Class, + CompodocJson, + Component, + Directive, + Injectable, + JsDocTag, + Method, + Pipe, + Property, +} from './compodoc-types'; + +const { FEATURES } = global; + +export const isMethod = (methodOrProp: Method | Property): methodOrProp is Method => { + return (methodOrProp as Method).args !== undefined; +}; + +export const setCompodocJson = (compodocJson: CompodocJson) => { + global.__STORYBOOK_COMPODOC_JSON__ = compodocJson; +}; + +export const getCompodocJson = (): CompodocJson => global.__STORYBOOK_COMPODOC_JSON__; + +export const checkValidComponentOrDirective = (component: Component | Directive) => { + if (!component.name) { + throw new Error(`Invalid component ${JSON.stringify(component)}`); + } +}; + +export const checkValidCompodocJson = (compodocJson: CompodocJson) => { + if (!compodocJson || !compodocJson.components) { + throw new Error('Invalid compodoc JSON'); + } +}; + +const hasDecorator = (item: Property, decoratorName: string) => + item.decorators && item.decorators.find((x: any) => x.name === decoratorName); + +const mapPropertyToSection = (item: Property) => { + if (hasDecorator(item, 'ViewChild')) { + return 'view child'; + } + if (hasDecorator(item, 'ViewChildren')) { + return 'view children'; + } + if (hasDecorator(item, 'ContentChild')) { + return 'content child'; + } + if (hasDecorator(item, 'ContentChildren')) { + return 'content children'; + } + return 'properties'; +}; + +const mapItemToSection = (key: string, item: Method | Property): string => { + switch (key) { + case 'methods': + case 'methodsClass': + return 'methods'; + case 'inputsClass': + return 'inputs'; + case 'outputsClass': + return 'outputs'; + case 'properties': + case 'propertiesClass': + if (isMethod(item)) { + throw new Error("Cannot be of type Method if key === 'propertiesClass'"); + } + return mapPropertyToSection(item); + default: + throw new Error(`Unknown key: ${key}`); + } +}; + +export const findComponentByName = (name: string, compodocJson: CompodocJson) => + compodocJson.components.find((c: Component) => c.name === name) || + compodocJson.directives.find((c: Directive) => c.name === name) || + compodocJson.pipes.find((c: Pipe) => c.name === name) || + compodocJson.injectables.find((c: Injectable) => c.name === name) || + compodocJson.classes.find((c: Class) => c.name === name); + +const getComponentData = (component: Component | Directive) => { + if (!component) { + return null; + } + checkValidComponentOrDirective(component); + const compodocJson = getCompodocJson(); + if (!compodocJson) { + return null; + } + checkValidCompodocJson(compodocJson); + const { name } = component; + const metadata = findComponentByName(name, compodocJson); + if (!metadata) { + logger.warn(`Component not found in compodoc JSON: '${name}'`); + } + return metadata; +}; + +const displaySignature = (item: Method): string => { + const args = item.args.map( + (arg: Argument) => `${arg.name}${arg.optional ? '?' : ''}: ${arg.type}` + ); + return `(${args.join(', ')}) => ${item.returnType}`; +}; + +const extractTypeFromValue = (defaultValue: any) => { + const valueType = typeof defaultValue; + return defaultValue || valueType === 'number' || valueType === 'boolean' || valueType === 'string' + ? valueType + : null; +}; + +const extractEnumValues = (compodocType: any) => { + const compodocJson = getCompodocJson(); + const enumType = compodocJson?.miscellaneous?.enumerations?.find((x) => x.name === compodocType); + + if (enumType?.childs.every((x) => x.value)) { + return enumType.childs.map((x) => x.value); + } + + if (typeof compodocType !== 'string' || compodocType.indexOf('|') === -1) { + return null; + } + + try { + return compodocType.split('|').map((value) => JSON.parse(value)); + } catch (e) { + return null; + } +}; + +export const extractType = (property: Property, defaultValue: any): SBType => { + const compodocType = property.type || extractTypeFromValue(defaultValue); + switch (compodocType) { + case 'string': + case 'boolean': + case 'number': + return { name: compodocType }; + case undefined: + case null: + return { name: 'other', value: 'void' }; + default: { + const resolvedType = resolveTypealias(compodocType); + const enumValues = extractEnumValues(resolvedType); + return enumValues + ? { name: 'enum', value: enumValues } + : { name: 'other', value: 'empty-enum' }; + } + } +}; + +const castDefaultValue = (property: Property, defaultValue: any) => { + const compodocType = property.type; + + // All these checks are necessary as compodoc does not always set the type ie. @HostBinding have empty types. + // null and undefined also have 'any' type + if (['boolean', 'number', 'string', 'EventEmitter'].includes(compodocType)) { + switch (compodocType) { + case 'boolean': + return defaultValue === 'true'; + case 'number': + return Number(defaultValue); + case 'EventEmitter': + return undefined; + default: + return defaultValue; + } + } else { + switch (defaultValue) { + case 'true': + return true; + case 'false': + return false; + case 'null': + return null; + case 'undefined': + return undefined; + default: + return defaultValue; + } + } +}; + +const extractDefaultValueFromComments = (property: Property, value: any) => { + let commentValue = value; + property.jsdoctags.forEach((tag: JsDocTag) => { + if (['default', 'defaultvalue'].includes(tag.tagName.escapedText)) { + const dom = new global.DOMParser().parseFromString(tag.comment, 'text/html'); + commentValue = dom.body.textContent; + } + }); + return commentValue; +}; + +const extractDefaultValue = (property: Property) => { + try { + let value: string = property.defaultValue?.replace(/^'(.*)'$/, '$1'); + value = castDefaultValue(property, value); + + if (value == null && property.jsdoctags?.length > 0) { + value = extractDefaultValueFromComments(property, value); + } + + return value; + } catch (err) { + logger.debug(`Error extracting ${property.name}: ${property.defaultValue}`); + return undefined; + } +}; + +const resolveTypealias = (compodocType: string): string => { + const compodocJson = getCompodocJson(); + const typeAlias = compodocJson?.miscellaneous?.typealiases?.find((x) => x.name === compodocType); + return typeAlias ? resolveTypealias(typeAlias.rawtype) : compodocType; +}; + +export const extractArgTypesFromData = (componentData: Class | Directive | Injectable | Pipe) => { + const sectionToItems: Record = {}; + const componentClasses = FEATURES.angularFilterNonInputControls + ? ['inputsClass'] + : ['propertiesClass', 'methodsClass', 'inputsClass', 'outputsClass']; + const compodocClasses = ['component', 'directive'].includes(componentData.type) + ? componentClasses + : ['properties', 'methods']; + + type COMPODOC_CLASS = + | 'properties' + | 'methods' + | 'propertiesClass' + | 'methodsClass' + | 'inputsClass' + | 'outputsClass'; + + compodocClasses.forEach((key: COMPODOC_CLASS) => { + const data = (componentData as any)[key] || []; + data.forEach((item: Method | Property) => { + const section = mapItemToSection(key, item); + const defaultValue = isMethod(item) ? undefined : extractDefaultValue(item as Property); + + const type: SBType = + isMethod(item) || (section !== 'inputs' && section !== 'properties') + ? { name: 'other', value: 'void' } + : extractType(item as Property, defaultValue); + const action = section === 'outputs' ? { action: item.name } : {}; + + const argType = { + name: item.name, + description: item.rawdescription || item.description, + type, + ...action, + table: { + category: section, + type: { + summary: isMethod(item) ? displaySignature(item) : item.type, + required: isMethod(item) ? false : !item.optional, + }, + defaultValue: { summary: defaultValue }, + }, + }; + + if (!sectionToItems[section]) { + sectionToItems[section] = []; + } + sectionToItems[section].push(argType); + }); + }); + + const SECTIONS = [ + 'properties', + 'inputs', + 'outputs', + 'methods', + 'view child', + 'view children', + 'content child', + 'content children', + ]; + const argTypes: ArgTypes = {}; + SECTIONS.forEach((section) => { + const items = sectionToItems[section]; + if (items) { + items.forEach((argType) => { + argTypes[argType.name] = argType; + }); + } + }); + + return argTypes; +}; + +export const extractArgTypes = (component: Component | Directive) => { + const componentData = getComponentData(component); + return componentData && extractArgTypesFromData(componentData); +}; + +export const extractComponentDescription = (component: Component | Directive) => { + const componentData = getComponentData(component); + return componentData && (componentData.rawdescription || componentData.description); +}; diff --git a/code/frameworks/angular-vite/src/client/config.ts b/code/frameworks/angular-vite/src/client/config.ts new file mode 100644 index 000000000000..0cc92ae39379 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/config.ts @@ -0,0 +1,20 @@ +import './globals'; + +export { render, renderToCanvas } from './render'; +export { decorateStory as applyDecorators } from './decorateStory'; + +import { enhanceArgTypes } from 'storybook/internal/docs-tools'; +import type { ArgTypesEnhancer, Parameters } from 'storybook/internal/types'; + +import { extractArgTypes, extractComponentDescription } from './compodoc'; + +export const parameters: Parameters = { + renderer: 'angular', + docs: { + story: { inline: true }, + extractArgTypes, + extractComponentDescription, + }, +}; + +export const argTypesEnhancers: ArgTypesEnhancer[] = [enhanceArgTypes]; diff --git a/code/frameworks/angular-vite/src/client/csf-factories.test.ts b/code/frameworks/angular-vite/src/client/csf-factories.test.ts new file mode 100644 index 000000000000..8e2ab022256b --- /dev/null +++ b/code/frameworks/angular-vite/src/client/csf-factories.test.ts @@ -0,0 +1,371 @@ +// this file primarily tests TypeScript types with some runtime assertions +import { Component, EventEmitter, input, Input, output, Output } from '@angular/core'; +import { describe, expect, it, test } from 'vitest'; + +import type { Args } from 'storybook/internal/types'; + +import { __definePreview } from './preview'; +import type { Decorator } from './public-types'; + +@Component({ + selector: 'storybook-button', + standalone: true, + template: ``, +}) +class ButtonComponent { + @Input() + label!: string; + + @Input() + disabled!: boolean; + + @Output() + disabledChange = new EventEmitter(); +} + +type ButtonProps = { label: string; disabled: boolean; disabledChange?: (e: void) => void }; + +const preview = __definePreview({ + addons: [], +}); + +test('csf factories', () => { + const meta = preview.meta({ + component: ButtonComponent, + args: { disabled: false }, + }); + + const MyStory = meta.story({ + args: { + label: 'Hello world', + }, + }); + + expect(MyStory.input.args?.label).toBe('Hello world'); +}); + +describe('Args can be provided in multiple ways', () => { + it('✅ All required args may be provided in meta', () => { + const meta = preview.meta({ + component: ButtonComponent, + args: { disabled: false }, + }); + + const Basic = meta.story({ + args: {}, + }); + }); + + it('✅ Required args may be provided partial in meta and the story', () => { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + args: { label: 'good' }, + }); + const Basic = meta.story({ + args: { disabled: false }, + }); + }); + + it('❌ The combined shape of meta args and story args must match the required args.', () => { + { + const meta = preview.type<{ args: { disabled: boolean } }>().meta({ + component: ButtonComponent, + }); + // @ts-expect-error disabled not provided ❌ + const Basic = meta.story({ + args: { label: 'good' }, + }); + } + { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + args: { label: 'good' }, + }); + // @ts-expect-error disabled not provided ❌ + const Basic = meta.story(); + } + { + const meta = preview.type<{ args: ButtonProps }>().meta({ component: ButtonComponent }); + // @ts-expect-error disabled not provided ❌ + const Basic = meta.story({ + args: { label: 'good' }, + }); + } + }); + + it("✅ Required args don't need to be provided when the user uses an empty render", () => { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + args: { label: 'good' }, + }); + const Basic = meta.story({ + render: () => ({ template: '
Hello world
' }), + }); + + const CSF1 = meta.story(() => ({ template: '
Hello world
' })); + }); + + it('❌ Required args need to be provided when the user uses a non-empty render', () => { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + args: { label: 'good' }, + }); + // @ts-expect-error disabled not provided ❌ + const Basic = meta.story({ + args: { + label: 'good', + }, + render: (args) => ({ template: '
Hello world
' }), + }); + }); +}); + +type ThemeData = 'light' | 'dark'; + +describe('Story args can be inferred', () => { + it('Correct args are inferred when type is widened for render function', () => { + const meta = preview.type<{ args: { theme: ThemeData } }>().meta({ + component: ButtonComponent, + args: { disabled: false }, + render: (args) => { + return { + template: `
+ +
`, + props: args, + }; + }, + }); + + const Basic = meta.story({ args: { theme: 'light', label: 'good' } }); + }); + + const withDecorator: Decorator<{ decoratorArg: number }> = (storyFunc, { args }) => { + const story = storyFunc(); + return { + ...story, + template: `
Decorator: ${args.decoratorArg}
${story.template}
`, + }; + }; + + it('Correct args are inferred when type is widened for decorators', () => { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + args: { disabled: false }, + decorators: [withDecorator], + }); + + const Basic = meta.story({ args: { decoratorArg: 0, label: 'good' } }); + }); + + it('Correct args are inferred when type is widened for multiple decorators', () => { + const secondDecorator: Decorator<{ decoratorArg2: string }> = (storyFunc, { args }) => { + const story = storyFunc(); + return { + ...story, + template: `
Decorator: ${args.decoratorArg2}
${story.template}
`, + }; + }; + + // decorator is not using args + const thirdDecorator: Decorator = (storyFunc) => { + const story = storyFunc(); + return { + ...story, + template: `
${story.template}
`, + }; + }; + + // decorator is not using args + const fourthDecorator: Decorator = (storyFunc) => { + const story = storyFunc(); + return { + ...story, + template: `
${story.template}
`, + }; + }; + + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + args: { disabled: false }, + decorators: [withDecorator, secondDecorator, thirdDecorator, fourthDecorator], + }); + + const Basic = meta.story({ + args: { decoratorArg: 0, decoratorArg2: '', label: 'good' }, + }); + }); + + it('Component type can be overridden', () => { + const meta = preview + .type<{ args: Omit & { disabledChange?: boolean } }>() + .meta({ + render: ({ disabledChange, ...args }) => { + return { + template: ``, + props: { + ...args, + onDisabledChangeHandler: disabledChange ? () => {} : undefined, + }, + }; + }, + args: { label: 'hello', disabledChange: false }, + }); + + const Basic = meta.story({ + args: { + disabled: false, + }, + }); + const WithHandler = meta.story({ args: { disabled: false, disabledChange: true } }); + }); + + it('Correct args are inferred when type is added in renderer', () => { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + args: { label: 'hello', disabledChangeToggle: false }, + render: ({ + disabledChangeToggle, + ...args + }: ButtonProps & { disabledChangeToggle?: boolean }) => { + return { + template: ``, + props: { + ...args, + onDisabledChangeHandler: disabledChangeToggle ? () => {} : undefined, + }, + }; + }, + }); + + const Basic = meta.story({ args: { disabled: false } }); + const WithHandler = meta.story({ args: { disabled: false, disabledChangeToggle: true } }); + }); + + it('Correct args are inferred when render arg type is required', () => { + const meta = preview.type<{ args: { disabledChangeToggle: boolean } }>().meta({ + component: ButtonComponent, + args: { label: 'hello' }, + render: (args) => { + return { + template: ``, + props: { + ...args, + onDisabledChangeHandler: args.disabledChangeToggle ? () => {} : undefined, + }, + }; + }, + }); + + // @ts-expect-error disabledChangeToggle is required + const Basic = meta.story({ args: { disabled: false } }); + const WithHandler = meta.story({ args: { disabled: false, disabledChangeToggle: true } }); + }); + + it('args can be reused', () => { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + }); + + const Enabled = meta.story({ args: { label: 'hello', disabled: false } }); + const Disabled = meta.story({ args: { ...Enabled.input.args, disabled: true } }); + }); + + it('stories can be extended', () => { + const meta = preview.type<{ args: ButtonProps }>().meta({ + component: ButtonComponent, + }); + + const Enabled = meta.story({ args: { label: 'hello', disabled: false } }); + const Disabled = Enabled.extend({ args: { disabled: true } }); + }); +}); + +it('Components without Props can be used', () => { + @Component({ + selector: 'storybook-simple', + standalone: true, + template: `
Simple
`, + }) + class SimpleComponent {} + + const withDecorator: Decorator = (storyFunc) => { + const story = storyFunc(); + return { + ...story, + template: `
${story.template}
`, + }; + }; + + const meta = preview.meta({ + component: SimpleComponent, + decorators: [withDecorator], + }); + + const Basic = meta.story(); +}); + +it('Signal components can be used', () => { + @Component({ + standalone: false, + // Needs to be a different name to the CLI template button + selector: 'storybook-signal-button', + template: ` `, + }) + class SignalButtonComponent { + /** Is this the principal call to action on the page? */ + primary = input(false); + + /** What background color to use */ + @Input() + backgroundColor?: string; + + /** How large should the button be? */ + size = input('medium', { + transform: (val: 'small' | 'medium') => val, + }); + + /** Button contents */ + label = input.required(); + + /** Optional click handler */ + onClick = output(); + + public get classes(): string[] { + const mode = this.primary() ? 'storybook-button--primary' : 'storybook-button--secondary'; + + return ['storybook-button', `storybook-button--${this.size()}`, mode]; + } + } + + const meta = preview.meta({ + component: SignalButtonComponent, + }); + + const Basic = meta.story({ + args: { + backgroundColor: 'red', + size: 'small', + label: '1', + }, + }); +}); diff --git a/code/frameworks/angular-vite/src/client/decorateStory.test.ts b/code/frameworks/angular-vite/src/client/decorateStory.test.ts new file mode 100644 index 000000000000..112c36eb0cfa --- /dev/null +++ b/code/frameworks/angular-vite/src/client/decorateStory.test.ts @@ -0,0 +1,345 @@ +import { Component, Input, Output } from '@angular/core'; +import type { DecoratorFunction, StoryContext } from 'storybook/internal/types'; +import { describe, expect, it } from 'vitest'; +import { componentWrapperDecorator } from './decorators'; + +import decorateStory from './decorateStory'; +import type { AngularRenderer } from './types'; + +// TODO: Fix. Test is infinitely running. +describe.skip('decorateStory', () => { + describe('angular behavior', () => { + it('should use componentWrapperDecorator with args', () => { + const decorators: DecoratorFunction[] = [ + componentWrapperDecorator(ParentComponent, ({ args }) => args), + componentWrapperDecorator( + (story) => `${story}`, + ({ args }) => args + ), + componentWrapperDecorator((story) => `${story}`), + ]; + const decorated = decorateStory(() => ({ template: '' }), decorators); + + expect( + decorated( + makeContext({ + component: FooComponent, + args: { + parentInput: 'Parent input', + grandparentInput: 'grandparent input', + parentOutput: () => {}, + }, + }) + ) + ).toEqual({ + props: { + parentInput: 'Parent input', + grandparentInput: 'grandparent input', + parentOutput: expect.any(Function), + }, + template: + '', + userDefinedTemplate: true, + }); + }); + + it('should use componentWrapperDecorator with input / output', () => { + const decorators: DecoratorFunction[] = [ + componentWrapperDecorator(ParentComponent, { + parentInput: 'Parent input', + parentOutput: () => {}, + }), + componentWrapperDecorator( + (story) => `${story}`, + { + grandparentInput: 'Grandparent input', + sameInput: 'Should be override by story props', + } + ), + componentWrapperDecorator((story) => `${story}`), + ]; + const decorated = decorateStory( + () => ({ template: '', props: { sameInput: 'Story input' } }), + decorators + ); + + expect( + decorated( + makeContext({ + component: FooComponent, + }) + ) + ).toEqual({ + props: { + parentInput: 'Parent input', + parentOutput: expect.any(Function), + grandparentInput: 'Grandparent input', + sameInput: 'Story input', + }, + template: + '', + userDefinedTemplate: true, + }); + }); + + it('should use componentWrapperDecorator', () => { + const decorators: DecoratorFunction[] = [ + componentWrapperDecorator(ParentComponent), + componentWrapperDecorator((story) => `${story}`), + componentWrapperDecorator((story) => `${story}`), + ]; + const decorated = decorateStory(() => ({ template: '' }), decorators); + + expect(decorated(makeContext({ component: FooComponent }))).toEqual({ + template: + '', + userDefinedTemplate: true, + }); + }); + + it('should use template in preference to component parameters', () => { + const decorators: DecoratorFunction[] = [ + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + ]; + const decorated = decorateStory(() => ({ template: '' }), decorators); + + expect(decorated(makeContext({ component: FooComponent }))).toEqual({ + template: + '', + userDefinedTemplate: true, + }); + }); + + it('should include story templates in decorators', () => { + const decorators: DecoratorFunction[] = [ + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + ]; + const decorated = decorateStory(() => ({ template: '' }), decorators); + + expect(decorated(makeContext({}))).toEqual({ + template: + '', + userDefinedTemplate: true, + }); + }); + + it('should include story components in decorators', () => { + const decorators: DecoratorFunction[] = [ + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + (s) => { + const story = s(); + return { + ...story, + template: `${story.template}`, + }; + }, + ]; + const decorated = decorateStory(() => ({}), decorators); + + expect(decorated(makeContext({ component: FooComponent }))).toEqual({ + template: + '', + userDefinedTemplate: false, + }); + }); + + it('should keep template with an empty value', () => { + const decorators: DecoratorFunction[] = [ + componentWrapperDecorator(ParentComponent), + ]; + const decorated = decorateStory(() => ({ template: '' }), decorators); + + expect(decorated(makeContext({ component: FooComponent }))).toEqual({ + template: '', + }); + }); + + it('should only keeps args with a control or an action in argTypes', () => { + const decorated = decorateStory( + (context: StoryContext) => ({ + template: `Args available in the story : ${Object.keys(context.args).join()}`, + }), + [] + ); + + expect( + decorated( + makeContext({ + component: FooComponent, + argTypes: { + withControl: { control: { type: 'object' }, name: 'withControl' }, + withAction: { action: 'onClick', name: 'withAction' }, + toRemove: { name: 'toRemove' }, + }, + args: { + withControl: 'withControl', + withAction: () => ({}), + toRemove: 'toRemove', + }, + }) + ) + ).toEqual({ + template: 'Args available in the story : withControl,withAction', + userDefinedTemplate: true, + }); + }); + }); + + describe('default behavior', () => { + it('calls decorators in out to in order', () => { + const decorators: DecoratorFunction[] = [ + (s) => { + const story = s(); + return { ...story, props: { a: [...story.props.a, 1] } }; + }, + (s) => { + const story = s(); + return { ...story, props: { a: [...story.props.a, 2] } }; + }, + (s) => { + const story = s(); + return { ...story, props: { a: [...story.props.a, 3] } }; + }, + ]; + const decorated = decorateStory(() => ({ props: { a: [0] } }), decorators); + + expect(decorated(makeContext({}))).toEqual({ props: { a: [0, 1, 2, 3] } }); + }); + + it('passes context through to sub decorators', () => { + const decorators: DecoratorFunction[] = [ + (s, c) => { + const story = s({ ...c, k: 1 }); + return { ...story, props: { a: [...story.props.a, c.k] } }; + }, + (s, c) => { + const story = s({ ...c, k: 2 }); + return { ...story, props: { a: [...story.props.a, c.k] } }; + }, + (s, c) => { + const story = s({ ...c, k: 3 }); + return { ...story, props: { a: [...story.props.a, c.k] } }; + }, + ]; + const decorated = decorateStory((c: StoryContext) => ({ props: { a: [c.k] } }), decorators); + + expect(decorated(makeContext({ k: 0 }))).toEqual({ props: { a: [1, 2, 3, 0] } }); + }); + + it('DOES NOT merge parameter or pass through parameters key in context', () => { + const decorators: DecoratorFunction[] = [ + (s, c) => { + const story = s({ ...c, k: 1, parameters: { p: 1 } }); + return { + ...story, + props: { a: [...story.props.a, c.k], p: [...story.props.p, c.parameters.p] }, + }; + }, + (s, c) => { + const story = s({ ...c, k: 2, parameters: { p: 2 } }); + return { + ...story, + props: { a: [...story.props.a, c.k], p: [...story.props.p, c.parameters.p] }, + }; + }, + (s, c) => { + const story = s({ ...c, k: 3, parameters: { p: 3 } }); + return { + ...story, + props: { a: [...story.props.a, c.k], p: [...story.props.p, c.parameters.p] }, + }; + }, + ]; + const decorated = decorateStory( + (c: StoryContext) => ({ props: { a: [c.k], p: [c.parameters.p] } }), + decorators + ); + + expect(decorated(makeContext({ k: 0, parameters: { p: 0 } }))).toEqual({ + props: { a: [1, 2, 3, 0], p: [0, 0, 0, 0] }, + }); + }); + }); +}); + +function makeContext(input: Record): StoryContext { + return { + id: 'id', + kind: 'kind', + name: 'name', + viewMode: 'story', + parameters: {}, + ...input, + } as StoryContext; +} + +@Component({ + selector: 'foo', + template: `foo`, +}) +class FooComponent {} + +@Component({ + selector: 'parent', + template: ``, +}) +class ParentComponent { + @Input() + parentInput: string; + + @Output() + parentOutput: any; +} diff --git a/code/frameworks/angular-vite/src/client/decorateStory.ts b/code/frameworks/angular-vite/src/client/decorateStory.ts new file mode 100644 index 000000000000..9311afc0fa94 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/decorateStory.ts @@ -0,0 +1,71 @@ +import { sanitizeStoryContextUpdate } from 'storybook/preview-api'; +import type { DecoratorFunction, LegacyStoryFn, StoryContext } from 'storybook/internal/types'; + +import { computesTemplateFromComponent } from './angular-beta/ComputesTemplateFromComponent'; +import type { AngularRenderer } from './types'; + +export default function decorateStory( + mainStoryFn: LegacyStoryFn, + decorators: DecoratorFunction[] +): LegacyStoryFn { + const returnDecorators = [cleanArgsDecorator, ...decorators].reduce( + (previousStoryFn: LegacyStoryFn, decorator) => + (context: StoryContext) => { + const decoratedStory = decorator((update) => { + return previousStoryFn({ + ...context, + ...sanitizeStoryContextUpdate(update), + }); + }, context); + + return decoratedStory; + }, + (context) => prepareMain(mainStoryFn(context), context) + ); + + return returnDecorators; +} + +export { decorateStory }; + +const prepareMain = ( + story: AngularRenderer['storyResult'], + context: StoryContext +): AngularRenderer['storyResult'] => { + let { template } = story; + + const { component } = context; + const userDefinedTemplate = !hasNoTemplate(template); + + if (!userDefinedTemplate && component) { + template = computesTemplateFromComponent(component, story.props, ''); + } + return { + ...story, + ...(template ? { template, userDefinedTemplate } : {}), + }; +}; + +function hasNoTemplate(template: string | null | undefined): template is undefined { + return template === null || template === undefined; +} + +const cleanArgsDecorator: DecoratorFunction = (storyFn, context) => { + if (!context.argTypes || !context.args) { + return storyFn(); + } + + const argsToClean = context.args; + + context.args = Object.entries(argsToClean).reduce((obj, [key, arg]) => { + const argType = context.argTypes[key]; + + // Only keeps args with a control or an action in argTypes + if (argType?.action || argType?.control) { + return { ...obj, [key]: arg }; + } + return obj; + }, {}); + + return storyFn(); +}; diff --git a/code/frameworks/angular-vite/src/client/decorators.test.ts b/code/frameworks/angular-vite/src/client/decorators.test.ts new file mode 100644 index 000000000000..a8f99edeaf64 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/decorators.test.ts @@ -0,0 +1,179 @@ +import type { Addon_StoryContext } from 'storybook/internal/types'; + +import { vi, expect, describe, it } from 'vitest'; +import { Component } from '@angular/core'; +import { moduleMetadata, applicationConfig } from './decorators'; +import type { AngularRenderer } from './types'; + +const defaultContext: Addon_StoryContext = { + componentId: 'unspecified', + kind: 'unspecified', + title: 'unspecified', + id: 'unspecified', + name: 'unspecified', + story: 'unspecified', + tags: [], + parameters: {}, + initialArgs: {}, + args: {}, + argTypes: {}, + globals: {}, + globalTypes: {}, + storyGlobals: {}, + reporting: { + reports: [], + addReport: vi.fn(), + }, + hooks: {}, + loaded: {}, + originalStoryFn: vi.fn(), + viewMode: 'story', + abortSignal: undefined, + canvasElement: undefined, + step: undefined, + context: undefined, + canvas: undefined, + userEvent: undefined, + mount: undefined, +}; + +defaultContext.context = defaultContext; + +class MockModule {} +class MockModuleTwo {} +class MockService {} +@Component({}) +class MockComponent {} + +describe('applicationConfig', () => { + const provider1 = () => {}; + const provider2 = () => {}; + + it('should apply global config', () => { + expect( + applicationConfig({ + providers: [provider1] as any, + })(() => ({}), defaultContext) + ).toEqual({ + applicationConfig: { + providers: [provider1], + }, + }); + }); + + it('should apply story config', () => { + expect( + applicationConfig({ + providers: [], + })( + () => ({ + applicationConfig: { + providers: [provider2] as any, + }, + }), + { + ...defaultContext, + } + ) + ).toEqual({ + applicationConfig: { + providers: [provider2], + }, + }); + }); + + it('should merge global and story config', () => { + expect( + applicationConfig({ + providers: [provider1] as any, + })( + () => ({ + applicationConfig: { + providers: [provider2] as any, + }, + }), + { + ...defaultContext, + } + ) + ).toEqual({ + applicationConfig: { + providers: [provider1, provider2], + }, + }); + }); +}); + +describe('moduleMetadata', () => { + it('should add metadata to a story without it', () => { + const result = moduleMetadata({ + imports: [MockModule], + providers: [MockService], + })( + () => ({}), + // deepscan-disable-next-line + defaultContext + ); + + expect(result).toEqual({ + moduleMetadata: { + declarations: [], + entryComponents: [], + imports: [MockModule], + schemas: [], + providers: [MockService], + }, + }); + }); + + it('should combine with individual metadata on a story', () => { + const result = moduleMetadata({ + imports: [MockModule], + })( + () => ({ + component: MockComponent, + moduleMetadata: { + imports: [MockModuleTwo], + providers: [MockService], + }, + }), + // deepscan-disable-next-line + defaultContext + ); + + expect(result).toEqual({ + component: MockComponent, + moduleMetadata: { + declarations: [], + entryComponents: [], + imports: [MockModule, MockModuleTwo], + schemas: [], + providers: [MockService], + }, + }); + }); + + it('should return the original metadata if passed null', () => { + const result = moduleMetadata(null)( + () => ({ + component: MockComponent, + moduleMetadata: { + providers: [MockService], + }, + }), + // deepscan-disable-next-line + defaultContext + ); + + expect(result).toEqual({ + component: MockComponent, + moduleMetadata: { + declarations: [], + entryComponents: [], + imports: [], + schemas: [], + providers: [MockService], + }, + }); + }); +}); diff --git a/code/frameworks/angular-vite/src/client/decorators.ts b/code/frameworks/angular-vite/src/client/decorators.ts new file mode 100644 index 000000000000..f214c708e611 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/decorators.ts @@ -0,0 +1,85 @@ +import type { DecoratorFunction, StoryContext } from 'storybook/internal/types'; + +import type { ApplicationConfig, Type } from '@angular/core'; + +import { computesTemplateFromComponent } from './angular-beta/ComputesTemplateFromComponent'; +import { isComponent } from './angular-beta/utils/NgComponentAnalyzer'; +import type { AngularRenderer, ICollection, NgModuleMetadata } from './types'; + +// We use `any` here as the default type rather than `Args` because we need something that is +// castable to any component-specific args type when the user is being careful. +export const moduleMetadata = + (metadata: Partial): DecoratorFunction => + (storyFn) => { + const story = storyFn(); + const storyMetadata = story.moduleMetadata || {}; + metadata = metadata || {}; + + return { + ...story, + moduleMetadata: { + declarations: [...(metadata.declarations || []), ...(storyMetadata.declarations || [])], + entryComponents: [ + ...(metadata.entryComponents || []), + ...(storyMetadata.entryComponents || []), + ], + imports: [...(metadata.imports || []), ...(storyMetadata.imports || [])], + schemas: [...(metadata.schemas || []), ...(storyMetadata.schemas || [])], + providers: [...(metadata.providers || []), ...(storyMetadata.providers || [])], + }, + }; + }; + +/** + * Decorator to set the config options which are available during the application bootstrap + * operation + */ +export function applicationConfig( + /** Set of config options available during the application bootstrap operation. */ + config: ApplicationConfig +): DecoratorFunction { + return (storyFn) => { + const story = storyFn(); + + const storyConfig: ApplicationConfig | undefined = story.applicationConfig; + + return { + ...story, + applicationConfig: + storyConfig || config + ? { + ...config, + ...storyConfig, + providers: [...(config?.providers || []), ...(storyConfig?.providers || [])], + } + : undefined, + }; + }; +} + +export const componentWrapperDecorator = + ( + element: Type | ((story: string) => string), + props?: ICollection | ((storyContext: StoryContext) => ICollection) + ): DecoratorFunction => + (storyFn, storyContext) => { + const story = storyFn(); + const currentProps = typeof props === 'function' ? (props(storyContext) as ICollection) : props; + + const template = isComponent(element) + ? computesTemplateFromComponent(element, currentProps ?? {}, story.template) + : element(story.template); + + return { + ...story, + template, + ...(currentProps || story.props + ? { + props: { + ...currentProps, + ...story.props, + }, + } + : {}), + }; + }; diff --git a/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/argtypes.snapshot b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/argtypes.snapshot new file mode 100644 index 000000000000..d3625f14e38f --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/argtypes.snapshot @@ -0,0 +1,441 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`angular component properties doc-button 1`] = ` +Object { + "_inputValue": Object { + "defaultValue": "some value", + "description": "", + "name": "_inputValue", + "table": Object { + "category": "properties", + "defaultValue": Object { + "summary": "some value", + }, + "type": Object { + "required": true, + "summary": "string", + }, + }, + "type": Object { + "name": "string", + }, + }, + "_value": Object { + "defaultValue": "Private hello", + "description": " +Private value.", + "name": "_value", + "table": Object { + "category": "properties", + "defaultValue": Object { + "summary": "Private hello", + }, + "type": Object { + "required": true, + "summary": "string", + }, + }, + "type": Object { + "name": "string", + }, + }, + "accent": Object { + "defaultValue": undefined, + "description": " +Specify the accent-type of the button", + "name": "accent", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "ButtonAccent", + }, + }, + "type": Object { + "name": "object", + }, + }, + "appearance": Object { + "defaultValue": "secondary", + "description": " +Appearance style of the button.", + "name": "appearance", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": "secondary", + }, + "type": Object { + "required": true, + "summary": "\\"primary\\" | \\"secondary\\"", + }, + }, + "type": Object { + "name": "enum", + "value": Array [ + "primary", + "secondary", + ], + }, + }, + "buttonRef": Object { + "defaultValue": undefined, + "description": "", + "name": "buttonRef", + "table": Object { + "category": "view child", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "ElementRef", + }, + }, + "type": Object { + "name": "void", + }, + }, + "calc": Object { + "defaultValue": undefined, + "description": " + +An internal calculation method which adds \`x\` and \`y\` together. + +", + "name": "calc", + "table": Object { + "category": "methods", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": false, + "summary": "(x: number, y: string | number) => number", + }, + }, + "type": Object { + "name": "void", + }, + }, + "focus": Object { + "defaultValue": false, + "description": "", + "name": "focus", + "table": Object { + "category": "properties", + "defaultValue": Object { + "summary": false, + }, + "type": Object { + "required": true, + "summary": "", + }, + }, + "type": Object { + "name": "boolean", + }, + }, + "inputValue": Object { + "defaultValue": undefined, + "description": " +Setter for \`inputValue\` that is also an \`@Input\`.", + "name": "inputValue", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "string", + }, + }, + "type": Object { + "name": "string", + }, + }, + "internalProperty": Object { + "defaultValue": "Public hello", + "description": " +Public value.", + "name": "internalProperty", + "table": Object { + "category": "properties", + "defaultValue": Object { + "summary": "Public hello", + }, + "type": Object { + "required": true, + "summary": "string", + }, + }, + "type": Object { + "name": "string", + }, + }, + "isDisabled": Object { + "defaultValue": false, + "description": " +Sets the button to a disabled state.", + "name": "isDisabled", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": false, + }, + "type": Object { + "required": true, + "summary": "boolean", + }, + }, + "type": Object { + "name": "boolean", + }, + }, + "item": Object { + "defaultValue": undefined, + "description": undefined, + "name": "item", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "T[]", + }, + }, + "type": Object { + "name": "object", + }, + }, + "label": Object { + "defaultValue": undefined, + "description": " + +The inner text of the button. + +", + "name": "label", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "string", + }, + }, + "type": Object { + "name": "string", + }, + }, + "onClick": Object { + "action": "onClick", + "defaultValue": undefined, + "description": " + +Handler to be called when the button is clicked by a user. + +Will also block the emission of the event if \`isDisabled\` is true. +", + "name": "onClick", + "table": Object { + "category": "outputs", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "EventEmitter", + }, + }, + "type": Object { + "name": "void", + }, + }, + "onClickListener": Object { + "defaultValue": undefined, + "description": undefined, + "name": "onClickListener", + "table": Object { + "category": "methods", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": false, + "summary": "(btn: ) => void", + }, + }, + "type": Object { + "name": "void", + }, + }, + "privateMethod": Object { + "defaultValue": undefined, + "description": " + +A private method. + +", + "name": "privateMethod", + "table": Object { + "category": "methods", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": false, + "summary": "(password: string) => void", + }, + }, + "type": Object { + "name": "void", + }, + }, + "processedItem": Object { + "defaultValue": undefined, + "description": "", + "name": "processedItem", + "table": Object { + "category": "properties", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "T[]", + }, + }, + "type": Object { + "name": "object", + }, + }, + "protectedMethod": Object { + "defaultValue": undefined, + "description": " + +A protected method. + +", + "name": "protectedMethod", + "table": Object { + "category": "methods", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": false, + "summary": "(id?: number) => void", + }, + }, + "type": Object { + "name": "void", + }, + }, + "publicMethod": Object { + "defaultValue": undefined, + "description": " +A public method using an interface.", + "name": "publicMethod", + "table": Object { + "category": "methods", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": false, + "summary": "(things: ISomeInterface) => void", + }, + }, + "type": Object { + "name": "void", + }, + }, + "showKeyAlias": Object { + "defaultValue": undefined, + "description": undefined, + "name": "showKeyAlias", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "", + }, + }, + "type": Object { + "name": "void", + }, + }, + "size": Object { + "defaultValue": "medium", + "description": " +Size of the button.", + "name": "size", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": "medium", + }, + "type": Object { + "required": true, + "summary": "ButtonSize", + }, + }, + "type": Object { + "name": "object", + }, + }, + "someDataObject": Object { + "defaultValue": undefined, + "description": " +Specifies some arbitrary object", + "name": "someDataObject", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": undefined, + }, + "type": Object { + "required": true, + "summary": "ISomeInterface", + }, + }, + "type": Object { + "name": "object", + }, + }, + "somethingYouShouldNotUse": Object { + "defaultValue": false, + "description": " + +Some input you shouldn't use. + +", + "name": "somethingYouShouldNotUse", + "table": Object { + "category": "inputs", + "defaultValue": Object { + "summary": false, + }, + "type": Object { + "required": true, + "summary": "boolean", + }, + }, + "type": Object { + "name": "boolean", + }, + }, +} +`; diff --git a/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-posix.snapshot b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-posix.snapshot new file mode 100644 index 000000000000..de95727d81f5 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-posix.snapshot @@ -0,0 +1,1326 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`angular component properties doc-button 1`] = ` +Object { + "classes": Array [], + "components": Array [ + Object { + "accessors": Object { + "inputValue": Object { + "getSignature": Object { + "description": "

Getter for inputValue.

+", + "line": 116, + "name": "inputValue", + "rawdescription": " +Getter for \`inputValue\`.", + "returnType": "", + "type": "", + }, + "name": "inputValue", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "type": "string", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Setter for inputValue that is also an @Input.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "tagName": Object { + "text": "param", + }, + "type": "string", + }, + ], + "line": 111, + "name": "inputValue", + "rawdescription": " +Setter for \`inputValue\` that is also an \`@Input\`.", + "returnType": "void", + "type": "void", + }, + }, + "item": Object { + "name": "item", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "item", + "type": "T[]", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "item", + "tagName": Object { + "text": "param", + }, + "type": "T[]", + }, + ], + "line": 196, + "name": "item", + "returnType": "void", + "type": "void", + }, + }, + "value": Object { + "getSignature": Object { + "description": "

Get the private value.

+", + "line": 155, + "name": "value", + "rawdescription": " +Get the private value.", + "returnType": "string | number", + "type": "", + }, + "name": "value", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "type": "string | number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Set the private value.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "tagName": Object { + "text": "param", + }, + "type": "string | number", + }, + ], + "line": 150, + "name": "value", + "rawdescription": " +Set the private value.", + "returnType": "void", + "type": "void", + }, + }, + }, + "assetsDirs": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular.

+

It supports markdown, so you can embed formatted text, +like bold, italic, and inline code.

+
+

How you like dem apples?! It's never been easier to document all your components.

+
+", + "encapsulation": Array [], + "entryComponents": Array [], + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "hostBindings": Array [ + Object { + "decorators": Array [], + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "line": 125, + "name": "class.focused", + "type": "boolean", + }, + ], + "hostListeners": Array [ + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "type": "", + }, + ], + "argsDecorator": Array [ + "$event.target", + ], + "deprecated": false, + "deprecationMessage": "", + "line": 121, + "name": "click", + }, + ], + "id": "component-InputComponent-d145da25329b094ee29610c45a9e46387cb39eddb2a67b4c9fadb84bcec76eacd60d131e48d98b2ee5725dedd25f2eb299b704e8e0a34307d6e84f6e57d57044", + "inputs": Array [], + "inputsClass": Array [ + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

Specify the accent-type of the button

+", + "line": 57, + "name": "accent", + "rawdescription": " +Specify the accent-type of the button", + "type": "ButtonAccent", + }, + Object { + "decorators": Array [], + "defaultValue": "'secondary'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Appearance style of the button.

+", + "line": 53, + "name": "appearance", + "rawdescription": " +Appearance style of the button.", + "type": "\\"primary\\" | \\"secondary\\"", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

Setter for inputValue that is also an @Input.

+", + "line": 111, + "name": "inputValue", + "rawdescription": " +Setter for \`inputValue\` that is also an \`@Input\`.", + "type": "string", + }, + Object { + "decorators": Array [], + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "description": "

Sets the button to a disabled state.

+", + "line": 61, + "name": "isDisabled", + "rawdescription": " +Sets the button to a disabled state.", + "type": "boolean", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "line": 196, + "name": "item", + "type": "T[]", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

The inner text of the button.

+", + "jsdoctags": Array [ + Object { + "comment": "", + "end": 1587, + "flags": 4227072, + "kind": 325, + "modifierFlagsCache": 0, + "pos": 1574, + "tagName": Object { + "end": 1583, + "escapedText": "required", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 1575, + "transformFlags": 0, + }, + "transformFlags": 0, + }, + ], + "line": 69, + "name": "label", + "rawdescription": " + +The inner text of the button. + +", + "type": "string", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "line": 193, + "name": "showKeyAlias", + "type": "", + }, + Object { + "decorators": Array [], + "defaultValue": "'medium'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Size of the button.

+", + "line": 73, + "name": "size", + "rawdescription": " +Size of the button.", + "type": "ButtonSize", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

Specifies some arbitrary object

+", + "line": 76, + "name": "someDataObject", + "rawdescription": " +Specifies some arbitrary object", + "type": "ISomeInterface", + }, + Object { + "decorators": Array [], + "defaultValue": "false", + "deprecated": true, + "deprecationMessage": "", + "description": "

Some input you shouldn't use.

+", + "jsdoctags": Array [ + Object { + "comment": "", + "end": 1864, + "flags": 4227072, + "kind": 329, + "modifierFlagsCache": 0, + "pos": 1849, + "tagName": Object { + "end": 1860, + "escapedText": "deprecated", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 1850, + "transformFlags": 0, + }, + "transformFlags": 0, + }, + ], + "line": 84, + "name": "somethingYouShouldNotUse", + "rawdescription": " + +Some input you shouldn't use. + +", + "type": "boolean", + }, + ], + "methodsClass": Array [ + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "x", + "type": "number", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "y", + "type": "string | number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

An internal calculation method which adds x and y together.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some number you'd like to use.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 3580, + "escapedText": "x", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3579, + "transformFlags": 0, + }, + "tagName": Object { + "end": 3578, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3573, + "transformFlags": 0, + }, + "type": "number", + }, + Object { + "comment": "

Some other number or string you'd like to use, will have parseInt() applied before calculation.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 3625, + "escapedText": "y", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3624, + "transformFlags": 0, + }, + "tagName": Object { + "end": 3623, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3618, + "transformFlags": 0, + }, + "type": "string | number", + }, + ], + "line": 165, + "modifierKind": Array [ + 123, + ], + "name": "calc", + "optional": false, + "rawdescription": " + +An internal calculation method which adds \`x\` and \`y\` together. + +", + "returnType": "number", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "type": "", + }, + ], + "decorators": Array [ + Object { + "name": "HostListener", + "stringifiedArguments": "'click', ['$event.target']", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "tagName": Object { + "text": "param", + }, + "type": "", + }, + ], + "line": 121, + "name": "onClickListener", + "optional": false, + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "password", + "type": "string", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A private method.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some password.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 4141, + "escapedText": "password", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 4133, + "transformFlags": 0, + }, + "tagName": Object { + "end": 4132, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 4127, + "transformFlags": 0, + }, + "type": "string", + }, + ], + "line": 188, + "modifierKind": Array [ + 121, + ], + "name": "privateMethod", + "optional": false, + "rawdescription": " + +A private method. + +", + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "id", + "optional": true, + "type": "number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A protected method.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some id.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 4000, + "escapedText": "id", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3998, + "transformFlags": 0, + }, + "optional": true, + "tagName": Object { + "end": 3997, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3992, + "transformFlags": 0, + }, + "type": "number", + }, + ], + "line": 179, + "modifierKind": Array [ + 122, + ], + "name": "protectedMethod", + "optional": false, + "rawdescription": " + +A protected method. + +", + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "things", + "type": "ISomeInterface", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A public method using an interface.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "things", + "tagName": Object { + "text": "param", + }, + "type": "ISomeInterface", + }, + ], + "line": 170, + "modifierKind": Array [ + 123, + ], + "name": "publicMethod", + "optional": false, + "rawdescription": " +A public method using an interface.", + "returnType": "void", + "typeParameters": Array [], + }, + ], + "name": "InputComponent", + "outputs": Array [], + "outputsClass": Array [ + Object { + "defaultValue": "new EventEmitter()", + "deprecated": false, + "deprecationMessage": "", + "description": "

Handler to be called when the button is clicked by a user.

+

Will also block the emission of the event if isDisabled is true.

+", + "line": 92, + "name": "onClick", + "rawdescription": " + +Handler to be called when the button is clicked by a user. + +Will also block the emission of the event if \`isDisabled\` is true. +", + "type": "EventEmitter", + }, + ], + "propertiesClass": Array [ + Object { + "defaultValue": "'some value'", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 107, + "modifierKind": Array [ + 121, + ], + "name": "_inputValue", + "optional": false, + "type": "string", + }, + Object { + "defaultValue": "'Private hello'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Private value.

+", + "line": 147, + "modifierKind": Array [ + 121, + ], + "name": "_value", + "optional": false, + "rawdescription": " +Private value.", + "type": "string", + }, + Object { + "decorators": Array [ + Object { + "name": "ViewChild", + "stringifiedArguments": "'buttonRef', {static: false}", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 49, + "name": "buttonRef", + "optional": false, + "type": "ElementRef", + }, + Object { + "decorators": Array [ + Object { + "name": "HostBinding", + "stringifiedArguments": "'class.focused'", + }, + ], + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 125, + "name": "focus", + "optional": false, + "type": "", + }, + Object { + "defaultValue": "'Public hello'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Public value.

+", + "line": 144, + "modifierKind": Array [ + 123, + ], + "name": "internalProperty", + "optional": false, + "rawdescription": " +Public value.", + "type": "string", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 200, + "modifierKind": Array [ + 123, + ], + "name": "processedItem", + "optional": false, + "type": "T[]", + }, + ], + "providers": Array [], + "rawdescription": " + +This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + +It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, +like **bold**, _italic_, and \`inline code\`. + +> How you like dem apples?! It's never been easier to document all your components. + +", + "selector": "doc-button", + "sourceCode": "import { + Component, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and \`inline code\`. + * + * > How you like dem apples?! It's never been easier to document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code \`ThingThing\` + * @html aaa + */ +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + @ViewChild('buttonRef', { static: false }) buttonRef: ElementRef; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if \`isDisabled\` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the \`ignore\` annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for \`inputValue\` that is also an \`@Input\`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for \`inputValue\`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event.target']) + onClickListener(btn) { + console.log('button', btn); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => \`btn-\${_class}\`); + } + + /** + * @ignore + */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = \`\${value}\`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds \`x\` and \`y\` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have \`parseInt()\` applied before calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(\`\${y}\`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some \`id\`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some \`password\`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem: T[]; +} +", + "styleUrls": Array [], + "styleUrlsData": "", + "styles": Array [], + "stylesData": "", + "template": "", + "templateUrl": Array [], + "type": "component", + "viewProviders": Array [], + }, + ], + "coverage": Object { + "count": 21, + "files": Array [ + Object { + "coverageCount": "16/25", + "coveragePercent": 64, + "filePath": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "linktype": "component", + "name": "InputComponent", + "status": "good", + "type": "component", + }, + Object { + "coverageCount": "0/4", + "coveragePercent": 0, + "filePath": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "linktype": "interface", + "name": "ISomeInterface", + "status": "low", + "type": "interface", + }, + Object { + "coverageCount": "0/1", + "coveragePercent": 0, + "filePath": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "linksubtype": "variable", + "linktype": "miscellaneous", + "name": "exportedConstant", + "status": "low", + "type": "variable", + }, + ], + "status": "low", + }, + "directives": Array [], + "guards": Array [], + "injectables": Array [], + "interceptors": Array [], + "interfaces": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "id": "interface-ISomeInterface-d145da25329b094ee29610c45a9e46387cb39eddb2a67b4c9fadb84bcec76eacd60d131e48d98b2ee5725dedd25f2eb299b704e8e0a34307d6e84f6e57d57044", + "indexSignatures": Array [], + "kind": 165, + "methods": Array [], + "name": "ISomeInterface", + "properties": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 26, + "name": "one", + "optional": false, + "type": "string", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 28, + "name": "three", + "optional": false, + "type": "any[]", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 27, + "name": "two", + "optional": false, + "type": "boolean", + }, + ], + "sourceCode": "import { + Component, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and \`inline code\`. + * + * > How you like dem apples?! It's never been easier to document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code \`ThingThing\` + * @html aaa + */ +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + @ViewChild('buttonRef', { static: false }) buttonRef: ElementRef; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if \`isDisabled\` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the \`ignore\` annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for \`inputValue\` that is also an \`@Input\`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for \`inputValue\`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event.target']) + onClickListener(btn) { + console.log('button', btn); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => \`btn-\${_class}\`); + } + + /** + * @ignore + */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = \`\${value}\`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds \`x\` and \`y\` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have \`parseInt()\` applied before calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(\`\${y}\`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some \`id\`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some \`password\`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem: T[]; +} +", + "type": "interface", + }, + ], + "miscellaneous": Object { + "enumerations": Array [ + Object { + "childs": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "Normal", + "value": "Normal", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "High", + "value": "High", + }, + ], + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "ButtonAccent", + "subtype": "enum", + }, + ], + "functions": Array [], + "groupedEnumerations": Object { + "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts": Array [ + Object { + "childs": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "Normal", + "value": "Normal", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "High", + "value": "High", + }, + ], + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "ButtonAccent", + "subtype": "enum", + }, + ], + }, + "groupedFunctions": Object {}, + "groupedTypeAliases": Object { + "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts": Array [ + Object { + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "kind": 186, + "name": "ButtonSize", + "rawtype": "\\"small\\" | \\"medium\\" | \\"large\\" | \\"xlarge\\"", + "subtype": "typealias", + }, + ], + }, + "groupedVariables": Object { + "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts": Array [ + Object { + "ctype": "miscellaneous", + "defaultValue": "'An exported constant'", + "deprecated": false, + "deprecationMessage": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "exportedConstant", + "subtype": "variable", + "type": "string", + }, + ], + }, + "typealiases": Array [ + Object { + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "kind": 186, + "name": "ButtonSize", + "rawtype": "\\"small\\" | \\"medium\\" | \\"large\\" | \\"xlarge\\"", + "subtype": "typealias", + }, + ], + "variables": Array [ + Object { + "ctype": "miscellaneous", + "defaultValue": "'An exported constant'", + "deprecated": false, + "deprecationMessage": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "exportedConstant", + "subtype": "variable", + "type": "string", + }, + ], + }, + "modules": Array [], + "pipes": Array [], + "routes": Array [], +} +`; diff --git a/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-undefined.snapshot b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-undefined.snapshot new file mode 100644 index 000000000000..de95727d81f5 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-undefined.snapshot @@ -0,0 +1,1326 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`angular component properties doc-button 1`] = ` +Object { + "classes": Array [], + "components": Array [ + Object { + "accessors": Object { + "inputValue": Object { + "getSignature": Object { + "description": "

Getter for inputValue.

+", + "line": 116, + "name": "inputValue", + "rawdescription": " +Getter for \`inputValue\`.", + "returnType": "", + "type": "", + }, + "name": "inputValue", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "type": "string", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Setter for inputValue that is also an @Input.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "tagName": Object { + "text": "param", + }, + "type": "string", + }, + ], + "line": 111, + "name": "inputValue", + "rawdescription": " +Setter for \`inputValue\` that is also an \`@Input\`.", + "returnType": "void", + "type": "void", + }, + }, + "item": Object { + "name": "item", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "item", + "type": "T[]", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "item", + "tagName": Object { + "text": "param", + }, + "type": "T[]", + }, + ], + "line": 196, + "name": "item", + "returnType": "void", + "type": "void", + }, + }, + "value": Object { + "getSignature": Object { + "description": "

Get the private value.

+", + "line": 155, + "name": "value", + "rawdescription": " +Get the private value.", + "returnType": "string | number", + "type": "", + }, + "name": "value", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "type": "string | number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Set the private value.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "tagName": Object { + "text": "param", + }, + "type": "string | number", + }, + ], + "line": 150, + "name": "value", + "rawdescription": " +Set the private value.", + "returnType": "void", + "type": "void", + }, + }, + }, + "assetsDirs": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular.

+

It supports markdown, so you can embed formatted text, +like bold, italic, and inline code.

+
+

How you like dem apples?! It's never been easier to document all your components.

+
+", + "encapsulation": Array [], + "entryComponents": Array [], + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "hostBindings": Array [ + Object { + "decorators": Array [], + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "line": 125, + "name": "class.focused", + "type": "boolean", + }, + ], + "hostListeners": Array [ + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "type": "", + }, + ], + "argsDecorator": Array [ + "$event.target", + ], + "deprecated": false, + "deprecationMessage": "", + "line": 121, + "name": "click", + }, + ], + "id": "component-InputComponent-d145da25329b094ee29610c45a9e46387cb39eddb2a67b4c9fadb84bcec76eacd60d131e48d98b2ee5725dedd25f2eb299b704e8e0a34307d6e84f6e57d57044", + "inputs": Array [], + "inputsClass": Array [ + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

Specify the accent-type of the button

+", + "line": 57, + "name": "accent", + "rawdescription": " +Specify the accent-type of the button", + "type": "ButtonAccent", + }, + Object { + "decorators": Array [], + "defaultValue": "'secondary'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Appearance style of the button.

+", + "line": 53, + "name": "appearance", + "rawdescription": " +Appearance style of the button.", + "type": "\\"primary\\" | \\"secondary\\"", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

Setter for inputValue that is also an @Input.

+", + "line": 111, + "name": "inputValue", + "rawdescription": " +Setter for \`inputValue\` that is also an \`@Input\`.", + "type": "string", + }, + Object { + "decorators": Array [], + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "description": "

Sets the button to a disabled state.

+", + "line": 61, + "name": "isDisabled", + "rawdescription": " +Sets the button to a disabled state.", + "type": "boolean", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "line": 196, + "name": "item", + "type": "T[]", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

The inner text of the button.

+", + "jsdoctags": Array [ + Object { + "comment": "", + "end": 1587, + "flags": 4227072, + "kind": 325, + "modifierFlagsCache": 0, + "pos": 1574, + "tagName": Object { + "end": 1583, + "escapedText": "required", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 1575, + "transformFlags": 0, + }, + "transformFlags": 0, + }, + ], + "line": 69, + "name": "label", + "rawdescription": " + +The inner text of the button. + +", + "type": "string", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "line": 193, + "name": "showKeyAlias", + "type": "", + }, + Object { + "decorators": Array [], + "defaultValue": "'medium'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Size of the button.

+", + "line": 73, + "name": "size", + "rawdescription": " +Size of the button.", + "type": "ButtonSize", + }, + Object { + "decorators": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

Specifies some arbitrary object

+", + "line": 76, + "name": "someDataObject", + "rawdescription": " +Specifies some arbitrary object", + "type": "ISomeInterface", + }, + Object { + "decorators": Array [], + "defaultValue": "false", + "deprecated": true, + "deprecationMessage": "", + "description": "

Some input you shouldn't use.

+", + "jsdoctags": Array [ + Object { + "comment": "", + "end": 1864, + "flags": 4227072, + "kind": 329, + "modifierFlagsCache": 0, + "pos": 1849, + "tagName": Object { + "end": 1860, + "escapedText": "deprecated", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 1850, + "transformFlags": 0, + }, + "transformFlags": 0, + }, + ], + "line": 84, + "name": "somethingYouShouldNotUse", + "rawdescription": " + +Some input you shouldn't use. + +", + "type": "boolean", + }, + ], + "methodsClass": Array [ + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "x", + "type": "number", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "y", + "type": "string | number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

An internal calculation method which adds x and y together.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some number you'd like to use.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 3580, + "escapedText": "x", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3579, + "transformFlags": 0, + }, + "tagName": Object { + "end": 3578, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3573, + "transformFlags": 0, + }, + "type": "number", + }, + Object { + "comment": "

Some other number or string you'd like to use, will have parseInt() applied before calculation.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 3625, + "escapedText": "y", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3624, + "transformFlags": 0, + }, + "tagName": Object { + "end": 3623, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3618, + "transformFlags": 0, + }, + "type": "string | number", + }, + ], + "line": 165, + "modifierKind": Array [ + 123, + ], + "name": "calc", + "optional": false, + "rawdescription": " + +An internal calculation method which adds \`x\` and \`y\` together. + +", + "returnType": "number", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "type": "", + }, + ], + "decorators": Array [ + Object { + "name": "HostListener", + "stringifiedArguments": "'click', ['$event.target']", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "tagName": Object { + "text": "param", + }, + "type": "", + }, + ], + "line": 121, + "name": "onClickListener", + "optional": false, + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "password", + "type": "string", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A private method.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some password.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 4141, + "escapedText": "password", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 4133, + "transformFlags": 0, + }, + "tagName": Object { + "end": 4132, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 4127, + "transformFlags": 0, + }, + "type": "string", + }, + ], + "line": 188, + "modifierKind": Array [ + 121, + ], + "name": "privateMethod", + "optional": false, + "rawdescription": " + +A private method. + +", + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "id", + "optional": true, + "type": "number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A protected method.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some id.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 4000, + "escapedText": "id", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3998, + "transformFlags": 0, + }, + "optional": true, + "tagName": Object { + "end": 3997, + "escapedText": "param", + "flags": 4227072, + "kind": 79, + "modifierFlagsCache": 0, + "pos": 3992, + "transformFlags": 0, + }, + "type": "number", + }, + ], + "line": 179, + "modifierKind": Array [ + 122, + ], + "name": "protectedMethod", + "optional": false, + "rawdescription": " + +A protected method. + +", + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "things", + "type": "ISomeInterface", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A public method using an interface.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "things", + "tagName": Object { + "text": "param", + }, + "type": "ISomeInterface", + }, + ], + "line": 170, + "modifierKind": Array [ + 123, + ], + "name": "publicMethod", + "optional": false, + "rawdescription": " +A public method using an interface.", + "returnType": "void", + "typeParameters": Array [], + }, + ], + "name": "InputComponent", + "outputs": Array [], + "outputsClass": Array [ + Object { + "defaultValue": "new EventEmitter()", + "deprecated": false, + "deprecationMessage": "", + "description": "

Handler to be called when the button is clicked by a user.

+

Will also block the emission of the event if isDisabled is true.

+", + "line": 92, + "name": "onClick", + "rawdescription": " + +Handler to be called when the button is clicked by a user. + +Will also block the emission of the event if \`isDisabled\` is true. +", + "type": "EventEmitter", + }, + ], + "propertiesClass": Array [ + Object { + "defaultValue": "'some value'", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 107, + "modifierKind": Array [ + 121, + ], + "name": "_inputValue", + "optional": false, + "type": "string", + }, + Object { + "defaultValue": "'Private hello'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Private value.

+", + "line": 147, + "modifierKind": Array [ + 121, + ], + "name": "_value", + "optional": false, + "rawdescription": " +Private value.", + "type": "string", + }, + Object { + "decorators": Array [ + Object { + "name": "ViewChild", + "stringifiedArguments": "'buttonRef', {static: false}", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 49, + "name": "buttonRef", + "optional": false, + "type": "ElementRef", + }, + Object { + "decorators": Array [ + Object { + "name": "HostBinding", + "stringifiedArguments": "'class.focused'", + }, + ], + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 125, + "name": "focus", + "optional": false, + "type": "", + }, + Object { + "defaultValue": "'Public hello'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Public value.

+", + "line": 144, + "modifierKind": Array [ + 123, + ], + "name": "internalProperty", + "optional": false, + "rawdescription": " +Public value.", + "type": "string", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 200, + "modifierKind": Array [ + 123, + ], + "name": "processedItem", + "optional": false, + "type": "T[]", + }, + ], + "providers": Array [], + "rawdescription": " + +This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + +It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, +like **bold**, _italic_, and \`inline code\`. + +> How you like dem apples?! It's never been easier to document all your components. + +", + "selector": "doc-button", + "sourceCode": "import { + Component, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and \`inline code\`. + * + * > How you like dem apples?! It's never been easier to document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code \`ThingThing\` + * @html aaa + */ +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + @ViewChild('buttonRef', { static: false }) buttonRef: ElementRef; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if \`isDisabled\` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the \`ignore\` annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for \`inputValue\` that is also an \`@Input\`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for \`inputValue\`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event.target']) + onClickListener(btn) { + console.log('button', btn); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => \`btn-\${_class}\`); + } + + /** + * @ignore + */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = \`\${value}\`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds \`x\` and \`y\` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have \`parseInt()\` applied before calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(\`\${y}\`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some \`id\`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some \`password\`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem: T[]; +} +", + "styleUrls": Array [], + "styleUrlsData": "", + "styles": Array [], + "stylesData": "", + "template": "", + "templateUrl": Array [], + "type": "component", + "viewProviders": Array [], + }, + ], + "coverage": Object { + "count": 21, + "files": Array [ + Object { + "coverageCount": "16/25", + "coveragePercent": 64, + "filePath": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "linktype": "component", + "name": "InputComponent", + "status": "good", + "type": "component", + }, + Object { + "coverageCount": "0/4", + "coveragePercent": 0, + "filePath": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "linktype": "interface", + "name": "ISomeInterface", + "status": "low", + "type": "interface", + }, + Object { + "coverageCount": "0/1", + "coveragePercent": 0, + "filePath": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "linksubtype": "variable", + "linktype": "miscellaneous", + "name": "exportedConstant", + "status": "low", + "type": "variable", + }, + ], + "status": "low", + }, + "directives": Array [], + "guards": Array [], + "injectables": Array [], + "interceptors": Array [], + "interfaces": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "id": "interface-ISomeInterface-d145da25329b094ee29610c45a9e46387cb39eddb2a67b4c9fadb84bcec76eacd60d131e48d98b2ee5725dedd25f2eb299b704e8e0a34307d6e84f6e57d57044", + "indexSignatures": Array [], + "kind": 165, + "methods": Array [], + "name": "ISomeInterface", + "properties": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 26, + "name": "one", + "optional": false, + "type": "string", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 28, + "name": "three", + "optional": false, + "type": "any[]", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 27, + "name": "two", + "optional": false, + "type": "boolean", + }, + ], + "sourceCode": "import { + Component, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and \`inline code\`. + * + * > How you like dem apples?! It's never been easier to document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code \`ThingThing\` + * @html aaa + */ +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + @ViewChild('buttonRef', { static: false }) buttonRef: ElementRef; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if \`isDisabled\` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the \`ignore\` annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for \`inputValue\` that is also an \`@Input\`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for \`inputValue\`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event.target']) + onClickListener(btn) { + console.log('button', btn); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => \`btn-\${_class}\`); + } + + /** + * @ignore + */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = \`\${value}\`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds \`x\` and \`y\` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have \`parseInt()\` applied before calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(\`\${y}\`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some \`id\`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some \`password\`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem: T[]; +} +", + "type": "interface", + }, + ], + "miscellaneous": Object { + "enumerations": Array [ + Object { + "childs": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "Normal", + "value": "Normal", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "High", + "value": "High", + }, + ], + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "ButtonAccent", + "subtype": "enum", + }, + ], + "functions": Array [], + "groupedEnumerations": Object { + "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts": Array [ + Object { + "childs": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "Normal", + "value": "Normal", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "High", + "value": "High", + }, + ], + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "ButtonAccent", + "subtype": "enum", + }, + ], + }, + "groupedFunctions": Object {}, + "groupedTypeAliases": Object { + "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts": Array [ + Object { + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "kind": 186, + "name": "ButtonSize", + "rawtype": "\\"small\\" | \\"medium\\" | \\"large\\" | \\"xlarge\\"", + "subtype": "typealias", + }, + ], + }, + "groupedVariables": Object { + "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts": Array [ + Object { + "ctype": "miscellaneous", + "defaultValue": "'An exported constant'", + "deprecated": false, + "deprecationMessage": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "exportedConstant", + "subtype": "variable", + "type": "string", + }, + ], + }, + "typealiases": Array [ + Object { + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "kind": 186, + "name": "ButtonSize", + "rawtype": "\\"small\\" | \\"medium\\" | \\"large\\" | \\"xlarge\\"", + "subtype": "typealias", + }, + ], + "variables": Array [ + Object { + "ctype": "miscellaneous", + "defaultValue": "'An exported constant'", + "deprecated": false, + "deprecationMessage": "", + "file": "frameworks/angular/src/client/docs/__testfixtures__/doc-button/input.ts", + "name": "exportedConstant", + "subtype": "variable", + "type": "string", + }, + ], + }, + "modules": Array [], + "pipes": Array [], + "routes": Array [], +} +`; diff --git a/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-windows.snapshot b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-windows.snapshot new file mode 100644 index 000000000000..87b561823850 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/compodoc-windows.snapshot @@ -0,0 +1,1297 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`angular component properties doc-button 1`] = ` +Object { + "classes": Array [], + "components": Array [ + Object { + "accessors": Object { + "inputValue": Object { + "getSignature": Object { + "description": "

Getter for inputValue.

+", + "line": 115, + "name": "inputValue", + "rawdescription": "Getter for \`inputValue\`.", + "returnType": "", + "type": "", + }, + "name": "inputValue", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "type": "string", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Setter for inputValue that is also an @Input.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "tagName": Object { + "text": "param", + }, + "type": "string", + }, + ], + "line": 110, + "name": "inputValue", + "rawdescription": "Setter for \`inputValue\` that is also an \`@Input\`.", + "returnType": "void", + "type": "void", + }, + }, + "item": Object { + "name": "item", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "item", + "type": "T[]", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "item", + "tagName": Object { + "text": "param", + }, + "type": "T[]", + }, + ], + "line": 195, + "name": "item", + "returnType": "void", + "type": "void", + }, + }, + "value": Object { + "getSignature": Object { + "description": "

Get the private value.

+", + "line": 154, + "name": "value", + "rawdescription": "Get the private value.", + "returnType": "string | number", + "type": "", + }, + "name": "value", + "setSignature": Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "type": "string | number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Set the private value.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "value", + "tagName": Object { + "text": "param", + }, + "type": "string | number", + }, + ], + "line": 149, + "name": "value", + "rawdescription": "Set the private value.", + "returnType": "void", + "type": "void", + }, + }, + }, + "assetsDirs": Array [], + "deprecated": false, + "deprecationMessage": "", + "description": "

This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular.

+

It supports markdown, so you can embed formatted text, +like bold, italic, and inline code.

+
+

How you like dem apples?! It's never been easier to document all your components.

+
+", + "encapsulation": Array [], + "entryComponents": Array [], + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "hostBindings": Array [ + Object { + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "line": 124, + "name": "class.focused", + "type": "boolean", + }, + ], + "hostListeners": Array [ + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "type": "", + }, + ], + "argsDecorator": Array [ + "$event.target", + ], + "deprecated": false, + "deprecationMessage": "", + "line": 120, + "name": "click", + }, + ], + "id": "component-InputComponent-fd2eff3e4da750f1c06d4928670993b3", + "inputs": Array [], + "inputsClass": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "

Specify the accent-type of the button

+", + "line": 56, + "name": "accent", + "rawdescription": "Specify the accent-type of the button", + "type": "ButtonAccent", + }, + Object { + "defaultValue": "'secondary'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Appearance style of the button.

+", + "line": 52, + "name": "appearance", + "rawdescription": "Appearance style of the button.", + "type": "\\"primary\\" | \\"secondary\\"", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "

Setter for inputValue that is also an @Input.

+", + "line": 110, + "name": "inputValue", + "rawdescription": "Setter for \`inputValue\` that is also an \`@Input\`.", + "type": "string", + }, + Object { + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "description": "

Sets the button to a disabled state.

+", + "line": 60, + "name": "isDisabled", + "rawdescription": "Sets the button to a disabled state.", + "type": "boolean", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "line": 195, + "name": "item", + "type": "[]", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "

The inner text of the button.

+", + "jsdoctags": Array [ + Object { + "comment": "", + "end": 1590, + "flags": 4227072, + "kind": 317, + "modifierFlagsCache": 0, + "pos": 1576, + "tagName": Object { + "end": 1585, + "escapedText": "required", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 1577, + "transformFlags": 0, + }, + "transformFlags": 0, + }, + ], + "line": 68, + "name": "label", + "rawdescription": "The inner text of the button.", + "type": "string", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "line": 192, + "name": "showKeyAlias", + "type": "", + }, + Object { + "defaultValue": "'medium'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Size of the button.

+", + "line": 72, + "name": "size", + "rawdescription": "Size of the button.", + "type": "ButtonSize", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "

Specifies some arbitrary object

+", + "line": 75, + "name": "someDataObject", + "rawdescription": "Specifies some arbitrary object", + "type": "ISomeInterface", + }, + Object { + "defaultValue": "false", + "deprecated": true, + "deprecationMessage": "", + "description": "

Some input you shouldn't use.

+", + "jsdoctags": Array [ + Object { + "comment": "", + "end": 1882, + "flags": 4227072, + "kind": 321, + "modifierFlagsCache": 0, + "pos": 1866, + "tagName": Object { + "end": 1877, + "escapedText": "deprecated", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 1867, + "transformFlags": 0, + }, + "transformFlags": 0, + }, + ], + "line": 83, + "name": "somethingYouShouldNotUse", + "rawdescription": "Some input you shouldn't use.", + "type": "boolean", + }, + ], + "methodsClass": Array [ + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "x", + "type": "number", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "y", + "type": "string | number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

An internal calculation method which adds x and y together.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some number you'd like to use.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 3678, + "escapedText": "x", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 3677, + "transformFlags": 0, + }, + "tagName": Object { + "end": 3676, + "escapedText": "param", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 3671, + "transformFlags": 0, + }, + "type": "number", + }, + Object { + "comment": "

Some other number or string you'd like to use, will have parseInt() applied before calculation.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 3724, + "escapedText": "y", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 3723, + "transformFlags": 0, + }, + "tagName": Object { + "end": 3722, + "escapedText": "param", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 3717, + "transformFlags": 0, + }, + "type": "string | number", + }, + ], + "line": 164, + "modifierKind": Array [ + 122, + ], + "name": "calc", + "optional": false, + "rawdescription": " + +An internal calculation method which adds \`x\` and \`y\` together. + +", + "returnType": "number", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "type": "", + }, + ], + "decorators": Array [ + Object { + "name": "HostListener", + "stringifiedArguments": "'click', ['$event.target']", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "btn", + "tagName": Object { + "text": "param", + }, + "type": "", + }, + ], + "line": 120, + "name": "onClickListener", + "optional": false, + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "password", + "type": "string", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A private method.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some password.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 4263, + "escapedText": "password", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 4255, + "transformFlags": 0, + }, + "tagName": Object { + "end": 4254, + "escapedText": "param", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 4249, + "transformFlags": 0, + }, + "type": "string", + }, + ], + "line": 187, + "modifierKind": Array [ + 120, + ], + "name": "privateMethod", + "optional": false, + "rawdescription": " + +A private method. + +", + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "id", + "optional": true, + "type": "number", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A protected method.

+", + "jsdoctags": Array [ + Object { + "comment": "

Some id.

+", + "deprecated": false, + "deprecationMessage": "", + "name": Object { + "end": 4113, + "escapedText": "id", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 4111, + "transformFlags": 0, + }, + "optional": true, + "tagName": Object { + "end": 4110, + "escapedText": "param", + "flags": 4227072, + "kind": 78, + "modifierFlagsCache": 0, + "pos": 4105, + "transformFlags": 0, + }, + "type": "number", + }, + ], + "line": 178, + "modifierKind": Array [ + 121, + ], + "name": "protectedMethod", + "optional": false, + "rawdescription": " + +A protected method. + +", + "returnType": "void", + "typeParameters": Array [], + }, + Object { + "args": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "things", + "type": "ISomeInterface", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

A public method using an interface.

+", + "jsdoctags": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "things", + "tagName": Object { + "text": "param", + }, + "type": "ISomeInterface", + }, + ], + "line": 169, + "modifierKind": Array [ + 122, + ], + "name": "publicMethod", + "optional": false, + "rawdescription": " +A public method using an interface.", + "returnType": "void", + "typeParameters": Array [], + }, + ], + "name": "InputComponent", + "outputs": Array [], + "outputsClass": Array [ + Object { + "defaultValue": "new EventEmitter()", + "deprecated": false, + "deprecationMessage": "", + "description": "

Handler to be called when the button is clicked by a user.

+

Will also block the emission of the event if isDisabled is true.

+", + "line": 91, + "name": "onClick", + "rawdescription": " + +Handler to be called when the button is clicked by a user. + +Will also block the emission of the event if \`isDisabled\` is true. +", + "type": "EventEmitter", + }, + ], + "propertiesClass": Array [ + Object { + "defaultValue": "'some value'", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 106, + "modifierKind": Array [ + 120, + ], + "name": "_inputValue", + "optional": false, + "type": "string", + }, + Object { + "defaultValue": "'Private hello'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Private value.

+", + "line": 146, + "modifierKind": Array [ + 120, + ], + "name": "_value", + "optional": false, + "rawdescription": " +Private value.", + "type": "string", + }, + Object { + "decorators": Array [ + Object { + "name": "ViewChild", + "stringifiedArguments": "'buttonRef', {static: false}", + }, + ], + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 48, + "name": "buttonRef", + "optional": false, + "type": "ElementRef", + }, + Object { + "decorators": Array [ + Object { + "name": "HostBinding", + "stringifiedArguments": "'class.focused'", + }, + ], + "defaultValue": "false", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 124, + "name": "focus", + "optional": false, + "type": "", + }, + Object { + "defaultValue": "'Public hello'", + "deprecated": false, + "deprecationMessage": "", + "description": "

Public value.

+", + "line": 143, + "modifierKind": Array [ + 122, + ], + "name": "internalProperty", + "optional": false, + "rawdescription": " +Public value.", + "type": "string", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 199, + "modifierKind": Array [ + 122, + ], + "name": "processedItem", + "optional": false, + "type": "T[]", + }, + ], + "providers": Array [], + "rawdescription": " + +This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + +It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, +like **bold**, _italic_, and \`inline code\`. + +> How you like dem apples?! It's never been easier to document all your components. + +", + "selector": "doc-button", + "sourceCode": "import { + Component, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and \`inline code\`. + * + * > How you like dem apples?! It's never been easier to document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code \`ThingThing\` + * @html aaa + */ +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + @ViewChild('buttonRef', { static: false }) buttonRef: ElementRef; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if \`isDisabled\` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the \`ignore\` annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for \`inputValue\` that is also an \`@Input\`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for \`inputValue\`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event.target']) + onClickListener(btn) { + console.log('button', btn); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => \`btn-\${_class}\`); + } + + /** + * @ignore + */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = \`\${value}\`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds \`x\` and \`y\` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have \`parseInt()\` applied before calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(\`\${y}\`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some \`id\`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some \`password\`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem: T[]; +} +", + "styleUrls": Array [], + "styleUrlsData": "", + "styles": Array [], + "stylesData": "", + "template": "", + "templateUrl": Array [], + "type": "component", + "viewProviders": Array [], + }, + ], + "coverage": Object { + "count": 21, + "files": Array [ + Object { + "coverageCount": "16/25", + "coveragePercent": 64, + "filePath": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "linktype": "component", + "name": "InputComponent", + "status": "good", + "type": "component", + }, + Object { + "coverageCount": "0/4", + "coveragePercent": 0, + "filePath": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "linktype": "interface", + "name": "ISomeInterface", + "status": "low", + "type": "interface", + }, + Object { + "coverageCount": "0/1", + "coveragePercent": 0, + "filePath": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "linksubtype": "variable", + "linktype": "miscellaneous", + "name": "exportedConstant", + "status": "low", + "type": "variable", + }, + ], + "status": "low", + }, + "directives": Array [], + "guards": Array [], + "injectables": Array [], + "interceptors": Array [], + "interfaces": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "id": "interface-ISomeInterface-fd2eff3e4da750f1c06d4928670993b3", + "indexSignatures": Array [], + "kind": 163, + "methods": Array [], + "name": "ISomeInterface", + "properties": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 25, + "name": "one", + "optional": false, + "type": "string", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 27, + "name": "three", + "optional": false, + "type": "any[]", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "description": "", + "line": 26, + "name": "two", + "optional": false, + "type": "boolean", + }, + ], + "sourceCode": "import { + Component, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and \`inline code\`. + * + * > How you like dem apples?! It's never been easier to document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code \`ThingThing\` + * @html aaa + */ +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + @ViewChild('buttonRef', { static: false }) buttonRef: ElementRef; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if \`isDisabled\` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the \`ignore\` annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for \`inputValue\` that is also an \`@Input\`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for \`inputValue\`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event.target']) + onClickListener(btn) { + console.log('button', btn); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => \`btn-\${_class}\`); + } + + /** + * @ignore + */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = \`\${value}\`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds \`x\` and \`y\` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have \`parseInt()\` applied before calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(\`\${y}\`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some \`id\`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some \`password\`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem: T[]; +} +", + "type": "interface", + }, + ], + "miscellaneous": Object { + "enumerations": Array [ + Object { + "childs": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "Normal", + "value": "Normal", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "High", + "value": "High", + }, + ], + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "name": "ButtonAccent", + "subtype": "enum", + }, + ], + "functions": Array [], + "groupedEnumerations": Object { + "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts": Array [ + Object { + "childs": Array [ + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "Normal", + "value": "Normal", + }, + Object { + "deprecated": false, + "deprecationMessage": "", + "name": "High", + "value": "High", + }, + ], + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "name": "ButtonAccent", + "subtype": "enum", + }, + ], + }, + "groupedFunctions": Object {}, + "groupedTypeAliases": Object { + "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts": Array [ + Object { + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "kind": 183, + "name": "ButtonSize", + "rawtype": "\\"small\\" | \\"medium\\" | \\"large\\" | \\"xlarge\\"", + "subtype": "typealias", + }, + ], + }, + "groupedVariables": Object { + "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts": Array [ + Object { + "ctype": "miscellaneous", + "defaultValue": "'An exported constant'", + "deprecated": false, + "deprecationMessage": "", + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "name": "exportedConstant", + "subtype": "variable", + "type": "string", + }, + ], + }, + "typealiases": Array [ + Object { + "ctype": "miscellaneous", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "kind": 183, + "name": "ButtonSize", + "rawtype": "\\"small\\" | \\"medium\\" | \\"large\\" | \\"xlarge\\"", + "subtype": "typealias", + }, + ], + "variables": Array [ + Object { + "ctype": "miscellaneous", + "defaultValue": "'An exported constant'", + "deprecated": false, + "deprecationMessage": "", + "file": "addons/docs/src/frameworks/angular/__testfixtures__/doc-button/input.ts", + "name": "exportedConstant", + "subtype": "variable", + "type": "string", + }, + ], + }, + "modules": Array [], + "pipes": Array [], + "routes": Array [], +} +`; diff --git a/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/input.ts b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/input.ts new file mode 100644 index 000000000000..c1f3662499ae --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/input.ts @@ -0,0 +1,199 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck + +import type { ElementRef } from '@angular/core'; +import { + Component, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and `inline code`.> How you like dem apples?! It's never been easier to + * document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code `ThingThing` + * @html aaa + */ +@Component({ + selector: 'doc-button', + template: '', +}) +export class InputComponent { + @ViewChild('buttonRef', { static: false }) buttonRef: ElementRef; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** Specifies some arbitrary object */ + @Input() public someDataObject: ISomeInterface; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if `isDisabled` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the `ignore` + * annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for `inputValue` that is also an `@Input`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for `inputValue`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event.target']) + onClickListener(btn) { + console.log('button', btn); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => `btn-${_class}`); + } + + /** @ignore */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = `${value}`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds `x` and `y` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have `parseInt()` applied before + * calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(`${y}`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some `id`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some `password`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem: T[]; +} diff --git a/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/properties.snapshot b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/properties.snapshot new file mode 100644 index 000000000000..efd774f746b2 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/properties.snapshot @@ -0,0 +1,230 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`angular component properties doc-button 1`] = ` +Object { + "sections": Object { + "inputs": Array [ + Object { + "defaultValue": Object { + "summary": "'secondary'", + }, + "description": "

Appearance style of the button.

+", + "name": "appearance", + "required": true, + "type": Object { + "summary": "\\"primary\\" | \\"secondary\\"", + }, + }, + Object { + "defaultValue": Object { + "summary": undefined, + }, + "description": "

Setter for inputValue that is also an @Input.

+", + "name": "inputValue", + "required": true, + "type": Object { + "summary": "string", + }, + }, + Object { + "defaultValue": Object { + "summary": "false", + }, + "description": "

Sets the button to a disabled state.

+", + "name": "isDisabled", + "required": true, + "type": Object { + "summary": undefined, + }, + }, + Object { + "defaultValue": Object { + "summary": undefined, + }, + "description": undefined, + "name": "item", + "required": true, + "type": Object { + "summary": "[]", + }, + }, + Object { + "defaultValue": Object { + "summary": undefined, + }, + "description": "

The inner text of the button.

+", + "name": "label", + "required": true, + "type": Object { + "summary": "string", + }, + }, + Object { + "defaultValue": Object { + "summary": undefined, + }, + "description": undefined, + "name": "showKeyAlias", + "required": true, + "type": Object { + "summary": "", + }, + }, + Object { + "defaultValue": Object { + "summary": "'medium'", + }, + "description": "

Size of the button.

+", + "name": "size", + "required": true, + "type": Object { + "summary": "ButtonSize", + }, + }, + Object { + "defaultValue": Object { + "summary": "false", + }, + "description": "

Some input you shouldn't use.

+", + "name": "somethingYouShouldNotUse", + "required": true, + "type": Object { + "summary": undefined, + }, + }, + ], + "methods": Array [ + Object { + "defaultValue": Object { + "summary": "", + }, + "description": "

An internal calculation method which adds x and y together.

+", + "name": "calc", + "required": false, + "type": Object { + "summary": "(x: number, y: string | number) => number", + }, + }, + Object { + "defaultValue": Object { + "summary": "", + }, + "description": "

A private method.

+", + "name": "privateMethod", + "required": false, + "type": Object { + "summary": "(password: string) => void", + }, + }, + Object { + "defaultValue": Object { + "summary": "", + }, + "description": "

A protected method.

+", + "name": "protectedMethod", + "required": false, + "type": Object { + "summary": "(id?: number) => void", + }, + }, + Object { + "defaultValue": Object { + "summary": "", + }, + "description": "

A public method using an interface.

+", + "name": "publicMethod", + "required": false, + "type": Object { + "summary": "(things: ISomeInterface) => void", + }, + }, + ], + "outputs": Array [ + Object { + "defaultValue": Object { + "summary": "new EventEmitter()", + }, + "description": "

Handler to be called when the button is clicked by a user.

+

Will also block the emission of the event if isDisabled is true.

+", + "name": "onClick", + "required": true, + "type": Object { + "summary": "EventEmitter", + }, + }, + ], + "properties": Array [ + Object { + "defaultValue": Object { + "summary": "'some value'", + }, + "description": "", + "name": "_inputValue", + "required": true, + "type": Object { + "summary": "string", + }, + }, + Object { + "defaultValue": Object { + "summary": "'Private hello'", + }, + "description": "

Private value.

+", + "name": "_value", + "required": true, + "type": Object { + "summary": "string", + }, + }, + Object { + "defaultValue": Object { + "summary": "'Public hello'", + }, + "description": "

Public value.

+", + "name": "internalProperty", + "required": true, + "type": Object { + "summary": "string", + }, + }, + Object { + "defaultValue": Object { + "summary": undefined, + }, + "description": "", + "name": "processedItem", + "required": true, + "type": Object { + "summary": "T[]", + }, + }, + ], + "view child": Array [ + Object { + "defaultValue": Object { + "summary": undefined, + }, + "description": "", + "name": "buttonRef", + "required": true, + "type": Object { + "summary": "ElementRef", + }, + }, + ], + }, +} +`; diff --git a/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/tsconfig.json b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/tsconfig.json new file mode 100644 index 000000000000..ced6b7ae2f7c --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/__testfixtures__/doc-button/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["./*.ts"] +} diff --git a/code/frameworks/angular-vite/src/client/docs/angular-properties.test.ts b/code/frameworks/angular-vite/src/client/docs/angular-properties.test.ts new file mode 100644 index 000000000000..a4531cbbe480 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/angular-properties.test.ts @@ -0,0 +1,39 @@ +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +// File hierarchy: __testfixtures__ / some-test-case / input.* +const inputRegExp = /^input\..*$/; + +describe('angular component properties', () => { + const fixturesDir = join(__dirname, '__testfixtures__'); + readdirSync(fixturesDir, { withFileTypes: true }).forEach((testEntry) => { + if (testEntry.isDirectory()) { + const testDir = join(fixturesDir, testEntry.name); + const testFile = readdirSync(testDir).find((fileName) => inputRegExp.test(fileName)); + if (testFile) { + // TODO: Remove this as soon as the real test is fixed + it('true', () => { + expect(true).toEqual(true); + }); + // TODO: Fix this test + // it(`${testEntry.name}`, async () => { + // const inputPath = join(testDir, testFile); + + // // snapshot the output of compodoc + // const compodocOutput = runCompodoc(inputPath); + // const compodocJson = JSON.parse(compodocOutput); + // await expect(compodocJson).toMatchFileSnapshot( + // join(testDir, `compodoc-${SNAPSHOT_OS}.snapshot`) + // ); + + // // snapshot the output of addon-docs angular-properties + // const componentData = findComponentByName('InputComponent', compodocJson); + // const argTypes = extractArgTypesFromData(componentData); + // await expect(argTypes).toMatchFileSnapshot(join(testDir, 'argtypes.snapshot')); + // }); + } + } + }); +}); diff --git a/code/frameworks/angular-vite/src/client/docs/config.ts b/code/frameworks/angular-vite/src/client/docs/config.ts new file mode 100644 index 000000000000..e9e4c8e0578a --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/config.ts @@ -0,0 +1,15 @@ +import { SourceType } from 'storybook/internal/docs-tools'; +import type { DecoratorFunction, Parameters } from 'storybook/internal/types'; + +import { sourceDecorator } from './sourceDecorator'; + +export const parameters: Parameters = { + docs: { + source: { + type: SourceType.DYNAMIC, + language: 'html', + }, + }, +}; + +export const decorators: DecoratorFunction[] = [sourceDecorator]; diff --git a/code/frameworks/angular-vite/src/client/docs/sourceDecorator.ts b/code/frameworks/angular-vite/src/client/docs/sourceDecorator.ts new file mode 100644 index 000000000000..d5315f8dff99 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/docs/sourceDecorator.ts @@ -0,0 +1,62 @@ +import { SourceType } from 'storybook/internal/docs-tools'; +import { useRef, emitTransformCode, useEffect } from 'storybook/preview-api'; +import type { ArgsStoryFn, PartialStoryFn } from 'storybook/internal/types'; + +import { computesTemplateSourceFromComponent } from '../../renderer'; +import type { AngularRenderer, StoryContext } from '../types'; + +export const skipSourceRender = (context: StoryContext) => { + const sourceParams = context?.parameters.docs?.source; + + // always render if the user forces it + if (sourceParams?.type === SourceType.DYNAMIC) { + return false; + } + // never render if the user is forcing the block to render code, or + // if the user provides code + return sourceParams?.code || sourceParams?.type === SourceType.CODE; +}; + +/** + * Angular source decorator. + * + * @param storyFn Fn + * @param context StoryContext + */ +export const sourceDecorator = ( + storyFn: PartialStoryFn, + context: StoryContext +) => { + const story = storyFn(); + const source = useRef(undefined); + + useEffect(() => { + if (skipSourceRender(context)) { + return; + } + + const { props, userDefinedTemplate } = story; + const { component, argTypes, parameters } = context; + const template: string = parameters.docs?.source?.excludeDecorators + ? (context.originalStoryFn as ArgsStoryFn)(context.args, context).template + : story.template; + + if (component && !userDefinedTemplate) { + const sourceFromComponent = computesTemplateSourceFromComponent(component, props, argTypes); + + // We might have a story with a Directive or Service defined as the component + // In these cases there might exist a template, even if we aren't able to create source from component + const newSource = sourceFromComponent || template; + + if (newSource && newSource !== source.current) { + emitTransformCode(newSource, context); + source.current = newSource; + } + } else if (template && template !== source.current) { + emitTransformCode(template, context); + source.current = template; + } + }); + + return story; +}; diff --git a/code/frameworks/angular-vite/src/client/globals.ts b/code/frameworks/angular-vite/src/client/globals.ts new file mode 100644 index 000000000000..6d3159bff7fa --- /dev/null +++ b/code/frameworks/angular-vite/src/client/globals.ts @@ -0,0 +1,37 @@ +import { global } from '@storybook/global'; + +/** + * This file includes polyfills needed by Angular and is loaded before the app. You can add your own + * extra polyfills to this file. + * + * This file is divided into 2 sections: + * + * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. + * 2. Application imports. Files imported after ZoneJS that should be loaded before your main file. + * + * The current setup is for so-called "evergreen" browsers; the last versions of browsers that + * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), + * Edge> = 13 on the desktop, and iOS 10 and Chrome on mobile. + * + * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html + */ + +/** + * Required to support Web Animations `@angular/animation`. Needed for: All but Chrome, Firefox and + * Opera. http://caniuse.com/#feat=web-animation + */ +// import 'web-animations-js'; // Run `npm install --save web-animations-js`. + +// Included with Angular CLI. + +/** APPLICATION IMPORTS */ + +/** + * Date, currency, decimal and percent pipes. Needed for: All but Chrome, Firefox, Edge, IE11 and + * Safari 10 + */ +// import 'intl'; // Run `npm install --save intl`. +/** Need to import at least one locale-data with intl. */ +// import 'intl/locale-data/jsonp/en'; + +global.STORYBOOK_ENV = 'angular'; diff --git a/code/frameworks/angular-vite/src/client/index.ts b/code/frameworks/angular-vite/src/client/index.ts new file mode 100644 index 000000000000..0fa5b5c1ad07 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/index.ts @@ -0,0 +1,10 @@ +import './globals'; + +export * from './public-types'; +export * from './portable-stories'; +export * from './preview'; + +export type { StoryFnAngularReturnType as IStory } from './types'; + +export { moduleMetadata, componentWrapperDecorator, applicationConfig } from './decorators'; +export { argsToTemplate } from './argsToTemplate'; diff --git a/code/frameworks/angular-vite/src/client/portable-stories.ts b/code/frameworks/angular-vite/src/client/portable-stories.ts new file mode 100644 index 000000000000..b436ebf01987 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/portable-stories.ts @@ -0,0 +1,42 @@ +import { + setProjectAnnotations as originalSetProjectAnnotations, + setDefaultProjectAnnotations, +} from 'storybook/preview-api'; +import type { + NamedOrDefaultProjectAnnotations, + NormalizedProjectAnnotations, +} from 'storybook/internal/types'; + +import * as INTERNAL_DEFAULT_PROJECT_ANNOTATIONS from './render'; +import type { AngularRenderer } from './types'; + +/** + * Function that sets the globalConfig of your storybook. The global config is the preview module of + * your .storybook folder. + * + * It should be run a single time, so that your global config (e.g. decorators) is applied to your + * stories when using `composeStories` or `composeStory`. + * + * Example: + * + * ```jsx + * // setup-file.js + * import { setProjectAnnotations } from '@storybook/angular'; + * + * import projectAnnotations from './.storybook/preview'; + * + * setProjectAnnotations(projectAnnotations); + * ``` + * + * @param projectAnnotations - E.g. (import projectAnnotations from '../.storybook/preview') + */ +export function setProjectAnnotations( + projectAnnotations: + | NamedOrDefaultProjectAnnotations + | NamedOrDefaultProjectAnnotations[] +): NormalizedProjectAnnotations { + setDefaultProjectAnnotations(INTERNAL_DEFAULT_PROJECT_ANNOTATIONS); + return originalSetProjectAnnotations( + projectAnnotations + ) as NormalizedProjectAnnotations; +} diff --git a/code/frameworks/angular-vite/src/client/preview-prod.ts b/code/frameworks/angular-vite/src/client/preview-prod.ts new file mode 100644 index 000000000000..13a257400434 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/preview-prod.ts @@ -0,0 +1,3 @@ +import { enableProdMode } from '@angular/core'; + +enableProdMode(); diff --git a/code/frameworks/angular-vite/src/client/preview.ts b/code/frameworks/angular-vite/src/client/preview.ts new file mode 100644 index 000000000000..128d8bdc5b4c --- /dev/null +++ b/code/frameworks/angular-vite/src/client/preview.ts @@ -0,0 +1,257 @@ +import type { + AddonTypes, + InferTypes, + Meta, + Preview, + PreviewAddon, + Story, +} from 'storybook/internal/csf'; +import { definePreview as definePreviewBase } from 'storybook/internal/csf'; +import type { + ArgsStoryFn, + ComponentAnnotations, + DecoratorFunction, + ProjectAnnotations, + Renderer, + StoryAnnotations, +} from 'storybook/internal/types'; + +import type { RemoveIndexSignature, SetOptional, Simplify, UnionToIntersection } from 'type-fest'; + +import * as angularAnnotations from './config'; +import * as angularDocsAnnotations from './docs/config'; +import type { TransformComponentType } from './public-types'; +import { type AngularRenderer } from './types'; + +/** + * Creates an Angular-specific preview configuration with CSF factories support. + * + * This function wraps the base `definePreview` and adds Angular-specific annotations for rendering + * and documentation. It returns an `AngularPreview` that provides type-safe `meta()` and `story()` + * factory methods. + * + * @example + * + * ```ts + * // .storybook/preview.ts + * import { definePreview } from '@storybook/angular'; + * + * export const preview = definePreview({ + * addons: [], + * parameters: { layout: 'centered' }, + * }); + * ``` + */ +export function __definePreview[]>( + input: { addons: Addons } & ProjectAnnotations> +): AngularPreview> { + const preview = definePreviewBase({ + ...input, + addons: [angularAnnotations, angularDocsAnnotations, ...(input.addons ?? [])], + }) as unknown as AngularPreview>; + + return preview; +} + +type InferArgs = Simplify< + TArgs & Simplify>> +>; + +type InferComponentArgs any> = Partial< + TransformComponentType> +>; + +type InferAngularTypes = AngularRenderer & + T & { args: Simplify> }; + +/** + * Angular-specific Preview interface that provides type-safe CSF factory methods. + * + * Use `preview.meta()` to create a meta configuration for a component, and then `meta.story()` to + * create individual stories. The type system will infer args from the component, decorators, and + * any addon types. + * + * @example + * + * ```ts + * const meta = preview.meta({ component: ButtonComponent }); + * export const Primary = meta.story({ args: { label: 'Click me' } }); + * ``` + */ +export interface AngularPreview extends Preview { + /** + * Narrows the type of the preview to include additional type information. This is useful when you + * need to add args that aren't inferred from the component. + * + * @example + * + * ```ts + * const meta = preview.type<{ args: { theme: 'light' | 'dark' } }>().meta({ + * component: ButtonComponent, + * }); + * ``` + */ + type(): AngularPreview; + + meta< + C extends abstract new (...args: any) => any, + Decorators extends DecoratorFunction, + // Try to make Exact, TMetaArgs> work + TMetaArgs extends Partial & T['args']>, + >( + meta: { + component?: C; + args?: TMetaArgs; + decorators?: Decorators | Decorators[]; + } & Omit< + ComponentAnnotations & T['args']>, + 'decorators' | 'component' | 'args' + > + ): AngularMeta< + InferAngularTypes, Decorators>, + Omit, Decorators>>, 'args'> & { + args: {} extends TMetaArgs ? {} : TMetaArgs; + } + >; + + meta< + TArgs, + Decorators extends DecoratorFunction, + TMetaArgs extends Partial, + >( + meta: { + render?: ArgsStoryFn; + args?: TMetaArgs; + decorators?: Decorators | Decorators[]; + } & Omit< + ComponentAnnotations, + 'decorators' | 'args' | 'render' | 'component' + > + ): AngularMeta< + InferAngularTypes, + Omit>, 'args'> & { + args: {} extends TMetaArgs ? {} : TMetaArgs; + } + >; +} + +/** Extracts and unions all args types from an array of decorators. */ +type DecoratorsArgs = UnionToIntersection< + Decorators extends DecoratorFunction ? TArgs : unknown +>; + +/** + * Angular-specific Meta interface returned by `preview.meta()`. + * + * Provides the `story()` method to create individual stories with proper type inference. Args + * provided in meta become optional in stories, while missing required args must be provided at the + * story level. + */ +export interface AngularMeta< + T extends AngularRenderer, + MetaInput extends ComponentAnnotations, +> extends Meta { + /** + * Creates a story with a custom render function that takes no args. + * + * This overload allows you to define a story using just a render function or an object with a + * render function that doesn't depend on args. Since the render function doesn't use args, no + * args need to be provided regardless of what's required by the component. + * + * @example + * + * ```ts + * // Using just a render function + * export const CustomTemplate = meta.story(() => ({ + * template: '
Custom static content
', + * })); + * + * // Using an object with render + * export const WithRender = meta.story({ + * render: () => ({ template: '' }), + * }); + * ``` + */ + story< + TInput extends + | (() => AngularRenderer['storyResult']) + | (StoryAnnotations & { + render: () => AngularRenderer['storyResult']; + }), + >( + story: TInput + ): AngularStory< + T, + TInput extends () => AngularRenderer['storyResult'] ? { render: TInput } : TInput + >; + + /** + * Creates a story with custom configuration including args, decorators, or other annotations. + * + * This is the primary overload for defining stories. Args that were already provided in meta + * become optional, while any remaining required args must be specified here. + * + * @example + * + * ```ts + * // Provide required args not in meta + * export const Primary = meta.story({ + * args: { label: 'Click me', disabled: false }, + * }); + * + * // Override meta args and add story-specific configuration + * export const Disabled = meta.story({ + * args: { disabled: true }, + * decorators: [withCustomWrapper], + * }); + * ``` + */ + story< + TInput extends Simplify< + StoryAnnotations< + T, + T['args'], + SetOptional + > + >, + >( + story: TInput + ): AngularStory; + + /** + * Creates a story with no additional configuration. + * + * This overload is only available when all required args have been provided in meta. The + * conditional type `Partial extends SetOptional<...>` checks if the remaining required + * args (after accounting for args provided in meta) are all optional. If so, the function accepts + * zero arguments `[]`. Otherwise, it requires `[never]` which makes this overload unmatchable, + * forcing the user to provide args. + * + * @example + * + * ```ts + * // When meta provides all required args, story() can be called with no arguments + * const meta = preview.meta({ component: Button, args: { label: 'Hi', disabled: false } }); + * export const Default = meta.story(); // Valid - all args provided in meta + * ``` + */ + story( + ..._args: Partial extends SetOptional< + T['args'], + keyof T['args'] & keyof MetaInput['args'] + > + ? [] + : [never] + ): AngularStory; +} + +/** + * Angular-specific Story interface returned by `meta.story()`. + * + * Represents a single story with its configuration and provides access to the composed story for + * testing via `story.run()`. + */ +export interface AngularStory< + T extends AngularRenderer, + TInput extends StoryAnnotations, +> extends Story {} diff --git a/code/frameworks/angular-vite/src/client/public-types.ts b/code/frameworks/angular-vite/src/client/public-types.ts new file mode 100644 index 000000000000..8eba089f676f --- /dev/null +++ b/code/frameworks/angular-vite/src/client/public-types.ts @@ -0,0 +1,90 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import type { + AnnotatedStoryFn, + Args, + ComponentAnnotations, + DecoratorFunction, + LoaderFunction, + StoryAnnotations, + StoryContext as GenericStoryContext, + StrictArgs, + ProjectAnnotations, +} from 'storybook/internal/types'; +import type * as AngularCore from '@angular/core'; +import type { AngularRenderer } from './types'; + +export type { Args, ArgTypes, Parameters, StrictArgs } from 'storybook/internal/types'; +export type { Parameters as AngularParameters } from './types'; +export type { AngularRenderer }; + +/** + * Metadata to configure the stories for a component. + * + * @see [Default export](https://storybook.js.org/docs/api/csf#default-export) + */ +export type Meta = ComponentAnnotations< + AngularRenderer, + TransformComponentType +>; + +/** + * Story function that represents a CSFv2 component example. + * + * @see [Named Story exports](https://storybook.js.org/docs/api/csf#named-story-exports) + */ +export type StoryFn = AnnotatedStoryFn< + AngularRenderer, + TransformComponentType +>; + +/** + * Story object that represents a CSFv3 component example. + * + * @see [Named Story exports](https://storybook.js.org/docs/api/csf#named-story-exports) + */ +export type StoryObj = StoryAnnotations< + AngularRenderer, + TransformComponentType +>; + +export type Decorator = DecoratorFunction; +export type Loader = LoaderFunction; +export type StoryContext = GenericStoryContext; +export type Preview = ProjectAnnotations; + +/** Utility type that transforms InputSignal and EventEmitter types */ +export type TransformComponentType = TransformInputSignalType< + TransformOutputSignalType> +>; + +// @ts-ignore Angular < 17.2 doesn't export InputSignal +type AngularInputSignal = AngularCore.InputSignal; +// @ts-ignore Angular < 17.2 doesn't export InputSignalWithTransform +type AngularInputSignalWithTransform = AngularCore.InputSignalWithTransform; +// @ts-ignore Angular < 17.3 doesn't export AngularOutputEmitterRef +type AngularOutputEmitterRef = AngularCore.OutputEmitterRef; + +type AngularHasInputSignal = typeof AngularCore extends { input: infer U } ? true : false; +type AngularHasOutputSignal = typeof AngularCore extends { output: infer U } ? true : false; + +type InputSignal = AngularHasInputSignal extends true ? AngularInputSignal : never; +type InputSignalWithTransform = AngularHasInputSignal extends true + ? AngularInputSignalWithTransform + : never; +type OutputEmitterRef = AngularHasOutputSignal extends true ? AngularOutputEmitterRef : never; + +type TransformInputSignalType = { + [K in keyof T]: T[K] extends InputSignal + ? E + : T[K] extends InputSignalWithTransform + ? U + : T[K]; +}; + +type TransformOutputSignalType = { + [K in keyof T]: T[K] extends OutputEmitterRef ? (e: E) => void : T[K]; +}; + +type TransformEventType = { + [K in keyof T]: T[K] extends AngularCore.EventEmitter ? (e: E) => void : T[K]; +}; diff --git a/code/frameworks/angular-vite/src/client/render.ts b/code/frameworks/angular-vite/src/client/render.ts new file mode 100644 index 000000000000..768275b21e70 --- /dev/null +++ b/code/frameworks/angular-vite/src/client/render.ts @@ -0,0 +1,26 @@ +import type { ArgsStoryFn, RenderContext } from 'storybook/internal/types'; + +import '@angular/compiler'; + +import { RendererFactory } from './angular-beta/RendererFactory'; +import type { AngularRenderer } from './types'; + +export const rendererFactory = new RendererFactory(); + +export const render: ArgsStoryFn = (props) => ({ props }); + +export async function renderToCanvas( + { storyFn, showMain, forceRemount, storyContext: { component } }: RenderContext, + element: HTMLElement +) { + showMain(); + + const renderer = await rendererFactory.getRendererInstance(element); + + await renderer.render({ + storyFnAngular: storyFn(), + component, + forced: !forceRemount, + targetDOMNode: element, + }); +} diff --git a/code/frameworks/angular-vite/src/client/types.ts b/code/frameworks/angular-vite/src/client/types.ts new file mode 100644 index 000000000000..f1b43189eedb --- /dev/null +++ b/code/frameworks/angular-vite/src/client/types.ts @@ -0,0 +1,49 @@ +import type { + Parameters as DefaultParameters, + StoryContext as DefaultStoryContext, + WebRenderer, +} from 'storybook/internal/types'; + +import type { ApplicationConfig, Provider } from '@angular/core'; + +export interface NgModuleMetadata { + /** List of components, directives, and pipes that belong to your component. */ + declarations?: any[]; + entryComponents?: any[]; + /** + * List of modules that should be available to the root Storybook Component and all its children. + * If you want to register application providers or if you want to use the forRoot() pattern, + * please use the `applicationConfig` decorator in combination with the importProvidersFrom helper + * function from @angular/core instead. + */ + imports?: any[]; + schemas?: any[]; + /** + * List of providers that should be available on the root component and all its children. Use the + * `applicationConfig` decorator to register environemt and application-wide providers. + */ + providers?: Provider[]; +} +export interface ICollection { + [p: string]: any; +} + +export interface StoryFnAngularReturnType { + props?: ICollection; + moduleMetadata?: NgModuleMetadata; + applicationConfig?: ApplicationConfig; + template?: string; + styles?: string[]; + userDefinedTemplate?: boolean; +} + +export interface AngularRenderer extends WebRenderer { + component: any; + storyResult: StoryFnAngularReturnType; +} + +export type Parameters = DefaultParameters & { + bootstrapModuleOptions?: unknown; +}; + +export type StoryContext = DefaultStoryContext & { parameters: Parameters }; diff --git a/code/frameworks/angular-vite/src/index.ts b/code/frameworks/angular-vite/src/index.ts new file mode 100644 index 000000000000..a4b02907a15a --- /dev/null +++ b/code/frameworks/angular-vite/src/index.ts @@ -0,0 +1,16 @@ +export * from './client/index'; +export * from './types'; + +export { __definePreview as definePreview } from './client/index'; + +/* + * ATTENTION: + * - moduleMetadata + * - NgModuleMetadata + * - ICollection + * + * These typings are coped out of decorators.d.ts and types.d.ts in order to fix a bug with tsc + * It was imported out of dist before which was not the proper way of exporting public API + * + * This can be fixed by migrating app/angular to typescript + */ diff --git a/code/frameworks/angular-vite/src/node/index.ts b/code/frameworks/angular-vite/src/node/index.ts new file mode 100644 index 000000000000..31a74ec3447c --- /dev/null +++ b/code/frameworks/angular-vite/src/node/index.ts @@ -0,0 +1,7 @@ +import type { StorybookConfig } from '../types'; + +export function defineMain(config: StorybookConfig) { + return config; +} + +export type { StorybookConfig }; diff --git a/code/frameworks/angular-vite/src/preset.ts b/code/frameworks/angular-vite/src/preset.ts new file mode 100644 index 000000000000..74182697d98b --- /dev/null +++ b/code/frameworks/angular-vite/src/preset.ts @@ -0,0 +1,237 @@ +import type { PresetProperty } from 'storybook/internal/types'; + +import { fileURLToPath, resolve } from 'node:url'; + +import type { StandaloneOptions } from './builders/utils/standalone-options'; +import type { FrameworkOptions } from './types'; +import type { UserConfig, Plugin } from 'vite'; + +export const addons: PresetProperty<'addons'> = []; + +export const previewAnnotations: PresetProperty<'previewAnnotations'> = async ( + entries = [], + options +) => { + const config = fileURLToPath( + import.meta.resolve('@storybook/angular-vite/client/config'), + ); + const annotations = [...entries, config]; + + if ((options as any as StandaloneOptions).enableProdMode) { + const previewProdPath = fileURLToPath( + import.meta.resolve('@storybook/angular-vite/client/preview-prod'), + ); + annotations.unshift(previewProdPath); + } + + const docsConfig = await options.presets.apply('docs', {}, options); + const docsEnabled = Object.keys(docsConfig).length > 0; + if (docsEnabled) { + const docsConfigPath = fileURLToPath( + import.meta.resolve('@storybook/angular-vite/client/docs/config'), + ); + annotations.push(docsConfigPath); + } + return annotations; +}; + +export const core: PresetProperty<'core'> = async (config, options) => { + const framework = await options.presets.apply('framework'); + + return { + ...config, + builder: { + name: import.meta.resolve('@storybook/builder-vite'), + options: typeof framework === 'string' ? {} : framework.options.builder || {}, + }, + }; +}; + +async function resolveExperimentalZoneless( + frameworkOptions: FrameworkOptions, + angularBuilderOptions: StandaloneOptions['angularBuilderOptions'], +) { + // 1. Explicit framework option (user's .storybook/main.ts) + if (typeof frameworkOptions?.experimentalZoneless === 'boolean') { + return frameworkOptions.experimentalZoneless; + } + + // 2. Angular builder options (set by start-storybook/build-storybook) + if (typeof angularBuilderOptions?.experimentalZoneless === 'boolean') { + return angularBuilderOptions.experimentalZoneless; + } + + // 3. Auto-detect Angular 21+ (matches @storybook/angular builder behavior) + try { + const { VERSION } = await import('@angular/core'); + return !!(VERSION.major && Number(VERSION.major) >= 21); + } catch { + return false; + } +} + +export const viteFinal = async (config: UserConfig, options?: StandaloneOptions) => { + // Remove any loaded analogjs plugins from a vite.config.(m)ts file + config.plugins = (config.plugins ?? []) + .flat() + .filter((plugin: any) => !plugin.name.includes('analogjs')); + + // Merge custom configuration into the default config + const { mergeConfig, normalizePath } = await import('vite'); + const { default: angular } = await import('@analogjs/vite-plugin-angular'); + + // @ts-ignore + const framework = await options.presets.apply('framework'); + const experimentalZoneless = await resolveExperimentalZoneless( + framework.options, + options?.angularBuilderOptions, + ); + return mergeConfig(config, { + // Add dependencies to pre-optimization + optimizeDeps: { + include: [ + '@storybook/angular-vite/client', + '@storybook/angular-vite', + '@angular/compiler', + '@angular/platform-browser', + '@angular/platform-browser/animations', + 'tslib', + ...(experimentalZoneless ? [] : ['zone.js']), + ], + }, + plugins: [ + angular({ + jit: + typeof framework.options?.jit !== 'undefined' + ? framework.options?.jit + : true, + liveReload: + typeof framework.options?.liveReload !== 'undefined' + ? framework.options?.liveReload + : false, + tsconfig: + typeof framework.options?.tsconfig !== 'undefined' + ? framework.options?.tsconfig + : (options?.tsConfig ?? './.storybook/tsconfig.json'), + inlineStylesExtension: + typeof framework.options?.inlineStylesExtension !== 'undefined' + ? framework.options?.inlineStylesExtension + : 'css', + }), + angularOptionsPlugin(options, { normalizePath, experimentalZoneless }), + storybookEsbuildPlugin(), + ], + define: { + STORYBOOK_ANGULAR_OPTIONS: JSON.stringify({ + experimentalZoneless: !!experimentalZoneless, + }), + }, + }); +}; + +function angularOptionsPlugin( + options: StandaloneOptions, + { normalizePath, experimentalZoneless }: any, +): Plugin { + let resolvedConfig: UserConfig; + return { + name: 'storybook-angular-vite-options-plugin', + config(userConfig: UserConfig) { + resolvedConfig = userConfig; + const loadPaths = + options?.angularBuilderOptions?.stylePreprocessorOptions?.loadPaths; + const sassOptions = + options?.angularBuilderOptions?.stylePreprocessorOptions?.sass; + + if (Array.isArray(loadPaths)) { + const workspaceRoot = + options.angularBuilderContext?.workspaceRoot ?? + userConfig?.root ?? + process.cwd(); + return { + css: { + preprocessorOptions: { + scss: { + ...sassOptions, + loadPaths: loadPaths.map( + (loadPath) => `${resolve(workspaceRoot, loadPath)}`, + ), + }, + }, + }, + }; + } + + return; + }, + async transform(code, id) { + if ( + normalizePath(id).endsWith( + normalizePath(`${options.configDir}/preview.ts`), + ) + ) { + const imports = []; + const styles = options?.angularBuilderOptions?.styles; + + if (Array.isArray(styles)) { + styles.forEach((style) => { + imports.push(style); + }); + } + + if (!experimentalZoneless) { + imports.push('zone.js'); + } + + // Use vite config root when angularBuilderContext is not available + // (e.g., when running via Vitest instead of Angular builders) + const projectRoot = resolvedConfig?.root ?? process.cwd(); + + return { + code: ` + ${imports + .map((extraImport) => { + if ( + extraImport.startsWith('.') || + extraImport.startsWith('src') + ) { + // relative to root + return `import '${resolve(projectRoot, extraImport)}';`; + } + + // absolute import + return `import '${extraImport}';`; + }) + .join('\n')} + ${code} + `, + }; + } + + return; + }, + }; +} + +function storybookEsbuildPlugin() { + return { + name: 'storybookjs-angular-vite-esbuild-config', + apply: 'build', + config() { + return { + esbuild: { + // Don't mangle class names during the build + // This fixes display of compodoc argtypes + keepNames: true, + }, + }; + }, + }; +} + +export const typescript: PresetProperty<'typescript'> = async (config) => { + return { + ...config, + skipCompiler: true, + }; +}; diff --git a/code/frameworks/angular-vite/src/renderer.ts b/code/frameworks/angular-vite/src/renderer.ts new file mode 100644 index 000000000000..a88691381c84 --- /dev/null +++ b/code/frameworks/angular-vite/src/renderer.ts @@ -0,0 +1,6 @@ +export { storyPropsProvider } from './client/angular-beta/StorybookProvider'; +export { computesTemplateSourceFromComponent } from './client/angular-beta/ComputesTemplateFromComponent'; +export { rendererFactory } from './client/render'; +export { AbstractRenderer } from './client/angular-beta/AbstractRenderer'; +export { getApplication } from './client/angular-beta/StorybookModule'; +export { PropertyExtractor } from './client/angular-beta/utils/PropertyExtractor'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/empty-projects-entry/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/empty-projects-entry/angular.json new file mode 100644 index 000000000000..98cc50ef4b92 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/empty-projects-entry/angular.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "projects": {} +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/angular.json new file mode 100644 index 000000000000..f3dcfcd45586 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/angular.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "assets": [] + } + } + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/tsconfig.app.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/tsconfig.app.json new file mode 100644 index 000000000000..644f410d7fb1 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/src/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "module": "es2015", + "types": ["node"] + }, + "exclude": ["karma.ts", "**/*.spec.ts"] +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/tsconfig.json new file mode 100644 index 000000000000..ed46a09da328 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/minimal-config/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/angular.json new file mode 100644 index 000000000000..358ec4f98f18 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/angular.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "options": { + "optimization": false, + "tsConfig": "src/tsconfig.app.json", + "assets": [] + } + } + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/tsconfig.app.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/tsconfig.app.json new file mode 100644 index 000000000000..644f410d7fb1 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/src/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "module": "es2015", + "types": ["node"] + }, + "exclude": ["karma.ts", "**/*.spec.ts"] +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/tsconfig.json new file mode 100644 index 000000000000..ed46a09da328 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/some-config/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/angular.json new file mode 100644 index 000000000000..f55b743522a1 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/angular.json @@ -0,0 +1,64 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "assets": [] + } + } + } + }, + "no-confs-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "target-build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "assets": [] + } + } + } + }, + "no-target-conf-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "target-build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "assets": [] + }, + "configurations": { + "other-conf": { + "styles": ["src/styles.css"] + } + } + } + } + }, + "target-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "target-build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "assets": [] + }, + "configurations": { + "target-conf": { + "styles": ["src/styles.css"] + } + } + } + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/styles.css b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/styles.css new file mode 100644 index 000000000000..25357ee7cc98 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/styles.css @@ -0,0 +1,2 @@ +.class { +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/tsconfig.app.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/tsconfig.app.json new file mode 100644 index 000000000000..644f410d7fb1 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/src/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "module": "es2015", + "types": ["node"] + }, + "exclude": ["karma.ts", "**/*.spec.ts"] +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/tsconfig.json new file mode 100644 index 000000000000..ed46a09da328 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-angularBrowserTarget/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/angular.json new file mode 100644 index 000000000000..1e9be4468f64 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/angular.json @@ -0,0 +1,28 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "pattern-lib": { + "projectType": "library", + "root": "projects/pattern-lib", + "sourceRoot": "projects/pattern-lib/src", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:ng-packagr", + "options": { + "tsConfig": "projects/pattern-lib/tsconfig.lib.json", + "project": "projects/pattern-lib/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "projects/pattern-lib/tsconfig.lib.prod.json" + } + } + } + } + } + }, + "defaultProject": "pattern-lib" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/tsconfig.lib.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/tsconfig.lib.json new file mode 100644 index 000000000000..b9a44f04464a --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/projects/pattern-lib/tsconfig.lib.json @@ -0,0 +1,20 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "../../out-tsc/lib", + "target": "es2015", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [], + "lib": ["dom", "es2018"] + }, + "angularCompilerOptions": { + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "enableResourceInlining": true + }, + "exclude": ["src/test.ts", "**/*.spec.ts"] +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/tsconfig.json new file mode 100644 index 000000000000..ed46a09da328 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-lib/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/nx.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/nx.json new file mode 100644 index 000000000000..1e0f6b56902f --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/nx.json @@ -0,0 +1,3 @@ +{ + "npmScope": "nx-example" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.css b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.css new file mode 100644 index 000000000000..25357ee7cc98 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.css @@ -0,0 +1,2 @@ +.class { +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.scss b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.scss new file mode 100644 index 000000000000..25357ee7cc98 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/styles.scss @@ -0,0 +1,2 @@ +.class { +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/tsconfig.app.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/tsconfig.app.json new file mode 100644 index 000000000000..e5a395ac067d --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/src/tsconfig.app.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "module": "es2015", + "types": ["node"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/tsconfig.json new file mode 100644 index 000000000000..4c19c82b6bab --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/tsconfig.json @@ -0,0 +1,14 @@ +{ + "include": ["./src"], + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/workspace.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/workspace.json new file mode 100644 index 000000000000..9d9fc9b3ef36 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx-workspace/workspace.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "styles": ["src/styles.css", "src/styles.scss"] + } + } + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/angular.json new file mode 100644 index 000000000000..9d9fc9b3ef36 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/angular.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "styles": ["src/styles.css", "src/styles.scss"] + } + } + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/nx.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/nx.json new file mode 100644 index 000000000000..1e0f6b56902f --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/nx.json @@ -0,0 +1,3 @@ +{ + "npmScope": "nx-example" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.css b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.css new file mode 100644 index 000000000000..25357ee7cc98 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.css @@ -0,0 +1,2 @@ +.class { +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.scss b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.scss new file mode 100644 index 000000000000..25357ee7cc98 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/styles.scss @@ -0,0 +1,2 @@ +.class { +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/tsconfig.app.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/tsconfig.app.json new file mode 100644 index 000000000000..e5a395ac067d --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/src/tsconfig.app.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "module": "es2015", + "types": ["node"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/tsconfig.json new file mode 100644 index 000000000000..4c19c82b6bab --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-nx/tsconfig.json @@ -0,0 +1,14 @@ +{ + "include": ["./src"], + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/angular.json new file mode 100644 index 000000000000..9d9fc9b3ef36 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/angular.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "options": { + "tsConfig": "src/tsconfig.app.json", + "styles": ["src/styles.css", "src/styles.scss"] + } + } + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.css b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.css new file mode 100644 index 000000000000..25357ee7cc98 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.css @@ -0,0 +1,2 @@ +.class { +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.scss b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.scss new file mode 100644 index 000000000000..25357ee7cc98 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/styles.scss @@ -0,0 +1,2 @@ +.class { +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/tsconfig.app.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/tsconfig.app.json new file mode 100644 index 000000000000..644f410d7fb1 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/src/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "module": "es2015", + "types": ["node"] + }, + "exclude": ["karma.ts", "**/*.spec.ts"] +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/tsconfig.json new file mode 100644 index 000000000000..ed46a09da328 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/with-options-styles/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build-options/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build-options/angular.json new file mode 100644 index 000000000000..f734ee08896e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build-options/angular.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "architect": { + "build": {} + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build/angular.json new file mode 100644 index 000000000000..8eead199dffd --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-architect-build/angular.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "projects": { "foo-project": {} }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-compatible-projects/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-compatible-projects/angular.json new file mode 100644 index 000000000000..2e3757eb833e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-compatible-projects/angular.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "projects": { + "noop-project": {} + }, + "defaultProject": "missing-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/angular.json new file mode 100644 index 000000000000..61a2092b1b7f --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/angular.json @@ -0,0 +1,3 @@ +{ + "version": 1 +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/tsconfig.lib.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/tsconfig.lib.json new file mode 100644 index 000000000000..b9a44f04464a --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/projects/pattern-lib/tsconfig.lib.json @@ -0,0 +1,20 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "../../out-tsc/lib", + "target": "es2015", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [], + "lib": ["dom", "es2018"] + }, + "angularCompilerOptions": { + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "enableResourceInlining": true + }, + "exclude": ["src/test.ts", "**/*.spec.ts"] +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/tsconfig.json new file mode 100644 index 000000000000..ed46a09da328 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-projects-entry/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/angular.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/angular.json new file mode 100644 index 000000000000..2b203f2551ec --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/angular.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "projects": { + "foo-project": { + "root": "", + "architect": { + "build": { + "options": { + "assets": [] + } + } + } + } + }, + "defaultProject": "foo-project" +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/main.ts b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/main.ts new file mode 100644 index 000000000000..63b661e3bd7e --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/main.ts @@ -0,0 +1,2 @@ +// To avoid "No inputs were found in config file" tsc error +export const not = 'empty'; diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/tsconfig.app.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/tsconfig.app.json new file mode 100644 index 000000000000..644f410d7fb1 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/src/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "module": "es2015", + "types": ["node"] + }, + "exclude": ["karma.ts", "**/*.spec.ts"] +} diff --git a/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/tsconfig.json b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/tsconfig.json new file mode 100644 index 000000000000..ed46a09da328 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__mocks-ng-workspace__/without-tsConfig/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "sourceMap": true, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es5", + "lib": ["es2017", "dom"] + } +} diff --git a/code/frameworks/angular-vite/src/server/__tests__/angular.json b/code/frameworks/angular-vite/src/server/__tests__/angular.json new file mode 100644 index 000000000000..d703e396ff22 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/__tests__/angular.json @@ -0,0 +1,96 @@ +{ + /* angular.json can have comments */ + // angular.json can have comments + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "angular-cli": { + "root": "", + "sourceRoot": "src", + "projectType": "application", + "prefix": "app", + "schematics": {}, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/angular-cli", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": ["src/favicon.ico", "src/assets"], + "styles": ["src/styles.css", "src/styles.scss"], + "stylePreprocessorOptions": { + "includePaths": ["src/commons"] + }, + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "angular-cli:build" + }, + "configurations": { + "production": { + "browserTarget": "angular-cli:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "angular-cli:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/karma.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": ["styles.css"], + "scripts": [], + "assets": ["src/favicon.ico", "src/assets"] + } + } + } + }, + "angular-cli-e2e": { + "root": "e2e/", + "projectType": "application", + "architect": { + "e2e": { + "builder": "@angular-devkit/build-angular:protractor", + "options": { + "protractorConfig": "e2e/protractor.conf.js", + "devServerTarget": "angular-cli:serve" + } + } + } + } + }, + "defaultProject": "angular-cli" +} diff --git a/code/frameworks/angular-vite/src/server/preset-options.ts b/code/frameworks/angular-vite/src/server/preset-options.ts new file mode 100644 index 000000000000..8d3d9c4f0b31 --- /dev/null +++ b/code/frameworks/angular-vite/src/server/preset-options.ts @@ -0,0 +1,14 @@ +import type { Options as CoreOptions } from 'storybook/internal/types'; + +import type { BuilderContext } from '@angular-devkit/architect'; +import type { StandaloneOptions } from '../builders/utils/standalone-options'; + +export type PresetOptions = CoreOptions & { + /* Allow to get the options of a targeted "browser builder" */ + angularBrowserTarget?: string | null; + /* Defined set of options. These will take over priority from angularBrowserTarget options */ + angularBuilderOptions?: StandaloneOptions['angularBuilderOptions']; + /* Angular context from builder */ + angularBuilderContext?: BuilderContext | null; + tsConfig?: string; +}; diff --git a/code/frameworks/angular-vite/src/test-setup.ts b/code/frameworks/angular-vite/src/test-setup.ts new file mode 100644 index 000000000000..5ed15c667bad --- /dev/null +++ b/code/frameworks/angular-vite/src/test-setup.ts @@ -0,0 +1,8 @@ +import '@analogjs/vite-plugin-angular/setup-vitest'; +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; + +getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); diff --git a/code/frameworks/angular-vite/src/types.ts b/code/frameworks/angular-vite/src/types.ts new file mode 100644 index 000000000000..eb5dd93b2561 --- /dev/null +++ b/code/frameworks/angular-vite/src/types.ts @@ -0,0 +1,42 @@ +import { CompatibleString } from 'storybook/internal/types'; + +import { StorybookConfig as StorybookConfigBase } from 'storybook/internal/types'; + +import type { BuilderOptions, StorybookConfigVite } from '@storybook/builder-vite'; + +type FrameworkName = CompatibleString<'@storybook/angular-vite'>; +type BuilderName = CompatibleString<'@storybook/builder-vite'>; + +export type FrameworkOptions = { + builder?: BuilderOptions; + jit?: boolean; + liveReload?: boolean; + inlineStylesExtension?: string; + tsconfig?: string; + experimentalZoneless?: boolean; +}; + +type StorybookConfigFramework = { + framework: + | FrameworkName + | { + name: FrameworkName; + options: FrameworkOptions; + }; + core?: StorybookConfigBase['core'] & { + builder?: + | BuilderName + | { + name: BuilderName; + options: BuilderOptions; + }; + }; +}; + +/** The interface for Storybook configuration in `main.ts` files. */ +export type StorybookConfig = Omit< + StorybookConfigBase, + keyof StorybookConfigVite | keyof StorybookConfigFramework +> & + StorybookConfigVite & + StorybookConfigFramework; diff --git a/code/frameworks/angular-vite/src/typings.d.ts b/code/frameworks/angular-vite/src/typings.d.ts new file mode 100644 index 000000000000..4b9bdbb7ac91 --- /dev/null +++ b/code/frameworks/angular-vite/src/typings.d.ts @@ -0,0 +1,18 @@ +// will be provided by the vite define config +declare var NODE_ENV: string | undefined; + +declare var __STORYBOOK_ADDONS_CHANNEL__: any; +declare var __STORYBOOK_ADDONS_PREVIEW: any; +declare var __STORYBOOK_COMPODOC_JSON__: any; +declare var __STORYBOOK_PREVIEW__: any; +declare var __STORYBOOK_STORY_STORE__: any; +declare var CHANNEL_OPTIONS: any; +declare var DOCS_OPTIONS: any; + +declare var FEATURES: import('storybook/internal/types').StorybookConfigRaw['features']; + +declare var IS_STORYBOOK: any; +declare var LOGLEVEL: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent' | undefined; +declare var STORIES: any; +declare var STORYBOOK_ENV: 'angular'; +declare var STORYBOOK_HOOKS_CONTEXT: any; diff --git a/code/frameworks/angular-vite/start-schema.json b/code/frameworks/angular-vite/start-schema.json new file mode 100644 index 000000000000..70b307e107bc --- /dev/null +++ b/code/frameworks/angular-vite/start-schema.json @@ -0,0 +1,227 @@ +{ + "$schema": "http://json-schema.org/schema", + "title": "Start Storybook", + "description": "Serve up storybook in development mode.", + "type": "object", + "properties": { + "browserTarget": { + "type": "string", + "description": "Build target to be served in project-name:builder:config format. Should generally target on the builder: '@angular-devkit/build-angular:browser'. Useful for Storybook to use options (styles, assets, ...).", + "pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$" + }, + "tsConfig": { + "type": "string", + "description": "The full path for the TypeScript configuration file, relative to the current workspace." + }, + "preserveSymlinks": { + "type": "boolean", + "description": "Do not use the real path when resolving modules. If true, symlinks are resolved to their real path, if false, symlinks are resolved to their symlinked path.", + "default": false + }, + "port": { + "type": "number", + "description": "Port to listen on.", + "default": 9009 + }, + "host": { + "type": "string", + "description": "Host to listen on.", + "default": "localhost" + }, + "configDir": { + "type": "string", + "description": "Directory where to load Storybook configurations from.", + "default": ".storybook" + }, + "https": { + "type": "boolean", + "description": "Serve Storybook over HTTPS. Note: You must provide your own certificate information.", + "default": false + }, + "sslCa": { + "type": "string", + "description": "Provide an SSL certificate authority. (Optional with --https, required if using a self-signed certificate)." + }, + "sslCert": { + "type": "string", + "description": "Provide an SSL certificate. (Required with --https)." + }, + "sslKey": { + "type": "string", + "description": "SSL key to use for serving HTTPS." + }, + "smokeTest": { + "type": "boolean", + "description": "Exit after successful start.", + "default": false + }, + "ci": { + "type": "boolean", + "description": "CI mode (skip interactive prompts, don't open browser).", + "default": false + }, + "open": { + "type": "boolean", + "description": "Whether to open Storybook automatically in the browser.", + "default": true + }, + "quiet": { + "type": "boolean", + "description": "Suppress verbose build output.", + "default": false + }, + "enableProdMode": { + "type": "boolean", + "description": "Disable Angular's development mode, which turns off assertions and other checks within the framework.", + "default": false + }, + "docs": { + "type": "boolean", + "description": "Starts Storybook in documentation mode. Learn more about it : https://storybook.js.org/docs/writing-docs/build-documentation#preview-storybooks-documentation.", + "default": false + }, + "compodoc": { + "type": "boolean", + "description": "Execute compodoc before.", + "default": true + }, + "compodocArgs": { + "type": "array", + "description": "Compodoc options : https://compodoc.app/guides/options.html. Options `-p` with tsconfig path and `-d` with workspace root is always given.", + "default": ["-e", "json"], + "items": { + "type": "string" + } + }, + "styles": { + "type": "array", + "description": "Global styles to be included in the build.", + "items": { + "$ref": "#/definitions/styleElement" + } + }, + "stylePreprocessorOptions": { + "description": "Options to pass to style preprocessors.", + "type": "object", + "properties": { + "includePaths": { + "description": "Paths to include. Paths will be resolved to workspace root.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "assets": { + "type": "array", + "description": "List of static application assets.", + "default": [], + "items": { + "$ref": "#/definitions/assetPattern" + } + }, + "initialPath": { + "type": "string", + "description": "URL path to be appended when visiting Storybook for the first time" + }, + "statsJson": { + "type": ["boolean", "string"], + "description": "Write stats JSON to disk", + "default": false + }, + "previewUrl": { + "type": "string", + "description": "Disables the default storybook preview and lets you use your own" + }, + "loglevel": { + "type": "string", + "description": "Controls level of logging during build. Can be one of: [trace, debug, info (default), warn, error, silent].", + "pattern": "(trace|debug|info|warn|error|silent)" + }, + "logfile": { + "type": "string", + "description": "If provided, the log output will be written to the specified file path." + }, + "sourceMap": { + "type": ["boolean", "object"], + "description": "Configure sourcemaps. See: https://angular.io/guide/workspace-config#source-map-configuration", + "default": false + }, + "experimentalZoneless": { + "type": "boolean", + "description": "Experimental: Use zoneless change detection." + } + }, + "additionalProperties": false, + "definitions": { + "assetPattern": { + "oneOf": [ + { + "type": "object", + "properties": { + "followSymlinks": { + "type": "boolean", + "default": false, + "description": "Allow glob patterns to follow symlink directories. This allows subdirectories of the symlink to be searched." + }, + "glob": { + "type": "string", + "description": "The pattern to match." + }, + "input": { + "type": "string", + "description": "The input directory path in which to apply 'glob'. Defaults to the project root." + }, + "ignore": { + "description": "An array of globs to ignore.", + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "string", + "description": "Absolute path within the output." + } + }, + "additionalProperties": false, + "required": ["glob", "input", "output"] + }, + { + "type": "string" + } + ] + }, + "styleElement": { + "oneOf": [ + { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The file to include." + }, + "bundleName": { + "type": "string", + "pattern": "^[\\w\\-.]*$", + "description": "The bundle name for this extra entry point." + }, + "inject": { + "type": "boolean", + "description": "If the bundle will be referenced in the HTML file.", + "default": true + } + }, + "additionalProperties": false, + "required": ["input"] + }, + { + "type": "string", + "description": "The file to include." + } + ] + } + } +} diff --git a/code/frameworks/angular-vite/template/cli/button.component.ts b/code/frameworks/angular-vite/template/cli/button.component.ts new file mode 100644 index 000000000000..a1cdf6a939e3 --- /dev/null +++ b/code/frameworks/angular-vite/template/cli/button.component.ts @@ -0,0 +1,48 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, Output, EventEmitter } from '@angular/core'; + +@Component({ + selector: 'storybook-button', + standalone: true, + imports: [CommonModule], + template: ` `, + styleUrls: ['./button.css'], +}) +export class ButtonComponent { + /** Is this the principal call to action on the page? */ + @Input() + primary = false; + + /** What background color to use */ + @Input() + backgroundColor?: string; + + /** How large should the button be? */ + @Input() + size: 'small' | 'medium' | 'large' = 'medium'; + + /** + * Button contents + * + * @required + */ + @Input() + label = 'Button'; + + /** Optional click handler */ + @Output() + onClick = new EventEmitter(); + + public get classes(): string[] { + const mode = this.primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + + return ['storybook-button', `storybook-button--${this.size}`, mode]; + } +} diff --git a/code/frameworks/angular-vite/template/cli/button.stories.ts b/code/frameworks/angular-vite/template/cli/button.stories.ts new file mode 100644 index 000000000000..4870e4cff917 --- /dev/null +++ b/code/frameworks/angular-vite/template/cli/button.stories.ts @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { fn } from 'storybook/test'; + +import { ButtonComponent } from './button.component'; + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories +const meta: Meta = { + title: 'Example/Button', + component: ButtonComponent, + tags: ['autodocs'], + argTypes: { + backgroundColor: { + control: 'color', + }, + }, + // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args + args: { onClick: fn() }, +}; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const Primary: Story = { + args: { + primary: true, + label: 'Button', + }, +}; + +export const Secondary: Story = { + args: { + label: 'Button', + }, +}; + +export const Large: Story = { + args: { + size: 'large', + label: 'Button', + }, +}; + +export const Small: Story = { + args: { + size: 'small', + label: 'Button', + }, +}; diff --git a/code/frameworks/angular-vite/template/cli/header.component.ts b/code/frameworks/angular-vite/template/cli/header.component.ts new file mode 100644 index 000000000000..4d3a4fdc53b6 --- /dev/null +++ b/code/frameworks/angular-vite/template/cli/header.component.ts @@ -0,0 +1,76 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { ButtonComponent } from './button.component'; +import type { User } from './user'; + +@Component({ + selector: 'storybook-header', + standalone: true, + imports: [CommonModule, ButtonComponent], + template: `
+
+
+ + + + + + + +

Acme

+
+
+
+ + Welcome, {{ user.name }}! + + +
+
+ + +
+
+
+
`, + styleUrls: ['./header.css'], +}) +export class HeaderComponent { + @Input() + user: User | null = null; + + @Output() + onLogin = new EventEmitter(); + + @Output() + onLogout = new EventEmitter(); + + @Output() + onCreateAccount = new EventEmitter(); +} diff --git a/code/frameworks/angular-vite/template/cli/header.stories.ts b/code/frameworks/angular-vite/template/cli/header.stories.ts new file mode 100644 index 000000000000..8a9f73b151c8 --- /dev/null +++ b/code/frameworks/angular-vite/template/cli/header.stories.ts @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { fn } from 'storybook/test'; + +import { HeaderComponent } from './header.component'; + +const meta: Meta = { + title: 'Example/Header', + component: HeaderComponent, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ['autodocs'], + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + layout: 'fullscreen', + }, + args: { + onLogin: fn(), + onLogout: fn(), + onCreateAccount: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const LoggedIn: Story = { + args: { + user: { + name: 'Jane Doe', + }, + }, +}; + +export const LoggedOut: Story = {}; diff --git a/code/frameworks/angular-vite/template/cli/page.component.ts b/code/frameworks/angular-vite/template/cli/page.component.ts new file mode 100644 index 000000000000..fca9d299d000 --- /dev/null +++ b/code/frameworks/angular-vite/template/cli/page.component.ts @@ -0,0 +1,82 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { HeaderComponent } from './header.component'; +import type { User } from './user'; + +@Component({ + selector: 'storybook-page', + standalone: true, + imports: [CommonModule, HeaderComponent], + template: `
+ +
+

Pages in Storybook

+

+ We recommend building UIs with a + + component-driven + + process starting with atomic components and ending with pages. +

+

+ Render pages with mock data. This makes it easy to build and review page states without + needing to navigate to them in your app. Here are some handy patterns for managing page data + in Storybook: +

+
    +
  • + Use a higher-level connected component. Storybook helps you compose such data from the + "args" of child component stories +
  • +
  • + Assemble data in the page component from your services. You can mock these services out + using Storybook. +
  • +
+

+ Get a guided tutorial on component-driven development at + + Storybook tutorials + + . Read more in the + docs + . +

+
+ Tip Adjust the width of the canvas with the + + + + + + Viewports addon in the toolbar +
+
+
`, + styleUrls: ['./page.css'], +}) +export class PageComponent { + user: User | null = null; + + doLogout() { + this.user = null; + } + + doLogin() { + this.user = { name: 'Jane Doe' }; + } + + doCreateAccount() { + this.user = { name: 'Jane Doe' }; + } +} diff --git a/code/frameworks/angular-vite/template/cli/page.stories.ts b/code/frameworks/angular-vite/template/cli/page.stories.ts new file mode 100644 index 000000000000..659a14c1effb --- /dev/null +++ b/code/frameworks/angular-vite/template/cli/page.stories.ts @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { expect, userEvent, within } from 'storybook/test'; + +import { PageComponent } from './page.component'; + +const meta: Meta = { + title: 'Example/Page', + component: PageComponent, + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +export const LoggedOut: Story = {}; + +// More on component testing: https://storybook.js.org/docs/writing-tests/interaction-testing +export const LoggedIn: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const loginButton = canvas.getByRole('button', { name: /Log in/i }); + await expect(loginButton).toBeInTheDocument(); + await userEvent.click(loginButton); + await expect(loginButton).not.toBeInTheDocument(); + + const logoutButton = canvas.getByRole('button', { name: /Log out/i }); + await expect(logoutButton).toBeInTheDocument(); + }, +}; diff --git a/code/frameworks/angular-vite/template/cli/user.ts b/code/frameworks/angular-vite/template/cli/user.ts new file mode 100644 index 000000000000..c66461927e2a --- /dev/null +++ b/code/frameworks/angular-vite/template/cli/user.ts @@ -0,0 +1,3 @@ +export interface User { + name: string; +} diff --git a/code/frameworks/angular-vite/template/components/button.component.ts b/code/frameworks/angular-vite/template/components/button.component.ts new file mode 100644 index 000000000000..4407ead9b050 --- /dev/null +++ b/code/frameworks/angular-vite/template/components/button.component.ts @@ -0,0 +1,47 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; + +@Component({ + standalone: false, + // Needs to be a different name to the CLI template button + selector: 'storybook-framework-button', + template: ` `, + styleUrls: ['./button.css'], +}) +export default class FrameworkButtonComponent { + /** Is this the principal call to action on the page? */ + @Input() + primary = false; + + /** What background color to use */ + @Input() + backgroundColor?: string; + + /** How large should the button be? */ + @Input() + size: 'small' | 'medium' | 'large' = 'medium'; + + /** + * Button contents + * + * @required + */ + @Input() + label = 'Button'; + + /** Optional click handler */ + @Output() + onClick = new EventEmitter(); + + public get classes(): string[] { + const mode = this.primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + + return ['storybook-button', `storybook-button--${this.size}`, mode]; + } +} diff --git a/code/frameworks/angular-vite/template/components/button.css b/code/frameworks/angular-vite/template/components/button.css new file mode 100644 index 000000000000..4e3620b0dcbf --- /dev/null +++ b/code/frameworks/angular-vite/template/components/button.css @@ -0,0 +1,30 @@ +.storybook-button { + display: inline-block; + cursor: pointer; + border: 0; + border-radius: 3em; + font-weight: 700; + line-height: 1; + font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; +} +.storybook-button--primary { + background-color: #555ab9; + color: white; +} +.storybook-button--secondary { + box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; + background-color: transparent; + color: #333; +} +.storybook-button--small { + padding: 10px 16px; + font-size: 12px; +} +.storybook-button--medium { + padding: 11px 20px; + font-size: 14px; +} +.storybook-button--large { + padding: 12px 24px; + font-size: 16px; +} diff --git a/code/frameworks/angular-vite/template/components/form.component.ts b/code/frameworks/angular-vite/template/components/form.component.ts new file mode 100644 index 000000000000..0d7316454a51 --- /dev/null +++ b/code/frameworks/angular-vite/template/components/form.component.ts @@ -0,0 +1,39 @@ +import { Component, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +@Component({ + standalone: true, + imports: [FormsModule], + selector: 'storybook-form', + template: ` +
+ + + @if (complete()) { +

Completed!!

+ } +
+ `, +}) +export default class FormComponent { + /** Optional success handler */ + onSuccess = output(); + + value = ''; + + complete = signal(false); + + handleSubmit(event: SubmitEvent) { + event.preventDefault(); + this.onSuccess.emit(this.value); + setTimeout(() => { + this.complete.set(true); + }, 500); + setTimeout(() => { + this.complete.set(false); + }, 1500); + } +} diff --git a/code/frameworks/angular-vite/template/components/html.component.ts b/code/frameworks/angular-vite/template/components/html.component.ts new file mode 100644 index 000000000000..bcb98a198a9d --- /dev/null +++ b/code/frameworks/angular-vite/template/components/html.component.ts @@ -0,0 +1,25 @@ +import { Component, Input } from '@angular/core'; +// DomSanitizer must be a regular import, not a type-only import, because it's used in dependency injection. +// Type-only imports are stripped during compilation, causing runtime errors like "DomSanitizer is not defined". +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { DomSanitizer } from '@angular/platform-browser'; +@Component({ + standalone: false, + selector: 'storybook-html', + template: `
`, +}) +export default class HtmlComponent { + /** + * The HTML to render + * + * @required + */ + @Input() + content = ''; + + constructor(private sanitizer: DomSanitizer) {} + + get safeContent() { + return this.sanitizer.bypassSecurityTrustHtml(this.content); + } +} diff --git a/code/frameworks/angular-vite/template/components/index.js b/code/frameworks/angular-vite/template/components/index.js new file mode 100644 index 000000000000..bb4f150af3b9 --- /dev/null +++ b/code/frameworks/angular-vite/template/components/index.js @@ -0,0 +1,6 @@ +import Button from './button.component'; +import Form from './form.component'; +import Html from './html.component'; +import Pre from './pre.component'; + +globalThis.__TEMPLATE_COMPONENTS__ = { Button, Html, Pre, Form }; diff --git a/code/frameworks/angular-vite/template/components/pre.component.ts b/code/frameworks/angular-vite/template/components/pre.component.ts new file mode 100644 index 000000000000..b71346e23825 --- /dev/null +++ b/code/frameworks/angular-vite/template/components/pre.component.ts @@ -0,0 +1,24 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'storybook-pre', + template: `
{{ finalText }}
`, +}) +export default class PreComponent { + /** Styles to apply to the component */ + @Input() + style?: object; + + /** An object to render */ + @Input() + object?: object; + + /** The code to render */ + @Input() + text?: string; + + get finalText() { + return this.object ? JSON.stringify(this.object, null, 2) : this.text; + } +} diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.html b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.html new file mode 100644 index 000000000000..7af61d6f344d --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.html @@ -0,0 +1,7 @@ + diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.scss b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.scss new file mode 100644 index 000000000000..52c3e2bf0e20 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.scss @@ -0,0 +1,3 @@ +.btn-primary { + background-color: #ff9899; +} diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.ts new file mode 100644 index 000000000000..2e7c0fe0aaee --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.component.ts @@ -0,0 +1,234 @@ +import type { ElementRef } from '@angular/core'; +import { + Component, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, + ViewChild, +} from '@angular/core'; + +export const exportedConstant = 'An exported constant'; + +export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export interface ISomeInterface { + one: string; + two: boolean; + three: any[]; +} + +export enum ButtonAccent { + 'Normal' = 'Normal', + 'High' = 'High', +} + +/** + * This is a simple button that demonstrates various JSDoc handling in Storybook Docs for Angular. + * + * It supports [markdown](https://en.wikipedia.org/wiki/Markdown), so you can embed formatted text, + * like **bold**, _italic_, and `inline code`.> How you like dem apples?! It's never been easier to + * document all your components. + * + * @string Hello world + * @link [Example](http://example.com) + * @code `ThingThing` + * @html aaa + */ +@Component({ + standalone: false, + selector: 'my-button', + templateUrl: './doc-button.component.html', + styleUrls: ['./doc-button.component.scss'], +}) +export class DocButtonComponent { + @ViewChild('buttonRef', { static: false }) buttonRef!: ElementRef; + + /** Test default value. */ + @Input() + public theDefaultValue = 'Default value in component'; + + /** + * Setting default value here because compodoc won't get the default value for accessors + * + * @default Another default value + */ + @Input() + get anotherDefaultValue() { + return this._anotherDefaultValue; + } + + set anotherDefaultValue(v: string) { + this._anotherDefaultValue = v; + } + + _anotherDefaultValue = 'Another default value'; + + /** Test null default value. */ + @Input() + public aNullValue: string | null = null; + + /** Test null default value. */ + @Input() + public anUndefinedValue: undefined; + + /** Test numeric default value. */ + @Input() + public aNumericValue = 123; + + /** Appearance style of the button. */ + @Input() + public appearance: 'primary' | 'secondary' = 'secondary'; + + /** Sets the button to a disabled state. */ + @Input() + public isDisabled = false; + + /** Specify the accent-type of the button */ + @Input() + public accent: ButtonAccent = ButtonAccent.Normal; + + /** + * Specifies some arbitrary object. This comment is to test certain chars like apostrophes - it's + * working + */ + @Input() public someDataObject!: ISomeInterface; + + /** + * The inner text of the button. + * + * @required + */ + @Input() + public label!: string; + + /** Size of the button. */ + @Input() + public size?: ButtonSize = 'medium'; + + /** + * Some input you shouldn't use. + * + * @deprecated + */ + @Input() + public somethingYouShouldNotUse = false; + + /** + * Handler to be called when the button is clicked by a user. + * + * Will also block the emission of the event if `isDisabled` is true. + */ + @Output() + public onClick = new EventEmitter(); + + /** + * This is an internal method that we don't want to document and have added the `ignore` + * annotation to. + * + * @ignore + */ + public handleClick(event: Event) { + event.stopPropagation(); + + if (!this.isDisabled) { + this.onClick.emit(event); + } + } + + private _inputValue = 'some value'; + + /** Setter for `inputValue` that is also an `@Input`. */ + @Input() + public set inputValue(value: string) { + this._inputValue = value; + } + + /** Getter for `inputValue`. */ + public get inputValue() { + return this._inputValue; + } + + @HostListener('click', ['$event']) + onClickListener(event: Event) { + console.log('button', event.target); + this.handleClick(event); + } + + @HostBinding('class.focused') focus = false; + + /** + * Returns all the CSS classes for the button. + * + * @ignore + */ + public get classes(): string[] { + return [this.appearance, this.size] + .filter((_class) => !!_class) + .map((_class) => `btn-${_class}`); + } + + /** @ignore */ + public ignoredProperty = 'Ignore me'; + + /** Public value. */ + public internalProperty = 'Public hello'; + + /** Private value. */ + private _value = 'Private hello'; + + /** Set the private value. */ + public set value(value: string | number) { + this._value = `${value}`; + } + + /** Get the private value. */ + public get value(): string | number { + return this._value; + } + + /** + * An internal calculation method which adds `x` and `y` together. + * + * @param x Some number you'd like to use. + * @param y Some other number or string you'd like to use, will have `parseInt()` applied before + * calculation. + */ + public calc(x: number, y: string | number): number { + return x + parseInt(`${y}`, 10); + } + + /** A public method using an interface. */ + public publicMethod(things: ISomeInterface) { + console.log(things); + } + + /** + * A protected method. + * + * @param id Some `id`. + */ + protected protectedMethod(id?: number) { + console.log(id); + } + + /** + * A private method. + * + * @param password Some `password`. + */ + private privateMethod(password: string) { + console.log(password); + } + + @Input('showKeyAlias') + public showKey!: keyof T; + + @Input() + public set item(item: T[]) { + this.processedItem = item; + } + + public processedItem!: T[]; +} diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.stories.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.stories.ts new file mode 100644 index 000000000000..964ee32a734b --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-button/doc-button.stories.ts @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { argsToTemplate } from '@storybook/angular'; + +import { DocButtonComponent } from './doc-button.component'; + +const meta: Meta> = { + component: DocButtonComponent, +}; + +export default meta; + +type Story = StoryObj>; + +export const Basic: Story = { + args: { label: 'Args test', isDisabled: false }, + argTypes: { + theDefaultValue: { + table: { + defaultValue: { summary: 'Basic default value' }, + }, + }, + }, +}; + +export const WithTemplate: Story = { + args: { label: 'Template test', appearance: 'primary' }, + render: (args) => ({ + props: args, + template: ``, + }), +}; diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.directive.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.directive.ts new file mode 100644 index 000000000000..cfc7e7f9e5fa --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.directive.ts @@ -0,0 +1,22 @@ +// ElementRef must be a regular import, not a type-only import, because it's used in dependency injection. +// Type-only imports are stripped during compilation, causing runtime errors like "ElementRef is not defined". +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { ElementRef, AfterViewInit, Directive, Input } from '@angular/core'; + +/** This is an Angular Directive example that has a Prop Table. */ +@Directive({ + standalone: false, + selector: '[docDirective]', +}) +export class DocDirective implements AfterViewInit { + constructor(private ref: ElementRef) {} + + /** Will apply gray background color if set to true. */ + @Input() hasGrayBackground = false; + + ngAfterViewInit(): void { + if (this.hasGrayBackground) { + this.ref.nativeElement.style = 'background-color: lightgray'; + } + } +} diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.stories.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.stories.ts new file mode 100644 index 000000000000..efc0bad9de2e --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-directive/doc-directive.stories.ts @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { DocDirective } from './doc-directive.directive'; + +const meta: Meta = { + component: DocDirective, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: () => ({ + moduleMetadata: { + declarations: [DocDirective], + }, + template: '

DocDirective

', + }), +}; diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.service.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.service.ts new file mode 100644 index 000000000000..5fe4fa2d2478 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { HttpHeaders } from '@angular/common/http'; + +/** This is an Angular Injectable example that has a Prop Table. */ +@Injectable({ + providedIn: 'root', +}) +export class DocInjectableService { + /** Auth headers to use. */ + auth: any; + + constructor() { + this.auth = new HttpHeaders({ 'Content-Type': 'application/json' }); + } + + /** Get posts from Backend. */ + getPosts(): unknown[] { + return []; + } +} diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.stories.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.stories.ts new file mode 100644 index 000000000000..d98e803c885a --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-injectable/doc-injectable.stories.ts @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { DocInjectableService } from './doc-injectable.service'; + +const meta: Meta = { + component: DocInjectableService, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: () => ({ + moduleMetadata: { + providers: [DocInjectableService], + }, + template: '

DocInjectable

', + }), +}; diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.pipe.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.pipe.ts new file mode 100644 index 000000000000..e1af0d8eeb29 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.pipe.ts @@ -0,0 +1,18 @@ +import type { PipeTransform } from '@angular/core'; +import { Pipe } from '@angular/core'; + +/** This is an Angular Pipe example that has a Prop Table. */ +@Pipe({ + standalone: false, + name: 'docPipe', +}) +export class DocPipe implements PipeTransform { + /** + * Transforms a string into uppercase. + * + * @param value String + */ + transform(value: string): string { + return value?.toUpperCase(); + } +} diff --git a/code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.stories.ts b/code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.stories.ts new file mode 100644 index 000000000000..ec57d46b7850 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/argTypes/doc-pipe/doc-pipe.stories.ts @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { DocPipe } from './doc-pipe.pipe'; + +const meta: Meta = { + component: DocPipe, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + render: () => ({ + moduleMetadata: { + declarations: [DocPipe], + }, + template: `

{{ 'DocPipe' | docPipe }}

`, + }), +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/README.mdx b/code/frameworks/angular-vite/template/stories/basics/README.mdx new file mode 100644 index 000000000000..f2f64c9634f6 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/README.mdx @@ -0,0 +1,7 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Examples for Angular features + +These examples serve to highlight the right Storybook operation for basics Angular features diff --git a/code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva-component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva-component.stories.ts new file mode 100644 index 000000000000..ffcf562390ea --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva-component.stories.ts @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { StoryFn, moduleMetadata } from '@storybook/angular'; + +import { FormsModule } from '@angular/forms'; + +import { CustomCvaComponent } from './custom-cva.component'; + +const meta: Meta = { + // title: 'Basics / Angular forms / ControlValueAccessor', + component: CustomCvaComponent, + decorators: [ + moduleMetadata({ + imports: [FormsModule], + }), + (storyFn) => { + const story = storyFn(); + console.log(story); + return story; + }, + ], +} as Meta; + +export default meta; + +type Story = StoryObj; + +export const SimpleInput: Story = { + name: 'Simple input', + render: () => ({ + props: { + ngModel: 'Type anything', + ngModelChange: () => {}, + }, + }), +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva.component.ts b/code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva.component.ts new file mode 100644 index 000000000000..758800852406 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/angular-forms/customControlValueAccessor/custom-cva.component.ts @@ -0,0 +1,57 @@ +import { Component, forwardRef } from '@angular/core'; +import type { ControlValueAccessor } from '@angular/forms'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; + +const NOOP = () => {}; + +@Component({ + standalone: false, + selector: 'storybook-custom-cva-component', + template: `
{{ value }}
+ `, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CustomCvaComponent), + multi: true, + }, + ], +}) +export class CustomCvaComponent implements ControlValueAccessor { + disabled?: boolean; + + protected onChange: (value: any) => void = NOOP; + + protected onTouch: () => void = NOOP; + + protected internalValue: any; + + get value(): any { + return this.internalValue; + } + + set value(value: any) { + if (value !== this.internalValue) { + this.internalValue = value; + this.onChange(value); + } + } + + writeValue(value: any): void { + if (value !== this.internalValue) { + this.internalValue = value; + } + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouch = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selector.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selector.component.ts new file mode 100644 index 000000000000..78fd72adcecb --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selector.component.ts @@ -0,0 +1,26 @@ +// ElementRef must be a regular import, not a type-only import, because it's used in dependency injection. +// Type-only imports are stripped during compilation, causing runtime errors like "ElementRef is not defined". +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { ComponentFactoryResolver, ElementRef, Component } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'storybook-attribute-selector[foo=bar]', + template: `

Attribute selector

+Selector: {{ selectors }}
+Generated template: {{ generatedTemplate }}`, +}) +export class AttributeSelectorComponent { + generatedTemplate!: string; + + selectors!: string; + + constructor( + public el: ElementRef, + private resolver: ComponentFactoryResolver + ) { + const factory = this.resolver.resolveComponentFactory(AttributeSelectorComponent); + this.selectors = factory.selector; + this.generatedTemplate = el.nativeElement.outerHTML; + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selectors.component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selectors.component.stories.ts new file mode 100644 index 000000000000..2d9e13dd4d78 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/attribute-selectors.component.stories.ts @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { AttributeSelectorComponent } from './attribute-selector.component'; + +const meta: Meta = { + // title: 'Basics / Component / With Complex Selectors', + component: AttributeSelectorComponent, +}; + +export default meta; + +type Story = StoryObj; + +export const AttributeSelectors: Story = {}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.stories.ts new file mode 100644 index 000000000000..c8da5e41e2c4 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.stories.ts @@ -0,0 +1,8 @@ +import { ClassSelectorComponent } from './class-selector.component'; + +export default { + // title: 'Basics / Component / With Complex Selectors', + component: ClassSelectorComponent, +}; + +export const ClassSelectors = {}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.ts new file mode 100644 index 000000000000..d6cbf90d2f4a --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/class-selector.component.ts @@ -0,0 +1,26 @@ +// ElementRef must be a regular import, not a type-only import, because it's used in dependency injection. +// Type-only imports are stripped during compilation, causing runtime errors like "ElementRef is not defined". +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { ComponentFactoryResolver, ElementRef, Component } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'storybook-class-selector.foo, storybook-class-selector.bar', + template: `

Class selector

+Selector: {{ selectors }}
+Generated template: {{ generatedTemplate }}`, +}) +export class ClassSelectorComponent { + generatedTemplate!: string; + + selectors!: string; + + constructor( + public el: ElementRef, + private resolver: ComponentFactoryResolver + ) { + const factory = this.resolver.resolveComponentFactory(ClassSelectorComponent); + this.selectors = factory.selector; + this.generatedTemplate = el.nativeElement.outerHTML; + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-class-selector.component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-class-selector.component.stories.ts new file mode 100644 index 000000000000..0ed46ecfdbcd --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-class-selector.component.stories.ts @@ -0,0 +1,8 @@ +import { MultipleClassSelectorComponent } from './multiple-selector.component'; + +export default { + // title: 'Basics / Component / With Complex Selectors', + component: MultipleClassSelectorComponent, +}; + +export const MultipleClassSelectors = {}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.stories.ts new file mode 100644 index 000000000000..3dac394c440a --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.stories.ts @@ -0,0 +1,8 @@ +import { MultipleSelectorComponent } from './multiple-selector.component'; + +export default { + // title: 'Basics / Component / With Complex Selectors', + component: MultipleSelectorComponent, +}; + +export const MultipleSelectors = {}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.ts new file mode 100644 index 000000000000..14a70bc230de --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-complex-selectors/multiple-selector.component.ts @@ -0,0 +1,48 @@ +// ElementRef must be a regular import, not a type-only import, because it's used in dependency injection. +// Type-only imports are stripped during compilation, causing runtime errors like "ElementRef is not defined". +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { ComponentFactoryResolver, ElementRef, Component } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'storybook-multiple-selector, storybook-multiple-selector2', + template: `

Multiple selector

+Selector: {{ selectors }}
+Generated template: {{ generatedTemplate }}`, +}) +export class MultipleSelectorComponent { + generatedTemplate!: string; + + selectors!: string; + + constructor( + public el: ElementRef, + private resolver: ComponentFactoryResolver + ) { + const factory = this.resolver.resolveComponentFactory(MultipleClassSelectorComponent); + this.selectors = factory.selector; + this.generatedTemplate = el.nativeElement.outerHTML; + } +} + +@Component({ + standalone: false, + selector: 'storybook-button, button[foo], .button[foo], button[baz]', + template: `

Multiple selector

+Selector: {{ selectors }}
+Generated template: {{ generatedTemplate }}`, +}) +export class MultipleClassSelectorComponent { + generatedTemplate!: string; + + selectors!: string; + + constructor( + public el: ElementRef, + private resolver: ComponentFactoryResolver + ) { + const factory = this.resolver.resolveComponentFactory(MultipleClassSelectorComponent); + this.selectors = factory.selector; + this.generatedTemplate = el.nativeElement.outerHTML; + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.html b/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.html new file mode 100644 index 000000000000..08584b9824f4 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.html @@ -0,0 +1,8 @@ +
+
unionType: {{ unionType }}
+
aliasedUnionType: {{ aliasedUnionType }}
+
enumNumeric: {{ enumNumeric }}
+
enumNumericInitial: {{ enumNumericInitial }}
+
enumStrings: {{ enumStrings }}
+
enumAlias: {{ enumAlias }}
+
diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.stories.ts new file mode 100644 index 000000000000..9e7317f3ffce --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.stories.ts @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { + EnumNumeric, + EnumNumericInitial, + EnumStringValues, + EnumsComponent, +} from './enums.component'; + +const meta: Meta = { + // title: 'Basics / Component / With Enum Types', + component: EnumsComponent, +}; + +export default meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + unionType: 'Union A', + aliasedUnionType: 'Type Alias 1', + enumNumeric: EnumNumeric.FIRST, + enumNumericInitial: EnumNumericInitial.UNO, + enumStrings: EnumStringValues.PRIMARY, + enumAlias: EnumNumeric.FIRST, + }, +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.ts new file mode 100644 index 000000000000..171e4ff51d0e --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-enums/enums.component.ts @@ -0,0 +1,50 @@ +import { Component, Input } from '@angular/core'; + +/** This component is used for testing the various forms of enum types */ +@Component({ + standalone: false, + selector: 'app-enums', + templateUrl: './enums.component.html', +}) +export class EnumsComponent { + /** Union Type of string literals */ + @Input() unionType?: 'Union A' | 'Union B' | 'Union C'; + + /** Union Type assigned as a Type Alias */ + @Input() aliasedUnionType?: TypeAlias; + + /** Base Enum Type with no assigned values */ + @Input() enumNumeric?: EnumNumeric; + + /** Enum with initial numeric value and auto-incrementing subsequent values */ + @Input() enumNumericInitial?: EnumNumericInitial; + + /** Enum with string values */ + @Input() enumStrings?: EnumStringValues; + + /** Type Aliased Enum Type */ + @Input() enumAlias?: EnumAlias; +} + +/** Button Priority */ +export enum EnumNumeric { + FIRST, + SECOND, + THIRD, +} + +export enum EnumNumericInitial { + UNO = 1, + DOS, + TRES, +} + +export enum EnumStringValues { + PRIMARY = 'PRIMARY', + SECONDARY = 'SECONDARY', + TERTIARY = 'TERTIARY', +} + +export type EnumAlias = EnumNumeric; + +type TypeAlias = 'Type Alias 1' | 'Type Alias 2' | 'Type Alias 3'; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.component.ts new file mode 100644 index 000000000000..b1d8111cef2a --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.component.ts @@ -0,0 +1,11 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + standalone: false, + selector: `storybook-base-button`, + template: ` `, +}) +export class BaseButtonComponent { + @Input() + label?: string; +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.stories.ts new file mode 100644 index 000000000000..e01e88953876 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/base-button.stories.ts @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { BaseButtonComponent } from './base-button.component'; + +const meta: Meta = { + // title: 'Basics / Component / With Inheritance', + component: BaseButtonComponent, +}; + +export default meta; + +export const BaseButton: StoryObj = { + args: { + label: 'this is label', + }, +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.component.ts new file mode 100644 index 000000000000..4dfc63e09c8a --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.component.ts @@ -0,0 +1,12 @@ +import { Component, Input } from '@angular/core'; +import { BaseButtonComponent } from './base-button.component'; + +@Component({ + standalone: false, + selector: `storybook-icon-button`, + template: ` `, +}) +export class IconButtonComponent extends BaseButtonComponent { + @Input() + icon?: string; +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.stories.ts new file mode 100644 index 000000000000..2af080a822de --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-inheritance/icon-button.stories.ts @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { IconButtonComponent } from './icon-button.component'; + +const meta: Meta = { + // title: 'Basics / Component / With Inheritance', + component: IconButtonComponent, +}; + +export default meta; + +type Story = StoryObj; + +export const IconButton: Story = { + args: { + icon: 'this is icon', + label: 'this is label', + }, +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-about-parent.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-about-parent.stories.ts new file mode 100644 index 000000000000..6bb6b33ae42c --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-about-parent.stories.ts @@ -0,0 +1,60 @@ +import { Component, Input } from '@angular/core'; +import type { Meta, StoryObj } from '@storybook/angular'; +import { componentWrapperDecorator } from '@storybook/angular'; + +@Component({ + standalone: false, + selector: 'sb-button', + template: ``, + styles: [ + ` + button { + padding: 4px; + } + `, + ], +}) +class SbButtonComponent { + @Input() + color = '#5eadf5'; +} + +const meta: Meta = { + // title: 'Basics / Component / With ng-content / Button with different contents', + // Implicitly declares the component to Angular + // This will be the component described by the addon docs + component: SbButtonComponent, + decorators: [ + // Wrap all stories with this template + componentWrapperDecorator( + (story) => `${story}`, + + ({ args }) => ({ propsColor: args['color'] }) + ), + ], + argTypes: { + color: { control: 'color' }, + }, +} as Meta; + +export default meta; + +type Story = StoryObj; + +// By default storybook uses the default export component if no template or component is defined in the story +// So Storybook nests the component twice because it is first added by the componentWrapperDecorator. +export const AlwaysDefineTemplateOrComponent: Story = {}; + +export const EmptyButton: Story = { + render: () => ({ + template: '', + }), +}; + +export const InH1: Story = { + render: () => ({ + template: 'My button in h1', + }), + decorators: [componentWrapperDecorator((story) => `

${story}

`)], + name: 'In

', +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-simple.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-simple.stories.ts new file mode 100644 index 000000000000..5cbdf30403b8 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-ng-content/ng-content-simple.stories.ts @@ -0,0 +1,38 @@ +import { Component } from '@angular/core'; + +import type { Meta, StoryObj } from '@storybook/angular'; + +@Component({ + standalone: false, + selector: 'storybook-with-ng-content', + template: `Content value: +
`, +}) +class WithNgContentComponent {} + +const meta: Meta = { + // title: 'Basics / Component / With ng-content / Simple', + component: WithNgContentComponent, +} as Meta; + +export default meta; + +type Story = StoryObj; + +export const OnlyComponent: Story = {}; + +export const Default: Story = { + render: () => ({ + template: `

This is rendered in ng-content

`, + }), +}; + +export const WithDynamicContentAndArgs: Story = { + render: (args) => ({ + template: `

${args['content']}

`, + }), + args: { content: 'Default content' }, + argTypes: { + content: { control: 'text' }, + }, +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-ng-on-destroy/component-with-on-destroy.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-ng-on-destroy/component-with-on-destroy.stories.ts new file mode 100644 index 000000000000..a678962eaa0c --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-ng-on-destroy/component-with-on-destroy.stories.ts @@ -0,0 +1,45 @@ +import type { OnDestroy, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; +import type { Meta, StoryObj } from '@storybook/angular'; + +@Component({ + standalone: false, + selector: 'on-destroy', + template: `Current time: {{ time }}
+📝 The current time in console should no longer display after a change of story`, +}) +class OnDestroyComponent implements OnInit, OnDestroy { + time?: string; + + interval: any; + + ngOnInit(): void { + const myTimer = () => { + const d = new Date(); + this.time = d.toLocaleTimeString(); + console.info(`Current time: ${this.time}`); + }; + + myTimer(); + this.interval = setInterval(myTimer, 3000); + } + + ngOnDestroy(): void { + clearInterval(this.interval); + } +} + +const meta: Meta = { + // title: 'Basics / Component / with ngOnDestroy', + component: OnDestroyComponent, + parameters: { + // disabled due to new Date() + chromatic: { disableSnapshot: true }, + }, +} as Meta; + +export default meta; + +type Story = StoryObj; + +export const SimpleComponent: Story = {}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push-box.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push-box.component.ts new file mode 100644 index 000000000000..9d8a86aa67fb --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push-box.component.ts @@ -0,0 +1,22 @@ +import { Component, Input, ChangeDetectionStrategy, HostBinding } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'storybook-on-push-box', + template: ` Word of the day: {{ word }} `, + styles: [ + ` + :host { + display: block; + padding: 1rem; + width: fit-content; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OnPushBoxComponent { + @Input() word?: string; + + @Input() @HostBinding('style.background-color') bgColor?: string; +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push.stories.ts new file mode 100644 index 000000000000..004dfec166c6 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-on-push/on-push.stories.ts @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { OnPushBoxComponent } from './on-push-box.component'; + +const meta: Meta = { + // title: 'Basics / Component / With OnPush strategy', + component: OnPushBoxComponent, + argTypes: { + word: { control: 'text' }, + bgColor: { control: 'color' }, + }, + args: { + word: 'The text', + bgColor: '#FFF000', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const ClassSpecifiedComponentWithOnPushAndArgs: Story = { + name: 'Class-specified component with OnPush and Args', +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom-pipes.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom-pipes.stories.ts new file mode 100644 index 000000000000..f6b4ff6e8121 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom-pipes.stories.ts @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { moduleMetadata } from '@storybook/angular'; + +import { CustomPipePipe } from './custom.pipe'; +import { WithPipeComponent } from './with-pipe.component'; + +const meta: Meta = { + // title: 'Basics / Component / With Pipes', + component: WithPipeComponent, + decorators: [ + moduleMetadata({ + declarations: [CustomPipePipe], + }), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Simple: Story = { + render: () => ({ + props: { + field: 'foobar', + }, + }), +}; + +export const WithArgsStory: Story = { + name: 'With args', + argTypes: { + field: { control: 'text' }, + }, + args: { + field: 'Foo Bar', + }, +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom.pipe.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom.pipe.ts new file mode 100644 index 000000000000..d8865dc3af2e --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/custom.pipe.ts @@ -0,0 +1,12 @@ +import type { PipeTransform } from '@angular/core'; +import { Pipe } from '@angular/core'; + +@Pipe({ + standalone: false, + name: 'customPipe', +}) +export class CustomPipePipe implements PipeTransform { + transform(value: any, args?: any): any { + return `CustomPipe: ${value}`; + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/with-pipe.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/with-pipe.component.ts new file mode 100644 index 000000000000..12ec82171649 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-pipe/with-pipe.component.ts @@ -0,0 +1,11 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'storybook-with-pipe', + template: `

{{ field | customPipe }}

`, +}) +export class WithPipeComponent { + @Input() + field: any; +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.html b/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.html new file mode 100644 index 000000000000..36768a998934 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.html @@ -0,0 +1,7 @@ +
+
All dependencies are defined: {{ isAllDeps() }}
+
Title: {{ title }}
+
Injector: {{ injector.constructor.toString() }}
+
ElementRef: {{ elRefStr() }}
+
TestToken: {{ testToken }}
+
diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.stories.ts new file mode 100644 index 000000000000..2226932cf759 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.stories.ts @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { DiComponent } from './di.component'; + +const meta: Meta = { + // title: 'Basics / Component / With Provider', + component: DiComponent, +}; + +export default meta; + +type Story = StoryObj; + +export const InputsAndInjectDependencies: Story = { + render: () => ({ + props: { + title: 'Component dependencies', + }, + }), + name: 'inputs and inject dependencies', +}; + +export const InputsAndInjectDependenciesWithArgs: Story = { + name: 'inputs and inject dependencies with args', + argTypes: { + title: { control: 'text' }, + }, + args: { + title: 'Component dependencies', + }, +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.ts new file mode 100644 index 000000000000..a07ade4696ff --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-provider/di.component.ts @@ -0,0 +1,33 @@ +// ElementRef must be a regular import, not a type-only import, because it's used in dependency injection. +// Type-only imports are stripped during compilation, causing runtime errors like "ElementRef is not defined". +// Do not remove `Inject` even though it seems unused, it is used in the constructor. +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { Injector, ElementRef, Component, Input, InjectionToken, Inject } from '@angular/core'; +import { stringify } from 'telejson'; + +export const TEST_TOKEN = new InjectionToken('test'); + +@Component({ + standalone: false, + selector: 'storybook-di-component', + templateUrl: './di.component.html', + providers: [{ provide: TEST_TOKEN, useValue: 123 }], +}) +export class DiComponent { + @Input() + title?: string; + + constructor( + protected injector: Injector, + protected elRef: ElementRef, + @Inject(TEST_TOKEN) protected testToken: number + ) {} + + isAllDeps(): boolean { + return Boolean(this.testToken && this.elRef && this.injector && this.title); + } + + elRefStr(): string { + return stringify(this.elRef, { maxDepth: 1 }); + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.css b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.css new file mode 100644 index 000000000000..fdfe0940158f --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.css @@ -0,0 +1,3 @@ +.red-color { + color: red; +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.html b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.html new file mode 100644 index 000000000000..129e735ec5b0 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.html @@ -0,0 +1,5 @@ +
+

Styled with scoped CSS

+

Styled with scoped SCSS

+

Styled with global CSS

+
diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.scss b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.scss new file mode 100644 index 000000000000..5895f510a1da --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.scss @@ -0,0 +1,5 @@ +div { + p.blue-color { + color: blue; + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.stories.ts new file mode 100644 index 000000000000..424feafc049a --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.stories.ts @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { StyledComponent } from './styled.component'; + +const meta: Meta = { + // title: 'Basics / Component / With StyleUrls', + component: StyledComponent, +}; + +export default meta; + +type Story = StoryObj; + +export const ComponentWithStyles: Story = { + name: 'Component with styles', +}; diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.ts new file mode 100644 index 000000000000..63ac3c56b07e --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-style/styled.component.ts @@ -0,0 +1,9 @@ +import { Component } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'storybook-styled-component', + templateUrl: './styled.component.html', + styleUrls: ['./styled.component.css', './styled.component.scss'], +}) +export class StyledComponent {} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-template/template.component.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-template/template.component.ts new file mode 100644 index 000000000000..8951a4532793 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-template/template.component.ts @@ -0,0 +1,27 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'app-template', + imports: [CommonModule], + template: `
+ Label: {{ label }} +
+ Label2: {{ label2 }} +
+ +
`, + styles: [], + standalone: true, +}) +export class Template { + @Input() label = 'default label'; + + @Input() label2 = 'default label2'; + + @Output() changed = new EventEmitter(); + + inc() { + this.changed.emit('Increase'); + } +} diff --git a/code/frameworks/angular-vite/template/stories/basics/component-with-template/template.stories.ts b/code/frameworks/angular-vite/template/stories/basics/component-with-template/template.stories.ts new file mode 100644 index 000000000000..9b9dcf72a554 --- /dev/null +++ b/code/frameworks/angular-vite/template/stories/basics/component-with-template/template.stories.ts @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { argsToTemplate } from '@storybook/angular'; + +import { Template } from './template.component'; + +const meta: Meta