diff --git a/README.md b/README.md index 881c8a3..b6ce37a 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,16 @@ The technology stack includes: - Create React App - the foundation - React Router Dom - routing -- React Query - data manipulation and caching +- TanStack React Query - data manipulation and caching - Axios - http client - Formik - form management - Yup - validation -- Tailwind - styling -- Material Symbols - icons - React Spring - animation - Lodash - utility functions - DayJS - date utility functions +- TanStack React Table - tables and datagrids +- Tailwind - styling +- Material Symbols - icons - Testing Library React - tests - Jest - tests - MSW - API mocking diff --git a/package-lock.json b/package-lock.json index de35c1e..9fd3d02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@react-spring/web": "9.7.3", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", + "@tanstack/react-table": "8.14.0", "axios": "1.6.7", "classnames": "2.5.1", "dayjs": "1.11.10", @@ -5481,6 +5482,37 @@ "react": "^18.0.0" } }, + "node_modules/@tanstack/react-table": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.14.0.tgz", + "integrity": "sha512-l9iwO99oz/azy5RT5VkVRsHHuy7o//fiXgLxzl3fad8qf7Bj+9ihsfmE6Q+BNjH4wHbxZkahwxtb3ngGq9FQxA==", + "dependencies": { + "@tanstack/table-core": "8.14.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.14.0.tgz", + "integrity": "sha512-wDhpKJahGHWhmRt4RxtV3pES63CoeadljGWS/xeS9OJr1HBl2NB+OO44ht3sxDH5j5TRDAbQzC0NvSlsUfn7lQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "9.3.3", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz", diff --git a/package.json b/package.json index feaa200..f0e8eed 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@react-spring/web": "9.7.3", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", + "@tanstack/react-table": "8.14.0", "axios": "1.6.7", "classnames": "2.5.1", "dayjs": "1.11.10", diff --git a/src/components/Router/Router.tsx b/src/components/Router/Router.tsx index 01dad1e..05c46bb 100644 --- a/src/components/Router/Router.tsx +++ b/src/components/Router/Router.tsx @@ -10,6 +10,8 @@ import AppearanceSettings from 'pages/SettingsPage/components/AppearanceSettings import ComponentsPage from 'pages/ComponentsPage/ComponentsPage'; import TextComponents from 'pages/ComponentsPage/components/TextComponents'; import ButtonComponents from 'pages/ComponentsPage/components/ButtonComponents'; +import BadgeComponents from 'pages/ComponentsPage/components/BadgeComponents'; +import CardComponents from 'pages/ComponentsPage/components/CardComponents'; import UsersPage from 'pages/UsersPage/UsersPage'; import UserDetailLayout from 'pages/UsersPage/components/UserDetailLayout'; import UserDetail from 'pages/UsersPage/components/UserDetail'; @@ -61,9 +63,17 @@ export const routes: RouteObject[] = [ element: , }, { - path: 'button', + path: 'badges', + element: , + }, + { + path: 'buttons', element: , }, + { + path: 'cards', + element: , + }, ], }, { diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx new file mode 100644 index 0000000..391b34b --- /dev/null +++ b/src/components/Table/Table.tsx @@ -0,0 +1,71 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import classNames from 'classnames'; + +/** + * Properties for the `Table` React component. + * @template TData - The type of the table data object. + * @param {ColumnDef[]} columns - An array of `ColumnDef`, column definition, objects. + * @param {TData[]} data - An array of data objects, of type `TData`, + * which are used to populate the rows of the table. + */ +interface TableProps extends PropsWithClassName, PropsWithTestId { + columns: ColumnDef[]; + data: TData[]; +} + +/** + * The `Table` component renders a `table` element using the column definitions + * and data supplied in the properties. + * + * Uses TanStack Table. + * @template TData - The type of the table data object. + * @param {TableProps} props - Component properteis. + * @returns {JSX.Element} JSX + * @see {@link https://tanstack.com/table/latest TanStack Table} + */ +const Table = ({ + className, + columns, + data, + testId = 'table', +}: TableProps): JSX.Element => { + const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }); + + return ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ); +}; + +export default Table; diff --git a/src/components/Table/__tests__/Table.test.tsx b/src/components/Table/__tests__/Table.test.tsx new file mode 100644 index 0000000..bf55b31 --- /dev/null +++ b/src/components/Table/__tests__/Table.test.tsx @@ -0,0 +1,62 @@ +import { createColumnHelper } from '@tanstack/react-table'; +import { render, screen } from 'test/test-utils'; +import Table from '../Table'; + +describe('Table', () => { + type Foo = { + bar: string; + baz: number; + max: number; + }; + const data: Foo[] = [ + { + bar: 'one', + baz: 1, + max: 20, + }, + { + bar: 'two', + baz: 2, + max: 40, + }, + ]; + const columnHelper = createColumnHelper(); + const columns = [ + columnHelper.group({ + id: 'grouped', + header: 'Grouped', + columns: [ + columnHelper.accessor('bar', { cell: (info) => info.renderValue(), header: 'Bar' }), + columnHelper.accessor('baz', { cell: (info) => info.renderValue(), header: 'Baz' }), + ], + }), + columnHelper.accessor('max', { cell: (info) => info.renderValue(), header: 'Max' }), + ]; + + it('should render successfully', async () => { + // ARRANGE + render( data={data} columns={columns} />); + await screen.findByTestId('table'); + + // ASSERT + expect(screen.getByTestId('table')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render( data={data} columns={columns} testId="custom-testId" />); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render( data={data} columns={columns} className="custom-className" />); + await screen.findByTestId('table'); + + // ASSERT + expect(screen.getByTestId('table').classList).toContain('custom-className'); + }); +}); diff --git a/src/pages/ComponentsPage/ComponentsPage.tsx b/src/pages/ComponentsPage/ComponentsPage.tsx index fb31cee..d904bc2 100644 --- a/src/pages/ComponentsPage/ComponentsPage.tsx +++ b/src/pages/ComponentsPage/ComponentsPage.tsx @@ -23,8 +23,14 @@ const ComponentsPage = (): JSX.Element => { Text - - Button + + Badges + + + Buttons + + + Cards
diff --git a/src/pages/ComponentsPage/components/BadgeComponents.tsx b/src/pages/ComponentsPage/components/BadgeComponents.tsx new file mode 100644 index 0000000..4511b02 --- /dev/null +++ b/src/pages/ComponentsPage/components/BadgeComponents.tsx @@ -0,0 +1,102 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import { createColumnHelper } from '@tanstack/react-table'; + +import { ComponentProperty } from '../model/components'; +import Text from 'components/Text/Text'; +import Table from 'components/Table/Table'; +import CodeSnippet from 'components/Text/CodeSnippet'; +import Badge from 'components/Badge/Badge'; + +/** + * Properties for the `BadgeComponents` React component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface BadgeComponentsProps extends PropsWithClassName, PropsWithTestId {} + +/** + * The `BadgeComponents` React component renders a set of examples illustrating + * the use of the `Badge` component. + * @param {BadgeComponentsProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const BadgeComponents = ({ + className, + testId = 'components-badge', +}: BadgeComponentsProps): JSX.Element => { + const data: ComponentProperty[] = [ + { + name: 'children', + description: 'The content to be displayed.', + }, + { + name: 'className', + description: 'Optional. Additional CSS class names.', + }, + { + name: 'testId', + description: 'Optional. Identifier for testing.', + }, + ]; + const columnHelper = createColumnHelper(); + const columns = [ + columnHelper.accessor('name', { + cell: (info) => {info.getValue()}, + header: () => 'Name', + }), + columnHelper.accessor('description', { + cell: (info) => info.renderValue(), + header: () => 'Description', + }), + ]; + + return ( +
+ + Badge Component + + +
+ The Badge component displays a stylized + counter. Useful for displaying the number of items of a specific type, for example, the + number of notifications. +
+ +
+ + Properties + + data={data} columns={columns} /> +
+ + Examples +
+
+ 3 +
+ 3`} /> +
+ +
+
+ 999+ +
+ 999+`} /> +
+ +
+
+ + 19 + +
+ 19`} + /> +
+
+ ); +}; + +export default BadgeComponents; diff --git a/src/pages/ComponentsPage/components/ButtonComponents.tsx b/src/pages/ComponentsPage/components/ButtonComponents.tsx index 039c5cf..63cdee6 100644 --- a/src/pages/ComponentsPage/components/ButtonComponents.tsx +++ b/src/pages/ComponentsPage/components/ButtonComponents.tsx @@ -3,6 +3,9 @@ import { ButtonVariant, PropsWithClassName, PropsWithTestId } from '@leanstacks/ import Button from 'components/Button/Button'; import CodeSnippet from 'components/Text/CodeSnippet'; import Text from 'components/Text/Text'; +import { ComponentProperty } from '../model/components'; +import { createColumnHelper } from '@tanstack/react-table'; +import Table from 'components/Table/Table'; /** * Properties for the `ButtonComponents` React component. @@ -21,19 +24,70 @@ const ButtonComponents = ({ className, testId = 'components-button', }: ButtonComponentsProps): JSX.Element => { + const data: ComponentProperty[] = [ + { + name: 'children', + description: 'The content to be displayed.', + }, + { + name: 'className', + description: 'Optional. Additional CSS class names.', + }, + { + name: 'onClick', + description: 'Optional. Click event handler function.', + }, + { + name: 'testId', + description: 'Optional. Identifier for testing.', + }, + { + name: 'variant', + description: 'Optional. Applies default styling. Default: solid', + }, + ]; + const columnHelper = createColumnHelper(); + const columns = [ + columnHelper.accessor('name', { + cell: (info) => {info.getValue()}, + header: () => 'Name', + }), + columnHelper.accessor('description', { + cell: (info) => info.renderValue(), + header: () => 'Description', + }), + ]; + return (
- Button Components + Button Component
- + The Button component displays a clickable + button which is styled in a standardized way. +
+ +
+ + Properties + + data={data} columns={columns} /> +
+ + Examples +
+
+ +
Default button`} />
- +
+ +
Outline button`} @@ -41,7 +95,9 @@ const ButtonComponents = ({
- +
+ +
Solid button`} @@ -49,12 +105,38 @@ const ButtonComponents = ({
- +
+ +
Text button`} />
+ +
+
+ +
+ alert('Hey! You clicked me!')} + testId="click-me-button" +> + Click me +`} + /> +
); }; diff --git a/src/pages/ComponentsPage/components/CardComponents.tsx b/src/pages/ComponentsPage/components/CardComponents.tsx new file mode 100644 index 0000000..dc74ad5 --- /dev/null +++ b/src/pages/ComponentsPage/components/CardComponents.tsx @@ -0,0 +1,186 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import { createColumnHelper } from '@tanstack/react-table'; + +import { ComponentProperty } from '../model/components'; +import Text from 'components/Text/Text'; +import Table from 'components/Table/Table'; +import CodeSnippet from 'components/Text/CodeSnippet'; +import Card from 'components/Card/Card'; +import MessageCard from 'components/Card/MessageCard'; + +/** + * Properties for the `CardComponents` React component. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +interface CardComponentsProps extends PropsWithClassName, PropsWithTestId {} + +/** + * The `CardComponents` React component renders a set of examples illustrating + * the use of the `Card` family of components. + * @param {CardComponentsProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const CardComponents = ({ + className, + testId = 'components-card', +}: CardComponentsProps): JSX.Element => { + const columnHelper = createColumnHelper(); + const cardData: ComponentProperty[] = [ + { + name: 'children', + description: 'The content to be displayed.', + }, + { + name: 'className', + description: 'Optional. Additional CSS class names.', + }, + { + name: 'testId', + description: 'Optional. Identifier for testing.', + }, + ]; + const messageCardData: ComponentProperty[] = [ + { + name: 'className', + description: 'Optional. Additional CSS class names.', + }, + { + name: 'iconProps', + description: 'Optional. Icon properties object.', + }, + { + name: 'message', + description: 'Messasge text.', + }, + { + name: 'testId', + description: 'Optional. Identifier for testing.', + }, + { + name: 'title', + description: 'Optional. Title text.', + }, + ]; + const columns = [ + columnHelper.accessor('name', { + cell: (info) => {info.getValue()}, + header: () => 'Name', + }), + columnHelper.accessor('description', { + cell: (info) => info.renderValue(), + header: () => 'Description', + }), + ]; + + return ( +
+
+ + Card Component + + +
+ The Card component displays block container + for a group of related content. +
+ +
+ + Properties + + data={cardData} columns={columns} /> +
+ + Examples +
+
+ I am the card content. +
+ I am the card content.`} /> +
+ +
+
+ + I am the card content. + +
+ + I am the card content. +`} + /> +
+
+ +
+ + MessageCard Component + + +
+ The MessageCard component displays block + container for displaying messages. The card consists of a message with optional title and + icon. +
+ +
+ + Properties + + data={messageCardData} columns={columns} /> +
+ + Examples +
+
+ +
+ `} /> +
+ +
+
+ +
+ `} + /> +
+ +
+
+ +
+ `} + /> +
+
+
+ ); +}; + +export default CardComponents; diff --git a/src/pages/ComponentsPage/components/TextComponents.tsx b/src/pages/ComponentsPage/components/TextComponents.tsx index 07d4741..0302cff 100644 --- a/src/pages/ComponentsPage/components/TextComponents.tsx +++ b/src/pages/ComponentsPage/components/TextComponents.tsx @@ -1,7 +1,10 @@ import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import { createColumnHelper } from '@tanstack/react-table'; import CodeSnippet from 'components/Text/CodeSnippet'; import Text from 'components/Text/Text'; +import { ComponentProperty } from '../model/components'; +import Table from 'components/Table/Table'; /** * Properties for the `TextComponents` React component. @@ -20,43 +23,93 @@ const TextComponents = ({ className, testId = 'components-text', }: TextComponentsProps): JSX.Element => { + const data: ComponentProperty[] = [ + { + name: 'children', + description: 'The content to be displayed.', + }, + { + name: 'className', + description: 'Optional. Additional CSS class names.', + }, + { + name: 'testId', + description: 'Optional. Identifier for testing.', + }, + { + name: 'variant', + description: 'Optional. Applies default styling. Default: body copy', + }, + ]; + const columnHelper = createColumnHelper(); + const columns = [ + columnHelper.accessor('name', { + cell: (info) => {info.getValue()}, + header: () => 'Name', + }), + columnHelper.accessor('description', { + cell: (info) => info.renderValue(), + header: () => 'Description', + }), + ]; + return (
- Text Components + Text Component
- Heading 1 + The Text component displays blocks of text + which is styled in a standardized way. +
+ +
+ + Properties + + data={data} columns={columns} /> +
+ + Examples +
+
+ Heading 1 +
Heading 1`} />
- Heading 2 +
+ Heading 2 +
Heading 2`} />
- Heading 3 +
+ Heading 3 +
Heading 3`} />
-
Body Copy
-
- This is the standard body copy text. It may be styled in various ways such as{' '} - bold or italic, as{' '} - underlined or{' '} - strikethrough. +
+ + This is standard body copy text. It may be styled in various ways such as{' '} + bold or italic, as{' '} + underlined or{' '} + strikethrough. +
+ code={` This is the standard body copy text. It may be styled in various ways such as{' '} bold or italic, as{' '} underlined or{' '} strikethrough. -
`} +`} />
diff --git a/src/pages/ComponentsPage/components/__tests__/BadgeComponents.test.tsx b/src/pages/ComponentsPage/components/__tests__/BadgeComponents.test.tsx new file mode 100644 index 0000000..b146672 --- /dev/null +++ b/src/pages/ComponentsPage/components/__tests__/BadgeComponents.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from 'test/test-utils'; +import BadgeComponents from '../BadgeComponents'; + +describe('BadgeComponents', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-badge'); + + // ASSERT + expect(screen.getByTestId('components-badge')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-badge'); + + // ASSERT + expect(screen.getByTestId('components-badge').classList).toContain('custom-className'); + }); +}); diff --git a/src/pages/ComponentsPage/components/__tests__/ButtonComponents.test.tsx b/src/pages/ComponentsPage/components/__tests__/ButtonComponents.test.tsx index 6272f2b..8f6a27c 100644 --- a/src/pages/ComponentsPage/components/__tests__/ButtonComponents.test.tsx +++ b/src/pages/ComponentsPage/components/__tests__/ButtonComponents.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from 'test/test-utils'; import ButtonComponents from '../ButtonComponents'; +import userEvent from '@testing-library/user-event'; describe('ButtonComponents', () => { it('should render successfully', async () => { @@ -28,4 +29,17 @@ describe('ButtonComponents', () => { // ASSERT expect(screen.getByTestId('components-button').classList).toContain('custom-className'); }); + + it('should display alert', async () => { + // ARRANGE + const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {}); + render(); + await screen.findByTestId('components-button'); + + // ACT + await userEvent.click(screen.getByTestId('click-me-button')); + + // ASSERT + expect(alertSpy).toHaveBeenCalled(); + }); }); diff --git a/src/pages/ComponentsPage/components/__tests__/CardComponents.test.tsx b/src/pages/ComponentsPage/components/__tests__/CardComponents.test.tsx new file mode 100644 index 0000000..2c8e115 --- /dev/null +++ b/src/pages/ComponentsPage/components/__tests__/CardComponents.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from 'test/test-utils'; +import CardComponents from '../CardComponents'; + +describe('CardComponents', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-card'); + + // ASSERT + expect(screen.getByTestId('components-card')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should use custom className', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-card'); + + // ASSERT + expect(screen.getByTestId('components-card').classList).toContain('custom-className'); + }); +}); diff --git a/src/pages/ComponentsPage/model/components.ts b/src/pages/ComponentsPage/model/components.ts new file mode 100644 index 0000000..0ce95f7 --- /dev/null +++ b/src/pages/ComponentsPage/model/components.ts @@ -0,0 +1,10 @@ +/** + * A `ComponentProperty` object contains metadata describing a single attribute + * in a React component's properties object. + * @param {string} name - The property, i.e. attribute, name. + * @param {string} description - A short description of the property. + */ +export type ComponentProperty = { + name: string; + description: string; +} \ No newline at end of file