fix: Various editor / diagnostic improvements (#2363)

- Closes: #2360 
 - Closes: #2328
This commit is contained in:
Dermot Duffy
2026-02-21 09:31:10 -08:00
committed by GitHub
parent 6d5a76ac0a
commit df3614f793
13 changed files with 313 additions and 58 deletions
+5 -1
View File
@@ -20,7 +20,11 @@ assignees: ''
**[REQUIRED] Card diagnostic information:**
<!--
On the card, hold down the Iris (default) menu button for a few seconds then paste the diagnostics *between* the pair of triple backticks below. No help can be provided without this.
No help can be provided without this information. Fetch the diagnostics and paste them *between* the pair of triple backticks below.
Use either of two ways to fetch the diagnostics:
- Hold down the Iris (default) menu button for a few seconds.
- Open the card editor and click the 'Toggle Diagnostics' button.
-->
```yaml
+4 -2
View File
@@ -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.
+1 -9
View File
@@ -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,
() => {
+29 -1
View File
@@ -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<ViewItem>): 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();
}
};
}
+29 -20
View File
@@ -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` <div class="upgrade">
<span>${localize('editor.upgrade_available')}</span>
<span>
<mwc-button
raised
label="${localize('editor.upgrade')}"
@click=${() => {
if (this._config) {
const upgradedConfig = copyConfig(this._config);
upgradeConfig(upgradedConfig);
this._updateConfig(upgradedConfig);
}
}}
>
</mwc-button>
</span>
</div>
<br />`
: html``}
<div class="card-config">
${this._renderOptionSetHeader('cameras')}
${this._expandedMenus[MENU_OPTIONS] === 'cameras'
@@ -3597,6 +3578,34 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
</div>`
: ''}`
: html``}
<div class="action-buttons">
${this._configUpgradeable
? html`<ha-button
appearance="filled"
variant="warning"
title=${localize('editor.upgrade_available')}
aria-label=${localize('editor.upgrade_available')}
@click=${() => {
if (this._config) {
const upgradedConfig = copyConfig(this._config);
upgradeConfig(upgradedConfig);
this._updateConfig(upgradedConfig);
}
}}
>
${localize('editor.upgrade')}
</ha-button>`
: ''}
<ha-button
title=${localize('editor.toggle_diagnostics')}
aria-label=${localize('editor.toggle_diagnostics')}
@click=${() => {
fireAdvancedCameraCardEvent(this, 'editor:diagnostics');
}}
>
${localize('editor.toggle_diagnostics')}
</ha-button>
</div>
</div>
`;
}
-1
View File
@@ -22,7 +22,6 @@ export const sideLoadHomeAssistantElements = async (): Promise<boolean> => {
'ha-spinner',
'ha-state-icon',
'ha-web-rtc-player',
'mwc-button',
'mwc-list-item',
'state-badge',
];
+1
View File
@@ -189,6 +189,7 @@ export interface LovelaceCard extends HTMLElement {
hass?: HomeAssistant;
isPanel?: boolean;
editMode?: boolean;
preview?: boolean;
getCardSize(): number | Promise<number>;
setConfig(config: LovelaceCardConfig): void;
}
+2 -1
View File
@@ -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"
+6 -9
View File
@@ -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;
}
+21
View File
@@ -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;
};
@@ -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<StateWatcher>();
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<StateWatcher>();
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();
});
});
});
+11 -1
View File
@@ -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();
+91
View File
@@ -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);
});
});