diff --git a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts index 39c77e48ca13..0d112970e9d4 100644 --- a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts +++ b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.test.ts @@ -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, outputs: Record) => { + 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(); + + @Output('outputPropertyName') public outputWithBindingPropertyName = + new EventEmitter(); + } + + 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(); + } + + 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({}) diff --git a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts index da072c2c646d..d69354c094ba 100644 --- a/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts +++ b/code/frameworks/angular/src/client/angular-beta/utils/NgComponentAnalyzer.ts @@ -6,6 +6,7 @@ import { Output, Pipe, ɵReflectionCapabilities as ReflectionCapabilities, + ɵgetComponentDef as getComponentDef, } from '@angular/core'; const reflectionCapabilities = new ReflectionCapabilities(); @@ -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 => { diff --git a/code/frameworks/angular/src/client/compodoc.ts b/code/frameworks/angular/src/client/compodoc.ts index 219010434c1c..5e17513fbcb8 100644 --- a/code/frameworks/angular/src/client/compodoc.ts +++ b/code/frameworks/angular/src/client/compodoc.ts @@ -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( + (((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(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 = @@ -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', diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes-filtered.snapshot b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes-filtered.snapshot new file mode 100644 index 000000000000..d77122d74876 --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes-filtered.snapshot @@ -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", + }, + }, +} \ No newline at end of file diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes.snapshot b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes.snapshot new file mode 100644 index 000000000000..d77122d74876 --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/argtypes.snapshot @@ -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", + }, + }, +} \ No newline at end of file diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-input.json b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-input.json new file mode 100644 index 000000000000..1dfb80c78da3 --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-input.json @@ -0,0 +1,118 @@ +{ + "pipes": [], + "interfaces": [], + "injectables": [], + "guards": [], + "interceptors": [], + "classes": [], + "directives": [], + "components": [ + { + "name": "ColorPickerComponent", + "id": "component-ColorPickerComponent-ee805ddb7f60da308cdcc8df0001da2e5a083f17f41e20836c705aea547615150fda99a5fd1a529c7916959272247b37e65a7aad6e078224274403e7f3a9f84b", + "file": "color-picker.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "cp", + "styleUrls": [], + "styles": [], + "template": "", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [125, 148], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "outputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [125, 148], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "propertiesClass": [], + "methodsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [], + "description": "", + "rawdescription": "\n", + "type": "component", + "sourceCode": "import { Component, model } from '@angular/core';\n\n@Component({ selector: 'cp', template: '' })\nexport class ColorPickerComponent {\n public readonly color = model('#345F92');\n\n showText = model.required();\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "", + "extends": [] + } + ], + "modules": [], + "miscellaneous": [], + "routes": { + "name": "", + "kind": "module", + "children": [] + }, + "coverage": { + "count": 0, + "status": "low", + "files": [ + { + "filePath": "color-picker.component.ts", + "type": "component", + "linktype": "component", + "name": "ColorPickerComponent", + "coveragePercent": 0, + "coverageCount": "0/5", + "status": "low" + } + ] + } +} diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-posix.snapshot b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-posix.snapshot new file mode 100644 index 000000000000..5fb5ba06a43e --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-posix.snapshot @@ -0,0 +1,124 @@ +{ + "pipes": [], + "interfaces": [], + "injectables": [], + "guards": [], + "interceptors": [], + "classes": [], + "directives": [], + "components": [ + { + "name": "ColorPickerComponent", + "id": "component-ColorPickerComponent-ee805ddb7f60da308cdcc8df0001da2e5a083f17f41e20836c705aea547615150fda99a5fd1a529c7916959272247b37e65a7aad6e078224274403e7f3a9f84b", + "file": "color-picker.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "cp", + "styleUrls": [], + "styles": [], + "template": "", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [ + 125, + 148 + ], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "outputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [ + 125, + 148 + ], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "propertiesClass": [], + "methodsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [], + "description": "", + "rawdescription": "\n", + "type": "component", + "sourceCode": "import { Component, model } from '@angular/core';\n\n@Component({ selector: 'cp', template: '' })\nexport class ColorPickerComponent {\n public readonly color = model('#345F92');\n\n showText = model.required();\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "", + "extends": [] + } + ], + "modules": [], + "miscellaneous": [], + "routes": { + "name": "", + "kind": "module", + "children": [] + }, + "coverage": { + "count": 0, + "status": "low", + "files": [ + { + "filePath": "color-picker.component.ts", + "type": "component", + "linktype": "component", + "name": "ColorPickerComponent", + "coveragePercent": 0, + "coverageCount": "0/5", + "status": "low" + } + ] + } +} \ No newline at end of file diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-undefined.snapshot b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-undefined.snapshot new file mode 100644 index 000000000000..5fb5ba06a43e --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-undefined.snapshot @@ -0,0 +1,124 @@ +{ + "pipes": [], + "interfaces": [], + "injectables": [], + "guards": [], + "interceptors": [], + "classes": [], + "directives": [], + "components": [ + { + "name": "ColorPickerComponent", + "id": "component-ColorPickerComponent-ee805ddb7f60da308cdcc8df0001da2e5a083f17f41e20836c705aea547615150fda99a5fd1a529c7916959272247b37e65a7aad6e078224274403e7f3a9f84b", + "file": "color-picker.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "cp", + "styleUrls": [], + "styles": [], + "template": "", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [ + 125, + 148 + ], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "outputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [ + 125, + 148 + ], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "propertiesClass": [], + "methodsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [], + "description": "", + "rawdescription": "\n", + "type": "component", + "sourceCode": "import { Component, model } from '@angular/core';\n\n@Component({ selector: 'cp', template: '' })\nexport class ColorPickerComponent {\n public readonly color = model('#345F92');\n\n showText = model.required();\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "", + "extends": [] + } + ], + "modules": [], + "miscellaneous": [], + "routes": { + "name": "", + "kind": "module", + "children": [] + }, + "coverage": { + "count": 0, + "status": "low", + "files": [ + { + "filePath": "color-picker.component.ts", + "type": "component", + "linktype": "component", + "name": "ColorPickerComponent", + "coveragePercent": 0, + "coverageCount": "0/5", + "status": "low" + } + ] + } +} \ No newline at end of file diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-windows.snapshot b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-windows.snapshot new file mode 100644 index 000000000000..5fb5ba06a43e --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/compodoc-windows.snapshot @@ -0,0 +1,124 @@ +{ + "pipes": [], + "interfaces": [], + "injectables": [], + "guards": [], + "interceptors": [], + "classes": [], + "directives": [], + "components": [ + { + "name": "ColorPickerComponent", + "id": "component-ColorPickerComponent-ee805ddb7f60da308cdcc8df0001da2e5a083f17f41e20836c705aea547615150fda99a5fd1a529c7916959272247b37e65a7aad6e078224274403e7f3a9f84b", + "file": "color-picker.component.ts", + "encapsulation": [], + "entryComponents": [], + "inputs": [], + "outputs": [], + "providers": [], + "selector": "cp", + "styleUrls": [], + "styles": [], + "template": "", + "templateUrl": [], + "viewProviders": [], + "hostDirectives": [], + "inputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [ + 125, + 148 + ], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "outputsClass": [ + { + "name": "color", + "defaultValue": "'#345F92'", + "deprecated": false, + "deprecationMessage": "", + "type": "string", + "indexKey": "", + "optional": false, + "description": "", + "line": 5, + "modifierKind": [ + 125, + 148 + ], + "required": false + }, + { + "name": "showText", + "deprecated": false, + "deprecationMessage": "", + "type": "boolean", + "indexKey": "", + "optional": false, + "description": "", + "line": 7, + "required": true + } + ], + "propertiesClass": [], + "methodsClass": [], + "deprecated": false, + "deprecationMessage": "", + "hostBindings": [], + "hostListeners": [], + "standalone": false, + "imports": [], + "description": "", + "rawdescription": "\n", + "type": "component", + "sourceCode": "import { Component, model } from '@angular/core';\n\n@Component({ selector: 'cp', template: '' })\nexport class ColorPickerComponent {\n public readonly color = model('#345F92');\n\n showText = model.required();\n}\n", + "assetsDirs": [], + "styleUrlsData": "", + "stylesData": "", + "extends": [] + } + ], + "modules": [], + "miscellaneous": [], + "routes": { + "name": "", + "kind": "module", + "children": [] + }, + "coverage": { + "count": 0, + "status": "low", + "files": [ + { + "filePath": "color-picker.component.ts", + "type": "component", + "linktype": "component", + "name": "ColorPickerComponent", + "coveragePercent": 0, + "coverageCount": "0/5", + "status": "low" + } + ] + } +} \ No newline at end of file diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/input.ts b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/input.ts new file mode 100644 index 000000000000..42f100be5832 --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/input.ts @@ -0,0 +1,17 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck + +import { Component, model } from '@angular/core'; + +/** + * A component exercising Angular's `model()` two-way binding signal. + * + * compodoc emits a `model()` member as an identical entry in BOTH `inputsClass` and + * `outputsClass` (see `.omc/plans/probe-fixtures/compodoc-model-probe-documentation.json`). + */ +@Component({ selector: 'cp', template: '' }) +export class ColorPickerComponent { + public readonly color = model('#345F92'); + + showText = model.required(); +} diff --git a/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/tsconfig.json b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/tsconfig.json new file mode 100644 index 000000000000..ced6b7ae2f7c --- /dev/null +++ b/code/frameworks/angular/src/client/docs/__testfixtures__/doc-model/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["./*.ts"] +} diff --git a/code/frameworks/angular/src/client/docs/angular-properties.test.ts b/code/frameworks/angular/src/client/docs/angular-properties.test.ts index a4531cbbe480..361cb725ef80 100644 --- a/code/frameworks/angular/src/client/docs/angular-properties.test.ts +++ b/code/frameworks/angular/src/client/docs/angular-properties.test.ts @@ -1,39 +1,102 @@ -import { readdirSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +import { extractArgTypesFromData, findComponentByName } from '../compodoc.ts'; +import type { CompodocJson } from '../compodoc-types.ts'; + +const featureFlags = vi.hoisted(() => { + const flags = { angularFilterNonInputControls: false }; + (globalThis as any).FEATURES = flags; + return flags; +}); // File hierarchy: __testfixtures__ / some-test-case / input.* const inputRegExp = /^input\..*$/; +// compodoc output is path-sensitive, so snapshots are OS-suffixed. +const SNAPSHOT_OS = + process.platform === 'win32' ? 'windows' : process.platform ? 'posix' : 'undefined'; + +const extractWithFilter = ( + componentName: string, + compodocJson: CompodocJson, + angularFilterNonInputControls: boolean +) => { + featureFlags.angularFilterNonInputControls = angularFilterNonInputControls; + const componentData = findComponentByName(componentName, compodocJson); + return extractArgTypesFromData(componentData as any); +}; + 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')); - // }); - } + if (!testEntry.isDirectory()) { + return; + } + const testDir = join(fixturesDir, testEntry.name); + const dirEntries = readdirSync(testDir); + const testFile = dirEntries.find((fileName) => inputRegExp.test(fileName)); + if (!testFile) { + return; + } + + // compodoc is an external, unpinned tool that is not a repo dependency, so it + // cannot be invoked from the unit-test harness. Fixtures that ship a captured, + // parseable `compodoc-input.json` (e.g. the `model()` case in + // `__testfixtures__/doc-model/compodoc-input.json`, byte-identical to the real + // compodoc v1.2.1 output) get the real `extractArgTypesFromData` assertions; + // legacy fixtures without it (which would require re-running compodoc) keep a + // trivial green test so they are not regressed. + const hasCapturedCompodocJson = dirEntries.includes('compodoc-input.json'); + if (!hasCapturedCompodocJson) { + it(`${testEntry.name} (compodoc capture not available)`, () => { + expect(true).toEqual(true); + }); + return; } + + const compodocJson = JSON.parse( + readFileSync(join(testDir, 'compodoc-input.json'), 'utf8') + ) as CompodocJson; + + it(`${testEntry.name}`, async () => { + // Snapshot the captured compodoc output (OS-suffixed, mirroring doc-button). + await expect(JSON.stringify(compodocJson, null, 2)).toMatchFileSnapshot( + join(testDir, `compodoc-${SNAPSHOT_OS}.snapshot`) + ); + + // angularFilterNonInputControls OFF (default): model input control + the + // synthesized `${name}Change` output both present; NO spurious bare-name output. + const argTypes = extractWithFilter('ColorPickerComponent', compodocJson, false); + await expect(argTypes).toMatchFileSnapshot(join(testDir, 'argtypes.snapshot')); + + expect(argTypes.color.table?.category).toBe('inputs'); + expect((argTypes.color as any).action).toBeUndefined(); + expect(argTypes.colorChange).toBeDefined(); + expect(argTypes.colorChange.table?.category).toBe('outputs'); + expect((argTypes.colorChange as any).action).toBe('colorChange'); + expect(argTypes.showText.table?.category).toBe('inputs'); + expect(argTypes.showTextChange).toBeDefined(); + expect((argTypes.showTextChange as any).action).toBe('showTextChange'); + + // angularFilterNonInputControls ON: iteration is restricted to `inputsClass` + // (compodoc.ts L227-229). The model input control AND the synthesized + // `${name}Change` output must STILL be re-surfaced. + const filteredArgTypes = extractWithFilter('ColorPickerComponent', compodocJson, true); + await expect(filteredArgTypes).toMatchFileSnapshot( + join(testDir, 'argtypes-filtered.snapshot') + ); + + expect(filteredArgTypes.color.table?.category).toBe('inputs'); + expect(filteredArgTypes.colorChange).toBeDefined(); + expect((filteredArgTypes.colorChange as any).action).toBe('colorChange'); + expect(filteredArgTypes.showText.table?.category).toBe('inputs'); + expect(filteredArgTypes.showTextChange).toBeDefined(); + expect((filteredArgTypes.showTextChange as any).action).toBe('showTextChange'); + + featureFlags.angularFilterNonInputControls = false; + }); }); }); diff --git a/code/frameworks/angular/src/client/public-types.test-d.ts b/code/frameworks/angular/src/client/public-types.test-d.ts new file mode 100644 index 000000000000..dae67b530e77 --- /dev/null +++ b/code/frameworks/angular/src/client/public-types.test-d.ts @@ -0,0 +1,66 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import { EventEmitter, Input, Output, input, model, numberAttribute, output } from '@angular/core'; + +import type { TransformComponentType } from './public-types.ts'; + +/** + * Type-inference coverage for Angular `model()` signal outputs, asserted on the + * composed `TransformComponentType` alongside the existing + * input()/output()/EventEmitter/@Input/@Output channels to guard regressions. + * + * Aliased `model(prop, { alias })` is a known gap: the type layer can only + * synthesize `${propName}Change` because TypeScript cannot observe the runtime + * alias. Runtime detection via `ɵcmp` resolves the alias correctly. + */ +class C { + color = model(); + reqd = model.required(); + plain = input(); + withT = input(0, { transform: numberAttribute }); + evt = output(); + ee = new EventEmitter(); + @Input() decIn!: string; + @Output() decOut = new EventEmitter(); +} + +type Transformed = TransformComponentType; + +describe('TransformComponentType — model() signal outputs', () => { + it('maps a model() field to its value type and synthesizes ${prop}Change', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<(e: string) => void>(); + }); + + it('covers model.required() identically to model()', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<(e: boolean) => void>(); + }); + + it('does not regress input() signal inputs', () => { + expectTypeOf().toEqualTypeOf(); + }); + + it('does not regress transform input() signal inputs', () => { + // `numberAttribute` types the signal as `InputSignalWithTransform`, so `TransformInputSignalType` surfaces the accepted input type + // `unknown`. Unchanged by model() support; pins the no-regression baseline. + expectTypeOf().toEqualTypeOf(); + }); + + it('does not regress output() signal outputs', () => { + expectTypeOf().toEqualTypeOf<(e: string) => void>(); + }); + + it('does not regress EventEmitter outputs', () => { + expectTypeOf().toEqualTypeOf<(e: number) => void>(); + }); + + it('does not regress @Input decorator inputs', () => { + expectTypeOf().toEqualTypeOf(); + }); + + it('does not regress @Output decorator outputs', () => { + expectTypeOf().toEqualTypeOf<(e: void) => void>(); + }); +}); diff --git a/code/frameworks/angular/src/client/public-types.ts b/code/frameworks/angular/src/client/public-types.ts index 4b5b3f3ab846..8d68416d46bb 100644 --- a/code/frameworks/angular/src/client/public-types.ts +++ b/code/frameworks/angular/src/client/public-types.ts @@ -52,9 +52,17 @@ export type Loader = LoaderFunction; export type StoryContext = GenericStoryContext; export type Preview = ProjectAnnotations; -/** Utility type that transforms InputSignal and EventEmitter types */ +/** + * Transforms InputSignal, ModelSignal, OutputEmitterRef and EventEmitter member + * types into the values/handlers Storybook args expect. + * + * Do NOT reorder: `TransformModelSignalType` must stay innermost. It synthesizes + * the `${K}Change` output key before the outer transforms run, and because + * `ModelSignal extends InputSignal` the model value field is then + * idempotently re-collapsed by `TransformInputSignalType` to the same type. + */ export type TransformComponentType = TransformInputSignalType< - TransformOutputSignalType> + TransformOutputSignalType>> >; // @ts-ignore Angular < 17.2 doesn't export InputSignal @@ -63,15 +71,19 @@ type AngularInputSignal = AngularCore.InputSignal; type AngularInputSignalWithTransform = AngularCore.InputSignalWithTransform; // @ts-ignore Angular < 17.3 doesn't export AngularOutputEmitterRef type AngularOutputEmitterRef = AngularCore.OutputEmitterRef; +// @ts-ignore Angular < 17.2 doesn't export ModelSignal +type AngularModelSignal = AngularCore.ModelSignal; type AngularHasInputSignal = typeof AngularCore extends { input: infer U } ? true : false; type AngularHasOutputSignal = typeof AngularCore extends { output: infer U } ? true : false; +type AngularHasModelSignal = typeof AngularCore extends { model: 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 ModelSignal = AngularHasModelSignal extends true ? AngularModelSignal : never; type TransformInputSignalType = { [K in keyof T]: T[K] extends InputSignal @@ -85,6 +97,14 @@ type TransformOutputSignalType = { [K in keyof T]: T[K] extends OutputEmitterRef ? (e: E) => void : T[K]; }; +type TransformModelSignalType = { + [K in keyof T]: T[K] extends ModelSignal ? E : T[K]; +} & { + [K in keyof T as T[K] extends ModelSignal + ? `${K & string}Change` + : never]: T[K] extends ModelSignal ? (e: E) => void : never; +}; + type TransformEventType = { [K in keyof T]: T[K] extends AngularCore.EventEmitter ? (e: E) => void : T[K]; }; diff --git a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.component.ts b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.component.ts new file mode 100644 index 000000000000..0ad6f1552749 --- /dev/null +++ b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.component.ts @@ -0,0 +1,15 @@ +import { Component, model } from '@angular/core'; + +@Component({ + standalone: false, + // Needs a unique selector so it does not clash with other template components + selector: 'storybook-color-picker', + template: `
+ {{ color() }} + +
`, + styleUrls: ['./color-picker.css'], +}) +export default class ColorPickerComponent { + color = model('#345F92'); +} diff --git a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.css b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.css new file mode 100644 index 000000000000..bef353025ac2 --- /dev/null +++ b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.css @@ -0,0 +1,19 @@ +div { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; +} +span { + font-weight: 700; +} +button { + cursor: pointer; + border: 0; + border-radius: 3em; + padding: 8px 16px; + font-size: 12px; + font-weight: 700; + background-color: #555ab9; + color: white; +} diff --git a/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts new file mode 100644 index 000000000000..8c2a8783d0fe --- /dev/null +++ b/code/frameworks/angular/template/stories_angular-cli-default-ts/model-signal/color-picker.stories.ts @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { STORY_RENDERED, UPDATE_STORY_ARGS } from 'storybook/internal/core-events'; + +import { addons, useArgs } from 'storybook/preview-api'; +import { expect, fn, userEvent, waitFor } from 'storybook/test'; + +import ColorPickerComponent from './color-picker.component'; + +const meta: Meta = { + component: ColorPickerComponent, + tags: ['autodocs'], + // Use `fn` to spy on the `colorChange` arg, which will appear in the actions panel once + // the synthesized `model()` output emits: https://storybook.js.org/docs/essentials/actions + args: { + colorChange: fn(), + }, +}; + +export default meta; + +type Story = StoryObj; + +export const ControlsAndActions: Story = { + args: { + color: '#345F92', + }, + play: async ({ canvas, args }) => { + // `color` is a Control/arg: its value reached the component instance and rendered. + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#345F92'); + + // Emitting the synthesized `model()` output (`colorChange`) fires the Action. + await userEvent.click(canvas.getByTestId('emit-green')); + await expect(args.colorChange).toHaveBeenCalledWith('#00FF00'); + }, +}; + +export const TwoWayRoundTrip: Story = { + // Live args updates inside `play` disrupt the Vitest runner; sandbox + Chromatic cover it. + tags: ['!vitest'], + args: { + color: '#345F92', + }, + render: (args) => { + const [, updateArgs] = useArgs(); + return { + props: { + ...args, + // Two-way `[(color)]`: an emitted `colorChange` writes back to the `color` arg. + colorChange: (value: string) => { + args.colorChange?.(value); + updateArgs({ color: value }); + }, + }, + }; + }, + play: async ({ canvas, args, id }) => { + const channel = addons.getChannel(); + + // 1. Initial render: the initial `args.color` reached the component instance. + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#345F92'); + + // 2. A live arg change (Controls/args update) reaches the Input. + channel.emit(UPDATE_STORY_ARGS, { storyId: id, updatedArgs: { color: '#FF0000' } }); + await new Promise((resolve) => channel.once(STORY_RENDERED, resolve)); + await waitFor(async () => { + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#FF0000'); + }); + + // 3. In-component `colorChange` emission round-trips back to `args.color`. + await userEvent.click(canvas.getByTestId('emit-green')); + await waitFor(async () => { + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#00FF00'); + }); + + // 4. The action received `colorChange`. + await expect(args.colorChange).toHaveBeenCalledWith('#00FF00'); + }, +}; diff --git a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.component.ts b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.component.ts new file mode 100644 index 000000000000..0ad6f1552749 --- /dev/null +++ b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.component.ts @@ -0,0 +1,15 @@ +import { Component, model } from '@angular/core'; + +@Component({ + standalone: false, + // Needs a unique selector so it does not clash with other template components + selector: 'storybook-color-picker', + template: `
+ {{ color() }} + +
`, + styleUrls: ['./color-picker.css'], +}) +export default class ColorPickerComponent { + color = model('#345F92'); +} diff --git a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.css b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.css new file mode 100644 index 000000000000..bef353025ac2 --- /dev/null +++ b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.css @@ -0,0 +1,19 @@ +div { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; +} +span { + font-weight: 700; +} +button { + cursor: pointer; + border: 0; + border-radius: 3em; + padding: 8px 16px; + font-size: 12px; + font-weight: 700; + background-color: #555ab9; + color: white; +} diff --git a/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts new file mode 100644 index 000000000000..8c2a8783d0fe --- /dev/null +++ b/code/frameworks/angular/template/stories_angular-cli-prerelease/model-signal/color-picker.stories.ts @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { STORY_RENDERED, UPDATE_STORY_ARGS } from 'storybook/internal/core-events'; + +import { addons, useArgs } from 'storybook/preview-api'; +import { expect, fn, userEvent, waitFor } from 'storybook/test'; + +import ColorPickerComponent from './color-picker.component'; + +const meta: Meta = { + component: ColorPickerComponent, + tags: ['autodocs'], + // Use `fn` to spy on the `colorChange` arg, which will appear in the actions panel once + // the synthesized `model()` output emits: https://storybook.js.org/docs/essentials/actions + args: { + colorChange: fn(), + }, +}; + +export default meta; + +type Story = StoryObj; + +export const ControlsAndActions: Story = { + args: { + color: '#345F92', + }, + play: async ({ canvas, args }) => { + // `color` is a Control/arg: its value reached the component instance and rendered. + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#345F92'); + + // Emitting the synthesized `model()` output (`colorChange`) fires the Action. + await userEvent.click(canvas.getByTestId('emit-green')); + await expect(args.colorChange).toHaveBeenCalledWith('#00FF00'); + }, +}; + +export const TwoWayRoundTrip: Story = { + // Live args updates inside `play` disrupt the Vitest runner; sandbox + Chromatic cover it. + tags: ['!vitest'], + args: { + color: '#345F92', + }, + render: (args) => { + const [, updateArgs] = useArgs(); + return { + props: { + ...args, + // Two-way `[(color)]`: an emitted `colorChange` writes back to the `color` arg. + colorChange: (value: string) => { + args.colorChange?.(value); + updateArgs({ color: value }); + }, + }, + }; + }, + play: async ({ canvas, args, id }) => { + const channel = addons.getChannel(); + + // 1. Initial render: the initial `args.color` reached the component instance. + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#345F92'); + + // 2. A live arg change (Controls/args update) reaches the Input. + channel.emit(UPDATE_STORY_ARGS, { storyId: id, updatedArgs: { color: '#FF0000' } }); + await new Promise((resolve) => channel.once(STORY_RENDERED, resolve)); + await waitFor(async () => { + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#FF0000'); + }); + + // 3. In-component `colorChange` emission round-trips back to `args.color`. + await userEvent.click(canvas.getByTestId('emit-green')); + await waitFor(async () => { + await expect(canvas.getByTestId('current-color')).toHaveTextContent('#00FF00'); + }); + + // 4. The action received `colorChange`. + await expect(args.colorChange).toHaveBeenCalledWith('#00FF00'); + }, +};