From df3614f7933d2a2185dce570c5b8db250c6faf6c Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 21 Feb 2026 09:31:10 -0800 Subject: [PATCH] fix: Various editor / diagnostic improvements (#2363) - Closes: #2360 - Closes: #2328 --- .github/ISSUE_TEMPLATE/bug_report.md | 6 +- docs/support.md | 6 +- src/action-handler-directive.ts | 10 +- src/card-controller/card-element-manager.ts | 30 ++++- src/editor.ts | 49 ++++--- src/ha/side-load-ha-elements.ts | 1 - src/ha/types.ts | 1 + src/localize/languages/en.json | 3 +- src/scss/editor.scss | 15 +-- src/utils/event-ancestor.ts | 21 +++ .../card-element-manager.test.ts | 126 ++++++++++++++++-- tests/test-utils.ts | 12 +- tests/utils/event-ancestor.test.ts | 91 +++++++++++++ 13 files changed, 313 insertions(+), 58 deletions(-) create mode 100644 src/utils/event-ancestor.ts create mode 100644 tests/utils/event-ancestor.test.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d43853ed..282319d1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -20,7 +20,11 @@ assignees: '' **[REQUIRED] Card diagnostic information:** ```yaml diff --git a/docs/support.md b/docs/support.md index a139f497..c66a4130 100644 --- a/docs/support.md +++ b/docs/support.md @@ -19,8 +19,10 @@ incrementally improve, and I think time is better spent working on reproducible issues or new features, than guessing what versions/options you are using. The new issue template asks for the minimum possible information to support you: -- **Diagnostics**: On the card, hold down the Iris (default) menu button for a - few seconds then paste the diagnostics into the issue between the backticks. +- **Diagnostics**: On the card, fetch the diagnostics and paste into the issue + between the backticks. You can fetch the diagnostics in either of two ways: + - Hold down the Iris (default) menu button for a few seconds. + - Open the card editor and click the 'Toggle Diagnostics' button. - **Description of the problem**: I need to understand the issue in sufficient detail. diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index 36611d83..cc924c34 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -32,15 +32,7 @@ class ActionHandler extends HTMLElement implements ActionHandlerInterface { private started = false; public connectedCallback(): void { - [ - 'touchcancel', - 'mouseout', - 'mouseup', - 'touchmove', - 'mousewheel', - 'wheel', - 'scroll', - ].forEach((ev) => { + ['mouseup', 'mousewheel', 'scroll', 'touchcancel', 'wheel'].forEach((ev) => { document.addEventListener( ev, () => { diff --git a/src/card-controller/card-element-manager.ts b/src/card-controller/card-element-manager.ts index d42ab04f..43d6999f 100644 --- a/src/card-controller/card-element-manager.ts +++ b/src/card-controller/card-element-manager.ts @@ -1,8 +1,10 @@ import { LitElement, ReactiveControllerHost } from 'lit'; import { ActionEventTarget } from '../action-handler-directive'; import { isCardInPanel } from '../ha/panel'; +import { LovelaceCard } from '../ha/types'; import { setOrRemoveAttribute } from '../utils/basic'; import { isBeingCasted } from '../utils/casting'; +import { isAncestorInEventPath } from '../utils/event-ancestor'; import { CardMediaReviewEventTarget } from '../utils/review'; import { ViewItem } from '../view/item'; import { ActionExecutionRequestEventTarget } from './actions/utils/execution-request'; @@ -12,7 +14,8 @@ import { CardElementAPI } from './types'; export type ScrollCallback = () => void; export type MenuToggleCallback = () => void; -export type CardHTMLElement = LitElement & +export type CardHTMLElement = LovelaceCard & + LitElement & ReactiveControllerHost & ActionEventTarget & ActionExecutionRequestEventTarget & @@ -141,6 +144,10 @@ export class CardElementManager { 'popstate', this._api.getQueryStringManager().requestExecution, ); + window.addEventListener( + 'advanced-camera-card:editor:diagnostics', + this._editorDiagnosticsHandler, + ); this._api.getConditionStateManager()?.setState({ userAgent: navigator.userAgent, @@ -218,6 +225,10 @@ export class CardElementManager { 'popstate', this._api.getQueryStringManager().requestExecution, ); + window.removeEventListener( + 'advanced-camera-card:editor:diagnostics', + this._editorDiagnosticsHandler, + ); } private _handleMediaReviewed = (ev: CustomEvent): void => { @@ -230,4 +241,21 @@ export class CardElementManager { this.update(); } }; + + protected _editorDiagnosticsHandler = (ev: Event): void => { + const toggleDiagnostics = (): void => { + const viewManager = this._api.getViewManager(); + if (viewManager.getView()?.view === 'diagnostics') { + viewManager.setViewDefault(); + } else { + viewManager.setViewByParameters({ + params: { view: 'diagnostics' }, + }); + } + }; + + if (isAncestorInEventPath(this._element, ev, 'hui-dialog-edit-card')) { + toggleDiagnostics(); + } + }; } diff --git a/src/editor.ts b/src/editor.ts index ea872716..11a76293 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -271,6 +271,7 @@ import { localize } from './localize/localize.js'; import editorStyle from './scss/editor.scss'; import { arrayMove, prettifyTitle } from './utils/basic.js'; import { getCameraID } from './utils/camera.js'; +import { fireAdvancedCameraCardEvent } from './utils/fire-advanced-camera-card-event.js'; import { getFolderID } from './utils/folder.js'; const MENU_CAMERAS = 'cameras'; @@ -2965,26 +2966,6 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard []) as RawAdvancedCameraCardConfigArray; return html` - ${this._configUpgradeable - ? html`
- ${localize('editor.upgrade_available')} - - { - if (this._config) { - const upgradedConfig = copyConfig(this._config); - upgradeConfig(upgradedConfig); - this._updateConfig(upgradedConfig); - } - }} - > - - -
-
` - : html``}
${this._renderOptionSetHeader('cameras')} ${this._expandedMenus[MENU_OPTIONS] === 'cameras' @@ -3597,6 +3578,34 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
` : ''}` : html``} +
+ ${this._configUpgradeable + ? html` { + if (this._config) { + const upgradedConfig = copyConfig(this._config); + upgradeConfig(upgradedConfig); + this._updateConfig(upgradedConfig); + } + }} + > + ${localize('editor.upgrade')} + ` + : ''} + { + fireAdvancedCameraCardEvent(this, 'editor:diagnostics'); + }} + > + ${localize('editor.toggle_diagnostics')} + +
`; } diff --git a/src/ha/side-load-ha-elements.ts b/src/ha/side-load-ha-elements.ts index 36f92625..0db569f3 100644 --- a/src/ha/side-load-ha-elements.ts +++ b/src/ha/side-load-ha-elements.ts @@ -22,7 +22,6 @@ export const sideLoadHomeAssistantElements = async (): Promise => { 'ha-spinner', 'ha-state-icon', 'ha-web-rtc-player', - 'mwc-button', 'mwc-list-item', 'state-badge', ]; diff --git a/src/ha/types.ts b/src/ha/types.ts index efb4d18c..98e0317b 100644 --- a/src/ha/types.ts +++ b/src/ha/types.ts @@ -189,6 +189,7 @@ export interface LovelaceCard extends HTMLElement { hass?: HomeAssistant; isPanel?: boolean; editMode?: boolean; + preview?: boolean; getCardSize(): number | Promise; setConfig(config: LovelaceCardConfig): void; } diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 273221a2..f1f922d5 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -688,11 +688,12 @@ "profiles_secondary": "Choose pre-configured sets of defaults", "remote_control": "Remote Control", "remote_control_secondary": "Options for remote controlling the card", + "toggle_diagnostics": "Toggle diagnostics", "status_bar": "Status bar", "status_bar_secondary": "Status bar look & feel options", "timeline": "Timeline", "timeline_secondary": "Event timeline options", - "upgrade": "Upgrade", + "upgrade": "Automatic Upgrade", "upgrade_available": "An automatic card configuration upgrade is available", "view": "View", "view_secondary": "What the card should show and how to show it" diff --git a/src/scss/editor.scss b/src/scss/editor.scss index db9380f0..de93b5b9 100644 --- a/src/scss/editor.scss +++ b/src/scss/editor.scss @@ -36,16 +36,9 @@ .submenu + .option { margin-top: 10px; } -div.upgrade { - width: auto; - border: 1px dotted var(--primary-color); - margin: 10px; +.action-buttons { display: flex; - justify-content: space-between; - align-items: center; -} -div.upgrade span { - padding: 10px; + flex-wrap: wrap; } .submenu-header { @@ -150,3 +143,7 @@ advanced-camera-card-message::part(icon) { margin-top: 0; pointer-events: none; } + +ha-button { + margin: 10px; +} diff --git a/src/utils/event-ancestor.ts b/src/utils/event-ancestor.ts new file mode 100644 index 00000000..e9bb7ce3 --- /dev/null +++ b/src/utils/event-ancestor.ts @@ -0,0 +1,21 @@ +/** + * Walk up `element`'s ancestor chain (through shadow boundaries) looking for + * an ancestor with the given tag name. Returns true if one is found and that + * same element also appears in the event's composedPath — indicating the event + * originated from within the same subtree as the element. + */ +export const isAncestorInEventPath = ( + element: Element, + ev: Event, + tagName: string, +): boolean => { + const composedPath = ev.composedPath(); + let node: Node | null = element; + while (node) { + if (node instanceof Element && node.tagName.toLowerCase() === tagName) { + return composedPath.includes(node); + } + node = node instanceof ShadowRoot ? node.host : node.parentNode; + } + return false; +}; diff --git a/tests/card-controller/card-element-manager.test.ts b/tests/card-controller/card-element-manager.test.ts index 61f4e527..e4d25f1c 100644 --- a/tests/card-controller/card-element-manager.test.ts +++ b/tests/card-controller/card-element-manager.test.ts @@ -1,13 +1,14 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, assert, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CardElementManager } from '../../src/card-controller/card-element-manager'; import { StateWatcher } from '../../src/card-controller/hass/state-watcher'; import { QueryResults } from '../../src/view/query-results'; +import { View } from '../../src/view/view'; import { callStateWatcherCallback, createCardAPI, + createCardHTMLElement, createConfig, - createLitElement, createStateEntity, createView, TestViewMedia, @@ -20,7 +21,7 @@ describe('CardElementManager', () => { }); it('should get element', () => { - const element = createLitElement(); + const element = createCardHTMLElement(); const manager = new CardElementManager( createCardAPI(), element, @@ -35,7 +36,7 @@ describe('CardElementManager', () => { const callback = vi.fn(); const manager = new CardElementManager( createCardAPI(), - createLitElement(), + createCardHTMLElement(), callback, () => undefined, ); @@ -49,7 +50,7 @@ describe('CardElementManager', () => { const callback = vi.fn(); const manager = new CardElementManager( createCardAPI(), - createLitElement(), + createCardHTMLElement(), () => undefined, callback, ); @@ -60,7 +61,7 @@ describe('CardElementManager', () => { }); it('should update', () => { - const element = createLitElement(); + const element = createCardHTMLElement(); const manager = new CardElementManager( createCardAPI(), element, @@ -73,7 +74,7 @@ describe('CardElementManager', () => { }); it('should get hasUpdated', () => { - const element = createLitElement(); + const element = createCardHTMLElement(); element.hasUpdated = true; const manager = new CardElementManager( createCardAPI(), @@ -89,7 +90,7 @@ describe('CardElementManager', () => { const windowAddEventListener = vi.spyOn(global.window, 'addEventListener'); const addEventListener = vi.fn(); - const element = createLitElement(); + const element = createCardHTMLElement(); element.addEventListener = addEventListener; const api = createCardAPI(); @@ -132,6 +133,10 @@ describe('CardElementManager', () => { ); expect(windowAddEventListener).toBeCalledWith('location-changed', expect.anything()); expect(windowAddEventListener).toBeCalledWith('popstate', expect.anything()); + expect(windowAddEventListener).toBeCalledWith( + 'advanced-camera-card:editor:diagnostics', + expect.anything(), + ); expect(api.getInteractionManager().initialize).toBeCalled(); expect(api.getFullscreenManager().initialize).toBeCalled(); @@ -143,7 +148,7 @@ describe('CardElementManager', () => { it('should disconnect', () => { const windowRemoveEventListener = vi.spyOn(global.window, 'removeEventListener'); - const element = createLitElement(); + const element = createCardHTMLElement(); element.setAttribute('panel', ''); element.setAttribute('casted', ''); @@ -195,6 +200,10 @@ describe('CardElementManager', () => { expect.anything(), ); expect(windowRemoveEventListener).toBeCalledWith('popstate', expect.anything()); + expect(windowRemoveEventListener).toBeCalledWith( + 'advanced-camera-card:editor:diagnostics', + expect.anything(), + ); expect(api.getMediaLoadedInfoManager().clear).toBeCalled(); expect(api.getFullscreenManager().disconnect).toBeCalled(); @@ -217,7 +226,7 @@ describe('CardElementManager', () => { const stateWatcher = mock(); vi.mocked(api.getHASSManager().getStateWatcher).mockReturnValue(stateWatcher); - const element = createLitElement(); + const element = createCardHTMLElement(); const manager = new CardElementManager( api, element, @@ -245,7 +254,7 @@ describe('CardElementManager', () => { const stateWatcher = mock(); vi.mocked(api.getHASSManager().getStateWatcher).mockReturnValue(stateWatcher); - const element = createLitElement(); + const element = createCardHTMLElement(); const manager = new CardElementManager( api, element, @@ -275,7 +284,7 @@ describe('CardElementManager', () => { vi.mocked(api.getViewManager().getView).mockReturnValue(view); - const element = createLitElement(); + const element = createCardHTMLElement(); const manager = new CardElementManager( api, element, @@ -310,7 +319,7 @@ describe('CardElementManager', () => { vi.mocked(api.getViewManager().getView).mockReturnValue(view); - const element = createLitElement(); + const element = createCardHTMLElement(); const manager = new CardElementManager( api, element, @@ -334,4 +343,95 @@ describe('CardElementManager', () => { expect(element.requestUpdate).not.toBeCalled(); }); }); + + describe('should handle diagnostics', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const createDialogWithCard = (element: HTMLElement) => { + const dialog = document.createElement('hui-dialog-edit-card'); + dialog.attachShadow({ mode: 'open' }); + assert(dialog.shadowRoot); + dialog.shadowRoot.append(element); + return dialog; + }; + + const fireFromDialog = (dialog: HTMLElement) => { + const editorDiv = document.createElement('div'); + assert(dialog.shadowRoot); + dialog.shadowRoot.append(editorDiv); + editorDiv.dispatchEvent( + new CustomEvent('advanced-camera-card:editor:diagnostics', { + bubbles: true, + composed: true, + }), + ); + }; + + it('sets view to diagnostics if card is in editor', () => { + const api = createCardAPI(); + const element = createCardHTMLElement(); + const manager = new CardElementManager( + api, + element, + () => undefined, + () => undefined, + ); + + const dialog = createDialogWithCard(element); + document.body.append(dialog); + manager.elementConnected(); + + fireFromDialog(dialog); + + expect(api.getViewManager().setViewByParameters).toBeCalledWith({ + params: { view: 'diagnostics' }, + }); + }); + + it('resets to default view if already in diagnostics view', () => { + const api = createCardAPI(); + const element = createCardHTMLElement(); + const manager = new CardElementManager( + api, + element, + () => undefined, + () => undefined, + ); + + vi.mocked(api.getViewManager().getView).mockReturnValue( + new View({ view: 'diagnostics' }), + ); + + const dialog = createDialogWithCard(element); + document.body.append(dialog); + manager.elementConnected(); + + fireFromDialog(dialog); + + expect(api.getViewManager().setViewDefault).toBeCalled(); + }); + + it('does not set view to diagnostics if card is not in editor', () => { + const api = createCardAPI(); + const element = createCardHTMLElement(); + const manager = new CardElementManager( + api, + element, + () => undefined, + () => undefined, + ); + + manager.elementConnected(); + + // Event fired from a different dialog that does not contain the card + const otherDialog = document.createElement('hui-dialog-edit-card'); + otherDialog.attachShadow({ mode: 'open' }); + document.body.append(otherDialog); + fireFromDialog(otherDialog); + + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); + }); + }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index a4d8f391..947311d7 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -24,7 +24,10 @@ import { import { ActionsManager } from '../src/card-controller/actions/actions-manager'; import { AutomationsManager } from '../src/card-controller/automations-manager'; import { CameraURLManager } from '../src/card-controller/camera-url-manager'; -import { CardElementManager } from '../src/card-controller/card-element-manager'; +import { + CardElementManager, + CardHTMLElement, +} from '../src/card-controller/card-element-manager'; import { ConfigManager } from '../src/card-controller/config/config-manager'; import { CardController } from '../src/card-controller/controller'; import { DefaultManager } from '../src/card-controller/default-manager'; @@ -626,6 +629,13 @@ export const createParent = (options?: { children?: HTMLElement[] }): HTMLElemen return parent; }; +export const createCardHTMLElement = (): CardHTMLElement => { + const element = createLitElement() as CardHTMLElement; + element.getCardSize = vi.fn(); + element.setConfig = vi.fn(); + return element; +}; + export const createLitElement = (): LitElement => { const element = document.createElement('div') as unknown as LitElement; element.addController = vi.fn(); diff --git a/tests/utils/event-ancestor.test.ts b/tests/utils/event-ancestor.test.ts new file mode 100644 index 00000000..d2a6938e --- /dev/null +++ b/tests/utils/event-ancestor.test.ts @@ -0,0 +1,91 @@ +import { assert, describe, expect, it } from 'vitest'; +import { isAncestorInEventPath } from '../../src/utils/event-ancestor'; + +// @vitest-environment jsdom +describe('isAncestorInEventPath', () => { + const dispatch = (source: EventTarget, listener: (ev: Event) => void) => { + window.addEventListener('test', listener, { once: true }); + source.dispatchEvent(new CustomEvent('test', { bubbles: true, composed: true })); + }; + + it('returns true when element shares the ancestor with the event source', () => { + const element = document.createElement('div'); + const ancestor = document.createElement('hui-dialog-edit-card'); + ancestor.attachShadow({ mode: 'open' }); + assert(ancestor.shadowRoot); + ancestor.shadowRoot.append(element); + document.body.append(ancestor); + + const eventSource = document.createElement('div'); + ancestor.shadowRoot.append(eventSource); + + let result: boolean | undefined; + dispatch(eventSource, (ev) => { + result = isAncestorInEventPath(element, ev, 'hui-dialog-edit-card'); + }); + + expect(result).toBe(true); + }); + + it('returns false when element and event source are in different ancestors with the same tag', () => { + const element = document.createElement('div'); + const ancestor1 = document.createElement('hui-dialog-edit-card'); + ancestor1.attachShadow({ mode: 'open' }); + assert(ancestor1.shadowRoot); + ancestor1.shadowRoot.append(element); + document.body.append(ancestor1); + + const ancestor2 = document.createElement('hui-dialog-edit-card'); + ancestor2.attachShadow({ mode: 'open' }); + assert(ancestor2.shadowRoot); + const eventSource = document.createElement('div'); + ancestor2.shadowRoot.append(eventSource); + document.body.append(ancestor2); + + let result: boolean | undefined; + dispatch(eventSource, (ev) => { + result = isAncestorInEventPath(element, ev, 'hui-dialog-edit-card'); + }); + + expect(result).toBe(false); + }); + + it('returns false when element has no ancestor with the given tag', () => { + const element = document.createElement('div'); + document.body.append(element); + + const eventSource = document.createElement('div'); + document.body.append(eventSource); + + let result: boolean | undefined; + dispatch(eventSource, (ev) => { + result = isAncestorInEventPath(element, ev, 'hui-dialog-edit-card'); + }); + + expect(result).toBe(false); + }); + + it('traverses shadow boundaries in the element ancestor chain', () => { + const element = document.createElement('div'); + const inner = document.createElement('hui-card'); + inner.attachShadow({ mode: 'open' }); + assert(inner.shadowRoot); + inner.shadowRoot.append(element); + + const ancestor = document.createElement('hui-dialog-edit-card'); + ancestor.attachShadow({ mode: 'open' }); + assert(ancestor.shadowRoot); + ancestor.shadowRoot.append(inner); + document.body.append(ancestor); + + const eventSource = document.createElement('div'); + ancestor.shadowRoot.append(eventSource); + + let result: boolean | undefined; + dispatch(eventSource, (ev) => { + result = isAncestorInEventPath(element, ev, 'hui-dialog-edit-card'); + }); + + expect(result).toBe(true); + }); +});