Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 120 additions & 5 deletions code/frameworks/angular-vite/src/docgen/story-docs-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { vol } from 'memfs';

import type { AngularClassMeta, AngularComponentMetaResult } from '@storybook/angular-cm';
import type { AngularComponentMetaSource } from './build-docgen.ts';
import type { BuildStoryDocsContext } from './story-docs-build.ts';
import { buildStoryDocsPayload } from './story-docs-build.ts';

vi.mock('node:fs', { spy: true });
Expand Down Expand Up @@ -157,16 +158,20 @@ const STORY_SHAPES_FILE = [
`Csf2AssignedArgs.args = { label: 'assigned', count: 11 };`,
].join('\n');

/** Story per name, for a file that declares more than one. */
const storiesOf = (storyFile: string) => {
const payloadOf = (storyFile: string, context: Partial<BuildStoryDocsContext> = {}) => {
givenStoryFile(storyFile);
const payload = buildStoryDocsPayload(
return buildStoryDocsPayload(
{ entry },
{ manager: managerReturning(metaFor(componentEntry())) }
{ manager: managerReturning(metaFor(componentEntry())), ...context }
);
return new Map(Object.values(payload?.stories ?? {}).map((story) => [story.name, story]));
};

/** Story per name, for a file that declares more than one. */
const storiesOf = (storyFile: string, context: Partial<BuildStoryDocsContext> = {}) =>
new Map(
Object.values(payloadOf(storyFile, context)?.stories ?? {}).map((story) => [story.name, story])
);

const snippetsOf = (storyFile: string) =>
new Map([...storiesOf(storyFile)].map(([name, story]) => [name, story.snippet]));

Expand Down Expand Up @@ -530,4 +535,114 @@ describe('buildStoryDocsPayload', () => {
expect(story.snippet).toBeUndefined();
expect(story.error).toBeUndefined();
});

it('carries no import block in the default template format', () => {
expect(payloadOf(DEFAULT_STORY_FILE)?.import).toBeUndefined();
});
});

