Skip to content

Commit f17d0b1

Browse files
ikraamgclaude
andcommitted
Updated dashboard dropdown to use HA panels API
Necessary to fix confusing dashboard listing that showed 4 path variants per custom dashboard, many resolving to the same page. Replaced lovelace/dashboards/list with get_panels WebSocket call (the same API HA's own sidebar uses) and filtered for lovelace panels to show only actual dashboards with their display titles. Extracted buildDashboardList as a testable pure function, which also caught and fixed a deduplication bug where duplicate url_paths could slip through the Set-based check. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bdb05af commit f17d0b1

3 files changed

Lines changed: 282 additions & 50 deletions

File tree

trmnl-ha/ha-trmnl/html/js/device-presets.ts

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,17 @@ interface HassThemes {
2626
themes?: Record<string, unknown>
2727
}
2828

29+
/** Dashboard entry from backend */
30+
interface DashboardEntry {
31+
path: string
32+
title: string
33+
}
34+
2935
/** Home Assistant global object */
3036
interface Hass {
3137
themes?: HassThemes
3238
config?: HassConfig
33-
dashboards?: string[]
39+
dashboards?: DashboardEntry[] | string[]
3440
}
3541

3642
// Extend Window to include Home Assistant global
@@ -64,7 +70,9 @@ export class DevicePresetsManager {
6470
}
6571

6672
#renderPresetOptions(): void {
67-
const select = document.getElementById('devicePreset') as HTMLSelectElement | null
73+
const select = document.getElementById(
74+
'devicePreset',
75+
) as HTMLSelectElement | null
6876
if (!select) return
6977

7078
while (select.options.length > 1) {
@@ -101,7 +109,9 @@ export class DevicePresetsManager {
101109
* Applies selected device preset to form inputs.
102110
*/
103111
applyDevicePreset(): boolean {
104-
const select = document.getElementById('devicePreset') as HTMLSelectElement | null
112+
const select = document.getElementById(
113+
'devicePreset',
114+
) as HTMLSelectElement | null
105115
if (!select) return false
106116

107117
const option = select.options[select.selectedIndex]
@@ -113,8 +123,12 @@ export class DevicePresetsManager {
113123

114124
const device = JSON.parse(option.dataset.device || '{}')
115125

116-
const widthInput = document.getElementById('s_width') as HTMLInputElement | null
117-
const heightInput = document.getElementById('s_height') as HTMLInputElement | null
126+
const widthInput = document.getElementById(
127+
's_width',
128+
) as HTMLInputElement | null
129+
const heightInput = document.getElementById(
130+
's_height',
131+
) as HTMLInputElement | null
118132

119133
if (widthInput && device.viewport?.width) {
120134
widthInput.value = device.viewport.width
@@ -127,15 +141,19 @@ export class DevicePresetsManager {
127141
}
128142

129143
if (device.rotate) {
130-
const rotateSelect = document.getElementById('s_rotate') as HTMLSelectElement | null
144+
const rotateSelect = document.getElementById(
145+
's_rotate',
146+
) as HTMLSelectElement | null
131147
if (rotateSelect) {
132148
rotateSelect.value = device.rotate
133149
rotateSelect.dispatchEvent(new Event('change'))
134150
}
135151
}
136152

137153
if (device.format) {
138-
const formatSelect = document.getElementById('s_format') as HTMLSelectElement | null
154+
const formatSelect = document.getElementById(
155+
's_format',
156+
) as HTMLSelectElement | null
139157
if (formatSelect) {
140158
formatSelect.value = device.format
141159
formatSelect.dispatchEvent(new Event('change'))
@@ -158,7 +176,9 @@ export class DevicePresetsManager {
158176
* Populates theme dropdown from Home Assistant themes.
159177
*/
160178
populateThemePicker(selectedTheme: string | null = null): void {
161-
const themeSelect = document.getElementById('s_theme') as HTMLSelectElement | null
179+
const themeSelect = document.getElementById(
180+
's_theme',
181+
) as HTMLSelectElement | null
162182
if (!themeSelect) return
163183

164184
themeSelect.innerHTML = '<option value="">Default</option>'
@@ -185,7 +205,9 @@ export class DevicePresetsManager {
185205
* Auto-fills language field from Home Assistant configuration.
186206
*/
187207
prefillLanguage(): void {
188-
const langInput = document.getElementById('s_lang') as HTMLInputElement | null
208+
const langInput = document.getElementById(
209+
's_lang',
210+
) as HTMLInputElement | null
189211
if (!langInput) return
190212

191213
if (window.hass?.config?.language && !langInput.value) {
@@ -196,9 +218,12 @@ export class DevicePresetsManager {
196218

197219
/**
198220
* Populates dashboard dropdown from Home Assistant dashboards.
221+
* Supports both { path, title } objects (new) and plain strings (legacy).
199222
*/
200223
populateDashboardPicker(): void {
201-
const select = document.getElementById('dashboardSelector') as HTMLSelectElement | null
224+
const select = document.getElementById(
225+
'dashboardSelector',
226+
) as HTMLSelectElement | null
202227
if (!select) return
203228

204229
while (select.options.length > 1) {
@@ -218,10 +243,15 @@ export class DevicePresetsManager {
218243
return
219244
}
220245

221-
window.hass.dashboards.forEach((path) => {
246+
window.hass.dashboards.forEach((entry) => {
222247
const option = document.createElement('option')
223-
option.value = path
224-
option.textContent = path
248+
if (typeof entry === 'string') {
249+
option.value = entry
250+
option.textContent = entry
251+
} else {
252+
option.value = entry.path
253+
option.textContent = `${entry.title} (${entry.path})`
254+
}
225255
select.appendChild(option)
226256
})
227257
}
@@ -230,8 +260,12 @@ export class DevicePresetsManager {
230260
* Copies selected dashboard path to dashboard input field.
231261
*/
232262
applyDashboardSelection(): boolean {
233-
const select = document.getElementById('dashboardSelector') as HTMLSelectElement | null
234-
const pathInput = document.getElementById('s_path') as HTMLInputElement | null
263+
const select = document.getElementById(
264+
'dashboardSelector',
265+
) as HTMLSelectElement | null
266+
const pathInput = document.getElementById(
267+
's_path',
268+
) as HTMLInputElement | null
235269

236270
if (!select || !pathInput) return false
237271

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* Tests for Dashboard List Builder
3+
*
4+
* Verifies that HA panel data is correctly mapped to dashboard entries
5+
* for the UI dropdown picker.
6+
*/
7+
8+
import { describe, it, expect } from 'bun:test'
9+
import { buildDashboardList } from '../../ui.js'
10+
11+
describe('buildDashboardList', () => {
12+
describe('with null/invalid panels', () => {
13+
it('returns default dashboard when panels is null', () => {
14+
const result = buildDashboardList(null)
15+
16+
expect(result).toEqual([
17+
{ path: '/lovelace/0', title: 'Default Dashboard' },
18+
])
19+
})
20+
21+
it('returns default dashboard when panels is not an object', () => {
22+
// @ts-expect-error - testing invalid input
23+
const result = buildDashboardList('not-an-object')
24+
25+
expect(result).toEqual([
26+
{ path: '/lovelace/0', title: 'Default Dashboard' },
27+
])
28+
})
29+
})
30+
31+
describe('with valid panels', () => {
32+
it('includes only lovelace panels', () => {
33+
const panels = {
34+
lovelace: {
35+
component_name: 'lovelace',
36+
url_path: 'lovelace',
37+
title: 'Overview',
38+
icon: null,
39+
},
40+
energy: {
41+
component_name: 'energy',
42+
url_path: 'energy',
43+
title: 'Energy',
44+
icon: 'mdi:lightning-bolt',
45+
},
46+
map: {
47+
component_name: 'map',
48+
url_path: 'map',
49+
title: 'Map',
50+
icon: 'mdi:map',
51+
},
52+
}
53+
54+
const result = buildDashboardList(panels)
55+
56+
expect(result).toHaveLength(2)
57+
expect(result[0]).toEqual({
58+
path: '/lovelace/0',
59+
title: 'Default Dashboard',
60+
})
61+
expect(result[1]).toEqual({ path: '/lovelace', title: 'Overview' })
62+
})
63+
64+
it('maps custom dashboards with correct paths', () => {
65+
const panels = {
66+
'my-trmnl': {
67+
component_name: 'lovelace',
68+
url_path: 'my-trmnl',
69+
title: 'TRMNL Display',
70+
icon: 'mdi:monitor',
71+
},
72+
kitchen: {
73+
component_name: 'lovelace',
74+
url_path: 'kitchen',
75+
title: 'Kitchen Panel',
76+
icon: null,
77+
},
78+
}
79+
80+
const result = buildDashboardList(panels)
81+
82+
expect(result).toHaveLength(3)
83+
expect(result[1]).toEqual({
84+
path: '/my-trmnl',
85+
title: 'TRMNL Display',
86+
})
87+
expect(result[2]).toEqual({
88+
path: '/kitchen',
89+
title: 'Kitchen Panel',
90+
})
91+
})
92+
93+
it('uses url_path as title when title is null', () => {
94+
const panels = {
95+
untitled: {
96+
component_name: 'lovelace',
97+
url_path: 'untitled-dash',
98+
title: null,
99+
icon: null,
100+
},
101+
}
102+
103+
const result = buildDashboardList(panels)
104+
105+
expect(result[1]).toEqual({
106+
path: '/untitled-dash',
107+
title: 'untitled-dash',
108+
})
109+
})
110+
111+
it('uses url_path as title when title is empty string', () => {
112+
const panels = {
113+
empty: {
114+
component_name: 'lovelace',
115+
url_path: 'empty-title',
116+
title: '',
117+
icon: null,
118+
},
119+
}
120+
121+
const result = buildDashboardList(panels)
122+
123+
expect(result[1]!.title).toBe('empty-title')
124+
})
125+
})
126+
127+
describe('deduplication', () => {
128+
it('does not duplicate default dashboard path', () => {
129+
const panels = {
130+
lovelace: {
131+
component_name: 'lovelace',
132+
url_path: 'lovelace/0',
133+
title: 'Overview',
134+
icon: null,
135+
},
136+
}
137+
138+
const result = buildDashboardList(panels)
139+
140+
const lovelacePaths = result.filter((d) => d.path === '/lovelace/0')
141+
expect(lovelacePaths).toHaveLength(1)
142+
})
143+
144+
it('keeps first occurrence when panels have duplicate url_paths', () => {
145+
const panels = {
146+
home: {
147+
component_name: 'lovelace',
148+
url_path: 'home',
149+
title: 'Home',
150+
icon: null,
151+
},
152+
'home-alias': {
153+
component_name: 'lovelace',
154+
url_path: 'home',
155+
title: 'Home Alias',
156+
icon: null,
157+
},
158+
}
159+
160+
const result = buildDashboardList(panels)
161+
162+
const homePaths = result.filter((d) => d.path === '/home')
163+
expect(homePaths).toHaveLength(1)
164+
expect(homePaths[0]!.title).toBe('Home')
165+
})
166+
})
167+
168+
describe('with empty panels object', () => {
169+
it('returns only default dashboard', () => {
170+
const result = buildDashboardList({})
171+
172+
expect(result).toEqual([
173+
{ path: '/lovelace/0', title: 'Default Dashboard' },
174+
])
175+
})
176+
})
177+
})

0 commit comments

Comments
 (0)