diff --git a/addons/rose/addon/styles/hds/overrides.scss b/addons/rose/addon/styles/hds/overrides.scss
index f21fb9c7ce..535de8dde5 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 7f61daa811..3cc8b2f8e7 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,51 @@
{{! Account Claim Maps }}
-
- <:fieldset as |F|>
-
- {{t 'form.account_claim_maps.label'}}
-
-
- {{t 'form.account_claim_maps.help'}}
-
-
- {{#if @model.errors.account_claim_maps}}
-
- {{#each @model.errors.account_claim_maps as |error|}}
- {{error.message}}
+ <:row as |R|>
+
+ {{t 'form.from_claim.label'}}
+
+
+
+
+ {{t 'form.to_claim.label'}}
+
+
+ {{#each this.toClaims as |claim|}}
+
{{/each}}
-
+
+
+
+ {{#if (R.hasData)}}
+
{{/if}}
-
- <:field as |F|>
-
- <:key as |K|>
-
-
- <:value as |V|>
-
-
-
-
-
+
+
+
{{! Certificates }}
-
- <: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}}
-
- {{/if}}
-
+
+ {{t 'form.value.label'}}
+
+
- <:field as |F|>
-
- <:key as |K|>
-
-
- <:value as |K|>
-
-
-
-
+ {{#if (R.hasData)}}
+
+ {{/if}}
+
-
+
+ <:header as |H|>
+ {{@legend}}
+ {{#if @helperText}}
+ {{@helperText}}
+ {{/if}}
+
+
+ <:row as |R|>
+ {{yield
+ (hash
+ Field=R.Field
+ DeleteRowButton=R.DeleteRowButton
+ rowData=R.rowData
+ hasData=(fn this.hasData R.rowData)
+ removeRow=(fn this.removeRow R.rowData)
+ updateAndNotify=(fn this.updateAndNotify R.rowData)
+ )
+ 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 0000000000..0887a8b7c2
--- /dev/null
+++ b/ui/admin/app/components/form/field/key-value/index.js
@@ -0,0 +1,73 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+
+export default class FormFieldKeyValueComponent extends Component {
+ // =properties
+
+ @tracked data = this.args.data?.length ? [...this.args.data] : [{}];
+
+ // =actions
+
+ @action
+ addNewRow() {
+ this.data = [...this.data, {}];
+ this.notifyChange();
+ }
+
+ @action
+ removeRow(rowData) {
+ this.data = this.data.filter((item) => item !== rowData);
+
+ // Ensure at least one row exists
+ if (this.data.length === 0) {
+ this.data = [{}];
+ }
+ this.notifyChange();
+ }
+
+ /**
+ * Action to update row data from input events and notify consumer
+ * @param {Object} rowData - The row object to update
+ * @param {string} property - The property name to update
+ * @param {string} value - The new value
+ */
+ @action
+ updateAndNotify(rowData, property, { target: { value } }) {
+ rowData[property] = value;
+ this.data = [...this.data];
+ this.notifyChange();
+ }
+
+ /**
+ * Checks if a row has any non-empty data
+ * @param {Object} rowData - The row object to check
+ * @returns {boolean} - True if the row has any non-empty values
+ */
+ @action
+ hasData(rowData) {
+ if (!this.data.includes(rowData)) return false;
+
+ return Object.values(rowData).some(
+ (value) => value != null && value !== '',
+ );
+ }
+
+ /**
+ * Notifies consumer of data changes with filtered results
+ */
+ notifyChange() {
+ if (!this.args.onChange) return;
+
+ // Filter out rows where the 'key' property is empty or missing
+ const filteredData = this.data.filter(
+ (item) => item.key != null && item.key !== '',
+ );
+ this.args.onChange(filteredData);
+ }
+}
diff --git a/ui/admin/tests/acceptance/auth-methods/create-test.js b/ui/admin/tests/acceptance/auth-methods/create-test.js
index ba75f63912..860222d47d 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 362ca5a403..374297a6ab 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 cae18aff50..f833397653 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 292ddeecf4..1f83182c2d 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 8b208703ed..7d581c2a59 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 1ea602eaf8..0fbd267514 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 0000000000..db287942cb
--- /dev/null
+++ b/ui/admin/tests/integration/components/form/field/key-value-test.js
@@ -0,0 +1,463 @@
+/**
+ * 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) {
+ await render(hbs`
+
+ <:row as |R|>
+
+
+
+
+
+
+
+
+ <: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]').exists();
+ });
+
+ 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|>
+
+
+
+
+
+
+
+
+ <: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');
+ });
+
+ test('it renders with legend and helper text', async function (assert) {
+ await render(hbs`
+
+ <:row as |R|>
+
+
+
+
+
+
+
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ assert.dom('[data-test-legend]').includesText('Test Legend');
+ assert.dom('[data-test-helper-text]').hasText('Test helper text');
+ });
+
+ test('it adds new rows', async function (assert) {
+ let updatedData = null;
+ this.set('onChange', (data) => {
+ updatedData = data;
+ });
+
+ await render(hbs`
+
+ <:row as |R|>
+
+
+
+
+
+
+
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ assert.strictEqual(getKeyInputs().length, 1);
+
+ await click('[data-test-add-button]');
+
+ assert.strictEqual(getKeyInputs().length, 2);
+ assert.strictEqual(getValueInputs().length, 2);
+
+ await fillIn(getKeyInputs()[1], 'test-key');
+
+ assert.strictEqual(updatedData.length, 1);
+ assert.strictEqual(updatedData[0].key, 'test-key');
+ });
+
+ test('it removes rows', async function (assert) {
+ let updatedData = null;
+ this.set('data', [
+ { key: 'key1', value: 'value1' },
+ { key: 'key2', value: 'value2' },
+ ]);
+ this.set('onChange', (data) => {
+ updatedData = data;
+ });
+
+ await render(hbs`
+
+ <:row as |R|>
+
+
+
+
+
+
+
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ assert.strictEqual(getKeyInputs().length, 2);
+
+ await click(getDeleteButtons()[0]);
+
+ assert.strictEqual(getKeyInputs().length, 1);
+ assert.strictEqual(updatedData.length, 1);
+ assert.dom('[data-test-key-input]').hasValue('key2');
+ assert.dom('[data-test-value-input]').hasValue('value2');
+ });
+
+ test('it removes one row and replaces with new data', async function (assert) {
+ let updatedData = null;
+ this.set('data', [{ key: 'key1', value: 'value1' }]);
+ this.set('onChange', (data) => {
+ updatedData = data;
+ });
+
+ await render(hbs`
+
+ <:row as |R|>
+
+
+
+
+
+
+
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ assert.strictEqual(getKeyInputs().length, 1);
+
+ // Remove row (component ensures at least one empty row remains)
+ await click(getDeleteButtons()[0]);
+
+ // Fill in the new row
+ await fillIn('[data-test-key-input]', 'newKey');
+ await fillIn('[data-test-value-input]', 'newValue');
+
+ assert.strictEqual(getKeyInputs().length, 1);
+ assert.strictEqual(updatedData.length, 1);
+ assert.deepEqual(updatedData[0], { key: 'newKey', value: 'newValue' });
+ });
+
+ test('it handles text input fields', async function (assert) {
+ await render(hbs`
+
+ <:row as |R|>
+
+
+
+
+
+
+
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ assert.dom('[data-test-key-input]').hasTagName('input');
+ assert.dom('[data-test-key-input]').hasAttribute('type', 'text');
+ assert.dom('[data-test-key-input]').hasAttribute('name', 'test-key');
+ assert.dom('[data-test-value-input]').hasTagName('input');
+ assert.dom('[data-test-value-input]').hasAttribute('type', 'text');
+ assert.dom('[data-test-value-input]').hasAttribute('name', 'test-value');
+ });
+
+ test('it handles textarea fields', async function (assert) {
+ await render(hbs`
+
+ <:row as |R|>
+
+
+
+
+
+
+
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ assert.dom('[data-test-key-input]').hasTagName('textarea');
+ assert.dom('[data-test-key-input]').hasAttribute('name', 'test-key');
+ assert.dom('[data-test-value-input]').hasTagName('textarea');
+ assert.dom('[data-test-value-input]').hasAttribute('name', 'test-value');
+ });
+
+ test('it supports single field', async function (assert) {
+ await render(hbs`
+
+ <:row as |R|>
+
+
+
+
+
+ <: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]').exists();
+ });
+
+ test('it displays error messages in footer', async function (assert) {
+ this.set('errors', [
+ { message: 'Key is required' },
+ { message: 'Value must be unique' },
+ ]);
+
+ await render(hbs`
+
+ <:row as |R|>
+
+
+
+
+
+
+
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ assert.dom('[data-test-error-message]').exists({ count: 2 });
+ assert.dom('[data-test-error-message]').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) {
+ await render(hbs`
+
+ <:row as |R|>
+
+
+
+
+
+
+
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ assert.dom('[data-test-error-message]').doesNotExist();
+ });
+
+ test('it updates on input and notifies consumer', async function (assert) {
+ let updatedData = null;
+ this.set('onChange', (data) => {
+ updatedData = data;
+ });
+
+ await render(hbs`
+
+ <:row as |R|>
+
+
+
+
+
+
+
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ await fillIn('[data-test-key-input]', 'test-key');
+
+ assert.strictEqual(updatedData.length, 1);
+ assert.strictEqual(updatedData[0].key, 'test-key');
+ });
+
+ test('it filters out rows with empty keys', async function (assert) {
+ let updatedData = null;
+ this.set('onChange', (data) => {
+ updatedData = data;
+ });
+
+ await render(hbs`
+
+ <:row as |R|>
+
+
+
+
+
+
+
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ await fillIn('[data-test-value-input]', 'test-value');
+
+ // Empty key is filtered out, so no data is sent to parent
+ assert.strictEqual(updatedData.length, 0);
+ });
+
+ test('it updates both key and value fields', async function (assert) {
+ let updatedData = null;
+ this.set('onChange', (data) => {
+ updatedData = data;
+ });
+
+ await render(hbs`
+
+ <:row as |R|>
+
+
+
+
+
+
+
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ await fillIn('[data-test-key-input]', 'test-key');
+ await fillIn('[data-test-value-input]', 'test-value');
+
+ assert.strictEqual(updatedData.length, 1);
+ assert.strictEqual(updatedData[0].key, 'test-key');
+ assert.strictEqual(updatedData[0].value, 'test-value');
+ });
+
+ test('it hides delete button for empty rows', async function (assert) {
+ await render(hbs`
+
+ <:row as |R|>
+
+
+
+
+
+
+ {{#if (R.hasData)}}
+
+ {{/if}}
+
+ <:footer as |F|>
+
+
+
+ `);
+
+ // Initially, the row is empty, so delete button should not exist
+ assert.dom('[data-test-delete-button]').doesNotExist();
+
+ // Fill in some data
+ await fillIn('[data-test-key-input]', 'test-key');
+
+ // Now delete button should appear
+ assert.dom('[data-test-delete-button]').exists();
+
+ // Clear the data
+ await fillIn('[data-test-key-input]', '');
+
+ // Delete button should be hidden again
+ assert.dom('[data-test-delete-button]').doesNotExist();
+ });
+});