feat: Add a simple mode to the visual card editor (#2588)

This commit is contained in:
Dermot Duffy
2026-07-21 19:13:29 -07:00
committed by GitHub
parent b6e1de5999
commit e29c0826cf
56 changed files with 1722 additions and 353 deletions
@@ -181,6 +181,44 @@ describe('EditorController', () => {
const { controller } = createController();
expect(controller.getNotices()).toEqual([]);
});
it('should say when the simple editor does not have full coverage', () => {
const { controller } = createController();
controller.setConfig({
cameras: [{ camera_entity: 'camera.office' }],
view: { dim: true },
editor: { mode: 'simple' },
});
expect(controller.getNotices()).toEqual([
{ type: 'info', message: localize('editor.simple_coverage') },
]);
});
it.each([
[
'the simple editor shows everything set',
{ cameras: [{ camera_entity: 'camera.office' }], editor: { mode: 'simple' } },
],
[
'the full editor is in use',
{ cameras: [{ camera_entity: 'camera.office' }], view: { dim: true } },
],
])('should have no coverage notice when %s', (_case, config) => {
const { controller } = createController();
controller.setConfig(config);
expect(controller.getNotices()).toEqual([]);
});
it.each([
[{ cameras: [{ camera_entity: 'camera.office' }] }, 'simple' as const],
[{ cameras: [], view: { dim: true } }, 'full' as const],
[{ cameras: [], editor: { mode: 'full' } }, 'full' as const],
])('should choose the editor for %j', (config, mode) => {
const { controller } = createController();
controller.setConfig(config);
expect(controller.getEditorMode()).toBe(mode);
});
});
describe('should describe what a section needs', () => {
@@ -31,7 +31,7 @@ describe('getDocLinkPath', () => {
expect(
getDocLinkPath(['cameras', 2], {
type: 'expandable',
docPath: 'cameras.engine',
docPath: ['cameras', 'engine'],
schema: [],
}),
).toEqual(['cameras', 'engine']);
@@ -41,6 +41,12 @@ describe('getDocLinkPath', () => {
expect(getDocLinkPath(['cameras'], { type: 'expandable', schema: [] })).toBeNull();
});
it('should not link a grid', () => {
// A grid only lays out the fields within it, and documentation is linked
// for what a group of settings is, not for how it is arranged.
expect(getDocLinkPath(['dimensions'], { type: 'grid', schema: [] })).toBeNull();
});
it('should order nested container paths into configuration order', () => {
expect(
getDocLinkPath(
@@ -5,6 +5,7 @@ import {
computeConfigChanges,
computeDisplayedData,
forEachFieldRecursively,
getFormConfigPaths,
} from '../../../src/components-lib/editor/form-data';
import type { ConfigChange, EditorForm } from '../../../src/components-lib/editor/types';
import { getConfigValue } from '../../../src/config/management';
@@ -58,6 +59,23 @@ describe('forEachFieldRecursively', () => {
expect(visited).toEqual([['style']]);
});
it('should not add a grid to the path', () => {
const visited: string[][] = [];
forEachFieldRecursively(
[
{
type: 'grid',
schema: [
{ name: 'aspect_ratio_mode', selector: { text: {} } },
{ name: 'aspect_ratio', selector: { text: {} } },
],
},
],
(path) => visited.push(path),
);
expect(visited).toEqual([['aspect_ratio_mode'], ['aspect_ratio']]);
});
it('should visit every leaf field with its relative path', () => {
const visited: [string[], string][] = [];
forEachFieldRecursively(SCHEMA, (path, field) => visited.push([path, field.name]));
@@ -72,6 +90,52 @@ describe('forEachFieldRecursively', () => {
});
});
describe('getFormConfigPaths', () => {
it('should place an unbound field beneath the base path', () => {
expect(getFormConfigPaths(createForm())).toEqual([
['live', 'title'],
['live', 'preload'],
['live', 'controls', 'wheel'],
['live', 'controls', 'size'],
['live', 'controls', 'thumbnails', 'mode'],
['live', 'modes'],
]);
});
it('should use the path a binding names', () => {
expect(
getFormConfigPaths({
basePath: [],
schema: [{ name: 'menu_style', selector: { text: {} } }],
bindings: [{ formPath: ['menu_style'], configPath: ['menu', 'style'] }],
}),
).toEqual([['menu', 'style']]);
});
it('should use every path a field that reads and writes itself declares', () => {
expect(
getFormConfigPaths({
basePath: [],
schema: [{ name: 'buttons', selector: { select: { options: [] } } }],
bindings: [
{
formPath: ['buttons'],
configPaths: [
['menu', 'buttons', 'cameras', 'enabled'],
['menu', 'buttons', 'timeline', 'enabled'],
],
read: () => [],
write: () => [],
},
],
}),
).toEqual([
['menu', 'buttons', 'cameras', 'enabled'],
['menu', 'buttons', 'timeline', 'enabled'],
]);
});
});
describe('computeDisplayedData', () => {
it('should fill in defaults for absent fields', () => {
expect(
@@ -435,6 +499,7 @@ describe('field bindings', () => {
bindings: [
{
formPath: ['buttons'],
configPaths: BUTTONS.map((button) => ['menu', 'buttons', button, 'enabled']),
read: (config, defaults) =>
BUTTONS.filter((button) => isEnabled(config, defaults, button)),
write: (value, config, defaults) => {
+67 -11
View File
@@ -4,6 +4,19 @@ import {
computeFormLabel,
getLocalizationKeyForPath,
} from '../../../src/components-lib/editor/form-labels';
import type {
ConfigPath,
EditorForm,
FieldBinding,
} from '../../../src/components-lib/editor/types';
const createForm = (basePath: ConfigPath, bindings?: FieldBinding[]): EditorForm => ({
basePath,
// The label is computed from the field it is given, so the form's own schema
// plays no part.
schema: [],
...(bindings ? { bindings } : {}),
});
describe('getLocalizationKeyForPath', () => {
it('should build a config key without array indices', () => {
@@ -16,7 +29,7 @@ describe('getLocalizationKeyForPath', () => {
describe('computeFormLabel', () => {
it('should prefer an explicit schema label', () => {
expect(
computeFormLabel(['cameras', 2], {
computeFormLabel(createForm(['cameras', 2]), {
name: 'title',
label: 'Explicit',
selector: { text: {} },
@@ -25,15 +38,18 @@ describe('computeFormLabel', () => {
});
it('should localize the configuration path', () => {
expect(computeFormLabel(['view'], { name: 'default', selector: { text: {} } })).toBe(
'Default view',
);
expect(
computeFormLabel(createForm(['view']), {
name: 'default',
selector: { text: {} },
}),
).toBe('Default view');
});
it('should include container paths provided by the form', () => {
expect(
computeFormLabel(
['cameras', 2],
createForm(['cameras', 2]),
{ name: 'title', selector: { text: {} } },
{ path: [] },
),
@@ -43,16 +59,46 @@ describe('computeFormLabel', () => {
it('should order nested container paths into configuration order', () => {
expect(
computeFormLabel(
['cameras', 2],
createForm(['cameras', 2]),
{ name: 'dashboard_path', selector: { text: {} } },
{ path: ['dashboard', 'cast'] },
),
).toBe('Dashboard path');
});
it('should localize a bound field by where its setting is stored', () => {
expect(
computeFormLabel(
createForm([], [{ formPath: ['menu_style'], configPath: ['menu', 'style'] }]),
{ name: 'menu_style', selector: { text: {} } },
),
).toBe('Menu style');
});
it('should localize a field that reads and writes itself where it sits', () => {
// Such a field stands for more than one setting, so there is no single
// stored setting to name it after.
expect(
computeFormLabel(
createForm(
['editor'],
[
{
formPath: ['mode'],
configPaths: [['editor', 'mode']],
read: () => null,
write: () => [],
},
],
),
{ name: 'mode', selector: { boolean: {} } },
),
).toBe('Full editor');
});
it('should use the title for container nodes', () => {
expect(
computeFormLabel(['image'], {
computeFormLabel(createForm(['image']), {
name: 'proxy',
type: 'expandable',
title: 'Proxy',
@@ -63,13 +109,23 @@ describe('computeFormLabel', () => {
it('should fall back to the name for container nodes without a title', () => {
expect(
computeFormLabel(['image'], { name: 'proxy', type: 'expandable', schema: [] }),
computeFormLabel(createForm(['image']), {
name: 'proxy',
type: 'expandable',
schema: [],
}),
).toBe('proxy');
});
it('should return an empty label for a grid', () => {
// The fields a grid lays out are labelled individually; the grid itself
// shows nothing.
expect(computeFormLabel(createForm([]), { type: 'grid', schema: [] })).toBe('');
});
it('should return an empty label for a nameless, titleless container', () => {
expect(computeFormLabel(['cameras', 0], { type: 'expandable', schema: [] })).toBe(
'',
);
expect(
computeFormLabel(createForm(['cameras', 0]), { type: 'expandable', schema: [] }),
).toBe('');
});
});
@@ -5,7 +5,7 @@ import { FormsController } from '../../../src/components-lib/editor/forms-contro
const OPTIONS = { cameras: [], folders: [] };
const INPUT = { config: {}, defaults: {}, options: OPTIONS };
const MENU = { kind: 'section' as const, name: 'menu' };
const MENU = { kind: 'full-section' as const, name: 'menu' };
const createController = () => {
const onChanges = vi.fn();
@@ -35,7 +35,7 @@ describe('FormsController', () => {
const { controller } = createController();
controller.setInput(MENU, INPUT);
controller.setInput({ kind: 'section', name: 'timeline' }, INPUT);
controller.setInput({ kind: 'full-section', name: 'timeline' }, INPUT);
expect(controller.getContexts()[0].form.basePath).toEqual(['timeline']);
});
@@ -62,7 +62,7 @@ describe('FormsController', () => {
it('should build the forms again when the values its selectors offer change', () => {
const { controller } = createController();
const request = { kind: 'camera' as const, index: 0 };
const request = { kind: 'full-camera' as const, index: 0 };
controller.setInput(request, INPUT);
const before = controller.getContexts()[0].form;
@@ -84,7 +84,7 @@ describe('FormsController', () => {
it('should keep the forms it has when a rebuild produces the same ones', () => {
const { controller } = createController();
const request = { kind: 'camera' as const, index: 0 };
const request = { kind: 'full-camera' as const, index: 0 };
const cameras = [
{ value: 'camera.one', label: 'One' },
{ value: 'camera.two', label: 'Two' },
@@ -188,7 +188,7 @@ describe('FormsController', () => {
// The dimensions section has a single form, so the last of the menu
// section's forms no longer exists once it is asked for.
controller.setInput({ kind: 'section', name: 'dimensions' }, INPUT);
controller.setInput({ kind: 'full-section', name: 'dimensions' }, INPUT);
last.valueChanged(
new CustomEvent('value-changed', { detail: { value: { style: 'outside' } } }),
);
@@ -87,15 +87,18 @@ describe('ListFormsController', () => {
describe('should build the forms of an item', () => {
it('should have no contexts before it is given any input', () => {
const { controller } = createController();
expect(controller.getFormContexts({ kind: 'camera', index: 0 })).toEqual([]);
expect(controller.getFormContexts({ kind: 'full-camera', index: 0 })).toEqual([]);
});
it.each([
[{ kind: 'camera' as const, index: 1 }, ['cameras', 1]],
[{ kind: 'folder' as const, index: 2 }, ['folders', 2]],
[{ kind: 'camera-triggers' as const, cameraIndex: 1 }, ['cameras', 1, 'triggers']],
[{ kind: 'full-camera' as const, index: 1 }, ['cameras', 1]],
[{ kind: 'full-folder' as const, index: 2 }, ['folders', 2]],
[
{ kind: 'camera-event' as const, cameraIndex: 1, eventIndex: 3 },
{ kind: 'full-camera-triggers' as const, cameraIndex: 1 },
['cameras', 1, 'triggers'],
],
[
{ kind: 'full-camera-event' as const, cameraIndex: 1, eventIndex: 3 },
['cameras', 1, 'triggers', 'events', 3],
],
])('should build the forms for %j', (request, basePath) => {
@@ -109,7 +112,7 @@ describe('ListFormsController', () => {
it('should keep the forms of an item across configuration changes', () => {
const { controller } = createController();
const request = { kind: 'camera' as const, index: 0 };
const request = { kind: 'full-camera' as const, index: 0 };
controller.setInput({ config: {}, defaults: {}, options: OPTIONS });
const before = controller.getFormContexts(request)[0].form;
@@ -124,7 +127,7 @@ describe('ListFormsController', () => {
it('should keep the same contexts when nothing changed', () => {
const { controller } = createController();
const request = { kind: 'camera' as const, index: 0 };
const request = { kind: 'full-camera' as const, index: 0 };
const input = { config: {}, defaults: {}, options: OPTIONS };
controller.setInput(input);
const before = controller.getFormContexts(request);
@@ -138,8 +141,8 @@ describe('ListFormsController', () => {
const { controller, listener } = createController();
controller.setInput({ config: {}, defaults: {}, options: OPTIONS });
const camera = controller.getFormContexts({ kind: 'camera', index: 0 });
const folder = controller.getFormContexts({ kind: 'folder', index: 0 });
const camera = controller.getFormContexts({ kind: 'full-camera', index: 0 });
const folder = controller.getFormContexts({ kind: 'full-folder', index: 0 });
expect(camera[0]).not.toBe(folder[0]);
folder[0].valueChanged(
@@ -156,7 +159,7 @@ describe('ListFormsController', () => {
it('should rebuild the forms when the values its selectors offer change', () => {
const { controller } = createController();
const request = { kind: 'camera' as const, index: 0 };
const request = { kind: 'full-camera' as const, index: 0 };
controller.setInput({ config: {}, defaults: {}, options: OPTIONS });
const before = controller.getFormContexts(request)[0].form;
@@ -180,7 +183,7 @@ describe('ListFormsController', () => {
controller.setInput({ config: {}, defaults: {}, options: OPTIONS });
controller
.getFormContexts({ kind: 'camera', index: 2 })[0]
.getFormContexts({ kind: 'full-camera', index: 2 })[0]
.valueChanged(
new CustomEvent('value-changed', { detail: { value: { id: 'front' } } }),
);
+139
View File
@@ -0,0 +1,139 @@
import { assert, describe, expect, it } from 'vitest';
import {
applyConfigChanges,
computeConfigChanges,
computeDisplayedData,
} from '../../../src/components-lib/editor/form-data';
import {
deriveEditorMode,
getEditorMode,
} from '../../../src/components-lib/editor/mode';
import { getSimpleMenuForms } from '../../../src/components-lib/editor/schema/simple';
import { getConfigValue } from '../../../src/config/management';
import { configDefaults } from '../../../src/config/schema/types';
// The card type is in every configuration Home Assistant hands the editor.
const createConfig = (config: Record<string, unknown> = {}) => ({
type: 'custom:advanced-camera-card',
...config,
});
describe('deriveEditorMode', () => {
it('should choose the simple editor for a configuration that sets nothing', () => {
expect(deriveEditorMode(createConfig())).toBe('simple');
});
it('should choose the simple editor for the settings it shows', () => {
expect(
deriveEditorMode(
createConfig({
cameras: [
{ camera_entity: 'camera.office', live_provider: 'go2rtc' },
{ camera_entity: 'camera.kitchen', title: 'Kitchen', icon: 'mdi:cctv' },
],
menu: {
style: 'outside',
position: 'left',
buttons: { clips: { enabled: true } },
},
dimensions: { aspect_ratio_mode: 'static', aspect_ratio: '16:9' },
view: { default: 'clips' },
profiles: ['casting'],
}),
),
).toBe('simple');
});
it('should choose the full editor for a setting the simple editor does not show', () => {
expect(deriveEditorMode(createConfig({ view: { dim: true } }))).toBe('full');
});
it('should choose the full editor for a menu button property it does not show', () => {
expect(
deriveEditorMode(createConfig({ menu: { buttons: { clips: { priority: 5 } } } })),
).toBe('full');
});
it.each([
[{ live: { preload: true } }],
[{ timeline: { window_seconds: 7200 } }],
[{ folders: [{ type: 'ha' }] }],
[{ cameras: [{ camera_entity: 'camera.office', engine: 'frigate' }] }],
])('should choose the full editor for %j', (config) => {
expect(deriveEditorMode(createConfig(config))).toBe('full');
});
it.each([
// Not a setting of the card at all.
[{ fake: true }],
// Settings the full editor does not show either, so sending the user there
// would show them nothing more.
[{ debug: { logging: true } }],
[{ card_mod: { style: 'body {}' } }],
[{ cameras_global: { live_provider: 'go2rtc' } }],
[{ elements: [{ type: 'custom:foo' }] }],
[{ automations: [{ conditions: [{ condition: 'fullscreen' }] }] }],
[{ overrides: [{ merge: { menu: { style: 'none' } } }] }],
[{ cameras: [{ camera_entity: 'camera.office', ptz: { presets: {} } }] }],
])('should choose the simple editor for %j', (config) => {
expect(deriveEditorMode(createConfig(config))).toBe('simple');
});
it('should disregard a section that sets nothing', () => {
expect(deriveEditorMode(createConfig({ live: {}, folders: [{}] }))).toBe('simple');
});
it('should disregard the editor mode itself', () => {
expect(deriveEditorMode(createConfig({ editor: { mode: 'full' } }))).toBe('simple');
});
});
describe('getEditorMode', () => {
it.each([['simple' as const], ['full' as const]])(
'should use the configured %s mode',
(mode) => {
// A configuration the derivation would send to the other editor, so that
// the configured mode is what the result comes from.
const config = createConfig({
editor: { mode },
...(mode === 'simple' ? { view: { dim: true } } : {}),
});
expect(getEditorMode(config)).toBe(mode);
},
);
it('should derive the mode when the configuration does not say', () => {
expect(getEditorMode(createConfig())).toBe('simple');
expect(getEditorMode(createConfig({ view: { dim: true } }))).toBe('full');
});
it('should derive the mode when the configured one is not an editor', () => {
expect(getEditorMode(createConfig({ editor: { mode: 'simpel' } }))).toBe('simple');
});
it('should still choose the simple editor after an edit made in it', () => {
const config = createConfig({ cameras: [{ camera_entity: 'camera.office' }] });
expect(getEditorMode(config)).toBe('simple');
// A menu style chosen in the simple editor, written the way the editor
// writes it.
const [form] = getSimpleMenuForms();
const displayed = computeDisplayedData(form, config, configDefaults);
const edited = applyConfigChanges(
config,
computeConfigChanges(
form,
displayed,
{ ...displayed, menu_style: 'outside' },
config,
configDefaults,
),
);
assert(edited);
expect(getConfigValue(edited, 'menu.style')).toBe('outside');
expect(getEditorMode(edited)).toBe('simple');
});
});
@@ -1,14 +1,19 @@
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { forEachFieldRecursively } from '../../../../src/components-lib/editor/form-data';
import {
forEachFieldRecursively,
getFormConfigPaths,
} from '../../../../src/components-lib/editor/form-data';
import {
getCameraSchema,
getCameraTriggersSchema,
getTriggerEventSchema,
} from '../../../../src/components-lib/editor/schema/cameras';
import { getDimensionsSectionForms } from '../../../../src/components-lib/editor/schema/dimensions';
import { getEditorModeForms } from '../../../../src/components-lib/editor/schema/editor-mode';
import { getFolderSchema } from '../../../../src/components-lib/editor/schema/folders';
import { getFullEditorForms } from '../../../../src/components-lib/editor/schema/full';
import { getImageSectionForms } from '../../../../src/components-lib/editor/schema/image';
import { getLiveSectionForms } from '../../../../src/components-lib/editor/schema/live';
import { getMediaGallerySectionForms } from '../../../../src/components-lib/editor/schema/media-gallery';
@@ -17,13 +22,23 @@ import { getMenuSectionForms } from '../../../../src/components-lib/editor/schem
import { getPerformanceSectionForms } from '../../../../src/components-lib/editor/schema/performance';
import { getProfilesSectionForms } from '../../../../src/components-lib/editor/schema/profiles';
import { getRemoteControlSectionForms } from '../../../../src/components-lib/editor/schema/remote-control';
import {
getSimpleCameraForms,
getSimpleMenuForms,
getSimpleTopLevelForms,
} from '../../../../src/components-lib/editor/schema/simple';
import { getStatusBarSectionForms } from '../../../../src/components-lib/editor/schema/status-bar';
import { getTimelineSectionForms } from '../../../../src/components-lib/editor/schema/timeline';
import {
getViewKeyboardShortcutsSectionForms,
getViewSectionForms,
} from '../../../../src/components-lib/editor/schema/view';
import type { EditorForm } from '../../../../src/components-lib/editor/types';
import {
findBinding,
isComputedFieldBinding,
type ConfigPath,
type EditorForm,
} from '../../../../src/components-lib/editor/types';
import { advancedCameraCardConfigSchema } from '../../../../src/config/schema/types';
import { PTZ_KEYBOARD_SHORTCUTS } from '../../../../src/config/schema/view';
import type { HAFormSelectorSchema } from '../../../../src/ha/types';
@@ -128,6 +143,26 @@ const SECTION_FORMS: Record<string, EditorForm[]> = {
],
};
// The forms that are not a section of the configuration: the switch between the
// editors, and the simple editor, which gathers its fields from across the
// configuration and binds each to where its setting is stored. Their names are
// the editor's own, so a failure says which set of forms it came from. Every
// path they address is one a section covers too, so they add nothing to
// direction 2 and everything to direction 1.
const UNSECTIONED_FORMS: Record<string, EditorForm[]> = {
editor_mode: getEditorModeForms(),
simple_top_level: getSimpleTopLevelForms(),
simple_menu: getSimpleMenuForms(),
// The camera form is the array item at index 0, as the array sections above.
simple_cameras: getSimpleCameraForms(0),
};
const ALL_FORMS: Record<string, EditorForm[]> = {
...SECTION_FORMS,
...UNSECTIONED_FORMS,
};
// `z.core.$ZodType` is zod v4's base schema type for introspection: it is what
// `.unwrap()` and `ZodArray.element` return, and what every schema (including
// the config root) is assignable to. The classic `z.ZodType` is a subtype with
@@ -156,7 +191,7 @@ const unwrap = (schema: z.core.$ZodType): z.core.$ZodType => {
// Resolve a configuration path to its zod leaf schema, or null if the path
// does not exist in the configuration schema.
const resolvePath = (path: (string | number)[]): z.core.$ZodType | null => {
const resolvePath = (path: ConfigPath): z.core.$ZodType | null => {
let current: z.core.$ZodType = advancedCameraCardConfigSchema;
for (const segment of path) {
current = unwrap(current);
@@ -204,7 +239,7 @@ const enumValues = (schema: z.core.$ZodType): unknown[] | null => {
// True if a config path lies inside a subtree the editor forms do not own
// (YAML-only, or rendered by a custom widget), so the completeness walk skips
// it.
const isPruned = (path: (string | number)[]): boolean => {
const isPruned = (path: ConfigPath): boolean => {
const key = path.join('.');
return [...EDITOR_CUSTOM_WIDGETS, ...EDITOR_EXCLUDED].some(
(prefix) => key === prefix || key.startsWith(`${prefix}.`),
@@ -216,7 +251,7 @@ const isPruned = (path: (string | number)[]): boolean => {
// as leaves), pruning the subtrees the editor does not own.
const enumerateConfigLeavesRecursively = (
schema: z.core.$ZodType,
prefix: (string | number)[] = [],
prefix: ConfigPath = [],
): string[] => {
if (prefix.length && isPruned(prefix)) {
return [];
@@ -235,62 +270,109 @@ const enumerateConfigLeavesRecursively = (
return [prefix.join('.')];
};
// Every configuration path the editor forms currently cover.
const coveredPaths = new Set<string>();
for (const forms of Object.values(SECTION_FORMS)) {
for (const form of forms) {
forEachFieldRecursively(form.schema, (path) => {
// Array sections carry a numeric index in `basePath` (`['folders', 0]`);
// strip it so covered paths match the index-free enumerated leaves.
const key = [...form.basePath, ...path]
.filter((segment) => typeof segment !== 'number')
.join('.');
coveredPaths.add(key);
});
}
// Every configuration path the editor forms currently cover. Array sections
// carry a numeric index (`['folders', 0]`); strip it so covered paths match the
// index-free enumerated leaves.
const coveredPaths = new Set(
Object.values(ALL_FORMS)
.flat()
.flatMap(getFormConfigPaths)
.map((path) => path.filter((segment) => typeof segment !== 'number').join('.')),
);
// One check per configuration path a field addresses. A field that reads and
// writes itself addresses more than one, and its selector deliberately does not
// match how the value is stored (a switch for a named mode, a list of names for
// one boolean per menu button), so only the existence of its paths is checked.
interface FieldCheck {
section: string;
path: ConfigPath;
field: HAFormSelectorSchema;
checkSelector: boolean;
}
const getFieldChecks = (): FieldCheck[] => {
const checks: FieldCheck[] = [];
for (const [section, forms] of Object.entries(ALL_FORMS)) {
for (const form of forms) {
forEachFieldRecursively(form.schema, (formPath, field) => {
const binding = findBinding(form, formPath);
const computed = !!binding && isComputedFieldBinding(binding);
const paths = !binding
? [[...form.basePath, ...formPath]]
: isComputedFieldBinding(binding)
? binding.configPaths
: [binding.configPath];
checks.push(
...paths.map((path) => ({ section, path, field, checkSelector: !computed })),
);
});
}
}
return checks;
};
describe('editor schema completeness', () => {
// The derivation asks `getFullEditorForms` what the full editor shows, while
// this harness walks the sections it is given above. The two must describe
// the same editor, or a section could be checked here and invisible to the
// derivation, or the reverse.
it('should check every form the full editor shows', () => {
const walked = new Set(
Object.entries(ALL_FORMS)
.filter(([section]) => !(section in UNSECTIONED_FORMS))
.flatMap(([, forms]) => forms)
.flatMap(getFormConfigPaths)
.map((path) => path.join('.')),
);
for (const path of getFullEditorForms().flatMap(getFormConfigPaths)) {
expect(walked, `${path.join('.')} is shown but not checked`).toContain(
path.join('.'),
);
}
});
// Direction 1: every editor field path is a real configuration key.
describe('every form field resolves to a matching configuration key', () => {
for (const [section, forms] of Object.entries(SECTION_FORMS)) {
for (const form of forms) {
forEachFieldRecursively(form.schema, (path, field) => {
const fullPath = [...form.basePath, ...path];
const key = fullPath.join('.');
for (const { section, path, field, checkSelector } of getFieldChecks()) {
const key = path.join('.');
it(`${section}: ${key}`, () => {
const resolved = resolvePath(fullPath);
expect(resolved, `path ${key} is not in the config schema`).not.toBeNull();
it(`${section}: ${key}`, () => {
const resolved = resolvePath(path);
expect(resolved, `path ${key} is not in the config schema`).not.toBeNull();
// Direction 1b: the selector kind matches the zod type where the
// type is unambiguous (number/boolean/enum).
const kind = selectorKind(field);
if (resolved instanceof z.ZodNumber) {
expect(kind, `${key} should use a number selector`).toBe('number');
} else if (resolved instanceof z.ZodBoolean) {
expect(kind, `${key} should use a boolean selector`).toBe('boolean');
} else {
const values = resolved ? enumValues(resolved) : null;
if (values) {
expect(kind, `${key} should use a select selector`).toBe('select');
// Direction 1c: the dropdown offers every enum value.
if ('select' in field.selector) {
const options = field.selector.select.options.map((option) =>
typeof option === 'object' ? option.value : option,
);
for (const value of values) {
expect(
options,
`${key} dropdown is missing enum value ${String(value)}`,
).toContain(value);
}
}
if (!checkSelector) {
return;
}
// Direction 1b: the selector kind matches the zod type where the
// type is unambiguous (number/boolean/enum).
const kind = selectorKind(field);
if (resolved instanceof z.ZodNumber) {
expect(kind, `${key} should use a number selector`).toBe('number');
} else if (resolved instanceof z.ZodBoolean) {
expect(kind, `${key} should use a boolean selector`).toBe('boolean');
} else {
const values = resolved ? enumValues(resolved) : null;
if (values) {
expect(kind, `${key} should use a select selector`).toBe('select');
// Direction 1c: the dropdown offers every enum value.
if ('select' in field.selector) {
const options = field.selector.select.options.map((option) =>
typeof option === 'object' ? option.value : option,
);
for (const value of values) {
expect(
options,
`${key} dropdown is missing enum value ${String(value)}`,
).toContain(value);
}
}
});
});
}
}
}
});
}
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import {
computeConfigChanges,
computeDisplayedData,
} from '../../../../src/components-lib/editor/form-data';
import { getEditorModeForms } from '../../../../src/components-lib/editor/schema/editor-mode';
import type { RawAdvancedCameraCardConfig } from '../../../../src/config/types';
const [MODE_FORM] = getEditorModeForms();
const isFullEditorShown = (config: RawAdvancedCameraCardConfig): unknown =>
computeDisplayedData(MODE_FORM, config, {})['mode'];
const setFullEditorShown = (config: RawAdvancedCameraCardConfig, shown: boolean) =>
computeConfigChanges(
MODE_FORM,
computeDisplayedData(MODE_FORM, config, {}),
{ mode: shown },
config,
{},
);
describe('the editor mode field', () => {
it('should be on for a configuration asking for the full editor', () => {
expect(isFullEditorShown({ editor: { mode: 'full' } })).toBe(true);
});
it('should be off for a configuration asking for the simple editor', () => {
expect(isFullEditorShown({ editor: { mode: 'simple' } })).toBe(false);
});
it('should follow the editor chosen for a configuration that does not ask', () => {
expect(isFullEditorShown({ type: 'custom:advanced-camera-card' })).toBe(false);
expect(isFullEditorShown({ view: { dim: true } })).toBe(true);
});
it('should ask for the full editor when turned on', () => {
expect(setFullEditorShown({}, true)).toEqual([
{ path: ['editor', 'mode'], type: 'set', value: 'full' },
]);
});
it('should ask for the simple editor when turned off', () => {
expect(setFullEditorShown({ editor: { mode: 'full' } }, false)).toEqual([
{ path: ['editor', 'mode'], type: 'set', value: 'simple' },
]);
});
});
@@ -1,11 +1,13 @@
import { assert, describe, expect, it } from 'vitest';
import { getForms } from '../../../../src/components-lib/editor/schema/registry';
import type { ConfigPath } from '../../../../src/components-lib/editor/types';
import { isFormFieldSchema } from '../../../../src/ha/types';
const OPTIONS = { cameras: [], folders: [] };
// Every section the editor offers, and where its first form edits.
const SECTIONS: [string, (string | number)[]][] = [
const SECTIONS: [string, ConfigPath][] = [
['dimensions', ['dimensions']],
['image', ['image']],
['live', ['live']],
@@ -24,7 +26,7 @@ const SECTIONS: [string, (string | number)[]][] = [
describe('getForms', () => {
describe('should build the forms of a section', () => {
it.each(SECTIONS)('should build the %s section', (name, basePath) => {
const forms = getForms({ kind: 'section', name }, OPTIONS);
const forms = getForms({ kind: 'full-section', name }, OPTIONS);
expect(forms.length).toBeGreaterThan(0);
expect(forms[0].basePath).toEqual(basePath);
@@ -32,7 +34,9 @@ describe('getForms', () => {
});
it('should build no forms for a section it does not know', () => {
expect(getForms({ kind: 'section', name: 'nonexistent' }, OPTIONS)).toEqual([]);
expect(getForms({ kind: 'full-section', name: 'nonexistent' }, OPTIONS)).toEqual(
[],
);
});
});
@@ -45,7 +49,7 @@ describe('getForms', () => {
const getDependencyOptions = (index: number): unknown => {
const [form] = getForms(
{ kind: 'camera', index },
{ kind: 'full-camera', index },
{ ...OPTIONS, cameras: CAMERAS },
);
const dependencies = form.schema.find(
@@ -55,7 +59,7 @@ describe('getForms', () => {
const cameras = dependencies.schema.find(
(field) => 'name' in field && field.name === 'cameras',
);
assert(cameras && 'selector' in cameras && 'select' in cameras.selector);
assert(cameras && isFormFieldSchema(cameras) && 'select' in cameras.selector);
return cameras.selector.select?.options;
};
@@ -70,13 +74,23 @@ describe('getForms', () => {
});
});
describe('should build the forms of a list item', () => {
// Every request other than a section, which is named rather than being a
// request of its own and is covered above. Each edits one place in the
// configuration, so each builds a single form.
describe('should build the forms of every other request', () => {
it.each([
[{ kind: 'camera' as const, index: 2 }, ['cameras', 2]],
[{ kind: 'folder' as const, index: 1 }, ['folders', 1]],
[{ kind: 'camera-triggers' as const, cameraIndex: 2 }, ['cameras', 2, 'triggers']],
[{ kind: 'editor-mode' as const }, ['editor']],
[{ kind: 'simple-top-level' as const }, []],
[{ kind: 'simple-menu' as const }, []],
[{ kind: 'simple-camera' as const, index: 3 }, ['cameras', 3]],
[{ kind: 'full-camera' as const, index: 2 }, ['cameras', 2]],
[{ kind: 'full-folder' as const, index: 1 }, ['folders', 1]],
[
{ kind: 'camera-event' as const, cameraIndex: 2, eventIndex: 4 },
{ kind: 'full-camera-triggers' as const, cameraIndex: 2 },
['cameras', 2, 'triggers'],
],
[
{ kind: 'full-camera-event' as const, cameraIndex: 2, eventIndex: 4 },
['cameras', 2, 'triggers', 'events', 4],
],
])('should build the forms for %j', (request, basePath) => {
@@ -0,0 +1,246 @@
import { assert, describe, expect, it } from 'vitest';
import {
applyConfigChanges,
computeConfigChanges,
computeDisplayedData,
} from '../../../../src/components-lib/editor/form-data';
import {
getSimpleMenuForms,
getSimpleTopLevelForms,
} from '../../../../src/components-lib/editor/schema/simple';
import {
findBinding,
isComputedFieldBinding,
} from '../../../../src/components-lib/editor/types';
import { copyConfig, getConfigValue } from '../../../../src/config/management';
import { setProfiles } from '../../../../src/config/profiles/set-profiles';
import { menuConfigDefault } from '../../../../src/config/schema/menu';
import type { ProfileType } from '../../../../src/config/schema/profiles';
import { configDefaults } from '../../../../src/config/schema/types';
import type { RawAdvancedCameraCardConfig } from '../../../../src/config/types';
const [MENU_FORM] = getSimpleMenuForms();
const [TOP_LEVEL_FORM] = getSimpleTopLevelForms();
// The paths the buttons field says it stands for, taken from the field itself
// rather than from a walk of the form, so that the two can be compared.
const getMenuButtonsConfigPaths = () => {
const binding = findBinding(MENU_FORM, ['menu_buttons']);
assert(binding && isComputedFieldBinding(binding));
return binding.configPaths;
};
// The values a field shows when the configuration does not set it, built the
// way the editor builds them so that profiles are taken into account. The
// configuration given to `setProfiles` must be a valid one: it does not apply a
// profile over a configuration it cannot parse.
const createDefaults = (profiles: ProfileType[] = []): RawAdvancedCameraCardConfig => {
const defaults = copyConfig(configDefaults);
setProfiles(
{
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
},
defaults,
profiles,
);
return defaults;
};
const getMenuButtons = (
config: RawAdvancedCameraCardConfig,
defaults: RawAdvancedCameraCardConfig = createDefaults(),
): unknown =>
getConfigValue(computeDisplayedData(MENU_FORM, config, defaults), 'menu_buttons');
// Edit the buttons as the form does: the form emits everything it displays,
// with the edited field changed.
const changeMenuButtons = (
config: RawAdvancedCameraCardConfig,
buttons: unknown,
defaults: RawAdvancedCameraCardConfig = createDefaults(),
) => {
const displayed = computeDisplayedData(MENU_FORM, config, defaults);
return computeConfigChanges(
MENU_FORM,
displayed,
{ ...displayed, menu_buttons: buttons },
config,
defaults,
);
};
describe('the simple editor forms', () => {
it('should show the value of the configuration key a field is bound to', () => {
expect(
computeDisplayedData(
MENU_FORM,
{ menu: { style: 'outside', position: 'left' } },
createDefaults(),
),
).toMatchObject({ menu_style: 'outside', menu_position: 'left' });
expect(
computeDisplayedData(
TOP_LEVEL_FORM,
{ view: { default: 'clips' } },
createDefaults(),
),
).toMatchObject({ default_view: 'clips' });
});
it('should write an edit to the configuration key a field is bound to', () => {
expect(
computeConfigChanges(
MENU_FORM,
{ menu_style: 'hidden' },
{ menu_style: 'outside' },
{},
createDefaults(),
),
).toEqual([{ path: ['menu', 'style'], type: 'set', value: 'outside' }]);
expect(
computeConfigChanges(
TOP_LEVEL_FORM,
{ default_view: 'live' },
{ default_view: 'clips' },
{},
createDefaults(),
),
).toEqual([{ path: ['view', 'default'], type: 'set', value: 'clips' }]);
});
});
describe('the menu buttons field', () => {
it('should select the buttons the menu shows by default', () => {
// `iris` is shown by default, `clips` is not.
expect(getMenuButtons({})).toContain('iris');
expect(getMenuButtons({})).not.toContain('clips');
});
it('should select buttons by what the configuration sets, not the defaults', () => {
const buttons = getMenuButtons({
menu: { buttons: { iris: { enabled: false }, clips: { enabled: true } } },
});
expect(buttons).not.toContain('iris');
expect(buttons).toContain('clips');
});
it('should not select a button that a profile hides', () => {
// The low performance profile hides `iris` and `timeline`.
expect(getMenuButtons({}, createDefaults(['low-performance']))).not.toContain(
'iris',
);
});
it('should store false for a default-shown button that is turned off', () => {
expect(changeMenuButtons({}, ['clips'])).toContainEqual({
path: ['menu', 'buttons', 'iris', 'enabled'],
type: 'set',
value: false,
});
});
it('should store true for a default-hidden button that is turned on', () => {
expect(changeMenuButtons({}, ['clips'])).toContainEqual({
path: ['menu', 'buttons', 'clips', 'enabled'],
type: 'set',
value: true,
});
});
it('should remove the stored value of a button returned to its default', () => {
const config = { menu: { buttons: { clips: { enabled: true } } } };
expect(changeMenuButtons(config, [])).toContainEqual({
path: ['menu', 'buttons', 'clips', 'enabled'],
type: 'delete',
});
});
it('should change nothing for the buttons that stay as they are', () => {
// Only `clips` moves: everything else is already as asked for.
const config = { menu: { buttons: { iris: { enabled: false } } } };
const buttons = getMenuButtons(config);
const wanted = Array.isArray(buttons) ? [...buttons, 'clips'] : ['clips'];
expect(changeMenuButtons(config, wanted)).toEqual([
{ path: ['menu', 'buttons', 'clips', 'enabled'], type: 'set', value: true },
]);
});
it('should treat a value that is not a list of buttons as none selected', () => {
expect(changeMenuButtons({}, undefined)).toContainEqual({
path: ['menu', 'buttons', 'iris', 'enabled'],
type: 'set',
value: false,
});
});
it('should stand for the enabled setting of every menu button the card has', () => {
// Taken from the card's own menu defaults rather than from the editor, so
// that a button the card gains and the editor does not is a failure.
const expected = Object.keys(menuConfigDefault.buttons).map(
(button) => `menu.buttons.${button}.enabled`,
);
expect(
getMenuButtonsConfigPaths()
.map((path) => path.join('.'))
.sort(),
).toEqual(expected.sort());
});
it('should change only the configuration keys it stands for', () => {
const declared = new Set(
Object.keys(menuConfigDefault.buttons).map(
(button) => `menu.buttons.${button}.enabled`,
),
);
const changes = changeMenuButtons({}, ['clips', 'iris']);
expect(changes.length).toBeGreaterThan(0);
for (const change of changes) {
expect(declared).toContain(change.path.join('.'));
}
});
it('should leave the rest of the configuration as it was', () => {
const config = {
cameras: [{ camera_entity: 'camera.office' }],
menu: {
style: 'outside',
buttons: { iris: { enabled: false, priority: 7, icon: 'mdi:cow' } },
},
live: { preload: true },
timeline: { window_seconds: 7200 },
};
// Turn `iris` back on, which returns it to its default, and `clips` on,
// leaving every other button as it is.
const shown = getMenuButtons(config);
assert(Array.isArray(shown));
const edited = applyConfigChanges(
config,
changeMenuButtons(config, [...shown, 'iris', 'clips']),
);
assert(edited);
expect(edited).toEqual({
cameras: [{ camera_entity: 'camera.office' }],
menu: {
style: 'outside',
buttons: {
// The button's other settings survive its `enabled` being removed.
iris: { priority: 7, icon: 'mdi:cow' },
clips: { enabled: true },
},
},
live: { preload: true },
timeline: { window_seconds: 7200 },
});
});
});
@@ -28,7 +28,7 @@ describe('SectionController', () => {
it('should build the forms of the requested section', () => {
const { controller } = createController();
controller.setInput({ kind: 'section', name: 'menu' }, INPUT);
controller.setInput({ kind: 'full-section', name: 'menu' }, INPUT);
const contexts = controller.getContexts();
expect(contexts.length).toBeGreaterThan(0);
@@ -39,7 +39,7 @@ describe('SectionController', () => {
const { host, controller } = createController();
const listener = vi.fn();
host.addEventListener('advanced-camera-card:editor:intent', listener);
controller.setInput({ kind: 'section', name: 'menu' }, INPUT);
controller.setInput({ kind: 'full-section', name: 'menu' }, INPUT);
controller
.getContexts()[0]