[#341] Added support for dynamic context in procedure editor components.

This commit is contained in:
Fabio Manganiello 2024-09-21 20:45:50 +02:00
parent 0e40d77bc7
commit 6dd1d481d5
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
21 changed files with 694 additions and 215 deletions

View file

@ -5,14 +5,15 @@
<!-- Supported action arguments --> <!-- Supported action arguments -->
<div class="arg" :key="name" v-for="name in Object.keys(action.args)"> <div class="arg" :key="name" v-for="name in Object.keys(action.args)">
<label> <label>
<input type="text" <ContextAutocomplete :value="action.args[name].value"
class="action-arg-value" :disabled="running"
:class="{required: action.args[name].required}" :items="contextAutocompleteItems"
:disabled="running" :placeholder="name"
:placeholder="name" :quote="true"
:value="action.args[name].value" :select-on-tab="false"
@input="onArgEdit(name, $event)" @input="onArgEdit(name, $event)"
@focus="onSelect(name)"> @blur="onSelect(name)"
@focus="onSelect(name)" />
<span class="required-flag" v-if="action.args[name].required">*</span> <span class="required-flag" v-if="action.args[name].required">*</span>
</label> </label>
@ -36,12 +37,13 @@
@input="onExtraArgNameEdit(i, $event.target.value)"> @input="onExtraArgNameEdit(i, $event.target.value)">
</label> </label>
<label class="col-6"> <label class="col-6">
<input type="text" <ContextAutocomplete :value="arg.value"
class="action-extra-arg-value" :disabled="running"
placeholder="Value" :items="contextAutocompleteItems"
:disabled="running" :quote="true"
:value="arg.value" :select-on-tab="false"
@input="onExtraArgValueEdit(i, $event.target.value)"> placeholder="Value"
@input="onExtraArgValueEdit(i, $event.detail)" />
</label> </label>
<label class="col-1 buttons"> <label class="col-1 buttons">
<button type="button" class="action-extra-arg-del" title="Remove argument" @click="$emit('remove', i)"> <button type="button" class="action-extra-arg-del" title="Remove argument" @click="$emit('remove', i)">
@ -68,10 +70,16 @@
<script> <script>
import Argdoc from "./Argdoc" import Argdoc from "./Argdoc"
import ContextAutocomplete from "./ContextAutocomplete"
import Mixin from "./Mixin"
export default { export default {
name: 'ActionArgs', mixins: [Mixin],
components: { Argdoc }, components: {
Argdoc,
ContextAutocomplete,
},
emits: [ emits: [
'add', 'add',
'arg-edit', 'arg-edit',
@ -117,7 +125,7 @@ export default {
onArgEdit(name, event) { onArgEdit(name, event) {
this.$emit('arg-edit', { this.$emit('arg-edit', {
name: name, name: name,
value: event.target.value, value: event.target?.value || event.detail,
}) })
}, },

View file

@ -77,6 +77,7 @@
</h2> </h2>
<ActionArgs :action="action" <ActionArgs :action="action"
:context="context"
:loading="loading" :loading="loading"
:running="running" :running="running"
:selected-arg="selectedArg" :selected-arg="selectedArg"
@ -143,6 +144,11 @@ export default {
}, },
props: { props: {
context: {
type: Object,
default: () => ({}),
},
value: { value: {
type: Object, type: Object,
}, },
@ -391,14 +397,14 @@ export default {
this.actionDocsCache[this.action.name].html = this.selectedDoc this.actionDocsCache[this.action.name].html = this.selectedDoc
this.setUrlArgs({action: this.action.name}) this.setUrlArgs({action: this.action.name})
const firstArg = this.$el.querySelector('.action-arg-value') this.$nextTick(() => {
if (firstArg) { const firstArg = this.$el.querySelector('.args-body input[type=text]')
firstArg.focus() if (firstArg) {
} else { firstArg.focus()
this.$nextTick(() => { } else {
this.actionInput.focus() this.actionInput.focus()
}) }
} })
this.response = undefined this.response = undefined
this.error = undefined this.error = undefined

View file

@ -55,6 +55,7 @@
<div class="action-editor-container"> <div class="action-editor-container">
<Modal ref="actionEditor" title="Edit Action"> <Modal ref="actionEditor" title="Edit Action">
<ActionEditor :value="value" <ActionEditor :value="value"
:context="context"
:with-save="!readOnly" :with-save="!readOnly"
@input="onInput" @input="onInput"
v-if="this.$refs.actionEditor?.$data?.isVisible" /> v-if="this.$refs.actionEditor?.$data?.isVisible" />
@ -68,9 +69,11 @@ import ActionEditor from "@/components/Action/ActionEditor"
import Draggable from "@/components/elements/Draggable" import Draggable from "@/components/elements/Draggable"
import Droppable from "@/components/elements/Droppable" import Droppable from "@/components/elements/Droppable"
import ExtensionIcon from "@/components/elements/ExtensionIcon" import ExtensionIcon from "@/components/elements/ExtensionIcon"
import Mixin from "./Mixin"
import Modal from "@/components/Modal" import Modal from "@/components/Modal"
export default { export default {
mixins: [Mixin],
emits: [ emits: [
'delete', 'delete',
'drag', 'drag',
@ -90,6 +93,11 @@ export default {
}, },
props: { props: {
context: {
type: Object,
default: () => ({}),
},
draggable: { draggable: {
type: Boolean, type: Boolean,
default: true, default: true,

View file

@ -11,6 +11,7 @@
<div class="actions-list" :class="actionListClasses"> <div class="actions-list" :class="actionListClasses">
<ActionsList :value="value[key]" <ActionsList :value="value[key]"
:context="context"
:dragging="dragging" :dragging="dragging"
:has-else="hasElse" :has-else="hasElse"
:indent="indent" :indent="indent"

View file

@ -68,6 +68,7 @@
:value="newAction" :value="newAction"
@drop="onDrop(0, $event)"> @drop="onDrop(0, $event)">
<ActionTile :value="newAction" <ActionTile :value="newAction"
:context="contexts[Object.keys(contexts).length - 1]"
:draggable="false" :draggable="false"
@input="addAction" /> @input="addAction" />
</ListItem> </ListItem>
@ -244,6 +245,7 @@ export default {
props: { props: {
value: action, value: action,
active: this.isDragging, active: this.isDragging,
context: this.contexts[index],
isInsideLoop: !!(this.isInsideLoop || this.getFor(action) || this.getWhile(action)), isInsideLoop: !!(this.isInsideLoop || this.getFor(action) || this.getWhile(action)),
readOnly: this.readOnly, readOnly: this.readOnly,
ref: `action-tile-${index}`, ref: `action-tile-${index}`,
@ -314,6 +316,34 @@ export default {
}, {}) || {} }, {}) || {}
}, },
contexts() {
const commonCtx = {...this.context}
const contexts = this.newValue?.reduce?.((acc, action, index) => {
acc[index] = this.getContext(action, index, commonCtx)
return acc
}, {}) || {}
const nContexts = Object.keys(contexts).length
if (nContexts > 0) {
contexts[nContexts] = this.getContext(
null,
nContexts,
contexts[nContexts - 1]
)
contexts[nContexts] = {
...this.context,
...commonCtx,
...contexts[nContexts - 1],
...contexts[nContexts],
}
} else {
contexts[0] = {...this.context}
}
return contexts
},
dragBlockIndex() { dragBlockIndex() {
if (this.dragIndex == null) { if (this.dragIndex == null) {
return return
@ -678,6 +708,33 @@ export default {
this.$emit('collapse') this.$emit('collapse')
}, },
getContext(action, index, context) {
const ctx = {...(context || this.context || {})}
if (index > 0) {
ctx.output = {
source: 'action',
action: this.newValue[index - 1],
}
}
if (this.isSet(action)) {
Object.keys(action.set).forEach((name) => {
if (!name?.length) {
return
}
context[name] = { source: 'local' }
})
}
const iterator = this.getFor(action)?.iterator
if (iterator?.length) {
context[iterator] = { source: 'for' }
}
return ctx
},
getParentBlock(indices) { getParentBlock(indices) {
indices = [...indices] indices = [...indices]
let parent = this.newValue let parent = this.newValue

View file

@ -7,6 +7,7 @@
:value="value" :value="value"
v-on="componentsData.on"> v-on="componentsData.on">
<ActionTile :value="value" <ActionTile :value="value"
:context="context"
:draggable="!readOnly" :draggable="!readOnly"
:read-only="readOnly" :read-only="readOnly"
:with-delete="!readOnly" :with-delete="!readOnly"
@ -47,6 +48,11 @@ export default {
default: false, default: false,
}, },
context: {
type: Object,
default: () => ({}),
},
readOnly: { readOnly: {
type: Boolean, type: Boolean,
default: false, default: false,

View file

@ -2,6 +2,7 @@
<div class="condition-block"> <div class="condition-block">
<ActionsBlock :value="value" <ActionsBlock :value="value"
:collapsed="collapsed" :collapsed="collapsed"
:context="context"
:dragging="isDragging" :dragging="isDragging"
:has-else="hasElse" :has-else="hasElse"
:is-inside-loop="isInsideLoop" :is-inside-loop="isInsideLoop"
@ -37,7 +38,7 @@
<EndBlockTile value="end if" <EndBlockTile value="end if"
icon="fas fa-question" icon="fas fa-question"
:active="active" :active="active"
:spacer-bottom="spacerBottom" :spacer-bottom="spacerBottom || dragging_"
@drop="onDrop" @drop="onDrop"
v-if="isElse || !hasElse" /> v-if="isElse || !hasElse" />
</template> </template>
@ -144,6 +145,7 @@ export default {
return { return {
props: { props: {
active: this.active, active: this.active,
context: this.context,
readOnly: this.readOnly, readOnly: this.readOnly,
spacerBottom: this.spacerBottom, spacerBottom: this.spacerBottom,
spacerTop: this.spacerTop, spacerTop: this.spacerTop,

View file

@ -46,10 +46,13 @@
:visible="true" :visible="true"
@close="showConditionEditor = false"> @close="showConditionEditor = false">
<ExpressionEditor :value="value" <ExpressionEditor :value="value"
:context="context"
ref="conditionEditor" ref="conditionEditor"
@input.prevent.stop="onConditionChange" @input.prevent.stop="onConditionChange"
v-if="showConditionEditor"> v-if="showConditionEditor">
Condition <div class="header">
Condition
</div>
</ExpressionEditor> </ExpressionEditor>
</Modal> </Modal>
</div> </div>
@ -59,6 +62,7 @@
<script> <script>
import ExpressionEditor from "./ExpressionEditor" import ExpressionEditor from "./ExpressionEditor"
import ListItem from "./ListItem" import ListItem from "./ListItem"
import Mixin from "./Mixin"
import Modal from "@/components/Modal" import Modal from "@/components/Modal"
import Tile from "@/components/elements/Tile" import Tile from "@/components/elements/Tile"
@ -76,6 +80,7 @@ export default {
'input', 'input',
], ],
mixins: [Mixin],
components: { components: {
ExpressionEditor, ExpressionEditor,
ListItem, ListItem,
@ -213,5 +218,11 @@ export default {
.drag-spacer { .drag-spacer {
height: 0; height: 0;
} }
:deep(.expression-editor) {
.header {
display: block;
}
}
} }
</style> </style>

View file

@ -0,0 +1,170 @@
<template>
<div class="autocomplete-with-context">
<Autocomplete
v-bind="autocompleteProps"
@blur="onBlur"
@focus="onFocus"
@input="onInput"
@select="onSelect"
ref="input" />
<div class="spacer" ref="spacer" />
</div>
</template>
<script>
import Autocomplete from "@/components/elements/Autocomplete"
import AutocompleteProps from "@/mixins/Autocomplete/Props"
export default {
emits: ["blur", "focus", "input"],
components: { Autocomplete },
mixins: [AutocompleteProps],
props: {
quote: {
type: Boolean,
default: false,
},
},
computed: {
autocompleteItemsElement() {
return this.$refs.input?.$el?.querySelector(".items")
},
autocompleteItemsHeight() {
return this.autocompleteItemsElement?.clientHeight || 0
},
autocompleteProps() {
return {
...Object.fromEntries(
Object.entries(this.$props).filter(([key]) => key !== "quote")
),
items: this.items,
value: this.value,
inputOnBlur: false,
inputOnSelect: false,
showAllItems: true,
showResultsWhenBlank: true,
}
},
textInput() {
return this.$refs.input?.$refs?.input
},
},
methods: {
emitInput(value) {
this.$emit(
"input",
new CustomEvent("input", {
detail: value,
bubbles: true,
cancelable: true,
})
)
},
isWithinQuote(selection) {
let ret = false
let value = '' + this.value
selection = [...selection]
while (selection[0] > 0) {
if (value[selection[0] - 1] === '$' && value[selection[0]] === '{') {
ret = true
break
}
selection[0]--
}
if (!ret)
return false
ret = false
while (selection[1] < value.length) {
if (value[selection[1]] === '}') {
ret = true
break
}
selection[1]++
}
return ret
},
onBlur(event) {
this.$emit("blur", event)
this.$refs.spacer.style.height = "0"
},
onFocus(event) {
this.$emit("focus", event)
setTimeout(() => {
this.$refs.spacer.style.height = `${this.autocompleteItemsHeight}px`
}, 10)
},
onInput(event) {
const selection = this.textSelection()
this.emitInput(event)
this.resetSelection(selection)
},
onSelect(value) {
this.$nextTick(() => {
const selection = this.textSelection()
value = this.quote && !this.isWithinQuote(selection) ? `\${${value}}` : value
const newValue = typeof this.value === 'string' ? (
this.value.slice(0, selection[0]) +
value +
this.value.slice(selection[1])
) : value
this.emitInput(newValue)
this.resetSelection([selection[0] + value.length, selection[0] + value.length])
})
},
resetSelection(selection) {
setTimeout(() => {
this.textInput?.focus()
this.textInput?.setSelectionRange(selection[0], selection[1])
}, 10)
},
textSelection() {
return [this.textInput?.selectionStart, this.textInput?.selectionEnd]
},
},
}
</script>
<style lang="scss">
.autocomplete-with-context {
.item {
width: 100%;
display: flex;
flex-direction: column;
@include from($tablet) {
flex-direction: row;
}
.suffix {
display: flex;
font-size: 0.9em;
color: $disabled-fg;
@include from($tablet) {
margin-left: auto;
}
}
}
}
</style>

View file

@ -3,14 +3,11 @@
<label for="expression"> <label for="expression">
<slot /> <slot />
<input type="text" <ContextAutocomplete :value="newValue"
name="expression" :items="contextAutocompleteItems"
autocomplete="off" :quote="quote"
:autofocus="true" @input.stop="onInput"
:placeholder="placeholder" ref="input" />
:value="value"
ref="text"
@input.stop="onInput" />
</label> </label>
<label> <label>
@ -22,10 +19,13 @@
</template> </template>
<script> <script>
import ContextAutocomplete from "./ContextAutocomplete"
import Mixin from "./Mixin"
export default { export default {
emits: [ emits: ['input'],
'input', mixins: [Mixin],
], components: { ContextAutocomplete },
props: { props: {
value: { value: {
@ -42,17 +42,23 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
quote: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
hasChanges: false, hasChanges: false,
newValue: null,
} }
}, },
methods: { methods: {
onSubmit(event) { onSubmit(event) {
const value = this.$refs.text.value.trim() const value = this.newValue?.trim()
if (!value.length && !this.allowEmpty) { if (!value.length && !this.allowEmpty) {
return return
} }
@ -62,7 +68,10 @@ export default {
}, },
onInput(event) { onInput(event) {
const value = '' + event.target.value if (event?.detail == null)
return
const value = '' + event.detail
if (!value?.trim()?.length) { if (!value?.trim()?.length) {
this.hasChanges = this.allowEmpty this.hasChanges = this.allowEmpty
} else { } else {
@ -70,7 +79,7 @@ export default {
} }
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.text.value = value this.newValue = value
}) })
}, },
}, },
@ -83,12 +92,14 @@ export default {
mounted() { mounted() {
this.hasChanges = false this.hasChanges = false
this.newValue = this.value
if (!this.value?.trim?.()?.length) { if (!this.value?.trim?.()?.length) {
this.hasChanges = this.allowEmpty this.hasChanges = this.allowEmpty
} }
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.text.focus() this.textInput?.focus()
}) })
}, },
} }
@ -107,8 +118,7 @@ export default {
label { label {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: row; flex-direction: column;
align-items: center;
justify-content: center; justify-content: center;
padding: 1em; padding: 1em;

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="row item list-item" :class="itemClass"> <div class="row item list-item" :class="itemClass">
<div class="spacer-wrapper" :class="{ hidden: !spacerTop }"> <div class="spacer-wrapper" :class="{ hidden: !spacerTop }">
<div class="spacer" :class="{ active }" ref="dropTargetTop"> <div class="spacer top" :class="{ active }" ref="dropTargetTop">
<div class="droppable-wrapper"> <div class="droppable-wrapper">
<div class="droppable-container"> <div class="droppable-container">
<div class="droppable-frame"> <div class="droppable-frame">
@ -14,14 +14,14 @@
<Droppable :element="$refs.dropTargetTop" :disabled="readOnly" v-on="droppableData.top.on" /> <Droppable :element="$refs.dropTargetTop" :disabled="readOnly" v-on="droppableData.top.on" />
</div> </div>
<div class="spacer" v-if="dragging" /> <div class="spacer top" v-if="dragging" />
<slot /> <slot />
<div class="spacer" v-if="dragging" /> <div class="spacer bottom" v-if="dragging" />
<div class="spacer-wrapper" :class="{ hidden: !spacerBottom }"> <div class="spacer-wrapper" :class="{ hidden: !spacerBottom }">
<div class="spacer" :class="{ active }" ref="dropTargetBottom"> <div class="spacer bottom" :class="{ active }" ref="dropTargetBottom">
<div class="droppable-wrapper"> <div class="droppable-wrapper">
<div class="droppable-container"> <div class="droppable-container">
<div class="droppable-frame"> <div class="droppable-frame">

View file

@ -2,6 +2,7 @@
<div class="loop-block"> <div class="loop-block">
<ActionsBlock :value="value" <ActionsBlock :value="value"
:collapsed="collapsed" :collapsed="collapsed"
:context="context_"
:dragging="isDragging" :dragging="isDragging"
:indent="indent" :indent="indent"
:is-inside-loop="true" :is-inside-loop="true"
@ -25,7 +26,7 @@
<EndBlockTile :value="`end ${type}`" <EndBlockTile :value="`end ${type}`"
icon="fas fa-arrow-rotate-right" icon="fas fa-arrow-rotate-right"
:active="active" :active="active"
:spacer-bottom="spacerBottom" :spacer-bottom="spacerBottom || dragging"
@drop="onDrop" /> @drop="onDrop" />
</template> </template>
</ActionsBlock> </ActionsBlock>
@ -134,6 +135,18 @@ export default {
return () => {} return () => {}
}, },
context_() {
const ctx = {...this.context}
const iterator = this.loop?.iterator?.trim()
if (iterator?.length) {
ctx[iterator] = {
source: 'for',
}
}
return ctx
},
isDragging() { isDragging() {
return this.dragging_ || this.dragging return this.dragging_ || this.dragging
}, },
@ -159,6 +172,7 @@ export default {
props: { props: {
...this.loop, ...this.loop,
active: this.active, active: this.active,
context: this.context_,
readOnly: this.readOnly, readOnly: this.readOnly,
spacerBottom: this.spacerBottom, spacerBottom: this.spacerBottom,
spacerTop: this.spacerTop, spacerTop: this.spacerTop,

View file

@ -7,7 +7,7 @@
autocomplete="off" autocomplete="off"
:autofocus="true" :autofocus="true"
placeholder="Iterator" placeholder="Iterator"
:value="iterator" :value="newValue.iterator"
ref="iterator" ref="iterator"
@input.stop="onInput('iterator', $event)" /> @input.stop="onInput('iterator', $event)" />
</label> </label>
@ -15,14 +15,12 @@
in in
<label for="iterable"> <label for="iterable">
<input type="text" <ContextAutocomplete :value="newValue.iterable"
name="iterable" :items="contextAutocompleteItems"
autocomplete="off" placeholder="Iterable"
:autofocus="true" @input.stop="onInput('iterable', $event)"
placeholder="Iterable" ref="iterable" />
:value="iterable"
ref="iterable"
@input.stop="onInput('iterable', $event)" />
</label> </label>
<label class="async"> <label class="async">
@ -44,12 +42,13 @@
</template> </template>
<script> <script>
export default { import ContextAutocomplete from "./ContextAutocomplete"
emits: [ import Mixin from "./Mixin"
'change',
'input',
],
export default {
emits: ['change', 'input'],
mixins: [Mixin],
components: { ContextAutocomplete },
props: { props: {
async: { async: {
type: Boolean, type: Boolean,
@ -70,6 +69,11 @@ export default {
data() { data() {
return { return {
hasChanges: true, hasChanges: true,
newValue: {
iterator: null,
iterable: null,
async: null,
},
} }
}, },
@ -86,7 +90,7 @@ export default {
}, },
onInput(target, event) { onInput(target, event) {
const value = '' + event.target.value const value = '' + (event.target?.value || event.detail)
if (!value?.trim()?.length) { if (!value?.trim()?.length) {
this.hasChanges = false this.hasChanges = false
} else { } else {
@ -104,7 +108,7 @@ export default {
} }
this.$nextTick(() => { this.$nextTick(() => {
event.target.value = value this.newValue[target] = value
}) })
}, },
}, },
@ -112,10 +116,21 @@ export default {
watch: { watch: {
value() { value() {
this.hasChanges = false this.hasChanges = false
this.newValue = {
iterator: this.iterator,
iterable: this.iterable,
async: this.async,
}
}, },
}, },
mounted() { mounted() {
this.newValue = {
iterator: this.iterator,
iterable: this.iterable,
async: this.async,
}
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.iterator.focus() this.$refs.iterator.focus()
}) })
@ -126,6 +141,24 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
@import "common"; @import "common";
@mixin label {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
input[type="text"] {
width: 100%;
margin-left: 1em;
}
&.async {
justify-content: flex-start;
padding-bottom: 0;
}
}
.loop-editor { .loop-editor {
min-width: 40em; min-width: 40em;
max-width: 100%; max-width: 100%;
@ -134,22 +167,17 @@ export default {
justify-content: center; justify-content: center;
label { label {
width: 100%; @include label;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 1em; padding: 1em;
}
}
input[type="text"] { :deep(label) {
width: 100%; @include label;
margin-left: 1em;
}
&.async { .autocomplete-with-context,
justify-content: flex-start; .autocomplete {
padding-bottom: 0; width: 100%;
}
} }
} }
</style> </style>

View file

@ -37,12 +37,14 @@
<LoopEditor :iterator="iterator" <LoopEditor :iterator="iterator"
:iterable="iterable" :iterable="iterable"
:async="async" :async="async"
:context="context"
@change="onLoopChange" @change="onLoopChange"
v-if="showLoopEditor && type === 'for'"> v-if="showLoopEditor && type === 'for'">
Loop Loop
</LoopEditor> </LoopEditor>
<ExpressionEditor :value="condition" <ExpressionEditor :value="condition"
:context="context"
@input.prevent.stop="onConditionChange" @input.prevent.stop="onConditionChange"
v-else-if="showLoopEditor && type === 'while'"> v-else-if="showLoopEditor && type === 'while'">
Loop Condition Loop Condition
@ -56,10 +58,12 @@
import ExpressionEditor from "./ExpressionEditor" import ExpressionEditor from "./ExpressionEditor"
import ListItem from "./ListItem" import ListItem from "./ListItem"
import LoopEditor from "./LoopEditor" import LoopEditor from "./LoopEditor"
import Mixin from "./Mixin"
import Modal from "@/components/Modal" import Modal from "@/components/Modal"
import Tile from "@/components/elements/Tile" import Tile from "@/components/elements/Tile"
export default { export default {
mixins: [Mixin],
emits: [ emits: [
'change', 'change',
'click', 'click',

View file

@ -1,5 +1,41 @@
<script> <script>
export default { export default {
props: {
context: {
type: Object,
default: () => ({}),
},
},
computed: {
contextAutocompleteItems() {
return Object.entries(this.context).reduce((acc, [key, value]) => {
let suffix = '<span class="source">from <b>' + (
value?.source === 'local'
? 'local context'
: value?.source
) + '</b>'
if (value?.source === 'action' && value?.action?.action)
suffix += ` (<i>${value?.action?.action}</i>)`
suffix += '</span>'
acc.push(
{
text: key,
suffix: suffix,
}
)
return acc
}, [])
},
textInput() {
return this.$refs.input?.$refs?.input?.$refs?.input
},
},
methods: { methods: {
getCondition(value) { getCondition(value) {
value = this.getKey(value) || value value = this.getKey(value) || value

View file

@ -25,6 +25,8 @@
@close="showExprEditor = false"> @close="showExprEditor = false">
<ExpressionEditor :value="value" <ExpressionEditor :value="value"
:allow-empty="true" :allow-empty="true"
:context="context"
:quote="true"
placeholder="Optional return value" placeholder="Optional return value"
ref="exprEditor" ref="exprEditor"
@input.prevent.stop="onExprChange" @input.prevent.stop="onExprChange"
@ -39,10 +41,12 @@
<script> <script>
import ExpressionEditor from "./ExpressionEditor" import ExpressionEditor from "./ExpressionEditor"
import ListItem from "./ListItem" import ListItem from "./ListItem"
import Mixin from "./Mixin"
import Modal from "@/components/Modal" import Modal from "@/components/Modal"
import Tile from "@/components/elements/Tile" import Tile from "@/components/elements/Tile"
export default { export default {
mixins: [Mixin],
emits: [ emits: [
'change', 'change',
'click', 'click',

View file

@ -1,6 +1,7 @@
<template> <template>
<ListItem class="set-variables-tile" <ListItem class="set-variables-tile"
:class="{active}" :class="{active}"
:dragging="dragging_"
:value="value" :value="value"
:active="active" :active="active"
:read-only="readOnly" :read-only="readOnly"
@ -43,10 +44,12 @@
v-model="newValue[i][0]">&nbsp;= v-model="newValue[i][0]">&nbsp;=
</span> </span>
<span class="value"> <span class="value">
<input type="text" <ContextAutocomplete :value="newValue[i][1]"
placeholder="Value" :items="contextAutocompleteItems"
@input.prevent.stop :quote="true"
v-model="newValue[i][1]"> :select-on-tab="false"
placeholder="Value"
@input.prevent.stop="newValue[i][1] = $event.detail" />
</span> </span>
</div> </div>
@ -59,12 +62,13 @@
v-model="newVariable.name">&nbsp;= v-model="newVariable.name">&nbsp;=
</span> </span>
<span class="value"> <span class="value">
<input type="text" <ContextAutocomplete :value="newVariable.value"
placeholder="Value" :items="contextAutocompleteItems"
ref="newVarValue" :quote="true"
@blur="onBlur(null)" :select-on-tab="false"
@input.prevent.stop placeholder="Value"
v-model="newVariable.value"> @input.prevent.stop="newVariable.value = $event.detail"
@blur="onBlur(null)" />
</span> </span>
</div> </div>
@ -80,11 +84,14 @@
</template> </template>
<script> <script>
import ContextAutocomplete from "./ContextAutocomplete"
import ListItem from "./ListItem" import ListItem from "./ListItem"
import Mixin from "./Mixin"
import Modal from "@/components/Modal" import Modal from "@/components/Modal"
import Tile from "@/components/elements/Tile" import Tile from "@/components/elements/Tile"
export default { export default {
mixins: [Mixin],
emits: [ emits: [
'click', 'click',
'delete', 'delete',
@ -98,6 +105,7 @@ export default {
], ],
components: { components: {
ContextAutocomplete,
ListItem, ListItem,
Modal, Modal,
Tile, Tile,
@ -109,6 +117,11 @@ export default {
default: false, default: false,
}, },
dragging: {
type: Boolean,
default: false,
},
readOnly: { readOnly: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -162,7 +175,7 @@ export default {
data() { data() {
return { return {
dragging: false, dragging_: false,
newValue: [], newValue: [],
newVariable: { newVariable: {
name: '', name: '',
@ -253,17 +266,17 @@ export default {
return return
} }
this.dragging = true this.dragging_ = true
this.$emit('drag', event) this.$emit('drag', event)
}, },
onDragEnd(event) { onDragEnd(event) {
this.dragging = false this.dragging_ = false
this.$emit('dragend', event) this.$emit('dragend', event)
}, },
onDrop(event) { onDrop(event) {
this.dragging = false this.dragging_ = false
if (this.readOnly) { if (this.readOnly) {
return return
} }
@ -315,12 +328,6 @@ export default {
@import "common"; @import "common";
.set-variables-tile { .set-variables-tile {
&.active {
.spacer {
display: none;
}
}
.variables { .variables {
margin-left: 2.5em; margin-left: 2.5em;
} }
@ -342,6 +349,7 @@ export default {
.variable { .variable {
display: flex; display: flex;
margin-bottom: 1em;
.value { .value {
flex: 1; flex: 1;

View file

@ -148,42 +148,51 @@ form {
} }
} }
.args-list { :deep(.args-body) {
padding-top: 0.5em; .args-list {
overflow: auto; padding-top: 0.5em;
overflow: auto;
@include until($tablet) {
width: 100%;
}
@include from($tablet) {
width: $params-tablet-width;
margin-right: 1.5em;
}
@include from($desktop) {
width: $params-desktop-width;
}
.arg {
margin-bottom: .25em;
@include until($tablet) { @include until($tablet) {
width: 100%; width: 100%;
} }
.required-flag { @include from($tablet) {
width: 1.25em; width: $params-tablet-width;
font-weight: bold; margin-right: 1.5em;
margin-left: 0.25em;
} }
input { @include from($desktop) {
width: $params-desktop-width;
}
label {
display: flex;
align-items: center;
}
.arg {
margin-bottom: .25em;
@include until($tablet) {
width: 100%;
}
.required-flag {
width: 1.25em;
display: inline-block;
font-weight: bold;
margin-left: 0.25em;
text-align: center;
}
}
.autocomplete-with-context {
width: calc(100% - 1.5em); width: calc(100% - 1.5em);
} }
}
.action-arg-value { .action-arg-value {
width: 100%; width: 100%;
}
} }
} }

View file

@ -63,6 +63,7 @@
</h3> </h3>
<ActionsList :value="newValue.actions" <ActionsList :value="newValue.actions"
:context="context"
:read-only="readOnly" :read-only="readOnly"
@input="onActionsEdit" /> @input="onActionsEdit" />
</div> </div>
@ -312,6 +313,16 @@ export default {
return JSON.stringify(this.newValue) return JSON.stringify(this.newValue)
}, },
context() {
return this.newValue?.args?.reduce((acc, arg) => {
acc[arg] = {
source: 'args',
}
return acc
}, {})
},
modal_() { modal_() {
if (this.readOnly) if (this.readOnly)
return null return null

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="autocomplete"> <div class="autocomplete" :class="{ 'with-items': showItems }">
<label :text="label"> <label :text="label">
<input <input
type="text" type="text"
@ -8,73 +8,43 @@
:placeholder="placeholder" :placeholder="placeholder"
:disabled="disabled" :disabled="disabled"
:value="value" :value="value"
@focus="onFocus" @focus.stop="onFocus"
@input="onInput" @input.stop="onInput"
@blur="onBlur" @blur="onBlur"
@keydown="onInputKeyDown" @keydown="onInputKeyDown"
@keyup="onInputKeyUp" @keyup="onInputKeyUp"
> >
</label> </label>
<div class="items" v-if="showItems"> <div class="items" ref="items" v-if="showItems">
<div <div
class="item" class="item"
:class="{ active: i === curIndex }" :class="{ active: i === curIndex }"
:key="getItemText(item)" :key="getItemText(item)"
:data-item="getItemText(item)" :data-item="getItemText(item)"
v-for="(item, i) in visibleItems" v-for="(item, i) in visibleItems"
@click="onItemSelect(item)"> @click.stop="onItemSelect(item)">
<span v-html="item.prefix" v-if="item.prefix"></span> <span class="prefix" v-html="item.prefix" v-if="item.prefix"></span>
<span class="matching" v-if="value?.length">{{ getItemText(item).substr(0, value.length) }}</span> <span class="matching" v-if="value?.length">{{ getItemText(item).substr(0, value.length) }}</span>
<span class="normal">{{ getItemText(item).substr(value?.length || 0) }}</span> <span class="normal">{{ getItemText(item).substr(value?.length || 0) }}</span>
<span v-html="item.suffix" v-if="item.suffix"></span> <span class="suffix" v-html="item.suffix" v-if="item.suffix"></span>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import Props from "@/mixins/Autocomplete/Props"
export default { export default {
emits: ["input"], emits: ["blur", "focus", "input", "select"],
props: { mixins: [Props],
items: {
type: Array,
required: true,
},
value: {
type: String,
default: "",
},
disabled: {
type: Boolean,
default: false,
},
autofocus: {
type: Boolean,
default: false,
},
label: {
type: String,
},
placeholder: {
type: String,
},
showResultsWhenBlank: {
type: Boolean,
default: false,
},
},
data() { data() {
return { return {
visible: false, visible: false,
curIndex: -1, curIndex: null,
selectItemTimer: null,
} }
}, },
@ -84,7 +54,7 @@ export default {
}, },
visibleItems() { visibleItems() {
if (!this.value?.length) if (!this.value?.length || this.showAllItems)
return this.items return this.items
const val = this.value.toUpperCase() const val = this.value.toUpperCase()
@ -103,109 +73,140 @@ export default {
methods: { methods: {
getItemText(item) { getItemText(item) {
return item.text || item return item?.text || item
},
selectNextItem() {
this.curIndex++
this.normalizeIndex()
},
selectPrevItem() {
this.curIndex--
this.normalizeIndex()
},
normalizeIndex() {
// Go to the beginning after reaching the end
if (this.curIndex >= this.visibleItems.length)
this.curIndex = 0
// Go to the end after moving back from the start
if (this.curIndex < 0)
this.curIndex = this.visibleItems.length - 1
// Scroll to the element
const curText = this.getItemText(this.visibleItems[this.curIndex])
const el = this.$el.querySelector(`[data-item='${curText}']`)
if (el)
el.scrollIntoView({
block: "start",
inline: "nearest",
behavior: "smooth",
})
}, },
valueIsInItems() { valueIsInItems() {
if (this.showAllItems)
return true
if (!this.value) if (!this.value)
return false return false
return this.itemsText.indexOf(this.value) >= 0 return this.itemsText.indexOf(this.value) >= 0
}, },
onFocus() { onFocus(e) {
this.$emit("focus", e)
if (this.showResultsWhenBlank || this.value?.length) if (this.showResultsWhenBlank || this.value?.length)
this.visible = true this.visible = true
}, },
onInput(e) { onInput(e) {
let val = e.target.value let val = e.target?.value
if (val == null) {
e.stopPropagation?.()
return
}
if (this.valueIsInItems()) if (this.valueIsInItems())
this.visible = false this.visible = false
e.stopPropagation() e.stopPropagation()
this.$emit("input", val.text || val) this.$emit("input", val.text || val)
this.curIndex = -1 this.curIndex = null
this.visible = true this.visible = true
}, },
onBlur(e) { onBlur(e) {
this.onInput(e) if (this.inputOnBlur) {
this.$nextTick(() => { this.onInput(e)
if (this.valueIsInItems()) this.$nextTick(() => {
if (this.valueIsInItems()) {
this.visible = false
}
})
return
}
e.stopPropagation()
this.$nextTick(() => this.$refs.input.focus())
setTimeout(() => {
if (this.selectItemTimer) {
this.$nextTick(() => this.$refs.input.focus())
return
}
this.$emit("blur", e)
if (this.valueIsInItems()) {
this.visible = false this.visible = false
}) }
}, 200)
}, },
onItemSelect(item) { onItemSelect(item) {
this.$emit("input", item.text || item) if (this.selectItemTimer) {
clearTimeout(this.selectItemTimer)
this.selectItemTimer = null
}
this.selectItemTimer = setTimeout(() => {
this.selectItemTimer = null
}, 250)
const text = item.text || item
this.$emit("select", text)
if (this.inputOnSelect)
this.$emit("input", text)
this.$nextTick(() => { this.$nextTick(() => {
if (this.valueIsInItems()) { if (this.valueIsInItems()) {
this.visible = false if (this.inputOnSelect) {
this.visible = false
} else {
this.visible = true
this.curIndex = this.visibleItems.indexOf(item)
if (this.curIndex < 0)
this.curIndex = null
this.$refs.input.focus()
}
} }
}) })
}, },
onInputKeyUp(e) { onInputKeyUp(e) {
if (["ArrowUp", "ArrowDown", "Tab", "Enter", "Escape"].indexOf(e.key) >= 0) if (
["ArrowUp", "ArrowDown", "Escape"].indexOf(e.key) >= 0 ||
(e.key === "Tab" && this.selectOnTab) ||
(e.key === "Enter" && this.curIndex != null)
) {
e.stopPropagation() e.stopPropagation()
}
if (e.key === "Enter" && this.valueIsInItems()) { if (e.key === "Enter" && this.valueIsInItems() && this.curIndex != null) {
this.$refs.input.blur() this.$refs.input.blur()
this.visible = false this.visible = false
} }
}, },
onInputKeyDown(e) { onInputKeyDown(e) {
if (!this.showItems)
return
e.stopPropagation()
if ( if (
e.key === 'ArrowDown' || e.key === 'ArrowDown' ||
(e.key === 'Tab' && !e.shiftKey) || (e.key === 'Tab' && !e.shiftKey && this.selectOnTab) ||
(e.key === 'j' && e.ctrlKey) (e.key === 'j' && e.ctrlKey)
) { ) {
this.selectNextItem() this.curIndex = this.curIndex == null ? 0 : this.curIndex + 1
e.preventDefault() e.preventDefault()
} else if ( } else if (
e.key === 'ArrowUp' || e.key === 'ArrowUp' ||
(e.key === 'Tab' && e.shiftKey) || (e.key === 'Tab' && e.shiftKey && this.selectOnTab) ||
(e.key === 'k' && e.ctrlKey) (e.key === 'k' && e.ctrlKey)
) { ) {
this.selectPrevItem() this.curIndex = this.curIndex == null ? this.visibleItems.length - 1 : this.curIndex - 1
e.preventDefault() e.preventDefault()
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
if (this.curIndex > -1 && this.visible) { if (this.curIndex != null && this.curIndex >= 0 && this.visible) {
e.preventDefault() e.preventDefault()
this.onItemSelect(this.visibleItems[this.curIndex]) this.onItemSelect(this.visibleItems[this.curIndex])
this.$refs.input.focus() this.$nextTick(() => this.$refs.input.focus())
} }
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
this.visible = false this.visible = false
@ -220,6 +221,33 @@ export default {
}, },
}, },
watch: {
curIndex() {
// Do nothing if the index is not set
if (this.curIndex == null)
return
// Go to the beginning after reaching the end
if (this.curIndex >= this.visibleItems.length)
this.curIndex = 0
// Go to the end after moving back from the start
if (this.curIndex < 0)
this.curIndex = this.visibleItems.length - 1
// Scroll to the element
const curText = this.getItemText(this.visibleItems[this.curIndex])
const el = this.$el.querySelector(`[data-item='${curText}']`)
if (el) {
el.scrollIntoView({
block: "start",
inline: "nearest",
behavior: "smooth",
})
}
},
},
mounted() { mounted() {
document.addEventListener("click", this.onDocumentClick) document.addEventListener("click", this.onDocumentClick)
if (this.autofocus) if (this.autofocus)

View file

@ -0,0 +1,58 @@
<script>
export default {
props: {
items: {
type: Array,
required: true,
},
value: {
type: [String, Number, Object, Array, Boolean],
default: "",
},
disabled: {
type: Boolean,
default: false,
},
autofocus: {
type: Boolean,
default: false,
},
label: {
type: String,
},
placeholder: {
type: String,
},
inputOnBlur: {
type: Boolean,
default: true,
},
inputOnSelect: {
type: Boolean,
default: true,
},
selectOnTab: {
type: Boolean,
default: true,
},
showAllItems: {
type: Boolean,
default: false,
},
showResultsWhenBlank: {
type: Boolean,
default: false,
},
},
}
</script>