Transform Advanced Templating (#13908)
* updates regex-validator component to optionally show pattern input and adds capture groups support * adds form-field-label component * adds autocomplete-input component * updates kv-object-editor component to yield block for value and glimmerizes * updates transform template model * adds transform-advanced-templating component * updates form-field with child component changes * updates transform template serializer to handle differences in regex named capture groups * fixes regex-validator test * adds changelog entry * updates for pr review feedback * reverts kv-object-editor guidFor removal
This commit is contained in:
parent
33a9218115
commit
e811821ac7
|
@ -0,0 +1,3 @@
|
|||
```release-note:improvement
|
||||
ui: Transform advanced templating with encode/decode format support
|
||||
```
|
|
@ -1,3 +1,11 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { isNone } from '@ember/utils';
|
||||
import { assert } from '@ember/debug';
|
||||
import { action } from '@ember/object';
|
||||
import { guidFor } from '@ember/object/internals';
|
||||
import KVObject from 'vault/lib/kv-object';
|
||||
|
||||
/**
|
||||
* @module KvObjectEditor
|
||||
* KvObjectEditor components are called in FormFields when the editType on the model is kv. They are used to show a key-value input field.
|
||||
|
@ -12,91 +20,59 @@
|
|||
* ```
|
||||
* @param {string} value - the value is captured from the model.
|
||||
* @param {function} onChange - function that captures the value on change
|
||||
* @param {function} onKeyUp - function passed in that handles the dom keyup event. Used for validation on the kv custom metadata.
|
||||
* @param {function} [onKeyUp] - function passed in that handles the dom keyup event. Used for validation on the kv custom metadata.
|
||||
* @param {string} [label] - label displayed over key value inputs
|
||||
* @param {string} [labelClass] - override default label class in FormFieldLabel component
|
||||
* @param {string} [warning] - warning that is displayed
|
||||
* @param {string} [helpText] - helper text. In tooltip.
|
||||
* @param {string} [subText] - placed under label.
|
||||
* @param {boolean} [small-label]- change label size.
|
||||
* @param {boolean} [formSection] - if false the component is meant to live outside of a form, like in the customMetadata which is nested already inside a form-section.
|
||||
* @param {string} [keyPlaceholder] - placeholder for key input
|
||||
* @param {string} [valuePlaceholder] - placeholder for value input
|
||||
*/
|
||||
|
||||
import { isNone } from '@ember/utils';
|
||||
import { assert } from '@ember/debug';
|
||||
import Component from '@ember/component';
|
||||
import { computed } from '@ember/object';
|
||||
import { guidFor } from '@ember/object/internals';
|
||||
import KVObject from 'vault/lib/kv-object';
|
||||
export default class KvObjectEditor extends Component {
|
||||
@tracked kvData;
|
||||
|
||||
export default Component.extend({
|
||||
'data-test-component': 'kv-object-editor',
|
||||
classNames: ['field'],
|
||||
classNameBindings: ['formSection:form-section'],
|
||||
formSection: true,
|
||||
// public API
|
||||
// Ember Object to mutate
|
||||
value: null,
|
||||
label: null,
|
||||
helpText: null,
|
||||
subText: null,
|
||||
// onChange will be called with the changed Value
|
||||
onChange() {},
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
const data = KVObject.create({ content: [] }).fromJSON(this.value);
|
||||
this.set('kvData', data);
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.kvData = KVObject.create({ content: [] }).fromJSON(this.args.value);
|
||||
this.addRow();
|
||||
},
|
||||
}
|
||||
|
||||
kvData: null,
|
||||
|
||||
kvDataAsJSON: computed('kvData', 'kvData.[]', function () {
|
||||
return this.kvData.toJSON();
|
||||
}),
|
||||
|
||||
kvDataIsAdvanced: computed('kvData', 'kvData.[]', function () {
|
||||
return this.kvData.isAdvanced();
|
||||
}),
|
||||
|
||||
kvHasDuplicateKeys: computed('kvData', 'kvData.@each.name', function () {
|
||||
let data = this.kvData;
|
||||
return data.uniqBy('name').length !== data.get('length');
|
||||
}),
|
||||
get placeholders() {
|
||||
return {
|
||||
key: this.args.keyPlaceholder || 'key',
|
||||
value: this.args.valuePlaceholder || 'value',
|
||||
};
|
||||
}
|
||||
get hasDuplicateKeys() {
|
||||
return this.kvData.uniqBy('name').length !== this.kvData.get('length');
|
||||
}
|
||||
|
||||
@action
|
||||
addRow() {
|
||||
let data = this.kvData;
|
||||
let newObj = { name: '', value: '' };
|
||||
if (!isNone(data.findBy('name', ''))) {
|
||||
if (!isNone(this.kvData.findBy('name', ''))) {
|
||||
return;
|
||||
}
|
||||
const newObj = { name: '', value: '' };
|
||||
guidFor(newObj);
|
||||
data.addObject(newObj);
|
||||
},
|
||||
actions: {
|
||||
addRow() {
|
||||
this.addRow();
|
||||
},
|
||||
|
||||
updateRow() {
|
||||
let data = this.kvData;
|
||||
this.onChange(data.toJSON());
|
||||
},
|
||||
|
||||
deleteRow(object, index) {
|
||||
let data = this.kvData;
|
||||
let oldObj = data.objectAt(index);
|
||||
|
||||
assert('object guids match', guidFor(oldObj) === guidFor(object));
|
||||
data.removeAt(index);
|
||||
this.onChange(data.toJSON());
|
||||
},
|
||||
|
||||
handleKeyUp(name, value) {
|
||||
if (!this.onKeyUp) {
|
||||
return;
|
||||
}
|
||||
this.onKeyUp(name, value);
|
||||
},
|
||||
},
|
||||
});
|
||||
this.kvData.addObject(newObj);
|
||||
}
|
||||
@action
|
||||
updateRow() {
|
||||
this.args.onChange(this.kvData.toJSON());
|
||||
}
|
||||
@action
|
||||
deleteRow(object, index) {
|
||||
const oldObj = this.kvData.objectAt(index);
|
||||
assert('object guids match', guidFor(oldObj) === guidFor(object));
|
||||
this.kvData.removeAt(index);
|
||||
this.args.onChange(this.kvData.toJSON());
|
||||
}
|
||||
@action
|
||||
handleKeyUp(event) {
|
||||
if (this.args.onKeyUp) {
|
||||
this.args.onKeyUp(event.target.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,58 +1,63 @@
|
|||
<div class="field">
|
||||
<div class="regex-label-wrapper">
|
||||
<div class="regex-label">
|
||||
<label for={{@attr.name}} class="is-label">
|
||||
{{@labelString}}
|
||||
{{#if @attr.options.helpText}}
|
||||
<InfoTooltip>
|
||||
<span data-test-help-text>
|
||||
{{@attr.options.helpText}}
|
||||
</span>
|
||||
</InfoTooltip>
|
||||
{{/if}}
|
||||
</label>
|
||||
{{#if @attr.options.subText}}
|
||||
<p class="sub-text">
|
||||
{{@attr.options.subText}}
|
||||
{{#if @attr.options.docLink}}
|
||||
<a href={{@attr.options.docLink}} target="_blank" rel="noopener noreferrer">
|
||||
See our documentation
|
||||
</a>
|
||||
for help.
|
||||
{{#if @attr}}
|
||||
<div class="field" data-test-regex-validator-pattern>
|
||||
<div class="regex-label-wrapper">
|
||||
<div class="regex-label">
|
||||
<label for={{@attr.name}} class="is-label">
|
||||
{{@labelString}}
|
||||
{{#if @attr.options.helpText}}
|
||||
<InfoTooltip>
|
||||
<span data-test-help-text>
|
||||
{{@attr.options.helpText}}
|
||||
</span>
|
||||
</InfoTooltip>
|
||||
{{/if}}
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div>
|
||||
<Toggle
|
||||
@name={{concat @attr.name "-validation-toggle"}}
|
||||
@status="success"
|
||||
@size="small"
|
||||
@checked={{this.showTestValue}}
|
||||
@onChange={{this.toggleTestValue}}
|
||||
>
|
||||
<span class="has-text-grey">Validation</span>
|
||||
</Toggle>
|
||||
</label>
|
||||
{{#if @attr.options.subText}}
|
||||
<p class="sub-text">
|
||||
{{@attr.options.subText}}
|
||||
{{#if @attr.options.docLink}}
|
||||
<a href={{@attr.options.docLink}} target="_blank" rel="noopener noreferrer">
|
||||
See our documentation
|
||||
</a>
|
||||
for help.
|
||||
{{/if}}
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div>
|
||||
<Toggle
|
||||
@name={{concat @attr.name "-validation-toggle"}}
|
||||
@status="success"
|
||||
@size="small"
|
||||
@checked={{this.showTestValue}}
|
||||
@onChange={{this.toggleTestValue}}
|
||||
>
|
||||
<span class="has-text-grey">Validation</span>
|
||||
</Toggle>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
id={{@attr.name}}
|
||||
data-test-input={{@attr.name}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
{{on "change" @onChange}}
|
||||
value={{@value}}
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
id={{@attr.name}}
|
||||
data-test-input={{@attr.name}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
{{on "change" @onChange}}
|
||||
value={{@value}}
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if this.showTestValue}}
|
||||
<div data-test-regex-validator-test-string>
|
||||
<label for={{@attr.name}} class="is-label">
|
||||
Test string
|
||||
<label for="regex-test-val" class="is-label">
|
||||
{{this.testInputLabel}}
|
||||
</label>
|
||||
{{#if @testInputSubText}}
|
||||
<p class="sub-text">{{@testInputSubText}}</p>
|
||||
{{/if}}
|
||||
<input
|
||||
data-test-input={{concat @attr.name "-testval"}}
|
||||
id={{concat @attr.name "-testval"}}
|
||||
data-test-input="regex-test-val"
|
||||
id="regex-test-val"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
value={{this.testValue}}
|
||||
|
@ -60,17 +65,50 @@
|
|||
class="input {{if this.regexError "has-error"}}"
|
||||
/>
|
||||
|
||||
{{#if (and this.testValue @value)}}
|
||||
{{#if this.testValue}}
|
||||
<div data-test-regex-validation-message>
|
||||
{{#if this.regexError}}
|
||||
<AlertInline @type="danger" @message="Your regex doesn't match the subject string" />
|
||||
{{#if (not @value)}}
|
||||
<AlertInline
|
||||
@type="warning"
|
||||
@message={{concat
|
||||
"A pattern has not been entered. Enter a pattern to check this "
|
||||
(lowercase this.testInputLabel)
|
||||
" against it."
|
||||
}}
|
||||
/>
|
||||
{{else if this.regexError}}
|
||||
<AlertInline @type="danger" @message="This test string does not match the pattern regex." />
|
||||
{{else}}
|
||||
<div class="message-inline">
|
||||
<Icon @name="check-circle-fill" class="has-text-success" />
|
||||
<p data-test-inline-success-message>Your regex matches the subject string</p>
|
||||
<p data-test-inline-success-message>
|
||||
This test string matches the pattern regex.
|
||||
</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if @showGroups}}
|
||||
<div class="has-top-margin-l">
|
||||
<label class="is-label">Groups</label>
|
||||
{{! check with design but should likely show a placeholder if testValue is blank }}
|
||||
{{#if (and @value this.testValue (not this.regexError))}}
|
||||
<div class="regex-group">
|
||||
{{#each this.captureGroups as |group|}}
|
||||
<span class="regex-group-position" data-test-regex-group-position={{group.position}}>
|
||||
<span>{{group.position}}</span>
|
||||
</span>
|
||||
<span class="regex-group-value" data-test-regex-group-value={{group.position}}>
|
||||
{{group.value}}
|
||||
</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="sub-text" data-test-regex-validator-groups-placeholder>
|
||||
Enter pattern and test string to show groupings.
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
|
@ -15,10 +15,14 @@
|
|||
* }
|
||||
* <RegexValidator @onChange={action 'myAction'} @attr={attrExample} @labelString="Label String" @value="initial value" />
|
||||
* ```
|
||||
* @param {func} onChange - the action that should trigger when the main input is changed.
|
||||
* @param {string} value - the value of the main input which will be updated in onChange
|
||||
* @param {string} labelString - Form label. Anticipated from form-field
|
||||
* @param {object} attr - attribute from model. Anticipated from form-field. Example of attribute shape above
|
||||
* @param {func} [onChange] - the action that should trigger when pattern input is changed. Required when attr is provided.
|
||||
* @param {string} [labelString] - Form label. Anticipated from form-field. Required when attr is provided.
|
||||
* @param {object} [attr] - attribute from model. Anticipated from form-field. Example of attribute shape above. When not provided pattern input is hidden
|
||||
* @param {string} [testInputLabel] - label for test input
|
||||
* @param {string} [testInputSubText] - sub text for test input
|
||||
* @param {boolean} [showGroups] - show groupings based on pattern and test input
|
||||
* @param {func} [onValidate] - action triggered every time the test string is validated against the regex -- passes testValue and captureGroups
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
|
@ -29,13 +33,42 @@ export default class RegexValidator extends Component {
|
|||
@tracked testValue = '';
|
||||
@tracked showTestValue = false;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.showTestValue = !this.args.attr;
|
||||
}
|
||||
|
||||
get testInputLabel() {
|
||||
return this.args.testInputLabel || 'Test string';
|
||||
}
|
||||
get regex() {
|
||||
return new RegExp(this.args.value, 'g');
|
||||
}
|
||||
get regexError() {
|
||||
const testString = this.testValue;
|
||||
if (!testString || !this.args.value) return false;
|
||||
const regex = new RegExp(this.args.value, 'g');
|
||||
const matchArray = testString.toString().match(regex);
|
||||
const matchArray = testString.toString().match(this.regex);
|
||||
if (this.args.onValidate) {
|
||||
this.args.onValidate(this.testValue, this.captureGroups);
|
||||
}
|
||||
return testString !== matchArray?.join('');
|
||||
}
|
||||
get captureGroups() {
|
||||
const result = this.regex.exec(this.testValue);
|
||||
if (result) {
|
||||
// first item is full string match but we are only interested in the captured groups
|
||||
const [fullMatch, ...matches] = result; // eslint-disable-line
|
||||
const groups = matches.map((m, index) => ({ position: `$${index + 1}`, value: m }));
|
||||
// push named capture groups into array -> eg (<lastFour>\d{4})
|
||||
if (result.groups) {
|
||||
for (const key in result.groups) {
|
||||
groups.push({ position: `$${key}`, value: result.groups[key] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@action
|
||||
updateTestValue(evt) {
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action, set } from '@ember/object';
|
||||
|
||||
/**
|
||||
* @module TransformAdvancedTemplating
|
||||
* TransformAdvancedTemplating components are used to modify encode/decode formats of transform templates
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <TransformAdvancedTemplating @model={{this.model}} />
|
||||
* ```
|
||||
* @param {Object} model - transform template model
|
||||
*/
|
||||
|
||||
export default class TransformAdvancedTemplating extends Component {
|
||||
@tracked inputOptions = [];
|
||||
|
||||
@action
|
||||
setInputOptions(testValue, captureGroups) {
|
||||
if (captureGroups && captureGroups.length) {
|
||||
this.inputOptions = captureGroups.map(({ position, value }) => {
|
||||
return {
|
||||
label: `${position}: ${value}`,
|
||||
value: position,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
this.inputOptions = [];
|
||||
}
|
||||
}
|
||||
@action
|
||||
decodeFormatValueChange(kvObject, kvData, value) {
|
||||
set(kvObject, 'value', value);
|
||||
this.args.model.decodeFormats = kvData.toJSON();
|
||||
}
|
||||
}
|
|
@ -31,13 +31,17 @@ const M = Model.extend({
|
|||
models: ['transform/alphabet'],
|
||||
selectLimit: 1,
|
||||
}),
|
||||
encodeFormat: attr('string'),
|
||||
decodeFormats: attr(),
|
||||
backend: attr('string', { readOnly: true }),
|
||||
|
||||
attrs: computed(function () {
|
||||
let keys = ['name', 'pattern', 'alphabet'];
|
||||
readAttrs: computed(function () {
|
||||
let keys = ['name', 'pattern', 'encodeFormat', 'decodeFormats', 'alphabet'];
|
||||
return expandAttributeMeta(this, keys);
|
||||
}),
|
||||
|
||||
backend: attr('string', { readOnly: true }),
|
||||
writeAttrs: computed(function () {
|
||||
return expandAttributeMeta(this, ['name', 'pattern', 'alphabet']);
|
||||
}),
|
||||
});
|
||||
|
||||
export default attachCapabilities(M, {
|
||||
|
|
|
@ -6,6 +6,10 @@ export default ApplicationSerializer.extend({
|
|||
if (payload.data.alphabet) {
|
||||
payload.data.alphabet = [payload.data.alphabet];
|
||||
}
|
||||
// strip out P character from any named capture groups
|
||||
if (payload.data.pattern) {
|
||||
this._formatNamedCaptureGroups(payload.data, '?P', '?');
|
||||
}
|
||||
return this._super(store, primaryModelClass, payload, id, requestType);
|
||||
},
|
||||
|
||||
|
@ -15,9 +19,26 @@ export default ApplicationSerializer.extend({
|
|||
// Templates should only ever have one alphabet
|
||||
json.alphabet = json.alphabet[0];
|
||||
}
|
||||
// add P character to any named capture groups
|
||||
if (json.pattern) {
|
||||
this._formatNamedCaptureGroups(json, '?', '?P');
|
||||
}
|
||||
return json;
|
||||
},
|
||||
|
||||
_formatNamedCaptureGroups(json, replace, replaceWith) {
|
||||
// named capture groups are handled differently between Go and js
|
||||
// first look for named capture groups in pattern string
|
||||
const regex = new RegExp(/\?P?(<(.+?)>)/, 'g');
|
||||
const namedGroups = json.pattern.match(regex);
|
||||
if (namedGroups) {
|
||||
namedGroups.forEach((group) => {
|
||||
// add or remove P depending on destination
|
||||
json.pattern = json.pattern.replace(group, group.replace(replace, replaceWith));
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
extractLazyPaginatedData(payload) {
|
||||
let ret;
|
||||
ret = payload.data.keys.map((key) => {
|
||||
|
|
|
@ -8,3 +8,21 @@
|
|||
.regex-toggle {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.regex-group {
|
||||
font-family: $family-monospace;
|
||||
font-size: $size-8;
|
||||
color: $ui-gray-600;
|
||||
}
|
||||
.regex-group-position {
|
||||
background-color: $ui-gray-200;
|
||||
border-radius: 3px;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 4px;
|
||||
margin-right: 4px;
|
||||
span {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
.regex-group-value {
|
||||
margin-right: $spacing-m;
|
||||
}
|
||||
|
|
|
@ -6,3 +6,9 @@
|
|||
padding: 14px;
|
||||
}
|
||||
}
|
||||
.transform-pattern-text div:not(:first-child) {
|
||||
font-family: $family-monospace;
|
||||
}
|
||||
.transform-decode-formats:not(:last-child) {
|
||||
margin-bottom: $spacing-s;
|
||||
}
|
||||
|
|
|
@ -329,3 +329,21 @@ fieldset.form-fieldset {
|
|||
.has-error-border {
|
||||
border: 1px solid $red-500;
|
||||
}
|
||||
|
||||
.autocomplete-input {
|
||||
background: $white !important;
|
||||
border: 1px solid $grey-light;
|
||||
box-sizing: border-box;
|
||||
border-radius: 3px;
|
||||
width: 99%;
|
||||
padding: 4px 0;
|
||||
margin-left: 0.5%;
|
||||
margin-top: -4px;
|
||||
}
|
||||
.autocomplete-input-option {
|
||||
padding: 12px;
|
||||
&:hover {
|
||||
background-color: $grey-lightest;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -204,3 +204,7 @@ ul.bullet {
|
|||
.has-text-semibold {
|
||||
font-weight: $font-weight-semibold;
|
||||
}
|
||||
|
||||
.has-text-grey-400 {
|
||||
color: $ui-gray-400;
|
||||
}
|
||||
|
|
|
@ -16,12 +16,14 @@ $vault-gray: $vault-gray-500;
|
|||
$vault-gray-dark: $vault-gray-700;
|
||||
|
||||
// UI Gray
|
||||
$ui-gray-010: #FBFBFC;
|
||||
$ui-gray-050: #F7F8FA;
|
||||
$ui-gray-100: #EBEEF2;
|
||||
$ui-gray-200: #DCE0E6;
|
||||
$ui-gray-300: #BAC1CC;
|
||||
$ui-gray-500: #6F7682;
|
||||
$ui-gray-010: #fbfbfc;
|
||||
$ui-gray-050: #f7f8fa;
|
||||
$ui-gray-100: #ebeef2;
|
||||
$ui-gray-200: #dce0e6;
|
||||
$ui-gray-300: #bac1cc;
|
||||
$ui-gray-400: #8e96a3;
|
||||
$ui-gray-500: #6f7682;
|
||||
$ui-gray-600: #626873;
|
||||
$ui-gray-700: #525761;
|
||||
$ui-gray-800: #373a42;
|
||||
$ui-gray-900: #1f2124;
|
||||
|
|
|
@ -1,71 +1,69 @@
|
|||
{{#if this.label}}
|
||||
<label class="title {{if this.small-label "is-5" "is-4"}}" data-test-kv-label="true">
|
||||
{{this.label}}
|
||||
{{#if this.helpText}}
|
||||
<InfoTooltip>
|
||||
{{this.helpText}}
|
||||
</InfoTooltip>
|
||||
{{/if}}
|
||||
</label>
|
||||
{{#if this.subText}}
|
||||
<p class="has-padding-bottom">
|
||||
{{this.subText}}
|
||||
</p>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#if (get this.validationMessages this.name)}}
|
||||
<div>
|
||||
<AlertInline @type="danger" @message={{get this.validationMessages this.name}} @paddingTop={{true}} />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#each this.kvData as |row index|}}
|
||||
<div class="columns is-variable" data-test-kv-row>
|
||||
<div class="column is-one-quarter">
|
||||
<Input
|
||||
data-test-kv-key={{true}}
|
||||
@value={{row.name}}
|
||||
placeholder="key"
|
||||
{{on "change" (action "updateRow" row index)}}
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="column">
|
||||
<Textarea
|
||||
data-test-kv-value={{true}}
|
||||
name={{row.name}}
|
||||
class="input {{if (get this.validationMessages this.name) "has-error-border"}}"
|
||||
{{on "change" (action "updateRow" row index)}}
|
||||
@value={{row.value}}
|
||||
wrap="off"
|
||||
placeholder="value"
|
||||
rows={{1}}
|
||||
onkeyup={{action (action "handleKeyUp" this.name) value="target.value"}}
|
||||
/>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{{#if (eq this.kvData.length (inc index))}}
|
||||
<button type="button" {{action "addRow"}} class="button is-outlined is-primary" data-test-kv-add-row={{true}}>
|
||||
Add
|
||||
</button>
|
||||
{{else}}
|
||||
<button
|
||||
class="button has-text-grey is-expanded is-icon"
|
||||
type="button"
|
||||
{{action "deleteRow" row index}}
|
||||
aria-label="Delete row"
|
||||
data-test-kv-delete-row
|
||||
>
|
||||
<Icon @name="trash" />
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{#if this.kvHasDuplicateKeys}}
|
||||
<AlertBanner
|
||||
@type="warning"
|
||||
@message="More than one key shares the same name. Please be sure to have unique key names or some data may be lost when saving."
|
||||
@class="is-marginless"
|
||||
data-test-duplicate-error-warnings
|
||||
<div class="field" ...attributes>
|
||||
<FormFieldLabel
|
||||
@label={{@label}}
|
||||
@helpText={{@helpText}}
|
||||
@subText={{@subText}}
|
||||
class={{@labelClass}}
|
||||
data-test-kv-label={{true}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if @validationError}}
|
||||
<div>
|
||||
<AlertInline @type="danger" @message={{@validationError}} @paddingTop={{true}} />
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#each this.kvData as |row index|}}
|
||||
<div class="columns is-variable" data-test-kv-row>
|
||||
<div class="column is-one-quarter">
|
||||
<Input
|
||||
data-test-kv-key={{true}}
|
||||
@value={{row.name}}
|
||||
placeholder={{this.placeholders.key}}
|
||||
{{on "change" (fn this.updateRow row index)}}
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="column">
|
||||
{{#if (has-block)}}
|
||||
{{yield row this.kvData}}
|
||||
{{else}}
|
||||
<Textarea
|
||||
data-test-kv-value={{index}}
|
||||
name={{row.name}}
|
||||
class="input {{if @validationError "has-error-border"}}"
|
||||
@value={{row.value}}
|
||||
wrap="off"
|
||||
placeholder={{this.placeholders.value}}
|
||||
rows={{1}}
|
||||
{{on "change" (fn this.updateRow row index)}}
|
||||
{{on "keyup" this.handleKeyUp}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{{#if (eq this.kvData.length (inc index))}}
|
||||
<button type="button" {{action "addRow"}} class="button is-outlined is-primary" data-test-kv-add-row={{true}}>
|
||||
Add
|
||||
</button>
|
||||
{{else}}
|
||||
<button
|
||||
class="button has-text-grey is-expanded is-icon"
|
||||
type="button"
|
||||
{{action "deleteRow" row index}}
|
||||
aria-label="Delete row"
|
||||
data-test-kv-delete-row={{index}}
|
||||
>
|
||||
<Icon @name="trash" />
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{#if this.hasDuplicateKeys}}
|
||||
<AlertBanner
|
||||
@type="warning"
|
||||
@message="More than one key shares the same name. Please be sure to have unique key names or some data may be lost when saving."
|
||||
@class="is-marginless"
|
||||
data-test-duplicate-error-warnings
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
|
@ -0,0 +1,62 @@
|
|||
<ToggleButton
|
||||
@toggleAttr={{"showForm"}}
|
||||
@toggleTarget={{this}}
|
||||
@openLabel="Advanced templating"
|
||||
@closedLabel="Advanced templating"
|
||||
data-test-toggle-advanced={{true}}
|
||||
/>
|
||||
|
||||
{{#if this.showForm}}
|
||||
<div class="box has-container is-fullwidth">
|
||||
<h4 class="title is-5">Advanced templating</h4>
|
||||
<p>
|
||||
Using your template's regex as a starting point, you can specify which parts of your input to encode and decode. For
|
||||
example, you may want to handle input formatting or only decode part of an input. For more information, see
|
||||
<a
|
||||
href="https://learn.hashicorp.com/tutorials/vault/transform#advanced-handling"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
our documentation.
|
||||
</a>
|
||||
</p>
|
||||
<div class="has-top-margin-l">
|
||||
<RegexValidator
|
||||
@value={{@model.pattern}}
|
||||
@testInputLabel="Sample input"
|
||||
@testInputSubText="Enter a sample input to match against your regex and identify capture groups. Optional."
|
||||
@showGroups={{true}}
|
||||
@onValidate={{this.setInputOptions}}
|
||||
/>
|
||||
</div>
|
||||
<AutocompleteInput
|
||||
@label="Encode format"
|
||||
@subText="Use the groups above to define how the input will be encoded. Refer to each group with $N. This is optional; if not specified, pattern will be used."
|
||||
@value={{@model.encodeFormat}}
|
||||
@optionsTrigger="$"
|
||||
@options={{this.inputOptions}}
|
||||
@onChange={{fn (mut @model.encodeFormat)}}
|
||||
class="has-top-margin-l"
|
||||
data-test-encode-format
|
||||
/>
|
||||
<KvObjectEditor
|
||||
@value={{@model.decodeFormats}}
|
||||
@onChange={{fn (mut @model.decodeFormats)}}
|
||||
@label="Decode formats"
|
||||
@subText="Using the groups above, define how this data will be decoded. Multiple decode_formats can be used. Optional. If not specified, pattern will be used."
|
||||
@keyPlaceholder="name"
|
||||
class="has-top-margin-l"
|
||||
data-test-kv-object-editor
|
||||
as |kvObject kvData|
|
||||
>
|
||||
<AutocompleteInput
|
||||
@value={{kvObject.value}}
|
||||
@placeholder="format"
|
||||
@optionsTrigger="$"
|
||||
@options={{this.inputOptions}}
|
||||
@onChange={{fn this.decodeFormatValueChange kvObject kvData}}
|
||||
data-test-decode-format
|
||||
/>
|
||||
</KvObjectEditor>
|
||||
</div>
|
||||
{{/if}}
|
|
@ -50,7 +50,7 @@
|
|||
<div class="box is-sideless is-fullwidth is-marginless">
|
||||
<MessageError @model={{this.model}} />
|
||||
<NamespaceReminder @mode={{this.mode}} @noun="transform template" />
|
||||
{{#each this.model.attrs as |attr|}}
|
||||
{{#each this.model.writeAttrs as |attr|}}
|
||||
{{#if (and (eq attr.name "name") (eq this.mode "edit"))}}
|
||||
<label for={{attr.name}} class="is-label">
|
||||
{{attr.options.label}}
|
||||
|
@ -69,6 +69,9 @@
|
|||
type={{attr.type}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#if (eq attr.name "alphabet")}}
|
||||
<TransformAdvancedTemplating @model={{this.model}} />
|
||||
{{/if}}
|
||||
<FormField data-test-field @attr={{attr}} @model={{this.model}} />
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
|
@ -104,25 +107,29 @@
|
|||
</div>
|
||||
{{/if}}
|
||||
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
|
||||
{{#each this.model.attrs as |attr|}}
|
||||
{{#if (eq attr.type "object")}}
|
||||
<InfoTableRow
|
||||
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
|
||||
@value={{stringify (get this.model attr.name)}}
|
||||
/>
|
||||
{{else if (eq attr.type "array")}}
|
||||
<InfoTableRow
|
||||
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
|
||||
@value={{get this.model attr.name}}
|
||||
@type={{attr.type}}
|
||||
@viewAll={{attr.name}}
|
||||
/>
|
||||
{{else}}
|
||||
<InfoTableRow
|
||||
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
|
||||
@value={{get this.model attr.name}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{#each this.model.readAttrs as |attr|}}
|
||||
{{#let (capitalize (or attr.options.label (humanize (dasherize attr.name)))) as |label|}}
|
||||
{{#if (eq attr.name "decodeFormats")}}
|
||||
{{#if (not (is-empty-value this.model.decodeFormats))}}
|
||||
<InfoTableRow @label={{label}}>
|
||||
<div>
|
||||
{{#each-in this.model.decodeFormats as |key value|}}
|
||||
<div class="transform-decode-formats">
|
||||
<p class="is-label has-text-grey-400">{{key}}</p>
|
||||
<p>{{value}}</p>
|
||||
</div>
|
||||
{{/each-in}}
|
||||
</div>
|
||||
</InfoTableRow>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<InfoTableRow
|
||||
@label={{label}}
|
||||
@value={{get this.model attr.name}}
|
||||
class={{if (eq attr.name "pattern") "transform-pattern-text"}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
|
@ -0,0 +1,24 @@
|
|||
<div {{did-insert this.setElement}} ...attributes>
|
||||
{{#if @label}}
|
||||
<FormFieldLabel @label={{@label}} @subText={{@subText}} />
|
||||
{{/if}}
|
||||
<div class="control">
|
||||
<Input
|
||||
@value={{@value}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder={{@placeholder}}
|
||||
class="input"
|
||||
{{on "input" this.onInput}}
|
||||
/>
|
||||
<BasicDropdown @registerAPI={{fn (mut this.dropdownAPI)}} @renderInPlace={{true}} as |D|>
|
||||
<D.Content class="autocomplete-input">
|
||||
{{#each @options as |option|}}
|
||||
<div class="autocomplete-input-option" role="button" {{on "click" (fn this.selectOption option.value)}}>
|
||||
{{option.label}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</D.Content>
|
||||
</BasicDropdown>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,48 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
/**
|
||||
* @module AutocompleteInput
|
||||
* AutocompleteInput components are used as standard string inputs or optionally select options to append to input value
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <AutocompleteInput @requiredParam={requiredParam} @optionalParam={optionalParam} @param1={{param1}}/>
|
||||
* ```
|
||||
* @callback inputChangeCallback
|
||||
* @param {string} value - input value
|
||||
* @param {inputChangeCallback} onChange - fires when input value changes to mutate value param by caller
|
||||
* @param {string} [optionsTrigger] - display options dropdown when trigger character is input
|
||||
* @param {Object[]} [options] - array of { label, value } objects where label is displayed in options dropdown and value is appended to input value
|
||||
* @param {string} [label] - label to display above input
|
||||
* @param {string} [subText] - text to display below label
|
||||
* @param {string} [placeholder] - input placeholder
|
||||
*/
|
||||
|
||||
export default class AutocompleteInput extends Component {
|
||||
dropdownAPI;
|
||||
inputElement;
|
||||
|
||||
@action
|
||||
setElement(element) {
|
||||
this.inputElement = element.querySelector('.input');
|
||||
}
|
||||
@action
|
||||
onInput(event) {
|
||||
const { options = [], optionsTrigger } = this.args;
|
||||
if (optionsTrigger && options.length) {
|
||||
const method = event.data === optionsTrigger ? 'open' : 'close';
|
||||
this.dropdownAPI.actions[method]();
|
||||
}
|
||||
this.args.onChange(event.target.value);
|
||||
}
|
||||
@action
|
||||
selectOption(value) {
|
||||
// if trigger character is at start of value it needs to be trimmed
|
||||
const appendValue = value.startsWith(this.args.optionsTrigger) ? value.slice(1) : value;
|
||||
const newValue = this.args.value + appendValue;
|
||||
this.args.onChange(newValue);
|
||||
this.dropdownAPI.actions.close();
|
||||
this.inputElement.focus();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
{{#if @label}}
|
||||
<label class="is-label" ...attributes>
|
||||
{{@label}}
|
||||
{{#if @helpText}}
|
||||
<InfoTooltip>
|
||||
<span data-test-help-text>{{@helpText}}</span>
|
||||
</InfoTooltip>
|
||||
{{/if}}
|
||||
</label>
|
||||
{{/if}}
|
||||
{{#if @subText}}
|
||||
<p class="sub-text" data-test-label-subtext>
|
||||
{{@subText}}
|
||||
{{#if @docLink}}
|
||||
<a href={{@docLink}} target="_blank" rel="noopener noreferrer">
|
||||
See our documentation
|
||||
</a>
|
||||
for help.
|
||||
{{/if}}
|
||||
</p>
|
||||
{{/if}}
|
|
@ -0,0 +1,17 @@
|
|||
import templateOnly from '@ember/component/template-only';
|
||||
|
||||
/**
|
||||
* @module FormFieldLabel
|
||||
* FormFieldLabel components add labels and descriptions to inputs
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <FormFieldLabel for="input-name" @label={{this.label}} @helpText={{this.helpText}} @subText={{this.subText}} @dockLink={{this.docLink}} />
|
||||
* ```
|
||||
* @param {string} [label] - label text -- component attributes are spread on label element
|
||||
* @param {string} [helpText] - adds a tooltip
|
||||
* @param {string} [subText] - de-emphasized text rendered below the label
|
||||
* @param {string} [docLink] - url to documentation rendered after the subText
|
||||
*/
|
||||
|
||||
export default templateOnly();
|
|
@ -9,27 +9,13 @@
|
|||
)
|
||||
}}
|
||||
{{#unless (eq this.attr.type "object")}}
|
||||
<label for={{this.attr.name}} class="is-label">
|
||||
{{this.labelString}}
|
||||
{{#if this.attr.options.helpText}}
|
||||
<InfoTooltip>
|
||||
<span data-test-help-text>
|
||||
{{this.attr.options.helpText}}
|
||||
</span>
|
||||
</InfoTooltip>
|
||||
{{/if}}
|
||||
</label>
|
||||
{{#if this.attr.options.subText}}
|
||||
<p class="sub-text">
|
||||
{{this.attr.options.subText}}
|
||||
{{#if this.attr.options.docLink}}
|
||||
<a href={{this.attr.options.docLink}} target="_blank" rel="noopener noreferrer">
|
||||
See our documentation
|
||||
</a>
|
||||
for help.
|
||||
{{/if}}
|
||||
</p>
|
||||
{{/if}}
|
||||
<FormFieldLabel
|
||||
for={{this.attr.name}}
|
||||
@label={{this.labelString}}
|
||||
@helpText={{this.attr.options.helpText}}
|
||||
@subText={{this.attr.options.subText}}
|
||||
@docLink={{this.attr.options.docLink}}
|
||||
/>
|
||||
{{/unless}}
|
||||
{{/unless}}
|
||||
{{#if this.attr.options.possibleValues}}
|
||||
|
@ -108,14 +94,12 @@
|
|||
@value={{get this.model this.valuePath}}
|
||||
@onChange={{action "setAndBroadcast" this.valuePath}}
|
||||
@label={{this.labelString}}
|
||||
@warning={{this.attr.options.warning}}
|
||||
@labelClass="title {{if (eq this.mode "create") "is-5" "is-4"}}"
|
||||
@helpText={{this.attr.options.helpText}}
|
||||
@subText={{this.attr.options.subText}}
|
||||
@small-label={{if (eq this.mode "create") true false}}
|
||||
@formSection={{if (eq this.mode "customMetadata") false true}}
|
||||
@name={{this.valuePath}}
|
||||
@onKeyUp={{this.onKeyUp}}
|
||||
@validationMessages={{this.validationMessages}}
|
||||
@onKeyUp={{action "handleKeyUp" this.valuePath}}
|
||||
@validationError={{get this.validationMessages this.valuePath}}
|
||||
class={{if (not-eq this.mode "customMetadata") "form-section"}}
|
||||
/>
|
||||
{{else if (eq this.attr.options.editType "file")}}
|
||||
{{! File Input }}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export { default } from 'core/components/autocomplete-input';
|
|
@ -0,0 +1 @@
|
|||
export { default } from 'core/components/form-field-label';
|
|
@ -0,0 +1,86 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { click, fillIn, triggerEvent, typeIn, render } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
|
||||
module('Integration | Component | autocomplete-input', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it should render label', async function (assert) {
|
||||
await render(
|
||||
hbs`
|
||||
<AutocompleteInput
|
||||
@label={{this.label}}
|
||||
@subText={{this.subText}}
|
||||
/>`
|
||||
);
|
||||
|
||||
assert.dom('label').doesNotExist('Label is hidden when not provided');
|
||||
this.setProperties({
|
||||
label: 'Some label',
|
||||
subText: 'Some description',
|
||||
});
|
||||
assert.dom('label').hasText('Some label', 'Label renders');
|
||||
assert.dom('[data-test-label-subtext]').hasText('Some description', 'Sub text renders');
|
||||
});
|
||||
|
||||
test('it should function as standard input', async function (assert) {
|
||||
const changeValue = 'foo bar';
|
||||
this.value = 'test';
|
||||
this.placeholder = 'text goes here';
|
||||
this.onChange = (value) => assert.equal(value, changeValue, 'Value sent in onChange callback');
|
||||
|
||||
await render(
|
||||
hbs`
|
||||
<AutocompleteInput
|
||||
@value={{this.value}}
|
||||
@placeholder={{this.placeholder}}
|
||||
@onChange={{this.onChange}}
|
||||
/>`
|
||||
);
|
||||
|
||||
assert.dom('input').hasAttribute('placeholder', this.placeholder, 'Input placeholder renders');
|
||||
assert.dom('input').hasValue(this.value, 'Initial input value renders');
|
||||
await fillIn('input', changeValue);
|
||||
});
|
||||
|
||||
test('it should trigger dropdown', async function (assert) {
|
||||
await render(
|
||||
hbs`
|
||||
<AutocompleteInput
|
||||
@value={{this.value}}
|
||||
@optionsTrigger="$"
|
||||
@options={{this.options}}
|
||||
@onChange={{fn (mut this.value)}}
|
||||
/>`
|
||||
);
|
||||
|
||||
await typeIn('input', '$');
|
||||
await triggerEvent('input', 'input', { data: '$' }); // simulate InputEvent for data prop with character pressed
|
||||
assert.dom('.autocomplete-input-option').doesNotExist('Trigger does not open dropdown with no options');
|
||||
|
||||
this.set('options', [
|
||||
{ label: 'Foo', value: '$foo' },
|
||||
{ label: 'Bar', value: 'bar' },
|
||||
]);
|
||||
await triggerEvent('input', 'input', { data: '$' });
|
||||
const options = this.element.querySelectorAll('.autocomplete-input-option');
|
||||
options.forEach((o, index) => {
|
||||
assert.dom(o).hasText(this.options[index].label, 'Label renders for option');
|
||||
});
|
||||
|
||||
await click(options[0]);
|
||||
assert.dom('input').isFocused('Focus is returned to input after selecting option');
|
||||
assert
|
||||
.dom('input')
|
||||
.hasValue('$foo', 'Value is updated correctly. Trigger character is not prepended to value.');
|
||||
|
||||
await typeIn('input', '-$');
|
||||
await triggerEvent('input', 'input', { data: '$' });
|
||||
await click('.autocomplete-input-option:last-child');
|
||||
assert
|
||||
.dom('input')
|
||||
.hasValue('$foo-$bar', 'Value is updated correctly. Trigger character is prepended to option.');
|
||||
assert.equal(this.value, '$foo-$bar', 'Value prop is updated correctly onChange');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,44 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import { click } from '@ember/test-helpers';
|
||||
|
||||
module('Integration | Component | form-field-label', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
this.setProperties({
|
||||
label: 'Test Label',
|
||||
helpText: null,
|
||||
subText: null,
|
||||
docLink: null,
|
||||
});
|
||||
|
||||
await render(hbs`
|
||||
<FormFieldLabel
|
||||
@label={{this.label}}
|
||||
@helpText={{this.helpText}}
|
||||
@subText={{this.subText}}
|
||||
@docLink={{this.docLink}}
|
||||
for="some-input"
|
||||
/>
|
||||
`);
|
||||
|
||||
assert.dom('label').hasAttribute('for', 'some-input', 'Attributes passed to label element');
|
||||
assert.dom('label').hasText(this.label, 'Label text renders');
|
||||
assert.dom('[data-test-help-text]').doesNotExist('Help text hidden when not provided');
|
||||
assert.dom('.sub-text').doesNotExist('Sub text hidden when not provided');
|
||||
this.setProperties({
|
||||
helpText: 'More info',
|
||||
subText: 'Some description',
|
||||
});
|
||||
await click('[data-test-tool-tip-trigger]');
|
||||
assert.dom('[data-test-help-text]').hasText(this.helpText, 'Help text renders in tooltip');
|
||||
assert.dom('.sub-text').hasText(this.subText, 'Sub text renders');
|
||||
assert.dom('a').doesNotExist('docLink hidden when not provided');
|
||||
this.set('docLink', 'foo.com/bar');
|
||||
assert.dom('.sub-text').includesText('See our documentation for help', 'Doc link text renders');
|
||||
assert.dom('a').hasAttribute('href', this.docLink, 'Doc link renders');
|
||||
});
|
||||
});
|
|
@ -9,26 +9,26 @@ import kvObjectEditor from '../../pages/components/kv-object-editor';
|
|||
import sinon from 'sinon';
|
||||
const component = create(kvObjectEditor);
|
||||
|
||||
module('Integration | Component | kv object editor', function (hooks) {
|
||||
module('Integration | Component | kv-object-editor', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.spy = sinon.spy();
|
||||
});
|
||||
|
||||
test('it renders with no initial value', async function (assert) {
|
||||
let spy = sinon.spy();
|
||||
this.set('onChange', spy);
|
||||
await render(hbs`{{kv-object-editor onChange=onChange}}`);
|
||||
await render(hbs`{{kv-object-editor onChange=this.spy}}`);
|
||||
assert.equal(component.rows.length, 1, 'renders a single row');
|
||||
await component.addRow();
|
||||
assert.equal(component.rows.length, 1, 'will only render row with a blank key');
|
||||
});
|
||||
|
||||
test('it calls onChange when the val changes', async function (assert) {
|
||||
let spy = sinon.spy();
|
||||
this.set('onChange', spy);
|
||||
await render(hbs`{{kv-object-editor onChange=onChange}}`);
|
||||
await render(hbs`{{kv-object-editor onChange=this.spy}}`);
|
||||
await component.rows.objectAt(0).kvKey('foo').kvVal('bar');
|
||||
assert.equal(spy.callCount, 2, 'calls onChange each time change is triggered');
|
||||
assert.equal(this.spy.callCount, 2, 'calls onChange each time change is triggered');
|
||||
assert.deepEqual(
|
||||
spy.lastCall.args[0],
|
||||
this.spy.lastCall.args[0],
|
||||
{ foo: 'bar' },
|
||||
'calls onChange with the JSON respresentation of the data'
|
||||
);
|
||||
|
@ -48,26 +48,42 @@ module('Integration | Component | kv object editor', function (hooks) {
|
|||
});
|
||||
|
||||
test('it deletes a row', async function (assert) {
|
||||
let spy = sinon.spy();
|
||||
this.set('onChange', spy);
|
||||
await render(hbs`{{kv-object-editor onChange=onChange}}`);
|
||||
await render(hbs`{{kv-object-editor onChange=this.spy}}`);
|
||||
await component.rows.objectAt(0).kvKey('foo').kvVal('bar');
|
||||
await component.addRow();
|
||||
assert.equal(component.rows.length, 2);
|
||||
assert.equal(spy.callCount, 2, 'calls onChange for editing');
|
||||
assert.equal(this.spy.callCount, 2, 'calls onChange for editing');
|
||||
await component.rows.objectAt(0).deleteRow();
|
||||
|
||||
assert.equal(component.rows.length, 1, 'only the blank row left');
|
||||
assert.equal(spy.callCount, 3, 'calls onChange deleting row');
|
||||
assert.deepEqual(spy.lastCall.args[0], {}, 'last call to onChange is an empty object');
|
||||
assert.equal(this.spy.callCount, 3, 'calls onChange deleting row');
|
||||
assert.deepEqual(this.spy.lastCall.args[0], {}, 'last call to onChange is an empty object');
|
||||
});
|
||||
|
||||
test('it shows a warning if there are duplicate keys', async function (assert) {
|
||||
let metadata = { foo: 'bar', baz: 'bop' };
|
||||
this.set('value', metadata);
|
||||
await render(hbs`{{kv-object-editor value=value}}`);
|
||||
await render(hbs`{{kv-object-editor value=value onChange=this.spy}}`);
|
||||
await component.rows.objectAt(0).kvKey('foo');
|
||||
|
||||
assert.ok(component.showsDuplicateError, 'duplicate keys are allowed but an error message is shown');
|
||||
});
|
||||
|
||||
test('it supports custom placeholders', async function (assert) {
|
||||
await render(hbs`<KvObjectEditor @keyPlaceholder="foo" @valuePlaceholder="bar" />`);
|
||||
assert.dom('input').hasAttribute('placeholder', 'foo', 'Placeholder applied to key input');
|
||||
assert.dom('textarea').hasAttribute('placeholder', 'bar', 'Placeholder applied to value input');
|
||||
});
|
||||
|
||||
test('it yields block in place of value input', async function (assert) {
|
||||
await render(
|
||||
hbs`
|
||||
<KvObjectEditor>
|
||||
<span data-test-yield></span>
|
||||
</KvObjectEditor>
|
||||
`
|
||||
);
|
||||
assert.dom('textarea').doesNotExist('Value input hidden when block is provided');
|
||||
assert.dom('[data-test-yield]').exists('Component yields block');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,7 +2,7 @@ import EmberObject from '@ember/object';
|
|||
import sinon from 'sinon';
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click, fillIn } from '@ember/test-helpers';
|
||||
import { render, click, fillIn, settled } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
|
||||
module('Integration | Component | regex-validator', function (hooks) {
|
||||
|
@ -32,29 +32,108 @@ module('Integration | Component | regex-validator', function (hooks) {
|
|||
|
||||
await click('[data-test-toggle-input="example-validation-toggle"]');
|
||||
assert.dom('[data-test-regex-validator-test-string]').exists('Test string input shows after toggle');
|
||||
assert
|
||||
.dom('[data-test-regex-validator-test-string] label')
|
||||
.hasText('Test string', 'Test input label renders');
|
||||
assert
|
||||
.dom('[data-test-regex-validator-test-string] .sub-text')
|
||||
.doesNotExist('Test input sub text is hidden when not provided');
|
||||
assert
|
||||
.dom('[data-test-regex-validation-message]')
|
||||
.doesNotExist('Validation message does not show if test string is empty');
|
||||
|
||||
await fillIn('[data-test-input="example-testval"]', '123a');
|
||||
await fillIn('[data-test-input="regex-test-val"]', '123a');
|
||||
assert.dom('[data-test-regex-validation-message]').exists('Validation message shows after input filled');
|
||||
assert
|
||||
.dom('[data-test-inline-error-message]')
|
||||
.hasText("Your regex doesn't match the subject string", 'Shows error when regex does not match string');
|
||||
.hasText(
|
||||
'This test string does not match the pattern regex.',
|
||||
'Shows error when regex does not match string'
|
||||
);
|
||||
|
||||
await fillIn('[data-test-input="example-testval"]', '1234');
|
||||
await fillIn('[data-test-input="regex-test-val"]', '1234');
|
||||
assert
|
||||
.dom('[data-test-inline-success-message]')
|
||||
.hasText('Your regex matches the subject string', 'Shows success when regex matches');
|
||||
.hasText('This test string matches the pattern regex.', 'Shows success when regex matches');
|
||||
|
||||
await fillIn('[data-test-input="example-testval"]', '12345');
|
||||
await fillIn('[data-test-input="regex-test-val"]', '12345');
|
||||
assert
|
||||
.dom('[data-test-inline-error-message]')
|
||||
.hasText(
|
||||
"Your regex doesn't match the subject string",
|
||||
'This test string does not match the pattern regex.',
|
||||
"Shows error if regex doesn't match complete string"
|
||||
);
|
||||
await fillIn('[data-test-input="example"]', '(\\d{5})');
|
||||
assert.ok(spy.calledOnce, 'Calls the passed onChange function when main input is changed');
|
||||
});
|
||||
|
||||
test('it renders test input only when attr is not provided', async function (assert) {
|
||||
this.setProperties({
|
||||
value: null,
|
||||
label: 'Sample input',
|
||||
subText: 'Some text to further describe the input',
|
||||
});
|
||||
|
||||
await render(hbs`
|
||||
<RegexValidator
|
||||
@value={{this.value}}
|
||||
@testInputLabel={{this.label}}
|
||||
@testInputSubText={{this.subText}}
|
||||
/>`);
|
||||
|
||||
assert
|
||||
.dom('[data-test-regex-validator-pattern]')
|
||||
.doesNotExist('Pattern input is hidden when attr is not provided');
|
||||
assert
|
||||
.dom('[data-test-regex-validator-test-string] label')
|
||||
.hasText(this.label, 'Test input label renders');
|
||||
assert
|
||||
.dom('[data-test-regex-validator-test-string] .sub-text')
|
||||
.hasText(this.subText, 'Test input sub text renders');
|
||||
|
||||
await fillIn('[data-test-input="regex-test-val"]', 'test');
|
||||
assert
|
||||
.dom('[data-test-inline-error-message]')
|
||||
.hasText(
|
||||
'A pattern has not been entered. Enter a pattern to check this sample input against it.',
|
||||
'Warning renders when test input has value but not regex exists'
|
||||
);
|
||||
|
||||
this.set('value', 'test');
|
||||
assert
|
||||
.dom('[data-test-inline-success-message]')
|
||||
.hasText('This test string matches the pattern regex.', 'Shows success when regex matches');
|
||||
|
||||
this.set('value', 'foo');
|
||||
await settled();
|
||||
assert
|
||||
.dom('[data-test-inline-error-message]')
|
||||
.hasText(
|
||||
'This test string does not match the pattern regex.',
|
||||
'Pattern is validated on external value change'
|
||||
);
|
||||
});
|
||||
|
||||
test('it renders capture groups', async function (assert) {
|
||||
this.set('value', '(test)(?<last>\\d?)');
|
||||
|
||||
await render(hbs`
|
||||
<RegexValidator
|
||||
@value={{this.value}}
|
||||
@showGroups={{true}}
|
||||
/>`);
|
||||
await fillIn('[data-test-input="regex-test-val"]', 'foobar');
|
||||
assert
|
||||
.dom('[data-test-regex-validator-groups-placeholder]')
|
||||
.exists('Placeholder is shown when regex does not match test input value');
|
||||
await fillIn('[data-test-input="regex-test-val"]', 'test8');
|
||||
assert.dom('[data-test-regex-group-position="$1"]').hasText('$1', 'First capture group position renders');
|
||||
assert.dom('[data-test-regex-group-value="$1"]').hasText('test', 'First capture group value renders');
|
||||
assert
|
||||
.dom('[data-test-regex-group-position="$2"]')
|
||||
.hasText('$2', 'Second capture group position renders');
|
||||
assert.dom('[data-test-regex-group-value="$2"]').hasText('8', 'Second capture group value renders');
|
||||
assert.dom('[data-test-regex-group-position="$last"]').hasText('$last', 'Named capture group renders');
|
||||
assert.dom('[data-test-regex-group-value="$last"]').hasText('8', 'Named capture group value renders');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { click, fillIn, render, triggerEvent } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
|
||||
module('Integration | Component | transform-advanced-templating', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it should render', async function (assert) {
|
||||
this.model = {
|
||||
pattern: '(\\d{3})-(\\d{2})-(?<last>\\d{5})',
|
||||
encodeFormat: null,
|
||||
decodeFormats: {},
|
||||
};
|
||||
await render(hbs`<TransformAdvancedTemplating @model={{this.model}} />`);
|
||||
|
||||
assert.dom('.box').doesNotExist('Form is hidden when not toggled');
|
||||
await click('[data-test-toggle-advanced]');
|
||||
await fillIn('[data-test-input="regex-test-val"]', '123-45-67890');
|
||||
[
|
||||
['$1', '123'],
|
||||
['$2', '45'],
|
||||
['$3', '67890'],
|
||||
['$last', '67890'],
|
||||
].forEach(([p, v]) => {
|
||||
assert.dom(`[data-test-regex-group-position="${p}"]`).hasText(`${p}`, `Capture group ${p} renders`);
|
||||
assert.dom(`[data-test-regex-group-value="${p}"]`).hasText(v, `Capture group value ${v} renders`);
|
||||
});
|
||||
// need to simulate InputEvent
|
||||
await triggerEvent('[data-test-encode-format] input', 'input', { data: '$' });
|
||||
const options = this.element.querySelectorAll('.autocomplete-input-option');
|
||||
['$1: 123', '$2: 45', '$3: 67890', '$last: 67890'].forEach((val, index) => {
|
||||
assert.dom(options[index]).hasText(val, 'Autocomplete option renders');
|
||||
});
|
||||
|
||||
assert.dom('[data-test-kv-object-editor]').exists('KvObjectEditor renders for decode formats');
|
||||
assert.dom('[data-test-decode-format]').exists('AutocompleteInput renders for decode format value');
|
||||
await fillIn('[data-test-kv-key]', 'last');
|
||||
await fillIn('[data-test-decode-format] input', '$last');
|
||||
assert.deepEqual(this.model.decodeFormats, { last: '$last' }, 'Decode formats updates correctly');
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue