From eaf47c4c00384ea882e34b33a0cc36b096cf128e Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Wed, 14 Aug 2024 11:52:33 -0700 Subject: [PATCH] UI: Build `kv-patch-editor` form (#28060) * build kv-patch-editor component * add tests * use validator helpers in kv-object-editor * update class name in version-history * remove is- from css class * move whitespace warning and non-string values warning messages to validators util * break editor component into smaller ones * fix typo * add docs * rename files and move to directory, add tests for new templates * fix some bugs and add tests! * fix validation bug and update tests * capitalize item in helper * remove comment * and one more comment change --- ui/app/components/secret-create-or-update.js | 3 + ui/app/models/auth-method.js | 4 +- ui/app/models/secret-engine.js | 4 +- ui/app/styles/helper-classes/layout.scss | 8 +- ui/app/styles/helper-classes/typography.scss | 3 + .../components/secret-create-or-update.hbs | 2 +- ui/app/utils/validators.js | 55 +- .../components/messages/preview-image.hbs | 2 +- .../addon/components/kv-object-editor.hbs | 12 +- .../core/addon/components/kv-object-editor.js | 13 +- .../components/kv-patch-editor/alerts.hbs | 22 + .../addon/components/kv-patch-editor/form.hbs | 83 +++ .../addon/components/kv-patch-editor/form.js | 194 ++++++ .../addon/components/kv-patch-editor/row.hbs | 76 +++ .../page/secret/metadata/version-history.hbs | 2 +- ui/tests/helpers/kv/kv-selectors.js | 6 + .../kv/kv-patch-editor/alerts-test.js | 80 +++ .../kv/kv-patch-editor/form-test.js | 566 ++++++++++++++++++ .../components/kv/kv-patch-editor/row-test.js | 169 ++++++ ui/tests/unit/utils/validators-test.js | 64 +- 20 files changed, 1335 insertions(+), 33 deletions(-) create mode 100644 ui/lib/kv/addon/components/kv-patch-editor/alerts.hbs create mode 100644 ui/lib/kv/addon/components/kv-patch-editor/form.hbs create mode 100644 ui/lib/kv/addon/components/kv-patch-editor/form.js create mode 100644 ui/lib/kv/addon/components/kv-patch-editor/row.hbs create mode 100644 ui/tests/integration/components/kv/kv-patch-editor/alerts-test.js create mode 100644 ui/tests/integration/components/kv/kv-patch-editor/form-test.js create mode 100644 ui/tests/integration/components/kv/kv-patch-editor/row-test.js diff --git a/ui/app/components/secret-create-or-update.js b/ui/app/components/secret-create-or-update.js index 9bc9b056e3..53e3593c2e 100644 --- a/ui/app/components/secret-create-or-update.js +++ b/ui/app/components/secret-create-or-update.js @@ -35,6 +35,7 @@ import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { isBlank, isNone } from '@ember/utils'; import { task, waitForEvent } from 'ember-concurrency'; +import { WHITESPACE_WARNING } from 'vault/utils/validators'; const LIST_ROUTE = 'vault.cluster.secrets.backend.list'; const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; @@ -53,6 +54,8 @@ export default class SecretCreateOrUpdate extends Component { @service router; @service store; + whitespaceWarning = WHITESPACE_WARNING('path'); + @action setup(elem, [secretData, mode]) { this.codemirrorString = secretData.toJSONString(); diff --git a/ui/app/models/auth-method.js b/ui/app/models/auth-method.js index d1b64b10bc..1d3ad89dba 100644 --- a/ui/app/models/auth-method.js +++ b/ui/app/models/auth-method.js @@ -12,14 +12,14 @@ import { allMethods } from 'vault/helpers/mountable-auth-methods'; import lazyCapabilities from 'vault/macros/lazy-capabilities'; import { action } from '@ember/object'; import { camelize } from '@ember/string'; +import { WHITESPACE_WARNING } from 'vault/utils/validators'; const validations = { path: [ { type: 'presence', message: "Path can't be blank." }, { type: 'containsWhiteSpace', - message: - "Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.", + message: WHITESPACE_WARNING('path'), level: 'warn', }, ], diff --git a/ui/app/models/secret-engine.js b/ui/app/models/secret-engine.js index 555918e97e..acb1fb0e2e 100644 --- a/ui/app/models/secret-engine.js +++ b/ui/app/models/secret-engine.js @@ -10,6 +10,7 @@ import { withModelValidations } from 'vault/decorators/model-validations'; import { withExpandedAttributes } from 'vault/decorators/model-expanded-attributes'; import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; import { isAddonEngine, allEngines } from 'vault/helpers/mountable-secret-engines'; +import { WHITESPACE_WARNING } from 'vault/utils/validators'; const LINKED_BACKENDS = supportedSecretBackends(); @@ -22,8 +23,7 @@ const validations = { { type: 'presence', message: "Path can't be blank." }, { type: 'containsWhiteSpace', - message: - "Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.", + message: WHITESPACE_WARNING('path'), level: 'warn', }, ], diff --git a/ui/app/styles/helper-classes/layout.scss b/ui/app/styles/helper-classes/layout.scss index fa654da798..25b307fecd 100644 --- a/ui/app/styles/helper-classes/layout.scss +++ b/ui/app/styles/helper-classes/layout.scss @@ -60,11 +60,15 @@ width: 100%; } -.is-three-fourths-width { +.three-fourths-width { width: 75%; } -.is-two-thirds-width { +.one-fourth-width { + width: 25%; +} + +.two-thirds-width { width: 66%; } diff --git a/ui/app/styles/helper-classes/typography.scss b/ui/app/styles/helper-classes/typography.scss index 8466b0c294..3a8f050641 100644 --- a/ui/app/styles/helper-classes/typography.scss +++ b/ui/app/styles/helper-classes/typography.scss @@ -61,6 +61,9 @@ .is-no-underline { text-decoration: none; } +.line-through { + text-decoration: line-through; +} // Text transformations .is-lowercase { diff --git a/ui/app/templates/components/secret-create-or-update.hbs b/ui/app/templates/components/secret-create-or-update.hbs index 8650d73fda..dd5e72029c 100644 --- a/ui/app/templates/components/secret-create-or-update.hbs +++ b/ui/app/templates/components/secret-create-or-update.hbs @@ -44,7 +44,7 @@ > Warning - Your secret path contains whitespace. If this is desired, you'll need to encode it with %20 in API calls. + {{this.whitespaceWarning}} {{/if}} diff --git a/ui/app/utils/validators.js b/ui/app/utils/validators.js index 2ee718f8f6..3d380f3758 100644 --- a/ui/app/utils/validators.js +++ b/ui/app/utils/validators.js @@ -4,7 +4,14 @@ */ import { isPresent } from '@ember/utils'; +import { capitalize } from '@ember/string'; +/* +* Model Validators +these return false when the condition fails because false means "invalid" +for example containsWhiteSpace returns "false" when a value HAS whitespace +because that is an invalid value +*/ export const presence = (value) => isPresent(value); export const length = (value, { nullable = false, min, max } = {}) => { @@ -25,12 +32,8 @@ export const number = (value, { nullable = false } = {}) => { return !isNaN(value); }; -/* -the following validations return false (invalid) if the condition is met -*/ export const containsWhiteSpace = (value) => { - const validation = new RegExp('\\s', 'g'); // search for whitespace - return !validation.test(value); + return !hasWhitespace(value); }; export const endsInSlash = (value) => { @@ -38,4 +41,44 @@ export const endsInSlash = (value) => { return !validation.test(value); }; -export default { presence, length, number, containsWhiteSpace, endsInSlash }; +/* +* General Validators +these utils return true or false relative to the function name +*/ + +export const hasWhitespace = (value) => { + const validation = new RegExp('\\s', 'g'); // search for whitespace + return validation.test(value); +}; + +// HTML form inputs transform values to a string type +// this returns if the value can be evaluated as non-string, i.e. "null" +export const isNonString = (value) => { + try { + // if parsable the value could be an object, array, number, null, true or false + JSON.parse(value); + return true; + } catch (e) { + return false; + } +}; + +export const WHITESPACE_WARNING = (item) => + `${capitalize( + item + )} contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.`; + +export const NON_STRING_WARNING = + 'This value will be saved as a string. If you need to save a non-string value, please use the JSON editor.'; + +export default { + presence, + length, + number, + containsWhiteSpace, + endsInSlash, + isNonString, + hasWhitespace, + WHITESPACE_WARNING, + NON_STRING_WARNING, +}; diff --git a/ui/lib/config-ui/addon/components/messages/preview-image.hbs b/ui/lib/config-ui/addon/components/messages/preview-image.hbs index b6bc75c9c8..17f5d8fc9c 100644 --- a/ui/lib/config-ui/addon/components/messages/preview-image.hbs +++ b/ui/lib/config-ui/addon/components/messages/preview-image.hbs @@ -6,7 +6,7 @@ diff --git a/ui/lib/core/addon/components/kv-object-editor.hbs b/ui/lib/core/addon/components/kv-object-editor.hbs index 04663d3a7a..a0b4695ee2 100644 --- a/ui/lib/core/addon/components/kv-object-editor.hbs +++ b/ui/lib/core/addon/components/kv-object-editor.hbs @@ -72,20 +72,12 @@ {{#if (this.showWhitespaceWarning row.name)}}
- +
{{/if}} {{#if (this.showNonStringWarning row.value)}}
- +
{{/if}} {{/each}} diff --git a/ui/lib/core/addon/components/kv-object-editor.js b/ui/lib/core/addon/components/kv-object-editor.js index 072dfa394b..c96a68c287 100644 --- a/ui/lib/core/addon/components/kv-object-editor.js +++ b/ui/lib/core/addon/components/kv-object-editor.js @@ -10,6 +10,7 @@ import { assert } from '@ember/debug'; import { action } from '@ember/object'; import { guidFor } from '@ember/object/internals'; import KVObject from 'vault/lib/kv-object'; +import { hasWhitespace, isNonString, NON_STRING_WARNING, WHITESPACE_WARNING } from 'vault/utils/validators'; /** * @module KvObjectEditor @@ -37,6 +38,8 @@ import KVObject from 'vault/lib/kv-object'; export default class KvObjectEditor extends Component { // kvData is type ArrayProxy, so addObject etc are fine here @tracked kvData; + whitespaceWarning = WHITESPACE_WARNING('key'); + nonStringWarning = NON_STRING_WARNING; get placeholders() { return { @@ -85,15 +88,11 @@ export default class KvObjectEditor extends Component { } showWhitespaceWarning = (name) => { if (this.args.allowWhiteSpace) return false; - return new RegExp('\\s', 'g').test(name); + return hasWhitespace(name); }; + showNonStringWarning = (value) => { if (!this.args.warnNonStringValues) return false; - try { - JSON.parse(value); - return true; - } catch (e) { - return false; - } + return isNonString(value); }; } diff --git a/ui/lib/kv/addon/components/kv-patch-editor/alerts.hbs b/ui/lib/kv/addon/components/kv-patch-editor/alerts.hbs new file mode 100644 index 0000000000..12dcd8ab8a --- /dev/null +++ b/ui/lib/kv/addon/components/kv-patch-editor/alerts.hbs @@ -0,0 +1,22 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{! display only template for rendering alerts for the kv patch editor }} + +{{#if @keyError}} + + {{@keyError}} + +{{/if}} +{{#if @keyWarning}} + + {{@keyWarning}} + +{{/if}} +{{#if @valueWarning}} + + {{@valueWarning}} + +{{/if}} \ No newline at end of file diff --git a/ui/lib/kv/addon/components/kv-patch-editor/form.hbs b/ui/lib/kv/addon/components/kv-patch-editor/form.hbs new file mode 100644 index 0000000000..8115e8dff3 --- /dev/null +++ b/ui/lib/kv/addon/components/kv-patch-editor/form.hbs @@ -0,0 +1,83 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+
+ + Key + + + Value + +
+ + {{! Rows for existing keys (includes new rows after user clicks "Add") }} + {{#each this.patchData as |kv idx|}} + + {{/each}} + + {{! Single row of empty inputs for adding new key/value pairs }} +
+ + +
+ + +
+ +
+
+
+ + + +
+ +
+ + Reveal subkeys in JSON + + {{#if this.showSubkeys}} + + {{/if}} +
+ +
+ + + + + + + {{#if this.submitError}} + + {{/if}} + \ No newline at end of file diff --git a/ui/lib/kv/addon/components/kv-patch-editor/form.js b/ui/lib/kv/addon/components/kv-patch-editor/form.js new file mode 100644 index 0000000000..e0771d8ef5 --- /dev/null +++ b/ui/lib/kv/addon/components/kv-patch-editor/form.js @@ -0,0 +1,194 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { A } from '@ember/array'; +import { hasWhitespace, isNonString, WHITESPACE_WARNING, NON_STRING_WARNING } from 'vault/utils/validators'; + +/** + * @module KvPatchEditor::Form + * @description + * This component renders one of two ways to patch a KV v2 secret (the other is using the JSON editor). + * Each top-level subkey returned by the API endpoint renders in a disabled column with an empty (also disabled) value input beside it. + * Initially, an edit or delete button is left of the value input. Clicking "Delete" marks a key for deletion (it does not remove the row). + * Clicking "Edit" enables the value input (the key input for retrieved subkeys is never editable). Users can then input a new value for that key. + * If either button is clicked it is replaced by a "Cancel" button. Canceling empties the value input and returns it to a 'disabled' state + * + * Additionally, there is one empty row at the bottom for adding new key/value pairs. + * Clicking "Add" adds the new key/value pair to the internally tracked state (an array) and creates a new empty row. + * Newly added keys are editable and therefore never disabled. + * A newly added pair can be undone by clicking "Remove" which deletes the row and removes it from the tracked array. + * + * Clicking the "Reveal subkeys in JSON" toggle displays the full, nested subkey structure returned by the API. + * + * + * @example + * + * + * @param {object} subkeys - leaf keys of a kv v2 secret, all values (unless a nested object with more keys) return null. https://developer.hashicorp.com/vault/api-docs/secret/kv/kv-v2#read-secret-subkeys + * @param {function} onSubmit - called when form is saved, called with with the key value object containing patch data + * @param {function} onCancel - called when form is canceled + * @param {boolean} isSaving - if true, disables the save and cancel buttons. useful if the onSubmit callback is a concurrency task + */ + +export class KeyValueState { + @tracked key; + @tracked value; + @tracked state; // 'enabled', 'disabled' or 'deleted' + @tracked keyError; + + constructor({ key, value = undefined, state = 'disabled' }) { + this.key = key; + this.value = value; + this.state = state; + } + + get keyWarning() { + return hasWhitespace(this.key) ? WHITESPACE_WARNING('this key') : ''; + } + + get valueWarning() { + if (this.value === null) return ''; + return isNonString(this.value) ? NON_STRING_WARNING : ''; + } + + reset() { + this.value = undefined; + this.state = 'disabled'; + } + + @action + updateValue(event) { + this.value = event.target.value; + } + + @action + updateState(state) { + this.state = state; + } +} + +export default class KvPatchEditor extends Component { + @tracked patchData; // key value pairs in form + @tracked showSubkeys = false; + @tracked submitError; + + // tracked variables for new (initially empty) row of inputs. + // once a user clicks "Add" a KeyValueState class is instantiated for that row + @tracked newKey; + @tracked newValue; + + isOriginalSubkey = (key) => Object.keys(this.args.subkeys).includes(key); + + constructor() { + super(...arguments); + const kvData = Object.keys(this.args.subkeys).map((key) => this.generateData(key)); + this.patchData = A(kvData); + this.resetNewRow(); + } + + get newKeyWarning() { + return hasWhitespace(this.newKey) ? WHITESPACE_WARNING('this key') : ''; + } + + get newValueWarning() { + if (this.newValue === null) return ''; + return isNonString(this.newValue) ? NON_STRING_WARNING : ''; + } + + get newKeyError() { + return this.validateKey(this.newKey); + } + + generateData(key, value, state) { + return new KeyValueState({ key, value, state }); + } + + resetNewRow() { + this.newKey = undefined; + this.newValue = undefined; + } + + validateKey(key) { + return this.patchData.any((KV) => KV.key === key) + ? `"${key}" key already exists. Update the value of the existing key or rename this one.` + : ''; + } + + @action + updateKey(KV, event) { + // KV is KeyValueState class + const key = event.target.value; + // if a user refocuses an input that already has a key + // validateKey miscalculates and thinks it's a duplicate + if (KV.key === key) return; // so we return if values match + const isInvalid = this.validateKey(key); + KV.keyError = isInvalid; + if (isInvalid) return; + // only set if valid, otherwise key matches original + // subkey and input state updates to readonly + KV.key = key; + } + + @action + updateNewKey(event) { + const key = event.target.value; + this.newKey = key; + } + + @action + updateNewValue(event) { + this.newValue = event.target.value; + } + + @action + addRow() { + if (!this.newKey || this.newKeyError) return; + const KV = this.generateData(this.newKey, this.newValue, 'enabled'); + this.patchData.pushObject(KV); + // reset tracked values after adding them to patchData + this.resetNewRow(); + } + + @action + undoKey(KV) { + if (this.isOriginalSubkey(KV.key)) { + // reset state to 'disabled' and value to undefined + KV.reset(); + } else { + // remove row all together + this.patchData.removeObject(KV); + } + } + + @action + submit(event) { + event.preventDefault(); + if (this.newKeyError || this.patchData.any((KV) => KV.keyError)) { + this.submitError = 'This form contains validations errors, please resolve those before submitting.'; + return; + } + + // patchData will not include the last row if a user has not clicked "Add" + // manually check for data and add it to this.patchData + if (this.newKey && this.newValue) { + this.addRow(); + } + + const data = this.patchData.reduce((obj, KV) => { + // only include edited inputs + const { state } = KV; + if (state === 'enabled' || state === 'deleted') { + const value = state === 'deleted' ? null : KV.value; + obj[KV.key] = value; + } + return obj; + }, {}); + + this.args.onSubmit(data); + } +} diff --git a/ui/lib/kv/addon/components/kv-patch-editor/row.hbs b/ui/lib/kv/addon/components/kv-patch-editor/row.hbs new file mode 100644 index 0000000000..c5df002b10 --- /dev/null +++ b/ui/lib/kv/addon/components/kv-patch-editor/row.hbs @@ -0,0 +1,76 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +{{! display only template for kv data rows to edit, delete or undo patching a key/value }} + +{{#let @kvClass.key @kvClass.value @kvClass.state as |key value state|}} +
+ + +
+ + +
+ {{#if (eq state "disabled")}} + + + {{else}} + + {{/if}} +
+
+
+ + {{#if (eq state "deleted")}} + + This key value pair is marked for deletion. + + {{/if}} + + +{{/let}} \ No newline at end of file diff --git a/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs b/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs index ea9fbabadd..16b1dcde85 100644 --- a/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs +++ b/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs @@ -47,7 +47,7 @@ data-test-version-linked-block={{versionData.version}} >
-
+
{{! 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'); + }); });