[Procedure Editor] Added support for conditions and nested blocks.

This commit is contained in:
Fabio Manganiello 2024-09-10 22:53:14 +02:00
parent 202cff093f
commit 471ec1370c
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
15 changed files with 1881 additions and 270 deletions

View file

@ -1,6 +1,7 @@
<template>
<div class="action-tile-container">
<div class="action-tile"
:class="{ new: isNew }"
ref="tile"
@click="$refs.actionEditor.show">
<div class="action-delete"
@ -122,6 +123,10 @@ export default {
},
computed: {
isNew() {
return !this.readOnly && !this.name?.length
},
name() {
return this.value.name || this.value.action
},
@ -179,6 +184,14 @@ export default {
background: $tile-hover-bg;
}
&.new {
background: $tile-bg-3;
&:hover {
background: $tile-hover-bg-3;
}
}
.action-delete {
width: 1.5em;
height: 1.5em;

View file

@ -0,0 +1,195 @@
<template>
<div class="actions-block" :class="{ hover }">
<slot name="before" />
<div class="actions-list-container" ref="actionsListContainer">
<button class="collapse-button"
@click="collapsed_ = !collapsed_"
v-if="isCollapsed">
<i class="fas fa-ellipsis-h" />
</button>
<div class="actions-list" :class="actionListClasses">
<ActionsList :value="value[key]"
:dragging="dragging"
:has-else="hasElse"
:indent="indent"
:parent="value"
:read-only="readOnly"
@add-else="$emit('add-else')"
@collapse="collapsed_ = !collapsed_"
@drag="$emit('drag', $event)"
@dragend="$emit('dragend', $event); hover = false"
@dragenter="$emit('dragenter', $event)"
@dragleave="$emit('dragleave', $event); hover = false"
@dragover="$emit('dragover', $event)"
@drop="$emit('drop', $event); hover = false"
@input="$emit('input', $event); hover = false" />
</div>
</div>
<slot name="after" />
<Droppable :element="$refs.actionsListContainer"
@dragenter="onDragEnter"
@dragleave="onDragLeave"
@drop="hover = false"
v-if="!readOnly" />
</div>
</template>
<script>
import { defineAsyncComponent } from "vue"
import Droppable from "@/components/elements/Droppable"
import Mixin from "./Mixin"
export default {
name: 'ActionsBlock',
mixins: [Mixin],
emits: [
'add-else',
'drag',
'dragend',
'dragenter',
'dragleave',
'dragover',
'drop',
'input',
],
components: {
// Handle indirect circular dependency
ActionsList: defineAsyncComponent(() => import('./ActionsList')),
Droppable,
},
props: {
value: {
type: Object,
required: true,
},
collapsed: {
type: Boolean,
default: false,
},
dragging: {
type: Boolean,
default: false,
},
indent: {
type: Number,
default: 0,
},
readOnly: {
type: Boolean,
default: false,
},
hasElse: {
type: Boolean,
default: false,
},
},
computed: {
actionListClasses() {
return {
hidden: this.isCollapsed,
fold: this.folding,
unfold: this.unfolding,
}
},
condition() {
return this.getCondition(this.key)
},
isCollapsed() {
const transitioning = this.hover || this.folding || this.unfolding
if (transitioning) {
return false
}
if (this.collapsed_) {
return true
}
return this.collapsed
},
key() {
return this.getKey(this.value)
},
},
data() {
return {
collapsed_: false,
folding: false,
hover: false,
hoverTimeout: null,
unfolding: false,
}
},
watch: {
collapsed_(value) {
if (value) {
this.folding = true
setTimeout(() => {
this.folding = false
}, 300)
} else {
this.unfolding = true
setTimeout(() => {
this.unfolding = false
}, 300)
}
},
},
methods: {
onDragEnter() {
if (this.hoverTimeout) {
return
}
this.hoverTimeout = setTimeout(() => {
this.hover = true
}, 500)
},
onDragLeave() {
if (this.hoverTimeout) {
clearTimeout(this.hoverTimeout)
this.hoverTimeout = null
}
this.hover = false
},
},
}
</script>
<style lang="scss" scoped>
.actions-block {
.collapse-button {
width: 100%;
background: none !important;
margin-left: 1em;
font-size: 0.85em;
text-align: left;
border: none;
}
&.hover {
.collapse-button {
color: $default-hover-fg;
}
}
}
</style>

View file

@ -1,28 +1,57 @@
<template>
<div class="actions" :class="{dragging: dragItem != null}">
<div class="row item action" v-for="(action, index) in actions" :key="index">
<ActionsListItem :value="action"
:active="dragItem != null"
:read-only="readOnly"
:spacer-bottom="visibleBottomSpacers[index]"
:spacer-top="visibleTopSpacers[index]"
:ref="`action-tile-${index}`"
@delete="deleteAction(index)"
@drag="dragItem = index"
@dragend.prevent="dragItem = null"
@dragenterspacer.prevent="dropIndex = index"
@dragleavespacer.prevent="dropIndex = undefined"
@dragover.prevent="onTileDragOver($event, index)"
@dragoverspacer.prevent="dropIndex = index"
@drop="onDrop(index)"
@input="editAction($event, index)" />
<div class="actions-list">
<div class="indent-spacers" v-if="indent > 0">
<div class="indent-spacer" @click="onCollapse">
<div class="left side" />
<div class="right side" />
</div>
</div>
<div class="row item action">
<ActionTile :value="newAction"
:draggable="false"
@input="addAction"
v-if="!readOnly" />
<div class="actions" :class="{dragging: isDragging}">
<div class="row item action"
v-for="(action, index) in newValue"
:key="index">
<ConditionBlock v-bind="componentsData[index].props"
v-on="componentsData[index].on"
:collapsed="collapsedBlocks[index]"
:dragging="isDragging"
:has-else="!!elses[index + 1]"
@add-else="addElse"
v-if="conditions[index]" />
<ConditionBlock v-bind="componentsData[index].props"
v-on="componentsData[index].on"
:collapsed="collapsedBlocks[index]"
:dragging="isDragging"
:is-else="true"
v-else-if="elses[index]" />
<ActionsListItem v-bind="componentsData[index].props"
v-on="componentsData[index].on"
v-else-if="isAction(action) && !collapsed" />
</div>
<div class="row item action add-action-container" v-if="!readOnly && !collapsed">
<ListItem :active="isDragging"
:readOnly="false"
:spacerBottom="false"
:spacerTop="!newValue.length"
:value="newAction"
@drop="onDrop(0, $event)">
<ActionTile :value="newAction"
:draggable="false"
@input="addAction" />
</ListItem>
</div>
<div class="row item action add-if-container" v-if="!readOnly && !collapsed">
<AddTile icon="fas fa-question" title="Add Condition" @click="addCondition" />
</div>
<div class="row item action add-else-container"
v-if="!readOnly && !collapsed && parent && getCondition(parent) && !hasElse">
<AddTile icon="fas fa-question" title="Add Else" @click="$emit('add-else')" />
</div>
</div>
</div>
</template>
@ -30,17 +59,68 @@
<script>
import ActionsListItem from "./ActionsListItem"
import ActionTile from "./ActionTile"
import AddTile from "./AddTile"
import ConditionBlock from "./ConditionBlock"
import ListItem from "./ListItem"
import Mixin from "./Mixin"
import Utils from "@/Utils"
export default {
mixins: [Utils],
emits: ['input'],
name: 'ActionsList',
mixins: [Mixin, Utils],
emits: [
'add-else',
'change',
'collapse',
'drag',
'dragend',
'dragenter',
'dragleave',
'dragover',
'drop',
'input',
'reset',
],
components: {
ActionsListItem,
ActionTile,
AddTile,
ConditionBlock,
ListItem,
},
props: {
collapsed: {
type: Boolean,
default: false,
},
dragging: {
type: Boolean,
default: false,
},
indent: {
type: Number,
default: 0,
},
hasElse: {
type: Boolean,
default: false,
},
parent: {
type: Object,
default: null,
},
readOnly: {
type: Boolean,
default: false,
},
value: {
type: Object,
default: () => ({
@ -48,112 +128,399 @@ export default {
actions: [],
}),
},
readOnly: {
type: Boolean,
default: false,
},
},
data() {
return {
actions: [],
dragItem: undefined,
dropIndex: undefined,
newValue: [],
dragIndices: undefined,
initialValue: undefined,
newAction: {},
spacerElements: {},
visibleTopSpacers: {},
visibleBottomSpacers: {},
}
},
computed: {
collapsedBlocks() {
return this.newValue.reduce((acc, action, index) => {
if (!this.isActionsBlock(action)) {
return acc
}
if (!this.isDragging) {
acc[index] = this.collapsed
return acc
}
if (this.elses[index]) {
acc[index] = this.dragBlockIndex === index - 1
} else {
acc[index] = this.dragBlockIndex === index
}
return acc
}, {})
},
componentsData() {
return this.newValue.map((action, index) => {
let data = {
props: {
value: action,
active: this.isDragging,
readOnly: this.readOnly,
ref: `action-tile-${index}`,
spacerBottom: this.visibleBottomSpacers[index],
spacerTop: this.visibleTopSpacers[index],
},
on: {
delete: () => this.deleteAction(index),
drag: (event) => this.onDragStart(index, event),
dragend: (event) => this.onDragEnd(event),
dragenter: (event) => this.onDragEnter(index, event),
dragleave: (event) => this.onDragLeave(index, event),
dragover: (event) => this.onDragOver(event),
drop: (event) => {
try {
this.onDrop(index, event)
} finally {
this.isDragging = false
}
},
input: (value) => this.editAction(value, index),
}
}
if (this.isActionsBlock(action)) {
data.props.indent = this.indent + 1
}
return data
})
},
conditions() {
return this.newValue?.reduce?.((acc, action, index) => {
const condition = this.getCondition(action)
if (condition) {
acc[index] = {
condition,
actions: action[Object.keys(action)[0]],
}
}
return acc
}, {}) || {}
},
dragBlockIndex() {
if (this.dragIndex == null) {
return
}
// Return an index only if the dragged item is the actual actions block
// and not one of its children.
if (!(this.dragIndices?.length === 1 && this.dragIndices[0] === this.dragIndex)) {
return
}
// Return an index only if the dragged item is an actions block.
if (!this.isActionsBlock(this.newValue[this.dragIndex])) {
return
}
return this.dragIndex
},
isDragging: {
get() {
return this.dragging || (this.dragIndices?.length || 0) > 0
},
set(value) {
if (!value) {
this.dragIndices = null
}
},
},
elses() {
return this.newValue?.reduce?.((acc, action, index) => {
if (this.isElse(action) && this.conditions[index - 1]) {
acc[index] = action[Object.keys(action)[0]]
}
return acc
}, {}) || {}
},
hasChanges() {
return JSON.stringify(this.value) !== JSON.stringify(this.actions)
return this.newStringValue !== this.stringValue
},
stringValue() {
return JSON.stringify(this.value)
},
newStringValue() {
return JSON.stringify(this.newValue)
},
dragIndex() {
if (!this.isDragging) {
return
}
return this.dragIndices?.[0]
},
visibleTopSpacers() {
const dragIndex = this.dragIndex
return this.newValue.reduce((acc, tile, index) => {
acc[index] = (
!this.isElse(tile) && (
dragIndex == null ||
dragIndex > index || (
dragIndex === index &&
this.dragIndices.length > 1
)
)
)
return acc
}, {})
},
visibleBottomSpacers() {
const dragIndex = this.dragIndex
return this.newValue.reduce((acc, _, index) => {
acc[index] = (
(
dragIndex != null && (
dragIndex < index || (
dragIndex === index &&
this.dragIndices.length > 1
)
)
) || (
dragIndex == null &&
index === this.newValue.length - 1
)
)
return acc
}, {})
},
},
methods: {
onDrop(index) {
if (this.dragItem == null || this.readOnly)
onDragStart(index, event) {
if (this.readOnly)
return
this.actions.splice(
index, 0, this.actions.splice(this.dragItem, 1)[0]
if (Array.isArray(event)) {
event = [index, ...event]
} else {
event = [index]
}
this.dragIndices = event
this.$emit('drag', event)
},
onDragEnd() {
this.isDragging = false
this.$emit('dragend')
},
onDragEnter(index, event) {
if (!this.isDragging || this.readOnly) {
return
}
event.stopPropagation()
this.$emit('dragenter', index)
},
onDragLeave(index, event) {
if (!this.isDragging || this.readOnly) {
return
}
event.stopPropagation()
this.$emit('dragleave', index)
},
onDragOver(event) {
this.$emit('dragover', event)
},
onDrop(dropIndex, event) {
if (!this.isDragging || event == null || dropIndex == null || this.readOnly) {
return
}
event.stopPropagation()
if (!event.detail?.length) {
event = new CustomEvent(
'drop', {
bubbles: false,
cancelable: true,
detail: [dropIndex],
}
)
} else {
event = new CustomEvent(
'drop', {
bubbles: false,
cancelable: true,
detail: [dropIndex, ...event.detail],
}
)
}
if (this.indent > 0) {
// If the current drop location is within a nested block, then we need to
// bubble up the drop event to the parent block, until we reach the top block.
this.$emit('drop', event)
return
}
// If we are at the root level, then we have the full picture of the underlying
// data structure, and we can perform the drop operation directly.
const dropIndices = event.detail
const dragIndex = this.dragIndices.slice(-1)[0]
dropIndex = event.detail.slice(-1)[0]
// Get the parent blocks of the dragged and dropped items.
const dragParent = this.getParentBlock(this.dragIndices)
const dropParent = this.getParentBlock(dropIndices)
if (!(dragParent && dropParent)) {
return
}
const dragItem = dragParent?.[dragIndex]
const dropItem = dropParent?.[dropIndex]
if (!dragItem) {
return
}
// If the dragged item is a condition, then we need to update the else block as well.
const draggedItems = (
this.getCondition(dragItem) && this.isElse(dragParent[dragIndex + 1]) ? 2 : 1
)
this.dragItem = null
this.dropIndex = null
// If the drop location is an else block, then the target needs to be the next
// slot, or we'll break the if-else chain.
if (this.isElse(dropItem)) {
dropIndex += 1
}
dropParent.splice(
dropIndex, 0, ...dragParent.splice(dragIndex, draggedItems)
)
// Emit the drop event to the parent block, so that it can update its
// view of the data structure.
this.$emit('input', this.newValue)
},
onTileDragOver(event, index) {
if (this.dragItem == null || this.readOnly || this.dragItem === index)
return
onCollapse() {
this.$emit('collapse')
},
const dragOverTile = this.$refs[`action-tile-${index}`]?.[0]
const dragOverTileEl = dragOverTile?.$el?.nextSibling
if (!dragOverTileEl)
return
const rootTop = this.$el.getBoundingClientRect().top
const cursorY = event.clientY - rootTop
const dragOverTilePos = {
top: dragOverTileEl.offsetTop,
bottom: dragOverTileEl.offsetTop + dragOverTileEl.offsetHeight,
getParentBlock(indices) {
indices = [...indices]
let parent = this.newValue
while (parent && indices.length > 1) {
parent = parent[indices.shift()]
}
const dragOverTileSectionHeight = (dragOverTilePos.bottom - dragOverTilePos.top) / 3
let cursorTileSection = null
if (cursorY < dragOverTilePos.top + dragOverTileSectionHeight) {
cursorTileSection = 'top'
} else if (cursorY < dragOverTilePos.bottom - dragOverTileSectionHeight) {
cursorTileSection = 'middle'
} else {
cursorTileSection = 'bottom'
}
if (cursorTileSection === 'middle') {
this.dropIndex = null
return
}
if (cursorTileSection === 'top' && index < this.dragItem) {
this.dropIndex = index
} else if (cursorTileSection === 'bottom' && index > this.dragItem) {
if (index === this.actions.length - 1) {
this.dropIndex = index + 1
} else {
this.dropIndex = index
if (parent) {
const blockKey = this.getKey(parent)
if (blockKey) {
parent = parent[blockKey]
}
}
return parent
},
editAction(action, index) {
this.actions[index] = action
editAction(event, index) {
if (event?.target && event.stopPropagation) {
// If the event is a native event, then we need to stop the propagation,
// otherwise the event will be caught by the parent element. If the parent
// is a modal, then the modal will be closed, making it impossible to edit
// text fields in the action tiles.
event.stopPropagation()
return
}
this.newValue[index] = event
this.$emit('input', this.newValue)
},
addAction(action) {
this.actions.push(action)
this.newValue.push(action)
},
addCondition() {
this.newValue.push({ 'if ${True}': [] })
this.$nextTick(() => {
const newTile = this.$refs[`action-tile-${this.newValue.length - 1}`]?.[0]
if (!newTile) {
return
}
const newTileElement = newTile.$el?.querySelector('.tile')
if (!newTileElement) {
return
}
newTileElement.click()
this.$nextTick(() => {
const conditionEditor = newTile.$el?.querySelector('.condition-editor-container')
if (!conditionEditor) {
return
}
const input = conditionEditor.querySelector('input[type="text"]')
if (!input) {
return
}
input.value = ''
input.focus()
})
})
},
addElse() {
this.newValue.push({ 'else': [] })
},
deleteAction(index) {
this.actions.splice(index, 1)
},
// If the action is a condition, then we need to also remove the else block
const items = (
this.getCondition(this.newValue[index]) && this.isElse(this.newValue?.[index + 1])
) ? 2 : 1
resetSpacers() {
this.visibleTopSpacers = Object.keys(this.actions).reduce((acc, index) => {
acc[index] = true
return acc
}, {})
this.visibleBottomSpacers = {[this.actions.length - 1]: true}
this.syncSpacers()
const el = this.$refs[`action-tile-${index}`]?.[0]?.$el
if (el) {
el.classList.add('shrink')
setTimeout(() => {
el.classList.remove('shrink')
this.newValue.splice(index, items)
}, 300)
} else {
this.newValue.splice(index, items)
}
},
syncSpacers() {
this.$nextTick(() => {
this.spacerElements = Object.keys(this.actions).reduce((acc, index) => {
this.spacerElements = Object.keys(this.newValue).reduce((acc, index) => {
acc[index] = this.$refs[`dropTarget_${index}`]?.[0]
return acc
}, {})
@ -164,35 +531,21 @@ export default {
if (!this.value || !this.hasChanges)
return
this.actions = this.value
this.newValue = this.value
},
},
watch: {
actions: {
newValue: {
deep: true,
handler(value) {
this.$emit('input', value)
this.resetSpacers()
this.syncSpacers()
},
},
dragItem(value) {
if (value == null || this.readOnly) {
this.resetSpacers()
} else {
this.visibleTopSpacers = Object.keys(this.actions).reduce((acc, index) => {
acc[index] = this.dragItem > index
return acc
}, {})
this.visibleBottomSpacers = Object.keys(this.actions).reduce((acc, index) => {
acc[index] = this.dragItem < index
return acc
}, {})
this.syncSpacers()
}
dragIndices() {
this.syncSpacers()
},
value: {
@ -206,17 +559,57 @@ export default {
mounted() {
this.syncValue()
this.resetSpacers()
this.syncSpacers()
},
}
</script>
<style lang="scss" scoped>
$spacer-height: 1em;
.actions-list {
display: flex;
flex-direction: row;
.actions {
.action {
margin: 0;
.actions {
flex: 1;
.action {
margin: 0;
}
.add-action-container {
margin-bottom: 0.5em;
}
}
.spacer {
height: 1em;
}
.indent-spacers {
display: flex;
flex-direction: row;
.indent-spacer {
width: 1.5em;
height: 100%;
margin: 0;
cursor: pointer;
.side {
width: 0.75em;
height: 100%;
&.left {
border-right: 1px solid $selected-fg;
}
}
&:hover {
.side.left {
border-right: 1px solid $tile-code-fg;
}
}
}
}
}
</style>

View file

@ -1,68 +1,25 @@
<template>
<div class="row item action" :class="{ dragging }">
<div class="spacer-wrapper" :class="{ hidden: !spacerTop }">
<div class="spacer"
:class="{ active }"
ref="dropTargetTop">
<div class="droppable-wrapper">
<div class="droppable-container">
<div class="droppable-frame">
<div class="droppable" />
</div>
</div>
</div>
</div>
<Droppable :element="$refs.dropTargetTop"
:disabled="readOnly"
@dragend.prevent="onDrop"
@dragenter.prevent="$emit('dragenterspacer', $event)"
@dragleave.prevent="$emit('dragleavespacer', $event)"
@dragover.prevent="$emit('dragoverspacer', $event)"
@drop="$emit('drop', $event)" />
</div>
<div class="spacer" v-if="dragging" />
<ListItem class="action"
:active="active"
:dragging="dragging"
:spacer-bottom="spacerBottom"
:spacer-top="spacerTop"
:value="value"
v-on="componentsData.on">
<ActionTile :value="value"
:draggable="!readOnly"
:read-only="readOnly"
:with-delete="!readOnly"
v-on="componentsData.on"
@contextmenu="$emit('contextmenu', $event)"
@drag="onDragStart"
@dragend.prevent="onDragEnd"
@dragover.prevent="$emit('dragover', $event)"
@drop="onDrop"
@input="$emit('input', $event)"
@drag.stop="onDragStart"
@delete="$emit('delete', $event)" />
<div class="spacer" v-if="dragging" />
<div class="spacer-wrapper" :class="{ hidden: !spacerBottom }">
<div class="spacer" :class="{ active }" ref="dropTargetBottom">
<div class="droppable-wrapper">
<div class="droppable-container">
<div class="droppable-frame">
<div class="droppable" />
</div>
</div>
</div>
</div>
<Droppable :element="$refs.dropTargetBottom"
:disabled="readOnly"
@dragend.prevent="onDrop"
@dragenter.prevent="$emit('dragenterspacer', $event)"
@dragleave.prevent="$emit('dragleavespacer', $event)"
@dragover.prevent="$emit('dragoverspacer', $event)"
@drop="onDrop" />
</div>
</div>
</ListItem>
</template>
<script>
import ActionTile from "@/components/Action/ActionTile"
import Droppable from "@/components/elements/Droppable"
import ListItem from "@/components/Action/ListItem"
import Utils from "@/Utils"
export default {
@ -73,18 +30,15 @@ export default {
'drag',
'dragend',
'dragenter',
'dragenterspacer',
'dragleave',
'dragleavespacer',
'dragover',
'dragoverspacer',
'drop',
'input',
],
components: {
ActionTile,
Droppable,
ListItem,
},
props: {
@ -117,94 +71,56 @@ export default {
data() {
return {
dragging: false,
dragItem: undefined,
dropIndex: undefined,
newAction: {},
spacerElements: {},
visibleTopSpacers: {},
visibleBottomSpacers: {},
}
},
computed: {
componentsData() {
return {
on: {
dragend: this.onDragEnd,
dragover: this.onDragOver,
drop: this.onDrop,
input: this.onInput,
}
}
},
},
methods: {
onDragStart(event) {
if (this.readOnly) {
return
}
this.dragging = true
this.$emit('drag', event)
},
onDragEnd(event) {
event.stopPropagation()
this.dragging = false
this.$emit('dragend', event)
},
onDragOver(event) {
event.stopPropagation()
this.$emit('dragover', event)
},
onDrop(event) {
if (this.readOnly) {
return
}
event.stopPropagation()
this.dragging = false
this.$emit('drop', event)
},
onInput(value) {
this.$emit('input', value)
},
},
}
</script>
<style lang="scss" scoped>
$spacer-height: 1em;
.action {
margin: 0;
.spacer {
height: $spacer-height;
&.active {
border-top: 1px dashed transparent;
border-bottom: 1px dashed transparent;
.droppable-frame {
border: 1px dashed $selected-fg;
border-radius: 0.5em;
}
}
&.selected {
height: 5em;
padding: 0.5em 0;
.droppable-frame {
height: 100%;
margin: 0.5em 0;
padding: 0.1em;
border: 2px dashed $ok-fg;
}
.droppable {
height: 100%;
background: $play-btn-fg;
border: none;
box-shadow: none;
border-radius: 1em;
opacity: 0.5;
}
}
}
.droppable-wrapper,
.droppable-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
}
.droppable-container {
.droppable-frame {
width: 100%;
height: 1px;
opacity: 0.5;
}
.droppable {
width: 100%;
opacity: 0.8;
}
}
}
</style>

View file

@ -0,0 +1,57 @@
<template>
<div class="add-tile-container">
<Tile class="add"
:draggable="false"
:read-only="true"
@click="$emit('click')">
<div class="add-tile">
<span class="icon">
<i :class="icon" />
</span>
<span class="name">
{{ title }}
</span>
</div>
</Tile>
</div>
</template>
<script>
import Tile from "@/components/elements/Tile"
export default {
emits: ['click'],
components: { Tile },
props: {
icon: {
type: String,
default: 'fas fa-plus',
},
title: {
type: String,
required: true,
},
},
}
</script>
<style lang="scss" scoped>
@import "common";
.add-tile-container {
position: relative;
margin: 0.5em 0;
.icon {
width: 1.5em;
height: 1.5em;
margin-right: 0.75em;
}
.name {
font-style: italic;
}
}
</style>

View file

@ -0,0 +1,219 @@
<template>
<div class="condition-block">
<ActionsBlock :value="value"
:collapsed="collapsed"
:dragging="isDragging"
:has-else="hasElse"
:indent="indent"
:read-only="readOnly"
@input="onActionsChange"
@add-else="$emit('add-else')"
@drag="$emit('drag', $event)"
@dragend="$emit('dragend', $event)"
@dragenter="$emit('dragenter', $event)"
@dragleave="$emit('dragleave', $event)"
@dragover="$emit('dragover', $event)"
@drop="$emit('drop', $event)">
<template #before>
<ConditionTile :value="condition"
v-bind="conditionTileConf.props"
v-on="conditionTileConf.on"
@input.prevent.stop
:spacer-top="spacerTop"
:spacer-bottom="false"
v-if="condition && !isElse" />
<ConditionTile value="else"
v-bind="conditionTileConf.props"
v-on="conditionTileConf.on"
:is-else="true"
:spacer-top="spacerTop"
:spacer-bottom="false"
v-else-if="isElse" />
</template>
<template #after>
<EndBlockTile value="end if"
icon="fas fa-question"
:active="active"
:spacer-bottom="spacerBottom"
@drop="onDrop"
v-if="isElse || !hasElse" />
</template>
</ActionsBlock>
</div>
</template>
<script>
import ActionsBlock from "./ActionsBlock"
import ConditionTile from "./ConditionTile"
import EndBlockTile from "./EndBlockTile"
import Mixin from "./Mixin"
export default {
name: 'ConditionBlock',
mixins: [Mixin],
emits: [
'add-else',
'delete',
'drag',
'dragend',
'dragenter',
'dragleave',
'dragover',
'drop',
'input',
],
components: {
ActionsBlock,
ConditionTile,
EndBlockTile,
},
props: {
value: {
type: Object,
required: true,
},
active: {
type: Boolean,
default: false,
},
collapsed: {
type: Boolean,
default: false,
},
dragging: {
type: Boolean,
default: false,
},
hasElse: {
type: Boolean,
default: false,
},
indent: {
type: Number,
default: 0,
},
isElse: {
type: Boolean,
default: false,
},
readOnly: {
type: Boolean,
default: false,
},
spacerBottom: {
type: Boolean,
default: false,
},
spacerTop: {
type: Boolean,
default: false,
},
},
data() {
return {
dragging_: false,
}
},
computed: {
condition() {
return this.getCondition(this.key)
},
conditionTileConf() {
return {
props: {
active: this.active,
readOnly: this.readOnly,
spacerBottom: this.spacerBottom,
spacerTop: this.spacerTop,
},
on: {
change: this.onConditionChange,
delete: (event) => this.$emit('delete', event),
drag: this.onDragStart,
dragend: this.onDragEnd,
dragenterspacer: (event) => this.$emit('dragenter', event),
dragleavespacer: (event) => this.$emit('dragleave', event),
dragover: (event) => this.$emit('dragover', event),
dragoverspacer: (event) => this.$emit('dragoverspacer', event),
drop: this.onDrop,
},
}
},
isDragging() {
return this.dragging_ || this.dragging
},
key() {
return this.getKey(this.value)
},
},
methods: {
onActionsChange(value) {
if (!this.key || this.readOnly) {
return
}
this.$emit('input', { [this.key]: value })
},
onConditionChange(condition) {
if (!this.key || this.readOnly || !condition?.length) {
return
}
condition = `if \${${condition.trim()}}`
this.$emit('input', { [condition]: this.value[this.key] })
},
onDragStart(event) {
if (this.readOnly) {
return
}
this.dragging_ = true
this.$emit('drag', event)
},
onDragEnd(event) {
this.dragging_ = false
this.$emit('dragend', event)
},
onDrop(event) {
if (this.readOnly) {
return
}
this.dragging_ = false
this.$emit('drop', event)
},
},
}
</script>
<style lang="scss" scoped>
:deep(.condition-block) {
.end-if-container {
margin-bottom: 1em;
}
}
</style>

View file

@ -0,0 +1,104 @@
<template>
<form class="condition-editor" @submit.prevent.stop="onSubmit">
<label for="condition">
Condition
<input type="text"
name="condition"
autocomplete="off"
:autofocus="true"
:value="value"
ref="text"
@input.stop="onInput" />
</label>
<label>
<button type="submit" :disabled="!hasChanges">
<i class="fas fa-check" />&nbsp;Save
</button>
</label>
</form>
</template>
<script>
export default {
emits: [
'input',
],
props: {
value: {
type: String,
required: true,
},
},
data() {
return {
hasChanges: false,
}
},
methods: {
onSubmit(event) {
const value = this.$refs.text.value.trim()
if (!value.length) {
return
}
event.target.value = value
this.$emit('input', event)
},
onInput(event) {
const value = '' + event.target.value
if (!value?.trim()?.length) {
this.hasChanges = false
} else {
this.hasChanges = value !== this.value
}
this.$nextTick(() => {
this.$refs.text.value = value
})
},
},
watch: {
value() {
this.hasChanges = false
},
},
mounted() {
this.$nextTick(() => {
this.$refs.text.focus()
})
},
}
</script>
<style lang="scss" scoped>
@import "common";
.condition-editor {
min-width: 40em;
max-width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
label {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 1em;
input[type="text"] {
width: 100%;
margin-left: 1em;
}
}
}
</style>

View file

@ -0,0 +1,216 @@
<template>
<ListItem class="condition-tile"
:value="value"
:active="active"
:read-only="readOnly"
:spacer-bottom="spacerBottom"
:spacer-top="spacerTop"
v-on="dragListeners"
@input="$emit('input', $event)">
<div class="drag-spacer" v-if="dragging && !spacerTop">&nbsp;</div>
<Tile v-bind="tileConf.props"
v-on="tileConf.on"
:draggable="!readOnly"
@click.stop="showConditionEditor = true"
v-if="!isElse">
<div class="tile-name">
<span class="icon">
<i class="fas fa-question" />
</span>
<span class="name">
<span class="keyword">if</span> [
<span class="code" v-text="value" /> ]
<span class="keyword">then</span>
</span>
</div>
</Tile>
<Tile v-bind="tileConf.props"
v-on="tileConf.on"
:draggable="false"
:read-only="true"
@click="$emit('click')"
v-else>
<div class="tile-name">
<span class="icon">
<i class="fas fa-question" />
</span>
<span class="name">
<span class="keyword">else</span>
</span>
</div>
</Tile>
<div class="condition-editor-container" v-if="showConditionEditor && !readOnly">
<Modal title="Edit Condition"
:visible="true"
@close="showConditionEditor = false">
<ConditionEditor :value="value"
ref="conditionEditor"
@input.prevent.stop="onConditionChange"
v-if="showConditionEditor" />
</Modal>
</div>
</ListItem>
</template>
<script>
import ConditionEditor from "./ConditionEditor"
import ListItem from "./ListItem"
import Modal from "@/components/Modal"
import Tile from "@/components/elements/Tile"
export default {
emits: [
'change',
'click',
'delete',
'drag',
'dragend',
'dragenter',
'dragleave',
'dragover',
'drop',
'input',
],
components: {
ConditionEditor,
ListItem,
Modal,
Tile,
},
props: {
value: {
type: String,
required: true,
},
active: {
type: Boolean,
default: false,
},
isElse: {
type: Boolean,
default: false,
},
readOnly: {
type: Boolean,
default: false,
},
spacerBottom: {
type: Boolean,
default: true,
},
spacerTop: {
type: Boolean,
default: true,
},
},
computed: {
dragListeners() {
return this.readOnly ? {} : {
drag: this.onDragStart,
dragend: this.onDragEnd,
dragenter: (event) => this.$emit('dragenter', event),
dragleave: (event) => this.$emit('dragleave', event),
dragover: (event) => this.$emit('dragover', event),
drop: this.onDrop,
}
},
tileConf() {
return {
props: {
value: this.value,
class: 'keyword',
readOnly: this.readOnly,
withDelete: !this.readOnly,
},
on: {
...this.dragListeners,
delete: () => this.$emit('delete'),
input: this.onInput,
},
}
},
},
data() {
return {
dragging: false,
showConditionEditor: false,
}
},
methods: {
onConditionChange(event) {
this.showConditionEditor = false
if (this.readOnly) {
return
}
const condition = event.target.value?.trim()
if (!condition?.length) {
return
}
event.target.value = condition
this.$emit('change', condition)
},
onInput(value) {
if (!value || this.readOnly) {
return
}
this.$emit('input', value)
},
onDragStart(event) {
if (this.readOnly) {
return
}
this.dragging = true
this.$emit('drag', event)
},
onDragEnd(event) {
this.dragging = false
this.$emit('dragend', event)
},
onDrop(event) {
this.dragging = false
if (this.readOnly) {
return
}
this.$emit('drop', event)
},
},
}
</script>
<style lang="scss" scoped>
@import "common";
.action-tile {
.condition {
font-style: italic;
}
.drag-spacer {
height: 0;
}
}
</style>

View file

@ -0,0 +1,64 @@
<template>
<ListItem class="end-block-container"
:active="active"
:value="{}"
:read-only="false"
:spacer-bottom="spacerBottom"
:spacer-top="spacerTop"
@dragenter="$emit('dragenter', $event)"
@dragleave="$emit('dragleave', $event)"
@dragover="$emit('dragover', $event)"
@drop="$emit('drop', $event)">
<Tile class="keyword" :draggable="false" :read-only="true">
<div class="tile-name">
<span class="icon">
<i class="fas fa-question" />
</span>
<span class="name">
<span class="keyword" v-text="value" />
</span>
</div>
</Tile>
</ListItem>
</template>
<script>
import ListItem from "./ListItem"
import Mixin from "./Mixin"
import Tile from "@/components/elements/Tile"
export default {
mixins: [Mixin],
components: {
ListItem,
Tile,
},
props: {
value: {
type: String,
required: true,
},
icon: {
type: String,
required: true,
},
active: {
type: Boolean,
default: false,
},
spacerTop: {
type: Boolean,
default: false,
},
spacerBottom: {
type: Boolean,
default: false,
},
},
}
</script>

View file

@ -0,0 +1,206 @@
<template>
<div class="row item list-item" :class="itemClass">
<div class="spacer-wrapper" :class="{ hidden: !spacerTop }">
<div class="spacer" :class="{ active }" ref="dropTargetTop">
<div class="droppable-wrapper">
<div class="droppable-container">
<div class="droppable-frame">
<div class="droppable" />
</div>
</div>
</div>
</div>
<Droppable :element="$refs.dropTargetTop" :disabled="readOnly" v-on="droppableData.top.on" />
</div>
<div class="spacer" v-if="dragging" />
<slot />
<div class="spacer" v-if="dragging" />
<div class="spacer-wrapper" :class="{ hidden: !spacerBottom }">
<div class="spacer" :class="{ active }" ref="dropTargetBottom">
<div class="droppable-wrapper">
<div class="droppable-container">
<div class="droppable-frame">
<div class="droppable" />
</div>
</div>
</div>
</div>
<Droppable :element="$refs.dropTargetBottom" :disabled="readOnly" v-on="droppableData.bottom.on" />
</div>
</div>
</template>
<script>
import Droppable from "@/components/elements/Droppable"
import Utils from "@/Utils"
export default {
mixins: [Utils],
emits: [
'contextmenu',
'dragend',
'dragenter',
'dragleave',
'dragover',
'drop',
],
components: {
Droppable,
},
props: {
active: {
type: Boolean,
default: false,
},
className: {
type: [String, Object],
default: '',
},
dragging: {
type: Boolean,
default: false,
},
readOnly: {
type: Boolean,
default: false,
},
spacerBottom: {
type: Boolean,
default: false,
},
spacerTop: {
type: Boolean,
default: true,
},
value: {
type: [String, Object],
required: true,
},
},
computed: {
droppableData() {
return ['bottom', 'top'].reduce((acc, key) => {
acc[key] = {
on: {
dragend: this.onDragEnd,
dragenter: this.onDragEnter,
dragleave: this.onDragLeave,
dragover: this.onDragOver,
drop: this.onDrop,
},
}
return acc
}, {})
},
itemClass() {
return {
dragging: this.dragging,
...(this.className?.trim ? { [this.className]: true } : (this.className || {})),
}
},
},
methods: {
onDragEnd(event) {
this.$emit('dragend', event)
},
onDragEnter(event) {
this.$emit('dragenter', event)
},
onDragLeave(event) {
this.$emit('dragleave', event)
},
onDragOver(event) {
this.$emit('dragover', event)
},
onDrop(event) {
this.$emit('drop', event)
},
},
}
</script>
<style lang="scss" scoped>
$spacer-height: 1em;
.list-item {
margin: 0;
.spacer {
height: $spacer-height;
&.active {
border-top: 1px dashed transparent;
border-bottom: 1px dashed transparent;
.droppable-frame {
border: 1px dashed $selected-fg;
border-radius: 0.5em;
}
}
&.selected {
height: 5em;
padding: 0.5em 0;
.droppable-frame {
height: 100%;
margin: 0.5em 0;
padding: 0.1em;
border: 2px dashed $ok-fg;
}
.droppable {
height: 100%;
background: $play-btn-fg;
border: none;
box-shadow: none;
border-radius: 1em;
opacity: 0.5;
}
}
}
.droppable-wrapper,
.droppable-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
}
.droppable-container {
.droppable-frame {
width: 100%;
height: 1px;
opacity: 0.5;
}
.droppable {
width: 100%;
opacity: 0.8;
}
}
}
</style>

View file

@ -0,0 +1,36 @@
<script>
export default {
methods: {
getCondition(value) {
value = this.getKey(value) || value
return value?.trim?.()?.match(/^if\s*\$\{(.*)\}\s*$/i)?.[1]?.trim?.()
},
getKey(value) {
return this.isKeyword(value) ? Object.keys(value)[0] : null
},
isAction(value) {
return typeof value === 'object' && !Array.isArray(value) && (value.action || value.name)
},
isActionsBlock(value) {
return this.getCondition(value) || this.isElse(value)
},
isKeyword(value) {
return (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.keys(value).length === 1 &&
!this.isAction(value)
)
},
isElse(value) {
return (this.getKey(value) || value)?.toLowerCase?.()?.trim?.() === 'else'
},
},
}
</script>

View file

@ -64,7 +64,7 @@
<ActionsList :value="newValue.actions"
:read-only="readOnly"
@input="newValue.actions = $event" />
@input="onActionsEdit" />
</div>
<!-- Structured response container -->
@ -176,7 +176,7 @@
<FloatingButton icon-class="fa fa-save"
right glow
title="Save Procedure"
:disabled="!hasChanges"
:disabled="!canSave"
@click="save"
v-if="showSave" />
<FloatingButton icon-class="fa fa-play"
@ -292,14 +292,24 @@ export default {
return this.$el.querySelector('.floating-btns')
},
hasChanges() {
if (!this.newValue?.name?.length)
canSave() {
if (
!this.withSave ||
this.readOnly ||
!this.newValue?.name?.length ||
!this.newValue?.actions?.length
)
return false
if (!this.newValue?.actions?.length)
return false
return this.valueString !== this.newValueString
},
return JSON.stringify(this.value) !== JSON.stringify(this.newValue)
valueString() {
return JSON.stringify(this.value)
},
newValueString() {
return JSON.stringify(this.newValue)
},
modal_() {
@ -310,7 +320,7 @@ export default {
},
shouldConfirmClose() {
return this.hasChanges && !this.readOnly && this.withSave && !this.shouldForceClose
return this.canSave && !this.shouldForceClose
},
showArgs() {
@ -324,7 +334,7 @@ export default {
methods: {
async save() {
if (!this.hasChanges)
if (!this.canSave)
return
this.loading = true
@ -468,12 +478,13 @@ export default {
duplicate() {
const name = `${this.newValue.name || ''}__copy`
const duplicate = JSON.parse(JSON.stringify(this.newValue))
this.duplicateValue = {
...this.newValue,
...duplicate,
...{
meta: {
...(this.newValue.meta || {}),
icon: {...(this.newValue.meta?.icon || {})},
...(duplicate.meta || {}),
icon: {...(duplicate.meta?.icon || {})},
}
},
id: null,
@ -482,16 +493,8 @@ export default {
}
},
editAction(action, index) {
this.newValue.actions[index] = action
},
addAction(action) {
this.newValue.actions.push(action)
},
deleteAction(index) {
this.newValue.actions.splice(index, 1)
onActionsEdit(actions) {
this.newValue.actions = actions
},
onArgInput(arg, index) {
@ -630,11 +633,12 @@ export default {
if (!this.value)
return
const value = JSON.parse(JSON.stringify(this.value))
this.newValue = {
...this.value,
actions: this.value.actions?.map(a => ({...a})),
args: [...(this.value?.args || [])],
meta: {...(this.value?.meta || {})},
...value,
actions: value.actions?.map(a => ({...a})),
args: [...(value?.args || [])],
meta: {...(value?.meta || {})},
}
},
},

View file

@ -0,0 +1,175 @@
<template>
<div class="tile-container" :class="className">
<div class="tile" ref="tile" @click="$emit('click', $event)">
<div class="delete"
title="Remove"
v-if="withDelete"
@click.stop="$emit('delete')">
<i class="icon fas fa-xmark" />
</div>
<slot />
</div>
<Draggable :element="tile"
:disabled="readOnly"
:value="value"
@drag="$emit('drag', $event)"
@drop="$emit('drop', $event)"
v-if="draggable" />
<Droppable :element="tile"
@dragenter="$emit('dragenter', $event)"
@dragleave="$emit('dragleave', $event)"
@dragover="$emit('dragover', $event)"
@drop="$emit('drop', $event)"
v-if="!readOnly" />
</div>
</template>
<script>
import Draggable from "@/components/elements/Draggable"
import Droppable from "@/components/elements/Droppable"
export default {
emits: [
'click',
'delete',
'drag',
'dragenter',
'dragleave',
'dragover',
'drop',
],
components: {
Draggable,
Droppable,
},
props: {
className: {
type: [String, Object],
default: '',
},
draggable: {
type: Boolean,
default: true,
},
readOnly: {
type: Boolean,
default: false,
},
value: {
type: [Object, String, Number, Boolean, Array],
},
withDelete: {
type: Boolean,
default: false,
},
},
data() {
return {
tile: undefined,
}
},
mounted() {
this.tile = this.$refs.tile
},
}
</script>
<style lang="scss" scoped>
.tile-container {
position: relative;
.tile {
min-width: 20em;
background: $tile-bg;
color: $tile-fg;
display: flex;
flex-direction: column;
padding: 0.5em 1em;
overflow: hidden;
text-overflow: ellipsis;
content: "";
position: relative;
border-radius: 1em;
cursor: pointer;
&:hover {
background: $tile-hover-bg;
}
&.selected {
background: $tile-hover-bg;
}
.delete {
width: 1.5em;
height: 1.5em;
font-size: 1.25em;
position: absolute;
top: 0.25em;
right: 0;
opacity: 0.7;
transition: opacity 0.25s ease-in-out;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
&.keyword {
.tile {
background: $tile-bg-2;
&:hover {
background: $tile-hover-bg-2;
}
}
}
&.add {
.tile {
background: $tile-bg-3;
&:hover {
background: $tile-hover-bg-3;
}
}
}
}
:deep(.tile) {
.tile-name {
display: inline-flex;
font-size: 1.1em;
font-weight: bold;
font-family: monospace;
align-items: center;
.icon {
width: 1.5em;
height: 1.5em;
margin-right: 0.75em;
}
}
.keyword {
color: $tile-keyword-fg;
}
.code {
color: $tile-code-fg;
}
}
</style>

View file

@ -160,7 +160,12 @@ export default {
watch: {
editIcon() {
this.newIcon = (this.entity.meta?.icon?.['class'] || this.entity.meta?.icon?.url)?.trim()
this.newIcon = (
this.entity.meta?.icon?.url ||
this.entity.meta?.icon?.['class'] ||
this.currentIcon.url ||
this.currentIcon.class
)?.trim()
},
newIcon() {

View file

@ -133,6 +133,10 @@
</div>
</div>
<div class="item">
<IconEditor :entity="value" />
</div>
<div class="item actions" v-if="value?.actions?.length">
<div class="label">Actions</div>
<div class="value">
@ -212,6 +216,7 @@ import ConfirmDialog from "@/components/elements/ConfirmDialog";
import EntityMixin from "./EntityMixin"
import EntityIcon from "./EntityIcon"
import FileEditor from "@/components/File/EditorModal";
import IconEditor from "@/components/panels/Entities/IconEditor";
import ProcedureEditor from "@/components/Procedure/ProcedureEditorModal"
import Response from "@/components/Action/Response"
@ -220,6 +225,7 @@ export default {
ConfirmDialog,
EntityIcon,
FileEditor,
IconEditor,
ProcedureEditor,
Response,
},
@ -658,6 +664,8 @@ $icon-width: 2em;
}
.value {
text-align: right;
@include until($tablet) {
width: 100%;
}