diff --git a/ui/app/components/page/namespaces.hbs b/ui/app/components/page/namespaces.hbs
index 46b88b326b..43ccec6450 100644
--- a/ui/app/components/page/namespaces.hbs
+++ b/ui/app/components/page/namespaces.hbs
@@ -5,10 +5,7 @@
{{#if (has-feature "Namespaces")}}
{{#if this.showWizard}}
-
+
{{else}}
<:breadcrumbs>
diff --git a/ui/app/components/page/namespaces.ts b/ui/app/components/page/namespaces.ts
index 0976b8e81e..13a0841175 100644
--- a/ui/app/components/page/namespaces.ts
+++ b/ui/app/components/page/namespaces.ts
@@ -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 {
@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 {
@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 {
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 {
@action
enterGuidedStart() {
- this.hasDismissedWizard = false;
+ // Reset the wizard dismissal state to allow re-entering the wizard
+ this.wizard.reset(this.wizardId);
}
@action handlePageChange() {
diff --git a/ui/app/components/wizard/index.hbs b/ui/app/components/wizard/index.hbs
index 26a893dafe..4e30dd872d 100644
--- a/ui/app/components/wizard/index.hbs
+++ b/ui/app/components/wizard/index.hbs
@@ -15,7 +15,7 @@
{{on "click" (fn (mut this.showWelcome) false)}}
data-test-button="Guided setup"
/>
-
+
<:exit>
{{#if this.shouldShowExitButton}}
-
+
{{/if}}
<:submit>
{{#if (eq this.wizardState.securityPolicyChoice this.policy.FLEXIBLE)}}
-
+
{{else if (eq this.wizardState.creationMethod this.methods.UI)}}
-
+
{{else}}
{
@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 {
@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 {
const { message } = await this.api.parseError(error);
this.flashMessages.danger(`Error creating namespaces: ${message}`);
} finally {
- await this.args.onRefresh();
this.onDismiss();
}
}
diff --git a/ui/app/components/wizard/namespaces/step-2.ts b/ui/app/components/wizard/namespaces/step-2.ts
index 93a60f8037..81599fba01 100644
--- a/ui/app/components/wizard/namespaces/step-2.ts
+++ b/ui/app/components/wizard/namespaces/step-2.ts
@@ -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 {
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 {
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 {
@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 {
@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 {
@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 {
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 {
// 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 {
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);
diff --git a/ui/app/components/wizard/namespaces/step-3.hbs b/ui/app/components/wizard/namespaces/step-3.hbs
index f4d4f89159..31d82ca486 100644
--- a/ui/app/components/wizard/namespaces/step-3.hbs
+++ b/ui/app/components/wizard/namespaces/step-3.hbs
@@ -40,28 +40,14 @@
{{#if (eq this.creationMethodChoice this.methods.APICLI)}}
-
- {{#each this.tabOptions as |tabName|}}
- {{tabName}}
-
-
-
- {{/each}}
-
-
+
{{else}}
{{/if}}
diff --git a/ui/app/components/wizard/namespaces/step-3.ts b/ui/app/components/wizard/namespaces/step-3.ts
index c8061e4536..c9a3f95851 100644
--- a/ui/app/components/wizard/namespaces/step-3.ts
+++ b/ui/app/components/wizard/namespaces/step-3.ts
@@ -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 {
@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 {
'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 {
}
@action
- onClickTab(_event: HTMLElementEvent, 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 {
// 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;
}
}
diff --git a/ui/app/services/wizard.ts b/ui/app/services/wizard.ts
new file mode 100644
index 0000000000..86ae5713fb
--- /dev/null
+++ b/ui/app/services/wizard.ts
@@ -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);
+ }
+}
diff --git a/ui/lib/core/addon/components/code-generator/automation-snippets.hbs b/ui/lib/core/addon/components/code-generator/automation-snippets.hbs
index d1c5b6004f..1037059774 100644
--- a/ui/lib/core/addon/components/code-generator/automation-snippets.hbs
+++ b/ui/lib/core/addon/components/code-generator/automation-snippets.hbs
@@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
-
+
{{! 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|}}
diff --git a/ui/lib/core/addon/components/code-generator/automation-snippets.ts b/ui/lib/core/addon/components/code-generator/automation-snippets.ts
index da30f0f2a9..e798e017fa 100644
--- a/ui/lib/core/addon/components/code-generator/automation-snippets.ts
+++ b/ui/lib/core/addon/components/code-generator/automation-snippets.ts
@@ -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 {
@@ -58,4 +61,12 @@ export default class CodeGeneratorAutomationSnippets extends Component {
}
return tfvpArgs;
}
+
+ @action
+ handleTabChange(_event: HTMLElementEvent, tabIndex: number) {
+ const { onTabChange } = this.args;
+ if (onTabChange) {
+ onTabChange(tabIndex);
+ }
+ }
}
diff --git a/ui/tests/integration/components/page/namespaces-wizard-test.js b/ui/tests/integration/components/page/namespaces-wizard-test.js
index 23fbd1dead..d1060e8424 100644
--- a/ui/tests/integration/components/page/namespaces-wizard-test.js
+++ b/ui/tests/integration/components/page/namespaces-wizard-test.js
@@ -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`
-
`);
};
@@ -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');
});
});
diff --git a/ui/tests/unit/services/wizard-test.js b/ui/tests/unit/services/wizard-test.js
new file mode 100644
index 0000000000..1a45f88bb2
--- /dev/null
+++ b/ui/tests/unit/services/wizard-test.js
@@ -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');
+ });
+ });
+});