Skip to content

Commit 67a26ae

Browse files
authored
feat: add custom primary actions for wizard component (#3994)
1 parent 05d1e35 commit 67a26ae

File tree

6 files changed

+242
-19
lines changed

6 files changed

+242
-19
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React, { useState } from 'react';
4+
5+
import { SpaceBetween } from '~components';
6+
import Button from '~components/button';
7+
import Container from '~components/container';
8+
import Header from '~components/header';
9+
import Wizard, { WizardProps } from '~components/wizard';
10+
11+
import { i18nStrings } from './common';
12+
13+
const steps: WizardProps.Step[] = [
14+
{
15+
title: 'Step 1',
16+
content: (
17+
<>
18+
<Container header={<Header>Step 1, substep one</Header>}></Container>
19+
<Container header={<Header>Step 1, substep two</Header>}></Container>
20+
</>
21+
),
22+
},
23+
{
24+
title: 'Step 2',
25+
content: (
26+
<>
27+
<Container header={<Header>Step 2, substep one</Header>}></Container>
28+
<Container header={<Header>Step 2, substep two</Header>}></Container>
29+
</>
30+
),
31+
isOptional: true,
32+
},
33+
{
34+
title: 'Step 3',
35+
content: (
36+
<>
37+
<Container header={<Header>Step 3, substep one</Header>}></Container>
38+
<Container header={<Header>Step 3, substep two</Header>}></Container>
39+
</>
40+
),
41+
},
42+
];
43+
44+
export default function WizardPage() {
45+
const [activeStepIndex, setActiveStepIndex] = useState(0);
46+
47+
const onNext = () => {
48+
if (activeStepIndex >= steps.length) {
49+
return;
50+
}
51+
setActiveStepIndex(activeStepIndex + 1);
52+
};
53+
54+
const onPrevious = () => {
55+
if (activeStepIndex <= 0) {
56+
return;
57+
}
58+
setActiveStepIndex(activeStepIndex - 1);
59+
};
60+
61+
const onFinish = () => {
62+
alert('Finish');
63+
};
64+
65+
const customPrimaryActions = (
66+
<SpaceBetween size="xs" direction="horizontal">
67+
{activeStepIndex > 0 && (
68+
<Button variant="normal" onClick={onPrevious}>
69+
Custom Previous
70+
</Button>
71+
)}
72+
{activeStepIndex < steps.length - 1 && (
73+
<Button variant="primary" onClick={onNext}>
74+
Custom Next
75+
</Button>
76+
)}
77+
{activeStepIndex === steps.length - 1 && (
78+
<Button variant="primary" onClick={onFinish}>
79+
Custom Finish
80+
</Button>
81+
)}
82+
</SpaceBetween>
83+
);
84+
85+
return (
86+
<Wizard
87+
id="wizard"
88+
steps={steps}
89+
i18nStrings={i18nStrings}
90+
activeStepIndex={activeStepIndex}
91+
onNavigate={e => setActiveStepIndex(e.detail.requestedStepIndex)}
92+
secondaryActions={activeStepIndex === 2 ? <Button>Save as draft</Button> : null}
93+
customPrimaryActions={customPrimaryActions}
94+
/>
95+
);
96+
}

src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28027,6 +28027,14 @@ Use this if you need to wait for a response from the server before the user can
2802728027
},
2802828028
],
2802928029
"regions": [
28030+
{
28031+
"description": "Specifies right-aligned custom primary actions for the wizard. Overwrites existing buttons (e.g. Cancel, Next, Finish).",
28032+
"isDefault": false,
28033+
"name": "customPrimaryActions",
28034+
"systemTags": [
28035+
"core",
28036+
],
28037+
},
2803028038
{
2803128039
"description": "Specifies left-aligned secondary actions for the wizard. Use a button dropdown if multiple actions are required.",
2803228040
"isDefault": false,

src/wizard/__tests__/wizard.test.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,110 @@ describe('Custom actions', () => {
515515
});
516516
});
517517

