fix(react): handle more scenarios when collecting component props for generating stories (#27528)

<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->

Stories generation for React components only handles components
receiving a `props` argument typed with an `Interface`.

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->

Stories generation for React components should handle receiving the
props with any name or as a destructured object and typed with an
`Interface`, a type literal definition, or an inline type literal. In
case it's a destructured object, it also supports having no type, in
which case the story args will have those properties with type
`unknown`.

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #22053
This commit is contained in:
Leosvel Pérez Espinosa 2024-08-20 14:41:05 +02:00 committed by GitHub
parent 6d6c3515fd
commit fd74969367
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 746 additions and 79 deletions

View File

@ -10,7 +10,7 @@ import type * as ts from 'typescript';
import {
findExportDeclarationsForJsx,
getComponentNode,
getComponentPropsInterface,
parseComponentPropsInfo,
} from '../../utils/ast-utils';
import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript';
@ -47,7 +47,7 @@ export function getArgsDefaultValue(property: ts.SyntaxKind): string {
};
const resolvedValue = typeNameToDefault[property];
if (typeof resolvedValue === undefined) {
if (resolvedValue === undefined) {
return '';
} else if (typeof resolvedValue === 'string') {
return resolvedValue.replace(/\s/g, '+');
@ -138,18 +138,24 @@ function findPropsAndGenerateFileForCypress(
js: boolean,
fromNodeArray?: boolean
) {
const propsInterface = getComponentPropsInterface(sourceFile, cmpDeclaration);
const info = parseComponentPropsInfo(sourceFile, cmpDeclaration);
let props: {
name: string;
defaultValue: any;
}[] = [];
if (propsInterface) {
props = propsInterface.members.map((member: ts.PropertySignature) => {
if (info) {
if (!tsModule) {
tsModule = ensureTypescript();
}
props = info.props.map((member) => {
return {
name: (member.name as ts.Identifier).text,
defaultValue: getArgsDefaultValue(member.type.kind),
defaultValue: tsModule.isBindingElement(member)
? getArgsDefaultValue(member.kind)
: getArgsDefaultValue(member.type.kind),
};
});
}

View File

@ -717,6 +717,42 @@ const meta: Meta<typeof Test> = {
export default meta;
type Story = StoryObj<typeof Test>;
export const Primary = {
args: {
name: '',
displayAge: false,
style: '',
},
};
export const Heading: Story = {
args: {
name: '',
displayAge: false,
style: '',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Welcome to Test!/gi)).toBeTruthy();
},
};
"
`;
exports[`react:component-story default setup component with props should setup controls based on the component destructured props defined in an inline literal type 1`] = `
"import type { Meta, StoryObj } from '@storybook/react';
import { Test } from './test-ui-lib';
import { within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
const meta: Meta<typeof Test> = {
component: Test,
title: 'Test',
};
export default meta;
type Story = StoryObj<typeof Test>;
export const Primary = {
args: {
name: '',
@ -737,7 +773,109 @@ export const Heading: Story = {
"
`;
exports[`react:component-story default setup component with props should setup controls based on the component props 1`] = `
exports[`react:component-story default setup component with props should setup controls based on the component destructured props without type 1`] = `
"import type { Meta, StoryObj } from '@storybook/react';
import { Test } from './test-ui-lib';
import { within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
const meta: Meta<typeof Test> = {
component: Test,
title: 'Test',
};
export default meta;
type Story = StoryObj<typeof Test>;
export const Primary = {
args: {
name: '',
displayAge: '',
},
};
export const Heading: Story = {
args: {
name: '',
displayAge: '',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Welcome to Test!/gi)).toBeTruthy();
},
};
"
`;
exports[`react:component-story default setup component with props should setup controls based on the component props defined in a literal type 1`] = `
"import type { Meta, StoryObj } from '@storybook/react';
import { Test } from './test-ui-lib';
import { within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
const meta: Meta<typeof Test> = {
component: Test,
title: 'Test',
};
export default meta;
type Story = StoryObj<typeof Test>;
export const Primary = {
args: {
name: '',
displayAge: false,
},
};
export const Heading: Story = {
args: {
name: '',
displayAge: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Welcome to Test!/gi)).toBeTruthy();
},
};
"
`;
exports[`react:component-story default setup component with props should setup controls based on the component props defined in an inline literal type 1`] = `
"import type { Meta, StoryObj } from '@storybook/react';
import { Test } from './test-ui-lib';
import { within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
const meta: Meta<typeof Test> = {
component: Test,
title: 'Test',
};
export default meta;
type Story = StoryObj<typeof Test>;
export const Primary = {
args: {
name: '',
displayAge: false,
},
};
export const Heading: Story = {
args: {
name: '',
displayAge: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Welcome to Test!/gi)).toBeTruthy();
},
};
"
`;
exports[`react:component-story default setup component with props should setup controls based on the component props defined in an interface 1`] = `
"import type { Meta, StoryObj } from '@storybook/react';
import { Test } from './test-ui-lib';

View File

@ -126,24 +126,16 @@ describe('react:component-story', () => {
});
describe('component with props', () => {
beforeEach(async () => {
it('should setup controls based on the component props defined in an interface', async () => {
appTree.write(
cmpPath,
`import React from 'react';
import './test.scss';
export interface TestProps {
`export interface TestProps {
name: string;
displayAge: boolean;
}
export const Test = (props: TestProps) => {
return (
<div>
<h1>Welcome to test component, {props.name}</h1>
</div>
);
return <h1>Welcome to test component, {props.name}</h1>;
};
export default Test;
@ -154,9 +146,88 @@ describe('react:component-story', () => {
componentPath: 'lib/test-ui-lib.tsx',
project: 'test-ui-lib',
});
expect(appTree.read(storyFilePath, 'utf-8')).toMatchSnapshot();
});
it('should setup controls based on the component props', () => {
it('should setup controls based on the component props defined in a literal type', async () => {
appTree.write(
cmpPath,
`export type TestProps = {
name: string;
displayAge: boolean;
}
export const Test = (props: TestProps) => {
return <h1>Welcome to test component, {props.name}</h1>;
};
export default Test;
`
);
await componentStoryGenerator(appTree, {
componentPath: 'lib/test-ui-lib.tsx',
project: 'test-ui-lib',
});
expect(appTree.read(storyFilePath, 'utf-8')).toMatchSnapshot();
});
it('should setup controls based on the component props defined in an inline literal type', async () => {
appTree.write(
cmpPath,
`export const Test = (props: { name: string; displayAge: boolean }) => {
return <h1>Welcome to test component, {props.name}</h1>;
};
export default Test;
`
);
await componentStoryGenerator(appTree, {
componentPath: 'lib/test-ui-lib.tsx',
project: 'test-ui-lib',
});
expect(appTree.read(storyFilePath, 'utf-8')).toMatchSnapshot();
});
it('should setup controls based on the component destructured props defined in an inline literal type', async () => {
appTree.write(
cmpPath,
`export const Test = ({ name, displayAge }: { name: string; displayAge: boolean }) => {
return <h1>Welcome to test component, {props.name}</h1>;
};
export default Test;
`
);
await componentStoryGenerator(appTree, {
componentPath: 'lib/test-ui-lib.tsx',
project: 'test-ui-lib',
});
expect(appTree.read(storyFilePath, 'utf-8')).toMatchSnapshot();
});
it('should setup controls based on the component destructured props without type', async () => {
appTree.write(
cmpPath,
`export const Test = ({ name, displayAge }) => {
return <h1>Welcome to test component, {props.name}</h1>;
};
export default Test;
`
);
await componentStoryGenerator(appTree, {
componentPath: 'lib/test-ui-lib.tsx',
project: 'test-ui-lib',
});
expect(appTree.read(storyFilePath, 'utf-8')).toMatchSnapshot();
});
});

View File

@ -11,7 +11,7 @@ import {
findExportDeclarationsForJsx,
getComponentNode,
} from '../../utils/ast-utils';
import { getDefaultsForComponent } from '../../utils/component-props';
import { getComponentPropDefaults } from '../../utils/component-props';
import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript';
let tsModule: typeof import('typescript');
@ -108,7 +108,7 @@ export function findPropsAndGenerateFile(
isPlainJs: boolean,
fromNodeArray?: boolean
) {
const { propsTypeName, props, argTypes } = getDefaultsForComponent(
const { props, argTypes } = getComponentPropDefaults(
sourceFile,
cmpDeclaration
);
@ -123,7 +123,6 @@ export function findPropsAndGenerateFile(
? `${name}--${(cmpDeclaration as any).name.text}`
: name,
componentImportFileName: name,
propsTypeName,
props,
argTypes,
componentName: (cmpDeclaration as any).name.text,

View File

@ -138,6 +138,36 @@ describe(AnotherCmp.name, () => {
"
`;
exports[`componentTestGenerator single component per file should handle destructured props with no type 1`] = `
"import * as React from 'react'
import { AnotherCmp } from './some-lib'
describe(AnotherCmp.name, () => {
let props: {
handleClick: unknown;
text: unknown;
count: unknown;
isOkay: unknown;
};
beforeEach(() => {
props = {
handleClick: '',
text: '',
count: '',
isOkay: '',
}
})
it('renders', () => {
cy.mount(<AnotherCmp {...props}/>)
})
})
"
`;
exports[`componentTestGenerator single component per file should handle named exports 1`] = `
"import * as React from 'react'
import { AnotherCmpProps, AnotherCmp } from './some-lib'
@ -201,3 +231,33 @@ describe(AnotherCmp.name, () => {
"
`;
exports[`componentTestGenerator single component per file should handle props with inline type 1`] = `
"import * as React from 'react'
import { AnotherCmp } from './some-lib'
describe(AnotherCmp.name, () => {
let props: {
handleClick: () => void;
text: string;
count: number;
isOkay: boolean;
};
beforeEach(() => {
props = {
text: '',
count: 0,
isOkay: false,
handleClick: undefined
}
})
it('renders', () => {
cy.mount(<AnotherCmp {...props}/>)
})
})
"
`;

View File

@ -321,6 +321,79 @@ export function AnotherCmp(props: AnotherCmpProps) {
tree.read('some-lib/src/lib/some-lib.cy.tsx', 'utf-8')
).toMatchSnapshot();
});
it('should handle props with inline type', async () => {
mockedAssertMinimumCypressVersion.mockReturnValue();
await libraryGenerator(tree, {
linter: Linter.EsLint,
name: 'some-lib',
skipFormat: true,
skipTsConfig: false,
style: 'scss',
unitTestRunner: 'none',
component: true,
projectNameAndRootFormat: 'as-provided',
});
tree.write(
'some-lib/src/lib/some-lib.tsx',
`export function AnotherCmp(props: {
handleClick: () => void;
text: string;
count: number;
isOkay: boolean;
}) {
return <button onClick='{handleClick}'>{props.text}</button>;
}
`
);
await componentTestGenerator(tree, {
project: 'some-lib',
componentPath: 'some-lib/src/lib/some-lib.tsx',
});
expect(tree.exists('some-lib/src/lib/some-lib.cy.tsx')).toBeTruthy();
expect(
tree.read('some-lib/src/lib/some-lib.cy.tsx', 'utf-8')
).toMatchSnapshot();
});
it('should handle destructured props with no type', async () => {
mockedAssertMinimumCypressVersion.mockReturnValue();
await libraryGenerator(tree, {
linter: Linter.EsLint,
name: 'some-lib',
skipFormat: true,
skipTsConfig: false,
style: 'scss',
unitTestRunner: 'none',
component: true,
projectNameAndRootFormat: 'as-provided',
});
tree.write(
'some-lib/src/lib/some-lib.tsx',
`export function AnotherCmp({
handleClick,
text,
count,
isOkay,
}) {
return <button onClick='{handleClick}'>{props.text}</button>;
}
`
);
await componentTestGenerator(tree, {
project: 'some-lib',
componentPath: 'some-lib/src/lib/some-lib.tsx',
});
expect(tree.exists('some-lib/src/lib/some-lib.cy.tsx')).toBeTruthy();
expect(
tree.read('some-lib/src/lib/some-lib.cy.tsx', 'utf-8')
).toMatchSnapshot();
});
it('should handle no props', async () => {
// this is the default behavior of the library component generator
mockedAssertMinimumCypressVersion.mockReturnValue();

View File

@ -10,7 +10,7 @@ import {
findExportDeclarationsForJsx,
getComponentNode,
} from '../../utils/ast-utils';
import { getDefaultsForComponent } from '../../utils/component-props';
import { getComponentPropDefaults } from '../../utils/component-props';
import { nxVersion } from '../../utils/versions';
import { ComponentTestSchema } from './schema';
import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript';
@ -72,7 +72,7 @@ function generateSpecsForComponents(tree: Tree, filePath: string) {
if (cmpNodes?.length) {
const components = cmpNodes.map((cmp) => {
const defaults = getDefaultsForComponent(sourceFile, cmp);
const defaults = getComponentPropDefaults(sourceFile, cmp);
const isDefaultExport = defaultExport
? (defaultExport as any).name.text === (cmp as any).name.text
: false;
@ -81,6 +81,7 @@ function generateSpecsForComponents(tree: Tree, filePath: string) {
props: [...defaults.props, ...defaults.argTypes],
name: (cmp as any).name.text as string,
typeName: defaults.propsTypeName,
inlineTypeString: defaults.inlineTypeString,
};
});
const namedImports = components

View File

@ -2,8 +2,8 @@ import * as React from 'react'
<%- importStatement %>
<% for (let cmp of components) { %>
describe(<%= cmp.name %>.name, () => {<% if (cmp.typeName) { %>
let props: <%= cmp.typeName%>;
describe(<%= cmp.name %>.name, () => {<% if (cmp.props.length > 0) { %>
let props: <% if (cmp.typeName) { %><%= cmp.typeName %><% } else if (cmp.inlineTypeString) { %><%- cmp.inlineTypeString %><% } else { %>unknown<% } %>;
beforeEach(() => {
props = {<% for (let prop of cmp.props) { %>

View File

@ -15,11 +15,15 @@ export default meta;
type Story = StoryObj<typeof NxWelcome>;
export const Primary = {
args: {},
args: {
title: '',
},
};
export const Heading: Story = {
args: {},
args: {
title: '',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/Welcome to NxWelcome!/gi)).toBeTruthy();
@ -74,7 +78,9 @@ export default meta;
type Story = StoryObj<typeof NxWelcome>;
export const Primary = {
args: {},
args: {
title: '',
},
};
"
`;

View File

@ -592,3 +592,221 @@ describe('getComponentNode', () => {
expect(result).toBeNull();
});
});
describe('parseComponentPropsInfo', () => {
it('should parse props from a function with typed props using an interface', () => {
const sourceCode = `export interface TestProps {
name: string;
age: number;
}
export function Test(props: TestProps) {}`;
const source = ts.createSourceFile(
'some-component.tsx',
sourceCode,
ts.ScriptTarget.Latest,
true
);
const result = utils.parseComponentPropsInfo(source, source.statements[1]);
expect(result.props.length).toBe(2);
expect(result.props[0].name.getText()).toBe('name');
expect((result.props[0] as ts.PropertySignature).type.getText()).toBe(
'string'
);
expect(result.props[1].name.getText()).toBe('age');
expect((result.props[1] as ts.PropertySignature).type.getText()).toBe(
'number'
);
expect(result.propsTypeName).toBe('TestProps');
expect(result.inlineTypeString).toBeNull();
});
it('should parse props from a function with destructured typed props using an interface', () => {
const sourceCode = `export interface TestProps {
name: string;
age: number;
}
export function Test({ name, age }: TestProps) {}`;
const source = ts.createSourceFile(
'some-component.tsx',
sourceCode,
ts.ScriptTarget.Latest,
true
);
const result = utils.parseComponentPropsInfo(source, source.statements[1]);
expect(result.props.length).toBe(2);
expect(result.props[0].name.getText()).toBe('name');
expect((result.props[0] as ts.PropertySignature).type.getText()).toBe(
'string'
);
expect(result.props[1].name.getText()).toBe('age');
expect((result.props[1] as ts.PropertySignature).type.getText()).toBe(
'number'
);
expect(result.propsTypeName).toBe('TestProps');
expect(result.inlineTypeString).toBeNull();
});
it('should parse props from a function with typed props using a literal type', () => {
const sourceCode = `export type TestProps = {
name: string;
age: number;
}
export function Test(props: TestProps) {}`;
const source = ts.createSourceFile(
'some-component.tsx',
sourceCode,
ts.ScriptTarget.Latest,
true
);
const result = utils.parseComponentPropsInfo(source, source.statements[1]);
expect(result.props.length).toBe(2);
expect(result.props[0].name.getText()).toBe('name');
expect((result.props[0] as ts.PropertySignature).type.getText()).toBe(
'string'
);
expect(result.props[1].name.getText()).toBe('age');
expect((result.props[1] as ts.PropertySignature).type.getText()).toBe(
'number'
);
expect(result.propsTypeName).toBe('TestProps');
expect(result.inlineTypeString).toBeNull();
});
it('should parse props from a function with destructured typed props using a literal type', () => {
const sourceCode = `export type TestProps = {
name: string;
age: number;
}
export function Test({ name, age }: TestProps) {}`;
const source = ts.createSourceFile(
'some-component.tsx',
sourceCode,
ts.ScriptTarget.Latest,
true
);
const result = utils.parseComponentPropsInfo(source, source.statements[1]);
expect(result.props.length).toBe(2);
expect(result.props[0].name.getText()).toBe('name');
expect((result.props[0] as ts.PropertySignature).type.getText()).toBe(
'string'
);
expect(result.props[1].name.getText()).toBe('age');
expect((result.props[1] as ts.PropertySignature).type.getText()).toBe(
'number'
);
expect(result.propsTypeName).toBe('TestProps');
expect(result.inlineTypeString).toBeNull();
});
it('should parse props from a function with typed props using an inline type', () => {
const sourceCode = `export function Test(props: { name: string; age: number }) {}`;
const source = ts.createSourceFile(
'some-component.tsx',
sourceCode,
ts.ScriptTarget.Latest,
true
);
const result = utils.parseComponentPropsInfo(source, source.statements[0]);
expect(result).toBeDefined();
expect(result.props.length).toBe(2);
expect(result.props[0].name.getText()).toBe('name');
expect((result.props[0] as ts.PropertySignature).type.getText()).toBe(
'string'
);
expect(result.props[1].name.getText()).toBe('age');
expect((result.props[1] as ts.PropertySignature).type.getText()).toBe(
'number'
);
expect(result.propsTypeName).toBeNull();
expect(result.inlineTypeString).toMatchInlineSnapshot(
`"{ name: string; age: number }"`
);
});
it('should parse props from a function with destructured typed props using an inline type', () => {
const sourceCode = `export function Test({ name, age }: { name: string; age: number }) {}`;
const source = ts.createSourceFile(
'some-component.tsx',
sourceCode,
ts.ScriptTarget.Latest,
true
);
const result = utils.parseComponentPropsInfo(source, source.statements[0]);
expect(result.props.length).toBe(2);
expect(result.props[0].name.getText()).toBe('name');
expect((result.props[0] as ts.PropertySignature).type.getText()).toBe(
'string'
);
expect(result.props[1].name.getText()).toBe('age');
expect((result.props[1] as ts.PropertySignature).type.getText()).toBe(
'number'
);
expect(result.propsTypeName).toBeNull();
expect(result.inlineTypeString).toMatchInlineSnapshot(
`"{ name: string; age: number }"`
);
});
it('should parse props from a function with no type', () => {
const sourceCode = `export function Test({ name, age }) {}`;
const source = ts.createSourceFile(
'some-component.tsx',
sourceCode,
ts.ScriptTarget.Latest,
true
);
const result = utils.parseComponentPropsInfo(source, source.statements[0]);
expect(result.props.length).toBe(2);
expect(result.props[0].name.getText()).toBe('name');
expect(result.props[1].name.getText()).toBe('age');
expect(result.propsTypeName).toBeNull();
expect(result.inlineTypeString).toMatchInlineSnapshot(`
"{
name: unknown;
age: unknown;
}"
`);
});
it('should return null when the props are not destructured and have not type', () => {
const sourceCode = `export function Test(props) {}`;
const source = ts.createSourceFile(
'some-component.tsx',
sourceCode,
ts.ScriptTarget.Latest,
true
);
const result = utils.parseComponentPropsInfo(source, source.statements[0]);
expect(result).toBeNull();
});
it('should return null when there are no props', () => {
const sourceCode = `export function Test() {}`;
const source = ts.createSourceFile(
'some-component.tsx',
sourceCode,
ts.ScriptTarget.Latest,
true
);
const result = utils.parseComponentPropsInfo(source, source.statements[0]);
expect(result).toBeNull();
});
});

View File

@ -665,44 +665,70 @@ export function getComponentNode(sourceFile: ts.SourceFile): ts.Node | null {
return defaultExport;
}
export function getComponentPropsInterface(
export function parseComponentPropsInfo(
sourceFile: ts.SourceFile,
cmpDeclaration: ts.Node
): ts.InterfaceDeclaration | null {
): {
props: Array<ts.PropertySignature | ts.BindingElement>;
propsTypeName: string | null;
inlineTypeString: string | null;
} | null {
if (!tsModule) {
tsModule = ensureTypescript();
}
let propsTypeName: string = null;
let inlineTypeString: string = null;
const props: Array<ts.PropertySignature | ts.BindingElement> = [];
const processParameters = (
parameters: ts.NodeArray<ts.ParameterDeclaration>
): boolean => {
if (!parameters.length) {
return null;
}
const propsParam = parameters[0];
if (propsParam.type) {
if (tsModule.isTypeReferenceNode(propsParam.type)) {
// function Cmp(props: Props) {}
propsTypeName = propsParam.type.typeName.getText();
} else if (tsModule.isTypeLiteralNode(propsParam.type)) {
// function Cmp(props: {a: string, b: number}) {}
props.push(
...(propsParam.type.members as ts.NodeArray<ts.PropertySignature>)
);
inlineTypeString = propsParam.type.getText();
} else {
// we don't support other types (e.g. union types)
return false;
}
} else if (tsModule.isObjectBindingPattern(propsParam.name)) {
// function Cmp({a, b}) {}
props.push(...propsParam.name.elements);
inlineTypeString = `{\n${propsParam.name.elements
.map((x) => `${x.name.getText()}: unknown;\n`)
.join('')}}`;
} else {
// function Cmp(props) {}
return false;
}
return true;
};
if (tsModule.isFunctionDeclaration(cmpDeclaration)) {
const propsParam: ts.ParameterDeclaration = cmpDeclaration.parameters.find(
(x) =>
tsModule.isParameter(x) && (x.name as ts.Identifier).text === 'props'
);
if (propsParam?.type?.['typeName']) {
propsTypeName = (
(propsParam.type as ts.TypeReferenceNode).typeName as ts.Identifier
).text;
const result = processParameters(cmpDeclaration.parameters);
if (!result) {
return null;
}
} else if (
(cmpDeclaration as ts.VariableDeclaration).initializer &&
tsModule.isArrowFunction(
(cmpDeclaration as ts.VariableDeclaration).initializer
)
tsModule.isVariableDeclaration(cmpDeclaration) &&
cmpDeclaration.initializer &&
tsModule.isArrowFunction(cmpDeclaration.initializer)
) {
const arrowFn = (cmpDeclaration as ts.VariableDeclaration)
.initializer as ts.ArrowFunction;
const propsParam: ts.ParameterDeclaration = arrowFn.parameters.find(
(x) =>
tsModule.isParameter(x) && (x.name as ts.Identifier).text === 'props'
);
if (propsParam?.type?.['typeName']) {
propsTypeName = (
(propsParam.type as ts.TypeReferenceNode).typeName as ts.Identifier
).text;
const result = processParameters(cmpDeclaration.initializer.parameters);
if (!result) {
return null;
}
} else if (
// do we have a class component extending from React.Component
@ -731,12 +757,68 @@ export function getComponentPropsInterface(
}
if (propsTypeName) {
return findNodes(sourceFile, tsModule.SyntaxKind.InterfaceDeclaration).find(
(x: ts.InterfaceDeclaration) => {
return (x.name as ts.Identifier).getText() === propsTypeName;
}
) as ts.InterfaceDeclaration;
} else {
const foundProps = getPropsFromTypeName(sourceFile, propsTypeName);
if (!foundProps) {
return null;
}
for (const prop of foundProps) {
props.push(prop);
}
}
return {
propsTypeName,
props,
inlineTypeString,
};
}
function getPropsFromTypeName(
sourceFile: ts.SourceFile,
propsTypeName: string
): Array<ts.PropertySignature | ts.BindingElement> {
const matchingNode = findNodes(sourceFile, [
tsModule.SyntaxKind.InterfaceDeclaration,
tsModule.SyntaxKind.TypeAliasDeclaration,
]).find((x): x is ts.InterfaceDeclaration | ts.TypeAliasDeclaration => {
if (
tsModule.isTypeAliasDeclaration(x) ||
tsModule.isInterfaceDeclaration(x)
) {
return x.name.getText() === propsTypeName;
}
return false;
});
if (!matchingNode) {
return null;
}
const props: Array<ts.PropertySignature | ts.BindingElement> = [];
if (tsModule.isTypeAliasDeclaration(matchingNode)) {
if (tsModule.isTypeLiteralNode(matchingNode.type)) {
for (const prop of matchingNode.type.members) {
props.push(prop as ts.PropertySignature);
}
} else if (tsModule.isTypeReferenceNode(matchingNode.type)) {
const result = getPropsFromTypeName(
sourceFile,
matchingNode.type.typeName.getText()
);
if (result) {
props.push(...result);
}
} else {
// we don't support other types of type aliases (e.g. union types)
return null;
}
} else {
for (const prop of matchingNode.members) {
props.push(prop as ts.PropertySignature);
}
}
return props;
}

View File

@ -1,6 +1,6 @@
import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript';
import type * as ts from 'typescript';
import { getComponentPropsInterface } from './ast-utils';
import { parseComponentPropsInfo } from './ast-utils';
let tsModule: typeof import('typescript');
@ -16,18 +16,19 @@ export function getArgsDefaultValue(property: ts.SyntaxKind): string {
};
const resolvedValue = typeNameToDefault[property];
if (typeof resolvedValue === undefined) {
if (resolvedValue === undefined) {
return "''";
} else {
return resolvedValue;
}
}
export function getDefaultsForComponent(
export function getComponentPropDefaults(
sourceFile: ts.SourceFile,
cmpDeclaration: ts.Node
): {
propsTypeName: string;
propsTypeName: string | null;
inlineTypeString: string | null;
props: {
name: string;
defaultValue: any;
@ -41,9 +42,11 @@ export function getDefaultsForComponent(
if (!tsModule) {
tsModule = ensureTypescript();
}
const propsInterface = getComponentPropsInterface(sourceFile, cmpDeclaration);
const info = parseComponentPropsInfo(sourceFile, cmpDeclaration);
let propsTypeName: string = null;
let inlineTypeString: string = null;
let props: {
name: string;
defaultValue: any;
@ -54,25 +57,35 @@ export function getDefaultsForComponent(
actionText: string;
}[] = [];
if (propsInterface) {
propsTypeName = propsInterface.name.text;
props = propsInterface.members.map((member: ts.PropertySignature) => {
if (member.type.kind === tsModule.SyntaxKind.FunctionType) {
argTypes.push({
name: (member.name as ts.Identifier).text,
type: 'action',
actionText: `${(member.name as ts.Identifier).text} executed!`,
});
if (info) {
propsTypeName = info.propsTypeName;
inlineTypeString = info.inlineTypeString;
props = info.props.map((member) => {
if (tsModule.isPropertySignature(member)) {
if (member.type.kind === tsModule.SyntaxKind.FunctionType) {
argTypes.push({
name: member.name.getText(),
type: 'action',
actionText: `${member.name.getText()} executed!`,
});
} else {
return {
name: member.name.getText(),
defaultValue: getArgsDefaultValue(member.type.kind),
};
}
} else {
// it's a binding element, which doesn't have a type, e.g.:
// const Cmp = ({ a, b }) => {}
return {
name: (member.name as ts.Identifier).text,
defaultValue: getArgsDefaultValue(member.type.kind),
name: member.name.getText(),
defaultValue: getArgsDefaultValue(member.kind),
};
}
});
props = props.filter((p) => p && p.defaultValue !== undefined);
}
return { propsTypeName, props, argTypes };
return { propsTypeName, inlineTypeString, props, argTypes };
}
export function getImportForType(sourceFile: ts.SourceFile, typeName: string) {