mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-28 04:10:44 -04:00
* 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:
parent
617b4627e7
commit
f0cf2a4b68
32 changed files with 2849 additions and 23 deletions
|
|
@ -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}}
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
76
ui/app/components/form/v2/apply.hbs
Normal file
76
ui/app/components/form/v2/apply.hbs
Normal 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>
|
||||
98
ui/app/components/form/v2/apply.ts
Normal file
98
ui/app/components/form/v2/apply.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
8
ui/app/components/form/v2/error-alert.hbs
Normal file
8
ui/app/components/form/v2/error-alert.hbs
Normal 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>
|
||||
29
ui/app/components/form/v2/error-alert.ts
Normal file
29
ui/app/components/form/v2/error-alert.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
213
ui/app/components/form/v2/field.hbs
Normal file
213
ui/app/components/form/v2/field.hbs
Normal 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}}
|
||||
87
ui/app/components/form/v2/field.ts
Normal file
87
ui/app/components/form/v2/field.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
28
ui/app/components/form/v2/index.hbs
Normal file
28
ui/app/components/form/v2/index.hbs
Normal 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>
|
||||
63
ui/app/components/form/v2/index.ts
Normal file
63
ui/app/components/form/v2/index.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
33
ui/app/components/form/v2/renderer.hbs
Normal file
33
ui/app/components/form/v2/renderer.hbs
Normal 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>
|
||||
71
ui/app/components/form/v2/renderer.ts
Normal file
71
ui/app/components/form/v2/renderer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
21
ui/app/components/form/v2/section.hbs
Normal file
21
ui/app/components/form/v2/section.hbs
Normal 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}}
|
||||
95
ui/app/components/form/v2/wizard.hbs
Normal file
95
ui/app/components/form/v2/wizard.hbs
Normal 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>
|
||||
219
ui/app/components/form/v2/wizard.ts
Normal file
219
ui/app/components/form/v2/wizard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
180
ui/app/forms/v2/form-validator.ts
Normal file
180
ui/app/forms/v2/form-validator.ts
Normal 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;
|
||||
}
|
||||
95
ui/app/forms/v2/form-validators.ts
Normal file
95
ui/app/forms/v2/form-validators.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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.',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
58
ui/app/forms/v2/get-form-config.ts
Normal file
58
ui/app/forms/v2/get-form-config.ts
Normal 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
230
ui/app/forms/v2/v2-form.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -286,3 +286,7 @@
|
|||
.has-right-margin-l {
|
||||
margin-right: size_variables.$spacing-24;
|
||||
}
|
||||
|
||||
.margin-left-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
35
ui/lib/core/addon/utils/code-generators/api.ts
Normal file
35
ui/lib/core/addon/utils/code-generators/api.ts
Normal 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)}
|
||||
`;
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>);
|
||||
|
|
|
|||
295
ui/tests/integration/components/form/v2/apply-test.js
Normal file
295
ui/tests/integration/components/form/v2/apply-test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
47
ui/tests/integration/components/form/v2/error-alert-test.js
Normal file
47
ui/tests/integration/components/form/v2/error-alert-test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
327
ui/tests/integration/components/form/v2/field-test.js
Normal file
327
ui/tests/integration/components/form/v2/field-test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
150
ui/tests/integration/components/form/v2/renderer-test.js
Normal file
150
ui/tests/integration/components/form/v2/renderer-test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
66
ui/tests/integration/components/form/v2/section-test.js
Normal file
66
ui/tests/integration/components/form/v2/section-test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
90
ui/tests/integration/components/form/v2/wizard-test.js
Normal file
90
ui/tests/integration/components/form/v2/wizard-test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue