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