From e811821ac7dbd130a49c6d7ac3c3f5a17a99c3d4 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Mon, 7 Feb 2022 13:07:53 -0700 Subject: [PATCH] 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 --- changelog/13908.txt | 3 + ui/app/components/kv-object-editor.js | 124 ++++++--------- ui/app/components/regex-validator.hbs | 142 +++++++++++------- ui/app/components/regex-validator.js | 43 +++++- .../transform-advanced-templating.js | 37 +++++ ui/app/models/transform/template.js | 12 +- ui/app/serializers/transform/template.js | 21 +++ ui/app/styles/components/regex-validator.scss | 18 +++ ui/app/styles/components/transform-edit.scss | 6 + ui/app/styles/core/forms.scss | 18 +++ ui/app/styles/core/helpers.scss | 4 + ui/app/styles/utils/_colors.scss | 14 +- .../templates/components/kv-object-editor.hbs | 138 +++++++++-------- .../transform-advanced-templating.hbs | 62 ++++++++ .../components/transform-template-edit.hbs | 47 +++--- .../addon/components/autocomplete-input.hbs | 24 +++ .../addon/components/autocomplete-input.js | 48 ++++++ .../addon/components/form-field-label.hbs | 21 +++ .../core/addon/components/form-field-label.js | 17 +++ .../addon/templates/components/form-field.hbs | 38 ++--- .../core/app/components/autocomplete-input.js | 1 + .../core/app/components/form-field-label.js | 1 + .../components/autocomplete-input-test.js | 86 +++++++++++ .../components/form-field-label-test.js | 44 ++++++ .../components/kv-object-editor-test.js | 48 ++++-- .../components/regex-validator-test.js | 93 +++++++++++- .../transform-advanced-templating-test.js | 42 ++++++ 27 files changed, 871 insertions(+), 281 deletions(-) create mode 100644 changelog/13908.txt create mode 100644 ui/app/components/transform-advanced-templating.js create mode 100644 ui/app/templates/components/transform-advanced-templating.hbs create mode 100644 ui/lib/core/addon/components/autocomplete-input.hbs create mode 100644 ui/lib/core/addon/components/autocomplete-input.js create mode 100644 ui/lib/core/addon/components/form-field-label.hbs create mode 100644 ui/lib/core/addon/components/form-field-label.js create mode 100644 ui/lib/core/app/components/autocomplete-input.js create mode 100644 ui/lib/core/app/components/form-field-label.js create mode 100644 ui/tests/integration/components/autocomplete-input-test.js create mode 100644 ui/tests/integration/components/form-field-label-test.js create mode 100644 ui/tests/integration/components/transform-advanced-templating-test.js 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 @@ -
-
-
- - {{#if @attr.options.subText}} -

- {{@attr.options.subText}} - {{#if @attr.options.docLink}} - - See our documentation - - for help. +{{#if @attr}} +

+
+
+
-
- - Validation - + + {{#if @attr.options.subText}} +

+ {{@attr.options.subText}} + {{#if @attr.options.docLink}} + + See our documentation + + for help. + {{/if}} +

+ {{/if}} +
+
+ + Validation + +
+
- -
+{{/if}} {{#if this.showTestValue}}
-
+{{/if}} +{{#if @showGroups}} +
+ + {{! 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}} - - {{#if this.subText}} -

- {{this.subText}} -

- {{/if}} -{{/if}} -{{#if (get this.validationMessages this.name)}} -
- -
-{{/if}} -{{#each this.kvData as |row index|}} -
-
- -
-
-