Skip to content

Commit 053e3a4

Browse files
committed
Simplified useTab template
Signed-off-by: Jeff James <jeff@james-online.com> (+5 squashed commits) Squashed commits: [10a262d] WIP Signed-off-by: Jeff James <jeff@james-online.com> [809cb47] WIP (+1 squashed commit) Squashed commits: [0ec8ddd] WIP (+3 squashed commits) Squashed commits: [0b1a9f3c] WIP [7d3dd91b] WIP [783e4ba5] remove skipLoadOnReturn Signed-off-by: Mark Herwege <mark.herwege@telenet.be> parallel load of suggestions Signed-off-by: Mark Herwege <mark.herwege@telenet.be> darkMode Signed-off-by: Mark Herwege <mark.herwege@telenet.be> dark mode Signed-off-by: Mark Herwege <mark.herwege@telenet.be> fix 404 Signed-off-by: Mark Herwege <mark.herwege@telenet.be> make load async Signed-off-by: Mark Herwege <mark.herwege@telenet.be> move PredefinedStrategies to configuration popup Signed-off-by: Mark Herwege <mark.herwege@telenet.be> remove unused filters field from persistence Signed-off-by: Mark Herwege <mark.herwege@telenet.be> cron expression smart select Signed-off-by: Mark Herwege <mark.herwege@telenet.be> make loading local variable Signed-off-by: Mark Herwege <mark.herwege@telenet.be> adjust theme color logic Signed-off-by: Mark Herwege <mark.herwege@telenet.be> Simplified cronexpression-editor Signed-off-by: Jeff James <jeff@james-online.com> avoid error when deleting persistence Signed-off-by: Mark Herwege <mark.herwege@telenet.be> avoid error when backing out of persistence with no configs Signed-off-by: Mark Herwege <mark.herwege@telenet.be> WIP WIP WIP WIP WIP WIP (+3 squashed commits) Squashed commits: [5af2284] avoid definitions nav-right error when not fully initialized Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [8f3fbb2] no more cron expression open method Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [95fbd52] fix documentation formatting Signed-off-by: Mark Herwege <mark.herwege@telenet.be> (+3 squashed commits) Squashed commits: [d515457] architecture documentation Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [aacc047] format Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [97d5be1] fix cronexpression (+1 squashed commit) Squashed commits: [b136ef8] copilot review adjustments Signed-off-by: Mark Herwege <mark.herwege@telenet.be> (+1 squashed commit) Squashed commits: [5601831] format Signed-off-by: Mark Herwege <mark.herwege@telenet.be> (+1 squashed commit) Squashed commits: [5ac8d34] fix cron smart select restore Signed-off-by: Mark Herwege <mark.herwege@telenet.be> (+19 squashed commits) Squashed commits: [0e7006d] fixes Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [ec3e529] fix Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [e4fd80a] alias fixes Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [2039dad] format Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [186c516] field subtitle change Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [417c961] fixes Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [de169aa] use hey api Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [237d8ba] format Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [5006859] refactor to simplify persistence config for user Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [c1ae402] cron expression editor fixes Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [d63d90e] delete confirm dialog Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [db2ed60] move definitions to separate page Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [7405b3a] graphic filter selection Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [01b8c4d] screen space optimization Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [639bc44] improved configuration visualization Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [0cfc446] format Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [aac4ad4] single add filter link Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [ce3b85d] fix Signed-off-by: Mark Herwege <mark.herwege@telenet.be> [680c04c] persistence configuration Signed-off-by: Mark Herwege <mark.herwege@telenet.be> Signed-off-by: Jeff James <jeff@james-online.com> [af41b46] ready for PR Signed-off-by: Jeff James <jeff@james-online.com> [b4ea740] WIP Signed-off-by: Jeff James <jeff@james-online.com> [b3a26a9] Update per openhab-core PR#5404 Signed-off-by: Jeff James <jeff@james-online.com>
1 parent 3c11bc2 commit 053e3a4

File tree

18 files changed

+1881
-1097
lines changed

18 files changed

+1881
-1097
lines changed

bundles/org.openhab.ui/web/src/assets/definitions/persistence.js renamed to bundles/org.openhab.ui/web/src/assets/definitions/persistence.ts

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1-
export const PredefinedStrategies = ['everyChange', 'everyUpdate', 'restoreOnStartup', 'forecast']
1+
import type { InjectionKey, Ref } from 'vue'
2+
import * as api from '@/api'
23

