UI: Add general wizard component (#11136) (#11252)

* add general wizard component

* add copyright headers

* remove header, improve submit block conditional logic, add integration tests

Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com>
This commit is contained in:
Vault Automation 2025-12-12 12:14:00 -05:00 committed by GitHub
parent 7bf7bf39fe
commit c5b3edc0e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 350 additions and 0 deletions

View File

@ -0,0 +1,30 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
{{#if @showWelcome}}
<div data-test-welcome-content>
{{yield to="welcome"}}
</div>
{{else}}
<Wizard::Quickstart
@currentStep={{@currentStep}}
@steps={{@steps}}
@onStepChange={{@onStepChange}}
@onDismiss={{@onDismiss}}
@hasSubmitBlock={{has-block "submit"}}
>
<:content>
{{yield to="quickstart"}}
</:content>
<:submit>
{{#if (has-block "submit")}}
{{yield to="submit"}}
{{else}}
<Hds::Button @text="Done" {{on "click" @onDismiss}} data-test-submit />
{{/if}}
</:submit>
</Wizard::Quickstart>
{{/if}}

View File

@ -0,0 +1,40 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<div class="wizard" data-test-quickstart-content>
<Hds::Stepper::Nav
class="has-top-margin-xl has-bottom-margin-xl"
@isInteractive={{false}}
@currentStep={{@currentStep}}
@steps={{@steps}}
/>
<div class="content" tabindex="0">
{{yield to="content"}}
</div>
<div class="button-bar">
{{#if (gt @currentStep 0)}}
<Hds::Button
@text="Back"
@color="tertiary"
@icon="chevron-left"
{{on "click" (fn this.onStepChange -1)}}
data-test-back-button
/>
{{/if}}
<Hds::ButtonSet>
<Hds::Button @text="Exit" @color="secondary" {{on "click" @onDismiss}} data-test-cancel />
{{#if this.isFinalStep}}
{{yield to="submit"}}
{{else}}
<Hds::Button @text="Next" {{on "click" (fn this.onStepChange 1)}} data-test-next-button />
{{/if}}
</Hds::ButtonSet>
</div>
</div>

View File

@ -0,0 +1,62 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
/**
* @module QuickStart
* QuickStart component holds the wizard content pages and navigation controls.
*
* @example
* <QuickStart @currentStep={{@currentStep}} @steps={{@steps}} @onStepChange={{@onStepChange}} @onDismiss={{@onDismiss}} @hasSubmitBlock={{has-block "submit"}} />
*/
interface Args {
/**
* The active step
*/
currentStep: number;
/**
* Define step information to be shown in the Stepper Nav
*/
steps: { title: string; description?: string }[];
/**
* Callback to update viewing state when the wizard is exited.
*/
onDismiss: CallableFunction;
/**
* Callback to update the current step when navigating backwards or
* forwards through the wizard
*/
onStepChange: CallableFunction;
/**
* Helper arg to conditionally render a custom submit button upon
* completion of the wizard. Necessary to avoid a nested block error.
*/
hasSubmitBlock: boolean;
}
export default class QuickStart extends Component<Args> {
constructor(owner: unknown, args: Args) {
super(owner, args);
}
get isFinalStep() {
return this.args.currentStep === this.args.steps.length - 1;
}
@action
onStepChange(change: number) {
const { currentStep, steps, onStepChange } = this.args;
const target = currentStep + change;
if (target < 0 || target > steps.length - 1) {
onStepChange(currentStep);
} else {
onStepChange(target);
}
}
}

View File

@ -0,0 +1,34 @@
@use '../helper-classes/flexbox-and-grid';
@use '../helper-classes/spacing';
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
.wizard {
@extend .is-flex-column;
@extend .is-flex-grow-1;
// a fixed height is needed for content to stretch properly
height: 1px;
.content {
// ensures button bar is always at the bottom even if content is not filled
@extend .is-flex-1;
overflow-y: auto;
}
.button-bar {
@extend .has-padding-m;
@extend .is-flex-between;
background: var(--token-color-surface-primary);
box-shadow:
0 2px 3px 0 rgba(59, 61, 69, 0.25),
0 12px 24px 0 rgba(59, 61, 69, 0.35);
.hds-button-set {
margin-left: auto;
}
}
}

View File

@ -103,3 +103,4 @@
@use 'components/unseal-warning';
@use 'components/usage-page';
@use 'components/vault-loading';
@use 'components/wizard';

View File

@ -34,6 +34,7 @@ export const GENERAL = {
confirmButton: '[data-test-confirm-button]', // used most often on modal or confirm popups
confirmTrigger: '[data-test-confirm-action-trigger]',
copyButton: '[data-test-copy-button]',
nextButton: '[data-test-next-button]',
revealButton: (label: string) => `[data-test-reveal="${label}"] button`, // intended for Hds::Reveal components
// there should only be one submit button per view (e.g. one per form) so this does not need to be dynamic
// this button should be used for any kind of "submit" on a form or "save" action.

View File

@ -0,0 +1,182 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import sinon from 'sinon';
import hbs from 'htmlbars-inline-precompile';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
const SELECTORS = {
welcome: '[data-test-welcome-content]',
quickstart: '[data-test-quickstart-content]',
};
module('Integration | Component | Wizard', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.steps = [
{ title: 'First step' },
{ title: 'Another stage' },
{ title: 'Almost done' },
{ title: 'Finale' },
];
this.step = 0;
this.showWelcome = false;
this.onDismiss = sinon.spy();
this.onStepChange = sinon.spy();
});
test('it shows welcome content initially, then hides it when entering wizard', async function (assert) {
this.set('showWelcome', true);
await render(hbs`<Wizard
@title="Example Wizard"
@showWelcome={{this.showWelcome}}
@currentStep={{this.step}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:welcome>
<div>Some welcome content</div>
{{!-- TODO: This will change once the welcome page structure is defined in a follow up PR --}}
<Hds::Button @text="Dismiss" {{on "click" this.onDismiss}} />
<Hds::Button @text="Enter wizard" {{on "click" (fn (mut this.showWelcome) false)}} data-test-enter-wizard-button />
</:welcome>
<:quickstart>
<div data-test-quickstart-content>Quickstart content</div>
</:quickstart>
</Wizard>`);
// Assert welcome content is rendered and quickstart content is not
assert.dom(SELECTORS.welcome).exists('Welcome content is rendered initially');
assert.dom(SELECTORS.welcome).hasTextContaining('Some welcome content');
assert
.dom(SELECTORS.quickstart)
.doesNotExist('Quickstart content is not rendered when welcome is displayed');
await click('[data-test-enter-wizard-button]');
// Assert welcome content is no longer rendered and that quickstart content is rendered
assert.dom(SELECTORS.welcome).doesNotExist('Welcome content is hidden after entering wizard');
assert.dom(SELECTORS.quickstart).exists('Quickstart content is now rendered');
assert.dom(SELECTORS.quickstart).hasTextContaining('Quickstart content');
});
test('it shows custom submit block when provided', async function (assert) {
// Go to final step
this.set('step', 3);
this.onCustomSubmit = sinon.spy();
await render(hbs`<Wizard
@title="Example Wizard"
@showWelcome={{this.showWelcome}}
@currentStep={{this.step}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:quickstart>
<div data-test-quickstart-content>Quickstart content</div>
</:quickstart>
<:submit>
<Hds::Button @text="Custom Submit" {{on "click" this.onCustomSubmit}} data-test-custom-submit />
</:submit>
</Wizard>`);
assert.dom('[data-test-custom-submit]').exists('Custom submit button is rendered');
assert.dom(GENERAL.submitButton).doesNotExist('Default submit button is not rendered');
await click('[data-test-custom-submit]');
assert.true(this.onCustomSubmit.calledOnce, 'Custom submit handler is called');
});
test('it shows default submit button when custom submit block is not provided', async function (assert) {
// Go to final step
this.set('step', 3);
await render(hbs`<Wizard
@title="Example Wizard"
@showWelcome={{this.showWelcome}}
@currentStep={{this.step}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:quickstart>
<div>Quickstart content</div>
</:quickstart>
</Wizard>`);
assert
.dom(GENERAL.submitButton)
.exists('Default submit button is rendered when no custom submit provided');
});
test('it renders next button when not on final step', async function (assert) {
await render(hbs`<Wizard
@title="Example Wizard"
@showWelcome={{this.showWelcome}}
@currentStep={{this.step}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:quickstart>
<div>Quickstart content</div>
</:quickstart>
</Wizard>`);
assert.dom(GENERAL.nextButton).exists('Next button is rendered when not on final step');
assert.dom(GENERAL.submitButton).doesNotExist('Submit button is not rendered when not on final step');
await click(GENERAL.nextButton);
assert.true(this.onStepChange.calledOnce, 'onStepChange is called');
// Go to final step
this.set('step', 3);
assert.dom(GENERAL.nextButton).doesNotExist('Next button is not rendered when on the final step');
assert.dom(GENERAL.submitButton).exists('Submit button is rendered on final step');
});
test('it renders back button when not on first step', async function (assert) {
await render(hbs`<Wizard
@title="Example Wizard"
@showWelcome={{this.showWelcome}}
@currentStep={{this.step}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:quickstart>
<div>Quickstart content</div>
</:quickstart>
</Wizard>`);
assert.dom(GENERAL.backButton).doesNotExist('Back button is not rendered on the first step');
this.set('step', 2);
assert.dom(GENERAL.backButton).exists('Back button is shown when not on first step');
await click(GENERAL.backButton);
assert.true(this.onStepChange.calledOnce, 'onStepChange is called');
});
test('it dismisses wizard when exit button is clicked within quickstart', async function (assert) {
await render(hbs`<Wizard
@title="Example Wizard"
@showWelcome={{this.showWelcome}}
@currentStep={{this.step}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:quickstart>
<div>Quickstart content</div>
</:quickstart>
</Wizard>`);
assert.dom(GENERAL.cancelButton).exists('Exit button is shown within quickstart');
await click(GENERAL.cancelButton);
assert.true(this.onDismiss.calledOnce, 'onDismiss is called when exit button is clicked');
});
});