describe('buildStoryDocsPayload in component format', () => {
const componentFormat = { snippetFormat: 'component' } as const;

it('wraps the generated bindings in a host component that declares their handlers', () => {
expect(storiesOf(DEFAULT_STORY_FILE, componentFormat).get('Default')?.snippet).toBe(
`@Component({
selector: 'app-root',
template: \`<sb-button [label]="'Save'" [count]="3" (clicked)="clicked($event)"></sb-button>\`,
imports: [ButtonComponent],
})
export class App {
clicked(event: unknown) {}
}`
);
});

it('carries the import block once for the whole component', () => {
expect(payloadOf(DEFAULT_STORY_FILE, componentFormat)?.import).toBe(
`import { Component } from '@angular/core';\nimport { ButtonComponent } from './button.component';`
);
});

it('declares no handlers for a story whose own markup binds no outputs', () => {
expect(storiesOf(STORY_SHAPES_FILE, componentFormat).get('Own Template')?.snippet).toBe(
`@Component({
selector: 'app-root',
template: \`<sb-button emphasis>hi</sb-button>\`,
imports: [ButtonComponent],
})
export class App {}`
);
});

// `argsToTemplate` expands into the same output bindings this generator emits, so the markup a
// story wrote around it does need handlers - and only for the outputs that survived the filter.
it('declares handlers only for the outputs the story kept', () => {
const stories = storiesOf(
[
`import { argsToTemplate } from '@storybook/angular-vite';`,
`import { ButtonComponent } from './button.component';`,
`export default { title: 'Example/Button', component: ButtonComponent };`,
`export const Kept = {`,
` args: { label: 'Save' },`,
' render: (args) => ({ props: args, template: `<sb-button ${argsToTemplate(args)}></sb-button>` }),',
`};`,
`export const Dropped = {`,
` args: { label: 'Save' },`,
" render: (args) => ({ props: args, template: `<sb-button ${argsToTemplate(args, { exclude: ['clicked'] })}></sb-button>` }),",
`};`,
].join('\n'),
componentFormat
);

expect(stories.get('Kept')?.snippet).toContain('clicked(event: unknown) {}');
expect(stories.get('Dropped')?.snippet).toContain('export class App {}');
});

it('exposes the class the outlet template reads as a value', () => {
givenStoryFile(DEFAULT_STORY_FILE);
const payload = buildStoryDocsPayload(
{ entry },
{
manager: managerReturning(metaFor(componentEntry({ selector: undefined }))),
...componentFormat,
}
);

expect(Object.values(payload!.stories)[0].snippet).toBe(
`@Component({
selector: 'app-root',
template: \`<ng-container *ngComponentOutlet="ButtonComponent"></ng-container>\`,
imports: [NgComponentOutlet],
})
export class App {
readonly ButtonComponent = ButtonComponent;
}`
);
expect(payload?.import).toContain(`import { NgComponentOutlet } from '@angular/common';`);
});

it('reports an unresolved arg on the story, not inside the component it emits', () => {
const story = storiesOf(STORY_SHAPES_FILE, componentFormat).get('Spread Args');

expect(story?.warning).toBe(
'Incomplete snippet: `...sharedArgs` could not be resolved statically.'
);
expect(story?.snippet).toContain('export class App {');
expect(story?.snippet).not.toContain('sharedArgs');
});

// A payload with nothing to wrap must not advertise an import block for markup it never emits.
it('emits neither a wrapper nor an import block when there is no component to host', () => {
givenStoryFile(`
export default { title: 'Example/Button' };
/** Documented without a component. */
export const Default = {};
`);
const payload = buildStoryDocsPayload({ entry }, { manager: undefined, ...componentFormat });
const stories = Object.values(payload!.stories);

expect(payload?.import).toBeUndefined();
expect(stories[0].snippet).toBeUndefined();
expect(stories[0].description).toBe('Documented without a component.');
});
});
49 changes: 48 additions & 1 deletion code/frameworks/angular-vite/src/docgen/story-docs-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
createMetaComponentResolver,
getComponentIdFromEntry,
getStoryImportPathFromEntry,
type ResolvedMetaComponent,
} from 'storybook/internal/common';
import { storyNameFromExport } from 'storybook/internal/csf';
import type { CsfFile } from 'storybook/internal/csf-tools';
Expand All @@ -15,6 +16,8 @@ import { resolve } from 'node:path';
import type { EnumType, Property } from '@storybook/angular-compodoc';
import type { AngularComponentMetaResult } from '@storybook/angular-cm';
import type { AngularComponentMetaSource } from './build-docgen.ts';
import type { AngularHostContext } from './story-docs-host.ts';
import { angularHostComponent, angularHostImports } from './story-docs-host.ts';
import {
type BindingFilter,
RawArgExpression,
Expand All @@ -23,16 +26,48 @@ import {
renderComponentSnippet,
type SnippetInputBinding,
} from './story-docs-snippet.ts';
import type { SnippetFormat } from '../types.ts';

const resolveMetaComponent = createMetaComponentResolver();

/** Preserves what the browser generator produces, so `component` stays an opt-in. */
export const DEFAULT_SNIPPET_FORMAT: SnippetFormat = 'template';

export interface BuildStoryDocsContext {
/** `undefined` when the analyzer could not be created; descriptions still extract without it. */
manager: AngularComponentMetaSource | undefined;
/** Same hook the docgen builder exposes, defaulting to the same resolution against cwd. */
resolvePath?: (importPath: string) => string;
/** Shape of the emitted snippet. Defaults to {@link DEFAULT_SNIPPET_FORMAT}. */
snippetFormat?: SnippetFormat;
}

/**
* How the host wrapper names and imports the component.
*
* The name is the class's own, which is already what the outlet form renders as a template
* expression; the import aliases the story file's export name back to it when the two differ.
*/
const hostContext = (
ref: ResolvedMetaComponent,
snippetContext: SnippetContext
): AngularHostContext => ({
componentName: snippetContext.componentName,
exportName: ref.exportName,
...(ref.importId === undefined ? {} : { importId: ref.importId }),
outlet: !snippetContext.selector,
});

/**
* Output names the markup binds, which the host has to declare methods for.
*
* Matched against the binding this generator emits rather than assumed to be every output: a story
* that wrote its own markup around `argsToTemplate(args, { exclude })` binds only some of them, and
* one that wrote plain markup binds none.
*/
const boundOutputs = (markup: string, outputs: readonly string[]): string[] =>
outputs.filter((name) => markup.includes(bindingAttributes({ inputs: [], outputs: [name] })[0]));

/**
* Builds a {@link StoryDocsPayload} for the stories in one CSF story file.
*
Expand Down Expand Up @@ -84,6 +119,13 @@ export const buildStoryDocsPayload = (
}
}
const snippetContext = meta ? createSnippetContext(meta) : undefined;
// Set only for the `component` format, and only when there is something to wrap: a payload that
// carries no snippets would otherwise advertise an import block for markup it never emits.
const host =
(context.snippetFormat ?? DEFAULT_SNIPPET_FORMAT) === 'component' && snippetContext && component
? hostContext(component, snippetContext)
: undefined;
const outputs = snippetContext?.outputs ?? [];

const displayName =
component && (component.exportName === 'default' ? component.localName : component.exportName);
Expand All @@ -105,11 +147,15 @@ export const buildStoryDocsPayload = (
const rendered = snippetContext
? renderStorySnippet(snippetContext, { csf, exportName, annotations, args, source })
: undefined;
const snippet =
rendered && host
? angularHostComponent(rendered.snippet, boundOutputs(rendered.snippet, outputs), host)
: rendered?.snippet;

stories[story.id] = {
id: story.id,
name,
...(rendered === undefined ? {} : { snippet: rendered.snippet }),
...(snippet === undefined ? {} : { snippet }),
...(rendered?.warning === undefined ? {} : { warning: rendered.warning }),
...(finalDescription ? { description: finalDescription } : {}),
...(summary === undefined ? {} : { summary }),
Expand All @@ -129,6 +175,7 @@ export const buildStoryDocsPayload = (
// The analyzer knows the class name even when the story file imported it as a default export.
name: meta?.entry.name ?? displayName ?? titleName,
path: storyImportPath,
...(host ? { import: angularHostImports(host) } : {}),
stories,
};
};
Expand Down
90 changes: 90 additions & 0 deletions code/frameworks/angular-vite/src/docgen/story-docs-host.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';

import type { AngularHostContext } from './story-docs-host.ts';
import { angularHostComponent, angularHostImports } from './story-docs-host.ts';

const context = (overrides: Partial<AngularHostContext> = {}): AngularHostContext => ({
componentName: 'ButtonComponent',
exportName: 'ButtonComponent',
importId: './button.component',
...overrides,
});

describe('angularHostImports', () => {
it('imports a named export the way the story file did', () => {
expect(angularHostImports(context())).toBe(
`import { Component } from '@angular/core';\nimport { ButtonComponent } from './button.component';`
);
});

it('imports a default export without braces', () => {
expect(angularHostImports(context({ exportName: 'default' }))).toBe(
`import { Component } from '@angular/core';\nimport ButtonComponent from './button.component';`
);
});

// The template names the class itself, so an export under another name has to be aliased back to
// it rather than left bound to a name the template never mentions.
it('aliases an export whose name is not the class name', () => {
expect(angularHostImports(context({ exportName: 'Button' }))).toContain(
`import { Button as ButtonComponent } from './button.component';`
);
});

it('emits no component import when the story file declares the component itself', () => {
expect(angularHostImports(context({ importId: undefined }))).toBe(
`import { Component } from '@angular/core';`
);
});

it('pulls in NgComponentOutlet for a component with no selector', () => {
expect(angularHostImports(context({ outlet: true }))).toContain(
`import { NgComponentOutlet } from '@angular/common';`
);
});
});

describe('angularHostComponent', () => {
it('declares a no-op method per bound output so the template type-checks', () => {
expect(angularHostComponent('<sb-button />', ['clicked', 'closed'], context())).toBe(
`@Component({
selector: 'app-root',
template: \`<sb-button />\`,
imports: [ButtonComponent],
})
export class App {
clicked(event: unknown) {}
closed(event: unknown) {}
}`
);
});

it('leaves the class empty when the template binds nothing', () => {
expect(angularHostComponent('<sb-button />', [], context())).toContain('export class App {}');
});

it('quotes an output name that is not a valid identifier', () => {
expect(angularHostComponent('<sb-button />', ['data-changed'], context())).toContain(
`['data-changed'](event: unknown) {}`
);
});

it('exposes the class the outlet template reads as a value', () => {
const snippet = angularHostComponent(
'<ng-container *ngComponentOutlet="ButtonComponent"></ng-container>',
[],
context({ outlet: true })
);

expect(snippet).toContain('imports: [NgComponentOutlet],');
expect(snippet).toContain('readonly ButtonComponent = ButtonComponent;');
});

it.each([
['a backtick', '<sb-button [label]="`hi`" />', '<sb-button [label]="\\`hi\\`" />'],
['an interpolation', '<sb-button>${x}</sb-button>', '<sb-button>\\${x}</sb-button>'],
['a backslash', '<sb-button [re]="/a\\d/" />', '<sb-button [re]="/a\\\\d/" />'],
])('escapes %s so it cannot break out of the host template literal', (_case, markup, escaped) => {
expect(angularHostComponent(markup, [], context())).toContain(`template: \`${escaped}\`,`);
});
});
Loading
Loading