feat: Modernize the visual card editor. (#2586)
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createNumberSelector,
|
||||
createSelectSelector,
|
||||
} from '../../../../../src/components-lib/editor/schema/common/selectors';
|
||||
|
||||
describe('createSelectSelector', () => {
|
||||
it('should build a single-value dropdown from options', () => {
|
||||
expect(createSelectSelector(['a', 'b'])).toEqual({
|
||||
select: {
|
||||
mode: 'dropdown',
|
||||
multiple: false,
|
||||
custom_value: false,
|
||||
options: ['a', 'b'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow multiple values', () => {
|
||||
expect(createSelectSelector(['a'], { multiple: true }).select.multiple).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow custom values only when no options are given', () => {
|
||||
expect(createSelectSelector([]).select.custom_value).toBe(true);
|
||||
expect(createSelectSelector(['a']).select.custom_value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNumberSelector', () => {
|
||||
it('should default to an input box with a zero minimum', () => {
|
||||
expect(createNumberSelector()).toEqual({
|
||||
number: { min: 0, max: undefined, mode: 'box', step: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('should be a slider when a maximum is given', () => {
|
||||
expect(createNumberSelector({ min: 1, max: 10, step: 2 })).toEqual({
|
||||
number: { min: 1, max: 10, mode: 'slider', step: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { forEachFieldRecursively } 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 { getFolderSchema } from '../../../../src/components-lib/editor/schema/folders';
|
||||
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';
|
||||
import { getMediaViewerSectionForms } from '../../../../src/components-lib/editor/schema/media-viewer';
|
||||
import { getMenuSectionForms } from '../../../../src/components-lib/editor/schema/menu';
|
||||
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 { 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 { advancedCameraCardConfigSchema } from '../../../../src/config/schema/types';
|
||||
import { PTZ_KEYBOARD_SHORTCUTS } from '../../../../src/config/schema/view';
|
||||
import type { HAFormSelectorSchema } from '../../../../src/ha/types';
|
||||
|
||||
// ============================================================================
|
||||
// Editor <-> Zod Schema Completeness tests
|
||||
//
|
||||
// Two directions:
|
||||
// - Direction 1 (editor -> config): every editor field path is a real config
|
||||
// key, its selector kind matches the zod type (number/boolean/enum), and its
|
||||
// dropdown offers every enum value.
|
||||
// - Direction 2 (config -> editor): every configurable zod leaf appears in an
|
||||
// editor form, unless explicitly excepted. Catches config fields forgotten in
|
||||
// the editor.
|
||||
// ============================================================================
|
||||
|
||||
// Configuration subtrees the editor renders with a dedicated widget instead of
|
||||
// an `ha-form` field, so the field walk cannot see them.
|
||||
const EDITOR_CUSTOM_WIDGETS = PTZ_KEYBOARD_SHORTCUTS.map(
|
||||
(name) => `view.keyboard_shortcuts.${name}`,
|
||||
);
|
||||
|
||||
// Configuration subtrees intentionally kept YAML-only.
|
||||
const EDITOR_EXCLUDED = [
|
||||
'cameras_global',
|
||||
'elements',
|
||||
'automations',
|
||||
'overrides',
|
||||
'debug',
|
||||
'type',
|
||||
'card_id',
|
||||
'card_mod',
|
||||
|
||||
// Advanced/freeform camera subtrees not surfaced in the editor.
|
||||
'cameras.jsmpeg',
|
||||
'cameras.ptz',
|
||||
'cameras.dimensions.grid',
|
||||
'cameras.triggers.events.context',
|
||||
|
||||
// Free-form CSS variable overrides.
|
||||
'view.theme.overrides',
|
||||
|
||||
// Not surfaced in the editor (the maintainer declined adding it).
|
||||
'view.render_entities',
|
||||
|
||||
// Section-level action handlers (`tap_action` etc.) need a dedicated action
|
||||
// editor and have never been exposed.
|
||||
'view.actions',
|
||||
'image.actions',
|
||||
'media_gallery.actions',
|
||||
'live.actions',
|
||||
'media_viewer.actions',
|
||||
|
||||
// Free-form CSS object for the PTZ control styling.
|
||||
'live.controls.ptz.style',
|
||||
'media_viewer.controls.ptz.style',
|
||||
|
||||
// Advanced folder path parsers/matchers/templating: text-editor only.
|
||||
'folders.ha.path',
|
||||
];
|
||||
|
||||
// Every section's forms, keyed by section, so the harness walks the full
|
||||
// field tree of everything rendered via `ha-form`.
|
||||
const SECTION_FORMS: Record<string, EditorForm[]> = {
|
||||
profiles: getProfilesSectionForms(),
|
||||
view: [...getViewSectionForms(), ...getViewKeyboardShortcutsSectionForms()],
|
||||
image: getImageSectionForms(),
|
||||
live: getLiveSectionForms(),
|
||||
media_gallery: getMediaGallerySectionForms(),
|
||||
media_viewer: getMediaViewerSectionForms(),
|
||||
menu: getMenuSectionForms(),
|
||||
status_bar: getStatusBarSectionForms(),
|
||||
timeline: getTimelineSectionForms(),
|
||||
dimensions: getDimensionsSectionForms(),
|
||||
performance: getPerformanceSectionForms(),
|
||||
remote_control: getRemoteControlSectionForms(),
|
||||
|
||||
// Array section: one representative item at index 0 for testing purposes. The
|
||||
// numeric index is stripped when building covered paths, so this checks the
|
||||
// item's fields (`folders.type`, ...) against the array element's schema.
|
||||
folders: [{ basePath: ['folders', 0], schema: getFolderSchema() }],
|
||||
|
||||
// Array section split across the camera form, the triggers sub-form, and the
|
||||
// per-event item form (the triggers group is hand-built to host the events
|
||||
// list, so its fields live in a separate schema).
|
||||
cameras: [
|
||||
{
|
||||
basePath: ['cameras', 0],
|
||||
schema: getCameraSchema({
|
||||
otherCameras: [{ value: 'camera.other', label: 'Other' }],
|
||||
folders: [{ value: 'folder-1', label: 'Folder' }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
basePath: ['cameras', 0, 'triggers'],
|
||||
schema: getCameraTriggersSchema(),
|
||||
},
|
||||
{
|
||||
basePath: ['cameras', 0, 'triggers', 'events', 0],
|
||||
schema: getTriggerEventSchema(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// `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
|
||||
// extra methods, so those introspection results would not assign back to it.
|
||||
|
||||
// Unwrap the zod wrappers that carry no structural meaning for path
|
||||
// navigation (optional/default/nullable/readonly/lazy).
|
||||
const unwrap = (schema: z.core.$ZodType): z.core.$ZodType => {
|
||||
let current: z.core.$ZodType = schema;
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
if (
|
||||
current instanceof z.ZodOptional ||
|
||||
current instanceof z.ZodNullable ||
|
||||
current instanceof z.ZodDefault ||
|
||||
current instanceof z.ZodReadonly
|
||||
) {
|
||||
current = current.unwrap();
|
||||
} else if (current instanceof z.ZodLazy) {
|
||||
current = current.unwrap();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
// 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 => {
|
||||
let current: z.core.$ZodType = advancedCameraCardConfigSchema;
|
||||
for (const segment of path) {
|
||||
current = unwrap(current);
|
||||
if (current instanceof z.ZodObject) {
|
||||
const next: z.core.$ZodType | undefined = current.shape[segment];
|
||||
if (!next) {
|
||||
return null;
|
||||
}
|
||||
current = next;
|
||||
} else if (current instanceof z.ZodArray) {
|
||||
current = current.element;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return unwrap(current);
|
||||
};
|
||||
|
||||
// A selector object has exactly one key naming its kind (`{ number: {} }` ->
|
||||
// 'number', `{ select: {} }` -> 'select'), so its single key is the kind.
|
||||
const selectorKind = (field: HAFormSelectorSchema): string =>
|
||||
Object.keys(field.selector)[0];
|
||||
|
||||
// Collect the literal values of a zod enum or union-of-literals, or null if the
|
||||
// schema is not an enumeration.
|
||||
const enumValues = (schema: z.core.$ZodType): unknown[] | null => {
|
||||
if (schema instanceof z.ZodEnum) {
|
||||
return Object.values(schema.enum);
|
||||
}
|
||||
if (schema instanceof z.ZodUnion) {
|
||||
const values: unknown[] = [];
|
||||
for (const option of schema.options) {
|
||||
const inner = unwrap(option);
|
||||
if (inner instanceof z.ZodLiteral) {
|
||||
values.push(inner.value);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
return 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 key = path.join('.');
|
||||
return [...EDITOR_CUSTOM_WIDGETS, ...EDITOR_EXCLUDED].some(
|
||||
(prefix) => key === prefix || key.startsWith(`${prefix}.`),
|
||||
);
|
||||
};
|
||||
|
||||
// Enumerate the configuration schema's leaf field paths (a leaf is anything
|
||||
// that is not a plain object; arrays are descended into rather than treated
|
||||
// as leaves), pruning the subtrees the editor does not own.
|
||||
const enumerateConfigLeavesRecursively = (
|
||||
schema: z.core.$ZodType,
|
||||
prefix: (string | number)[] = [],
|
||||
): string[] => {
|
||||
if (prefix.length && isPruned(prefix)) {
|
||||
return [];
|
||||
}
|
||||
const current = unwrap(schema);
|
||||
if (current instanceof z.ZodObject) {
|
||||
return Object.keys(current.shape).flatMap((key) =>
|
||||
enumerateConfigLeavesRecursively(current.shape[key], [...prefix, key]),
|
||||
);
|
||||
}
|
||||
if (current instanceof z.ZodArray) {
|
||||
// Descend into array items without an index: array sections cover the item
|
||||
// fields once (e.g. `folders.type`, not `folders.0.type`).
|
||||
return enumerateConfigLeavesRecursively(current.element, prefix);
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('editor schema completeness', () => {
|
||||
// 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('.');
|
||||
|
||||
it(`${section}: ${key}`, () => {
|
||||
const resolved = resolvePath(fullPath);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Direction 2: every configurable field (outside the excluded subtrees)
|
||||
// appears in the editor. This is the forgotten-feature catcher.
|
||||
describe('every configurable field appears in the editor', () => {
|
||||
for (const path of enumerateConfigLeavesRecursively(
|
||||
advancedCameraCardConfigSchema,
|
||||
)) {
|
||||
it(path, () => {
|
||||
expect(
|
||||
coveredPaths.has(path),
|
||||
`${path} is a configurable field but is not in any editor form ` +
|
||||
`(add it to the editor, or to EDITOR_CUSTOM_WIDGETS / EDITOR_EXCLUDED)`,
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { assert, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getForms } from '../../../../src/components-lib/editor/schema/registry';
|
||||
|
||||
const OPTIONS = { cameras: [], folders: [] };
|
||||
|
||||
// Every section the editor offers, and where its first form edits.
|
||||
const SECTIONS: [string, (string | number)[]][] = [
|
||||
['dimensions', ['dimensions']],
|
||||
['image', ['image']],
|
||||
['live', ['live']],
|
||||
['media_gallery', ['media_gallery', 'controls']],
|
||||
['media_viewer', ['media_viewer']],
|
||||
['menu', ['menu']],
|
||||
['performance', ['performance']],
|
||||
['profiles', []],
|
||||
['remote_control', ['remote_control']],
|
||||
['status_bar', ['status_bar']],
|
||||
['timeline', ['timeline']],
|
||||
['view', ['view']],
|
||||
['view.keyboard_shortcuts', ['view', 'keyboard_shortcuts']],
|
||||
];
|
||||
|
||||
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);
|
||||
|
||||
expect(forms.length).toBeGreaterThan(0);
|
||||
expect(forms[0].basePath).toEqual(basePath);
|
||||
forms.forEach((form) => expect(form.schema.length).toBeGreaterThan(0));
|
||||
});
|
||||
|
||||
it('should build no forms for a section it does not know', () => {
|
||||
expect(getForms({ kind: 'section', name: 'nonexistent' }, OPTIONS)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should offer a camera the other cameras, but not itself', () => {
|
||||
const CAMERAS = [
|
||||
{ value: 'one', label: 'One' },
|
||||
{ value: 'two', label: 'Two' },
|
||||
{ value: 'three', label: 'Three' },
|
||||
];
|
||||
|
||||
const getDependencyOptions = (index: number): unknown => {
|
||||
const [form] = getForms(
|
||||
{ kind: 'camera', index },
|
||||
{ ...OPTIONS, cameras: CAMERAS },
|
||||
);
|
||||
const dependencies = form.schema.find(
|
||||
(field) => 'name' in field && field.name === 'dependencies',
|
||||
);
|
||||
assert(dependencies && 'schema' in dependencies);
|
||||
const cameras = dependencies.schema.find(
|
||||
(field) => 'name' in field && field.name === 'cameras',
|
||||
);
|
||||
assert(cameras && 'selector' in cameras && 'select' in cameras.selector);
|
||||
return cameras.selector.select?.options;
|
||||
};
|
||||
|
||||
it.each([
|
||||
[0, ['two', 'three']],
|
||||
[1, ['one', 'three']],
|
||||
[2, ['one', 'two']],
|
||||
])('should leave camera %i out of its own dependencies', (index, expected) => {
|
||||
expect(getDependencyOptions(index)).toEqual(
|
||||
CAMERAS.filter((camera) => expected.includes(camera.value)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should build the forms of a list item', () => {
|
||||
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: 'camera-event' as const, cameraIndex: 2, eventIndex: 4 },
|
||||
['cameras', 2, 'triggers', 'events', 4],
|
||||
],
|
||||
])('should build the forms for %j', (request, basePath) => {
|
||||
const forms = getForms(request, OPTIONS);
|
||||
|
||||
expect(forms).toHaveLength(1);
|
||||
expect(forms[0].basePath).toEqual(basePath);
|
||||
expect(forms[0].schema.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user