mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-05 20:36:26 +02:00
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:
parent
474bcd8f11
commit
eaf47c4c00
@ -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();
|
||||
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
|
||||
|
||||
@ -61,6 +61,9 @@
|
||||
.is-no-underline {
|
||||
text-decoration: none;
|
||||
}
|
||||
.line-through {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
// Text transformations
|
||||
.is-lowercase {
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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|
|
||||
>
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
22
ui/lib/kv/addon/components/kv-patch-editor/alerts.hbs
Normal file
22
ui/lib/kv/addon/components/kv-patch-editor/alerts.hbs
Normal 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}}
|
||||
83
ui/lib/kv/addon/components/kv-patch-editor/form.hbs
Normal file
83
ui/lib/kv/addon/components/kv-patch-editor/form.hbs
Normal 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>
|
||||
194
ui/lib/kv/addon/components/kv-patch-editor/form.js
Normal file
194
ui/lib/kv/addon/components/kv-patch-editor/form.js
Normal 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);
|
||||
}
|
||||
}
|
||||
76
ui/lib/kv/addon/components/kv-patch-editor/row.hbs
Normal file
76
ui/lib/kv/addon/components/kv-patch-editor/row.hbs
Normal 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}}
|
||||
@ -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 />
|
||||
|
||||
@ -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]`,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
566
ui/tests/integration/components/kv/kv-patch-editor/form-test.js
Normal file
566
ui/tests/integration/components/kv/kv-patch-editor/form-test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
169
ui/tests/integration/components/kv/kv-patch-editor/row-test.js
Normal file
169
ui/tests/integration/components/kv/kv-patch-editor/row-test.js
Normal 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"');
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user