fix: Move upgrade button to top of editor (#2687)

This commit is contained in:
Dermot Duffy
2026-08-14 21:13:10 -07:00
committed by GitHub
parent c2f25861ea
commit 858c5d0610
13 changed files with 297 additions and 55 deletions
+6 -1
View File
@@ -370,6 +370,11 @@ export interface MountOptions {
width?: string;
height?: string;
// The element the container is created as, for a test about behavior the card
// offers only *within* a particular Home Assistant element (e.g. its card
// edit dialog, which the card looks for among its ancestors).
containerTagName?: string;
// Where that container is placed, as CSS lengths from the page's top left
// corner. The page grows to reach it, so a card put beyond the window can
// only be brought into view by scrolling.
@@ -424,7 +429,7 @@ export class MountedCard {
) {
this._hass = hass;
this._container = document.createElement('div');
this._container = document.createElement(options?.containerTagName ?? 'div');
this._container.style.width = options?.width ?? DEFAULT_CONTAINER_WIDTH;
if (options?.height) {
this._container.style.height = options.height;
@@ -1,13 +1,23 @@
import { describe, expect, it, onTestFinished } from 'vitest';
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
import { createLogAction } from '../../src/utils/action';
import { deepQueryAll, getFocusedElement, pressKey, pressTab } from '../browser/dom';
import {
deepQuery,
deepQueryAll,
getFocusedElement,
pressKey,
pressTab,
} from '../browser/dom';
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
import {
CARD_INITIALIZED_MESSAGE,
createGenericCameraHASS,
createInitializedAutomation,
createStillImageCameraConfig,
createStillImageCardConfig,
getBlockNotificationText,
isLiveMediaShowing,
} from '../browser/test-utils';
// What the automation writes when it runs, written as the pattern the console
@@ -68,7 +78,100 @@ const addTrailingControl = (): HTMLElement => {
return control;
};
// The Home Assistant dialog the card is previewed in while its configuration is
// edited, which is what the card looks for before answering the editor's
// diagnostics button.
const EDIT_DIALOG_TAG_NAME = 'hui-dialog-edit-card';
const INIT_FAILED_ISSUE_HEADING = 'Initialization failed';
const DIAGNOSTICS_SELECTOR = 'advanced-camera-card-diagnostics';
const mountCardInEditDialog = async (
config: RawAdvancedCameraCardConfig,
): Promise<MountedCard> =>
await MountedCardFactory.createFromSource(config, createGenericCameraHASS(), {
containerTagName: EDIT_DIALOG_TAG_NAME,
});
/**
* Press the editor's diagnostics button. The editor is elsewhere in the dialog
* rather than within the card, so the event is fired from a sibling of it.
*/
const toggleDiagnostics = (card: MountedCard): void => {
const editor = document.createElement('div');
card.card.parentElement?.append(editor);
editor.dispatchEvent(
new CustomEvent('advanced-camera-card:editor:diagnostics', {
bubbles: true,
composed: true,
}),
);
editor.remove();
};
const isDiagnosticsShowing = (card: MountedCard): boolean =>
!!deepQuery(card.card, DIAGNOSTICS_SELECTOR);
describe('CardElementManager', () => {
describe('should toggle diagnostics from the editor', () => {
it('should show diagnostics over a card that could not be started', async () => {
// A camera Home Assistant has never heard of, so the card cannot start
// and shows an issue in place of its views.
const card = await mountCardInEditDialog(
createStillImageCardConfig({
cameras: [createStillImageCameraConfig('camera.missing')],
view: { issues: { retry_seconds: 0 } },
}),
);
await card.waitForRender(
() =>
getBlockNotificationText(card.card).includes(INIT_FAILED_ISSUE_HEADING) ||
null,
'the initialization issue',
);
toggleDiagnostics(card);
// Diagnostics is what the user is asked for when the card is broken, so
// it must be reachable in the state the issue describes.
await card.waitForSelector(DIAGNOSTICS_SELECTOR);
expect(getBlockNotificationText(card.card)).not.toContain(
INIT_FAILED_ISSUE_HEADING,
);
toggleDiagnostics(card);
// Toggling diagnostics again just puts the issue back in front of the
// user.
await card.waitForRender(
() =>
getBlockNotificationText(card.card).includes(INIT_FAILED_ISSUE_HEADING) ||
null,
'the initialization issue',
);
expect(isDiagnosticsShowing(card)).toBe(false);
});
it('should return a started card to its default view', async () => {
const card = await mountCardInEditDialog(createStillImageCardConfig());
await card.events.waitForFirst('advanced-camera-card:media:loaded');
toggleDiagnostics(card);
await card.waitForSelector(DIAGNOSTICS_SELECTOR);
toggleDiagnostics(card);
await card.waitForRender(
() => isLiveMediaShowing(card.card) || null,
'the live view',
);
expect(isDiagnosticsShowing(card)).toBe(false);
});
});
it('should be reachable by tabbing', async () => {
const card = await mountCard();
@@ -439,6 +439,7 @@ describe('CardElementManager', () => {
vi.mocked(api.getViewManager().getView).mockReturnValue(
new View({ view: 'diagnostics' }),
);
vi.mocked(api.getViewManager().canSetViewDefault).mockReturnValue(true);
const dialog = createDialogWithCard(element);
document.body.append(dialog);
@@ -449,6 +450,31 @@ describe('CardElementManager', () => {
expect(api.getViewManager().setViewDefault).toHaveBeenCalled();
});
it('should reset the view when leaving diagnostics with no default view available', () => {
const api = createCardAPI();
const element = createCardHTMLElement();
const manager = new CardElementManager(
api,
element,
() => undefined,
() => undefined,
);
vi.mocked(api.getViewManager().getView).mockReturnValue(
new View({ view: 'diagnostics' }),
);
vi.mocked(api.getViewManager().canSetViewDefault).mockReturnValue(false);
const dialog = createDialogWithCard(element);
document.body.append(dialog);
manager.elementConnected();
fireFromDialog(dialog);
expect(api.getViewManager().reset).toHaveBeenCalled();
expect(api.getViewManager().setViewDefault).not.toHaveBeenCalled();
});
it('does not set view to diagnostics if card is not in editor', () => {
const api = createCardAPI();
const element = createCardHTMLElement();
@@ -203,6 +203,26 @@ describe('should not set view without cameras being initialized', () => {
expect(manager.getView()).toBeNull();
});
it('should report whether a default view can be set', () => {
expect(new ViewManager(createInitializedCardAPI()).canSetViewDefault()).toBe(true);
expect(new ViewManager(createInitializedCardAPI(false)).canSetViewDefault()).toBe(
false,
);
});
it('should set the diagnostics view without cameras being initialized', () => {
const view = createView({ view: 'diagnostics' });
const factory = mock<ViewFactory>();
factory.getViewByParameters.mockReturnValue(view);
const manager = new ViewManager(createInitializedCardAPI(false), {
viewFactory: factory,
});
manager.setViewByParameters({ params: { view: 'diagnostics' } });
expect(manager.getView()).toBe(view);
});
});
describe('should respect microphone navigation lock', () => {
@@ -494,6 +514,19 @@ describe('should handle exceptions', () => {
expect(api.getIssueManager().reset).toHaveBeenCalledWith('view_incompatible');
});
it('should retain issues when the diagnostics view is requested', () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewByParameters.mockReturnValue(createView({ view: 'diagnostics' }));
const api = createInitializedCardAPI();
const manager = new ViewManager(api, { viewFactory });
manager.setViewByParameters({ params: { view: 'diagnostics' } });
expect(manager.getView()?.is('diagnostics')).toBeTruthy();
expect(api.getIssueManager().reset).not.toHaveBeenCalledWith('view_incompatible');
expect(api.getIssueManager().reset).not.toHaveBeenCalledWith('media_query');
});
it('should return null when failSafe view factory also throws', () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockImplementation(() => {
+27 -6
View File
@@ -10,7 +10,10 @@ import {
type Mock,
} from 'vitest';
import { EditorController } from '../../../src/components-lib/editor/controller';
import {
EditorController,
type EditorNotice,
} from '../../../src/components-lib/editor/controller';
import { getConfigValue } from '../../../src/config/management';
import { configDefaults } from '../../../src/config/schema/types';
import type { RawAdvancedCameraCardConfig } from '../../../src/config/types';
@@ -62,6 +65,11 @@ const createUpgradeableConfig = (): RawAdvancedCameraCardConfig => ({
],
});
const getUpgradeNotice = (controller: EditorController): EditorNotice | null =>
controller
.getNotices()
.find((notice) => notice.message === localize('editor.upgrade_available')) ?? null;
// @vitest-environment jsdom
describe('EditorController', () => {
beforeEach(() => {
@@ -118,13 +126,24 @@ describe('EditorController', () => {
it('should detect an upgradeable configuration', () => {
const { controller } = createController();
expect(controller.isConfigUpgradeable()).toBeFalsy();
expect(getUpgradeNotice(controller)).toBeNull();
controller.setConfig(createUpgradeableConfig());
expect(controller.isConfigUpgradeable()).toBeTruthy();
expect(getUpgradeNotice(controller)).toBeTruthy();
controller.setConfig({ cameras: [] });
expect(controller.isConfigUpgradeable()).toBeFalsy();
expect(getUpgradeNotice(controller)).toBeNull();
});
it('should detect an invalid configuration', () => {
const { controller } = createController();
expect(controller.isConfigValid()).toBeFalsy();
controller.setConfig({ type: 'custom:advanced-camera-card', cameras: [] });
expect(controller.isConfigValid()).toBeTruthy();
controller.setConfig({ type: 'custom:advanced-camera-card', cameras: 'nope' });
expect(controller.isConfigValid()).toBeFalsy();
});
it('should apply profile defaults', () => {
@@ -278,13 +297,15 @@ describe('EditorController', () => {
const { controller, configListener } = createController();
controller.setConfig(createUpgradeableConfig());
controller.upgrade();
const notice = getUpgradeNotice(controller);
assert(notice?.button);
notice.button.callback();
const config = getLastConfig(configListener);
expect(getConfigValue(config, 'elements.0.tap_action.data')).toEqual({
message: 'Hello',
});
expect(controller.isConfigUpgradeable()).toBeFalsy();
expect(getUpgradeNotice(controller)).toBeNull();
});
it('should do nothing without a configuration', () => {