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
+3
View File
@@ -38,6 +38,7 @@ render and can operate in `image` or `folders` views depending on configuration.
| [`cameras_global`](cameras/README.md) | Global defaults that apply to all cameras from the `cameras` section. |
| `card_id` | An optional ID to uniquely identify this card. For use when actions are being sent to card(s) via [URL actions](../usage/url-actions.md). Must exclusively consist of these characters: `[a-zA-Z0-9_]`. |
| [`dimensions`](dimensions.md) | Configures the overall card dimensions. |
| [`editor`](editor.md) | Configures the visual card editor. |
| [`elements`](elements/README.md) | Add custom elements to the card. |
| [`folders`](folders.md) | Configures folders to be used in the card. Optional. If present, the first listed folder is the default. |
| [`image`](image.md) | Configures the `image` view. |
@@ -48,6 +49,8 @@ render and can operate in `image` or `folders` views depending on configuration.
| [`overrides`](overrides.md) | Override card configuration when conditions are met. |
| [`performance`](performance.md) | Configures the card performance. |
| [`profiles`](profiles.md) | Apply pre-configured sets of defaults to ease card configuration. |
| [`remote_control`](remote-control.md) | Configures how a card instance can be remotely controlled. |
| [`status_bar`](status-bar.md) | Configures the card status bar. |
| [`timeline`](timeline.md) | Configures the `timeline` view. |
| [`view`](view.md) | Configures the default view and behavior of the card. |
+1
View File
@@ -5,6 +5,7 @@
- [`cameras`](cameras/README.md)
- [`conditions` / `triggers`](conditions-triggers.md)
- [`dimensions`](dimensions.md)
- [`editor`](editor.md)
- [`elements`](elements/README.md)
- [`folders`](folders.md)
- [`image`](image.md)
+1
View File
@@ -7,6 +7,7 @@
- [`cameras`](../cameras/README.md)
- [`conditions` / `triggers`](../conditions-triggers.md)
- [`dimensions`](../dimensions.md)
- [`editor`](../editor.md)
- [`elements`](../elements/README.md)
- [`folders`](../folders.md)
- [`image`](../image.md)
@@ -7,6 +7,7 @@
- [`cameras`](../../cameras/README.md)
- [`conditions` / `triggers`](../../conditions-triggers.md)
- [`dimensions`](../../dimensions.md)
- [`editor`](../../editor.md)
- [`elements`](../../elements/README.md)
- [`folders`](../../folders.md)
- [`image`](../../image.md)
@@ -7,6 +7,7 @@
- [`cameras`](../../cameras/README.md)
- [`conditions` / `triggers`](../../conditions-triggers.md)
- [`dimensions`](../../dimensions.md)
- [`editor`](../../editor.md)
- [`elements`](../../elements/README.md)
- [`folders`](../../folders.md)
- [`image`](../../image.md)
+1
View File
@@ -7,6 +7,7 @@
- [`engine`](engine.md)
- [`conditions` / `triggers`](../conditions-triggers.md)
- [`dimensions`](../dimensions.md)
- [`editor`](../editor.md)
- [`folders`](../folders.md)
- [`elements`](../elements/README.md)
- [`image`](../image.md)
+21
View File
@@ -0,0 +1,21 @@
# `editor`
These options control the visual card editor.
```yaml
editor:
# [...]
```
| Option | Default | Description |
| ------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | _Automatic_ | Which editor to show. Acceptable values: `simple` for an abbreviated editor that includes bare bones settings or `full` for every option the editor can set. When unset, the `simple` editor is used unless the configuration sets something it does not show, in which case the `full` editor is used. |
## Fully expanded reference
[](common/expanded-warning.md ':include')
```yaml
editor:
mode: simple
```
+1
View File
@@ -5,6 +5,7 @@
- [`cameras`](../cameras/README.md)
- [`conditions` / `triggers`](../conditions-triggers.md)
- [`dimensions`](../dimensions.md)
- [`editor`](../editor.md)
- [`elements`](README.md)
- [Custom Elements](./custom/README.md)
- [Stock Elements](./stock/README.md)
@@ -5,6 +5,7 @@
- [`cameras`](../../cameras/README.md)
- [`conditions` / `triggers`](../../conditions-triggers.md)
- [`dimensions`](../../dimensions.md)
- [`editor`](../../editor.md)
- [`elements`](../../elements/README.md)
- [Custom Actions](README.md)
- [Stock Actions](../stock/README.md)
@@ -5,6 +5,7 @@
- [`cameras`](../../cameras/README.md)
- [`conditions` / `triggers`](../../conditions-triggers.md)
- [`dimensions`](../../dimensions.md)
- [`editor`](../../editor.md)
- [`elements`](../../elements/README.md)
- [Custom Actions](../custom/README.md)
- [Stock Actions](README.md)
+20
View File
@@ -11,6 +11,7 @@ import {
upgradeConfig,
} from '../../config/management';
import { setProfiles } from '../../config/profiles/set-profiles';
import type { EditorMode } from '../../config/schema/editor';
import { profilesSchema, type ProfileType } from '../../config/schema/profiles';
import { configDefaults } from '../../config/schema/types';
import type {
@@ -27,6 +28,7 @@ import { getFolderID } from '../../utils/folder';
import { applyConfigChanges } from './form-data';
import type { FormsInput } from './forms-controller';
import type { EditorIntent } from './intents';
import { deriveEditorMode, getEditorMode } from './mode';
import type { FormRequestOptions } from './schema/registry';
import { getEditorCameraTitle, getEditorFolderTitle } from './titles';
@@ -69,6 +71,9 @@ export class EditorController implements ReactiveController {
// The parsed configuration profiles (empty when unset or invalid).
private _profiles: ProfileType[] = [];
// Which editor the configuration asks for, or suits.
private _editorMode: EditorMode = 'simple';
constructor(host: EditorControllerHost) {
this._host = host;
host.addController(this);
@@ -103,6 +108,7 @@ export class EditorController implements ReactiveController {
private _applyConfig(config: RawAdvancedCameraCardConfig): void {
this._config = config;
this._configUpgradeable = isConfigUpgradeable(config);
this._editorMode = getEditorMode(config);
// The defaults are rebuilt from scratch so that removing (or breaking) a
// profile also removes its adjusted defaults.
@@ -129,6 +135,16 @@ export class EditorController implements ReactiveController {
if (Array.isArray(overrides) && overrides.length) {
notices.push({ type: 'info', message: localize('config.overrides.info') });
}
// The simple editor can be asked for by a configuration that sets more than
// it shows, so tell the user those settings are still in effect.
if (
this._config &&
this._editorMode === 'simple' &&
deriveEditorMode(this._config) === 'full'
) {
notices.push({ type: 'info', message: localize('editor.simple_coverage') });
}
return notices;
}
@@ -136,6 +152,10 @@ export class EditorController implements ReactiveController {
return this._config;
}
public getEditorMode(): EditorMode {
return this._editorMode;
}
public getDefaults(): RawAdvancedCameraCardConfig {
return this._defaults;
}
+11 -11
View File
@@ -1,6 +1,7 @@
import { DOCS_URL } from '../../const';
import type { HAFormSchema } from '../../ha/types';
import { isFormFieldSchema, type HAFormSchema } from '../../ha/types';
import { getFormContainerPath, stripArrayIndices } from './paths';
import type { ConfigPath } from './types';
// Documentation links keyed by configuration path (without array indices).
// Sections and expandable groups with an entry here render a documentation
@@ -51,7 +52,6 @@ const DOC_LINKS: Record<string, string> = {
'media_viewer.display': 'configuration/media-viewer?id=display',
menu: 'configuration/menu',
'menu.buttons': 'configuration/menu?id=buttons',
options: 'configuration/README',
performance: 'configuration/performance',
'performance.features': 'configuration/performance?id=features',
'performance.style': 'configuration/performance?id=style',
@@ -77,7 +77,7 @@ const DOC_LINKS: Record<string, string> = {
* @returns A full documentation URL, or null if the path has no documentation
* link.
*/
export const getDocURL = (path: (string | number)[]): string | null => {
export const getDocURL = (path: ConfigPath): string | null => {
const docPath = DOC_LINKS[stripArrayIndices(path).join('.')];
return docPath ? `${DOCS_URL}/#/${docPath}` : null;
};
@@ -90,22 +90,22 @@ export const getDocURL = (path: (string | number)[]): string | null => {
* @returns The configuration path to link to, or null for no link.
*/
export const getDocLinkPath = (
basePath: (string | number)[],
basePath: ConfigPath,
schema: HAFormSchema,
options?: { path?: string[] },
): (string | number)[] | null => {
): ConfigPath | null => {
// Documentation links attach to containers (sections/groups), never to
// individual fields.
if ('selector' in schema) {
if (isFormFieldSchema(schema)) {
return null;
}
// A nameless group (an editor-only grouping such as "Engines") carries an
// explicit `docPath`; a named group derives its path from the configuration
// path.
return schema.docPath
? schema.docPath.split('.')
: schema.name
? [...basePath, ...getFormContainerPath(options), schema.name]
: null;
const docPath = schema.type === 'expandable' ? schema.docPath : null;
return (
docPath ??
(schema.name ? [...basePath, ...getFormContainerPath(options), schema.name] : null)
);
};
+28 -4
View File
@@ -7,9 +7,14 @@ import {
setConfigValue,
} from '../../config/management';
import type { RawAdvancedCameraCardConfig } from '../../config/types';
import type { HAFormSchema, HAFormSelectorSchema } from '../../ha/types';
import {
isFormFieldSchema,
type HAFormSchema,
type HAFormSelectorSchema,
} from '../../ha/types';
import { isRecord } from '../../utils/basic';
import {
findBinding,
isComputedFieldBinding,
type ConfigChange,
type ConfigPath,
@@ -30,7 +35,7 @@ export const forEachFieldRecursively = (
): void => {
const recurse = (items: readonly HAFormSchema[], prefix: string[]): void => {
for (const item of items) {
if ('selector' in item) {
if (isFormFieldSchema(item)) {
callback([...prefix, item.name], item);
} else {
// A nameless expandable is a visual-only grouping, so it is not added
@@ -42,8 +47,27 @@ export const forEachFieldRecursively = (
recurse(schema, []);
};
const findBinding = (form: EditorForm, formPath: string[]): FieldBinding | undefined =>
form.bindings?.find((binding) => isEqual(binding.formPath, formPath));
/**
* Get the configuration paths a form's fields address: those a binding names,
* or, for an unbound field, its position within the form beneath the form's
* base path.
* @param form The form.
* @returns The absolute configuration paths, in field order.
*/
export const getFormConfigPaths = (form: EditorForm): ConfigPath[] => {
const paths: ConfigPath[] = [];
forEachFieldRecursively(form.schema, (path) => {
const binding = findBinding(form, path);
if (!binding) {
paths.push([...form.basePath, ...path]);
} else if (isComputedFieldBinding(binding)) {
paths.push(...binding.configPaths);
} else {
paths.push(binding.configPath);
}
});
return paths;
};
// The value to show for a bound field. A computed binding works its own value
// out; otherwise the configured value is shown, and only a missing value falls
+28 -13
View File
@@ -1,42 +1,57 @@
import type { HAFormSchema } from '../../ha/types';
import { isFormFieldSchema, type HAFormSchema } from '../../ha/types';
import { localize } from '../../localize/localize';
import { getFormContainerPath, stripArrayIndices } from './paths';
import {
findBinding,
isComputedFieldBinding,
type ConfigPath,
type EditorForm,
} from './types';
/**
* Get the localization key for a configuration path.
* @param path The configuration path segments.
* @returns The localization key.
*/
export const getLocalizationKeyForPath = (path: (string | number)[]): string =>
export const getLocalizationKeyForPath = (path: ConfigPath): string =>
['config', ...stripArrayIndices(path)].join('.');
/**
* Compute the label for a form field. Intended for use as an `ha-form`
* `computeLabel` callback, bound to the path of the form's data within the
* configuration. Container nodes are labelled by their explicit title, never
* `computeLabel` callback. A field is named for the setting it edits, which for
* a bound field is wherever that setting is stored rather than where the field
* sits in the form. Container nodes are labelled by their explicit title, never
* by configuration path (their headers already render the title).
* @param basePath The path of the form's data within the configuration.
* @param form The form the field belongs to.
* @param schema The field's schema.
* @param options Path context provided by `ha-form` for fields nested inside
* containers.
* @returns A localized label.
*/
export const computeFormLabel = (
basePath: (string | number)[],
form: EditorForm,
schema: HAFormSchema,
options?: { path?: string[] },
): string => {
if (schema.label !== undefined) {
return schema.label;
}
if (!('selector' in schema)) {
return schema.title ?? schema.name ?? '';
if (!isFormFieldSchema(schema)) {
// Only an expandable has a title: a grid lays out fields that are labelled
// individually, and shows nothing of its own.
return (schema.type === 'expandable' ? schema.title : null) ?? schema.name ?? '';
}
const formPath = [...getFormContainerPath(options), schema.name];
const binding = findBinding(form, formPath);
// A field standing for more than one setting has no single name to take, and
// so carries its own label.
return localize(
getLocalizationKeyForPath([
...basePath,
...getFormContainerPath(options),
schema.name,
]),
getLocalizationKeyForPath(
binding && !isComputedFieldBinding(binding)
? binding.configPath
: [...form.basePath, ...formPath],
),
);
};
@@ -173,7 +173,7 @@ export class FormsController {
schema: HAFormSchema,
_data?: unknown,
options?: { path?: string[] },
) => computeFormLabel(form.basePath, schema, options),
) => computeFormLabel(form, schema, options),
// Caution: `ha-form` types the helper as a string, but interpolates it
// into a Lit template, so a template renders as-is; should a future Home
@@ -10,7 +10,10 @@ import type { ConfigChange, ConfigPath } from './types';
type KeyboardShortcutsControllerHost = ReactiveControllerHost & EventTarget;
const FORM_REQUEST: FormRequest = { kind: 'section', name: 'view.keyboard_shortcuts' };
const FORM_REQUEST: FormRequest = {
kind: 'full-section',
name: 'view.keyboard_shortcuts',
};
/**
* The keyboard shortcuts panel: the settings that apply to all the shortcuts,
+74
View File
@@ -0,0 +1,74 @@
import { editorConfigSchema, type EditorMode } from '../../config/schema/editor';
import type { RawAdvancedCameraCardConfig } from '../../config/types';
import { isRecord } from '../../utils/basic';
import { getFormConfigPaths } from './form-data';
import { stripArrayIndices } from './paths';
import { getFullEditorForms } from './schema/full';
import {
getSimpleCameraForms,
getSimpleMenuForms,
getSimpleTopLevelForms,
} from './schema/simple';
import type { EditorForm } from './types';
// The configuration paths a set of forms shows, without array indices so that
// the third camera's title is the camera title field.
const getShownPaths = (forms: EditorForm[]): Set<string> =>
new Set(
forms.flatMap(getFormConfigPaths).map((path) => stripArrayIndices(path).join('.')),
);
// What the full editor shows and the simple editor does not. Configuration
// outside this is either something the simple editor shows too, or something
// neither shows (`elements`, `debug`, anything the card does not recognise at
// all), which the full editor would be no better at showing.
const getFullOnlyPaths = (): Set<string> => {
const simplePaths = getShownPaths([
...getSimpleCameraForms(0),
...getSimpleMenuForms(),
...getSimpleTopLevelForms(),
]);
return new Set(
[...getShownPaths(getFullEditorForms())].filter((path) => !simplePaths.has(path)),
);
};
// The paths of every value the configuration sets. Lists contribute the paths
// of their items' contents, without the position of the item.
const getSetPaths = (value: unknown, prefix: string[] = []): string[] => {
if (Array.isArray(value)) {
return value.flatMap((item) => getSetPaths(item, prefix));
}
if (isRecord(value)) {
return Object.entries(value).flatMap(([key, item]) =>
getSetPaths(item, [...prefix, key]),
);
}
return [prefix.join('.')];
};
/**
* Work out which editor to show for a configuration that does not say. The full
* editor is shown when the configuration sets something it shows and the simple
* editor does not, so that opening the simple editor never hides a setting the
* user could otherwise have seen.
* @param config The raw (possibly invalid) configuration.
* @returns The editor mode.
*/
export const deriveEditorMode = (config: RawAdvancedCameraCardConfig): EditorMode => {
const fullOnlyPaths = getFullOnlyPaths();
return getSetPaths(config).some((path) => fullOnlyPaths.has(path)) ? 'full' : 'simple';
};
/**
* Get which editor to show for a configuration: what it asks for, or, if it
* does not ask (or asks for something that is not an editor), what suits it.
* @param config The raw (possibly invalid) configuration.
* @returns The editor mode.
*/
export const getEditorMode = (config: RawAdvancedCameraCardConfig): EditorMode => {
const editor = editorConfigSchema.safeParse(config['editor']);
return editor.success && editor.data.mode
? editor.data.mode
: deriveEditorMode(config);
};
+3 -1
View File
@@ -1,3 +1,5 @@
import type { ConfigPath } from './types';
/**
* Convert the container path `ha-form` passes to `computeLabel`/`computeHelper`
* into configuration order. `ha-form` builds the path by appending each
@@ -16,7 +18,7 @@ export const getFormContainerPath = (options?: { path?: string[] }): string[] =>
* @param path The configuration path segments.
* @returns The path segments without array indices.
*/
export const stripArrayIndices = (path: (string | number)[]): string[] =>
export const stripArrayIndices = (path: ConfigPath): string[] =>
path.filter(
(segment): segment is string =>
typeof segment === 'string' && !/^\d+$/.test(segment),
+32 -23
View File
@@ -70,6 +70,35 @@ const getMediaLayoutSchema = (): HAFormExpandableSchema => ({
],
});
/**
* Get the camera fields the simple editor shows: the entity, how it is
* streamed, and how it is presented.
* @returns The camera fields.
*/
export const getCameraSimpleFields = (): HAFormSchema[] => [
{ name: 'camera_entity', selector: { entity: { domain: 'camera' } } },
{
name: 'live_provider',
selector: createSelectSelector([
{ value: 'auto', label: localize('config.cameras.live_providers.auto') },
{ value: 'ha', label: localize('config.cameras.live_providers.ha') },
{ value: 'image', label: localize('config.cameras.live_providers.image') },
{ value: 'jsmpeg', label: localize('config.cameras.live_providers.jsmpeg') },
{ value: 'go2rtc', label: localize('config.cameras.live_providers.go2rtc') },
{
value: 'go2rtc-experimental',
label: localize('config.cameras.live_providers.go2rtc-experimental'),
},
{
value: 'webrtc-card',
label: localize('config.cameras.live_providers.webrtc-card'),
},
]),
},
{ name: 'title', selector: { text: {} } },
{ name: 'icon', selector: { icon: {} } },
];
interface CameraSchemaOptions {
otherCameras: HASelectSelectorOption[];
folders: HASelectSelectorOption[];
@@ -89,27 +118,7 @@ export const getCameraSchema = (options: CameraSchemaOptions): HAFormSchema[] =>
}));
return [
{ name: 'camera_entity', selector: { entity: { domain: 'camera' } } },
{
name: 'live_provider',
selector: createSelectSelector([
{ value: 'auto', label: localize('config.cameras.live_providers.auto') },
{ value: 'ha', label: localize('config.cameras.live_providers.ha') },
{ value: 'image', label: localize('config.cameras.live_providers.image') },
{ value: 'jsmpeg', label: localize('config.cameras.live_providers.jsmpeg') },
{ value: 'go2rtc', label: localize('config.cameras.live_providers.go2rtc') },
{
value: 'go2rtc-experimental',
label: localize('config.cameras.live_providers.go2rtc-experimental'),
},
{
value: 'webrtc-card',
label: localize('config.cameras.live_providers.webrtc-card'),
},
]),
},
{ name: 'title', selector: { text: {} } },
{ name: 'icon', selector: { icon: {} } },
...getCameraSimpleFields(),
{ name: 'id', selector: { text: {} } },
{ name: 'always_error_if_entity_unavailable', selector: { boolean: {} } },
@@ -210,7 +219,7 @@ export const getCameraSchema = (options: CameraSchemaOptions): HAFormSchema[] =>
type: 'expandable',
title: localize('config.cameras.engines.editor_label'),
icon: 'mdi:engine',
docPath: 'cameras.engine',
docPath: ['cameras', 'engine'],
schema: [
{
name: 'engine',
@@ -295,7 +304,7 @@ export const getCameraSchema = (options: CameraSchemaOptions): HAFormSchema[] =>
type: 'expandable',
title: localize('config.cameras.live_provider_options.editor_label'),
icon: 'mdi:cctv',
docPath: 'cameras.live_provider',
docPath: ['cameras', 'live_provider'],
schema: [
{
name: 'go2rtc',
+21 -14
View File
@@ -1,7 +1,27 @@
import type { HASelectSelectorOption } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { createSelectSelector } from './common/selectors';
/**
* Get the options for how the card's aspect ratio is decided.
* @returns The aspect ratio mode options.
*/
export const getAspectRatioModeOptions = (): HASelectSelectorOption[] => [
{
value: 'dynamic',
label: localize('config.dimensions.aspect_ratio_modes.dynamic'),
},
{
value: 'static',
label: localize('config.dimensions.aspect_ratio_modes.static'),
},
{
value: 'unconstrained',
label: localize('config.dimensions.aspect_ratio_modes.unconstrained'),
},
];
/**
* Get the form for the dimensions section.
* @returns The section forms.
@@ -12,20 +32,7 @@ export const getDimensionsSectionForms = (): EditorForm[] => [
schema: [
{
name: 'aspect_ratio_mode',
selector: createSelectSelector([
{
value: 'dynamic',
label: localize('config.dimensions.aspect_ratio_modes.dynamic'),
},
{
value: 'static',
label: localize('config.dimensions.aspect_ratio_modes.static'),
},
{
value: 'unconstrained',
label: localize('config.dimensions.aspect_ratio_modes.unconstrained'),
},
]),
selector: createSelectSelector(getAspectRatioModeOptions()),
},
{
name: 'aspect_ratio',
@@ -0,0 +1,30 @@
import { getEditorMode } from '../mode';
import type { ConfigPath, EditorForm, FieldBinding } from '../types';
const EDITOR_MODE_PATH: ConfigPath = ['editor', 'mode'];
// The switch between the two editors. It shows whether the full editor is in
// use, while the configuration names the editor itself, so the two
// representations are converted here. The mode is always written out, including
// when it matches what would have been chosen anyway, so that the choice sticks.
const getEditorModeBinding = (): FieldBinding => ({
formPath: ['mode'],
configPaths: [EDITOR_MODE_PATH],
read: (config) => getEditorMode(config) === 'full',
write: (value) => [
{ path: EDITOR_MODE_PATH, type: 'set', value: value === true ? 'full' : 'simple' },
],
});
/**
* Get the form choosing which editor to show. It is shown above both editors,
* so that the way back is in the same place whichever is in use.
* @returns The editor mode forms.
*/
export const getEditorModeForms = (): EditorForm[] => [
{
basePath: ['editor'],
schema: [{ name: 'mode', selector: { boolean: {} } }],
bindings: [getEditorModeBinding()],
},
];
+114
View File
@@ -0,0 +1,114 @@
import { CONF_CAMERAS, CONF_FOLDERS } from '../../../config/const';
import type { EditorForm } from '../types';
import {
getCameraSchema,
getCameraTriggersSchema,
getTriggerEventSchema,
} from './cameras';
import { getDimensionsSectionForms } from './dimensions';
import { getFolderSchema } from './folders';
import { getImageSectionForms } from './image';
import { getLiveSectionForms } from './live';
import { getMediaGallerySectionForms } from './media-gallery';
import { getMediaViewerSectionForms } from './media-viewer';
import { getMenuSectionForms } from './menu';
import { getPerformanceSectionForms } from './performance';
import { getProfilesSectionForms } from './profiles';
import { getRemoteControlSectionForms } from './remote-control';
import { getStatusBarSectionForms } from './status-bar';
import { getTimelineSectionForms } from './timeline';
import { getViewKeyboardShortcutsSectionForms, getViewSectionForms } from './view';
// The forms of each section of the full editor, by section name. Kept as a
// lookup rather than a switch so that the full editor's fields can also be
// asked for as a whole, without a second list of the sections to keep in step.
const SECTION_FORM_BUILDERS: Record<string, () => EditorForm[]> = {
dimensions: getDimensionsSectionForms,
image: getImageSectionForms,
live: getLiveSectionForms,
media_gallery: getMediaGallerySectionForms,
media_viewer: getMediaViewerSectionForms,
menu: getMenuSectionForms,
performance: getPerformanceSectionForms,
profiles: getProfilesSectionForms,
remote_control: getRemoteControlSectionForms,
status_bar: getStatusBarSectionForms,
timeline: getTimelineSectionForms,
view: getViewSectionForms,
'view.keyboard_shortcuts': getViewKeyboardShortcutsSectionForms,
};
/**
* Get the forms of one section of the full editor.
* @param name The section name.
* @returns The section forms, or none for a section that has no forms of its
* own.
*/
export const getFullSectionForms = (name: string): EditorForm[] =>
SECTION_FORM_BUILDERS[name]?.() ?? [];
/**
* Get the form for one camera in the full editor, without its triggers, which
* are rendered by a hand-built panel so it can host the events list.
* @param index The camera's position in the configuration.
* @param options The camera and folder lists the dropdowns choose from.
* @returns The camera's forms.
*/
export const getFullCameraForms = (
index: number,
options: Parameters<typeof getCameraSchema>[0],
): EditorForm[] => [
{ basePath: [CONF_CAMERAS, index], schema: getCameraSchema(options) },
];
/**
* Get the form for a camera's triggers.
* @param index The camera's position in the configuration.
* @returns The triggers forms.
*/
export const getFullCameraTriggersForms = (index: number): EditorForm[] => [
{
basePath: [CONF_CAMERAS, index, 'triggers'],
schema: getCameraTriggersSchema(),
},
];
/**
* Get the form for one of a camera's trigger events.
* @param cameraIndex The camera's position in the configuration.
* @param eventIndex The event's position within the camera's events.
* @returns The event's forms.
*/
export const getFullCameraEventForms = (
cameraIndex: number,
eventIndex: number,
): EditorForm[] => [
{
basePath: [CONF_CAMERAS, cameraIndex, 'triggers', 'events', eventIndex],
schema: getTriggerEventSchema(),
},
];
/**
* Get the form for one folder.
* @param index The folder's position in the configuration.
* @returns The folder's forms.
*/
export const getFullFolderForms = (index: number): EditorForm[] => [
{ basePath: [CONF_FOLDERS, index], schema: getFolderSchema() },
];
/**
* Get every form the full editor shows, wherever it shows it: its sections, and
* the pages it opens for an item of a list. The items are those at index 0,
* since every item of a list has the same fields, and the dropdowns are given
* nothing to choose from since nothing is being chosen.
* @returns The full editor's forms.
*/
export const getFullEditorForms = (): EditorForm[] => [
...Object.values(SECTION_FORM_BUILDERS).flatMap((build) => build()),
...getFullCameraForms(0, { otherCameras: [], folders: [] }),
...getFullCameraTriggersForms(0),
...getFullCameraEventForms(0, 0),
...getFullFolderForms(0),
];
+42 -16
View File
@@ -1,11 +1,15 @@
import { BUTTON_SIZE_MIN, MENU_PRIORITY_MAX } from '../../../config/schema/common/const';
import type { HAFormExpandableSchema, HAFormSchema } from '../../../ha/types';
import type {
HAFormExpandableSchema,
HAFormSchema,
HASelectSelectorOption,
} from '../../../ha/types';
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { createNumberSelector, createSelectSelector } from './common/selectors';
// The menu buttons in their display order.
const MENU_BUTTONS = [
export const MENU_BUTTONS = [
// Iris menu button is first.
'iris',
@@ -39,6 +43,40 @@ const MENU_BUTTONS = [
'timeline',
];
/**
* Get the options for choosing a menu button.
* @returns The menu buttons, in display order.
*/
export const getMenuButtonOptions = (): HASelectSelectorOption[] =>
MENU_BUTTONS.map((button) => ({
value: button,
label: localize(`config.menu.buttons.${button}`),
}));
/**
* Get the options for the menu style.
* @returns The menu style options.
*/
export const getMenuStyleOptions = (): HASelectSelectorOption[] => [
{ value: 'none', label: localize('config.menu.styles.none') },
{ value: 'hidden', label: localize('config.menu.styles.hidden') },
{ value: 'overlay', label: localize('config.menu.styles.overlay') },
{ value: 'hover', label: localize('config.menu.styles.hover') },
{ value: 'hover-card', label: localize('config.menu.styles.hover-card') },
{ value: 'outside', label: localize('config.menu.styles.outside') },
];
/**
* Get the options for the menu position.
* @returns The menu position options.
*/
export const getMenuPositionOptions = (): HASelectSelectorOption[] => [
{ value: 'left', label: localize('config.menu.positions.left') },
{ value: 'right', label: localize('config.menu.positions.right') },
{ value: 'top', label: localize('config.menu.positions.top') },
{ value: 'bottom', label: localize('config.menu.positions.bottom') },
];
// The microphone button alone carries a `type` (momentary vs toggle).
const getMicrophoneTypeSchema = (): HAFormSchema => ({
name: 'type',
@@ -116,23 +154,11 @@ export const getMenuSectionForms = (): EditorForm[] => [
schema: [
{
name: 'style',
selector: createSelectSelector([
{ value: 'none', label: localize('config.menu.styles.none') },
{ value: 'hidden', label: localize('config.menu.styles.hidden') },
{ value: 'overlay', label: localize('config.menu.styles.overlay') },
{ value: 'hover', label: localize('config.menu.styles.hover') },
{ value: 'hover-card', label: localize('config.menu.styles.hover-card') },
{ value: 'outside', label: localize('config.menu.styles.outside') },
]),
selector: createSelectSelector(getMenuStyleOptions()),
},
{
name: 'position',
selector: createSelectSelector([
{ value: 'left', label: localize('config.menu.positions.left') },
{ value: 'right', label: localize('config.menu.positions.right') },
{ value: 'top', label: localize('config.menu.positions.top') },
{ value: 'bottom', label: localize('config.menu.positions.bottom') },
]),
selector: createSelectSelector(getMenuPositionOptions()),
},
{
name: 'alignment',
+23 -18
View File
@@ -1,7 +1,29 @@
import type { HAFormSchema } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { createSelectSelector } from './common/selectors';
/**
* Get the field choosing which pre-configured sets of defaults apply.
* @returns The profiles field.
*/
export const getProfilesField = (): HAFormSchema => ({
name: 'profiles',
label: localize('config.profiles.editor_label'),
selector: createSelectSelector(
[
{ value: 'casting', label: localize('config.profiles.casting') },
{ value: 'doorbell', label: localize('config.profiles.doorbell') },
{
value: 'low-performance',
label: localize('config.profiles.low-performance'),
},
{ value: 'scrubbing', label: localize('config.profiles.scrubbing') },
],
{ multiple: true },
),
});
/**
* Get the form for the profiles section.
* @returns The section forms.
@@ -9,23 +31,6 @@ import { createSelectSelector } from './common/selectors';
export const getProfilesSectionForms = (): EditorForm[] => [
{
basePath: [],
schema: [
{
name: 'profiles',
label: localize('config.profiles.editor_label'),
selector: createSelectSelector(
[
{ value: 'casting', label: localize('config.profiles.casting') },
{ value: 'doorbell', label: localize('config.profiles.doorbell') },
{
value: 'low-performance',
label: localize('config.profiles.low-performance'),
},
{ value: 'scrubbing', label: localize('config.profiles.scrubbing') },
],
{ multiple: true },
),
},
],
schema: [getProfilesField()],
},
];
+50 -92
View File
@@ -1,34 +1,37 @@
import { CONF_CAMERAS, CONF_FOLDERS } from '../../../config/const';
import type { HASelectSelectorOption } from '../../../ha/types';
import type { EditorForm } from '../types';
import { getEditorModeForms } from './editor-mode';
import {
getCameraSchema,
getCameraTriggersSchema,
getTriggerEventSchema,
} from './cameras';
import { getDimensionsSectionForms } from './dimensions';
import { getFolderSchema } from './folders';
import { getImageSectionForms } from './image';
import { getLiveSectionForms } from './live';
import { getMediaGallerySectionForms } from './media-gallery';
import { getMediaViewerSectionForms } from './media-viewer';
import { getMenuSectionForms } from './menu';
import { getPerformanceSectionForms } from './performance';
import { getProfilesSectionForms } from './profiles';
import { getRemoteControlSectionForms } from './remote-control';
import { getStatusBarSectionForms } from './status-bar';
import { getTimelineSectionForms } from './timeline';
import { getViewKeyboardShortcutsSectionForms, getViewSectionForms } from './view';
getFullCameraEventForms,
getFullCameraForms,
getFullCameraTriggersForms,
getFullFolderForms,
getFullSectionForms,
} from './full';
import {
getSimpleCameraForms,
getSimpleMenuForms,
getSimpleTopLevelForms,
} from './simple';
// The forms an editor component asks for. A request carries its indices as
// numbers rather than being named by a path string, so no caller has to build
// a string key and no builder has to pull one apart again.
export type FormRequest =
| { kind: 'section'; name: string }
| { kind: 'folder'; index: number }
| { kind: 'camera'; index: number }
| { kind: 'camera-triggers'; cameraIndex: number }
| { kind: 'camera-event'; cameraIndex: number; eventIndex: number };
// The editor mode (simple or full)
| { kind: 'editor-mode' }
// Simple mode forms.
| { kind: 'simple-camera'; index: number }
| { kind: 'simple-menu' }
| { kind: 'simple-top-level' }
// Full mode forms.
| { kind: 'full-section'; name: string }
| { kind: 'full-folder'; index: number }
| { kind: 'full-camera'; index: number }
| { kind: 'full-camera-triggers'; cameraIndex: number }
| { kind: 'full-camera-event'; cameraIndex: number; eventIndex: number };
// The lists a form's dropdowns choose from, gathered from the rest of the
// configuration: the cameras and the folders. Entity fields are not among them:
@@ -40,38 +43,6 @@ export interface FormRequestOptions {
folders: HASelectSelectorOption[];
}
const getSectionFormsByName = (name: string): EditorForm[] => {
switch (name) {
case 'dimensions':
return getDimensionsSectionForms();
case 'image':
return getImageSectionForms();
case 'live':
return getLiveSectionForms();
case 'media_gallery':
return getMediaGallerySectionForms();
case 'media_viewer':
return getMediaViewerSectionForms();
case 'menu':
return getMenuSectionForms();
case 'performance':
return getPerformanceSectionForms();
case 'profiles':
return getProfilesSectionForms();
case 'remote_control':
return getRemoteControlSectionForms();
case 'status_bar':
return getStatusBarSectionForms();
case 'timeline':
return getTimelineSectionForms();
case 'view':
return getViewSectionForms();
case 'view.keyboard_shortcuts':
return getViewKeyboardShortcutsSectionForms();
}
return [];
};
/**
* Get the forms for a request.
* @param request What forms are wanted.
@@ -83,42 +54,29 @@ export const getForms = (
options: FormRequestOptions,
): EditorForm[] => {
switch (request.kind) {
case 'section':
return getSectionFormsByName(request.name);
case 'folder':
return [{ basePath: [CONF_FOLDERS, request.index], schema: getFolderSchema() }];
case 'camera':
return [
{
basePath: [CONF_CAMERAS, request.index],
schema: getCameraSchema({
// A camera cannot depend on itself.
otherCameras: options.cameras.filter(
(_camera, index) => index !== request.index,
),
folders: options.folders,
}),
},
];
case 'camera-triggers':
return [
{
basePath: [CONF_CAMERAS, request.cameraIndex, 'triggers'],
schema: getCameraTriggersSchema(),
},
];
case 'camera-event':
return [
{
basePath: [
CONF_CAMERAS,
request.cameraIndex,
'triggers',
'events',
request.eventIndex,
],
schema: getTriggerEventSchema(),
},
];
case 'editor-mode':
return getEditorModeForms();
case 'full-section':
return getFullSectionForms(request.name);
case 'simple-camera':
return getSimpleCameraForms(request.index);
case 'simple-menu':
return getSimpleMenuForms();
case 'simple-top-level':
return getSimpleTopLevelForms();
case 'full-folder':
return getFullFolderForms(request.index);
case 'full-camera':
return getFullCameraForms(request.index, {
// A camera cannot depend on itself.
otherCameras: options.cameras.filter(
(_camera, index) => index !== request.index,
),
folders: options.folders,
});
case 'full-camera-triggers':
return getFullCameraTriggersForms(request.cameraIndex);
case 'full-camera-event':
return getFullCameraEventForms(request.cameraIndex, request.eventIndex);
}
};
+153
View File
@@ -0,0 +1,153 @@
import { CONF_CAMERAS } from '../../../config/const';
import { getConfigValue } from '../../../config/management';
import type { RawAdvancedCameraCardConfig } from '../../../config/types';
import { localize } from '../../../localize/localize';
import type { ConfigChange, ConfigPath, EditorForm, FieldBinding } from '../types';
import { getCameraSimpleFields } from './cameras';
import { createSelectSelector } from './common/selectors';
import { getAspectRatioModeOptions } from './dimensions';
import {
getMenuButtonOptions,
getMenuPositionOptions,
getMenuStyleOptions,
MENU_BUTTONS,
} from './menu';
import { getProfilesField } from './profiles';
import { getViewModeOptions } from './view';
const getMenuButtonEnabledPath = (button: string): ConfigPath => [
'menu',
'buttons',
button,
'enabled',
];
// Whether a button is shown with the configuration as it stands: what the
// configuration says, or what the (profile-adjusted) defaults say for a button
// the configuration does not mention.
const isMenuButtonEnabled = (
config: RawAdvancedCameraCardConfig,
defaults: RawAdvancedCameraCardConfig,
button: string,
): boolean => {
const path = getMenuButtonEnabledPath(button);
const configured = getConfigValue(config, path);
return typeof configured === 'boolean'
? configured
: getConfigValue(defaults, path) === true;
};
// The single control for which buttons the menu shows, standing for one
// `enabled` key per button. Roughly half the buttons are shown by default, so a
// button the user turns off has to be written out as `false` rather than having
// its key removed.
const getMenuButtonsBinding = (): FieldBinding => ({
formPath: ['menu_buttons'],
configPaths: MENU_BUTTONS.map(getMenuButtonEnabledPath),
read: (config, defaults) =>
MENU_BUTTONS.filter((button) => isMenuButtonEnabled(config, defaults, button)),
write: (value, config, defaults) => {
const selected = Array.isArray(value) ? value : [];
const changes: ConfigChange[] = [];
for (const button of MENU_BUTTONS) {
const wanted = selected.includes(button);
if (wanted === isMenuButtonEnabled(config, defaults, button)) {
continue;
}
const path = getMenuButtonEnabledPath(button);
changes.push(
wanted === (getConfigValue(defaults, path) === true)
? { path, type: 'delete' }
: { path, type: 'set', value: wanted },
);
}
return changes;
},
});
// The simple editor's fields are gathered from across the configuration, so
// each is named for what it means where it is shown and bound to where the
// setting actually lives.
export const getSimpleMenuForms = (): EditorForm[] => [
{
basePath: [],
schema: [
{
name: 'menu_style',
selector: createSelectSelector(getMenuStyleOptions()),
},
{
name: 'menu_position',
selector: createSelectSelector(getMenuPositionOptions()),
},
{
name: 'menu_buttons',
label: localize('config.menu.buttons.editor_label'),
selector: createSelectSelector(getMenuButtonOptions(), { multiple: true }),
},
],
bindings: [
{ formPath: ['menu_style'], configPath: ['menu', 'style'] },
{ formPath: ['menu_position'], configPath: ['menu', 'position'] },
getMenuButtonsBinding(),
],
},
];
/**
* Get the settings the simple editor shows above everything else. They are
* single controls with nothing behind them, so they are shown as they are
* rather than in a section that has to be opened.
* @returns The top level forms.
*/
export const getSimpleTopLevelForms = (): EditorForm[] => [
{
basePath: [],
schema: [
{
name: 'default_view',
selector: createSelectSelector(getViewModeOptions()),
},
// The ratio only means anything alongside the mode that uses it, so the
// two are shown together.
{
type: 'grid',
schema: [
{
name: 'aspect_ratio_mode',
selector: createSelectSelector(getAspectRatioModeOptions()),
},
{
name: 'aspect_ratio',
selector: { text: {} },
},
],
},
getProfilesField(),
],
bindings: [
{ formPath: ['default_view'], configPath: ['view', 'default'] },
{
formPath: ['aspect_ratio_mode'],
configPath: ['dimensions', 'aspect_ratio_mode'],
},
{ formPath: ['aspect_ratio'], configPath: ['dimensions', 'aspect_ratio'] },
],
},
];
/**
* Get the form for one camera in the simple editor: what the camera is and what
* it is called, without the settings a working camera does not need.
* @param index The camera's position in the configuration.
* @returns The camera's forms.
*/
export const getSimpleCameraForms = (index: number): EditorForm[] => [
{
basePath: [CONF_CAMERAS, index],
schema: getCameraSimpleFields(),
},
];
+5 -1
View File
@@ -4,7 +4,11 @@ import type { EditorForm } from '../types';
import { getInteractionModeOptions } from './common/interaction-mode';
import { createNumberSelector, createSelectSelector } from './common/selectors';
const getViewModeOptions = (): HASelectSelectorOption[] => [
/**
* Get the options for choosing a view.
* @returns The view options.
*/
export const getViewModeOptions = (): HASelectSelectorOption[] => [
{ value: 'auto', label: localize('config.view.views.auto') },
{ value: 'clip', label: localize('config.view.views.clip') },
{ value: 'clips', label: localize('config.view.views.clips') },
+19
View File
@@ -1,3 +1,5 @@
import { isEqual } from 'lodash-es';
import type { RawAdvancedCameraCardConfig } from '../../config/types';
import type { HAFormSchema } from '../../ha/types';
@@ -38,6 +40,11 @@ interface PathFieldBinding {
interface ComputedFieldBinding {
formPath: string[];
// Every configuration path the field stands for. Stated rather than worked
// out from the functions below, which cannot be inspected, so that what the
// form as a whole addresses is still answerable.
configPaths: ConfigPath[];
// The value to show for the field, given the whole configuration and the
// configuration defaults.
read: (
@@ -61,6 +68,18 @@ export const isComputedFieldBinding = (
binding: FieldBinding,
): binding is ComputedFieldBinding => 'read' in binding;
/**
* Get the binding for a field of a form, if it has one.
* @param form The form.
* @param formPath The field's path within the form's own data.
* @returns The binding, or undefined for a field stored where it sits.
*/
export const findBinding = (
form: EditorForm,
formPath: string[],
): FieldBinding | undefined =>
form.bindings?.find((binding) => isEqual(binding.formPath, formPath));
// One `ha-form` for part of a section. A section splits into more than one of
// these when its fields live at different configuration paths, since each form
// binds to a single base path. For example, the timeline section uses one form
+40 -24
View File
@@ -17,6 +17,7 @@ import {
} from '../../components-lib/editor/titles';
import type { ConfigPath } from '../../components-lib/editor/types';
import { CONF_CAMERAS } from '../../config/const';
import type { EditorMode } from '../../config/schema/editor';
import type { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import editorExpanderBodyStyle from '../../scss/editor-expander-body.scss';
@@ -45,6 +46,11 @@ export class AdvancedCameraCardEditorCameras extends LitElement {
@property({ attribute: false })
public input?: FormsInput;
// Which editor this list is part of. The simple editor shows fewer of a
// camera's settings, and none of its triggers.
@property()
public mode?: EditorMode;
private _pagesController = new ListPagesController(this);
private _formsController = new ListFormsController(this, (path) =>
renderDocumentation(path),
@@ -103,8 +109,7 @@ export class AdvancedCameraCardEditorCameras extends LitElement {
private _renderCamera(index: number, hidden: boolean): TemplateResult {
const camera = this._formsController.getList(CAMERAS_PATH)[index];
const eventsPath: ConfigPath = [CONF_CAMERAS, index, 'triggers', 'events'];
const events = this._formsController.getList(eventsPath);
const fullMode = this.mode !== 'simple';
return html`
<advanced-camera-card-editor-page
@@ -114,32 +119,43 @@ export class AdvancedCameraCardEditorCameras extends LitElement {
>
${renderForms(
this.hass,
this._formsController.getFormContexts({ kind: 'camera', index }),
this._formsController.getFormContexts(
fullMode ? { kind: 'full-camera', index } : { kind: 'simple-camera', index },
),
)}
<ha-expansion-panel
outlined
.header=${localize('config.cameras.triggers.editor_label')}
>
<advanced-camera-card-icon
slot="leading-icon"
.icon=${{ icon: 'mdi:magnify-scan' }}
></advanced-camera-card-icon>
${this._renderContained(html`
${renderDocumentation([CONF_CAMERAS, 'triggers'])}
${renderForms(
this.hass,
this._formsController.getFormContexts({
kind: 'camera-triggers',
cameraIndex: index,
}),
)}
${this._renderEvents(eventsPath, events)}
`)}
</ha-expansion-panel>
${fullMode ? this._renderTriggers(index) : nothing}
</advanced-camera-card-editor-page>
`;
}
private _renderTriggers(index: number): TemplateResult {
const eventsPath: ConfigPath = [CONF_CAMERAS, index, 'triggers', 'events'];
const events = this._formsController.getList(eventsPath);
return html`
<ha-expansion-panel
outlined
.header=${localize('config.cameras.triggers.editor_label')}
>
<advanced-camera-card-icon
slot="leading-icon"
.icon=${{ icon: 'mdi:magnify-scan' }}
></advanced-camera-card-icon>
${this._renderContained(html`
${renderDocumentation([CONF_CAMERAS, 'triggers'])}
${renderForms(
this.hass,
this._formsController.getFormContexts({
kind: 'full-camera-triggers',
cameraIndex: index,
}),
)}
${this._renderEvents(eventsPath, events)}
`)}
</ha-expansion-panel>
`;
}
// The Home Assistant events the triggers watch for, a group of their own
// within the triggers panel: they are one kind of trigger among the others,
// not a sibling of the whole trigger set.
@@ -216,7 +232,7 @@ export class AdvancedCameraCardEditorCameras extends LitElement {
${renderForms(
this.hass,
this._formsController.getFormContexts({
kind: 'camera-event',
kind: 'full-camera-event',
cameraIndex,
eventIndex,
}),
+2 -3
View File
@@ -8,6 +8,7 @@ import {
import { customElement, property } from 'lit/decorators.js';
import { getDocURL } from '../../components-lib/editor/doc-links';
import type { ConfigPath } from '../../components-lib/editor/types';
import { localize } from '../../localize/localize';
import editorDocLinkStyle from '../../scss/editor-doc-link.scss';
@@ -48,9 +49,7 @@ export class AdvancedCameraCardEditorDocLink extends LitElement {
* @param path The configuration path.
* @returns A rendered template, or null when the path has no documentation.
*/
export const renderDocumentation = (
path: (string | number)[],
): TemplateResult | null => {
export const renderDocumentation = (path: ConfigPath): TemplateResult | null => {
const url = getDocURL(path);
return url
? html`<advanced-camera-card-editor-doc-link
+1 -1
View File
@@ -64,7 +64,7 @@ export class AdvancedCameraCardEditorFolders extends LitElement {
>
${renderForms(
this.hass,
this._controller.getFormContexts({ kind: 'folder', index }),
this._controller.getFormContexts({ kind: 'full-folder', index }),
)}
<ha-alert alert-type="info">
${localize('config.folders.ha.path_info')}
+3 -2
View File
@@ -14,6 +14,7 @@ import type {
} from '../../components-lib/editor/forms-controller';
import type { FormRequest } from '../../components-lib/editor/schema/registry';
import { SectionController } from '../../components-lib/editor/section-controller';
import type { ConfigPath } from '../../components-lib/editor/types';
import type { HomeAssistant } from '../../ha/types';
import editorSectionStyle from '../../scss/editor-section.scss';
import { renderDocumentation } from './doc-link';
@@ -47,7 +48,7 @@ export class AdvancedCameraCardEditorSection extends LitElement {
// The path whose documentation the section links to; the section's own forms
// supply the links for everything within them.
@property({ attribute: false })
public documentationPath?: (string | number)[];
public docPath?: ConfigPath;
// Content shown after the section's schema forms, of which there may be
// none. Called only once the section has been opened, and in the render that
@@ -96,7 +97,7 @@ export class AdvancedCameraCardEditorSection extends LitElement {
@expanded-will-change=${this._stopPropagation}
@expanded-changed=${this._stopPropagation}
>
${this.documentationPath ? renderDocumentation(this.documentationPath) : nothing}
${this.docPath ? renderDocumentation(this.docPath) : nothing}
${renderForms(this.hass, this._controller.getContexts())}
${this.renderCustomContent?.() ?? nothing}
</div>
+14
View File
@@ -0,0 +1,14 @@
import { z } from 'zod';
const EDITOR_MODES = ['simple', 'full'] as const;
const editorModeSchema = z.enum(EDITOR_MODES);
export type EditorMode = z.infer<typeof editorModeSchema>;
// Options for the visual editor. The card itself renders identically whatever
// these are set to.
export const editorConfigSchema = z.object({
// Which editor experience to show. When unset, the editor chooses for itself
// based on whether the configuration only uses settings the simple editor can
// express.
mode: editorModeSchema.optional(),
});
+2
View File
@@ -6,6 +6,7 @@ import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './
import { cardIDRegex } from './common/const';
import { debugConfigDefault, debugConfigSchema, type DebugConfig } from './debug';
import { dimensionsConfigSchema } from './dimensions';
import { editorConfigSchema } from './editor';
import { pictureElementsSchema } from './elements/types';
import { foldersConfigSchema } from './folders';
import { imageConfigDefault, imageConfigSchema } from './image';
@@ -51,6 +52,7 @@ export const advancedCameraCardConfigSchema = z.object({
timeline: timelineConfigSchema,
performance: performanceConfigSchema,
debug: debugConfigSchema,
editor: editorConfigSchema.optional(),
automations: automationsSchema.optional(),
profiles: profilesSchema,
+104 -8
View File
@@ -16,9 +16,16 @@ import './components/editor/section.js';
import './components/icon.js';
import { EditorController } from './components-lib/editor/controller.js';
import type { FormsInput } from './components-lib/editor/forms-controller.js';
import {
FormsController,
type FormsInput,
} from './components-lib/editor/forms-controller.js';
import type { EditorIntent } from './components-lib/editor/intents.js';
import type { FormRequest } from './components-lib/editor/schema/registry.js';
import type { ConfigChange, ConfigPath } from './components-lib/editor/types.js';
import { renderDocumentation } from './components/editor/doc-link.js';
import { renderForms } from './components/editor/form.js';
import { CONF_CAMERAS } from './config/const.js';
import type { RawAdvancedCameraCardConfig } from './config/types.js';
import type { HAFormSchema, HomeAssistant, LovelaceCardEditor } from './ha/types.js';
import { localize } from './localize/localize.js';
@@ -30,6 +37,12 @@ interface EditorSection {
name: string;
description: string;
// The forms the section shows, absent for a section that has none of its own.
request?: FormRequest;
// The path whose documentation the section links to.
docPath?: ConfigPath;
// The part of the section a schema cannot express: a list the user adds to
// and reorders, or a key assigned by pressing it. A section whose whole
// configuration is such a list has no schema forms, and shows only this
@@ -133,6 +146,33 @@ const SECTIONS: Record<string, EditorSection> = {
},
};
// The two parts of the simple editor with more behind them than a handful of
// controls: a list of cameras to add to and drill into, and a menu whose
// buttons are a longer list than everything else in the editor put together.
// The rest of its settings are shown as they are, below.
const SIMPLE_SECTIONS: EditorSection[] = [
{
icon: 'video',
name: localize('editor.cameras'),
description: localize('editor.cameras_secondary'),
docPath: [CONF_CAMERAS],
renderCustomContent: (hass, input) => html`
<advanced-camera-card-editor-cameras
mode="simple"
.hass=${hass}
.input=${input}
></advanced-camera-card-editor-cameras>
`,
},
{
icon: 'menu',
name: localize('editor.menu'),
description: localize('editor.menu_secondary'),
request: { kind: 'simple-menu' },
docPath: ['menu'],
},
];
/**
* Used to side-load lazily-loaded selectors the editor will use.
* See {@link AdvancedCameraCardEditor._renderSelectorSideload}.
@@ -146,9 +186,13 @@ const SELECTOR_SIDELOAD_SCHEMA: HAFormSchema[] = [
{ name: 'entity', selector: { entity: {} } },
{ name: 'object', selector: { object: {} } },
{ name: 'sideload', type: 'expandable', title: '', schema: [] },
{ name: 'sideload-grid', type: 'grid', schema: [] },
];
const SELECTOR_SIDELOAD_DATA = {};
const MODE_REQUEST: FormRequest = { kind: 'editor-mode' };
const SIMPLE_TOP_LEVEL_REQUEST: FormRequest = { kind: 'simple-top-level' };
@customElement('advanced-camera-card-editor')
export class AdvancedCameraCardEditor extends LitElement implements LovelaceCardEditor {
@property({ attribute: false })
@@ -156,6 +200,20 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
private _controller = new EditorController(this);
// The forms the editor renders itself rather than within a full editor
// section: the switch between the editors, which belongs to neither of them,
// and the simple editor's remaining settings, which are shown as they are.
private _modeFormsController = this._createFormsController();
private _simpleFormsController = this._createFormsController();
private _createFormsController(): FormsController {
return new FormsController(
(changes: ConfigChange[]) =>
this._controller.applyIntent({ type: 'changes', changes }),
(path) => renderDocumentation(path),
);
}
public setConfig(config: RawAdvancedCameraCardConfig): void {
this._controller.setConfig(config);
}
@@ -186,32 +244,70 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
@advanced-camera-card:editor:intent=${(ev: CustomEvent<EditorIntent>) =>
this._controller.applyIntent(ev.detail)}
>
${this._renderNotices()}
${Object.keys(SECTIONS).map((name) => this._renderSection(name, hass, input))}
${[this._renderNotices(), this._renderModeSwitch(hass, input)]}
${this._controller.getEditorMode() === 'simple'
? this._renderSimple(hass, input)
: this._renderFull(hass, input)}
${this._renderActionButtons()}
</div>
`;
}
private _renderModeSwitch(hass: HomeAssistant, input: FormsInput): TemplateResult {
this._modeFormsController.setInput(MODE_REQUEST, input);
return html`
<div class="mode">
<advanced-camera-card-icon
.icon=${{ icon: 'mdi:tune' }}
></advanced-camera-card-icon>
${renderForms(hass, this._modeFormsController.getContexts())}
</div>
`;
}
private _renderSimple(hass: HomeAssistant, input: FormsInput): TemplateResult {
this._simpleFormsController.setInput(SIMPLE_TOP_LEVEL_REQUEST, input);
return html`
${SIMPLE_SECTIONS.map((section) => this._renderSection(section, hass, input))}
<div class="settings">
${renderForms(hass, this._simpleFormsController.getContexts())}
</div>
`;
}
private _renderFull(hass: HomeAssistant, input: FormsInput): TemplateResult {
return html`
${Object.entries(SECTIONS).map(([name, section]) =>
this._renderSection(
{
...section,
request: { kind: 'full-section', name },
docPath: [name],
},
hass,
input,
),
)}
`;
}
private _renderSection(
name: string,
section: EditorSection,
hass: HomeAssistant,
input: FormsInput,
): TemplateResult {
const section = SECTIONS[name];
const request: FormRequest = { kind: 'section', name };
const renderCustomContent = section.renderCustomContent;
return html`
<advanced-camera-card-editor-section
class="section"
.hass=${hass}
.request=${request}
.request=${section.request}
.input=${input}
.icon=${`mdi:${section.icon}`}
.heading=${section.name}
.description=${section.description}
.documentationPath=${[name]}
.docPath=${section.docPath}
.renderCustomContent=${renderCustomContent
? () => renderCustomContent(hass, input)
: undefined}
+35 -3
View File
@@ -336,6 +336,15 @@ interface StockHAFormExpandableSchema extends StockHAFormBaseSchema {
schema: readonly HAFormSchema[];
}
interface StockHAFormGridSchema extends StockHAFormBaseSchema {
type: 'grid';
// The width below which the columns fall back to one per row.
column_min_width?: string;
schema: readonly HAFormSchema[];
}
// Extensions layered onto the stock shapes; not part of `ha-form` itself.
interface CardHAFormSchemaExtensions {
// Override the standard path-derived label, e.g. to point shared fields at
@@ -356,9 +365,32 @@ export interface HAFormExpandableSchema
// the top-level `frigate`/`motioneye` camera keys.
name?: string;
// Explicit documentation-link key, for a nameless group whose link cannot be
// Explicit documentation-link path, for a nameless group whose link cannot be
// derived from a configuration path.
docPath?: string;
docPath?: string[];
}
export type HAFormSchema = HAFormSelectorSchema | HAFormExpandableSchema;
// Fields laid out in columns rather than one per row. A grid is nameless like
// any other visual-only grouping, so the fields within it are stored where they
// would be without it.
interface HAFormGridSchema
extends Omit<StockHAFormGridSchema, 'name'>,
CardHAFormSchemaExtensions {
name?: string;
}
export type HAFormSchema =
| HAFormSelectorSchema
| HAFormExpandableSchema
| HAFormGridSchema;
/**
* Whether a form node is a field the user fills in, rather than a container of
* further nodes. Recognized by the selector it carries, since `ha-form` gives a
* field nothing else to tell it apart by.
* @param schema The node's schema.
* @returns `true` for a field.
*/
export const isFormFieldSchema = (
schema: HAFormSchema,
): schema is HAFormSelectorSchema => 'selector' in schema;
-3
View File
@@ -420,8 +420,6 @@
"media_viewer_secondary": "Visor de suports estàtics (clips, instantànies o enregistraments)",
"menu": "Menú",
"menu_secondary": "Opcions d'aspecte del menú",
"move_down": "Moure cap avall",
"move_up": "Moure cap amunt",
"overrides": "Les substitucions estan actives",
"overrides_secondary": "S'han detectat substitucions de configuració dinàmica",
"performance": "Rendiment",
@@ -468,7 +466,6 @@
"no_camera_id": "No s'ha pogut determinar l'identificador de la càmera per a la següent càmera, és possible que hagis d'establir el paràmetre 'id' manualment",
"no_dashboard_or_view": "Tant els paràmetres 'dashboard_path' com 'view_path' són necessaris per al mètode d'emissió 'dashboard'",
"no_live_camera": "El paràmetre 'camera_entity' s'ha de configurar i validar per a aquest proveïdor en directe",
"reconnecting": "Reconnectant",
"too_many_automations": "Hi ha massa trucades d'automatització imbricades, comproveu la vostra configuració per veure si hi ha bucles",
"troubleshooting": "Comproveu la resolució de problemes",
"upgrade_available": "Hi ha disponible una actualització automàtica de la configuració de la targeta; visiteu l'editor de targetes visuals",
+7 -7
View File
@@ -429,6 +429,9 @@
},
"height": "Card height in CSS units (e.g. '500px')"
},
"editor": {
"mode": "Full editor"
},
"folders": {
"ha": {
"editor_label": "Home Assistant Media Folder Options",
@@ -534,6 +537,7 @@
"clips": "Clips",
"display_mode": "Display mode",
"download": "Download",
"editor_label": "Menu buttons",
"enabled": "Button enabled",
"expand": "Expand",
"folders": "Folders",
@@ -768,14 +772,13 @@
"media_viewer_secondary": "Viewer for static media",
"menu": "Menu",
"menu_secondary": "Menu look & feel options",
"move_down": "Move down",
"move_up": "Move up",
"performance": "Performance",
"performance_secondary": "Card performance options",
"profiles": "Profiles",
"profiles_secondary": "Choose pre-configured sets of defaults",
"remote_control": "Remote Control",
"remote_control_secondary": "Options for remote controlling the card",
"simple_coverage": "This card has settings the simple editor does not show. Turn on the full editor to see them",
"status_bar": "Status bar",
"status_bar_secondary": "Status bar look & feel options",
"timeline": "Timeline",
@@ -841,7 +844,6 @@
"no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually",
"no_camera_or_media_for_timeline": "No camera or media available for timeline",
"no_live_camera": "The camera_entity parameter must be set and valid for this live provider",
"reconnecting": "Reconnecting",
"too_many_automations": "Too many nested automation calls, please check your configuration for loops",
"troubleshooting": "Check troubleshooting",
"upgrade_available": "An automated card configuration upgrade is available, please visit the visual card editor",
@@ -850,8 +852,7 @@
},
"issues": {
"config_error": {
"heading": "Configuration error",
"text": "An error occurred evaluating the card configuration"
"heading": "Configuration error"
},
"config_upgrade": {
"heading": "Configuration upgrade available",
@@ -885,8 +886,7 @@
"text_only_legacy": "The legacy 'frigate-hass-card.js' resource must be replaced with 'advanced-camera-card.js'. It will be removed in a future release"
},
"media_query": {
"heading": "Media query failed",
"text": "An error occurred fetching media (e.g. thumbnails) for this view."
"heading": "Media query failed"
},
"media_unavailable": {
"heading": "Media unavailable",
-3
View File
@@ -563,8 +563,6 @@
"media_viewer_secondary": "Visionneuse de médias statiques (clips, instantanés ou enregistrements)",
"menu": "Menu",
"menu_secondary": "Options d'apparence et de convivialité du menu",
"move_down": "Descendre",
"move_up": "Déplacer vers le haut",
"overrides": "Remplacements activés",
"overrides_secondary": "Remplacements de configuration dynamique détectés",
"performance": "Performance",
@@ -620,7 +618,6 @@
"no_camera_id": "Impossible de déterminer l'identifiant de la caméra suivante. Il faudra peut-être définir le paramètre « ID » manuellement",
"no_dashboard_or_view": "Les paramètres 'dashboard_path' et 'view_path' sont requis pour la méthode de conversion 'dashboard'",
"no_live_camera": "Le paramètre camera_entity doit être défini et valide pour ce fournisseur en direct",
"reconnecting": "Reconnexion",
"too_many_automations": "Trop d'appels d'automatisation imbriqués, veuillez vérifier votre configuration pour les boucles",
"troubleshooting": "Vérifier le dépannage",
"upgrade_available": "Une mise à niveau automatisée de la configuration de la carte est disponible, veuillez visiter l'éditeur visuel de la carte",
-3
View File
@@ -307,8 +307,6 @@
"media_viewer_secondary": "Visualizzatore per supporti statici (clip, istantanee o registrazioni)",
"menu": "Menu",
"menu_secondary": "Opzioni di aspetto e funzionalità del menu",
"move_down": "Sposta verso il basso",
"move_up": "Sposta verso l'alto",
"overrides": "La sovrascrittura è attiva",
"overrides_secondary": "Rilevate sovrascritture della configurazione dinamica",
"timeline": "Timeline",
@@ -351,7 +349,6 @@
"no_camera_entity": "Impossibile trovare l'entità fotocamera",
"no_camera_id": "Impossibile determinare l'ID della telecamera , potrebbe essere necessario impostare manualmente il parametro 'ID'",
"no_live_camera": "Il parametro fotocamera_enty deve essere impostato e valido per questo provider live",
"reconnecting": "Riconnessione",
"troubleshooting": "Controllare la risoluzione dei problemi",
"upgrade_available": "È disponibile un aggiornamento di configurazione della scheda automatizzato, visitare l'editor di schede visive",
"webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore",
-3
View File
@@ -618,8 +618,6 @@
"media_viewer_secondary": "Przeglądarka dla mediów statycznych (klipy, zrzuty, nagrania)",
"menu": "Menu",
"menu_secondary": "Opcje wyglądu i działania menu",
"move_down": "Przesuń w dół",
"move_up": "Przesuń w górę",
"overrides": "Nadpisania są aktywne",
"overrides_secondary": "Wykryto dynamiczne nadpisania konfiguracji",
"performance": "Wydajność",
@@ -682,7 +680,6 @@
"no_camera_or_media_for_timeline": "Brak dostępnej kamery lub mediów dla osi czasu",
"no_dashboard_or_view": "Zarówno 'dashboard_path' jak i 'view_path' są wymagane dla metody cast 'dashboard'",
"no_live_camera": "Parametr camera_entity musi być ustawiony i prawidłowy dla tego dostawcy",
"reconnecting": "Ponowne łączenie",
"too_many_automations": "Zbyt wiele zagnieżdżonych wywołań automatyzacji, sprawdź konfigurację pod kątem pętli",
"troubleshooting": "Sprawdź rozwiązywanie problemów",
"upgrade_available": "Dostępna jest automatyczna aktualizacja konfiguracji karty, odwiedź wizualny edytor karty",
-3
View File
@@ -312,8 +312,6 @@
"media_viewer_secondary": "Opções do visualizador de Snapshots e clipes",
"menu": "Menu",
"menu_secondary": "Opções de aparência do menu",
"move_down": "Descer",
"move_up": "Subir",
"overrides": "As substituições estão ativas",
"overrides_secondary": "Substituições de configuração dinâmica detectadas",
"performance": "Desempenho",
@@ -358,7 +356,6 @@
"no_camera_entity": "Não foi possível encontrar a entidade da câmera",
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo",
"reconnecting": "Reconectando",
"troubleshooting": "Verifique a solução de problemas",
"upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
"webrtc_card_reported_error": "O cartão WebRTC relatou um erro",
-3
View File
@@ -307,8 +307,6 @@
"media_viewer_secondary": "Opções do visualizador de Snapshots e clipes",
"menu": "Menu",
"menu_secondary": "Opções de aparência do menu",
"move_down": "Descer",
"move_up": "Subir",
"overrides": "As substituições estão ativas",
"overrides_secondary": "Substituições de configuração dinâmica detectadas",
"timeline": "Linha do tempo",
@@ -351,7 +349,6 @@
"no_camera_entity": "Não existe uma entidade câmera",
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este serviço ativo",
"reconnecting": "A voltar a ligar",
"troubleshooting": "Verifique a solução de problemas",
"upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
"webrtc_card_reported_error": "O cartão WebRTC relatou um erro",
+34
View File
@@ -13,6 +13,40 @@
margin-bottom: 8px;
}
// The switch between the editors, set apart from what it switches: it is a
// control over the editor rather than one of the card settings below it. Its
// icon sits beside the form so the row reads as one of the editor's rows, each
// of which carries an icon.
.mode {
display: flex;
align-items: center;
margin-bottom: 8px;
padding: 0 10px 8px;
border-bottom: 1px solid var(--advanced-camera-card-divider-color);
}
// Spaced and de-emphasized to match the icons on the sections below. The form
// insets its own label, so the gap here is the remainder of that spacing.
.mode advanced-camera-card-icon {
margin-right: 8px;
color: var(--advanced-camera-card-editor-secondary-text-color);
}
.mode ha-form {
flex: 1;
}
// The simple editor's settings that are not in a section. They are inset to
// line up with the sections above, whose outlines the panels draw themselves.
.settings ha-form {
display: block;
margin: 8px 10px;
}
// Each top-level section is an HA expansion panel. An open section draws its
// own outline, which would otherwise sit directly against the neighbouring
// sections, so every section is spaced whether it is open or not: a gap that
@@ -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]