UI/v2 forms infrastructure (#14134) (#14694)

* copies v2 form components from POC branch

* fixes issue in form-config-generator when path parameters are not defined

* adds api code-generator for snippet creation

* expands cli and terraform code generators

* updates form-config-generator to return api path from spec

* fixes issue setting field value in v2-form class

* updates form-config types

* updates v2 form and renderer components to conditional render fields

* adds v2 form apply component

* updates v2 form wizard component to support apply step

* add support for field types (text input variants, text area, checkbox, radio, masked input) and add test coverage

* Dynamic field visibility and Select field support

* [POC] Public PKI (mocked) Wizard - revert this before merging

* Revert "[POC] Public PKI (mocked) Wizard - revert this before merging"

This reverts commit 66646f1d7a71d0e67028ebcabcfe33925197ffc9.

* cleanup & address copilot pr comments

* address PR comments

---------

Co-authored-by: Shannon Roberts (Beagin) <beagins@users.noreply.github.com>
Co-authored-by: Jordan Reimer <jordan.reimer@hashicorp.com>
This commit is contained in:
Vault Automation 2026-05-13 09:46:34 -06:00 committed by GitHub
parent 617b4627e7
commit f0cf2a4b68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2849 additions and 23 deletions

View file

@ -11,7 +11,7 @@ This document provides Handlebars template coding standards for HashiCorp Ember.
## Template Best Practices
- Check truthiness of arrays directly instead of using `.length` property
- Use string interpolation `"prefix/{{value}}"` instead of `{{concat}}` helper
- Use string interpolation `"prefix/{{value}}"` instead of `{{concat}}` helper
- Remove unnecessary quotes around dynamic component arguments
- Use `Hds::Link::Inline` for external documentation links instead of `<button>` elements
- Make `selected` attributes dynamic rather than static values - warn if static values are used
@ -19,6 +19,7 @@ This document provides Handlebars template coding standards for HashiCorp Ember.
- Avoid inline `style` attributes and `{{style ...}}` helpers - define CSS classes in `.scss` files instead
- Place `data-test-*` selectors as the last attribute on elements
- Remove quotes around dynamic data attributes: `data-test-id={{value}}` not `data-test-id="{{value}}"`
- **Avoid shadowed elements**: Avoid HTML element names (like `option`, `input`, `select`, etc.) as block parameter names in `{{#each}}` loops to prevent `no-shadowed-elements` lint errors and broken functionality.
Examples:
```handlebars
@ -64,6 +65,20 @@ Examples:
{{!-- Bad: unnecessary quotes --}}
<div data-test-namespace-link="{{option.label}}">
{{!-- Good: avoid shadowed elements by using descriptive block parameter names --}}
{{#each @field.options as |opt|}}
<option selected={{eq @value opt.value}} value={{opt.value}}>
{{opt.label}}
</option>
{{/each}}
{{!-- Bad: using HTML element name as block parameter causes lint error --}}
{{#each @field.options as |option|}}
<option selected={{eq @value option.value}} value={{option.value}}>
{{option.label}}
</option>
{{/each}}
```
---

View file

@ -0,0 +1,76 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Text::Display @tag="h1" @size="400" data-test-text-display="Step title">
Choose your implementation method
</Hds::Text::Display>
<Hds::Form::RadioCard::Group @name="implementation-method" @alignment="center" as |G|>
{{#each this.creationMethodOptions as |option|}}
<G.RadioCard
@alignment="left"
@checked={{eq option.label this.creationMethodChoice}}
{{on "change" (fn this.onChange option)}}
data-test-radio-card={{option.label}}
as |R|
>
<R.Icon @name={{option.icon}} />
<R.Label>{{option.label}}</R.Label>
{{#if option.isRecommended}}
<R.Badge @color="highlight" @text="Recommended" />
{{/if}}
<R.Description>{{option.description}}</R.Description>
</G.RadioCard>
{{/each}}
</Hds::Form::RadioCard::Group>
{{#unless (eq this.creationMethodChoice this.methods.UI)}}
<Hds::Text::Display @tag="h2" @size="400">Edit configuration</Hds::Text::Display>
<Hds::Text::Body @tag="p">
This configuration is generated based on your input in the previous step.
{{#if (eq this.creationMethodChoice this.methods.APICLI)}}
Copy it and run it in your terminal/use with Vault API to apply.
{{else}}
Copy and paste it directly to your Terraform Vault Provider terminal. For more details on the configuration language,
read the
<Hds::Link::Inline @href={{doc-link "/terraform/tutorials/configuration-language"}}>Terraform guide</Hds::Link::Inline>.
{{/if}}
</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)}}
<CodeGenerator::AutomationSnippets @customTabs={{this.customTabs}} @onTabChange={{fn (mut this.selectedTabIdx)}} />
{{else}}
<Hds::CodeBlock
@language="hcl"
@value={{this.tfSnippet}}
@hasLineNumbers={{true}}
@hasCopyButton={{true}}
data-test-field="terraform"
/>
<div class="is-flex-end has-top-margin-l has-bottom-margin-s">
<div>
<DownloadButton
@color="secondary"
@filename="vault-resources"
@data={{this.tfSnippet}}
@extension="tf"
@stringify={{true}}
@text="Export as tf file"
/>
</div>
</div>
{{/if}}
</Hds::Card::Container>
{{/unless}}
<div class="is-flex-center is-flex-between">
<Hds::Button @text="Back" @color="tertiary" @icon="chevron-left" {{on "click" @onBack}} />
<div>
<Hds::Button @text="Done & exit" @color="secondary" {{on "click" @onDone}} />
{{#if (eq this.creationMethodChoice this.methods.UI)}}
<Hds::Button @text="Apply changes" @color="primary" {{on "click" @onApply}} />
{{/if}}
</div>
</div>

View file

@ -0,0 +1,98 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { action } from '@ember/object';
import { service } from '@ember/service';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { generateCurlCommand } from 'core/utils/code-generators/api';
import { generateCliWriteCommand } from 'core/utils/code-generators/cli';
import { terraformGenericResourceTemplate } from 'core/utils/code-generators/terraform';
import type V2Form from 'vault/forms/v2/v2-form';
import type NamespaceService from 'vault/services/namespace';
interface Args {
form: V2Form;
onBack: () => void;
onDone: () => void;
onApply: () => void;
}
export enum CreationMethod {
TERRAFORM = 'Terraform automation',
APICLI = 'API/CLI',
UI = 'Vault UI workflow',
}
interface CreationMethodChoice {
icon: string;
label: CreationMethod;
description: string;
isRecommended?: boolean;
}
export default class FormV2Apply extends Component<Args> {
@service declare readonly namespace: NamespaceService;
@tracked creationMethodChoice = CreationMethod.TERRAFORM;
@tracked selectedTabIdx = 0;
methods = CreationMethod;
creationMethodOptions: CreationMethodChoice[] = [
{
icon: 'terraform-color',
label: CreationMethod.TERRAFORM,
description:
'Manage configurations by Infrastructure as Code. This creation method improves resilience and ensures common compliance requirements.',
isRecommended: true,
},
{
icon: 'terminal-screen',
label: CreationMethod.APICLI,
description:
'Manage namespaces directly via the Vault CLI or REST API. Best for quick updates, custom scripting, or terminal-based workflows.',
},
{
icon: 'sidebar',
label: CreationMethod.UI,
description:
'Apply changes immediately. Note: Changes made in the UI will be overwritten by any future updates made via Infrastructure as Code (Terraform).',
},
];
get requestData() {
const { payload } = this.args.form;
// payload has a top level key -- we need the actual data object for creating the snippets
return Object.values(payload)[0] as Record<string, unknown>;
}
get tfSnippet() {
const { config } = this.args.form;
return terraformGenericResourceTemplate(config.path, this.requestData, config.name, this.namespace.path);
}
get customTabs() {
const { config } = this.args.form;
return [
{
key: 'api',
label: 'API',
snippet: generateCurlCommand(config.path, this.requestData, this.namespace.path),
},
{
key: 'cli',
label: 'CLI',
snippet: generateCliWriteCommand(config.path, this.requestData),
},
];
}
@action
onChange(choice: CreationMethodChoice) {
this.creationMethodChoice = choice.label;
}
}

View file

@ -0,0 +1,8 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Alert @type="inline" @color="critical" as |A|>
<A.Title>{{this.title}}</A.Title>
<A.Description>{{@error}}</A.Description>
</Hds::Alert>

View file

@ -0,0 +1,29 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
interface Args {
/** Error message to display */
error?: string;
/** Optional custom title (defaults to "Submission error") */
title?: string;
}
/**
* Form::V2::ErrorAlert displays submission errors in a consistent format.
* Used by both Form::V2 and Form::V2::Wizard for standardized error display.
*
* @example
* ```handlebars
* <Form::V2::ErrorAlert @error={{this.submissionError}} />
* <Form::V2::ErrorAlert @error={{this.error}} @title="Configuration Error" />
* ```
*/
export default class FormV2ErrorAlert extends Component<Args> {
get title(): string {
return this.args.title || 'Submission error';
}
}

View file

@ -0,0 +1,213 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
{{!
TODO: Support other field types based on field.type.
- [ ] Key Value Input
- [ ] File Input
}}
{{#if (eq @field.type "TextArea")}}
<Hds::Form::Textarea::Field
@value={{@value}}
@isInvalid={{this.isInvalid}}
@isRequired={{@field.isRequired}}
@isDisabled={{@field.isDisabled}}
name={{this.name}}
placeholder={{@field.placeholder}}
{{on "input" (pick "target.value" (fn this.handleChange this.name))}}
as |F|
>
<F.Label>{{this.label}}</F.Label>
{{#if this.helperText}}
<F.HelperText>{{this.helperText}}</F.HelperText>
{{/if}}
{{#if this.isInvalid}}
<F.Error as |E|>
{{#each this.errors as |errorMessage|}}
<E.Message>{{errorMessage}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::Textarea::Field>
{{else if (eq @field.type "Select")}}
<Hds::Form::Select::Field
@value={{@value}}
@isInvalid={{this.isInvalid}}
@isRequired={{@field.isRequired}}
@isDisabled={{@field.isDisabled}}
name={{this.name}}
{{on "change" (pick "target.value" (fn this.handleChange this.name))}}
as |F|
>
<F.Label>{{this.label}}</F.Label>
{{#if this.helperText}}
<F.HelperText>{{this.helperText}}</F.HelperText>
{{/if}}
<F.Options>
{{#if @field.placeholder}}
<option value="">{{@field.placeholder}}</option>
{{/if}}
{{#each @field.options as |opt|}}
<option selected={{eq @value opt.value}} value={{opt.value}}>
{{opt.label}}
</option>
{{/each}}
</F.Options>
{{#if this.isInvalid}}
<F.Error as |E|>
{{#each this.errors as |errorMessage|}}
<E.Message>{{errorMessage}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::Select::Field>
{{else if (eq @field.type "Toggle")}}
<Hds::Form::Toggle::Field
checked={{@value}}
name={{this.name}}
disabled={{@field.isDisabled}}
{{on "change" (pick "target.checked" (fn this.handleChange this.name))}}
as |F|
>
<F.Label>{{this.label}}</F.Label>
{{#if this.helperText}}
<F.HelperText>{{this.helperText}}</F.HelperText>
{{/if}}
</Hds::Form::Toggle::Field>
{{else if (eq @field.type "Checkbox")}}
<Hds::Form::Checkbox::Field
checked={{@value}}
@isInvalid={{this.isInvalid}}
@isRequired={{@field.isRequired}}
@isDisabled={{@field.isDisabled}}
name={{this.name}}
{{on "change" (pick "target.checked" (fn this.handleChange this.name))}}
as |F|
>
<F.Label>{{this.label}}</F.Label>
{{#if this.helperText}}
<F.HelperText>{{this.helperText}}</F.HelperText>
{{/if}}
{{#if this.isInvalid}}
<F.Error as |E|>
{{#each this.errors as |errorMessage|}}
<E.Message>{{errorMessage}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::Checkbox::Field>
{{else if (eq @field.type "Radio")}}
<Hds::Form::Radio::Group @name={{this.name}} as |G|>
<G.Legend>{{this.label}}</G.Legend>
{{#if this.helperText}}
<G.HelperText>{{this.helperText}}</G.HelperText>
{{/if}}
{{#each @field.options as |option|}}
<G.RadioField
checked={{eq @value option.value}}
@isDisabled={{@field.isDisabled}}
{{on "change" (fn this.handleChange this.name option.value)}}
as |F|
>
<F.Label>{{option.label}}</F.Label>
</G.RadioField>
{{/each}}
{{#if this.isInvalid}}
<G.Error as |E|>
{{#each this.errors as |errorMessage|}}
<E.Message>{{errorMessage}}</E.Message>
{{/each}}
</G.Error>
{{/if}}
</Hds::Form::Radio::Group>
{{else if (eq @field.type "RadioCard")}}
<Hds::Form::RadioCard::Group @name={{this.name}} @alignment="left" as |G|>
<G.Legend>{{this.label}}</G.Legend>
{{#if this.helperText}}
<G.HelperText>{{this.helperText}}</G.HelperText>
{{/if}}
{{#each @field.options as |option|}}
<G.RadioCard
checked={{eq @value option.value}}
@isDisabled={{@field.isDisabled}}
{{on "change" (fn this.handleChange this.name option.value)}}
as |R|
>
<R.Label>{{option.label}}</R.Label>
{{#if option.description}}
<R.Description>{{option.description}}</R.Description>
{{/if}}
</G.RadioCard>
{{/each}}
{{#if this.isInvalid}}
<G.Error as |E|>
{{#each this.errors as |errorMessage|}}
<E.Message>{{errorMessage}}</E.Message>
{{/each}}
</G.Error>
{{/if}}
</Hds::Form::RadioCard::Group>
{{else if (eq @field.type "MaskedInput")}}
<Hds::Form::MaskedInput::Field
@value={{@value}}
@isInvalid={{this.isInvalid}}
@isRequired={{@field.isRequired}}
@isDisabled={{@field.isDisabled}}
name={{this.name}}
placeholder={{@field.placeholder}}
{{on "input" (pick "target.value" (fn this.handleChange this.name))}}
as |F|
>
<F.Label>{{this.label}}</F.Label>
{{#if this.helperText}}
<F.HelperText>{{this.helperText}}</F.HelperText>
{{/if}}
{{#if this.isInvalid}}
<F.Error as |E|>
{{#each this.errors as |errorMessage|}}
<E.Message>{{errorMessage}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::MaskedInput::Field>
{{else}}
{{!
Fallback for TextInput type and unsupported field types.
For unsupported types, a console warning is logged.
}}
<Hds::Form::TextInput::Field
@type={{this.inputType}}
@value={{@value}}
@isInvalid={{this.isInvalid}}
@isRequired={{@field.isRequired}}
name={{this.name}}
placeholder={{@field.placeholder}}
disabled={{@field.isDisabled}}
{{on "input" (pick "target.value" (fn this.handleChange this.name))}}
as |F|
>
<F.Label>{{this.label}}</F.Label>
{{#if this.helperText}}
<F.HelperText>{{this.helperText}}</F.HelperText>
{{/if}}
{{#if this.isInvalid}}
<F.Error as |E|>
{{#each this.errors as |errorMessage|}}
<E.Message>{{errorMessage}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
{{/if}}

View file

@ -0,0 +1,87 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import type { FieldValue, FormField } from 'vault/forms/v2/form-config';
/**
* FormV2Field component renders a single form field based on its configuration.
* Supports multiple value types (string, number, boolean, arrays) and delegates
* change events to the parent form component.
* Displays validation errors passed from the parent FormState.
*/
interface Args {
field: FormField;
value?: FieldValue;
errors?: string[];
onChange: (name: string, value: FieldValue) => void;
}
export default class FormV2Field extends Component<Args> {
constructor(owner: unknown, args: Args) {
super(owner, args);
// Log warning for unsupported field types
this.checkUnsupportedType();
}
get name(): string {
return this.args.field.name;
}
get label(): string {
return this.args.field.label;
}
get helperText(): string | undefined {
return this.args.field.helperText;
}
get inputType(): string {
return this.args.field.inputType || 'text';
}
get value(): FieldValue {
return this.args.value;
}
get errors(): string[] {
return this.args.errors || [];
}
get isInvalid(): boolean {
return this.errors.length > 0;
}
/**
* Checks if the field type is supported and logs a warning for unsupported types.
* Called in constructor to ensure warning is logged once when component is created.
*/
private checkUnsupportedType(): void {
const supportedTypes = [
'TextInput',
'TextArea',
'Select',
'Toggle',
'Checkbox',
'Radio',
'RadioCard',
'MaskedInput',
];
const isUnsupported = !supportedTypes.includes(this.args.field.type);
if (isUnsupported) {
console.warn(
`[Form::V2::Field] Unsupported field type "${this.args.field.type}" for field "${this.args.field.label}". Falling back to text input.`
);
}
}
/**
* Handles field value changes, supporting multiple value types
*/
handleChange = (name: string, value: FieldValue): void => {
this.args.onChange(name, value);
};
}

View file

@ -0,0 +1,28 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
{{! TODO: Add form-level title and description here - AC 12/25 }}
<Form::V2::Renderer
@form={{this.form}}
@renderFields={{not @hideFields}}
@error={{this.submissionError}}
...attributes
as |Form|
>
{{#if (has-block)}}
{{! Consumer provided custom content (e.g., wizard buttons) }}
{{yield Form this.submitTask}}
{{else}}
{{! No custom content - render default submit button }}
<Form.Section>
<Hds::Button
@text="Submit"
type="submit"
disabled={{or (not this.form.isValid) this.submitTask.isRunning}}
{{on "click" (perform this.submitTask)}}
/>
</Form.Section>
{{/if}}
</Form::V2::Renderer>

View file

@ -0,0 +1,63 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { service } from '@ember/service';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import type V2Form from 'vault/forms/v2/v2-form';
import type ApiService from 'vault/services/api';
interface Args {
form: V2Form;
hideFields?: boolean;
onSuccess?: (response: unknown) => void;
onError?: (errorMessage: string) => void;
}
export default class FormV2 extends Component<Args> {
@service declare readonly api: ApiService;
@tracked submissionError?: string;
@tracked lastResponse?: unknown;
get form() {
return this.args.form;
}
/**
* Handles form submission with validation and API call.
* Uses ember-concurrency to prevent double-submission and provide derived state.
* Invokes component callbacks before form config callbacks for wizard orchestration.
*/
submitTask = task({ drop: true }, async () => {
const { isValid } = this.form.validateForm();
if (!isValid) return;
try {
const response = await this.form.submit(this.api);
this.lastResponse = response;
// Call component's onSuccess first (for wizard orchestration)
this.args.onSuccess?.(response);
// Then call form config's onSuccess (for custom logic)
this.form.config.onSuccess?.(response);
return response;
} catch (error) {
const { message } = await this.api.parseError(error);
this.submissionError = message;
// Call component's onError first (for wizard orchestration)
this.args.onError?.(message);
// Then call form config's onError (for custom logic)
this.form.config.onError?.(message);
// Re-throw to maintain task error state
throw error;
}
});
}

View file

@ -0,0 +1,33 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Form ...attributes as |Form|>
{{#if @error}}
<Form.Section>
<Form::V2::ErrorAlert @error={{@error}} />
</Form.Section>
{{/if}}
{{#if @renderFields}}
{{#each @form.config.sections as |section|}}
{{#if (this.isSectionVisible section)}}
<Form::V2::Section @section={{section}} @formComponent={{Form}}>
{{#each section.fields as |field|}}
{{#if (this.isFieldVisible field)}}
<Form::V2::Field
@field={{field}}
@value={{get @form.payload field.name}}
@errors={{this.getFieldErrors field.name}}
@onChange={{this.handleFieldChange}}
/>
{{/if}}
{{/each}}
</Form::V2::Section>
{{/if}}
{{/each}}
{{/if}}
{{yield Form}}
</Hds::Form>

View file

@ -0,0 +1,71 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import type { FieldValue, FormField, FormSection, VisibilityRule } from 'vault/forms/v2/form-config';
import type V2Form from 'vault/forms/v2/v2-form';
interface Args {
/** The V2Form instance containing payload, config, and validation state */
form: V2Form;
/** Optional error message to display at the top of the form */
error?: string;
/** Whether to render form fields (used by wizard's apply step) */
renderFields?: boolean;
}
/**
* Form::V2::Renderer is a shared form rendering component that encapsulates
* the common form structure (Hds::Form, error alerts, sections, and fields).
*
* Usage:
* ```handlebars
* <Form::V2::Renderer @form={{this.form}} @error={{this.submissionError}} as |Form|>
* <Form.Section>
* <Hds::Button @text="Submit" type="submit" ... />
* </Form.Section>
* </Form::V2::Renderer>
* ```
*
* The component yields the Hds::Form context for consumers to define
* their own submit/navigation UI.
*/
export default class FormV2Renderer extends Component<Args> {
/**
* Get validation errors for a specific field.
* Arrow function to preserve `this` context when called from template.
*/
getFieldErrors = (fieldName: string): string[] => {
return this.args.form.getErrors(fieldName);
};
/**
* Handle field value changes.
* Arrow function to preserve `this` context when called from template.
*/
handleFieldChange = (name: string, value: FieldValue): void => {
this.args.form.set(name, value);
};
isSectionVisible = (section: FormSection): boolean => {
return this.#isVisible(section.isVisible);
};
isFieldVisible = (field: FormField): boolean => {
return this.#isVisible(field.isVisible);
};
#isVisible(rule?: VisibilityRule): boolean {
if (typeof rule === 'function') {
return rule(this.args.form.payload);
}
if (typeof rule === 'boolean') {
return rule;
}
return true;
}
}

View file

@ -0,0 +1,21 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
{{#let @formComponent as |Form|}}
<Form.Section>
{{#if @section.title}}
<Form.SectionHeader>
<Form.SectionHeaderTitle @tag="h2">{{@section.title}}</Form.SectionHeaderTitle>
{{#if @section.description}}
<Form.SectionHeaderDescription>
{{@section.description}}
</Form.SectionHeaderDescription>
{{/if}}
</Form.SectionHeader>
{{/if}}
{{yield}}
</Form.Section>
{{/let}}

View file

@ -0,0 +1,95 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Stepper::Nav
@currentStep={{this.currentStepIndex}}
@ariaLabel="{{this.config.title}} wizard"
@onStepChange={{this.onStepChange}}
...attributes
as |S|
>
{{#each @config.steps as |step|}}
<S.Step>
<:title>{{step.title}}</:title>
<:description>{{step.description}}</:description>
</S.Step>
<S.Panel>
{{#if step.heading}}
<h3>{{step.heading}}</h3>
{{/if}}
{{#if step.description}}
<p>{{step.description}}</p>
{{/if}}
{{! Delegate to Form::V2 component with wizard callbacks }}
<Form::V2
@form={{this.currentForm}}
@onSuccess={{this.onStepSuccess}}
@onError={{this.onStepError}}
as |Form submitTask|
>
{{! Custom wizard navigation buttons }}
<Form.Section>
<Hds::ButtonSet class="is-flex-center is-flex-between">
{{#unless this.isFirstStep}}
<Hds::Button @text="Back" @color="tertiary" @icon="chevron-left" {{on "click" this.previousStep}} />
{{/unless}}
<div class="margin-left-auto">
{{#if @config.applyChanges}}
<Hds::Button
@text="Continue"
@color="primary"
type="submit"
disabled={{or (not this.currentForm.isValid) submitTask.isRunning}}
{{on "click" (perform submitTask)}}
/>
{{else}}
<Hds::Button
@text={{if this.isLastStep "Complete" "Continue"}}
@color="primary"
type="submit"
disabled={{or (not this.currentForm.isValid) submitTask.isRunning}}
class="margin-left-auto"
{{on "click" (perform submitTask)}}
/>
{{/if}}
</div>
</Hds::ButtonSet>
</Form.Section>
</Form::V2>
</S.Panel>
{{/each}}
{{!
* Optional apply changes step defined in WizardConfig
* The Form::V2 component still handles submission, validation, error handling etc.
* The renderer bypasses rendering the form fields when the applyChanges block is present
}}
{{#if this.config.applyChanges}}
<S.Step>
<:title>Apply changes</:title>
</S.Step>
<S.Panel>
<Form::V2
@form={{this.currentForm}}
@hideFields={{true}}
@onSuccess={{@onSuccess}}
@onError={{this.onStepError}}
as |Form submitTask|
>
<Form::V2::Apply
@form={{this.currentForm}}
@onBack={{this.previousStep}}
@onApply={{perform submitTask}}
@onDone={{@onCancel}}
/>
</Form::V2>
</S.Panel>
{{/if}}
</Hds::Stepper::Nav>

View file

@ -0,0 +1,219 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { action } from '@ember/object';
import { service } from '@ember/service';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import type { FormConfig, WizardConfig, WizardState, WizardStepState } from 'vault/forms/v2/form-config';
import V2Form from 'vault/forms/v2/v2-form';
import type ApiService from 'vault/services/api';
interface Args {
config: WizardConfig;
onCancel: () => void;
onSuccess: () => void;
}
/**
* Form::V2::Wizard manages multi-step wizard flows with cross-step data sharing.
* Delegates form submission to Form::V2 components via callback pattern.
*
* Usage:
* ```handlebars
* <Form::V2::Wizard @config={{this.wizardConfig}} />
* ```
*
* Features:
* - Sequential step navigation
* - Cross-step data flow via wizardState
* - Dynamic payload resolution (supports functions)
* - Delegates submission/validation to Form::V2 components
*/
export default class FormV2Wizard extends Component<Args> {
@service declare readonly api: ApiService;
@tracked currentStepIndex = 0;
@tracked wizardState: WizardState = {};
// Cache the form for the current step (cleared on navigation)
#currentFormCache?: V2Form<any, any>;
get config(): WizardConfig {
return this.args.config;
}
get steps() {
return this.config.steps;
}
get currentStep() {
return this.steps[this.currentStepIndex];
}
get currentStepName() {
return this.currentStep?.name;
}
/**
* Get or create the V2Form instance for the current step.
* Resolves dynamic payloads using current wizard state.
* Cached to preserve field changes during a single step.
*/
get currentForm(): V2Form<any, any> {
// Return cached form if it exists for current step
if (this.#currentFormCache) {
return this.#currentFormCache;
}
// Special case: apply step uses the last real step's form
// When isApplyingChanges is true, currentStepIndex === steps.length,
// so we need to use the last actual step instead
const step = this.isApplyingChanges
? this.steps[this.steps.length - 1]
: this.steps[this.currentStepIndex];
const resolvedPayload = this.#resolvePayload(step?.formConfig.payload);
const resolvedConfig = {
...step?.formConfig,
payload: resolvedPayload,
} as FormConfig<any, any>;
const form = new V2Form<any, any>(resolvedConfig);
// eslint-disable-next-line ember/no-side-effects
this.#currentFormCache = form;
return form;
}
get currentStepState(): WizardStepState | undefined {
return this.currentStepName ? this.wizardState[this.currentStepName] : undefined;
}
get stepCount() {
return this.config.applyChanges ? this.steps.length + 1 : this.steps.length;
}
get isFirstStep() {
return this.currentStepIndex === 0;
}
get isLastStep() {
return this.currentStepIndex === this.stepCount - 1;
}
get isApplyingChanges() {
return this.isLastStep && this.config.applyChanges;
}
get canAdvance() {
// Can advance if current step has a response (successfully completed)
return !!this.currentStepState?.response;
}
/**
* Resolves a payload that might be a function or a static object.
*
* Function payloads enable cross-step data sharing in wizards by reading
* from wizardState to pre-populate form fields based on previous steps.
*
* Example: A mount path entered in step 1 can be reused in steps 2 and 3
* by defining their payloads as functions that read from wizardState:
*
* ```typescript
* payload: (wizardState) => ({
* mount_path: wizardState.step1?.payload?.path || 'default/'
* })
* ```
*
* This ensures consistency across steps and improves UX by avoiding
* repetitive data entry.
*/
#resolvePayload(payload: any): any {
if (typeof payload === 'function') {
return payload(this.wizardState);
}
return payload;
}
/**
* Updates wizard state after a step submission.
* Stores only data - execution state is derived from task properties.
*/
#updateWizardState(stepName: string, payload: any, response: any, error?: string) {
this.wizardState = {
...this.wizardState,
[stepName]: {
payload,
response,
error,
},
};
}
/**
* Handles successful step submission from Form::V2 component.
* Updates wizard state and advances to next step.
*/
@action
onStepSuccess(response: unknown) {
const stepName = this.currentStepName || '';
const payload = this.currentForm.payload;
// Update wizard state with response
this.#updateWizardState(stepName, payload, response);
// Auto-advance if not last step
if (!this.isLastStep) {
this.nextStep();
}
}
/**
* Handles failed step submission from Form::V2 component.
* Updates wizard state with error message.
*/
@action
onStepError(errorMessage: string) {
const stepName = this.currentStepName || '';
// Store error in wizard state
this.#updateWizardState(stepName, this.currentForm.payload, null, errorMessage);
}
@action
nextStep() {
if (this.currentStepIndex < this.stepCount - 1) {
this.currentStepIndex++;
// Clear form cache so next step gets fresh form with resolved payload
this.#currentFormCache = undefined;
}
}
@action
previousStep() {
if (this.currentStepIndex > 0) {
this.currentStepIndex--;
// Clear form cache when navigating back
this.#currentFormCache = undefined;
}
}
@action
onStepChange(_event: Event, stepIndex: number) {
// Only allow navigating to completed steps or previous steps
const targetStep = this.steps[stepIndex];
const targetStepState = this.wizardState[targetStep?.name || ''];
// Can navigate if:
// 1. Step is already completed (has response), OR
// 2. It's a previous step (allow going back)
if (targetStepState?.response || stepIndex <= this.currentStepIndex) {
this.currentStepIndex = stepIndex;
// Clear form cache to re-resolve payload with current wizard state
this.#currentFormCache = undefined;
}
}
}

View file

@ -3,28 +3,61 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import CONFIG_REGISTRY from './generated/index';
import type ApiService from 'vault/services/api';
import type { ValidationRule } from './form-validator';
import CONFIG_REGISTRY from './generated/index';
export type FormConfigKey = keyof typeof CONFIG_REGISTRY;
/**
* Form element types matching HDS (HashiCorp Design System) components.
* Currently only TextInput is supported.
*
* TODO: Add support for additional field types:
* - 'Toggle' | 'Checkbox'
* - 'Select' | 'SuperSelect' | 'Radio' | 'RadioCard'
* - 'TextArea' | 'MaskedInput'
* - 'FileInput' | 'KeyValueInput'
*/
export type FormElement = 'TextInput';
export type FormElement =
| 'TextInput'
| 'TextArea'
| 'Select'
| 'Toggle'
| 'Checkbox'
| 'Radio'
| 'RadioCard'
| 'MaskedInput';
/**
* HDS-supported text input variants for TextInput fields.
*/
export type TextInputType =
| 'text'
| 'email'
| 'password'
| 'url'
| 'search'
| 'date'
| 'time'
| 'datetime-local'
| 'month'
| 'week'
| 'tel';
/**
* Union type for all possible field values.
*/
export type FieldValue = string | number | boolean | string[] | null | undefined;
/**
* Visibility predicate evaluated against the form payload.
*/
export type VisibilityRule<Payload extends object = object> = boolean | ((payload: Payload) => boolean);
/**
* Option for Select, Radio, and RadioCard fields
*/
export interface FieldOption {
label: string;
value: string | number | boolean;
/** Optional description for RadioCard options */
description?: string;
}
/**
* Form field definition with common properties.
*/
@ -37,10 +70,22 @@ export type FormField = {
label: string;
/** Optional helper text shown below the field */
helperText?: string;
/** Optional HDS TextInput variant when type is TextInput */
inputType?: TextInputType;
/** Optional placeholder text */
placeholder?: string;
/** Default value for the field */
defaultValue?: FieldValue;
/** Validation rules for this field */
validations?: ValidationRule[];
/** Options for Select, Radio, and RadioCard fields */
options?: FieldOption[];
/** Whether the field is required */
isRequired?: boolean;
/** Whether the field is disabled */
isDisabled?: boolean;
/** Optional conditional visibility for this field */
isVisible?: VisibilityRule;
};
/**
@ -53,13 +98,71 @@ export interface FormSection {
title?: string;
/** Optional description for the section */
description?: string;
/** Optional conditional visibility for this section */
isVisible?: VisibilityRule;
/** Fields belonging to this section */
fields: FormField[];
}
/**
* Wizard state tracking data for each completed step.
* Stores only data - execution state is derived from ember-concurrency task properties.
* Keyed by step name, contains the submitted payload, API response, and error message.
*/
export interface WizardStepState {
/** The payload that was submitted for this step */
payload: any;
/** The API response received for this step (present = step succeeded) */
response: any;
/** Error message if the step failed */
error?: string;
}
/**
* Wizard state accumulator passed to payload resolver functions.
* Maps step names to their completed state.
*/
export type WizardState = {
[stepName: string]: WizardStepState;
};
/**
* A single step in a multi-step wizard.
* References a FormConfig and provides step-specific metadata.
*/
export interface WizardStep {
/** Unique identifier for this step (used to key wizard state) */
name: string;
/** Display title for the step */
title: string;
/** Optional heading for the step in the panel */
heading?: string;
/** Optional description for the step in the panel */
description?: string;
/** The form configuration for this step */
formConfig: FormConfig<any, any>;
}
/**
* Multi-step wizard configuration.
* Defines a sequential flow of forms with cross-step data sharing.
*/
export interface WizardConfig {
/** Display title for the wizard */
title: string;
/** Optional description for the wizard */
description?: string;
/** Optional flag to indicate if the wizard has a final apply changes step */
applyChanges?: boolean;
/** Sequential steps in the wizard */
steps: WizardStep[];
}
export interface FormConfig<Request extends object = object, Response = unknown> {
/** Unique identifier for the form, typically matching the API method name */
name: string;
/** API endpoint path associated with this form -- useful for generating CURL request snippets */
path: string;
/** Title or description for the form */
title?: string;
description?: string;
@ -72,6 +175,16 @@ export interface FormConfig<Request extends object = object, Response = unknown>
* returning the typed API response
*/
submit: (api: ApiService, payload: Request) => Promise<Response>;
/**
* Optional callback invoked after successful submission.
* Receives the API response for post-submission handling (e.g., redirects, notifications).
*/
onSuccess?: (response: Response) => void;
/**
* Optional callback invoked when submission fails.
* Receives the extracted error message for custom error handling.
*/
onError?: (error: string) => void;
/** Organized groups of fields with type-safe field names */
sections: FormSection[];
}

View file

@ -0,0 +1,180 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { get } from '@ember/object';
import type { FieldValue, FormField } from './form-config';
import { validators } from './form-validators';
/**
* Options that can be passed to validators.
*
* @property {boolean} [nullable] - For 'length' and 'number': allow null/undefined values
* @property {number} [min] - For 'min': minimum numeric value
* @property {number} [max] - For 'max': maximum numeric value
* @property {number} [minLength] - For 'minLength': minimum string length
* @property {number} [maxLength] - For 'maxLength': maximum string length
* @property {string | RegExp} [pattern] - For 'pattern': regex pattern string or RegExp object
* @property {string} [flags] - For 'pattern': regex flags (e.g., 'i', 'g', 'gi')
* @property {string | number | boolean} [value] - For 'isNot': value to compare against
*/
export interface ValidatorOptions {
nullable?: boolean;
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: string | RegExp;
flags?: string;
value?: string | number | boolean;
}
/**
* Named validator using a predefined validator type.
* References a validator in validators.js (e.g., 'required', 'email', 'url').
*
* @property {ValidatorType} type - Reference to a validator in validators.js (e.g., 'required', 'email', 'url')
* @property {string | ((formData: Record<string, unknown>) => string)} message - Error message shown when validation fails
* @property {ValidatorOptions} [options] - Options passed to the validator function (e.g., { min: 3, max: 10 })
*/
interface NamedValidationRule {
type: ValidatorType;
message: string | ((formData: Record<string, unknown>) => string);
options?: ValidatorOptions;
}
/**
* Custom validation rule with a custom validator function.
* The validator function receives the entire form data and returns true if valid.
*
* @property {(formData: Record<string, unknown>, options?: ValidatorOptions) => boolean} validator - Custom validator function that receives entire form data
* @property {string | ((formData: Record<string, unknown>) => string)} message - Error message shown when validation fails
* @property {ValidatorOptions} [options] - Optional configuration for the validator
*/
interface CustomValidationRule {
validator: (formData: Record<string, unknown>, options?: ValidatorOptions) => boolean;
message: string | ((formData: Record<string, unknown>) => string);
options?: ValidatorOptions;
}
/**
* Validation rule for a form field.
* Can be either a named validator (with type) or a custom validator function.
*/
export type ValidationRule = NamedValidationRule | CustomValidationRule;
/**
* HTML5 standard validator types.
* These correspond to built-in validators in form-validators.ts.
*/
export type ValidatorType =
| 'required'
| 'email'
| 'url'
| 'pattern'
| 'minLength'
| 'maxLength'
| 'min'
| 'max';
/**
* Default error messages for built-in validators.
* Used as fallback when validation rule doesn't provide a message.
*/
const DEFAULT_MESSAGES: Record<ValidatorType, string> = {
required: 'This field is required',
email: 'Please enter a valid email address',
url: 'Please enter a valid URL',
pattern: 'Invalid format',
minLength: 'Value is too short',
maxLength: 'Value is too long',
min: 'Value is too small',
max: 'Value is too large',
};
/**
* Validate a single field and return validation errors.
*
* @param field - The field configuration to validate
* @param value - The current value of the field
* @param payload - The entire form payload for cross-field validation
* @returns Array of error messages (empty if valid)
*/
export function validateField<TPayload extends object>(
field: FormField,
value: FieldValue,
payload: TPayload
): string[] {
if (!field.validations || field.validations.length === 0) {
return [];
}
return field.validations
.filter((rule) => !runValidator(rule, value, payload))
.map((rule) => {
// Function message (dynamic based on form data)
if (typeof rule.message === 'function') {
return rule.message(payload as Record<string, unknown>);
}
// Explicit message provided
if (rule.message) {
return rule.message;
}
// Fallback to default message for named validators
if ('type' in rule && rule.type in DEFAULT_MESSAGES) {
return DEFAULT_MESSAGES[rule.type];
}
// Last resort fallback
return 'Validation failed';
});
}
/**
* Validate all fields in a form and return a map of field names to errors.
*
* @param fields - Array of all field configurations in the form
* @param payload - The entire form payload
* @returns Map of field names to their validation errors
*/
export function validateAllFields<TPayload extends object>(
fields: FormField[],
payload: TPayload
): Map<string, string[]> {
const errors = new Map<string, string[]>();
for (const field of fields) {
const fieldErrors = validateField(field, get(payload, field.name) as FieldValue, payload);
if (fieldErrors.length > 0) {
errors.set(field.name, fieldErrors);
}
}
return errors;
}
/**
* Execute a single validation rule on a field value.
*/
function runValidator<TPayload extends object>(
rule: ValidationRule,
value: FieldValue,
payload: TPayload
): boolean {
// Named validator (type-based)
if ('type' in rule) {
const validatorFn = validators[rule.type];
if (!validatorFn) {
console.warn(`Unknown validator type: ${rule.type}`);
return true;
}
return validatorFn(value, rule.options || {});
}
// Custom validator function
if ('validator' in rule) {
return rule.validator(payload as Record<string, unknown>, rule.options);
}
return true;
}

View file

@ -0,0 +1,95 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import type { ValidatorOptions } from './form-validator';
/**
* Built-in validator functions
* All validators return true if valid, false if invalid
*/
export const validators = {
/**
* Required - value must be present
* Rejects: null, undefined, '', [], {}
*/
required: (value: unknown, _options?: ValidatorOptions): boolean => {
if (value === null || value === undefined) return false;
if (typeof value === 'string') return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === 'object') return Object.keys(value).length > 0;
return true;
},
/**
* Email - validates email format
*/
email: (value: unknown, _options?: ValidatorOptions): boolean => {
if (!value) return true; // Use with 'required' for mandatory emails
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(String(value));
},
/**
* URL - validates URL format
*/
url: (value: unknown, _options?: ValidatorOptions): boolean => {
if (!value) return true;
try {
new URL(String(value));
return true;
} catch {
return false;
}
},
/**
* Pattern - validates against regex pattern
*/
pattern: (value: unknown, { pattern, flags = '' }: ValidatorOptions): boolean => {
if (!value || !pattern) return true;
const regex = typeof pattern === 'string' ? new RegExp(pattern, flags) : pattern;
return regex.test(String(value));
},
/**
* MinLength - validates minimum string length
*/
minLength: (value: unknown, { minLength }: ValidatorOptions): boolean => {
if (minLength === undefined) return true;
if (!value) return false;
return String(value).length >= minLength;
},
/**
* MaxLength - validates maximum string length
*/
maxLength: (value: unknown, { maxLength }: ValidatorOptions): boolean => {
if (maxLength === undefined) return true;
if (!value) return true;
return String(value).length <= maxLength;
},
/**
* Min - validates minimum numeric value
*/
min: (value: unknown, { min }: ValidatorOptions): boolean => {
if (min === undefined) return true;
if (value === null || value === undefined || value === '') return true;
const num = Number(value);
if (isNaN(num)) return false;
return num >= min;
},
/**
* Max - validates maximum numeric value
*/
max: (value: unknown, { max }: ValidatorOptions): boolean => {
if (max === undefined) return true;
if (value === null || value === undefined || value === '') return true;
const num = Number(value);
if (isNaN(num)) return false;
return num <= max;
},
};

View file

@ -21,6 +21,7 @@ const mountsEnableSecretsEngineConfig: FormConfig<
unknown
> = {
name: 'mountsEnableSecretsEngine',
path: '/sys/mounts/{path}',
description: 'Mount a new backend at a new path.',
submit: async (api: ApiService, payload: SystemApiMountsEnableSecretsEngineOperationRequest) => {
return await api.sys.mountsEnableSecretsEngineRaw(payload);
@ -69,7 +70,7 @@ const mountsEnableSecretsEngineConfig: FormConfig<
{
name: 'MountsEnableSecretsEngineRequest.external_entropy_access',
type: 'TextInput',
label: 'External Entropy Access',
label: 'External entropy access',
helperText: "Whether to give the mount access to Vault's external entropy.",
},
{
@ -89,19 +90,19 @@ const mountsEnableSecretsEngineConfig: FormConfig<
{
name: 'MountsEnableSecretsEngineRequest.plugin_name',
type: 'TextInput',
label: 'Plugin Name',
label: 'Plugin name',
helperText: 'Name of the plugin to mount based from the name registered in the plugin catalog.',
},
{
name: 'MountsEnableSecretsEngineRequest.plugin_version',
type: 'TextInput',
label: 'Plugin Version',
label: 'Plugin version',
helperText: 'The semantic version of the plugin to use, or image tag if oci_image is provided.',
},
{
name: 'MountsEnableSecretsEngineRequest.seal_wrap',
type: 'TextInput',
label: 'Seal Wrap',
label: 'Seal wrap',
helperText: 'Whether to turn on seal wrapping for the mount.',
},
{

View file

@ -0,0 +1,58 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import GENERATED_CONFIGS from './generated/index';
import OVERRIDE_CONFIGS from './overrides/index';
import type { FormConfig } from './form-config';
export type FormConfigKey = keyof typeof GENERATED_CONFIGS | keyof typeof OVERRIDE_CONFIGS;
type AllConfigs = typeof GENERATED_CONFIGS & typeof OVERRIDE_CONFIGS;
/**
* Extract the payload type for a given form config key
*/
export type ExtractPayload<K extends FormConfigKey> = AllConfigs[K] extends FormConfig<
infer TPayload,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
infer _TResponse
>
? TPayload
: never;
/**
* Extract the response type for a given form config key
*/
export type ExtractResponse<K extends FormConfigKey> = AllConfigs[K] extends FormConfig<
// eslint-disable-next-line @typescript-eslint/no-unused-vars
infer _TPayload,
infer TResponse
>
? TResponse
: never;
/**
* Retrieves a V2 form configuration, preferring overrides when they exist.
* @param configName - The camelCase config name (e.g., 'aliCloudDeleteAuthRole', 'azureConfigureAuth')
* @returns V2FormConfig instance with the exact type for the given config
*/
export function getFormConfig<K extends FormConfigKey>(
configName: K
): FormConfig<ExtractPayload<K>, ExtractResponse<K>> {
// Check overrides first
const override = OVERRIDE_CONFIGS[configName as keyof typeof OVERRIDE_CONFIGS];
if (override) {
return override as unknown as FormConfig<ExtractPayload<K>, ExtractResponse<K>>;
}
// Fall back to generated configs
const config = GENERATED_CONFIGS[configName as keyof typeof GENERATED_CONFIGS];
if (!config) {
throw new Error(`Form configuration not found for: ${configName}`);
}
return config as unknown as FormConfig<ExtractPayload<K>, ExtractResponse<K>>;
}

230
ui/app/forms/v2/v2-form.ts Normal file
View file

@ -0,0 +1,230 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { get, set } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import type ApiService from 'vault/services/api';
import type { FieldValue, FormConfig, FormField, VisibilityRule } from './form-config';
import { validateAllFields, validateField } from './form-validator';
import { getFormConfig, type FormConfigKey } from './get-form-config';
/**
* V2Form manages the state of a form with type-safe property updates and validation.
* Supports nested property updates via dotted-path notation (e.g., "user.address.street").
* Automatically validates fields when values change.
*
* Usage with registry name:
* ```typescript
* const form = new V2Form('mountsEnableSecretsEngine');
* form.set('path', 'my-path');
* const errors = form.getErrors('path');
* await form.submit(api);
* ```
*
* Usage with config object:
* ```typescript
* const form = new V2Form(myFormConfig);
* form.set('path', 'my-path');
* await form.submit(api);
* ```
*
* @template TPayload - The form payload type
* @template TResponse - The API response type
*/
export default class V2Form<TPayload extends object = any, TResponse = unknown> {
@tracked payload: TPayload;
@tracked validationErrors: Map<string, string[]> = new Map();
#config: FormConfig<TPayload, TResponse>;
/**
* NOTE: Flexible constructor pattern
*
* Supports two instantiation methods to enable flexible form usage:
* 1. Name-based: `new V2Form('mountsEnableSecretsEngine')` - looks up config from registry
* - Use for single-step forms with globally registered configs
*
* 2. Config-based: `new V2Form<PayloadType, ResponseType>(configObject)` - accepts config directly
* - Use for wizard steps with local overrides (e.g., dynamic payloads)
* - Explicit generic parameters should match the config's types
* - For wizards, use `new V2Form<any, any>(config)` to simplify typing
*/
constructor(config: FormConfigKey | FormConfig<TPayload, TResponse>) {
if (typeof config === 'string') {
// Registry-based: getFormConfig returns FormConfig<never, never> but we need
// FormConfig<TPayload, TResponse>. Since generics default to `any`, this
// type assertion is safe and unavoidable due to TypeScript's structural typing.
const formConfig = getFormConfig(config);
this.#config = formConfig as unknown as FormConfig<TPayload, TResponse>;
this.payload = this.#resolvePayload(formConfig.payload as TPayload);
} else {
// Config-based: types must match the provided config
this.#config = config;
this.payload = this.#resolvePayload(config.payload);
}
// Auto-inject required validations for fields marked with isRequired: true
this.#injectRequiredValidations();
}
/**
* Automatically adds required validation rules to fields marked with isRequired: true
* if they don't already have a required validation.
*/
#injectRequiredValidations(): void {
for (const section of this.#config.sections) {
for (const field of section.fields) {
if (field.isRequired) {
// Check if field already has a required validation
const hasRequiredValidation = field.validations?.some(
(rule) => 'type' in rule && rule.type === 'required'
);
if (!hasRequiredValidation) {
// Add required validation
if (!field.validations) {
field.validations = [];
}
field.validations.unshift({
type: 'required',
message: `${field.label} is required`,
});
}
}
}
}
}
/**
* Resolves the payload from the config.
* If the config.payload is a function, it's assumed to be a static payload
* (wizard payload resolution happens at the ProvisionForm level).
* This method just extracts the value.
*/
#resolvePayload(payload: TPayload | ((wizardState: any) => TPayload)): TPayload {
// For V2Form, we expect the payload to already be resolved
// (either a static object or pre-resolved by the parent component)
const resolvePayload = typeof payload === 'function' ? payload({}) : payload;
return structuredClone(resolvePayload);
}
get config(): FormConfig<TPayload, TResponse> {
return this.#config;
}
get isValid(): boolean {
return this.validationErrors.size === 0;
}
/**
* Updates a property in the payload using dotted-path notation.
* Creates intermediate objects if they don't exist.
* Automatically validates the field after updating.
*
* @param propPath - Dotted-path to the property (e.g., "user.address.street")
* @param value - The new value for the property
*/
set(propPath: string, value: unknown): void {
const nextPayload = structuredClone(this.payload);
set(nextPayload, propPath, value);
this.payload = nextPayload;
this.#pruneHiddenFieldErrors();
this.#validateField(propPath);
}
/**
* Get validation errors for a specific field
*/
getErrors(propPath: string): string[] {
return this.validationErrors.get(propPath) ?? [];
}
/**
* Validate all fields in the form.
* Useful before form submission to show all validation errors.
*/
validateForm(): { isValid: boolean } {
const errors = validateAllFields(this.#visibleFields, this.payload);
this.validationErrors = errors;
return {
isValid: this.isValid,
};
}
/**
* Submit the form after validation.
* Validates the entire form and calls the config's submit handler.
*
* @param api - The API service instance
* @returns Promise resolving to the API response
* @throws Error if form validation fails
*/
async submit(api: ApiService): Promise<TResponse> {
const { isValid } = this.validateForm();
if (!isValid) {
throw new Error('Form validation failed');
}
return this.#config.submit(api, this.payload);
}
get #visibleFields(): FormField[] {
return this.#config.sections
.filter((section) => this.#isVisible(section.isVisible))
.flatMap((section) => section.fields.filter((field) => this.#isVisible(field.isVisible)));
}
#isVisible(rule?: VisibilityRule): boolean {
if (typeof rule === 'function') {
return rule(this.payload);
}
if (typeof rule === 'boolean') {
return rule;
}
return true;
}
#pruneHiddenFieldErrors(): void {
const visibleFieldNames = new Set(this.#visibleFields.map((field) => field.name));
const nextErrors = new Map(
[...this.validationErrors].filter(([fieldName]) => visibleFieldNames.has(fieldName))
);
if (nextErrors.size !== this.validationErrors.size) {
this.validationErrors = nextErrors;
}
}
/**
* Search through the config structure to find a field configuration object by its name.
*/
#findField(propPath: string): FormField | null {
return this.#visibleFields.find((field) => field.name === propPath) ?? null;
}
#validateField(propPath: string): void {
const field = this.#findField(propPath);
if (!field) {
this.validationErrors.delete(propPath);
this.validationErrors = new Map(this.validationErrors);
return;
}
const value = get(this.payload, propPath) as FieldValue;
const errors = validateField(field, value, this.payload);
if (errors.length > 0) {
this.validationErrors.set(propPath, errors);
} else {
this.validationErrors.delete(propPath);
}
// Trigger reactivity by creating a new Map
this.validationErrors = new Map(this.validationErrors);
}
}

View file

@ -286,3 +286,7 @@
.has-right-margin-l {
margin-right: size_variables.$spacing-24;
}
.margin-left-auto {
margin-left: auto;
}

View file

@ -62,14 +62,16 @@ const getOperationDetails = (spec, operationId) => {
// the parameters and the request body. These are in different places
// in the spec so we need to address them separately.
const params = [];
for (const param of pathItem.parameters) {
if (param.deprecated) continue;
params.push({
name: param.name,
description: param.description,
required: param.required,
type: param.schema.type,
});
if (Array.isArray(pathItem.parameters)) {
for (const param of pathItem.parameters) {
if (param.deprecated) continue;
params.push({
name: param.name,
description: param.description,
required: param.required,
type: param.schema.type,
});
}
}
const properties = {};
@ -82,6 +84,7 @@ const getOperationDetails = (spec, operationId) => {
operationId: post.operationId,
tag: post.tags?.[0],
description: pathItem.description || post.summary || '',
pathUrl,
parameters: params,
requestBody: [schemaName, properties],
};
@ -146,6 +149,7 @@ export const prepFormConfig = (spec, methodName) => {
return {
name: methodName,
path: operation.pathUrl,
description: operation.description,
payload: buildPayloadFromOperation(operation),
sections: buildSectionsFromOperation(operation),
@ -176,6 +180,7 @@ export const generateConfigContent = (config) => {
*/
const ${config.name}Config: FormConfig<${config.requestType},unknown> = {
name: '${config.name}',
path: '${config.path}',
description: '${config.description}',
submit: async (api: ApiService, payload: ${config.requestType}) => {
return await api.${config.apiClass}.${config.name}Raw(payload);

View file

@ -0,0 +1,35 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { sanitizePath } from '../sanitize-path';
import { formatArgsFromPayload } from './formatters';
/**
* Replaces OpenAPI style tokens {token} with values from an object.
* eg. - path /identity/oidc/client/{name} and params { name: 'root' } returns /identity/oidc/client/root
*/
export const formatDynamicApiPath = <T extends object>(path: string, params: T) => {
const formattedPath = path.replace(/{(\w+)}/g, (match, key) => {
// Type guard: check if 'key' is actually a property of 'params'
if (key in params) {
const value = params[key as keyof T];
return value !== undefined ? encodeURIComponent(String(value)) : match;
}
return match;
});
return sanitizePath(formattedPath);
};
// returns formatted CURL command for given API path and payload
export const generateCurlCommand = (path: string, payload: Record<string, unknown>, namespace?: string) => {
return `curl \\
--header "X-Vault-Token: $VAULT_TOKEN"${
namespace ? `\\\n --header "X-Vault-Namespace: ${namespace}"\\` : ''
}
--request POST \\
--data '${JSON.stringify(formatArgsFromPayload(payload))}' \\
$VAULT_ADDR/v1/${formatDynamicApiPath(path, payload)}
`;
};

View file

@ -3,6 +3,8 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { formatArgsFromPayload } from './formatters';
// The CliTemplateArgs intentionally does NOT include "namespace" because the location for CLI commands is not consistent.
// Additionally, namespace can be specified via the environment variable `VAULT_NAMESPACE` and passing a flag is unnecessary.
@ -20,3 +22,35 @@ export const cliTemplate = ({ command = '', content = '' }: CliTemplateArgs = {}
const segments = ['vault', command, content].filter(Boolean);
return segments.join(' ');
};
// generate a CLI command with args from a generic object or form payload
// the payload object will be converted to CLI flags in the format of `-key=value` and appended to the command
export const generateCliCommand = (command = '', payload: Record<string, unknown> = {}) => {
const filteredArgs = formatArgsFromPayload(payload);
const content = Object.entries(filteredArgs)
.map(([key, value]) => {
// For boolean flags, include the flag without a value if true, and omit if false
if (typeof value === 'boolean') {
return value ? `-${key}` : '';
}
// For array values, join them with commas (e.g., `-key=value1,value2`)
if (Array.isArray(value)) {
return `-${key}=${value.join(',')}`;
}
// for nested objects, repeat the flag for each key-value pair (e.g., `-options="version=2" -options="type=kv-v2"`)
if (typeof value === 'object' && value !== null) {
return Object.entries(value)
.map(([nestedKey, nestedValue]) => `-${key}="${nestedKey}=${nestedValue}"`)
.join(' ');
}
// For other types, include the flag with its value
return `-${key}=${value}`;
})
.filter(Boolean) // Remove any empty strings resulting from false boolean flags
.join(' ');
return cliTemplate({ command, content });
};
export const generateCliWriteCommand = (path: string, payload: Record<string, unknown> = {}) =>
generateCliCommand(`write ${path}`, payload);

View file

@ -13,3 +13,19 @@ export const formatEot = (content = '') => {
${content}
EOT`;
};
// returns a formatted args object to populate snippets with empty values removed (e.g. empty strings, empty objects/arrays, undefined, null)
export const formatArgsFromPayload = (payload: Record<string, unknown> = {}) => {
return Object.fromEntries(
Object.entries(payload).filter(([, value]) => {
const isEmptyValue = value === '' || value === undefined || value === null;
const isEmptyObject =
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
Object.keys(value).length === 0;
const isEmptyArray = Array.isArray(value) && value.length === 0;
return !isEmptyValue && !isEmptyObject && !isEmptyArray;
})
);
};

View file

@ -4,6 +4,8 @@
*/
import { typeOf } from '@ember/utils';
import { sanitizePath } from '../sanitize-path';
import { formatArgsFromPayload } from './formatters';
// Yes, this seems silly but pretty formatting these snippets was a...journey.
// Hopefully these consts make it easier for whoever comes next.
@ -75,9 +77,31 @@ ${formattedContent.join('\n')}
}`;
};
// if specific resource cannot be determined the generic endpoint resource can be used with the API path
export const terraformGenericResourceTemplate = (
path: string,
resourceArgs: Record<string, unknown> = {},
localId = '<local identifier>',
namespace?: string
) => {
const formattedContent = formatTerraformArgs(resourceArgs);
const ns = namespace ? `namespace = "${namespace}"\n` : '';
return `resource "vault_generic_endpoint" "${localId}" {
path = "${sanitizePath(path)}"
${ns}
data_json = jsonencode({
${formattedContent.join(`\n${TWO_SPACES}`)}
})
}`;
};
export const formatTerraformArgs = (resourceArgs: Record<string, unknown> = {}) => {
// strip empty values before formatting
// focus on empty strings, objects and arrays, as well as undefined values, but this can be expanded as needed
const filteredArgs = formatArgsFromPayload(resourceArgs);
const formattedArgs = [];
for (const [key, value] of Object.entries(resourceArgs)) {
for (const [key, value] of Object.entries(filteredArgs)) {
// Handle nested objects (like "options" above)
if (typeOf(value) === 'object') {
const formattedValue = formatNestedObject(key, value as Record<string, unknown>);

View file

@ -0,0 +1,295 @@
/**
* Copyright IBM Corp. 2016, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import { click, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { module, test } from 'qunit';
import sinon from 'sinon';
import V2Form from 'vault/forms/v2/v2-form';
import { setupRenderingTest } from 'vault/tests/helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
module('Integration | Component | form/v2/apply', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
// Create a minimal FormConfig for testing
const formConfig = {
name: 'test-resource',
path: '/v1/test/resource',
title: 'Test Resource',
payload: {
name: 'test-name',
description: 'test-description',
},
submit: sinon.stub().resolves({ id: 'test-123' }),
sections: [
{
name: 'basic',
fields: [
{
name: 'name',
label: 'Name',
type: 'TextInput',
},
],
},
],
};
this.form = new V2Form(formConfig);
this.onBack = sinon.spy();
this.onApply = sinon.spy();
this.onDone = sinon.spy();
});
test('it renders the apply component', async function (assert) {
await render(hbs`
<Form::V2::Apply
@form={{this.form}}
@onBack={{this.onBack}}
@onApply={{this.onApply}}
@onDone={{this.onDone}}
/>
`);
assert.dom(GENERAL.textDisplay('Step title')).hasText('Choose your implementation method');
assert.dom('.hds-form-radio-card').exists({ count: 3 }, 'renders three creation method options');
});
test('it renders Terraform as the default selected option', async function (assert) {
await render(hbs`
<Form::V2::Apply
@form={{this.form}}
@onBack={{this.onBack}}
@onApply={{this.onApply}}
@onDone={{this.onDone}}
/>
`);
const radios = this.element.querySelectorAll('input[type="radio"]');
assert.true(radios[0].checked, 'Terraform option (first radio) is checked by default');
});
test('it shows Terraform code snippet by default', async function (assert) {
await render(hbs`
<Form::V2::Apply
@form={{this.form}}
@onBack={{this.onBack}}
@onApply={{this.onApply}}
@onDone={{this.onDone}}
/>
`);
assert.dom(GENERAL.fieldByAttr('terraform')).exists('Terraform code block is rendered');
assert.dom('.hds-code-block').exists('Code block component is rendered');
});
test('it allows changing to API/CLI creation method', async function (assert) {
await render(hbs`
<Form::V2::Apply
@form={{this.form}}
@onBack={{this.onBack}}
@onApply={{this.onApply}}
@onDone={{this.onDone}}
/>
`);
const radios = this.element.querySelectorAll('input[type="radio"]');
await click(radios[1]); // API/CLI is second option
assert.true(radios[1].checked, 'API/CLI option is now checked');
// API/CLI shows code snippets too, just not the Terraform-specific download button
const buttons = this.element.querySelectorAll('button');
const downloadButton = Array.from(buttons).find((btn) => btn.textContent.includes('Export as tf file'));
assert.notOk(downloadButton, 'Terraform download button is hidden for API/CLI');
});
test('it allows changing to UI workflow method', async function (assert) {
await render(hbs`
<Form::V2::Apply
@form={{this.form}}
@onBack={{this.onBack}}
@onApply={{this.onApply}}
@onDone={{this.onDone}}
/>
`);
const radios = this.element.querySelectorAll('input[type="radio"]');
await click(radios[2]); // UI workflow is third option
assert.true(radios[2].checked, 'UI workflow option is now checked');
assert.dom('.hds-code-block').doesNotExist('Code snippets are hidden for UI workflow');
});
test('it shows Apply changes button only for UI workflow', async function (assert) {
await render(hbs`
<Form::V2::Apply
@form={{this.form}}
@onBack={{this.onBack}}
@onApply={{this.onApply}}
@onDone={{this.onDone}}
/>
`);
// Initially on Terraform - no Apply button
let buttons = this.element.querySelectorAll('button');
let applyButton = Array.from(buttons).find((btn) => btn.textContent.includes('Apply changes'));
assert.notOk(applyButton, 'Apply changes button not shown for Terraform');
// Switch to UI workflow
const radios = this.element.querySelectorAll('input[type="radio"]');
await click(radios[2]); // UI workflow is third option
buttons = this.element.querySelectorAll('button');
applyButton = Array.from(buttons).find((btn) => btn.textContent.includes('Apply changes'));
assert.ok(applyButton, 'Apply changes button shown for UI workflow');
});
test('it always shows Back and Done buttons', async function (assert) {
await render(hbs`
<Form::V2::Apply
@form={{this.form}}
@onBack={{this.onBack}}
@onApply={{this.onApply}}
@onDone={{this.onDone}}
/>
`);
const buttons = this.element.querySelectorAll('button');
const backButton = Array.from(buttons).find((btn) => btn.textContent.includes('Back'));
const doneButton = Array.from(buttons).find((btn) => btn.textContent.includes('Done & exit'));
assert.ok(backButton, 'Back button is rendered');
assert.ok(doneButton, 'Done & exit button is rendered');
});
test('it calls onBack when Back button is clicked', async function (assert) {
await render(hbs`
<Form::V2::Apply
@form={{this.form}}
@onBack={{this.onBack}}
@onApply={{this.onApply}}
@onDone={{this.onDone}}
/>
`);
const buttons = this.element.querySelectorAll('button');
const backButton = Array.from(buttons).find((btn) => btn.textContent.includes('Back'));
await click(backButton);
assert.ok(this.onBack.calledOnce, 'onBack callback was called');
});
test('it calls onDone when Done button is clicked', async function (assert) {
await render(hbs`
<Form::V2::Apply
@form={{this.form}}
@onBack={{this.onBack}}
@onApply={{this.onApply}}
@onDone={{this.onDone}}
/>
`);
const buttons = this.element.querySelectorAll('button');
const doneButton = Array.from(buttons).find((btn) => btn.textContent.includes('Done & exit'));
await click(doneButton);
assert.ok(this.onDone.calledOnce, 'onDone callback was called');
});
test('it calls onApply when Apply changes button is clicked', async function (assert) {
await render(hbs`
<Form::V2::Apply
@form={{this.form}}
@onBack={{this.onBack}}
@onApply={{this.onApply}}
@onDone={{this.onDone}}
/>
`);
// Switch to UI workflow to show Apply button
const radios = this.element.querySelectorAll('input[type="radio"]');
await click(radios[2]); // UI workflow is third option
const buttons = this.element.querySelectorAll('button');
const applyButton = Array.from(buttons).find((btn) => btn.textContent.includes('Apply changes'));
await click(applyButton);
assert.ok(this.onApply.calledOnce, 'onApply callback was called');
});
test('it shows download button for Terraform snippet', async function (assert) {
await render(hbs`
<Form::V2::Apply
@form={{this.form}}
@onBack={{this.onBack}}
@onApply={{this.onApply}}
@onDone={{this.onDone}}
/>
`);
const buttons = this.element.querySelectorAll('button');
const downloadButton = Array.from(buttons).find((btn) => btn.textContent.includes('Export as tf file'));
assert.ok(downloadButton, 'Download button is rendered for Terraform');
});
test('it hides download button for API/CLI method', async function (assert) {
await render(hbs`
<Form::V2::Apply
@form={{this.form}}
@onBack={{this.onBack}}
@onApply={{this.onApply}}
@onDone={{this.onDone}}
/>
`);
const radios = this.element.querySelectorAll('input[type="radio"]');
await click(radios[1]); // API/CLI is second option
const buttons = this.element.querySelectorAll('button');
const downloadButton = Array.from(buttons).find((btn) => btn.textContent.includes('Export as tf file'));
assert.notOk(downloadButton, 'Download button is not shown for API/CLI');
});
test('it shows appropriate descriptions for each creation method', async function (assert) {
await render(hbs`
<Form::V2::Apply
@form={{this.form}}
@onBack={{this.onBack}}
@onApply={{this.onApply}}
@onDone={{this.onDone}}
/>
`);
// Check for description text in the rendered cards
assert.dom('.hds-form-radio-card').exists({ count: 3 }, 'Three radio cards rendered');
assert.dom(this.element).includesText('Infrastructure as Code', 'Terraform description present');
assert.dom(this.element).includesText('Vault CLI or REST API', 'API/CLI description present');
assert.dom(this.element).includesText('Apply changes immediately', 'UI workflow description present');
});
test('it shows edit configuration section for non-UI methods', async function (assert) {
await render(hbs`
<Form::V2::Apply
@form={{this.form}}
@onBack={{this.onBack}}
@onApply={{this.onApply}}
@onDone={{this.onDone}}
/>
`);
// Terraform (default) should show edit configuration
assert.dom('h2').includesText('Edit configuration', 'Edit configuration section shown for Terraform');
// Switch to UI workflow
const radios = this.element.querySelectorAll('input[type="radio"]');
await click(radios[2]); // UI workflow is third option
const headings = this.element.querySelectorAll('h2');
const editConfigHeading = Array.from(headings).find((h) => h.textContent.includes('Edit configuration'));
assert.notOk(editConfigHeading, 'Edit configuration section hidden for UI workflow');
});
});

View file

@ -0,0 +1,47 @@
/**
* Copyright IBM Corp. 2016, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
module('Integration | Component | form/v2/error-alert', function (hooks) {
setupRenderingTest(hooks);
test('it renders the default title and error message', async function (assert) {
this.error = 'Something went wrong';
await render(hbs`
<Form::V2::ErrorAlert @error={{this.error}} />
`);
assert.dom('.hds-alert').exists('renders alert');
assert.dom('.hds-alert__title').hasText('Submission error', 'renders default title');
assert.dom('.hds-alert__description').hasText('Something went wrong', 'renders error message');
});
test('it renders a custom title', async function (assert) {
this.error = 'Configuration failed';
this.title = 'Configuration Error';
await render(hbs`
<Form::V2::ErrorAlert @error={{this.error}} @title={{this.title}} />
`);
assert.dom('.hds-alert__title').hasText('Configuration Error', 'renders custom title');
assert.dom('.hds-alert__description').hasText('Configuration failed', 'renders error message');
});
test('it renders without an error message', async function (assert) {
await render(hbs`
<Form::V2::ErrorAlert />
`);
assert.dom('.hds-alert').exists('renders alert');
assert.dom('.hds-alert__title').hasText('Submission error', 'renders default title');
assert.dom('.hds-alert__description').hasText('', 'renders empty description');
});
});

View file

@ -0,0 +1,327 @@
/**
* Copyright IBM Corp. 2016, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { module, test } from 'qunit';
import sinon from 'sinon';
import { setupRenderingTest } from 'vault/tests/helpers';
module('Integration | Component | form/v2/field', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.onChange = sinon.spy();
});
test('it renders a text input field', async function (assert) {
this.field = {
name: 'username',
label: 'Username',
type: 'TextInput',
helperText: 'Enter your username',
};
this.value = 'test-user';
await render(hbs`
<Form::V2::Field
@field={{this.field}}
@value={{this.value}}
@onChange={{this.onChange}}
/>
`);
assert.dom('label').includesText('Username', 'renders label');
assert.dom('input[type="text"]').exists('renders text input');
assert.dom('.hds-form-helper-text').includesText('Enter your username', 'renders helper text');
});
test('it renders a TextInput email variant', async function (assert) {
this.field = {
name: 'email',
label: 'Email',
type: 'TextInput',
inputType: 'email',
};
this.value = 'person@example.com';
await render(hbs`
<Form::V2::Field
@field={{this.field}}
@value={{this.value}}
@onChange={{this.onChange}}
/>
`);
assert.dom('input[type="email"]').exists('renders email input type');
});
test('it renders a TextInput password variant', async function (assert) {
this.field = {
name: 'password',
label: 'Password',
type: 'TextInput',
inputType: 'password',
};
await render(hbs`
<Form::V2::Field
@field={{this.field}}
@onChange={{this.onChange}}
/>
`);
assert.dom('input[type="password"]').exists('renders password input type');
});
test('it renders validation errors', async function (assert) {
this.field = {
name: 'password',
label: 'Password',
type: 'TextInput',
};
this.errors = ['Password is required'];
await render(hbs`
<Form::V2::Field
@field={{this.field}}
@errors={{this.errors}}
@onChange={{this.onChange}}
/>
`);
assert.dom('.hds-form-error__message').includesText('Password is required', 'renders error message');
});
test('it renders a textarea field', async function (assert) {
this.field = {
name: 'description',
label: 'Description',
type: 'TextArea',
helperText: 'Enter a detailed description',
};
this.value = 'Multi-line\ntext content';
await render(hbs`
<Form::V2::Field
@field={{this.field}}
@value={{this.value}}
@onChange={{this.onChange}}
/>
`);
assert.dom('label').includesText('Description', 'renders label');
assert.dom('textarea').exists('renders textarea');
assert.dom('.hds-form-helper-text').includesText('Enter a detailed description', 'renders helper text');
});
test('it renders a toggle field', async function (assert) {
this.field = {
name: 'enabled',
label: 'Enable feature',
type: 'Toggle',
helperText: 'Turn this on to enable the feature',
};
this.value = true;
await render(hbs`
<Form::V2::Field
@field={{this.field}}
@value={{this.value}}
@onChange={{this.onChange}}
/>
`);
assert.dom('label').includesText('Enable feature', 'renders label');
assert.dom('input[type="checkbox"]').exists('renders toggle');
assert
.dom('.hds-form-helper-text')
.includesText('Turn this on to enable the feature', 'renders helper text');
});
test('it renders a checkbox field', async function (assert) {
this.field = {
name: 'agree',
label: 'I agree to the terms',
type: 'Checkbox',
};
this.value = false;
await render(hbs`
<Form::V2::Field
@field={{this.field}}
@value={{this.value}}
@onChange={{this.onChange}}
/>
`);
assert.dom('label').includesText('I agree to the terms', 'renders label');
assert.dom('input[type="checkbox"]').exists('renders checkbox');
});
test('it renders a radio group', async function (assert) {
this.field = {
name: 'size',
label: 'Select size',
type: 'Radio',
options: [
{ label: 'Small', value: 'small' },
{ label: 'Medium', value: 'medium' },
{ label: 'Large', value: 'large' },
],
};
this.value = 'medium';
await render(hbs`
<Form::V2::Field
@field={{this.field}}
@value={{this.value}}
@onChange={{this.onChange}}
/>
`);
assert.dom('legend').includesText('Select size', 'renders legend');
assert.dom('input[type="radio"]').exists({ count: 3 }, 'renders radio options');
});
test('it renders radio cards', async function (assert) {
this.field = {
name: 'plan',
label: 'Choose a plan',
type: 'RadioCard',
options: [
{ label: 'Basic', value: 'basic', description: 'For small teams' },
{ label: 'Pro', value: 'pro', description: 'For growing teams' },
{ label: 'Enterprise', value: 'enterprise', description: 'For large organizations' },
],
};
this.value = 'pro';
await render(hbs`
<Form::V2::Field
@field={{this.field}}
@value={{this.value}}
@onChange={{this.onChange}}
/>
`);
assert.dom('legend').includesText('Choose a plan', 'renders legend');
assert.dom('input[type="radio"]').exists({ count: 3 }, 'renders radio card options');
});
test('it renders a masked input field', async function (assert) {
this.field = {
name: 'password',
label: 'Password',
type: 'MaskedInput',
helperText: 'Enter a secure password',
};
await render(hbs`
<Form::V2::Field
@field={{this.field}}
@onChange={{this.onChange}}
/>
`);
assert.dom('label').includesText('Password', 'renders label');
assert.dom('input').exists('renders masked input');
assert.dom('.hds-form-helper-text').includesText('Enter a secure password', 'renders helper text');
});
test('it renders a select field', async function (assert) {
this.field = {
name: 'country',
label: 'Select country',
type: 'Select',
helperText: 'Choose your country',
options: [
{ label: 'United States', value: 'us' },
{ label: 'Canada', value: 'ca' },
{ label: 'United Kingdom', value: 'uk' },
],
};
this.value = 'ca';
await render(hbs`
<Form::V2::Field
@field={{this.field}}
@value={{this.value}}
@onChange={{this.onChange}}
/>
`);
assert.dom('label').includesText('Select country', 'renders label');
assert.dom('select').exists('renders select element');
assert.dom('select option').exists({ count: 3 }, 'renders 3 options');
assert.dom('select').hasValue('ca', 'select has correct value');
assert.dom('.hds-form-helper-text').includesText('Choose your country', 'renders helper text');
});
test('it renders a select field with custom placeholder', async function (assert) {
this.field = {
name: 'region',
label: 'Select region',
type: 'Select',
placeholder: 'Pick a region',
options: [
{ label: 'North America', value: 'na' },
{ label: 'Europe', value: 'eu' },
],
};
await render(hbs`
<Form::V2::Field
@field={{this.field}}
@onChange={{this.onChange}}
/>
`);
assert.dom('select option:first-child').hasText('Pick a region', 'renders custom placeholder');
assert.dom('select option:first-child').hasValue('', 'placeholder has empty value');
});
test('it renders unsupported field types as a text input fallback with console warning', async function (assert) {
assert.expect(6);
this.field = {
name: 'unsupported',
label: 'Unsupported Field',
type: 'FutureFieldType',
};
// Capture console.warn calls
const originalWarn = console.warn;
let warnCalled = false;
let warnMessage = '';
console.warn = (message) => {
warnCalled = true;
warnMessage = message;
};
await render(hbs`
<Form::V2::Field
@field={{this.field}}
@onChange={{this.onChange}}
/>
`);
// Restore console.warn
console.warn = originalWarn;
// Assert text input is rendered as fallback
assert.dom('.hds-form-text-input').exists('renders text input as fallback');
assert.dom('label').hasText('Unsupported Field', 'displays correct label');
// Assert console warning was logged
assert.true(warnCalled, 'console.warn was called');
assert.true(
warnMessage.includes('Unsupported field type "FutureFieldType"'),
'warning includes field type'
);
assert.true(warnMessage.includes('Unsupported Field'), 'warning includes field label');
assert.true(warnMessage.includes('Falling back to text input'), 'warning includes fallback message');
});
});

View file

@ -0,0 +1,150 @@
/**
* Copyright IBM Corp. 2016, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import { render, waitFor } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { module, test } from 'qunit';
import V2Form from 'vault/forms/v2/v2-form';
import { setupRenderingTest } from 'vault/tests/helpers';
module('Integration | Component | form/v2/renderer', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.formConfig = {
name: 'test-form',
path: '/test/path',
title: 'Test Form',
payload: {
username: '',
email: '',
enabled: false,
},
submit: async () => ({ success: true }),
sections: [
{
name: 'section1',
title: 'User Information',
description: 'Enter user details',
fields: [
{
name: 'username',
type: 'TextInput',
label: 'Username',
helperText: 'Enter your username',
},
{
name: 'email',
type: 'TextInput',
label: 'Email',
},
],
},
{
name: 'section2',
title: 'Settings',
fields: [
{
name: 'enabled',
type: 'Toggle',
label: 'Enable feature',
},
],
},
],
};
this.form = new V2Form(this.formConfig);
});
test('it renders the form element', async function (assert) {
await render(hbs`
<Form::V2::Renderer @form={{this.form}} />
`);
assert.dom('form').exists('renders form element');
});
test('it renders sections and fields when renderFields is true', async function (assert) {
await render(hbs`
<Form::V2::Renderer @form={{this.form}} @renderFields={{true}} />
`);
await waitFor('h2');
assert.dom('h2').exists({ count: 2 }, 'renders section headers');
assert.dom('label').includesText('Username', 'renders field content');
});
test('it renders an error alert when error is provided', async function (assert) {
this.error = 'Something went wrong';
await render(hbs`
<Form::V2::Renderer @form={{this.form}} @error={{this.error}} @renderFields={{true}} />
`);
assert.dom('.hds-alert').exists('renders error alert');
assert.dom('.hds-alert__description').hasText('Something went wrong', 'renders error message');
});
test('it yields custom content', async function (assert) {
await render(hbs`
<Form::V2::Renderer @form={{this.form}} @renderFields={{true}} as |Form|>
<Form.Section>
<div data-test-custom-content>Custom submit button</div>
</Form.Section>
</Form::V2::Renderer>
`);
assert
.dom('[data-test-custom-content]')
.hasText('Custom submit button', 'renders yielded custom content');
});
test('it does not render fields when renderFields is false', async function (assert) {
await render(hbs`
<Form::V2::Renderer @form={{this.form}} @renderFields={{false}} />
`);
assert.dom('h2').doesNotExist('does not render section headers');
assert.dom('label').doesNotExist('does not render fields');
});
test('it handles forms with empty sections', async function (assert) {
this.formWithEmptySection = new V2Form({
name: 'form-empty-section',
path: '/test',
payload: {},
submit: async () => ({}),
sections: [
{
name: 'empty-section',
title: 'Empty Section',
fields: [],
},
],
});
await render(hbs`
<Form::V2::Renderer @form={{this.formWithEmptySection}} @renderFields={{true}} />
`);
assert.dom('h2').hasText('Empty Section', 'renders section title');
assert.dom('label').doesNotExist('does not render field content');
});
test('it passes attributes to the form element', async function (assert) {
await render(hbs`
<Form::V2::Renderer
@form={{this.form}}
@renderFields={{true}}
data-test-custom-form
class="custom-class"
/>
`);
assert.dom('form').hasAttribute('data-test-custom-form', '', 'passes data attributes');
assert.dom('form').hasClass('custom-class', 'passes CSS classes');
});
});

View file

@ -0,0 +1,66 @@
/**
* Copyright IBM Corp. 2016, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
module('Integration | Component | form/v2/section', function (hooks) {
setupRenderingTest(hooks);
test('it renders section title, description, and yielded content', async function (assert) {
this.section = {
name: 'test-section',
title: 'User Settings',
description: 'Configure your user preferences',
};
await render(hbs`
<Hds::Form as |Form|>
<Form::V2::Section @section={{this.section}} @formComponent={{Form}}>
<div data-test-content>Settings fields</div>
</Form::V2::Section>
</Hds::Form>
`);
assert.dom('h2').hasText('User Settings', 'renders section title');
assert.dom('p').includesText('Configure your user preferences', 'renders section description');
assert.dom('[data-test-content]').hasText('Settings fields', 'renders yielded content');
});
test('it renders without section header when title is not provided', async function (assert) {
this.section = {
name: 'no-title-section',
};
await render(hbs`
<Hds::Form as |Form|>
<Form::V2::Section @section={{this.section}} @formComponent={{Form}}>
<div data-test-content>Content without title</div>
</Form::V2::Section>
</Hds::Form>
`);
assert.dom('h2').doesNotExist('does not render title');
assert.dom('p').doesNotExist('does not render description');
assert.dom('[data-test-content]').hasText('Content without title', 'renders yielded content');
});
test('it renders an empty section', async function (assert) {
this.section = {
name: 'empty-section',
title: 'Empty Section',
};
await render(hbs`
<Hds::Form as |Form|>
<Form::V2::Section @section={{this.section}} @formComponent={{Form}} />
</Hds::Form>
`);
assert.dom('h2').hasText('Empty Section', 'renders section title');
});
});

View file

@ -0,0 +1,90 @@
/**
* Copyright IBM Corp. 2016, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { module, test } from 'qunit';
import sinon from 'sinon';
import { setupRenderingTest } from 'vault/tests/helpers';
module('Integration | Component | form/v2/wizard', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.onCancel = sinon.spy();
this.onSuccess = sinon.spy();
this.wizardConfig = {
title: 'Test Wizard',
description: 'A test wizard',
steps: [
{
name: 'step1',
title: 'Step 1',
description: 'First step',
formConfig: {
name: 'step1-form',
path: '/v1/test/step1',
payload: {
name: '',
},
submit: sinon.stub().resolves({ id: 'step1-result' }),
sections: [
{
name: 'basic',
fields: [
{
name: 'name',
label: 'Name',
type: 'TextInput',
validations: [{ type: 'required' }],
},
],
},
],
},
},
{
name: 'step2',
title: 'Step 2',
description: 'Second step',
formConfig: {
name: 'step2-form',
path: '/v1/test/step2',
payload: {
description: '',
},
submit: sinon.stub().resolves({ id: 'step2-result' }),
sections: [
{
name: 'details',
fields: [
{
name: 'description',
label: 'Description',
type: 'TextArea',
},
],
},
],
},
},
],
};
});
test('it renders the first step content', async function (assert) {
await render(hbs`
<Form::V2::Wizard
@config={{this.wizardConfig}}
@onCancel={{this.onCancel}}
@onSuccess={{this.onSuccess}}
/>
`);
assert.dom('label').includesText('Name', 'renders first step field');
assert.dom(this.element).includesText('Step 1', 'renders first step title');
});
});