mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-18 18:38:08 -05:00
* improve dismissal logic, use AutomationSnippet component, use wizard service for tracking dismissal * use class helper to check for multiple nodes when rendering tree chart, add test coverage * update comments * add wizard service unit test coverage * move item filtering into helper func Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com>
This commit is contained in:
parent
f1a0c8d745
commit
47849d7cea
13 changed files with 432 additions and 121 deletions
|
|
@ -5,10 +5,7 @@
|
|||
|
||||
{{#if (has-feature "Namespaces")}}
|
||||
{{#if this.showWizard}}
|
||||
<Wizard::Namespaces::NamespaceWizard
|
||||
@onDismiss={{fn (mut this.hasDismissedWizard) true}}
|
||||
@onRefresh={{this.refreshNamespaceList}}
|
||||
/>
|
||||
<Wizard::Namespaces::NamespaceWizard @onRefresh={{this.refreshNamespaceList}} />
|
||||
{{else}}
|
||||
<Page::Header @title="Namespaces">
|
||||
<:breadcrumbs>
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import type FlagsService from 'vault/services/flags';
|
|||
import type FlashMessageService from 'vault/services/flash-messages';
|
||||
import type NamespaceService from 'vault/services/namespace';
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
import type WizardService from 'vault/services/wizard';
|
||||
import type { HTMLElementEvent } from 'vault/forms';
|
||||
import { DISMISSED_WIZARD_KEY } from '../wizard';
|
||||
|
||||
/**
|
||||
* @module PageNamespaces
|
||||
|
|
@ -48,6 +48,7 @@ export default class PageNamespacesComponent extends Component<Args> {
|
|||
@service declare readonly router: RouterService;
|
||||
@service declare readonly flags: FlagsService;
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
@service declare readonly wizard: WizardService;
|
||||
@service declare namespace: NamespaceService;
|
||||
|
||||
// The `query` property is used to track the filter
|
||||
|
|
@ -56,19 +57,12 @@ export default class PageNamespacesComponent extends Component<Args> {
|
|||
@tracked query;
|
||||
@tracked nsToDelete = null;
|
||||
@tracked showSetupAlert = false;
|
||||
@tracked hasDismissedWizard = false;
|
||||
|
||||
wizardId = 'namespace';
|
||||
|
||||
constructor(owner: unknown, args: Args) {
|
||||
super(owner, args);
|
||||
this.query = this.args.model.pageFilter || '';
|
||||
|
||||
// check if the wizard has already been dismissed
|
||||
const dismissedWizards = localStorage.getItem(DISMISSED_WIZARD_KEY);
|
||||
if (dismissedWizards?.includes(this.wizardId)) {
|
||||
this.hasDismissedWizard = true;
|
||||
}
|
||||
}
|
||||
|
||||
// show the full available namespace path e.g. "root/ns1/child2", "admin/ns1/child2"
|
||||
|
|
@ -88,7 +82,7 @@ export default class PageNamespacesComponent extends Component<Args> {
|
|||
|
||||
get showWizard() {
|
||||
// Show when there are no existing namespaces and it is not in a dismissed state
|
||||
return !this.hasDismissedWizard && !this.args.model.namespaces?.length;
|
||||
return !this.wizard.isDismissed(this.wizardId) && !this.args.model.namespaces?.length;
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
@ -143,7 +137,8 @@ export default class PageNamespacesComponent extends Component<Args> {
|
|||
|
||||
@action
|
||||
enterGuidedStart() {
|
||||
this.hasDismissedWizard = false;
|
||||
// Reset the wizard dismissal state to allow re-entering the wizard
|
||||
this.wizard.reset(this.wizardId);
|
||||
}
|
||||
|
||||
@action handlePageChange() {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
{{on "click" (fn (mut this.showWelcome) false)}}
|
||||
data-test-button="Guided setup"
|
||||
/>
|
||||
<Hds::Button @color="secondary" @text="Skip" {{on "click" @onDismiss}} />
|
||||
<Hds::Button @color="secondary" @text="Skip" {{on "click" @onDismiss}} data-test-button="Skip" />
|
||||
<Hds::Link::Standalone
|
||||
@icon="docs-link"
|
||||
@iconPosition="trailing"
|
||||
|
|
|
|||
|
|
@ -18,14 +18,14 @@
|
|||
</:welcome>
|
||||
<:exit>
|
||||
{{#if this.shouldShowExitButton}}
|
||||
<Hds::Button @text={{this.exitText}} @color="secondary" {{on "click" this.onDismiss}} />
|
||||
<Hds::Button @text={{this.exitText}} @color="secondary" {{on "click" this.onDismiss}} data-test-button="Exit" />
|
||||
{{/if}}
|
||||
</:exit>
|
||||
<:submit>
|
||||
{{#if (eq this.wizardState.securityPolicyChoice this.policy.FLEXIBLE)}}
|
||||
<Hds::Button @text="Done" {{on "click" this.onDismiss}} data-test-button="done" />
|
||||
<Hds::Button @text="Done" {{on "click" this.onDismiss}} data-test-button="Done" />
|
||||
{{else if (eq this.wizardState.creationMethod this.methods.UI)}}
|
||||
<Hds::Button @text="Apply" {{on "click" this.onSubmit}} data-test-button="apply" />
|
||||
<Hds::Button @text="Apply" {{on "click" this.onSubmit}} data-test-button="Apply" />
|
||||
{{else}}
|
||||
<Hds::Copy::Button
|
||||
@text="Copy code"
|
||||
|
|
|
|||
|
|
@ -7,16 +7,15 @@ import { service } from '@ember/service';
|
|||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import Component from '@glimmer/component';
|
||||
import localStorage from 'vault/lib/local-storage';
|
||||
import { SecurityPolicy } from 'vault/components/wizard/namespaces/step-1';
|
||||
import { CreationMethod } from 'vault/components/wizard/namespaces/step-3';
|
||||
import { DISMISSED_WIZARD_KEY } from 'vault/components/wizard';
|
||||
|
||||
import type ApiService from 'vault/services/api';
|
||||
import type Block from 'vault/components/wizard/namespaces/step-2';
|
||||
import type FlashMessageService from 'vault/services/flash-messages';
|
||||
import type NamespaceService from 'vault/services/namespace';
|
||||
import type RouterService from '@ember/routing/router-service';
|
||||
import type WizardService from 'vault/services/wizard';
|
||||
|
||||
const DEFAULT_STEPS = [
|
||||
{ title: 'Select setup', component: 'wizard/namespaces/step-1' },
|
||||
|
|
@ -25,7 +24,6 @@ const DEFAULT_STEPS = [
|
|||
];
|
||||
|
||||
interface Args {
|
||||
onDismiss: CallableFunction;
|
||||
onRefresh: CallableFunction;
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +39,7 @@ export default class WizardNamespacesWizardComponent extends Component<Args> {
|
|||
@service declare readonly api: ApiService;
|
||||
@service declare readonly router: RouterService;
|
||||
@service declare readonly flashMessages: FlashMessageService;
|
||||
@service declare readonly wizard: WizardService;
|
||||
@service declare namespace: NamespaceService;
|
||||
|
||||
@tracked steps = DEFAULT_STEPS;
|
||||
|
|
@ -129,10 +128,8 @@ export default class WizardNamespacesWizardComponent extends Component<Args> {
|
|||
|
||||
@action
|
||||
async onDismiss() {
|
||||
const item = localStorage.getItem(DISMISSED_WIZARD_KEY) ?? [];
|
||||
localStorage.setItem(DISMISSED_WIZARD_KEY, [...item, this.wizardId]);
|
||||
this.wizard.dismiss(this.wizardId);
|
||||
await this.args.onRefresh();
|
||||
this.args.onDismiss();
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
@ -155,7 +152,6 @@ export default class WizardNamespacesWizardComponent extends Component<Args> {
|
|||
const { message } = await this.api.parseError(error);
|
||||
this.flashMessages.danger(`Error creating namespaces: ${message}`);
|
||||
} finally {
|
||||
await this.args.onRefresh();
|
||||
this.onDismiss();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,20 @@ class Block {
|
|||
this.orgs = orgs;
|
||||
}
|
||||
|
||||
hasMultipleItems(list: Org[] | Project[]): boolean {
|
||||
return list.filter((item) => Boolean(item.name)).length > 1;
|
||||
}
|
||||
|
||||
// The Carbon tree chart only supports datasets with at least 1 "fork" in the tree.
|
||||
// This checks whether a global node has multiple orgs or a org node has multiple project nodes
|
||||
// to determine whether the tree chart should be shown. If this criteria is not met, the tree flashes
|
||||
// briefly and then remains blank.
|
||||
get hasMultipleNodes() {
|
||||
const hasMultipleOrgs = this.hasMultipleItems(this.orgs);
|
||||
const orgHasMultipleProjects = this.orgs.some((org) => this.hasMultipleItems(org.projects));
|
||||
return hasMultipleOrgs || orgHasMultipleProjects;
|
||||
}
|
||||
|
||||
validateInput(value: string): string {
|
||||
if (value.includes('/')) {
|
||||
return '"/" is not allowed in namespace names';
|
||||
|
|
@ -84,19 +98,19 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
|
|||
|
||||
isValidNesting(block: Block) {
|
||||
// If there are non-empty orgs but no global, then it is invalid
|
||||
if (block.orgs.some((org) => org.name.trim()) && !block.global.trim()) {
|
||||
if (block.orgs.some((org) => org.name) && !block.global) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check all projects have proper parents (global and org)
|
||||
return block.orgs.every((org) => {
|
||||
const hasProjects = org.projects.some((project) => project.name.trim());
|
||||
return !hasProjects || (block.global.trim() && org.name.trim());
|
||||
const hasProjects = org.projects.some((project) => project.name);
|
||||
return !hasProjects || (block.global && org.name);
|
||||
});
|
||||
}
|
||||
|
||||
checkForDuplicateGlobals() {
|
||||
const globals = this.blocks.map((block) => block.global.trim()).filter((global) => global !== '');
|
||||
const globals = this.blocks.map((block) => block.global).filter((global) => global !== '');
|
||||
const globalCounts = new Map();
|
||||
|
||||
globals.forEach((global) => {
|
||||
|
|
@ -141,8 +155,9 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
|
|||
const target = event.target as HTMLInputElement;
|
||||
const block = this.blocks[blockIndex];
|
||||
if (block) {
|
||||
block.global = target.value;
|
||||
block.globalError = block.validateInput(target.value);
|
||||
const value = target.value.trim();
|
||||
block.global = value;
|
||||
block.globalError = block.validateInput(value);
|
||||
this.checkForDuplicateGlobals();
|
||||
this.updateWizardState();
|
||||
}
|
||||
|
|
@ -151,7 +166,7 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
|
|||
@action
|
||||
updateOrgValue(block: Block, orgToUpdate: Org, event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const value = target.value;
|
||||
const value = target.value.trim();
|
||||
const isDuplicate = block.orgs.some((org) => org !== orgToUpdate && org.name === value);
|
||||
|
||||
const updatedOrgs = block.orgs.map((org) => {
|
||||
|
|
@ -178,7 +193,6 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
|
|||
|
||||
@action
|
||||
removeOrg(block: Block, orgToRemove: Org) {
|
||||
if (block.orgs.length <= 1) return;
|
||||
block.orgs = block.orgs.filter((org) => org !== orgToRemove);
|
||||
|
||||
// Trigger tree reactivity
|
||||
|
|
@ -188,7 +202,7 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
|
|||
@action
|
||||
updateProjectValue(block: Block, org: Org, projectToUpdate: Project, event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const value = target.value;
|
||||
const value = target.value.trim();
|
||||
const isDuplicate = org.projects.some((project) => project !== projectToUpdate && project.name === value);
|
||||
|
||||
const updatedOrgs = block.orgs.map((currentOrg) => {
|
||||
|
|
@ -253,12 +267,12 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
|
|||
return {
|
||||
name: block.global,
|
||||
children: block.orgs
|
||||
.filter((org) => org.name.trim() !== '')
|
||||
.filter((org) => org.name !== '')
|
||||
.map((org) => {
|
||||
return {
|
||||
name: org.name,
|
||||
children: org.projects
|
||||
.filter((project) => project.name.trim() !== '')
|
||||
.filter((project) => project.name !== '')
|
||||
.map((project) => {
|
||||
return {
|
||||
name: project.name,
|
||||
|
|
@ -275,28 +289,15 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
|
|||
// The Carbon tree chart only supports displaying nodes with at least 1 "fork" i.e. at least 2 globals, 2 orgs or 2 projects
|
||||
get shouldShowTreeChart(): boolean {
|
||||
// Count total globals across blocks
|
||||
const globalsCount = this.blocks.filter((block) => block.global.trim() !== '').length;
|
||||
const globalsCount = this.blocks.filter((block) => block.global !== '').length;
|
||||
|
||||
// Check if there are multiple globals
|
||||
if (globalsCount > 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if any block has multiple orgs
|
||||
const hasMultipleOrgs = this.blocks.some(
|
||||
(block) => block.orgs.filter((org) => org.name.trim() !== '').length > 1
|
||||
);
|
||||
|
||||
if (hasMultipleOrgs) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if any org has multiple projects
|
||||
const hasMultipleProjects = this.blocks.some((block) =>
|
||||
block.orgs.some((org) => org.projects.filter((project) => project.name.trim() !== '').length > 1)
|
||||
);
|
||||
|
||||
return hasMultipleProjects;
|
||||
// Check for multiple projects or orgs within a block
|
||||
return this.blocks.some((block) => block.hasMultipleNodes);
|
||||
}
|
||||
|
||||
// Store namespace paths to be used for code snippets in the format "global", "global/org", "global/org/project"
|
||||
|
|
@ -306,23 +307,23 @@ export default class WizardNamespacesStepTemp extends Component<Args> {
|
|||
const results: string[] = [];
|
||||
|
||||
// Add global namespace if it exists
|
||||
if (block.global.trim() !== '') {
|
||||
if (block.global !== '') {
|
||||
results.push(block.global);
|
||||
}
|
||||
|
||||
block.orgs.forEach((org) => {
|
||||
if (org.name.trim() !== '') {
|
||||
if (org.name !== '') {
|
||||
// Add global/org namespace
|
||||
const globalOrg = [block.global, org.name].filter((value) => value.trim() !== '').join('/');
|
||||
const globalOrg = [block.global, org.name].filter((value) => value !== '').join('/');
|
||||
if (globalOrg && !results.includes(globalOrg)) {
|
||||
results.push(globalOrg);
|
||||
}
|
||||
|
||||
org.projects.forEach((project) => {
|
||||
if (project.name.trim() !== '') {
|
||||
if (project.name !== '') {
|
||||
// Add global/org/project namespace
|
||||
const fullNamespace = [block.global, org.name, project.name]
|
||||
.filter((value) => value.trim() !== '')
|
||||
.filter((value) => value !== '')
|
||||
.join('/');
|
||||
if (fullNamespace && !results.includes(fullNamespace)) {
|
||||
results.push(fullNamespace);
|
||||
|
|
|
|||
|
|
@ -40,28 +40,14 @@
|
|||
</Hds::Text::Body>
|
||||
<Hds::Card::Container @hasBorder={{true}} class="has-top-padding-m has-bottom-padding-m side-padding-24">
|
||||
{{#if (eq this.creationMethodChoice this.methods.APICLI)}}
|
||||
<Hds::Tabs @onClickTab={{this.onClickTab}} as |T|>
|
||||
{{#each this.tabOptions as |tabName|}}
|
||||
<T.Tab data-test-tab={{tabName}}>{{tabName}}</T.Tab>
|
||||
<T.Panel>
|
||||
<Hds::CodeBlock
|
||||
@language="bash"
|
||||
@value={{this.snippet}}
|
||||
@hasLineNumbers={{true}}
|
||||
@hasCopyButton={{true}}
|
||||
data-test-field="snippets"
|
||||
/>
|
||||
</T.Panel>
|
||||
{{/each}}
|
||||
|
||||
</Hds::Tabs>
|
||||
<CodeGenerator::AutomationSnippets @customTabs={{this.customTabs}} @onTabChange={{this.onTabChange}} />
|
||||
{{else}}
|
||||
<Hds::CodeBlock
|
||||
@language="hcl"
|
||||
@value={{this.snippet}}
|
||||
@value={{this.tfSnippet}}
|
||||
@hasLineNumbers={{true}}
|
||||
@hasCopyButton={{true}}
|
||||
data-test-field="snippets"
|
||||
data-test-field="terraform"
|
||||
/>
|
||||
{{/if}}
|
||||
</Hds::Card::Container>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { tracked } from '@glimmer/tracking';
|
|||
import { action } from '@ember/object';
|
||||
import { SecurityPolicy } from './step-1';
|
||||
import type NamespaceService from 'vault/services/namespace';
|
||||
import { HTMLElementEvent } from 'vault/forms';
|
||||
import {
|
||||
generateApiSnippet,
|
||||
generateCliSnippet,
|
||||
|
|
@ -42,7 +41,7 @@ interface CreationMethodChoice {
|
|||
export default class WizardNamespacesStep3 extends Component<Args> {
|
||||
@service declare readonly namespace: NamespaceService;
|
||||
@tracked creationMethodChoice: CreationMethod;
|
||||
@tracked selectedTab = 'API';
|
||||
@tracked selectedTabIdx = 0;
|
||||
|
||||
methods = CreationMethod;
|
||||
policy = SecurityPolicy;
|
||||
|
|
@ -73,20 +72,26 @@ export default class WizardNamespacesStep3 extends Component<Args> {
|
|||
'Apply changes immediately. Note: Changes made in the UI will be overwritten by any future updates made via Infrastructure as Code (Terraform).',
|
||||
},
|
||||
];
|
||||
tabOptions = ['API', 'CLI'];
|
||||
|
||||
get snippet() {
|
||||
get tfSnippet() {
|
||||
const { namespacePaths } = this.args.wizardState;
|
||||
switch (this.creationMethodChoice) {
|
||||
case CreationMethod.TERRAFORM:
|
||||
return generateTerraformSnippet(namespacePaths, this.namespace.path);
|
||||
case CreationMethod.APICLI:
|
||||
return this.selectedTab === 'API'
|
||||
? generateApiSnippet(namespacePaths, this.namespace.path)
|
||||
: generateCliSnippet(namespacePaths, this.namespace.path);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
return generateTerraformSnippet(namespacePaths, this.namespace.path);
|
||||
}
|
||||
|
||||
get customTabs() {
|
||||
const { namespacePaths } = this.args.wizardState;
|
||||
return [
|
||||
{
|
||||
key: 'api',
|
||||
label: 'API',
|
||||
snippet: generateApiSnippet(namespacePaths, this.namespace.path),
|
||||
},
|
||||
{
|
||||
key: 'cli',
|
||||
label: 'CLI',
|
||||
snippet: generateCliSnippet(namespacePaths, this.namespace.path),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
@ -98,8 +103,9 @@ export default class WizardNamespacesStep3 extends Component<Args> {
|
|||
}
|
||||
|
||||
@action
|
||||
onClickTab(_event: HTMLElementEvent<HTMLInputElement>, idx: number) {
|
||||
this.selectedTab = this.tabOptions[idx]!;
|
||||
onTabChange(idx: number) {
|
||||
this.selectedTabIdx = idx;
|
||||
|
||||
// Update the code snippet whenever the tab changes
|
||||
this.updateCodeSnippet();
|
||||
}
|
||||
|
|
@ -107,15 +113,11 @@ export default class WizardNamespacesStep3 extends Component<Args> {
|
|||
// Update the wizard state with the current code snippet
|
||||
@action
|
||||
updateCodeSnippet() {
|
||||
this.args.updateWizardState('codeSnippet', this.snippet);
|
||||
}
|
||||
|
||||
// Helper function to ensure valid Terraform identifiers
|
||||
sanitizeId(name: string): string {
|
||||
// If the name starts with a number, prefix with 'ns_'
|
||||
if (/^\d/.test(name)) {
|
||||
return `ns_${name}`;
|
||||
if (this.creationMethodChoice === CreationMethod.TERRAFORM) {
|
||||
this.args.updateWizardState('codeSnippet', this.tfSnippet);
|
||||
} else if (this.creationMethodChoice === CreationMethod.APICLI) {
|
||||
const snippet = this.customTabs[this.selectedTabIdx]?.snippet;
|
||||
this.args.updateWizardState('codeSnippet', snippet);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
ui/app/services/wizard.ts
Normal file
65
ui/app/services/wizard.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import localStorage from 'vault/lib/local-storage';
|
||||
|
||||
const DISMISSED_WIZARD_KEY = 'dismissed-wizards';
|
||||
|
||||
/**
|
||||
* WizardService manages the state of wizards across the application,
|
||||
* particularly tracking which wizards have been dismissed by the user.
|
||||
* This service provides a centralized way to check and update wizard
|
||||
* dismissal state instead of directly accessing localStorage.
|
||||
*/
|
||||
export default class WizardService extends Service {
|
||||
@tracked dismissedWizards: string[] = this.loadDismissedWizards();
|
||||
|
||||
/**
|
||||
* Load dismissed wizards from localStorage
|
||||
*/
|
||||
private loadDismissedWizards(): string[] {
|
||||
return localStorage.getItem(DISMISSED_WIZARD_KEY) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific wizard has been dismissed by the user
|
||||
* @param wizardId - The unique identifier for the wizard
|
||||
* @returns true if the wizard has been dismissed, false otherwise
|
||||
*/
|
||||
isDismissed(wizardId: string): boolean {
|
||||
return this.dismissedWizards.includes(wizardId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a wizard as dismissed
|
||||
* @param wizardId - The unique identifier for the wizard to dismiss
|
||||
*/
|
||||
dismiss(wizardId: string): void {
|
||||
// Only add if not already dismissed
|
||||
if (!this.dismissedWizards.includes(wizardId)) {
|
||||
this.dismissedWizards = [...this.dismissedWizards, wizardId];
|
||||
localStorage.setItem(DISMISSED_WIZARD_KEY, this.dismissedWizards);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the dismissed state for a specific wizard
|
||||
* @param wizardId - The unique identifier for the wizard to reset
|
||||
*/
|
||||
reset(wizardId: string): void {
|
||||
this.dismissedWizards = this.dismissedWizards.filter((id: string) => id !== wizardId);
|
||||
localStorage.setItem(DISMISSED_WIZARD_KEY, this.dismissedWizards);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all dismissed wizard states
|
||||
*/
|
||||
resetAll(): void {
|
||||
this.dismissedWizards = [];
|
||||
localStorage.removeItem(DISMISSED_WIZARD_KEY);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
<Hds::Tabs as |T|>
|
||||
<Hds::Tabs @onClickTab={{this.handleTabChange}} as |T|>
|
||||
{{! key="key" tells Ember to track each tab by its key property rather than array index. }}
|
||||
{{! This prevents the tabs from re-rendering and jarring page flashes when this.tabs recalculates. }}
|
||||
{{#each this.tabs key="key" as |tab|}}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@
|
|||
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { terraformResourceTemplate } from 'core/utils/code-generators/terraform';
|
||||
import { cliTemplate } from 'core/utils/code-generators/cli';
|
||||
|
||||
import type NamespaceService from 'vault/services/namespace';
|
||||
import type { CliTemplateArgs } from 'core/utils/code-generators/cli';
|
||||
import type { TerraformResourceTemplateArgs } from 'core/utils/code-generators/terraform';
|
||||
import type { HTMLElementEvent } from 'vault/forms';
|
||||
|
||||
interface SnippetOption {
|
||||
key: string;
|
||||
|
|
@ -23,6 +25,7 @@ interface Args {
|
|||
customTabs?: SnippetOption[];
|
||||
tfvpArgs?: TerraformResourceTemplateArgs;
|
||||
cliArgs?: CliTemplateArgs;
|
||||
onTabChange?: (tabIdx: number) => void;
|
||||
}
|
||||
|
||||
export default class CodeGeneratorAutomationSnippets extends Component<Args> {
|
||||
|
|
@ -58,4 +61,12 @@ export default class CodeGeneratorAutomationSnippets extends Component<Args> {
|
|||
}
|
||||
return tfvpArgs;
|
||||
}
|
||||
|
||||
@action
|
||||
handleTabChange(_event: HTMLElementEvent<HTMLInputElement>, tabIndex: number) {
|
||||
const { onTabChange } = this.args;
|
||||
if (onTabChange) {
|
||||
onTabChange(tabIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const SELECTORS = {
|
|||
content: '[data-test-content]',
|
||||
guidedSetup: '[data-test-guided-setup]',
|
||||
stepTitle: '[data-test-step-title]',
|
||||
tree: '[data-test-tree]',
|
||||
welcome: '[data-test-welcome]',
|
||||
inputRow: (index) => (index ? `[data-test-input-row="${index}"]` : '[data-test-input-row]'),
|
||||
};
|
||||
|
|
@ -24,15 +25,13 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
|
|||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.onFilterChange = sinon.spy();
|
||||
this.onDismiss = sinon.spy();
|
||||
this.onRefresh = sinon.spy();
|
||||
this.refreshSpy = sinon.spy();
|
||||
this.wizardService = this.owner.lookup('service:wizard');
|
||||
|
||||
this.renderComponent = () => {
|
||||
return render(hbs`
|
||||
<Wizard::Namespaces::NamespaceWizard
|
||||
@onDismiss={{this.onDismiss}}
|
||||
@onRefresh={{this.onRefresh}}
|
||||
<Wizard::Namespaces::NamespaceWizard
|
||||
@onRefresh={{this.refreshSpy}}
|
||||
/>
|
||||
`);
|
||||
};
|
||||
|
|
@ -55,17 +54,18 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
|
|||
// Step 1: Choose security policy
|
||||
assert.dom(GENERAL.button('Next')).isDisabled('Next button disabled with no policy choice');
|
||||
await click(GENERAL.radioByAttr('strict'));
|
||||
assert.dom(GENERAL.button('Next')).isNotDisabled('Next button enabled after policy selection');
|
||||
await click(GENERAL.button('Next'));
|
||||
|
||||
// Step 2: Add namespace data
|
||||
assert.dom(SELECTORS.stepTitle).hasText('Map out your namespaces');
|
||||
assert.dom(GENERAL.button('Next')).isDisabled('Next button disabled with no namespace data');
|
||||
await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('global-0')}`, 'global');
|
||||
|
||||
await click(GENERAL.button('Next'));
|
||||
|
||||
// Step 3: Choose implementation method
|
||||
assert.dom(SELECTORS.stepTitle).hasText('Choose your implementation method');
|
||||
assert.dom(GENERAL.copyButton).exists();
|
||||
assert.dom(GENERAL.copyButton).exists('Copy button exists for code snippets');
|
||||
});
|
||||
|
||||
test('it skips step 2 with flexible policy', async function (assert) {
|
||||
|
|
@ -76,9 +76,10 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
|
|||
await click(GENERAL.radioByAttr('flexible'));
|
||||
await click(GENERAL.button('Next'));
|
||||
|
||||
// Should skip directly to step 3
|
||||
// Should skip directly to step 3 (final step)
|
||||
assert.dom(SELECTORS.stepTitle).hasText(`No action needed, you're all set.`);
|
||||
assert.dom(GENERAL.button('identities')).exists();
|
||||
assert.dom(GENERAL.button('identities')).exists('Link to identities exists');
|
||||
assert.dom(GENERAL.button('Done')).exists('Done button exists for flexible policy');
|
||||
});
|
||||
|
||||
test('it shows different code snippets per creation method option', async function (assert) {
|
||||
|
|
@ -95,21 +96,22 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
|
|||
// Assert code snippet changes
|
||||
assert.dom(GENERAL.radioCardByAttr('Terraform automation')).exists('Terraform option exists');
|
||||
assert
|
||||
.dom(GENERAL.fieldByAttr('snippets'))
|
||||
.dom(GENERAL.fieldByAttr('terraform'))
|
||||
.hasTextContaining(`variable "global_child_namespaces"`, 'shows terraform code snippet by default');
|
||||
|
||||
await click(GENERAL.radioCardByAttr('API/CLI'));
|
||||
assert
|
||||
.dom(GENERAL.fieldByAttr('snippets'))
|
||||
.dom(GENERAL.fieldByAttr('api'))
|
||||
.hasTextContaining(`curl`, 'shows API code snippet by default for API/CLI radio card');
|
||||
|
||||
await click(GENERAL.hdsTab('CLI'));
|
||||
await click(GENERAL.hdsTab('cli'));
|
||||
assert
|
||||
.dom(GENERAL.fieldByAttr('snippets'))
|
||||
.dom(GENERAL.fieldByAttr('cli'))
|
||||
.hasTextContaining(`vault namespace create`, 'shows CLI code snippet by for CLI tab');
|
||||
|
||||
await click(GENERAL.radioCardByAttr('Vault UI workflow'));
|
||||
assert.dom(GENERAL.fieldByAttr('snippets')).doesNotExist('does not render a code snippet for UI flow');
|
||||
assert.dom(GENERAL.fieldByAttr('terraform')).doesNotExist();
|
||||
assert.dom(GENERAL.fieldByAttr('api')).doesNotExist();
|
||||
assert.dom(GENERAL.fieldByAttr('cli')).doesNotExist();
|
||||
});
|
||||
|
||||
test('it allows adding and removing blocks, org, and project inputs', async function (assert) {
|
||||
|
|
@ -118,7 +120,7 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
|
|||
await click(GENERAL.radioByAttr('strict'));
|
||||
await click(GENERAL.button('Next'));
|
||||
|
||||
// Add a second block
|
||||
// Test adding and removing a second namespace block
|
||||
await click(GENERAL.button('add namespace'));
|
||||
assert.dom(`${SELECTORS.inputRow(1)}`).exists('Second input block exists');
|
||||
await click(`${SELECTORS.inputRow(1)} ${GENERAL.button('delete namespace')}`);
|
||||
|
|
@ -128,18 +130,93 @@ module('Integration | Component | page/namespaces | Namespace Wizard', function
|
|||
await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('add project')}`);
|
||||
assert
|
||||
.dom(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('project-1')}`)
|
||||
.exists('project input was added');
|
||||
.exists('Second project input was added');
|
||||
await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('delete project')}`);
|
||||
assert
|
||||
.dom(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('project-1')}`)
|
||||
.doesNotExist('project input was removed');
|
||||
.doesNotExist('Second project input was removed');
|
||||
|
||||
// Test adding and removing org input
|
||||
await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('add org')}`);
|
||||
assert.dom(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('org-1')}`).exists('org input was added');
|
||||
assert
|
||||
.dom(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('org-1')}`)
|
||||
.exists('Second org input was added');
|
||||
await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('delete org')}`);
|
||||
assert
|
||||
.dom(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('org-1')}`)
|
||||
.doesNotExist('org input was removed');
|
||||
.doesNotExist('Second org input was removed');
|
||||
});
|
||||
|
||||
test('it dismisses from the welcome page', async function (assert) {
|
||||
await this.renderComponent();
|
||||
|
||||
assert.false(this.wizardService.isDismissed('namespace'), 'Wizard is not dismissed initially');
|
||||
|
||||
await click(GENERAL.button('Skip'));
|
||||
|
||||
assert.true(this.wizardService.isDismissed('namespace'), 'Wizard was marked as dismissed in service');
|
||||
assert.true(this.refreshSpy.calledOnce, 'onRefresh callback was called');
|
||||
});
|
||||
|
||||
test('it dismisses from the guided setup', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await click(GENERAL.button('Guided setup'));
|
||||
|
||||
assert.false(this.wizardService.isDismissed('namespace'), 'Wizard is not dismissed initially');
|
||||
|
||||
await click(GENERAL.button('Exit'));
|
||||
|
||||
assert.true(this.wizardService.isDismissed('namespace'), 'Wizard was marked as dismissed in service');
|
||||
assert.true(this.refreshSpy.calledOnce, 'onRefresh callback was called');
|
||||
});
|
||||
|
||||
test('it dismisses after completing flexible policy flow', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await click(GENERAL.button('Guided setup'));
|
||||
await click(GENERAL.radioByAttr('flexible'));
|
||||
await click(GENERAL.button('Next'));
|
||||
|
||||
assert.false(this.wizardService.isDismissed('namespace'), 'Wizard not dismissed before clicking Done');
|
||||
|
||||
await click(GENERAL.button('Done'));
|
||||
|
||||
assert.true(this.wizardService.isDismissed('namespace'), 'Wizard was marked as dismissed after Done');
|
||||
assert.true(this.refreshSpy.calledOnce, 'onRefresh callback was called');
|
||||
});
|
||||
|
||||
test('it shows tree chart only when there are multiple globals, orgs, or projects', async function (assert) {
|
||||
await this.renderComponent();
|
||||
await click(GENERAL.button('Guided setup'));
|
||||
await click(GENERAL.radioByAttr('strict'));
|
||||
await click(GENERAL.button('Next'));
|
||||
|
||||
// Initially with only one global and one org/project, tree should not show
|
||||
await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('global-0')}`, 'global1');
|
||||
await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('org-0')}`, 'org1');
|
||||
await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('project-0')}`, 'proj1');
|
||||
assert.dom(SELECTORS.tree).doesNotExist('Tree chart hidden with single global, org, and project');
|
||||
|
||||
// Add a second global namespace - tree should now show
|
||||
await click(GENERAL.button('add namespace'));
|
||||
await fillIn(`${SELECTORS.inputRow(1)} ${GENERAL.inputByAttr('global-1')}`, 'global2');
|
||||
assert.dom(SELECTORS.tree).exists('Tree chart shows with multiple globals');
|
||||
|
||||
// Remove second global - tree is hidden again
|
||||
await click(`${SELECTORS.inputRow(1)} ${GENERAL.button('delete namespace')}`);
|
||||
assert.dom(SELECTORS.tree).doesNotExist('Tree chart hidden after removing second global');
|
||||
|
||||
// Add a second org - tree should show
|
||||
await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('add org')}`);
|
||||
await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('org-1')}`, 'org2');
|
||||
assert.dom(SELECTORS.tree).exists('Tree chart shows with multiple orgs');
|
||||
|
||||
// Remove second org - tree is hidden again
|
||||
await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('delete org')}`);
|
||||
assert.dom(SELECTORS.tree).doesNotExist('Tree chart hidden after removing second org');
|
||||
|
||||
// Add a second project - tree should show
|
||||
await click(`${SELECTORS.inputRow(0)} ${GENERAL.button('add project')}`);
|
||||
await fillIn(`${SELECTORS.inputRow(0)} ${GENERAL.inputByAttr('project-1')}`, 'project2');
|
||||
assert.dom(SELECTORS.tree).exists('Tree chart shows with multiple projects');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
181
ui/tests/unit/services/wizard-test.js
Normal file
181
ui/tests/unit/services/wizard-test.js
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Copyright IBM Corp. 2016, 2025
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import sinon from 'sinon';
|
||||
import localStorage from 'vault/lib/local-storage';
|
||||
|
||||
const DISMISSED_WIZARD_KEY = 'dismissed-wizards';
|
||||
|
||||
module('Unit | Service | wizard', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.service = this.owner.lookup('service:wizard');
|
||||
|
||||
// Stub localStorage methods
|
||||
this.getItemStub = sinon.stub(localStorage, 'getItem');
|
||||
this.setItemStub = sinon.stub(localStorage, 'setItem');
|
||||
this.removeItemStub = sinon.stub(localStorage, 'removeItem');
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
this.getItemStub.restore();
|
||||
this.setItemStub.restore();
|
||||
this.removeItemStub.restore();
|
||||
});
|
||||
|
||||
module('#loadDismissedWizards', function () {
|
||||
test('loads dismissed wizards from localStorage', function (assert) {
|
||||
this.getItemStub.withArgs(DISMISSED_WIZARD_KEY).returns(['wizard1', 'wizard2']);
|
||||
const service = this.owner.lookup('service:wizard');
|
||||
|
||||
assert.deepEqual(
|
||||
service.dismissedWizards,
|
||||
['wizard1', 'wizard2'],
|
||||
'loads dismissed wizards from localStorage'
|
||||
);
|
||||
assert.true(this.getItemStub.calledWith(DISMISSED_WIZARD_KEY), 'calls localStorage.getItem');
|
||||
});
|
||||
|
||||
test('returns empty array when localStorage has no dismissed wizards', function (assert) {
|
||||
this.getItemStub.withArgs(DISMISSED_WIZARD_KEY).returns(null);
|
||||
const service = this.owner.lookup('service:wizard');
|
||||
|
||||
assert.deepEqual(service.dismissedWizards, [], 'returns empty array when no wizards dismissed');
|
||||
});
|
||||
});
|
||||
|
||||
module('#isDismissed', function () {
|
||||
test('returns true when wizard is in dismissed list', function (assert) {
|
||||
this.service.dismissedWizards = ['onboarding', 'tutorial'];
|
||||
|
||||
assert.true(this.service.isDismissed('onboarding'), 'returns true for dismissed wizard');
|
||||
assert.true(this.service.isDismissed('tutorial'), 'returns true for another dismissed wizard');
|
||||
});
|
||||
|
||||
test('returns false when wizard is not in dismissed list', function (assert) {
|
||||
this.service.dismissedWizards = ['onboarding'];
|
||||
|
||||
assert.false(this.service.isDismissed('tutorial'), 'returns false for non-dismissed wizard');
|
||||
});
|
||||
});
|
||||
|
||||
module('#dismiss', function () {
|
||||
test('adds wizard to dismissed list', function (assert) {
|
||||
this.service.dismissedWizards = [];
|
||||
|
||||
this.service.dismiss('onboarding');
|
||||
|
||||
assert.deepEqual(this.service.dismissedWizards, ['onboarding'], 'adds wizard to dismissed list');
|
||||
assert.true(this.setItemStub.calledWith(DISMISSED_WIZARD_KEY, ['onboarding']), 'saves to localStorage');
|
||||
});
|
||||
|
||||
test('handles multiple dismissals', function (assert) {
|
||||
this.service.dismissedWizards = [];
|
||||
|
||||
this.service.dismiss('wizard1');
|
||||
this.service.dismiss('wizard2');
|
||||
this.service.dismiss('wizard3');
|
||||
|
||||
assert.deepEqual(
|
||||
this.service.dismissedWizards,
|
||||
['wizard1', 'wizard2', 'wizard3'],
|
||||
'handles multiple dismissals'
|
||||
);
|
||||
assert.strictEqual(this.setItemStub.callCount, 3, 'calls localStorage.setItem three times');
|
||||
});
|
||||
|
||||
test('does not add duplicate wizards', function (assert) {
|
||||
this.service.dismissedWizards = ['onboarding'];
|
||||
|
||||
this.service.dismiss('onboarding');
|
||||
|
||||
assert.deepEqual(this.service.dismissedWizards, ['onboarding'], 'does not add duplicate wizard');
|
||||
assert.false(this.setItemStub.called, 'does not call localStorage.setItem for duplicate');
|
||||
});
|
||||
|
||||
test('preserves existing dismissed wizards', function (assert) {
|
||||
this.service.dismissedWizards = ['wizard1', 'wizard2'];
|
||||
|
||||
this.service.dismiss('wizard3');
|
||||
|
||||
assert.deepEqual(
|
||||
this.service.dismissedWizards,
|
||||
['wizard1', 'wizard2', 'wizard3'],
|
||||
'preserves existing wizards and adds new one'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('#reset', function () {
|
||||
test('removes specific wizard from dismissed list', function (assert) {
|
||||
this.service.dismissedWizards = ['wizard1', 'wizard2', 'wizard3'];
|
||||
|
||||
this.service.reset('wizard2');
|
||||
|
||||
assert.deepEqual(this.service.dismissedWizards, ['wizard1', 'wizard3'], 'removes specified wizard');
|
||||
assert.true(
|
||||
this.setItemStub.calledWith(DISMISSED_WIZARD_KEY, ['wizard1', 'wizard3']),
|
||||
'updates localStorage with remaining wizards'
|
||||
);
|
||||
});
|
||||
|
||||
test('handles multiple resets', function (assert) {
|
||||
this.service.dismissedWizards = ['wizard1', 'wizard2', 'wizard3'];
|
||||
|
||||
this.service.reset('wizard1');
|
||||
this.service.reset('wizard3');
|
||||
|
||||
assert.deepEqual(this.service.dismissedWizards, ['wizard2'], 'removes multiple wizards');
|
||||
assert.strictEqual(this.setItemStub.callCount, 2, 'calls localStorage.setItem twice');
|
||||
});
|
||||
|
||||
test('does nothing when wizard is not in list', function (assert) {
|
||||
this.service.dismissedWizards = ['wizard1'];
|
||||
|
||||
this.service.reset('wizard2');
|
||||
|
||||
assert.deepEqual(this.service.dismissedWizards, ['wizard1'], 'list unchanged when wizard not found');
|
||||
assert.true(
|
||||
this.setItemStub.calledWith(DISMISSED_WIZARD_KEY, ['wizard1']),
|
||||
'still updates localStorage'
|
||||
);
|
||||
});
|
||||
|
||||
test('handles resetting from empty list', function (assert) {
|
||||
this.service.dismissedWizards = [];
|
||||
|
||||
this.service.reset('wizard1');
|
||||
|
||||
assert.deepEqual(this.service.dismissedWizards, [], 'list remains empty');
|
||||
assert.true(
|
||||
this.setItemStub.calledWith(DISMISSED_WIZARD_KEY, []),
|
||||
'updates localStorage with empty array'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('#resetAll', function () {
|
||||
test('clears all dismissed wizards', function (assert) {
|
||||
this.service.dismissedWizards = ['wizard1', 'wizard2', 'wizard3'];
|
||||
|
||||
this.service.resetAll();
|
||||
|
||||
assert.deepEqual(this.service.dismissedWizards, [], 'clears all dismissed wizards');
|
||||
assert.true(this.removeItemStub.calledWith(DISMISSED_WIZARD_KEY), 'removes key from localStorage');
|
||||
});
|
||||
|
||||
test('handles resetAll when list is already empty', function (assert) {
|
||||
this.service.dismissedWizards = [];
|
||||
|
||||
this.service.resetAll();
|
||||
|
||||
assert.deepEqual(this.service.dismissedWizards, [], 'list remains empty');
|
||||
assert.true(this.removeItemStub.calledWith(DISMISSED_WIZARD_KEY), 'removes key from localStorage');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue