diff --git a/package-lock.json b/package-lock.json index 833edab..cb05f47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@nanostores/react": "^0.8.0", "@patternfly/patternfly": "^6.0.0", "@patternfly/react-core": "^6.0.0", + "@patternfly/react-table": "^6.0.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "astro": "^5.4.1", @@ -3444,17 +3445,16 @@ "license": "MIT" }, "node_modules/@patternfly/react-core": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.0.0.tgz", - "integrity": "sha512-UKFj9+YzBY+FfEDsLONgOM4N0e8SPV/27/UzNRiJ0gpgqbw2POuXwLpjGSRTTIUuCaLaGGM5PeTSj7mMB73ykw==", - "license": "MIT", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.1.0.tgz", + "integrity": "sha512-zj0lJPZxQanXKD8ae2kYnweT0kpp1CzpHYAkaBjTrw2k6ZMfr/UPlp0/ugCjWEokBqh79RUADLkKJJPce/yoSQ==", "dependencies": { - "@patternfly/react-icons": "^6.0.0", - "@patternfly/react-styles": "^6.0.0", - "@patternfly/react-tokens": "^6.0.0", - "focus-trap": "7.6.0", - "react-dropzone": "^14.2.3", - "tslib": "^2.7.0" + "@patternfly/react-icons": "^6.1.0", + "@patternfly/react-styles": "^6.1.0", + "@patternfly/react-tokens": "^6.1.0", + "focus-trap": "7.6.2", + "react-dropzone": "^14.3.5", + "tslib": "^2.8.1" }, "peerDependencies": { "react": "^17 || ^18", @@ -3462,26 +3462,40 @@ } }, "node_modules/@patternfly/react-icons": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.0.0.tgz", - "integrity": "sha512-ZFrsBVKrAp0DZrPOss98OA/EVUL4F0frXhR1uBId9+3ZrRArdKTgYgmQUCeSzMbxnSlxpmm3a2L05XQ36VUVbw==", - "license": "MIT", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.1.0.tgz", + "integrity": "sha512-V1w/j19YmOgvh72IRRf1p07k+u4M5+9P+o/IxunlF0fWzLDX4Hf+utBI11A8cRfUzpQN7eLw/vZIS3BLM8Ge3Q==", "peerDependencies": { "react": "^17 || ^18", "react-dom": "^17 || ^18" } }, "node_modules/@patternfly/react-styles": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.0.0.tgz", - "integrity": "sha512-fJFMB89sTRGlZTzTLmpRmthgOXqcN078scHMFJ3ttfi2D2btnem5oZrxmQ/gPZkZOxR+9MqwKDB6l3F5x1SqLQ==", - "license": "MIT" + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.1.0.tgz", + "integrity": "sha512-JQ3zIl5SFiSB0YWVYibcUwgZdsp6Wn8hkfZ7KhtCjHFccSDdJexPOXVV1O9f2h4PfxTlY3YntZ81ZsguBx/Q7A==" + }, + "node_modules/@patternfly/react-table": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-6.1.0.tgz", + "integrity": "sha512-eC8mKkvFR0btfv6yEOvE+J4gBXU8ZGe9i2RSezBM+MJaXEQt/CKRjV+SAB5EeE3PyBYKG8yYDdsOoNmaPxxvSA==", + "dependencies": { + "@patternfly/react-core": "^6.1.0", + "@patternfly/react-icons": "^6.1.0", + "@patternfly/react-styles": "^6.1.0", + "@patternfly/react-tokens": "^6.1.0", + "lodash": "^4.17.21", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "react": "^17 || ^18", + "react-dom": "^17 || ^18" + } }, "node_modules/@patternfly/react-tokens": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-6.0.0.tgz", - "integrity": "sha512-xd0ynDkiIW2rp8jz4TNvR4Dyaw9kSMkZdsuYcLlFXCVmvX//Mnl4rhBnid/2j2TaqK0NbkyTTPnPY/BU7SfLVQ==", - "license": "MIT" + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-6.1.0.tgz", + "integrity": "sha512-t1UcHbOa4txczTR5UlnG4XcAAdnDSfSlCaOddw/HTqRF59pn2ks2JUu9sfnFRZ8SiAAxKRiYdX5bT7Mf4R24+w==" }, "node_modules/@pkgr/core": { "version": "0.1.1", @@ -9538,10 +9552,9 @@ } }, "node_modules/focus-trap": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.0.tgz", - "integrity": "sha512-1td0l3pMkWJLFipobUcGaf+5DTY4PLDDrcqoSaKP8ediO/CoWCCYk/fT/Y2A4e6TNB+Sh6clRJCjOPPnKoNHnQ==", - "license": "MIT", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.2.tgz", + "integrity": "sha512-9FhUxK1hVju2+AiQIDJ5Dd//9R2n2RAfJ0qfhF4IHGHgcoEUTMpbTeG/zbEuwaiYXfuAH6XE0/aCyxDdRM+W5w==", "dependencies": { "tabbable": "^6.2.0" } @@ -20952,8 +20965,7 @@ "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", - "license": "MIT" + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" }, "node_modules/temp-dir": { "version": "3.0.0", diff --git a/package.json b/package.json index bfe5b40..4b039b2 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@nanostores/react": "^0.8.0", "@patternfly/patternfly": "^6.0.0", "@patternfly/react-core": "^6.0.0", + "@patternfly/react-table": "^6.0.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "astro": "^5.4.1", diff --git a/src/components/PropsTable.tsx b/src/components/PropsTable.tsx new file mode 100644 index 0000000..cfb6b7f --- /dev/null +++ b/src/components/PropsTable.tsx @@ -0,0 +1,146 @@ +import { Label, Stack } from '@patternfly/react-core' +import { + Table, + Thead, + Th, + Tr, + Tbody, + Td, + TableText, +} from '@patternfly/react-table' +import { css } from '@patternfly/react-styles' +import accessibleStyles from '@patternfly/react-styles/css/utilities/Accessibility/accessibility' +import textStyles from '@patternfly/react-styles/css/utilities/Text/text' + +export type ComponentProp = { + name: string + isRequired?: boolean + isBeta?: boolean + isHidden?: boolean + isDeprecated?: boolean + type?: string + defaultValue?: string + description?: string +} + +type PropsTableProps = { + componentName: string + headingLevel?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' + componentDescription?: string + componentProps?: ComponentProp[] +} + +export const PropsTable: React.FunctionComponent = ({ + componentName, + headingLevel = 'h3', + componentDescription, + componentProps, +}) => { + const SectionHeading = headingLevel + const publicProps = componentProps?.filter((prop) => !prop.isHidden) + const hasPropsToRender = !!publicProps?.length + + const renderTagLabel = (componentProp: ComponentProp) => { + const { name, isBeta, isDeprecated } = componentProp + if (!isBeta && !isDeprecated) { + return null + } + + if (isBeta && isDeprecated) { + // eslint-disable-next-line no-console + console.error( + `The ${name} prop for ${componentName} has both the isBeta and isDeprecated tag.`, + ) + } + + return ( + + ) + } + + const renderRequiredDescription = (isRequired: boolean | undefined) => { + if (!isRequired) { + return null + } + + return ( + <> + + required + + ) + } + + return ( + <> + {componentName} + + {componentDescription && ( +
+ {componentDescription} +
+ )} + {hasPropsToRender && ( + <> +
+ *{' '} + + indicates a required prop + +
+ + + + + + + + + + + {publicProps.map((prop: ComponentProp) => ( + + + + + + + ))} + +
NameTypeDefaultDescription
+ + {prop.name} + {renderRequiredDescription(prop.isRequired)}{' '} + {renderTagLabel(prop)} + + + + {prop.type || 'No type info available'} + + + + {prop.defaultValue || '-'} + + + + {prop.description || 'No description available.'} + +
+ + )} +
+ + ) +} diff --git a/src/components/__tests__/PropsTable.test.tsx b/src/components/__tests__/PropsTable.test.tsx new file mode 100644 index 0000000..348f28e --- /dev/null +++ b/src/components/__tests__/PropsTable.test.tsx @@ -0,0 +1,311 @@ +import { render, screen, within } from '@testing-library/react' +import { PropsTable, type ComponentProp } from '../PropsTable' + +const componentName = 'TestComponent' +const componentDescription = + 'This is the testable component for the PropsTable.' +const propWithAllTableColumns: ComponentProp = { + name: 'propWithAllTableColumns', + type: 'string | () => void', + defaultValue: 'foobarbaz', + description: 'This prop has data for all table fields.', +} +const propWithNameOnly: ComponentProp = { + name: 'propWithNameOnly', +} + +it('Renders component name in a heading level 3 by default', () => { + render() + + expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent( + componentName, + ) +}) + +it('Renders component name in heading level when headingLevel is passed in', () => { + render() + + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent( + componentName, + ) +}) + +it('Does not render component description by default', () => { + render() + + expect(screen.queryByTestId('component-description')).not.toBeInTheDocument() +}) + +it('Renders component description when componentDescription is passed in', () => { + render( + , + ) + + expect(screen.getByTestId('component-description')).toHaveTextContent( + componentDescription, + ) +}) + +it('Does not render props table if componentProps is not passed in', () => { + render() + + expect(screen.queryByRole('grid')).not.toBeInTheDocument() +}) + +it('Does not render props table if componentProps is empty array', () => { + render() + + expect(screen.queryByRole('grid')).not.toBeInTheDocument() +}) + +it('Does not render props table if componentProps contains only hidden props', () => { + render( + , + ) + + expect(screen.queryByRole('grid')).not.toBeInTheDocument() +}) + +it('Throws error if component prop has isBeta and isDeprecated both set to true', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation() + render( + , + ) + + expect(consoleSpy).toHaveBeenCalledTimes(1) + expect(consoleSpy).toHaveBeenCalledWith( + `The ${propWithAllTableColumns.name} prop for TestComponent has both the isBeta and isDeprecated tag.`, + ) + consoleSpy.mockRestore() +}) + +it('Does not throw error if only one of isBeta or isDeprecated is passed', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation() + render( + , + ) + + expect(consoleSpy).not.toHaveBeenCalled() + consoleSpy.mockRestore() +}) + +it('Does not throw error if neither isBeta nor isDeprecated are passed', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation() + render( + , + ) + + expect(consoleSpy).not.toHaveBeenCalled() + consoleSpy.mockRestore() +}) + +it('Renders props table with aria-label based on componentName', () => { + render( + , + ) + + expect(screen.getByRole('grid')).toHaveAccessibleName( + `Props for ${componentName}`, + ) +}) + +it('Renders props table with aria-description referencing required prop text', () => { + render( + , + ) + + expect(screen.getByRole('grid')).toHaveAccessibleDescription( + `* indicates a required prop`, + ) +}) + +it('Renders prop row with passed in componentProp data', () => { + render( + , + ) + + const tbody = screen.getAllByRole('rowgroup')[1] + const propDataCells = within(tbody).getAllByRole('cell') + + expect(propDataCells[0]).toHaveTextContent(propWithAllTableColumns.name) + expect(propDataCells[1]).toHaveTextContent( + propWithAllTableColumns.type as string, + ) + expect(propDataCells[2]).toHaveTextContent( + propWithAllTableColumns.defaultValue as string, + ) + expect(propDataCells[3]).toHaveTextContent( + propWithAllTableColumns.description as string, + ) +}) + +it('Does not render prop as required by default', () => { + render( + , + ) + + const tbody = screen.getAllByRole('rowgroup')[1] + const nameCell = within(tbody).getAllByRole('cell')[0] + + expect(within(nameCell).queryByText('required')).not.toBeInTheDocument() +}) + +it('Renders prop as required when its isRequired property is true', () => { + render( + , + ) + + const tbody = screen.getAllByRole('rowgroup')[1] + const nameCell = within(tbody).getAllByRole('cell')[0] + + expect(nameCell).toHaveTextContent(`${propWithAllTableColumns.name}*`) + expect(within(nameCell).getByText('required')).toBeInTheDocument() +}) + +it('Does not render prop as beta or deprecated by default', () => { + render( + , + ) + + const tbody = screen.getAllByRole('rowgroup')[1] + const nameCell = within(tbody).getAllByRole('cell')[0] + + expect(within(nameCell).queryByText(/beta/i)).not.toBeInTheDocument() + expect(within(nameCell).queryByText(/deprecated/i)).not.toBeInTheDocument() +}) + +it('Renders prop as beta when its isBeta property is true', () => { + render( + , + ) + + const tbody = screen.getAllByRole('rowgroup')[1] + const nameCell = within(tbody).getAllByRole('cell')[0] + + expect(within(nameCell).getByText(/beta/i)).toBeInTheDocument() +}) + +it('Renders prop as deprecated when its isDeprecated property is true', () => { + render( + , + ) + + const tbody = screen.getAllByRole('rowgroup')[1] + const nameCell = within(tbody).getAllByRole('cell')[0] + + expect(within(nameCell).getByText(/deprecated/i)).toBeInTheDocument() +}) + +it('Renders default content when type data is undefined', () => { + render( + , + ) + + const tbody = screen.getAllByRole('rowgroup')[1] + const typeCell = within(tbody).getAllByRole('cell')[1] + + expect(typeCell).toHaveTextContent('No type info available') +}) + +it('Renders default content when defaultValue data is undefined', () => { + render( + , + ) + + const tbody = screen.getAllByRole('rowgroup')[1] + const defaultValueCell = within(tbody).getAllByRole('cell')[2] + + expect(defaultValueCell).toHaveTextContent('-') +}) + +it('Renders default content when description data is undefined', () => { + render( + , + ) + + const tbody = screen.getAllByRole('rowgroup')[1] + const descriptionCell = within(tbody).getAllByRole('cell')[3] + + expect(descriptionCell).toHaveTextContent('No description available.') +}) + +it('Only renders rows for props that do not have their isHidden property set to true', () => { + render( + , + ) + + const tbody = screen.getAllByRole('rowgroup')[1] + const rows = within(tbody).getAllByRole('row') + + expect(rows).toHaveLength(1) + Object.values(propWithAllTableColumns).forEach((value) => { + expect(rows[0]).not.toHaveTextContent(value as string) + }) +}) + +it('Matches snapshot', () => { + const { asFragment } = render( + , + ) + + expect(asFragment()).toMatchSnapshot() +}) diff --git a/src/components/__tests__/__snapshots__/PropsTable.test.tsx.snap b/src/components/__tests__/__snapshots__/PropsTable.test.tsx.snap new file mode 100644 index 0000000..b7b7fe2 --- /dev/null +++ b/src/components/__tests__/__snapshots__/PropsTable.test.tsx.snap @@ -0,0 +1,176 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Matches snapshot 1`] = ` + +

+ TestComponent +

+
+
+ + * + + + + indicates a required prop + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ Name + + Type + + Default + + Description +
+ + propWithAllTableColumns + + + + string | () => void + + + + foobarbaz + + + + This prop has data for all table fields. + +
+ + propWithNameOnly + + + + No type info available + + + + - + + + + No description available. + +
+
+
+`;