feat: Add a simple mode to the visual card editor (#2588)
This commit is contained in:
@@ -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. |
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()],
|
||||
},
|
||||
];
|
||||
@@ -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),
|
||||
];
|
||||
@@ -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',
|
||||
|
||||
@@ -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()],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
];
|
||||
@@ -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') },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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' } } }),
|
||||
);
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user