Namespace Intro Page (#12255) (#12311)

* improve dismissal logic, use AutomationSnippet component, use wizard service for tracking dismissal

* use class helper to check for multiple nodes when rendering tree chart, add test coverage

* update comments

* add modal for namespace intro and improve reusability

* style updates and general reusability updates

* make intro pages more generic and rename welcome to intro

* update tests

* update styles, use service to track intro visibility, rename components

* Update arg docs

Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com>
This commit is contained in:
Vault Automation 2026-02-11 18:17:27 -05:00 committed by GitHub
parent 6a71edd6dc
commit a407faa971
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 426 additions and 227 deletions

View file

@ -4,9 +4,7 @@
}}
{{#if (has-feature "Namespaces")}}
{{#if this.showWizard}}
<Wizard::Namespaces::NamespaceWizard @onRefresh={{this.refreshNamespaceList}} />
{{else}}
{{#if this.showPageHeader}}
<Page::Header @title="Namespaces">
<:breadcrumbs>
<Page::Breadcrumbs
@ -18,25 +16,33 @@
</:badges>
<:actions>
{{#unless @model.namespaces}}
{{#unless this.showWizard}}
<Hds::Button
class="has-right-margin-4"
@color="secondary"
@icon="bulb"
@text="New to Namespaces?"
{{on "click" this.showIntroPage}}
data-test-button="guided-start"
/>
{{/unless}}
<Hds::Button
class="has-right-margin-4"
@color="secondary"
@icon="bulb"
@text="New to Namespaces?"
{{on "click" this.enterGuidedStart}}
data-test-button="guided-start"
/>
<Hds::Button
class="has-right-margin-4"
@color={{if this.showWizard "secondary" "primary"}}
@icon="plus"
@route="vault.cluster.access.namespaces.create"
@text="Create namespace"
class="has-right-margin-4"
data-test-button="create-namespace"
/>
{{/unless}}
</:actions>
</Page::Header>
{{/if}}
{{#if this.showWizard}}
<Wizard::Namespaces::NamespaceWizard @isIntroModal={{this.isIntroModal}} @onRefresh={{this.refreshNamespaceList}} />
{{else}}
{{! Show namespace list }}
{{#if @model.namespaces}}
<Toolbar>
<ToolbarFilters>
@ -120,6 +126,7 @@
{{/if}}
</ListView>
{{else}}
{{! Show empty state }}
{{#if this.showSetupAlert}}
<Hds::Alert @type="inline" class="top-margin-32" as |A|>
<A.Title>Your current setup is 1 namespace.</A.Title>

View file

@ -57,6 +57,7 @@ export default class PageNamespacesComponent extends Component<Args> {
@tracked query;
@tracked nsToDelete = null;
@tracked showSetupAlert = false;
@tracked isIntroModal = false;
wizardId = 'namespace';
@ -80,6 +81,12 @@ export default class PageNamespacesComponent extends Component<Args> {
return this.namespace.path;
}
// Show header and breadcrumbs when viewing the intro page or during the list view.
// Do not show during Guided Start as that has its own header
get showPageHeader() {
return !this.showWizard || this.wizard.isIntroVisible(this.wizardId);
}
get showWizard() {
// Show when there are no existing namespaces and it is not in a dismissed state
return !this.wizard.isDismissed(this.wizardId) && !this.args.model.namespaces?.length;
@ -136,9 +143,10 @@ export default class PageNamespacesComponent extends Component<Args> {
}
@action
enterGuidedStart() {
showIntroPage() {
// Reset the wizard dismissal state to allow re-entering the wizard
this.wizard.reset(this.wizardId);
this.isIntroModal = true;
}
@action handlePageChange() {

View file

@ -3,9 +3,9 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Page::Header @title="Namespaces Guided Start" />
<Page::Header @title={{concat @title " Guided Start"}} />
<div class="wizard" data-test-guided-setup>
<div class="wizard guided-start" ...attributes>
<Hds::Stepper::Nav
class="has-top-margin-xl has-bottom-margin-xl is-flex-column is-flex-grow-1"
@isInteractive={{true}}

View file

@ -38,7 +38,7 @@ interface Args {
updateWizardState?: CallableFunction;
}
export default class GuidedSetup extends Component<Args> {
export default class GuidedStart extends Component<Args> {
get isFinalStep() {
return this.args.currentStep === this.args.steps.length - 1;
}

View file

@ -3,39 +3,27 @@
SPDX-License-Identifier: BUSL-1.1
}}
{{#if (and (has-block "welcome") this.showWelcome)}}
<div data-test-welcome>
<Wizard::Welcome>
<:welcome>
{{yield to="welcome"}}
<Hds::ButtonSet>
<Hds::Button
@icon="rocket"
@text="Guided setup"
{{on "click" (fn (mut this.showWelcome) false)}}
data-test-button="Guided setup"
/>
<Hds::Button @color="secondary" @text="Skip" {{on "click" @onDismiss}} data-test-button="Skip" />
<Hds::Link::Standalone
@icon="docs-link"
@iconPosition="trailing"
@text="View documentation"
@href={{@welcomeDocLink}}
class="has-left-margin-m"
/>
</Hds::ButtonSet>
</:welcome>
</Wizard::Welcome>
</div>
{{#if (and (has-block "intro") this.isIntroVisible)}}
{{! pass @onDismiss on for modal closure via clicking outside modal @onClose}}
<Wizard::Intro @isModal={{@isModal}} @title={{@title}} @onDismiss={{@onDismiss}} data-test-intro>
<:body>
{{yield to="intro"}}
</:body>
<:actions>
{{yield to="introActions"}}
</:actions>
</Wizard::Intro>
{{else}}
<Wizard::GuidedSetup
<Wizard::GuidedStart
@canProceed={{@canProceed}}
@currentStep={{@currentStep}}
@steps={{@steps}}
@onStepChange={{@onStepChange}}
@onDismiss={{@onDismiss}}
@canProceed={{@canProceed}}
@title={{@title}}
@wizardState={{@wizardState}}
@updateWizardState={{@updateWizardState}}
@onStepChange={{@onStepChange}}
@onDismiss={{@onDismiss}}
data-test-guided-start
>
<:exit>
{{#if (has-block "exit")}}
@ -51,5 +39,5 @@
<Hds::Button @text="Mark as complete" {{on "click" @onDismiss}} data-test-submit />
{{/if}}
</:submit>
</Wizard::GuidedSetup>
</Wizard::GuidedStart>
{{/if}}

View file

@ -3,10 +3,27 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { service } from '@ember/service';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import type WizardService from 'vault/services/wizard';
interface Args {
/**
* The unique identifier for the wizard used for handling wizard dismissal and intro visibility state
*/
wizardId: string;
/**
* Whether the intro page is in the default view or in modal view depending on how it is triggered
*/
isModal: boolean;
/**
* Title of the wizard
*/
title: string;
/**
* Whether the current step allows proceeding to the next step
*/
canProceed: boolean;
/**
* The active step. Steps are zero-indexed.
*/
@ -20,27 +37,24 @@ interface Args {
*/
onDismiss: CallableFunction;
/**
* Callback to update the current step when navigating backwards or
* forwards through the wizard
* Whether the current step allows proceeding to the next step
*/
onStepChange: CallableFunction;
/**
* Whether the current step allows proceeding to the next step
*/
canProceed?: boolean;
/**
* State tracked across steps.
*/
wizardState?: unknown;
wizardState: unknown;
/**
* Callback to update state tracked across steps.
*/
updateWizardState?: CallableFunction;
updateWizardState: CallableFunction;
}
// each wizard implementation can track whether the user has already dismissed the wizard via local storage
export const DISMISSED_WIZARD_KEY = 'dismissed-wizards';
export default class WizardComponent extends Component<Args> {
@service declare readonly wizard: WizardService;
export default class Wizard extends Component<Args> {
@tracked showWelcome = true;
get isIntroVisible(): boolean {
// If wizardId is provided, use the wizard service to check intro visibility
return this.wizard.isIntroVisible(this.args.wizardId);
}
}

View file

@ -0,0 +1,22 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Layout::Flex @align="start" @direction="column" as |LF|>
<LF.Item class="has-bottom-margin-xs has-top-margin-xs">
<Hds::Badge @text={{concat "Setup time: " @setupTime}} />
{{#if @isOptional}}
<Hds::Badge @text="Optional" @type="outlined" class="has-left-margin-xs" />
{{/if}}
</LF.Item>
<Hds::Text::Body @tag="p" class="has-bottom-margin-l">{{@description}}</Hds::Text::Body>
{{yield to="features"}}
</Hds::Layout::Flex>
<Hds::Layout::Flex @direction="column">
<img src={{@imageSrc}} alt={{@imageAlt}} />
<Hds::Text::Body @align="left" @color="faint" @size="100" @tag="p">{{@imageCaption}}</Hds::Text::Body>
</Hds::Layout::Flex>

View file

@ -0,0 +1,11 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Layout::Flex class="has-bottom-margin-s">
<Hds::Icon @name={{@icon}} />
<Hds::Text::Body @tag="p" class="has-left-margin-xs">
{{yield}}
</Hds::Text::Body>
</Hds::Layout::Flex>

View file

@ -0,0 +1,28 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
{{#if @isModal}}
<Hds::Modal id={{@title "-intro-modal"}} @size="large" @onClose={{@onDismiss}} class="wizard intro" ...attributes as |M|>
<M.Header>Welcome to {{@title}} </M.Header>
<M.Body>
<Hds::Layout::Flex @align="start">
{{yield to="body"}}
</Hds::Layout::Flex>
</M.Body>
<M.Footer>
{{yield to="actions"}}
</M.Footer>
</Hds::Modal>
{{else}}
<Hds::Card::Container @level="mid" @hasBorder={{true}} class="wizard intro has-padding-l" ...attributes>
<Hds::Text::Display @tag="h1" @size="400" @weight="bold">
Welcome to
{{@title}}
</Hds::Text::Display>
<Hds::Layout::Grid @columnWidth="50%" @gap="16">
{{yield to="body"}}
</Hds::Layout::Grid>
{{yield to="actions"}}
</Hds::Card::Container>
{{/if}}

View file

@ -0,0 +1,29 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Wizard::IntroContent
@isOptional={{true}}
@setupTime="15min"
@description="Namespaces let you create secure, isolated environments where independent teams can manage their own secrets engines, auth methods, and policies within a single Vault cluster."
@imageAlt="namespace hierarchy example"
@imageCaption="Namespaces provide necessary isolation based on your company's organization and access requirements."
@imageSrc={{img-path "~/namespaces-intro.png"}}
>
<:features>
<Wizard::IntroContent::Feature @icon="service">
Use for multi-tenancy: strict
<strong>administrative and configuration isolation</strong>
between business units and client environments.
</Wizard::IntroContent::Feature>
<Wizard::IntroContent::Feature @icon="database">
Namespaces allow you to segment your cluster into
<strong>separate logical partitions</strong>, with isolated data, policy, and tokens.
</Wizard::IntroContent::Feature>
<Wizard::IntroContent::Feature @icon="org">
Namespaces should not be used like folders. Use them for
<strong>large-scale, highly-regulated</strong>
environments.
</Wizard::IntroContent::Feature>
</:features>
</Wizard::IntroContent>

View file

@ -4,18 +4,39 @@
}}
<Wizard
@currentStep={{this.currentStep}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
@wizardId={{this.wizardId}}
@canProceed={{this.canProceed}}
@welcomeDocLink={{doc-link "/vault/docs/enterprise/namespaces"}}
@currentStep={{this.currentStep}}
@isModal={{@this.isIntroModal}}
@steps={{this.steps}}
@title="Namespaces"
@wizardState={{this.wizardState}}
@updateWizardState={{this.updateWizardState}}
@onDismiss={{this.onDismiss}}
@onStepChange={{this.onStepChange}}
>
<:welcome>
<Wizard::Namespaces::Welcome />
</:welcome>
<:intro>
<Wizard::Namespaces::Intro />
</:intro>
<:introActions>
<Hds::ButtonSet>
<Hds::Button
@icon="rocket"
@text="Guided start"
{{on "click" (fn this.onIntroChange false)}}
data-test-button="Guided start"
/>
<Hds::Button @color="secondary" @text="Skip" {{on "click" this.onDismiss}} data-test-button="Skip" />
<Hds::Link::Standalone
@icon="docs-link"
@iconPosition="trailing"
@text="View documentation"
@href={{doc-link "/vault/docs/enterprise/namespaces"}}
class="has-left-margin-m"
/>
</Hds::ButtonSet>
</:introActions>
{{! The yielded blocks below are used in the Guided Start }}
<:exit>
{{#if this.shouldShowExitButton}}
<Hds::Button @text={{this.exitText}} @color="secondary" {{on "click" this.onDismiss}} data-test-button="Exit" />

View file

@ -24,6 +24,7 @@ const DEFAULT_STEPS = [
];
interface Args {
isIntroModal: boolean;
onRefresh: CallableFunction;
}
@ -42,6 +43,7 @@ export default class WizardNamespacesWizardComponent extends Component<Args> {
@service declare readonly wizard: WizardService;
@service declare namespace: NamespaceService;
@tracked currentStep = 0;
@tracked steps = DEFAULT_STEPS;
@tracked wizardState: WizardState = {
securityPolicyChoice: null,
@ -50,7 +52,6 @@ export default class WizardNamespacesWizardComponent extends Component<Args> {
creationMethod: null,
codeSnippet: null,
};
@tracked currentStep = 0;
methods = CreationMethod;
policy = SecurityPolicy;
@ -132,6 +133,11 @@ export default class WizardNamespacesWizardComponent extends Component<Args> {
await this.args.onRefresh();
}
@action
onIntroChange(visible: boolean) {
this.wizard.setIntroVisible(this.wizardId, visible);
}
@action
async createNamespacesFromWizard() {
try {

View file

@ -1,38 +0,0 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Layout::Flex @align="start" @direction="column">
<Hds::Text::Display @tag="h1" @size="400" @weight="bold">
Welcome to Namespaces
</Hds::Text::Display>
<div class="flex column-gap-8 has-bottom-margin-xs has-top-margin-xs">
<Hds::Badge @text="Optional" />
<Hds::Badge @text="Setup time: 15min" @type="outlined" />
</div>
<Hds::Text::Body @tag="p" class="has-bottom-margin-xl">Namespaces let you create secure, isolated environments where
independent teams can manage their own secrets engines, auth methods, and policies within a single Vault cluster.</Hds::Text::Body>
<div class="flex has-bottom-margin-s">
<Hds::Icon @name="service" />
<Hds::Text::Body @tag="p" class="has-left-margin-xs">Use for multi-tenancy: strict
<strong>administrative and configuration isolation</strong>
between business units and client environments.</Hds::Text::Body>
</div>
<div class="flex has-bottom-margin-s">
<Hds::Icon @name="database" />
<Hds::Text::Body @tag="p" class="has-left-margin-xs">Namespaces allow you to segment your cluster into
<strong>separate logical partitions</strong>, with isolated data, policy, and tokens.</Hds::Text::Body>
</div>
<div class="flex">
<Hds::Icon @name="org" />
<Hds::Text::Body @tag="p" class="has-left-margin-xs">Namespaces should not be used like folders. Use them for
<strong>large-scale, highly-regulated</strong>
environments.</Hds::Text::Body>
</div>
</Hds::Layout::Flex>
<div>
<img src={{img-path "~/namespaces-welcome.png"}} alt="namespace hierarchy example" />
<Hds::Text::Body @align="center" @tag="p">Namespaces provide necessary isolation based on your companys organization and
access requirements.</Hds::Text::Body>
</div>

View file

@ -1,12 +0,0 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<div class="wizard welcome">
<Hds::Card::Container @level="mid" @hasBorder={{true}} class="has-padding-l">
<Hds::Layout::Grid @columnWidth="50%" @gap="16">
{{yield to="welcome"}}
</Hds::Layout::Grid>
</Hds::Card::Container>
</div>

View file

@ -17,6 +17,7 @@ const DISMISSED_WIZARD_KEY = 'dismissed-wizards';
*/
export default class WizardService extends Service {
@tracked dismissedWizards: string[] = this.loadDismissedWizards();
@tracked introVisibleState: Record<string, boolean> = {};
/**
* Load dismissed wizards from localStorage
@ -53,6 +54,8 @@ export default class WizardService extends Service {
reset(wizardId: string): void {
this.dismissedWizards = this.dismissedWizards.filter((id: string) => id !== wizardId);
localStorage.setItem(DISMISSED_WIZARD_KEY, this.dismissedWizards);
// Reset intro visibility when wizard is reset
this.setIntroVisible(wizardId, true);
}
/**
@ -61,5 +64,33 @@ export default class WizardService extends Service {
resetAll(): void {
this.dismissedWizards = [];
localStorage.removeItem(DISMISSED_WIZARD_KEY);
this.introVisibleState = {};
}
/**
* Check if the intro is visible for a specific wizard
* @param wizardId - The unique identifier for the wizard
* @returns true if the intro is visible, false otherwise (defaults to true if wizard not dismissed, false if dismissed)
*/
isIntroVisible(wizardId: string): boolean {
// If intro visibility has been explicitly set, use that value
if (this.introVisibleState[wizardId] !== undefined) {
return this.introVisibleState[wizardId];
}
// Otherwise, default to true if wizard is not dismissed (first time showing)
// and false if wizard is dismissed
return !this.isDismissed(wizardId);
}
/**
* Set the intro visibility state for a specific wizard
* @param wizardId - The unique identifier for the wizard
* @param visible - Whether the intro should be visible
*/
setIntroVisible(wizardId: string, visible: boolean): void {
this.introVisibleState = {
...this.introVisibleState,
[wizardId]: visible,
};
}
}

View file

@ -7,11 +7,14 @@
*/
.wizard {
@extend .is-flex-column;
@extend .is-flex-grow-1;
&.guided-start {
@extend .is-flex-column;
@extend .is-flex-grow-1;
}
&.intro {
@extend .top-margin-32;
&.welcome {
@extend .has-top-margin-xxl;
svg {
flex-shrink: 0;
}

View file

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -13,10 +13,10 @@ import { GENERAL } from 'vault/tests/helpers/general-selectors';
const SELECTORS = {
content: '[data-test-content]',
guidedSetup: '[data-test-guided-setup]',
guidedStart: '[data-test-guided-start]',
stepTitle: '[data-test-step-title]',
tree: '[data-test-tree]',
welcome: '[data-test-welcome]',
intro: '[data-test-intro]',
inputRow: (index) => (index ? `[data-test-input-row="${index}"]` : '[data-test-input-row]'),
};
@ -44,12 +44,12 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
test('it shows wizard when no namespaces exist', async function (assert) {
await this.renderComponent();
assert.dom(SELECTORS.welcome).exists('Wizard welcome is rendered');
assert.dom(SELECTORS.intro).exists('Wizard intro is rendered');
});
test('it progresses through wizard steps with strict policy', async function (assert) {
await this.renderComponent();
await click(GENERAL.button('Guided setup'));
await click(GENERAL.button('Guided start'));
// Step 1: Choose security policy
assert.dom(GENERAL.button('Next')).isDisabled('Next button disabled with no policy choice');
@ -70,7 +70,7 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
test('it skips step 2 with flexible policy', async function (assert) {
await this.renderComponent();
await click(GENERAL.button('Guided setup'));
await click(GENERAL.button('Guided start'));
// Step 1: Choose flexible policy
await click(GENERAL.radioByAttr('flexible'));
@ -84,7 +84,7 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
test('it shows different code snippets per creation method option', async function (assert) {
await this.renderComponent();
await click(GENERAL.button('Guided setup'));
await click(GENERAL.button('Guided start'));
await click(GENERAL.radioByAttr('strict'));
await click(GENERAL.button('Next'));
@ -116,7 +116,7 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
test('it allows adding and removing blocks, org, and project inputs', async function (assert) {
await this.renderComponent();
await click(GENERAL.button('Guided setup'));
await click(GENERAL.button('Guided start'));
await click(GENERAL.radioByAttr('strict'));
await click(GENERAL.button('Next'));
@ -147,7 +147,7 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
.doesNotExist('Second org input was removed');
});
test('it dismisses from the welcome page', async function (assert) {
test('it dismisses from the intro page', async function (assert) {
await this.renderComponent();
assert.false(this.wizardService.isDismissed('namespace'), 'Wizard is not dismissed initially');
@ -158,9 +158,9 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
assert.true(this.refreshSpy.calledOnce, 'onRefresh callback was called');
});
test('it dismisses from the guided setup', async function (assert) {
test('it dismisses from the Guided start', async function (assert) {
await this.renderComponent();
await click(GENERAL.button('Guided setup'));
await click(GENERAL.button('Guided start'));
assert.false(this.wizardService.isDismissed('namespace'), 'Wizard is not dismissed initially');
@ -172,7 +172,7 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
test('it dismisses after completing flexible policy flow', async function (assert) {
await this.renderComponent();
await click(GENERAL.button('Guided setup'));
await click(GENERAL.button('Guided start'));
await click(GENERAL.radioByAttr('flexible'));
await click(GENERAL.button('Next'));
@ -186,7 +186,7 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
test('it shows tree chart only when there are multiple globals, orgs, or projects', async function (assert) {
await this.renderComponent();
await click(GENERAL.button('Guided setup'));
await click(GENERAL.button('Guided start'));
await click(GENERAL.radioByAttr('strict'));
await click(GENERAL.button('Next'));

View file

@ -5,138 +5,181 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import { render, click, waitFor } 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]',
guidedSetup: '[data-test-guided-setup]',
intro: '[data-test-intro]',
guidedStart: '[data-test-guided-start]',
};
module('Integration | Component | Wizard', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.canProceed = true;
this.currentStep = 0;
this.isModal = false;
this.steps = [
{ title: 'First step' },
{ title: 'Another stage' },
{ title: 'Almost done' },
{ title: 'Finale' },
];
this.currentStep = 0;
this.canProceed = true;
this.welcomeDocLink = 'test';
this.title = 'Example Wizard';
this.wizardState = undefined;
this.updateWizardState = sinon.spy();
this.onDismiss = sinon.spy();
this.onStepChange = sinon.spy();
this.wizardId = 'test-wizard';
this.wizardService = this.owner.lookup('service:wizard');
this.wizardService.setIntroVisible(this.wizardId, false);
this.renderComponent = () => {
return render(hbs`
<Wizard
@wizardId={{this.wizardId}}
@canProceed={{this.canProceed}}
@currentStep={{this.currentStep}}
@isModal={{@this.isIntroModal}}
@steps={{this.steps}}
@title="Example Wizard"
@wizardState={{this.wizardState}}
@updateWizardState={{this.updateWizardState}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:intro>
<div>Some intro content</div>
</:intro>
<:introActions>
<div> Some actions </div>
</:introActions>
<:submit>
<Hds::Button @text="Custom Submit" data-test-custom-submit />
</:submit>
<:exit>
<Hds::Button @text="Custom Exit" data-test-custom-exit />
</:exit>
</Wizard>`);
};
});
test('it shows welcome content initially, then hides it when entering wizard', async function (assert) {
await render(hbs`<Wizard
@title="Example Wizard"
@currentStep={{this.currentStep}}
@steps={{this.steps}}
@welcomeDocLink={{this.welcomeDocLink}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:welcome>
<div>Some welcome content</div>
</:welcome>
</Wizard>`);
test('it shows intro content initially, then hides it when entering wizard', async function (assert) {
this.wizardService.setIntroVisible(this.wizardId, true);
await this.renderComponent();
// Assert welcome content is rendered and guided setup content is not
assert.dom(SELECTORS.welcome).exists('Welcome content is rendered initially');
assert.dom(SELECTORS.welcome).hasTextContaining('Some welcome content');
// Assert intro content is rendered and guided start content is not
assert.dom(SELECTORS.intro).exists('intro content is rendered initially');
assert.dom(SELECTORS.intro).hasTextContaining('Some intro content');
assert
.dom(SELECTORS.guidedSetup)
.doesNotExist('guidedSetup content is not rendered when welcome is displayed');
.dom(SELECTORS.guidedStart)
.doesNotExist('guidedStart content is not rendered when intro is displayed');
await click(GENERAL.button('Guided setup'));
// Use wizard service to hide the intro
this.wizardService.setIntroVisible(this.wizardId, false);
await waitFor(SELECTORS.guidedStart);
// Assert welcome content is no longer rendered and that guided setup content is rendered
assert.dom(SELECTORS.welcome).doesNotExist('Welcome content is hidden after entering wizard');
assert.dom(SELECTORS.guidedSetup).exists('guidedSetup content is now rendered');
assert.dom(SELECTORS.guidedSetup).hasTextContaining('First step');
// Assert intro content is no longer rendered and that guided start content is rendered
assert.dom(SELECTORS.intro).doesNotExist('intro content is hidden after entering wizard');
assert.dom(SELECTORS.guidedStart).exists('guidedStart content is now rendered');
assert.dom(SELECTORS.guidedStart).hasTextContaining('First step');
});
test('it shows custom submit block when provided', async function (assert) {
// Go to final step
// Start wizard and go to final step
this.currentStep = 3;
this.onCustomSubmit = sinon.spy();
await render(hbs`<Wizard
@title="Example Wizard"
@currentStep={{this.currentStep}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:submit>
<Hds::Button @text="Custom Submit" {{on "click" this.onCustomSubmit}} data-test-custom-submit />
</:submit>
</Wizard>`);
await this.renderComponent();
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
// Start wizard and go to final step
this.currentStep = 3;
await render(hbs`<Wizard
@title="Example Wizard"
@showWelcome={{this.showWelcome}}
@currentStep={{this.currentStep}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:quickstart>
<div>Quickstart content</div>
</:quickstart>
</Wizard>`);
await render(hbs`
<Wizard
@wizardId={{this.wizardId}}
@canProceed={{this.canProceed}}
@currentStep={{this.currentStep}}
@isModal={{@this.isIntroModal}}
@steps={{this.steps}}
@title="Example Wizard"
@wizardState={{this.wizardState}}
@updateWizardState={{this.updateWizardState}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:intro>
<div>Some intro content</div>
</:intro>
<:introActions>
<div> Some actions </div>
</:introActions>
</Wizard>`);
assert
.dom(GENERAL.submitButton)
.exists('Default submit button is rendered when no custom submit provided');
});
test('it shows custom exit block when provided', async function (assert) {
await this.renderComponent();
assert.dom('[data-test-custom-exit]').exists('Custom exit button is rendered');
assert.dom(GENERAL.cancelButton).doesNotExist('Default exit button is not rendered');
});
test('it shows default exit button when custom exit block is not provided', async function (assert) {
await render(hbs`
<Wizard
@wizardId={{this.wizardId}}
@canProceed={{this.canProceed}}
@currentStep={{this.currentStep}}
@isModal={{@this.isIntroModal}}
@showIntro={{this.showIntro}}
@steps={{this.steps}}
@title="Example Wizard"
@wizardState={{this.wizardState}}
@updateWizardState={{this.updateWizardState}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
<:intro>
<div>Some intro content</div>
</:intro>
<:introActions>
<div> Some actions </div>
</:introActions>
</Wizard>`);
assert.dom(GENERAL.cancelButton).exists('Default exit button is rendered when no custom exit provided');
await click(GENERAL.cancelButton);
assert.true(this.onDismiss.calledOnce, 'onDismiss is called when exit button is clicked');
});
test('it renders next button when not on final step', async function (assert) {
await render(hbs`<Wizard
@title="Example Wizard"
@canProceed={{this.canProceed}}
@currentStep={{this.currentStep}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
</Wizard>`);
await this.renderComponent();
assert.dom(GENERAL.button('Next')).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');
assert
.dom('[data-test-custom-submit]')
.doesNotExist('Custom submit button is not rendered when not on final step');
await click(GENERAL.button('Next'));
assert.true(this.onStepChange.calledOnce, 'onStepChange is called');
// Go to final step
this.set('currentStep', 3);
assert.dom(GENERAL.button('next')).doesNotExist('Next button is not rendered when on the final step');
assert.dom(GENERAL.submitButton).exists('Submit button is rendered on final step');
assert.dom(GENERAL.button('Next')).doesNotExist('Next button is not rendered when on the final step');
assert.dom('[data-test-custom-submit]').exists('Custom 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.currentStep}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
</Wizard>`);
await this.renderComponent();
assert.dom(GENERAL.backButton).doesNotExist('Back button is not rendered on the first step');
this.set('currentStep', 2);
@ -144,20 +187,4 @@ module('Integration | Component | Wizard', function (hooks) {
await click(GENERAL.backButton);
assert.true(this.onStepChange.calledOnce, 'onStepChange is called');
});
test('it dismisses wizard when exit button is clicked within guided setup', async function (assert) {
await render(hbs`<Wizard
@title="Example Wizard"
@showWelcome={{this.showWelcome}}
@currentStep={{this.currentStep}}
@steps={{this.steps}}
@onStepChange={{this.onStepChange}}
@onDismiss={{this.onDismiss}}
>
</Wizard>`);
assert.dom(GENERAL.cancelButton).exists('Exit button is shown within guided setup');
await click(GENERAL.cancelButton);
assert.true(this.onDismiss.calledOnce, 'onDismiss is called when exit button is clicked');
});
});

View file

@ -178,4 +178,58 @@ module('Unit | Service | wizard', function (hooks) {
assert.true(this.removeItemStub.calledWith(DISMISSED_WIZARD_KEY), 'removes key from localStorage');
});
});
module('#isIntroVisible', function () {
test('returns true by default when wizard not dismissed and intro visibility not set', function (assert) {
this.service.dismissedWizards = [];
assert.true(
this.service.isIntroVisible('onboarding'),
'returns true for non-dismissed wizard with unset visibility'
);
});
test('returns false by default when wizard is dismissed', function (assert) {
this.service.dismissedWizards = ['onboarding'];
assert.false(this.service.isIntroVisible('onboarding'), 'returns false for dismissed wizard');
});
test('returns true when intro visibility is set to true', function (assert) {
this.service.introVisibleState = { onboarding: true };
assert.true(this.service.isIntroVisible('onboarding'), 'returns true when set');
});
test('returns false when intro visibility is set to false', function (assert) {
this.service.introVisibleState = { onboarding: false };
assert.false(this.service.isIntroVisible('onboarding'), 'returns false when set to false');
});
});
module('#setIntroVisible', function () {
test('sets intro visibility to false', function (assert) {
this.service.setIntroVisible('onboarding', false);
assert.false(this.service.introVisibleState.onboarding, 'sets intro visibility to false in state');
assert.false(this.service.isIntroVisible('onboarding'), 'isIntroVisible returns false');
});
test('sets intro visibility to true', function (assert) {
this.service.setIntroVisible('onboarding', true);
assert.true(this.service.introVisibleState.onboarding, 'sets intro visibility to true in state');
assert.true(this.service.isIntroVisible('onboarding'), 'isIntroVisible returns true');
});
test('handles multiple wizards independently', function (assert) {
this.service.setIntroVisible('wizard1', false);
this.service.setIntroVisible('wizard2', true);
this.service.setIntroVisible('wizard3', false);
assert.false(this.service.isIntroVisible('wizard1'), 'wizard1 is not visible');
assert.true(this.service.isIntroVisible('wizard2'), 'wizard2 is visible');
assert.false(this.service.isIntroVisible('wizard3'), 'wizard3 is not visible');
});
});
});