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'); + }); + }); +});