518+
describe('Custom primary actions', () => {
519+
test('renders custom primary actions instead of default buttons', () => {
520+
const customActions = (
521+
<>
522+
<Button data-testid="custom-cancel">Custom Cancel</Button>
523+
<Button data-testid="custom-next" variant="primary">
524+
Custom Next
525+
</Button>
526+
</>
527+
);
528+
const [wrapper] = renderDefaultWizard({ customPrimaryActions: customActions });
529+
530+
expect(wrapper.findPrimaryButton()).toBeNull();
531+
expect(wrapper.findCancelButton()).toBeNull();
532+
expect(wrapper.findPreviousButton()).toBeNull();
533+
expect(wrapper.findActions()!.findButton('[data-testid="custom-cancel"]')).not.toBeNull();
534+
expect(wrapper.findActions()!.findButton('[data-testid="custom-next"]')).not.toBeNull();
535+
});
536+
537+
test('custom primary actions work on first step', () => {
538+
const onCustomClick = jest.fn();
539+
const customActions = (
540+
<Button data-testid="custom-action" onClick={onCustomClick}>
541+
Custom Action
542+
</Button>
543+
);
544+
545+
const [wrapper] = renderDefaultWizard({
546+
customPrimaryActions: customActions,
547+
activeStepIndex: 0,
548+
});
549+
550+
const customActionButtonWrapper = wrapper.findActions()!.findButton('[data-testid="custom-action"]');
551+
expect(customActionButtonWrapper).not.toBeNull();
552+
customActionButtonWrapper!.click();
553+
expect(onCustomClick).toHaveBeenCalledTimes(1);
554+
});
555+
556+
test('custom primary actions work on middle step', () => {
557+
const onCustomClick = jest.fn();
558+
const customActions = (
559+
<Button data-testid="custom-action" onClick={onCustomClick}>
560+
Custom Action
561+
</Button>
562+
);
563+
564+
const [wrapper] = renderDefaultWizard({
565+
customPrimaryActions: customActions,
566+
activeStepIndex: 1,
567+
});
568+
569+
const customActionButtonWrapper = wrapper.findActions()!.findButton('[data-testid="custom-action"]');
570+
expect(customActionButtonWrapper).not.toBeNull();
571+
customActionButtonWrapper!.click();
572+
expect(onCustomClick).toHaveBeenCalledTimes(1);
573+
});
574+
575+
test('custom primary actions work on last step', () => {
576+
const onCustomClick = jest.fn();
577+
const customActions = (
578+
<Button data-testid="custom-action" onClick={onCustomClick}>
579+
Custom Action
580+
</Button>
581+
);
582+
583+
const [wrapper] = renderDefaultWizard({
584+
customPrimaryActions: customActions,
585+
activeStepIndex: DEFAULT_STEPS.length - 1,
586+
});
587+
588+
const customActionButtonWrapper = wrapper.findActions()!.findButton('[data-testid="custom-action"]');
589+
expect(customActionButtonWrapper).not.toBeNull();
590+
customActionButtonWrapper!.click();
591+
expect(onCustomClick).toHaveBeenCalledTimes(1);
592+
});
593+
594+
test('custom primary actions override skip-to button', () => {
595+
const customActions = <Button>Custom Only</Button>;
596+
const [wrapper] = renderDefaultWizard({
597+
customPrimaryActions: customActions,
598+
allowSkipTo: true,
599+
});
600+
601+
expect(wrapper.findSkipToButton()).toBeNull();
602+
expect(wrapper.findActions()!.findButton()!.getElement()).toHaveTextContent('Custom Only');
603+
});
604+
605+
test('falls back to default actions when customPrimaryActions is null', () => {
606+
const [wrapper] = renderDefaultWizard({ customPrimaryActions: null });
607+
608+
expect(wrapper.findPrimaryButton()).not.toBeNull();
609+
expect(wrapper.findCancelButton()).not.toBeNull();
610+
expect(wrapper.findPrimaryButton().getElement()).toHaveTextContent(DEFAULT_I18N_SETS[0].nextButton!);
611+
});
612+
613+
test('falls back to default actions when customPrimaryActions is undefined', () => {
614+
const [wrapper] = renderDefaultWizard({ customPrimaryActions: undefined });
615+
616+
expect(wrapper.findPrimaryButton()).not.toBeNull();
617+
expect(wrapper.findCancelButton()).not.toBeNull();
618+
expect(wrapper.findPrimaryButton().getElement()).toHaveTextContent(DEFAULT_I18N_SETS[0].nextButton!);
619+
});
620+
});
621+
518622
describe('i18n', () => {
519623
test('supports rendering static strings using i18n provider', () => {
520624
const { container } = render(

src/wizard/interfaces.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ export interface WizardProps extends BaseComponentProps {
103103
*/
104104
allowSkipTo?: boolean;
105105

106+
/**
107+
* Specifies right-aligned custom primary actions for the wizard. Overwrites existing buttons (e.g. Cancel, Next, Finish).
108+
*
109+
* @awsuiSystem core
110+
*/
111+
customPrimaryActions?: React.ReactNode;
112+
106113
/**
107114
* Specifies left-aligned secondary actions for the wizard. Use a button dropdown if multiple actions are required.
108115
*/

src/wizard/internal.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export default function InternalWizard({
4141
submitButtonText,
4242
isLoadingNextStep = false,
4343
allowSkipTo = false,
44+
customPrimaryActions,
4445
secondaryActions,
4546
onCancel,
4647
onSubmit,
@@ -201,6 +202,7 @@ export default function InternalWizard({
201202
activeStepIndex={actualActiveStepIndex}
202203
isPrimaryLoading={isLoadingNextStep}
203204
allowSkipTo={allowSkipTo}
205+
customPrimaryActions={customPrimaryActions}
204206
secondaryActions={secondaryActions}
205207
onCancelClick={onCancelClick}
206208
onPreviousClick={onPreviousClick}

src/wizard/wizard-form.tsx

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ interface WizardFormProps extends InternalBaseComponentProps {
3636
submitButtonText?: string;
3737
isPrimaryLoading: boolean;
3838
allowSkipTo: boolean;
39+
customPrimaryActions?: React.ReactNode;
3940
secondaryActions?: React.ReactNode;
4041
onCancelClick: () => void;
4142
onPreviousClick: () => void;
@@ -80,6 +81,7 @@ function WizardForm({
8081
isPrimaryLoading,
8182
allowSkipTo,
8283
secondaryActions,
84+
customPrimaryActions,
8385
onCancelClick,
8486
onPreviousClick,
8587
onPrimaryClick,
@@ -151,25 +153,29 @@ function WizardForm({
151153
__internalRootRef={ref}
152154
className={styles['form-component']}
153155
actions={
154-
<WizardActions
155-
cancelButtonText={i18nStrings.cancelButton}
156-
primaryButtonText={isLastStep ? (submitButtonText ?? i18nStrings.submitButton) : i18nStrings.nextButton}
157-
primaryButtonLoadingText={
158-
isLastStep ? i18nStrings.submitButtonLoadingAnnouncement : i18nStrings.nextButtonLoadingAnnouncement
159-
}
160-
previousButtonText={i18nStrings.previousButton}
161-
onCancelClick={onCancelClick}
162-
onPreviousClick={onPreviousClick}
163-
onPrimaryClick={onPrimaryClick}
164-
onSkipToClick={() => onSkipToClick(skipToTargetIndex)}
165-
showPrevious={activeStepIndex !== 0}
166-
isPrimaryLoading={isPrimaryLoading}
167-
showSkipTo={showSkipTo}
168-
skipToButtonText={skipToButtonText}
169-
isLastStep={isLastStep}
170-
activeStepIndex={activeStepIndex}
171-
skipToStepIndex={skipToTargetIndex}
172-
/>
156+
customPrimaryActions ? (
157+
customPrimaryActions
158+
) : (
159+
<WizardActions
160+
cancelButtonText={i18nStrings.cancelButton}
161+
primaryButtonText={isLastStep ? (submitButtonText ?? i18nStrings.submitButton) : i18nStrings.nextButton}
162+
primaryButtonLoadingText={
163+
isLastStep ? i18nStrings.submitButtonLoadingAnnouncement : i18nStrings.nextButtonLoadingAnnouncement
164+
}
165+
previousButtonText={i18nStrings.previousButton}
166+
onCancelClick={onCancelClick}
167+
onPreviousClick={onPreviousClick}
168+
onPrimaryClick={onPrimaryClick}
169+
onSkipToClick={() => onSkipToClick(skipToTargetIndex)}
170+
showPrevious={activeStepIndex !== 0}
171+
isPrimaryLoading={isPrimaryLoading}
172+
showSkipTo={showSkipTo}
173+
skipToButtonText={skipToButtonText}
174+
isLastStep={isLastStep}
175+
activeStepIndex={activeStepIndex}
176+
skipToStepIndex={skipToTargetIndex}
177+
/>
178+
)
173179
}
174180
secondaryActions={secondaryActions}
175181
errorText={errorText}

0 commit comments

Comments
 (0)