mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-18 18:38:08 -05:00
* 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:
parent
6a71edd6dc
commit
a407faa971
20 changed files with 426 additions and 227 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
ui/app/components/wizard/intro-content.hbs
Normal file
22
ui/app/components/wizard/intro-content.hbs
Normal 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>
|
||||
11
ui/app/components/wizard/intro-content/feature.hbs
Normal file
11
ui/app/components/wizard/intro-content/feature.hbs
Normal 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>
|
||||
28
ui/app/components/wizard/intro.hbs
Normal file
28
ui/app/components/wizard/intro.hbs
Normal 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}}
|
||||
29
ui/app/components/wizard/namespaces/intro.hbs
Normal file
29
ui/app/components/wizard/namespaces/intro.hbs
Normal 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>
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 company’s organization and
|
||||
access requirements.</Hds::Text::Body>
|
||||
</div>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue