diff --git a/docs/configuration/README.md b/docs/configuration/README.md index 96b05464..f6e37b2c 100644 --- a/docs/configuration/README.md +++ b/docs/configuration/README.md @@ -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. | diff --git a/docs/configuration/_sidebar.md b/docs/configuration/_sidebar.md index 1a52f297..b5cb0d73 100644 --- a/docs/configuration/_sidebar.md +++ b/docs/configuration/_sidebar.md @@ -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) diff --git a/docs/configuration/actions/_sidebar.md b/docs/configuration/actions/_sidebar.md index f7326979..0b518f4b 100644 --- a/docs/configuration/actions/_sidebar.md +++ b/docs/configuration/actions/_sidebar.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) diff --git a/docs/configuration/actions/custom/_sidebar.md b/docs/configuration/actions/custom/_sidebar.md index 3759a144..9ffdda57 100644 --- a/docs/configuration/actions/custom/_sidebar.md +++ b/docs/configuration/actions/custom/_sidebar.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) diff --git a/docs/configuration/actions/stock/_sidebar.md b/docs/configuration/actions/stock/_sidebar.md index a4c1b1c7..20707751 100644 --- a/docs/configuration/actions/stock/_sidebar.md +++ b/docs/configuration/actions/stock/_sidebar.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) diff --git a/docs/configuration/cameras/_sidebar.md b/docs/configuration/cameras/_sidebar.md index cb934b9f..bbe32435 100644 --- a/docs/configuration/cameras/_sidebar.md +++ b/docs/configuration/cameras/_sidebar.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) diff --git a/docs/configuration/editor.md b/docs/configuration/editor.md new file mode 100644 index 00000000..a4031388 --- /dev/null +++ b/docs/configuration/editor.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 +``` diff --git a/docs/configuration/elements/_sidebar.md b/docs/configuration/elements/_sidebar.md index 5374459a..2791c6d6 100644 --- a/docs/configuration/elements/_sidebar.md +++ b/docs/configuration/elements/_sidebar.md @@ -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) diff --git a/docs/configuration/elements/custom/_sidebar.md b/docs/configuration/elements/custom/_sidebar.md index 23048ae2..87d70cbb 100644 --- a/docs/configuration/elements/custom/_sidebar.md +++ b/docs/configuration/elements/custom/_sidebar.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) diff --git a/docs/configuration/elements/stock/_sidebar.md b/docs/configuration/elements/stock/_sidebar.md index 66c6c292..d2d312a2 100644 --- a/docs/configuration/elements/stock/_sidebar.md +++ b/docs/configuration/elements/stock/_sidebar.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) diff --git a/src/components-lib/editor/controller.ts b/src/components-lib/editor/controller.ts index eac7ca74..74aaa8c6 100644 --- a/src/components-lib/editor/controller.ts +++ b/src/components-lib/editor/controller.ts @@ -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; } diff --git a/src/components-lib/editor/doc-links.ts b/src/components-lib/editor/doc-links.ts index 8ef86b6a..d77cf2a4 100644 --- a/src/components-lib/editor/doc-links.ts +++ b/src/components-lib/editor/doc-links.ts @@ -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 = { '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 = { * @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) + ); }; diff --git a/src/components-lib/editor/form-data.ts b/src/components-lib/editor/form-data.ts index 6c87248c..d27dd608 100644 --- a/src/components-lib/editor/form-data.ts +++ b/src/components-lib/editor/form-data.ts @@ -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 diff --git a/src/components-lib/editor/form-labels.ts b/src/components-lib/editor/form-labels.ts index 51af7bd6..179e6497 100644 --- a/src/components-lib/editor/form-labels.ts +++ b/src/components-lib/editor/form-labels.ts @@ -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], + ), ); }; diff --git a/src/components-lib/editor/forms-controller.ts b/src/components-lib/editor/forms-controller.ts index 6aef46b7..f71ed7e9 100644 --- a/src/components-lib/editor/forms-controller.ts +++ b/src/components-lib/editor/forms-controller.ts @@ -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 diff --git a/src/components-lib/editor/keyboard-shortcuts-controller.ts b/src/components-lib/editor/keyboard-shortcuts-controller.ts index eadf85b4..6f381bb6 100644 --- a/src/components-lib/editor/keyboard-shortcuts-controller.ts +++ b/src/components-lib/editor/keyboard-shortcuts-controller.ts @@ -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, diff --git a/src/components-lib/editor/mode.ts b/src/components-lib/editor/mode.ts new file mode 100644 index 00000000..34f034a9 --- /dev/null +++ b/src/components-lib/editor/mode.ts @@ -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 => + 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 => { + 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); +}; diff --git a/src/components-lib/editor/paths.ts b/src/components-lib/editor/paths.ts index 76d90c3d..8ded8699 100644 --- a/src/components-lib/editor/paths.ts +++ b/src/components-lib/editor/paths.ts @@ -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), diff --git a/src/components-lib/editor/schema/cameras.ts b/src/components-lib/editor/schema/cameras.ts index 0aa4ba7b..eccbf3bd 100644 --- a/src/components-lib/editor/schema/cameras.ts +++ b/src/components-lib/editor/schema/cameras.ts @@ -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', diff --git a/src/components-lib/editor/schema/dimensions.ts b/src/components-lib/editor/schema/dimensions.ts index 7ec92c89..88e2e081 100644 --- a/src/components-lib/editor/schema/dimensions.ts +++ b/src/components-lib/editor/schema/dimensions.ts @@ -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', diff --git a/src/components-lib/editor/schema/editor-mode.ts b/src/components-lib/editor/schema/editor-mode.ts new file mode 100644 index 00000000..c2027971 --- /dev/null +++ b/src/components-lib/editor/schema/editor-mode.ts @@ -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()], + }, +]; diff --git a/src/components-lib/editor/schema/full.ts b/src/components-lib/editor/schema/full.ts new file mode 100644 index 00000000..c9a5dbdd --- /dev/null +++ b/src/components-lib/editor/schema/full.ts @@ -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 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[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), +]; diff --git a/src/components-lib/editor/schema/menu.ts b/src/components-lib/editor/schema/menu.ts index 8121d622..ab310e2b 100644 --- a/src/components-lib/editor/schema/menu.ts +++ b/src/components-lib/editor/schema/menu.ts @@ -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', diff --git a/src/components-lib/editor/schema/profiles.ts b/src/components-lib/editor/schema/profiles.ts index d3614b0e..3dfde77b 100644 --- a/src/components-lib/editor/schema/profiles.ts +++ b/src/components-lib/editor/schema/profiles.ts @@ -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()], }, ]; diff --git a/src/components-lib/editor/schema/registry.ts b/src/components-lib/editor/schema/registry.ts index bd84b324..977cd7c1 100644 --- a/src/components-lib/editor/schema/registry.ts +++ b/src/components-lib/editor/schema/registry.ts @@ -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); } }; diff --git a/src/components-lib/editor/schema/simple.ts b/src/components-lib/editor/schema/simple.ts new file mode 100644 index 00000000..fd705931 --- /dev/null +++ b/src/components-lib/editor/schema/simple.ts @@ -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(), + }, +]; diff --git a/src/components-lib/editor/schema/view.ts b/src/components-lib/editor/schema/view.ts index 8ca50d28..246f460b 100644 --- a/src/components-lib/editor/schema/view.ts +++ b/src/components-lib/editor/schema/view.ts @@ -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') }, diff --git a/src/components-lib/editor/types.ts b/src/components-lib/editor/types.ts index 5453e9bb..33344206 100644 --- a/src/components-lib/editor/types.ts +++ b/src/components-lib/editor/types.ts @@ -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 diff --git a/src/components/editor/cameras.ts b/src/components/editor/cameras.ts index 0bba5293..d406ac8c 100644 --- a/src/components/editor/cameras.ts +++ b/src/components/editor/cameras.ts @@ -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` ${renderForms( this.hass, - this._formsController.getFormContexts({ kind: 'camera', index }), + this._formsController.getFormContexts( + fullMode ? { kind: 'full-camera', index } : { kind: 'simple-camera', index }, + ), )} - - - ${this._renderContained(html` - ${renderDocumentation([CONF_CAMERAS, 'triggers'])} - ${renderForms( - this.hass, - this._formsController.getFormContexts({ - kind: 'camera-triggers', - cameraIndex: index, - }), - )} - ${this._renderEvents(eventsPath, events)} - `)} - + ${fullMode ? this._renderTriggers(index) : nothing} `; } + private _renderTriggers(index: number): TemplateResult { + const eventsPath: ConfigPath = [CONF_CAMERAS, index, 'triggers', 'events']; + const events = this._formsController.getList(eventsPath); + + return html` + + + ${this._renderContained(html` + ${renderDocumentation([CONF_CAMERAS, 'triggers'])} + ${renderForms( + this.hass, + this._formsController.getFormContexts({ + kind: 'full-camera-triggers', + cameraIndex: index, + }), + )} + ${this._renderEvents(eventsPath, events)} + `)} + + `; + } + // 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, }), diff --git a/src/components/editor/doc-link.ts b/src/components/editor/doc-link.ts index b7e90b56..c6487fbe 100644 --- a/src/components/editor/doc-link.ts +++ b/src/components/editor/doc-link.ts @@ -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` ${renderForms( this.hass, - this._controller.getFormContexts({ kind: 'folder', index }), + this._controller.getFormContexts({ kind: 'full-folder', index }), )} ${localize('config.folders.ha.path_info')} diff --git a/src/components/editor/section.ts b/src/components/editor/section.ts index 78c8bf65..cc906d9e 100644 --- a/src/components/editor/section.ts +++ b/src/components/editor/section.ts @@ -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} diff --git a/src/config/schema/editor.ts b/src/config/schema/editor.ts new file mode 100644 index 00000000..2b825adb --- /dev/null +++ b/src/config/schema/editor.ts @@ -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; + +// 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(), +}); diff --git a/src/config/schema/types.ts b/src/config/schema/types.ts index 151711f5..29787703 100644 --- a/src/config/schema/types.ts +++ b/src/config/schema/types.ts @@ -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, diff --git a/src/editor.ts b/src/editor.ts index df082eb9..a477d56e 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -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 = { }, }; +// 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` + + `, + }, + { + 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) => 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()} `; } + private _renderModeSwitch(hass: HomeAssistant, input: FormsInput): TemplateResult { + this._modeFormsController.setInput(MODE_REQUEST, input); + return html` +
+ + ${renderForms(hass, this._modeFormsController.getContexts())} +
+ `; + } + + 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))} +
+ ${renderForms(hass, this._simpleFormsController.getContexts())} +
+ `; + } + + 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` renderCustomContent(hass, input) : undefined} diff --git a/src/ha/types.ts b/src/ha/types.ts index 147b66f2..940dfa47 100644 --- a/src/ha/types.ts +++ b/src/ha/types.ts @@ -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, + 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; diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index b7fb9833..7698c130 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -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", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index c895bdd9..0516e8b2 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -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", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index 0ff84c27..94fe1e8b 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -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", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index ecffb746..0bfb0f2c 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -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", diff --git a/src/localize/languages/pl.json b/src/localize/languages/pl.json index 86da45cd..9f3ad067 100644 --- a/src/localize/languages/pl.json +++ b/src/localize/languages/pl.json @@ -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", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 6b1288af..c16ab00c 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -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", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 00be003a..85418efb 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -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", diff --git a/src/scss/editor.scss b/src/scss/editor.scss index c942b65e..9f7036f6 100644 --- a/src/scss/editor.scss +++ b/src/scss/editor.scss @@ -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 diff --git a/tests/components-lib/editor/controller.test.ts b/tests/components-lib/editor/controller.test.ts index 6a52bef9..a94ecbc4 100644 --- a/tests/components-lib/editor/controller.test.ts +++ b/tests/components-lib/editor/controller.test.ts @@ -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', () => { diff --git a/tests/components-lib/editor/doc-links.test.ts b/tests/components-lib/editor/doc-links.test.ts index 83f4386f..b811e3fd 100644 --- a/tests/components-lib/editor/doc-links.test.ts +++ b/tests/components-lib/editor/doc-links.test.ts @@ -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( diff --git a/tests/components-lib/editor/form-data.test.ts b/tests/components-lib/editor/form-data.test.ts index ac1293b8..4affb0c5 100644 --- a/tests/components-lib/editor/form-data.test.ts +++ b/tests/components-lib/editor/form-data.test.ts @@ -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) => { diff --git a/tests/components-lib/editor/form-labels.test.ts b/tests/components-lib/editor/form-labels.test.ts index c267d470..9404d858 100644 --- a/tests/components-lib/editor/form-labels.test.ts +++ b/tests/components-lib/editor/form-labels.test.ts @@ -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(''); }); }); diff --git a/tests/components-lib/editor/forms-controller.test.ts b/tests/components-lib/editor/forms-controller.test.ts index 1924d912..bfffd9b9 100644 --- a/tests/components-lib/editor/forms-controller.test.ts +++ b/tests/components-lib/editor/forms-controller.test.ts @@ -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' } } }), ); diff --git a/tests/components-lib/editor/list-forms-controller.test.ts b/tests/components-lib/editor/list-forms-controller.test.ts index 4b479eb0..5df1981b 100644 --- a/tests/components-lib/editor/list-forms-controller.test.ts +++ b/tests/components-lib/editor/list-forms-controller.test.ts @@ -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' } } }), ); diff --git a/tests/components-lib/editor/mode.test.ts b/tests/components-lib/editor/mode.test.ts new file mode 100644 index 00000000..0f5cecde --- /dev/null +++ b/tests/components-lib/editor/mode.test.ts @@ -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 = {}) => ({ + 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'); + }); +}); diff --git a/tests/components-lib/editor/schema/completeness.test.ts b/tests/components-lib/editor/schema/completeness.test.ts index 63156d28..fcfe1b2e 100644 --- a/tests/components-lib/editor/schema/completeness.test.ts +++ b/tests/components-lib/editor/schema/completeness.test.ts @@ -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 = { ], }; +// 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 = { + 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 = { + ...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(); -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); } } - }); - }); - } + } + } + }); } }); diff --git a/tests/components-lib/editor/schema/editor-mode.test.ts b/tests/components-lib/editor/schema/editor-mode.test.ts new file mode 100644 index 00000000..62939706 --- /dev/null +++ b/tests/components-lib/editor/schema/editor-mode.test.ts @@ -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' }, + ]); + }); +}); diff --git a/tests/components-lib/editor/schema/registry.test.ts b/tests/components-lib/editor/schema/registry.test.ts index 840669ef..73d603b9 100644 --- a/tests/components-lib/editor/schema/registry.test.ts +++ b/tests/components-lib/editor/schema/registry.test.ts @@ -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) => { diff --git a/tests/components-lib/editor/schema/simple.test.ts b/tests/components-lib/editor/schema/simple.test.ts new file mode 100644 index 00000000..d09e7644 --- /dev/null +++ b/tests/components-lib/editor/schema/simple.test.ts @@ -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 }, + }); + }); +}); diff --git a/tests/components-lib/editor/section-controller.test.ts b/tests/components-lib/editor/section-controller.test.ts index 9ba1d305..315229f9 100644 --- a/tests/components-lib/editor/section-controller.test.ts +++ b/tests/components-lib/editor/section-controller.test.ts @@ -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]