Transform Advanced Templating (#13908)
* updates regex-validator component to optionally show pattern input and adds capture groups support * adds form-field-label component * adds autocomplete-input component * updates kv-object-editor component to yield block for value and glimmerizes * updates transform template model * adds transform-advanced-templating component * updates form-field with child component changes * updates transform template serializer to handle differences in regex named capture groups * fixes regex-validator test * adds changelog entry * updates for pr review feedback * reverts kv-object-editor guidFor removal
This commit is contained in:
parent
33a9218115
commit
e811821ac7
|
@ -0,0 +1,3 @@
|
||||||
|
```release-note:improvement
|
||||||
|
ui: Transform advanced templating with encode/decode format support
|
||||||
|
```
|
|
@ -1,3 +1,11 @@
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { isNone } from '@ember/utils';
|
||||||
|
import { assert } from '@ember/debug';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { guidFor } from '@ember/object/internals';
|
||||||
|
import KVObject from 'vault/lib/kv-object';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @module KvObjectEditor
|
* @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: {
|
@action
|
||||||
addRow() {
|
updateRow() {
|
||||||
this.addRow();
|
this.args.onChange(this.kvData.toJSON());
|
||||||
},
|
}
|
||||||
|
@action
|
||||||
updateRow() {
|
deleteRow(object, index) {
|
||||||
let data = this.kvData;
|
const oldObj = this.kvData.objectAt(index);
|
||||||
this.onChange(data.toJSON());
|
assert('object guids match', guidFor(oldObj) === guidFor(object));
|
||||||
},
|
this.kvData.removeAt(index);
|
||||||
|
this.args.onChange(this.kvData.toJSON());
|
||||||
deleteRow(object, index) {
|
}
|
||||||
let data = this.kvData;
|
@action
|
||||||
let oldObj = data.objectAt(index);
|
handleKeyUp(event) {
|
||||||
|
if (this.args.onKeyUp) {
|
||||||
assert('object guids match', guidFor(oldObj) === guidFor(object));
|
this.args.onKeyUp(event.target.value);
|
||||||
data.removeAt(index);
|
}
|
||||||
this.onChange(data.toJSON());
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
handleKeyUp(name, value) {
|
|
||||||
if (!this.onKeyUp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.onKeyUp(name, value);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
@ -1,58 +1,63 @@
|
||||||
<div class="field">
|
{{#if @attr}}
|
||||||
<div class="regex-label-wrapper">
|
<div class="field" data-test-regex-validator-pattern>
|
||||||
<div class="regex-label">
|
<div class="regex-label-wrapper">
|
||||||
<label for={{@attr.name}} class="is-label">
|
<div class="regex-label">
|
||||||
{{@labelString}}
|
<label for={{@attr.name}} class="is-label">
|
||||||
{{#if @attr.options.helpText}}
|
{{@labelString}}
|
||||||
<InfoTooltip>
|
{{#if @attr.options.helpText}}
|
||||||
<span data-test-help-text>
|
<InfoTooltip>
|
||||||
{{@attr.options.helpText}}
|
<span data-test-help-text>
|
||||||
</span>
|
{{@attr.options.helpText}}
|
||||||
</InfoTooltip>
|
</span>
|
||||||
{{/if}}
|
</InfoTooltip>
|
||||||
</label>
|
|
||||||
{{#if @attr.options.subText}}
|
|
||||||
<p class="sub-text">
|
|
||||||
{{@attr.options.subText}}
|
|
||||||
{{#if @attr.options.docLink}}
|
|
||||||
<a href={{@attr.options.docLink}} target="_blank" rel="noopener noreferrer">
|
|
||||||
See our documentation
|
|
||||||
</a>
|
|
||||||
for help.
|
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</p>
|
</label>
|
||||||
{{/if}}
|
{{#if @attr.options.subText}}
|
||||||
</div>
|
<p class="sub-text">
|
||||||
<div>
|
{{@attr.options.subText}}
|
||||||
<Toggle
|
{{#if @attr.options.docLink}}
|
||||||
@name={{concat @attr.name "-validation-toggle"}}
|
<a href={{@attr.options.docLink}} target="_blank" rel="noopener noreferrer">
|
||||||
@status="success"
|
See our documentation
|
||||||
@size="small"
|
</a>
|
||||||
@checked={{this.showTestValue}}
|
for help.
|
||||||
@onChange={{this.toggleTestValue}}
|
{{/if}}
|
||||||
>
|
</p>
|
||||||
<span class="has-text-grey">Validation</span>
|
{{/if}}
|
||||||
</Toggle>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Toggle
|
||||||
|
@name={{concat @attr.name "-validation-toggle"}}
|
||||||
|
@status="success"
|
||||||
|
@size="small"
|
||||||
|
@checked={{this.showTestValue}}
|
||||||
|
@onChange={{this.toggleTestValue}}
|
||||||
|
>
|
||||||
|
<span class="has-text-grey">Validation</span>
|
||||||
|
</Toggle>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
id={{@attr.name}}
|
||||||
|
data-test-input={{@attr.name}}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
{{on "change" @onChange}}
|
||||||
|
value={{@value}}
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
{{/if}}
|
||||||
id={{@attr.name}}
|
|
||||||
data-test-input={{@attr.name}}
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
{{on "change" @onChange}}
|
|
||||||
value={{@value}}
|
|
||||||
class="input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{{#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 @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}}
|
{{/if}}
|
|
@ -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) {
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { action, set } from '@ember/object';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @module TransformAdvancedTemplating
|
||||||
|
* TransformAdvancedTemplating components are used to modify encode/decode formats of transform templates
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```js
|
||||||
|
* <TransformAdvancedTemplating @model={{this.model}} />
|
||||||
|
* ```
|
||||||
|
* @param {Object} model - transform template model
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default class TransformAdvancedTemplating extends Component {
|
||||||
|
@tracked inputOptions = [];
|
||||||
|
|
||||||
|
@action
|
||||||
|
setInputOptions(testValue, captureGroups) {
|
||||||
|
if (captureGroups && captureGroups.length) {
|
||||||
|
this.inputOptions = captureGroups.map(({ position, value }) => {
|
||||||
|
return {
|
||||||
|
label: `${position}: ${value}`,
|
||||||
|
value: position,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.inputOptions = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@action
|
||||||
|
decodeFormatValueChange(kvObject, kvData, value) {
|
||||||
|
set(kvObject, 'value', value);
|
||||||
|
this.args.model.decodeFormats = kvData.toJSON();
|
||||||
|
}
|
||||||
|
}
|
|
@ -31,13 +31,17 @@ const M = Model.extend({
|
||||||
models: ['transform/alphabet'],
|
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, {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -1,71 +1,69 @@
|
||||||
{{#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 this.subText}}
|
|
||||||
<p class="has-padding-bottom">
|
|
||||||
{{this.subText}}
|
|
||||||
</p>
|
|
||||||
{{/if}}
|
|
||||||
{{/if}}
|
|
||||||
{{#if (get this.validationMessages this.name)}}
|
|
||||||
<div>
|
|
||||||
<AlertInline @type="danger" @message={{get this.validationMessages this.name}} @paddingTop={{true}} />
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
{{#each this.kvData as |row index|}}
|
|
||||||
<div class="columns is-variable" data-test-kv-row>
|
|
||||||
<div class="column is-one-quarter">
|
|
||||||
<Input
|
|
||||||
data-test-kv-key={{true}}
|
|
||||||
@value={{row.name}}
|
|
||||||
placeholder="key"
|
|
||||||
{{on "change" (action "updateRow" row index)}}
|
|
||||||
class="input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<Textarea
|
|
||||||
data-test-kv-value={{true}}
|
|
||||||
name={{row.name}}
|
|
||||||
class="input {{if (get this.validationMessages this.name) "has-error-border"}}"
|
|
||||||
{{on "change" (action "updateRow" row index)}}
|
|
||||||
@value={{row.value}}
|
|
||||||
wrap="off"
|
|
||||||
placeholder="value"
|
|
||||||
rows={{1}}
|
|
||||||
onkeyup={{action (action "handleKeyUp" this.name) value="target.value"}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="column is-narrow">
|
|
||||||
{{#if (eq this.kvData.length (inc index))}}
|
|
||||||
<button type="button" {{action "addRow"}} class="button is-outlined is-primary" data-test-kv-add-row={{true}}>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
{{else}}
|
|
||||||
<button
|
|
||||||
class="button has-text-grey is-expanded is-icon"
|
|
||||||
type="button"
|
|
||||||
{{action "deleteRow" row index}}
|
|
||||||
aria-label="Delete row"
|
|
||||||
data-test-kv-delete-row
|
|
||||||
>
|
|
||||||
<Icon @name="trash" />
|
|
||||||
</button>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{/each}}
|
|
||||||
{{#if this.kvHasDuplicateKeys}}
|
|
||||||
<AlertBanner
|
|
||||||
@type="warning"
|
|
||||||
@message="More than one key shares the same name. Please be sure to have unique key names or some data may be lost when saving."
|
|
||||||
@class="is-marginless"
|
|
||||||
data-test-duplicate-error-warnings
|
|
||||||
/>
|
/>
|
||||||
{{/if}}
|
{{#if @validationError}}
|
||||||
|
<div>
|
||||||
|
<AlertInline @type="danger" @message={{@validationError}} @paddingTop={{true}} />
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{#each this.kvData as |row index|}}
|
||||||
|
<div class="columns is-variable" data-test-kv-row>
|
||||||
|
<div class="column is-one-quarter">
|
||||||
|
<Input
|
||||||
|
data-test-kv-key={{true}}
|
||||||
|
@value={{row.name}}
|
||||||
|
placeholder={{this.placeholders.key}}
|
||||||
|
{{on "change" (fn this.updateRow row index)}}
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
{{#if (has-block)}}
|
||||||
|
{{yield row this.kvData}}
|
||||||
|
{{else}}
|
||||||
|
<Textarea
|
||||||
|
data-test-kv-value={{index}}
|
||||||
|
name={{row.name}}
|
||||||
|
class="input {{if @validationError "has-error-border"}}"
|
||||||
|
@value={{row.value}}
|
||||||
|
wrap="off"
|
||||||
|
placeholder={{this.placeholders.value}}
|
||||||
|
rows={{1}}
|
||||||
|
{{on "change" (fn this.updateRow row index)}}
|
||||||
|
{{on "keyup" this.handleKeyUp}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
{{#if (eq this.kvData.length (inc index))}}
|
||||||
|
<button type="button" {{action "addRow"}} class="button is-outlined is-primary" data-test-kv-add-row={{true}}>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<button
|
||||||
|
class="button has-text-grey is-expanded is-icon"
|
||||||
|
type="button"
|
||||||
|
{{action "deleteRow" row index}}
|
||||||
|
aria-label="Delete row"
|
||||||
|
data-test-kv-delete-row={{index}}
|
||||||
|
>
|
||||||
|
<Icon @name="trash" />
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
{{#if this.hasDuplicateKeys}}
|
||||||
|
<AlertBanner
|
||||||
|
@type="warning"
|
||||||
|
@message="More than one key shares the same name. Please be sure to have unique key names or some data may be lost when saving."
|
||||||
|
@class="is-marginless"
|
||||||
|
data-test-duplicate-error-warnings
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
|
@ -0,0 +1,62 @@
|
||||||
|
<ToggleButton
|
||||||
|
@toggleAttr={{"showForm"}}
|
||||||
|
@toggleTarget={{this}}
|
||||||
|
@openLabel="Advanced templating"
|
||||||
|
@closedLabel="Advanced templating"
|
||||||
|
data-test-toggle-advanced={{true}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{#if this.showForm}}
|
||||||
|
<div class="box has-container is-fullwidth">
|
||||||
|
<h4 class="title is-5">Advanced templating</h4>
|
||||||
|
<p>
|
||||||
|
Using your template's regex as a starting point, you can specify which parts of your input to encode and decode. For
|
||||||
|
example, you may want to handle input formatting or only decode part of an input. For more information, see
|
||||||
|
<a
|
||||||
|
href="https://learn.hashicorp.com/tutorials/vault/transform#advanced-handling"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
our documentation.
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div class="has-top-margin-l">
|
||||||
|
<RegexValidator
|
||||||
|
@value={{@model.pattern}}
|
||||||
|
@testInputLabel="Sample input"
|
||||||
|
@testInputSubText="Enter a sample input to match against your regex and identify capture groups. Optional."
|
||||||
|
@showGroups={{true}}
|
||||||
|
@onValidate={{this.setInputOptions}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AutocompleteInput
|
||||||
|
@label="Encode format"
|
||||||
|
@subText="Use the groups above to define how the input will be encoded. Refer to each group with $N. This is optional; if not specified, pattern will be used."
|
||||||
|
@value={{@model.encodeFormat}}
|
||||||
|
@optionsTrigger="$"
|
||||||
|
@options={{this.inputOptions}}
|
||||||
|
@onChange={{fn (mut @model.encodeFormat)}}
|
||||||
|
class="has-top-margin-l"
|
||||||
|
data-test-encode-format
|
||||||
|
/>
|
||||||
|
<KvObjectEditor
|
||||||
|
@value={{@model.decodeFormats}}
|
||||||
|
@onChange={{fn (mut @model.decodeFormats)}}
|
||||||
|
@label="Decode formats"
|
||||||
|
@subText="Using the groups above, define how this data will be decoded. Multiple decode_formats can be used. Optional. If not specified, pattern will be used."
|
||||||
|
@keyPlaceholder="name"
|
||||||
|
class="has-top-margin-l"
|
||||||
|
data-test-kv-object-editor
|
||||||
|
as |kvObject kvData|
|
||||||
|
>
|
||||||
|
<AutocompleteInput
|
||||||
|
@value={{kvObject.value}}
|
||||||
|
@placeholder="format"
|
||||||
|
@optionsTrigger="$"
|
||||||
|
@options={{this.inputOptions}}
|
||||||
|
@onChange={{fn this.decodeFormatValueChange kvObject kvData}}
|
||||||
|
data-test-decode-format
|
||||||
|
/>
|
||||||
|
</KvObjectEditor>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
|
@ -50,7 +50,7 @@
|
||||||
<div class="box is-sideless is-fullwidth is-marginless">
|
<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>
|
||||||
{{else}}
|
</InfoTableRow>
|
||||||
<InfoTableRow
|
{{/if}}
|
||||||
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
|
{{else}}
|
||||||
@value={{get this.model attr.name}}
|
<InfoTableRow
|
||||||
/>
|
@label={{label}}
|
||||||
{{/if}}
|
@value={{get this.model attr.name}}
|
||||||
|
class={{if (eq attr.name "pattern") "transform-pattern-text"}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
{{/let}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
|
@ -0,0 +1,24 @@
|
||||||
|
<div {{did-insert this.setElement}} ...attributes>
|
||||||
|
{{#if @label}}
|
||||||
|
<FormFieldLabel @label={{@label}} @subText={{@subText}} />
|
||||||
|
{{/if}}
|
||||||
|
<div class="control">
|
||||||
|
<Input
|
||||||
|
@value={{@value}}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder={{@placeholder}}
|
||||||
|
class="input"
|
||||||
|
{{on "input" this.onInput}}
|
||||||
|
/>
|
||||||
|
<BasicDropdown @registerAPI={{fn (mut this.dropdownAPI)}} @renderInPlace={{true}} as |D|>
|
||||||
|
<D.Content class="autocomplete-input">
|
||||||
|
{{#each @options as |option|}}
|
||||||
|
<div class="autocomplete-input-option" role="button" {{on "click" (fn this.selectOption option.value)}}>
|
||||||
|
{{option.label}}
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</D.Content>
|
||||||
|
</BasicDropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,48 @@
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @module AutocompleteInput
|
||||||
|
* AutocompleteInput components are used as standard string inputs or optionally select options to append to input value
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```js
|
||||||
|
* <AutocompleteInput @requiredParam={requiredParam} @optionalParam={optionalParam} @param1={{param1}}/>
|
||||||
|
* ```
|
||||||
|
* @callback inputChangeCallback
|
||||||
|
* @param {string} value - input value
|
||||||
|
* @param {inputChangeCallback} onChange - fires when input value changes to mutate value param by caller
|
||||||
|
* @param {string} [optionsTrigger] - display options dropdown when trigger character is input
|
||||||
|
* @param {Object[]} [options] - array of { label, value } objects where label is displayed in options dropdown and value is appended to input value
|
||||||
|
* @param {string} [label] - label to display above input
|
||||||
|
* @param {string} [subText] - text to display below label
|
||||||
|
* @param {string} [placeholder] - input placeholder
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default class AutocompleteInput extends Component {
|
||||||
|
dropdownAPI;
|
||||||
|
inputElement;
|
||||||
|
|
||||||
|
@action
|
||||||
|
setElement(element) {
|
||||||
|
this.inputElement = element.querySelector('.input');
|
||||||
|
}
|
||||||
|
@action
|
||||||
|
onInput(event) {
|
||||||
|
const { options = [], optionsTrigger } = this.args;
|
||||||
|
if (optionsTrigger && options.length) {
|
||||||
|
const method = event.data === optionsTrigger ? 'open' : 'close';
|
||||||
|
this.dropdownAPI.actions[method]();
|
||||||
|
}
|
||||||
|
this.args.onChange(event.target.value);
|
||||||
|
}
|
||||||
|
@action
|
||||||
|
selectOption(value) {
|
||||||
|
// if trigger character is at start of value it needs to be trimmed
|
||||||
|
const appendValue = value.startsWith(this.args.optionsTrigger) ? value.slice(1) : value;
|
||||||
|
const newValue = this.args.value + appendValue;
|
||||||
|
this.args.onChange(newValue);
|
||||||
|
this.dropdownAPI.actions.close();
|
||||||
|
this.inputElement.focus();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
{{#if @label}}
|
||||||
|
<label class="is-label" ...attributes>
|
||||||
|
{{@label}}
|
||||||
|
{{#if @helpText}}
|
||||||
|
<InfoTooltip>
|
||||||
|
<span data-test-help-text>{{@helpText}}</span>
|
||||||
|
</InfoTooltip>
|
||||||
|
{{/if}}
|
||||||
|
</label>
|
||||||
|
{{/if}}
|
||||||
|
{{#if @subText}}
|
||||||
|
<p class="sub-text" data-test-label-subtext>
|
||||||
|
{{@subText}}
|
||||||
|
{{#if @docLink}}
|
||||||
|
<a href={{@docLink}} target="_blank" rel="noopener noreferrer">
|
||||||
|
See our documentation
|
||||||
|
</a>
|
||||||
|
for help.
|
||||||
|
{{/if}}
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import templateOnly from '@ember/component/template-only';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @module FormFieldLabel
|
||||||
|
* FormFieldLabel components add labels and descriptions to inputs
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```js
|
||||||
|
* <FormFieldLabel for="input-name" @label={{this.label}} @helpText={{this.helpText}} @subText={{this.subText}} @dockLink={{this.docLink}} />
|
||||||
|
* ```
|
||||||
|
* @param {string} [label] - label text -- component attributes are spread on label element
|
||||||
|
* @param {string} [helpText] - adds a tooltip
|
||||||
|
* @param {string} [subText] - de-emphasized text rendered below the label
|
||||||
|
* @param {string} [docLink] - url to documentation rendered after the subText
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default templateOnly();
|
|
@ -9,27 +9,13 @@
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
{{#unless (eq this.attr.type "object")}}
|
{{#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 }}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from 'core/components/autocomplete-input';
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from 'core/components/form-field-label';
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'ember-qunit';
|
||||||
|
import { click, fillIn, triggerEvent, typeIn, render } from '@ember/test-helpers';
|
||||||
|
import { hbs } from 'ember-cli-htmlbars';
|
||||||
|
|
||||||
|
module('Integration | Component | autocomplete-input', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
test('it should render label', async function (assert) {
|
||||||
|
await render(
|
||||||
|
hbs`
|
||||||
|
<AutocompleteInput
|
||||||
|
@label={{this.label}}
|
||||||
|
@subText={{this.subText}}
|
||||||
|
/>`
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('label').doesNotExist('Label is hidden when not provided');
|
||||||
|
this.setProperties({
|
||||||
|
label: 'Some label',
|
||||||
|
subText: 'Some description',
|
||||||
|
});
|
||||||
|
assert.dom('label').hasText('Some label', 'Label renders');
|
||||||
|
assert.dom('[data-test-label-subtext]').hasText('Some description', 'Sub text renders');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should function as standard input', async function (assert) {
|
||||||
|
const changeValue = 'foo bar';
|
||||||
|
this.value = 'test';
|
||||||
|
this.placeholder = 'text goes here';
|
||||||
|
this.onChange = (value) => assert.equal(value, changeValue, 'Value sent in onChange callback');
|
||||||
|
|
||||||
|
await render(
|
||||||
|
hbs`
|
||||||
|
<AutocompleteInput
|
||||||
|
@value={{this.value}}
|
||||||
|
@placeholder={{this.placeholder}}
|
||||||
|
@onChange={{this.onChange}}
|
||||||
|
/>`
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('input').hasAttribute('placeholder', this.placeholder, 'Input placeholder renders');
|
||||||
|
assert.dom('input').hasValue(this.value, 'Initial input value renders');
|
||||||
|
await fillIn('input', changeValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it should trigger dropdown', async function (assert) {
|
||||||
|
await render(
|
||||||
|
hbs`
|
||||||
|
<AutocompleteInput
|
||||||
|
@value={{this.value}}
|
||||||
|
@optionsTrigger="$"
|
||||||
|
@options={{this.options}}
|
||||||
|
@onChange={{fn (mut this.value)}}
|
||||||
|
/>`
|
||||||
|
);
|
||||||
|
|
||||||
|
await typeIn('input', '$');
|
||||||
|
await triggerEvent('input', 'input', { data: '$' }); // simulate InputEvent for data prop with character pressed
|
||||||
|
assert.dom('.autocomplete-input-option').doesNotExist('Trigger does not open dropdown with no options');
|
||||||
|
|
||||||
|
this.set('options', [
|
||||||
|
{ label: 'Foo', value: '$foo' },
|
||||||
|
{ label: 'Bar', value: 'bar' },
|
||||||
|
]);
|
||||||
|
await triggerEvent('input', 'input', { data: '$' });
|
||||||
|
const options = this.element.querySelectorAll('.autocomplete-input-option');
|
||||||
|
options.forEach((o, index) => {
|
||||||
|
assert.dom(o).hasText(this.options[index].label, 'Label renders for option');
|
||||||
|
});
|
||||||
|
|
||||||
|
await click(options[0]);
|
||||||
|
assert.dom('input').isFocused('Focus is returned to input after selecting option');
|
||||||
|
assert
|
||||||
|
.dom('input')
|
||||||
|
.hasValue('$foo', 'Value is updated correctly. Trigger character is not prepended to value.');
|
||||||
|
|
||||||
|
await typeIn('input', '-$');
|
||||||
|
await triggerEvent('input', 'input', { data: '$' });
|
||||||
|
await click('.autocomplete-input-option:last-child');
|
||||||
|
assert
|
||||||
|
.dom('input')
|
||||||
|
.hasValue('$foo-$bar', 'Value is updated correctly. Trigger character is prepended to option.');
|
||||||
|
assert.equal(this.value, '$foo-$bar', 'Value prop is updated correctly onChange');
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'ember-qunit';
|
||||||
|
import { render } from '@ember/test-helpers';
|
||||||
|
import { hbs } from 'ember-cli-htmlbars';
|
||||||
|
import { click } from '@ember/test-helpers';
|
||||||
|
|
||||||
|
module('Integration | Component | form-field-label', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
test('it renders', async function (assert) {
|
||||||
|
this.setProperties({
|
||||||
|
label: 'Test Label',
|
||||||
|
helpText: null,
|
||||||
|
subText: null,
|
||||||
|
docLink: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await render(hbs`
|
||||||
|
<FormFieldLabel
|
||||||
|
@label={{this.label}}
|
||||||
|
@helpText={{this.helpText}}
|
||||||
|
@subText={{this.subText}}
|
||||||
|
@docLink={{this.docLink}}
|
||||||
|
for="some-input"
|
||||||
|
/>
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.dom('label').hasAttribute('for', 'some-input', 'Attributes passed to label element');
|
||||||
|
assert.dom('label').hasText(this.label, 'Label text renders');
|
||||||
|
assert.dom('[data-test-help-text]').doesNotExist('Help text hidden when not provided');
|
||||||
|
assert.dom('.sub-text').doesNotExist('Sub text hidden when not provided');
|
||||||
|
this.setProperties({
|
||||||
|
helpText: 'More info',
|
||||||
|
subText: 'Some description',
|
||||||
|
});
|
||||||
|
await click('[data-test-tool-tip-trigger]');
|
||||||
|
assert.dom('[data-test-help-text]').hasText(this.helpText, 'Help text renders in tooltip');
|
||||||
|
assert.dom('.sub-text').hasText(this.subText, 'Sub text renders');
|
||||||
|
assert.dom('a').doesNotExist('docLink hidden when not provided');
|
||||||
|
this.set('docLink', 'foo.com/bar');
|
||||||
|
assert.dom('.sub-text').includesText('See our documentation for help', 'Doc link text renders');
|
||||||
|
assert.dom('a').hasAttribute('href', this.docLink, 'Doc link renders');
|
||||||
|
});
|
||||||
|
});
|
|
@ -9,26 +9,26 @@ import kvObjectEditor from '../../pages/components/kv-object-editor';
|
||||||
import sinon from 'sinon';
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'ember-qunit';
|
||||||
|
import { click, fillIn, render, triggerEvent } from '@ember/test-helpers';
|
||||||
|
import { hbs } from 'ember-cli-htmlbars';
|
||||||
|
|
||||||
|
module('Integration | Component | transform-advanced-templating', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
test('it should render', async function (assert) {
|
||||||
|
this.model = {
|
||||||
|
pattern: '(\\d{3})-(\\d{2})-(?<last>\\d{5})',
|
||||||
|
encodeFormat: null,
|
||||||
|
decodeFormats: {},
|
||||||
|
};
|
||||||
|
await render(hbs`<TransformAdvancedTemplating @model={{this.model}} />`);
|
||||||
|
|
||||||
|
assert.dom('.box').doesNotExist('Form is hidden when not toggled');
|
||||||
|
await click('[data-test-toggle-advanced]');
|
||||||
|
await fillIn('[data-test-input="regex-test-val"]', '123-45-67890');
|
||||||
|
[
|
||||||
|
['$1', '123'],
|
||||||
|
['$2', '45'],
|
||||||
|
['$3', '67890'],
|
||||||
|
['$last', '67890'],
|
||||||
|
].forEach(([p, v]) => {
|
||||||
|
assert.dom(`[data-test-regex-group-position="${p}"]`).hasText(`${p}`, `Capture group ${p} renders`);
|
||||||
|
assert.dom(`[data-test-regex-group-value="${p}"]`).hasText(v, `Capture group value ${v} renders`);
|
||||||
|
});
|
||||||
|
// need to simulate InputEvent
|
||||||
|
await triggerEvent('[data-test-encode-format] input', 'input', { data: '$' });
|
||||||
|
const options = this.element.querySelectorAll('.autocomplete-input-option');
|
||||||
|
['$1: 123', '$2: 45', '$3: 67890', '$last: 67890'].forEach((val, index) => {
|
||||||
|
assert.dom(options[index]).hasText(val, 'Autocomplete option renders');
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.dom('[data-test-kv-object-editor]').exists('KvObjectEditor renders for decode formats');
|
||||||
|
assert.dom('[data-test-decode-format]').exists('AutocompleteInput renders for decode format value');
|
||||||
|
await fillIn('[data-test-kv-key]', 'last');
|
||||||
|
await fillIn('[data-test-decode-format] input', '$last');
|
||||||
|
assert.deepEqual(this.model.decodeFormats, { last: '$last' }, 'Decode formats updates correctly');
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue