Skip to content
Merged
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
29 changes: 29 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,35 @@

## From version 10.5.x to 10.6.0

### Angular Vite: a new `propsTable` framework option

`@storybook/angular-vite` now lets you choose which members the props table documents, through a `propsTable` framework option. It defaults to `'api'`, which leaves out TypeScript `private` properties and methods, ECMAScript private `#` members, and anything tagged `@internal`. No template can reach a `private` property or method, and `@internal` declares a member non-API, so a row for them documents your component's wiring rather than its API. Injected services such as `private readonly cdr = inject(ChangeDetectorRef)` are the common case.

Declared inputs and outputs are always documented, whatever their TypeScript visibility. Angular only honors access modifiers on input bindings behind the opt-in `strictInputAccessModifiers` compiler flag and never checks them on output bindings, so even a `private` input or output is API a parent template can bind.

`protected` members are documented. Angular templates have been able to bind them since Angular 14, so they are real API.

The default only changes what you see when `features.experimentalDocgenServer` is on. With the Compodoc pipeline, which is still the default, Storybook cannot interpret Compodoc's visibility data reliably and the props table is unchanged.

Set the option to `'all'` to document every member:

```ts
// .storybook/main.ts
framework: {
name: '@storybook/angular-vite',
options: {
// 'all' documents every member.
// 'api' (the default) leaves out private and `#` properties and methods, and @internal members.
// 'inputs' documents the inputs section only.
propsTable: 'all',
},
},
```

To drop a single member the default keeps, tag it `@ignore`.

`features.angularFilterNonInputControls` is deprecated on `@storybook/angular-vite` and will be removed in Storybook 11: `true` maps to `propsTable: 'inputs'` and `false` to `propsTable: 'all'`. Setting both leaves `propsTable` in charge. `@storybook/angular` (webpack) is unaffected and keeps reading the feature.

### MCP tool names follow toolset.method

Storybook's MCP tools are now named from their toolset and method (`stories.preview` → `stories-preview`). Update agent prompts, skills, and hard-coded tool allowlists:
Expand Down
7 changes: 6 additions & 1 deletion code/core/src/types/modules/core-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,12 @@ export interface StorybookFeatures {
* Set NODE_ENV to development in built Storybooks for better testability and debuggability
*/
developmentModeForBuild?: boolean;
/** Only show input controls in Angular */
/**
* Only show input controls in Angular.
*
* @deprecated On `@storybook/angular-vite`, use the `propsTable` framework option instead:
* `'inputs'` for this flag on, `'all'` for it off. Still read by `@storybook/angular`.
*/
angularFilterNonInputControls?: boolean;

/**
Expand Down
183 changes: 56 additions & 127 deletions code/frameworks/angular-vite/src/client/compodoc.test.ts
Original file line number Diff line number Diff line change
@@ -1,136 +1,65 @@
import { describe, expect, it } from 'vitest';
// How this framework's `propsTable` mode reaches the legacy Compodoc adapter. The module reads
// `STORYBOOK_ANGULAR_OPTIONS` at evaluation time, so every case re-imports it fresh.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { extractType, setCompodocJson } from './compodoc.ts';
import type { CompodocJson, Decorator } from './compodoc-types.ts';
import type { CompodocJson, Directive } from './compodoc-types.ts';

const makeProperty = (compodocType?: string) => ({
type: compodocType,
name: 'dummy',
decorators: [] as Decorator[],
optional: true,
});
const compodocJson: Partial<CompodocJson> = {
components: [],
directives: [],
pipes: [],
injectables: [],
classes: [],
miscellaneous: { typealiases: [], enumerations: [] } as never,
};

const componentData: Partial<Directive> = {
name: 'ProbeComponent',
type: 'component',
inputsClass: [{ name: 'label', type: 'string', optional: false }],
outputsClass: [],
propertiesClass: [{ name: 'note', type: 'string', optional: false }],
methodsClass: [],
};

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: '<p>Button Priority</p>\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;
const extractedNames = async () => {
const { extractArgTypesFromData } = await import('./compodoc.ts');
return Object.keys(extractArgTypesFromData(componentData as never));
};

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);
});
beforeEach(() => {
vi.resetModules();
vi.stubGlobal('FEATURES', { angularFilterNonInputControls: false });
vi.stubGlobal('__STORYBOOK_COMPODOC_JSON__', compodocJson);
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe('resolving propsTable for the Compodoc pipeline', () => {
it("maps 'inputs' onto the legacy inputs-only filter", async () => {
vi.stubGlobal('STORYBOOK_ANGULAR_OPTIONS', { zoneless: true, propsTable: 'inputs' });

await expect(extractedNames()).resolves.toEqual(['label']);
});

it("reads 'api' as all, because Compodoc's visibility is not interpretable here", async () => {
vi.stubGlobal('STORYBOOK_ANGULAR_OPTIONS', { zoneless: true, propsTable: 'api' });

await expect(extractedNames()).resolves.toEqual(['note', 'label']);
});

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);
});
it("overrides the deprecated feature whenever a mode is defined, 'all' included", async () => {
vi.stubGlobal('FEATURES', { angularFilterNonInputControls: true });
vi.stubGlobal('STORYBOOK_ANGULAR_OPTIONS', { zoneless: true, propsTable: 'all' });

await expect(extractedNames()).resolves.toEqual(['note', 'label']);
});

it('falls back to the deprecated feature when the define never ran', async () => {
vi.stubGlobal('FEATURES', { angularFilterNonInputControls: true });

await expect(extractedNames()).resolves.toEqual(['label']);
});
});
32 changes: 28 additions & 4 deletions code/frameworks/angular-vite/src/client/compodoc.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,37 @@
/**
* The Compodoc parsing and its browser adapter live in `@storybook/angular-compodoc`, shared with
* `@storybook/angular` and with this package's Node docgen worker. This module only keeps the
* historical `client/compodoc` import path alive.
* `@storybook/angular` and with this package's Node docgen worker. This module keeps the
* historical `client/compodoc` import path alive and resolves this framework's `propsTable`
* option for the shared adapter.
*/
import {
extractArgTypes as extractArgTypesShared,
extractArgTypesFromData as extractArgTypesFromDataShared,
} from '@storybook/angular-compodoc/browser';

/**
* Compodoc encodes member visibility only as raw TypeScript `SyntaxKind` numbers, which the frozen
* legacy pipeline deliberately does not interpret, so `api` is not answerable here and reads as
* `all`; the preset warns when a user asks for it. Left undefined when Vite's `define` never ran,
* which is how a portable-stories host imports this file, so the shared adapter falls back to the
* deprecated `angularFilterNonInputControls` feature.
*/
const filterNonInputControls =
typeof STORYBOOK_ANGULAR_OPTIONS === 'undefined' ||
STORYBOOK_ANGULAR_OPTIONS.propsTable === undefined
? undefined
: STORYBOOK_ANGULAR_OPTIONS.propsTable === 'inputs';

export const extractArgTypes = (component: Parameters<typeof extractArgTypesShared>[0]) =>
extractArgTypesShared(component, { filterNonInputControls });

export const extractArgTypesFromData = (
componentData: Parameters<typeof extractArgTypesFromDataShared>[0]
) => extractArgTypesFromDataShared(componentData, { filterNonInputControls });

export {
checkValidCompodocJson,
checkValidComponentOrDirective,
extractArgTypes,
extractArgTypesFromData,
extractComponentDescription,
extractType,
findComponentByName,
Expand Down
6 changes: 5 additions & 1 deletion code/frameworks/angular-vite/src/client/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ export { decorateStory as applyDecorators } from './decorateStory.ts';
import { enhanceArgTypes } from 'storybook/internal/docs-tools';
import type { ArgTypesEnhancer, Parameters } from 'storybook/internal/types';

import { global } from '@storybook/global';

import type { Component, Directive } from './compodoc-types.ts';
import { extractArgTypes, extractComponentDescription } from './compodoc.ts';

export const parameters: Parameters = {
renderer: 'angular',
docs: {
story: { inline: true },
extractArgTypes,
extractArgTypes: (component: Component | Directive) =>
global.FEATURES?.experimentalDocgenServer === true ? {} : extractArgTypes(component),
extractComponentDescription,
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type StoryRenderInfo = {
declare global {
const STORYBOOK_ANGULAR_OPTIONS: {
zoneless: boolean;
propsTable?: import('@storybook/angular-cm').PropsTableMode;
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ it('builds a real payload through the TypeScript-backed analyzer', async () => {
{ entry },
{
manager,
options: {},
options: { propsTable: 'api' },
logger: { warn: vi.fn(), debug: vi.fn() },
resolvePath: () => STORY_PATH,
}
Expand Down
30 changes: 14 additions & 16 deletions code/frameworks/angular-vite/src/docgen/build-docgen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ const managerReturning = (meta: AngularComponentMetaResult | undefined) => ({

const context = (
manager: AngularComponentMetaSource,
options: BuildDocgenContext['options'] = {}
options: BuildDocgenContext['options'] = { propsTable: 'all' }
): BuildDocgenContext => ({ manager, options, logger });

describe('buildDocgenPayload', () => {
Expand Down Expand Up @@ -227,25 +227,23 @@ describe('buildDocgenPayload', () => {
});
});

it('honours `angularFilterNonInputControls`', () => {
it('hands `propsTable` to the conversion', () => {
givenStoryFile();
const classMeta = componentEntry({
propertiesClass: [{ name: 'internal', type: 'string', optional: false }],
propertiesClass: [
{ name: 'note', type: 'string', optional: false },
{ name: 'cdr', type: 'ChangeDetectorRef', optional: false, visibility: 'private' },
],
});

expect(
Object.keys(
buildDocgenPayload({ entry }, context(managerReturning(metaFor(classMeta))))?.argTypes ?? {}
)
).toEqual(['internal', 'label']);
expect(
const argNames = (options: BuildDocgenContext['options']) =>
Object.keys(
buildDocgenPayload(
{ entry },
context(managerReturning(metaFor(classMeta)), { angularFilterNonInputControls: true })
)?.argTypes ?? {}
)
).toEqual(['label']);
buildDocgenPayload({ entry }, context(managerReturning(metaFor(classMeta)), options))
?.argTypes ?? {}
);

expect(argNames({ propsTable: 'all' })).toEqual(['note', 'cdr', 'label']);
expect(argNames({ propsTable: 'api' })).toEqual(['note', 'label']);
expect(argNames({ propsTable: 'inputs' })).toEqual(['label']);
});

describe('component resolution', () => {
Expand Down
Loading
Loading