Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e10049a
feat: implemented Wizard component
aleksandernsilva Jun 11, 2025
07470b6
feat: updated WizardTabs to take full width
aleksandernsilva Jul 2, 2025
7881de6
chore: broke down files and added jsdocs to StepNode and StepsLinkedList
aleksandernsilva Jul 4, 2025
82d920a
test: unit tests for StepNode
aleksandernsilva Jul 4, 2025
a3dbd9a
test: unit tests for StepsLinkedList
aleksandernsilva Jul 4, 2025
cfed084
chore: jsdocs for useWizard
aleksandernsilva Jul 4, 2025
518e8ab
test: unit tests for useWizard
aleksandernsilva Jul 4, 2025
2e3982e
chore: jsdocs for useWizardSteps
aleksandernsilva Jul 4, 2025
90af166
test: unit tests for useWizardSteps
aleksandernsilva Jul 4, 2025
f0ff318
chore: added subcomponents to Wizard stories
aleksandernsilva Jul 4, 2025
ab2824d
refactor: removed unnecessary box from WizardContent
aleksandernsilva Jul 4, 2025
ef61381
chore: corrected type in WizardContext
aleksandernsilva Jul 4, 2025
8be18c8
test: createMockWizardApi
aleksandernsilva Jul 4, 2025
9c5425b
test: unit tests for WizardBackButton
aleksandernsilva Jul 4, 2025
13cf129
test: unit tests for WizardNextButton
aleksandernsilva Jul 4, 2025
a1684e6
test: added a11y tests to Wizard
aleksandernsilva Jul 4, 2025
b56dd9c
chore: moved Wizard to ui-client
aleksandernsilva Jul 8, 2025
ea05c20
chore: changeset
aleksandernsilva Jul 9, 2025
0f46fb6
refactor: adjusted layout of WizardActions
aleksandernsilva Jul 9, 2025
a2c1f81
Adjust story
tassoevan Jul 12, 2025
740a907
Do not mock context consumers
tassoevan Jul 15, 2025
b35711b
Merge branch 'develop' into feat/wizard
kodiakhq[bot] Jul 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thirty-experts-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/ui-client': minor
---

Adds Wizard component to ui-client package
16 changes: 16 additions & 0 deletions apps/meteor/client/components/Wizard/WizardActions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box is={ButtonGroup} mbs={24} display='flex' justifyContent='end'>
{children}
</Box>
);
};

export default WizardActions;
19 changes: 19 additions & 0 deletions packages/ui-client/src/components/Wizard/Wizard.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<Story />);
expect(baseElement).toMatchSnapshot();
});

test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);

const results = await axe(container);
expect(results).toHaveNoViolations();
});
92 changes: 92 additions & 0 deletions packages/ui-client/src/components/Wizard/Wizard.stories.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<Box>
<Story />
</Box>
),
],
} satisfies Meta<typeof Wizard>;

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 (
<Wizard api={wizardApi}>
<WizardTabs ordered={ordered} />

<WizardContent id='first-step'>
<Box width='100%' height='100%' pbs={24}>
<States>
<StatesIcon name='magnifier' />
<StatesTitle>First step</StatesTitle>
</States>
</Box>

<WizardActions>
<WizardNextButton />
</WizardActions>
</WizardContent>
<WizardContent id='second-step'>
<Box width='100%' height='100%' pbs={24}>
<States>
<StatesIcon name='magnifier' />
<StatesTitle>Second step</StatesTitle>
</States>
</Box>

<WizardActions>
<WizardBackButton />
<WizardNextButton />
</WizardActions>
</WizardContent>
<WizardContent id='third-step'>
<Box width='100%' height='100%' pbs={24}>
<States>
<StatesIcon name='magnifier' />
<StatesTitle>Third step</StatesTitle>
</States>
</Box>

<WizardActions>
<Button primary onClick={() => window.alert('Finished!')}>
Finish
</Button>
</WizardActions>
</WizardContent>
</Wizard>
);
};

export const BasicWizard: StoryFn<typeof Wizard> = () => <WizardExample />;

export const OrderedTabsWizard: StoryFn<typeof Wizard> = () => <WizardExample ordered />;
17 changes: 17 additions & 0 deletions packages/ui-client/src/components/Wizard/Wizard.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<WizardContext.Provider value={api}>
<div className='steps-wizard'>{children}</div>
</WizardContext.Provider>
);

