chore: 🤖 use hds keyvalue component (#3027)
This commit is contained in:
1 parent
ecefa39b98
commit
8b870556a6
19 files changed
+664
-84
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -247,45 +247,50 @@
|
||||
</Form::Field::ListWrapper>
|
||||
|
||||
{{! Account Claim Maps }}
|
||||
<Form::Field::ListWrapper
|
||||
@layout='horizontal'
|
||||
@isOptional={{true}}
|
||||
<Form::Field::KeyValue
|
||||
@name='account_claim_maps'
|
||||
@data={{@model.account_claim_maps}}
|
||||
@onAdd={{this.addAccountClaimMap}}
|
||||
@onRemove={{this.removeAccountClaimMap}}
|
||||
@onUpdate={{this.updateAccountClaimMap}}
|
||||
@legend={{t 'form.account_claim_maps.label'}}
|
||||
@helperText={{t 'form.account_claim_maps.help'}}
|
||||
@errors={{@model.errors.account_claim_maps}}
|
||||
@disabled={{form.disabled}}
|
||||
@isOptional={{true}}
|
||||
>
|
||||
<:fieldset as |F|>
|
||||
<F.Legend>
|
||||
{{t 'form.account_claim_maps.label'}}
|
||||
</F.Legend>
|
||||
<F.HelperText>
|
||||
{{t 'form.account_claim_maps.help'}}
|
||||
</F.HelperText>
|
||||
<:row as |R|>
|
||||
<R.Field as |F|>
|
||||
<F.Label>{{t 'form.from_claim.label'}}</F.Label>
|
||||
<F.TextInput
|
||||
data-test-key-input
|
||||
@value={{R.rowData.key}}
|
||||
{{on 'input' (fn R.updateRow 'key')}}
|
||||
/>
|
||||
</R.Field>
|
||||
|
||||
{{#if @model.errors.account_claim_maps}}
|
||||
<F.Error as |E|>
|
||||
{{#each @model.errors.account_claim_maps as |error|}}
|
||||
<E.Message>{{error.message}}</E.Message>
|
||||
<R.Field as |F|>
|
||||
<F.Label>{{t 'form.to_claim.label'}}</F.Label>
|
||||
<F.Select
|
||||
data-test-value-input
|
||||
{{on 'change' (fn R.updateRow 'value')}}
|
||||
>
|
||||
<option disabled hidden selected value=''>
|
||||
{{t 'titles.choose-an-option'}}
|
||||
</option>
|
||||
{{#each this.toClaims as |claim|}}
|
||||
<option value={{claim}} selected={{eq R.rowData.value claim}}>
|
||||
{{claim}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</F.Error>
|
||||
</F.Select>
|
||||
</R.Field>
|
||||
{{#if R.canDelete}}
|
||||
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
|
||||
{{/if}}
|
||||
</:fieldset>
|
||||
<:field as |F|>
|
||||
<F.KeyValue
|
||||
@name='account_claim_maps'
|
||||
@options={{@model.account_claim_maps}}
|
||||
@model={{@model}}
|
||||
@keyLabel={{t 'form.from_claim.label'}}
|
||||
@valueLabel={{t 'form.to_claim.label'}}
|
||||
@width='100%'
|
||||
>
|
||||
<:key as |K|>
|
||||
<K.text />
|
||||
</:key>
|
||||
<:value as |V|>
|
||||
<V.select @selectOptions={{this.toClaims}} />
|
||||
</:value>
|
||||
</F.KeyValue>
|
||||
</:field>
|
||||
</Form::Field::ListWrapper>
|
||||
</:row>
|
||||
|
||||
</Form::Field::KeyValue>
|
||||
|
||||
{{! Certificates }}
|
||||
<Form::Field::ListWrapper
|
||||
|
||||
@@ -10,6 +10,7 @@ import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class FormAuthMethodOidcComponent extends Component {
|
||||
// =attributes
|
||||
|
||||
/**
|
||||
* @type {object}
|
||||
*/
|
||||
@@ -54,6 +55,45 @@ export default class FormAuthMethodOidcComponent extends Component {
|
||||
|
||||
//actions
|
||||
|
||||
/**
|
||||
* Adds a new empty row to account claim maps
|
||||
*/
|
||||
@action
|
||||
addAccountClaimMap() {
|
||||
this.args.model.account_claim_maps = [
|
||||
...this.args.model.account_claim_maps,
|
||||
{ key: '', value: '' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a row from account claim maps
|
||||
* @param {Object} rowData - The row to remove
|
||||
*/
|
||||
@action
|
||||
removeAccountClaimMap(rowData) {
|
||||
let rows = this.args.model.account_claim_maps.filter(
|
||||
(item) => 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
|
||||
*/
|
||||
|
||||
@@ -225,45 +225,43 @@
|
||||
{{/if}}
|
||||
</Hds::Form::TextInput::Field>
|
||||
|
||||
<Form::Field::ListWrapper
|
||||
@layout='horizontal'
|
||||
@isOptional={{true}}
|
||||
<Form::Field::KeyValue
|
||||
@name='critical_options'
|
||||
@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}}
|
||||
@disabled={{form.disabled}}
|
||||
@isOptional={{true}}
|
||||
>
|
||||
<:fieldset as |F|>
|
||||
<F.Legend>
|
||||
{{t 'resources.credential-library.form.critical_options.label'}}
|
||||
</F.Legend>
|
||||
<F.HelperText>
|
||||
{{t 'resources.credential-library.form.critical_options.help'}}
|
||||
</F.HelperText>
|
||||
<:row as |R|>
|
||||
<R.Field as |F|>
|
||||
<F.Label>{{t 'form.key.label'}}</F.Label>
|
||||
<F.TextInput
|
||||
data-test-key-input
|
||||
@value={{R.rowData.key}}
|
||||
{{on 'input' (fn R.updateRow 'key')}}
|
||||
/>
|
||||
</R.Field>
|
||||
|
||||
{{#if @model.errors.critical_options}}
|
||||
<F.Error as |E|>
|
||||
{{#each @model.errors.critical_options as |error|}}
|
||||
<E.Message>{{error.message}}</E.Message>
|
||||
{{/each}}
|
||||
</F.Error>
|
||||
<R.Field as |F|>
|
||||
<F.Label>{{t 'form.value.label'}}</F.Label>
|
||||
<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>
|
||||
</:row>
|
||||
|
||||
<:field as |F|>
|
||||
<F.KeyValue
|
||||
@name='critical_options'
|
||||
@options={{@model.critical_options}}
|
||||
@model={{@model}}
|
||||
@disabled={{form.disabled}}
|
||||
>
|
||||
<:key as |K|>
|
||||
<K.text />
|
||||
</:key>
|
||||
<:value as |K|>
|
||||
<K.text />
|
||||
</:value>
|
||||
</F.KeyValue>
|
||||
</:field>
|
||||
|
||||
</Form::Field::ListWrapper>
|
||||
</Form::Field::KeyValue>
|
||||
|
||||
<Form::Field::ListWrapper
|
||||
@layout='horizontal'
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
import { action } from '@ember/object';
|
||||
import { options } from 'api/models/credential-library';
|
||||
|
||||
export default class FormCredentialLibraryVaultSshCertComponent extends Component {
|
||||
@@ -23,4 +24,41 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
<Hds::Form::KeyValueInputs
|
||||
name={{@name}}
|
||||
@data={{@data}}
|
||||
@isRequired={{@isRequired}}
|
||||
@isOptional={{@isOptional}}
|
||||
disabled={{@disabled}}
|
||||
>
|
||||
<:header as |H|>
|
||||
<H.Legend>{{@legend}}</H.Legend>
|
||||
{{#if @helperText}}
|
||||
<H.HelperText>{{@helperText}}</H.HelperText>
|
||||
{{/if}}
|
||||
</:header>
|
||||
<: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'
|
||||
}}
|
||||
</:row>
|
||||
<:footer as |F|>
|
||||
<F.AddRowButton
|
||||
@text={{t 'actions.add'}}
|
||||
data-test-add-button
|
||||
@onClick={{@onAdd}}
|
||||
/>
|
||||
{{#if @errors}}
|
||||
<F.Error as |E|>
|
||||
{{#each @errors as |error|}}
|
||||
<E.Message data-test-error-message>{{error.message}}</E.Message>
|
||||
{{/each}}
|
||||
</F.Error>
|
||||
{{/if}}
|
||||
</:footer>
|
||||
</Hds::Form::KeyValueInputs>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+10
@@ -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: '' }];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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`
|
||||
<Form::Field::KeyValue @data={{this.data}}>
|
||||
<: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.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`
|
||||
<Form::Field::KeyValue
|
||||
@data={{this.data}}
|
||||
@legend="Test Legend"
|
||||
@helperText="Test helper text">
|
||||
<: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>
|
||||
`);
|
||||
|
||||
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`
|
||||
<Form::Field::KeyValue @data={{this.data}} @onAdd={{this.onAdd}} @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>
|
||||
<:footer as |F|>
|
||||
<F.AddRowButton />
|
||||
</:footer>
|
||||
</Form::Field::KeyValue>
|
||||
`);
|
||||
|
||||
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 }]);
|
||||
|
||||
// Test TextInput and Select fields
|
||||
await render(hbs`
|
||||
<Form::Field::KeyValue @data={{this.data}}>
|
||||
<:row as |R|>
|
||||
<R.Field as |F|>
|
||||
<F.TextInput name="test-key" data-test-key-input @value={{R.rowData.key}} />
|
||||
</R.Field>
|
||||
<R.Field as |F|>
|
||||
<F.Select name="test-value" data-test-value-input>
|
||||
<option value={{R.rowData.value}} selected />
|
||||
</F.Select>
|
||||
</R.Field>
|
||||
<R.Field as |F|>
|
||||
<F.TextInput name="test-id" data-test-id-input @value={{R.rowData.id}} />
|
||||
</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.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`
|
||||
<Form::Field::KeyValue @data={{this.data}}>
|
||||
<:row as |R|>
|
||||
<R.Field as |F|>
|
||||
<F.Textarea name="test-key" data-test-key-input @value={{R.rowData.key}} />
|
||||
</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.dom('[data-test-key-input]').hasValue('k');
|
||||
|
||||
// Test single field
|
||||
this.set('data', [{ key: '' }]);
|
||||
await render(hbs`
|
||||
<Form::Field::KeyValue @data={{this.data}}>
|
||||
<:row as |R|>
|
||||
<R.Field as |F|>
|
||||
<F.TextInput data-test-key-input @value={{R.rowData.key}} />
|
||||
</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.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`
|
||||
<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}} />
|
||||
</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.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`
|
||||
<Form::Field::KeyValue @data={{this.data}}>
|
||||
<: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.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`
|
||||
<Form::Field::KeyValue @data={{this.data}} @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>
|
||||
<:footer as |F|>
|
||||
<F.AddRowButton />
|
||||
</:footer>
|
||||
</Form::Field::KeyValue>
|
||||
`);
|
||||
|
||||
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`
|
||||
<Form::Field::KeyValue @data={{this.data}}>
|
||||
<: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(getDeleteButtons().length, 3);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user