diff --git a/.changeset/delete-wizard.md b/.changeset/delete-wizard.md new file mode 100644 index 0000000000..f1d26deb48 --- /dev/null +++ b/.changeset/delete-wizard.md @@ -0,0 +1,57 @@ +--- +'@lg-templates/delete-wizard': minor +--- + +Initial release of `DeleteWizard`. + +```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 \ No newline at end of file diff --git a/.changeset/wizard.md b/.changeset/wizard.md index 0f6253b39b..e38a1ca100 100644 --- a/.changeset/wizard.md +++ b/.changeset/wizard.md @@ -32,6 +32,7 @@ Initial Wizard package release. The `Wizard` component establishes a context with an internal state, and will render only the `activeStep`. You can also control the Wizard externally using the `activeStep` and `onStepChange` callback. + Note: if you externally control the state, you opt out of the automatic range validation, and you must ensure that the provided `activeStep` index is valid relative to the `Wizard.Step`s provided. ### Wizard.Step diff --git a/package.json b/package.json index 196927163b..761202a655 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", @@ -99,7 +99,8 @@ "@lg-charts": "charts", "@lg-chat": "chat", "@lg-tools": "tools", - "@lg-mcp-ui": "mcp-ui" + "@lg-mcp-ui": "mcp-ui", + "@lg-templates": "templates" } }, "keywords": [ diff --git a/packages/wizard/src/Wizard/Wizard.tsx b/packages/wizard/src/Wizard/Wizard.tsx index 8a9c25f377..715c2c917b 100644 --- a/packages/wizard/src/Wizard/Wizard.tsx +++ b/packages/wizard/src/Wizard/Wizard.tsx @@ -7,6 +7,7 @@ import { import { useControlled } from '@leafygreen-ui/hooks'; import { WizardSubComponentProperties } from '../constants'; +import { getLgIds } from '../utils/getLgIds'; import { WizardProvider } from '../WizardContext/WizardContext'; import { WizardFooter } from '../WizardFooter'; import { WizardStep } from '../WizardStep'; @@ -14,7 +15,13 @@ import { WizardStep } from '../WizardStep'; import { WizardProps } from './Wizard.types'; export const Wizard = CompoundComponent( - ({ activeStep: activeStepProp, onStepChange, children }: WizardProps) => { + ({ + activeStep: activeStepProp, + onStepChange, + children, + 'data-lgid': dataLgId, + }: WizardProps) => { + const lgIds = getLgIds(dataLgId); const stepChildren = findChildren( children, WizardSubComponentProperties.Step, @@ -48,7 +55,11 @@ export const Wizard = CompoundComponent( ); return ( - + {stepChildren.map((child, i) => (i === activeStep ? child : null))} ); diff --git a/packages/wizard/src/Wizard/Wizard.types.ts b/packages/wizard/src/Wizard/Wizard.types.ts index fba53ea8ad..64412ccd64 100644 --- a/packages/wizard/src/Wizard/Wizard.types.ts +++ b/packages/wizard/src/Wizard/Wizard.types.ts @@ -1,6 +1,8 @@ import { ReactNode } from 'react'; -export interface WizardProps { +import { LgIdProps } from '@leafygreen-ui/lib'; + +export interface WizardProps extends LgIdProps { /** * The current active step index (0-based). * If provided, the component operates in controlled mode, and any interaction will not update internal state. diff --git a/packages/wizard/src/WizardContext/WizardContext.tsx b/packages/wizard/src/WizardContext/WizardContext.tsx index 06b4afba94..de5cb53a7a 100644 --- a/packages/wizard/src/WizardContext/WizardContext.tsx +++ b/packages/wizard/src/WizardContext/WizardContext.tsx @@ -1,5 +1,9 @@ import React, { createContext, PropsWithChildren, useContext } from 'react'; +import { Optional } from '@leafygreen-ui/lib'; + +import { getLgIds, GetLgIdsReturnType } from '../utils/getLgIds'; + export interface WizardContextData { isWizardContext: boolean; activeStep: number; @@ -11,21 +15,32 @@ export interface WizardContextData { * @returns */ updateStep: (step: number) => void; + lgIds: GetLgIdsReturnType; } export const WizardContext = createContext({ isWizardContext: false, activeStep: 0, updateStep: () => {}, + lgIds: { + step: 'lg-wizard-step', + footer: 'lg-wizard-footer', + footerPrimaryButton: 'lg-wizard-footer-primary_button', + footerBackButton: 'lg-wizard-footer-back_button', + footerCancelButton: 'lg-wizard-footer-cancel_button', + }, }); interface WizardProviderProps - extends PropsWithChildren> {} + extends PropsWithChildren< + Omit, 'isWizardContext'> + > {} export const WizardProvider = ({ children, activeStep, updateStep, + lgIds = getLgIds('lg-wizard'), }: WizardProviderProps) => { return ( {children} diff --git a/packages/wizard/src/WizardFooter/WizardFooter.tsx b/packages/wizard/src/WizardFooter/WizardFooter.tsx index 5a3ae710b2..a333c1b19d 100644 --- a/packages/wizard/src/WizardFooter/WizardFooter.tsx +++ b/packages/wizard/src/WizardFooter/WizardFooter.tsx @@ -18,7 +18,8 @@ export const WizardFooter = CompoundSubComponent( className, ...rest }: WizardFooterProps) => { - const { isWizardContext, activeStep, updateStep } = useWizardContext(); + const { isWizardContext, activeStep, updateStep, lgIds } = + useWizardContext(); const { isAcknowledged, requiresAcknowledgement } = useWizardStepContext(); const isPrimaryButtonDisabled = (requiresAcknowledgement && !isAcknowledged) || @@ -48,6 +49,7 @@ export const WizardFooter = CompoundSubComponent( 0 ? { diff --git a/packages/wizard/src/WizardStep/WizardStep.tsx b/packages/wizard/src/WizardStep/WizardStep.tsx index 4be39a9c69..c40e5eab97 100644 --- a/packages/wizard/src/WizardStep/WizardStep.tsx +++ b/packages/wizard/src/WizardStep/WizardStep.tsx @@ -17,7 +17,7 @@ import { WizardStepProvider } from './WizardStepContext'; export const WizardStep = CompoundSubComponent( ({ children, requiresAcknowledgement = false }: WizardStepProps) => { const stepId = useIdAllocator({ prefix: 'wizard-step' }); - const { isWizardContext } = useWizardContext(); + const { isWizardContext, lgIds } = useWizardContext(); if (!isWizardContext) { consoleOnce.error( @@ -36,13 +36,15 @@ export const WizardStep = CompoundSubComponent( ]); return ( - - {restChildren} - {footerChild} - +
+ + {restChildren} + {footerChild} + +
); }, { diff --git a/packages/wizard/src/index.ts b/packages/wizard/src/index.ts index 1c370edbab..56018ddec8 100644 --- a/packages/wizard/src/index.ts +++ b/packages/wizard/src/index.ts @@ -1,3 +1,4 @@ +export { WizardSubComponentProperties } from './constants'; export { Wizard, type WizardProps } from './Wizard'; export { useWizardContext, diff --git a/packages/wizard/src/testing/getTestUtils.spec.tsx b/packages/wizard/src/testing/getTestUtils.spec.tsx index 6e928dca63..5323afc5ab 100644 --- a/packages/wizard/src/testing/getTestUtils.spec.tsx +++ b/packages/wizard/src/testing/getTestUtils.spec.tsx @@ -1,8 +1,350 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { Wizard } from '.'; +import { Wizard } from '../Wizard'; + +import { getTestUtils } from './getTestUtils'; describe('packages/wizard/getTestUtils', () => { - test('condition', () => {}); + describe('Current Step utils', () => { + test('getCurrentStep returns the currently active step element', () => { + render( + + +
Step 1 content
+ +
+ +
Step 2 content
+ +
+
, + ); + + const { getCurrentStep } = getTestUtils(); + const step = getCurrentStep(); + expect(step).toBeInTheDocument(); + expect(step).toHaveAttribute('data-lgid', 'lg-wizard-step'); + // Verify it's the first step + expect(step).toContainElement( + document.querySelector('[data-testid="step-1"]'), + ); + expect(step).toHaveTextContent('Step 1 content'); + // Verify second step is not rendered + expect(document.querySelector('[data-testid="step-2"]')).toBeNull(); + }); + + test('getCurrentStep returns different step when activeStep changes', () => { + const { rerender } = render( + + +
Step 1
+ +
+ +
Step 2
+ +
+
, + ); + + const { getCurrentStep } = getTestUtils(); + const step1 = getCurrentStep(); + expect(step1).toHaveTextContent('Step 1'); + + rerender( + + +
Step 1
+ +
+ +
Step 2
+ +
+
, + ); + + const step2 = getCurrentStep(); + expect(step2).toHaveTextContent('Step 2'); + expect(step2).not.toBe(step1); + }); + + test('queryCurrentStep returns null when no step is present', () => { + render(
No wizard here
); + + const { queryCurrentStep } = getTestUtils(); + const step = queryCurrentStep(); + expect(step).not.toBeInTheDocument(); + }); + + test('findCurrentStep finds the current step element', async () => { + render( + + +
Step 1
+ +
+
, + ); + + const { findCurrentStep } = getTestUtils(); + const step = await findCurrentStep(); + expect(step).toBeInTheDocument(); + expect(step).toContainElement( + document.querySelector('[data-testid="step-content"]'), + ); + }); + }); + + describe('Footer utils', () => { + test('getFooter returns the correct footer element', () => { + render( + + +
Step 1
+ +
+
, + ); + + const { getFooter } = getTestUtils(); + const footer = getFooter(); + expect(footer).toBeInTheDocument(); + expect(footer.tagName).toBe('FOOTER'); + expect(footer).toHaveAttribute('data-testid', 'lg-wizard-footer'); + // Verify it contains the buttons + expect(footer).toHaveTextContent('Next Step'); + expect(footer).toHaveTextContent('Cancel Action'); + }); + + test('queryFooter returns null when footer is not present', () => { + render(
No wizard here
); + + const { queryFooter } = getTestUtils(); + const footer = queryFooter(); + expect(footer).not.toBeInTheDocument(); + }); + + test('findFooter finds the footer element', async () => { + render( + + +
Step 1
+ +
+
, + ); + + const { findFooter } = getTestUtils(); + const footer = await findFooter(); + expect(footer).toBeInTheDocument(); + expect(footer).toHaveTextContent('Submit'); + }); + }); + + describe('Button utils', () => { + test('getPrimaryButton returns the correct primary button element', () => { + render( + + +
Step 1
+ +
+
, + ); + + const { getPrimaryButton } = getTestUtils(); + const primaryButton = getPrimaryButton(); + expect(primaryButton).toBeInTheDocument(); + expect(primaryButton.tagName).toBe('BUTTON'); + expect(primaryButton).toHaveAttribute( + 'data-testid', + 'lg-wizard-footer-primary_button', + ); + expect(primaryButton).toHaveTextContent('Next Step'); + }); + + test('queryPrimaryButton returns null when button is not present', () => { + render(
No wizard here
); + + const { queryPrimaryButton } = getTestUtils(); + const button = queryPrimaryButton(); + expect(button).not.toBeInTheDocument(); + }); + + test('findPrimaryButton finds the primary button element', async () => { + render( + + +
Step 1
+ +
+
, + ); + + const { findPrimaryButton } = getTestUtils(); + const button = await findPrimaryButton(); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('Continue'); + }); + + test('getBackButton returns the correct back button element', () => { + render( + + +
Step 1
+ +
+ +
Step 2
+ +
+
, + ); + + const { getBackButton } = getTestUtils(); + const backButton = getBackButton(); + expect(backButton).toBeInTheDocument(); + expect(backButton.tagName).toBe('BUTTON'); + expect(backButton).toHaveAttribute( + 'data-testid', + 'lg-wizard-footer-back_button', + ); + expect(backButton).toHaveTextContent('Go Back'); + }); + + test('queryBackButton returns null when back button is not present', () => { + render( + + +
Step 1
+ +
+
, + ); + + const { queryBackButton } = getTestUtils(); + const button = queryBackButton(); + expect(button).not.toBeInTheDocument(); + }); + + test('findBackButton finds the back button element', async () => { + render( + + +
Step 1
+ +
+ +
Step 2
+ +
+
, + ); + + const { findBackButton } = getTestUtils(); + const button = await findBackButton(); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('Previous'); + }); + + test('getCancelButton returns the correct cancel button element', () => { + render( + + +
Step 1
+ +
+
, + ); + + const { getCancelButton } = getTestUtils(); + const cancelButton = getCancelButton(); + expect(cancelButton).toBeInTheDocument(); + expect(cancelButton.tagName).toBe('BUTTON'); + expect(cancelButton).toHaveAttribute( + 'data-testid', + 'lg-wizard-footer-cancel_button', + ); + expect(cancelButton).toHaveTextContent('Cancel Process'); + }); + + test('queryCancelButton returns null when cancel button is not present', () => { + render( + + +
Step 1
+ +
+
, + ); + + const { queryCancelButton } = getTestUtils(); + const button = queryCancelButton(); + expect(button).not.toBeInTheDocument(); + }); + + test('findCancelButton finds the cancel button element', async () => { + render( + + +
Step 1
+ +
+
, + ); + + const { findCancelButton } = getTestUtils(); + const button = await findCancelButton(); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('Abort'); + }); + }); + + describe('with custom lgId', () => { + test('uses custom lgId when provided', () => { + render( + + +
Step 1
+ +
+
, + ); + + const { getCurrentStep, getFooter, getPrimaryButton } = + getTestUtils('custom-wizard'); + + const step = getCurrentStep(); + expect(step).toBeInTheDocument(); + expect(step).toHaveAttribute('data-lgid', 'custom-wizard-step'); + + const footer = getFooter(); + expect(footer).toBeInTheDocument(); + expect(footer).toHaveAttribute('data-testid', 'custom-wizard-footer'); + + const button = getPrimaryButton(); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute( + 'data-testid', + 'custom-wizard-footer-primary_button', + ); + }); + }); }); diff --git a/packages/wizard/src/testing/getTestUtils.tsx b/packages/wizard/src/testing/getTestUtils.tsx index ad89a6e99d..6eb64f61cc 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 { LgIdString } from '@leafygreen-ui/lib'; @@ -11,5 +12,120 @@ export const getTestUtils = ( ): TestUtilsReturnType => { const lgIds = getLgIds(lgId); - return {}; + /** + * @returns a promise that resolves to the current 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 findCurrentStep = () => findByLgId!(lgIds.step); + + /** + * @returns the current WizardStep element using the `data-lgid` data attribute. + * Will throw if no elements match or if more than one match is found. + */ + const getCurrentStep = () => getByLgId!(lgIds.step); + + /** + * @returns the current WizardStep element using the `data-lgid` data attribute or `null` if no elements match. + * Will throw if more than one match is found. + */ + const queryCurrentStep = () => queryByLgId!(lgIds.step); + + /** + * @returns a promise that resolves to the WizardFooter element using the `data-testid` data attribute. + * The promise is rejected if no elements match or if more than one match is found. + */ + const findFooter = () => screen.findByTestId(lgIds.footer); + + /** + * @returns the WizardFooter element using the `data-testid` data attribute. + * Will throw if no elements match or if more than one match is found. + */ + const getFooter = () => screen.getByTestId(lgIds.footer); + + /** + * @returns the WizardFooter element using the `data-testid` data attribute or `null` if no elements match. + * Will throw if more than one match is found. + */ + const queryFooter = () => screen.queryByTestId(lgIds.footer); + + /** + * @returns the primary button element using the `data-testid` data attribute. + * Will throw if no elements match or if more than one match is found. + */ + const getPrimaryButton = () => + screen.getByTestId(lgIds.footerPrimaryButton); + + /** + * @returns the primary button element using the `data-testid` data attribute or `null` if no elements match. + * Will throw if more than one match is found. + */ + const queryPrimaryButton = () => + screen.queryByTestId(lgIds.footerPrimaryButton); + + /** + * @returns a promise that resolves to the primary button element using the `data-testid` data attribute. + * The promise is rejected if no elements match or if more than one match is found. + */ + const findPrimaryButton = () => + screen.findByTestId(lgIds.footerPrimaryButton); + + /** + * @returns the back button element using the `data-testid` data attribute. + * Will throw if no elements match or if more than one match is found. + */ + const getBackButton = () => + screen.getByTestId(lgIds.footerBackButton); + + /** + * @returns the back button element using the `data-testid` data attribute or `null` if no elements match. + * Will throw if more than one match is found. + */ + const queryBackButton = () => + screen.queryByTestId(lgIds.footerBackButton); + + /** + * @returns a promise that resolves to the back button element using the `data-testid` data attribute. + * The promise is rejected if no elements match or if more than one match is found. + */ + const findBackButton = () => + screen.findByTestId(lgIds.footerBackButton); + + /** + * @returns the cancel button element using the `data-testid` data attribute. + * Will throw if no elements match or if more than one match is found. + */ + const getCancelButton = () => + screen.getByTestId(lgIds.footerCancelButton); + + /** + * @returns the cancel button element using the `data-testid` data attribute or `null` if no elements match. + * Will throw if more than one match is found. + */ + const queryCancelButton = () => + screen.queryByTestId(lgIds.footerCancelButton); + + /** + * @returns a promise that resolves to the cancel button element using the `data-testid` data attribute. + * The promise is rejected if no elements match or if more than one match is found. + */ + const findCancelButton = () => + screen.findByTestId(lgIds.footerCancelButton); + + return { + findCurrentStep, + getCurrentStep, + queryCurrentStep, + findFooter, + getFooter, + queryFooter, + getPrimaryButton, + queryPrimaryButton, + findPrimaryButton, + getBackButton, + queryBackButton, + findBackButton, + getCancelButton, + queryCancelButton, + findCancelButton, + }; }; diff --git a/packages/wizard/src/testing/getTestUtils.types.ts b/packages/wizard/src/testing/getTestUtils.types.ts index 4b2df87c73..5c16c95026 100644 --- a/packages/wizard/src/testing/getTestUtils.types.ts +++ b/packages/wizard/src/testing/getTestUtils.types.ts @@ -1 +1,26 @@ -export interface TestUtilsReturnType {} +export interface TestUtilsReturnType { + // Current Step utils + findCurrentStep: () => Promise; + getCurrentStep: () => HTMLDivElement; + queryCurrentStep: () => HTMLDivElement | null; + + // Footer utils + findFooter: () => Promise; + getFooter: () => HTMLElement; + queryFooter: () => HTMLElement | null; + + // Primary Button utils + getPrimaryButton: () => HTMLButtonElement; + queryPrimaryButton: () => HTMLButtonElement | null; + findPrimaryButton: () => Promise; + + // Back Button utils + getBackButton: () => HTMLButtonElement; + queryBackButton: () => HTMLButtonElement | null; + findBackButton: () => Promise; + + // Cancel Button utils + getCancelButton: () => HTMLButtonElement; + queryCancelButton: () => HTMLButtonElement | null; + findCancelButton: () => Promise; +} diff --git a/packages/wizard/src/utils/getLgIds.ts b/packages/wizard/src/utils/getLgIds.ts index 9590c84563..565a9db506 100644 --- a/packages/wizard/src/utils/getLgIds.ts +++ b/packages/wizard/src/utils/getLgIds.ts @@ -4,7 +4,11 @@ export const DEFAULT_LGID_ROOT = 'lg-wizard'; export const getLgIds = (root: LgIdString = DEFAULT_LGID_ROOT) => { const ids = { - root, + step: `${root}-step`, + footer: `${root}-footer`, + footerPrimaryButton: `${root}-footer-primary_button`, + footerBackButton: `${root}-footer-back_button`, + footerCancelButton: `${root}-footer-cancel_button`, } as const; return ids; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9287f9bb2..e925282fd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3871,6 +3871,40 @@ importers: specifier: workspace:^ version: link:../typography + templates/delete-wizard: + dependencies: + '@leafygreen-ui/canvas-header': + specifier: workspace:^ + version: link:../../packages/canvas-header + '@leafygreen-ui/compound-component': + specifier: workspace:^ + version: link:../../packages/compound-component + '@leafygreen-ui/emotion': + specifier: workspace:^ + version: link:../../packages/emotion + '@leafygreen-ui/lib': + specifier: workspace:^ + version: link:../../packages/lib + '@leafygreen-ui/tokens': + specifier: workspace:^ + version: link:../../packages/tokens + '@leafygreen-ui/wizard': + specifier: workspace:^ + version: link:../../packages/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/icon': + specifier: workspace:^ + version: link:../../packages/icon + '@leafygreen-ui/typography': + specifier: workspace:^ + version: link:../../packages/typography + tools/build: dependencies: '@babel/core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 89652f8019..fa74b62b57 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,3 +6,4 @@ packages: - 'mcp-ui/*' - 'packages/*' - 'tools/*' + - 'templates/*' diff --git a/templates/delete-wizard/README.md b/templates/delete-wizard/README.md new file mode 100644 index 0000000000..b4488e72a3 --- /dev/null +++ b/templates/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/templates/delete-wizard/package.json b/templates/delete-wizard/package.json new file mode 100644 index 0000000000..8b17ea2cd4 --- /dev/null +++ b/templates/delete-wizard/package.json @@ -0,0 +1,51 @@ +{ + "name": "@lg-templates/delete-wizard", + "version": "0.1.0-local.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/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/icon": "workspace:^", + "@leafygreen-ui/typography": "workspace:^" + } +} diff --git a/templates/delete-wizard/src/DeleteWizard.stories.tsx b/templates/delete-wizard/src/DeleteWizard.stories.tsx new file mode 100644 index 0000000000..4790b00d2f --- /dev/null +++ b/templates/delete-wizard/src/DeleteWizard.stories.tsx @@ -0,0 +1,123 @@ +/* eslint-disable no-console */ +import React from 'react'; +import { faker } from '@faker-js/faker'; +import { StoryObj } from '@storybook/react'; + +import { css } from '@leafygreen-ui/emotion'; +import BeakerIcon from '@leafygreen-ui/icon/Beaker'; +import TrashIcon from '@leafygreen-ui/icon/Trash'; +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'], + }, + }, + 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(); + }; + + return ( +
+ + } + backLink={Back} + className={css` + margin-inline: 72px; + `} + /> + + + ( + {p} + ))} + /> + + + + + + + ( + {p} + ))} + /> + + , + variant: 'danger', + children: 'Delete my thing', + onClick: handleDelete, + }} + /> + + +
+ ); + }, +}; diff --git a/templates/delete-wizard/src/DeleteWizard/DeleteWizard.spec.tsx b/templates/delete-wizard/src/DeleteWizard/DeleteWizard.spec.tsx new file mode 100644 index 0000000000..609e4ac6b7 --- /dev/null +++ b/templates/delete-wizard/src/DeleteWizard/DeleteWizard.spec.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { DeleteWizard } from '.'; + +describe('packages/delete-wizard', () => { + test('condition', () => {}); +}); diff --git a/templates/delete-wizard/src/DeleteWizard/DeleteWizard.styles.ts b/templates/delete-wizard/src/DeleteWizard/DeleteWizard.styles.ts new file mode 100644 index 0000000000..80ced6203e --- /dev/null +++ b/templates/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/templates/delete-wizard/src/DeleteWizard/DeleteWizard.tsx b/templates/delete-wizard/src/DeleteWizard/DeleteWizard.tsx new file mode 100644 index 0000000000..9ca67cb533 --- /dev/null +++ b/templates/delete-wizard/src/DeleteWizard/DeleteWizard.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import { + CompoundComponent, + findChild, +} from '@leafygreen-ui/compound-component'; +import { cx } from '@leafygreen-ui/emotion'; +import { useWizardContext, Wizard } from '@leafygreen-ui/wizard'; + +import { DeleteWizardSubComponentKeys } from './compoundComponentProperties'; +import { wizardWrapperStyles } from './DeleteWizard.styles'; +import { DeleteWizardProps } from './DeleteWizard.types'; +import { DeleteWizardFooter } from './DeleteWizardFooter'; +import { DeleteWizardHeader } from './DeleteWizardHeader'; +import { DeleteWizardStep } from './DeleteWizardStep'; +import { DeleteWizardStepContent } from './DeleteWizardStepContents'; + +/** + * A re-export of `useWizardContext` specifically for this DeleteWizard + */ +export const useDeleteWizardContext = useWizardContext; + +/** + * The parent DeleteWizard component. + * Pass a `DeleteWizard.Header` and any number of `DeleteWizard.Step`s as children + */ +export const DeleteWizard = CompoundComponent( + ({ activeStep, onStepChange, children, className }: DeleteWizardProps) => { + 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 styled `div` for use inside a `DeleteWizard.Step` to ensure proper page scrolling and footer positioning + */ + StepContent: DeleteWizardStepContent, + + /** + * 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/templates/delete-wizard/src/DeleteWizard/DeleteWizard.types.ts b/templates/delete-wizard/src/DeleteWizard/DeleteWizard.types.ts new file mode 100644 index 0000000000..ea069fcd32 --- /dev/null +++ b/templates/delete-wizard/src/DeleteWizard/DeleteWizard.types.ts @@ -0,0 +1,7 @@ +import { ComponentProps } from 'react'; + +import { WizardProps } from '@leafygreen-ui/wizard'; + +export interface DeleteWizardProps + extends WizardProps, + Omit, 'children'> {} diff --git a/templates/delete-wizard/src/DeleteWizard/DeleteWizardFooter.tsx b/templates/delete-wizard/src/DeleteWizard/DeleteWizardFooter.tsx new file mode 100644 index 0000000000..e4e13016c1 --- /dev/null +++ b/templates/delete-wizard/src/DeleteWizard/DeleteWizardFooter.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import { css, cx } from '@leafygreen-ui/emotion'; +import { breakpoints } from '@leafygreen-ui/tokens'; +import { Wizard, WizardFooterProps } from '@leafygreen-ui/wizard'; + +const footerStyles = css` + position: sticky; + bottom: 0; +`; + +const footerContentStyles = css` + margin-inline: auto; + max-width: ${breakpoints.XLDesktop}px; +`; + +/** + * A wrapper around Wizard.Footer with embedded styles for the DeleteWizard template + */ +export const DeleteWizardFooter = ({ + className, + contentClassName, + ...props +}: WizardFooterProps) => { + return ( + + ); +}; diff --git a/templates/delete-wizard/src/DeleteWizard/DeleteWizardHeader.tsx b/templates/delete-wizard/src/DeleteWizard/DeleteWizardHeader.tsx new file mode 100644 index 0000000000..0183f1e4f3 --- /dev/null +++ b/templates/delete-wizard/src/DeleteWizard/DeleteWizardHeader.tsx @@ -0,0 +1,12 @@ +import { CanvasHeader } from '@leafygreen-ui/canvas-header'; +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; + +import { DeleteWizardSubComponentKeys } from './compoundComponentProperties'; + +/** + * A wrapper around the {@link CanvasHeader} component for embedding into a DeleteWizard + */ +export const DeleteWizardHeader = CompoundSubComponent(CanvasHeader, { + displayName: 'DeleteWizardHeader', + key: DeleteWizardSubComponentKeys.Header, +}); diff --git a/templates/delete-wizard/src/DeleteWizard/DeleteWizardStep.tsx b/templates/delete-wizard/src/DeleteWizard/DeleteWizardStep.tsx new file mode 100644 index 0000000000..1f8b961161 --- /dev/null +++ b/templates/delete-wizard/src/DeleteWizard/DeleteWizardStep.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { + useWizardStepContext, + Wizard, + WizardStepProps, +} from '@leafygreen-ui/wizard'; + +import { DeleteWizardSubComponentKeys } from './compoundComponentProperties'; + +export const useDeleteWizardStepContext = useWizardStepContext; + +/** + * A wrapper around Wizard.Step + */ +export const DeleteWizardStep = CompoundSubComponent( + (props: WizardStepProps) => { + return ; + }, + { + displayName: 'DeleteWizardStep', + key: DeleteWizardSubComponentKeys.Step, + }, +); diff --git a/templates/delete-wizard/src/DeleteWizard/DeleteWizardStepContents.tsx b/templates/delete-wizard/src/DeleteWizard/DeleteWizardStepContents.tsx new file mode 100644 index 0000000000..f35b41d75b --- /dev/null +++ b/templates/delete-wizard/src/DeleteWizard/DeleteWizardStepContents.tsx @@ -0,0 +1,31 @@ +import React, { ComponentProps } from 'react'; + +import { CompoundSubComponent } from '@leafygreen-ui/compound-component'; +import { css, cx } from '@leafygreen-ui/emotion'; + +import { DeleteWizardSubComponentKeys } from './compoundComponentProperties'; + +/** + * A styled `div` for use inside a `DeleteWizard.Step` to ensure proper page scrolling and footer positioning + */ +export const DeleteWizardStepContent = CompoundSubComponent( + ({ children, className, ...rest }: ComponentProps<'div'>) => { + return ( +
+ {children} +
+ ); + }, + { + displayName: 'DeleteWizardStepContent', + key: DeleteWizardSubComponentKeys.StepContent, + }, +); diff --git a/templates/delete-wizard/src/DeleteWizard/compoundComponentProperties.ts b/templates/delete-wizard/src/DeleteWizard/compoundComponentProperties.ts new file mode 100644 index 0000000000..3d6139f351 --- /dev/null +++ b/templates/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/templates/delete-wizard/src/DeleteWizard/index.ts b/templates/delete-wizard/src/DeleteWizard/index.ts new file mode 100644 index 0000000000..6f385d4d71 --- /dev/null +++ b/templates/delete-wizard/src/DeleteWizard/index.ts @@ -0,0 +1,3 @@ +export { DeleteWizard, useDeleteWizardContext } from './DeleteWizard'; +export { type DeleteWizardProps } from './DeleteWizard.types'; +export { useDeleteWizardStepContext } from './DeleteWizardStep'; diff --git a/templates/delete-wizard/src/index.ts b/templates/delete-wizard/src/index.ts new file mode 100644 index 0000000000..e73d5c48d3 --- /dev/null +++ b/templates/delete-wizard/src/index.ts @@ -0,0 +1,6 @@ +export { + DeleteWizard, + type DeleteWizardProps, + useDeleteWizardContext, + useDeleteWizardStepContext, +} from './DeleteWizard'; diff --git a/templates/delete-wizard/src/testUtils/ExampleStepContent.tsx b/templates/delete-wizard/src/testUtils/ExampleStepContent.tsx new file mode 100644 index 0000000000..f36d6dcd6d --- /dev/null +++ b/templates/delete-wizard/src/testUtils/ExampleStepContent.tsx @@ -0,0 +1,55 @@ +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/templates/delete-wizard/src/testing/getTestUtils.spec.tsx b/templates/delete-wizard/src/testing/getTestUtils.spec.tsx new file mode 100644 index 0000000000..6b1e673bdc --- /dev/null +++ b/templates/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/templates/delete-wizard/src/testing/getTestUtils.tsx b/templates/delete-wizard/src/testing/getTestUtils.tsx new file mode 100644 index 0000000000..ad89a6e99d --- /dev/null +++ b/templates/delete-wizard/src/testing/getTestUtils.tsx @@ -0,0 +1,15 @@ +import { findByLgId, getByLgId, queryByLgId } from '@lg-tools/test-harnesses'; + +import { LgIdString } from '@leafygreen-ui/lib'; + +import { DEFAULT_LGID_ROOT, getLgIds } from '../utils/getLgIds'; + +import { TestUtilsReturnType } from './getTestUtils.types'; + +export const getTestUtils = ( + lgId: LgIdString = DEFAULT_LGID_ROOT, +): TestUtilsReturnType => { + const lgIds = getLgIds(lgId); + + return {}; +}; diff --git a/templates/delete-wizard/src/testing/getTestUtils.types.ts b/templates/delete-wizard/src/testing/getTestUtils.types.ts new file mode 100644 index 0000000000..4b2df87c73 --- /dev/null +++ b/templates/delete-wizard/src/testing/getTestUtils.types.ts @@ -0,0 +1 @@ +export interface TestUtilsReturnType {} diff --git a/templates/delete-wizard/src/testing/index.ts b/templates/delete-wizard/src/testing/index.ts new file mode 100644 index 0000000000..4c102995fa --- /dev/null +++ b/templates/delete-wizard/src/testing/index.ts @@ -0,0 +1,2 @@ +export { getTestUtils } from './getTestUtils'; +export { type TestUtilsReturnType } from './getTestUtils.types'; diff --git a/templates/delete-wizard/src/utils/getLgIds.ts b/templates/delete-wizard/src/utils/getLgIds.ts new file mode 100644 index 0000000000..d3adcf2af9 --- /dev/null +++ b/templates/delete-wizard/src/utils/getLgIds.ts @@ -0,0 +1,12 @@ +import { LgIdString } from '@leafygreen-ui/lib'; + +export const DEFAULT_LGID_ROOT = 'lg-delete_wizard'; + +export const getLgIds = (root: LgIdString = DEFAULT_LGID_ROOT) => { + const ids = { + root, + } as const; + return ids; +}; + +export type GetLgIdsReturnType = ReturnType; diff --git a/templates/delete-wizard/tsconfig.json b/templates/delete-wizard/tsconfig.json new file mode 100644 index 0000000000..85a2a89e70 --- /dev/null +++ b/templates/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/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;