diff --git a/.changeset/thirty-experts-thank.md b/.changeset/thirty-experts-thank.md new file mode 100644 index 0000000000000..e8ec62448a0e7 --- /dev/null +++ b/.changeset/thirty-experts-thank.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/ui-client': minor +--- + +Adds Wizard component to ui-client package diff --git a/apps/meteor/client/components/Wizard/WizardActions.tsx b/apps/meteor/client/components/Wizard/WizardActions.tsx new file mode 100644 index 0000000000000..82c3b22fb55ce --- /dev/null +++ b/apps/meteor/client/components/Wizard/WizardActions.tsx @@ -0,0 +1,16 @@ +import { Box, ButtonGroup } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; + +type WizardActionsProps = { + children: ReactNode; +}; + +const WizardActions = ({ children }: WizardActionsProps) => { + return ( + + {children} + + ); +}; + +export default WizardActions; diff --git a/packages/ui-client/src/components/Wizard/Wizard.spec.tsx b/packages/ui-client/src/components/Wizard/Wizard.spec.tsx new file mode 100644 index 0000000000000..7fe77f2476656 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/Wizard.spec.tsx @@ -0,0 +1,19 @@ +import { composeStories } from '@storybook/react'; +import { render } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import * as stories from './Wizard.stories'; + +const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); + +test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const { baseElement } = render(); + expect(baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(); + + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); diff --git a/packages/ui-client/src/components/Wizard/Wizard.stories.tsx b/packages/ui-client/src/components/Wizard/Wizard.stories.tsx new file mode 100644 index 0000000000000..b84904f9fe841 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/Wizard.stories.tsx @@ -0,0 +1,92 @@ +import { Box, Button, States, StatesIcon, StatesTitle } from '@rocket.chat/fuselage'; +import type { Meta, StoryFn } from '@storybook/react'; + +import Wizard from './Wizard'; +import WizardActions from './WizardActions'; +import WizardBackButton from './WizardBackButton'; +import WizardContent from './WizardContent'; +import WizardNextButton from './WizardNextButton'; +import WizardTabs from './WizardTabs'; +import { useWizard } from './useWizard'; + +export default { + title: 'Components/Wizard', + component: Wizard, + subcomponents: { + WizardActions, + WizardBackButton, + WizardContent, + WizardNextButton, + WizardTabs, + }, + parameters: { + layout: 'centered', + }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +const WizardExample = ({ ordered = false }: { ordered?: boolean }) => { + const wizardApi = useWizard({ + steps: [ + { id: 'first-step', title: 'First step' }, + { id: 'second-step', title: 'Second step' }, + { id: 'third-step', title: 'Third step' }, + ], + }); + + return ( + + + + + + + + First step + + + + + + + + + + + + Second step + + + + + + + + + + + + + Third step + + + + + window.alert('Finished!')}> + Finish + + + + + ); +}; + +export const BasicWizard: StoryFn = () => ; + +export const OrderedTabsWizard: StoryFn = () => ; diff --git a/packages/ui-client/src/components/Wizard/Wizard.tsx b/packages/ui-client/src/components/Wizard/Wizard.tsx new file mode 100644 index 0000000000000..4630398d024d4 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/Wizard.tsx @@ -0,0 +1,17 @@ +import { memo, type ReactNode } from 'react'; + +import { WizardContext } from './WizardContext'; +import type { WizardAPI } from './WizardContext'; + +type WizardProps = { + api: WizardAPI; + children: ReactNode; +}; + +const Wizard = ({ children, api }: WizardProps) => ( + + {children} + +); + +export default memo(Wizard); diff --git a/packages/ui-client/src/components/Wizard/WizardActions.tsx b/packages/ui-client/src/components/Wizard/WizardActions.tsx new file mode 100644 index 0000000000000..29d5928e9551f --- /dev/null +++ b/packages/ui-client/src/components/Wizard/WizardActions.tsx @@ -0,0 +1,14 @@ +import { Box, ButtonGroup } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; + +type WizardActionsProps = { + children: ReactNode; +}; + +const WizardActions = ({ children }: WizardActionsProps) => ( + + {children} + +); + +export default WizardActions; diff --git a/packages/ui-client/src/components/Wizard/WizardBackButton.spec.tsx b/packages/ui-client/src/components/Wizard/WizardBackButton.spec.tsx new file mode 100644 index 0000000000000..b2034bffb6912 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/WizardBackButton.spec.tsx @@ -0,0 +1,114 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import WizardBackButton from './WizardBackButton'; +import { WizardContext } from './WizardContext'; +import StepNode from './lib/StepNode'; +import { createMockWizardApi } from './mocks/createMockWizardApi'; + +const mockWizardApi = createMockWizardApi(); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('should render with default "Back" text', () => { + render(, { + wrapper: mockAppRoot() + .wrap((children) => {children}) + .build(), + }); + expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument(); +}); + +it('should render with custom children', () => { + render(Go Back, { + wrapper: mockAppRoot() + .wrap((children) => {children}) + .build(), + }); + expect(screen.getByRole('button', { name: 'Go Back' })).toBeInTheDocument(); +}); + +it('should be disabled if there is no previous step', () => { + render(, { + wrapper: mockAppRoot() + .wrap((children) => ( + + {children} + + )) + .build(), + }); + expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); +}); + +it('should be disabled if the disabled prop is true', () => { + render(, { + wrapper: mockAppRoot() + .wrap((children) => {children}) + .build(), + }); + expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); +}); + +it('should be enabled if there is a previous step and disabled prop is false', () => { + const currentStep = new StepNode({ id: 'step2', title: 'Step 2' }); + const prevStep = new StepNode({ id: 'step1', title: 'Step 1' }); + currentStep.prev = prevStep; + + render(, { + wrapper: mockAppRoot() + .wrap((children) => {children}) + .build(), + }); + expect(screen.getByRole('button', { name: 'Back' })).toBeEnabled(); +}); + +it('should call previous() on click', async () => { + render(, { + wrapper: mockAppRoot() + .wrap((children) => {children}) + .build(), + }); + const button = screen.getByRole('button', { name: 'Back' }); + await userEvent.click(button); + + expect(mockWizardApi.previous).toHaveBeenCalledTimes(1); +}); + +it('should call the onClick prop when clicked', async () => { + const mockOnClick = jest.fn(); + + render(, { + wrapper: mockAppRoot() + .wrap((children) => {children}) + .build(), + }); + const button = screen.getByRole('button', { name: 'Back' }); + await userEvent.click(button); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + expect(mockWizardApi.previous).toHaveBeenCalledTimes(1); +}); + +it('should not call previous() if onClick prevents default', async () => { + const mockOnClick = jest.fn((e) => e.preventDefault()); + + render(, { + wrapper: mockAppRoot() + .wrap((children) => {children}) + .build(), + }); + const button = screen.getByRole('button', { name: 'Back' }); + await userEvent.click(button); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + expect(mockWizardApi.previous).not.toHaveBeenCalled(); +}); diff --git a/packages/ui-client/src/components/Wizard/WizardBackButton.tsx b/packages/ui-client/src/components/Wizard/WizardBackButton.tsx new file mode 100644 index 0000000000000..52bb9278007f9 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/WizardBackButton.tsx @@ -0,0 +1,57 @@ +import { Button } from '@rocket.chat/fuselage'; +import type { ComponentProps, MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useWizardContext } from './useWizardContext'; + +type WizardBackButtonProps = Omit, 'primary' | 'onClick'> & { + manual?: boolean; + onClick?(event?: MouseEvent): unknown | Promise; +}; + +/** + * A specialized button for navigating to the previous step within a Wizard component. + * + * This button is context-aware and will automatically trigger the `previous` function + * from the `WizardContext` when clicked. + * + * The automatic step navigation can be prevented in two ways: + * 1. By setting the `manual` prop to `true`. + * 2. By calling `event.preventDefault()` inside the `onClick` event handler. + * + * This is particularly useful when the button needs to perform another primary action, + * such as submitting a form, before manually proceeding to the previous step. + * + * Inherits all props from Button, except for `primary` and `onClick`, which are managed internally. + * @param {object} props - The component's props. + * @param {ReactNode} [props.children='Back'] - Button label. + * @param {boolean} [props.disabled=false] - Disables the button, unless the current step does not have a previous step, which always takes priority. + * @param {boolean} [props.manual=false] - Prevents automatic navigation to the previous step. + * @param {(event?: MouseEvent) => unknown | Promise} [props.onClick] - Click handler. + * @returns {JSX.Element} The rendered button. + * + * @example + * + */ +const WizardBackButton = ({ children, disabled, manual, onClick, ...props }: WizardBackButtonProps) => { + const { t } = useTranslation(); + const { previous, currentStep } = useWizardContext(); + + const handleClick = async (event: MouseEvent) => { + await onClick?.(event); + + if (manual || event.isDefaultPrevented()) { + return; + } + + previous(); + }; + + return ( + + {children || t('Back')} + + ); +}; + +export default WizardBackButton; diff --git a/packages/ui-client/src/components/Wizard/WizardContent.tsx b/packages/ui-client/src/components/Wizard/WizardContent.tsx new file mode 100644 index 0000000000000..e5211b2933a95 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/WizardContent.tsx @@ -0,0 +1,20 @@ +import { memo, type ReactNode } from 'react'; + +import { useWizardContext } from './useWizardContext'; + +type WizardContentProps = { + id: string; + children: ReactNode; +}; + +const WizardContent = ({ id, children }: WizardContentProps) => { + const { currentStep } = useWizardContext(); + + if (currentStep?.id !== id) { + return null; + } + + return {children}; +}; + +export default memo(WizardContent); diff --git a/packages/ui-client/src/components/Wizard/WizardContext.tsx b/packages/ui-client/src/components/Wizard/WizardContext.tsx new file mode 100644 index 0000000000000..596eb56745cac --- /dev/null +++ b/packages/ui-client/src/components/Wizard/WizardContext.tsx @@ -0,0 +1,17 @@ +import { createContext } from 'react'; + +import type { StepMetadata } from './lib/StepNode'; +import type StepNode from './lib/StepNode'; +import type StepsLinkedList from './lib/StepsLinkedList'; + +export type WizardAPI = { + steps: StepsLinkedList; + register(metadata: StepMetadata): () => void; + currentStep: StepNode | null; + next(): void; + previous(): void; + goTo(step: StepNode): void; + resetNextSteps(): void; +}; + +export const WizardContext = createContext(null); diff --git a/packages/ui-client/src/components/Wizard/WizardNextButton.spec.tsx b/packages/ui-client/src/components/Wizard/WizardNextButton.spec.tsx new file mode 100644 index 0000000000000..1195633e1fc39 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/WizardNextButton.spec.tsx @@ -0,0 +1,114 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { WizardContext } from './WizardContext'; +import WizardNextButton from './WizardNextButton'; +import StepNode from './lib/StepNode'; +import { createMockWizardApi } from './mocks/createMockWizardApi'; + +const mockWizardApi = createMockWizardApi(); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('should render with default "Next" text', () => { + render(, { + wrapper: mockAppRoot() + .wrap((children) => {children}) + .build(), + }); + expect(screen.getByRole('button', { name: 'Next' })).toBeInTheDocument(); +}); + +it('should render with custom children', () => { + render(Go Next, { + wrapper: mockAppRoot() + .wrap((children) => {children}) + .build(), + }); + expect(screen.getByRole('button', { name: 'Go Next' })).toBeInTheDocument(); +}); + +it('should be disabled if there is no previous step', () => { + render(, { + wrapper: mockAppRoot() + .wrap((children) => ( + + {children} + + )) + .build(), + }); + expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled(); +}); + +it('should be disabled if the disabled prop is true', () => { + render(, { + wrapper: mockAppRoot() + .wrap((children) => {children}) + .build(), + }); + expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled(); +}); + +it('should be enabled if there is a next step and disabled prop is false', () => { + const currentStep = new StepNode({ id: 'step2', title: 'Step 2' }); + const nextStep = new StepNode({ id: 'step1', title: 'Step 1' }); + currentStep.next = nextStep; + + render(, { + wrapper: mockAppRoot() + .wrap((children) => {children}) + .build(), + }); + expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled(); +}); + +it('should call next() on click', async () => { + render(, { + wrapper: mockAppRoot() + .wrap((children) => {children}) + .build(), + }); + const button = screen.getByRole('button', { name: 'Next' }); + await userEvent.click(button); + + expect(mockWizardApi.next).toHaveBeenCalledTimes(1); +}); + +it('should call the onClick prop when clicked', async () => { + const mockOnClick = jest.fn(); + + render(, { + wrapper: mockAppRoot() + .wrap((children) => {children}) + .build(), + }); + const button = screen.getByRole('button', { name: 'Next' }); + await userEvent.click(button); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + expect(mockWizardApi.next).toHaveBeenCalledTimes(1); +}); + +it('should not call next() if onClick prevents default', async () => { + const mockOnClick = jest.fn((e) => e.preventDefault()); + + render(, { + wrapper: mockAppRoot() + .wrap((children) => {children}) + .build(), + }); + const button = screen.getByRole('button', { name: 'Next' }); + await userEvent.click(button); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + expect(mockWizardApi.next).not.toHaveBeenCalled(); +}); diff --git a/packages/ui-client/src/components/Wizard/WizardNextButton.tsx b/packages/ui-client/src/components/Wizard/WizardNextButton.tsx new file mode 100644 index 0000000000000..fa26efb3eaf03 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/WizardNextButton.tsx @@ -0,0 +1,57 @@ +import { Button } from '@rocket.chat/fuselage'; +import type { ComponentProps, MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useWizardContext } from './useWizardContext'; + +type WizardNextButtonProps = Omit, 'primary' | 'onClick'> & { + manual?: boolean; + onClick?(event?: MouseEvent): unknown | Promise; +}; + +/** + * A specialized button for navigating to the next step within a Wizard component. + * + * This button is context-aware and will automatically trigger the `next` function + * from the `WizardContext` when clicked. + * + * The automatic step navigation can be prevented in two ways: + * 1. By setting the `manual` prop to `true`. + * 2. By calling `event.preventDefault()` inside the `onClick` event handler. + * + * This is particularly useful when the button needs to perform another primary action, + * such as submitting a form, before manually proceeding to the next step. + * + * Inherits all props from Button, except for `primary` and `onClick`, which are managed internally. + * @param {object} props - The component's props. + * @param {ReactNode} [props.children='Next'] - Button label. + * @param {boolean} [props.disabled=false] - Disables the button, unless the current step does not have a next step, which always takes priority. + * @param {boolean} [props.manual=false] - Prevents automatic navigation to the next step. + * @param {(event?: MouseEvent) => unknown | Promise} [props.onClick] - Click handler. + * @returns {JSX.Element} The rendered button. + * + * @example + * + */ +const WizardNextButton = ({ children, disabled = false, manual, onClick, ...props }: WizardNextButtonProps) => { + const { t } = useTranslation(); + const { next, currentStep } = useWizardContext(); + + const handleClick = async (event: MouseEvent) => { + await onClick?.(event); + + if (manual || event.isDefaultPrevented()) { + return; + } + + next(); + }; + + return ( + + {children || t('Next')} + + ); +}; + +export default WizardNextButton; diff --git a/packages/ui-client/src/components/Wizard/WizardTabs.tsx b/packages/ui-client/src/components/Wizard/WizardTabs.tsx new file mode 100644 index 0000000000000..9bf1ea933ed8e --- /dev/null +++ b/packages/ui-client/src/components/Wizard/WizardTabs.tsx @@ -0,0 +1,32 @@ +import { Tabs, TabsItem } from '@rocket.chat/fuselage'; + +import { useWizardContext } from './useWizardContext'; +import { useWizardSteps } from './useWizardSteps'; + +type WizardTabsProps = { + ordered?: boolean; +}; + +const WizardTabs = ({ ordered }: WizardTabsProps) => { + const { steps, currentStep, goTo } = useWizardContext(); + const items = useWizardSteps(steps); + + return ( + + {items.map((step, index) => ( + goTo(step)} + > + {ordered ? `${index + 1}. ${step.title}` : step.title} + + ))} + + ); +}; + +export default WizardTabs; diff --git a/packages/ui-client/src/components/Wizard/__snapshots__/Wizard.spec.tsx.snap b/packages/ui-client/src/components/Wizard/__snapshots__/Wizard.spec.tsx.snap new file mode 100644 index 0000000000000..98e98ea793020 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/__snapshots__/Wizard.spec.tsx.snap @@ -0,0 +1,179 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders BasicWizard without crashing 1`] = ` + + + + + + + + + First step + + + Second step + + + Third step + + + + + + + + + + + + First step + + + + + + + + + +`; + +exports[`renders OrderedTabsWizard without crashing 1`] = ` + + + + + + + + + 1. First step + + + 2. Second step + + + 3. Third step + + + + + + + + + + + + First step + + + + + + + + + +`; diff --git a/packages/ui-client/src/components/Wizard/index.ts b/packages/ui-client/src/components/Wizard/index.ts new file mode 100644 index 0000000000000..b87f26a23d545 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/index.ts @@ -0,0 +1,14 @@ +export { default } from './Wizard'; +export { default as Wizard } from './Wizard'; +export { default as WizardContent } from './WizardContent'; +export { default as WizardTabs } from './WizardTabs'; +export { default as WizardActions } from './WizardActions'; +export { default as WizardBackButton } from './WizardBackButton'; +export { default as WizardNextButton } from './WizardNextButton'; +export { default as StepsLinkedList } from './lib/StepsLinkedList'; +export { default as StepNode } from './lib/StepNode'; +export * from './lib/StepNode'; +export * from './WizardContext'; +export * from './useWizardContext'; +export * from './useWizard'; +export * from './mocks/createMockWizardApi'; diff --git a/packages/ui-client/src/components/Wizard/lib/StepNode.spec.ts b/packages/ui-client/src/components/Wizard/lib/StepNode.spec.ts new file mode 100644 index 0000000000000..28fbcc1e860a3 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/lib/StepNode.spec.ts @@ -0,0 +1,40 @@ +import StepNode, { type StepMetadata } from './StepNode'; + +describe('StepNode', () => { + const baseMetadata: StepMetadata = { id: 'step1', title: 'Step 1' }; + + it('should correctly initialize with provided value and default disabled state', () => { + const node = new StepNode(baseMetadata); + expect(node.value).toEqual(baseMetadata); + expect(node.next).toBeNull(); + expect(node.prev).toBeNull(); + expect(node.disabled).toBe(true); + }); + + it('should allow overriding the default disabled state via constructor', () => { + const node = new StepNode(baseMetadata, false); + expect(node.disabled).toBe(false); + }); + + it('should return the correct id via the id getter', () => { + const node = new StepNode(baseMetadata); + expect(node.id).toBe(baseMetadata.id); + }); + + it('should return the correct title via the title getter', () => { + const node = new StepNode(baseMetadata); + expect(node.title).toBe(baseMetadata.title); + }); + + it('should enable the node when enable() is called', () => { + const node = new StepNode(baseMetadata, true); + node.enable(); + expect(node.disabled).toBe(false); + }); + + it('should disable the node when disable() is called', () => { + const node = new StepNode(baseMetadata, false); + node.disable(); + expect(node.disabled).toBe(true); + }); +}); diff --git a/packages/ui-client/src/components/Wizard/lib/StepNode.ts b/packages/ui-client/src/components/Wizard/lib/StepNode.ts new file mode 100644 index 0000000000000..8b055f8adb752 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/lib/StepNode.ts @@ -0,0 +1,57 @@ +/** + * Represents the metadata for a step in the wizard. + * @property id - Unique identifier for the step. + * @property title - Title of the step. + * @property onNext - Optional callback function to execute when moving to the next step. + * @property onPrev - Optional callback function to execute when moving to the previous step. + */ +export type StepMetadata = { + id: string; + title: string; +}; + +/** + * Represents a node in the linked list for managing wizard steps. + * It contains the step metadata and pointers to the next and previous nodes. + */ +class StepNode { + public value: StepMetadata; + + public next: StepNode | null = null; + + public prev: StepNode | null = null; + + public disabled = true; + + /** + * Creates an instance of StepNode. + * @param value - The metadata for the step. + * @param disabled - Whether the step should be initially disabled. Defaults to true. + */ + constructor(value: StepMetadata, disabled = true) { + this.value = value; + this.disabled = disabled; + } + + get id() { + return this.value.id; + } + + get title() { + return this.value.title; + } + + setDisabled(disabled: boolean) { + this.disabled = disabled; + } + + enable() { + this.setDisabled(false); + } + + disable() { + this.setDisabled(true); + } +} + +export default StepNode; diff --git a/packages/ui-client/src/components/Wizard/lib/StepsLinkedList.spec.ts b/packages/ui-client/src/components/Wizard/lib/StepsLinkedList.spec.ts new file mode 100644 index 0000000000000..7d1ba03a7bc0f --- /dev/null +++ b/packages/ui-client/src/components/Wizard/lib/StepsLinkedList.spec.ts @@ -0,0 +1,117 @@ +import type { StepMetadata } from './StepNode'; +import StepsLinkedList from './StepsLinkedList'; + +describe('StepsLinkedList', () => { + const steps: StepMetadata[] = [ + { id: '1', title: 'Step 1' }, + { id: '2', title: 'Step 2' }, + { id: '3', title: 'Step 3' }, + ]; + + it('should initialize with a list of steps', () => { + const list = new StepsLinkedList(steps); + expect(list.size).toBe(3); + expect(list.head?.id).toBe('1'); + expect(list.tail?.id).toBe('3'); + expect(list.head?.disabled).toBe(false); + expect(list.head?.next?.disabled).toBe(true); + }); + + it('should append a new step to the list', () => { + const list = new StepsLinkedList([]); + list.append({ id: '1', title: 'Step 1' }); + expect(list.size).toBe(1); + expect(list.head?.id).toBe('1'); + expect(list.tail?.id).toBe('1'); + + list.append({ id: '2', title: 'Step 2' }); + expect(list.size).toBe(2); + expect(list.tail?.id).toBe('2'); + expect(list.tail?.prev?.id).toBe('1'); + expect(list.head?.next?.id).toBe('2'); + }); + + it('should update an existing step if append is called with an existing id', () => { + const list = new StepsLinkedList([{ id: '1', title: 'Old Title' }]); + const stateChangeListener = jest.fn(); + list.on('stateChanged', stateChangeListener); + + list.append({ id: '1', title: 'New Title' }); + + expect(list.size).toBe(1); + expect(list.get('1')?.title).toBe('New Title'); + expect(stateChangeListener).toHaveBeenCalledTimes(1); + }); + + it('should remove a step from the list', () => { + const list = new StepsLinkedList(steps); + const removedNode = list.remove('2'); + + expect(removedNode?.id).toBe('2'); + expect(list.size).toBe(2); + expect(list.get('2')).toBeNull(); + expect(list.head?.next?.id).toBe('3'); + expect(list.tail?.prev?.id).toBe('1'); + }); + + it('should handle removing the head node', () => { + const list = new StepsLinkedList(steps); + list.remove('1'); + expect(list.head?.id).toBe('2'); + expect(list.head?.prev).toBeNull(); + expect(list.size).toBe(2); + }); + + it('should handle removing the tail node', () => { + const list = new StepsLinkedList(steps); + list.remove('3'); + expect(list.tail?.id).toBe('2'); + expect(list.tail?.next).toBeNull(); + expect(list.size).toBe(2); + }); + + it('should return null when trying to remove a non-existent node', () => { + const list = new StepsLinkedList(steps); + const result = list.remove('4'); + expect(result).toBeNull(); + expect(list.size).toBe(3); + }); + + it('should retrieve a step by its id', () => { + const list = new StepsLinkedList(steps); + const node = list.get('2'); + expect(node).not.toBeNull(); + expect(node?.id).toBe('2'); + }); + + it('should return null if a step id does not exist', () => { + const list = new StepsLinkedList(steps); + const node = list.get('non-existent'); + expect(node).toBeNull(); + }); + + it('should convert the list to an array', () => { + const list = new StepsLinkedList(steps); + const array = list.toArray(); + expect(Array.isArray(array)).toBe(true); + expect(array.length).toBe(3); + expect(array.map((node) => node.id)).toEqual(['1', '2', '3']); + }); + + it('should notify listeners on state changes', () => { + const list = new StepsLinkedList([]); + const stateChangeListener = jest.fn(); + list.on('stateChanged', stateChangeListener); + + list.append({ id: '1', title: 'Step 1' }); // 1 + list.append({ id: '2', title: 'Step 2' }); // 2 + const step2 = list.get('2'); + if (step2) { + list.enableStep(step2); // 3 + list.disableStep(step2); // 4 + } + list.remove('1'); // 5 + + expect(stateChangeListener).toHaveBeenCalledTimes(5); + }); +}); diff --git a/packages/ui-client/src/components/Wizard/lib/StepsLinkedList.ts b/packages/ui-client/src/components/Wizard/lib/StepsLinkedList.ts new file mode 100644 index 0000000000000..999cba2941b81 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/lib/StepsLinkedList.ts @@ -0,0 +1,138 @@ +import { Emitter } from '@rocket.chat/emitter'; + +import type { StepMetadata } from './StepNode'; +import StepNode from './StepNode'; + +/** + * A doubly linked list implementation to manage the state of wizard steps. + * It extends Emitter to notify about state changes. + */ +class StepsLinkedList extends Emitter<{ stateChanged: undefined }> { + public head: StepNode | null = null; + + public tail: StepNode | null = null; + + private stepNodeMap: Map = new Map(); + + /** + * Creates an instance of StepsLinkedList. + * @param steps - An array of step metadata to initialize the list with. + */ + constructor(steps: readonly StepMetadata[]) { + super(); + steps.forEach((step) => this.append(step)); + } + + /** + * Appends a new step to the end of the list or updates an existing one. + * @param value - The metadata for the step to append or update. + * @returns The created or updated StepNode. + */ + public append(value: StepMetadata): StepNode { + const existingStep = this.stepNodeMap.get(value.id); + + if (existingStep) { + existingStep.value = value; + this.notifyChanges(); + return existingStep; + } + + const newNode = new StepNode(value, !!this.head); + if (!this.head) { + this.head = newNode; + this.tail = newNode; + } else if (this.tail) { + this.tail.next = newNode; + newNode.prev = this.tail; + this.tail = newNode; + } + + this.stepNodeMap.set(value.id, newNode); + this.notifyChanges(); + return newNode; + } + + /** + * Removes a step from the list by its ID. + * @param id - The ID of the step to remove. + * @returns The removed StepNode, or null if not found. + */ + public remove(id: string | number): StepNode | null { + const nodeToRemove = this.stepNodeMap.get(id); + + if (!nodeToRemove) { + return null; + } + + if (nodeToRemove.prev) { + nodeToRemove.prev.next = nodeToRemove.next; + } else { + this.head = nodeToRemove.next; + } + + if (nodeToRemove.next) { + nodeToRemove.next.prev = nodeToRemove.prev; + } else { + this.tail = nodeToRemove.prev; + } + + nodeToRemove.prev = null; + nodeToRemove.next = null; + + this.stepNodeMap.delete(id); + this.notifyChanges(); + return nodeToRemove; + } + + /** + * Enables a specific step. + * @param step - The StepNode to enable. + */ + public enableStep(step: StepNode) { + step.enable(); + this.notifyChanges(); + } + + /** + * Disables a specific step. + * @param step - The StepNode to disable. + */ + public disableStep(step: StepNode) { + step.disable(); + this.notifyChanges(); + } + + /** + * Emits a 'stateChanged' event to notify listeners of changes. + */ + private notifyChanges() { + this.emit('stateChanged'); + } + + /** + * Retrieves a step by its ID. + * @param id - The ID of the step to retrieve. + * @returns The StepNode if found, otherwise null. + */ + public get(id: string | number): StepNode | null { + const node = this.stepNodeMap.get(id); + return node || null; + } + + /** + * Converts the linked list to an array of StepNodes. + * @returns An array of all StepNodes in the list. + */ + public toArray(): StepNode[] { + return Array.from(this.stepNodeMap.values()); + } + + /** + * Gets the total number of steps in the list. + */ + get size(): number { + return this.stepNodeMap.size; + } +} + +export default StepsLinkedList; diff --git a/packages/ui-client/src/components/Wizard/mocks/createMockWizardApi.ts b/packages/ui-client/src/components/Wizard/mocks/createMockWizardApi.ts new file mode 100644 index 0000000000000..fec96cccafb73 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/mocks/createMockWizardApi.ts @@ -0,0 +1,21 @@ +import { WizardAPI } from '../WizardContext'; +import StepsLinkedList from '../lib/StepsLinkedList'; + +export const createMockWizardApi = (overrides?: Partial) => { + const steps = new StepsLinkedList([ + { id: 'test-step-1', title: 'Test Step 1' }, + { id: 'test-step-2', title: 'Test Step 2' }, + { id: 'test-step-3', title: 'Test Step 3' }, + ]); + + return { + steps, + currentStep: steps.head?.next ?? null, + next: jest.fn(), + previous: jest.fn(), + register: jest.fn(), + goTo: jest.fn(), + resetNextSteps: jest.fn(), + ...overrides, + }; +}; diff --git a/packages/ui-client/src/components/Wizard/useWizard.spec.tsx b/packages/ui-client/src/components/Wizard/useWizard.spec.tsx new file mode 100644 index 0000000000000..4fd04ccdff085 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/useWizard.spec.tsx @@ -0,0 +1,132 @@ +import { renderHook, act } from '@testing-library/react'; + +import type { StepMetadata } from './lib/StepNode'; +import { useWizard } from './useWizard'; + +const initialSteps: StepMetadata[] = [ + { id: 'step1', title: 'Step 1' }, + { id: 'step2', title: 'Step 2' }, + { id: 'step3', title: 'Step 3' }, +]; + +describe('useWizard', () => { + it('should initialize with the first step as the current step', () => { + const { result } = renderHook(() => useWizard({ steps: initialSteps })); + expect(result.current.currentStep?.id).toBe('step1'); + }); + + it('should navigate to the next step and enable it', async () => { + const { result } = renderHook(() => useWizard({ steps: initialSteps })); + + expect(result.current.steps.get('step2')?.disabled).toBe(true); + + await act(() => result.current.next()); + + expect(result.current.currentStep?.id).toBe('step2'); + expect(result.current.steps.get('step2')?.disabled).toBe(false); + }); + + it('should not navigate if there is no next step', async () => { + const { result } = renderHook(() => useWizard({ steps: initialSteps })); + + await act(() => result.current.next()); // To step2 + await act(() => result.current.next()); // To step3 + + expect(result.current.currentStep?.id).toBe('step3'); + + await act(() => result.current.next()); // Already at the end + + expect(result.current.currentStep?.id).toBe('step3'); + }); + + it('should navigate to the previous step', async () => { + const { result } = renderHook(() => useWizard({ steps: initialSteps })); + + await act(() => result.current.next()); + + expect(result.current.currentStep?.id).toBe('step2'); + + await act(() => result.current.previous()); + + expect(result.current.currentStep?.id).toBe('step1'); + }); + + it('should not navigate if there is no previous step', async () => { + const { result } = renderHook(() => useWizard({ steps: initialSteps })); + + expect(result.current.currentStep?.id).toBe('step1'); + + await act(() => result.current.previous()); + + expect(result.current.currentStep?.id).toBe('step1'); + }); + + it('should navigate to a specific step if it is enabled', async () => { + const { result } = renderHook(() => useWizard({ steps: initialSteps })); + + await act(() => { + const step3 = result.current.steps.get('step3'); + step3 && result.current.steps.enableStep(step3); + }); + + await act(() => { + const step3 = result.current.steps.get('step3'); + step3 && result.current.goTo(step3); + }); + + expect(result.current.currentStep?.id).toBe('step3'); + }); + + it('should not navigate to a specific step if it is disabled', async () => { + const { result } = renderHook(() => useWizard({ steps: initialSteps })); + + expect(result.current.steps.get('step3')?.disabled).toBe(true); + + await act(() => { + const step3 = result.current.steps.get('step3'); + step3 && result.current.goTo(step3); + }); + + expect(result.current.currentStep?.id).toBe('step1'); + }); + + it('should disable all steps after the current one', async () => { + const { result } = renderHook(() => useWizard({ steps: initialSteps })); + + // Enable all steps + await act(() => result.current.next()); + await act(() => result.current.next()); + + expect(result.current.steps.get('step2')?.disabled).toBe(false); + expect(result.current.steps.get('step3')?.disabled).toBe(false); + + // Go back to step 1 + await act(() => { + const step1 = result.current.steps.get('step1'); + step1 && result.current.goTo(step1); + }); + + // Reset + await act(() => result.current.resetNextSteps()); + + expect(result.current.steps.get('step2')?.disabled).toBe(true); + expect(result.current.steps.get('step3')?.disabled).toBe(true); + }); + + it('should register a new step and allow unregistering it', async () => { + const { result } = renderHook(() => useWizard({ steps: initialSteps })); + + expect(result.current.steps.get('step4')).toBeNull(); + + let unregister: () => void; + await act(() => { + unregister = result.current.register({ id: 'step4', title: 'Step 4' }); + }); + + expect(result.current.steps.get('step4')?.id).toBe('step4'); + + await act(() => unregister()); + + expect(result.current.steps.get('step4')).toBeNull(); + }); +}); diff --git a/packages/ui-client/src/components/Wizard/useWizard.tsx b/packages/ui-client/src/components/Wizard/useWizard.tsx new file mode 100644 index 0000000000000..e882f9a8d43e7 --- /dev/null +++ b/packages/ui-client/src/components/Wizard/useWizard.tsx @@ -0,0 +1,101 @@ +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useMemo, useState } from 'react'; + +import type { WizardAPI } from './WizardContext'; +import type { StepMetadata } from './lib/StepNode'; +import type StepNode from './lib/StepNode'; +import StepsLinkedList from './lib/StepsLinkedList'; + +type UseWizardProps = { + steps: StepMetadata[]; +}; + +/** + * Custom hook to manage the state and navigation of a wizard. + * It provides methods to register steps, navigate between them, and manage their state. + * + * @param {UseWizardProps} props - The properties for the wizard. + * @returns {WizardAPI} The API for managing the wizard state and navigation. + */ +export const useWizard = ({ steps: stepsMetadata }: UseWizardProps) => { + const [steps] = useState(new StepsLinkedList(stepsMetadata)); + const [currentStep, setCurrentStep] = useState(steps.head); + + /** + * Registers a new step in the wizard. + * If a step with the same ID already exists, it updates the existing step. + */ + const register = useEffectEvent((stepMetadata: StepMetadata) => { + const step = steps.append(stepMetadata); + return () => steps.remove(step.id); + }); + + /** + * Navigates to a specific step in the wizard. + * If the step is disabled, it does nothing. + * + * @param {StepNode} step - The step to navigate to. + */ + const goTo = useEffectEvent(async (step: StepNode) => { + if (step.disabled) { + return; + } + + setCurrentStep(step); + }); + + /** + * Navigates to the next step in the wizard. + * If there is no next step, it does nothing. + */ + const next = useEffectEvent(async () => { + if (!currentStep?.next) { + return; + } + + steps.enableStep(currentStep.next); + goTo(currentStep.next); + }); + + /** + * Navigates to the previous step in the wizard. + * If there is no previous step, it does nothing. + */ + const previous = useEffectEvent(async () => { + if (!currentStep?.prev) { + return; + } + + steps.enableStep(currentStep.prev); + goTo(currentStep.prev); + }); + + /** + * Resets the next steps in the wizard. + * It disables all steps that come after the current step. + */ + const resetNextSteps = useEffectEvent(() => { + if (!currentStep) { + return; + } + + let step = currentStep; + while (step.next) { + steps.disableStep(step.next); + step = step.next; + } + }); + + return useMemo( + () => ({ + steps, + register, + currentStep, + next, + previous, + goTo, + resetNextSteps, + }), + [currentStep, goTo, next, previous, register, steps, resetNextSteps], + ); +}; diff --git a/packages/ui-client/src/components/Wizard/useWizardContext.tsx b/packages/ui-client/src/components/Wizard/useWizardContext.tsx new file mode 100644 index 0000000000000..077463019ef7b --- /dev/null +++ b/packages/ui-client/src/components/Wizard/useWizardContext.tsx @@ -0,0 +1,13 @@ +import { useContext } from 'react'; + +import { WizardContext } from './WizardContext'; + +export const useWizardContext = () => { + const context = useContext(WizardContext); + + if (!context) { + throw new Error('useWizardContext must be used within a WizardProvider'); + } + + return context; +}; diff --git a/packages/ui-client/src/components/Wizard/useWizardSteps.spec.ts b/packages/ui-client/src/components/Wizard/useWizardSteps.spec.ts new file mode 100644 index 0000000000000..2672182ed7dee --- /dev/null +++ b/packages/ui-client/src/components/Wizard/useWizardSteps.spec.ts @@ -0,0 +1,62 @@ +import { renderHook, act } from '@testing-library/react'; + +import type { StepMetadata } from './lib/StepNode'; +import StepsLinkedList from './lib/StepsLinkedList'; +import { useWizardSteps } from './useWizardSteps'; + +it('should return the initial array of steps from the list', () => { + const initialSteps: StepMetadata[] = [{ id: '1', title: 'Step 1' }]; + const list = new StepsLinkedList(initialSteps); + + const { result } = renderHook(() => useWizardSteps(list)); + + expect(result.current).toEqual(list.toArray()); +}); + +it('should update the steps when the list emits a stateChanged event', () => { + const initialSteps: StepMetadata[] = [{ id: '1', title: 'Step 1' }]; + const list = new StepsLinkedList(initialSteps); + + const { result } = renderHook(() => useWizardSteps(list)); + + expect(result.current).toEqual(list.toArray()); + + act(() => list.append({ id: '2', title: 'Step 2' })); + + expect(result.current).toEqual(list.toArray()); +}); + +it('should subscribe to list changes on mount and unsubscribe on unmount', () => { + const list = new StepsLinkedList([]); + const onSpy = jest.spyOn(list, 'on'); + const offSpy = jest.spyOn(list, 'off'); + + const { unmount } = renderHook(() => useWizardSteps(list)); + + expect(onSpy).toHaveBeenCalledWith('stateChanged', expect.any(Function)); + + unmount(); + + expect(offSpy).toHaveBeenCalledWith('stateChanged', expect.any(Function)); +}); + +it('should return the latest state on re-render if the list instance changes', () => { + const initialSteps1: StepMetadata[] = [{ id: '1', title: 'Step 1' }]; + const list1 = new StepsLinkedList(initialSteps1); + + const { result, rerender } = renderHook(({ list }) => useWizardSteps(list), { + initialProps: { list: list1 }, + }); + + expect(result.current).toEqual(list1.toArray()); + + const initialSteps2: StepMetadata[] = [ + { id: 'a', title: 'Step A' }, + { id: 'b', title: 'Step B' }, + ]; + const list2 = new StepsLinkedList(initialSteps2); + + rerender({ list: list2 }); + + expect(result.current).toEqual(list2.toArray()); +}); diff --git a/packages/ui-client/src/components/Wizard/useWizardSteps.tsx b/packages/ui-client/src/components/Wizard/useWizardSteps.tsx new file mode 100644 index 0000000000000..1ae7793a55add --- /dev/null +++ b/packages/ui-client/src/components/Wizard/useWizardSteps.tsx @@ -0,0 +1,31 @@ +import { useCallback, useRef, useSyncExternalStore } from 'react'; + +import type StepNode from './lib/StepNode'; +import type StepsLinkedList from './lib/StepsLinkedList'; + +/** + * Custom hook to manage the state of wizard steps. + * It uses a linked list to store the steps and provides a way to subscribe to changes. + * + * @param {StepsLinkedList} list - The linked list containing the steps. + * @returns {StepNode[]} The current state of the steps. + */ +export const useWizardSteps = (list: StepsLinkedList) => { + const stateRef = useRef([]); + + const getSnapshot = useCallback(() => stateRef.current, []); + + const subscribe = useCallback( + (onStoreChange: () => void) => { + stateRef.current = list.toArray(); + + return list.on('stateChanged', (): void => { + stateRef.current = list.toArray(); + onStoreChange(); + }); + }, + [list], + ); + + return useSyncExternalStore(subscribe, getSnapshot); +}; diff --git a/packages/ui-client/src/components/index.ts b/packages/ui-client/src/components/index.ts index e199f5ff638c3..702dc0550b8b1 100644 --- a/packages/ui-client/src/components/index.ts +++ b/packages/ui-client/src/components/index.ts @@ -15,3 +15,4 @@ export { default as AnnouncementBanner } from './AnnouncementBanner'; export { default as UserAutoComplete } from './UserAutoComplete'; export * from './GenericMenu'; export * from './Modal'; +export * from './Wizard';