From dd52541fd9faf4f406d98eb35e42b27c5c353d8a Mon Sep 17 00:00:00 2001 From: Matt Warman Date: Sun, 16 Jun 2024 06:07:19 -0400 Subject: [PATCH] 57 Tabs Example (#60) * #57 use node v20.14.0 * #57 initial tab components * #57 docs * #57 tests * #57 tests * #57 tabs variant --- .nvmrc | 2 +- src/components/Tabs/Tab.tsx | 64 +++++++++++++ src/components/Tabs/TabContent.tsx | 31 ++++++ src/components/Tabs/Tabs.tsx | 96 +++++++++++++++++++ src/components/Tabs/__tests__/Tab.test.tsx | 66 +++++++++++++ .../Tabs/__tests__/TabContent.test.tsx | 47 +++++++++ src/components/Tabs/__tests__/Tabs.test.tsx | 67 +++++++++++++ src/pages/UsersPage/UsersPage.tsx | 16 +++- src/pages/UsersPage/components/UserList.tsx | 2 +- .../UsersPage/components/UserListItem.tsx | 4 +- src/pages/UsersPage/components/UserTasks.tsx | 2 +- .../UsersPage/components/UserTasksCard.tsx | 2 +- .../__tests__/UserTasksCard.test.tsx | 2 +- src/utils/__tests__/numbers.test.ts | 80 ++++++++++++++++ src/utils/constants.ts | 7 ++ src/utils/numbers.ts | 37 +++++++ 16 files changed, 516 insertions(+), 9 deletions(-) create mode 100644 src/components/Tabs/Tab.tsx create mode 100644 src/components/Tabs/TabContent.tsx create mode 100644 src/components/Tabs/Tabs.tsx create mode 100644 src/components/Tabs/__tests__/Tab.test.tsx create mode 100644 src/components/Tabs/__tests__/TabContent.test.tsx create mode 100644 src/components/Tabs/__tests__/Tabs.test.tsx create mode 100644 src/utils/__tests__/numbers.test.ts create mode 100644 src/utils/numbers.ts diff --git a/.nvmrc b/.nvmrc index 2efc7e1..93a75dd 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.11.1 \ No newline at end of file +v20.14.0 \ No newline at end of file diff --git a/src/components/Tabs/Tab.tsx b/src/components/Tabs/Tab.tsx new file mode 100644 index 0000000..e91d05c --- /dev/null +++ b/src/components/Tabs/Tab.tsx @@ -0,0 +1,64 @@ +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; +import classNames from 'classnames'; + +/** + * Properties for the `Tab` React component. + * @param {boolean} [isActive=false] - Optional. Indicates if this tab is the + * active tab. + * @param {string} label - The tab label. + * @param {function} [onClick] - Optional. A function to be invoked when the + * tab is clicked. + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +export interface TabProps extends PropsWithClassName, PropsWithTestId { + isActive?: boolean; + label: string; + onClick?: () => void; +} + +/** + * The `Tab` component renders a single tab for the display of tabbed content. + * + * A `Tab` is typically not rendered outside of the `Tabs` component, but rather + * the `TabProps` are supplied to the `Tabs` component so that the `Tabs` component + * may render one or more `Tab` components. + * + * @param {TabProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const Tab = ({ + className, + isActive = false, + label, + onClick, + testId = 'tab', +}: TabProps): JSX.Element => { + /** + * Handle tab click events. + */ + const handleClick = () => { + onClick?.(); + }; + + return ( +
+ {label} +
+ ); +}; + +export default Tab; diff --git a/src/components/Tabs/TabContent.tsx b/src/components/Tabs/TabContent.tsx new file mode 100644 index 0000000..63e5758 --- /dev/null +++ b/src/components/Tabs/TabContent.tsx @@ -0,0 +1,31 @@ +import { PropsWithChildren } from 'react'; +import { PropsWithClassName, PropsWithTestId } from '@leanstacks/react-common'; + +/** + * Properties for the `TabContent` React component. + * @see {@link PropsWithChildren} + * @see {@link PropsWithClassName} + * @see {@link PropsWithTestId} + */ +export interface TabContentProps extends PropsWithChildren, PropsWithClassName, PropsWithTestId {} + +/** + * The `TabContent` component renders a single block of tabbed content. + * + * A `TabContent` is typically not rendered outside of the `Tabs` component, but + * rather the `TabContentProps` are supplied to the `Tabs` component. The `Tabs` + * component renders one or more `TabContent` components. + */ +const TabContent = ({ + children, + className, + testId = 'tab-content', +}: TabContentProps): JSX.Element => { + return ( +
+ {children} +
+ ); +}; + +export default TabContent; diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx new file mode 100644 index 0000000..698c55b --- /dev/null +++ b/src/components/Tabs/Tabs.tsx @@ -0,0 +1,96 @@ +import { PropsWithTestId } from '@leanstacks/react-common'; +import { useSearchParams } from 'react-router-dom'; +import classNames from 'classnames'; + +import { toNumberBetween } from 'utils/numbers'; +import { SearchParam } from 'utils/constants'; +import Tab, { TabProps } from './Tab'; +import TabContent, { TabContentProps } from './TabContent'; + +/** + * The `TabVariant` describes variations of display behavior for `Tabs`. + */ +type TabVariant = 'fullWidth' | 'standard'; + +/** + * Properties for the `Tabs` React component. + * @param {TabProps[]} tabs - An array of `Tab` component properties. + * @param {TabConent[]} tabContents - An array of `TabContent` component properties. + * @param {TabVariant} [variant='standard'] - Optional. The tab display behavior. + * Default: `standard`. + * @see {@link PropsWithTestId} + */ +interface TabsProps extends PropsWithTestId { + tabs: Omit[]; + tabContents: TabContentProps[]; + variant?: TabVariant; +} + +/** + * The `Tabs` component is a wrapper for rendering tabbed content. + * + * Supply one to many `TabProps` objects in the `tabs` property describing each + * `Tab` to render. Supply one to many `TabContentProps` objects in the `tabContents` property + * describing each `TabContent` to render. + * + * The number of `tabs` and `tabContents` items should be equal. The order of each array + * matters. The first item in the `tabs` array should correspond to content in the first + * item in the `tabContents` array and so on. + * + * *Example:* + * ``` + * }, { children: , className: 'my-6' }]} + * /> + * ``` + * @param {TabsProps} - Component properties + * @returns {JSX.Element} JSX + */ +const Tabs = ({ + tabs, + tabContents, + testId = 'tabs', + variant = 'standard', +}: TabsProps): JSX.Element => { + const [searchParams, setSearchParams] = useSearchParams(); + + // obtain activeTabIndex from query string + const activeTabIndex = toNumberBetween(searchParams.get(SearchParam.tab), 0, tabs.length - 1, 0); + + /** + * Set the active tab index. + * @param {number} index - A tab index. + */ + const setTab = (index: number = 0): void => { + const tabIndex = toNumberBetween(index, 0, tabs.length - 1, 0); + if (tabIndex !== activeTabIndex) { + searchParams.set(SearchParam.tab, tabIndex.toString()); + setSearchParams(searchParams); + } + }; + + return ( +
+
+ {tabs.map(({ className, ...tabProps }, index) => ( + setTab(index)} + key={index} + /> + ))} +
+
+ +
+
+ ); +}; + +export default Tabs; diff --git a/src/components/Tabs/__tests__/Tab.test.tsx b/src/components/Tabs/__tests__/Tab.test.tsx new file mode 100644 index 0000000..bcd18ce --- /dev/null +++ b/src/components/Tabs/__tests__/Tab.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; + +import { render, screen } from 'test/test-utils'; + +import Tab from '../Tab'; + +describe('Tab', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('tab'); + + // ASSERT + expect(screen.getByTestId('tab')).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('tab'); + + // ASSERT + expect(screen.getByTestId('tab').classList).toContain('custom-className'); + }); + + it('should render label', async () => { + // ARRANGE + render(); + await screen.findByTestId('tab'); + + // ASSERT + expect(screen.getByTestId('tab').textContent).toBe('Label'); + }); + + it('should render active state', async () => { + // ARRANGE + render(); + await screen.findByTestId('tab'); + + // ASSERT + expect(screen.getByTestId('tab').classList).toContain('border-b-blue-300'); + }); + + it('should call click handler', async () => { + // ARRANGE + const mockClickFn = vi.fn(); + render(); + await screen.findByTestId('tab'); + + // ACT + await userEvent.click(screen.getByTestId('tab')); + + // ASSERT + expect(mockClickFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Tabs/__tests__/TabContent.test.tsx b/src/components/Tabs/__tests__/TabContent.test.tsx new file mode 100644 index 0000000..f516324 --- /dev/null +++ b/src/components/Tabs/__tests__/TabContent.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import TabContent from '../TabContent'; + +describe('TabContent', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('tab-content'); + + // ASSERT + expect(screen.getByTestId('tab-content')).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('tab-content'); + + // ASSERT + expect(screen.getByTestId('tab-content').classList).toContain('custom-className'); + }); + + it('should render children', async () => { + // ARRANGE + render( + +
+
, + ); + await screen.findByTestId('tab-content-children'); + + // ASSERT + expect(screen.getByTestId('tab-content-children')).toBeDefined(); + }); +}); diff --git a/src/components/Tabs/__tests__/Tabs.test.tsx b/src/components/Tabs/__tests__/Tabs.test.tsx new file mode 100644 index 0000000..e4d8b1c --- /dev/null +++ b/src/components/Tabs/__tests__/Tabs.test.tsx @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import userEvent from '@testing-library/user-event'; + +import { render, screen } from 'test/test-utils'; +import { TabProps } from '../Tab'; +import { TabContentProps } from '../TabContent'; + +import Tabs from '../Tabs'; + +describe('Tabs', () => { + const tabs: TabProps[] = [ + { label: 'One', testId: 'tab-one' }, + { label: 'Two', testId: 'tab-two' }, + ]; + const tabContents: TabContentProps[] = [ + { + children:
, + }, + { + children:
, + }, + ]; + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('tabs'); + + // ASSERT + expect(screen.getByTestId('tabs')).toBeDefined(); + expect(screen.getByTestId('tabs-tabs').children.length).toBe(tabs.length); + expect(screen.getByTestId('tab-content-one')).toBeDefined(); + }); + + it('should use custom testId', async () => { + // ARRANGE + render(); + await screen.findByTestId('custom-testId'); + + // ASSERT + expect(screen.getByTestId('custom-testId')).toBeDefined(); + }); + + it('should show tab content when tab is clicked', async () => { + // ARRANGE + render(); + await screen.findByTestId('tabs'); + + // ACT + await userEvent.click(screen.getByTestId('tab-two')); + + // ASSERT + expect(screen.getByTestId('tab-content-two')).toBeDefined(); + }); + + it('should render full width variant', async () => { + // ARRANGE + render(); + await screen.findByTestId('tabs'); + + // ASSERT + expect(screen.getByTestId('tabs')).toBeDefined(); + expect(screen.getByTestId('tabs-tabs').children.length).toBe(tabs.length); + expect(screen.getByTestId('tab-content-one')).toBeDefined(); + expect(screen.getByTestId('tab-one').classList).toContain('flex-grow'); + }); +}); diff --git a/src/pages/UsersPage/UsersPage.tsx b/src/pages/UsersPage/UsersPage.tsx index 1c46586..5958e16 100644 --- a/src/pages/UsersPage/UsersPage.tsx +++ b/src/pages/UsersPage/UsersPage.tsx @@ -3,6 +3,7 @@ import { Outlet } from 'react-router-dom'; import Page from 'components/Page/Page'; import Text from 'components/Text/Text'; import UserList from './components/UserList'; +import Tabs from 'components/Tabs/Tabs'; /** * The `UsersPage` component renders the layout for the users page. It @@ -13,12 +14,23 @@ import UserList from './components/UserList'; const UsersPage = (): JSX.Element => { return ( -
+
Users -
+
+ }, { children: , className: 'my-6' }]} + variant="fullWidth" + /> +
+ +
diff --git a/src/pages/UsersPage/components/UserList.tsx b/src/pages/UsersPage/components/UserList.tsx index 0fc1ad9..c0a2668 100644 --- a/src/pages/UsersPage/components/UserList.tsx +++ b/src/pages/UsersPage/components/UserList.tsx @@ -25,7 +25,7 @@ const UserList = ({ className, testId = 'list-users' }: UserListProps): JSX.Elem return (
{isPending && ( diff --git a/src/pages/UsersPage/components/UserListItem.tsx b/src/pages/UsersPage/components/UserListItem.tsx index a6ca7c9..c8a481b 100644 --- a/src/pages/UsersPage/components/UserListItem.tsx +++ b/src/pages/UsersPage/components/UserListItem.tsx @@ -35,13 +35,13 @@ const UserListItem = ({ const navigate = useNavigate(); const doClick = () => { - navigate(`${user.id}`); + navigate(`${user.id}?tab=1`); }; return (
- + View all
diff --git a/src/pages/UsersPage/components/UserTasksCard.tsx b/src/pages/UsersPage/components/UserTasksCard.tsx index c83edb8..aeaeff1 100644 --- a/src/pages/UsersPage/components/UserTasksCard.tsx +++ b/src/pages/UsersPage/components/UserTasksCard.tsx @@ -49,7 +49,7 @@ const UserTasksCard = ({ }, [error, incompleteTasks, t]); return ( -
navigate(`/app/users/${userId}/tasks`)} data-testid={testId}> +
navigate(`/app/users/${userId}/tasks?tab=1`)} data-testid={testId}> { await userEvent.click(screen.getByTestId('card-user-tasks')); // ASSERT - expect(mockNavigate).toHaveBeenCalledWith(`/app/users/1/tasks`); + expect(mockNavigate).toHaveBeenCalledWith(`/app/users/1/tasks?tab=1`); }); }); diff --git a/src/utils/__tests__/numbers.test.ts b/src/utils/__tests__/numbers.test.ts new file mode 100644 index 0000000..b576a0c --- /dev/null +++ b/src/utils/__tests__/numbers.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { toNumberBetween } from 'utils/numbers'; + +describe('numbers', () => { + it('should convert to number successfully', () => { + // ARRANGE + const value: string = '1'; + const result = toNumberBetween(value, 0, 2, 0); + + // ASSERT + expect(result).toBe(1); + }); + + it('should convert boolean to number successfully', () => { + // ARRANGE + const falseValue: boolean = false; + const trueValue: boolean = true; + const falseResult = toNumberBetween(falseValue, 0, 2, 1); + const trueResult = toNumberBetween(trueValue, 0, 2, 0); + + // ASSERT + expect(falseResult).toBe(0); + expect(trueResult).toBe(1); + }); + + it('should convert number to number successfully', () => { + // ARRANGE + const value: number = 1; + const result = toNumberBetween(value, 0, 2, 0); + + // ASSERT + expect(result).toBe(1); + }); + + it('should convert string to default', () => { + // ARRANGE + const value: string = 'a'; + const result = toNumberBetween(value, 0, 2, 0); + + // ASSERT + expect(result).toBe(0); + }); + + it('should return min when number equal to min', () => { + // ARRANGE + const value: string = '10'; + const result = toNumberBetween(value, 10, 20, 15); + + // ASSERT + expect(result).toBe(10); + }); + + it('should return default when number less than min', () => { + // ARRANGE + const value: string = '0'; + const result = toNumberBetween(value, 10, 20, 10); + + // ASSERT + expect(result).toBe(10); + }); + + it('should return max when number equal to max', () => { + // ARRANGE + const value: string = '20'; + const result = toNumberBetween(value, 10, 20, 10); + + // ASSERT + expect(result).toBe(20); + }); + + it('should return default when number greater than max', () => { + // ARRANGE + const value: string = '500'; + const result = toNumberBetween(value, 10, 20, 10); + + // ASSERT + expect(result).toBe(10); + }); +}); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index a81489c..fd000cd 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -26,3 +26,10 @@ export enum StorageKeys { export const DEFAULT_SETTINGS: Settings = { theme: 'light', }; + +/** + * URL search parameter, i.e. query string, keys. + */ +export enum SearchParam { + tab = 'tab', +} diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts new file mode 100644 index 0000000..1e34ed5 --- /dev/null +++ b/src/utils/numbers.ts @@ -0,0 +1,37 @@ +import isNaN from 'lodash/isNaN'; +import toNumber from 'lodash/toNumber'; + +/** + * Converts the supplied `value` to a `number`. + * + * If type conversion + * fails, returns the supplied default value, `defaultValue`. + * + * If value exceeds the `minimum` or `maxaximum`, returns the supplied default + * value. + * @param value - The value to be converted. + * @param minimum - The minimum allowed value. + * @param maximum - The maximum allowed value. + * @param defaultValue - The default value. + * @returns {number} The resulting `number` value after type conversion and + * validation. + */ +export const toNumberBetween = ( + value: boolean | number | string | null, + minimum: number, + maximum: number, + defaultValue: number, +): number => { + // convert value to a number + const numVal = toNumber(value); + // if result is not a number (NaN), return default + if (isNaN(numVal)) { + return defaultValue; + } + // if result is outside the boundaries, return default + if (numVal < minimum || numVal > maximum) { + return defaultValue; + } + // otherwise return result + return numVal; +};