[VAULT-34808] UI: move the radio block in FormField under the HDS block (#30555)

* [UI] moved template logic for `radio` editType in `FormField` under the `isHdsField` block (#34742)

* [UI] added integration tests for `FormField` with editType=‘radio’ (#34742)

* [UI] fix broken tests (#34742)
This commit is contained in:
Cristiano Rastelli 2025-05-14 16:45:53 +01:00 committed by GitHub
parent 1551d6943e
commit 71254a4e0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 225 additions and 124 deletions

View File

@ -10,7 +10,39 @@
{{! HDS COMPONENTS - START }}
{{! •••••••••••••••••••••••••••••••••••••••••••••••••••••••• }}
{{#if @attr.options.possibleValues}}
{{#if (eq @attr.options.editType "checkboxList")}}
{{#if (eq @attr.options.editType "radio")}}
<Hds::Form::Radio::Group
@name={{@attr.name}}
@layout={{if (or this.hasRadioSubText this.hasRadioHelpText) "vertical" "horizontal"}}
data-test-input-group={{@attr.name}}
as |G|
>
{{#each (path-or-array @attr.options.possibleValues @model) as |val|}}
<G.RadioField
@id={{or val.id (this.radioValue val)}}
@value={{this.radioValue val}}
checked={{if (eq (this.radioValue val) (get @model this.valuePath)) "checked"}}
disabled={{and @attr.options.editDisabled (not @model.isNew)}}
{{on "change" (fn this.setAndBroadcastRadio val)}}
data-test-radio={{or val.id (this.radioValue val)}}
as |F|
>
<F.Label data-test-input-group-item-label={{or val.label val.value val}}>
{{or val.label val.value val}}
</F.Label>
{{! Note: if we have both `subText` and `helpText`, we display only the `subText` because in these situations, the helpText is likely there to clarify or improve upon the OpenAPI-generated text }}
{{#if this.hasRadioSubText}}
<F.HelperText data-test-help-text={{val.subText}}>{{val.subText}}</F.HelperText>
{{else if this.hasRadioHelpText}}
<F.HelperText data-test-help-text={{val.helpText}}>{{val.helpText}}</F.HelperText>
{{/if}}
</G.RadioField>
{{/each}}
{{#if this.validationError}}
<G.Error data-test-validation-error={{this.valuePath}}>{{this.validationError}}</G.Error>
{{/if}}
</Hds::Form::Radio::Group>
{{else if (eq @attr.options.editType "checkboxList")}}
<Hds::Form::Checkbox::Group @name={{@attr.name}} data-test-input-group={{@attr.name}} as |G|>
{{#if this.labelString}}
<G.Legend data-test-form-field-label>{{this.labelString}}</G.Legend>
@ -143,51 +175,7 @@
@docLink={{@attr.options.docLink}}
/>
{{/unless}}
{{#if @attr.options.possibleValues}}
{{#if (eq @attr.options.editType "radio")}}
<div class="control {{unless this.hasRadioSubText 'columns'}}" data-test-radio-input>
{{#each (path-or-array @attr.options.possibleValues @model) as |val|}}
<div
class="is-flex-center
{{if this.hasRadioSubText 'has-top-padding-xs has-bottom-padding-s' 'column is-narrow has-right-margin-s'}}"
data-test-input={{@attr.name}}
>
<RadioButton
class="radio"
name={{@attr.name}}
id={{or val.id (this.radioValue val)}}
value={{this.radioValue val}}
@value={{this.radioValue val}}
@onChange={{this.setAndBroadcast}}
@groupValue={{get @model this.valuePath}}
@disabled={{and @attr.options.editDisabled (not @model.isNew)}}
data-test-radio={{or val.id (this.radioValue val)}}
/>
<div class="has-left-margin-xs">
<label
for={{or val.id (this.radioValue val)}}
value={{this.radioValue val}}
class="has-left-margin-xs is-size-7"
data-test-radio-label={{or val.label val.value val}}
>
{{or val.label val.value val}}
{{#if val.helpText}}
<Hds::TooltipButton @text={{val.helpText}} aria-label="More information">
<Hds::Icon @name="info" @isInline={{true}} />
</Hds::TooltipButton>
{{/if}}
{{#if this.hasRadioSubText}}
<p class="has-left-margin-xs has-text-grey is-size-8" data-test-radio-subText={{val.subText}}>
{{val.subText}}
</p>
{{/if}}
</label>
</div>
</div>
{{/each}}
</div>
{{/if}}
{{else if (eq @attr.options.editType "dateTimeLocal")}}
{{#if (eq @attr.options.editType "dateTimeLocal")}}
<Input
@type="datetime-local"
@value={{date-format (get @model this.valuePath) "yyyy-MM-dd'T'HH:mm"}}

View File

@ -95,12 +95,7 @@ export default class FormFieldComponent extends Component {
// here we replicate the logic in the template, to make sure we don't change the order in which the "ifs" are evaluated
if (options?.possibleValues?.length > 0) {
// we still have to migrate the `radio` use case
if (options?.editType === 'radio') {
return false;
} else {
return true;
}
return true;
} else {
if (type === 'number' || type === 'string') {
if (options?.editType === 'password') {
@ -116,10 +111,15 @@ export default class FormFieldComponent extends Component {
}
get hasRadioSubText() {
// for 'radio' editType, check to see if every of the possibleValues has a subText and label
// for 'radio' editType, check to see if any of the possibleValues has a subText
return this.args?.attr?.options?.possibleValues?.any((v) => v.subText);
}
get hasRadioHelpText() {
// for 'radio' editType, check to see if any of the possibleValues has a helpText
return this.args?.attr?.options?.possibleValues?.any((v) => v.helpText);
}
get hideLabel() {
const { type, options } = this.args.attr;
if (type === 'boolean' || type === 'object' || options?.isSectionHeader) {
@ -205,6 +205,12 @@ export default class FormFieldComponent extends Component {
this.setAndBroadcast(updatedValue);
}
@action
setAndBroadcastRadio(item) {
// we want to read the original value instead of `event.target.value` so we have `false` (boolean) and not `"false"` (string)
const valueToSet = this.radioValue(item);
this.setAndBroadcast(valueToSet);
}
@action
setAndBroadcastTtl(value) {
const alwaysSendValue = this.valuePath === 'expiry' || this.valuePath === 'safetyBuffer';
const attrOptions = this.args.attr.options || {};

View File

@ -27,7 +27,7 @@ module('Acceptance | totp key backend', function (hooks) {
};
const createNonVaultKey = async (keyName, issuer, accountName, url, key) => {
await click('[data-test-radio="Other service"]');
await click(GENERAL.radioByAttr('Other service'));
await fillIn(GENERAL.inputByAttr('name'), keyName);
await fillIn(GENERAL.inputByAttr('issuer'), issuer);
await fillIn(GENERAL.inputByAttr('accountName'), accountName);

View File

@ -78,7 +78,7 @@ module('Acceptance | sync | destinations (plural)', function (hooks) {
// check default values
const attr = 'granularity';
assert
.dom(`${ts.inputByAttr(attr)} input#${defaultValues[attr]}`)
.dom(`${ts.inputGroupByAttr(attr)} input#${defaultValues[attr]}`)
.isChecked(`${defaultValues[attr]} is checked`);
});
}

View File

@ -52,6 +52,7 @@ export const GENERAL = {
inputGroupByAttr: (attr: string) => `[data-test-input-group="${attr}"]`,
labelById: (id: string) => `label[id="${id}"]`,
labelByGroupControlIndex: (index: number) => `.hds-form-group__control-field:nth-of-type(${index}) label`,
radioByAttr: (attr: string) => `[data-test-radio="${attr}"]`,
selectByAttr: (attr: string) => `[data-test-select="${attr}"]`,
textToggle: '[data-test-text-toggle]',
textToggleTextarea: '[data-test-text-file-textarea]',

View File

@ -92,7 +92,7 @@ export const PAGE = {
// for handling more complex form input elements by attr name
switch (attr) {
case 'granularity':
return await click(`[data-test-radio="secret-key"]`);
return await click(`${GENERAL.radioByAttr('secret-key')}`);
case 'credentials':
await click('[data-test-text-toggle]');
return fillIn('[data-test-text-file-textarea]', value);
@ -100,9 +100,9 @@ export const PAGE = {
await fillIn('[data-test-kv-key="0"]', 'foo');
return fillIn('[data-test-kv-value="0"]', value);
case 'deploymentEnvironments':
await click('[data-test-input-group="deploymentEnvironments"] input#development');
await click('[data-test-input-group="deploymentEnvironments"] input#preview');
return await click('[data-test-input-group="deploymentEnvironments"] input#production');
await click(`${GENERAL.inputGroupByAttr('deploymentEnvironments')} input#development`);
await click(`${GENERAL.inputGroupByAttr('deploymentEnvironments')} input#preview`);
return await click(`${GENERAL.inputGroupByAttr('deploymentEnvironments')} input#production`);
default:
return fillIn(`[data-test-input="${attr}"]`, value);
}

View File

@ -202,61 +202,6 @@ module('Integration | Component | form field', function (hooks) {
assert.ok(spy.calledWith('foo', expectedSeconds), 'onChange called with correct args');
});
test('it renders: radio buttons for possible values', async function (assert) {
const [model, spy] = await setup.call(
this,
createAttr('foo', null, { editType: 'radio', possibleValues: ['SHA1', 'SHA256'] })
);
assert.ok(component.hasRadio, 'renders radio buttons');
const selectedValue = 'SHA256';
await component.selectRadioInput(selectedValue);
assert.strictEqual(model.get('foo'), selectedValue);
assert.ok(spy.calledWith('foo', selectedValue), 'onChange called with correct args');
});
test('it renders: radio buttons for possible values, labels, and subtext', async function (assert) {
const [model, spy] = await setup.call(
this,
createAttr('foo', null, {
editType: 'radio',
possibleValues: [
{ label: 'Label 1', subText: 'Some subtext 1', value: 'SHA1' },
{ label: 'Label 2', subText: 'Some subtext 2', value: 'SHA256' },
{ subText: 'Some subtext 3', value: 'SHA256' },
],
})
);
assert.ok(component.hasRadio, 'renders radio buttons');
const selectedValue = 'SHA256';
await component.selectRadioInput(selectedValue);
assert.dom('[data-test-radio-label="Label 1"]').hasTextContaining('Label 1');
assert.dom('[data-test-radio-label="Label 2"]').hasTextContaining('Label 2');
assert.dom('[data-test-radio-label="SHA256"]').hasTextContaining('SHA256');
assert.dom('[data-test-radio-subText="Some subtext 1"]').hasText('Some subtext 1');
assert.dom('[data-test-radio-subText="Some subtext 2"]').hasText('Some subtext 2');
assert.dom('[data-test-radio-subText="Some subtext 3"]').hasText('Some subtext 3');
assert.strictEqual(model.get('foo'), selectedValue);
assert.ok(spy.calledWith('foo', selectedValue), 'onChange called with correct args');
});
test('it renders: radio buttons false value and id', async function (assert) {
const [model, spy] = await setup.call(
this,
createAttr('foo', null, {
editType: 'radio',
possibleValues: [
{ label: 'True option', value: true, id: 'true-option' },
{ label: 'False option', value: false, id: 'false-option' },
],
})
);
assert.dom('[data-test-radio-label="True option"]').hasTextContaining('True option');
assert.dom('[data-test-radio-label="False option"]').hasTextContaining('False option');
assert.dom('[data-test-radio="true-option"]').hasAttribute('id', 'true-option');
assert.dom('[data-test-radio="false-option"]').hasAttribute('id', 'false-option');
await component.selectRadioInput('false-option');
assert.false(model.get('foo'));
assert.ok(spy.calledWith('foo', false), 'onChange called with correct args');
});
test('it renders: datetimelocal', async function (assert) {
const [model] = await setup.call(
this,
@ -360,6 +305,166 @@ module('Integration | Component | form field', function (hooks) {
// Note: some tests may be duplicative of the generic tests above
//
// editType === 'radio' / possibleValues
test('it renders: editType=radio / possibleValues - as Hds::Form::Radio::Group', async function (assert) {
const possibleValues = ['foo', 'bar', 'baz'];
await setup.call(this, createAttr('myfield', '-', { editType: 'radio', possibleValues }));
const labels = findAll(`${GENERAL.inputGroupByAttr('myfield')} label`);
const inputs = findAll(`${GENERAL.inputGroupByAttr('myfield')} input[type="radio"]`);
assert
.dom('.field fieldset[class^="hds-form-group"] input[type="radio"].hds-form-radio')
.exists('renders as Hds::Form::Radio::Group');
assert.strictEqual(inputs.length, 3, 'renders a fieldset element with 3 radio elements');
possibleValues.forEach((possibleValue, index) => {
assert
.dom(labels[index])
.hasAttribute('id', `label-${possibleValue}`, 'label has correct id')
.hasText(possibleValue, 'label has correct text');
assert
.dom(inputs[index])
.hasAttribute('id', possibleValue, 'input[type="radio"] has correct id')
.hasAttribute(
'data-test-radio',
possibleValue,
'input[type="radio"] has correct `data-test-radio` attribute'
);
});
});
test('it renders: editType=radio / possibleValues - with no selected radio', async function (assert) {
const possibleValues = ['foo', 'bar', 'baz'];
await setup.call(this, createAttr('myfield', '-', { editType: 'radio', possibleValues }));
possibleValues.forEach((possibleValue) => {
assert
.dom(GENERAL.radioByAttr(possibleValue))
.isNotChecked(`input[type="radio"] "${possibleValue}" is not checked`);
});
});
test('it renders: editType=radio / possibleValues - with selected value and changes it', async function (assert) {
const [model, spy] = await setup.call(
this,
createAttr('myfield', '-', {
editType: 'radio',
possibleValues: ['foo', 'bar', 'baz'],
defaultValue: 'baz',
})
);
assert.dom(GENERAL.radioByAttr('baz')).isChecked(`input[type="radio"] "baz" is checked`);
await click(GENERAL.radioByAttr('foo'));
assert.strictEqual(model.get('myfield'), 'foo');
assert.ok(spy.calledWith('myfield', 'foo'), 'onChange called with correct args');
});
test('it renders: editType=radio / possibleValues - with `true/false` boolean values', async function (assert) {
const [model, spy] = await setup.call(
this,
createAttr('myfield', '-', {
editType: 'radio',
// we need to pass custom ID or the `true` value will not be assigned as `for` argument to the label
// see bug in HDS: https://github.com/hashicorp/design-system/pull/2863
// once the bug is fixed, we can change this to `possibleValues: [true, false],`
possibleValues: [
{ value: true, id: 'true-option' },
{ value: false, id: 'false-option' },
],
defaultValue: true,
})
);
assert.dom(GENERAL.radioByAttr('true-option')).isChecked(`input[type="radio"] "true" is checked`);
await click(GENERAL.radioByAttr('false-option'));
// eslint-disable-next-line qunit/no-assert-equal-boolean
assert.strictEqual(model.get('myfield'), false);
assert.ok(spy.calledWith('myfield', false), 'onChange called with correct args');
});
test('it renders: editType=radio / possibleValues - with passed custom id, label, subtext, helptext', async function (assert) {
await setup.call(
this,
createAttr('myfield', '-', {
editType: 'radio',
possibleValues: [
{ value: 'foo', id: 'custom-id-1' },
{ value: 'bar', label: 'Custom label 2', subText: 'Some subtext 2' },
{ value: 'baz', label: 'Custom label 3', helpText: 'Some helptext 3' },
{ value: 'qux', label: 'Custom label 4', subText: 'Some subtext 4', helpText: 'Some helptext 2' },
],
})
);
// first item should have custom ID, label `foo`, and no subText/helpText
assert
.dom(GENERAL.radioByAttr('custom-id-1'))
.hasAttribute('id', 'custom-id-1', 'renders the radio input with a custom `id` attribute');
assert.dom(GENERAL.labelByGroupControlIndex(1)).hasText('foo', 'renders default label from `foo` value');
assert
.dom(GENERAL.helpTextByGroupControlIndex(1))
.doesNotExist('does not render subtext/helptext for `foo`');
// second item should have custom label and subText but no helpText
assert
.dom(GENERAL.labelByGroupControlIndex(2))
.hasText('Custom label 2', 'renders the custom label for `bar` from options');
assert
.dom(GENERAL.helpTextByGroupControlIndex(2))
.hasText('Some subtext 2', 'renders the right subtext string for `bar` from options');
// third item should have custom label and no subText/helpText (helpText is visible only if no subText is defined for any of the items)
assert
.dom(GENERAL.labelByGroupControlIndex(3))
.hasText('Custom label 3', 'renders the custom label for `baz` from options');
assert.dom(GENERAL.helpTextByGroupControlIndex(3)).doesNotExist('does not render the helptext for `baz`');
// fourth item should have custom label and subText but no helpText (helpText is visible only if no subText is defined for any of the items)
assert
.dom(GENERAL.labelByGroupControlIndex(4))
.hasText('Custom label 4', 'renders the custom label for `qux` from options');
assert
.dom(GENERAL.helpTextByGroupControlIndex(4))
.exists({ count: 1 }, 'renders only the subtext for `qux` and not the helptext')
.hasText('Some subtext 4', 'renders the right subtext string for `qux` from options');
});
test('it renders: editType=radio / possibleValues - with passed helptext', async function (assert) {
await setup.call(
this,
createAttr('myfield', '-', {
editType: 'radio',
possibleValues: [{ value: 'foo' }, { value: 'bar', helpText: 'Some helptext 2' }],
})
);
// first item should not have helpText
assert.dom(GENERAL.helpTextByGroupControlIndex(1)).doesNotExist('does not render helptext for `foo`');
// second item should have helpText
assert
.dom(GENERAL.helpTextByGroupControlIndex(2))
.hasText('Some helptext 2', 'renders the right helptext string for `bar` from options');
});
test('it renders: editType=radio / possibleValues - with validation errors and warnings', async function (assert) {
this.setProperties({
attr: createAttr('myfield', '-', { editType: 'radio', possibleValues: ['foo', 'bar', 'baz'] }),
model: { myfield: 'bar' },
modelValidations: {
myfield: {
isValid: false,
errors: ['Error message #1', 'Error message #2'],
warnings: ['Warning message #1', 'Warning message #2'],
},
},
onChange: () => {},
});
await render(
hbs`<FormField @attr={{this.attr}} @model={{this.model}} @modelValidations={{this.modelValidations}} @onChange={{this.onChange}} />`
);
assert
.dom(GENERAL.validationErrorByAttr('myfield'))
.exists('Validation error renders')
.hasText('Error message #1 Error message #2', 'Validation errors are combined');
assert
.dom(GENERAL.validationWarningByAttr('myfield'))
.exists('Validation warning renders')
.hasText('Warning message #1 Warning message #2', 'Validation warnings are combined');
});
// editType === 'checkboxList' / possibleValues
test('it renders: editType=checkboxList / possibleValues - as Hds::Form::Checkbox::Group', async function (assert) {

View File

@ -122,7 +122,7 @@ module('Integration | Component | kubernetes | Page::Role::CreateAndEdit', funct
'Kubernetes role name cleared when switching from expanded to full'
);
await click('[data-test-input="kubernetesRoleType"] input');
await click('[data-test-input-group="kubernetesRoleType"] input');
await click('[data-test-toggle-input="show-nameTemplate"]');
await fillIn('[data-test-input="nameTemplate"]', 'bar');
await fillIn('[data-test-select-template]', '6');

View File

@ -66,7 +66,7 @@ module('Integration | Component | ldap | Page::Library::CreateAndEdit', function
assert.dom('[data-test-ttl-value="Max lease TTL"]').hasAnyValue('Max lease ttl renders');
const checkInValue = this.libraryData.disable_check_in_enforcement ? 'Disabled' : 'Enabled';
assert
.dom(`[data-test-input="disable_check_in_enforcement"] input#${checkInValue}`)
.dom(`[data-test-input-group="disable_check_in_enforcement"] input#${checkInValue}`)
.isChecked('Correct radio is checked for check-in enforcement');
});
@ -121,7 +121,7 @@ module('Integration | Component | ldap | Page::Library::CreateAndEdit', function
await click('[data-test-string-list-button="add"]');
await fillIn('[data-test-string-list-input="1"]', 'bar@baz.com');
await click('[data-test-string-list-button="add"]');
await click('[data-test-input="disable_check_in_enforcement"] input#Disabled');
await click('[data-test-input-group="disable_check_in_enforcement"] input#Disabled');
await click('[data-test-save]');
assert.ok(
@ -149,7 +149,7 @@ module('Integration | Component | ldap | Page::Library::CreateAndEdit', function
await this.renderComponent();
await click('[data-test-string-list-row="0"] [data-test-string-list-button="delete"]');
await click('[data-test-input="disable_check_in_enforcement"] input#Disabled');
await click('[data-test-input-group="disable_check_in_enforcement"] input#Disabled');
await click('[data-test-save]');
assert.ok(

View File

@ -33,7 +33,7 @@ module('Integration | Component | mfa-method-form', function (hooks) {
assert.dom('[data-test-input="period"]').exists('Period field ttl renders');
assert.dom('[data-test-input="key_size"]').exists('Key size field input renders');
assert.dom('[data-test-input="qr_size"]').exists('QR size field input renders');
assert.dom('[data-test-input="algorithm"]').exists(`Algorithm field radio input renders`);
assert.dom('[data-test-input-group="algorithm"]').exists(`Algorithm field radio input renders`);
assert
.dom('[data-test-input="max_validation_attempts"]')
.exists(`Max validation attempts field input renders`);

View File

@ -153,9 +153,11 @@ module('Integration | Component | oidc/client-form', function (hooks) {
assert.dom('[data-test-input="name"]').hasValue('test-app', 'Name input is populated with model value');
assert.dom('[data-test-input="key"]').isDisabled('Signing key input is disabled');
assert.dom('[data-test-input="key"]').hasValue('default', 'Key input populated with default');
assert.dom('[data-test-input="clientType"] input').isDisabled('client type input is disabled on edit');
assert
.dom('[data-test-input="clientType"] input#confidential')
.dom('[data-test-input-group="clientType"] input')
.isDisabled('client type input is disabled on edit');
assert
.dom('[data-test-input-group="clientType"] input#confidential')
.isChecked('Correct radio button is selected');
assert.dom('[data-test-oidc-radio="allow-all"] input').isChecked('Allow all radio button is selected');
await click(SELECTORS.clientSaveButton);

View File

@ -116,7 +116,7 @@ module('Integration | Component | totp/key-form', function (hooks) {
assert.dom('[data-test-secret-header]').hasText('Create a TOTP key', 'Form title renders');
// switch to non-generated form fields
await click('[data-test-radio="Other service"]');
await click(GENERAL.radioByAttr('Other service'));
// check validation errors
await click(GENERAL.saveButton);
@ -156,7 +156,7 @@ module('Integration | Component | totp/key-form', function (hooks) {
assert.dom(SELECTORS.toggleGroup('Provider Options')).exists('Generated exclusive group is shown');
// switch to non-generated form fields
await click('[data-test-radio="Other service"]');
await click(GENERAL.radioByAttr('Other service'));
// check non generated groups
assert.dom(SELECTORS.toggleGroup('TOTP Code Options')).exists('Common group is shown');

View File

@ -28,7 +28,6 @@ export default {
hasMaskedInput: isPresent('[data-test-masked-input]'),
hasTooltip: isPresent('[data-test-component=info-tooltip]'),
tooltipTrigger: focusable('[data-test-tool-tip-trigger]'),
hasRadio: isPresent('[data-test-radio-input]'),
radioButtons: collection('input[type=radio]', {
select: clickable(),
id: attribute('id'),