3-
export const CommonCronStrategies = [
4+
export const persistenceKey: InjectionKey<Ref<api.PersistenceServiceConfiguration>> = Symbol('persistence')
5+
6+
export const PredefinedStrategies: api.PersistenceStrategy[] = [
7+
{ name: 'everyChange' },
8+
{ name: 'everyUpdate' },
9+
{ name: 'restoreOnStartup' },
10+
{ name: 'forecast' }
11+
]
12+
13+
export const CommonCronStrategies: api.PersistenceCronStrategy[] = [
414
{
515
name: 'everyMinute',
616
cronExpression: '0 * * ? * *'
@@ -23,18 +33,45 @@ const filterInvertedParameter = {
2333
name: 'inverted',
2434
required: false,
2535
type: 'BOOLEAN'
36+
} satisfies api.ConfigDescriptionParameter
37+
38+
export interface FilterType {
39+
footerFn: (f: api.PersistenceFilter) => string
40+
configDescriptionParameters: api.ConfigDescriptionParameter[]
41+
name: string
42+
icon: string
43+
label: string
44+
}
45+
46+
export enum FilterTypeName {
47+
ThresholdFilters = 'thresholdFilters',
48+
TimeFilters = 'timeFilters',
49+
EqualsFilters = 'equalsFilters',
50+
IncludeFilters = 'includeFilters'
51+
}
52+
53+
export interface FiltersDefinition {
54+
[FilterTypeName.ThresholdFilters]: api.PersistenceFilter[]
55+
[FilterTypeName.TimeFilters]: api.PersistenceFilter[]
56+
[FilterTypeName.EqualsFilters]: api.PersistenceFilter[]
57+
[FilterTypeName.IncludeFilters]: api.PersistenceFilter[]
58+
}
59+
60+
export interface Filter {
61+
filterTypeName: FilterTypeName
62+
filter: api.PersistenceFilter
2663
}
2764

2865
/**
2966
* Filter configuration is completely based on these definitions.
3067
* However, please note that some for some filter types validation and checks are added to persistence-edit.vue in editFilter(), saveFilter() and filter-popup.vue.
3168
*
32-
* @type {[{footerFn: (function(*): string|*), configDescriptionParameters: [{advanced: boolean, name: string, description: string, label: string, type: string, required: boolean},{advanced: boolean, name: string, description: string, label: string, type: string, required: boolean},{advanced: boolean, name: string, description: string, label: string, type: string, required: boolean}], name: string, label: string},{footerFn: (function(*): string), configDescriptionParameters: [{advanced: boolean, name: string, description: string, label: string, type: string, required: boolean},{limitToOptions: boolean, advanced: boolean, multiple: boolean, name: string, options: [{label: string, value: string},{label: string, value: string},{label: string, value: string},{label: string, value: string}], description: string, label: string, type: string, required: boolean}], name: string, label: string},{footerFn: (function(*): string), configDescriptionParameters: [{advanced: boolean, name: string, description: string, label: string, type: string, required: boolean},{advanced: boolean, name: string, description: string, label: string, type: string, required: boolean}], name: string, label: string},{footerFn: (function(*): string), configDescriptionParameters: [{advanced: boolean, name: string, description: string, label: string, type: string, required: boolean},{advanced: boolean, name: string, description: string, label: string, type: string, required: boolean},{advanced: boolean, name: string, description: string, label: string, type: string, required: boolean},{advanced: boolean, name: string, description: string, label: string, type: string, required: boolean}], name: string, label: string}]}
3369
*/
34-
export const FilterTypes = [
35-
{
70+
export const FilterTypes: Record<FilterTypeName, FilterType> = {
71+
[FilterTypeName.ThresholdFilters]: {
3672
name: 'thresholdFilters',
3773
label: 'Threshold',
74+
icon: 'arrow_up_right_circle',
3875
configDescriptionParameters: [
3976
{
4077
advanced: false,
@@ -61,11 +98,12 @@ export const FilterTypes = [
6198
type: 'TEXT'
6299
}
63100
],
64-
footerFn: (f) => (f.relative ? f.value + ' %' : f.unit ? f.value + ' ' + f.unit : f.value)
101+
footerFn: (f) => (f.relative ? `${f.value} %` : f.unit ? `${f.value} ${f.unit}` : `${f.value ?? ''}`)
65102
},
66-
{
103+
[FilterTypeName.TimeFilters]: {
67104
name: 'timeFilters',
68105
label: 'Time',
106+
icon: 'clock',
69107
configDescriptionParameters: [
70108
{
71109
advanced: false,
@@ -94,9 +132,10 @@ export const FilterTypes = [
94132
],
95133
footerFn: (f) => f.value + ' ' + (f.unit || 's')
96134
},
97-
{
135+
[FilterTypeName.EqualsFilters]: {
98136
name: 'equalsFilters',
99137
label: 'Equals/Not Equals',
138+
icon: 'equal_circle',
100139
configDescriptionParameters: [
101140
{
102141
advanced: false,
@@ -105,15 +144,16 @@ export const FilterTypes = [
105144
label: 'Values',
106145
name: 'values',
107146
required: true,
108-
type: ''
147+
type: 'TEXT'
109148
},
110149
filterInvertedParameter
111150
],
112-
footerFn: (f) => (f.inverted === true ? 'not ' : '') + 'equals ' + f.values.join(', ')
151+
footerFn: (f) => (f.inverted === true ? 'not ' : '') + 'equals ' + f.values?.join(', ')
113152
},
114-
{
153+
[FilterTypeName.IncludeFilters]: {
115154
name: 'includeFilters',
116155
label: 'Include/Exclude',
156+
icon: 'arrow_left_right_square',
117157
configDescriptionParameters: [
118158
{
119159
advanced: false,
@@ -144,4 +184,17 @@ export const FilterTypes = [
144184
footerFn: (f) =>
145185
(f.inverted === true ? ']' : '[') + f.lower + ';' + f.upper + (f.inverted === true ? '[' : ']' + (f.unit ? ' ' + f.unit : ''))
146186
}
147-
]
187+
}
188+
189+
export const emptyPersistenceServiceConfig = {
190+
serviceId: '',
191+
configs: [],
192+
aliases: {},
193+
cronStrategies: [],
194+
defaults: [],
195+
timeFilters: [],
196+
equalsFilters: [],
197+
thresholdFilters: [],
198+
includeFilters: [],
199+
editable: true
200+
} satisfies api.PersistenceServiceConfiguration

bundles/org.openhab.ui/web/src/components/persistence/item-persistence-details.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ const loadService = async (service: api.PersistenceService): Promise<Persistence
114114
115115
let itemsPersisted: api.PersistenceItemInfo | 'not_persisted' | 'unsupported' = 'unsupported'
116116
try {
117-
itemsPersisted = (await api.getItemsForPersistenceService({ serviceId: service.id, itemName: props.item.name }))![0] ?? 'not_persisted'
117+
itemsPersisted = (await api.getItemsForPersistenceService({ serviceId: service.id, itemName: props.item.name }))![0]!
118118
} catch (err: unknown) {
119119
if (err instanceof ApiError && err.response.status === 404) {
120120
itemsPersisted = 'not_persisted'
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef } from 'vue'
2+
import { f7 } from 'framework7-vue'
3+
import Dom7 from 'dom7'
4+
5+
import { showConfirmDialog } from '@/js/dialog-promises'
6+
7+
type TemplateRefTarget = HTMLElement | { $el?: unknown; el?: unknown } | null | undefined
8+
9+
// Use this helper on pages that have both a design/object tab and a code tab.
10+
// It keeps both representations in sync by converting to code before entering
11+
// the code tab and parsing code back into object state when leaving it.
12+
export async function confirmCodeSwitch(toCode: () => void, fromCode: () => void, tabTo: string, tabFrom: string): Promise<boolean> {
13+
if (tabTo === 'code') {
14+
toCode()
15+
return true
16+
} else if (tabFrom === 'code') {
17+
try {
18+
fromCode()
19+
} catch (e: unknown) {
20+
return await showConfirmDialog(
21+
'Error parsing code - switching tabs will discard changes. Do you want to continue?<BR><BR>' +
22+
(e instanceof Error ? e.message : String(e)),
23+
'Error parsing code'
24+
)
25+
}
26+
}
27+
return true
28+
}
29+
30+
// To use useTabs, ensure the tabbar has a ref and the tab links have oh-tab-link attributes instead of the default tab-link attributes. Example:
31+
// <f7-toolbar ref="myTabs" tabbar position="top">
32+
// <f7-link oh-tab-link="#design" tab-link-active> Design </f7-link>
33+
// <f7-link oh-tab-link="#code"> Code </f7-link>
34+
// </f7-toolbar>
35+
export function useTabs(
36+
canSwitchTo: (tabTo: string, tabFrom: string) => Promise<boolean> | boolean = () => true,
37+
tabbarRefOrName: string | TemplateRefTarget
38+
) {
39+
const currentTab = ref<string | null>(null)
40+
const tabbarRef =
41+
typeof tabbarRefOrName === 'string'
42+
? useTemplateRef<TemplateRefTarget>(tabbarRefOrName)
43+
: shallowRef<TemplateRefTarget>(tabbarRefOrName)
44+
45+
let highlightEl: HTMLElement | null = null
46+
let tabbarEl: HTMLElement | null = null
47+
let tabElements: HTMLElement[] = []
48+
let tabbarClickHandler: ((event: Event) => void) | null = null
49+
50+
function normalizeTabSelector(tab: string): string {
51+
return tab.startsWith('#') ? tab : `#${tab}`
52+
}
53+
54+
function getTabsScopeElement(): ParentNode {
55+
if (!tabbarEl) return document
56+
return tabbarEl.closest('.page') || document
57+
}
58+
59+
function isActiveTab(tabSelector: string): boolean {
60+
const activeTabEl = getActiveTab()
61+
return !!activeTabEl && activeTabEl.getAttribute('id') === tabSelector.slice(1)
62+
}
63+
64+
function getActiveTab(): Element | null {
65+
const tabsScopeEl = getTabsScopeElement()
66+
67+
for (const tabLinkEl of tabElements) {
68+
const tabSelector = getTabSelectorFromLinkEl(tabLinkEl)
69+
if (!tabSelector) continue
70+
const targetTabEl = Dom7(tabsScopeEl).find(tabSelector)
71+
if (targetTabEl.length > 0 && targetTabEl.hasClass('tab-active')) {
72+
return targetTabEl[0] || null
73+
}
74+
}
75+
76+
const activeTabEl = Dom7(tabsScopeEl).find('.tab.tab-active')
77+
if (activeTabEl.length > 0) {
78+
return activeTabEl[0] || null
79+
}
80+
return null
81+
}
82+
83+
function getTabSelectorFromLinkEl(tabLinkEl: HTMLElement): string | null {
84+
const tabTarget = tabLinkEl.getAttribute('oh-tab-link')
85+
if (!tabTarget || !tabTarget.startsWith('#')) return null
86+
return tabTarget ? normalizeTabSelector(tabTarget) : null
87+
}
88+
89+
function getTabLinkElement(tabSelector: string): HTMLElement | null {
90+
const normalizedSelector = normalizeTabSelector(tabSelector)
91+
92+
return tabElements.find((el) => normalizeTabSelector(el.getAttribute('oh-tab-link') || '') === normalizedSelector) || null
93+
}
94+
95+
function resolveElement(target: TemplateRefTarget): HTMLElement | null {
96+
if (target instanceof HTMLElement) return target
97+
if (!target || typeof target !== 'object') return null
98+
if (target.$el instanceof HTMLElement) return target.$el
99+
if (target.el instanceof HTMLElement) return target.el
100+
return null
101+
}
102+
103+
const setupHighlightBar = () => {
104+
if (!tabbarEl) return
105+
106+
highlightEl = document.createElement('div')
107+
highlightEl.className = 'oh-tab-highlight'
108+
const initialSelector = currentTab.value ? normalizeTabSelector(currentTab.value) : '#'
109+
const initialTabLinkEl = getTabLinkElement(initialSelector) || tabElements[0]
110+
Object.assign(highlightEl.style, getHighlightStyle(initialSelector, initialTabLinkEl))
111+
112+
tabbarEl.insertBefore(highlightEl, tabbarEl.firstChild)
113+
}
114+
115+
const getHighlightStyle = (tabSelector: string, tabLinkEl?: HTMLElement) => {
116+
const count = tabElements.length || 1
117+
const width = 100 / count
118+
const resolvedTabLinkEl = tabLinkEl || getTabLinkElement(tabSelector)
119+
const index = resolvedTabLinkEl ? Math.max(0, tabElements.indexOf(resolvedTabLinkEl)) : 0
120+
const isBottom = tabbarEl?.getAttribute('position') === 'bottom'
121+
122+
return {
123+
left: `${index * width}%`,
124+
width: `${width}%`,
125+
top: isBottom ? '0' : 'auto',
126+
bottom: isBottom ? 'auto' : '0'
127+
}
128+
}
129+
130+
const updateHighlight = (tabSelector: string, tabLinkEl?: HTMLElement) => {
131+
if (!highlightEl) return
132+
const style = getHighlightStyle(tabSelector, tabLinkEl)
133+
highlightEl.style.left = style.left
134+
highlightEl.style.width = style.width
135+
highlightEl.style.top = style.top
136+
highlightEl.style.bottom = style.bottom
137+
}
138+
139+
async function switchTab(tab: HTMLElement | string) {
140+
let tabSelector: string | null = typeof tab === 'string' ? normalizeTabSelector(tab) : getTabSelectorFromLinkEl(tab)
141+
const targetTabLinkEl = typeof tab === 'string' ? getTabLinkElement(tabSelector || '') : tab
142+
if (!targetTabLinkEl || !tabSelector) return
143+
144+
if (isActiveTab(tabSelector)) {
145+
return
146+
}
147+
148+
if (canSwitchTo && (await canSwitchTo(tabSelector.slice(1), currentTab.value || ''))) {
149+
currentTab.value = tabSelector.slice(1)
150+
f7.tab.show(tabSelector, targetTabLinkEl)
151+
updateHighlight(tabSelector, targetTabLinkEl)
152+
}
153+
}
154+
155+
onMounted(() => {
156+
tabbarEl = resolveElement(tabbarRef.value)
157+
if (!tabbarEl) {
158+
console.warn(
159+
`[useTabs] Tabbar ref "${typeof tabbarRefOrName === 'string' ? tabbarRefOrName : '(element)'}" did not resolve to an element.`
160+
)
161+
return
162+
}
163+
164+
tabElements = Array.from(tabbarEl.querySelectorAll('[oh-tab-link]'))
165+
currentTab.value = getActiveTab()?.getAttribute('id') || null
166+
167+
// We render our own tab highlight instead of using Framework7's built-in
168+
// tab-link handling. F7 needs link-tab metadata to style/animate the active
169+
// tab, but that also triggers automatic tab switching on click, which bypasses
170+
// canSwitchTo and prevents us from validating/parsing before switching.
171+
if (f7.theme !== 'ios') {
172+
setupHighlightBar()
173+
}
174+
175+
tabbarClickHandler = (event: Event) => {
176+
const clickedEl = (event.target as HTMLElement | null)?.closest('[oh-tab-link]')
177+
if (!(clickedEl instanceof HTMLElement)) return
178+
if (!tabbarEl?.contains(clickedEl)) return
179+
180+
event.preventDefault()
181+
void switchTab(clickedEl)
182+
}
183+
184+
tabbarEl.addEventListener('click', tabbarClickHandler)
185+
})
186+
187+
onBeforeUnmount(() => {
188+
if (tabbarEl && tabbarClickHandler) {
189+
tabbarEl.removeEventListener('click', tabbarClickHandler)
190+
}
191+
})
192+
193+
return {
194+
currentTab,
195+
switchTab
196+
}
197+
}

bundles/org.openhab.ui/web/src/css/app.styl

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ html
6363
--f7-table-card-header-height 48px
6464
--f7-table-head-cell-height 48px
6565
--f7-table-body-cell-height 40px
66+
// toast
67+
--f7-toast-font-size 16px
6668

6769
/* Fixes for desktop devices */
6870
.device-desktop
@@ -254,6 +256,12 @@ html
254256
--f7-bars-link-color #fff
255257
--f7-tabbar-link-inactive-color rgba(255,255,255,0.54)
256258

259+
// Color to autoselect - use as color="auto-dark" on elements where the color should be auto switched between black and white based on dark mode
260+
.color-auto-dark
261+
--f7-theme-color #000
262+
263+
.dark .auto-dark-text
264+
--f7-theme-color #fff
257265

258266
.ios .contextual-toolbar
259267
.delete
@@ -452,3 +460,17 @@ html
452460

453461
.advanced-label + .advanced-label
454462
margin-left 5px
463+
464+
.toast .toast-content
465+
justify-content: center
466+
467+
.oh-tabbar
468+
position relative
469+
overflow hidden
470+
471+
.oh-tab-highlight
472+
position absolute
473+
height 2px
474+
left 0
475+
background-color var(--f7-theme-color, #007AFF)
476+
transition left 0.3s ease-out, width 0.3s ease-out

0 commit comments

Comments
 (0)