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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
## 10.4.1

- Angular: Detect model() signal outputs (type inference + compodoc autodocs + runtime binding) - [#34833](https://github.com/storybookjs/storybook/pull/34833), thanks @valentinpalkovic!
- Build: Upgrade type-fest to latest version 5.6.0 - [#34791](https://github.com/storybookjs/storybook/pull/34791), thanks @tobiasdiez!
- CLI: Run `npx expo install --fix` after init for Expo projects - [#34803](https://github.com/storybookjs/storybook/pull/34803), thanks @ndelangen!
- CLI: Support `peerDependencies` in framework detection for component libraries - [#34516](https://github.com/storybookjs/storybook/pull/34516), thanks @zhyd1997!
- Next.js: Add useLinkStatus mock to next/link export mock - [#34593](https://github.com/storybookjs/storybook/pull/34593), thanks @philwolstenholme!
- Vue3: Specify a specific version for non-dev dependency - [#34794](https://github.com/storybookjs/storybook/pull/34794), thanks @ScopeyNZ!

## 10.4.0

> _AI-assisted setup, change-aware review, and stronger framework support_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,93 @@ describe('getComponentInputsOutputs', () => {
*/
});

describe('getComponentInputsOutputs (signal-based I/O)', () => {
// The unit harness leaves `ɵcmp` empty for signal members, so we attach a
// synthetic `ɵcmp` in the AOT shape and assert the production reader. Real
// end-to-end signal detection is covered by the `model-signal` sandbox stories.
// inputs: { [templateName]: [propName, flags] }
// outputs: { [templateName]: propName }
const withCmp = (inputs: Record<string, unknown>, outputs: Record<string, string>) => {
class FooComponent {}
(FooComponent as any).ɵcmp = { inputs, outputs };
return FooComponent;
};

it('detects @Input / @Output (decorator path, unchanged)', () => {
@Component({ template: '', standalone: false })
class FooComponent {
@Input() public input: string;

@Input('inputPropertyName') public inputWithBindingPropertyName: string;

@Output() public output = new EventEmitter<Event>();

@Output('outputPropertyName') public outputWithBindingPropertyName =
new EventEmitter<Event>();
}

const { inputs, outputs } = getComponentInputsOutputs(FooComponent);

expect(sortByPropName(inputs)).toEqual(
sortByPropName([
{ propName: 'input', templateName: 'input' },
{ propName: 'inputWithBindingPropertyName', templateName: 'inputPropertyName' },
])
);
expect(sortByPropName(outputs)).toEqual(
sortByPropName([
{ propName: 'output', templateName: 'output' },
{ propName: 'outputWithBindingPropertyName', templateName: 'outputPropertyName' },
])
);
});

it('detects EventEmitter @Output (decorator path, unchanged)', () => {
@Component({ template: '', standalone: true })
class FooComponent {
@Output() public emitter = new EventEmitter<string>();
}

const { outputs } = getComponentInputsOutputs(FooComponent);

expect(outputs).toContainEqual({ propName: 'emitter', templateName: 'emitter' });
});

it('detects input() / output() signal members from ɵcmp', () => {
const FooComponent = withCmp(
{ signalInput: ['signalInput', 1] },
{ signalOutput: 'signalOutput' }
);

const { inputs, outputs } = getComponentInputsOutputs(FooComponent);

expect(inputs).toContainEqual({ propName: 'signalInput', templateName: 'signalInput' });
expect(outputs).toContainEqual({ propName: 'signalOutput', templateName: 'signalOutput' });
});

it('detects model() as an input plus its synthesized `${name}Change` output', () => {
// `color = model()`, `reqd = model.required()`, `aliased = model(_, { alias: 'al' })`.
// The Angular compiler resolves the alias in `ɵcmp`, so the input is keyed by the
// binding name (`al`) and the synthesized output by `${alias}Change` (`alChange`).
const FooComponent = withCmp(
{ color: ['color', 1], reqd: ['reqd', 1], al: ['aliased', 1] },
{ colorChange: 'color', reqdChange: 'reqd', alChange: 'aliased' }
);

const { inputs, outputs } = getComponentInputsOutputs(FooComponent);

expect(inputs).toContainEqual({ propName: 'color', templateName: 'color' });
expect(outputs).toContainEqual({ propName: 'color', templateName: 'colorChange' });

expect(inputs).toContainEqual({ propName: 'reqd', templateName: 'reqd' });
expect(outputs).toContainEqual({ propName: 'reqd', templateName: 'reqdChange' });

// Aliased model(): the resolved binding name (`al`/`alChange`) flows through.
expect(inputs).toContainEqual({ propName: 'aliased', templateName: 'al' });
expect(outputs).toContainEqual({ propName: 'aliased', templateName: 'alChange' });
});
});

describe('isDeclarable', () => {
it('should return true with a Component', () => {
@Component({})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Output,
Pipe,
ɵReflectionCapabilities as ReflectionCapabilities,
ɵgetComponentDef as getComponentDef,
} from '@angular/core';

const reflectionCapabilities = new ReflectionCapabilities();
Expand Down Expand Up @@ -46,44 +47,102 @@ export const getComponentInputsOutputs = (component: any): ComponentInputsOutput
);
}

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],
};
const decoratorDerived: ComponentInputsOutputs = !componentPropsMetadata
? initialValue
: 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);

// Add signal-based I/O, which the decorator path above cannot see.
return addSignalInputsOutputs(component, decoratorDerived);
};

const hasEntry = (
list: { propName: string; templateName: string }[],
propName: string,
templateName: string
) => list.some((e) => e.propName === propName || e.templateName === templateName);

/**
* `model()`/`input()`/`output()` are decorator-less, so they never appear in
* `ɵReflectionCapabilities.propMetadata`. They are read instead from the compiled
* component definition (`ɵcmp`), which Storybook always receives from the Angular
* builder and which already encodes resolved binding names (aliased
* `model(x, { alias })` and `model.required()` included). Purely static — never
* instantiates the component. Results are additive and de-duplicated against
* `base`, so decorator-based I/O is unchanged.
*/
const addSignalInputsOutputs = (
component: any,
base: ComponentInputsOutputs
): ComponentInputsOutputs => {
const result: ComponentInputsOutputs = {
inputs: [...base.inputs],
outputs: [...base.outputs],
};

let def: any;
try {
def = getComponentDef(component);
} catch {
// `ɵgetComponentDef` may be unavailable for non-component classes.
return result;
}
if (!def) {
return result;
}

// `ɵcmp` keys the I/O maps by template (binding) name, not property name:
// def.inputs: { [templateName]: propName | [propName, flags, transform] }
// def.outputs: { [templateName]: propName }
for (const templateName of Object.keys(def.inputs ?? {})) {
const rawPropName = def.inputs[templateName];
const propName = Array.isArray(rawPropName)
? (rawPropName[0] ?? templateName)
: (rawPropName ?? templateName);
if (!hasEntry(result.inputs, propName, templateName)) {
result.inputs.push({ propName, templateName });
}
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],
};
}
for (const templateName of Object.keys(def.outputs ?? {})) {
const propName = def.outputs[templateName] ?? templateName;
if (!hasEntry(result.outputs, propName, templateName)) {
result.outputs.push({ propName, templateName });
}
return previousValue;
}, initialValue);
}

