diff --git a/ui/app/components/wizard/index.hbs b/ui/app/components/wizard/index.hbs new file mode 100644 index 0000000000..16420a1f85 --- /dev/null +++ b/ui/app/components/wizard/index.hbs @@ -0,0 +1,30 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + +{{#if @showWelcome}} +
+ {{yield to="welcome"}} +
+{{else}} + + <:content> + {{yield to="quickstart"}} + + + <:submit> + {{#if (has-block "submit")}} + {{yield to="submit"}} + {{else}} + + {{/if}} + + +{{/if}} \ No newline at end of file diff --git a/ui/app/components/wizard/quickstart.hbs b/ui/app/components/wizard/quickstart.hbs new file mode 100644 index 0000000000..f984585b5a --- /dev/null +++ b/ui/app/components/wizard/quickstart.hbs @@ -0,0 +1,40 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + +
+ + +
+ {{yield to="content"}} +
+ +
+ {{#if (gt @currentStep 0)}} + + {{/if}} + + + + + {{#if this.isFinalStep}} + {{yield to="submit"}} + {{else}} + + {{/if}} + +
+ +
\ No newline at end of file diff --git a/ui/app/components/wizard/quickstart.ts b/ui/app/components/wizard/quickstart.ts new file mode 100644 index 0000000000..f1e58e8e6b --- /dev/null +++ b/ui/app/components/wizard/quickstart.ts @@ -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 + * + */ + +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 { + 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); + } + } +} diff --git a/ui/app/styles/components/wizard.scss b/ui/app/styles/components/wizard.scss new file mode 100644 index 0000000000..2cc5515465 --- /dev/null +++ b/ui/app/styles/components/wizard.scss @@ -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; + } + } +} diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index dd018ba939..e60a334ede 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -103,3 +103,4 @@ @use 'components/unseal-warning'; @use 'components/usage-page'; @use 'components/vault-loading'; +@use 'components/wizard'; diff --git a/ui/tests/helpers/general-selectors.ts b/ui/tests/helpers/general-selectors.ts index d8713c86de..43fe55d815 100644 --- a/ui/tests/helpers/general-selectors.ts +++ b/ui/tests/helpers/general-selectors.ts @@ -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. diff --git a/ui/tests/integration/components/wizard-test.js b/ui/tests/integration/components/wizard-test.js new file mode 100644 index 0000000000..ae1e453d47 --- /dev/null +++ b/ui/tests/integration/components/wizard-test.js @@ -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` + <:welcome> +
Some welcome content
+ {{!-- TODO: This will change once the welcome page structure is defined in a follow up PR --}} + + + + <:quickstart> +
Quickstart content
+ +
`); + + // 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` + <:quickstart> +
Quickstart content
+ + <:submit> + + +
`); + + 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` + <:quickstart> +
Quickstart content
+ +
`); + + 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` + <:quickstart> +
Quickstart content
+ +
`); + + 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` + <:quickstart> +
Quickstart content
+ +
`); + + 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` + <:quickstart> +
Quickstart content
+ +
`); + + 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'); + }); +});