diff --git a/changelog/13908.txt b/changelog/13908.txt
new file mode 100644
index 000000000..7cc942a2e
--- /dev/null
+++ b/changelog/13908.txt
@@ -0,0 +1,3 @@
+```release-note:improvement
+ui: Transform advanced templating with encode/decode format support
+```
\ No newline at end of file
diff --git a/ui/app/components/kv-object-editor.js b/ui/app/components/kv-object-editor.js
index d7577cc4e..907e2f997 100644
--- a/ui/app/components/kv-object-editor.js
+++ b/ui/app/components/kv-object-editor.js
@@ -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);
+ }
+ }
+}
diff --git a/ui/app/components/regex-validator.hbs b/ui/app/components/regex-validator.hbs
index a16cda211..8a6eb18c9 100644
--- a/ui/app/components/regex-validator.hbs
+++ b/ui/app/components/regex-validator.hbs
@@ -1,58 +1,63 @@
-
-
-
-
- {{@labelString}}
- {{#if @attr.options.helpText}}
-
-
- {{@attr.options.helpText}}
-
-
- {{/if}}
-
- {{#if @attr.options.subText}}
-
- {{@attr.options.subText}}
- {{#if @attr.options.docLink}}
-
- See our documentation
-
- for help.
+{{#if @attr}}
+
+
+
+
+ {{@labelString}}
+ {{#if @attr.options.helpText}}
+
+
+ {{@attr.options.helpText}}
+
+
{{/if}}
-
- {{/if}}
-
-
-
- Validation
-
+
+ {{#if @attr.options.subText}}
+
+ {{@attr.options.subText}}
+ {{#if @attr.options.docLink}}
+
+ See our documentation
+
+ for help.
+ {{/if}}
+
+ {{/if}}
+
+
+
+ Validation
+
+
+
-
-
+{{/if}}
{{#if this.showTestValue}}
-
- Test string
+
+ {{this.testInputLabel}}
+ {{#if @testInputSubText}}
+ {{@testInputSubText}}
+ {{/if}}
- {{#if (and this.testValue @value)}}
+ {{#if this.testValue}}
- {{#if this.regexError}}
-
+ {{#if (not @value)}}
+
+ {{else if this.regexError}}
+
{{else}}
-
Your regex matches the subject string
+
+ This test string matches the pattern regex.
+
{{/if}}
{{/if}}
+{{/if}}
+{{#if @showGroups}}
+
+
Groups
+ {{! check with design but should likely show a placeholder if testValue is blank }}
+ {{#if (and @value this.testValue (not this.regexError))}}
+
+ {{#each this.captureGroups as |group|}}
+
+ {{group.position}}
+
+
+ {{group.value}}
+
+ {{/each}}
+
+ {{else}}
+
+ Enter pattern and test string to show groupings.
+
+ {{/if}}
+
{{/if}}
\ No newline at end of file
diff --git a/ui/app/components/regex-validator.js b/ui/app/components/regex-validator.js
index f71539435..5f769e4f4 100644
--- a/ui/app/components/regex-validator.js
+++ b/ui/app/components/regex-validator.js
@@ -15,10 +15,14 @@
* }
*
* ```
- * @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 (
\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) {
diff --git a/ui/app/components/transform-advanced-templating.js b/ui/app/components/transform-advanced-templating.js
new file mode 100644
index 000000000..762130dc3
--- /dev/null
+++ b/ui/app/components/transform-advanced-templating.js
@@ -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
+ *
+ * ```
+ * @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();
+ }
+}
diff --git a/ui/app/models/transform/template.js b/ui/app/models/transform/template.js
index 9d1af764e..045fb96b9 100644
--- a/ui/app/models/transform/template.js
+++ b/ui/app/models/transform/template.js
@@ -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, {
diff --git a/ui/app/serializers/transform/template.js b/ui/app/serializers/transform/template.js
index 7a4cf7e21..749cbe6be 100644
--- a/ui/app/serializers/transform/template.js
+++ b/ui/app/serializers/transform/template.js
@@ -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) => {
diff --git a/ui/app/styles/components/regex-validator.scss b/ui/app/styles/components/regex-validator.scss
index 72fb255c7..2f3213335 100644
--- a/ui/app/styles/components/regex-validator.scss
+++ b/ui/app/styles/components/regex-validator.scss
@@ -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;
+}
diff --git a/ui/app/styles/components/transform-edit.scss b/ui/app/styles/components/transform-edit.scss
index 574001004..3d6cb8903 100644
--- a/ui/app/styles/components/transform-edit.scss
+++ b/ui/app/styles/components/transform-edit.scss
@@ -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;
+}
diff --git a/ui/app/styles/core/forms.scss b/ui/app/styles/core/forms.scss
index e2d8f347a..fb0b5fef9 100644
--- a/ui/app/styles/core/forms.scss
+++ b/ui/app/styles/core/forms.scss
@@ -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;
+ }
+}
diff --git a/ui/app/styles/core/helpers.scss b/ui/app/styles/core/helpers.scss
index 17fdd97ad..73f31d248 100644
--- a/ui/app/styles/core/helpers.scss
+++ b/ui/app/styles/core/helpers.scss
@@ -204,3 +204,7 @@ ul.bullet {
.has-text-semibold {
font-weight: $font-weight-semibold;
}
+
+.has-text-grey-400 {
+ color: $ui-gray-400;
+}
diff --git a/ui/app/styles/utils/_colors.scss b/ui/app/styles/utils/_colors.scss
index e78f3800b..49c3ae66b 100644
--- a/ui/app/styles/utils/_colors.scss
+++ b/ui/app/styles/utils/_colors.scss
@@ -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;
diff --git a/ui/app/templates/components/kv-object-editor.hbs b/ui/app/templates/components/kv-object-editor.hbs
index b9b4476a2..716f6f488 100644
--- a/ui/app/templates/components/kv-object-editor.hbs
+++ b/ui/app/templates/components/kv-object-editor.hbs
@@ -1,71 +1,69 @@
-{{#if this.label}}
-
- {{this.label}}
- {{#if this.helpText}}
-
- {{this.helpText}}
-
- {{/if}}
-
- {{#if this.subText}}
-
- {{this.subText}}
-
- {{/if}}
-{{/if}}
-{{#if (get this.validationMessages this.name)}}
-
-{{/if}}
-{{#each this.kvData as |row index|}}
-
-
-
-
-
-
-
-
- {{#if (eq this.kvData.length (inc index))}}
-
- Add
-
- {{else}}
-
-
-
- {{/if}}
-
-
-{{/each}}
-{{#if this.kvHasDuplicateKeys}}
-
+
-{{/if}}
\ No newline at end of file
+ {{#if @validationError}}
+
+ {{/if}}
+ {{#each this.kvData as |row index|}}
+
+
+
+
+
+ {{#if (has-block)}}
+ {{yield row this.kvData}}
+ {{else}}
+
+ {{/if}}
+
+
+ {{#if (eq this.kvData.length (inc index))}}
+
+ Add
+
+ {{else}}
+
+
+
+ {{/if}}
+
+
+ {{/each}}
+ {{#if this.hasDuplicateKeys}}
+
+ {{/if}}
+
\ No newline at end of file
diff --git a/ui/app/templates/components/transform-advanced-templating.hbs b/ui/app/templates/components/transform-advanced-templating.hbs
new file mode 100644
index 000000000..47578c01b
--- /dev/null
+++ b/ui/app/templates/components/transform-advanced-templating.hbs
@@ -0,0 +1,62 @@
+
+
+{{#if this.showForm}}
+
+
Advanced templating
+
+ 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
+
+ our documentation.
+
+
+
+
+
+
+
+
+
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/app/templates/components/transform-template-edit.hbs b/ui/app/templates/components/transform-template-edit.hbs
index 0df415d9b..056b1683f 100644
--- a/ui/app/templates/components/transform-template-edit.hbs
+++ b/ui/app/templates/components/transform-template-edit.hbs
@@ -50,7 +50,7 @@
- {{#each this.model.attrs as |attr|}}
+ {{#each this.model.writeAttrs as |attr|}}
{{#if (and (eq attr.name "name") (eq this.mode "edit"))}}
{{attr.options.label}}
@@ -69,6 +69,9 @@
type={{attr.type}}
/>
{{else}}
+ {{#if (eq attr.name "alphabet")}}
+
+ {{/if}}
{{/if}}
{{/each}}
@@ -104,25 +107,29 @@
{{/if}}
- {{#each this.model.attrs as |attr|}}
- {{#if (eq attr.type "object")}}
-
- {{else if (eq attr.type "array")}}
-
- {{else}}
-
- {{/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))}}
+
+
+ {{#each-in this.model.decodeFormats as |key value|}}
+
+ {{/each-in}}
+
+
+ {{/if}}
+ {{else}}
+
+ {{/if}}
+ {{/let}}
{{/each}}
{{/if}}
\ No newline at end of file
diff --git a/ui/lib/core/addon/components/autocomplete-input.hbs b/ui/lib/core/addon/components/autocomplete-input.hbs
new file mode 100644
index 000000000..928f88662
--- /dev/null
+++ b/ui/lib/core/addon/components/autocomplete-input.hbs
@@ -0,0 +1,24 @@
+
+ {{#if @label}}
+
+ {{/if}}
+
+
+
+
+ {{#each @options as |option|}}
+
+ {{option.label}}
+
+ {{/each}}
+
+
+
+
\ No newline at end of file
diff --git a/ui/lib/core/addon/components/autocomplete-input.js b/ui/lib/core/addon/components/autocomplete-input.js
new file mode 100644
index 000000000..695113c77
--- /dev/null
+++ b/ui/lib/core/addon/components/autocomplete-input.js
@@ -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
+ *
+ * ```
+ * @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();
+ }
+}
diff --git a/ui/lib/core/addon/components/form-field-label.hbs b/ui/lib/core/addon/components/form-field-label.hbs
new file mode 100644
index 000000000..a6779bba6
--- /dev/null
+++ b/ui/lib/core/addon/components/form-field-label.hbs
@@ -0,0 +1,21 @@
+{{#if @label}}
+
+ {{@label}}
+ {{#if @helpText}}
+
+ {{@helpText}}
+
+ {{/if}}
+
+{{/if}}
+{{#if @subText}}
+
+ {{@subText}}
+ {{#if @docLink}}
+
+ See our documentation
+
+ for help.
+ {{/if}}
+
+{{/if}}
\ No newline at end of file
diff --git a/ui/lib/core/addon/components/form-field-label.js b/ui/lib/core/addon/components/form-field-label.js
new file mode 100644
index 000000000..4e0ee35f9
--- /dev/null
+++ b/ui/lib/core/addon/components/form-field-label.js
@@ -0,0 +1,17 @@
+import templateOnly from '@ember/component/template-only';
+
+/**
+ * @module FormFieldLabel
+ * FormFieldLabel components add labels and descriptions to inputs
+ *
+ * @example
+ * ```js
+ *
+ * ```
+ * @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();
diff --git a/ui/lib/core/addon/templates/components/form-field.hbs b/ui/lib/core/addon/templates/components/form-field.hbs
index 37c0b3042..2ed0827fa 100644
--- a/ui/lib/core/addon/templates/components/form-field.hbs
+++ b/ui/lib/core/addon/templates/components/form-field.hbs
@@ -9,27 +9,13 @@
)
}}
{{#unless (eq this.attr.type "object")}}
-
- {{this.labelString}}
- {{#if this.attr.options.helpText}}
-
-
- {{this.attr.options.helpText}}
-
-
- {{/if}}
-
- {{#if this.attr.options.subText}}
-
- {{this.attr.options.subText}}
- {{#if this.attr.options.docLink}}
-
- See our documentation
-
- for help.
- {{/if}}
-
- {{/if}}
+
{{/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 }}
diff --git a/ui/lib/core/app/components/autocomplete-input.js b/ui/lib/core/app/components/autocomplete-input.js
new file mode 100644
index 000000000..969b2694f
--- /dev/null
+++ b/ui/lib/core/app/components/autocomplete-input.js
@@ -0,0 +1 @@
+export { default } from 'core/components/autocomplete-input';
diff --git a/ui/lib/core/app/components/form-field-label.js b/ui/lib/core/app/components/form-field-label.js
new file mode 100644
index 000000000..22b4835af
--- /dev/null
+++ b/ui/lib/core/app/components/form-field-label.js
@@ -0,0 +1 @@
+export { default } from 'core/components/form-field-label';
diff --git a/ui/tests/integration/components/autocomplete-input-test.js b/ui/tests/integration/components/autocomplete-input-test.js
new file mode 100644
index 000000000..1b1cc87f1
--- /dev/null
+++ b/ui/tests/integration/components/autocomplete-input-test.js
@@ -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`
+
`
+ );
+
+ 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`
+
`
+ );
+
+ 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`
+
`
+ );
+
+ 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');
+ });
+});
diff --git a/ui/tests/integration/components/form-field-label-test.js b/ui/tests/integration/components/form-field-label-test.js
new file mode 100644
index 000000000..d56b963a5
--- /dev/null
+++ b/ui/tests/integration/components/form-field-label-test.js
@@ -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`
+
+ `);
+
+ 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');
+ });
+});
diff --git a/ui/tests/integration/components/kv-object-editor-test.js b/ui/tests/integration/components/kv-object-editor-test.js
index c93fa5ecd..dc05a2721 100644
--- a/ui/tests/integration/components/kv-object-editor-test.js
+++ b/ui/tests/integration/components/kv-object-editor-test.js
@@ -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`
`);
+ 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`
+
+
+
+ `
+ );
+ assert.dom('textarea').doesNotExist('Value input hidden when block is provided');
+ assert.dom('[data-test-yield]').exists('Component yields block');
+ });
});
diff --git a/ui/tests/integration/components/regex-validator-test.js b/ui/tests/integration/components/regex-validator-test.js
index 9752c3ae6..2769097c2 100644
--- a/ui/tests/integration/components/regex-validator-test.js
+++ b/ui/tests/integration/components/regex-validator-test.js
@@ -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`
+
`);
+
+ 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)(?
\\d?)');
+
+ await render(hbs`
+ `);
+ 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');
+ });
});
diff --git a/ui/tests/integration/components/transform-advanced-templating-test.js b/ui/tests/integration/components/transform-advanced-templating-test.js
new file mode 100644
index 000000000..1435c7507
--- /dev/null
+++ b/ui/tests/integration/components/transform-advanced-templating-test.js
@@ -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})-(?\\d{5})',
+ encodeFormat: null,
+ decodeFormats: {},
+ };
+ await render(hbs` `);
+
+ 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');
+ });
+});