chore: migrate listwrapper Target and worker fields to keyvalue component (#3284)

This commit is contained in:
Dharini committed 2026-06-03 16:36:29 -07:00
1 parent e38d3afa58
commit 0e580e3c9f
23 files changed
+349 -184

No files matched your search

@@ -98,7 +98,6 @@
name='client_id'
@value={{@model.client_id}}
@isRequired={{true}}
@isInvalid={{@model.errors.client_id}}
disabled={{form.disabled}}
{{on 'input' (set-from-event @model 'client_id')}}
as |F|
@@ -35,9 +35,9 @@
@onClick={{this.handleAdd}}
/>
{{#if (get @model.errors @name)}}
{{#if this.errors}}
<F.Error as |E|>
{{#each (get @model.errors @name) as |error|}}
{{#each this.errors as |error|}}
<E.Message data-test-error-message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
@@ -21,13 +21,33 @@ export default class FormFieldKeyValueComponent extends Component {
// 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);
const row = rows[0];
for (const key in row) {
if (row[key]) {
return true;
}
}
}
// Return false if there are no rows
return false;
}
get errors() {
// If errors are passed directly as an argument, use those
if (this.args.errors) {
return this.args.errors;
}
const { model, name } = this.args;
if (model && name && model.errors) {
return model.errors[name];
}
return null;
}
/**
* Default handler for adding a row.
* Uses @model, @name, and @properties if provided, otherwise calls @onAdd.
@@ -434,7 +434,6 @@
data-test-key-input
@value={{R.rowData.key}}
{{on 'input' (fn R.updateRow 'key')}}
disabled={{form.disabled}}
/>
</R.Field>
<R.Field as |F|>
@@ -442,15 +441,10 @@
data-test-value-input
@value={{R.rowData.value}}
{{on 'input' (fn R.updateRow 'value')}}
disabled={{form.disabled}}
/>
</R.Field>
{{#if R.canDelete}}
<R.DeleteRowButton
data-test-delete-button
@onClick={{R.removeRow}}
disabled={{form.disabled}}
/>
<R.DeleteRowButton data-test-delete-button @onClick={{R.removeRow}} />
{{/if}}
</:row>
</Form::Field::KeyValue>
@@ -244,37 +244,30 @@
{{#if
(and @model.isNew (can 'create model' @globalScope collection='aliases'))
}}
<Form::Field::ListWrapper
@layout='horizontal'
@isOptional={{true}}
<Form::Field::KeyValue
@name='with_aliases'
@data={{@model.with_aliases}}
@model={{@model}}
@properties={{array 'value'}}
@legend={{t 'resources.alias.form.alias.label'}}
@helperText={{t 'resources.alias.form.alias.help'}}
@disabled={{form.disabled}}
@isOptional={{true}}
>
<:fieldset as |F|>
<F.Legend>
{{t 'resources.alias.form.alias.label'}}
</F.Legend>
<F.HelperText>
{{t 'resources.alias.form.alias.help'}}
</F.HelperText>
<: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 @model.errors.with_aliases}}
<F.Error as |E|>
{{#each @model.errors.with_aliases as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</:fieldset>
<:field as |F|>
<F.TextInput
@name='with_aliases'
@options={{@model.with_aliases}}
@model={{@model}}
/>
</:field>
</Form::Field::ListWrapper>
</:row>
</Form::Field::KeyValue>
{{/if}}
{{#if (can 'save model' @model)}}
<form.actions
@@ -10,24 +10,38 @@
@showEditToggle={{false}}
as |form|
>
<Form::Field::ListWrapper @layout='vertical' @disabled={{form.disabled}}>
<:field as |F|>
<F.KeyValue
@name='api_tags'
@options={{@apiTags}}
@disabled={{form.disabled}}
@addOption={{this.addApiTag}}
@removeOptionByIndex={{this.removeApiTagByIndex}}
>
<:key as |K|>
<K.text />
</:key>
<:value as |V|>
<V.text />
</:value>
</F.KeyValue>
</:field>
</Form::Field::ListWrapper>
<Form::Field::KeyValue
@name='api_tags'
@data={{@apiTags}}
@onAdd={{this.addApiTag}}
@onRemove={{this.removeApiTagByIndex}}
@onUpdate={{this.updateApiTagValue}}
@isOptional={{true}}
@disabled={{form.disabled}}
>
<: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>
<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}}
</:row>
</Form::Field::KeyValue>
<form.actions
@submitText={{t 'actions.save'}}
@@ -20,8 +20,8 @@ export default class FormWorkerCreateTagsIndexComponent extends Component {
* @param {object} option
*/
@action
addApiTag(option) {
this.args.apiTags.push(new Tag(option.key, option.value));
addApiTag() {
this.args.apiTags.push(new Tag());
}
/**
@@ -29,10 +29,20 @@ export default class FormWorkerCreateTagsIndexComponent extends Component {
* @param {number} index
*/
@action
removeApiTagByIndex(index) {
this.args.apiTags.splice(index, 1);
removeApiTagByIndex(rowData) {
const index = this.args.apiTags.indexOf(rowData);
if (index !== -1) {
this.args.apiTags.splice(index, 1);
}
if (this.args.apiTags.length === 0) {
this.args.apiTags.push(new Tag());
}
}
@action
updateApiTagValue(rowData, property, event) {
rowData[property] = event.target.value;
}
/**
* Uses `@apiTags` to build and submits it to the parent component.
* If there are no tags, it transitions to the tags route.
@@ -40,13 +50,15 @@ export default class FormWorkerCreateTagsIndexComponent extends Component {
*/
@action
save() {
if (this.args.apiTags.length === 0) {
// Filter out empty tags
const tagsToProcess = this.args.apiTags.filter((tag) => tag.key?.trim());
if (tagsToProcess.length === 0) {
this.router.transitionTo('scopes.scope.workers.worker.tags');
return;
}
const apiTags = structuredClone(this.args.model.api_tags ?? {});
this.args.apiTags.forEach((tag) => {
tagsToProcess.forEach((tag) => {
let key = tag.key;
let values = tag.value.split(',').map((value) => value.trim());
@@ -78,35 +78,39 @@
</F.HelperText>
</Hds::Form::TextInput::Field>
{{/if}}
<Form::Field::ListWrapper
@layout='horizontal'
@disabled={{form.disabled}}
@isOptional={{true}}
>
<:fieldset as |F|>
<F.Legend>{{t 'resources.worker.form.worker_tags.label'}}</F.Legend>
<F.HelperText>
{{t 'resources.worker.form.worker_tags.help'}}
</F.HelperText>
</:fieldset>
<:field as |F|>
<F.KeyValue
@name='worker_tags'
@options={{this.workerTags}}
@disabled={{form.disabled}}
@addOption={{this.addWorkerTag}}
@removeOptionByIndex={{this.removeWorkerTagByIndex}}
>
<:key as |K|>
<K.text />
</:key>
<:value as |V|>
<V.text />
</:value>
</F.KeyValue>
</:field>
</Form::Field::ListWrapper>
<Form::Field::KeyValue
@name='worker_tags'
@data={{this.workerTags}}
@onAdd={{this.addWorkerTag}}
@onRemove={{this.removeWorkerTagByIndex}}
@onUpdate={{this.updateWorkerTagValue}}
@legend={{t 'resources.worker.form.worker_tags.label'}}
@helperText={{t 'resources.worker.form.worker_tags.help'}}
@isOptional={{true}}
@disabled={{form.disabled}}
>
<: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>
{{#if (feature-flag 'ssh-session-recording')}}
<Hds::Form::Fieldset @isOptional={{true}} as |F|>
@@ -21,7 +21,8 @@ export default class FormWorkerCreateWorkerLedComponent extends Component {
@tracked ipAddress;
@tracked configFilePath;
@tracked initialUpstreams;
@tracked workerTags = new TrackedArray([]);
@tracked workerTags = new TrackedArray([new Tag()]);
@tracked enableRecordingStoragePath = false;
@tracked recording_storage_path = '';
@@ -66,7 +67,7 @@ touch ${this.configFilePath || '<path>'}/pki-worker.hcl`;
const tagsText = `tags {
${
this.workerTags.length
this.workerTags.filter((t) => t.key?.trim() || t.value?.trim()).length
? this.getTagConfigString()
: 'key = ["<tag1>", "<tag2>"]'
}
@@ -141,6 +142,7 @@ unzip *.zip ;\\
getTagConfigString() {
return this.workerTags
.filter((t) => t.key?.trim() || t.value?.trim())
.map(
(tag) =>
`${tag.key} = [${this.convertCommaSeparatedValuesToArray(
@@ -151,13 +153,24 @@ unzip *.zip ;\\
}
@action
addWorkerTag(e) {
this.workerTags.push(new Tag(e.key, e.value));
addWorkerTag() {
this.workerTags.push(new Tag());
}
@action
removeWorkerTagByIndex(index) {
this.workerTags.splice(index, 1);
removeWorkerTagByIndex(rowData) {
const index = this.workerTags.indexOf(rowData);
if (index !== -1) {
this.workerTags.splice(index, 1);
}
if (this.workerTags.length === 0) {
this.workerTags.push(new Tag());
}
}
@action
updateWorkerTagValue(rowData, property, event) {
rowData[property] = event.target.value;
}
@action
+1 -1
View File
@@ -9,7 +9,7 @@ export default class Tag {
@tracked key;
@tracked value;
constructor(key, value) {
constructor(key = '', value = '') {
this.key = key;
this.value = value;
}
@@ -186,6 +186,11 @@ export default class ScopesScopeTargetsIndexController extends Controller {
isNew ? 'notifications.create-success' : 'notifications.save-success',
)
async save(target) {
if (target.with_aliases) {
target.with_aliases = target.with_aliases.filter((item) =>
item.value?.trim(),
);
}
await target.save();
if (this.abilities.can('read model', target)) {
await this.router.transitionTo('scopes.scope.targets.target', target);
@@ -9,6 +9,7 @@ import { action } from '@ember/object';
import { loading } from 'ember-loading';
import { notifySuccess, notifyError } from 'core/decorators/notify';
import { TrackedArray } from 'tracked-built-ins';
import Tag from 'admin/components/form/worker/tag';
export default class ScopesScopeWorkersWorkerCreateTagsController extends Controller {
// =services
@@ -16,7 +17,7 @@ export default class ScopesScopeWorkersWorkerCreateTagsController extends Contro
@service router;
// =attributes
apiTags = new TrackedArray([]);
apiTags = new TrackedArray([new Tag()]);
// =actions
@@ -32,7 +33,7 @@ export default class ScopesScopeWorkersWorkerCreateTagsController extends Contro
const worker = this.model;
await worker.setApiTags(apiTags);
// Clear the apiTags array after saving
this.apiTags = new TrackedArray([]);
this.apiTags = new TrackedArray([new Tag()]);
this.router.transitionTo('scopes.scope.workers.worker.tags', worker);
}
@@ -41,7 +42,7 @@ export default class ScopesScopeWorkersWorkerCreateTagsController extends Contro
*/
@action
cancel() {
this.apiTags = new TrackedArray([]);
this.apiTags = new TrackedArray([new Tag()]);
this.router.replaceWith('scopes.scope.workers.worker.tags');
}
}
@@ -60,6 +60,7 @@ export default class ScopesScopeTargetsNewRoute extends Route {
description,
});
record.scopeModel = scopeModel;
record.with_aliases = [{ value: '' }];
return record;
}
@@ -7,6 +7,7 @@ import Route from '@ember/routing/route';
import { action } from '@ember/object';
import { service } from '@ember/service';
import { TrackedArray } from 'tracked-built-ins';
import Tag from 'admin/components/form/worker/tag';
export default class ScopesScopeWorkersWorkerCreateTagsRoute extends Route {
// =services
@@ -18,14 +19,16 @@ export default class ScopesScopeWorkersWorkerCreateTagsRoute extends Route {
async willTransition(transition) {
// eslint-disable-next-line ember/no-controller-access-in-routes
const controller = this.controllerFor(this.routeName);
if (controller.apiTags.length) {
if (
controller.apiTags.some((tag) => tag.key?.trim() || tag.value?.trim())
) {
transition.abort();
try {
await this.confirm.confirm(this.intl.t('questions.abandon-confirm'), {
title: 'titles.abandon-confirm',
confirm: 'actions.discard',
});
controller.set('apiTags', new TrackedArray([]));
controller.set('apiTags', new TrackedArray([new Tag()]));
transition.retry();
} catch {
// if user denies, do nothing
@@ -318,17 +318,14 @@ module('Acceptance | targets | create', function (hooks) {
),
);
await click(selectors.FIELD_ALIASES_ADD_BTN);
await fillIn(selectors.FIELD_ALIASES, 'alias 1');
await click(selectors.FIELD_ALIASES_ADD_BTN);
await fillIn(selectors.FIELD_ALIASES, 'alias 2');
await click(selectors.FIELD_ALIASES_ADD_BTN);
await click(commonSelectors.SAVE_BTN);
assert.strictEqual(getTargetCount(), targetCount + 1);
const target = this.server.schema.targets.findBy({ name });
assert.deepEqual(target.withAliases, [
{ value: 'alias 1', scope_id: 'global' },
{ value: 'alias 2', scope_id: 'global' },
]);
assert.strictEqual(getTCPTargetCount(), tcpTargetCount + 1);
});
@@ -14,10 +14,9 @@ export const FIELD_INFO = '.info-field';
export const FIELD_INFO_LABEL = '.info-field .hds-form-helper-text';
export const FIELD_ALIAS = '[name="value"]';
export const FIELD_DESTINATION_ID = '[name="destination_id"]';
export const FIELD_ALIASES =
'[name="with_aliases"] tbody tr:last-of-type input';
export const FIELD_ALIASES = '[name=with_aliases] [data-test-value-input]';
export const FIELD_ALIASES_ADD_BTN =
'[name="with_aliases"] tbody tr:last-of-type button';
'[name=with_aliases] [data-test-add-button]';
export const FIELD_ADDRESS = '[name="address"]';
export const FIELD_ADDRESS_VALUE = '0.0.0.0';
export const FIELD_NAME_ERROR = '[data-test-error-message-name]';
@@ -96,6 +96,7 @@ module('Acceptance | workers | worker | create-tags', function (hooks) {
await click(selectors.MANAGE_DROPDOWN_WORKER);
await click(selectors.MANAGE_DROPDOWN_WORKER_CREATE_TAGS);
await click(selectors.ADD_WORKER_TAG_ACTION);
await fillIn(selectors.FIELD_KEY, selectors.FIELD_KEY_VALUE);
await fillIn(selectors.FIELD_VALUE, selectors.FIELD_VALUE_VALUE);
await click(selectors.ADD_WORKER_TAG_ACTION);
@@ -137,9 +138,11 @@ module('Acceptance | workers | worker | create-tags', function (hooks) {
confirmService.enabled = true;
await visit(urls.createTags);
await click(selectors.ADD_WORKER_TAG_ACTION);
await fillIn(selectors.FIELD_KEY, selectors.FIELD_KEY_VALUE);
await fillIn(selectors.FIELD_VALUE, selectors.FIELD_VALUE_VALUE);
await click(selectors.ADD_WORKER_TAG_ACTION);
await click(commonSelectors.HREF(urls.workers));
assert.dom(commonSelectors.MODAL_WARNING).isVisible();
@@ -10,8 +10,7 @@ export const FIELD_WORKER_AUTH_REGISTRATION_VALUE = 'token';
export const WORKER_CREATE_SECTION_FORM_LABEL =
'.worker-create-section label.hds-form-label';
export const WORKER_CREATE_SECTION = '.worker-create-section';
export const ADD_WORKER_TAG_ACTION =
'[name="api_tags"] tr td:last-child button';
export const ADD_WORKER_TAG_ACTION = '[name="api_tags"] [data-test-add-button]';
export const MANAGE_DROPDOWN_WORKER =
'[data-test-manage-worker-dropdown] button:first-child';
@@ -20,10 +19,10 @@ export const MANAGE_DROPDOWN_WORKER_REMOVE =
export const MANAGE_DROPDOWN_WORKER_CREATE_TAGS =
'[data-test-manage-worker-dropdown] ul a:first-child';
export const FIELD_KEY = '[name="api_tags"] tr td:first-child input';
export const FIELD_KEY = '[name="api_tags"] [data-test-key-input]';
export const FIELD_KEY_VALUE = 'key';
export const FIELD_VALUE = '[name="api_tags"] tr td:nth-child(2) input';
export const FIELD_VALUE = '[name="api_tags"] [data-test-value-input]';
export const FIELD_VALUE_VALUE = 'value';
export const WORKER_TAGS_FLYOUT = '[data-test-worker-tags-flyout]';
+20 -1
View File
@@ -183,7 +183,7 @@ module('Acceptance | workers | worker | tags', function (hooks) {
.doesNotExist();
});
test('users can edit a specific tag', async function (assert) {
test('users can edit a specific tag and create a new one afterwards', async function (assert) {
setRunOptions({
rules: {
'color-contrast': {
@@ -210,6 +210,25 @@ module('Acceptance | workers | worker | tags', function (hooks) {
assert.dom(selectors.TABLE_ROW_API_TAG_KEY(11)).hasText('red');
assert.dom(selectors.TABLE_ROW_API_TAG_VALUE(11)).hasText('blue');
// After editing, go to create tags route and create a new tag
await click(selectors.MANAGE_DROPDOWN_WORKER);
await click(selectors.MANAGE_DROPDOWN_WORKER_CREATE_TAGS);
await click(selectors.ADD_WORKER_TAG_ACTION);
await fillIn(selectors.FIELD_KEY, selectors.FIELD_KEY_VALUE);
await fillIn(selectors.FIELD_VALUE, selectors.FIELD_VALUE_VALUE);
await click(selectors.ADD_WORKER_TAG_ACTION);
await click(commonSelectors.SAVE_BTN);
assert
.dom(selectors.TABLE_ROW_API_TAG_KEY(12))
.hasText(selectors.FIELD_KEY_VALUE);
assert
.dom(selectors.TABLE_ROW_API_TAG_VALUE(12))
.hasText(selectors.FIELD_VALUE_VALUE);
});
test('users can cancel editing a tag', async function (assert) {
@@ -13,10 +13,17 @@ module('Integration | Component | form/field/key-value', function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks, 'en-us');
// Test selectors
const KEY_INPUT = '[data-test-key-input]';
const VALUE_INPUT = '[data-test-value-input]';
const DELETE_BUTTON = '[data-test-delete-button]';
const ADD_BUTTON = '[data-test-add-button]';
const ERROR_MESSAGE = '[data-test-error-message]';
// Helper functions
const getKeyInputs = () => findAll('[data-test-key-input]');
const getValueInputs = () => findAll('[data-test-value-input]');
const getDeleteButtons = () => findAll('[data-test-delete-button]');
const getKeyInputs = () => findAll(KEY_INPUT);
const getValueInputs = () => findAll(VALUE_INPUT);
const getDeleteButtons = () => findAll(DELETE_BUTTON);
test('it renders with default and provided data', async function (assert) {
// Default empty row
@@ -39,10 +46,10 @@ module('Integration | Component | form/field/key-value', function (hooks) {
</: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();
assert.dom(KEY_INPUT).exists();
assert.dom(VALUE_INPUT).exists();
assert.dom(ADD_BUTTON).exists();
assert.dom(DELETE_BUTTON).doesNotExist();
// Provided data
this.set('data', [
@@ -112,7 +119,9 @@ module('Integration | Component | form/field/key-value', function (hooks) {
`);
assert.strictEqual(getKeyInputs().length, 1, 'Initially has one row');
await click('[data-test-add-button]');
await click(ADD_BUTTON);
assert.strictEqual(
getKeyInputs().length,
2,
@@ -156,8 +165,8 @@ module('Integration | Component | form/field/key-value', function (hooks) {
</Form::Field::KeyValue>
`);
assert.dom('[data-test-key-input]').hasValue('k');
assert.dom('[data-test-value-input]').hasValue('v');
assert.dom(KEY_INPUT).hasValue('k');
assert.dom(VALUE_INPUT).hasValue('v');
assert.dom('[data-test-id-input]').hasValue('1');
// Test Textarea field
@@ -177,7 +186,7 @@ module('Integration | Component | form/field/key-value', function (hooks) {
</Form::Field::KeyValue>
`);
assert.dom('[data-test-key-input]').hasValue('k');
assert.dom(KEY_INPUT).hasValue('k');
// Test single field
this.set('data', [{ key: '' }]);
@@ -197,14 +206,14 @@ module('Integration | Component | form/field/key-value', function (hooks) {
</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();
assert.dom(KEY_INPUT).exists();
assert.dom(VALUE_INPUT).doesNotExist();
assert.dom(ADD_BUTTON).exists();
assert.dom(DELETE_BUTTON).doesNotExist();
});
test('it displays error messages in footer (model errors)', async function (assert) {
const model = {
test('it displays error messages in footer from model errors', async function (assert) {
this.set('model', {
tags: [{ key: '', value: '' }],
errors: {
tags: [
@@ -212,17 +221,10 @@ module('Integration | Component | form/field/key-value', function (hooks) {
{ 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}}>
<Form::Field::KeyValue @model={{this.model}} @name="tags" @data={{this.model.tags}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} />
@@ -239,19 +241,18 @@ 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(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: {} });
this.set('model', {
tags: [{ key: '', value: '' }],
errors: {},
});
await render(hbs`
<Form::Field::KeyValue
@model={{this.model}}
@name="tags"
@properties={{this.properties}}
@data={{this.model.tags}}>
<Form::Field::KeyValue @model={{this.model}} @name="tags" @data={{this.model.tags}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} />
@@ -268,7 +269,33 @@ module('Integration | Component | form/field/key-value', function (hooks) {
</:footer>
</Form::Field::KeyValue>
`);
assert.dom('[data-test-error-message]').doesNotExist();
assert.dom(ERROR_MESSAGE).doesNotExist();
});
test('it displays error messages in footer from @errors arg', async function (assert) {
this.set('errors', [
{ message: 'Direct error one' },
{ message: 'Direct error two' },
]);
this.set('data', []);
await render(hbs`
<Form::Field::KeyValue @errors={{this.errors}} @data={{this.data}}>
<:row as |R|>
<R.Field as |F|>
<F.TextInput data-test-key-input @value={{R.rowData.key}} />
</R.Field>
</:row>
<:footer as |F|>
<F.AddRowButton />
</:footer>
</Form::Field::KeyValue>
`);
assert.dom(ERROR_MESSAGE).exists().hasText('Direct error one');
assert
.dom('[data-test-error-message]:nth-child(2)')
.hasText('Direct error two');
});
test('it updates both key and value fields using custom handlers', async function (assert) {
@@ -296,8 +323,8 @@ module('Integration | Component | form/field/key-value', function (hooks) {
</Form::Field::KeyValue>
`);
await fillIn('[data-test-key-input]', 'test-key');
await fillIn('[data-test-value-input]', 'test-value');
await fillIn(KEY_INPUT, 'test-key');
await fillIn(VALUE_INPUT, 'test-value');
assert.strictEqual(this.data.length, 1);
assert.strictEqual(this.data[0].key, 'test-key');
@@ -336,12 +363,6 @@ module('Integration | Component | form/field/key-value', function (hooks) {
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);
@@ -379,7 +400,7 @@ module('Integration | Component | form/field/key-value', function (hooks) {
`);
// Test custom add handler takes precedence
await click('[data-test-add-button]');
await click(ADD_BUTTON);
assert.strictEqual(model.tags.length, 2);
assert.strictEqual(model.tags[1].key, 'custom');
@@ -425,7 +446,7 @@ module('Integration | Component | form/field/key-value', function (hooks) {
assert.strictEqual(model.tags.length, 1);
await click('[data-test-add-button]');
await click(ADD_BUTTON);
assert.strictEqual(model.tags.length, 2);
@@ -502,10 +523,76 @@ module('Integration | Component | form/field/key-value', function (hooks) {
</Form::Field::KeyValue>
`);
await fillIn('[data-test-key-input]', 'updated-key');
await fillIn('[data-test-value-input]', 'updated-value');
await fillIn(KEY_INPUT, 'updated-key');
await fillIn(VALUE_INPUT, 'updated-value');
assert.strictEqual(model.tags[0].key, 'updated-key');
assert.strictEqual(model.tags[0].value, 'updated-value');
});
test('canDelete uses properties argument correctly (trash icon visible)', async function (assert) {
// Multiple rows: trash icon should be visible
this.set('data', [
{ key: 'foo', value: 'bar' },
{ key: '', value: '' },
]);
this.set('properties', ['key', 'value']);
await render(hbs`
<Form::Field::KeyValue @data={{this.data}} @properties={{this.properties}}>
<: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>
</Form::Field::KeyValue>
`);
assert.dom(DELETE_BUTTON).isVisible();
// Single row, property is empty: trash icon should not be visible
this.set('data', [{ key: '', value: '' }]);
await render(hbs`
<Form::Field::KeyValue @data={{this.data}} @properties={{this.properties}}>
<: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>
</Form::Field::KeyValue>
`);
assert.dom(DELETE_BUTTON).isNotVisible();
// Single row, property is set: trash icon should be visible
this.set('data', [{ key: 'foo', value: '' }]);
await render(hbs`
<Form::Field::KeyValue @data={{this.data}} @properties={{this.properties}}>
<: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>
</Form::Field::KeyValue>
`);
assert.dom(DELETE_BUTTON).isVisible();
});
});
@@ -28,9 +28,8 @@ module(
hbs`<Form::Worker::CreateTags @model={{this.model}} @apiTags={{this.apiTags}} @submit={{this.save}} @cancel={{this.cancel}} />`,
);
assert
.dom(this.element)
.includesText('Key Value Actions Add Save Cancel');
assert.dom(this.element).includesText('Add Save Cancel');
assert.dom('[data-test-add-button]').exists();
});
test('it calls the passed in save action', async function (assert) {
@@ -5,7 +5,7 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, fillIn, render } from '@ember/test-helpers';
import { click, fillIn, render, findAll } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { v4 as uuidv4 } from 'uuid';
import { setupIntl } from 'ember-intl/test-support';
@@ -18,6 +18,10 @@ module(
setupBrowserFakes(hooks, { window: true });
setupIntl(hooks, 'en-us');
const ADD_BTN = '[name="worker_tags"] [data-test-add-button]';
const KEY_VALUE_ROW =
'[name="worker_tags"] .hds-form-key-value-inputs__row';
hooks.beforeEach(function () {
const featuresService = this.owner.lookup('service:features');
featuresService.enable('byow-pki-hcp-cluster-id');
@@ -91,11 +95,12 @@ module(
this.submit = () => {};
this.cancel = () => {};
const KEY_VALUE_INPUT_SELECTOR = (row, column) =>
`[name="worker_tags"] tbody :nth-child(${row}) td:nth-child(${column}) input`;
const KEY_VALUE_BUTTON_SELECTOR = (row) =>
`[name="worker_tags"] tbody :nth-child(${row}) button`;
const KEY_VALUE_ROW = '[name="worker_tags"] tbody tr';
const getKeyInputs = () =>
findAll('[name="worker_tags"] [data-test-key-input]');
const getValueInputs = () =>
findAll('[name="worker_tags"] [data-test-value-input]');
const getDeleteBtns = () =>
findAll('[name="worker_tags"] [data-test-delete-button]');
await render(
hbs`<Form::Worker::CreateWorkerLed @model={{this.model}} @submit={{this.submit}} @cancel={{this.cancel}} />`,
@@ -103,21 +108,17 @@ module(
assert.dom(KEY_VALUE_ROW).exists({ count: 1 });
// Test adding a worker tag
await fillIn(KEY_VALUE_INPUT_SELECTOR(1, 1), 'environment');
await fillIn(KEY_VALUE_INPUT_SELECTOR(1, 2), 'dev');
await click(KEY_VALUE_BUTTON_SELECTOR(1));
// Add second worker tag
await click(ADD_BTN);
assert.dom(KEY_VALUE_ROW).exists({ count: 2 });
await fillIn(getKeyInputs()[1], 'environment');
await fillIn(getValueInputs()[1], 'test');
await fillIn(KEY_VALUE_INPUT_SELECTOR(2, 1), 'environment');
await fillIn(KEY_VALUE_INPUT_SELECTOR(2, 2), 'test');
await click(KEY_VALUE_BUTTON_SELECTOR(2));
assert.dom(KEY_VALUE_ROW).exists({ count: 3 });
// Remove the first worker tag
await click(getDeleteBtns()[0]);
// Remove the worker tag
await click(KEY_VALUE_BUTTON_SELECTOR(1));
assert.dom(KEY_VALUE_ROW).exists({ count: 2 });
assert.dom(KEY_VALUE_ROW).exists({ count: 1 });
});
},
);
@@ -75,7 +75,9 @@ module(
await controller.cancel();
assert.deepEqual(controller.apiTags, []);
assert.strictEqual(controller.apiTags.length, 1);
assert.strictEqual(controller.apiTags[0].key, '');
assert.strictEqual(controller.apiTags[0].value, '');
assert.strictEqual(currentURL(), urls.tags);
});
},