diff --git a/.changeset/delete-wizard.md b/.changeset/delete-wizard.md new file mode 100644 index 0000000000..76dd935f1d --- /dev/null +++ b/.changeset/delete-wizard.md @@ -0,0 +1,64 @@ +--- +'@lg-templates/delete-wizard': minor +--- + +Initial release of `DeleteWizard`. + +```tsx + + + + +
Step 1 contents
+ + + + + + +
Step 2 contents
+ + + + +``` + +### DeleteWizard +Establishes a context, and only renders the `activeStep` (managed internally, or provided with the `activeStep` prop). Accepts a `DeleteWizard.Header` and any number of `DeleteWizard.Step`s as children. + +`DeleteWizard` and all sub-components include template styling. + +### DeleteWizard.Header +A convenience wrapper around `CanvasHeader` + +### DeleteWizard.Step +A convenience wrapper around `Wizard.Step` to ensure the correct context. +Like the basic `Wizard.Step`, of `requiresAcknowledgement` is true, the step must have `isAcknowledged` set in context, (or passed in as a controlled prop) for the Footer's primary button to be enabled. (see the Wizard and DeleteWizard demos in Storybook) + + +### DeleteWizard.StepContent +A styled `div` for use inside a `DeleteWizard.Step` to ensure proper page scrolling and footer positioning + +### DeleteWizard.Footer +A wrapper around `Wizard.Footer` with embedded styles and convenience props for the DeleteWizard template. +`DeleteWizard.Footer` accepts optional `backButtonText`, `cancelButtonText` and `primaryButtonText` props for simpler wizard creation. +The primary button variant is defined based on the `activeStep`— `"danger"` for the final steps, and `"primary"` for all preceding steps. +Also defines the `leftGlyph` to for the final step. + +You can override this behavior by providing the button props object (see FormFooter). + +Use the top level `onDelete`, `onCancel` and `onStepChange` callbacks to handle footer button clicks. \ No newline at end of file diff --git a/.changeset/wizard.md b/.changeset/wizard.md index 43a87b291f..6e23497c04 100644 --- a/.changeset/wizard.md +++ b/.changeset/wizard.md @@ -4,4 +4,4 @@ Initial Wizard package release. -See [README.md](./README.md) for usage guidelines \ No newline at end of file +See [README.md](./README.md) for usage guidelines diff --git a/package.json b/package.json index 196927163b..8eea14ade0 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "link": "lg link", "lint": "lg lint", "prepublishOnly": "pnpm run build && pnpm build:ts-downlevel && pnpm build:docs", - "publish": "pnpm changeset publish --public", + "publish": "pnpm publish -r", "reset:react17": "npx node ./scripts/react17/reset.mjs; pnpm run init", "slackbot": "lg slackbot", "start": "npx storybook dev -p 9001 --no-version-updates --no-open", diff --git a/packages/delete-wizard/README.md b/packages/delete-wizard/README.md new file mode 100644 index 0000000000..b4488e72a3 --- /dev/null +++ b/packages/delete-wizard/README.md @@ -0,0 +1,80 @@ +# Delete Wizard + +![npm (scoped)](https://img.shields.io/npm/v/@leafygreen-ui/delete-wizard.svg) + +#### [View on MongoDB.design](https://www.mongodb.design/component/delete-wizard/live-example/) + +## Installation + +### PNPM + +```shell +pnpm add @leafygreen-ui/delete-wizard +``` + +### Yarn + +```shell +yarn add @leafygreen-ui/delete-wizard +``` + +### NPM + +```shell +npm install @leafygreen-ui/delete-wizard +``` + +```tsx + + + + +
Step 1 contents
+ + + + + + +
Step 2 contents
+ + , + variant: 'danger', + children: 'Delete my thing', + onClick: handleDelete, + }} + /> + + +``` + +### DeleteWizard + +Establishes a context, and only renders the `activeStep` (managed internally, or provided with the `activeStep` prop). Accepts a `DeleteWizard.Header` and any number of `DeleteWizard.Step`s as children. + +`DeleteWizard` and all sub-components include template styling. + +### DeleteWizard.Header + +A convenience wrapper around `CanvasHeader` + +### DeleteWizard.Step + +A convenience wrapper around `Wizard.Step` to ensure the correct context. +Like the basic `Wizard.Step`, of `requiresAcknowledgement` is true, the step must have `isAcknowledged` set in context, (or passed in as a controlled prop) for the Footer's primary button to be enabled. (see the Wizard and DeleteWizard demos in Storybook) + +### DeleteWizard.StepContent + +A styled `div` for use inside a `DeleteWizard.Step` to ensure proper page scrolling and footer positioning + +### DeleteWizard.Footer + +A wrapper around Wizard.Footer with embedded styles for the DeleteWizard template diff --git a/packages/delete-wizard/package.json b/packages/delete-wizard/package.json new file mode 100644 index 0000000000..68016d844d --- /dev/null +++ b/packages/delete-wizard/package.json @@ -0,0 +1,52 @@ +{ + "name": "@leafygreen-ui/delete-wizard", + "version": "0.0.1", + "description": "LeafyGreen UI Kit Delete Wizard", + "main": "./dist/umd/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "license": "Apache-2.0", + "exports": { + ".": { + "require": "./dist/umd/index.js", + "import": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts" + }, + "./testing": { + "require": "./dist/umd/testing/index.js", + "import": "./dist/esm/testing/index.js", + "types": "./dist/types/testing/index.d.ts" + } + }, + "scripts": { + "build": "lg-build bundle", + "tsc": "lg-build tsc", + "docs": "lg-build docs" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@leafygreen-ui/canvas-header": "workspace:^", + "@leafygreen-ui/compound-component": "workspace:^", + "@leafygreen-ui/emotion": "workspace:^", + "@leafygreen-ui/form-footer": "workspace:^", + "@leafygreen-ui/icon": "workspace:^", + "@leafygreen-ui/lib": "workspace:^", + "@leafygreen-ui/tokens": "workspace:^", + "@leafygreen-ui/wizard": "workspace:^", + "@lg-tools/test-harnesses": "workspace:^" + }, + "homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/packages/delete-wizard", + "repository": { + "type": "git", + "url": "https://github.com/mongodb/leafygreen-ui" + }, + "bugs": { + "url": "https://jira.mongodb.org/projects/LG/summary" + }, + "devDependencies": { + "@faker-js/faker": "^10.1.0", + "@leafygreen-ui/typography": "workspace:^" + } +} diff --git a/packages/delete-wizard/src/DeleteWizard.stories.tsx b/packages/delete-wizard/src/DeleteWizard.stories.tsx new file mode 100644 index 0000000000..ffe471d80f --- /dev/null +++ b/packages/delete-wizard/src/DeleteWizard.stories.tsx @@ -0,0 +1,144 @@ +/* eslint-disable no-console */ +import React from 'react'; +import { faker } from '@faker-js/faker'; +import { StoryObj } from '@storybook/react'; +import { userEvent, within } from '@storybook/test'; + +import { css } from '@leafygreen-ui/emotion'; +import BeakerIcon from '@leafygreen-ui/icon/Beaker'; +import { BackLink, Body } from '@leafygreen-ui/typography'; + +import { ExampleStepContent } from './testUtils/ExampleStepContent'; +import { DeleteWizard } from '.'; + +faker.seed(0); +const demoResourceName = faker.database.mongodbObjectId(); +const demoSteps = [ + { + description: faker.lorem.paragraph(), + content: faker.lorem.paragraphs(24), + }, + { + description: faker.lorem.paragraph(), + content: faker.lorem.paragraphs(24), + }, +]; + +export default { + title: 'Templates/DeleteWizard', + component: DeleteWizard, +}; + +export const LiveExample: StoryObj = { + parameters: { + controls: { + exclude: ['children', 'onStepChange'], + }, + chromatic: { + disableSnapshot: true, + }, + }, + args: { + activeStep: undefined, + }, + render: args => { + const handleCancel = () => { + console.log('[STORYBOOK]: Cancelling wizard. Reloading iFrame'); + window.location.reload(); + }; + + const handleDelete = () => { + alert('[STORYBOOK]: Deleting thing!'); + console.log('[STORYBOOK]: Deleting thing! Reloading iFrame'); + window.location.reload(); + }; + + const handleStepChange = step => { + console.log('[STORYBOOK] step changed to ', step); + }; + + return ( +
+ + } + backLink={Back} + className={css` + margin-inline: 72px; + `} + /> + + ( + {p} + ))} + /> + + + + + ( + {p} + ))} + /> + + + +
+ ); + }, +}; + +export const Step1: StoryObj = { + args: { + activeStep: 0, + }, + render: LiveExample.render, +}; + +export const Step2Default: StoryObj = { + args: { + activeStep: 1, + }, + render: LiveExample.render, +}; + +export const Step2Acknowledged: StoryObj = { + args: { + activeStep: 1, + }, + play: ({ canvasElement }) => { + const checkbox = within(canvasElement).getByTestId( + 'acknowledgement-checkbox', + ); + userEvent.click(checkbox); + }, + render: LiveExample.render, +}; diff --git a/packages/delete-wizard/src/DeleteWizard/DeleteWizard.spec.tsx b/packages/delete-wizard/src/DeleteWizard/DeleteWizard.spec.tsx new file mode 100644 index 0000000000..411d5bcb2e --- /dev/null +++ b/packages/delete-wizard/src/DeleteWizard/DeleteWizard.spec.tsx @@ -0,0 +1,252 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { getTestUtils } from '../testing'; + +import { DeleteWizard } from '.'; + +describe('packages/delete-wizard', () => { + describe('rendering', () => { + test('renders step 1 by default', () => { + const { getByTestId, queryByTestId } = render( + + +
Step 1 content
+
+ +
Step 2 content
+
+
, + ); + + expect(getByTestId('step-1-content')).toBeInTheDocument(); + expect(queryByTestId('step-2-content')).not.toBeInTheDocument(); + }); + + test('renders a Header component as child', () => { + render( + + + +
Step 1 content
+
+
, + ); + + const { getHeader } = getTestUtils(); + const header = getHeader(); + + expect(header).toBeInTheDocument(); + expect(header).toHaveTextContent('Delete Cluster'); + }); + + test('renders Header above steps regardless of whether it is passed as the last react child', () => { + const { container } = render( + + +
Step 1 content
+
+ +
, + ); + + const { getHeader, getActiveStep } = getTestUtils(); + const header = getHeader(); + const activeStep = getActiveStep(); + + // Get the parent (DeleteWizard root) to check order + const deleteWizardRoot = container.firstChild as HTMLElement; + const children = Array.from(deleteWizardRoot.children); + + // Header should come before the active step in the DOM + const headerIndex = children.indexOf(header); + const stepIndex = children.findIndex(child => child.contains(activeStep)); + + expect(headerIndex).toBeLessThan(stepIndex); + }); + + test('does not render any non-step or non-header children', () => { + const { queryByTestId } = render( + + +
This should not render
+ +
Step 1 content
+
+ Also should not render +
, + ); + + // Non-DeleteWizard elements should not be rendered + expect(queryByTestId('invalid-element-1')).not.toBeInTheDocument(); + expect(queryByTestId('invalid-element-2')).not.toBeInTheDocument(); + // But valid children should be rendered + expect(queryByTestId('step-1-content')).toBeInTheDocument(); + }); + + test('renders the step via activeStep prop', () => { + const { queryByTestId, getByTestId } = render( + + + +
Step 1 content
+
+ +
Step 2 content
+
+
, + ); + + // Should render the second step when activeStep is 1 + expect(queryByTestId('step-1-content')).not.toBeInTheDocument(); + expect(getByTestId('step-2-content')).toBeInTheDocument(); + }); + }); + + describe('interaction', () => { + test('renders step 2 when primary button is clicked', () => { + const { getByTestId, queryByTestId } = render( + + + +
Step 1 content
+ +
+ +
Step 2 content
+ +
+
, + ); + + // Start at step 1 + expect(getByTestId('step-1-content')).toBeInTheDocument(); + expect(queryByTestId('step-2-content')).not.toBeInTheDocument(); + + // Click next to go to step 2 + const { getPrimaryButton } = getTestUtils(); + userEvent.click(getPrimaryButton()); + + expect(queryByTestId('step-1-content')).not.toBeInTheDocument(); + expect(getByTestId('step-2-content')).toBeInTheDocument(); + }); + + test('calls onStepChange when the primary button is clicked', () => { + const onStepChange = jest.fn(); + + render( + + + +
Step 1 content
+ +
+ +
Step 2 content
+ +
+
, + ); + + const { getPrimaryButton } = getTestUtils(); + userEvent.click(getPrimaryButton()); + + expect(onStepChange).toHaveBeenCalledWith(1); + }); + + test('calls onStepChange when the back button is clicked', () => { + const onStepChange = jest.fn(); + + render( + + + +
Step 1 content
+ +
+ +
Step 2 content
+ +
+
, + ); + + const { getBackButton } = getTestUtils(); + userEvent.click(getBackButton()); + + expect(onStepChange).toHaveBeenCalledWith(0); + }); + + test('calls onCancel when the cancel button is clicked', () => { + const onCancel = jest.fn(); + + render( + + + +
Step 1 content
+ +
+
, + ); + + const { getCancelButton } = getTestUtils(); + userEvent.click(getCancelButton()); + + expect(onCancel).toHaveBeenCalled(); + }); + + test('calls onDelete when the primary button is clicked on final step', () => { + const onDelete = jest.fn(); + + render( + + + +
Step 1 content
+ +
+ +
Step 2 content
+ +
+
, + ); + + const { getPrimaryButton } = getTestUtils(); + userEvent.click(getPrimaryButton()); + + expect(onDelete).toHaveBeenCalled(); + }); + + test('does not call onDelete when primary button is clicked on non-final step', () => { + const onDelete = jest.fn(); + + render( + + + +
Step 1 content
+ +
+ +
Step 2 content
+ +
+
, + ); + + const { getPrimaryButton } = getTestUtils(); + userEvent.click(getPrimaryButton()); + + expect(onDelete).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/delete-wizard/src/DeleteWizard/DeleteWizard.styles.ts b/packages/delete-wizard/src/DeleteWizard/DeleteWizard.styles.ts new file mode 100644 index 0000000000..80ced6203e --- /dev/null +++ b/packages/delete-wizard/src/DeleteWizard/DeleteWizard.styles.ts @@ -0,0 +1,8 @@ +import { css } from '@leafygreen-ui/emotion'; + +export const wizardWrapperStyles = css` + position: relative; + display: flex; + flex-direction: column; + overflow: scroll; +`; diff --git a/packages/delete-wizard/src/DeleteWizard/DeleteWizard.tsx b/packages/delete-wizard/src/DeleteWizard/DeleteWizard.tsx new file mode 100644 index 0000000000..0609be8ca7 --- /dev/null +++ b/packages/delete-wizard/src/DeleteWizard/DeleteWizard.tsx @@ -0,0 +1,82 @@ +import React from 'react'; + +import { + CompoundComponent, + findChild, +} from '@leafygreen-ui/compound-component'; +import { cx } from '@leafygreen-ui/emotion'; +import { Wizard } from '@leafygreen-ui/wizard'; + +import { DEFAULT_LGID_ROOT, getLgIds } from '../utils/getLgIds'; + +import { DeleteWizardSubComponentKeys } from './compoundComponentProperties'; +import { wizardWrapperStyles } from './DeleteWizard.styles'; +import { DeleteWizardProps } from './DeleteWizard.types'; +import { DeleteWizardContextProvider } from './DeleteWizardContext'; +import { DeleteWizardFooter } from './DeleteWizardFooter'; +import { DeleteWizardHeader } from './DeleteWizardHeader'; +import { DeleteWizardStep } from './DeleteWizardStep'; + +/** + * The parent DeleteWizard component. + * Pass a `DeleteWizard.Header` and any number of `DeleteWizard.Step`s as children + */ +export const DeleteWizard = CompoundComponent( + ({ + activeStep, + children, + className, + onCancel, + onDelete, + onStepChange, + 'data-lgid': dataLgId = DEFAULT_LGID_ROOT, + ...rest + }: DeleteWizardProps) => { + const lgIds = getLgIds(dataLgId); + const header = findChild(children, DeleteWizardSubComponentKeys.Header); + + return ( +
+ {header} + + + {children} + + +
+ ); + }, + { + displayName: 'DeleteWizard', + /** + * A wrapper around the {@link CanvasHeader} component for embedding into a DeleteWizard + */ + Header: DeleteWizardHeader, + + /** + * A simple wrapper around Wizard.Step to ensure correct Wizard context + */ + Step: DeleteWizardStep, + + /** + * A wrapper around Wizard.Footer with embedded styles for the DeleteWizard template. + * Render this inside of each Step with the relevant button props for that Step. + * + * Back and Primary buttons trigger onStepChange. + * Automatically renders the "Back" button for all Steps except the first + */ + Footer: DeleteWizardFooter, + }, +); diff --git a/packages/delete-wizard/src/DeleteWizard/DeleteWizard.types.ts b/packages/delete-wizard/src/DeleteWizard/DeleteWizard.types.ts new file mode 100644 index 0000000000..853800f6c9 --- /dev/null +++ b/packages/delete-wizard/src/DeleteWizard/DeleteWizard.types.ts @@ -0,0 +1,10 @@ +import { ComponentProps, MouseEventHandler } from 'react'; + +import { WizardProps } from '@leafygreen-ui/wizard'; + +export interface DeleteWizardProps + extends WizardProps, + Omit, 'children'> { + onCancel?: MouseEventHandler; + onDelete?: MouseEventHandler; +} diff --git a/packages/delete-wizard/src/DeleteWizard/DeleteWizardContext.tsx b/packages/delete-wizard/src/DeleteWizard/DeleteWizardContext.tsx new file mode 100644 index 0000000000..146140f9ab --- /dev/null +++ b/packages/delete-wizard/src/DeleteWizard/DeleteWizardContext.tsx @@ -0,0 +1,50 @@ +import React, { createContext, PropsWithChildren, useContext } from 'react'; + +import { useWizardContext, WizardContextData } from '@leafygreen-ui/wizard'; + +import { getLgIds, GetLgIdsReturnType } from '../utils/getLgIds'; + +import { DeleteWizardProps } from './DeleteWizard.types'; + +export interface DeleteWizardContextData { + onCancel?: DeleteWizardProps['onCancel']; + onDelete?: DeleteWizardProps['onDelete']; + lgIds: GetLgIdsReturnType; +} + +export const DeleteWizardContext = createContext({ + lgIds: getLgIds(), +}); + +export const DeleteWizardContextProvider = ({ + children, + onCancel, + onDelete, + lgIds, +}: PropsWithChildren) => { + return ( + + {children} + + ); +}; + +/** + * A re-export of `useWizardContext` specifically for this DeleteWizard + */ +export const useDeleteWizardContext = (): DeleteWizardContextData & + WizardContextData => { + const wizardContext = useWizardContext(); + const deleteWizardContext = useContext(DeleteWizardContext); + + return { + ...wizardContext, + ...deleteWizardContext, + }; +}; diff --git a/packages/delete-wizard/src/DeleteWizard/DeleteWizardFooter.tsx b/packages/delete-wizard/src/DeleteWizard/DeleteWizardFooter.tsx new file mode 100644 index 0000000000..bf6ab2aef1 --- /dev/null +++ b/packages/delete-wizard/src/DeleteWizard/DeleteWizardFooter.tsx @@ -0,0 +1,120 @@ +import React, { MouseEventHandler } from 'react'; + +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { css, cx } from '@leafygreen-ui/emotion'; +import { type PrimaryStandardButtonProps } from '@leafygreen-ui/form-footer'; +import TrashIcon from '@leafygreen-ui/icon/Trash'; +import { Either } from '@leafygreen-ui/lib'; +import { breakpoints } from '@leafygreen-ui/tokens'; +import { + Wizard, + WizardFooterProps, + WizardSubComponentProperties, +} from '@leafygreen-ui/wizard'; + +import { useDeleteWizardContext } from './DeleteWizardContext'; + +const footerStyles = css` + position: sticky; + bottom: 0; +`; + +const footerContentStyles = css` + margin-inline: auto; + max-width: ${breakpoints.XLDesktop}px; +`; + +type DeleteWizardFooterProps = Either< + WizardFooterProps & { + /** + * Sets the Back button text (for steps > 1) + */ + backButtonText?: string; + /** + * Sets the Cancel button text. + * The Cancel button will not render if no text is provided + */ + cancelButtonText?: string; + + /** + * Sets the Primary button text. + * + * The Primary button icon and variant are set automatically based on the activeStep index. + * Provide a `primaryButtonProps` to override this behavior + */ + primaryButtonText?: string; + }, + 'primaryButtonProps' | 'primaryButtonText' +>; + +/** + * A wrapper around Wizard.Footer with embedded styles for the DeleteWizard template + */ +export const DeleteWizardFooter = CompoundSubComponent( + ({ + className, + contentClassName, + primaryButtonText, + cancelButtonText, + backButtonText, + primaryButtonProps, + cancelButtonProps, + backButtonProps, + ...props + }: DeleteWizardFooterProps) => { + const { activeStep, totalSteps, onCancel, onDelete } = + useDeleteWizardContext(); + const isLastStep = activeStep === totalSteps - 1; + + const handlePrimaryButtonClick: MouseEventHandler< + HTMLButtonElement + > = e => { + primaryButtonProps?.onClick?.(e); + + if (isLastStep) { + onDelete?.(e); + } + }; + + const handleCancelButtonClick: MouseEventHandler = e => { + cancelButtonProps?.onClick?.(e); + onCancel?.(e); + }; + + const _primaryButtonProps: PrimaryStandardButtonProps = { + children: primaryButtonText ?? '', + variant: isLastStep ? 'danger' : 'primary', + leftGlyph: isLastStep ? : undefined, + ...primaryButtonProps, + // we define `onClick` after spreading props so this handler will always take precedence + onClick: handlePrimaryButtonClick, + }; + + const _backButtonProps = { + children: backButtonText, + ...backButtonProps, + }; + + const _cancelButtonProps = { + children: cancelButtonText, + ...cancelButtonProps, + // we define `onClick` after spreading props so this handler will always take precedence + onClick: handleCancelButtonClick, + }; + + return ( + + ); + }, + { + displayName: 'DeleteWizardFooter', + key: WizardSubComponentProperties.Footer, + }, +); diff --git a/packages/delete-wizard/src/DeleteWizard/DeleteWizardHeader.tsx b/packages/delete-wizard/src/DeleteWizard/DeleteWizardHeader.tsx new file mode 100644 index 0000000000..fd44bdd9d5 --- /dev/null +++ b/packages/delete-wizard/src/DeleteWizard/DeleteWizardHeader.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { CanvasHeader, CanvasHeaderProps } from '@leafygreen-ui/canvas-header'; +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { LgIdProps } from '@leafygreen-ui/lib'; + +import { DeleteWizardSubComponentKeys } from './compoundComponentProperties'; +import { useDeleteWizardContext } from './DeleteWizardContext'; + +/** + * A wrapper around the {@link CanvasHeader} component for embedding into a DeleteWizard + */ +export const DeleteWizardHeader = CompoundSubComponent( + (props: CanvasHeaderProps & LgIdProps) => { + const { lgIds } = useDeleteWizardContext(); + return ; + }, + { + displayName: 'DeleteWizardHeader', + key: DeleteWizardSubComponentKeys.Header, + }, +); diff --git a/packages/delete-wizard/src/DeleteWizard/DeleteWizardStep.tsx b/packages/delete-wizard/src/DeleteWizard/DeleteWizardStep.tsx new file mode 100644 index 0000000000..69fb69bf8e --- /dev/null +++ b/packages/delete-wizard/src/DeleteWizard/DeleteWizardStep.tsx @@ -0,0 +1,66 @@ +import React, { ComponentProps } from 'react'; + +import { + CompoundSubComponent, + filterChildren, + findChild, +} from '@leafygreen-ui/compound-component'; +import { css, cx } from '@leafygreen-ui/emotion'; +import { + useWizardStepContext, + Wizard, + WizardStepProps, + WizardSubComponentProperties, +} from '@leafygreen-ui/wizard'; + +import { DeleteWizardSubComponentKeys } from './compoundComponentProperties'; +import { useDeleteWizardContext } from './DeleteWizardContext'; + +export const useDeleteWizardStepContext = useWizardStepContext; + +export interface DeleteWizardStepProps + extends WizardStepProps, + ComponentProps<'div'> {} +/** + * A wrapper around Wizard.Step + */ +export const DeleteWizardStep = CompoundSubComponent( + ({ + children, + className, + requiresAcknowledgement, + ...rest + }: DeleteWizardStepProps) => { + const { lgIds } = useDeleteWizardContext(); + const footerChild = findChild( + children, + WizardSubComponentProperties.Footer, + ); + + const restChildren = filterChildren(children, [ + WizardSubComponentProperties.Footer, + ]); + + return ( + +
+ {restChildren} +
+ {footerChild} +
+ ); + }, + { + displayName: 'DeleteWizardStep', + key: DeleteWizardSubComponentKeys.Step, + }, +); diff --git a/packages/delete-wizard/src/DeleteWizard/compoundComponentProperties.ts b/packages/delete-wizard/src/DeleteWizard/compoundComponentProperties.ts new file mode 100644 index 0000000000..3d6139f351 --- /dev/null +++ b/packages/delete-wizard/src/DeleteWizard/compoundComponentProperties.ts @@ -0,0 +1,8 @@ +import { WizardSubComponentProperties } from '@leafygreen-ui/wizard'; + +export const DeleteWizardSubComponentKeys = { + Header: 'isDeleteWizardHeader', + Step: WizardSubComponentProperties.Step, + StepContent: 'isDeleteWizardStepContent', + Footer: WizardSubComponentProperties.Footer, +}; diff --git a/packages/delete-wizard/src/DeleteWizard/index.ts b/packages/delete-wizard/src/DeleteWizard/index.ts new file mode 100644 index 0000000000..fcc50a1d6d --- /dev/null +++ b/packages/delete-wizard/src/DeleteWizard/index.ts @@ -0,0 +1,9 @@ +export { DeleteWizard } from './DeleteWizard'; +export { type DeleteWizardProps } from './DeleteWizard.types'; +export { + DeleteWizardContext, + type DeleteWizardContextData, + DeleteWizardContextProvider, + useDeleteWizardContext, +} from './DeleteWizardContext'; +export { useDeleteWizardStepContext } from './DeleteWizardStep'; diff --git a/packages/delete-wizard/src/index.ts b/packages/delete-wizard/src/index.ts new file mode 100644 index 0000000000..e73d5c48d3 --- /dev/null +++ b/packages/delete-wizard/src/index.ts @@ -0,0 +1,6 @@ +export { + DeleteWizard, + type DeleteWizardProps, + useDeleteWizardContext, + useDeleteWizardStepContext, +} from './DeleteWizard'; diff --git a/packages/delete-wizard/src/testUtils/ExampleStepContent.tsx b/packages/delete-wizard/src/testUtils/ExampleStepContent.tsx new file mode 100644 index 0000000000..1c2e98fbc0 --- /dev/null +++ b/packages/delete-wizard/src/testUtils/ExampleStepContent.tsx @@ -0,0 +1,56 @@ +import React, { PropsWithChildren, ReactNode } from 'react'; + +import { css } from '@leafygreen-ui/emotion'; +import { Description, H3, Label } from '@leafygreen-ui/typography'; + +import { useDeleteWizardStepContext } from '../'; + +export const ExampleStepContent = ({ + index, + description, + content, +}: PropsWithChildren<{ + index: number; + description: string; + content: ReactNode; +}>) => { + const { isAcknowledged, setAcknowledged, requiresAcknowledgement } = + useDeleteWizardStepContext(); + return ( +
+

Step {index + 1}

+ {description} + +
+ {requiresAcknowledgement && ( +
+ setAcknowledged(e.target.checked)} + /> + +
+ )} + {content} +
+
+ ); +}; diff --git a/packages/delete-wizard/src/testing/getTestUtils.spec.tsx b/packages/delete-wizard/src/testing/getTestUtils.spec.tsx new file mode 100644 index 0000000000..6b1e673bdc --- /dev/null +++ b/packages/delete-wizard/src/testing/getTestUtils.spec.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { DeleteWizard } from '.'; + +describe('packages/delete-wizard/getTestUtils', () => { + test('condition', () => {}); +}); diff --git a/packages/delete-wizard/src/testing/getTestUtils.tsx b/packages/delete-wizard/src/testing/getTestUtils.tsx new file mode 100644 index 0000000000..9f33dda6a1 --- /dev/null +++ b/packages/delete-wizard/src/testing/getTestUtils.tsx @@ -0,0 +1,86 @@ +import { findByLgId, getByLgId, queryByLgId } from '@lg-tools/test-harnesses'; + +import { LgIdString } from '@leafygreen-ui/lib'; +import { getTestUtils as getWizardTestUtils } from '@leafygreen-ui/wizard/testing'; + +import { DEFAULT_LGID_ROOT, getLgIds } from '../utils/getLgIds'; + +import { DeleteWizardTestUtilsReturnType } from './getTestUtils.types'; + +export const getTestUtils = ( + lgId: LgIdString = DEFAULT_LGID_ROOT, +): DeleteWizardTestUtilsReturnType => { + const lgIds = getLgIds(lgId); + + // Get the Wizard test utils (the DeleteWizard wraps a Wizard component internally) + const wizardUtils = getWizardTestUtils(lgIds.wizard); + + /** + * @returns the DeleteWizard root element using the `data-lgid` data attribute. + * Will throw if no elements match or if more than one match is found. + */ + const getDeleteWizard = () => getByLgId!(lgIds.root); + + /** + * @returns the DeleteWizard root element using the `data-lgid` data attribute or `null` if no elements match. + * Will throw if more than one match is found. + */ + const queryDeleteWizard = () => queryByLgId!(lgIds.root); + + /** + * @returns a promise that resolves to the DeleteWizard root element using the `data-lgid` data attribute. + * The promise is rejected if no elements match or if more than one match is found. + */ + const findDeleteWizard = () => findByLgId!(lgIds.root); + + /** + * @returns the DeleteWizard Header element using the `data-lgid` data attribute. + * Will throw if no elements match or if more than one match is found. + */ + const getHeader = () => getByLgId!(lgIds.header); + + /** + * @returns the DeleteWizard Header element using the `data-lgid` data attribute or `null` if no elements match. + * Will throw if more than one match is found. + */ + const queryHeader = () => queryByLgId!(lgIds.header); + + /** + * @returns a promise that resolves to the DeleteWizard Header element using the `data-lgid` data attribute. + * The promise is rejected if no elements match or if more than one match is found. + */ + const findHeader = () => findByLgId!(lgIds.header); + + /** + * @returns the currently active WizardStep element using the `data-lgid` data attribute. + * Will throw if no elements match or if more than one match is found. + */ + const getActiveStep = () => getByLgId!(lgIds.step); + + /** + * @returns the currently active WizardStep element using the `data-lgid` data attribute or `null` if no elements match. + * Will throw if more than one match is found. + */ + const queryActiveStep = () => queryByLgId!(lgIds.step); + + /** + * @returns a promise that resolves to the currently active WizardStep element using the `data-lgid` data attribute. + * The promise is rejected if no elements match or if more than one match is found. + */ + const findActiveStep = () => findByLgId!(lgIds.step); + + return { + // Spread all Wizard test utils (footer, buttons, etc.) + ...wizardUtils, + // DeleteWizard-specific utils + getDeleteWizard, + queryDeleteWizard, + findDeleteWizard, + getHeader, + queryHeader, + findHeader, + getActiveStep, + queryActiveStep, + findActiveStep, + }; +}; diff --git a/packages/delete-wizard/src/testing/getTestUtils.types.ts b/packages/delete-wizard/src/testing/getTestUtils.types.ts new file mode 100644 index 0000000000..86367125d7 --- /dev/null +++ b/packages/delete-wizard/src/testing/getTestUtils.types.ts @@ -0,0 +1,19 @@ +import { TestUtilsReturnType as WizardTestUtilsReturnType } from '@leafygreen-ui/wizard/testing'; + +export interface DeleteWizardTestUtilsReturnType + extends WizardTestUtilsReturnType { + // DeleteWizard root element utils + getDeleteWizard: () => HTMLElement; + queryDeleteWizard: () => HTMLElement | null; + findDeleteWizard: () => Promise; + + // Header utils + getHeader: () => HTMLElement; + queryHeader: () => HTMLElement | null; + findHeader: () => Promise; + + // Active Step utils (from Wizard) + getActiveStep: () => HTMLElement; + queryActiveStep: () => HTMLElement | null; + findActiveStep: () => Promise; +} diff --git a/packages/delete-wizard/src/testing/index.ts b/packages/delete-wizard/src/testing/index.ts new file mode 100644 index 0000000000..d07ed40dec --- /dev/null +++ b/packages/delete-wizard/src/testing/index.ts @@ -0,0 +1,2 @@ +export { getTestUtils } from './getTestUtils'; +export { type DeleteWizardTestUtilsReturnType } from './getTestUtils.types'; diff --git a/packages/delete-wizard/src/utils/getLgIds.ts b/packages/delete-wizard/src/utils/getLgIds.ts new file mode 100644 index 0000000000..d8013c828d --- /dev/null +++ b/packages/delete-wizard/src/utils/getLgIds.ts @@ -0,0 +1,18 @@ +import { LgIdString } from '@leafygreen-ui/lib'; +import { getLgIds as getWizardLgIds } from '@leafygreen-ui/wizard'; + +export const DEFAULT_LGID_ROOT = 'lg-delete_wizard'; + +export const getLgIds = (root: LgIdString = DEFAULT_LGID_ROOT) => { + const wizardRoot: LgIdString = `${root}-wizard`; + const wizardLgIds = getWizardLgIds(wizardRoot); + const ids = { + root, + header: `${root}-header`, + wizard: wizardRoot, + ...wizardLgIds, + } as const; + return ids; +}; + +export type GetLgIdsReturnType = ReturnType; diff --git a/packages/delete-wizard/tsconfig.json b/packages/delete-wizard/tsconfig.json new file mode 100644 index 0000000000..85a2a89e70 --- /dev/null +++ b/packages/delete-wizard/tsconfig.json @@ -0,0 +1,40 @@ +{ + "extends": "@lg-tools/build/config/package.tsconfig.json", + "compilerOptions": { + "paths": { + "@leafygreen-ui/icon/dist/*": ["../../packages/icon/src/generated/*"], + "@leafygreen-ui/*": ["../../packages/*/src"] + } + }, + "include": ["src/**/*"], + "exclude": ["**/*.spec.*", "**/*.stories.*"], + "references": [ + { + "path": "../../packages/canvas-header" + }, + { + "path": "../../packages/compound-component" + }, + { + "path": "../../packages/emotion" + }, + { + "path": "../../packages/icon" + }, + { + "path": "../../packages/lib" + }, + { + "path": "../../packages/tokens" + }, + { + "path": "../../packages/typography" + }, + { + "path": "../../packages/wizard" + }, + { + "path": "../../tools/test-harnesses" + } + ] +} diff --git a/packages/wizard/package.json b/packages/wizard/package.json index ffa0e34b5f..3d1b790261 100644 --- a/packages/wizard/package.json +++ b/packages/wizard/package.json @@ -1,6 +1,6 @@ { "name": "@leafygreen-ui/wizard", - "version": "0.1.0-local.1", + "version": "0.0.1", "description": "LeafyGreen UI Kit Wizard", "main": "./dist/umd/index.js", "module": "./dist/esm/index.js", diff --git a/packages/wizard/src/WizardContext/WizardContext.tsx b/packages/wizard/src/WizardContext/WizardContext.tsx index a8d40c933d..568b709b12 100644 --- a/packages/wizard/src/WizardContext/WizardContext.tsx +++ b/packages/wizard/src/WizardContext/WizardContext.tsx @@ -1,7 +1,9 @@ import React, { createContext, PropsWithChildren, useContext } from 'react'; +import { Optional } from '@leafygreen-ui/lib'; + +import type { GetLgIdsReturnType } from '../utils/getLgIds'; import { getLgIds } from '../utils/getLgIds'; -import { GetLgIdsReturnType } from '../utils/getLgIds'; export interface WizardContextData { /** @@ -43,7 +45,9 @@ export const WizardContext = createContext({ }); interface WizardProviderProps - extends PropsWithChildren> {} + extends PropsWithChildren< + Omit, 'isWizardContext'> + > {} export const WizardProvider = ({ children, diff --git a/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx b/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx index 9f8f10d366..74be2869cd 100644 --- a/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx +++ b/packages/wizard/src/WizardFooter/WizardFooter.spec.tsx @@ -256,4 +256,216 @@ describe('packages/wizard-footer', () => { expect(getByTestId('step-1')).toBeInTheDocument(); }); }); + + describe('primary button behavior', () => { + test('primary button is enabled by default', () => { + const { getByRole } = render( + + + + + , + ); + + const primaryButton = getByRole('button', { name: 'Continue' }); + expect(primaryButton).toBeEnabled(); + }); + + test('primary button advances to next step when clicked', async () => { + const onStepChange = jest.fn(); + + const { getByRole, getByTestId } = render( + + +
Step 1
+ +
+ +
Step 2
+ +
+
, + ); + + expect(getByTestId('step-1')).toBeInTheDocument(); + + const nextButton = getByRole('button', { name: 'Next' }); + await userEvent.click(nextButton); + + expect(onStepChange).toHaveBeenCalledWith(1); + expect(getByTestId('step-2')).toBeInTheDocument(); + }); + + describe('requiresAcknowledgement', () => { + test('primary button is disabled when step requires acknowledgement and is not acknowledged', () => { + const { getByRole } = render( + + +
Step content
+ +
+
, + ); + + const primaryButton = getByRole('button', { name: 'Continue' }); + expect(primaryButton).toHaveAttribute('aria-disabled', 'true'); + }); + + test('primary button is enabled when step requires acknowledgement and is acknowledged', async () => { + const TestComponent = () => { + const { setAcknowledged } = useWizardStepContext(); + return ( + <> +
Step content
+ + + + ); + }; + + const { getByRole } = render( + + + + + , + ); + + const primaryButton = getByRole('button', { name: 'Continue' }); + expect(primaryButton).toHaveAttribute('aria-disabled', 'true'); + + await userEvent.click(getByRole('button', { name: 'Acknowledge' })); + + expect(primaryButton).toHaveAttribute('aria-disabled', 'false'); + }); + + test('primary button is enabled when step does not require acknowledgement', () => { + const { getByRole } = render( + + +
Step content
+ +
+
, + ); + + const primaryButton = getByRole('button', { name: 'Continue' }); + expect(primaryButton).toHaveAttribute('aria-disabled', 'false'); + }); + + test('primary button can advance step after acknowledgement', async () => { + const TestComponent = () => { + const { setAcknowledged } = useWizardStepContext(); + return ( + <> +
Step content
+ + + + ); + }; + + const { getByRole, getByTestId } = render( + + +
Step 1
+ +
+ +
Step 2
+
+
, + ); + + expect(getByTestId('step-1')).toBeInTheDocument(); + + const primaryButton = getByRole('button', { name: 'Continue' }); + expect(primaryButton).toHaveAttribute('aria-disabled', 'true'); + + // Acknowledge the step + await userEvent.click(getByRole('button', { name: 'Acknowledge' })); + expect(primaryButton).toHaveAttribute('aria-disabled', 'false'); + + // Advance to next step + await userEvent.click(primaryButton); + expect(getByTestId('step-2')).toBeInTheDocument(); + }); + }); + }); + + describe('back button', () => { + test('back button is not rendered on first step', () => { + const { queryByRole } = render( + + + + + , + ); + + expect(queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); + }); + + test('back button is rendered on subsequent steps', async () => { + const { getByRole } = render( + + +
Step 1
+ +
+ +
Step 2
+ +
+
, + ); + + // Move to step 2 + await userEvent.click(getByRole('button', { name: 'Next' })); + + // Back button should now be visible + expect(getByRole('button', { name: 'Back' })).toBeInTheDocument(); + }); + + test('back button navigates to previous step', async () => { + const onStepChange = jest.fn(); + + const { getByRole, getByTestId } = render( + + +
Step 1
+ +
+ +
Step 2
+ +
+
, + ); + + // Move to step 2 + await userEvent.click(getByRole('button', { name: 'Next' })); + expect(getByTestId('step-2')).toBeInTheDocument(); + + // Go back to step 1 + await userEvent.click(getByRole('button', { name: 'Back' })); + expect(onStepChange).toHaveBeenCalledWith(0); + expect(getByTestId('step-1')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/wizard/src/index.ts b/packages/wizard/src/index.ts index 1c370edbab..5a3311bcca 100644 --- a/packages/wizard/src/index.ts +++ b/packages/wizard/src/index.ts @@ -1,3 +1,5 @@ +export { WizardSubComponentProperties } from './constants'; +export { getLgIds } from './utils/getLgIds'; export { Wizard, type WizardProps } from './Wizard'; export { useWizardContext, diff --git a/packages/wizard/src/testing/getTestUtils.tsx b/packages/wizard/src/testing/getTestUtils.tsx index 6dd5b53162..ec3c4175c8 100644 --- a/packages/wizard/src/testing/getTestUtils.tsx +++ b/packages/wizard/src/testing/getTestUtils.tsx @@ -1,4 +1,5 @@ import { findByLgId, getByLgId, queryByLgId } from '@lg-tools/test-harnesses'; +import { screen } from '@testing-library/react'; import { getTestUtils as getButtonUtils } from '@leafygreen-ui/button/testing'; import { LgIdString } from '@leafygreen-ui/lib'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8747f81e5f..21651b6c36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1713,6 +1713,43 @@ importers: specifier: ^29.5.12 version: 29.5.12 + packages/delete-wizard: + dependencies: + '@leafygreen-ui/canvas-header': + specifier: workspace:^ + version: link:../canvas-header + '@leafygreen-ui/compound-component': + specifier: workspace:^ + version: link:../compound-component + '@leafygreen-ui/emotion': + specifier: workspace:^ + version: link:../emotion + '@leafygreen-ui/form-footer': + specifier: workspace:^ + version: link:../form-footer + '@leafygreen-ui/icon': + specifier: workspace:^ + version: link:../icon + '@leafygreen-ui/lib': + specifier: workspace:^ + version: link:../lib + '@leafygreen-ui/tokens': + specifier: workspace:^ + version: link:../tokens + '@leafygreen-ui/wizard': + specifier: workspace:^ + version: link:../wizard + '@lg-tools/test-harnesses': + specifier: workspace:^ + version: link:../../tools/test-harnesses + devDependencies: + '@faker-js/faker': + specifier: ^10.1.0 + version: 10.1.0 + '@leafygreen-ui/typography': + specifier: workspace:^ + version: link:../typography + packages/descendants: dependencies: '@leafygreen-ui/hooks': diff --git a/tools/install/src/ALL_PACKAGES.ts b/tools/install/src/ALL_PACKAGES.ts index c2964f5cfc..ad324772f6 100644 --- a/tools/install/src/ALL_PACKAGES.ts +++ b/tools/install/src/ALL_PACKAGES.ts @@ -118,4 +118,5 @@ export const ALL_PACKAGES = [ '@lg-tools/update', '@lg-tools/validate', '@lg-mcp-ui/list-databases', + '@lg-templates/delete-wizard', ] as const;