Model editor: Add drag & drop (#2970)

Closes #2728.
Closes #969.

Allow drag and drop in the model view:

1. Drag and drop inside the semantic model
2. Move items into the semantic model
3. Move between non-semantic groups (and duplicate)
4. Ask for creation of location, equipment or point if item does not
have a semantic class yet when moving into the semantic model

---------

Signed-off-by: Mark Herwege <mark.herwege@telenet.be>
pull/3195/head
Mark Herwege 2025-05-21 11:32:19 +02:00 committed by GitHub
parent d17072ae31
commit 6bdfe408e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 806 additions and 75 deletions

View File

@ -8,7 +8,7 @@
:subtitle="noType ? '' : getItemTypeAndMetaLabel(item)"
:after="state"
v-on="$listeners">
<oh-icon v-if="!noIcon && item.category" slot="media" :icon="item.category" :state="(noState || item.type === 'Image') ? null : (context.store[item.name].state || item.state)" height="32" width="32" />
<oh-icon v-if="!noIcon && item.category" slot="media" :icon="item.category" :state="(noState || item.type === 'Image') ? null : (context?.store[item.name]?.state || item.state)" height="32" width="32" />
<span v-else-if="!noIcon" slot="media" class="item-initial">{{ item.name[0] }}</span>
<f7-icon v-if="!item.editable" slot="after-title" f7="lock_fill" size="1rem" color="gray" />
<slot name="footer" #footer />

View File

@ -66,7 +66,7 @@
<f7-block strong class="no-padding" v-if="ready">
<model-treeview class="model-picker-treeview" :root-nodes="rootNodes"
:includeItemName="includeItemName" :includeItemTags="includeItemTags"
:selected-item="selectedItem" @selected="selectItem" @checked="checkItem" />
:selected="selectedItem" @selected="selectItem" @checked="checkItem" />
</f7-block>
<f7-block v-else-if="!ready" class="text-align-center">
<f7-preloader />
@ -178,7 +178,7 @@ export default {
this.loadModel().then(() => {
this.$nextTick(() => {
this.initSearchbar = true
this.applyExpandedOption()
this.restoreExpanded()
})
})
},

View File

@ -1,10 +1,15 @@
<template>
<f7-treeview class="model-treeview">
<model-treeview-item v-for="node in rootNodes"
:key="node.item.name" :model="node"
:includeItemName="includeItemName" :includeItemTags="includeItemTags"
@selected="nodeSelected" :selected="selectedItem"
@checked="(item, check) => $emit('checked', item, check)" />
<draggable :disabled="!canDragDrop" :list="children" group="model-treeview" animation="150" fallbackOnBody="true" fallbackThreshold="5"
scrollSensitivity="200" delay="400" delayOnTouchOnly="true" invertSwap="true"
@start="onDragStart" @change="onDragChange" @end="onDragEnd" :move="onDragMove">
<model-treeview-item v-for="(node, index) in children"
:key="node.item.name + '_' + index" :model="node" :parentNode="model"
:includeItemName="includeItemName" :includeItemTags="includeItemTags" :canDragDrop="canDragDrop" :moveState="moveState"
@selected="nodeSelected" :selected="selected"
@checked="(item, check) => $emit('checked', item, check)"
@reload="$emit('reload')" />
</draggable>
</f7-treeview>
</template>
@ -22,12 +27,35 @@
<script>
import ModelTreeviewItem from '@/components/model/treeview-item.vue'
import ModelDragDropMixin from '@/pages/settings/model/model-dragdrop-mixin'
import Draggable from 'vuedraggable'
export default {
props: ['rootNodes', 'selectedItem', 'includeItemName', 'includeItemTags'],
mixins: [ModelDragDropMixin],
props: ['rootNodes', 'selected', 'includeItemName', 'includeItemTags', 'canDragDrop'],
emits: ['reload'],
components: {
Draggable,
ModelTreeviewItem
},
computed: {
model: {
get: function () {
return {
class: '',
children: {
locations: this.rootNodes.filter(n => n.class.startsWith('Location')),
equipment: this.rootNodes.filter(n => n.class.startsWith('Equipment')),
points: this.rootNodes.filter(n => n.class.startsWith('Point')),
groups: this.rootNodes.filter(n => !n.class && n.item.type === 'Group'),
items: this.rootNodes.filter(n => !n.class && n.item.type !== 'Group')
},
opened: true,
item: null
}
}
}
},
methods: {
nodeSelected (node) {
this.$emit('selected', node)

View File

@ -3,17 +3,23 @@
:icon-ios="icon('ios')" :icon-aurora="icon('aurora')" :icon-md="icon('md')"
:textColor="iconColor" :color="(model.item.created !== false) ? 'blue' :'orange'"
:selected="selected && selected.item.name === model.item.name"
:opened="model.opened"
@click="select">
<model-treeview-item v-for="node in [model.children.locations,
model.children.equipment, model.children.points,
model.children.groups, model.children.items].flat()"
:key="node.item.name"
:model="node"
@selected="(event) => $emit('selected', event)"
:selected="selected"
:includeItemName="includeItemName" :includeItemTags="includeItemTags"
@checked="(item, check) => $emit('checked', item, check)" />
:opened="model.opened" :toggle="canHaveChildren"
@treeview:open="model.opened = true" @treeview:close="model.opened = false" @click="select">
<draggable :disabled="!canDragDrop && !dropAllowed(model)" :list="children" group="model-treeview" animation="150" fallbackOnBody="true" fallbackThreshold="5"
scrollSensitivity="200" delay="400" delayOnTouchOnly="true" invertSwap="true"
@start="onDragStart" @change="onDragChange" @end="onDragEnd" :move="onDragMove">
<model-treeview-item v-for="(node, index) in children"
:key="node.item.name + '_' + index"
:model="node"
:parentNode="model"
@selected="(event) => $emit('selected', event)"
:selected="selected"
:includeItemName="includeItemName" :includeItemTags="includeItemTags"
:canDragDrop="canDragDrop"
:moveState="moveState"
@checked="(item, check) => $emit('checked', item, check)"
@reload="$emit('reload')" />
</draggable>
<div slot="label" class="semantic-class">
{{ className() }}
<template v-if="includeItemTags">
@ -34,31 +40,25 @@
<script>
import ItemMixin from '@/components/item/item-mixin'
import ModelDragDropMixin from '@/pages/settings/model/model-dragdrop-mixin'
import Draggable from 'vuedraggable'
export default {
name: 'model-treeview-item',
mixins: [ItemMixin],
props: ['model', 'selected', 'includeItemName', 'includeItemTags'],
mixins: [ItemMixin, ModelDragDropMixin],
props: ['model', 'parentNode', 'selected', 'includeItemName', 'includeItemTags', 'canDragDrop'],
emits: ['reload'],
components: {
Draggable,
ModelTreeviewItem: 'model-treeview-item'
},
computed: {
children () {
return [this.model.children.locations,
this.model.children.equipment, this.model.children.points,
this.model.children.groups, this.model.children.items].flat()
},
iconColor () {
return (this.model.item.metadata && this.model.item.metadata.semantics) ? '' : 'gray'
}
},
methods: {
icon (theme) {
if (this.model.class.indexOf('Location') === 0) {
if (this.model.class?.indexOf('Location') === 0) {
return (theme === 'md') ? 'material:place' : 'f7:placemark'
} else if (this.model.class.indexOf('Equipment') === 0) {
} else if (this.model.class?.indexOf('Equipment') === 0) {
return (theme === 'md') ? 'material:payments' : 'f7:cube_box'
} else if (this.model.class.indexOf('Point') === 0) {
} else if (this.model.class?.indexOf('Point') === 0) {
return (theme === 'md') ? 'material:flash_on' : 'f7:bolt_fill'
} else if (this.model.item.type === 'Group') {
return (theme === 'md') ? 'material:folder' : 'f7:folder'
@ -76,6 +76,7 @@ export default {
},
select (event) {
let self = this
if (self.dragDropActive) return // avoid opening item properties during drag drop
let $ = self.$$
if ($(event.target).is('.treeview-toggle')) return
if ($(event.target).is('.checkbox') || $(event.target).is('.icon-checkbox') || $(event.target).is('input')) return

View File

@ -43,7 +43,7 @@
</f7-block-footer>
<f7-block class="semantic-tree">
<model-treeview class="model-picker-treeview" :rootNodes="rootLocations"
:selected-item="selectedItem" @selected="selectItem" @checked="checkItem" />
:selected="selectedItem" @selected="selectItem" @checked="checkItem" />
</f7-block>
</f7-block>
</f7-col>

View File

@ -0,0 +1,683 @@
import cloneDeep from 'lodash/cloneDeep'
import * as types from '@/assets/item-types.js'
import ItemMixin from '@/components/item/item-mixin'
import TagMixin from '@/components/tags/tag-mixin'
import fastDeepEqual from 'fast-deep-equal/es6'
export default {
mixins: [ItemMixin, TagMixin],
props: {
moveState: {
type: Object,
default: () => ({
moving: false,
canAdd: false,
canRemove: false,
dragEnd: true,
dragFinished: false,
saving: false,
cancelled: false,
moveConfirmed: false,
node: null,
newParent: null,
oldParent: null,
oldIndex: null,
dragStartTimestamp: null,
nodesToUpdate: [],
moveDelayedOpen: null,
moveTarget: null
})
}
},
watch: {
moveState: {
handler: function () {
if (this.canSave) {
this.saveUpdate()
} else if (this.canRemove) {
this.validateRemove()
} else if (this.canAdd) {
this.validateAdd()
}
},
deep: true
}
},
computed: {
children: {
get: function () {
if (!this.model.children) return []
return [this.model.children.locations, this.model.children.equipment, this.model.children.points, this.model.children.groups, this.model.children.items].flat()
},
set: function (nodeList) {
const newChildren = {}
newChildren.locations = nodeList.filter(n => n.item.metadata?.semantics?.value?.startsWith('Location'))
newChildren.equipment = nodeList.filter(n => n.item.metadata?.semantics?.value?.startsWith('Equipment'))
newChildren.points = nodeList.filter(n => n.item.metadata?.semantics?.value?.startsWith('Point'))
newChildren.groups = nodeList.filter(n => !n.item.metadata?.semantics && n.item.type === 'Group')
newChildren.items = nodeList.filter(n => !n.item.metadata?.semantics && n.item.type !== 'Group')
this.$set(this.model, 'children', newChildren)
}
},
iconColor () {
return (this.model.item.metadata && this.model.item.metadata.semantics) ? '' : 'gray'
},
dragDropActive () {
return !this.moveState.dragEnd
},
canAdd () {
return !this.moveState.cancelled && this.moveState.newParent && this.moveState.dragEnd &&
!this.moveState.dragFinished && this.moveState.canAdd && !this.moveState.adding
},
canRemove () {
return !this.moveState.cancelled && this.moveState.newParent && this.moveState.oldParent &&
this.moveState.dragEnd && !this.moveState.dragFinished && !this.moveState.canAdd && this.moveState.canRemove && !this.moveState.removing
},
canSave () {
return !this.moveState.cancelled && this.moveState.dragEnd && this.moveState.dragFinished && !this.moveState.canAdd && !this.moveState.canRemove && !this.moveState.saving
},
canHaveChildren () {
return ((this.model.item.type === 'Group') && (this.children.length > 0 || this.moveState.moving)) === true
}
},
methods: {
onDragStart (event) {
this.moveState.node = this.children[event.oldIndex]
if (!this.moveState.node.item.editable) return
console.debug('Drag start - event:', event)
window.addEventListener('keydown', this.keyDownHandler)
this.moveState.moving = true
this.moveState.canAdd = false
this.moveState.canRemove = false
this.moveState.dragEnd = false
this.moveState.dragFinished = false
this.moveState.saving = false
this.moveState.cancelled = false
this.moveState.moveConfirmed = false
this.moveState.dragStartTimestamp = Date.now()
this.moveState.nodesToUpdate.splice(0)
this.moveState.moveDelayedOpen = null
this.moveState.moveTarget = null
console.debug('Drag start - moveState:', cloneDeep(this.moveState))
console.debug('runtime onDragStart', Date.now() - this.moveState.dragStartTimestamp)
},
onDragChange (event) {
const dropAllowed = this.moveState.moveTarget ? this.dropAllowed(this.moveState.moveTarget) : true
if (this.moveState.cancelled || !this.moveState.node.item.editable || !dropAllowed) {
return
}
console.debug('runtime onDragChange', Date.now() - this.moveState.dragStartTimestamp)
console.debug('Drag change - event:', event)
if (this.moveState.cancelled) {
console.debug('Drag change - cancelled')
return
}
if (event.added) {
this.moveState.newParent = this.model
this.moveState.canAdd = true
}
if (event.removed) {
this.moveState.oldParent = this.model
this.moveState.oldIndex = event.removed.oldIndex
this.moveState.canRemove = true
}
console.debug('Drag change - moveState:', cloneDeep(this.moveState))
},
onDragMove (event) {
console.debug('Drag move - event:', event)
// cancel opening previous group we moved over as we moved away from it
const movedToSamePlace = event.relatedContext?.element?.item?.name === this.moveState.moveTarget?.item?.name
if (!movedToSamePlace) {
clearTimeout(this.moveState.moveDelayedOpen)
this.moveState.moveDelayedOpen = null
}
this.moveState.moveTarget = event.relatedContext?.element
// return if we cannot drop here
if (this.moveState.cancelled || !this.moveState.node.item.editable || !this.dropAllowed(this.moveState.moveTarget)) {
return false
}
// Open group if not open yet, with a delay so you don't open it if you just drag over it
if (!movedToSamePlace && this.moveState.moveTarget?.item?.type === 'Group' && !this.moveState.moveTarget?.opened) {
const element = event.relatedContext.element
this.moveState.moveDelayedOpen = setTimeout(() => {
// this.$set(element, "opened", true)
element.opened = true
}, 1000, element)
}
return true
},
onDragEnd (event) {
if (!this.moveState.node.item.editable) {
return
}
console.debug('runtime onDragEnd', Date.now() - this.moveState.dragStartTimestamp)
console.debug('Drag end - event:', event)
window.removeEventListener('keydown', this.keyDownHandler)
if (this.moveState.cancelled) {
console.debug('Drag end - cancelled')
this.restoreModelUpdate()
return
}
this.moveState.moving = false
this.moveState.dragEnd = true
console.debug('Drag end - moveState:', cloneDeep(this.moveState))
},
dropAllowed (node) {
if (node?.class?.startsWith('Point')) {
return false
}
if (this.moveState.node?.class?.startsWith('Location') && node?.class?.startsWith('Equipment')) {
return false
}
return true
},
nestedSemanticNode (node) {
const children = [...node.children.locations, ...node.children.equipment, ...node.children.points, ...node.children.groups, ...node.children.items]
const semanticNode = children.find((c) => c.class !== '')
if (semanticNode) return semanticNode
return children.find((c) => {
return this.nestedSemanticNode(c)
})
},
validateAdd () {
console.debug('runtime validateAdd start', Date.now() - this.moveState.dragStartTimestamp)
this.moveState.adding = true
const node = this.moveState.node
const parentNode = this.moveState.newParent
const oldParentNode = this.moveState.oldParent
if (node.item.name === parentNode?.item?.name) {
// This should not be possible, but just to make sure to avoid infinite loop
this.restoreModelUpdate()
console.debug('runtime validateAdd end', Date.now() - this.moveState.dragStartTimestamp)
return
}
if (parentNode.item && node.item.groupNames?.includes(parentNode.item.name)) {
const message = 'Group "' + this.itemLabel(parentNode.item) +
'" already contains item "' + this.itemLabel(node.item) + '"'
console.debug('Add rejected: ' + message)
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
this.$f7.dialog.alert(message).open()
this.restoreModelUpdate()
console.debug('runtime validateAdd end', Date.now() - this.moveState.dragStartTimestamp)
return
}
if (node.item.type === 'Group' && node.class === '') {
const semanticNode = this.nestedSemanticNode(node)
if (semanticNode) {
const message = 'Cannot insert non-semantic group "' + this.itemLabel(node.item) +
'" with semantic child "' + this.itemLabel(semanticNode.item) +
'" into semantic group "' + this.itemLabel(parentNode.item) + '"'
console.debug('Add rejected: ' + message)
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
this.$f7.dialog.alert(message).open()
this.restoreModelUpdate()
console.debug('runtime validateAdd end', Date.now() - this.moveState.dragStartTimestamp)
return
}
}
if (node.class !== '' && parentNode.class !== '' && (oldParentNode.item && oldParentNode?.class === '')) {
const message = 'Cannot move semantic item "' + this.itemLabel(node.item) +
'" from non-semantic group "' + this.itemLabel(oldParentNode.item) +
'" into semantic group "' + this.itemLabel(parentNode.item) + '"'
console.debug('Add rejected:' + message)
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
this.$f7.dialog.alert(message).open()
this.restoreModelUpdate()
console.debug('runtime validateAdd end', Date.now() - this.moveState.dragStartTimestamp)
return
}
if (node.class.startsWith('Point') && parentNode.class !== '') {
const groups = node.item.groupNames
if (oldParentNode.class.startsWith('Equipment') && parentNode.class.startsWith('Location')) {
const oldLocation = node.item.metadata.semantics.config.hasLocation
if (oldLocation) {
const message = 'Cannot move Point "' + this.itemLabel(node.item) +
'" from Equipment "' + this.itemLabel(oldParentNode.item) +
'" to Location "' + this.itemLabel(parentNode.item) +
'" as it is already in Location "' + oldLocation + '"'
console.debug('Add rejected:' + message)
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
this.$f7.dialog.alert(message).open()
this.restoreModelUpdate()
console.debug('runtime validateAdd end', Date.now() - this.moveState.dragStartTimestamp)
return
}
} else if (oldParentNode.class.startsWith('Location') && parentNode.class.startsWith('Equipment')) {
const oldEquipment = node.item.metadata.semantics.config.isPointOf
if (oldEquipment) {
const message = 'Cannot move Point "' + this.itemLabel(node.item) +
'" from Location "' + this.itemLabel(oldParentNode.item) +
'" to Equipment "' + this.itemLabel(parentNode.item) +
'" as it is already part of Equipment "' + oldEquipment + '"'
console.debug('Add rejected:' + message)
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
this.$f7.dialog.alert(message).open()
this.restoreModelUpdate()
console.debug('runtime validateAdd end', Date.now() - this.moveState.dragStartTimestamp)
return
}
}
}
if (!this.isValidGroupType(node, parentNode)) {
this.restoreModelUpdate()
console.debug('runtime validateAdd end', Date.now() - this.moveState.dragStartTimestamp)
return
}
if (parentNode.class.startsWith('Location')) {
this.addIntoLocation(node, parentNode)
} else if (parentNode.class.startsWith('Equipment')) {
this.addIntoEquipment(node, parentNode)
} else if (parentNode.item) {
this.addIntoGroup(node, parentNode)
} else {
this.addIntoRoot(node, parentNode)
}
this.moveState.canAdd = false
this.moveState.adding = false
console.debug('runtime validateAdd end', Date.now() - this.moveState.dragStartTimestamp)
},
isValidGroupType (node, parentNode) {
console.debug('runtime isValidGroupType start', Date.now() - this.moveState.dragStartTimestamp)
const groupTypeDef = parentNode.item?.groupType?.split(':')
const baseType = groupTypeDef ? groupTypeDef[0] : 'None'
if (baseType === 'None') return true
const baseDimension = groupTypeDef && groupTypeDef.length > 1 ? groupTypeDef[1] : null
const typeDef = node.item.type !== 'Group' ? node.item.type?.split(':') : node.item.groupType?.split(':')
const type = typeDef ? typeDef[0] : 'None'
const dimension = typeDef.length > 1 ? typeDef[1] : null
if ((type === 'Number' || type === 'None') && baseType === 'Number') {
if (baseDimension && dimension && baseDimension !== dimension) {
const message = 'Group dimension "' + baseDimension +
'" of group "' + this.itemLabel(parentNode.item) +
'" not compatible with "' + (node.item.type === 'Group' ? 'group ' : '') + 'item dimension "' + dimension +
'" of "' + (node.item.type === 'Group' ? 'group ' : '') + '" item "' + this.itemLabel(node.item) + '"'
console.debug('Add rejected: ' + message)
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
this.$f7.dialog.alert(message).open()
console.debug('runtime isValidGroupType end', Date.now() - this.moveState.dragStartTimestamp)
return false
}
if (dimension) {
const childWithDifferentDimension = parentNode.children.map((child) => {
const childTypeDef = child.item.type !== 'Group' ? child.item.type.split(':') : child.item.groupType?.split(':')
return childTypeDef.length > 1 ? { item: child.item, dimension: childTypeDef[1] } : null
}).find((child) => { return dimension !== child?.dimension })
if (childWithDifferentDimension) {
const message = 'Group "' + this.itemLabel(parentNode.item) +
'" already contains item "' + this.itemLabel(childWithDifferentDimension.item) +
'" with dimension "' + childWithDifferentDimension.dimension +
'" different from group dimension "' + dimension + '"'
console.debug('Add rejected: ' + message)
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
this.$f7.dialog.alert(message).open()
console.debug('runtime isValidGroupType end', Date.now() - this.moveState.dragStartTimestamp)
return false
}
}
}
const aggregationFunction = parentNode.item?.function?.name
if (aggregationFunction && !this.aggregationFunctions(type).includes(aggregationFunction)) {
const message = 'Group aggreggation function "' + aggregationFunction +
'" for group "' + this.itemLabel(parentNode.item) +
'" not compatible with type "' + type +
'" of item "' + this.itemLabel(node.item) + '"'
console.debug('Add rejected: ' + message)
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
this.$f7.dialog.alert(message).open()
console.debug('runtime isValidGroupType end', Date.now() - this.moveState.dragStartTimestamp)
return false
}
console.debug('runtime isValidGroupType end', Date.now() - this.moveState.dragStartTimestamp)
return true
},
aggregationFunctions (type) {
const specificAggregationFunctions = (type) => {
switch (type) {
case 'Dimmer':
case 'Rollershutter':
case 'Number':
return types.ArithmeticFunctions
case 'Contact':
return types.LogicalOpenClosedFunctions
case 'Player':
return types.LogicalPlayPauseFunctions
case 'DateTime':
return types.DateTimeFunctions
case 'Switch':
return types.LogicalOnOffFunctions
}
return []
}
return [...types.CommonFunctions, ...specificAggregationFunctions(type)]
},
addIntoLocation (node, parentNode) {
console.debug('runtime addIntoLocation start', Date.now() - this.moveState.dragStartTimestamp)
if (node.class.startsWith('Location')) {
this.addLocation(node, parentNode)
} else if (node.class.startsWith('Equipment')) {
this.addEquipment(node, parentNode)
} else if (node.class.startsWith('Point')) {
this.addPoint(node, parentNode)
} else if (node.item.type === 'Group') {
this.moveState.moveConfirmed = true
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
this.$f7.dialog.create({
text: 'Insert "' + this.itemLabel(node.item) +
'" into "' + this.itemLabel(parentNode.item) +
'" as',
verticalButtons: true,
buttons: [
{ text: 'Cancel', color: 'gray', keycodes: [27], onClick: () => this.restoreModelUpdate() },
{ text: 'Location', strong: true, keycodes: [13], onClick: () => this.addLocation(node, parentNode) },
{ text: 'Equipment', onClick: () => this.addEquipment(node, parentNode) }
]
}).open()
} else {
this.moveState.moveConfirmed = true
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
this.$f7.dialog.create({
text: 'Insert "' + this.itemLabel(node.item) +
'" into "' + this.itemLabel(parentNode.item) +
'" as',
verticalButtons: true,
buttons: [
{ text: 'Cancel', color: 'gray', keycodes: [27], onClick: () => this.restoreModelUpdate() },
{ text: 'Equipment', onClick: () => this.addEquipment(node, parentNode) },
{ text: 'Point', strong: true, keycodes: [13], onClick: () => this.addPoint(node, parentNode) }
]
}).open()
}
console.debug('runtime addIntoLocation end', Date.now() - this.moveState.dragStartTimestamp)
},
addIntoEquipment (node, parentNode) {
console.debug('runtime addIntoEquipment start', Date.now() - this.moveState.dragStartTimestamp)
if (node.class.startsWith('Location')) {
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
this.$f7.dialog.alert(
'Cannot move Location "' + this.itemLabel(node.item) +
'" into Equipment "' + this.itemLabel(parentNode.item) + '"'
).open()
this.restoreModelUpdate()
} else if (node.class.startsWith('Equipment')) {
this.addEquipment(node, parentNode)
} else if (node.class.startsWith('Point')) {
this.addPoint(node, parentNode)
} else if (node.item.type === 'Group') {
this.addEquipment(node, parentNode)
} else {
this.moveState.moveConfirmed = true
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
const dialog = this.$f7.dialog.create({
text: 'Insert "' + this.itemLabel(node.item) +
'" into "' + this.itemLabel(parentNode.item) +
'" as',
verticalButtons: true,
buttons: [
{ text: 'Cancel', color: 'gray', keycodes: [27], onClick: () => this.restoreModelUpdate() },
{ text: 'Equipment', onClick: () => this.addEquipment(node, parentNode) },
{ text: 'Point', strong: true, keycodes: [13], onClick: () => this.addPoint(node, parentNode) }
]
}).open()
}
console.debug('runtime addIntoEquipment end', Date.now() - this.moveState.dragStartTimestamp)
},
addIntoGroup (node, parentNode) {
console.debug('runtime addIntoGroup start', Date.now() - this.moveState.dragStartTimestamp)
if (node.class.startsWith('Location')) {
this.addLocation(node, parentNode)
} else if (node.class.startsWith('Equipment')) {
this.addEquipment(node, parentNode)
} else if (node.class.startsWith('Point')) {
this.addPoint(node, parentNode)
} else {
this.addNonSemantic(node, parentNode)
}
console.debug('runtime addIntoGroup end', Date.now() - this.moveState.dragStartTimestamp)
},
addIntoRoot (node, parentNode) {
console.debug('runtime addIntoRoot start', Date.now() - this.moveState.dragStartTimestamp)
if (node.class.startsWith('Location')) {
this.addLocation(node, parentNode)
} else if (node.class.startsWith('Equipment')) {
this.addEquipment(node, parentNode)
} else if (node.class.startsWith('Point')) {
this.addPoint(node, parentNode)
} else if (node.item.type === 'Group') {
this.moveState.moveConfirmed = true
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
this.$f7.dialog.create({
text: 'Insert "' + this.itemLabel(node.item) +
'" into "' + this.itemLabel(parentNode.item) +
'" as',
verticalButtons: true,
buttons: [
{ text: 'Cancel', color: 'gray', keycodes: [27], onClick: () => this.restoreModelUpdate() },
{ text: 'Location', onClick: () => this.addLocation(node, parentNode) },
{ text: 'Equipment', onClick: () => this.addEquipment(node, parentNode) },
{ text: 'Non Semantic', strong: true, keycodes: [13], onClick: () => this.addNonSemantic(node, parentNode) }
]
}).open()
} else {
this.moveState.moveConfirmed = true
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
this.$f7.dialog.create({
text: 'Insert "' + this.itemLabel(node.item) +
'" into "' + this.itemLabel(parentNode.item) +
'" as',
verticalButtons: true,
buttons: [
{ text: 'Cancel', color: 'gray', keycodes: [27], onClick: () => this.restoreModelUpdate() },
{ text: 'Equipment', onClick: () => this.addEquipment(node, parentNode) },
{ text: 'Point', onClick: () => this.addPoint(node, parentNode) },
{ text: 'Non Semantic', strong: true, keycodes: [13], onClick: () => this.addNonSemantic(node, parentNode) }
]
}).open()
}
console.debug('runtime addIntoRoot end', Date.now() - this.moveState.dragStartTimestamp)
},
addLocation (node, parentNode) {
console.debug('runtime addLocation start', Date.now() - this.moveState.dragStartTimestamp)
const semantics = { config: {} }
semantics.value = node.item?.metadata?.semantics?.value || 'Location'
if (parentNode.class.startsWith('Location')) {
semantics.config.isPartOf = parentNode.item.name
}
if (!node.item.tags.includes(semantics.value)) node.item.tags.push(semantics.value)
node.class = semantics.value
const nodeChildren = this.nodeChildren(node)
nodeChildren.filter((n) => !n.class).forEach((n) => this.addIntoLocation(n, node))
this.updateAfterAdd(node, parentNode, semantics)
console.debug('runtime addLocation end', Date.now() - this.moveState.dragStartTimestamp)
},
addEquipment (node, parentNode) {
console.debug('runtime addEquipment start', Date.now() - this.moveState.dragStartTimestamp)
const semantics = { config: {} }
semantics.value = node.item?.metadata?.semantics?.value || 'Equipment'
if (parentNode.class.startsWith('Location')) {
semantics.config.hasLocation = parentNode.item.name
} else if (parentNode.class.startsWith('Equipment')) {
semantics.config.isPartOf = parentNode.item.name
}
if (!node.item.tags.includes(semantics.value)) node.item.tags.push(semantics.value)
node.class = semantics.value
const nodeChildren = this.nodeChildren(node)
nodeChildren.filter((n) => !n.class).forEach((n) => this.addIntoEquipment(n, node))
this.updateAfterAdd(node, parentNode, semantics)
console.debug('runtime addEquipment end', Date.now() - this.moveState.dragStartTimestamp)
},
addPoint (node, parentNode) {
console.debug('runtime addPoint start', Date.now() - this.moveState.dragStartTimestamp)
const semantics = { config: {} }
semantics.value = node.item?.metadata?.semantics?.value || 'Point'
if (parentNode.class.startsWith('Location')) {
semantics.config.hasLocation = parentNode.item.name
} else if (parentNode.class.startsWith('Equipment')) {
semantics.config.isPointOf = parentNode.item.name
}
if (!node.item.tags.includes(semantics.value)) node.item.tags.push(semantics.value)
node.class = semantics.value
this.updateAfterAdd(node, parentNode, semantics)
console.debug('runtime addPoint end', Date.now() - this.moveState.dragStartTimestamp)
},
addNonSemantic (node, parentNode) {
console.debug('runtime addNonSemantic start', Date.now() - this.moveState.dragStartTimestamp)
node.class = ''
this.updateAfterAdd(node, parentNode, null)
console.debug('runtime addNonSemantic end', Date.now() - this.moveState.dragStartTimestamp)
},
updateAfterAdd (node, parentNode, semantics) {
console.debug('runtime updateAfterAdd start', Date.now() - this.moveState.dragStartTimestamp)
let updateRequired = false
if (semantics === null) {
if (node.item.metadata?.semantics) {
node.item.metadata.semantics = null
updateRequired = true
}
} else if (node.item.metadata) {
if (!fastDeepEqual(node.item.metadata.semantics, semantics)) {
node.item.metadata.semantics = semantics
updateRequired = true
}
} else {
node.item.metadata = { semantics }
updateRequired = true
}
if (parentNode.item?.type === 'Group' && !node.item.groupNames.includes(parentNode.item.name)) {
node.item.groupNames.push(parentNode.item.name)
updateRequired = true
}
console.debug('Add - new moveState:', cloneDeep(this.moveState))
if (!this.children.some(n => n.item.name === node.item.name)) {
// sometimes the list gets updates when dragging, sometimes it is missed so we have to add here
this.children.push(node)
}
const newChildren = this.children
this.children = newChildren // force setters to update model
if (updateRequired) {
this.moveState.nodesToUpdate.push(node)
}
console.debug('Add - finished, new moveState:', cloneDeep(this.moveState))
console.debug('runtime updateAfterAdd end', Date.now() - this.moveState.dragStartTimestamp)
},
validateRemove () {
console.debug('runtime validateRemove start', Date.now() - this.moveState.dragStartTimestamp)
this.moveState.removing = true
const node = this.moveState.node
const newParentNode = this.moveState.newParent
const parentNode = this.moveState.oldParent
const oldIndex = this.moveState.oldIndex
console.debug('Remove - new moveState:', cloneDeep(this.moveState))
if (!newParentNode.item) {
// moving into root, so remove from source
// issue: it will only remove from the current parent, not all
this.remove(node, parentNode, oldIndex)
} else if (parentNode.class !== '' && newParentNode.class !== '') {
// in general remove from semantic model group, unless moving into non-semantic group
this.remove(node, parentNode, oldIndex)
} else if (!parentNode.item && node.class !== '') {
// always remove semantic item from root level when moving into another group
this.remove(node, parentNode, oldIndex)
} else if (parentNode.item?.type === 'Group') {
this.moveState.moveConfirmed = true
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
this.$f7.dialog.create({
text: 'Item "' + this.itemLabel(node.item) +
'" dragged from group "' + this.itemLabel(parentNode.item) +
'" into "' + this.itemLabel(newParentNode.item) +
'", keep original?',
buttons: [
{ text: 'Cancel', color: 'gray', keycodes: [27], onClick: () => this.restoreModelUpdate() },
{ text: 'Yes', strong: true, keycodes: [13], onClick: () => this.updateAfterRemove() },
{ text: 'No', onClick: () => this.remove(node, parentNode, oldIndex) }
]
}).open()
} else {
this.updateAfterRemove()
}
console.debug('runtime validateRemove end', Date.now() - this.moveState.dragStartTimestamp)
},
remove (node, parentNode, oldIndex) {
console.debug('runtime remove start', Date.now() - this.moveState.dragStartTimestamp)
const groupNameIndex = node.item.groupNames.findIndex(g => g === parentNode.item?.name)
if (groupNameIndex >= 0) {
node.item.groupNames.splice(groupNameIndex, 1)
}
const newChildren = this.nodeChildren(parentNode)
newChildren.splice(oldIndex, 1)
this.children = newChildren
if (parentNode.class === '' && parentNode.item?.type === 'Group') {
// Moving a semantic item to a non-semantic group, remove semantics
if (node.item.metadata) {
node.item.metadata.semantics = null
}
}
this.updateAfterRemove()
console.debug('Remove - finished, new moveState:', cloneDeep(this.moveState))
console.debug('runtime remove end', Date.now() - this.moveState.dragStartTimestamp)
},
updateAfterRemove () {
console.debug('runtime updateAfterRemove start', Date.now() - this.moveState.dragStartTimestamp)
this.moveState.canRemove = false
this.moveState.removing = false
this.moveState.dragFinished = true
console.debug('runtime updateAfterRemove end', Date.now() - this.moveState.dragStartTimestamp)
},
saveUpdate () {
console.debug('runtime saveUpdate start', Date.now() - this.moveState.dragStartTimestamp)
this.moveState.saving = true
const node = this.moveState.node
const parentNode = this.moveState.newParent
if (!this.moveState.moveConfirmed) {
console.debug('runtime dialog open', Date.now() - this.moveState.dragStartTimestamp)
this.$f7.dialog.confirm(
'Move "' + this.itemLabel(node.item) + '" into "' + this.itemLabel(parentNode.item) + '"?',
() => this.saveModelUpdate(),
() => this.restoreModelUpdate()
).open()
} else {
this.saveModelUpdate()
}
console.debug('runtime saveUpdate end', Date.now() - this.moveState.dragStartTimestamp)
},
saveModelUpdate () {
console.debug('runtime saveModelUpdate start', Date.now() - this.moveState.dragStartTimestamp)
this.moveState.dragFinished = false
this.moveState.nodesToUpdate.forEach((n) => {
const updatedItem = n.item
console.debug('Save - updatedItem: ', cloneDeep(updatedItem))
this.saveItem(updatedItem)
})
this.moveState.saving = false
console.debug('runtime saveModelUpdate end', Date.now() - this.moveState.dragStartTimestamp)
},
restoreModelUpdate () {
console.debug('Restore model')
console.debug('runtime restoreModelUpdate start', Date.now() - this.moveState.dragStartTimestamp)
this.moveState.cancelled = true
this.moveState.canRemove = false
this.moveState.canAdd = false
this.moveState.adding = false
this.moveState.removing = false
this.moveState.saving = false
this.$emit('reload')
console.debug('runtime restoreModelUpdate end', Date.now() - this.moveState.dragStartTimestamp)
},
itemLabel (item) {
if (!item) return 'model root'
return (item.label ? (this.includeItemName ? item.label + ' (' + item.name + ')' : item.label) : item.name)
},
nodeChildren (node) {
if (!node) return this.children
if (!node.children) return []
return [node.children.locations, node.children.equipment, node.children.points, node.children.groups, node.children.items].flat()
},
keyDownHandler (event) {
if (!event.repeat && event.keyCode === 27) {
console.debug('escape pressed')
console.debug('runtime escape', Date.now() - this.moveState.dragStartTimestamp)
this.moveState.cancelled = true
}
}
}
}

View File

@ -5,7 +5,7 @@ function compareModelItems (o1, o2) {
}
/**
* Mixin for model page and model picker popup.
* Mixin for the model page and model picker popup.
*
* The component using this mixin has to provide the following methods:
* - `selectItem(item)`: Called when an item is selected.
@ -22,12 +22,14 @@ export default {
links: [],
locations: [],
rootLocations: [],
equipment: {},
equipment: [],
rootEquipment: [],
rootPoints: [],
rootGroups: [],
rootItems: [],
expandedTreeviewItems: [],
previousSelection: null,
selectedItem: null
}
@ -39,9 +41,11 @@ export default {
* @returns {Promise<void>}
*/
loadModel () {
if (this.loading) return
if (this.loading) return Promise.resolve()
this.loading = true
this.saveExpanded()
const items = this.$oh.api.get('/rest/items?staticDataOnly=true&metadata=.+')
const links = this.$oh.api.get('/rest/links')
return Promise.all([items, links]).then((data) => {
@ -125,7 +129,7 @@ export default {
if (this.includeNonSemantic) {
parent.children.groups = this.items
.filter((i) => i.type === 'Group' && (!i.metadata || (i.metadata && !i.metadata.semantics)) && i.groupNames.indexOf(parent.item.name) >= 0)
.filter((i) => i.type === 'Group' && !(parent.item.metadata && parent.item.metadata.semantics) && i.groupNames.indexOf(parent.item.name) >= 0)
.map(this.modelItem).sort(compareModelItems)
parent.children.groups.forEach(this.getChildren)
if (parent.item.metadata && parent.item.metadata.semantics) {
@ -155,6 +159,20 @@ export default {
}
}
})
},
saveExpanded () {
this.expandedTreeviewItems = [...document.querySelectorAll('.treeview-item-opened')]
},
restoreExpanded () {
const treeviewItems = document.querySelectorAll('.treeview-item')
treeviewItems.forEach(item => {
if (item.classList.contains('treeview-item')) {
if (this.expanded || this.expandedTreeviewItems.includes(item)) {
item.classList.add('treeview-item-opened')
}
}
})
}
}
}

View File

@ -35,7 +35,7 @@
</div>
<f7-link class="right details-link padding-right" ref="detailsLink" @click="detailsOpened = true" icon-f7="chevron_up" />
</f7-toolbar>
<f7-toolbar v-else bottom class="toolbar-details" style="height: calc(50px + var(--f7-safe-area-bottom))">
<f7-toolbar v-else bottom class="toolbar-details">
<f7-link :disabled="selectedItem != null" class="left" @click="selectedItem = null">
Clear
</f7-link>
@ -71,13 +71,9 @@
</f7-block>
<f7-block v-show="!empty" strong class="semantic-tree" no-gap @click.native="clearSelection">
<!-- <empty-state-placeholder v-if="empty" icon="list_bullet_indent" title="model.title" text="model.text" /> -->
<f7-treeview>
<model-treeview-item v-for="node in [rootLocations, rootEquipment, rootPoints, rootGroups, rootItems].flat()"
:key="node.item.name" :model="node"
:includeItemName="includeItemName" :includeItemTags="includeItemTags"
@selected="selectItem" :selected="selectedItem" />
</f7-treeview>
<model-treeview :rootNodes="[rootLocations, rootEquipment, rootPoints, rootGroups, rootItems].flat()" :items="items"
:includeItemName="includeItemName" :includeItemTags="includeItemTags" :canDragDrop="true"
@selected="selectItem" :selected="selectedItem" @reload="load" />
</f7-block>
</f7-col>
<f7-col width="100" medium="50" class="details-pane">
@ -172,20 +168,21 @@
<style lang="stylus">
.semantic-tree-wrapper
user-select: none
padding 0
margin-bottom 0
.row
height 100%
.col-100
height 100%
overflow auto
.semantic-tree
min-height 100%
margin 0
height auto
.semantic-tree
padding 0
user-select: none
margin 0 !important
border-right 1px solid var(--f7-block-strong-border-color)
.treeview
--f7-treeview-item-height 40px
.treeview-item-label
font-size 10pt
white-space nowrap
overflow-x hidden
.semantic-class
font-size 8pt
color var(--f7-list-item-footer-text-color)
.model-details-sheet
.toolbar
--f7-theme-color var(--f7-color-blue)
@ -196,16 +193,8 @@
@media (min-width: 768px)
.semantic-tree-wrapper
height calc(100% - var(--f7-navbar-height))
height calc(100% - var(--f7-toolbar-height))
.row
height 100%
.col-100
height 100%
overflow auto
.semantic-tree
min-height 100%
margin 0
height auto
.details-pane
padding-top 0
.block
@ -217,12 +206,22 @@
visibility hidden !important
@media (max-width: 767px)
.semantic-tree-wrapper.block:first-child
margin-top 5px
.semantic-tree-wrapper
height calc(100% - 20px)
margin-bottom 5px
.details-pane
display none
.semantic-tree-wrapper.sheet-opened
margin-bottom var(--f7-sheet-height)
.details-sheet
height calc(1.4*var(--f7-sheet-height))
height calc(100% - 5px - var(--f7-sheet-height) + var(--f7-page-toolbar-bottom-offset, 0px) + var(--f7-page-content-extra-padding-bottom, 0px))
margin-bottom calc(var(--f7-sheet-height) - var(--f7-page-toolbar-bottom-offset, 0px) - var(--f7-page-content-extra-padding-bottom, 0px))
.toolbar-details.toolbar.toolbar-bottom
height calc( 50px + var(--f7-safe-area-bottom))
.model-details-sheet.sheet-modal.sheet-modal-bottom .block
margin-top 0px
padding-left 0px
padding-right 0px
.expand-button
margin-right 8px
@ -234,6 +233,7 @@
<script>
import ModelDetailsPane from '@/components/model/details-pane.vue'
import ModelTreeview from '@/components/model/model-treeview.vue'
import AddFromThing from './add-from-thing.vue'
import AddFromTemplate from './add-from-template.vue'
@ -241,20 +241,20 @@ import ItemStatePreview from '@/components/item/item-state-preview.vue'
import ItemDetails from '@/components/model/item-details.vue'
import MetadataMenu from '@/components/item/metadata/item-metadata-menu.vue'
import LinkDetails from '@/components/model/link-details.vue'
import ModelTreeviewItem from '@/components/model/treeview-item.vue'
import ModelMixin from '@/pages/settings/model/model-mixin'
import ItemsAddFromTextualDefinition from '../items/parser/items-add-from-textual-definition.vue'
export default {
mixins: [ModelMixin],
components: {
'empty-state-placeholder': () => import('@/components/empty-state-placeholder.vue'),
ModelDetailsPane,
ModelTreeview,
ItemStatePreview,
ItemDetails,
MetadataMenu,
LinkDetails,
ModelTreeviewItem
LinkDetails
},
data () {
if (!this.$f7.data.model) this.$f7.data.model = {}
@ -336,7 +336,7 @@ export default {
this.$refs.searchbar.f7Searchbar.$inputEl[0].focus()
}
this.$refs.searchbar?.f7Searchbar.search(this.$f7.data.lastModelSearchQuery || '')
this.applyExpandedOption()
this.restoreExpanded()
})
if (!this.eventSource) this.startEventSource()
})
@ -381,6 +381,7 @@ export default {
clearSelection (ev) {
if (ev.target && ev.currentTarget && ev.target === ev.currentTarget) {
this.selectedItem = null
this.detailsOpened = false
}
},
toggleNonSemantic () {