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:
parent
6d6c3515fd
commit
fd74969367
@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}/>)
|
||||
})
|
||||
})
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) { %>
|
||||
|
||||
@ -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: '',
|
||||
},
|
||||
};
|
||||
"
|
||||
`;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user