feat(wizard-service): extract namespaces wizard state logic into wizard service (#14793) (#14815)

Co-authored-by: Nina Bucholtz <nina.balachandranmary@gmail.com>
This commit is contained in:
Vault Automation 2026-05-15 07:23:11 -06:00 committed by GitHub
parent 3a3b6a3725
commit 86e46a1691
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 267 additions and 22 deletions

View file

@ -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<Args> {
@service declare readonly api: ApiService;
@service declare readonly router: RouterService;
@ -45,21 +53,24 @@ export default class WizardNamespacesWizardComponent extends Component<Args> {
@service declare readonly wizard: WizardService;
@service declare namespace: NamespaceService;
@tracked currentStep = 0;
@tracked steps = DEFAULT_STEPS;
@tracked wizardState: WizardState = {
securityPolicyChoice: null,
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<Partial<WizardState>>(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<Args> {
}
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<Args> {
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<Args> {
@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<Args> {
@action
async onDismiss() {
this.wizard.dismiss(this.wizardId);
this.wizard.clearWizardState(this.wizardId);
await this.args.onRefresh();
}

View file

@ -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<string, unknown>;
/**
* 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<string, boolean> = {};
/* Tracked properties for step state management */
@tracked private stepData: Record<WizardID, WizardState> = {};
@tracked private currentStep: Record<WizardID, number> = {};
@tracked private steps: Record<WizardID, StepConfig[]> = {};
/**
* 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<T extends object>(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;
}
}

View file

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