- ${this._renderOptionSetHeader('cameras')}
- ${this._expandedMenus[MENU_OPTIONS] === 'cameras'
- ? html`
-
- ${SECTION_DOC_LINKS['cameras']
- ? this._renderDocLinkRow(SECTION_DOC_LINKS['cameras'])
- : ''}
- ${cameras.map((_, index) =>
- this._renderCamera(cameras, index, entities, folders),
- )}
- ${this._renderCamera(cameras, cameras.length, entities, folders, true)}
-
- `
- : ''}
- ${this._renderOptionSetHeader('profiles')}
- ${this._expandedMenus[MENU_OPTIONS] === 'profiles'
- ? html`
- ${this._renderOptionSelector(CONF_PROFILES, this._profiles, {
- multiple: true,
- label: localize('config.profiles.editor_label'),
- })}
-
`
- : ''}
- ${this._renderOptionSetHeader('view')}
- ${this._expandedMenus[MENU_OPTIONS] === 'view'
- ? html`
-
- ${SECTION_DOC_LINKS['view']
- ? this._renderDocLinkRow(SECTION_DOC_LINKS['view'])
- : ''}
- ${this._renderOptionSelector(CONF_VIEW_DEFAULT, this._viewModes)}
- ${this._renderOptionSelector(
- CONF_VIEW_CAMERA_SELECT,
- this._cameraSelectViewModes,
- )}
- ${this._renderSwitch(CONF_VIEW_DIM, this._defaults.view.dim)}
- ${this._renderNumberInput(CONF_VIEW_INTERACTION_SECONDS)}
- ${this._renderSwitch(
- CONF_VIEW_DEFAULT_CYCLE_CAMERA,
- this._defaults.view.default_cycle_camera,
- )}
- ${[
- this._renderViewDefaultResetMenu(),
- this._renderViewIssuesMenu(),
- this._renderViewTriggersMenu(),
- this._renderViewKeyboardShortcutMenu(),
- ]}
- ${this._renderOptionSelector(CONF_VIEW_THEME_THEMES, this._themes, {
- label: localize('config.view.theme.themes.editor_label'),
- multiple: true,
- })}
-
- `
- : ''}
- ${this._renderOptionSetHeader('menu')}
- ${this._expandedMenus[MENU_OPTIONS] === 'menu'
- ? html`
-
- ${SECTION_DOC_LINKS['menu']
- ? this._renderDocLinkRow(SECTION_DOC_LINKS['menu'])
- : ''}
- ${this._renderOptionSelector(CONF_MENU_STYLE, this._menuStyles)}
- ${this._renderOptionSelector(CONF_MENU_POSITION, this._menuPositions)}
- ${this._renderOptionSelector(CONF_MENU_ALIGNMENT, this._menuAlignments)}
- ${this._renderOptionSelector(
- CONF_MENU_AUTO_HIDE,
- this._autoHideConditions,
- {
- multiple: true,
- label: localize('config.menu.auto_hide'),
- },
- )}
- ${this._renderNumberInput(CONF_MENU_BUTTON_SIZE, {
- min: BUTTON_SIZE_MIN,
- })}
- ${[
- this._renderMenuButton('iris'),
- this._renderMenuButton('call'),
- this._renderMenuButton('camera_ui'),
- this._renderMenuButton('cameras'),
- this._renderMenuButton('clips'),
- this._renderMenuButton('display_mode'),
- this._renderMenuButton('download'),
- this._renderMenuButton('expand'),
- this._renderMenuButton('folders'),
- this._renderMenuButton('fullscreen'),
- this._renderMenuButton('gallery'),
- this._renderMenuButton('image'),
- this._renderMenuButton('info'),
- this._renderMenuButton('live'),
- this._renderMenuButton('media_player'),
- this._renderMenuButton(
- 'microphone',
- html`${this._renderOptionSelector(
- `${CONF_MENU_BUTTONS}.microphone.type`,
- this._microphoneButtonTypes,
- { label: localize('config.menu.buttons.type') },
- )}`,
- ),
- this._renderMenuButton('mute'),
- this._renderMenuButton('pip'),
- this._renderMenuButton('play'),
- this._renderMenuButton('ptz_controls'),
- this._renderMenuButton('ptz_home'),
- this._renderMenuButton('recordings'),
- this._renderMenuButton('reviews'),
- this._renderMenuButton('screenshot'),
- this._renderMenuButton('set_review'),
- this._renderMenuButton('snapshots'),
- this._renderMenuButton('substreams'),
- this._renderMenuButton('timeline'),
- ]}
-
- `
- : ''}
- ${this._renderOptionSetHeader('status_bar')}
- ${this._expandedMenus[MENU_OPTIONS] === 'status_bar'
- ? html`
-
- ${SECTION_DOC_LINKS['status_bar']
- ? this._renderDocLinkRow(SECTION_DOC_LINKS['status_bar'])
- : ''}
- ${this._renderOptionSelector(
- CONF_STATUS_BAR_STYLE,
- this._statusBarStyles,
- )}
- ${this._renderOptionSelector(
- CONF_STATUS_BAR_POSITION,
- this._statusBarPositions,
- )}
- ${this._renderOptionSelector(
- CONF_STATUS_BAR_AUTO_HIDE,
- this._autoHideConditions,
- {
- multiple: true,
- label: localize('config.status_bar.auto_hide'),
- },
- )}
- ${this._renderNumberInput(CONF_STATUS_BAR_HEIGHT, {
- min: STATUS_BAR_HEIGHT_MIN,
- label: localize('config.status_bar.height'),
- })}
- ${this._renderNumberInput(CONF_STATUS_BAR_POPUP_SECONDS, {
- min: 0,
- max: 60,
- default: this._defaults.status_bar.popup_seconds,
- label: localize('config.status_bar.popup_seconds'),
- })}
- ${[
- this._renderStatusBarItem('title'),
- this._renderStatusBarItem('resolution'),
- this._renderStatusBarItem('technology'),
- this._renderStatusBarItem('engine'),
- this._renderStatusBarItem('severity'),
- this._renderStatusBarItem('issues'),
- ]}
-
- `
- : ''}
- ${this._renderOptionSetHeader('live')}
- ${this._expandedMenus[MENU_OPTIONS] === 'live'
- ? html`
-
- ${SECTION_DOC_LINKS['live']
- ? this._renderDocLinkRow(SECTION_DOC_LINKS['live'])
- : ''}
- ${this._renderSwitch(CONF_LIVE_PRELOAD, this._defaults.live.preload)}
- ${this._renderSwitch(CONF_LIVE_DRAGGABLE, this._defaults.live.draggable)}
- ${this._renderSwitch(CONF_LIVE_ZOOMABLE, this._defaults.live.zoomable)}
- ${this._renderSwitch(CONF_LIVE_LAZY_LOAD, this._defaults.live.lazy_load)}
- ${this._renderOptionSelector(
- CONF_LIVE_LAZY_UNLOAD,
- this._mediaActionNegativeConditions,
- {
- multiple: true,
- },
- )}
- ${this._renderOptionSelector(
- CONF_LIVE_AUTO_PLAY,
- this._mediaActionPositiveConditions,
- {
- multiple: true,
- },
- )}
- ${this._renderOptionSelector(
- CONF_LIVE_AUTO_PAUSE,
- this._mediaActionNegativeConditions,
- {
- multiple: true,
- },
- )}
- ${this._renderOptionSelector(
- CONF_LIVE_AUTO_MUTE,
- this._mediaLiveMuteConditions,
- {
- multiple: true,
- },
- )}
- ${this._renderOptionSelector(
- CONF_LIVE_AUTO_UNMUTE,
- this._mediaLiveUnmuteConditions,
- {
- multiple: true,
- },
- )}
- ${this._renderOptionSelector(
- CONF_LIVE_TRANSITION_EFFECT,
- this._transitionEffects,
- )}
- ${this._renderSwitch(
- CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
- this._defaults.live.show_image_during_load,
- )}
- ${this._renderViewDisplay(
- MENU_LIVE_DISPLAY,
- CONF_LIVE_DISPLAY_MODE,
- CONF_LIVE_DISPLAY_GRID_SELECTED_POSITION,
- CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR,
- CONF_LIVE_DISPLAY_GRID_COLUMNS,
- CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS,
- )}
- ${this._putInSubmenu(
- MENU_LIVE_CONTROLS,
- true,
- 'config.live.controls.editor_label',
- 'mdi:gamepad',
- html`
- ${this._renderSwitch(
- CONF_LIVE_CONTROLS_BUILTIN,
- this._defaults.live.controls.builtin,
- {
- label: localize('config.common.controls.builtin'),
- },
- )}
- ${this._renderSwitch(
- CONF_LIVE_CONTROLS_WHEEL,
- this._defaults.live.controls.wheel,
- {
- label: localize('config.common.controls.wheel'),
- },
- )}
- ${this._putInSubmenu(
- MENU_LIVE_CONTROLS_CALL,
- true,
- 'config.live.controls.call.editor_label',
- 'mdi:phone',
- html`
- ${this._renderSwitch(
- CONF_LIVE_CONTROLS_CALL_LOCK,
- this._defaults.live.controls.call.lock,
- )}
- ${this._renderOptionSelector(
- CONF_LIVE_CONTROLS_CALL_RINGTONE_TYPE,
- this._callRingtoneTypes,
- {
- label: localize('config.live.controls.call.ringtone.type'),
- },
- )}
- ${this._renderStringInput(CONF_LIVE_CONTROLS_CALL_RINGTONE_URL, {
- label: localize('config.live.controls.call.ringtone.url'),
- })}
- ${this._renderNumberInput(
- CONF_LIVE_CONTROLS_CALL_RINGTONE_REPEAT,
- {
- min: 0,
- default: this._defaults.live.controls.call.ringtone.repeat,
- },
- )}
- ${this._renderNumberInput(
- CONF_LIVE_CONTROLS_CALL_UNANSWERED_TIMEOUT_SECONDS,
- {
- min: 0,
- default:
- this._defaults.live.controls.call
- .unanswered_timeout_seconds,
- },
- )}
- ${this._renderNumberInput(CONF_LIVE_CONTROLS_CALL_BUTTON_SIZE, {
- min: BUTTON_SIZE_MIN,
- })}
- `,
- )}
- ${this._renderNextPreviousControls(
- MENU_LIVE_CONTROLS_NEXT_PREVIOUS,
- CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
- CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
- CONF_LIVE_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE,
- {
- allowIcons: true,
- allowCall: true,
- },
- )}
- ${this._renderThumbnailsControls(
- MENU_LIVE_CONTROLS_THUMBNAILS,
- CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
- CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
- CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
- CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
- CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
- CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
- CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
- this._defaults.live.controls.thumbnails,
- {
- configPathMode: CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
- },
- )}
- ${this._renderMiniTimeline(
- MENU_LIVE_CONTROLS_TIMELINE,
- MENU_LIVE_CONTROLS_TIMELINE_FORMAT,
- CONF_LIVE_CONTROLS_TIMELINE_MODE,
- CONF_LIVE_CONTROLS_TIMELINE_STYLE,
- CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
- CONF_LIVE_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
- CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
- CONF_LIVE_CONTROLS_TIMELINE_FORMAT_24H,
- this._defaults.live.controls.timeline.show_recordings,
- this._defaults.live.controls.timeline.format['24h'],
- CONF_LIVE_CONTROLS_TIMELINE_PAN_MODE,
- )}
- ${this._putInSubmenu(
- MENU_LIVE_CONTROLS_PTZ,
- true,
- 'config.live.controls.ptz.editor_label',
- 'mdi:pan',
- html`
- ${this._renderOptionSelector(
- CONF_LIVE_CONTROLS_PTZ_MODE,
- this._ptzModes,
- )}
- ${this._renderOptionSelector(
- CONF_LIVE_CONTROLS_PTZ_TYPE,
- this._ptzTypes,
- )}
- ${this._renderOptionSelector(
- CONF_LIVE_CONTROLS_PTZ_POSITION,
- this._ptzPositions,
- )}
- ${this._renderOptionSelector(
- CONF_LIVE_CONTROLS_PTZ_ORIENTATION,
- this._ptzOrientations,
- )}
- ${this._renderSwitch(
- CONF_LIVE_CONTROLS_PTZ_HIDE_PAN_TILT,
- this._defaults.live.controls.ptz.hide_pan_tilt,
- {
- label: localize('config.live.controls.ptz.hide_pan_tilt'),
- },
- )}
- ${this._renderSwitch(
- CONF_LIVE_CONTROLS_PTZ_HIDE_ZOOM,
- this._defaults.live.controls.ptz.hide_pan_tilt,
- {
- label: localize('config.live.controls.ptz.hide_zoom'),
- },
- )}
- ${this._renderSwitch(
- CONF_LIVE_CONTROLS_PTZ_HIDE_HOME,
- this._defaults.live.controls.ptz.hide_home,
- {
- label: localize('config.live.controls.ptz.hide_home'),
- },
- )}
- ${this._renderSwitch(
- CONF_LIVE_CONTROLS_PTZ_HIDE_TYPE,
- this._defaults.live.controls.ptz.hide_type,
- {
- label: localize('config.live.controls.ptz.hide_type'),
- },
- )}
- `,
- )}
- `,
- )}
- ${this._putInSubmenu(
- MENU_LIVE_MICROPHONE,
- true,
- 'config.live.microphone.editor_label',
- 'mdi:microphone',
- html`
- ${this._renderNumberInput(CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS)}
- ${this._renderSwitch(
- CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
- this._defaults.live.microphone.always_connected,
- )}
- ${this._renderOptionSelector(
- CONF_LIVE_MICROPHONE_AUTO_MUTE,
- this._microphoneMuteConditions,
- {
- multiple: true,
- },
- )}
- ${this._renderOptionSelector(
- CONF_LIVE_MICROPHONE_AUTO_UNMUTE,
- this._microphoneUnmuteConditions,
- {
- multiple: true,
- },
- )}
- ${this._renderNumberInput(
- CONF_LIVE_MICROPHONE_MUTE_AFTER_MICROPHONE_MUTE_SECONDS,
- )}
- `,
- )}
-
- `
- : ''}
- ${this._renderOptionSetHeader('folders')}
- ${this._expandedMenus[MENU_OPTIONS] === 'folders'
- ? html`
- ${SECTION_DOC_LINKS['folders']
- ? this._renderDocLinkRow(SECTION_DOC_LINKS['folders'])
- : ''}
- ${folders.map((_, index) => this._renderFolder(folders, index))}
- ${this._renderFolder(folders, folders.length, true)}
-
`
- : ''}
- ${this._renderOptionSetHeader('media_gallery')}
- ${this._expandedMenus[MENU_OPTIONS] === 'media_gallery'
- ? html`
- ${SECTION_DOC_LINKS['media_gallery']
- ? this._renderDocLinkRow(SECTION_DOC_LINKS['media_gallery'])
- : ''}
- ${this._renderThumbnailsControls(
- MENU_MEDIA_GALLERY_CONTROLS_THUMBNAILS,
- CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SIZE,
- CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS,
- CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
- CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
- CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
- CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
- CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
- this._defaults.media_gallery.controls.thumbnails,
- )}
- ${this._renderFilterControls(
- MENU_MEDIA_GALLERY_CONTROLS_FILTER,
- CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE,
- )}
-
`
- : ''}
- ${this._renderOptionSetHeader('media_viewer')}
- ${this._expandedMenus[MENU_OPTIONS] === 'media_viewer'
- ? html`
- ${SECTION_DOC_LINKS['media_viewer']
- ? this._renderDocLinkRow(SECTION_DOC_LINKS['media_viewer'])
- : ''}
- ${this._renderOptionSelector(
- CONF_MEDIA_VIEWER_AUTO_PLAY,
- this._mediaActionPositiveConditions,
- {
- multiple: true,
- },
- )}
- ${this._renderOptionSelector(
- CONF_MEDIA_VIEWER_AUTO_PAUSE,
- this._mediaActionNegativeConditions,
- {
- multiple: true,
- },
- )}
- ${this._renderOptionSelector(
- CONF_MEDIA_VIEWER_AUTO_MUTE,
- this._mediaActionNegativeConditions,
- {
- multiple: true,
- },
- )}
- ${this._renderOptionSelector(
- CONF_MEDIA_VIEWER_AUTO_UNMUTE,
- this._mediaActionPositiveConditions,
- {
- multiple: true,
- },
- )}
- ${this._renderSwitch(
- CONF_MEDIA_VIEWER_DRAGGABLE,
- this._defaults.media_viewer.draggable,
- )}
- ${this._renderSwitch(
- CONF_MEDIA_VIEWER_ZOOMABLE,
- this._defaults.media_viewer.zoomable,
- )}
- ${this._renderSwitch(
- CONF_MEDIA_VIEWER_LAZY_LOAD,
- this._defaults.media_viewer.lazy_load,
- )}
- ${this._renderOptionSelector(
- CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
- this._transitionEffects,
- )}
- ${this._renderSwitch(
- CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP,
- this._defaults.media_viewer.snapshot_click_plays_clip,
- )}
- ${this._renderViewDisplay(
- MENU_MEDIA_VIEWER_DISPLAY,
- CONF_MEDIA_VIEWER_DISPLAY_MODE,
- CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_POSITION,
- CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR,
- CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS,
- CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS,
- )}
- ${this._putInSubmenu(
- MENU_MEDIA_VIEWER_CONTROLS,
- true,
- 'config.media_viewer.controls.editor_label',
- 'mdi:gamepad',
- html`
- ${this._renderSwitch(
- CONF_MEDIA_VIEWER_CONTROLS_BUILTIN,
- this._defaults.media_viewer.controls.builtin,
- {
- label: localize('config.common.controls.builtin'),
- },
- )}
- ${this._renderSwitch(
- CONF_MEDIA_VIEWER_CONTROLS_WHEEL,
- this._defaults.media_viewer.controls.wheel,
- {
- label: localize('config.common.controls.wheel'),
- },
- )}
- ${this._renderNextPreviousControls(
- MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS,
- CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
- CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
- CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_AUTO_HIDE,
- {
- allowThumbnails: true,
- },
- )}
- ${this._renderThumbnailsControls(
- MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS,
- CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SIZE,
- CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
- CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
- CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
- CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
- CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
- CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
- this._defaults.media_viewer.controls.thumbnails,
- {
- configPathMode: CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
- },
- )}
- ${this._renderMiniTimeline(
- MENU_MEDIA_VIEWER_CONTROLS_TIMELINE,
- MENU_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT,
- CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
- CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
- CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
- CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_CLUSTERING_THRESHOLD,
- CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
- CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_FORMAT_24H,
- this._defaults.media_viewer.controls.timeline.show_recordings,
- this._defaults.media_viewer.controls.timeline.format['24h'],
- CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_PAN_MODE,
- )}
- `,
- )}
-
`
- : ''}
- ${this._renderOptionSetHeader('image')}
- ${this._expandedMenus[MENU_OPTIONS] === 'image'
- ? html`
- ${SECTION_DOC_LINKS['image']
- ? this._renderDocLinkRow(SECTION_DOC_LINKS['image'])
- : ''}
- ${this._renderImageOptions(
- CONF_IMAGE_MODE,
- CONF_IMAGE_URL,
- CONF_IMAGE_ENTITY,
- CONF_IMAGE_ENTITY_PARAMETERS,
- CONF_IMAGE_REFRESH_SECONDS,
- {
- proxyMenu: this._renderProxySubmenu(
- MENU_IMAGE_PROXY,
- true,
- 'config.common.image.proxy.editor_label',
- CONF_IMAGE_PROXY_DYNAMIC,
- this._defaults.image.proxy.dynamic,
- CONF_IMAGE_PROXY_SSL_CIPHERS,
- CONF_IMAGE_PROXY_SSL_VERIFICATION,
- {
- configPathEnabled: CONF_IMAGE_PROXY_ENABLED,
- enabledDefault: this._defaults.image.proxy.enabled,
- },
- ),
- },
- )}
-
`
- : ''}
- ${this._renderOptionSetHeader('timeline')}
- ${this._expandedMenus[MENU_OPTIONS] === 'timeline'
- ? html`
- ${SECTION_DOC_LINKS['timeline']
- ? this._renderDocLinkRow(SECTION_DOC_LINKS['timeline'])
- : ''}
- ${this._renderTimelineCoreControls(
- MENU_TIMELINE_FORMAT,
- CONF_TIMELINE_STYLE,
- CONF_TIMELINE_WINDOW_SECONDS,
- CONF_TIMELINE_CLUSTERING_THRESHOLD,
- CONF_TIMELINE_SHOW_RECORDINGS,
- CONF_TIMELINE_FORMAT_24H,
- this._defaults.timeline.show_recordings,
- this._defaults.timeline.format['24h'],
- )}
- ${this._renderThumbnailsControls(
- MENU_TIMELINE_CONTROLS_THUMBNAILS,
- CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
- CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
- CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
- CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
- CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
- CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_REVIEW_CONTROL,
- CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_INFO_CONTROL,
- this._defaults.timeline.controls.thumbnails,
- {
- configPathMode: CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
- },
- )}
-
`
- : ''}
- ${this._renderOptionSetHeader('dimensions')}
- ${this._expandedMenus[MENU_OPTIONS] === 'dimensions'
- ? html`
- ${SECTION_DOC_LINKS['dimensions']
- ? this._renderDocLinkRow(SECTION_DOC_LINKS['dimensions'])
- : ''}
- ${this._renderOptionSelector(
- CONF_DIMENSIONS_ASPECT_RATIO_MODE,
- this._aspectRatioModes,
- )}
- ${this._renderStringInput(CONF_DIMENSIONS_ASPECT_RATIO)}
- ${this._renderStringInput(CONF_DIMENSIONS_HEIGHT)}
-
`
- : ''}
- ${this._renderOptionSetHeader(
- 'performance',
- getConfigValue(this._config, CONF_PERFORMANCE_PROFILE) === 'low'
- ? 'warning'
- : undefined,
- )}
- ${this._expandedMenus[MENU_OPTIONS] === 'performance'
- ? html`
- ${getConfigValue(this._config, CONF_PERFORMANCE_PROFILE) === 'low'
- ? this._renderInfo(localize('config.performance.warning'))
- : html``}
- ${this._putInSubmenu(
- MENU_PERFORMANCE_FEATURES,
- true,
- 'config.performance.features.editor_label',
- 'mdi:feature-search',
- html`
- ${this._renderSwitch(
- CONF_PERFORMANCE_FEATURES_CARD_LOADING_INDICATOR,
- this._defaults.performance.features.card_loading_indicator,
- )}
- ${this._renderSwitch(
- CONF_PERFORMANCE_FEATURES_CARD_LOADING_EFFECTS,
- this._defaults.performance.features.card_loading_effects,
- )}
- ${this._renderSwitch(
- CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
- this._defaults.performance.features.animated_progress_indicator,
- )}
- ${this._renderNumberInput(CONF_PERFORMANCE_FEATURES_MEDIA_CHUNK_SIZE, {
- max: MEDIA_CHUNK_SIZE_MAX,
- })}
- ${this._renderNumberInput(
- CONF_PERFORMANCE_FEATURES_MAX_SIMULTANEOUS_ENGINE_REQUESTS,
- {
- min: 1,
- },
- )}
- `,
- )}
- ${this._putInSubmenu(
- MENU_PERFORMANCE_STYLE,
- true,
- 'config.performance.style.editor_label',
- 'mdi:palette-swatch-variant',
- html`
- ${this._renderSwitch(
- CONF_PERFORMANCE_STYLE_BORDER_RADIUS,
- this._defaults.performance.style.border_radius,
- )}
- ${this._renderSwitch(
- CONF_PERFORMANCE_STYLE_BOX_SHADOW,
- this._defaults.performance.style.box_shadow,
- )}
- `,
- )}
-
`
- : ''}
- ${this._renderOptionSetHeader('remote_control')}
- ${this._expandedMenus[MENU_OPTIONS] === 'remote_control'
- ? html`
- ${SECTION_DOC_LINKS['remote_control']
- ? this._renderDocLinkRow(SECTION_DOC_LINKS['remote_control'])
- : ''}
- ${this._putInSubmenu(
- MENU_REMOTE_CONTROL_ENTITIES,
- true,
- 'config.remote_control.entities.editor_label',
- 'mdi:devices',
- html`
- ${this._renderEntitySelector(
- CONF_REMOTE_CONTROL_ENTITIES_CAMERA,
- 'input_select',
- )}
- `,
- )}
-
`
- : ''}
- ${this._config['overrides'] !== undefined
- ? html` ${this._renderOptionSetHeader('overrides')}
- ${this._expandedMenus[MENU_OPTIONS] === 'overrides'
- ? html`
- ${this._renderInfo(localize('config.overrides.info'))}
-
`
- : ''}`
- : html``}
-
- ${this._configUpgradeable
- ? html` {
- if (this._config) {
- const upgradedConfig = copyConfig(this._config);
- upgradeConfig(upgradedConfig);
- this._updateConfig(upgradedConfig);
- }
- }}
- >
- ${localize('editor.upgrade')}
- `
- : ''}
- {
- fireAdvancedCameraCardEvent(this, 'editor:diagnostics');
- }}
- >
- ${localize('editor.toggle_diagnostics')}
-
-
+ ${this._renderSelectorSideload()}
+
) =>
+ this._controller.applyIntent(ev.detail)}
+ >
+ ${this._renderNotices()}
+ ${Object.keys(SECTIONS).map((name) => this._renderSection(name, hass, input))}
+ ${this._renderActionButtons()}
`;
}
- /**
- * Close the editor menu with the given domain.
- * @param targetDomain The menu domain to close.
- */
- private _closeMenu(targetDomain: string) {
- delete this._expandedMenus[targetDomain];
- this.requestUpdate();
+ private _renderSection(
+ name: string,
+ 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}
+ >
+ `;
}
- /**
- * Open an editor menu.
- * @param targetDomain The menu domain to open.
- * @param key The menu object key to open.
- */
- private _openMenu(targetDomain: string, key: number | string) {
- this._expandedMenus[targetDomain] = key;
- this.requestUpdate();
+ // Card-state notices rendered as banners above the sections (matching how
+ // native Home Assistant editors surface such notes).
+ private _renderNotices(): TemplateResult {
+ return html`${this._controller
+ .getNotices()
+ .map(
+ (notice) =>
+ html`
${notice.message} `,
+ )}`;
}
- /**
- * Toggle an editor menu.
- * @param ev An event.
- */
- private _toggleMenu(ev: { target: EditorMenuTarget | null }): void {
- if (ev && ev.target) {
- const domain = ev.target.domain;
- const key = ev.target.key;
-
- if (this._expandedMenus[domain] === key) {
- this._closeMenu(domain);
- } else {
- this._openMenu(domain, key);
- }
- }
+ private _renderActionButtons(): TemplateResult {
+ return html`
+
+ ${this._controller.isConfigUpgradeable()
+ ? html` this._controller.upgrade()}
+ >
+ ${localize('editor.upgrade')}
+ `
+ : ''}
+ {
+ fireAdvancedCameraCardEvent(this, 'editor:diagnostics');
+ }}
+ >
+ ${localize('editor.toggle_diagnostics')}
+
+
+ `;
}
- /**
- * Handle a changed option value.
- * @param ev Event triggering the change.
- */
- private _valueChangedHandler(key: string, ev: CustomEvent<{ value: unknown }>): void {
- if (!this._config || !this.hass) {
- return;
- }
-
- let value;
- if (ev.detail && ev.detail.value !== undefined) {
- value = ev.detail.value;
- if (typeof value === 'string') {
- value = value.trim();
- }
- }
- if (getConfigValue(this._config, key) === value) {
- return;
- }
-
- const newConfig = copyConfig(this._config);
- if (value === '' || value === undefined) {
- deleteConfigValue(newConfig, key);
- } else {
- setConfigValue(newConfig, key, value);
- }
- this._updateConfig(newConfig);
+ // `ha-form` and `ha-selector` lazily `import()` their per-type sub-elements
+ // on first use. A section rendered before those imports resolve is not laid
+ // out in time for its `ha-expansion-panel` to measure the correct height, so
+ // a hidden form covering every element type the editor uses is rendered up
+ // front to resolve the imports before the user opens anything.
+ private _renderSelectorSideload(): TemplateResult {
+ return html`
+
+ `;
}
static get styles(): CSSResultGroup {
diff --git a/src/ha/custom-icons.ts b/src/ha/custom-icons.ts
new file mode 100644
index 00000000..b02d8685
--- /dev/null
+++ b/src/ha/custom-icons.ts
@@ -0,0 +1,64 @@
+// Registers the card's custom icons as a Home Assistant custom iconset, so
+// any `ha-icon` (and the icon picker) can render `advanced-camera-card:
`
+// icons -- inside or outside the card. The iconset format supports a single
+// monochrome SVG path per icon, so each asset is a single-path silhouette,
+// with its path data extracted at build time.
+
+import frigateIcon from '../images/icons/frigate.svg';
+import irisIcon from '../images/icons/iris.svg';
+import motioneyeIcon from '../images/icons/motioneye.svg';
+import reolinkIcon from '../images/icons/reolink.svg';
+import tplinkIcon from '../images/icons/tplink.svg';
+
+interface CustomIconsetIcon {
+ path: string;
+ viewBox?: string;
+}
+
+interface CustomIconsetListItem {
+ name: string;
+}
+
+interface CustomIconsetHelpers {
+ getIcon: (name: string) => Promise;
+ getIconList: () => Promise;
+}
+
+declare global {
+ interface Window {
+ customIcons?: Record;
+ }
+}
+
+export const CUSTOM_ICONSET_PREFIX = 'advanced-camera-card';
+
+const ICONS: Record = {
+ frigate: frigateIcon,
+ iris: irisIcon,
+ motioneye: motioneyeIcon,
+ reolink: reolinkIcon,
+ tplink: tplinkIcon,
+};
+
+export const CUSTOM_ICON_NAMES = Object.keys(ICONS);
+
+const getIcon = async (name: string): Promise => {
+ const icon: CustomIconsetIcon | undefined = ICONS[name];
+ if (!icon) {
+ throw new Error(`Unknown icon: ${CUSTOM_ICONSET_PREFIX}:${name}`);
+ }
+ return icon;
+};
+
+const getIconList = async (): Promise =>
+ CUSTOM_ICON_NAMES.map((name) => ({ name }));
+
+/**
+ * Register the card's custom iconset with Home Assistant. Safe to call more
+ * than once; an existing registration (e.g. from a second loaded copy of the
+ * card) is left in place.
+ */
+export const registerCustomIconset = (): void => {
+ window.customIcons ??= {};
+ window.customIcons[CUSTOM_ICONSET_PREFIX] ??= { getIcon, getIconList };
+};
diff --git a/src/ha/side-load-ha-elements.ts b/src/ha/side-load-ha-elements.ts
index cd6dfcae..77664cbe 100644
--- a/src/ha/side-load-ha-elements.ts
+++ b/src/ha/side-load-ha-elements.ts
@@ -18,17 +18,24 @@ class HomeAssistantElementsLoadError extends AdvancedCameraCardError {
*/
export const sideLoadHomeAssistantElements = async (): Promise => {
const neededElements = [
+ 'ha-alert',
'ha-button',
'ha-camera-stream',
'ha-card',
'ha-combo-box',
'ha-dropdown-item',
'ha-dropdown',
+ 'ha-expansion-panel',
+ 'ha-form',
'ha-hls-player',
+ 'ha-icon-button-prev',
'ha-icon-button',
'ha-icon',
+ 'ha-md-list-item',
+ 'ha-md-list',
'ha-menu-button',
'ha-selector',
+ 'ha-sortable',
'ha-spinner',
'ha-state-icon',
'ha-web-rtc-player',
diff --git a/src/ha/types.ts b/src/ha/types.ts
index a85c4f5e..147b66f2 100644
--- a/src/ha/types.ts
+++ b/src/ha/types.ts
@@ -254,3 +254,111 @@ export const resolvedMediaSchema = z.object({
mime_type: z.string(),
});
export type ResolvedMedia = z.infer;
+
+// *************************************************************************
+// Home Assistant form types.
+// *************************************************************************
+// Home Assistant does not publish frontend types as a package, so the subset
+// of the `ha-form` schema and `ha-selector` configuration shapes used by this
+// card are declared here. They must remain structurally compatible with:
+// https://github.com/home-assistant/frontend/blob/dev/src/components/ha-form/types.ts
+// https://github.com/home-assistant/frontend/blob/dev/src/data/selector.ts
+
+interface HAEntitySelector {
+ entity: {
+ domain?: string;
+ multiple?: boolean;
+ };
+}
+
+export interface HASelectSelectorOption {
+ value: unknown;
+ label: string;
+}
+
+export interface HASelectSelector {
+ select: {
+ mode?: 'dropdown' | 'list';
+ multiple?: boolean;
+ custom_value?: boolean;
+ options: string[] | HASelectSelectorOption[];
+ };
+}
+
+interface HAStringSelector {
+ text: {
+ type?: string;
+ };
+}
+
+export interface HANumberSelector {
+ number: {
+ min?: number;
+ max?: number;
+ mode?: 'box' | 'slider';
+ step?: number;
+ };
+}
+
+interface HABooleanSelector {
+ boolean: Record;
+}
+
+interface HAIconSelector {
+ icon: Record;
+}
+
+interface HAObjectSelector {
+ object: Record;
+}
+
+type HASelector =
+ | HABooleanSelector
+ | HAEntitySelector
+ | HAIconSelector
+ | HANumberSelector
+ | HAObjectSelector
+ | HASelectSelector
+ | HAStringSelector;
+
+interface StockHAFormBaseSchema {
+ name: string;
+}
+
+interface StockHAFormSelectorSchema extends StockHAFormBaseSchema {
+ selector: HASelector;
+}
+
+interface StockHAFormExpandableSchema extends StockHAFormBaseSchema {
+ type: 'expandable';
+ title?: string;
+ icon?: 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
+ // `config.common.*` rather than duplicating keys under each section.
+ label?: string;
+}
+
+export interface HAFormSelectorSchema
+ extends StockHAFormSelectorSchema,
+ CardHAFormSchemaExtensions {}
+
+export interface HAFormExpandableSchema
+ extends Omit,
+ CardHAFormSchemaExtensions {
+ // hass-frontend requires `name`, but `ha-form`'s runtime treats a missing name
+ // as a flattened, visual-only group (the `!item.name` paths in ha-form.ts).
+ // Relaxed to optional here for nameless grouping, e.g. an "Engine" group over
+ // the top-level `frigate`/`motioneye` camera keys.
+ name?: string;
+
+ // Explicit documentation-link key, for a nameless group whose link cannot be
+ // derived from a configuration path.
+ docPath?: string;
+}
+
+export type HAFormSchema = HAFormSelectorSchema | HAFormExpandableSchema;
diff --git a/src/images/README.md b/src/images/README.md
index 98289132..d3cbdda6 100644
--- a/src/images/README.md
+++ b/src/images/README.md
@@ -19,7 +19,7 @@
$ convert -strip -interlace Plane -quality 85% -scale 492x277 iris-screensaver-original.jpg iris-screensaver.jpg
```
-## iris.svg / iris-outline.svg
+## icons/iris.svg / iris-background.svg
**Link**: https://pictogrammers.com/library/mdi/icon/camera-iris/
@@ -27,8 +27,13 @@ $ convert -strip -interlace Plane -quality 85% -scale 492x277 iris-screensaver-o
**Image Formatting Process**:
-- Outline addded manually
-- Opacity adjustmented manually
+- `icons/iris.svg`: single-path variant for the card's custom iconset
+- `iris-background.svg`: outline / opacity adjusted manually
+
+## icons/{frigate,motioneye,reolink,tplink}.svg
+
+**Description**: Vendor logos, flattened to single-path monochrome silhouettes
+for the card's custom iconset.
## check-circle.svg
diff --git a/src/images/icons/frigate.svg b/src/images/icons/frigate.svg
new file mode 100644
index 00000000..86a024e6
--- /dev/null
+++ b/src/images/icons/frigate.svg
@@ -0,0 +1 @@
+
diff --git a/src/images/icons/iris.svg b/src/images/icons/iris.svg
new file mode 100644
index 00000000..d1b3da1c
--- /dev/null
+++ b/src/images/icons/iris.svg
@@ -0,0 +1 @@
+
diff --git a/src/images/icons/motioneye.svg b/src/images/icons/motioneye.svg
new file mode 100644
index 00000000..4b321aca
--- /dev/null
+++ b/src/images/icons/motioneye.svg
@@ -0,0 +1 @@
+
diff --git a/src/images/icons/reolink.svg b/src/images/icons/reolink.svg
new file mode 100644
index 00000000..88447c17
--- /dev/null
+++ b/src/images/icons/reolink.svg
@@ -0,0 +1 @@
+
diff --git a/src/images/icons/tplink.svg b/src/images/icons/tplink.svg
new file mode 100644
index 00000000..0227968b
--- /dev/null
+++ b/src/images/icons/tplink.svg
@@ -0,0 +1 @@
+
diff --git a/src/images/iris.svg b/src/images/iris.svg
deleted file mode 100644
index 0616d097..00000000
--- a/src/images/iris.svg
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json
index e27dabbd..b7fb9833 100644
--- a/src/localize/languages/ca.json
+++ b/src/localize/languages/ca.json
@@ -129,6 +129,29 @@
"thumbnails": "Miniatures"
}
},
+ "ptz": {
+ "editor_label": "PTZ",
+ "hide_home": "Amaga el control de casa",
+ "hide_pan_tilt": "Amaga el control d'escombratge i inclinació",
+ "hide_zoom": "Amaga el control de zoom",
+ "mode": "Mode",
+ "modes": {
+ "off": "Desactivat",
+ "on": "Activat"
+ },
+ "orientation": "Orientació",
+ "orientations": {
+ "horizontal": "Horitzontal",
+ "vertical": "Vertical"
+ },
+ "position": "Posició",
+ "positions": {
+ "bottom-left": "Inferior esquerra",
+ "bottom-right": "Inferior dreta",
+ "top-left": "Superior left",
+ "top-right": "Superior dreta"
+ }
+ },
"thumbnails": {
"editor_label": "Miniatures",
"mode": "Mode de miniatures",
@@ -204,6 +227,10 @@
"stack": "Esdeveniments apilats i agrupats"
},
"window_seconds": "La durada predeterminada de la visualització de la lÃnia de temps en segons"
+ },
+ "transition_effects": {
+ "none": "Sense transició",
+ "slide": "Transició de diapositives"
}
},
"dimensions": {
@@ -221,30 +248,7 @@
"auto_play": "Reprodueix automà ticament les cà meres en directe",
"auto_unmute": "Activa automà ticament el so de les cà meres en directe",
"controls": {
- "editor_label": "Controls del directe",
- "ptz": {
- "editor_label": "PTZ",
- "hide_home": "Amaga el control de casa",
- "hide_pan_tilt": "Amaga el control d'escombratge i inclinació",
- "hide_zoom": "Amaga el control de zoom",
- "mode": "Mode",
- "modes": {
- "off": "Desactivat",
- "on": "Activat"
- },
- "orientation": "Orientació",
- "orientations": {
- "horizontal": "Horitzontal",
- "vertical": "Vertical"
- },
- "position": "Posició",
- "positions": {
- "bottom-left": "Inferior esquerra",
- "bottom-right": "Inferior dreta",
- "top-left": "Superior left",
- "top-right": "Superior dreta"
- }
- }
+ "editor_label": "Controls del directe"
},
"draggable": "La vista de cà meres en directe es pot arrossegar/lliscar",
"lazy_load": "Les cà meres en directe es carreguen amb mandra",
@@ -272,10 +276,6 @@
"lazy_load": "El contingut multimèdia del visualitzador multimèdia es carreguen amb mandra al carrusel",
"snapshot_click_plays_clip": "Si feu clic a una instantà nia, es reprodueix un clip relacionat",
"transition_effect": "Efecte de transició del visualitzador multimèdia",
- "transition_effects": {
- "none": "Sense transició",
- "slide": "Transició de diapositives"
- },
"zoomable": "El visualitzador de mitjans es pot ampliar/escombrar"
},
"menu": {
diff --git a/src/localize/languages/de.json b/src/localize/languages/de.json
index 5a3c0df0..a9247c7d 100644
--- a/src/localize/languages/de.json
+++ b/src/localize/languages/de.json
@@ -97,7 +97,29 @@
"common": {
"controls": {
"builtin": "Eingebettete Video Bedienelemente",
- "thumbnails": {},
+ "ptz": {
+ "editor_label": "PTZ",
+ "hide_home": "Verstecke Start & Vorlagen Bedienung",
+ "hide_pan_tilt": "Verstecke Pan & Tilt Steuerung",
+ "hide_zoom": "Verstecke Zoom-Steuerung",
+ "mode": "Modus",
+ "modes": {
+ "off": "Aus",
+ "on": "An"
+ },
+ "orientation": "Ausrichtung",
+ "orientations": {
+ "horizontal": "Horizontal",
+ "vertical": "Vertikal"
+ },
+ "position": "Position",
+ "positions": {
+ "bottom-left": "Unten links",
+ "bottom-right": "Oben rechts",
+ "top-left": "Unten links",
+ "top-right": "Oben rechts"
+ }
+ },
"timeline": {
"format": {
"24h": "24h Uhr",
@@ -191,31 +213,6 @@
}
},
"live": {
- "controls": {
- "ptz": {
- "editor_label": "PTZ",
- "hide_home": "Verstecke Start & Vorlagen Bedienung",
- "hide_pan_tilt": "Verstecke Pan & Tilt Steuerung",
- "hide_zoom": "Verstecke Zoom-Steuerung",
- "mode": "Modus",
- "modes": {
- "off": "Aus",
- "on": "An"
- },
- "orientation": "Ausrichtung",
- "orientations": {
- "horizontal": "Horizontal",
- "vertical": "Vertikal"
- },
- "position": "Position",
- "positions": {
- "bottom-left": "Unten links",
- "bottom-right": "Oben rechts",
- "top-left": "Unten links",
- "top-right": "Oben rechts"
- }
- }
- },
"microphone": {
"always_connected": "Mikrofon immer verbunden lassen",
"disconnect_seconds": "Sekunden nach denen das Mikrofon getrennt wird (0=niemals)",
diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json
index ff58faf9..c895bdd9 100644
--- a/src/localize/languages/en.json
+++ b/src/localize/languages/en.json
@@ -46,15 +46,16 @@
},
"disable": "Disable",
"disable_except": "Disable except",
- "editor_label": "Camera capabilities",
+ "editor_label": "Capabilities",
"force": "Force"
},
"cast": {
"dashboard": {
"dashboard_path": "Dashboard path",
+ "editor_label": "Dashboard",
"view_path": "View path"
},
- "editor_label": "Cast options",
+ "editor_label": "Casting",
"method": "Cast method",
"methods": {
"dashboard": "Dashboard",
@@ -64,11 +65,11 @@
"dependencies": {
"all_cameras": "Show events for all cameras with this camera",
"cameras": "Show events for specific cameras with this camera",
- "editor_label": "Dependency options"
+ "editor_label": "Dependencies"
},
"dimensions": {
"aspect_ratio": "Static aspect ratio",
- "editor_label": "Camera dimensions",
+ "editor_label": "Dimensions",
"layout": {
"editor_label": "Camera Layout",
"fit": "Layout fit",
@@ -78,6 +79,7 @@
"fill": "Media is stretched to fill the card"
},
"pan": {
+ "editor_label": "Layout Pan",
"x": "Pan horizontal percentage",
"y": "Pan vertical percentage"
},
@@ -103,19 +105,26 @@
"90": "90 degrees clockwise"
}
},
+ "engine": "Camera engine",
"engines": {
- "editor_label": "Camera engine options"
+ "auto": "Automatic",
+ "editor_label": "Engine",
+ "frigate": "Frigate",
+ "generic": "Generic",
+ "motioneye": "MotionEye",
+ "reolink": "Reolink",
+ "tplink": "TP-Link"
},
"frigate": {
"camera_name": "Frigate camera name (autodetected from entity)",
"client_id": "Frigate client id (for >1 Frigate server)",
- "editor_label": "Frigate options",
+ "editor_label": "Frigate",
"labels": "Frigate labels/object filters",
"url": "Frigate server URL",
"zones": "Frigate zones"
},
"go2rtc": {
- "editor_label": "go2rtc Options",
+ "editor_label": "go2rtc",
"metadata_fetch_timeout_seconds": "Metadata fetch timeout in seconds",
"modes": {
"editor_label": "go2rtc Modes",
@@ -130,13 +139,13 @@
"icon": "Icon for this camera (autodetected from entity)",
"id": "Unique ID for this camera in this card",
"image": {
- "editor_label": "Image Options",
+ "editor_label": "Image",
"refresh_seconds": "Seconds between live image refreshes (0=never)",
"url": "Image URL to use instead of camera entity snapshot"
},
"live_provider": "Live view provider for this camera",
"live_provider_options": {
- "editor_label": "Live provider options"
+ "editor_label": "Live Provider"
},
"live_providers": {
"auto": "Automatic",
@@ -148,7 +157,7 @@
"webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)"
},
"media": {
- "editor_label": "Default Media",
+ "editor_label": "Media",
"events_type": "Events media subtype",
"folders": "Folder IDs",
"reviewed": "Review status filter",
@@ -160,24 +169,26 @@
"type": "Default media type"
},
"motioneye": {
- "editor_label": "MotionEye options",
+ "editor_label": "MotionEye",
"images": {
"directory_pattern": "Images directory pattern",
+ "editor_label": "Images",
"file_pattern": "Images file pattern"
},
"movies": {
"directory_pattern": "Movies directory pattern",
+ "editor_label": "Movies",
"file_pattern": "Movies file pattern"
},
"url": "MotionEye UI URL"
},
"proxy": {
- "editor_label": "Camera proxying",
+ "editor_label": "Proxying",
"live": "Live proxying",
"media": "Media proxying"
},
"reolink": {
- "editor_label": "Reolink options",
+ "editor_label": "Reolink",
"media_resolution": {
"editor_label": "Media Resolution",
"high": "High Resolution",
@@ -187,15 +198,15 @@
},
"title": "Title for this camera (autodetected from entity)",
"triggers": {
- "editor_label": "Trigger options",
+ "doorbell": "Trigger by auto-detecting doorbell event entities",
+ "editor_label": "Triggers",
"entities": "Trigger from other entities",
"events": {
- "add_new_event": "Add new event trigger",
+ "add_new_event": "Add event trigger",
"editor_label": "Home Assistant Events",
"event_data": "Event data filter",
"event_type": "Event type"
},
- "doorbell": "Trigger by auto-detecting doorbell event entities",
"media_events": {
"clips": "Events with new clips",
"editor_label": "Trigger Media Events",
@@ -210,7 +221,7 @@
}
},
"webrtc_card": {
- "editor_label": "WebRTC Card Options",
+ "editor_label": "WebRTC Card",
"entity": "WebRTC Card Camera Entity",
"url": "WebRTC Card Camera URL"
}
@@ -243,6 +254,36 @@
"thumbnails": "Thumbnails"
}
},
+ "ptz": {
+ "editor_label": "PTZ",
+ "hide_home": "Hide home & preset controls",
+ "hide_pan_tilt": "Hide pan & tilt control",
+ "hide_type": "Hide type toggle button",
+ "hide_zoom": "Hide zoom control",
+ "mode": "Mode",
+ "modes": {
+ "auto": "Automatic",
+ "off": "Off",
+ "on": "On"
+ },
+ "orientation": "Orientation",
+ "orientations": {
+ "horizontal": "Horizontal",
+ "vertical": "Vertical"
+ },
+ "position": "Position",
+ "positions": {
+ "bottom-left": "Bottom left",
+ "bottom-right": "Bottom right",
+ "top-left": "Top left",
+ "top-right": "Top right"
+ },
+ "type": "Default type",
+ "types": {
+ "buttons": "Buttons",
+ "gestures": "Gestures"
+ }
+ },
"thumbnails": {
"editor_label": "Thumbnails",
"mode": "Thumbnails mode",
@@ -306,6 +347,7 @@
"entity_parameters": "Query parameters added to the entity-based picture URLs (e.g. width=1920&height=1080)",
"mode": "Image mode",
"modes": {
+ "auto": "Automatically chosen based on configuration",
"camera": "Home Assistant camera snapshot of camera entity",
"default": "Default embedded image",
"entity": "Entity with entity_picture attribute",
@@ -371,6 +413,10 @@
"stack": "Stacked & clustered events"
},
"window_seconds": "Default timeline window length in seconds"
+ },
+ "transition_effects": {
+ "none": "No transition",
+ "slide": "Slide transition"
}
},
"dimensions": {
@@ -397,6 +443,9 @@
"ha": "Home Assistant Media Folder"
}
},
+ "image": {
+ "zoomable": "Whether the image can be zoomed and panned"
+ },
"live": {
"auto_mute": "Automatically mute live cameras",
"auto_pause": "Automatically pause live cameras",
@@ -413,6 +462,7 @@
"mute_microphone": "Mute microphone",
"reject": "Reject call",
"ringtone": {
+ "editor_label": "Ringtone",
"repeat": "Ringtone repeats per inbound call (0=indefinite)",
"type": "Ringtone for inbound calls",
"types": {
@@ -430,36 +480,7 @@
"unmute_audio": "Unmute audio",
"unmute_microphone": "Unmute microphone"
},
- "editor_label": "Live Controls",
- "ptz": {
- "editor_label": "PTZ",
- "hide_home": "Hide home & preset controls",
- "hide_pan_tilt": "Hide pan & tilt control",
- "hide_type": "Hide type toggle button",
- "hide_zoom": "Hide zoom control",
- "mode": "Mode",
- "modes": {
- "off": "Off",
- "on": "On"
- },
- "orientation": "Orientation",
- "orientations": {
- "horizontal": "Horizontal",
- "vertical": "Vertical"
- },
- "position": "Position",
- "positions": {
- "bottom-left": "Bottom left",
- "bottom-right": "Bottom right",
- "top-left": "Top left",
- "top-right": "Top right"
- },
- "type": "Default type",
- "types": {
- "buttons": "Buttons",
- "gestures": "Gestures"
- }
- }
+ "editor_label": "Live Controls"
},
"draggable": "Live cameras view can be dragged/swiped",
"lazy_load": "Live cameras are lazily loaded",
@@ -489,10 +510,6 @@
"lazy_load": "Media Viewer media is lazily loaded in carousel",
"snapshot_click_plays_clip": "Clicking on a snapshot plays a related clip",
"transition_effect": "Media Viewer transition effect",
- "transition_effects": {
- "none": "No transition",
- "slide": "Slide transition"
- },
"zoomable": "Media Viewer can be zoomed/panned"
},
"menu": {
@@ -524,6 +541,7 @@
"gallery": "Gallery",
"icon": "Icon",
"image": "Image",
+ "inert": "Button non-interactive",
"info": "Info",
"iris": "Iris / Default View / Unhide menu",
"live": "Live",
@@ -542,6 +560,7 @@
"screenshot": "Screenshot",
"set_review": "Set review status",
"snapshots": "Snapshots",
+ "state_color": "Color button by entity state",
"substreams": "Substream(s)",
"timeline": "Timeline",
"type": "Button type",
@@ -568,7 +587,7 @@
}
},
"overrides": {
- "info": "This card has manually specified overrides that may override values shown in the visual editor. Consult the code editor to view or modify them"
+ "info": "Conditional overrides are active and may supersede values shown here. Edit them in the code editor"
},
"performance": {
"features": {
@@ -584,7 +603,7 @@
"box_shadow": "Shadows",
"editor_label": "Style Options"
},
- "warning": "This card is in low profile mode, so defaults have changed to optimize performance"
+ "warning": "Low performance profile is active: defaults are optimized to reduce load"
},
"profiles": {
"casting": "Casting",
@@ -596,6 +615,11 @@
"remote_control": {
"entities": {
"camera": "Input Select entity to control camera",
+ "camera_priorities": {
+ "card": "Card",
+ "entity": "Entity"
+ },
+ "camera_priority": "Which camera selection takes priority",
"editor_label": "Remote Control Entities"
}
},
@@ -606,6 +630,7 @@
"enabled": "Item enabled",
"engine": "Camera engine",
"issues": "Issues",
+ "permanent": "Item keeps status bar shown",
"priority": "Item priority",
"resolution": "Resolution",
"severity": "Severity",
@@ -676,7 +701,8 @@
"default": "Change to or update default view",
"live": "Change to or update live view",
"media": "Change to the relevant media view for new media",
- "none": "No action"
+ "none": "No action",
+ "update": "Update the current view"
},
"untrigger": "Untrigger action",
"untriggers": {
@@ -686,9 +712,9 @@
}
},
"editor_label": "Trigger behavior",
+ "event_hold_seconds": "Seconds to hold a momentary trigger (e.g. a doorbell press, an HA bus event) visible before the post-end untrigger delay",
"filter_selected_camera": "Only trigger on selected camera",
"show_trigger_status": "Show pulsing border when triggered",
- "event_hold_seconds": "Seconds to hold a momentary trigger (e.g. a doorbell press, an HA bus event) visible before the post-end untrigger delay",
"untrigger_delay_seconds": "Seconds delay after trigger state change before untrigger",
"untrigger_force_seconds": "Seconds before forced untrigger"
},
@@ -697,6 +723,7 @@
"clip": "Most recent clip",
"clips": "Clips gallery",
"current": "Current view",
+ "diagnostics": "Diagnostics",
"folder": "Folder media",
"folders": "Folders gallery",
"gallery": "Default media gallery",
@@ -718,13 +745,15 @@
"single": "Show single media viewer"
},
"editor": {
- "add_new_camera": "Add new camera",
- "add_new_folder": "Add new folder",
+ "add_new_camera": "Add camera",
+ "back": "Back",
+ "edit": "Edit",
+ "add_new_folder": "Add folder",
"camera": "Camera",
"cameras": "Cameras",
"cameras_secondary": "What cameras to render on this card",
"delete": "Delete",
- "dimensions": "Card dimensions",
+ "dimensions": "Dimensions",
"dimensions_secondary": "Card dimensions & shape options",
"docs": "Documentation",
"folders": "Folders",
@@ -736,16 +765,14 @@
"media_gallery": "Media gallery",
"media_gallery_secondary": "Media gallery options",
"media_viewer": "Media viewer",
- "media_viewer_secondary": "Viewer for static media (clips, snapshots or recordings)",
+ "media_viewer_secondary": "Viewer for static media",
"menu": "Menu",
"menu_secondary": "Menu look & feel options",
"move_down": "Move down",
"move_up": "Move up",
- "overrides": "Overrides are active",
- "overrides_secondary": "Dynamic configuration overrides detected",
"performance": "Performance",
"performance_secondary": "Card performance options",
- "profiles": "Configuration profiles",
+ "profiles": "Profiles",
"profiles_secondary": "Choose pre-configured sets of defaults",
"remote_control": "Remote Control",
"remote_control_secondary": "Options for remote controlling the card",
@@ -775,11 +802,11 @@
"error": {
"awaiting_live": "Waiting for live stream to load...",
"awaiting_media": "Waiting for media to load",
- "call_unavailable_heading": "Two-way audio unavailable",
"call_invalid_target": "The requested camera or stream is not available to call.",
"call_microphone_forbidden": "Microphone access has been denied for this page. Update your browser permissions and try again.",
"call_microphone_unsupported": "Microphone access is not available in this browser (e.g. requires HTTPS).",
"call_no_two_way_audio": "This camera does not support two-way audio.",
+ "call_unavailable_heading": "Two-way audio unavailable",
"camera_initialization": "Camera initialization failed",
"camera_initialization_reolink": "Could not initialize Reolink camera",
"configuration_error": "Configuration error",
@@ -828,7 +855,7 @@
},
"config_upgrade": {
"heading": "Configuration upgrade available",
- "text": "A configuration upgrade is available. To upgrade, edit this card (Dashboard pencil icon \u2192 Three-dot menu \u2192 Edit) and click the 'Automatic Upgrade' button in the card editor"
+ "text": "A configuration upgrade is available. To upgrade, edit this card (Dashboard pencil icon → Three-dot menu → Edit) and click the 'Automatic Upgrade' button in the card editor"
},
"config_upgrade_failure": {
"heading": "Configuration could not be fully upgraded",
diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json
index 070da092..0ff84c27 100644
--- a/src/localize/languages/fr.json
+++ b/src/localize/languages/fr.json
@@ -178,6 +178,29 @@
"thumbnails": "Vignettes"
}
},
+ "ptz": {
+ "editor_label": "PTZ",
+ "hide_home": "Cacher le bouton position d'origine",
+ "hide_pan_tilt": "Cacher les boutons panoramiques/inclinaison",
+ "hide_zoom": "Cacher le bouton de zoom",
+ "mode": "Mode",
+ "modes": {
+ "off": "Désactivé",
+ "on": "Activé"
+ },
+ "orientation": "Orientation",
+ "orientations": {
+ "horizontal": "Horizontale",
+ "vertical": "Verticale"
+ },
+ "position": "Position",
+ "positions": {
+ "bottom-left": "En bas à gauche",
+ "bottom-right": "En bas à droite",
+ "top-left": "En haut à gauche",
+ "top-right": "En haut à droite"
+ }
+ },
"thumbnails": {
"editor_label": "Vignettes",
"mode": "Mode vignettes",
@@ -284,6 +307,10 @@
"stack": "Événements empilés et groupés"
},
"window_seconds": "Durée par défaut de la vue chronologique en secondes"
+ },
+ "transition_effects": {
+ "none": "Pas de transition",
+ "slide": "Transition de diapositive"
}
},
"dimensions": {
@@ -302,30 +329,7 @@
"auto_play": "Lire automatiquement les caméras en direct",
"auto_unmute": "Activer automatiquement le son des caméras en direct",
"controls": {
- "editor_label": "Contrôles en direct",
- "ptz": {
- "editor_label": "PTZ",
- "hide_home": "Cacher le bouton position d'origine",
- "hide_pan_tilt": "Cacher les boutons panoramiques/inclinaison",
- "hide_zoom": "Cacher le bouton de zoom",
- "mode": "Mode",
- "modes": {
- "off": "Désactivé",
- "on": "Activé"
- },
- "orientation": "Orientation",
- "orientations": {
- "horizontal": "Horizontale",
- "vertical": "Verticale"
- },
- "position": "Position",
- "positions": {
- "bottom-left": "En bas à gauche",
- "bottom-right": "En bas à droite",
- "top-left": "En haut à gauche",
- "top-right": "En haut à droite"
- }
- }
+ "editor_label": "Contrôles en direct"
},
"draggable": "La vue des caméras en direct peut être déplacée/glissée",
"lazy_load": "Les caméras en direct sont chargeés en différé",
@@ -353,10 +357,6 @@
"lazy_load": "Les médias Media Viewer sont chargés en différé dans le carrousel",
"snapshot_click_plays_clip": "Cliquer sur un instantané lit l'extrait associé",
"transition_effect": "Effet de transition de la visionneuse multimédia",
- "transition_effects": {
- "none": "Pas de transition",
- "slide": "Transition de diapositive"
- },
"zoomable": "La visionneuse multimédia peut être zoomée/panoramique"
},
"menu": {
diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json
index 6e0deed0..ecffb746 100644
--- a/src/localize/languages/it.json
+++ b/src/localize/languages/it.json
@@ -163,6 +163,10 @@
"clustering_threshold": "Il conteggio degli eventi in cui sono raggruppati (0 = nessun clustering)",
"show_recordings": "Mostra registrazioni",
"window_seconds": "La lunghezza predefinita della vista della sequenza temporale in secondi"
+ },
+ "transition_effects": {
+ "none": "Nessuna transizione",
+ "slide": "Transizione diapositiva"
}
},
"dimensions": {
@@ -199,11 +203,7 @@
},
"draggable": "Il visualizzatore multimediale può essere trascinato oppure può scorrere",
"lazy_load": "Il media Viewer viene caricato pigramente nel carosello",
- "transition_effect": "Effetto di transizione del visualizzatore multimediale",
- "transition_effects": {
- "none": "Nessuna transizione",
- "slide": "Transizione diapositiva"
- }
+ "transition_effect": "Effetto di transizione del visualizzatore multimediale"
},
"menu": {
"alignment": "Allineamento dei menu",
diff --git a/src/localize/languages/pl.json b/src/localize/languages/pl.json
index d09ac778..86da45cd 100644
--- a/src/localize/languages/pl.json
+++ b/src/localize/languages/pl.json
@@ -195,6 +195,29 @@
"thumbnails": "Miniatury"
}
},
+ "ptz": {
+ "editor_label": "PTZ",
+ "hide_home": "Ukryj przyciski Start i presetów",
+ "hide_pan_tilt": "Ukryj sterowanie obrotem (Pan & Tilt)",
+ "hide_zoom": "Ukryj sterowanie przybliżeniem (Zoom)",
+ "mode": "Tryb",
+ "modes": {
+ "off": "Wyłączony",
+ "on": "Włączony"
+ },
+ "orientation": "Orientacja",
+ "orientations": {
+ "horizontal": "Pozioma",
+ "vertical": "Pionowa"
+ },
+ "position": "Pozycja",
+ "positions": {
+ "bottom-left": "Dół, lewo",
+ "bottom-right": "Dół, prawo",
+ "top-left": "Góra, lewo",
+ "top-right": "Góra, prawo"
+ }
+ },
"thumbnails": {
"editor_label": "Miniatury",
"events_media_types": {
@@ -312,6 +335,10 @@
"stack": "Zdarzenia ułożone w stos i grupowane"
},
"window_seconds": "Domyślna długość widoku osi czasu (sekundy)"
+ },
+ "transition_effects": {
+ "none": "Brak przejścia",
+ "slide": "Przesunięcie (Slide)"
}
},
"dimensions": {
@@ -344,30 +371,7 @@
"auto_play": "Automatycznie odtwarzaj kamery na żywo",
"auto_unmute": "Automatycznie włącz dźwięk kamer na żywo",
"controls": {
- "editor_label": "Sterowanie na żywo",
- "ptz": {
- "editor_label": "PTZ",
- "hide_home": "Ukryj przyciski Start i presetów",
- "hide_pan_tilt": "Ukryj sterowanie obrotem (Pan & Tilt)",
- "hide_zoom": "Ukryj sterowanie przybliżeniem (Zoom)",
- "mode": "Tryb",
- "modes": {
- "off": "Wyłączony",
- "on": "Włączony"
- },
- "orientation": "Orientacja",
- "orientations": {
- "horizontal": "Pozioma",
- "vertical": "Pionowa"
- },
- "position": "Pozycja",
- "positions": {
- "bottom-left": "Dół, lewo",
- "bottom-right": "Dół, prawo",
- "top-left": "Góra, lewo",
- "top-right": "Góra, prawo"
- }
- }
+ "editor_label": "Sterowanie na żywo"
},
"draggable": "Widok kamer można przeciągać",
"lazy_load": "Kamery ładowane leniwie (na żądanie)",
@@ -395,10 +399,6 @@
"lazy_load": "Media ładowane leniwie w karuzeli",
"snapshot_click_plays_clip": "Kliknięcie w zrzut odtwarza powiązany klip",
"transition_effect": "Efekt przejścia przeglądarki mediów",
- "transition_effects": {
- "none": "Brak przejścia",
- "slide": "Przesunięcie (Slide)"
- },
"zoomable": "Przeglądarkę mediów można przybliżać/przesuwać"
},
"menu": {
diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json
index 99a4bbff..6b1288af 100644
--- a/src/localize/languages/pt-BR.json
+++ b/src/localize/languages/pt-BR.json
@@ -163,6 +163,10 @@
"clustering_threshold": "A contagem de eventos nos quais eles são agrupados (0 = sem agrupamento)",
"show_recordings": "Mostrar gravações",
"window_seconds": "A duração padrão da visualização da linha do tempo em segundos"
+ },
+ "transition_effects": {
+ "none": "Sem transição",
+ "slide": "Transição de slides"
}
},
"dimensions": {
@@ -200,11 +204,7 @@
"draggable": "Visualizador de eventos pode ser arrastado/deslizado",
"lazy_load": "A mÃdia do Visualizador de eventos é carregada lentamente no carrossel",
"snapshot_click_plays_clip": "Clicar em um instantâneo reproduz um clipe relacionado",
- "transition_effect": "Efeito de transição do Visualizador de eventos",
- "transition_effects": {
- "none": "Sem transição",
- "slide": "Transição de slides"
- }
+ "transition_effect": "Efeito de transição do Visualizador de eventos"
},
"menu": {
"alignment": "Alinhamento do menu",
diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json
index df6211b9..00be003a 100644
--- a/src/localize/languages/pt-PT.json
+++ b/src/localize/languages/pt-PT.json
@@ -163,6 +163,10 @@
"clustering_threshold": "A contagem de eventos nos quais eles são agrupados (0 = sem agrupamento)",
"show_recordings": "Mostrar gravações",
"window_seconds": "A duração padrão da visualização da linha do tempo em segundos"
+ },
+ "transition_effects": {
+ "none": "Sem transição",
+ "slide": "Transição de slides"
}
},
"dimensions": {
@@ -199,11 +203,7 @@
},
"draggable": "Visualizador de eventos pode ser arrastado/deslizado",
"lazy_load": "A mÃdia do Visualizador de eventos é carregada lentamente no carrossel",
- "transition_effect": "Efeito de transição do Visualizador de eventos",
- "transition_effects": {
- "none": "Sem transição",
- "slide": "Transição de slides"
- }
+ "transition_effect": "Efeito de transição do Visualizador de eventos"
},
"menu": {
"alignment": "Alinhamento do menu",
diff --git a/src/localize/localize.ts b/src/localize/localize.ts
index 93c5c946..06a42eb4 100644
--- a/src/localize/localize.ts
+++ b/src/localize/localize.ts
@@ -91,7 +91,16 @@ export function localize(string: string, search = '', replace = ''): string {
} catch (_e) {}
if (!translated) {
- translated = string.split('.').reduce((o, i) => o[i], languages[DEFAULT_LANG]);
+ try {
+ translated = string.split('.').reduce((o, i) => o[i], languages[DEFAULT_LANG]);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ } catch (_e) {}
+ }
+
+ // Fall back to the key itself: a missing translation shows as its (self-
+ // describing) key rather than throwing and blanking the entire component.
+ if (!translated) {
+ return string;
}
if (search !== '' && replace !== '') {
diff --git a/src/scss/editor-doc-link.scss b/src/scss/editor-doc-link.scss
new file mode 100644
index 00000000..ea8d95d2
--- /dev/null
+++ b/src/scss/editor-doc-link.scss
@@ -0,0 +1,34 @@
+a {
+ display: flex;
+ align-items: center;
+ padding: 10px 10px 10px 14px;
+
+ border-bottom: 1px solid var(--advanced-camera-card-divider-color);
+
+ text-decoration: none;
+ font-style: italic;
+
+ color: var(--advanced-camera-card-editor-doc-link-color);
+ cursor: help;
+
+ transition:
+ background-color 0.2s ease,
+ color 0.2s ease;
+
+ opacity: 0.7;
+}
+
+a:hover {
+ background-color: var(--advanced-camera-card-editor-doc-link-hover-background);
+ color: var(--advanced-camera-card-editor-doc-link-hover-color);
+ opacity: 1;
+}
+
+a ha-icon:first-child {
+ margin-right: 15px;
+}
+
+a div {
+ flex: 1;
+ pointer-events: none;
+}
diff --git a/src/scss/editor-expander-body.scss b/src/scss/editor-expander-body.scss
new file mode 100644
index 00000000..385ee276
--- /dev/null
+++ b/src/scss/editor-expander-body.scss
@@ -0,0 +1,45 @@
+// The content the editor renders inside an expander body.
+//
+// Every editor component that renders such content does so in its own shadow
+// root, which the editor's own stylesheet cannot reach, so each includes these
+// rules itself. The theme variables they use need no such treatment: custom
+// properties inherit across shadow boundaries from the editor element.
+
+:host {
+ display: block;
+}
+
+// A single-column grid rather than block layout: the `minmax(0, 1fr)` column
+// lets children shrink below their intrinsic minimum width, so a form field
+// with long unbreakable content (an entity ID, a select option) clamps to the
+// panel width instead of overflowing it horizontally.
+@mixin shrinkable-column {
+ display: grid;
+
+ grid-template-columns: minmax(0, 1fr);
+}
+
+// No background or border of its own: the body is the panel's own surface, so
+// the margins around the form read as part of it rather than as a frame around
+// it.
+.values {
+ @include shrinkable-column;
+}
+
+// The blocks stacked in a body all take the same inset, so their outlines line
+// up with each other and none touches the body edge. It is applied to each
+// block rather than as body padding because a form's own fields cannot be
+// reached from outside its shadow DOM, so the form element itself has to carry
+// the spacing.
+.values ha-form,
+.values ha-expansion-panel {
+ display: block;
+ margin: 10px;
+}
+
+// De-emphasized to the same degree Home Assistant de-emphasizes the icons on
+// the panels `ha-form` renders, so the panels the editor builds itself sit
+// alongside them without standing out.
+advanced-camera-card-icon[slot='leading-icon'] {
+ color: var(--advanced-camera-card-editor-secondary-text-color);
+}
diff --git a/src/scss/editor-keyboard-shortcuts.scss b/src/scss/editor-keyboard-shortcuts.scss
new file mode 100644
index 00000000..72b28175
--- /dev/null
+++ b/src/scss/editor-keyboard-shortcuts.scss
@@ -0,0 +1,19 @@
+// The keyboard shortcuts panel renders editor body content into its own shadow
+// root, so it carries the shared body rules alongside its own.
+@use './editor-expander-body.scss';
+
+// The key assigners are rows of a shared three-column table (label, buttons,
+// assigned keys): the columns are defined here and each assigner joins them
+// with a subgrid, so the rows align across components.
+.values {
+ // `minmax(0, 1fr)` rather than `1fr`: the keys column must be able to
+ // shrink below its content so long key combinations wrap instead of
+ // overflowing.
+ grid-template-columns: max-content max-content minmax(0, 1fr);
+}
+
+// Content other than the assigners (the documentation link, the shortcuts
+// form) spans the whole row.
+.values > *:not(advanced-camera-card-key-assigner) {
+ grid-column: 1 / -1;
+}
diff --git a/src/scss/editor-list.scss b/src/scss/editor-list.scss
new file mode 100644
index 00000000..beb8798f
--- /dev/null
+++ b/src/scss/editor-list.scss
@@ -0,0 +1,66 @@
+// A list of configuration items as rows, styled as Home Assistant styles the
+// editable lists in its own editors.
+@use './editor-expander-body.scss';
+
+// Whatever precedes the list draws a line of its own (a documentation link's
+// lower border, a form's fields), which the rows' own borders would otherwise
+// sit directly against.
+:host {
+ padding-top: 8px;
+}
+
+ha-md-list {
+ gap: 8px;
+
+ padding: 0 10px;
+}
+
+ha-md-list-item {
+ border: 1px solid var(--advanced-camera-card-divider-color);
+ border-radius: var(--ha-border-radius-md, 8px);
+
+ --ha-md-list-item-gap: 8px;
+ --md-list-item-top-space: 0;
+ --md-list-item-bottom-space: 0;
+ --md-list-item-leading-space: 12px;
+ --md-list-item-trailing-space: 4px;
+ --md-list-item-one-line-container-height: 48px;
+ --md-list-item-two-line-container-height: 56px;
+}
+
+// Sized and de-emphasized as Home Assistant sizes the controls on the rows of
+// its own editable lists: 36px buttons rather than the 48px default, against a
+// 48px row.
+.handle,
+ha-md-list-item advanced-camera-card-icon[slot='start'] {
+ color: var(--advanced-camera-card-editor-secondary-text-color);
+}
+
+.handle {
+ cursor: move; // Fallback where the grab cursor is unsupported.
+ cursor: grab;
+}
+
+// The handle is what a drag is started from, so nothing inside it may become
+// the target of the pointer instead.
+.handle > * {
+ pointer-events: none;
+}
+
+ha-icon-button {
+ --ha-icon-button-size: 36px;
+
+ color: var(--advanced-camera-card-editor-secondary-text-color);
+}
+
+.title,
+.description {
+ overflow: hidden;
+
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.add {
+ margin: 8px 10px;
+}
diff --git a/src/scss/editor-page.scss b/src/scss/editor-page.scss
new file mode 100644
index 00000000..e7827e63
--- /dev/null
+++ b/src/scss/editor-page.scss
@@ -0,0 +1,36 @@
+// The editor for one item of a list, shown in place of the list, headed as
+// Home Assistant heads the editor for a sub-element of a card.
+@use './editor-expander-body.scss';
+
+// The shared body rules set `:host { display: block }`, which outranks the
+// browser's default styling of the `hidden` attribute, so the page is hidden
+// explicitly. A page is hidden while it stays mounted under a page opened on
+// top of it, keeping its expanded panels expanded for when it returns.
+:host([hidden]) {
+ display: none;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+
+ margin: 8px 10px;
+}
+
+.heading {
+ overflow: hidden;
+
+ white-space: nowrap;
+ text-overflow: ellipsis;
+
+ font-size: var(--ha-font-size-l, 1.125rem);
+}
+
+// The page's blocks (the item's forms, then any list of its own) are spaced
+// as Home Assistant spaces the blocks within a form, so a list below a form
+// reads at the same distance as one form group below another.
+::slotted(*:not(:last-child)) {
+ display: block;
+
+ margin-bottom: 24px;
+}
diff --git a/src/scss/editor-section.scss b/src/scss/editor-section.scss
new file mode 100644
index 00000000..5796cb35
--- /dev/null
+++ b/src/scss/editor-section.scss
@@ -0,0 +1,12 @@
+// A section renders its own panel, so it carries the rules for the panel's
+// frame and body. The editor around it positions the sections themselves.
+@use './editor-expander-body.scss';
+
+// Content the section was given ends its body; without this it sits flush
+// against the section's bottom edge.
+.values > advanced-camera-card-editor-cameras,
+.values > advanced-camera-card-editor-folders,
+.values > advanced-camera-card-editor-keyboard-shortcuts {
+ display: block;
+ margin-bottom: 10px;
+}
diff --git a/src/scss/editor.scss b/src/scss/editor.scss
index 528ff6ed..c942b65e 100644
--- a/src/scss/editor.scss
+++ b/src/scss/editor.scss
@@ -1,147 +1,36 @@
+@use './themes/base.scss';
@use './button.scss';
-.option {
- padding: 8px 4px;
- cursor: pointer;
+// A hidden form rendered only to pre-load `ha-form`/`ha-selector`'s lazily
+// imported element types.
+.selector-sideload {
+ display: none;
}
-.option.option-overrides .title {
- color: var(--warning-color);
-}
-.row {
- display: flex;
- margin-bottom: -14px;
- pointer-events: none;
-}
-.title {
- padding-left: 16px;
- margin-top: -6px;
- pointer-events: none;
-}
-.title.warning {
- color: var(--warning-color);
-}
-.secondary {
- padding-left: 40px;
- color: var(--secondary-text-color);
- pointer-events: none;
-}
-.values {
- background: var(--secondary-background-color);
- display: grid;
- grid-template-columns: minmax(0, 1fr);
+// Card-state notice banners sit above the section stack.
+.card-config > ha-alert {
+ display: block;
+ margin-bottom: 8px;
}
-// Space submenus from the next top-level options.
-.values + .option,
-.submenu + .option {
- margin-top: 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
+// appeared only on opening would move the sections below as the panel grew.
+// The margins of two adjacent sections collapse together, so the gap is the
+// same wherever it falls.
+.section {
+ display: block;
+
+ margin-top: 8px;
+ margin-bottom: 8px;
}
+
.action-buttons {
display: flex;
flex-wrap: wrap;
}
-.submenu-header {
- display: flex;
- align-items: center;
- padding: 10px;
-
- cursor: pointer;
-}
-.submenu.selected > .submenu-header {
- background-color: var(--primary-color);
- color: var(--primary-text-color);
-}
-
-.submenu-header * {
- flex-basis: auto;
-
- // Only allow clicks on the header.
- pointer-events: none;
-}
-.submenu-header .new {
- font-style: italic;
-}
-.submenu:not(.selected) > .submenu-header .new {
- color: var(--secondary-text-color, 'black');
-}
-
-.submenu-header advanced-camera-card-icon {
- margin-right: 15px;
-}
-
-.submenu.selected {
- border: 1px solid var(--primary-color);
-}
-
-// Nested menus should shrink width.
-.submenu {
- width: calc(100% - 10px * 2);
- margin-left: auto;
- margin-right: auto;
- margin-bottom: 10px;
-}
-
-// Add a margin above a submenu unless it's another submenu.
-.submenu:first-child,
-:not(.submenu) + .submenu {
- margin-top: 10px;
-}
-
-.submenu .values .controls {
- display: inline-block;
- margin-left: auto;
- margin-right: 0px;
- margin-bottom: 5px;
- margin-top: 5px;
-}
-.submenu .values .controls ha-icon-button {
- --ha-icon-button-size: 32px;
- --mdc-icon-size: calc(var(--ha-icon-button-size) / 2);
-}
-span.info {
- padding: 10px;
-}
-
-ha-selector {
- padding: 10px;
- border: 1px solid var(--divider-color);
-}
-
-.doc-link-row {
- display: flex;
- align-items: center;
- padding: 10px 10px 10px 14px;
-
- border-bottom: 1px solid var(--divider-color);
-
- text-decoration: none;
- font-style: italic;
-
- color: var(--secondary-text-color);
- cursor: help;
-
- transition:
- background-color 0.2s ease,
- color 0.2s ease;
-
- opacity: 0.7;
-}
-.doc-link-row:hover {
- background-color: var(--secondary-background-color);
- color: var(--primary-color);
- opacity: 1;
-}
-.doc-link-row ha-icon:first-child {
- margin-right: 15px;
-}
-.doc-link-row div {
- flex: 1;
- padding-left: 0;
- margin-top: 0;
- pointer-events: none;
-}
-
ha-button {
margin: 10px;
}
diff --git a/src/scss/icon.scss b/src/scss/icon.scss
index ab557475..8ec3e259 100644
--- a/src/scss/icon.scss
+++ b/src/scss/icon.scss
@@ -7,19 +7,13 @@
}
ha-icon,
-state-badge,
-svg {
+state-badge {
display: block;
height: 100%;
width: 100%;
box-sizing: border-box;
}
-svg path {
- // !important is necessary to overcome the fill set on the path itself.
- fill: currentColor !important;
-}
-
:host([allow-override-non-active-styles]) state-badge {
--state-inactive-color: inherit;
}
diff --git a/src/scss/key-assigner.scss b/src/scss/key-assigner.scss
index 48283936..630d7008 100644
--- a/src/scss/key-assigner.scss
+++ b/src/scss/key-assigner.scss
@@ -1,12 +1,29 @@
+// Fade content out over the last `$distance` of the element's width. The
+// prefixed property covers Safari before 15.4 and Chrome before 120.
+@mixin fade-out-right($distance: 20px) {
+ $gradient: linear-gradient(
+ to right,
+ black calc(100% - #{$distance}),
+ transparent 100%
+ );
+
+ -webkit-mask-image: $gradient;
+ mask-image: $gradient;
+}
+
+// Each assigner is one row of the columns its parent grid defines (label,
+// buttons, assigned keys), so the rows align like a table across components.
:host {
- display: flex;
- flex-direction: row;
+ display: grid;
+ grid-column: 1 / -1;
+ grid-template-columns: subgrid;
align-items: center;
- // Values match other HA editor components.
+ // Values match other HA editor components. A minimum rather than a fixed
+ // height: a long key combination may wrap onto multiple lines.
padding: 10px;
- height: 56px;
- border: 1px solid var(--divider-color);
+ min-height: 56px;
+ box-sizing: border-box;
}
:host([assigning]) ha-button.assign span,
@@ -15,7 +32,7 @@
@keyframes pulse {
50% {
- color: var(--warning-color);
+ color: var(--advanced-camera-card-editor-warning-color);
}
}
}
@@ -30,16 +47,27 @@ advanced-camera-card-icon {
}
div.label {
- width: 100px;
margin-left: 4px;
}
-div.key-row {
- flex: 1;
-
+div.buttons {
display: flex;
- justify-content: center;
align-items: center;
+}
+
+div.key-row {
+ display: flex;
+ align-items: center;
+
+ // A long key combination wraps rather than overflowing the row. When even a
+ // single key is wider than the row (a very narrow editor), wrapping cannot
+ // help, so the overflow is clipped and faded out at the edge rather than
+ // spilling out of the panel.
+ flex-wrap: wrap;
+ row-gap: 4px;
+ min-width: 0;
+ overflow: hidden;
+ @include fade-out-right;
padding-left: 10px;
padding-right: 10px;
@@ -49,7 +77,6 @@ div.key {
display: flex;
align-items: center;
- height: 90%;
width: min-content;
margin-left: 5px;
@@ -57,32 +84,25 @@ div.key {
}
div.key-inner {
- height: 100%;
- width: 100%;
-
- padding-top: 2px;
- padding-bottom: 2px;
- padding-left: 4px;
- padding-right: 4px;
-
- border: 2px;
- border-radius: 4px;
- border-style: outset;
- border-color: var(--divider-color);
-
- font-family: monospace;
- text-transform: capitalize;
-}
-
-div.unassigned {
- font-style: italic;
-}
-
-div.key + div.key:before {
display: flex;
align-items: center;
+ justify-content: center;
- margin-right: 5px;
+ min-width: 24px;
- content: ' + ';
+ padding: 4px 8px;
+
+ background-color: var(--secondary-background-color);
+ border: 1px solid var(--advanced-camera-card-divider-color);
+ border-radius: 4px;
+
+ // The darker lower edge reads as the side of a raised keycap.
+ box-shadow: var(
+ --advanced-camera-card-box-shadow-override,
+ inset 0 -2px 0 var(--advanced-camera-card-divider-color)
+ );
+
+ font-family: monospace;
+ font-size: 0.9em;
+ text-transform: capitalize;
}
diff --git a/src/scss/loading.scss b/src/scss/loading.scss
index 56d8bedb..d1077f50 100644
--- a/src/scss/loading.scss
+++ b/src/scss/loading.scss
@@ -9,6 +9,10 @@
pointer-events: none;
+ // The rotating icon's bounding box oscillates as it turns; clip it here so
+ // it cannot grow ancestor scrollable overflow (flickering scrollbars).
+ overflow: hidden;
+
background-color: var(--advanced-camera-card-loading-background-color);
color: var(--advanced-camera-card-loading-foreground-color);
@@ -20,10 +24,20 @@
opacity: 0;
}
+// The hidden element stays mounted: without this the icon would rotate
+// invisibly forever.
+:host([loaded]) advanced-camera-card-icon {
+ animation: none;
+}
+
advanced-camera-card-icon {
+ // The icon's own box sets the size; the inner `ha-icon` machinery sizes
+ // itself from `--mdc-icon-size`, which needs a definite box to fill.
+ --mdc-icon-size: 100%;
+
margin-bottom: 20px;
height: 25%;
- width: auto;
+ aspect-ratio: 1;
animation: rotate 8s linear infinite;
}
diff --git a/src/scss/themes/base.scss b/src/scss/themes/base.scss
index 8921a9b9..302f5d38 100644
--- a/src/scss/themes/base.scss
+++ b/src/scss/themes/base.scss
@@ -301,6 +301,22 @@
transparent 20%
);
+ /********
+ * Editor
+ ********/
+ --advanced-camera-card-editor-doc-link-color: var(--secondary-text-color);
+ --advanced-camera-card-editor-doc-link-hover-color: var(
+ --advanced-camera-card-foreground-primary
+ );
+ --advanced-camera-card-editor-doc-link-hover-background: var(
+ --secondary-background-color
+ );
+ --advanced-camera-card-editor-secondary-text-color: var(--secondary-text-color);
+
+ // Home Assistant's amber `--warning-color`, not the card's own
+ // `--advanced-camera-card-warning-color`.
+ --advanced-camera-card-editor-warning-color: var(--warning-color);
+
/**************
* Notification (block)
**************/
diff --git a/src/utils/animation.ts b/src/utils/animation.ts
index 3b31a66a..80dc212e 100644
--- a/src/utils/animation.ts
+++ b/src/utils/animation.ts
@@ -1,4 +1,5 @@
-import { POP_OUT_ANIMATION_NAME } from '../const.js';
+// The name of the exit keyframe defined in `scss/pop-animation.scss`.
+export const POP_OUT_ANIMATION_NAME = 'pop-out';
/**
* Whether `ev` marks the end of a pop-out (exit) animation on the element the
diff --git a/tests/card-controller/media-player-manager.test.ts b/tests/card-controller/media-player-manager.test.ts
index 1c753b7e..6095d12d 100644
--- a/tests/card-controller/media-player-manager.test.ts
+++ b/tests/card-controller/media-player-manager.test.ts
@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
-import { MediaPlayerManager } from '../../src/card-controller/media-player-manager.js';
import {
MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA,
MEDIA_PLAYER_SUPPORT_STOP,
MEDIA_PLAYER_SUPPORT_TURN_OFF,
-} from '../../src/const';
+ MediaPlayerManager,
+} from '../../src/card-controller/media-player-manager.js';
import type { EntityRegistryManager } from '../../src/ha/registry/entity/types.js';
import type { HomeAssistant } from '../../src/ha/types.js';
import { ViewMediaType } from '../../src/view/item.js';
diff --git a/tests/components-lib/editor/controller.test.ts b/tests/components-lib/editor/controller.test.ts
new file mode 100644
index 00000000..6a52bef9
--- /dev/null
+++ b/tests/components-lib/editor/controller.test.ts
@@ -0,0 +1,358 @@
+import type { LitElement } from 'lit';
+import {
+ afterEach,
+ assert,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+ type Mock,
+} from 'vitest';
+
+import { EditorController } from '../../../src/components-lib/editor/controller';
+import { getConfigValue } from '../../../src/config/management';
+import { configDefaults } from '../../../src/config/schema/types';
+import type { RawAdvancedCameraCardConfig } from '../../../src/config/types';
+import { sideLoadHomeAssistantElements } from '../../../src/ha/side-load-ha-elements';
+import { localize } from '../../../src/localize/localize';
+import { isRecord } from '../../../src/utils/basic';
+import { createHASS, createLitElement, flushPromises } from '../../test-utils';
+
+vi.mock('../../../src/ha/side-load-ha-elements');
+
+interface ControllerHarness {
+ controller: EditorController;
+ host: LitElement;
+ configListener: Mock;
+}
+
+const createController = (): ControllerHarness => {
+ const host = createLitElement();
+ const controller = new EditorController(host);
+ const configListener = vi.fn();
+ host.addEventListener('config-changed', configListener);
+
+ return { controller, host, configListener };
+};
+
+const getLastConfig = (configListener: Mock): RawAdvancedCameraCardConfig => {
+ const event = configListener.mock.lastCall?.[0];
+ assert(event instanceof CustomEvent);
+ const config: unknown = event.detail.config;
+ assert(isRecord(config));
+
+ return config;
+};
+
+// A configuration the upgrade rules can rewrite (`service_data` was renamed to
+// `data`).
+const createUpgradeableConfig = (): RawAdvancedCameraCardConfig => ({
+ cameras: [{ camera_entity: 'camera.office' }],
+ elements: [
+ {
+ type: 'icon',
+ icon: 'mdi:cow',
+ tap_action: {
+ action: 'call-service',
+ service: 'notify.persistent_notification',
+ service_data: { message: 'Hello' },
+ },
+ },
+ ],
+});
+
+// @vitest-environment jsdom
+describe('EditorController', () => {
+ beforeEach(() => {
+ vi.mocked(sideLoadHomeAssistantElements).mockReset();
+ vi.mocked(sideLoadHomeAssistantElements).mockResolvedValue();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should register with the host', () => {
+ const host = createLitElement();
+ const controller = new EditorController(host);
+ expect(host.addController).toHaveBeenCalledWith(controller);
+ controller.hostConnected();
+ });
+
+ describe('should initialize', () => {
+ it('should side-load Home Assistant elements only once', async () => {
+ const { controller } = createController();
+
+ controller.initialize();
+ await flushPromises();
+ controller.initialize();
+
+ expect(sideLoadHomeAssistantElements).toHaveBeenCalledTimes(1);
+ });
+
+ it('should log and retry after a failed side-load', async () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockReturnValue(undefined);
+ vi.mocked(sideLoadHomeAssistantElements).mockRejectedValue(new Error('fail'));
+ const { controller } = createController();
+
+ controller.initialize();
+ await vi.waitFor(() => expect(consoleSpy).toHaveBeenCalledWith('fail'));
+
+ controller.initialize();
+ expect(sideLoadHomeAssistantElements).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('should set the configuration', () => {
+ it('should store the configuration and request an update', () => {
+ const { controller, host } = createController();
+ expect(controller.getConfig()).toBeNull();
+
+ const config = { cameras: [] };
+ controller.setConfig(config);
+
+ expect(controller.getConfig()).toBe(config);
+ expect(host.requestUpdate).toHaveBeenCalled();
+ });
+
+ it('should detect an upgradeable configuration', () => {
+ const { controller } = createController();
+ expect(controller.isConfigUpgradeable()).toBeFalsy();
+
+ controller.setConfig(createUpgradeableConfig());
+ expect(controller.isConfigUpgradeable()).toBeTruthy();
+
+ controller.setConfig({ cameras: [] });
+ expect(controller.isConfigUpgradeable()).toBeFalsy();
+ });
+
+ it('should apply profile defaults', () => {
+ const { controller } = createController();
+ controller.setConfig({ cameras: [], profiles: ['scrubbing'] });
+
+ expect(
+ getConfigValue(
+ controller.getFormsInput().defaults,
+ 'media_viewer.controls.timeline.style',
+ ),
+ ).toBe('ribbon');
+ });
+
+ it('should reset profile defaults when profiles become invalid', () => {
+ const { controller } = createController();
+ controller.setConfig({ cameras: [], profiles: ['scrubbing'] });
+ controller.setConfig({ cameras: [], profiles: 42 });
+
+ expect(
+ getConfigValue(
+ controller.getFormsInput().defaults,
+ 'media_viewer.controls.timeline.style',
+ ),
+ ).toBe(configDefaults.media_viewer.controls.timeline.style);
+ });
+
+ it.each([
+ [{ cameras: [] }, []],
+ [{ cameras: [], profiles: ['low-performance'] }, ['warning']],
+ [{ cameras: [], profiles: 42 }, []],
+ [{ cameras: [], overrides: [] }, []],
+ [{ cameras: [], overrides: [{}] }, ['info']],
+ ])('should compute the notices for %j', (config, noticeTypes) => {
+ const { controller } = createController();
+ controller.setConfig(config);
+ expect(controller.getNotices().map((notice) => notice.type)).toEqual(noticeTypes);
+ });
+
+ it('should have notices for the low-performance profile and overrides', () => {
+ const { controller } = createController();
+ controller.setConfig({
+ cameras: [],
+ profiles: ['low-performance'],
+ overrides: [{}],
+ });
+ expect(controller.getNotices()).toEqual([
+ { type: 'warning', message: localize('config.performance.warning') },
+ { type: 'info', message: localize('config.overrides.info') },
+ ]);
+ });
+
+ it('should have no notices without a configuration', () => {
+ const { controller } = createController();
+ expect(controller.getNotices()).toEqual([]);
+ });
+ });
+
+ describe('should describe what a section needs', () => {
+ it('should offer an empty configuration before one is set', () => {
+ const { controller } = createController();
+ expect(controller.getFormsInput().config).toEqual({});
+ });
+
+ it('should offer the configuration and its defaults', () => {
+ const { controller } = createController();
+ const config = { cameras: [] };
+ controller.setConfig(config);
+
+ const input = controller.getFormsInput();
+ expect(input.config).toBe(config);
+ expect(input.defaults).toBe(controller.getDefaults());
+ });
+
+ it('should offer the cameras and folders its selectors need', () => {
+ const { controller } = createController();
+ controller.setConfig({
+ cameras: [{ id: 'one' }, { camera_entity: 'camera.office' }],
+ folders: [{ id: 'recordings', title: 'Recordings' }],
+ });
+
+ const { options } = controller.getFormsInput();
+ expect(options.cameras).toEqual([
+ { value: 'one', label: 'one' },
+ { value: 'camera.office', label: 'Camera #1' },
+ ]);
+ expect(options.folders).toEqual([{ value: 'recordings', label: 'Recordings' }]);
+ });
+
+ it('should report a list holding entries that are not objects', () => {
+ const { controller } = createController();
+ controller.setConfig({ cameras: [{ id: 'one' }, 'junk'], folders: 'junk' });
+
+ const { options } = controller.getFormsInput();
+ expect(options.cameras).toHaveLength(2);
+ expect(options.folders).toEqual([]);
+ });
+ });
+
+ describe('should set HASS', () => {
+ it('should offer the HomeAssistant object it was given', () => {
+ const { controller } = createController();
+ const hass = createHASS();
+
+ controller.setHASS(hass);
+
+ expect(controller.getHASS()).toBe(hass);
+ });
+ });
+
+ describe('should upgrade', () => {
+ it('should upgrade an upgradeable configuration', () => {
+ const { controller, configListener } = createController();
+ controller.setConfig(createUpgradeableConfig());
+
+ controller.upgrade();
+
+ const config = getLastConfig(configListener);
+ expect(getConfigValue(config, 'elements.0.tap_action.data')).toEqual({
+ message: 'Hello',
+ });
+ expect(controller.isConfigUpgradeable()).toBeFalsy();
+ });
+
+ it('should do nothing without a configuration', () => {
+ const { controller, configListener } = createController();
+
+ controller.upgrade();
+
+ expect(configListener).not.toHaveBeenCalled();
+ });
+
+ it('should not fire for a configuration that needs no upgrade', () => {
+ const { controller, configListener } = createController();
+ controller.setConfig({ cameras: [] });
+
+ controller.upgrade();
+
+ expect(configListener).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('should carry out an intent', () => {
+ it('should apply changes', () => {
+ const { controller, configListener } = createController();
+ controller.setConfig({ cameras: [] });
+
+ controller.applyIntent({
+ type: 'changes',
+ changes: [{ path: ['menu', 'style'], type: 'set', value: 'outside' }],
+ });
+
+ expect(getConfigValue(getLastConfig(configListener), 'menu.style')).toBe(
+ 'outside',
+ );
+ });
+
+ it('should not fire when the changes leave the configuration unmodified', () => {
+ const { controller, configListener } = createController();
+ controller.setConfig({ menu: { style: 'outside' } });
+
+ controller.applyIntent({
+ type: 'changes',
+ changes: [{ path: ['menu', 'style'], type: 'set', value: 'outside' }],
+ });
+
+ expect(configListener).not.toHaveBeenCalled();
+ });
+
+ it('should add an item to a list', () => {
+ const { controller, configListener } = createController();
+ controller.setConfig({ cameras: [{ id: 'one' }] });
+
+ controller.applyIntent({
+ type: 'list-add',
+ path: ['cameras'],
+ item: { id: 'two' },
+ });
+
+ expect(getConfigValue(getLastConfig(configListener), 'cameras')).toEqual([
+ { id: 'one' },
+ { id: 'two' },
+ ]);
+ });
+
+ it('should move an item within a list', () => {
+ const { controller, configListener } = createController();
+ controller.setConfig({ cameras: [{ id: 'one' }, { id: 'two' }] });
+
+ controller.applyIntent({ type: 'list-move', path: ['cameras'], from: 0, to: 1 });
+
+ expect(getConfigValue(getLastConfig(configListener), 'cameras')).toEqual([
+ { id: 'two' },
+ { id: 'one' },
+ ]);
+ });
+
+ it('should delete an item from a list', () => {
+ const { controller, configListener } = createController();
+ controller.setConfig({ cameras: [{ id: 'one' }, { id: 'two' }] });
+
+ controller.applyIntent({ type: 'list-delete', path: ['cameras'], index: 0 });
+
+ expect(getConfigValue(getLastConfig(configListener), 'cameras')).toEqual([
+ { id: 'two' },
+ ]);
+ });
+
+ it('should not fire for a list change that cannot be made', () => {
+ const { controller, configListener } = createController();
+ controller.setConfig({ cameras: [{ id: 'one' }] });
+
+ controller.applyIntent({ type: 'list-delete', path: ['cameras'], index: 5 });
+
+ expect(configListener).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ [{ type: 'changes' as const, changes: [] }],
+ [{ type: 'list-add' as const, path: ['cameras'], item: {} }],
+ [{ type: 'list-move' as const, path: ['cameras'], from: 0, to: 1 }],
+ [{ type: 'list-delete' as const, path: ['cameras'], index: 0 }],
+ ])('should do nothing for %j without a configuration', (intent) => {
+ const { controller, configListener } = createController();
+
+ controller.applyIntent(intent);
+
+ expect(configListener).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/tests/components-lib/editor/doc-links.test.ts b/tests/components-lib/editor/doc-links.test.ts
new file mode 100644
index 00000000..83f4386f
--- /dev/null
+++ b/tests/components-lib/editor/doc-links.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest';
+
+import { getDocLinkPath, getDocURL } from '../../../src/components-lib/editor/doc-links';
+
+describe('getDocURL', () => {
+ it('should return the documentation URL for a known path', () => {
+ expect(getDocURL(['live', 'controls', 'thumbnails'])).toBe(
+ 'https://card.camera/#/configuration/live?id=thumbnails',
+ );
+ });
+
+ it('should ignore array indices', () => {
+ expect(getDocURL(['cameras', 0, 'triggers'])).toBe(
+ 'https://card.camera/#/configuration/cameras/README?id=triggers',
+ );
+ });
+
+ it('should return null for a path without documentation', () => {
+ expect(getDocURL(['unknown', 'path'])).toBeNull();
+ });
+});
+
+describe('getDocLinkPath', () => {
+ it('should not link a field', () => {
+ expect(
+ getDocLinkPath(['cameras'], { name: 'title', selector: { text: {} } }),
+ ).toBeNull();
+ });
+
+ it('should link a group by its explicit documentation path', () => {
+ expect(
+ getDocLinkPath(['cameras', 2], {
+ type: 'expandable',
+ docPath: 'cameras.engine',
+ schema: [],
+ }),
+ ).toEqual(['cameras', 'engine']);
+ });
+
+ it('should not link a group that has neither a name nor a path', () => {
+ expect(getDocLinkPath(['cameras'], { type: 'expandable', schema: [] })).toBeNull();
+ });
+
+ it('should order nested container paths into configuration order', () => {
+ expect(
+ getDocLinkPath(
+ ['cameras', 2],
+ { name: 'dashboard', type: 'expandable', schema: [] },
+ { path: ['cast'] },
+ ),
+ ).toEqual(['cameras', 2, 'cast', 'dashboard']);
+ });
+});
diff --git a/tests/components-lib/editor/form-data.test.ts b/tests/components-lib/editor/form-data.test.ts
new file mode 100644
index 00000000..ac1293b8
--- /dev/null
+++ b/tests/components-lib/editor/form-data.test.ts
@@ -0,0 +1,518 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ applyConfigChanges,
+ computeConfigChanges,
+ computeDisplayedData,
+ forEachFieldRecursively,
+} from '../../../src/components-lib/editor/form-data';
+import type { ConfigChange, EditorForm } from '../../../src/components-lib/editor/types';
+import { getConfigValue } from '../../../src/config/management';
+import type { RawAdvancedCameraCardConfig } from '../../../src/config/types';
+import type { HAFormSchema } from '../../../src/ha/types';
+
+const createForm = (schema: HAFormSchema[] = SCHEMA): EditorForm => ({
+ basePath: ['live'],
+ schema,
+});
+const createConfig = (live: unknown) => ({ live });
+
+const SCHEMA: HAFormSchema[] = [
+ { name: 'title', selector: { text: {} } },
+ { name: 'preload', selector: { boolean: {} } },
+ {
+ name: 'controls',
+ type: 'expandable',
+ schema: [
+ { name: 'wheel', selector: { boolean: {} } },
+ { name: 'size', selector: { number: { min: 0 } } },
+ {
+ name: 'thumbnails',
+ type: 'expandable',
+ schema: [{ name: 'mode', selector: { select: { options: ['none'] } } }],
+ },
+ ],
+ },
+ { name: 'modes', selector: { select: { options: ['a', 'b'], multiple: true } } },
+];
+
+// Defaults mirror the configuration shape; the form displays these where the
+// configuration leaves a field unset.
+const DEFAULTS = {
+ preload: false,
+ controls: { wheel: true },
+};
+
+describe('forEachFieldRecursively', () => {
+ it('should not add a nameless grouping to the path', () => {
+ const visited: string[][] = [];
+ forEachFieldRecursively(
+ [
+ {
+ type: 'expandable',
+ schema: [{ name: 'style', selector: { text: {} } }],
+ },
+ ],
+ (path) => visited.push(path),
+ );
+ expect(visited).toEqual([['style']]);
+ });
+
+ it('should visit every leaf field with its relative path', () => {
+ const visited: [string[], string][] = [];
+ forEachFieldRecursively(SCHEMA, (path, field) => visited.push([path, field.name]));
+ expect(visited).toEqual([
+ [['title'], 'title'],
+ [['preload'], 'preload'],
+ [['controls', 'wheel'], 'wheel'],
+ [['controls', 'size'], 'size'],
+ [['controls', 'thumbnails', 'mode'], 'mode'],
+ [['modes'], 'modes'],
+ ]);
+ });
+});
+
+describe('computeDisplayedData', () => {
+ it('should fill in defaults for absent fields', () => {
+ expect(
+ computeDisplayedData(createForm(), createConfig({}), createConfig(DEFAULTS)),
+ ).toEqual({
+ preload: false,
+ controls: { wheel: true },
+ });
+ });
+
+ it('should not override configured values with defaults', () => {
+ expect(
+ computeDisplayedData(
+ createForm(),
+ createConfig({ preload: true, controls: { wheel: false } }),
+ createConfig(DEFAULTS),
+ ),
+ ).toEqual({
+ preload: true,
+ controls: { wheel: false },
+ });
+ });
+
+ it('should only fill in fields that have a default', () => {
+ // `title` and `modes` have no default in DEFAULTS, so they stay absent.
+ expect(
+ computeDisplayedData(createForm(), createConfig({}), createConfig(DEFAULTS)),
+ ).not.toHaveProperty('title');
+ });
+
+ it('should preserve unknown keys', () => {
+ expect(
+ computeDisplayedData(
+ createForm(),
+ createConfig({ unknown_key: 42 }),
+ createConfig(DEFAULTS),
+ ),
+ ).toEqual({
+ unknown_key: 42,
+ preload: false,
+ controls: { wheel: true },
+ });
+ });
+
+ it('should treat a non-object configuration as empty', () => {
+ expect(
+ computeDisplayedData(
+ createForm(),
+ createConfig('NOT_AN_OBJECT'),
+ createConfig(DEFAULTS),
+ ),
+ ).toEqual({
+ preload: false,
+ controls: { wheel: true },
+ });
+ });
+
+ it('should tolerate absent defaults', () => {
+ expect(
+ computeDisplayedData(createForm(), createConfig({ title: 'Front' }), {}),
+ ).toEqual({
+ title: 'Front',
+ });
+ });
+
+ it('should not modify the raw configuration', () => {
+ const raw = { title: 'Front' };
+ computeDisplayedData(createForm(), createConfig(raw), createConfig(DEFAULTS));
+ expect(raw).toEqual({ title: 'Front' });
+ });
+
+ it('should copy non-primitive defaults rather than share them', () => {
+ const defaults = { modes: ['a'] };
+ const schema: HAFormSchema[] = [
+ { name: 'modes', selector: { select: { options: ['a', 'b'], multiple: true } } },
+ ];
+ const data = computeDisplayedData(
+ createForm(schema),
+ createConfig({}),
+ createConfig(defaults),
+ );
+ expect(data.modes).toEqual(['a']);
+ expect(data.modes).not.toBe(defaults.modes);
+ });
+});
+
+describe('computeConfigChanges', () => {
+ it('should return no changes for a non-object emission', () => {
+ expect(computeConfigChanges(createForm(), {}, 'NOT_AN_OBJECT', {})).toEqual([]);
+ });
+
+ it('should return no changes when nothing changed', () => {
+ const displayed = computeDisplayedData(
+ createForm(),
+ createConfig({ title: 'Front' }),
+ createConfig(DEFAULTS),
+ );
+ expect(computeConfigChanges(createForm(), displayed, displayed, {})).toEqual([]);
+ });
+
+ it('should never report an untouched display default', () => {
+ const displayed = computeDisplayedData(
+ createForm(),
+ createConfig({}),
+ createConfig(DEFAULTS),
+ );
+ expect(computeConfigChanges(createForm(), displayed, { ...displayed }, {})).toEqual(
+ [],
+ );
+ });
+
+ it('should report a changed value', () => {
+ const displayed = computeDisplayedData(
+ createForm(),
+ createConfig({}),
+ createConfig(DEFAULTS),
+ );
+ expect(
+ computeConfigChanges(
+ createForm(),
+ displayed,
+ { ...displayed, title: 'Front' },
+ {},
+ ),
+ ).toEqual([{ path: ['live', 'title'], type: 'set', value: 'Front' }]);
+ });
+
+ it('should report a changed nested value', () => {
+ const displayed = computeDisplayedData(
+ createForm(),
+ createConfig({}),
+ createConfig(DEFAULTS),
+ );
+ expect(
+ computeConfigChanges(
+ createForm(),
+ displayed,
+ { preload: false, controls: { wheel: false } },
+ {},
+ ),
+ ).toEqual([{ path: ['live', 'controls', 'wheel'], type: 'set', value: false }]);
+ });
+
+ it('should report a value set back to its default as a change', () => {
+ // Diffing against the displayed baseline: when the stored value is
+ // non-default, returning it to the default value is a real change (this is
+ // how a user pins a default-equal value).
+ const displayed = computeDisplayedData(
+ createForm(),
+ createConfig({ controls: { wheel: false } }),
+ createConfig(DEFAULTS),
+ );
+ const emitted = { ...displayed, controls: { wheel: true } };
+ expect(computeConfigChanges(createForm(), displayed, emitted, {})).toEqual([
+ { path: ['live', 'controls', 'wheel'], type: 'set', value: true },
+ ]);
+ });
+
+ it('should trim string values', () => {
+ expect(computeConfigChanges(createForm(), {}, { title: ' Front ' }, {})).toEqual([
+ { path: ['live', 'title'], type: 'set', value: 'Front' },
+ ]);
+ });
+
+ it('should not report a value that only differs by whitespace', () => {
+ expect(
+ computeConfigChanges(createForm(), { title: 'Front' }, { title: ' Front ' }, {}),
+ ).toEqual([]);
+ });
+
+ it('should request deletion for an emptied string', () => {
+ expect(
+ computeConfigChanges(createForm(), { title: 'Front' }, { title: '' }, {}),
+ ).toEqual([{ path: ['live', 'title'], type: 'delete' }]);
+ });
+
+ it('should request deletion for a cleared value', () => {
+ expect(
+ computeConfigChanges(
+ createForm(),
+ { controls: { size: 5 } },
+ { controls: { size: undefined } },
+ {},
+ ),
+ ).toEqual([{ path: ['live', 'controls', 'size'], type: 'delete' }]);
+ });
+
+ it('should set a zero value rather than delete it', () => {
+ expect(
+ computeConfigChanges(createForm(), {}, { controls: { size: 0 } }, {}),
+ ).toEqual([{ path: ['live', 'controls', 'size'], type: 'set', value: 0 }]);
+ });
+
+ it('should compare multi-select arrays by value', () => {
+ expect(
+ computeConfigChanges(createForm(), { modes: ['a'] }, { modes: ['a'] }, {}),
+ ).toEqual([]);
+ expect(
+ computeConfigChanges(createForm(), { modes: ['a'] }, { modes: ['a', 'b'] }, {}),
+ ).toEqual([{ path: ['live', 'modes'], type: 'set', value: ['a', 'b'] }]);
+ });
+
+ it('should ignore emitted keys that are not in the schema', () => {
+ expect(computeConfigChanges(createForm(), {}, { unknown_key: 42 }, {})).toEqual([]);
+ });
+});
+
+describe('applyConfigChanges', () => {
+ it('should return null without changes', () => {
+ expect(applyConfigChanges({ title: 'Front' }, [])).toBeNull();
+ });
+
+ it('should set values', () => {
+ expect(
+ applyConfigChanges({}, [{ path: ['title'], type: 'set', value: 'Front' }]),
+ ).toEqual({ title: 'Front' });
+ });
+
+ it('should create intermediate objects for absent branches', () => {
+ expect(
+ applyConfigChanges({}, [
+ { path: ['image', 'proxy', 'dynamic'], type: 'set', value: true },
+ ]),
+ ).toEqual({ image: { proxy: { dynamic: true } } });
+ });
+
+ it('should delete values', () => {
+ expect(
+ applyConfigChanges({ image: { preload: true } }, [
+ { path: ['image', 'preload'], type: 'delete' },
+ ]),
+ ).toEqual({ image: {} });
+ });
+
+ it('should leave unknown keys untouched', () => {
+ expect(
+ applyConfigChanges({ image: { unknown_key: 42 } }, [
+ { path: ['image', 'title'], type: 'set', value: 'Front' },
+ ]),
+ ).toEqual({ image: { unknown_key: 42, title: 'Front' } });
+ });
+
+ it('should return null when the changes leave the configuration unmodified', () => {
+ expect(
+ applyConfigChanges({}, [{ path: ['image', 'preload'], type: 'delete' }]),
+ ).toBeNull();
+ });
+
+ it('should not modify the given configuration', () => {
+ const config = { image: { preload: true } };
+ applyConfigChanges(config, [
+ { path: ['image', 'preload'], type: 'set', value: false },
+ ]);
+ expect(config).toEqual({ image: { preload: true } });
+ });
+
+ it('should copy the values it sets rather than share them', () => {
+ const value = { mode: 'none' };
+ const result = applyConfigChanges({}, [
+ { path: ['image', 'thumbnails'], type: 'set', value },
+ ]);
+ expect(getConfigValue(result ?? {}, 'image.thumbnails')).toEqual(value);
+ expect(getConfigValue(result ?? {}, 'image.thumbnails')).not.toBe(value);
+ });
+});
+
+describe('field bindings', () => {
+ // A form gathering fields from across the configuration: the field is shown
+ // in the form under its own name, but stored somewhere else entirely.
+ const BOUND_FORM: EditorForm = {
+ basePath: [],
+ schema: [{ name: 'menu_style', selector: { text: {} } }],
+ bindings: [{ formPath: ['menu_style'], configPath: ['menu', 'style'] }],
+ };
+
+ it('should display the value from the bound path', () => {
+ expect(computeDisplayedData(BOUND_FORM, { menu: { style: 'outside' } }, {})).toEqual(
+ { menu: { style: 'outside' }, menu_style: 'outside' },
+ );
+ });
+
+ it('should display nothing for a bound path with no value or default', () => {
+ expect(computeDisplayedData(BOUND_FORM, {}, {})).toEqual({ menu_style: undefined });
+ });
+
+ it('should display a bound path configured to null as null', () => {
+ // As an unbound field would: only an absent value falls back to a default.
+ expect(
+ computeDisplayedData(
+ BOUND_FORM,
+ { menu: { style: null } },
+ { menu: { style: 'hidden' } },
+ ),
+ ).toMatchObject({ menu_style: null });
+ });
+
+ it('should bind nothing for a path that names no field', () => {
+ const form: EditorForm = {
+ basePath: ['live'],
+ schema: [
+ {
+ name: 'controls',
+ type: 'expandable',
+ schema: [{ name: 'mode', selector: { text: {} } }],
+ },
+ ],
+ // A group, not a field: its fields are bound individually or not at all.
+ bindings: [{ formPath: ['controls'], configPath: ['elsewhere'] }],
+ };
+
+ expect(computeConfigChanges(form, {}, { controls: { mode: 'above' } }, {})).toEqual([
+ { path: ['live', 'controls', 'mode'], type: 'set', value: 'above' },
+ ]);
+ });
+
+ it('should display the default of the bound path', () => {
+ expect(computeDisplayedData(BOUND_FORM, {}, { menu: { style: 'hidden' } })).toEqual({
+ menu_style: 'hidden',
+ });
+ });
+
+ it('should write an edit to the bound path', () => {
+ expect(
+ computeConfigChanges(
+ BOUND_FORM,
+ { menu_style: 'hidden' },
+ { menu_style: 'overlay' },
+ {},
+ ),
+ ).toEqual([{ path: ['menu', 'style'], type: 'set', value: 'overlay' }]);
+ });
+
+ it('should delete the bound path for an emptied field', () => {
+ expect(
+ computeConfigChanges(BOUND_FORM, { menu_style: 'hidden' }, { menu_style: '' }, {}),
+ ).toEqual([{ path: ['menu', 'style'], type: 'delete' }]);
+ });
+
+ describe('with a field that reads and writes itself', () => {
+ // One field standing for many configuration keys: a list of which menu
+ // buttons are on, where each button stores its own `enabled`. This is what
+ // a computed binding exists for: the field's shape in the form and its
+ // shape in the configuration have nothing to do with each other.
+ const BUTTONS = ['cameras', 'fullscreen', 'timeline'];
+ const isEnabled = (
+ config: RawAdvancedCameraCardConfig,
+ defaults: RawAdvancedCameraCardConfig,
+ button: string,
+ ): boolean =>
+ (getConfigValue(config, ['menu', 'buttons', button, 'enabled']) ??
+ getConfigValue(defaults, ['menu', 'buttons', button, 'enabled'])) !== false;
+
+ const BUTTONS_FORM: EditorForm = {
+ basePath: [],
+ schema: [
+ {
+ name: 'buttons',
+ selector: { select: { options: BUTTONS, multiple: true } },
+ },
+ ],
+ bindings: [
+ {
+ formPath: ['buttons'],
+ read: (config, defaults) =>
+ BUTTONS.filter((button) => isEnabled(config, defaults, button)),
+ write: (value, config, defaults) => {
+ const enabled = Array.isArray(value) ? value : [];
+ return BUTTONS.flatMap((button): ConfigChange[] => {
+ const path = ['menu', 'buttons', button, 'enabled'];
+ const wanted = enabled.includes(button);
+ if (wanted === isEnabled(config, defaults, button)) {
+ return [];
+ }
+ // Returning to the default deletes the key rather than writing a
+ // value the user never set.
+ const isDefault = wanted === (getConfigValue(defaults, path) !== false);
+ return isDefault
+ ? [{ path, type: 'delete' }]
+ : [{ path, type: 'set', value: wanted }];
+ });
+ },
+ },
+ ],
+ };
+
+ const DEFAULTS = {
+ menu: {
+ buttons: {
+ cameras: { enabled: true },
+ fullscreen: { enabled: false },
+ timeline: { enabled: true },
+ },
+ },
+ };
+
+ it('should display the buttons that are on', () => {
+ expect(
+ computeDisplayedData(
+ BUTTONS_FORM,
+ { menu: { buttons: { timeline: { enabled: false } } } },
+ DEFAULTS,
+ ),
+ ).toMatchObject({ buttons: ['cameras'] });
+ });
+
+ it('should write only the buttons whose state actually changed', () => {
+ const config = {};
+ const displayed = computeDisplayedData(BUTTONS_FORM, config, DEFAULTS);
+
+ // Turn `fullscreen` on (it defaults off) and `cameras` off (defaults on).
+ expect(
+ computeConfigChanges(
+ BUTTONS_FORM,
+ displayed,
+ { buttons: ['fullscreen', 'timeline'] },
+ config,
+ DEFAULTS,
+ ),
+ ).toEqual([
+ { path: ['menu', 'buttons', 'cameras', 'enabled'], type: 'set', value: false },
+ {
+ path: ['menu', 'buttons', 'fullscreen', 'enabled'],
+ type: 'set',
+ value: true,
+ },
+ ]);
+ });
+
+ it('should delete a button returned to its default rather than writing it', () => {
+ const config = { menu: { buttons: { cameras: { enabled: false } } } };
+ const displayed = computeDisplayedData(BUTTONS_FORM, config, DEFAULTS);
+
+ expect(
+ computeConfigChanges(
+ BUTTONS_FORM,
+ displayed,
+ { buttons: ['cameras', 'timeline'] },
+ config,
+ DEFAULTS,
+ ),
+ ).toEqual([{ path: ['menu', 'buttons', 'cameras', 'enabled'], type: 'delete' }]);
+ });
+ });
+});
diff --git a/tests/components-lib/editor/form-labels.test.ts b/tests/components-lib/editor/form-labels.test.ts
new file mode 100644
index 00000000..c267d470
--- /dev/null
+++ b/tests/components-lib/editor/form-labels.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ computeFormLabel,
+ getLocalizationKeyForPath,
+} from '../../../src/components-lib/editor/form-labels';
+
+describe('getLocalizationKeyForPath', () => {
+ it('should build a config key without array indices', () => {
+ expect(getLocalizationKeyForPath(['cameras', 2, 'title'])).toBe(
+ 'config.cameras.title',
+ );
+ });
+});
+
+describe('computeFormLabel', () => {
+ it('should prefer an explicit schema label', () => {
+ expect(
+ computeFormLabel(['cameras', 2], {
+ name: 'title',
+ label: 'Explicit',
+ selector: { text: {} },
+ }),
+ ).toBe('Explicit');
+ });
+
+ it('should localize the configuration path', () => {
+ expect(computeFormLabel(['view'], { name: 'default', selector: { text: {} } })).toBe(
+ 'Default view',
+ );
+ });
+
+ it('should include container paths provided by the form', () => {
+ expect(
+ computeFormLabel(
+ ['cameras', 2],
+ { name: 'title', selector: { text: {} } },
+ { path: [] },
+ ),
+ ).toBe('Title for this camera (autodetected from entity)');
+ });
+
+ it('should order nested container paths into configuration order', () => {
+ expect(
+ computeFormLabel(
+ ['cameras', 2],
+ { name: 'dashboard_path', selector: { text: {} } },
+ { path: ['dashboard', 'cast'] },
+ ),
+ ).toBe('Dashboard path');
+ });
+
+ it('should use the title for container nodes', () => {
+ expect(
+ computeFormLabel(['image'], {
+ name: 'proxy',
+ type: 'expandable',
+ title: 'Proxy',
+ schema: [],
+ }),
+ ).toBe('Proxy');
+ });
+
+ it('should fall back to the name for container nodes without a title', () => {
+ expect(
+ computeFormLabel(['image'], { name: 'proxy', type: 'expandable', schema: [] }),
+ ).toBe('proxy');
+ });
+
+ it('should return an empty label for a nameless, titleless container', () => {
+ expect(computeFormLabel(['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
new file mode 100644
index 00000000..1924d912
--- /dev/null
+++ b/tests/components-lib/editor/forms-controller.test.ts
@@ -0,0 +1,198 @@
+import { html } from 'lit';
+import { describe, expect, it, vi } from 'vitest';
+
+import { FormsController } from '../../../src/components-lib/editor/forms-controller';
+
+const OPTIONS = { cameras: [], folders: [] };
+const INPUT = { config: {}, defaults: {}, options: OPTIONS };
+const MENU = { kind: 'section' as const, name: 'menu' };
+
+const createController = () => {
+ const onChanges = vi.fn();
+ return {
+ onChanges,
+ controller: new FormsController(onChanges, () => html`doc`),
+ };
+};
+
+describe('FormsController', () => {
+ it('should have no contexts before it is given a request', () => {
+ const { controller } = createController();
+ expect(controller.getContexts()).toEqual([]);
+ });
+
+ it('should build the forms of the requested section', () => {
+ const { controller } = createController();
+
+ controller.setInput(MENU, INPUT);
+
+ const contexts = controller.getContexts();
+ expect(contexts.length).toBeGreaterThan(0);
+ expect(contexts[0].form.basePath).toEqual(['menu']);
+ });
+
+ it('should build the forms again for a different request', () => {
+ const { controller } = createController();
+ controller.setInput(MENU, INPUT);
+
+ controller.setInput({ kind: 'section', name: 'timeline' }, INPUT);
+
+ expect(controller.getContexts()[0].form.basePath).toEqual(['timeline']);
+ });
+
+ it('should keep the same forms when only the configuration changes', () => {
+ const { controller } = createController();
+ controller.setInput(MENU, INPUT);
+ const before = controller.getContexts()[0].form;
+
+ controller.setInput(MENU, { ...INPUT, config: { menu: { style: 'outside' } } });
+
+ expect(controller.getContexts()[0].form).toBe(before);
+ });
+
+ it('should keep the same contexts when nothing changed', () => {
+ const { controller } = createController();
+ controller.setInput(MENU, INPUT);
+ const before = controller.getContexts();
+
+ controller.setInput(MENU, { ...INPUT });
+
+ expect(controller.getContexts()).toBe(before);
+ });
+
+ it('should build the forms again when the values its selectors offer change', () => {
+ const { controller } = createController();
+ const request = { kind: 'camera' as const, index: 0 };
+ controller.setInput(request, INPUT);
+ const before = controller.getContexts()[0].form;
+
+ // A second camera: the first camera's dependency dropdown, which lists the
+ // cameras other than itself, now has something to offer.
+ controller.setInput(request, {
+ ...INPUT,
+ options: {
+ ...OPTIONS,
+ cameras: [
+ { value: 'camera.one', label: 'One' },
+ { value: 'camera.other', label: 'Other' },
+ ],
+ },
+ });
+
+ expect(controller.getContexts()[0].form).not.toBe(before);
+ });
+
+ 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 cameras = [
+ { value: 'camera.one', label: 'One' },
+ { value: 'camera.two', label: 'Two' },
+ ];
+ controller.setInput(request, { ...INPUT, options: { ...OPTIONS, cameras } });
+ const before = controller.getContexts()[0];
+
+ // Renaming the camera whose form this is: its own form leaves it out, so
+ // nothing it shows has changed.
+ controller.setInput(request, {
+ ...INPUT,
+ options: {
+ ...OPTIONS,
+ cameras: [{ ...cameras[0], label: 'Renamed' }, cameras[1]],
+ },
+ });
+
+ expect(controller.getContexts()[0]).toBe(before);
+ });
+
+ it('should show the configured value of a field', () => {
+ const { controller } = createController();
+
+ controller.setInput(MENU, { ...INPUT, config: { menu: { style: 'outside' } } });
+
+ expect(controller.getContexts()[0].displayedData['style']).toBe('outside');
+ });
+
+ it('should name a field', () => {
+ const { controller } = createController();
+ controller.setInput(MENU, INPUT);
+
+ const label = controller.getContexts()[0].computeLabel({
+ name: 'style',
+ selector: { text: {} },
+ });
+
+ expect(label).not.toBe('');
+ });
+
+ it('should link a documented field to its documentation', () => {
+ const { controller } = createController();
+ controller.setInput(MENU, INPUT);
+
+ expect(
+ controller.getContexts()[0].computeHelper({
+ name: 'buttons',
+ type: 'expandable',
+ title: '',
+ schema: [],
+ }),
+ ).not.toBeNull();
+ });
+
+ it('should not link a field that has no documentation', () => {
+ const { controller } = createController();
+ controller.setInput(MENU, INPUT);
+
+ expect(
+ controller.getContexts()[0].computeHelper({
+ name: 'style',
+ selector: { text: {} },
+ }),
+ ).toBeNull();
+ });
+
+ it('should report an edit as changes on absolute paths', () => {
+ const { controller, onChanges } = createController();
+ controller.setInput(MENU, INPUT);
+
+ controller
+ .getContexts()[0]
+ .valueChanged(
+ new CustomEvent('value-changed', { detail: { value: { style: 'outside' } } }),
+ );
+
+ expect(onChanges).toHaveBeenCalledWith([
+ { path: ['menu', 'style'], type: 'set', value: 'outside' },
+ ]);
+ });
+
+ it('should ignore an edit that changes nothing', () => {
+ const { controller, onChanges } = createController();
+ controller.setInput(MENU, { ...INPUT, config: { menu: { style: 'outside' } } });
+
+ controller
+ .getContexts()[0]
+ .valueChanged(
+ new CustomEvent('value-changed', { detail: { value: { style: 'outside' } } }),
+ );
+
+ expect(onChanges).not.toHaveBeenCalled();
+ });
+
+ it('should ignore an edit reported by a form it no longer has', () => {
+ const { controller, onChanges } = createController();
+ controller.setInput(MENU, INPUT);
+ const contexts = controller.getContexts();
+ expect(contexts.length).toBeGreaterThan(1);
+ const last = contexts[contexts.length - 1];
+
+ // 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);
+ last.valueChanged(
+ new CustomEvent('value-changed', { detail: { value: { style: 'outside' } } }),
+ );
+
+ expect(onChanges).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/components-lib/editor/keyboard-shortcuts-controller.test.ts b/tests/components-lib/editor/keyboard-shortcuts-controller.test.ts
new file mode 100644
index 00000000..9fdb056f
--- /dev/null
+++ b/tests/components-lib/editor/keyboard-shortcuts-controller.test.ts
@@ -0,0 +1,132 @@
+import { html } from 'lit';
+import { assert, describe, expect, it, vi, type Mock } from 'vitest';
+
+import { KeyboardShortcutsController } from '../../../src/components-lib/editor/keyboard-shortcuts-controller';
+import { configDefaults } from '../../../src/config/schema/types';
+import { PTZ_KEYBOARD_SHORTCUTS } from '../../../src/config/schema/view';
+import { createLitElement } from '../../test-utils';
+
+const OPTIONS = { cameras: [], folders: [] };
+
+const createController = () => {
+ const host = createLitElement();
+ const listener = vi.fn();
+ host.addEventListener('advanced-camera-card:editor:intent', listener);
+ return {
+ host,
+ listener,
+ controller: new KeyboardShortcutsController(host, () => html`doc`),
+ };
+};
+
+const getLastIntent = (listener: Mock): unknown => {
+ const event = listener.mock.lastCall?.[0];
+ assert(event instanceof CustomEvent);
+ return event.detail;
+};
+
+// @vitest-environment jsdom
+describe('KeyboardShortcutsController', () => {
+ it('should register with the host', () => {
+ const { host, controller } = createController();
+ expect(host.addController).toHaveBeenCalledWith(controller);
+ controller.hostConnected();
+ });
+
+ describe('should resolve the shortcuts', () => {
+ it('should have every shortcut, in order, before it is given any input', () => {
+ const { controller } = createController();
+ expect(Object.keys(controller.getShortcuts())).toEqual([
+ ...PTZ_KEYBOARD_SHORTCUTS,
+ ]);
+ });
+
+ it('should resolve a shortcut the configuration leaves unset to its default', () => {
+ const { controller } = createController();
+ controller.setInput({ config: {}, defaults: configDefaults, options: OPTIONS });
+
+ expect(controller.getShortcuts()['ptz_left']).toEqual(
+ configDefaults.view.keyboard_shortcuts.ptz_left,
+ );
+ });
+
+ it('should resolve a configured shortcut', () => {
+ const { controller } = createController();
+ controller.setInput({
+ config: { view: { keyboard_shortcuts: { ptz_home: { key: 'k', ctrl: true } } } },
+ defaults: configDefaults,
+ options: OPTIONS,
+ });
+
+ expect(controller.getShortcuts()['ptz_home']).toEqual({ key: 'k', ctrl: true });
+ });
+ });
+
+ describe('should report an assignment', () => {
+ it('should report a shortcut as an intent', () => {
+ const { controller, listener } = createController();
+ controller.setInput({ config: {}, defaults: configDefaults, options: OPTIONS });
+
+ controller.setShortcut('ptz_home', { key: 'k' });
+
+ expect(getLastIntent(listener)).toEqual({
+ type: 'changes',
+ changes: [
+ {
+ path: ['view', 'keyboard_shortcuts', 'ptz_home'],
+ type: 'set',
+ value: { key: 'k' },
+ },
+ ],
+ });
+ });
+
+ it('should record an unassignment rather than deleting the shortcut', () => {
+ const { controller, listener } = createController();
+ controller.setInput({ config: {}, defaults: configDefaults, options: OPTIONS });
+
+ controller.setShortcut('ptz_home', null);
+
+ expect(getLastIntent(listener)).toEqual({
+ type: 'changes',
+ changes: [
+ { path: ['view', 'keyboard_shortcuts', 'ptz_home'], type: 'set', value: null },
+ ],
+ });
+ });
+ });
+
+ describe('should offer the panel its own form', () => {
+ it('should have no forms before it is given any input', () => {
+ const { controller } = createController();
+ expect(controller.getContexts()).toEqual([]);
+ });
+
+ it('should build the shortcuts form', () => {
+ const { controller } = createController();
+ controller.setInput({ config: {}, defaults: configDefaults, options: OPTIONS });
+
+ const contexts = controller.getContexts();
+ expect(contexts).toHaveLength(1);
+ expect(contexts[0].form.basePath).toEqual(['view', 'keyboard_shortcuts']);
+ });
+
+ it('should report an edit to that form as an intent', () => {
+ const { controller, listener } = createController();
+ controller.setInput({ config: {}, defaults: configDefaults, options: OPTIONS });
+
+ controller
+ .getContexts()[0]
+ .valueChanged(
+ new CustomEvent('value-changed', { detail: { value: { enabled: false } } }),
+ );
+
+ expect(getLastIntent(listener)).toEqual({
+ type: 'changes',
+ changes: [
+ { path: ['view', 'keyboard_shortcuts', 'enabled'], type: 'set', value: false },
+ ],
+ });
+ });
+ });
+});
diff --git a/tests/components-lib/editor/list-forms-controller.test.ts b/tests/components-lib/editor/list-forms-controller.test.ts
new file mode 100644
index 00000000..4b479eb0
--- /dev/null
+++ b/tests/components-lib/editor/list-forms-controller.test.ts
@@ -0,0 +1,233 @@
+import { html } from 'lit';
+import { assert, describe, expect, it, vi, type Mock } from 'vitest';
+
+import { ListFormsController } from '../../../src/components-lib/editor/list-forms-controller';
+import { createLitElement } from '../../test-utils';
+
+const OPTIONS = { cameras: [], folders: [] };
+
+const createController = () => {
+ const host = createLitElement();
+ const listener = vi.fn();
+ host.addEventListener('advanced-camera-card:editor:intent', listener);
+ return {
+ host,
+ listener,
+ controller: new ListFormsController(host, () => html`doc`),
+ };
+};
+
+const getLastIntent = (listener: Mock): unknown => {
+ const event = listener.mock.lastCall?.[0];
+ assert(event instanceof CustomEvent);
+ return event.detail;
+};
+
+// @vitest-environment jsdom
+describe('ListFormsController', () => {
+ it('should register with the host', () => {
+ const { host, controller } = createController();
+ expect(host.addController).toHaveBeenCalledWith(controller);
+ controller.hostConnected();
+ });
+
+ describe('should read a list', () => {
+ it('should have no items before it is given any input', () => {
+ const { controller } = createController();
+ expect(controller.getList(['cameras'])).toEqual([]);
+ });
+
+ it('should read the items at a path', () => {
+ const { controller } = createController();
+ controller.setInput({
+ config: { cameras: [{ id: 'one' }] },
+ defaults: {},
+ options: OPTIONS,
+ });
+
+ expect(controller.getList(['cameras'])).toEqual([{ id: 'one' }]);
+ });
+
+ it('should read a nested list', () => {
+ const { controller } = createController();
+ controller.setInput({
+ config: { cameras: [{ triggers: { events: [{ event_type: 'a' }] } }] },
+ defaults: {},
+ options: OPTIONS,
+ });
+
+ expect(controller.getList(['cameras', 0, 'triggers', 'events'])).toEqual([
+ { event_type: 'a' },
+ ]);
+ });
+
+ it('should report an item that is not an object as an empty one', () => {
+ const { controller } = createController();
+ controller.setInput({
+ config: { cameras: [{ id: 'one' }, 'junk'] },
+ defaults: {},
+ options: OPTIONS,
+ });
+
+ expect(controller.getList(['cameras'])).toEqual([{ id: 'one' }, {}]);
+ });
+
+ it('should report no items for a value that is not a list', () => {
+ const { controller } = createController();
+ controller.setInput({
+ config: { cameras: 'junk' },
+ defaults: {},
+ options: OPTIONS,
+ });
+
+ expect(controller.getList(['cameras'])).toEqual([]);
+ });
+ });
+
+ 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([]);
+ });
+
+ 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: 'camera-event' as const, cameraIndex: 1, eventIndex: 3 },
+ ['cameras', 1, 'triggers', 'events', 3],
+ ],
+ ])('should build the forms for %j', (request, basePath) => {
+ const { controller } = createController();
+ controller.setInput({ config: {}, defaults: {}, options: OPTIONS });
+
+ const contexts = controller.getFormContexts(request);
+ expect(contexts).toHaveLength(1);
+ expect(contexts[0].form.basePath).toEqual(basePath);
+ });
+
+ it('should keep the forms of an item across configuration changes', () => {
+ const { controller } = createController();
+ const request = { kind: 'camera' as const, index: 0 };
+ controller.setInput({ config: {}, defaults: {}, options: OPTIONS });
+ const before = controller.getFormContexts(request)[0].form;
+
+ controller.setInput({
+ config: { cameras: [{ id: 'one' }] },
+ defaults: {},
+ options: OPTIONS,
+ });
+
+ expect(controller.getFormContexts(request)[0].form).toBe(before);
+ });
+
+ it('should keep the same contexts when nothing changed', () => {
+ const { controller } = createController();
+ const request = { kind: 'camera' as const, index: 0 };
+ const input = { config: {}, defaults: {}, options: OPTIONS };
+ controller.setInput(input);
+ const before = controller.getFormContexts(request);
+
+ controller.setInput({ ...input });
+
+ expect(controller.getFormContexts(request)).toBe(before);
+ });
+
+ it('should give each request its own contexts', () => {
+ 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 });
+ expect(camera[0]).not.toBe(folder[0]);
+
+ folder[0].valueChanged(
+ new CustomEvent('value-changed', { detail: { value: { title: 'Clips' } } }),
+ );
+
+ // The edit is attributed to the folder, not to the camera of the same
+ // index.
+ expect(getLastIntent(listener)).toEqual({
+ type: 'changes',
+ changes: [{ path: ['folders', 0, 'title'], type: 'set', value: 'Clips' }],
+ });
+ });
+
+ it('should rebuild the forms when the values its selectors offer change', () => {
+ const { controller } = createController();
+ const request = { kind: 'camera' as const, index: 0 };
+ controller.setInput({ config: {}, defaults: {}, options: OPTIONS });
+ const before = controller.getFormContexts(request)[0].form;
+
+ controller.setInput({
+ config: {},
+ defaults: {},
+ options: {
+ ...OPTIONS,
+ cameras: [
+ { value: 'camera.one', label: 'One' },
+ { value: 'camera.other', label: 'Other' },
+ ],
+ },
+ });
+
+ expect(controller.getFormContexts(request)[0].form).not.toBe(before);
+ });
+
+ it('should report an edit as an intent with an absolute path', () => {
+ const { controller, listener } = createController();
+ controller.setInput({ config: {}, defaults: {}, options: OPTIONS });
+
+ controller
+ .getFormContexts({ kind: 'camera', index: 2 })[0]
+ .valueChanged(
+ new CustomEvent('value-changed', { detail: { value: { id: 'front' } } }),
+ );
+
+ expect(getLastIntent(listener)).toEqual({
+ type: 'changes',
+ changes: [{ path: ['cameras', 2, 'id'], type: 'set', value: 'front' }],
+ });
+ });
+ });
+
+ describe('should report a list change as an intent', () => {
+ it('should report an addition', () => {
+ const { controller, listener } = createController();
+
+ controller.addItem(['cameras'], { id: 'one' });
+
+ expect(getLastIntent(listener)).toEqual({
+ type: 'list-add',
+ path: ['cameras'],
+ item: { id: 'one' },
+ });
+ });
+
+ it('should report a move', () => {
+ const { controller, listener } = createController();
+
+ controller.moveItem(['cameras'], 0, 1);
+
+ expect(getLastIntent(listener)).toEqual({
+ type: 'list-move',
+ path: ['cameras'],
+ from: 0,
+ to: 1,
+ });
+ });
+
+ it('should report a deletion', () => {
+ const { controller, listener } = createController();
+
+ controller.deleteItem(['cameras'], 1);
+
+ expect(getLastIntent(listener)).toEqual({
+ type: 'list-delete',
+ path: ['cameras'],
+ index: 1,
+ });
+ });
+ });
+});
diff --git a/tests/components-lib/editor/list-pages-controller.test.ts b/tests/components-lib/editor/list-pages-controller.test.ts
new file mode 100644
index 00000000..1bd9b624
--- /dev/null
+++ b/tests/components-lib/editor/list-pages-controller.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from 'vitest';
+
+import { ListPagesController } from '../../../src/components-lib/editor/list-pages-controller';
+import { createLitElement } from '../../test-utils';
+
+const createController = () => {
+ const host = createLitElement();
+ return { host, controller: new ListPagesController(host) };
+};
+
+// @vitest-environment jsdom
+describe('ListPagesController', () => {
+ it('should register with the host', () => {
+ const { host, controller } = createController();
+ expect(host.addController).toHaveBeenCalledWith(controller);
+ controller.hostConnected();
+ });
+
+ it('should start on the list itself', () => {
+ const { controller } = createController();
+ expect(controller.getPath()).toEqual([]);
+ });
+
+ it('should open an item and request an update', () => {
+ const { host, controller } = createController();
+
+ controller.open('cameras', 2);
+
+ expect(controller.getPath()).toEqual([{ list: 'cameras', index: 2 }]);
+ expect(host.requestUpdate).toHaveBeenCalledTimes(1);
+ });
+
+ it('should open an item within an open item', () => {
+ const { controller } = createController();
+ controller.open('cameras', 2);
+
+ controller.open('events', 0);
+
+ expect(controller.getPath()).toEqual([
+ { list: 'cameras', index: 2 },
+ { list: 'events', index: 0 },
+ ]);
+ });
+
+ it('should go back to the item above', () => {
+ const { controller } = createController();
+ controller.open('cameras', 2);
+ controller.open('events', 0);
+
+ controller.back();
+
+ expect(controller.getPath()).toEqual([{ list: 'cameras', index: 2 }]);
+ });
+
+ it('should go back to the list itself', () => {
+ const { host, controller } = createController();
+ controller.open('cameras', 2);
+
+ controller.back();
+
+ expect(controller.getPath()).toEqual([]);
+ expect(host.requestUpdate).toHaveBeenCalledTimes(2);
+ });
+
+ it('should ignore going back from the list itself', () => {
+ const { host, controller } = createController();
+
+ controller.back();
+
+ expect(controller.getPath()).toEqual([]);
+ expect(host.requestUpdate).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/components-lib/editor/paths.test.ts b/tests/components-lib/editor/paths.test.ts
new file mode 100644
index 00000000..4595d0a5
--- /dev/null
+++ b/tests/components-lib/editor/paths.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ getFormContainerPath,
+ stripArrayIndices,
+} from '../../../src/components-lib/editor/paths';
+
+describe('getFormContainerPath', () => {
+ it('should reverse the innermost-first segments into configuration order', () => {
+ expect(getFormContainerPath({ path: ['dashboard', 'cast'] })).toEqual([
+ 'cast',
+ 'dashboard',
+ ]);
+ });
+
+ it('should return an empty path without options', () => {
+ expect(getFormContainerPath()).toEqual([]);
+ });
+
+ it('should return an empty path without a path in the options', () => {
+ expect(getFormContainerPath({})).toEqual([]);
+ });
+
+ it('should not modify the passed segments', () => {
+ const path = ['dashboard', 'cast'];
+ getFormContainerPath({ path });
+ expect(path).toEqual(['dashboard', 'cast']);
+ });
+});
+
+describe('stripArrayIndices', () => {
+ it('should strip numeric segments', () => {
+ expect(stripArrayIndices(['cameras', 2, 'title'])).toEqual(['cameras', 'title']);
+ });
+
+ it('should strip numeric string segments', () => {
+ expect(stripArrayIndices(['cameras', '2', 'title'])).toEqual(['cameras', 'title']);
+ });
+
+ it('should leave paths without indices untouched', () => {
+ expect(stripArrayIndices(['live', 'controls'])).toEqual(['live', 'controls']);
+ });
+});
diff --git a/tests/components-lib/editor/schema/common/selectors.test.ts b/tests/components-lib/editor/schema/common/selectors.test.ts
new file mode 100644
index 00000000..40e93b78
--- /dev/null
+++ b/tests/components-lib/editor/schema/common/selectors.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ createNumberSelector,
+ createSelectSelector,
+} from '../../../../../src/components-lib/editor/schema/common/selectors';
+
+describe('createSelectSelector', () => {
+ it('should build a single-value dropdown from options', () => {
+ expect(createSelectSelector(['a', 'b'])).toEqual({
+ select: {
+ mode: 'dropdown',
+ multiple: false,
+ custom_value: false,
+ options: ['a', 'b'],
+ },
+ });
+ });
+
+ it('should allow multiple values', () => {
+ expect(createSelectSelector(['a'], { multiple: true }).select.multiple).toBe(true);
+ });
+
+ it('should allow custom values only when no options are given', () => {
+ expect(createSelectSelector([]).select.custom_value).toBe(true);
+ expect(createSelectSelector(['a']).select.custom_value).toBe(false);
+ });
+});
+
+describe('createNumberSelector', () => {
+ it('should default to an input box with a zero minimum', () => {
+ expect(createNumberSelector()).toEqual({
+ number: { min: 0, max: undefined, mode: 'box', step: undefined },
+ });
+ });
+
+ it('should be a slider when a maximum is given', () => {
+ expect(createNumberSelector({ min: 1, max: 10, step: 2 })).toEqual({
+ number: { min: 1, max: 10, mode: 'slider', step: 2 },
+ });
+ });
+});
diff --git a/tests/components-lib/editor/schema/completeness.test.ts b/tests/components-lib/editor/schema/completeness.test.ts
new file mode 100644
index 00000000..63156d28
--- /dev/null
+++ b/tests/components-lib/editor/schema/completeness.test.ts
@@ -0,0 +1,312 @@
+import { describe, expect, it } from 'vitest';
+import { z } from 'zod';
+
+import { forEachFieldRecursively } from '../../../../src/components-lib/editor/form-data';
+import {
+ getCameraSchema,
+ getCameraTriggersSchema,
+ getTriggerEventSchema,
+} from '../../../../src/components-lib/editor/schema/cameras';
+import { getDimensionsSectionForms } from '../../../../src/components-lib/editor/schema/dimensions';
+import { getFolderSchema } from '../../../../src/components-lib/editor/schema/folders';
+import { getImageSectionForms } from '../../../../src/components-lib/editor/schema/image';
+import { getLiveSectionForms } from '../../../../src/components-lib/editor/schema/live';
+import { getMediaGallerySectionForms } from '../../../../src/components-lib/editor/schema/media-gallery';
+import { getMediaViewerSectionForms } from '../../../../src/components-lib/editor/schema/media-viewer';
+import { getMenuSectionForms } from '../../../../src/components-lib/editor/schema/menu';
+import { getPerformanceSectionForms } from '../../../../src/components-lib/editor/schema/performance';
+import { getProfilesSectionForms } from '../../../../src/components-lib/editor/schema/profiles';
+import { getRemoteControlSectionForms } from '../../../../src/components-lib/editor/schema/remote-control';
+import { getStatusBarSectionForms } from '../../../../src/components-lib/editor/schema/status-bar';
+import { getTimelineSectionForms } from '../../../../src/components-lib/editor/schema/timeline';
+import {
+ getViewKeyboardShortcutsSectionForms,
+ getViewSectionForms,
+} from '../../../../src/components-lib/editor/schema/view';
+import type { EditorForm } from '../../../../src/components-lib/editor/types';
+import { advancedCameraCardConfigSchema } from '../../../../src/config/schema/types';
+import { PTZ_KEYBOARD_SHORTCUTS } from '../../../../src/config/schema/view';
+import type { HAFormSelectorSchema } from '../../../../src/ha/types';
+
+// ============================================================================
+// Editor <-> Zod Schema Completeness tests
+//
+// Two directions:
+// - Direction 1 (editor -> config): every editor field path is a real config
+// key, its selector kind matches the zod type (number/boolean/enum), and its
+// dropdown offers every enum value.
+// - Direction 2 (config -> editor): every configurable zod leaf appears in an
+// editor form, unless explicitly excepted. Catches config fields forgotten in
+// the editor.
+// ============================================================================
+
+// Configuration subtrees the editor renders with a dedicated widget instead of
+// an `ha-form` field, so the field walk cannot see them.
+const EDITOR_CUSTOM_WIDGETS = PTZ_KEYBOARD_SHORTCUTS.map(
+ (name) => `view.keyboard_shortcuts.${name}`,
+);
+
+// Configuration subtrees intentionally kept YAML-only.
+const EDITOR_EXCLUDED = [
+ 'cameras_global',
+ 'elements',
+ 'automations',
+ 'overrides',
+ 'debug',
+ 'type',
+ 'card_id',
+ 'card_mod',
+
+ // Advanced/freeform camera subtrees not surfaced in the editor.
+ 'cameras.jsmpeg',
+ 'cameras.ptz',
+ 'cameras.dimensions.grid',
+ 'cameras.triggers.events.context',
+
+ // Free-form CSS variable overrides.
+ 'view.theme.overrides',
+
+ // Not surfaced in the editor (the maintainer declined adding it).
+ 'view.render_entities',
+
+ // Section-level action handlers (`tap_action` etc.) need a dedicated action
+ // editor and have never been exposed.
+ 'view.actions',
+ 'image.actions',
+ 'media_gallery.actions',
+ 'live.actions',
+ 'media_viewer.actions',
+
+ // Free-form CSS object for the PTZ control styling.
+ 'live.controls.ptz.style',
+ 'media_viewer.controls.ptz.style',
+
+ // Advanced folder path parsers/matchers/templating: text-editor only.
+ 'folders.ha.path',
+];
+
+// Every section's forms, keyed by section, so the harness walks the full
+// field tree of everything rendered via `ha-form`.
+const SECTION_FORMS: Record = {
+ profiles: getProfilesSectionForms(),
+ view: [...getViewSectionForms(), ...getViewKeyboardShortcutsSectionForms()],
+ image: getImageSectionForms(),
+ live: getLiveSectionForms(),
+ media_gallery: getMediaGallerySectionForms(),
+ media_viewer: getMediaViewerSectionForms(),
+ menu: getMenuSectionForms(),
+ status_bar: getStatusBarSectionForms(),
+ timeline: getTimelineSectionForms(),
+ dimensions: getDimensionsSectionForms(),
+ performance: getPerformanceSectionForms(),
+ remote_control: getRemoteControlSectionForms(),
+
+ // Array section: one representative item at index 0 for testing purposes. The
+ // numeric index is stripped when building covered paths, so this checks the
+ // item's fields (`folders.type`, ...) against the array element's schema.
+ folders: [{ basePath: ['folders', 0], schema: getFolderSchema() }],
+
+ // Array section split across the camera form, the triggers sub-form, and the
+ // per-event item form (the triggers group is hand-built to host the events
+ // list, so its fields live in a separate schema).
+ cameras: [
+ {
+ basePath: ['cameras', 0],
+ schema: getCameraSchema({
+ otherCameras: [{ value: 'camera.other', label: 'Other' }],
+ folders: [{ value: 'folder-1', label: 'Folder' }],
+ }),
+ },
+ {
+ basePath: ['cameras', 0, 'triggers'],
+ schema: getCameraTriggersSchema(),
+ },
+ {
+ basePath: ['cameras', 0, 'triggers', 'events', 0],
+ schema: getTriggerEventSchema(),
+ },
+ ],
+};
+
+// `z.core.$ZodType` is zod v4's base schema type for introspection: it is what
+// `.unwrap()` and `ZodArray.element` return, and what every schema (including
+// the config root) is assignable to. The classic `z.ZodType` is a subtype with
+// extra methods, so those introspection results would not assign back to it.
+
+// Unwrap the zod wrappers that carry no structural meaning for path
+// navigation (optional/default/nullable/readonly/lazy).
+const unwrap = (schema: z.core.$ZodType): z.core.$ZodType => {
+ let current: z.core.$ZodType = schema;
+ for (let guard = 0; guard < 20; guard++) {
+ if (
+ current instanceof z.ZodOptional ||
+ current instanceof z.ZodNullable ||
+ current instanceof z.ZodDefault ||
+ current instanceof z.ZodReadonly
+ ) {
+ current = current.unwrap();
+ } else if (current instanceof z.ZodLazy) {
+ current = current.unwrap();
+ } else {
+ break;
+ }
+ }
+ return current;
+};
+
+// Resolve a configuration path to its zod leaf schema, or null if the path
+// does not exist in the configuration schema.
+const resolvePath = (path: (string | number)[]): z.core.$ZodType | null => {
+ let current: z.core.$ZodType = advancedCameraCardConfigSchema;
+ for (const segment of path) {
+ current = unwrap(current);
+ if (current instanceof z.ZodObject) {
+ const next: z.core.$ZodType | undefined = current.shape[segment];
+ if (!next) {
+ return null;
+ }
+ current = next;
+ } else if (current instanceof z.ZodArray) {
+ current = current.element;
+ } else {
+ return null;
+ }
+ }
+ return unwrap(current);
+};
+
+// A selector object has exactly one key naming its kind (`{ number: {} }` ->
+// 'number', `{ select: {} }` -> 'select'), so its single key is the kind.
+const selectorKind = (field: HAFormSelectorSchema): string =>
+ Object.keys(field.selector)[0];
+
+// Collect the literal values of a zod enum or union-of-literals, or null if the
+// schema is not an enumeration.
+const enumValues = (schema: z.core.$ZodType): unknown[] | null => {
+ if (schema instanceof z.ZodEnum) {
+ return Object.values(schema.enum);
+ }
+ if (schema instanceof z.ZodUnion) {
+ const values: unknown[] = [];
+ for (const option of schema.options) {
+ const inner = unwrap(option);
+ if (inner instanceof z.ZodLiteral) {
+ values.push(inner.value);
+ } else {
+ return null;
+ }
+ }
+ return values;
+ }
+ return null;
+};
+
+// True if a config path lies inside a subtree the editor forms do not own
+// (YAML-only, or rendered by a custom widget), so the completeness walk skips
+// it.
+const isPruned = (path: (string | number)[]): boolean => {
+ const key = path.join('.');
+ return [...EDITOR_CUSTOM_WIDGETS, ...EDITOR_EXCLUDED].some(
+ (prefix) => key === prefix || key.startsWith(`${prefix}.`),
+ );
+};
+
+// Enumerate the configuration schema's leaf field paths (a leaf is anything
+// that is not a plain object; arrays are descended into rather than treated
+// as leaves), pruning the subtrees the editor does not own.
+const enumerateConfigLeavesRecursively = (
+ schema: z.core.$ZodType,
+ prefix: (string | number)[] = [],
+): string[] => {
+ if (prefix.length && isPruned(prefix)) {
+ return [];
+ }
+ const current = unwrap(schema);
+ if (current instanceof z.ZodObject) {
+ return Object.keys(current.shape).flatMap((key) =>
+ enumerateConfigLeavesRecursively(current.shape[key], [...prefix, key]),
+ );
+ }
+ if (current instanceof z.ZodArray) {
+ // Descend into array items without an index: array sections cover the item
+ // fields once (e.g. `folders.type`, not `folders.0.type`).
+ return enumerateConfigLeavesRecursively(current.element, prefix);
+ }
+ return [prefix.join('.')];
+};
+
+// Every configuration path the editor forms currently cover.
+const coveredPaths = new Set();
+for (const forms of Object.values(SECTION_FORMS)) {
+ for (const form of forms) {
+ forEachFieldRecursively(form.schema, (path) => {
+ // Array sections carry a numeric index in `basePath` (`['folders', 0]`);
+ // strip it so covered paths match the index-free enumerated leaves.
+ const key = [...form.basePath, ...path]
+ .filter((segment) => typeof segment !== 'number')
+ .join('.');
+ coveredPaths.add(key);
+ });
+ }
+}
+
+describe('editor schema completeness', () => {
+ // Direction 1: every editor field path is a real configuration key.
+ describe('every form field resolves to a matching configuration key', () => {
+ for (const [section, forms] of Object.entries(SECTION_FORMS)) {
+ for (const form of forms) {
+ forEachFieldRecursively(form.schema, (path, field) => {
+ const fullPath = [...form.basePath, ...path];
+ const key = fullPath.join('.');
+
+ it(`${section}: ${key}`, () => {
+ const resolved = resolvePath(fullPath);
+ expect(resolved, `path ${key} is not in the config schema`).not.toBeNull();
+
+ // Direction 1b: the selector kind matches the zod type where the
+ // type is unambiguous (number/boolean/enum).
+ const kind = selectorKind(field);
+ if (resolved instanceof z.ZodNumber) {
+ expect(kind, `${key} should use a number selector`).toBe('number');
+ } else if (resolved instanceof z.ZodBoolean) {
+ expect(kind, `${key} should use a boolean selector`).toBe('boolean');
+ } else {
+ const values = resolved ? enumValues(resolved) : null;
+ if (values) {
+ expect(kind, `${key} should use a select selector`).toBe('select');
+ // Direction 1c: the dropdown offers every enum value.
+ if ('select' in field.selector) {
+ const options = field.selector.select.options.map((option) =>
+ typeof option === 'object' ? option.value : option,
+ );
+ for (const value of values) {
+ expect(
+ options,
+ `${key} dropdown is missing enum value ${String(value)}`,
+ ).toContain(value);
+ }
+ }
+ }
+ }
+ });
+ });
+ }
+ }
+ });
+
+ // Direction 2: every configurable field (outside the excluded subtrees)
+ // appears in the editor. This is the forgotten-feature catcher.
+ describe('every configurable field appears in the editor', () => {
+ for (const path of enumerateConfigLeavesRecursively(
+ advancedCameraCardConfigSchema,
+ )) {
+ it(path, () => {
+ expect(
+ coveredPaths.has(path),
+ `${path} is a configurable field but is not in any editor form ` +
+ `(add it to the editor, or to EDITOR_CUSTOM_WIDGETS / EDITOR_EXCLUDED)`,
+ ).toBe(true);
+ });
+ }
+ });
+});
diff --git a/tests/components-lib/editor/schema/registry.test.ts b/tests/components-lib/editor/schema/registry.test.ts
new file mode 100644
index 00000000..840669ef
--- /dev/null
+++ b/tests/components-lib/editor/schema/registry.test.ts
@@ -0,0 +1,90 @@
+import { assert, describe, expect, it } from 'vitest';
+
+import { getForms } from '../../../../src/components-lib/editor/schema/registry';
+
+const OPTIONS = { cameras: [], folders: [] };
+
+// Every section the editor offers, and where its first form edits.
+const SECTIONS: [string, (string | number)[]][] = [
+ ['dimensions', ['dimensions']],
+ ['image', ['image']],
+ ['live', ['live']],
+ ['media_gallery', ['media_gallery', 'controls']],
+ ['media_viewer', ['media_viewer']],
+ ['menu', ['menu']],
+ ['performance', ['performance']],
+ ['profiles', []],
+ ['remote_control', ['remote_control']],
+ ['status_bar', ['status_bar']],
+ ['timeline', ['timeline']],
+ ['view', ['view']],
+ ['view.keyboard_shortcuts', ['view', 'keyboard_shortcuts']],
+];
+
+describe('getForms', () => {
+ describe('should build the forms of a section', () => {
+ it.each(SECTIONS)('should build the %s section', (name, basePath) => {
+ const forms = getForms({ kind: 'section', name }, OPTIONS);
+
+ expect(forms.length).toBeGreaterThan(0);
+ expect(forms[0].basePath).toEqual(basePath);
+ forms.forEach((form) => expect(form.schema.length).toBeGreaterThan(0));
+ });
+
+ it('should build no forms for a section it does not know', () => {
+ expect(getForms({ kind: 'section', name: 'nonexistent' }, OPTIONS)).toEqual([]);
+ });
+ });
+
+ describe('should offer a camera the other cameras, but not itself', () => {
+ const CAMERAS = [
+ { value: 'one', label: 'One' },
+ { value: 'two', label: 'Two' },
+ { value: 'three', label: 'Three' },
+ ];
+
+ const getDependencyOptions = (index: number): unknown => {
+ const [form] = getForms(
+ { kind: 'camera', index },
+ { ...OPTIONS, cameras: CAMERAS },
+ );
+ const dependencies = form.schema.find(
+ (field) => 'name' in field && field.name === 'dependencies',
+ );
+ assert(dependencies && 'schema' in dependencies);
+ const cameras = dependencies.schema.find(
+ (field) => 'name' in field && field.name === 'cameras',
+ );
+ assert(cameras && 'selector' in cameras && 'select' in cameras.selector);
+ return cameras.selector.select?.options;
+ };
+
+ it.each([
+ [0, ['two', 'three']],
+ [1, ['one', 'three']],
+ [2, ['one', 'two']],
+ ])('should leave camera %i out of its own dependencies', (index, expected) => {
+ expect(getDependencyOptions(index)).toEqual(
+ CAMERAS.filter((camera) => expected.includes(camera.value)),
+ );
+ });
+ });
+
+ describe('should build the forms of a list item', () => {
+ it.each([
+ [{ kind: 'camera' as const, index: 2 }, ['cameras', 2]],
+ [{ kind: 'folder' as const, index: 1 }, ['folders', 1]],
+ [{ kind: 'camera-triggers' as const, cameraIndex: 2 }, ['cameras', 2, 'triggers']],
+ [
+ { kind: 'camera-event' as const, cameraIndex: 2, eventIndex: 4 },
+ ['cameras', 2, 'triggers', 'events', 4],
+ ],
+ ])('should build the forms for %j', (request, basePath) => {
+ const forms = getForms(request, OPTIONS);
+
+ expect(forms).toHaveLength(1);
+ expect(forms[0].basePath).toEqual(basePath);
+ expect(forms[0].schema.length).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/tests/components-lib/editor/section-controller.test.ts b/tests/components-lib/editor/section-controller.test.ts
new file mode 100644
index 00000000..9ba1d305
--- /dev/null
+++ b/tests/components-lib/editor/section-controller.test.ts
@@ -0,0 +1,100 @@
+import { html } from 'lit';
+import { describe, expect, it, vi } from 'vitest';
+
+import { SectionController } from '../../../src/components-lib/editor/section-controller';
+import { createLitElement } from '../../test-utils';
+
+const OPTIONS = { cameras: [], folders: [] };
+const INPUT = { config: {}, defaults: {}, options: OPTIONS };
+
+const createController = () => {
+ const host = createLitElement();
+ return { host, controller: new SectionController(host, () => html`doc`) };
+};
+
+// @vitest-environment jsdom
+describe('SectionController', () => {
+ it('should register with the host', () => {
+ const { host, controller } = createController();
+ expect(host.addController).toHaveBeenCalledWith(controller);
+ controller.hostConnected();
+ });
+
+ it('should have no contexts before it is given a request', () => {
+ const { controller } = createController();
+ expect(controller.getContexts()).toEqual([]);
+ });
+
+ it('should build the forms of the requested section', () => {
+ const { controller } = createController();
+
+ controller.setInput({ kind: 'section', name: 'menu' }, INPUT);
+
+ const contexts = controller.getContexts();
+ expect(contexts.length).toBeGreaterThan(0);
+ expect(contexts[0].form.basePath).toEqual(['menu']);
+ });
+
+ it('should report an edit as an intent', () => {
+ const { host, controller } = createController();
+ const listener = vi.fn();
+ host.addEventListener('advanced-camera-card:editor:intent', listener);
+ controller.setInput({ kind: 'section', name: 'menu' }, INPUT);
+
+ controller
+ .getContexts()[0]
+ .valueChanged(
+ new CustomEvent('value-changed', { detail: { value: { style: 'outside' } } }),
+ );
+
+ const event = listener.mock.lastCall?.[0];
+ expect(event).toBeInstanceOf(CustomEvent);
+ if (event instanceof CustomEvent) {
+ expect(event.detail).toEqual({
+ type: 'changes',
+ changes: [{ path: ['menu', 'style'], type: 'set', value: 'outside' }],
+ });
+ // The editor is the only thing that listens, and it sits outside this
+ // section's shadow root.
+ expect(event.bubbles).toBe(true);
+ expect(event.composed).toBe(true);
+ }
+ });
+
+ describe('should track whether it is open', () => {
+ it('should start closed and never opened', () => {
+ const { controller } = createController();
+ expect(controller.isOpen()).toBe(false);
+ expect(controller.wasEverOpened()).toBe(false);
+ });
+
+ it('should open and request an update', () => {
+ const { host, controller } = createController();
+
+ controller.setOpen(true);
+
+ expect(controller.isOpen()).toBe(true);
+ expect(controller.wasEverOpened()).toBe(true);
+ expect(host.requestUpdate).toHaveBeenCalledTimes(1);
+ });
+
+ it('should remember having been opened after it closes', () => {
+ const { controller } = createController();
+ controller.setOpen(true);
+
+ controller.setOpen(false);
+
+ expect(controller.isOpen()).toBe(false);
+ expect(controller.wasEverOpened()).toBe(true);
+ });
+
+ it('should ignore a state it is already in', () => {
+ const { host, controller } = createController();
+ controller.setOpen(true);
+
+ controller.setOpen(true);
+
+ expect(host.requestUpdate).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/tests/components-lib/editor/titles.test.ts b/tests/components-lib/editor/titles.test.ts
new file mode 100644
index 00000000..45a3db97
--- /dev/null
+++ b/tests/components-lib/editor/titles.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ getEditorCameraTitle,
+ getEditorFolderTitle,
+ getEditorTriggerEventTitle,
+} from '../../../src/components-lib/editor/titles';
+import { createHASS, createStateEntity } from '../../test-utils';
+
+describe('getEditorCameraTitle', () => {
+ it('should prefer the configured title', () => {
+ expect(getEditorCameraTitle(0, { title: 'Front', id: 'front' })).toBe('Front');
+ });
+
+ it('should use the camera entity title', () => {
+ const hass = createHASS({
+ 'camera.front': createStateEntity({
+ attributes: { friendly_name: 'Front Door' },
+ }),
+ });
+ expect(getEditorCameraTitle(0, { camera_entity: 'camera.front' }, hass)).toBe(
+ 'Front Door',
+ );
+ });
+
+ it('should use the webrtc_card entity', () => {
+ expect(getEditorCameraTitle(0, { webrtc_card: { entity: 'camera.front' } })).toBe(
+ 'camera.front',
+ );
+ });
+
+ it('should prettify the frigate camera name', () => {
+ expect(getEditorCameraTitle(0, { frigate: { camera_name: 'front_door' } })).toBe(
+ 'Front Door',
+ );
+ });
+
+ it('should use the camera id', () => {
+ expect(getEditorCameraTitle(0, { id: 'front' })).toBe('front');
+ });
+
+ it('should fall back to an indexed label', () => {
+ expect(getEditorCameraTitle(2, {})).toBe('Camera #2');
+ expect(getEditorCameraTitle(2, 'NOT_AN_OBJECT')).toBe('Camera #2');
+ });
+});
+
+describe('getEditorFolderTitle', () => {
+ it('should prefer the configured title', () => {
+ expect(getEditorFolderTitle(0, { title: 'Snapshots', id: 'snaps' })).toBe(
+ 'Snapshots',
+ );
+ });
+
+ it('should use the folder id', () => {
+ expect(getEditorFolderTitle(0, { id: 'snaps' })).toBe('snaps');
+ });
+
+ it('should fall back to an indexed label', () => {
+ expect(getEditorFolderTitle(1, {})).toBe('Folder #1');
+ expect(getEditorFolderTitle(1, 'NOT_AN_OBJECT')).toBe('Folder #1');
+ });
+});
+
+describe('getEditorTriggerEventTitle', () => {
+ it('should prefer the event type', () => {
+ expect(getEditorTriggerEventTitle(0, { event_type: 'motion' })).toBe('motion');
+ });
+
+ it('should fall back to an indexed label', () => {
+ expect(getEditorTriggerEventTitle(3, {})).toBe('Event #3');
+ expect(getEditorTriggerEventTitle(3, 'NOT_AN_OBJECT')).toBe('Event #3');
+ });
+});
diff --git a/tests/components-lib/icon-controller.test.ts b/tests/components-lib/icon-controller.test.ts
index e1ac40dc..17d80e0f 100644
--- a/tests/components-lib/icon-controller.test.ts
+++ b/tests/components-lib/icon-controller.test.ts
@@ -4,41 +4,28 @@ import { IconController } from '../../src/components-lib/icon-controller';
import { createHASS, createStateEntity } from '../test-utils';
describe('IconController', () => {
- describe('should get custom icon', () => {
- it('should return frigate SVG for frigate icon', () => {
- expect(new IconController().getCustomIcon({ icon: 'frigate' })).toMatch(
- /frigate.svg$/,
- );
+ describe('should get icon name', () => {
+ it.each(['frigate', 'iris', 'motioneye', 'reolink', 'tplink'])(
+ 'should prefix the bare legacy name %s',
+ (name) => {
+ expect(new IconController().getIconName({ icon: name })).toBe(
+ `advanced-camera-card:${name}`,
+ );
+ },
+ );
+
+ it('should leave an iconset-prefixed name untouched', () => {
+ expect(
+ new IconController().getIconName({ icon: 'advanced-camera-card:frigate' }),
+ ).toBe('advanced-camera-card:frigate');
});
- it('should return motioneye SVG for motioneye icon', () => {
- expect(new IconController().getCustomIcon({ icon: 'motioneye' })).toMatch(
- /motioneye.svg$/,
- );
+ it('should leave an mdi name untouched', () => {
+ expect(new IconController().getIconName({ icon: 'mdi:car' })).toBe('mdi:car');
});
- it('should return reolink SVG for reolink icon', () => {
- expect(new IconController().getCustomIcon({ icon: 'reolink' })).toMatch(
- /reolink.svg$/,
- );
- });
-
- it('should return tplink SVG for tplink icon', () => {
- expect(new IconController().getCustomIcon({ icon: 'tplink' })).toMatch(
- /tplink.svg$/,
- );
- });
-
- it('should return iris SVG for iris icon', () => {
- expect(new IconController().getCustomIcon({ icon: 'iris' })).toMatch(/iris.svg$/);
- });
-
- it('should return null for mdi icon', () => {
- expect(new IconController().getCustomIcon({ icon: 'mdi:car' })).toBeNull();
- });
-
- it('should return null for undefined icon', () => {
- expect(new IconController().getCustomIcon()).toBeNull();
+ it('should return null for an undefined icon', () => {
+ expect(new IconController().getIconName()).toBeNull();
});
});
diff --git a/tests/components-lib/key-assigner-controller.test.ts b/tests/components-lib/key-assigner-controller.test.ts
index 529a89ac..64bbd697 100644
--- a/tests/components-lib/key-assigner-controller.test.ts
+++ b/tests/components-lib/key-assigner-controller.test.ts
@@ -40,6 +40,35 @@ describe('KeyAssignerController', () => {
});
});
+ describe('should show a value without reporting it', () => {
+ it('should adopt the value it is given', () => {
+ const element = createLitElement();
+ const controller = new KeyAssignerController(element);
+ const listener = vi.fn();
+ element.addEventListener('value-changed', listener);
+
+ controller.showValue({ key: 'ArrowLeft' });
+
+ expect(controller.getValue()).toEqual({ key: 'ArrowLeft' });
+ expect(element.requestUpdate).toHaveBeenCalled();
+
+ // The value came from outside: reporting it back would have the owner
+ // store a shortcut the user never assigned.
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('should do nothing for the value it already has', () => {
+ const element = createLitElement();
+ const controller = new KeyAssignerController(element);
+ controller.showValue({ key: 'ArrowLeft' });
+ vi.mocked(element.requestUpdate).mockClear();
+
+ controller.showValue({ key: 'ArrowLeft' });
+
+ expect(element.requestUpdate).not.toHaveBeenCalled();
+ });
+ });
+
describe('should manage assignment state', () => {
it('should not be assigned to start', () => {
const element = createLitElement();
diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts
index 2ed3c5fa..f2000aad 100644
--- a/tests/components-lib/menu-button-controller.test.ts
+++ b/tests/components-lib/menu-button-controller.test.ts
@@ -107,7 +107,7 @@ describe('MenuButtonController', () => {
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
- icon: 'iris',
+ icon: 'advanced-camera-card:iris',
enabled: true,
permanent: true,
priority: 50,
@@ -126,7 +126,7 @@ describe('MenuButtonController', () => {
expect(buttons).toContainEqual({
alignment: 'matching',
state_color: true,
- icon: 'iris',
+ icon: 'advanced-camera-card:iris',
enabled: true,
permanent: true,
priority: 50,
diff --git a/tests/components-lib/notification/notification-popup-controller.test.ts b/tests/components-lib/notification/notification-popup-controller.test.ts
index 7f04be44..6ce301f3 100644
--- a/tests/components-lib/notification/notification-popup-controller.test.ts
+++ b/tests/components-lib/notification/notification-popup-controller.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it, onTestFinished, vi } from 'vitest';
import { NotificationPopupController } from '../../../src/components-lib/notification/notification-popup-controller';
-import { POP_OUT_ANIMATION_NAME } from '../../../src/const';
+import { POP_OUT_ANIMATION_NAME } from '../../../src/utils/animation';
import { createLitElement } from '../../test-utils';
// @vitest-environment jsdom
diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts
index 0b4d8646..d4522f2c 100644
--- a/tests/config/management.test.ts
+++ b/tests/config/management.test.ts
@@ -1,15 +1,17 @@
import { describe, expect, it } from 'vitest';
import {
+ addConfigArrayItem,
copyConfig,
createRangedTransform,
+ deleteConfigArrayItem,
deleteConfigValue,
deleteTransform,
deleteWithOverrides,
- getArrayConfigPath,
getConfigValue,
hasConfigUpgradeFailures,
isConfigUpgradeable,
+ moveConfigArrayItem,
moveConfigValue,
setConfigValue,
upgradeArrayOfObjects,
@@ -88,8 +90,98 @@ describe('general functions', () => {
expect(copy).not.toBe(target);
});
- it('should get array config path', () => {
- expect(getArrayConfigPath('a.#.b', 10)).toBe('a.[10].b');
+ describe('addConfigArrayItem', () => {
+ it('should append an item', () => {
+ const config = { cameras: [{ id: 'a' }] };
+ expect(addConfigArrayItem(config, 'cameras', { id: 'b' })).toBe(true);
+ expect(config.cameras).toEqual([{ id: 'a' }, { id: 'b' }]);
+ });
+
+ it('should append at a segment-array path', () => {
+ const config = { cameras: [{ triggers: { events: [{ a: 1 }] } }] };
+ expect(
+ addConfigArrayItem(config, ['cameras', 0, 'triggers', 'events'], { b: 2 }),
+ ).toBe(true);
+ expect(config.cameras[0].triggers.events).toEqual([{ a: 1 }, { b: 2 }]);
+ });
+
+ it('should create the array when the configuration has none', () => {
+ const config = {};
+ expect(addConfigArrayItem(config, 'cameras', { id: 'a' })).toBe(true);
+ expect(config).toEqual({ cameras: [{ id: 'a' }] });
+ });
+
+ it('should replace a value that is not an array', () => {
+ const config = { cameras: 'junk' };
+ expect(addConfigArrayItem(config, 'cameras', { id: 'a' })).toBe(true);
+ expect(config).toEqual({ cameras: [{ id: 'a' }] });
+ });
+ });
+
+ describe('moveConfigArrayItem', () => {
+ it('should move an item at a segment-array path', () => {
+ const config = { cameras: [{ triggers: { events: [{ a: 1 }, { b: 2 }] } }] };
+ expect(
+ moveConfigArrayItem(config, ['cameras', 0, 'triggers', 'events'], 0, 1),
+ ).toBe(true);
+ expect(config.cameras[0].triggers.events).toEqual([{ b: 2 }, { a: 1 }]);
+ });
+
+ it('should move an item in place and report a change', () => {
+ const config = { cameras: [{ id: 'a' }, { id: 'b' }] };
+ expect(moveConfigArrayItem(config, 'cameras', 0, 1)).toBe(true);
+ expect(config).toEqual({ cameras: [{ id: 'b' }, { id: 'a' }] });
+ });
+
+ it('should return false when the path is not an array', () => {
+ const config = { cameras: 'nope' };
+ expect(moveConfigArrayItem(config, 'cameras', 0, 1)).toBe(false);
+ expect(config).toEqual({ cameras: 'nope' });
+ });
+
+ it.each([
+ ['same index', 0, 0],
+ ['from out of bounds', 2, 0],
+ ['to out of bounds', 0, 2],
+ ['negative from', -1, 0],
+ ['negative to', 0, -1],
+ ['non-integer from', 0.5, 1],
+ ['non-integer to', 0, Number.NaN],
+ ])(
+ 'should return false and not modify for an impossible move (%s)',
+ (_name, from, to) => {
+ const config = { cameras: [{ id: 'a' }, { id: 'b' }] };
+ expect(moveConfigArrayItem(config, 'cameras', from, to)).toBe(false);
+ expect(config).toEqual({ cameras: [{ id: 'a' }, { id: 'b' }] });
+ },
+ );
+ });
+
+ describe('deleteConfigArrayItem', () => {
+ it('should delete an item in place and report a change', () => {
+ const config = { cameras: [{ id: 'a' }, { id: 'b' }] };
+ expect(deleteConfigArrayItem(config, 'cameras', 0)).toBe(true);
+ expect(config).toEqual({ cameras: [{ id: 'b' }] });
+ });
+
+ it('should return false when the path is not an array', () => {
+ const config = { cameras: 'nope' };
+ expect(deleteConfigArrayItem(config, 'cameras', 0)).toBe(false);
+ expect(config).toEqual({ cameras: 'nope' });
+ });
+
+ it.each([
+ ['out of bounds', 1],
+ ['negative', -1],
+ ['non-integer', 0.5],
+ ])(
+ 'should return false and not modify for an impossible delete (%s)',
+ (_name, index) => {
+ const config = { cameras: [{ id: 'a' }] };
+ expect(deleteConfigArrayItem(config, 'cameras', index)).toBe(false);
+ expect(config).toEqual({ cameras: [{ id: 'a' }] });
+ },
+ );
});
});
@@ -5220,14 +5312,27 @@ describe('should handle version specific upgrades', () => {
postUpgradeChecks(config);
});
- it('should migrate an empty legacy array', () => {
+ it('should leave an empty array alone', () => {
+ // An empty list says nothing about which of the two shapes it is, and
+ // is valid under the new schema. Claiming it would rename the key of a
+ // user who has simply deleted their last event trigger.
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office', triggers: { events: [] } }],
};
- expect(upgradeConfig(config)).toBeTruthy();
- expect(config.cameras[0].triggers).toEqual({ media_events: [] });
- postUpgradeChecks(config);
+ upgradeConfig(config);
+ expect(config.cameras[0].triggers).toEqual({ events: [] });
+ });
+
+ it('should not report an upgrade for an empty array', () => {
+ // Otherwise the editor offers an upgrade to a user who has only
+ // deleted their last event trigger.
+ expect(
+ isConfigUpgradeable({
+ type: 'custom:advanced-camera-card',
+ cameras: [{ camera_entity: 'camera.office', triggers: { events: [] } }],
+ }),
+ ).toBeFalsy();
});
it('should leave the new object-array shape untouched', () => {
diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts
index d9e2f6c4..a702ad1f 100644
--- a/tests/config/types.test.ts
+++ b/tests/config/types.test.ts
@@ -503,7 +503,6 @@ describe('config defaults', () => {
format: {
'24h': true,
},
- pan_mode: 'pan',
show_recordings: true,
style: 'stack',
window_seconds: 3600,
diff --git a/tests/ha/custom-icons.test.ts b/tests/ha/custom-icons.test.ts
new file mode 100644
index 00000000..c78dccc7
--- /dev/null
+++ b/tests/ha/custom-icons.test.ts
@@ -0,0 +1,103 @@
+import { readFileSync } from 'fs';
+import { join } from 'path';
+
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import {
+ CUSTOM_ICON_NAMES,
+ CUSTOM_ICONSET_PREFIX,
+ registerCustomIconset,
+} from '../../src/ha/custom-icons';
+
+// @vitest-environment jsdom
+describe('registerCustomIconset', () => {
+ beforeEach(() => {
+ delete window.customIcons;
+ });
+
+ it('should register the iconset', () => {
+ registerCustomIconset();
+
+ expect(window.customIcons?.[CUSTOM_ICONSET_PREFIX]).toBeDefined();
+ });
+
+ it('should leave an existing registration in place', () => {
+ registerCustomIconset();
+ const helpers = window.customIcons?.[CUSTOM_ICONSET_PREFIX];
+
+ registerCustomIconset();
+
+ expect(window.customIcons?.[CUSTOM_ICONSET_PREFIX]).toBe(helpers);
+ });
+
+ it('should leave other iconsets untouched', () => {
+ const other = {
+ getIcon: async () => ({ path: '' }),
+ getIconList: async () => [],
+ };
+ window.customIcons = { other: other };
+
+ registerCustomIconset();
+
+ expect(window.customIcons['other']).toBe(other);
+ expect(window.customIcons[CUSTOM_ICONSET_PREFIX]).toBeDefined();
+ });
+
+ it('should resolve an icon', async () => {
+ registerCustomIconset();
+
+ await expect(
+ window.customIcons?.[CUSTOM_ICONSET_PREFIX].getIcon('frigate'),
+ ).resolves.toEqual({
+ path: expect.stringMatching(/^M/),
+ viewBox: '0 0 24 24',
+ });
+ });
+
+ it('should resolve an icon with a non-default viewBox', async () => {
+ registerCustomIconset();
+
+ await expect(
+ window.customIcons?.[CUSTOM_ICONSET_PREFIX].getIcon('tplink'),
+ ).resolves.toEqual({
+ path: expect.stringMatching(/^M/),
+ viewBox: '0 0 256 256',
+ });
+ });
+
+ it('should reject an unknown icon', async () => {
+ registerCustomIconset();
+
+ await expect(
+ window.customIcons?.[CUSTOM_ICONSET_PREFIX].getIcon('unknown'),
+ ).rejects.toThrow('Unknown icon: advanced-camera-card:unknown');
+ });
+
+ it('should list the icons', async () => {
+ registerCustomIconset();
+
+ await expect(
+ window.customIcons?.[CUSTOM_ICONSET_PREFIX].getIconList(),
+ ).resolves.toEqual([
+ { name: 'frigate' },
+ { name: 'iris' },
+ { name: 'motioneye' },
+ { name: 'reolink' },
+ { name: 'tplink' },
+ ]);
+ });
+});
+
+describe('custom icon assets', () => {
+ it.each(CUSTOM_ICON_NAMES)('should have a single-path SVG asset for %s', (name) => {
+ const source = readFileSync(
+ join(process.cwd(), 'src', 'images', 'icons', `${name}.svg`),
+ 'utf-8',
+ );
+ const doc = new DOMParser().parseFromString(source, 'image/svg+xml');
+
+ expect(doc.querySelectorAll('svg > path')).toHaveLength(1);
+ expect(doc.querySelector('svg > path')?.getAttribute('d')).toBeTruthy();
+ expect(doc.querySelector('svg')?.getAttribute('viewBox')).toBeTruthy();
+ });
+});
diff --git a/tests/localize/localize.test.ts b/tests/localize/localize.test.ts
index c788bdc1..602d7a73 100644
--- a/tests/localize/localize.test.ts
+++ b/tests/localize/localize.test.ts
@@ -244,12 +244,12 @@ describe('localize', () => {
expect(localize('common.advanced_camera_card', '', '')).toBe('Advanced Camera Card');
});
- it('should throw for completely missing key', async () => {
+ it('should return the key for completely missing key', async () => {
const { localize } = await importFresh();
- // The first reduce throws (caught), the English fallback also throws
- // (uncaught), so this will throw a TypeError.
- expect(() => localize('nonexistent.deeply.nested.key')).toThrow();
+ expect(localize('nonexistent.deeply.nested.key')).toBe(
+ 'nonexistent.deeply.nested.key',
+ );
});
it('should return translated value for single-segment key', async () => {
diff --git a/tsconfig.json b/tsconfig.json
index 06b87214..8177daaf 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -24,12 +24,15 @@
// These tags are provided by the HA frontend and [almost all] cannot be
// imported by a custom card directly.
"globalTags": [
+ "ha-alert",
"ha-button",
"ha-camera-stream",
"ha-card",
"ha-combo-box",
"ha-dropdown-item",
"ha-dropdown",
+ "ha-expansion-panel",
+ "ha-form",
"ha-hls-player",
"ha-icon-button",
"ha-icon",
diff --git a/vite.config.ts b/vite.config.ts
index 5a00461e..e2a591b9 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,5 +1,7 @@
import { defineConfig } from 'vitest/config';
+import { svgPath } from './scripts/svg-path-plugin.js';
+
const EXCLUSIONS = [
'.eslintrc.cjs',
'docs/**',
@@ -21,6 +23,7 @@ const INCLUSIONS = ['tests/**/*.test.ts'];
// ts-prune-ignore-next
export default defineConfig({
+ plugins: [svgPath()],
test: {
server: {
deps: {
diff --git a/yarn.lock b/yarn.lock
index fbf032ec..4bcd3e25 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2679,7 +2679,6 @@ __metadata:
rollup-plugin-git-info: "npm:^1.0.0"
rollup-plugin-serve: "npm:^1.1.1"
rollup-plugin-styler: "npm:^1.8.0"
- rollup-plugin-svgo: "npm:^2.0.0"
rollup-plugin-visualizer: "npm:^5.12.0"
sass: "patch:sass@npm%3A1.77.4#~/.yarn/patches/sass-npm-1.77.4-13b6910aea.patch"
screenfull: "npm:^6.0.2"
@@ -3890,19 +3889,6 @@ __metadata:
languageName: node
linkType: hard
-"css-select@npm:^4.1.3":
- version: 4.3.0
- resolution: "css-select@npm:4.3.0"
- dependencies:
- boolbase: "npm:^1.0.0"
- css-what: "npm:^6.0.1"
- domhandler: "npm:^4.3.1"
- domutils: "npm:^2.8.0"
- nth-check: "npm:^2.0.1"
- checksum: 10c0/a489d8e5628e61063d5a8fe0fa1cc7ae2478cb334a388a354e91cf2908154be97eac9fa7ed4dffe87a3e06cf6fcaa6016553115335c4fd3377e13dac7bd5a8e1
- languageName: node
- linkType: hard
-
"css-select@npm:^5.1.0":
version: 5.1.0
resolution: "css-select@npm:5.1.0"
@@ -3916,16 +3902,6 @@ __metadata:
languageName: node
linkType: hard
-"css-tree@npm:^1.1.2, css-tree@npm:^1.1.3":
- version: 1.1.3
- resolution: "css-tree@npm:1.1.3"
- dependencies:
- mdn-data: "npm:2.0.14"
- source-map: "npm:^0.6.1"
- checksum: 10c0/499a507bfa39b8b2128f49736882c0dd636b0cd3370f2c69f4558ec86d269113286b7df469afc955de6a68b0dba00bc533e40022a73698081d600072d5d83c1c
- languageName: node
- linkType: hard
-
"css-tree@npm:^2.3.1":
version: 2.3.1
resolution: "css-tree@npm:2.3.1"
@@ -3946,7 +3922,7 @@ __metadata:
languageName: node
linkType: hard
-"css-what@npm:^6.0.1, css-what@npm:^6.1.0":
+"css-what@npm:^6.1.0":
version: 6.1.0
resolution: "css-what@npm:6.1.0"
checksum: 10c0/a09f5a6b14ba8dcf57ae9a59474722e80f20406c53a61e9aedb0eedc693b135113ffe2983f4efc4b5065ae639442e9ae88df24941ef159c218b231011d733746
@@ -4030,15 +4006,6 @@ __metadata:
languageName: node
linkType: hard
-"csso@npm:^4.2.0":
- version: 4.2.0
- resolution: "csso@npm:4.2.0"
- dependencies:
- css-tree: "npm:^1.1.2"
- checksum: 10c0/f8c6b1300efaa0f8855a7905ae3794a29c6496e7f16a71dec31eb6ca7cfb1f058a4b03fd39b66c4deac6cb06bf6b4ba86da7b67d7320389cb9994d52b924b903
- languageName: node
- linkType: hard
-
"csso@npm:^5.0.5":
version: 5.0.5
resolution: "csso@npm:5.0.5"
@@ -4354,17 +4321,6 @@ __metadata:
languageName: node
linkType: hard
-"dom-serializer@npm:^1.0.1":
- version: 1.4.1
- resolution: "dom-serializer@npm:1.4.1"
- dependencies:
- domelementtype: "npm:^2.0.1"
- domhandler: "npm:^4.2.0"
- entities: "npm:^2.0.0"
- checksum: 10c0/67d775fa1ea3de52035c98168ddcd59418356943b5eccb80e3c8b3da53adb8e37edb2cc2f885802b7b1765bf5022aec21dfc32910d7f9e6de4c3148f095ab5e0
- languageName: node
- linkType: hard
-
"dom-serializer@npm:^2.0.0":
version: 2.0.0
resolution: "dom-serializer@npm:2.0.0"
@@ -4376,7 +4332,7 @@ __metadata:
languageName: node
linkType: hard
-"domelementtype@npm:^2.0.1, domelementtype@npm:^2.2.0, domelementtype@npm:^2.3.0":
+"domelementtype@npm:^2.3.0":
version: 2.3.0
resolution: "domelementtype@npm:2.3.0"
checksum: 10c0/686f5a9ef0fff078c1412c05db73a0dce096190036f33e400a07e2a4518e9f56b1e324f5c576a0a747ef0e75b5d985c040b0d51945ce780c0dd3c625a18cd8c9
@@ -4392,15 +4348,6 @@ __metadata:
languageName: node
linkType: hard
-"domhandler@npm:^4.2.0, domhandler@npm:^4.3.1":
- version: 4.3.1
- resolution: "domhandler@npm:4.3.1"
- dependencies:
- domelementtype: "npm:^2.2.0"
- checksum: 10c0/5c199c7468cb052a8b5ab80b13528f0db3d794c64fc050ba793b574e158e67c93f8336e87fd81e9d5ee43b0e04aea4d8b93ed7be4899cb726a1601b3ba18538b
- languageName: node
- linkType: hard
-
"domhandler@npm:^5.0.2, domhandler@npm:^5.0.3":
version: 5.0.3
resolution: "domhandler@npm:5.0.3"
@@ -4410,17 +4357,6 @@ __metadata:
languageName: node
linkType: hard
-"domutils@npm:^2.8.0":
- version: 2.8.0
- resolution: "domutils@npm:2.8.0"
- dependencies:
- dom-serializer: "npm:^1.0.1"
- domelementtype: "npm:^2.2.0"
- domhandler: "npm:^4.2.0"
- checksum: 10c0/d58e2ae01922f0dd55894e61d18119924d88091837887bf1438f2327f32c65eb76426bd9384f81e7d6dcfb048e0f83c19b222ad7101176ad68cdc9c695b563db
- languageName: node
- linkType: hard
-
"domutils@npm:^3.0.1":
version: 3.1.0
resolution: "domutils@npm:3.1.0"
@@ -4559,13 +4495,6 @@ __metadata:
languageName: node
linkType: hard
-"entities@npm:^2.0.0":
- version: 2.2.0
- resolution: "entities@npm:2.2.0"
- checksum: 10c0/7fba6af1f116300d2ba1c5673fc218af1961b20908638391b4e1e6d5850314ee2ac3ec22d741b3a8060479911c99305164aed19b6254bde75e7e6b1b2c3f3aa3
- languageName: node
- linkType: hard
-
"entities@npm:^4.2.0, entities@npm:^4.4.0":
version: 4.5.0
resolution: "entities@npm:4.5.0"
@@ -7464,13 +7393,6 @@ __metadata:
languageName: node
linkType: hard
-"mdn-data@npm:2.0.14":
- version: 2.0.14
- resolution: "mdn-data@npm:2.0.14"
- checksum: 10c0/67241f8708c1e665a061d2b042d2d243366e93e5bf1f917693007f6d55111588b952dcbfd3ea9c2d0969fb754aad81b30fdcfdcc24546495fc3b24336b28d4bd
- languageName: node
- linkType: hard
-
"mdn-data@npm:2.0.28":
version: 2.0.28
resolution: "mdn-data@npm:2.0.28"
@@ -9794,15 +9716,6 @@ __metadata:
languageName: node
linkType: hard
-"rollup-plugin-svgo@npm:^2.0.0":
- version: 2.0.0
- resolution: "rollup-plugin-svgo@npm:2.0.0"
- dependencies:
- svgo: "npm:2.8.0"
- checksum: 10c0/3dead444692eca0c99da8ed668e9a835ceaf6a1e0c6eaf060ae0b113f3f227127d2efb2ba657f910bc51c5733b92a2181bdc393dbf6b905da19c2e37997a8fa3
- languageName: node
- linkType: hard
-
"rollup-plugin-visualizer@npm:^5.12.0":
version: 5.12.0
resolution: "rollup-plugin-visualizer@npm:5.12.0"
@@ -10456,13 +10369,6 @@ __metadata:
languageName: node
linkType: hard
-"stable@npm:^0.1.8":
- version: 0.1.8
- resolution: "stable@npm:0.1.8"
- checksum: 10c0/df74b5883075076e78f8e365e4068ecd977af6c09da510cfc3148a303d4b87bc9aa8f7c48feb67ed4ef970b6140bd9eabba2129e28024aa88df5ea0114cba39d
- languageName: node
- linkType: hard
-
"stackback@npm:0.0.2":
version: 0.0.2
resolution: "stackback@npm:0.0.2"
@@ -10716,23 +10622,6 @@ __metadata:
languageName: node
linkType: hard
-"svgo@npm:2.8.0":
- version: 2.8.0
- resolution: "svgo@npm:2.8.0"
- dependencies:
- "@trysound/sax": "npm:0.2.0"
- commander: "npm:^7.2.0"
- css-select: "npm:^4.1.3"
- css-tree: "npm:^1.1.3"
- csso: "npm:^4.2.0"
- picocolors: "npm:^1.0.0"
- stable: "npm:^0.1.8"
- bin:
- svgo: bin/svgo
- checksum: 10c0/0741f5d5cad63111a90a0ce7a1a5a9013f6d293e871b75efe39addb57f29a263e45294e485a4d2ff9cc260a5d142c8b5937b2234b4ef05efdd2706fb2d360ecc
- languageName: node
- linkType: hard
-
"svgo@npm:^3.2.0":
version: 3.3.2
resolution: "svgo@npm:3.3.2"