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:
Jordan Reimer 2022-02-07 13:07:53 -07:00 committed by GitHub
parent 33a9218115
commit e811821ac7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 871 additions and 281 deletions

3
changelog/13908.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:improvement
ui: Transform advanced templating with encode/decode format support
```

View file

@ -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 * @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. * 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 {string} value - the value is captured from the model.
* @param {function} onChange - function that captures the value on change * @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} [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} [warning] - warning that is displayed
* @param {string} [helpText] - helper text. In tooltip. * @param {string} [helpText] - helper text. In tooltip.
* @param {string} [subText] - placed under label. * @param {string} [subText] - placed under label.
* @param {boolean} [small-label]- change label size. * @param {string} [keyPlaceholder] - placeholder for key input
* @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} [valuePlaceholder] - placeholder for value input
*/ */
import { isNone } from '@ember/utils'; export default class KvObjectEditor extends Component {
import { assert } from '@ember/debug'; @tracked kvData;
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 Component.extend({ constructor() {
'data-test-component': 'kv-object-editor', super(...arguments);
classNames: ['field'], this.kvData = KVObject.create({ content: [] }).fromJSON(this.args.value);
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);
this.addRow(); this.addRow();
}, }
kvData: null, get placeholders() {
return {
kvDataAsJSON: computed('kvData', 'kvData.[]', function () { key: this.args.keyPlaceholder || 'key',
return this.kvData.toJSON(); value: this.args.valuePlaceholder || 'value',
}), };
}
kvDataIsAdvanced: computed('kvData', 'kvData.[]', function () { get hasDuplicateKeys() {
return this.kvData.isAdvanced(); return this.kvData.uniqBy('name').length !== this.kvData.get('length');
}), }
kvHasDuplicateKeys: computed('kvData', 'kvData.@each.name', function () {
let data = this.kvData;
return data.uniqBy('name').length !== data.get('length');
}),
@action
addRow() { addRow() {
let data = this.kvData; if (!isNone(this.kvData.findBy('name', ''))) {
let newObj = { name: '', value: '' };
if (!isNone(data.findBy('name', ''))) {
return; return;
} }
const newObj = { name: '', value: '' };
guidFor(newObj); guidFor(newObj);
data.addObject(newObj); this.kvData.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); @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);
}
}
}

View file

@ -1,4 +1,5 @@
<div class="field"> {{#if @attr}}
<div class="field" data-test-regex-validator-pattern>
<div class="regex-label-wrapper"> <div class="regex-label-wrapper">
<div class="regex-label"> <div class="regex-label">
<label for={{@attr.name}} class="is-label"> <label for={{@attr.name}} class="is-label">
@ -44,15 +45,19 @@
value={{@value}} value={{@value}}
class="input" class="input"
/> />
</div> </div>
{{/if}}
{{#if this.showTestValue}} {{#if this.showTestValue}}
<div data-test-regex-validator-test-string> <div data-test-regex-validator-test-string>
<label for={{@attr.name}} class="is-label"> <label for="regex-test-val" class="is-label">
Test string {{this.testInputLabel}}
</label> </label>
{{#if @testInputSubText}}
<p class="sub-text">{{@testInputSubText}}</p>
{{/if}}
<input <input
data-test-input={{concat @attr.name "-testval"}} data-test-input="regex-test-val"
id={{concat @attr.name "-testval"}} id="regex-test-val"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
value={{this.testValue}} value={{this.testValue}}
@ -60,17 +65,50 @@
class="input {{if this.regexError "has-error"}}" class="input {{if this.regexError "has-error"}}"
/> />
{{#if (and this.testValue @value)}} {{#if this.testValue}}
<div data-test-regex-validation-message> <div data-test-regex-validation-message>
{{#if this.regexError}} {{#if (not @value)}}
<AlertInline @type="danger" @message="Your regex doesn't match the subject string" /> <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}} {{else}}
<div class="message-inline"> <div class="message-inline">
<Icon @name="check-circle-fill" class="has-text-success" /> <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> </div>
{{/if}} {{/if}}
</div> </div>
{{/if}} {{/if}}
</div> </div>
{{/if}} {{/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}}

View file

@ -15,10 +15,14 @@
* } * }
* <RegexValidator @onChange={action 'myAction'} @attr={attrExample} @labelString="Label String" @value="initial value" /> * <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} value - the value of the main input which will be updated in onChange
* @param {string} labelString - Form label. Anticipated from form-field * @param {func} [onChange] - the action that should trigger when pattern input is changed. Required when attr is provided.
* @param {object} attr - attribute from model. Anticipated from form-field. Example of attribute shape above * @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'; import Component from '@glimmer/component';
@ -29,13 +33,42 @@ export default class RegexValidator extends Component {
@tracked testValue = ''; @tracked testValue = '';
@tracked showTestValue = false; @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() { get regexError() {
const testString = this.testValue; const testString = this.testValue;
if (!testString || !this.args.value) return false; if (!testString || !this.args.value) return false;
const regex = new RegExp(this.args.value, 'g'); const matchArray = testString.toString().match(this.regex);
const matchArray = testString.toString().match(regex); if (this.args.onValidate) {
this.args.onValidate(this.testValue, this.captureGroups);
}
return testString !== matchArray?.join(''); 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 @action
updateTestValue(evt) { updateTestValue(evt) {

View file

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

View file

@ -31,13 +31,17 @@ const M = Model.extend({
models: ['transform/alphabet'], models: ['transform/alphabet'],
selectLimit: 1, selectLimit: 1,
}), }),
encodeFormat: attr('string'),
decodeFormats: attr(),
backend: attr('string', { readOnly: true }),
attrs: computed(function () { readAttrs: computed(function () {
let keys = ['name', 'pattern', 'alphabet']; let keys = ['name', 'pattern', 'encodeFormat', 'decodeFormats', 'alphabet'];
return expandAttributeMeta(this, keys); return expandAttributeMeta(this, keys);
}), }),
writeAttrs: computed(function () {
backend: attr('string', { readOnly: true }), return expandAttributeMeta(this, ['name', 'pattern', 'alphabet']);
}),
}); });
export default attachCapabilities(M, { export default attachCapabilities(M, {

View file

@ -6,6 +6,10 @@ export default ApplicationSerializer.extend({
if (payload.data.alphabet) { if (payload.data.alphabet) {
payload.data.alphabet = [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); return this._super(store, primaryModelClass, payload, id, requestType);
}, },
@ -15,9 +19,26 @@ export default ApplicationSerializer.extend({
// Templates should only ever have one alphabet // Templates should only ever have one alphabet
json.alphabet = json.alphabet[0]; json.alphabet = json.alphabet[0];
} }
// add P character to any named capture groups
if (json.pattern) {
this._formatNamedCaptureGroups(json, '?', '?P');
}
return json; 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) { extractLazyPaginatedData(payload) {
let ret; let ret;
ret = payload.data.keys.map((key) => { ret = payload.data.keys.map((key) => {

View file

@ -8,3 +8,21 @@
.regex-toggle { .regex-toggle {
flex: 0 1 auto; 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;
}

View file

@ -6,3 +6,9 @@
padding: 14px; padding: 14px;
} }
} }
.transform-pattern-text div:not(:first-child) {
font-family: $family-monospace;
}
.transform-decode-formats:not(:last-child) {
margin-bottom: $spacing-s;
}

View file

@ -329,3 +329,21 @@ fieldset.form-fieldset {
.has-error-border { .has-error-border {
border: 1px solid $red-500; 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;
}
}

View file

@ -204,3 +204,7 @@ ul.bullet {
.has-text-semibold { .has-text-semibold {
font-weight: $font-weight-semibold; font-weight: $font-weight-semibold;
} }
.has-text-grey-400 {
color: $ui-gray-400;
}

View file

@ -16,12 +16,14 @@ $vault-gray: $vault-gray-500;
$vault-gray-dark: $vault-gray-700; $vault-gray-dark: $vault-gray-700;
// UI Gray // UI Gray
$ui-gray-010: #FBFBFC; $ui-gray-010: #fbfbfc;
$ui-gray-050: #F7F8FA; $ui-gray-050: #f7f8fa;
$ui-gray-100: #EBEEF2; $ui-gray-100: #ebeef2;
$ui-gray-200: #DCE0E6; $ui-gray-200: #dce0e6;
$ui-gray-300: #BAC1CC; $ui-gray-300: #bac1cc;
$ui-gray-500: #6F7682; $ui-gray-400: #8e96a3;
$ui-gray-500: #6f7682;
$ui-gray-600: #626873;
$ui-gray-700: #525761; $ui-gray-700: #525761;
$ui-gray-800: #373a42; $ui-gray-800: #373a42;
$ui-gray-900: #1f2124; $ui-gray-900: #1f2124;

View file

@ -1,46 +1,43 @@
{{#if this.label}} <div class="field" ...attributes>
<label class="title {{if this.small-label "is-5" "is-4"}}" data-test-kv-label="true"> <FormFieldLabel
{{this.label}} @label={{@label}}
{{#if this.helpText}} @helpText={{@helpText}}
<InfoTooltip> @subText={{@subText}}
{{this.helpText}} class={{@labelClass}}
</InfoTooltip> data-test-kv-label={{true}}
{{/if}} />
</label> {{#if @validationError}}
{{#if this.subText}}
<p class="has-padding-bottom">
{{this.subText}}
</p>
{{/if}}
{{/if}}
{{#if (get this.validationMessages this.name)}}
<div> <div>
<AlertInline @type="danger" @message={{get this.validationMessages this.name}} @paddingTop={{true}} /> <AlertInline @type="danger" @message={{@validationError}} @paddingTop={{true}} />
</div> </div>
{{/if}} {{/if}}
{{#each this.kvData as |row index|}} {{#each this.kvData as |row index|}}
<div class="columns is-variable" data-test-kv-row> <div class="columns is-variable" data-test-kv-row>
<div class="column is-one-quarter"> <div class="column is-one-quarter">
<Input <Input
data-test-kv-key={{true}} data-test-kv-key={{true}}
@value={{row.name}} @value={{row.name}}
placeholder="key" placeholder={{this.placeholders.key}}
{{on "change" (action "updateRow" row index)}} {{on "change" (fn this.updateRow row index)}}
class="input" class="input"
/> />
</div> </div>
<div class="column"> <div class="column">
{{#if (has-block)}}
{{yield row this.kvData}}
{{else}}
<Textarea <Textarea
data-test-kv-value={{true}} data-test-kv-value={{index}}
name={{row.name}} name={{row.name}}
class="input {{if (get this.validationMessages this.name) "has-error-border"}}" class="input {{if @validationError "has-error-border"}}"
{{on "change" (action "updateRow" row index)}}
@value={{row.value}} @value={{row.value}}
wrap="off" wrap="off"
placeholder="value" placeholder={{this.placeholders.value}}
rows={{1}} rows={{1}}
onkeyup={{action (action "handleKeyUp" this.name) value="target.value"}} {{on "change" (fn this.updateRow row index)}}
{{on "keyup" this.handleKeyUp}}
/> />
{{/if}}
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">
{{#if (eq this.kvData.length (inc index))}} {{#if (eq this.kvData.length (inc index))}}
@ -53,19 +50,20 @@
type="button" type="button"
{{action "deleteRow" row index}} {{action "deleteRow" row index}}
aria-label="Delete row" aria-label="Delete row"
data-test-kv-delete-row data-test-kv-delete-row={{index}}
> >
<Icon @name="trash" /> <Icon @name="trash" />
</button> </button>
{{/if}} {{/if}}
</div> </div>
</div> </div>
{{/each}} {{/each}}
{{#if this.kvHasDuplicateKeys}} {{#if this.hasDuplicateKeys}}
<AlertBanner <AlertBanner
@type="warning" @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." @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" @class="is-marginless"
data-test-duplicate-error-warnings data-test-duplicate-error-warnings
/> />
{{/if}} {{/if}}
</div>

View file

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

View file

@ -50,7 +50,7 @@
<div class="box is-sideless is-fullwidth is-marginless"> <div class="box is-sideless is-fullwidth is-marginless">
<MessageError @model={{this.model}} /> <MessageError @model={{this.model}} />
<NamespaceReminder @mode={{this.mode}} @noun="transform template" /> <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"))}} {{#if (and (eq attr.name "name") (eq this.mode "edit"))}}
<label for={{attr.name}} class="is-label"> <label for={{attr.name}} class="is-label">
{{attr.options.label}} {{attr.options.label}}
@ -69,6 +69,9 @@
type={{attr.type}} type={{attr.type}}
/> />
{{else}} {{else}}
{{#if (eq attr.name "alphabet")}}
<TransformAdvancedTemplating @model={{this.model}} />
{{/if}}
<FormField data-test-field @attr={{attr}} @model={{this.model}} /> <FormField data-test-field @attr={{attr}} @model={{this.model}} />
{{/if}} {{/if}}
{{/each}} {{/each}}
@ -104,25 +107,29 @@
</div> </div>
{{/if}} {{/if}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless"> <div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each this.model.attrs as |attr|}} {{#each this.model.readAttrs as |attr|}}
{{#if (eq attr.type "object")}} {{#let (capitalize (or attr.options.label (humanize (dasherize attr.name)))) as |label|}}
<InfoTableRow {{#if (eq attr.name "decodeFormats")}}
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} {{#if (not (is-empty-value this.model.decodeFormats))}}
@value={{stringify (get this.model attr.name)}} <InfoTableRow @label={{label}}>
/> <div>
{{else if (eq attr.type "array")}} {{#each-in this.model.decodeFormats as |key value|}}
<InfoTableRow <div class="transform-decode-formats">
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} <p class="is-label has-text-grey-400">{{key}}</p>
@value={{get this.model attr.name}} <p>{{value}}</p>
@type={{attr.type}} </div>
@viewAll={{attr.name}} {{/each-in}}
/> </div>
</InfoTableRow>
{{/if}}
{{else}} {{else}}
<InfoTableRow <InfoTableRow
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}} @label={{label}}
@value={{get this.model attr.name}} @value={{get this.model attr.name}}
class={{if (eq attr.name "pattern") "transform-pattern-text"}}
/> />
{{/if}} {{/if}}
{{/let}}
{{/each}} {{/each}}
</div> </div>
{{/if}} {{/if}}

View file

@ -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>

View file

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

View file

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

View file

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

View file

@ -9,27 +9,13 @@
) )
}} }}
{{#unless (eq this.attr.type "object")}} {{#unless (eq this.attr.type "object")}}
<label for={{this.attr.name}} class="is-label"> <FormFieldLabel
{{this.labelString}} for={{this.attr.name}}
{{#if this.attr.options.helpText}} @label={{this.labelString}}
<InfoTooltip> @helpText={{this.attr.options.helpText}}
<span data-test-help-text> @subText={{this.attr.options.subText}}
{{this.attr.options.helpText}} @docLink={{this.attr.options.docLink}}
</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}}
{{/unless}} {{/unless}}
{{/unless}} {{/unless}}
{{#if this.attr.options.possibleValues}} {{#if this.attr.options.possibleValues}}
@ -108,14 +94,12 @@
@value={{get this.model this.valuePath}} @value={{get this.model this.valuePath}}
@onChange={{action "setAndBroadcast" this.valuePath}} @onChange={{action "setAndBroadcast" this.valuePath}}
@label={{this.labelString}} @label={{this.labelString}}
@warning={{this.attr.options.warning}} @labelClass="title {{if (eq this.mode "create") "is-5" "is-4"}}"
@helpText={{this.attr.options.helpText}} @helpText={{this.attr.options.helpText}}
@subText={{this.attr.options.subText}} @subText={{this.attr.options.subText}}
@small-label={{if (eq this.mode "create") true false}} @onKeyUp={{action "handleKeyUp" this.valuePath}}
@formSection={{if (eq this.mode "customMetadata") false true}} @validationError={{get this.validationMessages this.valuePath}}
@name={{this.valuePath}} class={{if (not-eq this.mode "customMetadata") "form-section"}}
@onKeyUp={{this.onKeyUp}}
@validationMessages={{this.validationMessages}}
/> />
{{else if (eq this.attr.options.editType "file")}} {{else if (eq this.attr.options.editType "file")}}
{{! File Input }} {{! File Input }}

View file

@ -0,0 +1 @@
export { default } from 'core/components/autocomplete-input';

View file

@ -0,0 +1 @@
export { default } from 'core/components/form-field-label';

View file

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

View file

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

View file

@ -9,26 +9,26 @@ import kvObjectEditor from '../../pages/components/kv-object-editor';
import sinon from 'sinon'; import sinon from 'sinon';
const component = create(kvObjectEditor); const component = create(kvObjectEditor);
module('Integration | Component | kv object editor', function (hooks) { module('Integration | Component | kv-object-editor', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.spy = sinon.spy();
});
test('it renders with no initial value', async function (assert) { test('it renders with no initial value', async function (assert) {
let spy = sinon.spy(); await render(hbs`{{kv-object-editor onChange=this.spy}}`);
this.set('onChange', spy);
await render(hbs`{{kv-object-editor onChange=onChange}}`);
assert.equal(component.rows.length, 1, 'renders a single row'); assert.equal(component.rows.length, 1, 'renders a single row');
await component.addRow(); await component.addRow();
assert.equal(component.rows.length, 1, 'will only render row with a blank key'); 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) { test('it calls onChange when the val changes', async function (assert) {
let spy = sinon.spy(); await render(hbs`{{kv-object-editor onChange=this.spy}}`);
this.set('onChange', spy);
await render(hbs`{{kv-object-editor onChange=onChange}}`);
await component.rows.objectAt(0).kvKey('foo').kvVal('bar'); 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( assert.deepEqual(
spy.lastCall.args[0], this.spy.lastCall.args[0],
{ foo: 'bar' }, { foo: 'bar' },
'calls onChange with the JSON respresentation of the data' '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) { test('it deletes a row', async function (assert) {
let spy = sinon.spy(); await render(hbs`{{kv-object-editor onChange=this.spy}}`);
this.set('onChange', spy);
await render(hbs`{{kv-object-editor onChange=onChange}}`);
await component.rows.objectAt(0).kvKey('foo').kvVal('bar'); await component.rows.objectAt(0).kvKey('foo').kvVal('bar');
await component.addRow(); await component.addRow();
assert.equal(component.rows.length, 2); 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(); await component.rows.objectAt(0).deleteRow();
assert.equal(component.rows.length, 1, 'only the blank row left'); assert.equal(component.rows.length, 1, 'only the blank row left');
assert.equal(spy.callCount, 3, 'calls onChange deleting row'); assert.equal(this.spy.callCount, 3, 'calls onChange deleting row');
assert.deepEqual(spy.lastCall.args[0], {}, 'last call to onChange is an empty object'); 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) { test('it shows a warning if there are duplicate keys', async function (assert) {
let metadata = { foo: 'bar', baz: 'bop' }; let metadata = { foo: 'bar', baz: 'bop' };
this.set('value', metadata); 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'); await component.rows.objectAt(0).kvKey('foo');
assert.ok(component.showsDuplicateError, 'duplicate keys are allowed but an error message is shown'); 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');
});
}); });

View file

@ -2,7 +2,7 @@ import EmberObject from '@ember/object';
import sinon from 'sinon'; import sinon from 'sinon';
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-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'; import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | regex-validator', function (hooks) { 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"]'); 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]').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 assert
.dom('[data-test-regex-validation-message]') .dom('[data-test-regex-validation-message]')
.doesNotExist('Validation message does not show if test string is empty'); .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-regex-validation-message]').exists('Validation message shows after input filled');
assert assert
.dom('[data-test-inline-error-message]') .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 assert
.dom('[data-test-inline-success-message]') .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 assert
.dom('[data-test-inline-error-message]') .dom('[data-test-inline-error-message]')
.hasText( .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" "Shows error if regex doesn't match complete string"
); );
await fillIn('[data-test-input="example"]', '(\\d{5})'); await fillIn('[data-test-input="example"]', '(\\d{5})');
assert.ok(spy.calledOnce, 'Calls the passed onChange function when main input is changed'); 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');
});
}); });

View file

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