Vault Automation 7cd77ffaeb
fix tree chart label cut off, tree chart visibility state, validation (#13094) (#13119)
Co-authored-by: lane-wetmore <lane.wetmore@hashicorp.com>
2026-03-18 09:41:17 -05:00

349 lines
10 KiB
TypeScript

/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { service } from '@ember/service';
import type NamespaceService from 'vault/services/namespace';
import { isEmpty } from '@ember/utils';
interface Project {
name: string;
error?: string;
}
interface Org {
name: string;
projects: Project[];
error?: string;
}
class Block {
@tracked global = '';
@tracked orgs: Org[] = [{ name: '', projects: [{ name: '' }] }];
@tracked globalError = '';
constructor(global = '', orgs: Org[] = [{ name: '', projects: [{ name: '' }] }]) {
this.global = global;
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 filledOrgs = this.orgs.filter((org) => !isEmpty(org.name));
const orgHasMultipleProjects = filledOrgs.some((org) => this.hasMultipleItems(org.projects));
return hasMultipleOrgs || orgHasMultipleProjects;
}
validateInput(value: string): string {
if (value.includes('/')) {
return '"/" is not allowed in namespace names';
} else if (value.includes(' ')) {
return 'spaces are not allowed in namespace names';
}
return '';
}
}
interface Args {
wizardState: {
namespacePaths: string[] | null;
namespaceBlocks: Block[] | null;
};
updateWizardState: (key: string, value: unknown) => void;
}
export default class WizardNamespacesStepTemp extends Component<Args> {
@service declare namespace: NamespaceService;
@tracked blocks: Block[];
duplicateErrorMessage = 'No duplicate namespaces names are allowed within the same level';
constructor(owner: unknown, args: Args) {
super(owner, args);
this.blocks = args.wizardState.namespaceBlocks || [new Block()];
}
get treeChartOptions() {
const currentNamespace = this.namespace.currentNamespace || 'root';
return {
height: '400px',
tree: {
type: 'tree',
rootTitle: currentNamespace,
},
};
}
get hasErrors(): boolean {
return this.blocks.some((block) => {
// Check valid nesting
if (!this.isValidNesting(block)) return true;
// Check global error
if (block.globalError) return true;
// Check org errors
if (block.orgs.some((org) => org.error)) return true;
// Check project errors
return block.orgs.some((org) => org.projects.some((project) => project.error));
});
}
isValidNesting(block: Block) {
// If there are non-empty orgs but no global, then it is invalid
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);
return !hasProjects || (block.global && org.name);
});
}
checkForDuplicateGlobals() {
const globals = this.blocks.map((block) => block.global).filter((global) => !isEmpty(global));
const globalCounts = new Map();
globals.forEach((global) => {
globalCounts.set(global, (globalCounts.get(global) || 0) + 1);
});
this.blocks.forEach((block) => {
if (!block.globalError && globalCounts.get(block.global) > 1) {
block.globalError = this.duplicateErrorMessage;
} else if (globalCounts.get(block.global) === 1 && block.globalError === this.duplicateErrorMessage) {
// remove outdated error message
block.globalError = '';
}
});
}
updateWizardState() {
this.args.updateWizardState('namespacePaths', this.hasErrors ? null : this.namespacePaths);
this.args.updateWizardState('namespaceBlocks', this.hasErrors ? null : this.blocks);
}
@action
addBlock() {
this.blocks = [...this.blocks, new Block()];
}
@action
deleteBlock(index: number) {
if (this.blocks.length > 1) {
this.blocks = this.blocks.filter((_, i) => i !== index);
} else {
// Reset the only remaining block to initial state
this.blocks = [new Block()];
}
// Re-validate duplicate globals in case a duplicate was deleted
this.checkForDuplicateGlobals();
this.updateWizardState();
}
@action
updateGlobalValue(blockIndex: number, event: Event) {
const target = event.target as HTMLInputElement;
const block = this.blocks[blockIndex];
if (block) {
const value = target.value.trim();
block.global = value;
block.globalError = block.validateInput(value);
this.checkForDuplicateGlobals();
this.updateWizardState();
}
}
@action
updateOrgValue(block: Block, orgToUpdate: Org, event: Event) {
const target = event.target as HTMLInputElement;
const value = target.value.trim();
const isDuplicate = isEmpty(value)
? false
: block.orgs.some((org) => org !== orgToUpdate && org.name === value);
const updatedOrgs = block.orgs.map((org) => {
if (org === orgToUpdate) {
return {
...org,
name: value,
error: isDuplicate ? this.duplicateErrorMessage : block.validateInput(value),
};
}
return org;
});
block.orgs = updatedOrgs;
// Trigger tree reactivity by reassigning the blocks array
this.blocks = [...this.blocks];
this.updateWizardState();
}
@action
addOrg(block: Block) {
block.orgs = [...block.orgs, { name: '', projects: [{ name: '' }] }];
}
@action
removeOrg(block: Block, orgToRemove: Org) {
block.orgs = block.orgs.filter((org) => org !== orgToRemove);
// Trigger tree reactivity
this.blocks = [...this.blocks];
}
@action
updateProjectValue(block: Block, org: Org, projectToUpdate: Project, event: Event) {
const target = event.target as HTMLInputElement;
const value = target.value.trim();
const isDuplicate = isEmpty(value)
? false
: org.projects.some((project) => project !== projectToUpdate && project.name === value);
const updatedOrgs = block.orgs.map((currentOrg) => {
if (currentOrg === org) {
return {
...currentOrg,
projects: currentOrg.projects.map((project) => {
if (project === projectToUpdate) {
return {
name: value,
error: isDuplicate ? this.duplicateErrorMessage : block.validateInput(value),
};
}
return project;
}),
};
}
return currentOrg;
});
block.orgs = updatedOrgs;
// Trigger tree reactivity by reassigning the blocks array
this.blocks = [...this.blocks];
this.updateWizardState();
}
@action
addProject(block: Block, org: Org) {
const updatedOrgs = block.orgs.map((currentOrg) => {
if (currentOrg === org) {
return {
...currentOrg,
projects: [...currentOrg.projects, { name: '' }],
};
}
return currentOrg;
});
block.orgs = updatedOrgs;
}
@action
removeProject(block: Block, org: Org, projectToRemove: Project) {
if (org.projects.length <= 1) return;
const updatedOrgs = block.orgs.map((currentOrg) => {
if (currentOrg === org) {
return {
...currentOrg,
projects: currentOrg.projects.filter((project) => project !== projectToRemove),
};
}
return currentOrg;
});
block.orgs = updatedOrgs;
// Trigger tree reactivity
this.blocks = [...this.blocks];
}
get treeData() {
const parsed = this.blocks
.filter((block) => !isEmpty(block.global))
.map((block) => {
return {
name: block.global,
children: block.orgs
.filter((org) => !isEmpty(org.name))
.map((org) => {
return {
name: org.name,
children: org.projects
.filter((project) => !isEmpty(project.name))
.map((project) => {
return {
name: project.name,
};
}),
};
}),
};
});
return parsed;
}
// 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 filledBlocks = this.blocks.filter((block) => !isEmpty(block.global));
// Check if there are multiple globals
if (filledBlocks.length > 1) {
return true;
}
// Check for multiple projects or orgs within a block
return filledBlocks.some((block) => block.hasMultipleNodes);
}
// Store namespace paths to be used for code snippets in the format "global", "global/org", "global/org/project"
get namespacePaths(): string[] {
return this.blocks
.map((block) => {
const results: string[] = [];
// Add global namespace if it exists
if (!isEmpty(block.global)) {
results.push(block.global);
}
block.orgs.forEach((org) => {
if (!isEmpty(org.name)) {
// Add global/org namespace
const globalOrg = [block.global, org.name].filter((value) => !isEmpty(value)).join('/');
if (globalOrg && !results.includes(globalOrg)) {
results.push(globalOrg);
}
org.projects.forEach((project) => {
if (!isEmpty(project.name)) {
// Add global/org/project namespace
const fullNamespace = [block.global, org.name, project.name]
.filter((value) => !isEmpty(value))
.join('/');
if (fullNamespace && !results.includes(fullNamespace)) {
results.push(fullNamespace);
}
}
});
}
});
return results;
})
.flat()
.filter((namespace) => !isEmpty(namespace));
}
}