return result;
};

export const isDeclarable = (component: any): boolean => {
Expand Down
47 changes: 47 additions & 0 deletions code/frameworks/angular/src/client/compodoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,30 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec
| 'inputsClass'
| 'outputsClass';

// Detect Angular `model()` signals. compodoc emits no `model()` marker: a
// `model()` lands under the same bare name in BOTH `inputsClass` and
// `outputsClass`, whereas plain inputs/outputs land in only one. A name in
// both arrays is the only version-tolerant discriminator.
const inputClassNames = new Set<string>(
(((componentData as any).inputsClass as Property[]) || []).map((item) => item.name)
);
const modelProperties: Property[] = (
((componentData as any).outputsClass as Property[]) || []
).filter((item) => inputClassNames.has(item.name));
const modelPropertyNames = new Set<string>(modelProperties.map((item) => item.name));

compodocClasses.forEach((key: COMPODOC_CLASS) => {
const data = (componentData as any)[key] || [];
data.forEach((item: Method | Property) => {
const section = mapItemToSection(key, item);

// Suppress compodoc's spurious bare-name `outputsClass` duplicate of a
// `model()`. The model surfaces as an INPUT control (from `inputsClass`); its
// output is the synthesized `${name}Change` added below.
if (key === 'outputsClass' && !isMethod(item) && modelPropertyNames.has(item.name)) {
return;
}

const defaultValue = isMethod(item) ? undefined : extractDefaultValue(item as Property);

const type: SBType =
Expand Down Expand Up @@ -273,6 +293,33 @@ export const extractArgTypesFromData = (componentData: Class | Directive | Injec
});
});

// Synthesize the `${name}Change` output compodoc never emits. Runs after the
// loop so it is unaffected by `FEATURES.angularFilterNonInputControls`.
modelProperties.forEach((item) => {
const changeName = `${item.name}Change`;

// This is an OUTPUT, not the model INPUT it derives from: omit `defaultValue`
// and render the type as the emitted-payload handler signature.
const argType = {
name: changeName,
description: item.rawdescription || item.description,
type: { name: 'other', value: 'void' } as SBType,
action: changeName,
table: {
category: 'outputs',
type: {
summary: `(e: ${item.type}) => void`,
required: !item.optional,
},
},
};

if (!sectionToItems.outputs) {
sectionToItems.outputs = [];
}
sectionToItems.outputs.push(argType);
});

const SECTIONS = [
'properties',
'inputs',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"color": {
"description": "",
"name": "color",
"table": {
"category": "inputs",
"defaultValue": {
"summary": "#345F92",
},
"type": {
"required": true,
"summary": "string",
},
},
"type": {
"name": "string",
},
},
"colorChange": {
"action": "colorChange",
"description": "",
"name": "colorChange",
"table": {
"category": "outputs",
"type": {
"required": true,
"summary": "(e: string) => void",
},
},
"type": {
"name": "other",
"value": "void",
},
},
"showText": {
"description": "",
"name": "showText",
"table": {
"category": "inputs",
"defaultValue": {
"summary": false,
},
"type": {
"required": true,
"summary": "boolean",
},
},
"type": {
"name": "boolean",
},
},
"showTextChange": {
"action": "showTextChange",
"description": "",
"name": "showTextChange",
"table": {
"category": "outputs",
"type": {
"required": true,
"summary": "(e: boolean) => void",
},
},
"type": {
"name": "other",
"value": "void",
},
},
}
Loading
Loading