export default memo(Wizard);
14 changes: 14 additions & 0 deletions packages/ui-client/src/components/Wizard/WizardActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Box, ButtonGroup } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';

type WizardActionsProps = {
children: ReactNode;
};

const WizardActions = ({ children }: WizardActionsProps) => (
<Box className='steps-wizard-footer' pbs={24} display='flex' justifyContent='end'>
<ButtonGroup>{children}</ButtonGroup>
</Box>
);

export default WizardActions;
114 changes: 114 additions & 0 deletions packages/ui-client/src/components/Wizard/WizardBackButton.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<WizardBackButton />, {
wrapper: mockAppRoot()
.wrap((children) => <WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>)
.build(),
});
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument();
});

it('should render with custom children', () => {
render(<WizardBackButton>Go Back</WizardBackButton>, {
wrapper: mockAppRoot()
.wrap((children) => <WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>)
.build(),
});
expect(screen.getByRole('button', { name: 'Go Back' })).toBeInTheDocument();
});

it('should be disabled if there is no previous step', () => {
render(<WizardBackButton />, {
wrapper: mockAppRoot()
.wrap((children) => (
<WizardContext.Provider
value={{
...mockWizardApi,
currentStep: new StepNode({ id: 'step1', title: 'Step 1' }),
}}
>
{children}
</WizardContext.Provider>
))
.build(),
});
expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled();
});

it('should be disabled if the disabled prop is true', () => {
render(<WizardBackButton disabled />, {
wrapper: mockAppRoot()
.wrap((children) => <WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>)
.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(<WizardBackButton />, {
wrapper: mockAppRoot()
.wrap((children) => <WizardContext.Provider value={{ ...mockWizardApi, currentStep }}>{children}</WizardContext.Provider>)
.build(),
});
expect(screen.getByRole('button', { name: 'Back' })).toBeEnabled();
});

it('should call previous() on click', async () => {
render(<WizardBackButton />, {
wrapper: mockAppRoot()
.wrap((children) => <WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>)
.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(<WizardBackButton onClick={mockOnClick} />, {
wrapper: mockAppRoot()
.wrap((children) => <WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>)
.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(<WizardBackButton onClick={mockOnClick} />, {
wrapper: mockAppRoot()
.wrap((children) => <WizardContext.Provider value={mockWizardApi}>{children}</WizardContext.Provider>)
.build(),
});
const button = screen.getByRole('button', { name: 'Back' });
await userEvent.click(button);

expect(mockOnClick).toHaveBeenCalledTimes(1);
expect(mockWizardApi.previous).not.toHaveBeenCalled();
});
57 changes: 57 additions & 0 deletions packages/ui-client/src/components/Wizard/WizardBackButton.tsx
Original file line number Diff line number Diff line change
@@ -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<ComponentProps<typeof Button>, 'primary' | 'onClick'> & {
manual?: boolean;
onClick?(event?: MouseEvent<HTMLElement>): unknown | Promise<unknown>;
};

/**
* 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<HTMLButtonElement>) => unknown | Promise<unknown>} [props.onClick] - Click handler.
* @returns {JSX.Element} The rendered button.
*
* @example
* <WizardNextButton onClick={handleAction} />
*/
const WizardBackButton = ({ children, disabled, manual, onClick, ...props }: WizardBackButtonProps) => {
const { t } = useTranslation();
const { previous, currentStep } = useWizardContext();

const handleClick = async (event: MouseEvent<HTMLButtonElement>) => {
await onClick?.(event);

if (manual || event.isDefaultPrevented()) {
return;
}

previous();
};

return (
<Button {...props} disabled={!currentStep?.prev || disabled} onClick={handleClick}>
{children || t('Back')}
</Button>
);
};

export default WizardBackButton;
20 changes: 20 additions & 0 deletions packages/ui-client/src/components/Wizard/WizardContent.tsx
Original file line number Diff line number Diff line change
@@ -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 <div>{children}</div>;
};

export default memo(WizardContent);
17 changes: 17 additions & 0 deletions packages/ui-client/src/components/Wizard/WizardContext.tsx
Original file line number Diff line number Diff line change
@@ -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<WizardAPI | null>(null);
Loading
Loading