chore: 🤖 use hds keyvalue component (#3027)

This commit is contained in:
Dharini committed 2026-06-03 16:36:29 -07:00
1 parent ecefa39b98
commit 8b870556a6
19 files changed
+664 -84

No files matched your search

@@ -45,6 +45,9 @@ export default factory.extend({
username: faker.internet.userName(),
key_bits: faker.number.int(999),
path: faker.system.directoryPath(),
critical_options: {
[faker.word.noun()]: faker.word.words(2),
},
};
case TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP:
return {
@@ -13,6 +13,7 @@
form {
[class*='hds-form-field--layout'],
[class*='hds-form-group--layout'],
[class='hds-form-key-value-inputs'],
.rose-form-actions.hds-button-set {
margin-bottom: 1.5rem;
}
@@ -247,45 +247,50 @@
</Form::Field::ListWrapper>
{{! Account Claim Maps }}
<Form::Field::ListWrapper
@layout='horizontal'
@isOptional={{true}}
<Form::Field::KeyValue
@name='account_claim_maps'
@data={{@model.account_claim_maps}}
@onAdd={{this.addAccountClaimMap}}
@onRemove={{this.removeAccountClaimMap}}
@onUpdate={{this.updateAccountClaimMap}}
@legend={{t 'form.account_claim_maps.label'}}
@helperText={{t 'form.account_claim_maps.help'}}
@errors={{@model.errors.account_claim_maps}}
@disabled={{form.disabled}}
@isOptional={{true}}
>
<:fieldset as |F|>
<F.Legend>
{{t 'form.account_claim_maps.label'}}
</F.Legend>
<F.HelperText>
{{t 'form.account_claim_maps.help'}}
</F.HelperText>
<:row as |R|>
<R.Field as |F|>
<F.Label>{{t 'form.from_claim.label'}}</F.Label>
<F.TextInput
data-test-key-input
@value={{R.rowData.key}}
{{on 'input' (fn R.updateRow 'key')}}
/>
</R.Field>
{{#if @model.errors.account_claim_maps}}
<F.Error as |E|>
{{#each @model.errors.account_claim_maps as |error|}}
<E.Message>{{error.message}}</E.Message>
<R.Field as |F|>
<F.Label>{{t 'form.to_claim.label'}}</F.Label>
<F.Select
data-test-value-input
{{on 'change' (fn R.updateRow 'value')}}
>
<option disabled hidden selected value=''>
{{t 'titles.choose-an-option'}}
</option>
{{#each this.toClaims as |claim|}}
<option value={{claim}} selected={{eq R.rowData.value claim}}>
{{claim}}
</option>
{{/each}}
</F.Error>
</F.Select>
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:fieldset>
<:field as |F|>
<F.KeyValue
@name='account_claim_maps'
@options={{@model.account_claim_maps}}
@model={{@model}}
@keyLabel={{t 'form.from_claim.label'}}
@valueLabel={{t 'form.to_claim.label'}}
@width='100%'
>
<:key as |K|>
<K.text />
</:key>
<:value as |V|>
<V.select @selectOptions={{this.toClaims}} />
</:value>
</F.KeyValue>
</:field>
</Form::Field::ListWrapper>
</:row>
</Form::Field::KeyValue>
{{! Certificates }}
<Form::Field::ListWrapper
@@ -10,6 +10,7 @@ import { tracked } from '@glimmer/tracking';
export default class FormAuthMethodOidcComponent extends Component {
// =attributes
/**
* @type {object}
*/
@@ -54,6 +55,45 @@ export default class FormAuthMethodOidcComponent extends Component {
//actions
/**
* Adds a new empty row to account claim maps
*/
@action
addAccountClaimMap() {
this.args.model.account_claim_maps = [
...this.args.model.account_claim_maps,
{ key: '', value: '' },
];
}
/**
* Removes a row from account claim maps
* @param {Object} rowData - The row to remove
*/
@action
removeAccountClaimMap(rowData) {
let rows = this.args.model.account_claim_maps.filter(
(item) => item !== rowData,
);
// Ensure at least one empty row exists for editing
if (rows.length === 0) {
rows = [{ key: '', value: '' }];
}
this.args.model.account_claim_maps = rows;
}
/**
* Updates the account claim maps in the model
*/
@action
updateAccountClaimMap(rowData, property, { target: { value } }) {
rowData[property] = value;
this.args.model.account_claim_maps = [
...this.args.model.account_claim_maps,
];
}
/**
* @param {string} value
*/
@@ -225,45 +225,43 @@
{{/if}}
</Hds::Form::TextInput::Field>
<Form::Field::ListWrapper
@layout='horizontal'
@isOptional={{true}}
<Form::Field::KeyValue
@name='critical_options'
@data={{@model.critical_options}}
@onAdd={{this.addCriticalOption}}
@onRemove={{this.removeCriticalOption}}
@onUpdate={{this.updateCriticalOptions}}
@legend={{t 'resources.credential-library.form.critical_options.label'}}
@helperText={{t 'resources.credential-library.form.critical_options.help'}}
@errors={{@model.errors.critical_options}}
@disabled={{form.disabled}}
@isOptional={{true}}
>
<:fieldset as |F|>
<F.Legend>
{{t 'resources.credential-library.form.critical_options.label'}}
</F.Legend>
<F.HelperText>
{{t 'resources.credential-library.form.critical_options.help'}}
</F.HelperText>
<:row as |R|>
<R.Field as |F|>
<F.Label>{{t 'form.key.label'}}</F.Label>
<F.TextInput
data-test-key-input
@value={{R.rowData.key}}
{{on 'input' (fn R.updateRow 'key')}}
/>
</R.Field>
{{#if @model.errors.critical_options}}
<F.Error as |E|>
{{#each @model.errors.critical_options as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
<R.Field as |F|>
<F.Label>{{t 'form.value.label'}}</F.Label>
<F.TextInput
data-test-value-input
@value={{R.rowData.value}}
{{on 'input' (fn R.updateRow 'value')}}
/>
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:fieldset>
</:row>
<:field as |F|>
<F.KeyValue
@name='critical_options'
@options={{@model.critical_options}}
@model={{@model}}
@disabled={{form.disabled}}
>
<:key as |K|>
<K.text />
</:key>
<:value as |K|>
<K.text />
</:value>
</F.KeyValue>
</:field>
</Form::Field::ListWrapper>
</Form::Field::KeyValue>
<Form::Field::ListWrapper
@layout='horizontal'
@@ -5,6 +5,7 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { options } from 'api/models/credential-library';
export default class FormCredentialLibraryVaultSshCertComponent extends Component {
@@ -23,4 +24,41 @@ export default class FormCredentialLibraryVaultSshCertComponent extends Componen
const keyType = this.args.model.key_type;
return keyType === 'rsa' || keyType === 'ecdsa';
}
/**
* Adds a new empty row to critical options
*/
@action
addCriticalOption() {
this.args.model.critical_options = [
...this.args.model.critical_options,
{ key: '', value: '' },
];
}
/**
* Updates critical options data
*/
@action
updateCriticalOptions(rowData, property, { target: { value } }) {
rowData[property] = value;
this.args.model.critical_options = [...this.args.model.critical_options];
}
/**
* Removes a row from critical options
* @param {Object} rowData - The row to remove
*/
@action
removeCriticalOption(rowData) {
let rows = this.args.model.critical_options.filter(
(item) => item !== rowData,
);
// Ensure at least one empty row exists for editing
if (rows.length === 0) {
rows = [{ key: '', value: '' }];
}
this.args.model.critical_options = rows;
}
}
@@ -0,0 +1,45 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Form::KeyValueInputs
name={{@name}}
@data={{@data}}
@isRequired={{@isRequired}}
@isOptional={{@isOptional}}
disabled={{@disabled}}
>
<:header as |H|>
<H.Legend>{{@legend}}</H.Legend>
{{#if @helperText}}
<H.HelperText>{{@helperText}}</H.HelperText>
{{/if}}
</:header>
<:row as |R|>
{{yield
(hash
Field=R.Field
DeleteRowButton=R.DeleteRowButton
rowData=R.rowData
removeRow=(fn @onRemove R.rowData)
updateRow=(fn @onUpdate R.rowData)
canDelete=this.canDelete
)
to='row'
}}
</:row>
<:footer as |F|>
<F.AddRowButton
@text={{t 'actions.add'}}
data-test-add-button
@onClick={{@onAdd}}
/>
{{#if @errors}}
<F.Error as |E|>
{{#each @errors as |error|}}
<E.Message data-test-error-message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</:footer>
</Hds::Form::KeyValueInputs>
@@ -0,0 +1,28 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
export default class FormFieldKeyValueComponent extends Component {
/**
* Determines if the delete button should be shown.
*/
get canDelete() {
const rows = this.args.data || [];
// Always show delete for multiple rows
if (rows.length > 1) {
return true;
}
// For a single row, only show if it has any non-empty values
if (rows.length === 1) {
return Object.values(rows[0]).some((value) => value);
}
// Return false if there are no rows
return false;
}
}
@@ -147,6 +147,12 @@ export default class ScopesScopeAuthMethodsIndexController extends Controller {
isNew ? 'notifications.create-success' : 'notifications.save-success',
)
async save(authMethod) {
// Filter out empty account claim maps before saving (only check key)
if (authMethod.account_claim_maps) {
authMethod.account_claim_maps = authMethod.account_claim_maps.filter(
(item) => item.key?.trim(),
);
}
await authMethod.save();
if (this.abilities.can('read model', authMethod)) {
await this.router.transitionTo(
@@ -308,6 +314,10 @@ export default class ScopesScopeAuthMethodsIndexController extends Controller {
authMethod.account_claim_maps = structuredClone(
authMethod.account_claim_maps,
);
// Ensure at least one empty row exists for editing
if (authMethod.account_claim_maps.length === 0) {
authMethod.account_claim_maps = [{ key: '', value: '' }];
}
}
if (authMethod.certificates) {
authMethod.certificates = structuredClone(authMethod.certificates);
@@ -74,6 +74,11 @@ export default class ScopesScopeCredentialStoresCredentialStoreCredentialLibrari
isNew ? 'notifications.create-success' : 'notifications.save-success',
)
async save(credentialLibrary) {
// Filter out empty critical_options before saving (only check key)
if (credentialLibrary.critical_options) {
credentialLibrary.critical_options =
credentialLibrary.critical_options.filter((item) => item.key?.trim());
}
await credentialLibrary.save();
if (this.abilities.can('read model', credentialLibrary)) {
await this.router.transitionTo(
@@ -120,6 +125,11 @@ export default class ScopesScopeCredentialStoresCredentialStoreCredentialLibrari
const { critical_options, extensions } = credentialLibrary;
credentialLibrary.critical_options = structuredClone(critical_options);
credentialLibrary.extensions = structuredClone(extensions);
// Ensure at least one empty row exists for editing
if (credentialLibrary.critical_options?.length === 0) {
credentialLibrary.critical_options = [{ key: '', value: '' }];
}
}
/**
@@ -5,6 +5,7 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { TYPE_AUTH_METHOD_OIDC } from 'api/models/auth-method';
export default class ScopesScopeAuthMethodsNewRoute extends Route {
// =services
@@ -47,6 +48,12 @@ export default class ScopesScopeAuthMethodsNewRoute extends Route {
type: params.type,
});
record.scopeModel = scopeModel;
// if the auth method type is OIDC, initialize account_claim_maps with an empty row
if (params.type === TYPE_AUTH_METHOD_OIDC) {
record.account_claim_maps = [{ key: '', value: '' }];
}
return record;
}
}
@@ -71,11 +71,17 @@ export default class ScopesScopeCredentialStoresCredentialStoreCredentialLibrari
resolvedType = TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC;
}
return this.store.createRecord('credential-library', {
const attrs = {
type: resolvedType,
credential_store_id,
name,
description,
});
};
// if the type is vault-ssh-certificate, initialize critical_options with an empty row
if (resolvedType === TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE) {
attrs.critical_options = [{ key: '', value: '' }];
}
return this.store.createRecord('credential-library', attrs);
}
}
@@ -124,11 +124,11 @@ module('Acceptance | auth-methods | create', function (hooks) {
selectors.FIELD_ACCOUNT_CLAIM_MAPS_FROM_CLAIM,
selectors.FIELD_ACCOUNT_CLAIM_MAPS_FROM_CLAIM_VALUE,
);
await select(
selectors.FIELD_ACCOUNT_CLAIM_MAPS_TO_CLAIM,
selectors.FIELD_ACCOUNT_CLAIM_MAPS_TO_CLAIM_VALUE,
);
await click(selectors.FIELD_ACCOUNT_CLAIM_MAPS_ADD_BTN);
await fillIn(selectors.FIELD_IDP_CERTS, selectors.FIELD_IDP_CERTS_VALUE);
await click(selectors.FIELD_IDP_CERTS_ADD_BTN);
@@ -26,15 +26,12 @@ export const FIELD_CLAIMS_SCOPES_ADD_BTN = '[name=claims_scopes] button';
export const FIELD_CLAIMS_SCOPES_DELETE_BTN =
'[name=claims_scopes] tbody td:last-child button[aria-label=Remove]';
export const FIELD_ACCOUNT_CLAIM_MAPS_FROM_CLAIM =
'[name=account_claim_maps] tbody td:nth-of-type(1) input';
'[name=account_claim_maps] [data-test-key-input]';
export const FIELD_ACCOUNT_CLAIM_MAPS_FROM_CLAIM_VALUE = 'from_claim';
export const FIELD_ACCOUNT_CLAIM_MAPS_TO_CLAIM =
'[name=account_claim_maps] tbody td:nth-of-type(2) select';
'[name=account_claim_maps] [data-test-value-input]';
export const FIELD_ACCOUNT_CLAIM_MAPS_TO_CLAIM_VALUE = 'email';
export const FIELD_ACCOUNT_CLAIM_MAPS_ADD_BTN =
'[name=account_claim_maps] button';
export const FIELD_ACCOUNT_CLAIM_MAPS_DELETE_BTN =
'[name=account_claim_maps] tbody td:last-child button[aria-label=Remove]';
export const FIELD_ACCOUNT_CLAIM_MAPS_DELETE_BTN = '[data-test-delete-button]';
export const FIELD_IDP_CERTS = '[name=idp_ca_certs] textarea';
export const FIELD_IDP_CERTS_VALUE = 'IDP certificates';
export const FIELD_IDP_CERTS_ADD_BTN = '[name=idp_ca_certs] button';
@@ -181,8 +181,6 @@ module('Acceptance | auth-methods | update', function (hooks) {
selectors.FIELD_ACCOUNT_CLAIM_MAPS_TO_CLAIM_VALUE,
);
await click(selectors.FIELD_ACCOUNT_CLAIM_MAPS_ADD_BTN);
// Remove all certificates
const certificatesList = findAll(selectors.FIELD_IDP_CERTS_DELETE_BTN);
@@ -191,7 +191,6 @@ module('Acceptance | credential-libraries | create', function (hooks) {
await fillIn(selectors.FIELD_KEY_ID, selectors.FIELD_KEY_ID_VALUE);
await fillIn(selectors.FIELD_CRIT_OPTS_KEY, 'co_key');
await fillIn(selectors.FIELD_CRIT_OPTS_VALUE, 'co_value');
await click(selectors.FIELD_CRIT_OPTS_BTN);
await fillIn(selectors.FIELD_EXT_KEY, 'ext_key');
await fillIn(selectors.FIELD_EXT_VALUE, 'ext_value');
await click(selectors.FIELD_EXT_BTN);
@@ -33,10 +33,9 @@ export const TYPE_VAULT_LDAP = '[value="vault-ldap"]';
export const TYPE_VAULT_GENERIC = '[value="vault-generic"]';
export const FIELD_CRIT_OPTS_KEY =
'[name="critical_options"] tbody td:nth-of-type(1) input';
'[name="critical_options"] [data-test-key-input]';
export const FIELD_CRIT_OPTS_VALUE =
'[name="critical_options"] tbody td:nth-of-type(2) input';
export const FIELD_CRIT_OPTS_BTN = '[name="critical_options"] button';
'[name="critical_options"] [data-test-value-input]';
export const FIELD_EXT_KEY =
'[name="extensions"] tbody td:nth-of-type(1) input';
@@ -355,10 +355,10 @@ module('Acceptance | credential-libraries | update', function (hooks) {
await fillIn(selectors.FIELD_KEY_ID, selectors.FIELD_KEY_ID_VALUE);
await fillIn(selectors.FIELD_CRIT_OPTS_KEY, 'co_key');
await fillIn(selectors.FIELD_CRIT_OPTS_VALUE, 'co_value');
await click(selectors.FIELD_CRIT_OPTS_BTN);
await fillIn(selectors.FIELD_EXT_KEY, 'ext_key');
await fillIn(selectors.FIELD_EXT_VALUE, 'ext_value');
await click(selectors.FIELD_EXT_BTN);
await click(commonSelectors.SAVE_BTN);
const credentialLibrary = this.server.schema.credentialLibraries.findBy({
@@ -0,0 +1,396 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, fillIn, findAll } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupIntl } from 'ember-intl/test-support';
module('Integration | Component | form/field/key-value', function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks, 'en-us');
// Helper functions
const getKeyInputs = () => findAll('[data-test-key-input]');
const getValueInputs = () => findAll('[data-test-value-input]');
const getDeleteButtons = () => findAll('[data-test-delete-button]');
test('it renders with default empty row', async function (assert) {
this.set('data', [{ key: '', value: '' }]);
await render(hbs`
<Form::Field::KeyValue @data={{this.data}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} />
</R.Field>
<R.Field as |F|>
<F.TextInput data-test-value-input @value={{R.rowData.value}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
assert.dom('[data-test-key-input]').exists();
assert.dom('[data-test-value-input]').exists();
assert.dom('[data-test-add-button]').exists();
assert.dom('[data-test-delete-button]').doesNotExist();
});
test('it renders with provided data', async function (assert) {
this.set('data', [
{ key: 'env', value: 'production' },
{ key: 'region', value: 'us-east-1' },
]);
await render(hbs`
<Form::Field::KeyValue
@data={{this.data}}
@legend="Test Legend"
@helperText="Test helper text">
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} />
</R.Field>
<R.Field as |F|>
<F.TextInput data-test-value-input @value={{R.rowData.value}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
const keyInputs = getKeyInputs();
const valueInputs = getValueInputs();
assert.strictEqual(keyInputs.length, 2);
assert.strictEqual(valueInputs.length, 2);
assert.dom(keyInputs[0]).hasValue('env');
assert.dom(keyInputs[1]).hasValue('region');
assert.dom(valueInputs[0]).hasValue('production');
assert.dom(valueInputs[1]).hasValue('us-east-1');
assert.dom('legend').exists().includesText('Test Legend');
assert.dom('.hds-form-helper-text ').hasText('Test helper text');
});
test('it adds new rows', async function (assert) {
this.set('data', [{ key: '', value: '' }]);
this.set('onAdd', () => {
this.data.push({ key: '', value: '' });
this.set('data', [...this.data]);
});
this.set('onUpdate', (rowData, property, { target: { value } }) => {
rowData[property] = value;
});
await render(hbs`
<Form::Field::KeyValue @data={{this.data}} @onAdd={{this.onAdd}} @onUpdate={{this.onUpdate}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} {{on 'input' (fn R.updateRow 'key')}} />
</R.Field>
<R.Field as |F|>
<F.TextInput data-test-value-input @value={{R.rowData.value}} {{on 'input' (fn R.updateRow 'value')}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
assert.strictEqual(getKeyInputs().length, 1, 'Initially has one row');
await click('[data-test-add-button]');
assert.strictEqual(
getKeyInputs().length,
2,
'After clicking add, has two rows',
);
assert.strictEqual(getValueInputs().length, 2);
// Fill in the second row
await fillIn(getKeyInputs()[1], 'test-key');
await fillIn(getValueInputs()[1], 'test-value');
// Check that the data was updated
assert.strictEqual(this.data.length, 2);
assert.strictEqual(this.data[1].key, 'test-key');
assert.strictEqual(this.data[1].value, 'test-value');
});
test('it removes rows', async function (assert) {
this.set('data', [
{ key: 'key1', value: 'value1' },
{ key: 'key2', value: 'value2' },
]);
this.set('onRemove', (rowData) => {
// Remove the row from the data array
let newData = this.data.filter((item) => item !== rowData);
// Ensure at least one empty row remains
if (newData.length === 0) {
newData = [{ key: '', value: '' }];
}
this.set('data', newData);
});
await render(hbs`
<Form::Field::KeyValue @data={{this.data}} @onRemove={{this.onRemove}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} {{on 'input' (fn R.updateRow 'key')}} />
</R.Field>
<R.Field as |F|>
<F.TextInput data-test-value-input @value={{R.rowData.value}} {{on 'input' (fn R.updateRow 'value')}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
assert.strictEqual(getKeyInputs().length, 2, 'Initially has two rows');
// Remove first row
await click(getDeleteButtons()[0]);
assert.strictEqual(
getKeyInputs().length,
1,
'After removing first row, has one row',
);
assert.dom('[data-test-key-input]').hasValue('key2');
assert.dom('[data-test-value-input]').hasValue('value2');
// Remove second row - should ensure at least one empty row remains
await click(getDeleteButtons()[0]);
assert.strictEqual(
getKeyInputs().length,
1,
'After removing all rows, still has one empty row',
);
assert
.dom('[data-test-key-input]')
.hasValue('', 'The remaining row has empty key');
assert
.dom('[data-test-value-input]')
.hasValue('', 'The remaining row has empty value');
});
test('it handles different field types', async function (assert) {
this.set('data', [{ key: 'k', value: 'v', id: 1 }]);
// Test TextInput and Select fields
await render(hbs`
<Form::Field::KeyValue @data={{this.data}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput name="test-key" data-test-key-input @value={{R.rowData.key}} />
</R.Field>
<R.Field as |F|>
<F.Select name="test-value" data-test-value-input>
<option value={{R.rowData.value}} selected />
</F.Select>
</R.Field>
<R.Field as |F|>
<F.TextInput name="test-id" data-test-id-input @value={{R.rowData.id}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
assert.dom('[data-test-key-input]').hasValue('k');
assert.dom('[data-test-value-input]').hasValue('v');
assert.dom('[data-test-id-input]').hasValue('1');
// Test Textarea field
await render(hbs`
<Form::Field::KeyValue @data={{this.data}}>
<:row as |R|>
<R.Field as |F|>
<F.Textarea name="test-key" data-test-key-input @value={{R.rowData.key}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
assert.dom('[data-test-key-input]').hasValue('k');
// Test single field
this.set('data', [{ key: '' }]);
await render(hbs`
<Form::Field::KeyValue @data={{this.data}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
assert.dom('[data-test-key-input]').exists();
assert.dom('[data-test-value-input]').doesNotExist();
assert.dom('[data-test-add-button]').exists();
assert.dom('[data-test-delete-button]').doesNotExist();
});
test('it displays error messages in footer', async function (assert) {
this.set('data', [{ key: '', value: '' }]);
this.set('errors', [
{ message: 'Key is required' },
{ message: 'Value must be unique' },
]);
await render(hbs`
<Form::Field::KeyValue @data={{this.data}} @errors={{this.errors}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} />
</R.Field>
<R.Field as |F|>
<F.TextInput data-test-value-input @value={{R.rowData.value}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
assert.dom('[data-test-error-message]').exists().hasText('Key is required');
assert
.dom('[data-test-error-message]:nth-child(2)')
.hasText('Value must be unique');
});
test('it does not show error messages when no errors are provided', async function (assert) {
this.set('data', [{ key: '', value: '' }]);
await render(hbs`
<Form::Field::KeyValue @data={{this.data}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} />
</R.Field>
<R.Field as |F|>
<F.TextInput data-test-value-input @value={{R.rowData.value}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
assert.dom('[data-test-error-message]').doesNotExist();
});
test('it updates both key and value fields', async function (assert) {
this.set('data', [{ key: '', value: '' }]);
this.set('onUpdate', (rowData, property, { target: { value } }) => {
rowData[property] = value;
});
await render(hbs`
<Form::Field::KeyValue @data={{this.data}} @onUpdate={{this.onUpdate}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} {{on 'input' (fn R.updateRow 'key')}} />
</R.Field>
<R.Field as |F|>
<F.TextInput data-test-value-input @value={{R.rowData.value}} {{on 'input' (fn R.updateRow 'value')}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
await fillIn('[data-test-key-input]', 'test-key');
await fillIn('[data-test-value-input]', 'test-value');
assert.strictEqual(this.data.length, 1);
assert.strictEqual(this.data[0].key, 'test-key');
assert.strictEqual(this.data[0].value, 'test-value');
});
test('delete button shows per row based on index and data', async function (assert) {
this.set('data', [
{ key: '', value: '' },
{ key: '', value: '' },
{ key: 'key3', value: 'value3' },
]);
await render(hbs`
<Form::Field::KeyValue @data={{this.data}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} />
</R.Field>
<R.Field as |F|>
<F.TextInput data-test-value-input @value={{R.rowData.value}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
assert.strictEqual(getDeleteButtons().length, 3);
});
});