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
This commit is contained in:
claire bontempo 2024-08-14 11:52:33 -07:00 committed by GitHub
parent 474bcd8f11
commit eaf47c4c00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1335 additions and 33 deletions

View File

@ -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();

View File

@ -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',
},
],

View File

@ -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',
},
],

View File

@ -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%;
}

View File

@ -61,6 +61,9 @@
.is-no-underline {
text-decoration: none;
}
.line-through {
text-decoration: line-through;
}
// Text transformations
.is-lowercase {

View File

@ -44,7 +44,7 @@
>
<A.Title>Warning</A.Title>
<A.Description>
Your secret path contains whitespace. If this is desired, you'll need to encode it with %20 in API calls.
{{this.whitespaceWarning}}
</A.Description>
</Hds::Alert>
{{/if}}

View File

@ -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,
};

View File

@ -6,7 +6,7 @@
<Hds::Modal
@onClose={{fn (mut @showMessagePreviewModal) false}}
id="message-alert-preview"
class="is-calc-large-height is-two-thirds-width"
class="is-calc-large-height two-thirds-width"
data-test-modal="preview image"
as |M|
>

View File

@ -72,20 +72,12 @@
</div>
{{#if (this.showWhitespaceWarning row.name)}}
<div class="has-bottom-margin-s">
<AlertInline
@type="warning"
@message="Key contains whitespace. If this is desired, you'll need to encode it with %20 in API requests."
data-test-kv-whitespace-warning={{index}}
/>
<AlertInline @type="warning" @message={{this.whitespaceWarning}} data-test-kv-whitespace-warning={{index}} />
</div>
{{/if}}
{{#if (this.showNonStringWarning row.value)}}
<div class="has-bottom-margin-s">
<AlertInline
@type="warning"
@message="This value will be saved as a string. If you need to save a non-string value, please use the JSON editor."
data-test-kv-object-warning={{index}}
/>
<AlertInline @type="warning" @message={{this.nonStringWarning}} data-test-kv-object-warning={{index}} />
</div>
{{/if}}
{{/each}}

View File

@ -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);
};
}

View File

@ -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}}
<Hds::Alert @type="compact" @color="critical" @icon="alert-diamond-fill" data-test-alert-validation={{@idx}} as |A|>
<A.Description>{{@keyError}}</A.Description>
</Hds::Alert>
{{/if}}
{{#if @keyWarning}}
<Hds::Alert @type="compact" @color="warning" @icon="alert-triangle-fill" data-test-alert-key-warning={{@idx}} as |A|>
<A.Description>{{@keyWarning}}</A.Description>
</Hds::Alert>
{{/if}}
{{#if @valueWarning}}
<Hds::Alert @type="compact" @color="warning" @icon="alert-triangle-fill" data-test-alert-value-warning={{@idx}} as |A|>
<A.Description>{{@valueWarning}}</A.Description>
</Hds::Alert>
{{/if}}

View File

@ -0,0 +1,83 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<form {{on "submit" this.submit}} ...attributes>
<div class="flex column-gap-16 has-top-padding-s">
<Hds::Form::Label @controlId="newKey" class="one-fourth-width">
Key
</Hds::Form::Label>
<Hds::Form::Label @controlId="newValue" class="three-fourths-width">
Value
</Hds::Form::Label>
</div>
{{! Rows for existing keys (includes new rows after user clicks "Add") }}
{{#each this.patchData as |kv idx|}}
<KvPatchEditor::Row
@idx={{idx}}
@kvClass={{kv}}
@isOriginalSubkey={{this.isOriginalSubkey}}
@updateKey={{this.updateKey}}
@undoKey={{this.undoKey}}
/>
{{/each}}
{{! Single row of empty inputs for adding new key/value pairs }}
<div class="flex column-gap-16 has-top-padding-s">
<Hds::Form::TextInput::Base
@type="text"
@value={{this.newKey}}
class="one-fourth-width"
aria-label="New key"
placeholder="key"
name="newKey"
{{on "blur" this.updateNewKey}}
data-test-kv-key="new"
/>
<div class="flex column-gap-16 three-fourths-width">
<Hds::Form::MaskedInput::Base
@value={{this.newValue}}
aria-label="New value"
name="newValue"
{{on "blur" this.updateNewValue}}
data-test-kv-value="new"
/>
<div class="flex column-gap-16" {{style width="9rem"}}>
<Hds::Button @text="Add" @color="secondary" {{on "click" this.addRow}} @isFullWidth={{true}} data-test-add-button />
</div>
</div>
</div>
<KvPatchEditor::Alerts
@idx="new"
@keyError={{this.newKeyError}}
@keyWarning={{this.newKeyWarning}}
@valueWarning={{this.newValueWarning}}
/>
<hr class="has-background-gray-200" />
<div class="has-top-margin-m">
<Toggle @onChange={{fn (mut this.showSubkeys)}} @checked={{this.showSubkeys}} @name="Reveal subkeys">
<Hds::Text::Body @tag="p" @weight="semibold">Reveal subkeys in JSON</Hds::Text::Body>
</Toggle>
{{#if this.showSubkeys}}
<Hds::CodeBlock @value={{stringify @subkeys}} @hasLineNumbers={{false}} data-test-subkeys />
{{/if}}
</div>
<hr class="has-background-gray-200" />
<Hds::ButtonSet>
<Hds::Button @text="Save" type="submit" @icon={{if @isSaving "loading"}} disabled={{@isSaving}} data-test-kv-save />
<Hds::Button @text="Cancel" {{on "click" @onCancel}} @color="secondary" disabled={{@isSaving}} data-test-kv-cancel />
</Hds::ButtonSet>
{{#if this.submitError}}
<AlertInline @type="danger" @message={{this.submitError}} class="has-top-padding-s" />
{{/if}}
</form>

View File

@ -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
* <KvPatchEditor::Form @subkeys={{@subkeys}} @onSubmit={{perform this.save}} @onCancel={{this.onCancel}} @isSaving={{this.save.isRunning}} />
*
* @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);
}
}

View File

@ -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|}}
<div class="flex column-gap-16 has-top-padding-s">
<Hds::Form::TextInput::Base
@value={{key}}
aria-label="Key {{@idx}}"
class="one-fourth-width {{if (eq state 'deleted') 'line-through'}}"
{{! Original subkeys are not editable, only their values can be updated }}
disabled={{and (eq state "disabled") (@isOriginalSubkey key)}}
readonly={{and (not-eq state "disabled") (@isOriginalSubkey key)}}
{{on "blur" (fn @updateKey @kvClass)}}
data-test-kv-key={{@idx}}
/>
<div class="flex column-gap-16 three-fourths-width">
<Hds::Form::MaskedInput::Base
@value={{value}}
aria-label="Value {{@idx}}"
disabled={{eq state "disabled"}}
readonly={{eq state "deleted"}}
{{on "blur" @kvClass.updateValue}}
data-test-kv-value={{@idx}}
/>
<div class="flex column-gap-16" {{style width="9rem"}}>
{{#if (eq state "disabled")}}
<Hds::Button
@text="Edit"
@icon="edit"
@color="secondary"
@isIconOnly={{true}}
@isFullWidth={{true}}
{{on "click" (fn @kvClass.updateState "enabled")}}
data-test-edit-button={{@idx}}
/>
<Hds::Button
@text="Delete"
@icon="trash"
@color="critical"
@isIconOnly={{true}}
@isFullWidth={{true}}
{{on "click" (fn @kvClass.updateState "deleted")}}
data-test-delete-button={{@idx}}
/>
{{else}}
<Hds::Button
@text={{if (@isOriginalSubkey key) "Cancel" "Remove"}}
@color="secondary"
@isFullWidth={{true}}
{{on "click" (fn @undoKey @kvClass)}}
data-test-undo-button={{@idx}}
/>
{{/if}}
</div>
</div>
</div>
{{#if (eq state "deleted")}}
<Hds::Alert @type="compact" @color="critical" @icon="trash" data-test-alert-delete={{@idx}} as |A|>
<A.Description>This key value pair is marked for deletion.</A.Description>
</Hds::Alert>
{{/if}}
<KvPatchEditor::Alerts
@idx={{@idx}}
@keyError={{@kvClass.keyError}}
@keyWarning={{@kvClass.keyWarning}}
@valueWarning={{@kvClass.valueWarning}}
/>
{{/let}}

View File

@ -47,7 +47,7 @@
data-test-version-linked-block={{versionData.version}}
>
<div class="level is-mobile">
<div class="is-grid is-grid-3-columns is-three-fourths-width">
<div class="is-grid is-grid-3-columns three-fourths-width">
{{! version number and icon }}
<div class="align-self-center">
<Icon @name="history" class="has-text-grey" data-test-version />

View File

@ -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}"]`,
// <KvPatchEditor>
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]`,

View File

@ -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`
<KvPatchEditor::Alerts
@idx={{1}}
@keyError={{this.keyError}}
@keyWarning={{this.keyWarning}}
@valueWarning={{this.valueWarning}}
/>`,
{ 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);
});
});

View File

@ -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`
<KvPatchEditor::Form
@subkeys={{this.subkeys}}
@onSubmit={{this.onSubmit}}
@onCancel={{this.onCancel}}
@isSaving={{this.isSaving}}
/>`,
{ 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');
});
});
});
});

View File

@ -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`
<KvPatchEditor::Row
@idx={{0}}
@kvClass={{this.kvClass}}
@isOriginalSubkey={{this.isOriginalSubkey}}
@updateKey={{this.updateKey}}
@undoKey={{this.undoKey}}
/>`,
{ 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"');
});
});

View File

@ -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');
});
});