Files
threshold-ui/ui/admin/tests/integration/components/form/field/key-value-test.js
T

512 lines
17 KiB
JavaScript

/**
* Copyright IBM Corp. 2021, 2026
* 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 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|>
<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();
// Provided data
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 using custom handlers', 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 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 (model errors)', async function (assert) {
const model = {
tags: [{ key: '', value: '' }],
errors: {
tags: [
{ message: 'Key is required' },
{ message: 'Value must be unique' },
],
},
};
this.set('model', model);
this.set('properties', ['key', 'value']);
// With errors
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.dom('[data-test-error-message]').exists().hasText('Key is required');
assert
.dom('[data-test-error-message]:nth-child(2)')
.hasText('Value must be unique');
// Without errors
this.set('model', { ...model, errors: {} });
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.dom('[data-test-error-message]').doesNotExist();
});
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;
});
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);
});
test('custom handlers take precedence over default handlers', async function (assert) {
const model = {
tags: [{ key: '', value: '' }],
errors: {
tags: [
{ message: 'Key is required' },
{ message: 'Value must be unique' },
],
},
};
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');
});
});