UI: Wizard Improvements (#12254) (#12261)

* 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 wizard service unit test coverage

* move item filtering into helper func

Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com>
This commit is contained in:
Vault Automation 2026-02-09 18:33:13 -05:00 committed by GitHub
parent f1a0c8d745
commit 47849d7cea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 432 additions and 121 deletions

View file

@ -5,10 +5,7 @@
{{#if (has-feature "Namespaces")}}
{{#if this.showWizard}}
<Wizard::Namespaces::NamespaceWizard
@onDismiss={{fn (mut this.hasDismissedWizard) true}}
@onRefresh={{this.refreshNamespaceList}}
/>
<Wizard::Namespaces::NamespaceWizard @onRefresh={{this.refreshNamespaceList}} />
{{else}}
<Page::Header @title="Namespaces">
<:breadcrumbs>

View file

@ -14,8 +14,8 @@ import type FlagsService from 'vault/services/flags';
import type FlashMessageService from 'vault/services/flash-messages';
import type NamespaceService from 'vault/services/namespace';
import type RouterService from '@ember/routing/router-service';
import type WizardService from 'vault/services/wizard';
import type { HTMLElementEvent } from 'vault/forms';
import { DISMISSED_WIZARD_KEY } from '../wizard';
/**
* @module PageNamespaces
@ -48,6 +48,7 @@ export default class PageNamespacesComponent extends Component<Args> {
@service declare readonly router: RouterService;
@service declare readonly flags: FlagsService;
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly wizard: WizardService;
@service declare namespace: NamespaceService;
// The `query` property is used to track the filter
@ -56,19 +57,12 @@ export default class PageNamespacesComponent extends Component<Args> {
@tracked query;
@tracked nsToDelete = null;
@tracked showSetupAlert = false;
@tracked hasDismissedWizard = false;
wizardId = 'namespace';
constructor(owner: unknown, args: Args) {
super(owner, args);
this.query = this.args.model.pageFilter || '';
// check if the wizard has already been dismissed
const dismissedWizards = localStorage.getItem(DISMISSED_WIZARD_KEY);
if (dismissedWizards?.includes(this.wizardId)) {
this.hasDismissedWizard = true;
}
}
// show the full available namespace path e.g. "root/ns1/child2", "admin/ns1/child2"
@ -88,7 +82,7 @@ export default class PageNamespacesComponent extends Component<Args> {
get showWizard() {
// Show when there are no existing namespaces and it is not in a dismissed state
return !this.hasDismissedWizard && !this.args.model.namespaces?.length;
return !this.wizard.isDismissed(this.wizardId) && !this.args.model.namespaces?.length;
}
@action
@ -143,7 +137,8 @@ export default class PageNamespacesComponent extends Component<Args> {
@action
enterGuidedStart() {
this.hasDismissedWizard = false;
// Reset the wizard dismissal state to allow re-entering the wizard
this.wizard.reset(this.wizardId);
}
@action handlePageChange() {

View file

@ -15,7 +15,7 @@
{{on "click" (fn (mut this.showWelcome) false)}}
data-test-button="Guided setup"
/>
<Hds::Button @color="secondary" @text="Skip" {{on "click" @onDismiss}} />
<Hds::Button @color="secondary" @text="Skip" {{on "click" @onDismiss}} data-test-button="Skip" />
<Hds::Link::Standalone
@icon="docs-link"
@iconPosition="trailing"

View file

@ -18,14 +18,14 @@
</:welcome>
<:exit>
{{#if this.shouldShowExitButton}}
<Hds::Button @text={{this.exitText}} @color="secondary" {{on "click" this.onDismiss}} />
<Hds::Button @text={{this.exitText}} @color="secondary" {{on "click" this.onDismiss}} data-test-button="Exit" />
{{/if}}
</:exit>
<:submit>
{{#if (eq this.wizardState.securityPolicyChoice this.policy.FLEXIBLE)}}
<Hds::Button @text="Done" {{on "click" this.onDismiss}} data-test-button="done" />
<Hds::Button @text="Done" {{on "click" this.onDismiss}} data-test-button="Done" />
{{else if (eq this.wizardState.creationMethod this.methods.UI)}}
<Hds::Button @text="Apply" {{on "click" this.onSubmit}} data-test-button="apply" />
<Hds::Button @text="Apply" {{on "click" this.onSubmit}} data-test-button="Apply" />
{{else}}
<Hds::Copy::Button
@text="Copy code"

View file

@ -7,16 +7,15 @@ import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import Component from '@glimmer/component';
import localStorage from 'vault/lib/local-storage';
import { SecurityPolicy } from 'vault/components/wizard/namespaces/step-1';
import { CreationMethod } from 'vault/components/wizard/namespaces/step-3';
import { DISMISSED_WIZARD_KEY } from 'vault/components/wizard';
import type ApiService from 'vault/services/api';
import type Block from 'vault/components/wizard/namespaces/step-2';
import type FlashMessageService from 'vault/services/flash-messages';
import type NamespaceService from 'vault/services/namespace';
import type RouterService from '@ember/routing/router-service';
import type WizardService from 'vault/services/wizard';
const DEFAULT_STEPS = [
{ title: 'Select setup', component: 'wizard/namespaces/step-1' },
@ -25,7 +24,6 @@ const DEFAULT_STEPS = [
];
interface Args {
onDismiss: CallableFunction;
onRefresh: CallableFunction;
}
@ -41,6 +39,7 @@ export default class WizardNamespacesWizardComponent extends Component<Args> {
@service declare readonly api: ApiService;
@service declare readonly router: RouterService;
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly wizard: WizardService;
@service declare namespace: NamespaceService;
@tracked steps = DEFAULT_STEPS;
@ -129,10 +128,8 @@ export default class WizardNamespacesWizardComponent extends Component<Args> {
@action
async onDismiss() {
const item = localStorage.getItem(DISMISSED_WIZARD_KEY) ?? [];
localStorage.setItem(DISMISSED_WIZARD_KEY, [...item, this.wizardId]);
this.wizard.dismiss(this.wizardId);
await this.args.onRefresh();
this.args.onDismiss();
}
@action
@ -155,7 +152,6 @@ export default class WizardNamespacesWizardComponent extends Component<Args> {
const { message } = await this.api.parseError(error);
this.flashMessages.danger(`Error creating namespaces: ${message}`);
} finally {
await this.args.onRefresh();
this.onDismiss();
}
}

View file

@ -30,6 +30,20 @@ class Block {
this.orgs = orgs;
}
hasMultipleItems(list: Org[] | Project[]): boolean {
return list.filter((item) => Boolean(item.name)).length > 1;
}
// The Carbon tree chart only supports datasets with at least 1 "fork" in the tree.
// This checks whether a global node has multiple orgs or a org node has multiple project nodes
// to determine whether the tree chart should be shown. If this criteria is not met, the tree flashes
// briefly and then remains blank.
get hasMultipleNodes() {
const hasMultipleOrgs = this.hasMultipleItems(this.orgs);
const orgHasMultipleProjects = this.orgs.some((org) => this.hasMultipleItems(org.projects));
return hasMultipleOrgs || orgHasMultipleProjects;
}
validateInput(value: string): string {
if (value.includes('/')) {
return '"/" is not allowed in namespace names';
@ -84,19 +98,19 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
isValidNesting(block: Block) {
// If there are non-empty orgs but no global, then it is invalid
if (block.orgs.some((org) => org.name.trim()) && !block.global.trim()) {
if (block.orgs.some((org) => org.name) && !block.global) {
return false;
}
// Check all projects have proper parents (global and org)
return block.orgs.every((org) => {
const hasProjects = org.projects.some((project) => project.name.trim());
return !hasProjects || (block.global.trim() && org.name.trim());
const hasProjects = org.projects.some((project) => project.name);
return !hasProjects || (block.global && org.name);
});
}
checkForDuplicateGlobals() {
const globals = this.blocks.map((block) => block.global.trim()).filter((global) => global !== '');
const globals = this.blocks.map((block) => block.global).filter((global) => global !== '');
const globalCounts = new Map();
globals.forEach((global) => {
@ -141,8 +155,9 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
const target = event.target as HTMLInputElement;
const block = this.blocks[blockIndex];
if (block) {
block.global = target.value;
block.globalError = block.validateInput(target.value);
const value = target.value.trim();
block.global = value;
block.globalError = block.validateInput(value);
this.checkForDuplicateGlobals();
this.updateWizardState();
}
@ -151,7 +166,7 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
@action
updateOrgValue(block: Block, orgToUpdate: Org, event: Event) {
const target = event.target as HTMLInputElement;
const value = target.value;
const value = target.value.trim();
const isDuplicate = block.orgs.some((org) => org !== orgToUpdate && org.name === value);
const updatedOrgs = block.orgs.map((org) => {
@ -178,7 +193,6 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
@action
removeOrg(block: Block, orgToRemove: Org) {
if (block.orgs.length <= 1) return;
block.orgs = block.orgs.filter((org) => org !== orgToRemove);
// Trigger tree reactivity
@ -188,7 +202,7 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
@action
updateProjectValue(block: Block, org: Org, projectToUpdate: Project, event: Event) {
const target = event.target as HTMLInputElement;
const value = target.value;
const value = target.value.trim();
const isDuplicate = org.projects.some((project) => project !== projectToUpdate && project.name === value);
const updatedOrgs = block.orgs.map((currentOrg) => {
@ -253,12 +267,12 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
return {
name: block.global,
children: block.orgs
.filter((org) => org.name.trim() !== '')
.filter((org) => org.name !== '')
.map((org) => {
return {
name: org.name,
children: org.projects
.filter((project) => project.name.trim() !== '')
.filter((project) => project.name !== '')
.map((project) => {
return {
name: project.name,
@ -275,28 +289,15 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
// The Carbon tree chart only supports displaying nodes with at least 1 "fork" i.e. at least 2 globals, 2 orgs or 2 projects
get shouldShowTreeChart(): boolean {
// Count total globals across blocks
const globalsCount = this.blocks.filter((block) => block.global.trim() !== '').length;
const globalsCount = this.blocks.filter((block) => block.global !== '').length;
// Check if there are multiple globals
if (globalsCount > 1) {
return true;
}
// Check if any block has multiple orgs
const hasMultipleOrgs = this.blocks.some(
(block) => block.orgs.filter((org) => org.name.trim() !== '').length > 1
);
if (hasMultipleOrgs) {
return true;
}
// Check if any org has multiple projects
const hasMultipleProjects = this.blocks.some((block) =>
block.orgs.some((org) => org.projects.filter((project) => project.name.trim() !== '').length > 1)
);
return hasMultipleProjects;
// Check for multiple projects or orgs within a block
return this.blocks.some((block) => block.hasMultipleNodes);
}
// Store namespace paths to be used for code snippets in the format "global", "global/org", "global/org/project"
@ -306,23 +307,23 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
const results: string[] = [];
// Add global namespace if it exists
if (block.global.trim() !== '') {
if (block.global !== '') {
results.push(block.global);
}
block.orgs.forEach((org) => {
if (org.name.trim() !== '') {
if (org.name !== '') {
// Add global/org namespace
const globalOrg = [block.global, org.name].filter((value) => value.trim() !== '').join('/');
const globalOrg = [block.global, org.name].filter((value) => value !== '').join('/');
if (globalOrg && !results.includes(globalOrg)) {
results.push(globalOrg);
}
org.projects.forEach((project) => {
if (project.name.trim() !== '') {
if (project.name !== '') {
// Add global/org/project namespace
const fullNamespace = [block.global, org.name, project.name]
.filter((value) => value.trim() !== '')
.filter((value) => value !== '')
.join('/');
if (fullNamespace && !results.includes(fullNamespace)) {
results.push(fullNamespace);

View file

@ -40,28 +40,14 @@
</Hds::Text::Body>
<Hds::Card::Container @hasBorder={{true}} class="has-top-padding-m has-bottom-padding-m side-padding-24">
{{#if (eq this.creationMethodChoice this.methods.APICLI)}}
<Hds::Tabs @onClickTab={{this.onClickTab}} as |T|>
{{#each this.tabOptions as |tabName|}}
<T.Tab data-test-tab={{tabName}}>{{tabName}}</T.Tab>
<T.Panel>
<Hds::CodeBlock
@language="bash"
@value={{this.snippet}}
@hasLineNumbers={{true}}
@hasCopyButton={{true}}
data-test-field="snippets"
/>
</T.Panel>
{{/each}}
</Hds::Tabs>
<CodeGenerator::AutomationSnippets @customTabs={{this.customTabs}} @onTabChange={{this.onTabChange}} />
{{else}}
<Hds::CodeBlock
@language="hcl"
@value={{this.snippet}}
@value={{this.tfSnippet}}
@hasLineNumbers={{true}}
@hasCopyButton={{true}}
data-test-field="snippets"
data-test-field="terraform"
/>
{{/if}}
</Hds::Card::Container>

View file

@ -9,7 +9,6 @@ import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { SecurityPolicy } from './step-1';
import type NamespaceService from 'vault/services/namespace';
import { HTMLElementEvent } from 'vault/forms';
import {
generateApiSnippet,
generateCliSnippet,
@ -42,7 +41,7 @@ interface CreationMethodChoice {
export default class WizardNamespacesStep3 extends Component<Args> {
@service declare readonly namespace: NamespaceService;
@tracked creationMethodChoice: CreationMethod;
@tracked selectedTab = 'API';
@tracked selectedTabIdx = 0;
methods = CreationMethod;
policy = SecurityPolicy;
@ -73,20 +72,26 @@ export default class WizardNamespacesStep3 extends Component<Args> {
'Apply changes immediately. Note: Changes made in the UI will be overwritten by any future updates made via Infrastructure as Code (Terraform).',
},
];
tabOptions = ['API', 'CLI'];
get snippet() {
get tfSnippet() {
const { namespacePaths } = this.args.wizardState;
switch (this.creationMethodChoice) {
case CreationMethod.TERRAFORM:
return generateTerraformSnippet(namespacePaths, this.namespace.path);
case CreationMethod.APICLI:
return this.selectedTab === 'API'
? generateApiSnippet(namespacePaths, this.namespace.path)
: generateCliSnippet(namespacePaths, this.namespace.path);
default:
return null;
}
return generateTerraformSnippet(namespacePaths, this.namespace.path);
}
get customTabs() {
const { namespacePaths } = this.args.wizardState;
return [
{
key: 'api',
label: 'API',
snippet: generateApiSnippet(namespacePaths, this.namespace.path),
},
{
key: 'cli',
label: 'CLI',
snippet: generateCliSnippet(namespacePaths, this.namespace.path),
},
];
}
@action
@ -98,8 +103,9 @@ export default class WizardNamespacesStep3 extends Component<Args> {
}
@action
onClickTab(_event: HTMLElementEvent<HTMLInputElement>, idx: number) {
this.selectedTab = this.tabOptions[idx]!;
onTabChange(idx: number) {
this.selectedTabIdx = idx;
// Update the code snippet whenever the tab changes
this.updateCodeSnippet();
}
@ -107,15 +113,11 @@ export default class WizardNamespacesStep3 extends Component<Args> {
// Update the wizard state with the current code snippet
@action
updateCodeSnippet() {
this.args.updateWizardState('codeSnippet', this.snippet);
}
// Helper function to ensure valid Terraform identifiers
sanitizeId(name: string): string {
// If the name starts with a number, prefix with 'ns_'
if (/^\d/.test(name)) {
return `ns_${name}`;
if (this.creationMethodChoice === CreationMethod.TERRAFORM) {
this.args.updateWizardState('codeSnippet', this.tfSnippet);
} else if (this.creationMethodChoice === CreationMethod.APICLI) {
const snippet = this.customTabs[this.selectedTabIdx]?.snippet;
this.args.updateWizardState('codeSnippet', snippet);
}
return name;
}
}

65
ui/app/services/wizard.ts Normal file
View file

@ -0,0 +1,65 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import localStorage from 'vault/lib/local-storage';
const DISMISSED_WIZARD_KEY = 'dismissed-wizards';
/**
* WizardService manages the state of wizards across the application,
* particularly tracking which wizards have been dismissed by the user.
* This service provides a centralized way to check and update wizard
* dismissal state instead of directly accessing localStorage.
*/
export default class WizardService extends Service {
@tracked dismissedWizards: string[] = this.loadDismissedWizards();
/**
* Load dismissed wizards from localStorage
*/
private loadDismissedWizards(): string[] {
return localStorage.getItem(DISMISSED_WIZARD_KEY) ?? [];
}
/**
* Check if a specific wizard has been dismissed by the user
* @param wizardId - The unique identifier for the wizard
* @returns true if the wizard has been dismissed, false otherwise
*/
isDismissed(wizardId: string): boolean {
return this.dismissedWizards.includes(wizardId);
}
/**
* Mark a wizard as dismissed
* @param wizardId - The unique identifier for the wizard to dismiss
*/
dismiss(wizardId: string): void {
// Only add if not already dismissed
if (!this.dismissedWizards.includes(wizardId)) {
this.dismissedWizards = [...this.dismissedWizards, wizardId];
localStorage.setItem(DISMISSED_WIZARD_KEY, this.dismissedWizards);
}
}
/**
* Clear the dismissed state for a specific wizard
* @param wizardId - The unique identifier for the wizard to reset
*/
reset(wizardId: string): void {
this.dismissedWizards = this.dismissedWizards.filter((id: string) => id !== wizardId);
localStorage.setItem(DISMISSED_WIZARD_KEY, this.dismissedWizards);
}
/**
* Clear all dismissed wizard states
*/
resetAll(): void {
this.dismissedWizards = [];
localStorage.removeItem(DISMISSED_WIZARD_KEY);
}
}

View file

@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Tabs as |T|>
<Hds::Tabs @onClickTab={{this.handleTabChange}} as |T|>
{{! key="key" tells Ember to track each tab by its key property rather than array index. }}
{{! This prevents the tabs from re-rendering and jarring page flashes when this.tabs recalculates. }}
{{#each this.tabs key="key" as |tab|}}

View file

@ -5,12 +5,14 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { terraformResourceTemplate } from 'core/utils/code-generators/terraform';
import { cliTemplate } from 'core/utils/code-generators/cli';
import type NamespaceService from 'vault/services/namespace';
import type { CliTemplateArgs } from 'core/utils/code-generators/cli';
import type { TerraformResourceTemplateArgs } from 'core/utils/code-generators/terraform';
import type { HTMLElementEvent } from 'vault/forms';
interface SnippetOption {
key: string;
@ -23,6 +25,7 @@ interface Args {
customTabs?: SnippetOption[];
tfvpArgs?: TerraformResourceTemplateArgs;
cliArgs?: CliTemplateArgs;
onTabChange?: (tabIdx: number) => void;
}
export default class CodeGeneratorAutomationSnippets extends Component<Args> {
@ -58,4 +61,12 @@ export default class CodeGeneratorAutomationSnippets extends Component<Args> {
}
return tfvpArgs;
}
@action
handleTabChange(_event: HTMLElementEvent<HTMLInputElement>, tabIndex: number) {
const { onTabChange } = this.args;
if (onTabChange) {
onTabChange(tabIndex);
}
}
}

View file

@ -15,6 +15,7 @@ const SELECTORS = {
content: '[data-test-content]',
guidedSetup: '[data-test-guided-setup]',
stepTitle: '[data-test-step-title]',
tree: '[data-test-tree]',
welcome: '[data-test-welcome]',
inputRow: (index) => (index ? `[data-test-input-row="${index}"]` : '[data-test-input-row]'),
};
@ -24,15 +25,13 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
setupMirage(hooks);
hooks.beforeEach(function () {
this.onFilterChange = sinon.spy();
this.onDismiss = sinon.spy();
this.onRefresh = sinon.spy();
this.refreshSpy = sinon.spy();
this.wizardService = this.owner.lookup('service:wizard');
this.renderComponent = () => {
return render(hbs`
<Wizard::Namespaces::NamespaceWizard
@onDismiss={{this.onDismiss}}
@onRefresh={{this.onRefresh}}
<Wizard::Namespaces::NamespaceWizard
@onRefresh={{this.refreshSpy}}
/>
`);
};
@ -55,17 +54,18 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
// Step 1: Choose security policy
assert.dom(GENERAL.button('Next')).isDisabled('Next button disabled with no policy choice');
await click(GENERAL.radioByAttr('strict'));
assert.dom(GENERAL.button('Next')).isNotDisabled('Next button enabled after policy selection');
await click(GENERAL.button('Next'));
// Step 2: Add namespace data
assert.dom(SELECTORS.stepTitle).hasText('Map out your namespaces');
assert.dom(GENERAL.button('Next')).isDisabled('Next button disabled with no namespace data');
await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('global-0')}`, 'global');
await click(GENERAL.button('Next'));
// Step 3: Choose implementation method
assert.dom(SELECTORS.stepTitle).hasText('Choose your implementation method');
assert.dom(GENERAL.copyButton).exists();
assert.dom(GENERAL.copyButton).exists('Copy button exists for code snippets');
});
test('it skips step 2 with flexible policy', async function (assert) {
@ -76,9 +76,10 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
await click(GENERAL.radioByAttr('flexible'));
await click(GENERAL.button('Next'));
// Should skip directly to step 3
// Should skip directly to step 3 (final step)
assert.dom(SELECTORS.stepTitle).hasText(`No action needed, you're all set.`);
assert.dom(GENERAL.button('identities')).exists();
assert.dom(GENERAL.button('identities')).exists('Link to identities exists');
assert.dom(GENERAL.button('Done')).exists('Done button exists for flexible policy');
});
test('it shows different code snippets per creation method option', async function (assert) {
@ -95,21 +96,22 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
// Assert code snippet changes
assert.dom(GENERAL.radioCardByAttr('Terraform automation')).exists('Terraform option exists');
assert
.dom(GENERAL.fieldByAttr('snippets'))
.dom(GENERAL.fieldByAttr('terraform'))
.hasTextContaining(`variable "global_child_namespaces"`, 'shows terraform code snippet by default');
await click(GENERAL.radioCardByAttr('API/CLI'));
assert
.dom(GENERAL.fieldByAttr('snippets'))
.dom(GENERAL.fieldByAttr('api'))
.hasTextContaining(`curl`, 'shows API code snippet by default for API/CLI radio card');
await click(GENERAL.hdsTab('CLI'));
await click(GENERAL.hdsTab('cli'));
assert
.dom(GENERAL.fieldByAttr('snippets'))
.dom(GENERAL.fieldByAttr('cli'))
.hasTextContaining(`vault namespace create`, 'shows CLI code snippet by for CLI tab');
await click(GENERAL.radioCardByAttr('Vault UI workflow'));
assert.dom(GENERAL.fieldByAttr('snippets')).doesNotExist('does not render a code snippet for UI flow');
assert.dom(GENERAL.fieldByAttr('terraform')).doesNotExist();
assert.dom(GENERAL.fieldByAttr('api')).doesNotExist();
assert.dom(GENERAL.fieldByAttr('cli')).doesNotExist();
});
test('it allows adding and removing blocks, org, and project inputs', async function (assert) {
@ -118,7 +120,7 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
await click(GENERAL.radioByAttr('strict'));
await click(GENERAL.button('Next'));
// Add a second block
// Test adding and removing a second namespace block
await click(GENERAL.button('add namespace'));
assert.dom(`${SELECTORS.inputRow(1)}`).exists('Second input block exists');
await click(`${SELECTORS.inputRow(1)} ${GENERAL.button('delete namespace')}`);
@ -128,18 +130,93 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('add project')}`);
assert
.dom(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('project-1')}`)
.exists('project input was added');
.exists('Second project input was added');
await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('delete project')}`);
assert
.dom(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('project-1')}`)
.doesNotExist('project input was removed');
.doesNotExist('Second project input was removed');
// Test adding and removing org input
await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('add org')}`);
assert.dom(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('org-1')}`).exists('org input was added');
assert
.dom(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('org-1')}`)
.exists('Second org input was added');
await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('delete org')}`);
assert
.dom(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('org-1')}`)
.doesNotExist('org input was removed');
.doesNotExist('Second org input was removed');
});
test('it dismisses from the welcome page', async function (assert) {
await this.renderComponent();
assert.false(this.wizardService.isDismissed('namespace'), 'Wizard is not dismissed initially');
await click(GENERAL.button('Skip'));
assert.true(this.wizardService.isDismissed('namespace'), 'Wizard was marked as dismissed in service');
assert.true(this.refreshSpy.calledOnce, 'onRefresh callback was called');
});
test('it dismisses from the guided setup', async function (assert) {
await this.renderComponent();
await click(GENERAL.button('Guided setup'));
assert.false(this.wizardService.isDismissed('namespace'), 'Wizard is not dismissed initially');
await click(GENERAL.button('Exit'));
assert.true(this.wizardService.isDismissed('namespace'), 'Wizard was marked as dismissed in service');
assert.true(this.refreshSpy.calledOnce, 'onRefresh callback was called');
});
test('it dismisses after completing flexible policy flow', async function (assert) {
await this.renderComponent();
await click(GENERAL.button('Guided setup'));
await click(GENERAL.radioByAttr('flexible'));
await click(GENERAL.button('Next'));
assert.false(this.wizardService.isDismissed('namespace'), 'Wizard not dismissed before clicking Done');
await click(GENERAL.button('Done'));
assert.true(this.wizardService.isDismissed('namespace'), 'Wizard was marked as dismissed after Done');
assert.true(this.refreshSpy.calledOnce, 'onRefresh callback was called');
});
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.radioByAttr('strict'));
await click(GENERAL.button('Next'));
// Initially with only one global and one org/project, tree should not show
await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('global-0')}`, 'global1');
await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('org-0')}`, 'org1');
await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('project-0')}`, 'proj1');
assert.dom(SELECTORS.tree).doesNotExist('Tree chart hidden with single global, org, and project');
// Add a second global namespace - tree should now show
await click(GENERAL.button('add namespace'));
await fillIn(`${SELECTORS.inputRow(1)} ${GENERAL.inputByAttr('global-1')}`, 'global2');
assert.dom(SELECTORS.tree).exists('Tree chart shows with multiple globals');
// Remove second global - tree is hidden again
await click(`${SELECTORS.inputRow(1)} ${GENERAL.button('delete namespace')}`);
assert.dom(SELECTORS.tree).doesNotExist('Tree chart hidden after removing second global');
// Add a second org - tree should show
await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('add org')}`);
await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('org-1')}`, 'org2');
assert.dom(SELECTORS.tree).exists('Tree chart shows with multiple orgs');
// Remove second org - tree is hidden again
await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('delete org')}`);
assert.dom(SELECTORS.tree).doesNotExist('Tree chart hidden after removing second org');
// Add a second project - tree should show
await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('add project')}`);
await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('project-1')}`, 'project2');
assert.dom(SELECTORS.tree).exists('Tree chart shows with multiple projects');
});
});

View file

@ -0,0 +1,181 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import sinon from 'sinon';
import localStorage from 'vault/lib/local-storage';
const DISMISSED_WIZARD_KEY = 'dismissed-wizards';
module('Unit | Service | wizard', function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.service = this.owner.lookup('service:wizard');
// Stub localStorage methods
this.getItemStub = sinon.stub(localStorage, 'getItem');
this.setItemStub = sinon.stub(localStorage, 'setItem');
this.removeItemStub = sinon.stub(localStorage, 'removeItem');
});
hooks.afterEach(function () {
this.getItemStub.restore();
this.setItemStub.restore();
this.removeItemStub.restore();
});
module('#loadDismissedWizards', function () {
test('loads dismissed wizards from localStorage', function (assert) {
this.getItemStub.withArgs(DISMISSED_WIZARD_KEY).returns(['wizard1', 'wizard2']);
const service = this.owner.lookup('service:wizard');
assert.deepEqual(
service.dismissedWizards,
['wizard1', 'wizard2'],
'loads dismissed wizards from localStorage'
);
assert.true(this.getItemStub.calledWith(DISMISSED_WIZARD_KEY), 'calls localStorage.getItem');
});
test('returns empty array when localStorage has no dismissed wizards', function (assert) {
this.getItemStub.withArgs(DISMISSED_WIZARD_KEY).returns(null);
const service = this.owner.lookup('service:wizard');
assert.deepEqual(service.dismissedWizards, [], 'returns empty array when no wizards dismissed');
});
});
module('#isDismissed', function () {
test('returns true when wizard is in dismissed list', function (assert) {
this.service.dismissedWizards = ['onboarding', 'tutorial'];
assert.true(this.service.isDismissed('onboarding'), 'returns true for dismissed wizard');
assert.true(this.service.isDismissed('tutorial'), 'returns true for another dismissed wizard');
});
test('returns false when wizard is not in dismissed list', function (assert) {
this.service.dismissedWizards = ['onboarding'];
assert.false(this.service.isDismissed('tutorial'), 'returns false for non-dismissed wizard');
});
});
module('#dismiss', function () {
test('adds wizard to dismissed list', function (assert) {
this.service.dismissedWizards = [];
this.service.dismiss('onboarding');
assert.deepEqual(this.service.dismissedWizards, ['onboarding'], 'adds wizard to dismissed list');
assert.true(this.setItemStub.calledWith(DISMISSED_WIZARD_KEY, ['onboarding']), 'saves to localStorage');
});
test('handles multiple dismissals', function (assert) {
this.service.dismissedWizards = [];
this.service.dismiss('wizard1');
this.service.dismiss('wizard2');
this.service.dismiss('wizard3');
assert.deepEqual(
this.service.dismissedWizards,
['wizard1', 'wizard2', 'wizard3'],
'handles multiple dismissals'
);
assert.strictEqual(this.setItemStub.callCount, 3, 'calls localStorage.setItem three times');
});
test('does not add duplicate wizards', function (assert) {
this.service.dismissedWizards = ['onboarding'];
this.service.dismiss('onboarding');
assert.deepEqual(this.service.dismissedWizards, ['onboarding'], 'does not add duplicate wizard');
assert.false(this.setItemStub.called, 'does not call localStorage.setItem for duplicate');
});
test('preserves existing dismissed wizards', function (assert) {
this.service.dismissedWizards = ['wizard1', 'wizard2'];
this.service.dismiss('wizard3');
assert.deepEqual(
this.service.dismissedWizards,
['wizard1', 'wizard2', 'wizard3'],
'preserves existing wizards and adds new one'
);
});
});
module('#reset', function () {
test('removes specific wizard from dismissed list', function (assert) {
this.service.dismissedWizards = ['wizard1', 'wizard2', 'wizard3'];
this.service.reset('wizard2');
assert.deepEqual(this.service.dismissedWizards, ['wizard1', 'wizard3'], 'removes specified wizard');
assert.true(
this.setItemStub.calledWith(DISMISSED_WIZARD_KEY, ['wizard1', 'wizard3']),
'updates localStorage with remaining wizards'
);
});
test('handles multiple resets', function (assert) {
this.service.dismissedWizards = ['wizard1', 'wizard2', 'wizard3'];
this.service.reset('wizard1');
this.service.reset('wizard3');
assert.deepEqual(this.service.dismissedWizards, ['wizard2'], 'removes multiple wizards');
assert.strictEqual(this.setItemStub.callCount, 2, 'calls localStorage.setItem twice');
});
test('does nothing when wizard is not in list', function (assert) {
this.service.dismissedWizards = ['wizard1'];
this.service.reset('wizard2');
assert.deepEqual(this.service.dismissedWizards, ['wizard1'], 'list unchanged when wizard not found');
assert.true(
this.setItemStub.calledWith(DISMISSED_WIZARD_KEY, ['wizard1']),
'still updates localStorage'
);
});
test('handles resetting from empty list', function (assert) {
this.service.dismissedWizards = [];
this.service.reset('wizard1');
assert.deepEqual(this.service.dismissedWizards, [], 'list remains empty');
assert.true(
this.setItemStub.calledWith(DISMISSED_WIZARD_KEY, []),
'updates localStorage with empty array'
);
});
});
module('#resetAll', function () {
test('clears all dismissed wizards', function (assert) {
this.service.dismissedWizards = ['wizard1', 'wizard2', 'wizard3'];
this.service.resetAll();
assert.deepEqual(this.service.dismissedWizards, [], 'clears all dismissed wizards');
assert.true(this.removeItemStub.calledWith(DISMISSED_WIZARD_KEY), 'removes key from localStorage');
});
test('handles resetAll when list is already empty', function (assert) {
this.service.dismissedWizards = [];
this.service.resetAll();
assert.deepEqual(this.service.dismissedWizards, [], 'list remains empty');
assert.true(this.removeItemStub.calledWith(DISMISSED_WIZARD_KEY), 'removes key from localStorage');
});
});
});