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(); + }); +});