From 0e223ded8f1c94a5cdb9f1a74fa2f4a698bd95f0 Mon Sep 17 00:00:00 2001 From: Dharini Date: Fri, 22 May 2026 14:46:37 -0700 Subject: [PATCH] =?UTF-8?q?chore:=20=F0=9F=A4=96=20migrate=20host=20catalo?= =?UTF-8?q?g=20and=20host=20set=20list=20wrapper=20fields=20to=20key=20val?= =?UTF-8?q?ue=20(#3276)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/components/form/auth-method/index.hbs | 3 - .../app/components/form/auth-method/index.js | 44 --- .../form/auth-method/ldap/index.hbs | 10 +- .../form/auth-method/oidc/index.hbs | 25 +- .../vault-ssh-certificate/index.hbs | 5 +- .../vault-ssh-certificate/index.js | 39 --- .../components/form/field/key-value/index.hbs | 6 +- .../components/form/field/key-value/index.js | 76 +++++ .../form/host-catalog/aws/index.hbs | 64 ++--- .../form/host-catalog/azure/index.hbs | 1 + .../form/host-catalog/gcp/index.hbs | 1 + .../components/form/host-catalog/index.hbs | 1 + .../form/host-catalog/static/index.hbs | 1 + .../components/form/host-set/aws/index.hbs | 100 +++---- .../components/form/host-set/azure/index.hbs | 48 ++-- .../components/form/host-set/gcp/index.hbs | 100 +++---- .../host-catalog/host-sets/index.js | 16 ++ .../scopes/scope/host-catalogs/index.js | 44 ++- .../host-catalog/host-sets/new.js | 19 +- .../routes/scopes/scope/host-catalogs/new.js | 7 + .../host-catalogs/host-catalog/index.hbs | 1 + .../scopes/scope/host-catalogs/new.hbs | 1 + .../host-catalogs/host-sets/create-test.js | 5 - .../host-catalogs/host-sets/selectors.js | 13 +- .../host-catalogs/host-sets/update-test.js | 5 - .../components/form/field/key-value-test.js | 265 ++++++++++++------ 26 files changed, 512 insertions(+), 388 deletions(-) diff --git a/ui/admin/app/components/form/auth-method/index.hbs b/ui/admin/app/components/form/auth-method/index.hbs index 1b49fc1df..b90392ee0 100644 --- a/ui/admin/app/components/form/auth-method/index.hbs +++ b/ui/admin/app/components/form/auth-method/index.hbs @@ -11,8 +11,5 @@ @removeItemByIndex={{@removeItemByIndex}} @addStringItem={{@addStringItem}} @addAccountMapItem={{@addAccountMapItem}} - @addRow={{this.addRow}} - @removeRow={{this.removeRow}} - @updateRow={{this.updateRow}} /> {{/if}} \ No newline at end of file diff --git a/ui/admin/app/components/form/auth-method/index.js b/ui/admin/app/components/form/auth-method/index.js index d8379c637..168acb9f3 100644 --- a/ui/admin/app/components/form/auth-method/index.js +++ b/ui/admin/app/components/form/auth-method/index.js @@ -5,7 +5,6 @@ import Component from '@glimmer/component'; import { assert } from '@ember/debug'; -import { action } from '@ember/object'; import oidc from './oidc'; import ldap from './ldap'; import password from './password'; @@ -25,47 +24,4 @@ export default class FormAuthMethodIndex extends Component { ); return component; } - - /** - * Adds a new empty row to the specified field - * @param {string} field - The field name to add a row to - * @param {Array} properties - Array of property names for the empty row (e.g., ['value'] or ['key', 'value']) - */ - @action - addRow(field, properties) { - const emptyRow = Object.fromEntries(properties.map((prop) => [prop, ''])); - this.args.model[field] = [...this.args.model[field], emptyRow]; - } - - /** - * Removes a row from the specified field - * @param {string} field - The field name to remove a row from - * @param {object} rowData - The row data to remove - */ - @action - removeRow(field, rowData) { - let rows = this.args.model[field].filter((item) => item !== rowData); - // Ensure at least one empty row exists for editing - if (rows.length === 0) { - const emptyRow = Object.fromEntries( - Object.keys(rowData).map((key) => [key, '']), - ); - rows = [emptyRow]; - } - - this.args.model[field] = rows; - } - - /** - * Updates a specific property in a row - * @param {string} field - The field name containing the row - * @param {object} rowData - The row object to update - * @param {string} property - The property name to update - * @param {object} event - The DOM event containing the new value - */ - @action - updateRow(field, rowData, property, { target: { value } }) { - rowData[property] = value; - this.args.model[field] = [...this.args.model[field]]; - } } diff --git a/ui/admin/app/components/form/auth-method/ldap/index.hbs b/ui/admin/app/components/form/auth-method/ldap/index.hbs index ee144ce8b..a2ade405b 100644 --- a/ui/admin/app/components/form/auth-method/ldap/index.hbs +++ b/ui/admin/app/components/form/auth-method/ldap/index.hbs @@ -84,10 +84,9 @@ 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 index abe7708e5..ff1101f64 100644 --- a/ui/admin/app/components/form/field/key-value/index.hbs +++ b/ui/admin/app/components/form/field/key-value/index.hbs @@ -21,8 +21,8 @@ Field=R.Field DeleteRowButton=R.DeleteRowButton rowData=R.rowData - removeRow=(fn @onRemove R.rowData) - updateRow=(fn @onUpdate R.rowData) + removeRow=(fn this.handleRemove R.rowData) + updateRow=(fn this.handleUpdate R.rowData) canDelete=this.canDelete ) to='row' @@ -32,7 +32,7 @@ {{#if @errors}} diff --git a/ui/admin/app/components/form/field/key-value/index.js b/ui/admin/app/components/form/field/key-value/index.js index 6f48a0fd8..cdacf67fd 100644 --- a/ui/admin/app/components/form/field/key-value/index.js +++ b/ui/admin/app/components/form/field/key-value/index.js @@ -4,6 +4,8 @@ */ import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { assert } from '@ember/debug'; export default class FormFieldKeyValueComponent extends Component { /** @@ -25,4 +27,78 @@ export default class FormFieldKeyValueComponent extends Component { // Return false if there are no rows return false; } + + /** + * Default handler for adding a row. + * Uses @model, @name, and @properties if provided, otherwise calls @onAdd. + */ + @action + handleAdd() { + // If an onAdd handler is provided, use it + if (this.args.onAdd) { + return this.args.onAdd(); + } + + const { model, name, properties } = this.args; + + assert( + 'FormFieldKeyValueComponent: model, name, and properties are required if no custom onAdd handler is provided.', + model && name && properties, + ); + + const emptyRow = Object.fromEntries(properties.map((prop) => [prop, ''])); + model[name] = [...(model[name] ?? []), emptyRow]; + } + + /** + * Default handler for removing a row. + * Uses @model and @name if provided, otherwise calls @onRemove. + */ + @action + handleRemove(rowData) { + // If an onRemove handler is provided, use it + if (this.args.onRemove) { + return this.args.onRemove(rowData); + } + + const { model, name, properties } = this.args; + + assert( + 'FormFieldKeyValueComponent: model, name, and properties are required if no custom onRemove handler is provided.', + model && name && properties, + ); + + const rows = (model[name] ?? []).filter((item) => item !== rowData); + + // If removing the last row, add an empty row back + if (rows.length === 0) { + const emptyRow = Object.fromEntries(properties.map((prop) => [prop, ''])); + model[name] = [emptyRow]; + } else { + model[name] = rows; + } + } + + /** + * Default handler for updating a row. + * Uses @model and @name if provided, otherwise calls @onUpdate. + */ + @action + handleUpdate(rowData, property, event) { + // If an onUpdate handler is provided, use it + if (this.args.onUpdate) { + return this.args.onUpdate(rowData, property, event); + } + + const { model, name } = this.args; + + assert( + 'FormFieldKeyValueComponent: model and name are required if no custom onUpdate handler is provided.', + model && name, + ); + + const value = event?.target?.value ?? event; + rowData[property] = value; + model[name] = [...(model[name] ?? [])]; + } } diff --git a/ui/admin/app/components/form/host-catalog/aws/index.hbs b/ui/admin/app/components/form/host-catalog/aws/index.hbs index e6b73401a..ac7bc46e7 100644 --- a/ui/admin/app/components/form/host-catalog/aws/index.hbs +++ b/ui/admin/app/components/form/host-catalog/aws/index.hbs @@ -4,6 +4,7 @@ }} {{! role_tags }} - - <:fieldset as |F|> - - {{t 'resources.storage-bucket.form.role_tags.label'}} - - - {{t 'resources.storage-bucket.form.role_tags.help'}} - - - {{#if @model.errors.role_tags}} - - {{#each @model.errors.role_tags as |error|}} - - {{error.message}} - - {{/each}} - + <:row as |R|> + + + + + + + {{#if R.canDelete}} + {{/if}} - - <:field as |F|> - - <:key as |K|> - - - <:value as |V|> - - - - - + + {{else}} - - <:fieldset as |F|> - - {{t 'resources.host-set.form.preferred_endpoints.label'}} - - - {{t 'resources.host-set.form.preferred_endpoints.help'}} - - - {{#if @model.errors.preferred_endpoints}} - - {{#each @model.errors.preferred_endpoints as |error|}} - {{error.message}} - {{/each}} - - {{/if}} - - <:field as |F|> - - - - - - <:fieldset as |F|> - - {{t 'resources.host-set.form.filter.label'}} - - - {{t 'resources.host-set.form.filter.aws.help'}} - - - {{#if @model.errors.filters}} - - {{#each @model.errors.filters as |error|}} - {{error.message}} - {{/each}} - + <:row as |R|> + + + + {{#if R.canDelete}} + {{/if}} - - <:field as |F|> - - - + + + + + <:row as |R|> + + + + {{#if R.canDelete}} + + {{/if}} + + - - <:fieldset as |F|> - - {{t 'resources.host-set.form.preferred_endpoints.label'}} - - - {{t 'resources.host-set.form.preferred_endpoints.help'}} - - - {{#if @model.errors.preferred_endpoints}} - - {{#each @model.errors.preferred_endpoints as |error|}} - {{error.message}} - {{/each}} - + <:row as |R|> + + + + {{#if R.canDelete}} + {{/if}} - - - <:field as |F|> - - - + + - - <:fieldset as |F|> - - {{t 'resources.host-set.form.preferred_endpoints.label'}} - - - {{t 'resources.host-set.form.preferred_endpoints.help'}} - - - {{#if @model.errors.preferred_endpoints}} - - {{#each @model.errors.preferred_endpoints as |error|}} - {{error.message}} - {{/each}} - - {{/if}} - - <:field as |F|> - - - - - - <:fieldset as |F|> - - {{t 'resources.host-set.form.filter.label'}} - - - {{t 'resources.host-set.form.filter.gcp.help'}} - - - {{#if @model.errors.filters}} - - {{#each @model.errors.filters as |error|}} - {{error.message}} - {{/each}} - + <:row as |R|> + + + + {{#if R.canDelete}} + {{/if}} - - <:field as |F|> - - - + + + + + <:row as |R|> + + + + {{#if R.canDelete}} + + {{/if}} + + + item.value?.trim(), + ); + } + + if (hostSet.filters) { + hostSet.filters = hostSet.filters.filter((item) => item.value?.trim()); + } + // Fetch newest host set as updates to host set attributes cause an async db update which // updates the version again and can cause a version mismatch if the host set is updated // again and we haven't fetched the newest version. @@ -138,9 +148,15 @@ export default class ScopesScopeHostCatalogsHostCatalogHostSetsIndexController e hostSet.preferred_endpoints = structuredClone( hostSet.preferred_endpoints, ); + if (hostSet.preferred_endpoints.length === 0) { + hostSet.preferred_endpoints = [{ value: '' }]; + } } if (hostSet.filters) { hostSet.filters = structuredClone(hostSet.filters); + if (hostSet.filters.length === 0) { + hostSet.filters = [{ value: '' }]; + } } } diff --git a/ui/admin/app/controllers/scopes/scope/host-catalogs/index.js b/ui/admin/app/controllers/scopes/scope/host-catalogs/index.js index f871e4ae3..6b3887a25 100644 --- a/ui/admin/app/controllers/scopes/scope/host-catalogs/index.js +++ b/ui/admin/app/controllers/scopes/scope/host-catalogs/index.js @@ -10,7 +10,11 @@ import { action } from '@ember/object'; import { loading } from 'ember-loading'; import { confirm } from 'core/decorators/confirm'; import { notifySuccess, notifyError } from 'core/decorators/notify'; -import { TYPE_CREDENTIAL_STATIC } from 'api/models/host-catalog'; +import { + TYPE_CREDENTIAL_DYNAMIC, + TYPE_CREDENTIAL_STATIC, + TYPE_HOST_CATALOG_PLUGIN_AWS, +} from 'api/models/host-catalog'; export default class ScopesScopeHostCatalogsIndexController extends Controller { // =services @@ -60,6 +64,19 @@ export default class ScopesScopeHostCatalogsIndexController extends Controller { return this.intl.t(description, { resource }); } + /** + * Generates one empty role_tags row for AWS host catalogs when the field is empty. + * @param {HostCatalogModel} hostCatalog + */ + generateAwsRoleTagsRowIfEmpty(hostCatalog) { + if ( + hostCatalog.compositeType === TYPE_HOST_CATALOG_PLUGIN_AWS && + (!hostCatalog.role_tags || hostCatalog.role_tags.length === 0) + ) { + hostCatalog.role_tags = [{ key: '', value: '' }]; + } + } + // =actions /** @@ -96,6 +113,12 @@ export default class ScopesScopeHostCatalogsIndexController extends Controller { isNew ? 'notifications.create-success' : 'notifications.save-success', ) async save(hostCatalog) { + if (hostCatalog.role_tags) { + hostCatalog.role_tags = hostCatalog.role_tags.filter((item) => + item.key?.trim(), + ); + } + // If the role_arn is empty, then the credential type should be static if (!hostCatalog.role_arn) { hostCatalog.credentialType = TYPE_CREDENTIAL_STATIC; @@ -145,6 +168,25 @@ export default class ScopesScopeHostCatalogsIndexController extends Controller { @action changeCredentialType(hostCatalog, credentialType) { hostCatalog.credentialType = credentialType; + + if (credentialType === TYPE_CREDENTIAL_DYNAMIC) { + this.generateAwsRoleTagsRowIfEmpty(hostCatalog); + } + } + + /** + * Prepares key-value array fields for editing. + * @param {HostCatalogModel} hostCatalog + */ + @action + edit(hostCatalog) { + if (hostCatalog.role_tags) { + hostCatalog.role_tags = structuredClone(hostCatalog.role_tags); + } + + if (hostCatalog.credentialType === TYPE_CREDENTIAL_DYNAMIC) { + this.generateAwsRoleTagsRowIfEmpty(hostCatalog); + } } /** diff --git a/ui/admin/app/routes/scopes/scope/host-catalogs/host-catalog/host-sets/new.js b/ui/admin/app/routes/scopes/scope/host-catalogs/host-catalog/host-sets/new.js index 82496f5c2..6611768c2 100644 --- a/ui/admin/app/routes/scopes/scope/host-catalogs/host-catalog/host-sets/new.js +++ b/ui/admin/app/routes/scopes/scope/host-catalogs/host-catalog/host-sets/new.js @@ -5,6 +5,11 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; +import { + TYPE_HOST_CATALOG_PLUGIN_AWS, + TYPE_HOST_CATALOG_PLUGIN_AZURE, + TYPE_HOST_CATALOG_PLUGIN_GCP, +} from 'api/models/host-catalog'; export default class ScopesScopeHostCatalogsHostCatalogHostSetsNewRoute extends Route { // =services @@ -41,9 +46,21 @@ export default class ScopesScopeHostCatalogsHostCatalogHostSetsNewRoute extends const { id: host_catalog_id, compositeType } = this.modelFor( 'scopes.scope.host-catalogs.host-catalog', ); - return this.store.createRecord('host-set', { + const record = this.store.createRecord('host-set', { compositeType, host_catalog_id, }); + + if ( + compositeType === TYPE_HOST_CATALOG_PLUGIN_AWS || + compositeType === TYPE_HOST_CATALOG_PLUGIN_GCP + ) { + record.preferred_endpoints = [{ value: '' }]; + record.filters = [{ value: '' }]; + } else if (compositeType === TYPE_HOST_CATALOG_PLUGIN_AZURE) { + record.preferred_endpoints = [{ value: '' }]; + } + + return record; } } diff --git a/ui/admin/app/routes/scopes/scope/host-catalogs/new.js b/ui/admin/app/routes/scopes/scope/host-catalogs/new.js index b3f92b311..64d7f37de 100644 --- a/ui/admin/app/routes/scopes/scope/host-catalogs/new.js +++ b/ui/admin/app/routes/scopes/scope/host-catalogs/new.js @@ -5,6 +5,7 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; +import { TYPE_HOST_CATALOG_PLUGIN_AWS } from 'api/models/host-catalog'; export default class ScopesScopeHostCatalogsNewRoute extends Route { // =services @@ -59,6 +60,12 @@ export default class ScopesScopeHostCatalogsNewRoute extends Route { description, disable_credential_rotation, }); + + if (compositeType === TYPE_HOST_CATALOG_PLUGIN_AWS) { + // Ensure one editable role_tags row exists for dynamic credentials. + record.role_tags = [{ key: '', value: '' }]; + } + record.scopeModel = scopeModel; return record; } diff --git a/ui/admin/app/templates/scopes/scope/host-catalogs/host-catalog/index.hbs b/ui/admin/app/templates/scopes/scope/host-catalogs/host-catalog/index.hbs index c5604b8aa..6686edb2c 100644 --- a/ui/admin/app/templates/scopes/scope/host-catalogs/host-catalog/index.hbs +++ b/ui/admin/app/templates/scopes/scope/host-catalogs/host-catalog/index.hbs @@ -27,6 +27,7 @@ findAll('[data-test-value-input]'); const getDeleteButtons = () => findAll('[data-test-delete-button]'); - test('it renders with default empty row', async function (assert) { + test('it renders with default and provided data', async function (assert) { + // Default empty row this.set('data', [{ key: '', value: '' }]); - await render(hbs` <:row as |R|> @@ -43,14 +43,12 @@ module('Integration | Component | form/field/key-value', function (hooks) { 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) { + // Provided data this.set('data', [ { key: 'env', value: 'production' }, { key: 'region', value: 'us-east-1' }, ]); - await render(hbs` `); - 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'); @@ -87,7 +82,7 @@ module('Integration | Component | form/field/key-value', function (hooks) { assert.dom('.hds-form-helper-text ').hasText('Test helper text'); }); - test('it adds new rows', async function (assert) { + test('it adds new rows using custom handlers', async function (assert) { this.set('data', [{ key: '', value: '' }]); this.set('onAdd', () => { this.data.push({ key: '', value: '' }); @@ -107,7 +102,7 @@ module('Integration | Component | form/field/key-value', function (hooks) { {{#if R.canDelete}} - + {{/if}} <:footer as |F|> @@ -117,89 +112,22 @@ module('Integration | Component | form/field/key-value', function (hooks) { `); 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 }]); @@ -282,6 +210,7 @@ module('Integration | Component | form/field/key-value', function (hooks) { { message: 'Value must be unique' }, ]); + // With errors await render(hbs` <:row as |R|> @@ -300,18 +229,15 @@ module('Integration | Component | form/field/key-value', function (hooks) { `); - 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: '' }]); + // Without errors + this.set('errors', undefined); await render(hbs` - + <:row as |R|> @@ -328,11 +254,10 @@ module('Integration | Component | form/field/key-value', function (hooks) { `); - assert.dom('[data-test-error-message]').doesNotExist(); }); - test('it updates both key and value fields', async function (assert) { + test('it updates both key and value fields using custom handlers', async function (assert) { this.set('data', [{ key: '', value: '' }]); this.set('onUpdate', (rowData, property, { target: { value } }) => { rowData[property] = value; @@ -393,4 +318,174 @@ module('Integration | Component | form/field/key-value', function (hooks) { assert.strictEqual(getDeleteButtons().length, 3); }); + + test('custom handlers take precedence over default handlers', async function (assert) { + const model = { + tags: [{ key: '', value: '' }], + }; + this.set('model', model); + + this.set('onAdd', () => { + model.tags.push({ key: 'custom', value: 'custom' }); + this.set('model', { ...model, tags: [...model.tags] }); + }); + + this.set('onRemove', (rowData) => { + model.tags = model.tags.filter((item) => item !== rowData); + this.set('model', { ...model, tags: [...model.tags] }); + }); + + await render(hbs` + + <:row as |R|> + + + + + + + {{#if R.canDelete}} + + {{/if}} + + + `); + + // Test custom add handler takes precedence + await click('[data-test-add-button]'); + + assert.strictEqual(model.tags.length, 2); + assert.strictEqual(model.tags[1].key, 'custom'); + assert.strictEqual(model.tags[1].value, 'custom'); + + // Test custom update handler takes precedence + await fillIn(getKeyInputs()[0], 'test'); + + // Test custom remove handler takes precedence + await click(getDeleteButtons()[0]); + + assert.strictEqual(model.tags.length, 1); + assert.strictEqual(model.tags[0].key, 'custom'); + }); + + test('it adds row using default handler (model/name/properties)', async function (assert) { + const model = { tags: [{ key: 'a', value: 'b' }] }; + this.set('model', model); + this.set('properties', ['key', 'value']); + + await render(hbs` + + <:row as |R|> + + + + + + + {{#if R.canDelete}} + + {{/if}} + + <:footer as |F|> + + + + `); + + assert.strictEqual(model.tags.length, 1); + + await click('[data-test-add-button]'); + + assert.strictEqual(model.tags.length, 2); + + assert.deepEqual(model.tags[1], { key: '', value: '' }); + }); + + test('it removes row using default handler (model/name)', async function (assert) { + const model = { + tags: [ + { key: 'a', value: 'b' }, + { key: 'c', value: 'd' }, + ], + }; + this.set('model', model); + this.set('properties', ['key', 'value']); + + await render(hbs` + + <:row as |R|> + + + + + + + {{#if R.canDelete}} + + {{/if}} + + <:footer as |F|> + + + + `); + + assert.strictEqual(model.tags.length, 2); + + await click(getDeleteButtons()[0]); + + assert.strictEqual(model.tags.length, 1); + + assert.deepEqual(model.tags[0], { key: 'c', value: 'd' }); + }); + + test('it updates row using default handler (model/name)', async function (assert) { + const model = { tags: [{ key: '', value: '' }] }; + this.set('model', model); + this.set('properties', ['key', 'value']); + + await render(hbs` + + <:row as |R|> + + + + + + + {{#if R.canDelete}} + + {{/if}} + + <:footer as |F|> + + + + `); + + await fillIn('[data-test-key-input]', 'updated-key'); + await fillIn('[data-test-value-input]', 'updated-value'); + + assert.strictEqual(model.tags[0].key, 'updated-key'); + assert.strictEqual(model.tags[0].value, 'updated-value'); + }); });