[#341] [UI] Implemented support for procedure entities.

- Added UI panel.

- Added support for entity types.

- Enhanced ability to edit procedures.

- Added ability to create, rename, edit, duplicate and delete stored
  procedures.

- Added support for YAML dumps of non-Python procedures.

- Added support for visualizing Python procedures directly in their
  source files.
This commit is contained in:
Fabio Manganiello 2024-09-05 02:02:44 +02:00
parent bbfc5b32e6
commit 15cf611c95
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
12 changed files with 2624 additions and 258 deletions

View file

@ -1,5 +1,5 @@
<template>
<div class="args-body">
<div class="args-body" @keydown="onKeyDown">
<div class="args-list"
v-if="Object.keys(action.args).length || action.supportsExtraArgs">
<!-- Supported action arguments -->
@ -77,9 +77,11 @@ export default {
'arg-edit',
'extra-arg-name-edit',
'extra-arg-value-edit',
'input',
'remove',
'select',
],
props: {
action: Object,
loading: Boolean,
@ -88,6 +90,18 @@ export default {
selectedArgdoc: String,
},
computed: {
allArgs() {
return {
...this.action.args,
...this.action.extraArgs.reduce((acc, arg) => {
acc[arg.name] = arg
return acc
}, {}),
}
},
},
methods: {
onArgAdd() {
this.$emit('add')
@ -124,6 +138,19 @@ export default {
onSelect(arg) {
this.$emit('select', arg)
},
onKeyDown(event) {
if (event.key === 'Enter' && !(event.shiftKey || event.ctrlKey || event.altKey || event.metaKey))
this.onEnter(event)
},
onEnter(event) {
if (!event.target.tagName.match(/input|textarea/i))
return
event.preventDefault()
this.$emit('input', this.allArgs)
},
},
}
</script>

View file

@ -1,55 +1,99 @@
<template>
<div class="action-tile"
:class="{drag: draggable && dragging}"
:draggable="draggable"
@dragstart="onDragStart"
@dragend="onDragEnd"
@click="$refs.actionEditor.show">
<div class="action-delete" title="Remove" v-if="withDelete" @click.stop="$emit('delete')">
<i class="icon fas fa-xmark" />
</div>
<div class="action-tile-container">
<div class="action-tile"
ref="tile"
@click="$refs.actionEditor.show">
<div class="action-delete"
title="Remove"
v-if="withDelete && !readOnly"
@click.stop="$emit('delete')">
<i class="icon fas fa-xmark" />
</div>
<div class="action-name" v-if="name?.length">
{{ name }}
</div>
<div class="new-action" v-else>
<i class="icon fas fa-plus" />&nbsp; Add Action
</div>
<div class="action-args" v-if="Object.keys(value.args || {})?.length">
<div class="arg" v-for="(arg, name) in value.args" :key="name">
<div class="arg-name">
<div class="action-name" v-if="name?.length">
<span class="icon">
<ExtensionIcon :name="name.split('.')[0]" size="1.5em" />
</span>
<span class="name">
{{ name }}
</div>
</span>
</div>
<div class="arg-value">
{{ arg }}
<div class="new-action" v-else>
<i class="icon fas fa-plus" />&nbsp; Add Action
</div>
<div class="action-args" v-if="Object.keys(value.args || {})?.length">
<div class="arg" v-for="(arg, name) in value.args" :key="name">
<div class="arg-name">
{{ name }}
</div>
<div class="arg-value">
{{ arg }}
</div>
</div>
</div>
</div>
</div>
<div class="action-editor-container">
<Modal ref="actionEditor" title="Edit Action">
<ActionEditor :value="value" with-save @input="onInput"
v-if="this.$refs.actionEditor?.$data?.isVisible" />
</Modal>
<Draggable :element="tile"
:disabled="readOnly"
:value="value"
@drag="$emit('drag', $event)"
@drop="$emit('drop', $event)"
v-if="draggable" />
<Droppable :element="tile"
:disabled="readOnly"
@dragenter="$emit('dragenter', $event)"
@dragleave="$emit('dragleave', $event)"
@dragover="$emit('dragover', $event)"
@drop="$emit('drop', $event)"
v-if="draggable" />
<div class="action-editor-container">
<Modal ref="actionEditor" title="Edit Action">
<ActionEditor :value="value"
:with-save="!readOnly"
@input="onInput"
v-if="this.$refs.actionEditor?.$data?.isVisible" />
</Modal>
</div>
</div>
</template>
<script>
import ActionEditor from "@/components/Action/ActionEditor"
import Modal from "@/components/Modal";
import Draggable from "@/components/elements/Draggable"
import Droppable from "@/components/elements/Droppable"
import ExtensionIcon from "@/components/elements/ExtensionIcon"
import Modal from "@/components/Modal"
export default {
emits: ['input', 'delete', 'drag', 'drop'],
emits: [
'delete',
'drag',
'dragenter',
'dragleave',
'dragover',
'drop',
'input',
],
components: {
ActionEditor,
Draggable,
Droppable,
ExtensionIcon,
Modal,
},
props: {
draggable: {
type: Boolean,
default: true,
},
value: {
type: Object,
default: () => ({
@ -65,7 +109,7 @@ export default {
default: false,
},
draggable: {
readOnly: {
type: Boolean,
default: false,
},
@ -73,7 +117,7 @@ export default {
data() {
return {
dragging: false,
tile: null,
}
},
@ -84,20 +128,11 @@ export default {
},
methods: {
onDragStart(event) {
this.dragging = true
event.dataTransfer.dropEffect = 'move'
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('application/json', JSON.stringify(this.value))
this.$emit('drag')
},
onDragEnd() {
this.dragging = false
this.$emit('drop')
},
onInput(value) {
if (!value || this.readOnly) {
return
}
this.$emit('input', {
...this.value,
name: value.action,
@ -109,118 +144,135 @@ export default {
this.$refs.actionEditor.close()
},
},
mounted() {
this.tile = this.$refs.tile
},
}
</script>
<style lang="scss" scoped>
@import "common";
.action-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: "";
.action-tile-container {
position: relative;
border-radius: 1em;
cursor: pointer;
&:hover {
background: $tile-hover-bg;
}
&.drag {
opacity: 0.5;
}
.action-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;
&:hover {
opacity: 1;
}
}
.action-name {
font-size: 1.1em;
font-weight: bold;
font-family: monospace;
}
.new-action {
font-style: italic;
}
.action-args {
.action-tile {
min-width: 20em;
background: $tile-bg;
color: $tile-fg;
display: flex;
flex-direction: column;
margin-top: 0.5em;
padding: 0.5em 1em;
overflow: hidden;
text-overflow: ellipsis;
content: "";
position: relative;
border-radius: 1em;
cursor: pointer;
.arg {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 0.5em;
&:hover {
background: $tile-hover-bg;
}
.arg-name {
font-weight: bold;
margin-right: 0.5em;
}
&.selected {
background: $tile-hover-bg;
}
.arg-value {
font-family: monospace;
font-size: 0.9em;
flex: 1;
.action-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;
}
}
}
}
.action-editor-container {
:deep(.modal-container) {
@include until($tablet) {
.modal {
width: calc(100vw - 1em);
.action-name {
display: inline-flex;
font-size: 1.1em;
font-weight: bold;
font-family: monospace;
align-items: center;
.content {
width: 100%;
.icon {
width: 1.5em;
height: 1.5em;
margin-right: 0.75em;
}
}
.body {
width: 100%;
}
.new-action {
font-style: italic;
}
.action-args {
display: flex;
flex-direction: column;
margin-top: 0.5em;
.arg {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 0.5em;
.arg-name {
font-weight: bold;
margin-right: 0.5em;
}
.arg-value {
font-family: monospace;
font-size: 0.9em;
flex: 1;
}
}
}
}
.content .body {
width: 80vw;
height: 80vh;
max-width: 800px;
padding: 0;
}
.action-editor-container {
:deep(.modal-container) {
@include until($tablet) {
.modal {
width: calc(100vw - 1em);
.tabs {
margin-top: 0;
}
.content {
width: 100%;
.action-editor {
height: 100%;
}
.body {
width: 100%;
}
}
}
}
form {
height: calc(100% - $tab-height);
overflow: auto;
.content .body {
width: 80vw;
height: 80vh;
max-width: 800px;
padding: 0;
}
.tabs {
margin-top: 0;
}
.action-editor {
height: 100%;
}
form {
height: calc(100% - $tab-height);
overflow: auto;
}
}
}
}

View file

@ -0,0 +1,222 @@
<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>
<div class="row item action">
<ActionTile :value="newAction"
:draggable="false"
@input="addAction"
v-if="!readOnly" />
</div>
</div>
</template>
<script>
import ActionsListItem from "./ActionsListItem"
import ActionTile from "./ActionTile"
import Utils from "@/Utils"
export default {
mixins: [Utils],
emits: ['input'],
components: {
ActionsListItem,
ActionTile,
},
props: {
value: {
type: Object,
default: () => ({
name: undefined,
actions: [],
}),
},
readOnly: {
type: Boolean,
default: false,
},
},
data() {
return {
actions: [],
dragItem: undefined,
dropIndex: undefined,
newAction: {},
spacerElements: {},
visibleTopSpacers: {},
visibleBottomSpacers: {},
}
},
computed: {
hasChanges() {
return JSON.stringify(this.value) !== JSON.stringify(this.actions)
},
},
methods: {
onDrop(index) {
if (this.dragItem == null || this.readOnly)
return
this.actions.splice(
index, 0, this.actions.splice(this.dragItem, 1)[0]
)
this.dragItem = null
this.dropIndex = null
},
onTileDragOver(event, index) {
if (this.dragItem == null || this.readOnly || this.dragItem === index)
return
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,
}
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
}
}
},
editAction(action, index) {
this.actions[index] = action
},
addAction(action) {
this.actions.push(action)
},
deleteAction(index) {
this.actions.splice(index, 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()
},
syncSpacers() {
this.$nextTick(() => {
this.spacerElements = Object.keys(this.actions).reduce((acc, index) => {
acc[index] = this.$refs[`dropTarget_${index}`]?.[0]
return acc
}, {})
})
},
syncValue() {
if (!this.value || !this.hasChanges)
return
this.actions = this.value
},
},
watch: {
actions: {
deep: true,
handler(value) {
this.$emit('input', value)
this.resetSpacers()
},
},
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()
}
},
value: {
immediate: true,
deep: true,
handler() {
this.syncValue()
},
},
},
mounted() {
this.syncValue()
this.resetSpacers()
},
}
</script>
<style lang="scss" scoped>
$spacer-height: 1em;
.actions {
.action {
margin: 0;
}
}
</style>

View file

@ -0,0 +1,210 @@
<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" />
<ActionTile :value="value"
:draggable="!readOnly"
:read-only="readOnly"
:with-delete="!readOnly"
@contextmenu="$emit('contextmenu', $event)"
@drag="onDragStart"
@dragend.prevent="onDragEnd"
@dragover.prevent="$emit('dragover', $event)"
@drop="onDrop"
@input="$emit('input', $event)"
@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>
</template>
<script>
import ActionTile from "@/components/Action/ActionTile"
import Droppable from "@/components/elements/Droppable"
import Utils from "@/Utils"
export default {
mixins: [Utils],
emits: [
'contextmenu',
'delete',
'drag',
'dragend',
'dragenter',
'dragenterspacer',
'dragleave',
'dragleavespacer',
'dragover',
'dragoverspacer',
'drop',
'input',
],
components: {
ActionTile,
Droppable,
},
props: {
active: {
type: Boolean,
default: false,
},
readOnly: {
type: Boolean,
default: false,
},
spacerBottom: {
type: Boolean,
default: false,
},
spacerTop: {
type: Boolean,
default: true,
},
value: {
type: Object,
required: true,
},
},
data() {
return {
dragging: false,
dragItem: undefined,
dropIndex: undefined,
newAction: {},
spacerElements: {},
visibleTopSpacers: {},
visibleBottomSpacers: {},
}
},
methods: {
onDragStart(event) {
this.dragging = true
this.$emit('drag', event)
},
onDragEnd(event) {
this.dragging = false
this.$emit('dragend', event)
},
onDrop(event) {
this.dragging = false
this.$emit('drop', event)
},
},
}
</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

@ -1,15 +1,7 @@
$section-shadow: 0 3px 3px 0 rgba(187,187,187,0.75), 0 3px 3px 0 rgba(187,187,187,0.75);
$title-bg: #eee;
$title-border: 1px solid #ddd;
$title-shadow: 0 3px 3px 0 rgba(187,187,187,0.75);
$extra-params-btn-bg: #eee;
$doc-shadow: 0 1px 3px 1px #d7d3c0, inset 0 1px 1px 0 #d7d3c9;
$output-bg: #151515;
$output-shadow: $doc-shadow;
$response-fg: white;
$error-fg: red;
$doc-bg: linear-gradient(#effbe3, #e0ecdb);
$procedure-submit-btn-bg: #ebffeb;
$extra-params-btn-bg: $title-bg;
$response-fg: $inverse-fg;
$error-fg: $error-fg-2;
$procedure-submit-btn-bg: $submit-btn-bg;
$section-title-bg: rgba(0, 0, 0, .04);
$params-desktop-width: 30em;
$params-tablet-width: 20em;

View file

@ -117,7 +117,7 @@ export default {
computed: {
specialPlugins() {
return ['execute', 'entities', 'file']
return ['execute', 'entities', 'file', 'procedures']
},
panelNames() {
@ -131,6 +131,7 @@ export default {
let panelNames = Object.keys(this.panels).sort()
panelNames = prepend(panelNames, 'file')
panelNames = prepend(panelNames, 'procedures')
panelNames = prepend(panelNames, 'execute')
panelNames = prepend(panelNames, 'entities')
return panelNames
@ -156,6 +157,8 @@ export default {
return 'Execute'
if (name === 'file')
return 'Files'
if (name === 'procedures')
return 'Procedures'
return name
},

View file

@ -0,0 +1,91 @@
<template>
<div class="procedure-dump">
<Loading v-if="loading" />
<div class="dump-container" v-else>
<CopyButton :text="yaml?.trim()" />
<pre><code v-html="highlightedYAML" /></pre>
</div>
</div>
</template>
<script>
import 'highlight.js/lib/common'
import 'highlight.js/styles/stackoverflow-dark.min.css'
import hljs from "highlight.js"
import CopyButton from "@/components/elements/CopyButton"
import Loading from "@/components/Loading";
import Utils from "@/Utils"
export default {
mixins: [Utils],
components: {
CopyButton,
Loading
},
props: {
procedure: {
type: Object,
required: true
}
},
data() {
return {
loading: false,
yaml: null,
}
},
computed: {
highlightedYAML() {
return hljs.highlight(
'# You can copy this code in a YAML configuration file\n' +
'# if you prefer to store this procedure in a file.\n' +
this.yaml || '',
{language: 'yaml'}
).value
},
},
methods: {
async refresh() {
this.loading = true
try {
this.yaml = await this.request('procedures.to_yaml', {procedure: this.procedure})
} finally {
this.loading = false
}
}
},
mounted() {
this.refresh()
},
}
</script>
<style lang="scss" scoped>
.procedure-dump {
width: 100%;
height: 100%;
background: #181818;
.dump-container {
width: 100%;
height: 100%;
position: relative;
padding: 1em;
overflow: auto;
}
pre {
margin: 0;
padding: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
}
</style>

View file

@ -1,73 +1,227 @@
<template>
<div class="procedure-editor-container"
:class="{dragging: dragItem != null}">
<div class="procedure-editor">
<form autocomplete="off" @submit.prevent="executeAction">
<div class="name-editor-container" v-if="withName">
<div class="row item">
<div class="name">
<label>
<i class="icon fas fa-pen-to-square" />
Name
</label>
</div>
<div class="procedure-editor-container">
<main>
<div class="procedure-editor">
<form class="procedure-edit-form" autocomplete="off" @submit.prevent="executeAction">
<input type="submit" style="display: none" />
<div class="value">
<input type="text" v-model="newValue.name" />
<div class="name-editor-container" v-if="withName">
<div class="row item">
<div class="name">
<label>
<i class="icon fas fa-pen-to-square" />
Name
</label>
</div>
<div class="value">
<input type="text"
v-model="newValue.name"
ref="nameInput"
:disabled="readOnly" />
</div>
</div>
</div>
<div class="icon-editor-container"
v-if="Object.keys(newValue?.meta?.icon || {}).length">
<IconEditor :entity="newValue"
@input="onIconChange"
@change="onIconChange" />
</div>
<div class="args-editor-container" v-if="showArgs">
<h3>
<i class="icon fas fa-code" />&nbsp;
Arguments
</h3>
<div class="args" ref="args">
<div class="row item" v-for="(arg, index) in newValue.args" :key="index">
<input type="text"
placeholder="Argument Name"
:value="arg"
:disabled="readOnly"
@input="onArgInput($event.target.value?.trim(), index)"
@blur="onArgEdit(arg, index)" />
</div>
<div class="row item new-arg" v-if="!readOnly">
<input type="text"
placeholder="New Argument"
ref="newArgInput"
v-model="newArg"
@blur="onNewArg" />
</div>
</div>
</div>
<div class="actions-container">
<h3 v-if="showArgs">
<i class="icon fas fa-play" />&nbsp;
Actions
</h3>
<ActionsList :value="newValue.actions"
:read-only="readOnly"
@input="newValue.actions = $event" />
</div>
<!-- Structured response container -->
<div class="response-container" v-if="response || error">
<Response :response="response" :error="error" />
</div>
</form>
</div>
<div class="args-modal-container" ref="argsModalContainer" v-if="showArgsModal">
<Modal title="Run Arguments"
:visible="true"
ref="argsModal"
@close="onRunArgsModalClose">
<form class="args" @submit.prevent="executeWithArgs">
<div class="row item" v-for="value, arg in runArgs" :key="arg">
<span class="arg-name">
<span v-if="newValue.args?.includes(arg)">
{{ arg }}
</span>
<span v-else>
<input type="text"
placeholder="New Argument"
:value="arg"
@input="onEditRunArgName($event, arg)">
</span>
<span class="mobile">
&nbsp;=&nbsp;
</span>
</span>
<span class="arg-value">
<span class="from tablet">
&nbsp;=&nbsp;
</span>
<input type="text"
placeholder="Argument Value"
:ref="`run-arg-value-${arg}`"
v-model="runArgs[arg]" />
</span>
</div>
<div class="row item new-arg">
<span class="arg-name">
<input type="text"
placeholder="New Argument"
ref="newRunArgName"
v-model="newRunArg[0]"
@blur="onNewRunArgName" />
<span class="mobile">
&nbsp;=&nbsp;
</span>
</span>
<span class="arg-value">
<span class="from tablet">
&nbsp;=&nbsp;
</span>
<input type="text"
placeholder="Argument Value"
v-model="newRunArg[1]" />
</span>
</div>
<input type="submit" style="display: none" />
<FloatingButton icon-class="fa fa-play"
title="Run Procedure"
:disabled="newValue.actions?.length === 0 || running"
@click="executeWithArgs" />
</form>
</Modal>
</div>
<div class="confirm-dialog-container">
<ConfirmDialog ref="confirmClose" @input="forceClose">
This procedure has unsaved changes. Are you sure you want to close it?
</ConfirmDialog>
</div>
<div class="confirm-dialog-container">
<ConfirmDialog ref="confirmOverwrite" @input="forceSave">
A procedure with the same name already exists. Do you want to overwrite it?
</ConfirmDialog>
</div>
<div class="spacer" />
<div class="floating-buttons">
<div class="buttons left">
<FloatingButtons direction="row">
<FloatingButton icon-class="fa fa-code"
left glow
title="Export to YAML"
@click="showYAML = true" />
<FloatingButton icon-class="fa fa-copy"
left glow
title="Duplicate Procedure"
@click="duplicate"
v-if="newValue.name?.length && newValue.actions?.length" />
</FloatingButtons>
</div>
<div class="actions">
<div class="row item" v-for="(action, index) in newValue.actions" :key="index">
<div class="drop-target-container"
:class="{active: dropIndex === index}"
v-if="dragItem != null && dragItem > index"
@dragover.prevent="dropIndex = index"
@dragenter.prevent="dropIndex = index"
@dragleave.prevent="dropIndex = undefined"
@dragend.prevent="dropIndex = undefined"
@drop="onDrop(index)">
<div class="drop-target" />
</div>
<div class="separator" v-else-if="dragItem != null && dragItem === index" />
<ActionTile :value="action"
draggable with-delete
@drag="dragItem = index"
@drop="dragItem = undefined"
@input="editAction($event, index)"
@delete="deleteAction(index)" />
<div class="drop-target-container"
:class="{active: dropIndex === index}"
@dragover.prevent="dropIndex = index"
@dragenter.prevent="dropIndex = index"
@dragleave.prevent="dropIndex = undefined"
@dragend.prevent="dropIndex = undefined"
@drop="onDrop(index)"
v-if="dragItem != null && dragItem < index">
<div class="drop-target" />
</div>
<div class="separator" v-else-if="dragItem != null && dragItem === index" />
</div>
<div class="row item">
<ActionTile :value="newAction" @input="addAction" />
</div>
<div class="buttons right">
<FloatingButtons direction="row">
<FloatingButton icon-class="fa fa-save"
right glow
title="Save Procedure"
:disabled="!hasChanges"
@click="save"
v-if="showSave" />
<FloatingButton icon-class="fa fa-play"
right glow
title="Run Procedure"
:disabled="newValue.actions?.length === 0 || running"
@click="executeAction" />
</FloatingButtons>
</div>
</div>
</main>
<!-- Structured response container -->
<Response :response="response" :error="error" />
</form>
<div class="duplicate-editor-container" v-if="duplicateValue != null">
<Modal title="Duplicate Procedure"
ref="duplicateModal"
:visible="true"
:before-close="(() => $refs.duplicateEditor?.checkCanClose())"
@close="duplicateValue = null">
<ProcedureEditor :value="duplicateValue"
:with-name="true"
:with-save="true"
:modal="() => $refs.duplicateModal"
ref="duplicateEditor"
@input="duplicateValue = null" />
</Modal>
</div>
<div class="dump-modal-container" v-if="showYAML">
<Modal title="Procedure Dump"
:visible="true"
@close="showYAML = false">
<ProcedureDump :procedure="newValue" />
</Modal>
</div>
</div>
</template>
<script>
import ActionTile from "@/components/Action/ActionTile"
import ActionsList from "@/components/Action/ActionsList"
import ConfirmDialog from "@/components/elements/ConfirmDialog";
import FloatingButton from "@/components/elements/FloatingButton";
import FloatingButtons from "@/components/elements/FloatingButtons";
import IconEditor from "@/components/panels/Entities/IconEditor";
import Modal from "@/components/Modal";
import ProcedureDump from "./ProcedureDump";
import Response from "@/components/Action/Response"
import Utils from "@/Utils"
@ -75,7 +229,13 @@ export default {
mixins: [Utils],
emits: ['input'],
components: {
ActionTile,
ActionsList,
ConfirmDialog,
FloatingButton,
FloatingButtons,
IconEditor,
Modal,
ProcedureDump,
Response,
},
@ -85,6 +245,11 @@ export default {
default: false,
},
withSave: {
type: Boolean,
default: false,
},
value: {
type: Object,
default: () => ({
@ -92,23 +257,134 @@ export default {
actions: [],
}),
},
readOnly: {
type: Boolean,
default: false,
},
modal: {
type: [Object, Function],
},
},
data() {
return {
loading: false,
running: false,
response: undefined,
confirmOverwrite: false,
duplicateValue: null,
error: undefined,
actions: [],
newValue: {...this.value},
loading: false,
newAction: {},
dragItem: undefined,
dropIndex: undefined,
newArg: null,
newRunArg: [null, null],
newValue: {},
response: undefined,
running: false,
runArgs: {},
shouldForceClose: false,
showArgsModal: false,
showYAML: false,
}
},
computed: {
floatingButtons() {
return this.$el.querySelector('.floating-btns')
},
hasChanges() {
if (!this.newValue?.name?.length)
return false
if (!this.newValue?.actions?.length)
return false
return JSON.stringify(this.value) !== JSON.stringify(this.newValue)
},
modal_() {
if (this.readOnly)
return null
return typeof this.modal === 'function' ? this.modal() : this.modal
},
shouldConfirmClose() {
return this.hasChanges && !this.readOnly && this.withSave && !this.shouldForceClose
},
showArgs() {
return !this.readOnly || this.newValue.args?.length
},
showSave() {
return this.withSave && !this.readOnly
},
},
methods: {
async save() {
if (!this.hasChanges)
return
this.loading = true
try {
const overwriteOk = await this.overwriteOk()
if (!overwriteOk)
return
const actions = this.newValue.actions.map((action) => {
const a = {...action}
if ('name' in a) {
a.action = a.name
delete a.name
}
return a
})
const args = {...this.newValue, actions}
if (this.value?.name?.length && this.value.name !== this.newValue.name) {
args.old_name = this.value.name
}
await this.request('procedures.save', args)
this.$emit('input', this.newValue)
this.notify({
text: 'Procedure saved successfully',
image: {
icon: 'check',
}
})
} finally {
this.loading = false
}
},
async forceSave() {
this.confirmOverwrite = true
await this.save()
},
async overwriteOk() {
if (this.confirmOverwrite) {
this.confirmOverwrite = false
return true
}
const procedures = await this.request('procedures.status', {publish: false})
if (
this.value.name?.length &&
this.value.name !== this.newValue.name &&
procedures[this.newValue.name]
) {
this.$refs.confirmOverwrite?.open()
return false
}
return true
},
onResponse(response) {
this.response = (
typeof response === 'string' ? response : JSON.stringify(response, null, 2)
@ -118,50 +394,248 @@ export default {
},
onError(error) {
if (error.message)
error = error.message
this.response = undefined
this.error = error
},
onDone() {
this.running = false
this.runArgs = {}
},
emitInput() {
this.$emit('input', this.newValue)
},
async executeAction() {
if (!this.newValue.actions?.length) {
this.notify({
text: 'No actions to execute',
warning: true,
image: {
icon: 'exclamation-triangle',
}
})
onDrop(index) {
if (this.dragItem === undefined)
return
}
this.newValue.actions.splice(
index, 0, this.newValue.actions.splice(this.dragItem, 1)[0]
)
this.emitInput()
},
executeAction() {
if (!this.value.actions?.length)
if (this.newValue.args?.length && !Object.keys(this.runArgs).length) {
this.showArgsModal = true
return
}
this.running = true
this.execute(this.value.actions).then(this.onResponse).catch(this.onError).finally(this.onDone)
try {
const procedure = {
actions: this.newValue.actions.map((action) => {
const a = {...action}
if ('name' in a) {
a.action = a.name
delete a.name
}
return a
}),
args: this.runArgs,
}
const response = await this.request('procedures.exec', {procedure})
this.onResponse(response)
} catch (e) {
console.error(e)
this.onError(e)
} finally {
this.onDone()
}
},
async executeWithArgs() {
this.$refs.argsModal?.close()
Object.entries(this.runArgs).forEach(([arg, value]) => {
if (!value?.length)
this.runArgs[arg] = null
try {
this.runArgs[arg] = JSON.parse(value)
} catch (e) {
// ignore
}
})
await this.executeAction()
},
duplicate() {
const name = `${this.newValue.name || ''}__copy`
this.duplicateValue = {
...this.newValue,
...{
meta: {
...(this.newValue.meta || {}),
icon: {...(this.newValue.meta?.icon || {})},
}
},
id: null,
external_id: name,
name: name,
}
},
editAction(action, index) {
this.newValue.actions[index] = action
this.emitInput()
},
addAction(action) {
this.newValue.actions.push(action)
this.emitInput()
},
deleteAction(index) {
this.newValue.actions.splice(index, 1)
this.emitInput()
},
onArgInput(arg, index) {
this.newValue.args[index] = arg
},
onArgEdit(arg, index) {
arg = arg?.trim()
const isDuplicate = !!(
this.newValue.args?.filter(
(a, i) => a === arg && i !== index
).length
)
if (!arg?.length || isDuplicate) {
this.newValue.args.splice(index, 1)
if (index === this.newValue.args.length) {
setTimeout(() => this.$refs.newArgInput?.focus(), 50)
} else {
const nextInput = this.$refs.args.children[index]?.querySelector('input[type=text]')
setTimeout(() => {
nextInput?.focus()
nextInput?.select()
}, 50)
}
}
},
onNewArg(event) {
const value = event.target.value?.trim()
if (!value?.length) {
return
}
if (!this.newValue.args) {
this.newValue.args = []
}
if (!this.newValue.args.includes(value)) {
this.newValue.args.push(value)
}
this.newArg = null
setTimeout(() => this.$refs.newArgInput?.focus(), 50)
},
onNewRunArgName() {
const arg = this.newRunArg[0]?.trim()
const value = this.newRunArg[1]?.trim()
if (!arg?.length) {
return
}
this.runArgs[arg] = value
this.newRunArg = [null, null]
this.$nextTick(() => this.$refs[`run-arg-value-${arg}`]?.[0]?.focus())
},
onEditRunArgName(event, arg) {
const newArg = event.target.value?.trim()
if (newArg === arg) {
return
}
if (newArg?.length) {
this.runArgs[newArg] = this.runArgs[arg]
}
delete this.runArgs[arg]
this.$nextTick(
() => this.$el.querySelector(`.args-modal-container .args input[type=text][value="${newArg}"]`)?.focus()
)
},
onIconChange(icon) {
this.newValue.meta.icon = icon
},
onRunArgsModalClose() {
this.showArgsModal = false
this.$nextTick(() => {
this.runArgs = {}
})
},
checkCanClose() {
if (!this.shouldConfirmClose)
return true
this.$refs.confirmClose?.open()
return false
},
forceClose() {
this.shouldForceClose = true
this.$nextTick(() => {
if (!this.modal_)
return
let modal = this.modal_
if (typeof modal === 'function') {
modal = modal()
}
try {
modal?.close()
} catch (e) {
console.warn('Failed to close modal', e)
}
this.reset()
})
},
beforeUnload(e) {
if (this.shouldConfirmClose) {
e.preventDefault()
e.returnValue = ''
}
},
addBeforeUnload() {
window.addEventListener('beforeunload', this.beforeUnload)
},
removeBeforeUnload() {
window.removeEventListener('beforeunload', this.beforeUnload)
},
reset() {
this.removeBeforeUnload()
},
syncValue() {
if (!this.value)
return
this.newValue = {
...this.value,
actions: this.value.actions?.map(a => ({...a})),
args: [...(this.value?.args || [])],
meta: {...(this.value?.meta || {})},
}
},
},
@ -169,66 +643,217 @@ export default {
value: {
immediate: true,
deep: true,
handler(value) {
this.newValue = {...value}
handler() {
this.syncValue()
},
},
newValue: {
deep: true,
handler(value) {
if (this.withSave)
return
this.$emit('input', value)
},
},
showArgsModal(value) {
if (value) {
this.runArgs = this.newValue.args?.reduce((acc, arg) => {
acc[arg] = null
return acc
}, {})
this.$nextTick(() => {
this.$el.querySelector('.args-modal-container .args input[type=text]')?.focus()
})
}
},
},
mounted() {
this.addBeforeUnload()
this.syncValue()
this.$nextTick(() => {
if (this.withName)
this.$refs.nameInput?.focus()
})
},
unmouted() {
this.reset()
},
}
</script>
<style lang="scss" scoped>
$floating-btns-height: 3em;
.procedure-editor-container {
display: flex;
flex-direction: column;
padding-top: 0.75em;
position: relative;
max-height: 75vh;
main {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: auto;
}
.procedure-editor {
width: 100%;
height: calc(100% - #{$floating-btns-height});
overflow: auto;
padding: 0 1em;
.procedure-edit-form {
padding-bottom: calc(#{$floating-btns-height} + 1em);
}
}
.actions {
.item {
margin-bottom: 0.75em;
}
h3 {
font-size: 1.2em;
}
.drop-target-container {
width: 100%;
height: 0.75em;
.name-editor-container {
.row {
display: flex;
border-bottom: 1px solid $default-shadow-color;
align-items: center;
justify-content: space-between;
padding-bottom: 0.5em;
margin-bottom: 0.5em;
&.active {
.drop-target {
height: 0.5em;
background: $tile-bg;
border: none;
opacity: 0.75;
@include until($tablet) {
flex-direction: column;
align-items: flex-start;
}
@include from($tablet) {
flex-direction: row;
}
.name {
margin-right: 0.5em;
}
.value {
flex: 1;
@include until($tablet) {
width: 100%;
}
input {
width: 100%;
}
}
}
}
.drop-target {
width: 100%;
height: 2px;
border: 1px solid $default-fg-2;
border-radius: 0.25em;
padding: 0 0.5em;
.icon-editor-container {
border-bottom: 1px solid $default-shadow-color;
margin-bottom: 0.5em;
}
.spacer {
width: 100%;
height: calc(#{$floating-btns-height} + 1em);
flex-grow: 1;
}
.args-editor-container {
.args {
margin-bottom: 1em;
.item {
padding-bottom: 0.5em;
}
}
}
&.dragging {
padding-top: 0;
:deep(.args-modal-container) {
.modal-container .modal {
width: 50em;
.actions {
.item {
margin-bottom: 0;
.body {
padding: 1em;
}
}
.separator {
height: 0.75em;
.args {
position: relative;
padding-bottom: calc(#{$floating-btn-size} + 2em);
.row {
display: flex;
align-items: center;
margin-bottom: 0.5em;
@include until($tablet) {
flex-direction: column;
align-items: flex-start;
border-bottom: 1px solid $default-shadow-color;
padding-bottom: 0.5em;
}
.arg-name {
@extend .col-s-12;
@extend .col-m-5;
font-weight: bold;
input {
width: 100%;
@include until($tablet) {
width: calc(100% - 2em);
}
}
}
.arg-value {
@extend .col-s-12;
@extend .col-m-7;
flex: 1;
input[type=text] {
width: 95%;
}
}
}
}
}
:deep(.floating-buttons) {
width: 100%;
height: $floating-btns-height;
position: absolute;
bottom: 0;
left: 0;
background: $default-bg-5;
box-shadow: $border-shadow-top;
display: flex;
justify-content: space-between;
.buttons {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 1em;
position: relative;
}
}
:deep(.dump-modal-container) {
.body {
max-width: 47em;
}
}
}
</style>

View file

@ -0,0 +1,126 @@
<template>
<div class="procedure-editor-modal-container">
<Modal :title="title || value.name"
:visible="visible"
:uppercase="!value.name"
:before-close="(() => $refs.editor?.checkCanClose())"
ref="editorModal"
@close="$emit('close')">
<ProcedureEditor :procedure="value"
:read-only="isReadOnly"
:with-name="!isReadOnly"
:with-save="!isReadOnly"
:value="value"
:modal="isReadOnly ? null : (() => $refs.editorModal)"
ref="editor"
@input="$emit('input', $event)" />
</Modal>
</div>
</template>
<script>
import Modal from "@/components/Modal";
import ProcedureEditor from "@/components/Procedure/ProcedureEditor"
export default {
mixins: [Modal, ProcedureEditor],
emits: ['close', 'input'],
components: {
Modal,
ProcedureEditor,
},
data: function() {
return {
args: {},
defaultIconClass: 'fas fa-cogs',
extraArgs: {},
collapsed_: true,
infoCollapsed: false,
lastError: null,
lastResponse: null,
newArgName: '',
newArgValue: '',
runCollapsed: false,
showConfirmDelete: false,
showFileEditor: false,
showProcedureEditor: false,
}
},
computed: {
isReadOnly() {
return this.value.procedure_type && this.value.procedure_type !== 'db'
},
},
methods: {
// Proxy and delegate the Modal's methods
open() {
this.$refs.editorModal.open()
},
close() {
this.$refs.editorModal.close()
},
show() {
this.$refs.editorModal.show()
},
hide() {
this.$refs.editorModal.hide()
},
toggle() {
this.$refs.editorModal.toggle()
},
},
watch: {
collapsed: {
immediate: true,
handler(value) {
this.collapsed_ = value
},
},
selected: {
immediate: true,
handler(value) {
this.collapsed_ = value
},
},
showProcedureEditor(value) {
if (!value) {
this.$refs.editor?.reset()
}
},
},
mounted() {
this.collapsed_ = !this.selected
},
}
</script>
<style lang="scss" scoped>
.procedure-editor-modal-container {
cursor: default;
:deep(.modal-container) {
.body {
padding: 0;
@include until($tablet) {
width: calc(100vw - 2em);
}
@include from($tablet) {
width: 50em;
}
}
}
}
</style>

View file

@ -535,6 +535,7 @@ export default {
max-height: calc(100vh - #{$header-height} - #{$main-margin});
}
width: 100%;
display: flex;
flex-direction: column;
flex-grow: 1;

View file

@ -0,0 +1,707 @@
<template>
<div class="entity procedure-container">
<div class="head" :class="{collapsed: collapsed_}" @click="onHeaderClick">
<div class="icon">
<EntityIcon :entity="value" :icon="icon" :loading="loading" />
</div>
<div class="label">
<div class="name" v-text="value.name" />
</div>
<div class="value-and-toggler">
<div class="value">
<button class="btn btn-primary head-run-btn"
title="Run Procedure"
:disabled="loading"
@click.stop="run"
v-if="collapsed_">
<i class="fas fa-play" />
</button>
</div>
<div class="collapse-toggler" @click.stop="collapsed_ = !collapsed_">
<i class="fas" :class="{'fa-chevron-down': collapsed_, 'fa-chevron-up': !collapsed_}" />
</div>
</div>
</div>
<div class="body" v-if="!collapsed_" @click.stop>
<section class="run">
<header :class="{collapsed: runCollapsed}" @click="runCollapsed = !runCollapsed">
<span class="col-10">
<i class="fas fa-play" />&nbsp; Run
</span>
<span class="col-2 buttons">
<button type="button"
class="btn btn-primary"
:disabled="loading"
:title="runCollapsed ? 'Expand' : 'Collapse'">
<i class="fas" :class="{'fa-chevron-down': runCollapsed, 'fa-chevron-up': !runCollapsed}" />
</button>
</span>
</header>
<div class="run-body" v-if="!runCollapsed">
<form @submit.prevent="run">
<div class="args" v-if="value.args?.length">
Arguments
<div class="row arg" v-for="(arg, index) in value.args || []" :key="index">
<input type="text"
class="argname"
:value="arg"
:disabled="true" />&nbsp;=
<input type="text"
class="argvalue"
placeholder="Value"
:disabled="loading"
@input="updateArg(arg, $event)" />
</div>
</div>
<div class="extra args">
Extra Arguments
<div class="row arg" v-for="(value, name) in extraArgs" :key="name">
<input type="text"
class="argname"
placeholder="Name"
:value="name"
:disabled="loading"
@blur="updateExtraArgName(name, $event)" />&nbsp;=
<input type="text"
placeholder="Value"
class="argvalue"
:value="value"
:disabled="loading"
@input="updateExtraArgValue(arg, $event)" />
</div>
<div class="row add-arg">
<input type="text"
class="argname"
placeholder="Name"
v-model="newArgName"
:disabled="loading"
ref="newArgName"
@blur="addExtraArg" />&nbsp;=
<input type="text"
class="argvalue"
placeholder="Value"
v-model="newArgValue"
:disabled="loading"
@blur="addExtraArg" />
</div>
</div>
<div class="row run-container">
<button type="submit"
class="btn btn-primary"
:disabled="loading"
title="Run Procedure">
<i class="fas fa-play" />
</button>
</div>
</form>
<div class="response-container" v-if="lastResponse || lastError">
<Response :response="lastResponse" :error="lastError" />
</div>
</div>
</section>
<section class="info">
<header :class="{collapsed: infoCollapsed}" @click="infoCollapsed = !infoCollapsed">
<span class="col-10">
<i class="fas fa-info-circle" />&nbsp; Info
</span>
<span class="col-2 buttons">
<button type="button"
class="btn btn-primary"
:disabled="loading"
:title="infoCollapsed ? 'Expand' : 'Collapse'">
<i class="fas" :class="{'fa-chevron-down': infoCollapsed, 'fa-chevron-up': !infoCollapsed}" />
</button>
</span>
</header>
<div class="info-body" v-if="!infoCollapsed">
<div class="item">
<div class="label">Type</div>
<div class="value">
<i :class="procedureTypeIconClass" />&nbsp;
{{ value.procedure_type }}
</div>
</div>
<div class="item actions" v-if="value?.actions?.length">
<div class="label">Actions</div>
<div class="value">
<div class="item">
<button type="button"
class="btn btn-primary"
title="Edit Actions"
:disabled="loading"
@click="showProcedureEditor = !showProcedureEditor">
<span v-if="isReadOnly && !showProcedureEditor">
<i class="fas fa-eye" />&nbsp; View
</span>
<span v-else-if="!isReadOnly && !showProcedureEditor">
<i class="fas fa-edit" />&nbsp; Edit
</span>
<span v-else>
<i class="fas fa-times" />&nbsp; Close
</span>
</button>
</div>
<div class="item delete" v-if="!isReadOnly">
<button type="button"
title="Delete Procedure"
:disabled="loading"
@click="showConfirmDelete = true">
<i class="fas fa-trash" />&nbsp; Delete
</button>
</div>
</div>
</div>
<div class="item" v-if="value.source">
<div class="label">Path</div>
<div class="value">
<a :href="$route.path" @click.prevent="showFileEditor = true">
{{ displayPath }}
</a>
</div>
</div>
</div>
</section>
</div>
<div class="file-editor-container" v-if="showFileEditor && value.source">
<FileEditor :file="value.source"
:line="value.line"
:visible="true"
:uppercase="false"
@close="showFileEditor = false" />
</div>
<ProcedureEditor :procedure="value"
:read-only="isReadOnly"
:with-name="!isReadOnly"
:with-save="!isReadOnly"
:value="value"
:visible="showProcedureEditor"
@input="onUpdate"
@close="showProcedureEditor = false"
ref="editor"
v-if="value?.actions?.length && showProcedureEditor" />
<div class="confirm-delete-container">
<ConfirmDialog :visible="true"
@input="remove"
@close="showConfirmDelete = false"
v-if="showConfirmDelete">
Are you sure you want to delete the procedure <b>{{ value.name }}</b>?
</ConfirmDialog>
</div>
</div>
</template>
<script>
import ConfirmDialog from "@/components/elements/ConfirmDialog";
import EntityMixin from "./EntityMixin"
import EntityIcon from "./EntityIcon"
import FileEditor from "@/components/File/EditorModal";
import ProcedureEditor from "@/components/Procedure/ProcedureEditorModal"
import Response from "@/components/Action/Response"
export default {
components: {
ConfirmDialog,
EntityIcon,
FileEditor,
ProcedureEditor,
Response,
},
mixins: [EntityMixin],
emits: ['delete', 'input', 'loading'],
props: {
collapseOnHeaderClick: {
type: Boolean,
default: false,
},
selected: {
type: Boolean,
default: false,
},
},
data: function() {
return {
args: {},
defaultIconClass: 'fas fa-cogs',
extraArgs: {},
collapsed_: true,
infoCollapsed: false,
lastError: null,
lastResponse: null,
newArgName: '',
newArgValue: '',
runCollapsed: false,
showConfirmDelete: false,
showFileEditor: false,
showProcedureEditor: false,
}
},
computed: {
icon() {
const defaultClass = this.defaultIconClass
const currentClass = this.value.meta?.icon?.['class']
let iconClass = currentClass
if (!currentClass || currentClass === defaultClass) {
iconClass = this.procedureTypeIconClass || defaultClass
}
return {
...(this.value.meta?.icon || {}),
class: iconClass,
}
},
isReadOnly() {
return this.value.procedure_type !== 'db'
},
allArgs() {
return Object.entries({...this.args, ...this.extraArgs})
.map(([key, value]) => [key?.trim(), value])
.filter(
([key, value]) => (
key?.length
&& value != null
&& (
typeof value !== 'string'
|| value?.trim()?.length > 0
)
)
).reduce((acc, [key, value]) => {
acc[key] = value
return acc
}, {})
},
displayPath() {
let src = this.value.source
if (!src?.length) {
return null
}
const configDir = this.$root.configDir
if (configDir) {
src = src.replace(new RegExp(`^${configDir}/`), '')
}
const line = parseInt(this.value.line)
if (!isNaN(line)) {
src += `:${line}`
}
return src
},
procedureTypeIconClass() {
if (this.value.procedure_type === 'python')
return 'fab fa-python'
if (this.value.procedure_type === 'config')
return 'fas fa-rectangle-list'
return this.defaultIconClass
},
},
methods: {
async run() {
this.$emit('loading', true)
try {
this.lastResponse = await this.request(`procedure.${this.value.name}`, this.allArgs)
this.lastError = null
this.notify({
text: 'Procedure executed successfully',
image: {
icon: 'play',
}
})
} catch (e) {
this.lastResponse = null
this.lastError = e
this.notify({
text: 'Failed to execute procedure',
error: true,
image: {
icon: 'exclamation-triangle',
}
})
} finally {
this.$emit('loading', false)
}
},
async remove() {
this.$emit('loading', true)
try {
await this.request('procedures.delete', {name: this.value.name})
this.$emit('loading', false)
this.$emit('delete')
this.notify({
text: 'Procedure deleted successfully',
image: {
icon: 'trash',
}
})
} finally {
this.$emit('loading', false)
}
},
onHeaderClick(event) {
if (this.collapseOnHeaderClick) {
event.stopPropagation()
this.collapsed_ = !this.collapsed_
}
},
onUpdate(value) {
if (!this.isReadOnly) {
this.$emit('input', value)
this.$nextTick(() => this.$refs.editor?.close())
}
},
updateArg(arg, event) {
let value = event.target.value
if (!value?.length) {
delete this.args[arg]
}
try {
value = JSON.parse(value)
} catch (e) {
// Do nothing
}
this.args[arg] = value
},
updateExtraArgName(oldName, event) {
let newName = event.target.value?.trim()
if (newName === oldName) {
return
}
if (newName?.length) {
if (oldName) {
this.extraArgs[newName] = this.extraArgs[oldName]
} else {
this.extraArgs[newName] = ''
}
} else {
this.focusNewArgName()
}
if (oldName) {
delete this.extraArgs[oldName]
}
},
updateExtraArgValue(arg, event) {
let value = event.target.value
if (!value?.length) {
delete this.extraArgs[arg]
return
}
this.extraArgs[arg] = this.deserializeValue(value)
},
addExtraArg() {
let name = this.newArgName?.trim()
let value = this.newArgValue
if (!name?.length || !value?.length) {
return
}
this.extraArgs[name] = this.deserializeValue(value)
this.newArgName = ''
this.newArgValue = ''
this.focusNewArgName()
},
deserializeValue(value) {
try {
return JSON.parse(value)
} catch (e) {
return value
}
},
focusNewArgName() {
this.$nextTick(() => this.$refs.newArgName.focus())
},
},
watch: {
collapsed: {
immediate: true,
handler(value) {
this.collapsed_ = value
},
},
selected: {
immediate: true,
handler(value) {
this.collapsed_ = value
},
},
showProcedureEditor(value) {
if (!value) {
this.$refs.editor?.reset()
}
},
},
mounted() {
this.collapsed_ = !this.selected
},
}
</script>
<style lang="scss" scoped>
@import "common";
$icon-width: 2em;
.procedure-container {
.body {
padding-bottom: 0;
cursor: default;
}
section {
header {
background: $tab-bg;
display: flex;
align-items: center;
font-size: 1.1em;
font-weight: bold;
margin: 1em -0.5em 0.5em -0.5em;
padding: 0.25em 1em 0.25em 0.5em;
border-top: 1px solid $default-shadow-color;
box-shadow: $border-shadow-bottom;
cursor: pointer;
&:hover {
background: $header-bg;
}
&.collapsed {
margin-bottom: -0.1em;
}
.buttons {
display: flex;
justify-content: flex-end;
}
button.btn {
background: none;
border: none;
color: initial;
&:hover {
color: $default-hover-fg;
}
}
}
&:first-of-type header {
margin-top: -0.5em;
&.collapsed {
margin-bottom: -1em;
}
}
}
.head {
&:not(.collapsed) {
background: $selected-bg;
border-bottom: 1px solid $default-shadow-color;
}
.icon, .collapse-toggler {
width: $icon-width;
}
.label, .value-and-toggler {
min-width: calc(((100% - (2 * $icon-width)) / 2) - 1em);
max-width: calc(((100% - (2 * $icon-width)) / 2) - 1em);
}
.label {
margin-left: 1em;
}
.value-and-toggler {
text-align: right;
}
}
form {
width: 100%;
.row {
width: 100%;
input[type=text] {
width: 100%;
}
}
.args {
.arg {
margin-bottom: 0.25em;
padding-bottom: 0.25em;
border-bottom: 1px solid $default-shadow-color;
}
.argname {
font-weight: bold;
}
@include until($tablet) {
.argname {
width: calc(100% - 2em) !important;
}
.argvalue {
width: 100% !important;
}
}
@include from($tablet) {
display: flex;
flex-wrap: wrap;
.argname {
width: calc(35% - 2em) !important;
}
.argvalue {
width: 65% !important;
}
}
}
.run-container {
display: flex;
justify-content: center;
font-size: 1.5em;
margin-top: 0.5em;
button {
padding: 0 1em;
text-align: center;
border-radius: 0.5em;
}
}
}
.info-body {
.item {
display: flex;
padding: 0.5em 0.25em;
@include until($tablet) {
border-bottom: 1px solid $default-shadow-color;
}
&.delete {
justify-content: center;
button {
width: 100%;
color: $error-fg;
padding: 0;
&:hover {
color: $default-hover-fg;
}
}
}
}
.label {
font-weight: bold;
@include until($tablet) {
width: 100%;
}
@include from($tablet) {
width: 33.3333%;
}
}
.value {
@include until($tablet) {
width: 100%;
}
@include from($tablet) {
width: 66.6667%;
}
a {
width: 100%;
}
}
.actions {
@include until($tablet) {
flex-direction: column;
}
.item {
border-bottom: none;
}
button {
width: 100%;
height: 2.5em;
padding: 0;
border-radius: 1em;
&:hover {
color: $default-hover-fg;
}
}
}
}
.head-run-btn {
background: none;
border: none;
font-size: 1.25em;
&:hover {
background: none;
color: $default-hover-fg-2;
}
}
}
</style>

View file

@ -0,0 +1,310 @@
<template>
<div class="procedures-container">
<header>
<input type="search"
class="filter"
title="Filter procedures"
placeholder="🔎"
v-model="filter" />
</header>
<main>
<Loading v-if="loading" />
<NoItems v-else-if="!Object.keys(procedures || {}).length">
No Procedures Configured
</NoItems>
<div class="procedures-list" v-else>
<div class="procedures items">
<div class="item" v-for="procedure in displayedProcedures" :key="procedure.name">
<Procedure :value="procedure"
:selected="selectedProcedure === procedure.name"
:collapseOnHeaderClick="true"
@click="toggleProcedure(procedure)"
@input="updateProcedure(procedure)"
@delete="() => delete procedures[procedure.name]" />
</div>
</div>
<ProcedureEditor :value="newProcedure"
title="Add Procedure"
:with-name="true"
:with-save="true"
:read-only="false"
:visible="showNewProcedureEditor"
@input="updateProcedure(newProcedure)"
@close="resetNewProcedure"
v-if="showNewProcedureEditor" />
</div>
<FloatingButton icon-class="fa fa-plus"
text="Add Procedure"
@click="showNewProcedureEditor = true" />
</main>
</div>
</template>
<script>
import Loading from "@/components/Loading";
import FloatingButton from "@/components/elements/FloatingButton";
import NoItems from "@/components/elements/NoItems";
import Procedure from "@/components/panels/Entities/Procedure"
import ProcedureEditor from "@/components/Procedure/ProcedureEditorModal"
import Utils from "@/Utils";
export default {
components: {
FloatingButton,
Loading,
NoItems,
Procedure,
ProcedureEditor,
},
mixins: [Utils],
props: {
pluginName: {
type: String,
},
config: {
type: Object,
default: () => {},
},
},
data() {
return {
filter: '',
loading: false,
newProcedure: null,
newProcedureTemplate: {
name: '',
actions: [],
meta: {
icon: {
class: 'fas fa-cogs',
url: null,
color: null,
},
},
},
procedures: {},
selectedProcedure: null,
showConfirmClose: false,
showNewProcedureEditor: false,
}
},
computed: {
displayedProcedures() {
return Object.values(this.procedures)
.filter(procedure => procedure.name.toLowerCase().includes(this.filter.toLowerCase()))
},
},
methods: {
mergeArgs(oldObj, newObj) {
return {
...Object.fromEntries(
Object.entries(oldObj || {}).map(([key, value]) => {
const newValue = newObj?.[key]
if (newValue != null) {
if (typeof value === 'object' && !Array.isArray(value))
return [key, this.mergeArgs(value, newValue)]
return [key, newValue]
}
return [key, value]
})
),
...Object.fromEntries(
Object.entries(newObj || {}).filter(([key]) => oldObj?.[key] == null)
),
}
},
updateProcedure(procedure) {
if (!procedure?.name?.length)
return
const curProcedure = this.procedures[procedure.name]
this.procedures[procedure.name] = {
...this.mergeArgs(curProcedure, procedure),
name: procedure?.meta?.name_override || procedure.name,
}
this.showNewProcedureEditor = false
},
async refresh() {
const args = this.getUrlArgs()
if (args.filter)
this.filter = args.filter
this.loading = true
try {
this.procedures = await this.request('procedures.status')
} finally {
this.loading = false
}
},
onEntityUpdate(msg) {
const entity = msg?.entity
if (entity?.plugin !== this.pluginName || !entity?.name?.length)
return
this.updateProcedure(entity)
},
onEntityDelete(msg) {
const entity = msg?.entity
if (entity?.plugin !== this.pluginName)
return
if (this.selectedProcedure === entity.name)
this.selectedProcedure = null
if (this.procedures[entity.name])
delete this.procedures[entity.name]
},
resetNewProcedure() {
this.showNewProcedureEditor = false
this.newProcedure = JSON.parse(JSON.stringify(this.newProcedureTemplate))
},
toggleProcedure(procedure) {
this.selectedProcedure =
this.selectedProcedure === procedure.name ?
null : procedure.name
},
},
watch: {
filter() {
if (!this.filter?.length)
this.setUrlArgs({ filter: null })
else
this.setUrlArgs({ filter: this.filter })
},
showNewProcedureEditor(val) {
if (!val)
this.resetNewProcedure()
},
},
async mounted() {
this.resetNewProcedure()
await this.refresh()
this.subscribe(
this.onEntityUpdate,
'on-procedure-entity-update',
'platypush.message.event.entities.EntityUpdateEvent'
)
this.subscribe(
this.onEntityDelete,
'on-procedure-entity-delete',
'platypush.message.event.entities.EntityDeleteEvent'
)
},
unmounted() {
this.unsubscribe('on-procedure-entity-update')
this.unsubscribe('on-procedure-entity-delete')
this.setUrlArgs({ filter: null })
},
}
</script>
<style lang="scss" scoped>
@import "src/style/items";
$header-height: 3em;
.procedures-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
flex-grow: 1;
header {
width: 100%;
height: $header-height;
background: $tab-bg;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid $default-shadow-color;
input.filter {
max-width: 600px;
@include until($tablet) {
width: calc(100% - 2em);
}
@include from($tablet) {
width: 600px;
}
}
}
main {
width: 100%;
height: calc(100% - #{$header-height});
overflow: auto;
margin-bottom: 2em;
}
.procedures-list {
width: 100%;
height: 100%;
display: flex;
background: $default-bg-6;
flex-grow: 1;
justify-content: center;
}
.procedures {
@include until($tablet) {
width: 100%;
}
@include from($tablet) {
width: calc(100% - 2em);
margin-top: 1em;
border-radius: 1em;
}
max-width: 800px;
background: $default-bg-2;
display: flex;
flex-direction: column;
margin-bottom: auto;
box-shadow: $border-shadow-bottom-right;
.item {
padding: 0;
&:first-child {
border-top-left-radius: 1em;
border-top-right-radius: 1em;
}
&:last-child {
border-bottom-left-radius: 1em;
border-bottom-right-radius: 1em;
}
}
}
}
</style>