From e92899d9c782041699438a5132b84a9d13e463d5 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Tue, 19 Nov 2024 18:01:09 +0000 Subject: [PATCH] Permit wysiwyg editing add/remove spacers closes WYSIWYG: Ability to add and remove spacers #1479 --- nodes/config/ui_base.js | 105 ++++++--- ui/src/EditTracking.js | 92 +------- ui/src/layouts/Flex.vue | 20 +- ui/src/layouts/Grid.vue | 20 +- ui/src/layouts/Group.vue | 33 ++- ui/src/layouts/wysiwyg/index.js | 151 +++++++----- ui/src/layouts/wysiwyg/resizable.scss | 37 +++ ui/src/store/index.mjs | 4 +- ui/src/store/ui.mjs | 34 ++- ui/src/store/wysiwyg.mjs | 324 ++++++++++++++++++++++++++ 10 files changed, 632 insertions(+), 188 deletions(-) create mode 100644 ui/src/store/wysiwyg.mjs diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js index 48d1de96c..bc13c70f8 100644 --- a/nodes/config/ui_base.js +++ b/nodes/config/ui_base.js @@ -722,7 +722,7 @@ module.exports = function (RED) { // any widgets we hard-code into our front end (e.g ui-notification for connection alerts) will start with ui- // Node-RED built nodes will be a random UUID if (!wNode && !id.startsWith('ui-')) { - console.log('widget does not exist any more') + console.log('widget does not exist in the runtime', id) // TODO: Handle this better for edit-time added nodes (e.g. ui-spacer) return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed) } async function handler () { @@ -1138,12 +1138,16 @@ module.exports = function (RED) { const changes = req.body.changes || {} const editKey = req.body.key const groups = changes.groups || [] - const widgets = changes.widgets || [] + const allWidgets = (changes.widgets || []) + const updatedWidgets = allWidgets.filter(w => !w.__DB2_ADD_WIDGET && !w.__DB2_REMOVE_WIDGET) + const addedWidgets = allWidgets.filter(w => !!w.__DB2_ADD_WIDGET).map(w => { delete w.__DB2_ADD_WIDGET; return w }) + const removedWidgets = allWidgets.filter(w => !!w.__DB2_REMOVE_WIDGET).map(w => { delete w.__DB2_REMOVE_WIDGET; return w }) + console.log(changes, editKey, dashboardId) const baseNode = RED.nodes.getNode(dashboardId) // validity checks - if (groups.length === 0 && widgets.length === 0) { + if (groups.length === 0 && allWidgets.length === 0) { // this could be a 200 but since the group data might be missing due to // a bug or regression, we'll return a 400 and let the user know // there were no changes provided. @@ -1166,14 +1170,30 @@ module.exports = function (RED) { return res.status(400).json({ error: 'Invalid page id' }) } } - for (const key in widgets) { - const groupWidgets = widgets[key] - for (const modified of groupWidgets) { - // ensure widget exists - const widget = baseNode.ui.widgets.get(modified.id) - if (!widget) { - return res.status(400).json({ error: 'Widget not found' }) - } + + for (const widget of updatedWidgets) { + const existingWidget = baseNode.ui.widgets.get(widget.id) + if (!existingWidget) { + return res.status(400).json({ error: 'Widget not found' }) + } + } + + for (const added of addedWidgets) { + // for now, only ui-spacer is supported + if (added.type !== 'ui-spacer') { + return res.status(400).json({ error: 'Cannot add this kind of widget' }) + } + + // check if the widget is being added to a valid group + const group = baseNode.ui.groups.get(added.group) + if (!group) { + return res.status(400).json({ error: 'Invalid group id' }) + } + } + for (const removed of removedWidgets) { + // for now, only ui-spacer is supported + if (removed.type !== 'ui-spacer') { + return res.status(400).json({ error: 'Cannot remove this kind of widget' }) } } @@ -1237,21 +1257,54 @@ module.exports = function (RED) { changeResult.push(applyIfDifferent(current, modified, 'width')) changeResult.push(applyIfDifferent(current, modified, 'order')) } - for (const widgetKey in widgets) { - const groupWidgets = widgets[widgetKey] - for (const modified of groupWidgets) { - const current = flows.find(n => n.id === modified.id) - if (!current) { - // widget not found in current flows! integrity of data suspect! Has flows changed on the server? - return res.status(400).json({ error: 'Widget not found', code: 'WIDGET_NOT_FOUND' }) - } - if (modified.group !== current.group) { - // integrity of data suspect! Has flow changed on the server? - return res.status(400).json({ error: 'Invalid group id', code: 'INVALID_GROUP_ID' }) - } - changeResult.push(applyIfDifferent(current, modified, 'order')) - changeResult.push(applyIfDifferent(current, modified, 'width')) - changeResult.push(applyIfDifferent(current, modified, 'height')) + // scan through the widgets and apply changes (if any) + for (const modified of updatedWidgets) { + const current = flows.find(n => n.id === modified.id) + if (!current) { + // widget not found in current flows! integrity of data suspect! Has flows changed on the server? + return res.status(400).json({ error: 'Widget not found', code: 'WIDGET_NOT_FOUND' }) + } + if (modified.group !== current.group) { + // integrity of data suspect! Has flow changed on the server? + // Currently we dont support moving widgets between groups + return res.status(400).json({ error: 'Invalid group id', code: 'INVALID_GROUP_ID' }) + } + changeResult.push(applyIfDifferent(current, modified, 'order')) + changeResult.push(applyIfDifferent(current, modified, 'width')) + changeResult.push(applyIfDifferent(current, modified, 'height')) + } + + // scan through the added widgets + for (const added of addedWidgets) { + const current = flows.find(n => n.id === added.id) + if (current) { + // widget already exists in current flows! integrity of data suspect! Has flows changed on the server? + return res.status(400).json({ error: 'Widget already exists', code: 'WIDGET_ALREADY_EXISTS' }) + } + // sanitize the added widget (NOTE: only ui-spacer is supported for now & these are the only properties we care about) + const newWidget = { + id: added.id, + type: added.type, + group: added.group, + name: added.name || '', + order: added.order ?? 0, + width: added.width ?? 1, + height: added.height ?? 1, + className: added.className || '' + } + flows.push(newWidget) + changeResult.push(true) + } + for (const removed of removedWidgets) { + const current = flows.find(n => n.id === removed.id) + if (!current) { + // widget not found in current flows! integrity of data suspect! Has flows changed on the server? + return res.status(400).json({ error: 'Widget not found', code: 'WIDGET_NOT_FOUND' }) + } + const index = flows.indexOf(current) + if (index > -1) { + flows.splice(index, 1) + changeResult.push(true) } } if (changeResult.length === 0 || !changeResult.includes(true)) { diff --git a/ui/src/EditTracking.js b/ui/src/EditTracking.js index 95750fedf..fc6ecb08d 100644 --- a/ui/src/EditTracking.js +++ b/ui/src/EditTracking.js @@ -6,9 +6,7 @@ const state = reactive({ editPage: '', editMode: false, editorPath: '', // the custom httpAdminRoot path for the NR editor - isTrackingEdits: false, - originalGroups: [], - originalWidgets: [] + isTrackingEdits: false }) // Methods @@ -28,49 +26,19 @@ function initialise (editKey, editPage, editorPath) { /** * Start tracking edits */ -function startEditTracking (groups, widgets) { +function startEditTracking () { state.isTrackingEdits = true - updateEditTracking(groups, widgets) } /** * Stop tracking edits, clear editKey/editPage & exit edit mode */ -function exitEditMode () { +function endEditMode () { state.editKey = '' state.editPage = '' state.editMode = false state.isTrackingEdits = false state.initialised = false - state.originalGroups = [] - state.originalWidgets = [] -} - -/** - * Update the original groups with the current groups - */ -function updateEditTracking (groups, widgets) { - if (typeof groups !== 'undefined') { - state.originalGroups = JSON.parse(JSON.stringify(groups)) - } - if (typeof widgets !== 'undefined') { - // only store the id, props and layout of each widget (that's all we need for comparison) - const groupIds = Object.keys(widgets) - const partials = {} - for (let i = 0; i < groupIds.length; i++) { - const groupId = groupIds[i] - const groupWidgets = widgets[groupId] - const partialWidgets = groupWidgets.map((w) => { - return { - id: w.id, - props: w.props, - layout: w.layout - } - }) - partials[groupId] = partialWidgets - } - state.originalWidgets = JSON.parse(JSON.stringify(partials)) - } } // RO computed props @@ -78,58 +46,6 @@ const editKey = computed(() => state.editKey) const editPage = computed(() => state.editPage) const editMode = computed(() => !!state.editKey && !!state.editPage) const editorPath = computed(() => state.editorPath) -const originalGroups = computed(() => state.originalGroups) -const originalWidgets = computed(() => state.originalWidgets) const isTrackingEdits = computed(() => state.isTrackingEdits) -const groupPropertiesToCheck = [ - (original, current) => +original.width === +current.width, - (original, current) => +original.height === +current.height, - (original, current) => +original.order === +current.order -] - -const widgetPropertiesToCheck = [ - (original, current) => +original.layout?.width === +current.layout?.width, - (original, current) => +original.layout?.height === +current.layout?.height, - (original, current) => +original.layout?.order === +current.layout?.order, - (original, current) => +original.props?.width === +current.props?.width, - (original, current) => +original.props?.height === +current.props?.height, - (original, current) => +original.props?.order === +current.props?.order -] - -function isDirty (groups, widgets) { - console.log('isDirty', groups, widgets) - const originalGroups = state.originalGroups || [] - // scan through each group and revert changes - - for (let i = 0; i < originalGroups.length; i++) { - const originalGroup = originalGroups[i] - const currentGroup = groups?.find(group => group.id === originalGroup.id) - if (!currentGroup) { - console.warn('Group not found in pageGroups - as we do not currently support adding/removing groups, this should not happen!') - return true - } - // test group properties - if (groupPropertiesToCheck.some(check => !check(originalGroup, currentGroup))) { - return true - } - - // test widgets belonging to this group - const originalWidgetValues = state.originalWidgets?.[originalGroup.id] || [] - const currentWidgets = widgets?.[originalGroup.id] || [] - for (let j = 0; j < originalWidgetValues.length; j++) { - const originalWidget = originalWidgetValues[j] - const currentWidget = currentWidgets.find(widget => widget.id === originalWidget.id) - if (!currentWidget) { - console.warn('Widget not found in pageWidgets - as we do not currently support adding/removing widgets, this should not happen!') - return true - } - if (widgetPropertiesToCheck.some(check => !check(originalWidget, currentWidget))) { - return true - } - } - } - return false -} - -export { editKey, editMode, editPage, editorPath, originalGroups, originalWidgets, isDirty, isTrackingEdits, initialise, exitEditMode, startEditTracking, updateEditTracking } +export { editKey, editMode, editPage, editorPath, isTrackingEdits, initialise, startEditTracking, endEditMode } diff --git a/ui/src/layouts/Flex.vue b/ui/src/layouts/Flex.vue index f85839535..6ab5476ae 100644 --- a/ui/src/layouts/Flex.vue +++ b/ui/src/layouts/Flex.vue @@ -22,7 +22,7 @@ {{ g.name }} @@ -117,12 +117,7 @@ export default { mounted () { console.log('flex layout mounted') if (this.editMode) { // mixin property - this.pageGroups = this.getPageGroups() - const pageGroupWidgets = {} - for (const group of this.pageGroups) { - pageGroupWidgets[group.id] = this.getGroupWidgets(group.id) - } - this.pageGroupWidgets = pageGroupWidgets + this.updateEditStateObjects() this.initializeEditTracking() // Mixin method } }, @@ -225,7 +220,7 @@ export default { }, discardEdits () { this.revertEdits() // Mixin method - this.pageGroups = this.getPageGroups() + this.updateEditStateObjects() }, async leaveEditMode () { let leave = true @@ -253,6 +248,15 @@ export default { return } this.pageGroups[opts.index].width = opts.width + }, + updateEditStateObjects () { + console.log('updateEditStateObjects') + this.pageGroups = this.getPageGroups() + const pageGroupWidgets = {} + for (const group of this.pageGroups) { + pageGroupWidgets[group.id] = this.getGroupWidgets(group.id) + } + this.pageGroupWidgets = pageGroupWidgets } } } diff --git a/ui/src/layouts/Grid.vue b/ui/src/layouts/Grid.vue index d42563a80..3b81e5f29 100644 --- a/ui/src/layouts/Grid.vue +++ b/ui/src/layouts/Grid.vue @@ -22,7 +22,7 @@ {{ g.name }} @@ -117,12 +117,7 @@ export default { mounted () { console.log('grid layout mounted') if (this.editMode) { // mixin property - this.pageGroups = this.getPageGroups() - const pageGroupWidgets = {} - for (const group of this.pageGroups) { - pageGroupWidgets[group.id] = this.getGroupWidgets(group.id) - } - this.pageGroupWidgets = pageGroupWidgets + this.updateEditStateObjects() this.initializeEditTracking() // Mixin method } }, @@ -225,7 +220,7 @@ export default { }, discardEdits () { this.revertEdits() // Mixin method - this.pageGroups = this.getPageGroups() + this.updateEditStateObjects() }, async leaveEditMode () { let leave = true @@ -253,6 +248,15 @@ export default { return } this.pageGroups[opts.index].width = opts.width + }, + updateEditStateObjects () { + console.log('Updating edit state objects') + this.pageGroups = this.getPageGroups() + const pageGroupWidgets = {} + for (const group of this.pageGroups) { + pageGroupWidgets[group.id] = this.getGroupWidgets(group.id) + } + this.pageGroupWidgets = pageGroupWidgets } } } diff --git a/ui/src/layouts/Group.vue b/ui/src/layouts/Group.vue index 71c44687b..1cbb597de 100644 --- a/ui/src/layouts/Group.vue +++ b/ui/src/layouts/Group.vue @@ -13,6 +13,10 @@ @dragend="onHandleDragEnd($event, index, 'group', group, 'top', 'right')" @dragenter.prevent /> + +
+ +
+ +
+ +
{ + console.log('New Widget:', newWidget) + this.$emit('widget-added', { widget: newWidget, group: this.group }) + }).catch((error) => { + console.error('Error adding spacer:', error) + }) + }, + removeWidget (widget) { + this.$store.dispatch('wysiwyg/removeWidget', { id: widget.id }).then(() => { + console.log('Widget removed') + this.$emit('widget-removed', { widget }) + }).catch((error) => { + console.error('Error deleting widget:', error) + }) } } } diff --git a/ui/src/layouts/wysiwyg/index.js b/ui/src/layouts/wysiwyg/index.js index b65920bd3..1791791d9 100644 --- a/ui/src/layouts/wysiwyg/index.js +++ b/ui/src/layouts/wysiwyg/index.js @@ -1,4 +1,6 @@ -import { editKey, editMode, editPage, editorPath, exitEditMode, isDirty, isTrackingEdits, originalGroups, originalWidgets, startEditTracking, updateEditTracking } from '../../EditTracking.js' +import { mapGetters } from 'vuex' + +import { editKey, editMode, editPage, editorPath, isTrackingEdits } from '../../EditTracking.js' import NodeRedApi from '../../api/node-red' import DraggableGroupMixin from './draggableGroup.js' @@ -13,12 +15,14 @@ export default { pageGroupWidgets: [] } }, + emits: ['refresh-state-from-store'], computed: { + ...mapGetters('wysiwyg', ['isDirty', 'originalGroups', 'originalWidgets']), dirty () { if (!this.editMode || !isTrackingEdits.value) { return false } - return isDirty(this.pageGroups, this.pageGroupWidgets) + return this.isDirty(this.page.id, this.pageGroups, this.pageGroupWidgets) }, editMode: function () { return editMode.value && editPage.value === this.$route.meta.id @@ -27,12 +31,11 @@ export default { methods: { initializeEditTracking () { if (this.editMode && !isTrackingEdits.value) { - startEditTracking(this.pageGroups, this.pageGroupWidgets) + this.$store.dispatch('wysiwyg/beginEditTracking', { groups: this.pageGroups, widgets: this.pageGroupWidgets }) } }, acceptChanges () { - // TODO: implement widgets - updateEditTracking(this.pageGroups, this.pageGroupWidgets) + this.$store.dispatch('wysiwyg/updateEditTracking', { groups: this.pageGroups, widgets: this.pageGroupWidgets }) }, exitEditMode () { const url = new URL(window.location.href) @@ -41,65 +44,107 @@ export default { delete query['edit-key'] this.$router.replace({ query }) window.history.replaceState({}, document.title, url) - exitEditMode() // EditTracking method + this.$store.dispatch('wysiwyg/endEditTracking') }, revertEdits () { - const originalGroupValues = originalGroups.value || [] - // scan through each group and revert changes - const groupPropertiesOfInterest = ['width', 'height', 'order'] - const widgetLayoutPropertiesOfInterest = ['width', 'height', 'order'] - const widgetPropsPropertiesOfInterest = ['width', 'height', 'order'] + console.log('revertEdits') + this.$store.dispatch('wysiwyg/revertEdits').then(() => { + console.log('reverted edits') + this.$emit('refresh-state-from-store') + }).catch((error) => { + console.error('Error reverting edits', error) + }) + }, + async deployChanges ({ dashboard, page, groups, widgets }) { + console.log('deployChanges', dashboard, page, groups, widgets) + const normalisedWidgetsAll = new Map() + const normaliseWidget = widget => { + return { + id: widget.id, + type: widget.type, + ...widget.props + } + } + + // first normalise the widgets ready for Node-RED + const widgetGroups = Object.keys(widgets) + for (const groupId of widgetGroups) { + const groupWidgets = widgets[groupId] + if (!groupWidgets?.length) { continue } + const normalised = groupWidgets.map(normaliseWidget) + for (const widget of normalised) { + if (!widget.id || !widget.type) { + return Promise.reject(new Error('Widget is missing id or type')) + } + normalisedWidgetsAll.set(widget.id, widget) + } + } - originalGroupValues.forEach((originalGroup, index) => { + // determine which widgets have been added and removed + const originalGroupsLookup = this.originalGroups || {} + const currentPageGroups = [] + for (const key in originalGroupsLookup) { + const originalGroup = originalGroupsLookup[key] + if (!originalGroup || originalGroup.page !== page) { + continue // skip groups that are not on this page + } const pageGroup = this.pageGroups?.find(group => group.id === originalGroup.id) if (!pageGroup) { console.warn('Group not found in pageGroups - as we do not currently support adding/removing groups, this should not happen!') - return + throw new Error('Group not found in pageGroups') } - // restore group properties - for (const prop of groupPropertiesOfInterest) { - if (originalGroup[prop] !== pageGroup[prop]) { - pageGroup[prop] = originalGroup[prop] - } + currentPageGroups.push(pageGroup) + } + + // look for added / removed widgets. + const originalWidgetsLookup = this.originalWidgets || {} + // If a widget is in the original list but not in the current list, it has been removed + for (const key in originalWidgetsLookup) { + const originalWidget = originalWidgetsLookup[key] + const widget = normalisedWidgetsAll.get(originalWidget.id) + if (!currentPageGroups.find(group => group.id === originalWidget?.props?.group)) { + continue // skip widgets that are not in a group we are tracking } - // restore widget properties - const originalWidgetValues = originalWidgets.value?.[originalGroup.id] || [] - const pageWidgets = this.pageGroupWidgets?.[originalGroup.id] || [] - originalWidgetValues.forEach((originalWidget, index) => { - const pageWidget = pageWidgets.find(widget => widget.id === originalWidget.id) - const widgetIndex = pageWidgets.indexOf(pageWidget) - if (!pageWidget) { - console.warn('Widget not found in pageGroupWidgets - as we do not currently support adding/removing widgets, this should not happen!') - return - } - if (widgetIndex !== index) { - pageWidgets.splice(widgetIndex, 1) - pageWidgets.splice(index, 0, pageWidget) + if (!widget) { + if (originalWidget.type !== 'ui-spacer') { + throw new Error('Only spacers can be removed') } - for (const prop of widgetPropsPropertiesOfInterest) { - if (originalWidget.props?.[prop] !== pageWidget.props?.[prop]) { - pageWidget.props[prop] = originalWidget.props[prop] - } + const removeWidget = { + __DB2_REMOVE_WIDGET: true, + id: originalWidget.id, + type: originalWidget.type, + ...originalWidget.props } - for (const prop of widgetLayoutPropertiesOfInterest) { - if (originalWidget.layout?.[prop] !== pageWidget.layout?.[prop]) { - pageWidget.layout[prop] = originalWidget.layout[prop] - } - } - }) - }) - }, - deployChanges ({ dashboard, page, groups, widgets }) { - const normalisedWidgets = {} - for (const widgetKey in widgets) { - normalisedWidgets[widgetKey] = widgets[widgetKey].map(widget => { - return { - id: widget.id, - ...widget.props - } - }) + normalisedWidgetsAll.set(removeWidget.id, removeWidget) + } } - return NodeRedApi.deployChanges({ dashboard, page, groups, widgets: normalisedWidgets, key: editKey.value, editorPath: editorPath.value }) + + // If a widget is in normalisedWidgets but not in the original list, it has been added + const normalisedWidgets = Array.from(normalisedWidgetsAll.values()).filter(widget => currentPageGroups.find(group => group.id === widget.group)) || [] + for (const widget of normalisedWidgets) { + // sanity check - all widgets must belong to a group and that group must be on the page + if (!widget.group || !currentPageGroups.find(group => group.id === widget.group)) { + throw new Error('Widget does not belong to a group on this page') + } + const origWidget = originalWidgetsLookup[widget.id] + if (!origWidget) { + widget.__DB2_ADD_WIDGET = true + } + } + + if (!normalisedWidgets.length || !currentPageGroups.length) { + console.warn('No changes to deploy - it should not be possible to get here!') + return Promise.resolve() + } + + return NodeRedApi.deployChanges({ + dashboard, + page, + groups: currentPageGroups, + widgets: normalisedWidgets, + key: editKey.value, + editorPath: editorPath.value + }) } } } diff --git a/ui/src/layouts/wysiwyg/resizable.scss b/ui/src/layouts/wysiwyg/resizable.scss index f496ed242..e77279e44 100644 --- a/ui/src/layouts/wysiwyg/resizable.scss +++ b/ui/src/layouts/wysiwyg/resizable.scss @@ -7,6 +7,11 @@ --handle-size: 12px; --handle-opacity: 0.1; // initially mostly transparent - updated in hover --handle-accent: transparent; // initially no accent color - updated in hover + /* transitions to match vuetify cards/buttons etc */ + transition-property: box-shadow, transform, opacity, border; + transition-duration: 0.28s; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + border-color: 0px dashed transparent; cursor: grab; &.resizing { z-index: 1000 !important; @@ -25,6 +30,10 @@ border: 1px solid black; border-radius: 6px; opacity: var(--handle-opacity); + /* transitions to match vuetify cards/buttons etc */ + transition-property: box-shadow, transform, opacity, background; + transition-duration: 0.28s; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); &:hover, &:active { background-color: #eee; --handle-opacity: 1; @@ -71,3 +80,31 @@ display: inline-block; background-color: var(--handle-accent); } + +.nrdb-resizable { + --toolbar-opacity: 0; + &:hover { + --toolbar-opacity: 1; + } + .nrdb-resizable--toolbar { + position: absolute; + top: 8px; + right: 12px; + z-index: 101; + opacity: var(--toolbar-opacity, 0); + &:hover { + --toolbar-opacity: 1; + } + > .nrdb-resizable--toolbar-button { + width: 20px; // override theme sizing + height: 20px; + min-height: 20px; + min-width: 20px; + cursor: pointer; + &:hover { + --toolbar-opacity: 1; + } + } + } +} + diff --git a/ui/src/store/index.mjs b/ui/src/store/index.mjs index f08bf4c7a..3564dc4ec 100644 --- a/ui/src/store/index.mjs +++ b/ui/src/store/index.mjs @@ -4,12 +4,14 @@ import { createStore } from 'vuex' import ui from './ui.mjs' import data from './data.mjs' import setup from './setup.mjs' +import wysiwyg from './wysiwyg.mjs' export default createStore({ modules: { ui, data, - setup + setup, + wysiwyg }, plugins: [] }) diff --git a/ui/src/store/ui.mjs b/ui/src/store/ui.mjs index 0a08b0c11..4de7f11b2 100644 --- a/ui/src/store/ui.mjs +++ b/ui/src/store/ui.mjs @@ -1,9 +1,40 @@ +/** + * @typedef {Object} Group + * @property {string} id - the group ID + * @property {'ui-group'} type + * @property {string} page - owner page ID + * @property {*} [*] - Other group properties + */ +/** + * @typedef {Object} Widget + * @property {string} id - the widget ID + * @property {string} type - the widget type e.g. ui-button, ui-text, ui-template + * @property {Object} props - the widget properties (typically the node properties) + * @property {Object} layout - the widget layout properties + * @property {Number | String} layout.order - order of the widget in the group + * @property {Number | String} layout.width - width of the widget in the group + * @property {Number | String} layout.height - height of the widget in the group + * @property {Object} state + * @property {Object} component + */ +/** + * @typedef {Object.} Widgets - Widget ID to Widget lookup as per ui store state format + */ +/** + * @typedef {Array} PageGroups - Array of Group on the current given page + */ +/** + * @typedef {Object.} PageGroupsWidgets - Look up of Group ID to Array of Widgets for the current page + */ + // initial state const state = () => ({ dashboards: null, pages: null, - groups: null, themes: null, + /** @type {Widgets} */ + groups: null, + /** @type {Widgets} */ widgets: null }) @@ -107,7 +138,6 @@ const getters = { // 4. Flatten the grouped data back into a single array const sorted = sortedGroups.flatMap((e) => e[1]) - // sortData2(widgetsInGroup).map(e=>{ return {widgetorder: e.layout.order, ...{ ...(e.props.subflow ||{})}}}) return sorted } }, diff --git a/ui/src/store/wysiwyg.mjs b/ui/src/store/wysiwyg.mjs new file mode 100644 index 000000000..70d014510 --- /dev/null +++ b/ui/src/store/wysiwyg.mjs @@ -0,0 +1,324 @@ +import { markRaw } from 'vue' + +import { endEditMode, startEditTracking } from '../EditTracking.js' +import UISpacer from '../widgets/ui-spacer/UISpacer.vue' + +// initial state +const state = () => ({ + originalGroups: null, + originalWidgets: null, + shadowWidgets: null // this is used to store widgets that have been removed during an edit session +}) + +// getters +const getters = { + // edit time tracking: + originalGroups (state) { + return state.originalGroups + }, + originalWidgets (state) { + return state.originalWidgets + }, + isDirty: (state, getters, rootState) => (pageId, groups = null, widgets = null) => { + // when groups are passed in, they are an array of groups, convert to a key-value object + if (groups) { + groups = flattenPageGroups(groups) + } + // when widgets are passed in, they are in a nested object {groupId:{ id: widget1, id: widget2 } structure + // so we need to flatten them to a key-value object + if (widgets) { + widgets = flattenPageWidgets(widgets) + } + + const originalWidgets = state.originalWidgets || {} + const currentWidgets = widgets || rootState.ui.widgets || {} + const originalGroups = state.originalGroups || {} + const currentGroups = groups || rootState.ui.groups || {} + + let originalGroupsCount = 0 + let currentGroupsCount = 0 + let originalWidgetsCount = 0 + let currentWidgetsCount = 0 + + // first filter to only items of interest (those matching the currentGroupId) + const originalPageGroups = new Map() + const originalPageGroupsWidgets = new Map() + const currentPageGroups = new Map() + const currentPageGroupsWidgets = new Map() + for (const key in originalGroups) { + if (originalGroups[key].page === pageId) { + originalPageGroups.set(originalGroups[key].id, originalGroups[key]) + originalGroupsCount++ + } + } + for (const key in currentGroups) { + if (currentGroups[key].page === pageId) { + currentPageGroups.set(currentGroups[key].id, currentGroups[key]) + currentGroupsCount++ + } + } + for (const key in originalWidgets) { + if (originalPageGroups.has(originalWidgets[key]?.props?.group)) { + originalPageGroupsWidgets.set(originalWidgets[key].id, originalWidgets[key]) + originalWidgetsCount++ + } + } + for (const key in currentWidgets) { + if (originalPageGroups.has(currentWidgets[key]?.props?.group)) { + currentPageGroupsWidgets.set(currentWidgets[key].id, currentWidgets[key]) + currentWidgetsCount++ + } + } + + // fast test: if the number of groups or widgets has changed, we are dirty + if (originalGroupsCount !== currentGroupsCount) { + return true + } + if (originalWidgetsCount !== currentWidgetsCount) { + return true + } + + // scan test: compare each group and widget + const groupPropertiesToCheck = [ + (original, current) => +original.width === +current.width, + (original, current) => +original.height === +current.height, + (original, current) => +original.order === +current.order + ] + for (let idx = 0; idx < originalGroupsCount.length; idx++) { + const originalGroup = originalGroupsCount[idx] + const group = currentGroups[originalGroup.id] + if (!group) { + return true + } + for (const check of groupPropertiesToCheck) { + if (!check(originalGroup, group)) { + return true + } + } + } + + const widgetPropertiesToCheck = [ + (original, current) => +original.layout?.width === +current.layout?.width, + (original, current) => +original.layout?.height === +current.layout?.height, + (original, current) => +original.layout?.order === +current.layout?.order, + (original, current) => +original.props?.width === +current.props?.width, + (original, current) => +original.props?.height === +current.props?.height, + (original, current) => +original.props?.order === +current.props?.order + ] + const pageGroupsWidgetsArray = Array.from(originalPageGroupsWidgets.values()) + for (let idx = 0; idx < originalWidgetsCount; idx++) { + const originalWidget = pageGroupsWidgetsArray[idx] + const widget = currentWidgets[originalWidget.id] + if (!widget) { + return true // not found must be dirty + } + for (const check of widgetPropertiesToCheck) { + if (!check(originalWidget, widget)) { + return true + } + } + } + return false + } +} + +const mutations = { + originalGroups (state, groups) { + state.originalGroups = groups + }, + originalWidgets (state, widgets) { + state.originalWidgets = widgets + }, + // addWidget (state, widget) { + // state.widgets[widget.id] = widget + // }, + // removeWidget (state, widgetId) { + // state.shadowWidgets[widgetId] = state.widgets[widgetId] + // delete state.widgets[widgetId] + // }, + shadowWidgets (state, widgets) { + state.shadowWidgets = widgets + } +} +const actions = { + addSpacer ({ rootState, state, commit }, { group, name, order, height, width }) { + console.log('addSpacer', group, name, order, height, width) + if (!group) { + throw new Error('group is required') + } + // ensure group exists in state.groups + if (!rootState.ui.groups[group]) { + throw new Error('group does not exist') + } + + const widget = { + id: newId(), + type: 'ui-spacer', + name: 'spacer', + component: markRaw(UISpacer), + props: { + group, + name: name || 'spacer', + tooltip: '', + order: order ?? 0, + width: width ?? 1, + height: height ?? 1, + className: '', + _users: [], + enabled: true, + visible: true + }, + layout: { + order: order ?? 0, + width: width ?? 1, + height: height ?? 1 + }, + state: { enabled: true, visible: true, class: '' } + } + rootState.ui.widgets[widget.id] = widget + commit('ui/widgets', rootState.ui.widgets, { root: true }) + return widget + }, + removeWidget ({ rootState, state, commit }, payload) { + state.shadowWidgets[payload.id] = rootState.ui.widgets[payload.id] + delete rootState.ui.widgets[payload.id] + commit('ui/widgets', rootState.ui.widgets, { root: true }) + }, + /** + * @param {import('vuex').ActionContext} context + * @param {Object} payload + * @param {import('./ui.mjs').Groups} payload.groups + * @param {import('./ui.mjs').Widgets} payload.widgets + */ + beginEditTracking ({ dispatch, state }, { groups, widgets }) { + console.log('beginEditTracking') + startEditTracking() // EditTracking method + dispatch('updateEditTracking', { groups, widgets }) + state.shadowWidgets = {} + }, + /** + * @param {import('vuex').ActionContext} context + * @param {Object} payload + * @param {Array} payload.groups + * @param {import('./ui.mjs').GroupsWidgets} payload.widgets + */ + updateEditTracking ({ commit }, { groups, widgets }) { + console.log('updateEditTracking') + commit('originalGroups', JSON.parse(JSON.stringify(flattenPageGroups(groups)))) + const slimCopy = {} + /** @type {import('./ui.mjs').Widgets} */ + const src = flattenPageWidgets(widgets) + for (const key in src) { + const w = src[key] + slimCopy[key] = { + id: w.id, + type: w.type, + props: { ...w.props }, + layout: { ...w.layout } + } + } + commit('originalWidgets', slimCopy) + }, + revertEdits ({ rootState, state, commit }) { + console.log('revertEdits') + const uiWidgets = rootState.ui.widgets + const uiGroups = rootState.ui.groups + const groupPropertiesOfInterest = ['width', 'height', 'order'] + for (const key in uiGroups) { + const existingGroup = uiGroups[key] + const originalGroup = state.originalGroups[key] + if (!originalGroup) { + // group was added, remove it + delete uiGroups[key] + } + if (originalGroup) { + for (const prop of groupPropertiesOfInterest) { + existingGroup[prop] = originalGroup[prop] + } + } + } + + /** @type {Widgets} */ + const originalWidgets = state.originalWidgets + const widgetLayoutPropertiesOfInterest = ['width', 'height', 'order'] + const widgetPropsPropertiesOfInterest = ['width', 'height', 'order'] + for (const key in originalWidgets) { + const originalWidget = originalWidgets[key] + if (!uiWidgets[key] && originalWidget) { + // widget was removed, bring it back + uiWidgets[key] = state.shadowWidgets[key] + } + if (uiWidgets[key] && !originalWidget) { + // widget was added, remove it + delete uiWidgets[key] + } + if (uiWidgets[key] && originalWidget) { + for (const prop of widgetPropsPropertiesOfInterest) { + uiWidgets[key].props[prop] = originalWidget.props[prop] + } + for (const prop of widgetLayoutPropertiesOfInterest) { + uiWidgets[key].layout[prop] = originalWidget.layout[prop] + } + } + } + // now scan for widgets that were added to uiWidgets and do not exist in originalWidgets + for (const key in uiWidgets) { + if (!originalWidgets[key]) { + delete uiWidgets[key] + } + } + commit('shadowWidgets', {}) // reset the shadow widgets since we have reverted the changes + commit('ui/widgets', uiWidgets, { root: true }) + commit('ui/groups', uiGroups, { root: true }) + }, + endEditTracking ({ commit }) { + console.log('endEditTracking') + endEditMode() // EditTracking method + commit('originalGroups', null) + commit('originalWidgets', null) + commit('shadowWidgets', null) + } +} + +function newId () { + return Date.now().toString(16).slice(2, 10) + Math.random().toString(16).slice(2, 10) +} + +/** + * Flattens the PageGroups array to a flat lookup object + * @param {import('./ui.mjs').PageGroups} groups + * @returns {Object.} + */ +function flattenPageGroups (groups) { + const flat = {} + for (const key in groups) { + const g = groups[key] + flat[g.id] = g + } + return flat +} + +/** + * Flattens the PageGroupsWidgets object to a flat lookup object + * @param {import('./ui.mjs').PageGroupsWidgets} groupWidgets + * @returns {Object.} - Widget ID to Widget lookup + */ +function flattenPageWidgets (groupWidgets) { + const flat = {} + for (const groupId in groupWidgets) { + const widgets = groupWidgets[groupId] + for (let idx = 0; idx < widgets.length; idx++) { + const w = widgets[idx] + flat[w.id] = w + } + } + return flat +} + +export default { + namespaced: true, + actions, + state, + getters, + mutations +}