diff --git a/addons/api/app/mirage/factories/credential-library.js b/addons/api/app/mirage/factories/credential-library.js
index 7965b70f6..222e26c0a 100644
--- a/addons/api/app/mirage/factories/credential-library.js
+++ b/addons/api/app/mirage/factories/credential-library.js
@@ -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 {
diff --git a/addons/rose/addon/styles/hds/overrides.scss b/addons/rose/addon/styles/hds/overrides.scss
index 466db6ca9..2f4709c66 100644
--- a/addons/rose/addon/styles/hds/overrides.scss
+++ b/addons/rose/addon/styles/hds/overrides.scss
@@ -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;
}
diff --git a/ui/admin/app/components/form/auth-method/oidc/index.hbs b/ui/admin/app/components/form/auth-method/oidc/index.hbs
index 2a9830d39..63f2b5ada 100644
--- a/ui/admin/app/components/form/auth-method/oidc/index.hbs
+++ b/ui/admin/app/components/form/auth-method/oidc/index.hbs
@@ -247,45 +247,50 @@
{{! Account Claim Maps }}
-
- <:fieldset as |F|>
-
- {{t 'form.account_claim_maps.label'}}
-
-
- {{t 'form.account_claim_maps.help'}}
-
+ <:row as |R|>
+
+ {{t 'form.from_claim.label'}}
+
+
- {{#if @model.errors.account_claim_maps}}
-
- {{#each @model.errors.account_claim_maps as |error|}}
- {{error.message}}
+
+ {{t 'form.to_claim.label'}}
+
+
+ {{#each this.toClaims as |claim|}}
+
{{/each}}
-
+
+
+ {{#if R.canDelete}}
+
{{/if}}
-
- <:field as |F|>
-
- <:key as |K|>
-
-
- <:value as |V|>
-
-
-
-
-
+
+
+
{{! Certificates }}
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
*/
diff --git a/ui/admin/app/components/form/credential-library/vault-ssh-certificate/index.hbs b/ui/admin/app/components/form/credential-library/vault-ssh-certificate/index.hbs
index d6ab77f4b..2d7c6e862 100644
--- a/ui/admin/app/components/form/credential-library/vault-ssh-certificate/index.hbs
+++ b/ui/admin/app/components/form/credential-library/vault-ssh-certificate/index.hbs
@@ -225,45 +225,43 @@
{{/if}}
-
- <:fieldset as |F|>
-
- {{t 'resources.credential-library.form.critical_options.label'}}
-
-
- {{t 'resources.credential-library.form.critical_options.help'}}
-
+ <:row as |R|>
+
+ {{t 'form.key.label'}}
+
+
- {{#if @model.errors.critical_options}}
-
- {{#each @model.errors.critical_options as |error|}}
- {{error.message}}
- {{/each}}
-
+
+ {{t 'form.value.label'}}
+
+
+
+ {{#if R.canDelete}}
+
{{/if}}
-
+
- <:field as |F|>
-
- <:key as |K|>
-
-
- <:value as |K|>
-
-
-
-
-
-
+
item !== rowData,
+ );
+
+ // Ensure at least one empty row exists for editing
+ if (rows.length === 0) {
+ rows = [{ key: '', value: '' }];
+ }
+ this.args.model.critical_options = rows;
+ }
}
diff --git a/ui/admin/app/components/form/field/key-value/index.hbs b/ui/admin/app/components/form/field/key-value/index.hbs
new file mode 100644
index 000000000..abe7708e5
--- /dev/null
+++ b/ui/admin/app/components/form/field/key-value/index.hbs
@@ -0,0 +1,45 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: BUSL-1.1
+}}
+
+ <:header as |H|>
+ {{@legend}}
+ {{#if @helperText}}
+ {{@helperText}}
+ {{/if}}
+
+ <: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'
+ }}
+
+ <:footer as |F|>
+
+ {{#if @errors}}
+
+ {{#each @errors as |error|}}
+ {{error.message}}
+ {{/each}}
+
+ {{/if}}
+
+
\ No newline at end of file
diff --git a/ui/admin/app/components/form/field/key-value/index.js b/ui/admin/app/components/form/field/key-value/index.js
new file mode 100644
index 000000000..426ea9270
--- /dev/null
+++ b/ui/admin/app/components/form/field/key-value/index.js
@@ -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;
+ }
+}
diff --git a/ui/admin/app/controllers/scopes/scope/auth-methods/index.js b/ui/admin/app/controllers/scopes/scope/auth-methods/index.js
index 1322d5bf7..f41d059b5 100644
--- a/ui/admin/app/controllers/scopes/scope/auth-methods/index.js
+++ b/ui/admin/app/controllers/scopes/scope/auth-methods/index.js
@@ -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);
diff --git a/ui/admin/app/controllers/scopes/scope/credential-stores/credential-store/credential-libraries/index.js b/ui/admin/app/controllers/scopes/scope/credential-stores/credential-store/credential-libraries/index.js
index 3c53696fb..cfe95dd3c 100644
--- a/ui/admin/app/controllers/scopes/scope/credential-stores/credential-store/credential-libraries/index.js
+++ b/ui/admin/app/controllers/scopes/scope/credential-stores/credential-store/credential-libraries/index.js
@@ -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: '' }];
+ }
}
/**
diff --git a/ui/admin/app/routes/scopes/scope/auth-methods/new.js b/ui/admin/app/routes/scopes/scope/auth-methods/new.js
index 0e2ef3929..8e7a59fce 100644
--- a/ui/admin/app/routes/scopes/scope/auth-methods/new.js
+++ b/ui/admin/app/routes/scopes/scope/auth-methods/new.js
@@ -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;
}
}
diff --git a/ui/admin/app/routes/scopes/scope/credential-stores/credential-store/credential-libraries/new.js b/ui/admin/app/routes/scopes/scope/credential-stores/credential-store/credential-libraries/new.js
index cfaa04ed2..d2dbaebd1 100644
--- a/ui/admin/app/routes/scopes/scope/credential-stores/credential-store/credential-libraries/new.js
+++ b/ui/admin/app/routes/scopes/scope/credential-stores/credential-store/credential-libraries/new.js
@@ -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);
}
}
diff --git a/ui/admin/tests/acceptance/auth-methods/create-test.js b/ui/admin/tests/acceptance/auth-methods/create-test.js
index 82521aa9b..d9db1ca77 100644
--- a/ui/admin/tests/acceptance/auth-methods/create-test.js
+++ b/ui/admin/tests/acceptance/auth-methods/create-test.js
@@ -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);
diff --git a/ui/admin/tests/acceptance/auth-methods/selectors.js b/ui/admin/tests/acceptance/auth-methods/selectors.js
index fc7a02aee..f3b0d8fef 100644
--- a/ui/admin/tests/acceptance/auth-methods/selectors.js
+++ b/ui/admin/tests/acceptance/auth-methods/selectors.js
@@ -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';
diff --git a/ui/admin/tests/acceptance/auth-methods/update-test.js b/ui/admin/tests/acceptance/auth-methods/update-test.js
index d62e20cc9..4f4b63bed 100644
--- a/ui/admin/tests/acceptance/auth-methods/update-test.js
+++ b/ui/admin/tests/acceptance/auth-methods/update-test.js
@@ -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);
diff --git a/ui/admin/tests/acceptance/credential-library/create-test.js b/ui/admin/tests/acceptance/credential-library/create-test.js
index d4c3d31b1..389dfec2b 100644
--- a/ui/admin/tests/acceptance/credential-library/create-test.js
+++ b/ui/admin/tests/acceptance/credential-library/create-test.js
@@ -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);
diff --git a/ui/admin/tests/acceptance/credential-library/selectors.js b/ui/admin/tests/acceptance/credential-library/selectors.js
index 6b7dc982f..cdf1e5728 100644
--- a/ui/admin/tests/acceptance/credential-library/selectors.js
+++ b/ui/admin/tests/acceptance/credential-library/selectors.js
@@ -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';
diff --git a/ui/admin/tests/acceptance/credential-library/update-test.js b/ui/admin/tests/acceptance/credential-library/update-test.js
index 4041b0528..e8e7389d1 100644
--- a/ui/admin/tests/acceptance/credential-library/update-test.js
+++ b/ui/admin/tests/acceptance/credential-library/update-test.js
@@ -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({
diff --git a/ui/admin/tests/integration/components/form/field/key-value-test.js b/ui/admin/tests/integration/components/form/field/key-value-test.js
new file mode 100644
index 000000000..c9308775e
--- /dev/null
+++ b/ui/admin/tests/integration/components/form/field/key-value-test.js
@@ -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`
+
+ <:row as |R|>
+
+
+
+
+
+
+ {{#if R.canDelete}}
+
+ {{/if}}
+
+ <:footer as |F|>
+
+
+
+ `);
+ 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`
+
+ <:row as |R|>
+
+
+
+
+
+
+ {{#if R.canDelete}}
+
+ {{/if}}
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ 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`
+
+ <:row as |R|>
+
+
+
+
+
+
+ {{#if R.canDelete}}
+
+ {{/if}}
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ 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`
+
+ <:row as |R|>
+
+
+
+
+
+
+ {{#if R.canDelete}}
+
+ {{/if}}
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ 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`
+
+ <:row as |R|>
+
+
+
+
+
+
+
+
+
+
+
+ {{#if R.canDelete}}
+
+ {{/if}}
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ 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`
+
+ <:row as |R|>
+
+
+
+ {{#if R.canDelete}}
+
+ {{/if}}
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ assert.dom('[data-test-key-input]').hasValue('k');
+
+ // Test single field
+ this.set('data', [{ key: '' }]);
+ await render(hbs`
+
+ <:row as |R|>
+
+
+
+ {{#if R.canDelete}}
+
+ {{/if}}
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ 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`
+
+ <:row as |R|>
+
+
+
+
+
+
+ {{#if R.canDelete}}
+
+ {{/if}}
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ 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`
+
+ <:row as |R|>
+
+
+
+
+
+
+ {{#if R.canDelete}}
+
+ {{/if}}
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ 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`
+
+ <:row as |R|>
+
+
+
+
+
+
+ {{#if R.canDelete}}
+
+ {{/if}}
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ 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`
+
+ <:row as |R|>
+
+
+
+
+
+
+ {{#if R.canDelete}}
+
+ {{/if}}
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ assert.strictEqual(getDeleteButtons().length, 3);
+ });
+});