diff --git a/ui/app/components/wizard/namespaces/namespace-wizard.ts b/ui/app/components/wizard/namespaces/namespace-wizard.ts index e519d19ce5..1455966fa6 100644 --- a/ui/app/components/wizard/namespaces/namespace-wizard.ts +++ b/ui/app/components/wizard/namespaces/namespace-wizard.ts @@ -5,7 +5,6 @@ import { service } from '@ember/service'; import { action } from '@ember/object'; -import { tracked } from '@glimmer/tracking'; import Component from '@glimmer/component'; import { SecurityPolicy } from 'vault/components/wizard/namespaces/step-1'; import { CreationMethod } from 'vault/utils/constants/snippet'; @@ -17,8 +16,9 @@ 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 { StepConfig } from 'vault/services/wizard'; -const DEFAULT_STEPS = [ +const DEFAULT_STEPS: StepConfig[] = [ { title: 'Select setup', component: 'wizard/namespaces/step-1' }, { title: 'Map out namespaces', component: 'wizard/namespaces/step-2' }, { title: 'Apply changes', component: 'wizard/namespaces/step-3' }, @@ -38,6 +38,14 @@ interface WizardState { codeSnippet: string | null; } +const DEFAULT_WIZARD_STATE: WizardState = { + securityPolicyChoice: null, + namespacePaths: null, + namespaceBlocks: null, + creationMethod: null, + codeSnippet: null, +}; + export default class WizardNamespacesWizardComponent extends Component { @service declare readonly api: ApiService; @service declare readonly router: RouterService; @@ -45,21 +53,24 @@ export default class WizardNamespacesWizardComponent extends Component { @service declare readonly wizard: WizardService; @service declare namespace: NamespaceService; - @tracked currentStep = 0; - @tracked steps = DEFAULT_STEPS; - @tracked wizardState: WizardState = { - securityPolicyChoice: null, - namespacePaths: null, - namespaceBlocks: null, - creationMethod: null, - codeSnippet: null, - }; - methods = CreationMethod; policy = SecurityPolicy; wizardId = WIZARD_ID_MAP.namespace; + get currentStep() { + return this.wizard.getCurrentStep(this.wizardId); + } + + get steps() { + const steps = this.wizard.getSteps(this.wizardId); + return steps.length > 0 ? steps : DEFAULT_STEPS; + } + + get wizardState(): WizardState { + return { ...DEFAULT_WIZARD_STATE, ...this.wizard.getState>(this.wizardId) }; + } + // Whether the current step requirements have been met to proceed to the next step get canProceed() { switch (this.currentStep) { @@ -75,7 +86,7 @@ export default class WizardNamespacesWizardComponent extends Component { } get isFinalStep() { - return this.currentStep === this.steps.length - 1; + return this.wizard.isFinalStep(this.wizardId); } get shouldShowExitButton() { @@ -91,18 +102,18 @@ export default class WizardNamespacesWizardComponent extends Component { updateSteps() { if (this.wizardState.securityPolicyChoice === SecurityPolicy.FLEXIBLE) { - this.steps = [ + this.wizard.setSteps(this.wizardId, [ { title: 'Select setup', component: 'wizard/namespaces/step-1' }, { title: 'Apply changes', component: 'wizard/namespaces/step-3' }, - ]; + ]); } else { - this.steps = DEFAULT_STEPS; + this.wizard.setSteps(this.wizardId, DEFAULT_STEPS); } } @action onStepChange(step: number) { - this.currentStep = step; + this.wizard.setCurrentStep(this.wizardId, step); // if user policy selection changes which steps we show, update upon page navigation // instead of flashing the changes when toggling this.updateSteps(); @@ -110,10 +121,7 @@ export default class WizardNamespacesWizardComponent extends Component { @action updateWizardState(key: string, value: unknown) { - this.wizardState = { - ...this.wizardState, - [key]: value, - }; + this.wizard.updateState(this.wizardId, key, value); } @action @@ -126,6 +134,7 @@ export default class WizardNamespacesWizardComponent extends Component { @action async onDismiss() { this.wizard.dismiss(this.wizardId); + this.wizard.clearWizardState(this.wizardId); await this.args.onRefresh(); } diff --git a/ui/app/services/wizard.ts b/ui/app/services/wizard.ts index 7674eba6e1..f925e8f515 100644 --- a/ui/app/services/wizard.ts +++ b/ui/app/services/wizard.ts @@ -10,9 +10,22 @@ import { DISMISSED_WIZARD_KEY } from 'vault/utils/constants/wizard'; import type { WizardId } from 'vault/app-types'; +export interface StepConfig { + title: string; + component: string; +} + +// Unique identifier for each wizard, used to track dismissal state and step state. +export type WizardID = string; + +// Dynamic state storage for each wizard defined at the component level. +// This allows for flexible state management across different wizards without +// needing to predefine specific properties for each wizard in the service. +export type WizardState = Record; + /** * WizardService manages the state of wizards across the application, - * particularly tracking which wizards have been dismissed by the user. + * including 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. */ @@ -20,6 +33,11 @@ export default class WizardService extends Service { @tracked dismissedWizards: string[] = this.loadDismissedWizards(); @tracked introVisibleState: Record = {}; + /* Tracked properties for step state management */ + @tracked private stepData: Record = {}; + @tracked private currentStep: Record = {}; + @tracked private steps: Record = {}; + /** * Load dismissed wizards from localStorage */ @@ -94,4 +112,94 @@ export default class WizardService extends Service { [wizardId]: visible, }; } + + /* Step state management */ + + /** + * Retrieve the stored state for a wizard, typed by the caller. + * Returns an empty object if no state has been set yet, so consumers + * should merge with their own defaults when a fully-typed object is required. + * @param wizardId - The unique identifier for the wizard + * @returns The wizard's current state, cast to the caller-supplied type + */ + getState(wizardId: WizardId): T { + return (this.stepData[wizardId] ?? {}) as T; + } + + /** + * Immutably update a single key in the wizard's stored state. + * Other keys in the state are left unchanged. + * @param wizardId - The unique identifier for the wizard + * @param key - The state key to update + * @param value - The new value for that key + */ + updateState(wizardId: WizardId, key: string, value: unknown): void { + this.stepData = { + ...this.stepData, + [wizardId]: { ...this.stepData[wizardId], [key]: value }, + }; + } + + /** + * Reset the wizard's step data to an empty object and return navigation to step 0. + * The step configuration (registered via setSteps) is intentionally preserved so + * the same wizard can be re-entered without re-registering its steps. + * @param wizardId - The unique identifier for the wizard + */ + clearWizardState(wizardId: WizardId): void { + this.stepData = { ...this.stepData, [wizardId]: {} }; + this.currentStep = { ...this.currentStep, [wizardId]: 0 }; + } + + /** + * Return the index of the currently active step for a wizard. + * Defaults to 0 if the wizard has not yet navigated to any step. + * @param wizardId - The unique identifier for the wizard + * @returns The zero-indexed current step number + */ + getCurrentStep(wizardId: WizardId): number { + return this.currentStep[wizardId] ?? 0; + } + + /** + * Set the active step index for a wizard. + * @param wizardId - The unique identifier for the wizard + * @param step - The zero-indexed step to navigate to + */ + setCurrentStep(wizardId: WizardId, step: number): void { + this.currentStep = { ...this.currentStep, [wizardId]: step }; + } + + /** + * Return the registered step configuration array for a wizard. + * Returns an empty array if no steps have been registered yet, allowing + * consumers to supply their own defaults on first render. + * @param wizardId - The unique identifier for the wizard + * @returns Array of step configuration objects + */ + getSteps(wizardId: WizardId): StepConfig[] { + return this.steps[wizardId] ?? []; + } + + /** + * Replace the step configuration array for a wizard. + * Use this to add, remove, or reorder steps dynamically — for example, + * skipping a step based on a user's earlier selection. + * @param wizardId - The unique identifier for the wizard + * @param steps - The new step configuration to register + */ + setSteps(wizardId: WizardId, steps: StepConfig[]): void { + this.steps = { ...this.steps, [wizardId]: steps }; + } + + /** + * Return true when the current step is the last one in the registered configuration. + * Returns false if no steps have been registered yet. + * @param wizardId - The unique identifier for the wizard + * @returns Whether the wizard is on its final step + */ + isFinalStep(wizardId: WizardId): boolean { + const steps = this.getSteps(wizardId); + return steps.length > 0 && this.getCurrentStep(wizardId) === steps.length - 1; + } } diff --git a/ui/tests/unit/services/wizard-test.js b/ui/tests/unit/services/wizard-test.js index 0355cc375b..ea9e16e254 100644 --- a/ui/tests/unit/services/wizard-test.js +++ b/ui/tests/unit/services/wizard-test.js @@ -9,6 +9,12 @@ import sinon from 'sinon'; import localStorage from 'vault/lib/local-storage'; import { DISMISSED_WIZARD_KEY } from 'vault/utils/constants/wizard'; +const STEPS = [ + { title: 'Step 1', component: 'wizard/step-1' }, + { title: 'Step 2', component: 'wizard/step-2' }, + { title: 'Step 3', component: 'wizard/step-3' }, +]; + module('Unit | Service | wizard', function (hooks) { setupTest(hooks); @@ -231,4 +237,126 @@ module('Unit | Service | wizard', function (hooks) { assert.false(this.service.isIntroVisible('wizard3'), 'wizard3 is not visible'); }); }); + + /* Step state management */ + module('#getState / #updateState', function () { + test('getState returns empty object for uninitialized wizard', function (assert) { + assert.deepEqual(this.service.getState('namespace'), {}, 'returns empty object when not set'); + }); + + test('updateState sets a single key without mutating other keys', function (assert) { + this.service.updateState('namespace', 'choice', 'strict'); + + assert.strictEqual(this.service.getState('namespace').choice, 'strict', 'updates the specified key'); + }); + + test('successive updates accumulate correctly', function (assert) { + this.service.updateState('namespace', 'choice', 'strict'); + this.service.updateState('namespace', 'data', ['ns1', 'ns2']); + + const state = this.service.getState('namespace'); + assert.strictEqual(state.choice, 'strict', 'first update persists'); + assert.deepEqual(state.data, ['ns1', 'ns2'], 'second update persists'); + }); + + test('updating one wizard does not affect another', function (assert) { + this.service.updateState('namespace', 'choice', 'strict'); + + assert.strictEqual(this.service.getState('namespace').choice, 'strict', 'namespace updated'); + assert.strictEqual(this.service.getState('acl-policy').choice, undefined, 'acl-policy unchanged'); + }); + }); + + module('#clearWizardState', function () { + test('resets state to empty object and step to 0', function (assert) { + this.service.updateState('namespace', 'choice', 'strict'); + this.service.setCurrentStep('namespace', 2); + + this.service.clearWizardState('namespace'); + + assert.deepEqual(this.service.getState('namespace'), {}, 'state is empty after clear'); + assert.strictEqual(this.service.getCurrentStep('namespace'), 0, 'step resets to 0'); + }); + + test('preserves step configuration after clear', function (assert) { + this.service.setSteps('namespace', STEPS); + + this.service.clearWizardState('namespace'); + + assert.deepEqual(this.service.getSteps('namespace'), STEPS, 'step config is preserved'); + }); + }); + + module('#getCurrentStep / #setCurrentStep', function () { + test('returns 0 for uninitialized wizard', function (assert) { + assert.strictEqual(this.service.getCurrentStep('namespace'), 0, 'defaults to 0'); + }); + + test('setCurrentStep updates the active step', function (assert) { + this.service.setCurrentStep('namespace', 2); + + assert.strictEqual(this.service.getCurrentStep('namespace'), 2, 'step is updated'); + }); + + test('step changes for one wizard do not affect another', function (assert) { + this.service.setCurrentStep('namespace', 1); + + assert.strictEqual(this.service.getCurrentStep('namespace'), 1, 'namespace at step 1'); + assert.strictEqual(this.service.getCurrentStep('acl-policy'), 0, 'acl-policy still at step 0'); + }); + }); + + module('#getSteps / #setSteps', function () { + test('getSteps returns empty array for uninitialized wizard', function (assert) { + assert.deepEqual(this.service.getSteps('namespace'), [], 'returns empty array'); + }); + + test('setSteps replaces the step configuration', function (assert) { + this.service.setSteps('namespace', STEPS); + + assert.deepEqual(this.service.getSteps('namespace'), STEPS, 'step config is stored'); + }); + + test('setSteps for one wizard does not affect another', function (assert) { + this.service.setSteps('namespace', STEPS); + + assert.strictEqual(this.service.getSteps('namespace').length, 3, 'namespace has 3 steps'); + assert.deepEqual(this.service.getSteps('acl-policy'), [], 'acl-policy still has no steps'); + }); + }); + + module('#isFinalStep', function () { + test('returns false for uninitialized wizard (no steps)', function (assert) { + assert.false(this.service.isFinalStep('namespace'), 'returns false when no steps registered'); + }); + + test('returns false when not on the last step', function (assert) { + this.service.setSteps('namespace', STEPS); + + assert.false(this.service.isFinalStep('namespace'), 'returns false on step 0 of 3'); + + this.service.setCurrentStep('namespace', 1); + assert.false(this.service.isFinalStep('namespace'), 'returns false on step 1 of 3'); + }); + + test('returns true when on the last step', function (assert) { + this.service.setSteps('namespace', STEPS); + this.service.setCurrentStep('namespace', 2); + + assert.true(this.service.isFinalStep('namespace'), 'returns true on final step'); + }); + + test('reflects the new final step after setSteps reduces the step count', function (assert) { + this.service.setSteps('namespace', STEPS); + this.service.setCurrentStep('namespace', 1); + + // Reduce to 2 steps — step 1 is now the final step + this.service.setSteps('namespace', [STEPS[0], STEPS[2]]); + + assert.true( + this.service.isFinalStep('namespace'), + 'correctly identifies new final step after setSteps' + ); + }); + }); });