chore: 🤖 migrate host catalog and host set list wrapper fields to key value (#3276)

This commit is contained in:
Dharini committed 2026-06-03 16:36:29 -07:00
1 parent 4e2b779d96
commit 0e223ded8f
26 files changed
+512 -388

No files matched your search

@@ -11,8 +11,5 @@
@removeItemByIndex={{@removeItemByIndex}}
@addStringItem={{@addStringItem}}
@addAccountMapItem={{@addAccountMapItem}}
@addRow={{this.addRow}}
@removeRow={{this.removeRow}}
@updateRow={{this.updateRow}}
/>
{{/if}}
@@ -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<string>} 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]];
}
}
@@ -84,10 +84,9 @@
<Form::Field::KeyValue
@name='certificates'
@model={{@model}}
@properties={{array 'value'}}
@data={{@model.certificates}}
@onAdd={{fn @addRow 'certificates' (array 'value')}}
@onRemove={{fn @removeRow 'certificates'}}
@onUpdate={{fn @updateRow 'certificates'}}
@legend={{t 'resources.auth-method.form.certificates.label'}}
@helperText={{t 'resources.auth-method.form.certificates.help'}}
@errors={{@model.errors.certificates}}
@@ -402,10 +401,9 @@
<Form::field::KeyValue
@name='account_attribute_maps'
@model={{@model}}
@properties={{array 'key' 'value'}}
@data={{@model.account_attribute_maps}}
@onAdd={{fn @addRow 'account_attribute_maps' (array 'key' 'value')}}
@onRemove={{fn @removeRow 'account_attribute_maps'}}
@onUpdate={{fn @updateRow 'account_attribute_maps'}}
@legend={{t 'resources.auth-method.form.account_attribute_maps.label'}}
@helperText={{t 'resources.auth-method.form.account_attribute_maps.help'}}
@errors={{@model.errors.account_attribute_maps}}
@@ -149,10 +149,9 @@
{{! Signing algorithms }}
<Form::Field::KeyValue
@name='signing_algorithms'
@model={{@model}}
@properties={{array 'value'}}
@data={{@model.signing_algorithms}}
@onAdd={{fn @addRow 'signing_algorithms' (array 'value')}}
@onRemove={{fn @removeRow 'signing_algorithms'}}
@onUpdate={{fn @updateRow 'signing_algorithms'}}
@legend={{t 'form.signing_algorithms.label'}}
@helperText={{t 'form.signing_algorithms.help'}}
@errors={{@model.errors.signing_algorithms}}
@@ -187,10 +186,9 @@
{{! Allowed audiences }}
<Form::Field::KeyValue
@name='allowed_audiences'
@model={{@model}}
@properties={{array 'value'}}
@data={{@model.allowed_audiences}}
@onAdd={{fn @addRow 'allowed_audiences' (array 'value')}}
@onRemove={{fn @removeRow 'allowed_audiences'}}
@onUpdate={{fn @updateRow 'allowed_audiences'}}
@legend={{t 'form.allowed_audiences.label'}}
@helperText={{t 'form.allowed_audiences.help'}}
@errors={{@model.errors.allowed_audiences}}
@@ -214,10 +212,9 @@
{{! Claims Scopes }}
<Form::Field::KeyValue
@name='claims_scopes'
@model={{@model}}
@properties={{array 'value'}}
@data={{@model.claims_scopes}}
@onAdd={{fn @addRow 'claims_scopes' (array 'value')}}
@onRemove={{fn @removeRow 'claims_scopes'}}
@onUpdate={{fn @updateRow 'claims_scopes'}}
@legend={{t 'form.claims_scopes.label'}}
@helperText={{t 'form.claims_scopes.help'}}
@errors={{@model.errors.claims_scopes}}
@@ -241,10 +238,9 @@
{{! Account Claim Maps }}
<Form::Field::KeyValue
@name='account_claim_maps'
@model={{@model}}
@properties={{array 'key' 'value'}}
@data={{@model.account_claim_maps}}
@onAdd={{fn @addRow 'account_claim_maps' (array 'key' 'value')}}
@onRemove={{fn @removeRow 'account_claim_maps'}}
@onUpdate={{fn @updateRow 'account_claim_maps'}}
@legend={{t 'form.account_claim_maps.label'}}
@helperText={{t 'form.account_claim_maps.help'}}
@errors={{@model.errors.account_claim_maps}}
@@ -287,10 +283,9 @@
{{! Certificates }}
<Form::Field::KeyValue
@name='idp_ca_certs'
@model={{@model}}
@properties={{array 'value'}}
@data={{@model.idp_ca_certs}}
@onAdd={{fn @addRow 'idp_ca_certs' (array 'value')}}
@onRemove={{fn @removeRow 'idp_ca_certs'}}
@onUpdate={{fn @updateRow 'idp_ca_certs'}}
@legend={{t 'form.idp_ca_certs.label'}}
@helperText={{t 'form.idp_ca_certs.help'}}
@errors={{@model.errors.idp_ca_certs}}
@@ -227,10 +227,9 @@
<Form::Field::KeyValue
@name='critical_options'
@model={{@model}}
@properties={{array 'key' 'value'}}
@data={{@model.critical_options}}
@onAdd={{this.addCriticalOption}}
@onRemove={{this.removeCriticalOption}}
@onUpdate={{this.updateCriticalOptions}}
@legend={{t 'resources.credential-library.form.critical_options.label'}}
@helperText={{t 'resources.credential-library.form.critical_options.help'}}
@errors={{@model.errors.critical_options}}
@@ -4,8 +4,6 @@
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { options } from 'api/models/credential-library';
export default class FormCredentialLibraryVaultSshCertComponent extends Component {
@@ -24,41 +22,4 @@ export default class FormCredentialLibraryVaultSshCertComponent extends Componen
const keyType = this.args.model.key_type;
return keyType === 'rsa' || keyType === 'ecdsa';
}
/**
* Adds a new empty row to critical options
*/
@action
addCriticalOption() {
this.args.model.critical_options = [
...this.args.model.critical_options,
{ key: '', value: '' },
];
}
/**
* Updates critical options data
*/
@action
updateCriticalOptions(rowData, property, { target: { value } }) {
rowData[property] = value;
this.args.model.critical_options = [...this.args.model.critical_options];
}
/**
* Removes a row from critical options
* @param {Object} rowData - The row to remove
*/
@action
removeCriticalOption(rowData) {
let rows = this.args.model.critical_options.filter(
(item) => item !== rowData,
);
// Ensure at least one empty row exists for editing
if (rows.length === 0) {
rows = [{ key: '', value: '' }];
}
this.args.model.critical_options = rows;
}
}
@@ -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 @@
<F.AddRowButton
@text={{t 'actions.add'}}
data-test-add-button
@onClick={{@onAdd}}
@onClick={{this.handleAdd}}
/>
{{#if @errors}}
<F.Error as |E|>
@@ -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] ?? [])];
}
}
@@ -4,6 +4,7 @@
}}
<Rose::Form
@edit={{@edit}}
@onSubmit={{@submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@@ -271,44 +272,37 @@
</Hds::Form::TextInput::Field>
{{! role_tags }}
<Form::Field::ListWrapper
@layout='horizontal'
@isOptional={{true}}
<Form::Field::KeyValue
@name='role_tags'
@model={{@model}}
@properties={{array 'key' 'value'}}
@data={{@model.role_tags}}
@legend={{t 'resources.storage-bucket.form.role_tags.label'}}
@helperText={{t 'resources.storage-bucket.form.role_tags.help'}}
@errors={{@model.errors.role_tags}}
@disabled={{form.disabled}}
@isOptional={{true}}
>
<:fieldset as |F|>
<F.Legend>
{{t 'resources.storage-bucket.form.role_tags.label'}}
</F.Legend>
<F.HelperText>
{{t 'resources.storage-bucket.form.role_tags.help'}}
</F.HelperText>
{{#if @model.errors.role_tags}}
<F.Error as |E|>
{{#each @model.errors.role_tags as |error|}}
<E.Message>
{{error.message}}
</E.Message>
{{/each}}
</F.Error>
<:row as |R|>
<R.Field as |F|>
<F.TextInput
data-test-key-input
@value={{R.rowData.key}}
{{on 'input' (fn R.updateRow 'key')}}
/>
</R.Field>
<R.Field as |F|>
<F.TextInput
data-test-value-input
@value={{R.rowData.value}}
{{on 'input' (fn R.updateRow 'value')}}
/>
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:fieldset>
<:field as |F|>
<F.KeyValue
@name='role_tags'
@options={{@model.role_tags}}
@model={{@model}}
>
<:key as |K|>
<K.text />
</:key>
<:value as |V|>
<V.text />
</:value>
</F.KeyValue>
</:field>
</Form::Field::ListWrapper>
</:row>
</Form::Field::KeyValue>
{{else}}
<Hds::Form::TextInput::Field
@@ -4,6 +4,7 @@
}}
<Rose::Form
@edit={{@edit}}
@onSubmit={{@submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@@ -4,6 +4,7 @@
}}
<Rose::Form
@edit={{@edit}}
@onSubmit={{@submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@@ -5,6 +5,7 @@
<this.hostCatalogFormComponent
@model={{@model}}
@edit={{@edit}}
@submit={{@submit}}
@cancel={{@cancel}}
@changeType={{@changeType}}
@@ -4,6 +4,7 @@
}}
<Rose::Form
@edit={{@edit}}
@onSubmit={{@submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@@ -62,65 +62,55 @@
</F.HelperText>
</InfoField>
<Form::Field::ListWrapper
@layout='horizontal'
@isOptional={{true}}
<Form::Field::KeyValue
@name='preferred_endpoints'
@model={{@model}}
@properties={{array 'value'}}
@data={{@model.preferred_endpoints}}
@legend={{t 'resources.host-set.form.preferred_endpoints.label'}}
@helperText={{t 'resources.host-set.form.preferred_endpoints.help'}}
@errors={{@model.errors.preferred_endpoints}}
@disabled={{form.disabled}}
>
<:fieldset as |F|>
<F.Legend>
{{t 'resources.host-set.form.preferred_endpoints.label'}}
</F.Legend>
<F.HelperText>
{{t 'resources.host-set.form.preferred_endpoints.help'}}
</F.HelperText>
{{#if @model.errors.preferred_endpoints}}
<F.Error as |E|>
{{#each @model.errors.preferred_endpoints as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</:fieldset>
<:field as |F|>
<F.TextInput
@name='preferred_endpoints'
@options={{@model.preferred_endpoints}}
@model={{@model}}
/>
</:field>
</Form::Field::ListWrapper>
<Form::Field::ListWrapper
@layout='horizontal'
@isOptional={{true}}
@disabled={{form.disabled}}
>
<:fieldset as |F|>
<F.Legend>
{{t 'resources.host-set.form.filter.label'}}
</F.Legend>
<F.HelperText>
{{t 'resources.host-set.form.filter.aws.help'}}
</F.HelperText>
{{#if @model.errors.filters}}
<F.Error as |E|>
{{#each @model.errors.filters as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
<:row as |R|>
<R.Field as |F|>
<F.TextInput
data-test-value-input
@value={{R.rowData.value}}
{{on 'input' (fn R.updateRow 'value')}}
/>
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:fieldset>
<:field as |F|>
<F.TextInput
@name='filters'
@options={{@model.filters}}
@model={{@model}}
/>
</:field>
</Form::Field::ListWrapper>
</:row>
</Form::Field::KeyValue>
<Form::Field::KeyValue
@name='filters'
@model={{@model}}
@properties={{array 'value'}}
@data={{@model.filters}}
@legend={{t 'resources.host-set.form.filter.label'}}
@helperText={{t 'resources.host-set.form.filter.aws.help'}}
@errors={{@model.errors.filters}}
@disabled={{form.disabled}}
@isOptional={{true}}
>
<:row as |R|>
<R.Field as |F|>
<F.TextInput
data-test-value-input
@value={{R.rowData.value}}
{{on 'input' (fn R.updateRow 'value')}}
/>
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
</Form::Field::KeyValue>
<Hds::Form::TextInput::Field
name='sync_interval_seconds'
@@ -62,36 +62,30 @@
</F.HelperText>
</InfoField>
<Form::Field::ListWrapper
@layout='horizontal'
@isOptional={{true}}
<Form::Field::KeyValue
@name='preferred_endpoints'
@model={{@model}}
@properties={{array 'value'}}
@data={{@model.preferred_endpoints}}
@legend={{t 'resources.host-set.form.preferred_endpoints.label'}}
@helperText={{t 'resources.host-set.form.preferred_endpoints.help'}}
@errors={{@model.errors.preferred_endpoints}}
@disabled={{form.disabled}}
@isOptional={{true}}
>
<:fieldset as |F|>
<F.Legend>
{{t 'resources.host-set.form.preferred_endpoints.label'}}
</F.Legend>
<F.HelperText>
{{t 'resources.host-set.form.preferred_endpoints.help'}}
</F.HelperText>
{{#if @model.errors.preferred_endpoints}}
<F.Error as |E|>
{{#each @model.errors.preferred_endpoints as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
<:row as |R|>
<R.Field as |F|>
<F.TextInput
data-test-value-input
@value={{R.rowData.value}}
{{on 'input' (fn R.updateRow 'value')}}
/>
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:fieldset>
<:field as |F|>
<F.TextInput
@name='preferred_endpoints'
@options={{@model.preferred_endpoints}}
@model={{@model}}
/>
</:field>
</Form::Field::ListWrapper>
</:row>
</Form::Field::KeyValue>
<Hds::Form::Textarea::Field
name='filter'
@@ -61,65 +61,55 @@
</F.HelperText>
</InfoField>
<Form::Field::ListWrapper
@layout='horizontal'
@isOptional={{true}}
<Form::Field::KeyValue
@name='preferred_endpoints'
@model={{@model}}
@properties={{array 'value'}}
@data={{@model.preferred_endpoints}}
@legend={{t 'resources.host-set.form.preferred_endpoints.label'}}
@helperText={{t 'resources.host-set.form.preferred_endpoints.help'}}
@errors={{@model.errors.preferred_endpoints}}
@disabled={{form.disabled}}
>
<:fieldset as |F|>
<F.Legend>
{{t 'resources.host-set.form.preferred_endpoints.label'}}
</F.Legend>
<F.HelperText>
{{t 'resources.host-set.form.preferred_endpoints.help'}}
</F.HelperText>
{{#if @model.errors.preferred_endpoints}}
<F.Error as |E|>
{{#each @model.errors.preferred_endpoints as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</:fieldset>
<:field as |F|>
<F.TextInput
@name='preferred_endpoints'
@options={{@model.preferred_endpoints}}
@model={{@model}}
/>
</:field>
</Form::Field::ListWrapper>
<Form::Field::ListWrapper
@layout='horizontal'
@isOptional={{true}}
@disabled={{form.disabled}}
>
<:fieldset as |F|>
<F.Legend>
{{t 'resources.host-set.form.filter.label'}}
</F.Legend>
<F.HelperText>
{{t 'resources.host-set.form.filter.gcp.help'}}
</F.HelperText>
{{#if @model.errors.filters}}
<F.Error as |E|>
{{#each @model.errors.filters as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
<:row as |R|>
<R.Field as |F|>
<F.TextInput
data-test-value-input
@value={{R.rowData.value}}
{{on 'input' (fn R.updateRow 'value')}}
/>
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:fieldset>
<:field as |F|>
<F.TextInput
@name='filters'
@options={{@model.filters}}
@model={{@model}}
/>
</:field>
</Form::Field::ListWrapper>
</:row>
</Form::Field::KeyValue>
<Form::Field::KeyValue
@name='filters'
@model={{@model}}
@properties={{array 'value'}}
@data={{@model.filters}}
@legend={{t 'resources.host-set.form.filter.label'}}
@helperText={{t 'resources.host-set.form.filter.gcp.help'}}
@errors={{@model.errors.filters}}
@disabled={{form.disabled}}
@isOptional={{true}}
>
<:row as |R|>
<R.Field as |F|>
<F.TextInput
data-test-value-input
@value={{R.rowData.value}}
{{on 'input' (fn R.updateRow 'value')}}
/>
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
</Form::Field::KeyValue>
<Hds::Form::TextInput::Field
name='sync_interval_seconds'
@@ -76,6 +76,16 @@ export default class ScopesScopeHostCatalogsHostCatalogHostSetsIndexController e
isNew ? 'notifications.create-success' : 'notifications.save-success',
)
async save(hostSet) {
if (hostSet.preferred_endpoints) {
hostSet.preferred_endpoints = hostSet.preferred_endpoints.filter((item) =>
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: '' }];
}
}
}
@@ -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);
}
}
/**
@@ -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;
}
}
@@ -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;
}
@@ -27,6 +27,7 @@
<page.body>
<Form::HostCatalog
@model={{@model}}
@edit={{fn this.hostCatalogs.edit @model}}
@submit={{fn this.hostCatalogs.save @model}}
@cancel={{fn this.hostCatalogs.cancel @model}}
@changeCredentialType={{fn this.hostCatalogs.changeCredentialType @model}}
@@ -29,6 +29,7 @@
<page.body>
<Form::HostCatalog
@model={{@model}}
@edit={{fn this.hostCatalogs.edit @model}}
@submit={{fn this.hostCatalogs.save @model}}
@cancel={{fn this.hostCatalogs.cancel @model}}
@changeType={{this.hostCatalogs.changeType}}
@@ -97,9 +97,7 @@ module('Acceptance | host-catalogs | host sets | create', function (hooks) {
const name = 'aws host set';
await fillIn(commonSelectors.FIELD_NAME, name);
await fillIn(selectors.FIELD_PREFERRED_ENDPOINT, 'endpoint');
await click(selectors.FIELD_PREFERRED_ENDPOINT_ADD_BTN);
await fillIn(selectors.FIELD_FILTERS, 'filter_test');
await click(selectors.FIELD_FILTERS_ADD_BTN);
await fillIn(selectors.FIELD_SYNC_INTERVAL, 10);
await click(commonSelectors.SAVE_BTN);
@@ -128,7 +126,6 @@ module('Acceptance | host-catalogs | host sets | create', function (hooks) {
const name = 'azure host set';
await fillIn(commonSelectors.FIELD_NAME, name);
await fillIn(selectors.FIELD_PREFERRED_ENDPOINT, 'endpoint');
await click(selectors.FIELD_PREFERRED_ENDPOINT_ADD_BTN);
await fillIn(selectors.FIELD_AZURE_FILTER, 'filter');
await fillIn(selectors.FIELD_SYNC_INTERVAL, 10);
await click(commonSelectors.SAVE_BTN);
@@ -157,9 +154,7 @@ module('Acceptance | host-catalogs | host sets | create', function (hooks) {
const name = 'gcp host set';
await fillIn(commonSelectors.FIELD_NAME, name);
await fillIn(selectors.FIELD_PREFERRED_ENDPOINT, 'endpoint');
await click(selectors.FIELD_PREFERRED_ENDPOINT_ADD_BTN);
await fillIn(selectors.FIELD_FILTERS, 'filter_test');
await click(selectors.FIELD_FILTERS_ADD_BTN);
await fillIn(selectors.FIELD_SYNC_INTERVAL, 10);
await click(commonSelectors.SAVE_BTN);
@@ -3,17 +3,18 @@
* SPDX-License-Identifier: BUSL-1.1
*/
export const FIELD_PREFERRED_ENDPOINT = '[name=preferred_endpoints] input';
export const FIELD_PREFERRED_ENDPOINT =
'[name=preferred_endpoints] [data-test-value-input]';
export const FIELD_PREFERRED_ENDPOINT_ADD_BTN =
'[name=preferred_endpoints] button';
'[name=preferred_endpoints] [data-test-add-button]';
export const FIELD_PREFERRED_ENDPOINT_DELETE_BTN =
'[name=preferred_endpoints] [data-test-remove-button]';
export const FIELD_FILTERS = '[name=filters] input';
export const FIELD_FILTERS_ADD_BTN = '[name=filters] button';
'[name=preferred_endpoints] [data-test-delete-button]';
export const FIELD_FILTERS = '[name=filters] [data-test-value-input]';
export const FIELD_FILTERS_ADD_BTN = '[name=filters] [data-test-add-button]';
export const FIELD_SYNC_INTERVAL = '[name=sync_interval_seconds]';
export const FIELD_AZURE_FILTER = '[name=filter]';
export const FIELD_FILTERS_REMOVE_BTN =
'[name=filters] [data-test-remove-button]';
'[name=filters] [data-test-delete-button]';
export const MANAGE_DROPDOWN_HOST_CATALOG =
'[data-test-manage-host-catalogs-dropdown] button';
@@ -172,7 +172,6 @@ module('Acceptance | host-catalogs | host sets | update', function (hooks) {
);
await fillIn(selectors.FIELD_PREFERRED_ENDPOINT, mockEndpoint);
await click(selectors.FIELD_PREFERRED_ENDPOINT_ADD_BTN);
const filterList = await Promise.all(
findAll(selectors.FIELD_FILTERS_REMOVE_BTN),
@@ -184,7 +183,6 @@ module('Acceptance | host-catalogs | host sets | update', function (hooks) {
assert.strictEqual(findAll(selectors.FIELD_FILTERS_REMOVE_BTN).length, 0);
await fillIn(selectors.FIELD_FILTERS, mockFilter);
await click(selectors.FIELD_FILTERS_ADD_BTN);
await fillIn(selectors.FIELD_SYNC_INTERVAL, 10);
await click(commonSelectors.SAVE_BTN);
@@ -216,7 +214,6 @@ module('Acceptance | host-catalogs | host sets | update', function (hooks) {
);
await fillIn(selectors.FIELD_PREFERRED_ENDPOINT, mockEndpoint);
await click(selectors.FIELD_PREFERRED_ENDPOINT_ADD_BTN);
await fillIn(selectors.FIELD_AZURE_FILTER, 'filter');
await fillIn(selectors.FIELD_SYNC_INTERVAL, 10);
await click(commonSelectors.SAVE_BTN);
@@ -247,7 +244,6 @@ module('Acceptance | host-catalogs | host sets | update', function (hooks) {
);
await fillIn(selectors.FIELD_PREFERRED_ENDPOINT, 'sample endpoint');
await click(selectors.FIELD_PREFERRED_ENDPOINT_ADD_BTN);
// Remove all the filters
const filterList = await Promise.all(
@@ -260,7 +256,6 @@ module('Acceptance | host-catalogs | host sets | update', function (hooks) {
assert.strictEqual(findAll(selectors.FIELD_FILTERS_REMOVE_BTN).length, 0);
await fillIn(selectors.FIELD_FILTERS, 'sample filters');
await click(selectors.FIELD_FILTERS_ADD_BTN);
await fillIn(selectors.FIELD_SYNC_INTERVAL, 10);
await click(commonSelectors.SAVE_BTN);
@@ -18,9 +18,9 @@ module('Integration | Component | form/field/key-value', function (hooks) {
const getValueInputs = () => 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`
<Form::Field::KeyValue @data={{this.data}}>
<: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`
<Form::Field::KeyValue
@data={{this.data}}
@@ -72,13 +70,10 @@ module('Integration | Component | form/field/key-value', function (hooks) {
</:footer>
</Form::Field::KeyValue>
`);
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) {
<F.TextInput data-test-value-input @value={{R.rowData.value}} {{on 'input' (fn R.updateRow 'value')}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<: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`
<Form::Field::KeyValue @data={{this.data}} @onRemove={{this.onRemove}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} {{on 'input' (fn R.updateRow 'key')}} />
</R.Field>
<R.Field as |F|>
<F.TextInput data-test-value-input @value={{R.rowData.value}} {{on 'input' (fn R.updateRow 'value')}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
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`
<Form::Field::KeyValue @data={{this.data}} @errors={{this.errors}}>
<:row as |R|>
@@ -300,18 +229,15 @@ module('Integration | Component | form/field/key-value', function (hooks) {
</:footer>
</Form::Field::KeyValue>
`);
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`
<Form::Field::KeyValue @data={{this.data}}>
<Form::Field::KeyValue @data={{this.data}} @errors={{this.errors}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} />
@@ -328,11 +254,10 @@ module('Integration | Component | form/field/key-value', function (hooks) {
</:footer>
</Form::Field::KeyValue>
`);
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`
<Form::Field::KeyValue
@model={{this.model}}
@name="tags"
@properties={{array 'key' 'value'}}
@data={{this.model.tags}}
@onAdd={{this.onAdd}}
@onRemove={{this.onRemove}}
@onUpdate={{this.onUpdate}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} {{on 'input' (fn R.updateRow 'key')}} />
</R.Field>
<R.Field as |F|>
<F.TextInput data-test-value-input @value={{R.rowData.value}} {{on 'input' (fn R.updateRow 'value')}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
</Form::Field::KeyValue>
`);
// 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`
<Form::Field::KeyValue
@model={{this.model}}
@name="tags"
@properties={{this.properties}}
@data={{this.model.tags}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} />
</R.Field>
<R.Field as |F|>
<F.TextInput data-test-value-input @value={{R.rowData.value}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
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`
<Form::Field::KeyValue
@model={{this.model}}
@name="tags"
@properties={{this.properties}}
@data={{this.model.tags}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} />
</R.Field>
<R.Field as |F|>
<F.TextInput data-test-value-input @value={{R.rowData.value}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
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`
<Form::Field::KeyValue
@model={{this.model}}
@name="tags"
@properties={{this.properties}}
@data={{this.model.tags}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} {{on 'input' (fn R.updateRow 'key')}} />
</R.Field>
<R.Field as |F|>
<F.TextInput data-test-value-input @value={{R.rowData.value}} {{on 'input' (fn R.updateRow 'value')}} />
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
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');
});
});