Add raw editor toggle for using variables in flows operations (#14021)

* add raw editor for flows operation options

* add comment to explain reasoning for watcher

* add simple raw editor with syntax highlighting

* Add multiline to text fields & hide in json fields

* update input icon for toggle

* do not unset value for text fields

* fix mustache tag value checking

* enable raw editor for Insights

* remove lint warning

* Reduce size + inline icons

* add background-highlight when active toggle

* change multiline prop to type prop

* show toggle for all field types (including json)

* remove watcher to toggle rawEditor on load

* fix raw editor emit

* fix request operation headers field type json

* fix raw editor value passed to codemirror

* prevent tags from crashing

* do not unset values anymore when toggling raw

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
Azri Kahar
2022-07-15 23:38:23 +08:00
committed by GitHub
parent 8d1966ab04
commit 9015667d02
23 changed files with 320 additions and 16 deletions

View File

@@ -13,7 +13,7 @@
? `interface-${field.meta.interface}`
: `interface-${getDefaultInterfaceForType(field.type)}`
"
v-if="interfaceExists"
v-if="interfaceExists && !rawEditorActive"
v-bind="(field.meta && field.meta.options) || {}"
:autofocus="disabled !== true && autofocus"
:disabled="disabled"
@@ -30,6 +30,13 @@
@set-field-value="$emit('setFieldValue', $event)"
/>
<interface-system-raw-editor
v-else-if="rawEditorEnabled && rawEditorActive"
:value="modelValue === undefined ? field.schema?.default_value : modelValue"
:type="field.type"
@input="$emit('update:modelValue', $event)"
/>
<v-notice v-else type="warning">
{{ t('interface_not_found', { interface: field.meta && field.meta.interface }) }}
</v-notice>
@@ -77,6 +84,14 @@ export default defineComponent({
type: Boolean,
default: false,
},
rawEditorEnabled: {
type: Boolean,
default: false,
},
rawEditorActive: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue', 'setFieldValue'],
setup(props) {

View File

@@ -11,6 +11,16 @@
<v-text-overflow :text="field.name" />
<v-icon v-if="field.meta?.required === true" class="required" :class="{ 'has-badge': badge }" sup name="star" />
<v-chip v-if="badge" x-small>{{ badge }}</v-chip>
<v-icon
v-if="!disabled && rawEditorEnabled"
v-tooltip="t('toggle_raw_editor')"
class="raw-editor-toggle"
:class="{ active: rawEditorActive }"
name="data_object"
:filled="!rawEditorActive"
small
@click.stop="$emit('toggle-raw', !rawEditorActive)"
/>
<v-icon v-if="!disabled" class="ctx-arrow" :class="{ active }" name="arrow_drop_down" />
</span>
</div>
@@ -63,8 +73,16 @@ export default defineComponent({
type: Boolean,
default: false,
},
rawEditorEnabled: {
type: Boolean,
default: false,
},
rawEditorActive: {
type: Boolean,
default: false,
},
},
emits: ['toggle-batch'],
emits: ['toggle-batch', 'toggle-raw'],
setup() {
const { t } = useI18n();
@@ -127,6 +145,28 @@ export default defineComponent({
}
}
.raw-editor-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
width: 24px;
margin-top: -2px;
margin-left: 5px;
color: var(--foreground-subdued);
transition: color var(--fast) var(--transition);
&:hover {
color: var(--foreground-normal);
}
&.active {
color: var(--primary);
background-color: var(--primary-alt);
border-radius: 50%;
}
}
&.edited {
.edit-dot {
position: absolute;

View File

@@ -11,8 +11,11 @@
:edited="isEdited"
:has-error="!!validationError"
:badge="badge"
:raw-editor-enabled="rawEditorEnabled"
:raw-editor-active="rawEditorActive"
:loading="loading"
@toggle-batch="$emit('toggle-batch', $event)"
@toggle-raw="$emit('toggle-raw', $event)"
/>
</template>
@@ -39,6 +42,8 @@
:batch-active="batchActive"
:disabled="isDisabled"
:primary-key="primaryKey"
:raw-editor-enabled="rawEditorEnabled"
:raw-editor-active="rawEditorActive"
@update:model-value="emitValue($event)"
@set-field-value="$emit('setFieldValue', $event)"
/>
@@ -91,6 +96,8 @@ interface Props {
validationError?: ValidationError;
autofocus?: boolean;
badge?: string;
rawEditorEnabled?: boolean;
rawEditorActive?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
@@ -104,9 +111,11 @@ const props = withDefaults(defineProps<Props>(), {
validationError: undefined,
autofocus: false,
badge: undefined,
rawEditorEnabled: false,
rawEditorActive: false,
});
const emit = defineEmits(['toggle-batch', 'unset', 'update:modelValue', 'setFieldValue']);
const emit = defineEmits(['toggle-batch', 'toggle-raw', 'unset', 'update:modelValue', 'setFieldValue']);
const { t } = useI18n();

View File

@@ -32,6 +32,7 @@
:loading="loading"
:validation-errors="validationErrors"
:badge="badge"
:raw-editor-enabled="rawEditorEnabled"
v-bind="fieldsMeta[field.field]?.options || {}"
@apply="apply"
/>
@@ -62,10 +63,13 @@
)
"
:badge="badge"
:raw-editor-enabled="rawEditorEnabled"
:raw-editor-active="rawActiveFields.has(field.field)"
@update:model-value="setValue(field.field, $event)"
@set-field-value="setValue($event.field, $event.value)"
@unset="unsetValue(field)"
@toggle-batch="toggleBatchField(field)"
@toggle-raw="toggleRawField(field)"
/>
</template>
</div>
@@ -145,6 +149,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
rawEditorEnabled: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
@@ -178,6 +186,7 @@ export default defineComponent({
const { formFields, getFieldsForGroup, fieldsForGroup, isDisabled, fieldsMeta } = useForm();
const { toggleBatchField, batchActiveFields } = useBatch();
const { toggleRawField, rawActiveFields } = useRawEditor();
const firstEditableFieldIndex = computed(() => {
for (let i = 0; i < formFields.value.length; i++) {
@@ -215,6 +224,8 @@ export default defineComponent({
setValue,
batchActiveFields,
toggleBatchField,
rawActiveFields,
toggleRawField,
unsetValue,
firstEditableFieldIndex,
firstVisibleFieldIndex,
@@ -406,6 +417,20 @@ export default defineComponent({
if (!formFieldEls.value[field]) return;
formFieldEls.value[field].$el.scrollIntoView({ behavior: 'smooth' });
}
function useRawEditor() {
const rawActiveFields = ref(new Set<string>());
return { rawActiveFields, toggleRawField };
function toggleRawField(field: Field) {
if (rawActiveFields.value.has(field.field)) {
rawActiveFields.value.delete(field.field);
} else {
rawActiveFields.value.add(field.field);
}
}
}
},
});
</script>

View File

@@ -0,0 +1,27 @@
import { defineInterface } from '@directus/shared/utils';
import InterfaceInputTranslatedString from './system-raw-editor.vue';
export default defineInterface({
id: 'system-raw-editor',
name: '$t:interfaces.system-raw-editor.system-raw-editor',
description: '$t:interfaces.system-raw-editor.description',
icon: 'code',
component: InterfaceInputTranslatedString,
system: true,
types: ['string', 'text'],
group: 'standard',
options: [
{
field: 'placeholder',
name: '$t:placeholder',
type: 'string',
meta: {
width: 'half',
interface: 'input',
options: {
placeholder: '$t:enter_a_placeholder',
},
},
},
],
});

View File

@@ -0,0 +1,19 @@
export const mustacheMode = {
start: [{ regex: /\{\{/, push: 'mustache', token: 'tag' }],
mustache: [
{ regex: /\}\}/, pop: true, token: 'tag' },
// Double and single quotes
{ regex: /"(?:[^\\"]|\\.)*"?/, token: 'string' },
{ regex: /'(?:[^\\']|\\.)*'?/, token: 'string' },
// Flows variables keywords
{ regex: />|[$/]([A-Za-z0-9_-]\w*)/, token: 'keyword' },
// Numeral
{ regex: /\d+/i, token: 'number' },
// Paths
{ regex: /(?:\.\.\/)*(?:[A-Za-z_][\w.]*)+/, token: 'variable-2' },
],
};

View File

@@ -0,0 +1,147 @@
<template>
<div class="system-raw-editor" :class="{ disabled, 'multi-line': isMultiLine }">
<div ref="codemirrorEl"></div>
</div>
</template>
<script lang="ts" setup>
import { useWindowSize } from '@/composables/use-window-size';
import CodeMirror from 'codemirror';
import 'codemirror/addon/mode/simple';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { mustacheMode } from './mustacheMode';
const props = withDefaults(
defineProps<{
value?: string;
autofocus?: boolean;
disabled?: boolean;
type?: string;
}>(),
{
value: undefined,
autofocus: false,
disabled: false,
type: undefined,
}
);
const emit = defineEmits(['input']);
const { t } = useI18n();
const { width } = useWindowSize();
const codemirrorEl = ref<HTMLTextAreaElement | null>();
let codemirror: CodeMirror.Editor | null;
const isMultiLine = computed(() => ['text', 'json'].includes(props.type));
onMounted(async () => {
if (codemirrorEl.value) {
CodeMirror.defineSimpleMode('mustache', mustacheMode);
codemirror = CodeMirror(codemirrorEl.value, {
mode: 'mustache',
value: typeof props.value === 'object' ? JSON.stringify(props.value, null, 4) : String(props.value ?? ''),
tabSize: 0,
autoRefresh: true,
indentUnit: 4,
styleActiveLine: true,
highlightSelectionMatches: { showToken: /\w/, annotateScrollbar: true, delay: 100 },
matchBrackets: true,
showCursorWhenSelecting: true,
lineWiseCopyCut: false,
theme: 'default',
scrollbarStyle: isMultiLine.value ? 'native' : 'null',
extraKeys: { Ctrl: 'autocomplete' },
cursorBlinkRate: props.disabled ? -1 : 530,
placeholder: t('raw_editor_placeholder'),
});
// prevent new lines for single lines
if (!isMultiLine.value) {
codemirror.on('beforeChange', function (_doc, { origin, text, cancel, update }) {
const typedNewLine = origin === '+input' && typeof text === 'object' && text.join('') === '';
if (typedNewLine) return cancel();
const pastedNewLine = origin === 'paste' && typeof text === 'object' && text.length > 1;
if (pastedNewLine) {
const newText = text.join(' ');
if (!update) return;
return update(undefined, undefined, [newText]);
}
return null;
});
}
codemirror.on('change', (doc, { origin }) => {
if (origin === 'setValue') return;
const content = doc.getValue();
emit('input', content !== '' ? content : null);
});
}
});
const readOnly = computed(() => {
if (width.value < 600) {
// mobile requires 'nocursor' to avoid bringing up the keyboard
return props.disabled ? 'nocursor' : false;
} else {
// desktop cannot use 'nocursor' as it prevents copy/paste
return props.disabled;
}
});
watch(
() => props.disabled,
(disabled) => {
codemirror?.setOption('readOnly', readOnly.value);
codemirror?.setOption('cursorBlinkRate', disabled ? -1 : 530);
},
{ immediate: true }
);
</script>
<style lang="scss" scoped>
.system-raw-editor {
position: relative;
height: var(--input-height);
min-height: var(--input-height);
border-radius: var(--border-radius);
:deep(.CodeMirror) {
width: 100%;
line-height: 18px;
padding: var(--input-padding);
.cm-tag {
color: var(--foreground-subdued);
}
.cm-variable-2 {
color: var(--secondary);
}
}
:deep(.CodeMirror),
:deep(.CodeMirror-scroll) {
max-height: var(--input-height);
}
&.multi-line {
height: auto;
:deep(.CodeMirror),
:deep(.CodeMirror-scroll) {
max-height: 480px;
}
:deep(.CodeMirror-scroll) {
padding-bottom: 0;
}
}
}
</style>

View File

@@ -14,6 +14,7 @@
:loading="loading"
:validation-errors="validationErrors"
:badge="badge"
:raw-editor-enabled="rawEditorEnabled"
:group="field.meta.field"
:multiple="accordionMode === false"
@apply="$emit('apply', $event)"
@@ -77,6 +78,10 @@ export default defineComponent({
type: String,
default: null,
},
rawEditorEnabled: {
type: Boolean,
default: false,
},
accordionMode: {
type: Boolean,
default: true,

View File

@@ -10,6 +10,7 @@
:loading="loading"
:disabled="disabled"
:badge="badge"
:raw-editor-enabled="rawEditorEnabled"
nested
@update:model-value="$emit('apply', $event)"
/>
@@ -66,6 +67,10 @@ export default defineComponent({
type: String,
default: null,
},
rawEditorEnabled: {
type: Boolean,
default: false,
},
},
emits: ['apply'],
});

View File

@@ -55,7 +55,7 @@ export default defineComponent({
default: false,
},
value: {
type: Array as PropType<string[]>,
type: [Array, String] as PropType<string[] | string>,
default: null,
},
placeholder: {
@@ -100,7 +100,7 @@ export default defineComponent({
return [];
});
const selectedValsLocal = ref<string[]>(processArray(props.value || []));
const selectedValsLocal = ref<string[]>(Array.isArray(props.value) ? processArray(props.value) : []);
watch(
() => props.value,

View File

@@ -315,6 +315,8 @@ primary_key: Primary Key
foreign_key: Foreign Key
finish_setup: Finish Setup
dismiss: Dismiss
toggle_raw_editor: Toggle Raw Editor
raw_editor_placeholder: "{'{'}{'{'}$trigger.payload.example{'}'}{'}'}"
raw_value: Raw value
copy_raw_value: Copy Raw Value
copy_raw_value_success: Copied
@@ -1725,6 +1727,9 @@ interfaces:
regenerate: Regenerate Token
generate_success_copy: Make sure to backup and copy the token above. For security reasons, you will not be able to view the token again after saving and navigate off this page.
remove_token: Remove Token
system-raw-editor:
system-raw-editor: Raw Editor
description: Allow entering of raw or mustache templating values
displays:
translations:
translations: Translations

View File

@@ -29,6 +29,7 @@
:options="customOptionsFields"
type="panel"
:extension="panel.type"
raw-editor-enabled
@update:model-value="edits.options = $event"
/>

View File

@@ -10,6 +10,7 @@
:fields="optionsFields"
:initial-values="disabled ? optionsValues : null"
:disabled="disabled"
:raw-editor-enabled="rawEditorEnabled"
primary-key="+"
/>
@@ -59,6 +60,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
rawEditorEnabled: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {

View File

@@ -53,6 +53,7 @@
v-model="options"
:extension="operationType"
:options="operationOptions"
raw-editor-enabled
type="operation"
></extension-options>
<component

View File

@@ -69,7 +69,7 @@ export default defineOperationApp({
{
field: 'payload',
name: '$t:operations.item-create.payload',
type: 'string',
type: 'json',
meta: {
width: 'full',
interface: 'input-code',

View File

@@ -86,7 +86,7 @@ export default defineOperationApp({
{
field: 'query',
name: '$t:operations.item-delete.query',
type: 'string',
type: 'json',
meta: {
width: 'full',
interface: 'input-code',

View File

@@ -74,7 +74,7 @@ export default defineOperationApp({
{
field: 'query',
name: '$t:operations.item-read.query',
type: 'string',
type: 'json',
meta: {
width: 'full',
interface: 'input-code',

View File

@@ -85,7 +85,7 @@ export default defineOperationApp({
{
field: 'payload',
name: '$t:operations.item-update.payload',
type: 'string',
type: 'json',
meta: {
width: 'full',
interface: 'input-code',
@@ -98,7 +98,7 @@ export default defineOperationApp({
{
field: 'query',
name: '$t:operations.item-update.query',
type: 'string',
type: 'json',
meta: {
width: 'full',
interface: 'input-code',

View File

@@ -48,7 +48,7 @@ export default defineOperationApp({
{
field: 'body',
name: '$t:operations.mail.body',
type: 'string',
type: 'text',
meta: {
width: 'full',
interface: 'input-rich-text-md',

View File

@@ -73,7 +73,7 @@ export default defineOperationApp({
{
field: 'message',
name: '$t:operations.notification.message',
type: 'string',
type: 'text',
meta: {
width: 'full',
interface: 'input-rich-text-md',

View File

@@ -52,7 +52,7 @@ export default defineOperationApp({
{
field: 'headers',
name: '$t:operations.request.headers',
type: 'string',
type: 'json',
meta: {
width: 'full',
interface: 'list',
@@ -89,7 +89,7 @@ export default defineOperationApp({
{
field: 'body',
name: '$t:request_body',
type: 'string',
type: 'text',
meta: {
width: 'full',
interface: 'input-multiline',

View File

@@ -15,7 +15,7 @@ export default defineOperationApp({
{
field: 'json',
name: '$t:json',
type: 'string',
type: 'json',
meta: {
width: 'full',
interface: 'input-code',

View File

@@ -28,7 +28,7 @@ export default defineOperationApp({
{
field: 'payload',
name: '$t:payload',
type: 'string',
type: 'json',
meta: {
width: 'full',
interface: 'input-code',