From 0864b52e0902414e6b15e9c14e2b73c005991561 Mon Sep 17 00:00:00 2001 From: Mark Herwege Date: Tue, 15 Apr 2025 23:34:23 +0200 Subject: [PATCH] Persistence edit: Support configuring aliases (#3070) Configuration in the UI for persistence alias changes introduced in https://github.com/openhab/openhab-core/pull/4363. Signed-off-by: Mark Herwege --- .../config/controls/item-picker.vue | 14 +- .../settings/persistence/persistence-edit.vue | 176 +++++++++++++++++- 2 files changed, 182 insertions(+), 8 deletions(-) diff --git a/bundles/org.openhab.ui/web/src/components/config/controls/item-picker.vue b/bundles/org.openhab.ui/web/src/components/config/controls/item-picker.vue index 68533e7c9..5f7bdf99f 100644 --- a/bundles/org.openhab.ui/web/src/components/config/controls/item-picker.vue +++ b/bundles/org.openhab.ui/web/src/components/config/controls/item-picker.vue @@ -7,11 +7,13 @@ {{ item.label ? item.label + ' (' + item.name + ')' : item.name }} - + + - + + @@ -30,12 +32,16 @@ import ModelPickerPopup from '@/components/model/model-picker-popup.vue' export default { - props: ['title', 'name', 'value', 'items', 'multiple', 'filterType', 'required', 'editableOnly', 'disabled', 'setValueText'], + props: ['title', 'name', 'value', 'items', 'multiple', 'filterType', 'required', 'editableOnly', 'disabled', 'setValueText', 'noModelPicker', + 'iconColor', 'auroraIcon', 'iosIcon', 'mdIcon'], data () { return { ready: false, preparedItems: [], - icons: {}, + aurora: this.auroraIcon || 'f7:list_bullet_indent', + ios: this.iosIcon || 'f7:list_bullet_indent', + md: this.mdIcon || 'f7:list_bullet_indent', + color: this.iconColor || undefined, smartSelectParams: { view: this.$f7.view.main, openIn: 'popup', diff --git a/bundles/org.openhab.ui/web/src/pages/settings/persistence/persistence-edit.vue b/bundles/org.openhab.ui/web/src/pages/settings/persistence/persistence-edit.vue index 5f6f7b933..a47a339dd 100644 --- a/bundles/org.openhab.ui/web/src/pages/settings/persistence/persistence-edit.vue +++ b/bundles/org.openhab.ui/web/src/pages/settings/persistence/persistence-edit.vue @@ -67,6 +67,14 @@ +
+ + Aliases + + + + +
@@ -173,6 +181,45 @@ + +
+ + Aliases + + + + +
+ {{ i }} +
+
+ +
+ + + Delete + + +
+
+ + + +
@@ -215,6 +262,21 @@ .list margin-top 0 +.list-alias-item .item-content .item-inner + display: flex + align-items: center +.alias-label + min-width: 20% + margin-right: 5% + flex-shrink: 0 + font-weight: var(--f7-list-media-item-title-font-weight, var(--f7-list-item-title-font-weight, inherit)) +.alias-input + flex-grow: 1 + .input input + text-align: right +.alias-item-picker .item-picker .item-content + padding-left: calc(var(--f7-list-item-padding-horizontal) + var(--f7-safe-area-left)) + .persistence-code-editor.vue-codemirror display block top calc(var(--f7-navbar-height) + var(--f7-tabbar-height)) @@ -230,6 +292,7 @@ import fastDeepEqual from 'fast-deep-equal/es6' import DirtyMixin from '../dirty-mixin' import { FilterTypes, PredefinedStrategies } from '@/assets/definitions/persistence' import CronStrategyPopup from '@/pages/settings/persistence/cron-strategy-popup.vue' +import ItemPicker from '@/components/config/controls/item-picker.vue' import StrategyPicker from '@/pages/settings/persistence/strategy-picker.vue' import ConfigurationPopup from '@/pages/settings/persistence/configuration-popup.vue' import FilterPopup from '@/pages/settings/persistence/filter-popup.vue' @@ -237,6 +300,7 @@ import FilterPopup from '@/pages/settings/persistence/filter-popup.vue' export default { mixins: [DirtyMixin], components: { + ItemPicker, StrategyPicker, 'editor': () => import(/* webpackChunkName: "script-editor" */ '@/components/config/controls/script-editor.vue') }, @@ -277,6 +341,9 @@ export default { if (this.persistence[filterTypeName]) names = names.concat(this.persistence[filterTypeName].map((f) => f.name)) } return names + }, + currentItemsWithAlias () { + return Object.keys(this.persistence.aliases).sort() } }, watch: { @@ -306,6 +373,7 @@ export default { this.persistence = { serviceId: this.serviceId, configs: [], + aliases: [], defaults: [ 'everyChange' ], @@ -352,13 +420,16 @@ export default { } }) }, - save (noToast) { + async save (noToast) { if (!this.editable) return if (this.currentTab === 'code') this.fromYaml() // Update the code tab if (this.persistenceYaml) this.toYaml() + const saveConfirmed = await this.validateAliases() + if (!saveConfirmed) return + return this.$oh.api.put('/rest/persistence/' + this.persistence.serviceId, this.persistence).then((data) => { this.dirty = false if (this.newPersistence) { @@ -525,6 +596,70 @@ export default { }) this.deleteModule(ev, module, index) }, + updateAliasItems (items) { + if (!this.editable) return + const aliases = this.persistence.aliases + Object.keys(aliases) + .filter((i) => !items.includes(i)) + .forEach((i) => { delete aliases[i] }) + items + .filter((i) => !Object.keys(aliases).includes(i)) + .forEach((i) => { aliases[i] = '' }) + const newAliases = Object.keys(aliases) + .reduce((obj, key) => { + obj[key] = aliases[key] + return obj + }, {}) + this.$set(this.persistence, 'aliases', newAliases) + }, + editAlias (ev, item, alias) { + if (!this.editable) return + // Warn when alias already exists + const duplicate = Object.entries(this.persistence.aliases).find(([i, a]) => (item !== i) && (alias === a)) + if (duplicate) { + this.$f7.dialog.alert('Alias ' + alias + ' for item ' + item + ' already exists for item ' + duplicate[0]) + this.$set(this.persistence.aliases, item, '') + return + } + this.$set(this.persistence.aliases, item, alias) + }, + deleteAlias (ev, item) { + this.deleteModuleKey(ev, 'aliases', item) + }, + async validateAliases () { + const entries = Object.entries(this.persistence.aliases) + // Check for invalid alias format + const invalidEntry = entries.find(([i, a]) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(a)) + if (invalidEntry) { + const confirmed = await this.showConfirmDialog( + `Alias not valid for item ${invalidEntry[0]}!\nSave anyway?`, + 'Alias Validation Error' + ) + if (!confirmed) return false + } + // Check for duplicate aliases + for (let idx = 1; idx < entries.length; idx++) { + const firstIdx = entries.slice(0, idx).findIndex(([i, a]) => a === entries[idx][1]) + if (firstIdx >= 0) { + const confirmed = await this.showConfirmDialog( + `Alias "${entries[idx][1]}" for item "${entries[idx][0]}" already exists for item "${entries[firstIdx][0]}".\nSave anyway?`, + 'Alias Validation Error' + ) + if (!confirmed) return false + } + } + return true + }, + showConfirmDialog (message, title) { + return new Promise((resolve) => { + this.$f7.dialog.confirm( + message, + title, + () => resolve(true), + () => resolve(false) + ) + }) + }, saveModule (module, index, updatedModule) { if (index === null) { console.debug(`Adding ${module}:`) @@ -539,8 +674,8 @@ export default { this.checkDirty() }, deleteModule (ev, module, index) { - let swipeoutElement = ev.target if (!this.editable) return + let swipeoutElement = ev.target ev.cancelBubble = true while (!swipeoutElement.classList.contains('swipeout')) { swipeoutElement = swipeoutElement.parentElement @@ -552,6 +687,20 @@ export default { this.checkDirty() }) }, + deleteModuleKey (ev, module, key) { + if (!this.editable) return + let swipeoutElement = ev.target + ev.cancelBubble = true + while (!swipeoutElement.classList.contains('swipeout')) { + swipeoutElement = swipeoutElement.parentElement + } + this.$f7.swipeout.delete(swipeoutElement, () => { + console.debug(`Removing ${module}:`) + console.debug(key) + this.$delete(this.persistence[module], key) + this.checkDirty() + }) + }, onEditorInput (value) { this.persistenceYaml = value this.dirty = true @@ -559,6 +708,7 @@ export default { toYaml () { const toCode = { configurations: this.persistence.configs, + aliases: this.persistence.aliases, cronStrategies: this.persistence.cronStrategies, defaultStrategies: this.persistence.defaults } @@ -572,6 +722,7 @@ export default { try { const updatedPersistence = YAML.parse(this.persistenceYaml) this.$set(this.persistence, 'configs', updatedPersistence.configurations) + this.$set(this.persistence, 'aliases', updatedPersistence.aliases) this.$set(this.persistence, 'cronStrategies', updatedPersistence.cronStrategies) this.$set(this.persistence, 'defaults', updatedPersistence.defaultStrategies) this.FilterTypes.forEach((ft) => { @@ -583,8 +734,25 @@ export default { return false } }, - keyDown (ev) { - if ((ev.ctrlKey || ev.metaKey) && !(ev.altKey || ev.shiftKey)) { + keyDown (ev, index) { + if (ev.key === 'Tab') { + ev.stopPropagation() + ev.preventDefault() + const newIndex = index || 0 + const total = this.currentItemsWithAlias.length + let targetIndex + if (ev.shiftKey) { + targetIndex = newIndex - 1 < 0 ? total - 1 : newIndex - 1 + } else { + targetIndex = newIndex + 1 >= total ? 0 : newIndex + 1 + } + const ref = this.$refs[`alias-input-${targetIndex}`] + const target = Array.isArray(ref) ? ref[0] : ref + if (target && target.$el) { + const inputEl = target.$el.querySelector('input') + if (inputEl) inputEl.focus() + } + } else if ((ev.ctrlKey || ev.metaKey) && !(ev.altKey || ev.shiftKey)) { switch (ev.keyCode) { case 83: this.save()