diff --git a/packages/react/src/generators/component-cypress-spec/component-cypress-spec.ts b/packages/react/src/generators/component-cypress-spec/component-cypress-spec.ts index ffc9119fa6..4ba434fe52 100644 --- a/packages/react/src/generators/component-cypress-spec/component-cypress-spec.ts +++ b/packages/react/src/generators/component-cypress-spec/component-cypress-spec.ts @@ -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), }; }); } diff --git a/packages/react/src/generators/component-story/__snapshots__/component-story.spec.ts.snap b/packages/react/src/generators/component-story/__snapshots__/component-story.spec.ts.snap index 397b6af856..55b29dd23c 100644 --- a/packages/react/src/generators/component-story/__snapshots__/component-story.spec.ts.snap +++ b/packages/react/src/generators/component-story/__snapshots__/component-story.spec.ts.snap @@ -717,6 +717,42 @@ const meta: Meta = { export default meta; type Story = StoryObj; +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 = { + component: Test, + title: 'Test', +}; +export default meta; +type Story = StoryObj; + 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 = { + component: Test, + title: 'Test', +}; +export default meta; +type Story = StoryObj; + +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 = { + component: Test, + title: 'Test', +}; +export default meta; +type Story = StoryObj; + +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 = { + component: Test, + title: 'Test', +}; +export default meta; +type Story = StoryObj; + +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'; diff --git a/packages/react/src/generators/component-story/component-story.spec.ts b/packages/react/src/generators/component-story/component-story.spec.ts index 3945b70569..397a5b6c0b 100644 --- a/packages/react/src/generators/component-story/component-story.spec.ts +++ b/packages/react/src/generators/component-story/component-story.spec.ts @@ -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 ( -
-

Welcome to test component, {props.name}

-
- ); + return

Welcome to test component, {props.name}

; }; 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

Welcome to test component, {props.name}

; + }; + + 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

Welcome to test component, {props.name}

; + }; + + 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

Welcome to test component, {props.name}

; + }; + + 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

Welcome to test component, {props.name}

; + }; + + export default Test; + ` + ); + + await componentStoryGenerator(appTree, { + componentPath: 'lib/test-ui-lib.tsx', + project: 'test-ui-lib', + }); + expect(appTree.read(storyFilePath, 'utf-8')).toMatchSnapshot(); }); }); diff --git a/packages/react/src/generators/component-story/component-story.ts b/packages/react/src/generators/component-story/component-story.ts index 29bc3ed0a0..d4b913d449 100644 --- a/packages/react/src/generators/component-story/component-story.ts +++ b/packages/react/src/generators/component-story/component-story.ts @@ -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, diff --git a/packages/react/src/generators/component-test/__snapshots__/component-test.spec.ts.snap b/packages/react/src/generators/component-test/__snapshots__/component-test.spec.ts.snap index 05450ac5e3..1addc57900 100644 --- a/packages/react/src/generators/component-test/__snapshots__/component-test.spec.ts.snap +++ b/packages/react/src/generators/component-test/__snapshots__/component-test.spec.ts.snap @@ -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() + }) +}) + +" +`; + 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() + }) +}) + +" +`; diff --git a/packages/react/src/generators/component-test/component-test.spec.ts b/packages/react/src/generators/component-test/component-test.spec.ts index b6b64387a8..c738c4470f 100644 --- a/packages/react/src/generators/component-test/component-test.spec.ts +++ b/packages/react/src/generators/component-test/component-test.spec.ts @@ -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 ; +} +` + ); + 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 ; +} +` + ); + 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(); diff --git a/packages/react/src/generators/component-test/component-test.ts b/packages/react/src/generators/component-test/component-test.ts index a7e462517b..a46fc85b00 100644 --- a/packages/react/src/generators/component-test/component-test.ts +++ b/packages/react/src/generators/component-test/component-test.ts @@ -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 diff --git a/packages/react/src/generators/component-test/files/__fileName__.cy__ext__ b/packages/react/src/generators/component-test/files/__fileName__.cy__ext__ index 16d05f81b5..0a4a9b820e 100644 --- a/packages/react/src/generators/component-test/files/__fileName__.cy__ext__ +++ b/packages/react/src/generators/component-test/files/__fileName__.cy__ext__ @@ -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) { %> diff --git a/packages/react/src/generators/stories/__snapshots__/stories.app.spec.ts.snap b/packages/react/src/generators/stories/__snapshots__/stories.app.spec.ts.snap index cdbb8fc9eb..61f2da04d5 100644 --- a/packages/react/src/generators/stories/__snapshots__/stories.app.spec.ts.snap +++ b/packages/react/src/generators/stories/__snapshots__/stories.app.spec.ts.snap @@ -15,11 +15,15 @@ export default meta; type Story = StoryObj; 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; export const Primary = { - args: {}, + args: { + title: '', + }, }; " `; diff --git a/packages/react/src/utils/ast-utils.spec.ts b/packages/react/src/utils/ast-utils.spec.ts index d97b3b3e53..55e69314de 100644 --- a/packages/react/src/utils/ast-utils.spec.ts +++ b/packages/react/src/utils/ast-utils.spec.ts @@ -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(); + }); +}); diff --git a/packages/react/src/utils/ast-utils.ts b/packages/react/src/utils/ast-utils.ts index 40f53e83ed..3afa85aab1 100644 --- a/packages/react/src/utils/ast-utils.ts +++ b/packages/react/src/utils/ast-utils.ts @@ -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; + propsTypeName: string | null; + inlineTypeString: string | null; +} | null { if (!tsModule) { tsModule = ensureTypescript(); } let propsTypeName: string = null; + let inlineTypeString: string = null; + const props: Array = []; + + const processParameters = ( + parameters: ts.NodeArray + ): 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) + ); + 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 { + 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 = []; + 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; } diff --git a/packages/react/src/utils/component-props.ts b/packages/react/src/utils/component-props.ts index 655be8b17d..c1776ed205 100644 --- a/packages/react/src/utils/component-props.ts +++ b/packages/react/src/utils/component-props.ts @@ -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) {