{{! version number and icon }}
diff --git a/ui/tests/helpers/kv/kv-selectors.js b/ui/tests/helpers/kv/kv-selectors.js
index 23a50553f1..9602728c88 100644
--- a/ui/tests/helpers/kv/kv-selectors.js
+++ b/ui/tests/helpers/kv/kv-selectors.js
@@ -113,6 +113,12 @@ export const FORM = {
maskedValueInput: (idx = 0) => `[data-test-kv-value="${idx}"] [data-test-textarea]`,
addRow: (idx = 0) => `[data-test-kv-add-row="${idx}"]`,
deleteRow: (idx = 0) => `[data-test-kv-delete-row="${idx}"]`,
+ //
+ patchEdit: (idx = 0) => `[data-test-edit-button="${idx}"]`,
+ patchDelete: (idx = 0) => `[data-test-delete-button="${idx}"]`,
+ patchUndo: (idx = 0) => `[data-test-undo-button="${idx}"]`,
+ patchAdd: '[data-test-add-button]',
+ patchAlert: (type, idx) => `[data-test-alert-${type}="${idx}"]`,
// Alerts & validation
inlineAlert: '[data-test-inline-alert]',
validation: (attr) => `[data-test-field="${attr}"] [data-test-inline-alert]`,
diff --git a/ui/tests/integration/components/kv/kv-patch-editor/alerts-test.js b/ui/tests/integration/components/kv/kv-patch-editor/alerts-test.js
new file mode 100644
index 0000000000..12c318c219
--- /dev/null
+++ b/ui/tests/integration/components/kv/kv-patch-editor/alerts-test.js
@@ -0,0 +1,80 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'vault/tests/helpers';
+import { setupEngine } from 'ember-engines/test-support';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { GENERAL } from 'vault/tests/helpers/general-selectors';
+import { FORM } from 'vault/tests/helpers/kv/kv-selectors';
+
+module('Integration | Component | kv | kv-patch-editor/alerts', function (hooks) {
+ setupRenderingTest(hooks);
+ setupEngine(hooks, 'kv');
+
+ hooks.beforeEach(function () {
+ this.keyError = '';
+ this.keyWarning = '';
+ this.valueWarning = '';
+ this.renderComponent = async () => {
+ return render(
+ hbs`
+ `,
+ { owner: this.engine }
+ );
+ };
+ });
+
+ test('it renders', async function (assert) {
+ await this.renderComponent();
+ assert.dom(FORM.patchAlert('validation', 1)).doesNotExist();
+ assert.dom(FORM.patchAlert('value-warning', 1)).doesNotExist();
+ assert.dom(FORM.patchAlert('key-warning', 1)).doesNotExist();
+ });
+
+ test('it renders key error', async function (assert) {
+ this.keyError = "There's a problem with your key";
+ await this.renderComponent();
+ assert.dom(FORM.patchAlert('validation', 1)).hasClass('hds-alert--color-critical');
+ assert.dom(`${FORM.patchAlert('validation', 1)} ${GENERAL.icon('alert-diamond-fill')}`).exists();
+
+ assert.dom(FORM.patchAlert('key-warning', 1)).doesNotExist();
+ assert.dom(FORM.patchAlert('value-warning', 1)).doesNotExist();
+ });
+
+ test('it renders key warning', async function (assert) {
+ this.keyWarning = 'Key warning';
+ await this.renderComponent();
+ assert.dom(FORM.patchAlert('key-warning', 1)).hasClass('hds-alert--color-warning');
+ assert.dom(`${FORM.patchAlert('key-warning', 1)} ${GENERAL.icon('alert-triangle-fill')}`).exists();
+ assert.dom(FORM.patchAlert('validation', 1)).doesNotExist();
+ assert.dom(FORM.patchAlert('value-warning', 1)).doesNotExist();
+ });
+
+ test('it renders value warning', async function (assert) {
+ this.valueWarning = 'Value warning';
+ await this.renderComponent();
+ assert.dom(FORM.patchAlert('value-warning', 1)).hasClass('hds-alert--color-warning');
+ assert.dom(`${FORM.patchAlert('value-warning', 1)} ${GENERAL.icon('alert-triangle-fill')}`).exists();
+ assert.dom(FORM.patchAlert('validation', 1)).doesNotExist();
+ assert.dom(FORM.patchAlert('key-warning', 1)).doesNotExist();
+ });
+
+ test('it renders all three alerts', async function (assert) {
+ this.keyError = "There's a problem with your key";
+ this.keyWarning = 'Key warning';
+ this.valueWarning = 'Value warning';
+ await this.renderComponent();
+ assert.dom(FORM.patchAlert('validation', 1)).hasText(this.keyError);
+ assert.dom(FORM.patchAlert('key-warning', 1)).hasText(this.keyWarning);
+ assert.dom(FORM.patchAlert('value-warning', 1)).hasText(this.valueWarning);
+ });
+});
diff --git a/ui/tests/integration/components/kv/kv-patch-editor/form-test.js b/ui/tests/integration/components/kv/kv-patch-editor/form-test.js
new file mode 100644
index 0000000000..5df06cadd4
--- /dev/null
+++ b/ui/tests/integration/components/kv/kv-patch-editor/form-test.js
@@ -0,0 +1,566 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'vault/tests/helpers';
+import { setupEngine } from 'ember-engines/test-support';
+import { blur, click, fillIn, typeIn, render, focus } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { GENERAL } from 'vault/tests/helpers/general-selectors';
+import sinon from 'sinon';
+import { FORM } from 'vault/tests/helpers/kv/kv-selectors';
+import { NON_STRING_WARNING, WHITESPACE_WARNING } from 'vault/utils/validators';
+
+module('Integration | Component | kv | kv-patch-editor/form', function (hooks) {
+ setupRenderingTest(hooks);
+ setupEngine(hooks, 'kv');
+
+ hooks.beforeEach(function () {
+ this.subkeys = {
+ foo: null,
+ baz: null,
+ };
+ this.onSubmit = sinon.spy();
+ this.onCancel = sinon.spy();
+ this.isSaving = false;
+
+ this.renderComponent = async () => {
+ return render(
+ hbs`
+ `,
+ { owner: this.engine }
+ );
+ };
+
+ // HELPERS
+ this.assertDefaultRow = (idx, key, assert) => {
+ assert.dom(FORM.keyInput(idx)).hasValue(key);
+ assert.dom(FORM.keyInput(idx)).isDisabled();
+ assert.dom(FORM.valueInput(idx)).hasValue('');
+ assert.dom(FORM.valueInput(idx)).isDisabled();
+ assert.dom(FORM.patchEdit(idx)).exists();
+ assert.dom(FORM.patchDelete(idx)).exists();
+ };
+
+ this.assertEmptyRow = (assert) => {
+ assert.dom(FORM.keyInput('new')).hasValue('');
+ assert.dom(FORM.keyInput('new')).isNotDisabled();
+ assert.dom(FORM.keyInput('new')).hasAttribute('placeholder', 'key');
+ assert.dom(FORM.valueInput('new')).hasValue('');
+ assert.dom(FORM.valueInput('new')).isNotDisabled();
+ assert.dom(FORM.patchAdd).exists({ count: 1 });
+ };
+ });
+
+ test('it renders', async function (assert) {
+ await this.renderComponent();
+
+ this.assertDefaultRow(0, 'foo', assert);
+ this.assertDefaultRow(1, 'baz', assert);
+ this.assertEmptyRow(assert);
+
+ await click(FORM.saveBtn);
+ assert.true(this.onSubmit.calledOnce, 'clicking "Save" calls @onSubmit');
+ await click(FORM.cancelBtn);
+ assert.true(this.onCancel.calledOnce, 'clicking "Cancel" calls @onCancel');
+ });
+
+ test('it renders with no subkeys', async function (assert) {
+ this.subkeys = {};
+ await this.renderComponent();
+
+ this.assertEmptyRow(assert);
+ });
+
+ test('it reveals subkeys', async function (assert) {
+ this.subkeys = {
+ foo: null,
+ bar: {
+ baz: null,
+ quux: {
+ hello: null,
+ },
+ },
+ };
+ await this.renderComponent();
+
+ assert.dom(GENERAL.toggleInput('Reveal subkeys')).isNotChecked('toggle is initially unchecked');
+ assert.dom('[data-test-subkeys]').doesNotExist();
+ await click(GENERAL.toggleInput('Reveal subkeys'));
+ assert.dom(GENERAL.toggleInput('Reveal subkeys')).isChecked();
+ assert.dom('[data-test-subkeys]').hasText(JSON.stringify(this.subkeys, null, 2));
+
+ await click(GENERAL.toggleInput('Reveal subkeys'));
+ assert.dom(GENERAL.toggleInput('Reveal subkeys')).isNotChecked();
+ assert.dom('[data-test-subkeys]').doesNotExist('unchecking re-hides subkeys');
+ });
+
+ test('it enables and disables inputs', async function (assert) {
+ await this.renderComponent();
+
+ const enableAndAssert = async (idx, key) => {
+ await click(FORM.patchEdit(idx));
+ assert.dom(FORM.valueInput(idx)).isEnabled('clicking edit enables value input');
+ assert.dom(FORM.keyInput(idx)).hasAttribute('readonly', '', `${key} input updates to readonly`);
+ assert.dom(FORM.patchEdit(idx)).doesNotExist('edit button disappears');
+ assert.dom(FORM.patchDelete(idx)).doesNotExist('delete button disappears');
+ assert
+ .dom(FORM.patchUndo(idx))
+ .hasText('Cancel', 'Undo button reads "Cancel" and replaces edit and delete');
+ };
+
+ await enableAndAssert(0, 'foo');
+ await click(FORM.patchUndo(0));
+ this.assertDefaultRow(0, 'foo', assert);
+
+ await enableAndAssert(1, 'baz');
+ await click(FORM.patchUndo(1));
+ this.assertDefaultRow(1, 'baz', assert);
+ });
+
+ test('it adds a new row', async function (assert) {
+ await this.renderComponent();
+
+ await click(FORM.patchAdd);
+ assert
+ .dom('[data-test-kv-key]')
+ .exists({ count: 3 }, 'clicking add does not create a new row if key input is empty');
+
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'aValue');
+ await click(FORM.patchAdd);
+
+ assert.dom(FORM.keyInput(2)).hasValue('aKey');
+ assert.dom(FORM.keyInput(2)).isEnabled('new key inputs are enabled');
+ assert.dom(FORM.valueInput(2)).isEnabled('new value inputs are enabled');
+ assert.dom(FORM.patchUndo(2)).hasText('Remove', 'Undo button reads "Remove" for new keys');
+
+ // assert a new row is added
+ this.assertEmptyRow(assert);
+ });
+
+ test('it renders loading state', async function (assert) {
+ this.isSaving = true;
+ await this.renderComponent();
+
+ assert.dom(FORM.saveBtn).isDisabled();
+ assert.dom(FORM.cancelBtn).isDisabled();
+ assert.dom(`${FORM.saveBtn} ${GENERAL.icon('loading')}`).exists();
+ });
+
+ module('it submits', function () {
+ test('patch data for existing, deleted and new keys', async function (assert) {
+ await this.renderComponent();
+
+ // patch existing key
+ await click(FORM.patchEdit());
+ await fillIn(FORM.valueInput(), 'bar');
+ // in qunit we have to unfocus the input so the following click event works on first try
+ await blur(FORM.valueInput());
+
+ // delete existing key
+ await click(FORM.patchDelete(1));
+ assert.dom(FORM.patchAlert('delete', 1)).hasText('This key value pair is marked for deletion.');
+ assert.dom(FORM.keyInput(1)).hasClass('line-through');
+ assert.dom(`${FORM.patchAlert('delete', 1)} ${GENERAL.icon('trash')}`).exists();
+ // value is set to null under the hood, confirm the non-string warning doesn't display
+ assert
+ .dom(FORM.patchAlert('value-warning', 1))
+ .doesNotExist('non-string warning does not render for null values');
+
+ // add new key and click add
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'aValue');
+ await click(FORM.patchAdd);
+
+ // add new key and do NOT click add
+ await fillIn(FORM.keyInput('new'), 'bKey');
+ await fillIn(FORM.valueInput('new'), 'bValue');
+
+ await click(FORM.saveBtn);
+
+ const [data] = this.onSubmit.lastCall.args;
+ assert.propEqual(
+ data,
+ { baz: null, foo: 'bar', aKey: 'aValue', bKey: 'bValue' },
+ `onSubmit called with ${JSON.stringify(data)}`
+ );
+ });
+
+ test('patch data when every action is canceled', async function (assert) {
+ await this.renderComponent();
+
+ await click(FORM.patchEdit());
+ await fillIn(FORM.valueInput(), 'bar');
+ // in qunit we have to unfocus the input so the following click event works on the first try
+ await blur(FORM.valueInput());
+ await click(FORM.patchDelete(1));
+
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'aValue');
+ await click(FORM.patchAdd);
+
+ // undo every action
+ await click(FORM.patchUndo(0)); // undo edit
+ await click(FORM.patchUndo(1)); // undo delete
+ await click(FORM.patchUndo(2)); // remove new row
+ await click(FORM.saveBtn);
+
+ const [data] = this.onSubmit.lastCall.args;
+ assert.propEqual(data, {}, `onSubmit called with ${JSON.stringify(data)}`);
+ });
+ });
+
+ module('it does not submit', function () {
+ test('new keys that duplicate original subkeys', async function (assert) {
+ await this.renderComponent();
+ // patch existing key
+ await click(FORM.patchEdit());
+ await fillIn(FORM.valueInput(), 'bar');
+ // add duplicate
+ await fillIn(FORM.keyInput('new'), 'foo');
+ await fillIn(FORM.valueInput('new'), 'duplicate');
+ await click(FORM.saveBtn);
+
+ assert
+ .dom(GENERAL.inlineError)
+ .hasText('This form contains validations errors, please resolve those before submitting.');
+ });
+
+ test('newly added keys edited to duplicate original subkeys', async function (assert) {
+ await this.renderComponent();
+
+ // patch existing key
+ await click(FORM.patchEdit());
+ await fillIn(FORM.valueInput(), 'bar');
+ // add new key and click add
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'aValue');
+ await click(FORM.patchAdd);
+ // go back and edit "aKey" to match pre-existing subkey 'foo'
+ await fillIn(FORM.keyInput(2), 'foo');
+ await click(FORM.saveBtn);
+
+ assert
+ .dom(GENERAL.inlineError)
+ .hasText('This form contains validations errors, please resolve those before submitting.');
+ });
+
+ test('new keys that duplicate recently added keys', async function (assert) {
+ await this.renderComponent();
+
+ // create new key and click add
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'aValue');
+ await click(FORM.patchAdd);
+ // add same key name as above
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'duplicate');
+ await click(FORM.saveBtn);
+
+ assert
+ .dom(GENERAL.inlineError)
+ .hasText('This form contains validations errors, please resolve those before submitting.');
+ });
+ });
+
+ module('duplicate keys error', function () {
+ const validationMessage = (name) =>
+ `"${name}" key already exists. Update the value of the existing key or rename this one.`;
+
+ test('it renders for new keys that duplicate original subkeys', async function (assert) {
+ await this.renderComponent();
+
+ await fillIn(FORM.keyInput('new'), 'foo');
+ await blur(FORM.keyInput('new')); // unfocus input to fire input change event and validation
+ assert.dom(FORM.patchAlert('validation', 'new')).hasText(validationMessage('foo'));
+
+ await click(FORM.patchAdd);
+ assert
+ .dom(FORM.keyInput('new'))
+ .hasValue('foo', 'clicking "Add" is a noop, new row still has invalid value');
+
+ await typeIn(FORM.keyInput('new'), '2'); // input value is now "foo2"
+ await blur(FORM.keyInput('new')); // unfocus input
+ assert
+ .dom(FORM.patchAlert('validation', 'new'))
+ .doesNotExist('error disappears when key no longer matches');
+
+ await click(FORM.patchAdd);
+ assert.dom(FORM.keyInput('new')).hasValue('', 'clicking "Add" creates a new row');
+ });
+
+ test('it renders for newly added keys edited to duplicate original subkeys', async function (assert) {
+ // if a key is a duplicate then clicking "Add" does not work
+ // this test asserts an error appears if a user goes back to rename a previously added key
+ // to a duplicate and that it is not added to the payload
+ await this.renderComponent();
+
+ // add new key and click add
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'aValue');
+ await click(FORM.patchAdd);
+
+ // add another
+ await fillIn(FORM.keyInput('new'), 'bKey');
+ await fillIn(FORM.valueInput('new'), 'bValue');
+
+ // go back and update "aKey" to match a pre-existing subkey
+ await fillIn(FORM.keyInput(2), 'foo');
+ await blur(FORM.keyInput(2)); // unfocus input
+ assert.dom(FORM.patchAlert('validation', 2)).hasText(validationMessage('foo'));
+ await typeIn(FORM.keyInput(2), '2');
+ await blur(FORM.keyInput(2)); // unfocus input
+ assert
+ .dom(FORM.patchAlert('validation', 2))
+ .doesNotExist('error disappears when key no longer matches');
+ });
+
+ test('it renders for new keys that duplicate recently added keys', async function (assert) {
+ await this.renderComponent();
+
+ // create new key and click add
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'aValue');
+ await click(FORM.patchAdd);
+
+ // add same key name as above
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'bValue');
+ await blur(FORM.keyInput('new')); // unfocus input
+ assert.dom(FORM.patchAlert('validation', 'new')).hasText(validationMessage('aKey'));
+
+ await typeIn(FORM.keyInput('new'), '2');
+ await blur(FORM.keyInput('new')); // unfocus input
+ assert
+ .dom(FORM.patchAlert('validation', 'new'))
+ .doesNotExist('error disappears when key no longer matches');
+ });
+
+ test('it disappears after clicking "Remove" for duplicate', async function (assert) {
+ await this.renderComponent();
+
+ // create new key and click add
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'aValue');
+ await click(FORM.patchAdd);
+
+ // add same key name as above
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'bValue');
+ await blur(FORM.keyInput('new')); // unfocus input
+ assert.dom(FORM.patchAlert('validation', 'new')).hasText(validationMessage('aKey'));
+
+ await click(FORM.patchUndo(2));
+ assert.dom(FORM.patchAlert('validation', 'new')).doesNotExist('error clears when duplicate is removed');
+ await click(FORM.patchAdd);
+ // assert a new row is added
+ this.assertEmptyRow(assert);
+ });
+
+ test('it disappears after clicking "Remove" for multiple duplicates', async function (assert) {
+ await this.renderComponent();
+
+ // create new key and click add
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'aValue');
+ await click(FORM.patchAdd);
+
+ await fillIn(FORM.keyInput('new'), 'bKey');
+ await fillIn(FORM.valueInput('new'), 'bValue');
+ await click(FORM.patchAdd);
+
+ // and add another duplicate
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'aValue');
+
+ // edit key to be same as first
+ await fillIn(FORM.keyInput(3), 'aKey');
+
+ // remove all but latest key
+ await click(FORM.patchUndo(3));
+ await click(FORM.patchUndo(2));
+ await click(FORM.patchAdd);
+
+ // assert a new row is added
+ this.assertEmptyRow(assert);
+ });
+
+ test('it does not render when refocusing a previously inputted key', async function (assert) {
+ await this.renderComponent();
+
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'aValue');
+ await click(FORM.patchAdd);
+ // focus and unfocus
+ await focus(FORM.keyInput(2));
+ await blur(FORM.keyInput(2));
+ assert.dom(FORM.patchAlert('validation', 2)).doesNotExist();
+ });
+
+ // accounts for an edge case where not setting invalid key values caused
+ // error to show for outdated keys and then updating the key did not remove error
+ test('it disappears for new key when another duplicate key is edited', async function (assert) {
+ await this.renderComponent();
+
+ // create new key and click add
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'aValue');
+ await click(FORM.patchAdd);
+ // edit key to be a duplicate of original subkey, "foo"
+ // since "foo" is invalid it does not update the tracked KV.key value.
+ // the input value reads "foo" but underlying KV class key value is still "aKey"
+ await fillIn(FORM.keyInput(2), 'foo');
+ // fill in new key that matches underlying value of input above
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ // validation errors now show for both inputs even though no visible input reads "aKey" (while strange UX, it's a super edge case)
+ // editing input at index 2 ("foo") should make both disappear
+ await fillIn(FORM.keyInput(2), 'foo2');
+ await blur(FORM.keyInput(2)); // unfocus input
+ assert.dom(FORM.patchAlert('validation', 2)).doesNotExist();
+ assert.dom(FORM.patchAlert('validation', 'new')).doesNotExist();
+ });
+ });
+
+ module('it shows whitespace warning', function () {
+ test('for new keys with whitespace', async function (assert) {
+ await this.renderComponent();
+
+ await fillIn(FORM.keyInput('new'), 'a space');
+ await blur(FORM.keyInput('new')); // unfocus input
+ assert.dom(FORM.patchAlert('key-warning', 'new')).hasText(WHITESPACE_WARNING('this key'));
+
+ await fillIn(FORM.keyInput('new'), 'nospace');
+ await blur(FORM.keyInput('new')); // unfocus input
+ assert.dom(FORM.patchAlert('key-warning', 'new')).doesNotExist('warning disappears when key updates');
+ });
+
+ test('for newly added keys edited to have whitespace', async function (assert) {
+ await this.renderComponent();
+
+ // add new key and click add
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'aValue');
+ await click(FORM.patchAdd);
+
+ // add another
+ await fillIn(FORM.keyInput('new'), 'bKey');
+ await fillIn(FORM.valueInput('new'), 'bValue');
+
+ // go back and change "aKey" to have a space
+ await fillIn(FORM.keyInput(2), 'a key');
+ await blur(FORM.keyInput(2)); // unfocus input
+ assert.dom(FORM.patchAlert('key-warning', 2)).hasText(WHITESPACE_WARNING('this key'));
+
+ await fillIn(FORM.keyInput(2), 'aKey');
+ await blur(FORM.keyInput(2)); // unfocus input
+ assert.dom(FORM.patchAlert('key-warning', 2)).doesNotExist('warning disappears when key updates');
+ });
+
+ test('for keys with whitespace after clicking "Add"', async function (assert) {
+ await this.renderComponent();
+
+ // add new key with space and click add
+ await fillIn(FORM.keyInput('new'), 'a key');
+ await click(FORM.patchAdd);
+ assert
+ .dom(FORM.patchAlert('key-warning', 2))
+ .hasText(WHITESPACE_WARNING('this key'), 'warning is attached to relevant key');
+ assert
+ .dom(FORM.patchAlert('key-warning', 'new'))
+ .doesNotExist('there is no whitespace warning for the new empty row');
+
+ // add another
+ await fillIn(FORM.keyInput('new'), 'b key');
+ await blur(FORM.keyInput('new')); // unfocus input
+ assert
+ .dom(FORM.patchAlert('key-warning', 2))
+ .hasText(WHITESPACE_WARNING('this key'), 'warning is still attached to relevant key');
+ assert
+ .dom(FORM.patchAlert('key-warning', 'new'))
+ .hasText(WHITESPACE_WARNING('this key'), 'new key also has whitespace warning');
+ });
+ });
+
+ module('it shows non-string warning', function () {
+ const NON_STRING_VALUES = [0, 123, '{ "a": "b" }', 'null'];
+
+ NON_STRING_VALUES.forEach((value) => {
+ test(`for new non-string values: ${value}`, async function (assert) {
+ await this.renderComponent();
+
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), value);
+ await blur(FORM.valueInput('new')); // unfocus input
+ assert.dom(FORM.patchAlert('value-warning', 'new')).hasText(NON_STRING_WARNING);
+
+ await typeIn(FORM.valueInput('new'), 'abc');
+ await blur(FORM.valueInput('new')); // unfocus input
+ assert
+ .dom(FORM.patchAlert('value-warning', 'new'))
+ .doesNotExist(`warning disappears when ${value} includes a non-parsable string`);
+ });
+ });
+
+ NON_STRING_VALUES.forEach((value) => {
+ test(`for newly added values edited to non-string values: ${value}`, async function (assert) {
+ await this.renderComponent();
+
+ // add new key and click add
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), 'aValue');
+ await click(FORM.patchAdd);
+
+ // add another
+ await fillIn(FORM.keyInput('new'), 'bKey');
+ await fillIn(FORM.valueInput('new'), 'bValue');
+
+ // go back and change "aKey" to have a non-string
+ await fillIn(FORM.valueInput(2), value);
+ await blur(FORM.valueInput(2)); // unfocus input
+ assert.dom(FORM.patchAlert('value-warning', 2)).hasText(NON_STRING_WARNING);
+
+ await fillIn(FORM.valueInput(2), 'abc');
+ await blur(FORM.valueInput(2)); // unfocus input
+ assert
+ .dom(FORM.patchAlert('value-warning', 2))
+ .doesNotExist(`warning disappears when ${value} is replaced with a string`);
+ });
+ });
+
+ NON_STRING_VALUES.forEach((value) => {
+ test(`for non-string values after clicking "Add": ${value}`, async function (assert) {
+ await this.renderComponent();
+
+ // add non-string value and click add
+ await fillIn(FORM.keyInput('new'), 'aKey');
+ await fillIn(FORM.valueInput('new'), value);
+ await click(FORM.patchAdd);
+ assert
+ .dom(FORM.patchAlert('value-warning', 2))
+ .hasText(NON_STRING_WARNING, 'warning is attached to relevant row');
+ assert
+ .dom(FORM.patchAlert('value-warning', 'new'))
+ .doesNotExist('there is no non-string warning for the new empty row');
+
+ // add another
+ await fillIn(FORM.keyInput('new'), 'bKey');
+ await fillIn(FORM.valueInput('new'), value);
+ await blur(FORM.valueInput('new')); // unfocus input
+ assert
+ .dom(FORM.patchAlert('value-warning', 2))
+ .hasText(NON_STRING_WARNING, 'warning is still attached to relevant row');
+ assert
+ .dom(FORM.patchAlert('value-warning', 'new'))
+ .hasText(NON_STRING_WARNING, 'new row also has non-string warning');
+ });
+ });
+ });
+});
diff --git a/ui/tests/integration/components/kv/kv-patch-editor/row-test.js b/ui/tests/integration/components/kv/kv-patch-editor/row-test.js
new file mode 100644
index 0000000000..0e024d3cf3
--- /dev/null
+++ b/ui/tests/integration/components/kv/kv-patch-editor/row-test.js
@@ -0,0 +1,169 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'vault/tests/helpers';
+import { setupEngine } from 'ember-engines/test-support';
+import { blur, click, fillIn, render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { FORM } from 'vault/tests/helpers/kv/kv-selectors';
+import sinon from 'sinon';
+
+module('Integration | Component | kv | kv-patch-editor/row', function (hooks) {
+ setupRenderingTest(hooks);
+ setupEngine(hooks, 'kv');
+
+ hooks.beforeEach(function () {
+ // for simplicity using an object to test views
+ this.updateKey = sinon.spy();
+ this.undoKey = sinon.spy();
+ this.updateValue = sinon.spy();
+ this.updateState = sinon.spy();
+ this.kvClass = {
+ key: 'foo',
+ value: undefined,
+ state: 'disabled',
+ updateValue: this.updateValue,
+ updateState: this.updateState,
+ };
+
+ this.renderComponent = async () => {
+ return render(
+ hbs`
+ `,
+ { owner: this.engine }
+ );
+ };
+ });
+
+ module('it renders original subkeys', function (hooks) {
+ hooks.beforeEach(function () {
+ this.isOriginalSubkey = () => true;
+ });
+
+ test('in disabled state', async function (assert) {
+ await this.renderComponent();
+ assert.dom(FORM.keyInput()).hasValue('foo');
+ assert.dom(FORM.keyInput()).isDisabled();
+
+ assert.dom(FORM.valueInput()).hasValue('');
+ assert.dom(FORM.valueInput()).isDisabled();
+
+ assert.dom(FORM.patchEdit()).exists();
+ assert.dom(FORM.patchDelete()).exists();
+ assert.dom(FORM.patchUndo()).doesNotExist();
+ });
+
+ test('in enabled state', async function (assert) {
+ this.kvClass.state = 'enabled';
+ await this.renderComponent();
+ assert.dom(FORM.keyInput()).hasValue('foo');
+ assert.dom(FORM.keyInput()).hasAttribute('readonly');
+
+ assert.dom(FORM.valueInput()).hasValue('');
+ assert.dom(FORM.valueInput()).isEnabled();
+
+ assert.dom(FORM.patchEdit()).doesNotExist();
+ assert.dom(FORM.patchDelete()).doesNotExist();
+ assert.dom(FORM.patchUndo()).hasText('Cancel');
+ });
+
+ test('in deleted state', async function (assert) {
+ this.kvClass.state = 'deleted';
+ await this.renderComponent();
+ assert.dom(FORM.keyInput()).hasValue('foo');
+ assert.dom(FORM.keyInput()).hasAttribute('readonly');
+
+ assert.dom(FORM.valueInput()).hasValue('');
+ assert.dom(FORM.keyInput()).hasAttribute('readonly');
+
+ assert.dom(FORM.patchEdit()).doesNotExist();
+ assert.dom(FORM.patchDelete()).doesNotExist();
+ assert.dom(FORM.patchUndo()).hasText('Cancel');
+ assert.dom(FORM.patchAlert('delete', 0)).hasText('This key value pair is marked for deletion.');
+ });
+
+ test('it clicks undo', async function (assert) {
+ this.kvClass.state = 'enabled';
+ await this.renderComponent();
+ await click(FORM.patchUndo());
+
+ const [arg] = this.undoKey.lastCall.args;
+ assert.propEqual(arg, this.kvClass, 'undoKey is called with class');
+ });
+ });
+
+ module('it renders new subkeys', function (hooks) {
+ hooks.beforeEach(function () {
+ this.isOriginalSubkey = () => false;
+ this.kvClass = { ...this.kvClass, value: 'bar', state: 'enabled' };
+ });
+
+ // only test this state because new keys are only ever 'enabled'
+ test('in enabled state', async function (assert) {
+ await this.renderComponent();
+ assert.dom(FORM.keyInput()).hasValue('foo');
+ assert.dom(FORM.keyInput()).isNotDisabled();
+
+ assert.dom(FORM.valueInput()).hasValue('bar');
+ assert.dom(FORM.valueInput()).isNotDisabled();
+
+ assert.dom(FORM.patchEdit()).doesNotExist();
+ assert.dom(FORM.patchDelete()).doesNotExist();
+ assert.dom(FORM.patchUndo()).hasText('Remove');
+ });
+
+ test('it updates key', async function (assert) {
+ this.kvClass.key = '';
+ await this.renderComponent();
+ await fillIn(FORM.keyInput(), 'foo');
+ await blur(FORM.keyInput());
+
+ const [arg, event] = this.updateKey.lastCall.args;
+ assert.propEqual(arg, this.kvClass, 'updateKey is called with class object');
+ assert.strictEqual(event.target.value, 'foo', 'updateKey is called with event');
+ });
+
+ test('it clicks undo', async function (assert) {
+ await this.renderComponent();
+ await click(FORM.patchUndo());
+
+ const [arg] = this.undoKey.lastCall.args;
+ assert.propEqual(arg, this.kvClass, 'undoKey is called with class');
+ });
+ });
+
+ test('it updates value', async function (assert) {
+ this.kvClass.state = 'enabled';
+ await this.renderComponent();
+ await fillIn(FORM.valueInput(), 'bar');
+ await blur(FORM.valueInput());
+
+ const [event] = this.updateValue.lastCall.args;
+ assert.strictEqual(event.target.value, 'bar', 'updateValue is called with blur event');
+ });
+
+ test('it clicks enable', async function (assert) {
+ await this.renderComponent();
+ await click(FORM.patchEdit());
+
+ const [state] = this.updateState.lastCall.args;
+ assert.strictEqual(state, 'enabled', 'updateState is called with "enabled"');
+ });
+
+ test('it clicks delete', async function (assert) {
+ await this.renderComponent();
+ await click(FORM.patchDelete());
+
+ const [state] = this.updateState.lastCall.args;
+ assert.strictEqual(state, 'deleted', 'updateState is called with "deleted"');
+ });
+});
diff --git a/ui/tests/unit/utils/validators-test.js b/ui/tests/unit/utils/validators-test.js
index f8d292447e..acd0011bf1 100644
--- a/ui/tests/unit/utils/validators-test.js
+++ b/ui/tests/unit/utils/validators-test.js
@@ -10,6 +10,8 @@ import validators from 'vault/utils/validators';
module('Unit | Util | validators', function (hooks) {
setupTest(hooks);
+ // * MODEL VALIDATORS
+
test('it should validate presence', function (assert) {
let isValid;
const check = (value) => (isValid = validators.presence(value));
@@ -76,7 +78,7 @@ module('Unit | Util | validators', function (hooks) {
assert.true(isValid, 'Valid for 0 as a string');
});
- test('it should validate white space', function (assert) {
+ test('it should validate whitespace', function (assert) {
let isValid;
const check = (prop) => (isValid = validators.containsWhiteSpace(prop));
check('validText');
@@ -105,4 +107,64 @@ module('Unit | Util | validators', function (hooks) {
check('also/invalid/');
assert.false(isValid, 'Invalid when text contains and ends in slash');
});
+
+ // * GENERAL VALIDATORS
+ test('it returns whether a value has whitespace or not', function (assert) {
+ let hasWhitespace;
+ const check = (value) => (hasWhitespace = validators.hasWhitespace(value));
+
+ check('someText');
+ assert.false(hasWhitespace, 'False when text contains no spaces');
+
+ check('some-text');
+ assert.false(hasWhitespace, 'False when text contains no spaces and hyphen');
+
+ check('some space');
+ assert.true(hasWhitespace, 'True when text contains single space');
+
+ check('text with spaces');
+ assert.true(hasWhitespace, 'True when text contains multiple spaces');
+
+ check(' leadingSpace');
+ assert.true(hasWhitespace, 'True when text has leading whitespace');
+
+ check('trailingSpace ');
+ assert.true(hasWhitespace, 'True when text has trailing whitespace');
+ });
+
+ test('it returns whether a string input values evaluated as non-strings', function (assert) {
+ let isNonString;
+ const check = (value) => (isNonString = validators.isNonString(value));
+ check(' {"foo": "bar"} ');
+ assert.true(isNonString, 'returns true when value contains an object');
+
+ check(' ["a", "b", "c"] ');
+ assert.true(isNonString, 'returns true when value contains an array');
+
+ check('123');
+ assert.true(isNonString, 'returns true when value is numbers');
+
+ check('123e6');
+ assert.true(isNonString, 'returns true when value is numbers with exponents');
+
+ check('true');
+ assert.true(isNonString, 'returns true when value is true');
+
+ // falsy values that return true because JSON.parse() is successful
+ check('null');
+ assert.true(isNonString, 'returns true when value is null');
+
+ check('false');
+ assert.true(isNonString, 'returns true when value is false');
+
+ check('0');
+ assert.true(isNonString, 'returns true when value is "0"');
+
+ // falsy
+ check('undefined');
+ assert.false(isNonString, 'returns false when value is undefined');
+
+ check('my string');
+ assert.false(isNonString, 'returns false when value is letters');
+ });
});