From 24c6b989486717efa4c1932ae861d98c0b6c25df Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 7 Mar 2026 20:47:24 -0800 Subject: [PATCH] feat: Allow user generated notifications (#2401) --- INSTRUCTIONS.md | 16 ++ docs/configuration/actions/custom/README.md | 69 ++++++ docs/configuration/elements/custom/README.md | 6 + docs/configuration/status-bar.md | 4 + docs/examples.md | 36 +++ src/camera-manager/types.ts | 5 +- src/card-controller/actions/actions/info.ts | 4 +- .../actions/actions/notification.ts | 10 + src/card-controller/actions/factory.ts | 3 + src/card-controller/config/config-manager.ts | 10 +- src/card-controller/controller.ts | 12 +- src/card-controller/notification-manager.ts | 33 +++ .../overlay-message-manager.ts | 31 --- .../status-bar-item-manager.ts | 24 ++ src/card-controller/types.ts | 6 +- src/card.ts | 22 +- src/components-lib/icon-controller.ts | 2 +- .../media/details-controller.ts | 156 +++++++------ src/components/icon.ts | 4 +- src/components/next-prev-control.ts | 2 +- .../{overlay-message.ts => notification.ts} | 94 +++++--- src/components/submenu/select-button.ts | 4 +- src/components/thumbnail/details.ts | 16 +- src/components/thumbnail/feature/feature.ts | 22 +- src/config/schema/actions/types.ts | 55 ++++- src/config/schema/common/icon.ts | 18 ++ src/config/schema/status-bar.ts | 2 + src/editor.ts | 1 + src/localize/languages/en.json | 10 +- ...overlay-message.scss => notification.scss} | 61 +++--- src/scss/status.scss | 1 + src/scss/themes/base.scss | 24 +- src/scss/themes/light.scss | 20 +- src/scss/z-index.scss | 2 +- src/types.ts | 34 --- src/utils/action.ts | 20 +- src/utils/notification.ts | 5 + src/utils/overlay-message.ts | 13 -- .../actions/actions/info.test.ts | 4 +- .../actions/actions/notification.test.ts | 28 +++ tests/card-controller/actions/factory.test.ts | 10 +- .../config/config-manager.test.ts | 21 ++ tests/card-controller/controller.test.ts | 10 +- .../notification-manager.test.ts | 51 +++++ .../overlay-message-manager.test.ts | 51 ----- .../status-bar-item-manager.test.ts | 50 +++++ .../media/details-controller.test.ts | 206 ++++++++++-------- tests/config/schema/common/icon.test.ts | 22 ++ tests/config/types.test.ts | 28 +++ tests/test-utils.ts | 4 +- tests/utils/action.test.ts | 22 ++ tests/utils/notification.test.ts | 15 ++ tests/utils/overlay-message.test.ts | 31 --- 53 files changed, 939 insertions(+), 471 deletions(-) create mode 100644 src/card-controller/actions/actions/notification.ts create mode 100644 src/card-controller/notification-manager.ts delete mode 100644 src/card-controller/overlay-message-manager.ts rename src/components/{overlay-message.ts => notification.ts} (52%) create mode 100644 src/config/schema/common/icon.ts rename src/scss/{overlay-message.scss => notification.scss} (72%) create mode 100644 src/utils/notification.ts delete mode 100644 src/utils/overlay-message.ts create mode 100644 tests/card-controller/actions/actions/notification.test.ts create mode 100644 tests/card-controller/notification-manager.test.ts delete mode 100644 tests/card-controller/overlay-message-manager.test.ts create mode 100644 tests/config/schema/common/icon.test.ts create mode 100644 tests/utils/notification.test.ts delete mode 100644 tests/utils/overlay-message.test.ts diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 7180b98a..75fbd2c5 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -67,3 +67,19 @@ - **Think through UX before implementing.** For any user-visible change, reason through the full set of states and edge cases it touches. Flag them before writing code, not after. - **Be concise.** Short, direct answers are preferred. Skip preamble, avoid restating the question, and don't summarize what you just did unless asked. + +- **No re-exports or pass-through files.** Have callers import directly from the source module. Don't create files that only re-export from another file. + +- **Single source of truth for types.** When a Zod schema defines a shape, derive the TypeScript type from it (`z.infer`). Don't maintain a parallel interface that duplicates the schema. + +- **Separate external API from internal extensions.** User-facing schemas should only contain user-configurable fields. For internal-only fields, create a derived interface (e.g. `interface InternalFoo extends Foo { internalField?: ... }`). Never expose internal plumbing in external schemas. + +- **Extract shared schemas eagerly.** If a schema (e.g. `iconSchema`) could apply to multiple features, put it in `config/schema/common/` from the start rather than nesting it under a specific feature. + +- **Name fields for their semantic purpose.** Choose names that reflect what the field means in context. Ask yourself what the field actually does. + +- **Accurate comment headings.** If a comment section covers multiple related items, the heading must reflect all of them — not just the first one added. + +- **Check CSS inheritance before adding interactive elements.** Parent rules like `pointer-events: none` silently block children. When adding clickable items to a container, verify the full CSS cascade allows interaction. + +- **Preserve alphabetical ordering.** When inserting into a list, file, or set of sections that is fully or mostly alphabetized, maintain that ordering. diff --git a/docs/configuration/actions/custom/README.md b/docs/configuration/actions/custom/README.md index 00656985..05e07f85 100644 --- a/docs/configuration/actions/custom/README.md +++ b/docs/configuration/actions/custom/README.md @@ -341,6 +341,52 @@ action: custom:advanced-camera-card-action advanced_camera_card_action: mute ``` +## `notification` + +[](../../common/experimental-warning.md ':include') + +Show a notification overlay on the card with optional controls. + +```yaml +action: custom:advanced-camera-card-action +advanced_camera_card_action: notification +# [...] +``` + +| Parameter | Description | +| ----------------------------- | --------------------------------------------- | +| `action` | Must be `custom:advanced-camera-card-action`. | +| `advanced_camera_card_action` | Must be `notification`. | +| `notification` | The notification to display. See below. | + +### `notification` + +| Parameter | Description | +| ---------- | ------------------------------------------------------------------------------------- | +| `heading` | An optional heading. Uses the same format as [`details`](README.md?id=details) below. | +| `text` | An optional text string to display as the notification body. | +| `details` | An optional list of metadata. See [`details`](README.md?id=details) below. | +| `controls` | An optional list of controls. See [`controls`](README.md?id=controls) below. | + +### Details + +| Parameter | Description | +| ---------- | ------------------------------------------------------------- | +| `text` | The detail text. | +| `icon` | An optional icon (e.g. `mdi:alert`). | +| `tooltip` | An optional tooltip string (shown on hover). | +| `severity` | An optional severity level, one of `low`, `medium` or `high`. | + +### Controls + +| Parameter | Default | Description | +| ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `icon` | | An optional icon (e.g. `mdi:check-circle`). | +| `tooltip` | | An optional tooltip string (shown on hover). | +| `severity` | | An optional severity level, one of `low`, `medium` or `high`. | +| `actions` | | [Actions](../README.md) to execute on interaction. Supports `tap_action`, `hold_action`, `double_tap_action`, `start_tap_action`, `end_tap_action`. See [example](../../../examples.md?id=notifications). | +| `dismiss` | `true` | Whether to dismiss the notification after the control's actions execute. | + ## `pause` Pause the selected media. @@ -740,6 +786,29 @@ elements: tap_action: action: custom:advanced-camera-card-action advanced_camera_card_action: info + - type: custom:advanced-camera-card-menu-icon + icon: mdi:alpha-m-circle-outline + title: Show notification + tap_action: + action: custom:advanced-camera-card-action + advanced_camera_card_action: notification + notification: + heading: + text: Attention + icon: mdi:alert + severity: high + text: Something important happened. + controls: + - icon: mdi:filmstrip + tooltip: View clips (tap) / View clip (double-tap) + actions: + tap_action: + action: custom:advanced-camera-card-action + advanced_camera_card_action: clips + double_tap_action: + action: custom:advanced-camera-card-action + advanced_camera_card_action: clip + dismiss: false - type: custom:advanced-camera-card-menu-icon icon: mdi:alpha-n-circle title: Turn off substream diff --git a/docs/configuration/elements/custom/README.md b/docs/configuration/elements/custom/README.md index e45ccda5..ec98cee5 100644 --- a/docs/configuration/elements/custom/README.md +++ b/docs/configuration/elements/custom/README.md @@ -154,6 +154,7 @@ elements: | `expand` | `false` | If `false` this status bar item will consume the minimum possible space, if `true` will expand to the available space. | | `icon` | | The icon to show in the status bar, e.g. `mdi:camera-front`. See also [custom icons](../../../usage/custom-icons.md). | | `priority` | `50` | The item priority. Higher priority items are ordered closer to the start of the status bar (i.e. an item with priority `70` will order further to the left than an item with priority `60`). Minimum `0`, maximum `100`. | +| `severity` | | An optional severity level, one of `low`, `medium` or `high`. Colors the item accordingly. | | `sufficient` | `false` | Whether or not this item is sufficient to display the status bar if it's otherwise hidden (e.g. with the `popup` [status bar style](../../status-bar.md)). | ## `status-bar-image` @@ -175,6 +176,7 @@ elements: | `expand` | `false` | If `false` this status bar item will consume the minimum possible space, if `true` will expand to the available space. | | `image` | | The image to show in the status bar, e.g. `https://my.site.com/status.png`. | | `priority` | `50` | The item priority. Higher priority items are ordered closer to the start of the status bar (i.e. an item with priority `70` will order further to the left than an item with priority `60`). Minimum `0`, maximum `100`. | +| `severity` | | An optional severity level, one of `low`, `medium` or `high`. Colors the item accordingly. | | `sufficient` | `false` | Whether or not this item is sufficient to display the status bar if it's otherwise hidden (e.g. with the `popup` [status bar style](../../status-bar.md)). | ## `status-bar-string` @@ -196,6 +198,7 @@ elements: | `expand` | `false` | If `false` this status bar item will consume the minimum possible space, if `true` will expand to the available space. | | `string` | | The string to show in the status bar, e.g. `Intruder detected!` | | `priority` | `50` | The item priority. Higher priority items are ordered closer to the start of the status bar (i.e. an item with priority `70` will order further to the left than an item with priority `60`). Minimum `0`, maximum `100`. | +| `severity` | | An optional severity level, one of `low`, `medium` or `high`. Colors the item accordingly. | | `sufficient` | `false` | Whether or not this item is sufficient to display the status bar if it's otherwise hidden (e.g. with the `popup` [status bar style](../../status-bar.md)). | ## Fully expanded reference @@ -331,6 +334,7 @@ elements: expand: false string: 'Intruder alert!' priority: 50 + severity: high sufficient: false - type: custom:advanced-camera-card-status-bar-icon enabled: true @@ -338,6 +342,7 @@ elements: expand: false icon: 'mdi:cow' priority: 50 + severity: medium sufficient: false - type: custom:advanced-camera-card-status-bar-image enabled: true @@ -345,5 +350,6 @@ elements: expand: false image: https://my.site.com/status.png priority: 50 + severity: low sufficient: false ``` diff --git a/docs/configuration/status-bar.md b/docs/configuration/status-bar.md index c68cf7e0..63933270 100644 --- a/docs/configuration/status-bar.md +++ b/docs/configuration/status-bar.md @@ -35,6 +35,7 @@ status_bar: | `severity` | The media severity indicator (if any) for review severity (e.g. Frigate alerts/detections). | | `technology` | The detected media technology (if any). | | `title` | The media title. | +| `upgrade` | An indicator that appears when a configuration upgrade is available. | ### Options for each item @@ -85,4 +86,7 @@ status_bar: title: enabled: true priority: 50 + upgrade: + enabled: true + priority: 50 ``` diff --git a/docs/examples.md b/docs/examples.md index 1e5c0f14..04c2247a 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -729,6 +729,42 @@ cameras: id: office-webrtc ``` +## Notifications + +Show a notification with controls that respond to different interactions. In +this example, tapping the control navigates to the `clips` (media gallery) view +and double-tapping navigates to the `clip` (media player) view. + +```yaml +type: custom:advanced-camera-card +cameras: + - camera_entity: camera.office +elements: + - type: custom:advanced-camera-card-menu-icon + icon: mdi:bell + title: Show notification + tap_action: + action: custom:advanced-camera-card-action + advanced_camera_card_action: notification + notification: + heading: + text: Motion detected + icon: mdi:motion-sensor + severity: medium + text: Motion was detected in the office. + controls: + - icon: mdi:filmstrip + tooltip: View clips (tap) / clip (double-tap) + actions: + tap_action: + action: custom:advanced-camera-card-action + advanced_camera_card_action: clips + double_tap_action: + action: custom:advanced-camera-card-action + advanced_camera_card_action: clip + dismiss: false +``` + ## Overriding configuration You can override card configuration when certain [conditions](configuration/conditions.md) are met. diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index db091693..b67f5619 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -1,8 +1,9 @@ import { ExpiringEqualityCache } from '../cache/expiring-cache'; import { SSLCiphers } from '../config/schema/cameras'; import { AdvancedCameraCardView } from '../config/schema/common/const'; +import { InternalIcon } from '../config/schema/common/icon'; import { BaseQuery, QueryFilters, QuerySource } from '../query-source'; -import { CapabilityKey, Endpoint, Icon } from '../types'; +import { CapabilityKey, Endpoint } from '../types'; import { ViewMedia } from '../view/item'; // ==== @@ -118,7 +119,7 @@ export interface CapabilitySearchOptions { export interface CameraManagerCameraMetadata { title: string; - icon: Icon; + icon: InternalIcon; // Engine icon is just a string since it will never be entity-derived. engineIcon?: string; diff --git a/src/card-controller/actions/actions/info.ts b/src/card-controller/actions/actions/info.ts index c05089a9..b299888d 100644 --- a/src/card-controller/actions/actions/info.ts +++ b/src/card-controller/actions/actions/info.ts @@ -16,8 +16,8 @@ export class InfoAction extends AdvancedCameraCardAction { const controller = new MediaDetailsController(); controller.calculate(api.getCameraManager(), item); - api.getOverlayMessageManager().setMessage( - controller.getMessage({ + api.getNotificationManager().setNotification( + controller.getNotification({ hass: api.getHASSManager().getHASS() ?? undefined, viewItemManager: api.getViewItemManager(), viewManagerEpoch: api.getViewManager().getEpoch(), diff --git a/src/card-controller/actions/actions/notification.ts b/src/card-controller/actions/actions/notification.ts new file mode 100644 index 00000000..41c30870 --- /dev/null +++ b/src/card-controller/actions/actions/notification.ts @@ -0,0 +1,10 @@ +import { NotificationActionConfig } from '../../../config/schema/actions/types'; +import { CardActionsAPI } from '../../types'; +import { AdvancedCameraCardAction } from './base'; + +export class NotificationAction extends AdvancedCameraCardAction { + public async execute(api: CardActionsAPI): Promise { + await super.execute(api); + api.getNotificationManager().setNotification(this._action.notification); + } +} diff --git a/src/card-controller/actions/factory.ts b/src/card-controller/actions/factory.ts index 9bddc17d..f70d5b34 100644 --- a/src/card-controller/actions/factory.ts +++ b/src/card-controller/actions/factory.ts @@ -25,6 +25,7 @@ import { MoreInfoAction } from './actions/more-info'; import { MuteAction } from './actions/mute'; import { NavigateAction } from './actions/navigate'; import { NoneAction } from './actions/none'; +import { NotificationAction } from './actions/notification'; import { PauseAction } from './actions/pause'; import { PerformActionAction } from './actions/perform-action'; import { PIPAction } from './actions/pip'; @@ -163,6 +164,8 @@ export class ActionFactory { return new PTZControlsAction(context, action, options?.config); case 'log': return new LogAction(context, action, options?.config); + case 'notification': + return new NotificationAction(context, action, options?.config); case 'status_bar': return new StatusBarAction(context, action, options?.config); case 'reload': diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index 4477adc3..55c2b4ba 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -28,6 +28,7 @@ export class ConfigManager { private _overriddenConfig: AdvancedCameraCardConfig | null = null; private _rawConfig: RawAdvancedCameraCardConfig | null = null; private _cardWideConfig: CardWideConfig | null = null; + private _upgradeable = false; private _overridesManager = new OverridesManager(() => this._processOverrideConfig()); constructor(api: CardConfigAPI) { @@ -54,17 +55,22 @@ export class ConfigManager { return this._rawConfig; } + public isUpgradeable(): boolean { + return this._upgradeable; + } + public setConfig(inputConfig?: RawAdvancedCameraCardConfig): void { if (!inputConfig) { throw new Error(localize('error.invalid_configuration')); } const parseResult = advancedCameraCardConfigSchema.safeParse(inputConfig); + this._upgradeable = isConfigUpgradeable(inputConfig); + if (!parseResult.success) { - const configUpgradeable = isConfigUpgradeable(inputConfig); const hint = getParseError(parseResult.error); let upgradeMessage = ''; - if (configUpgradeable) { + if (this._upgradeable) { upgradeMessage = `${localize('error.upgrade_available')}. `; } throw new Error( diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index 02e2f441..cd7e1dda 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -31,7 +31,7 @@ import { MediaLoadedInfoManager } from './media-info-manager'; import { MediaPlayerManager } from './media-player-manager'; import { MessageManager } from './message-manager'; import { MicrophoneManager } from './microphone-manager'; -import { OverlayMessageManager } from './overlay-message-manager'; +import { NotificationManager } from './notification-manager'; import { PIPManager } from './pip-manager'; import { QueryStringManager } from './query-string-manager'; import { StatusBarItemManager } from './status-bar-item-manager'; @@ -58,7 +58,7 @@ import { CardMediaPlayerAPI, CardMessageAPI, CardMicrophoneAPI, - CardOverlayMessageAPI, + CardNotificationAPI, CardPIPAPI, CardQueryStringAPI, CardStyleAPI, @@ -90,7 +90,7 @@ export class CardController CardMediaPlayerAPI, CardMessageAPI, CardMicrophoneAPI, - CardOverlayMessageAPI, + CardNotificationAPI, CardQueryStringAPI, CardStyleAPI, CardTriggersAPI, @@ -125,7 +125,7 @@ export class CardController private _mediaPlayerManager = new MediaPlayerManager(this); private _messageManager = new MessageManager(this); private _microphoneManager = new MicrophoneManager(this); - private _overlayMessageManager = new OverlayMessageManager(this); + private _notificationManager = new NotificationManager(this); private _pipManager = new PIPManager(this); private _queryStringManager = new QueryStringManager(this); private _statusBarItemManager = new StatusBarItemManager(this); @@ -252,8 +252,8 @@ export class CardController this._microphoneManager = new MicrophoneManager(this); } - public getOverlayMessageManager(): OverlayMessageManager { - return this._overlayMessageManager; + public getNotificationManager(): NotificationManager { + return this._notificationManager; } public getPIPManager(): PIPManager { diff --git a/src/card-controller/notification-manager.ts b/src/card-controller/notification-manager.ts new file mode 100644 index 00000000..c6f1b8b0 --- /dev/null +++ b/src/card-controller/notification-manager.ts @@ -0,0 +1,33 @@ +import { Notification } from '../config/schema/actions/types'; +import { CardNotificationAPI } from './types'; + +export class NotificationManager { + private _notification: Notification | null = null; + private _api: CardNotificationAPI; + + constructor(api: CardNotificationAPI) { + this._api = api; + } + + public getNotification(): Notification | null { + return this._notification; + } + + public hasNotification(): boolean { + return this._notification !== null; + } + + // Also used to replace the current notification in-place (e.g. to refresh + // control state after a toggle action). + public setNotification(notification: Notification): void { + this._notification = notification; + this._api.getCardElementManager().update(); + } + + public reset(): void { + if (this._notification) { + this._notification = null; + this._api.getCardElementManager().update(); + } + } +} diff --git a/src/card-controller/overlay-message-manager.ts b/src/card-controller/overlay-message-manager.ts deleted file mode 100644 index 86157065..00000000 --- a/src/card-controller/overlay-message-manager.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { OverlayMessage } from '../types'; -import { CardOverlayMessageAPI } from './types'; - -export class OverlayMessageManager { - private _message: OverlayMessage | null = null; - private _api: CardOverlayMessageAPI; - - constructor(api: CardOverlayMessageAPI) { - this._api = api; - } - - public getMessage(): OverlayMessage | null { - return this._message; - } - - public hasMessage(): boolean { - return this._message !== null; - } - - public setMessage(message: OverlayMessage): void { - this._message = message; - this._api.getCardElementManager().update(); - } - - public reset(): void { - if (this._message) { - this._message = null; - this._api.getCardElementManager().update(); - } - } -} diff --git a/src/card-controller/status-bar-item-manager.ts b/src/card-controller/status-bar-item-manager.ts index 6124803d..ead32196 100644 --- a/src/card-controller/status-bar-item-manager.ts +++ b/src/card-controller/status-bar-item-manager.ts @@ -2,7 +2,9 @@ import { isEqual } from 'lodash-es'; import { CameraManager } from '../camera-manager/manager'; import { StatusBarItem } from '../config/schema/actions/types'; import { StatusBarConfig } from '../config/schema/status-bar'; +import { localize } from '../localize/localize'; import { MediaLoadedInfo } from '../types'; +import { createNotificationAction } from '../utils/action'; import { View } from '../view/view'; import { CardStatusBarAPI } from './types'; @@ -42,6 +44,7 @@ export class StatusBarItemManager { cameraManager?: CameraManager | null; view?: View | null; mediaLoadedInfo?: MediaLoadedInfo | null; + isUpgradeable?: boolean; }): StatusBarItem[] { const cameraMetadata = options?.view?.camera ? options?.cameraManager?.getCameraMetadata(options.view.camera) @@ -124,6 +127,27 @@ export class StatusBarItemManager { }, ] : []), + + ...(options?.isUpgradeable + ? [ + { + type: 'custom:advanced-camera-card-status-bar-icon' as const, + icon: 'mdi:update', + severity: 'medium' as const, + actions: { + tap_action: createNotificationAction({ + heading: { + text: localize('notification.upgrade.heading'), + icon: 'mdi:update', + severity: 'medium', + }, + text: localize('notification.upgrade.text'), + }), + }, + ...options?.statusConfig?.items.upgrade, + }, + ] + : []), ...this._dynamicItems, ]; } diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 9c67592a..ce2079c3 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -22,7 +22,7 @@ import type { MediaLoadedInfoManager } from './media-info-manager'; import type { MediaPlayerManager } from './media-player-manager'; import type { MessageManager } from './message-manager'; import type { MicrophoneManager } from './microphone-manager'; -import type { OverlayMessageManager } from './overlay-message-manager'; +import type { NotificationManager } from './notification-manager'; import type { PIPManager } from './pip-manager'; import type { QueryStringManager } from './query-string-manager'; import type { StatusBarItemManager } from './status-bar-item-manager'; @@ -54,7 +54,7 @@ export interface CardActionsAPI { getMediaPlayerManager(): MediaPlayerManager; getMessageManager(): MessageManager; getMicrophoneManager(): MicrophoneManager; - getOverlayMessageManager(): OverlayMessageManager; + getNotificationManager(): NotificationManager; getPIPManager(): PIPManager; getStatusBarItemManager(): StatusBarItemManager; getTriggersManager(): TriggersManager; @@ -257,7 +257,7 @@ export interface CardMessageAPI { getMediaLoadedInfoManager(): MediaLoadedInfoManager; } -export interface CardOverlayMessageAPI { +export interface CardNotificationAPI { getCardElementManager(): CardElementManager; } diff --git a/src/card.ts b/src/card.ts index 332b5075..66b30d04 100644 --- a/src/card.ts +++ b/src/card.ts @@ -16,7 +16,7 @@ import './components/menu.js'; import { AdvancedCameraCardMenu } from './components/menu.js'; import './components/message.js'; import { renderMessage } from './components/message.js'; -import './components/overlay-message.js'; +import './components/notification.js'; import './components/overlay.js'; import { AdvancedCameraCardOverlay } from './components/overlay.js'; import './components/status-bar'; @@ -32,7 +32,7 @@ import { REPO_URL } from './const.js'; import { HomeAssistant, LovelaceCardEditor } from './ha/types.js'; import { localize } from './localize/localize.js'; import cardStyle from './scss/card.scss'; -import { MediaLoadedInfo, Message, OverlayMessage } from './types.js'; +import { MediaLoadedInfo, Message } from './types.js'; import { hasAction } from './utils/action.js'; import { getReleaseVersion } from './utils/diagnostics'; @@ -288,6 +288,7 @@ class AdvancedCameraCard extends LitElement { cameraManager: this._controller.getCameraManager(), view: this._controller.getViewManager().getView(), mediaLoadedInfo: this._controller.getMediaLoadedInfoManager().get(), + isUpgradeable: this._controller.getConfigManager().isUpgradeable(), })} .config=${this._config.status_bar} > @@ -374,11 +375,8 @@ class AdvancedCameraCard extends LitElement { () => this.requestUpdate() /* Refresh play/pause menu button */ } @advanced-camera-card:focus=${() => this.focus()} - @advanced-camera-card:overlay-message:show=${( - ev: CustomEvent, - ) => this._controller.getOverlayMessageManager().setMessage(ev.detail)} - @advanced-camera-card:overlay-message:dismiss=${() => - this._controller.getOverlayMessageManager().reset()} + @advanced-camera-card:notification:dismiss=${() => + this._controller.getNotificationManager().reset()} > ${showLoading ? html` ` : ``} - ${this._controller.getOverlayMessageManager().getMessage() - ? html`` + ${this._controller.getNotificationManager().getNotification() + ? html`` : ''} `, ); diff --git a/src/components-lib/icon-controller.ts b/src/components-lib/icon-controller.ts index c798ec30..48525bdf 100644 --- a/src/components-lib/icon-controller.ts +++ b/src/components-lib/icon-controller.ts @@ -3,9 +3,9 @@ import frigateSVG from '../camera-manager/frigate/assets/frigate.svg'; import motioneyeSVG from '../camera-manager/motioneye/assets/motioneye.svg'; import reolinkSVG from '../camera-manager/reolink/assets/reolink.svg'; import tplinkSVG from '../camera-manager/tplink/assets/tplink.svg'; +import { Icon } from '../config/schema/common/icon'; import { HomeAssistant } from '../ha/types'; import irisSVG from '../images/iris.svg'; -import { Icon } from '../types'; export class IconController { public getCustomIcon(icon?: Icon): string | null { diff --git a/src/components-lib/media/details-controller.ts b/src/components-lib/media/details-controller.ts index 66be934d..88888d1c 100644 --- a/src/components-lib/media/details-controller.ts +++ b/src/components-lib/media/details-controller.ts @@ -3,9 +3,14 @@ import { CameraManager } from '../../camera-manager/manager'; import { CameraManagerCameraMetadata } from '../../camera-manager/types'; import { ViewItemManager } from '../../card-controller/view/item-manager'; import { ViewManagerEpoch } from '../../card-controller/view/types'; +import { + Notification, + NotificationControl, + NotificationDetail, +} from '../../config/schema/actions/types'; import { HomeAssistant } from '../../ha/types'; import { localize } from '../../localize/localize'; -import { MetadataField, OverlayMessage, OverlayMessageControl } from '../../types'; +import { createInternalCallbackAction } from '../../utils/action'; import { getDurationString, prettifyTitle } from '../../utils/basic'; import { downloadMedia, @@ -17,7 +22,7 @@ import { ViewItem } from '../../view/item'; import { ViewItemClassifier } from '../../view/item-classifier'; import { ViewItemCapabilities } from '../../view/types'; -export interface OverlayControlsContext { +export interface NotificationControlsContext { hass?: HomeAssistant; viewItemManager?: ViewItemManager; viewManagerEpoch?: ViewManagerEpoch; @@ -29,8 +34,8 @@ export interface OverlayControlsContext { } export class MediaDetailsController { - private _details: MetadataField[] = []; - private _heading: MetadataField | null = null; + private _details: NotificationDetail[] = []; + private _heading: NotificationDetail | null = null; private _item: ViewItem | null = null; public calculate( @@ -61,7 +66,7 @@ export class MediaDetailsController { const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null; this._heading = whatWithTags - ? { title: `${whatWithTags}${score ? ` ${score}` : ''}` } + ? { text: `${whatWithTags}${score ? ` ${score}` : ''}` } : null; return; } @@ -72,13 +77,13 @@ export class MediaDetailsController { this._heading = title ? { - title: title, - emphasis: severity ?? undefined, - hint: + text: title, + severity: severity ?? undefined, + tooltip: localize('common.severity') + ': ' + localize('common.severities.' + severity), - icon: { icon: 'mdi:circle-medium' }, + icon: 'mdi:circle-medium', } : null; return; @@ -86,7 +91,7 @@ export class MediaDetailsController { if (cameraMetadata?.title) { this._heading = { - title: cameraMetadata.title, + text: cameraMetadata.title, }; return; } @@ -121,54 +126,54 @@ export class MediaDetailsController { ...(startTime ? [ { - hint: localize('thumbnail.start'), - icon: { icon: 'mdi:calendar-clock-outline' }, - title: format(startTime, 'yyyy-MM-dd HH:mm:ss'), + tooltip: localize('thumbnail.start'), + icon: 'mdi:calendar-clock-outline', + text: format(startTime, 'yyyy-MM-dd HH:mm:ss'), }, ] : []), ...(duration || inProgress ? [ { - hint: localize('thumbnail.duration'), - icon: { icon: 'mdi:clock-outline' }, - title: `${duration ?? ''}${duration && inProgress ? ' ' : ''}${inProgress ?? ''}`, + tooltip: localize('thumbnail.duration'), + icon: 'mdi:clock-outline', + text: `${duration ?? ''}${duration && inProgress ? ' ' : ''}${inProgress ?? ''}`, }, ] : []), ...(cameraMetadata?.title ? [ { - hint: localize('thumbnail.camera'), - title: cameraMetadata.title, - icon: { icon: 'mdi:cctv' }, + tooltip: localize('thumbnail.camera'), + text: cameraMetadata.title, + icon: 'mdi:cctv', }, ] : []), ...(where ? [ { - hint: localize('thumbnail.where'), - title: where, - icon: { icon: 'mdi:map-marker-outline' }, + tooltip: localize('thumbnail.where'), + text: where, + icon: 'mdi:map-marker-outline', }, ] : []), ...(tags ? [ { - hint: localize('thumbnail.tag'), - title: tags, - icon: { icon: 'mdi:tag' }, + tooltip: localize('thumbnail.tag'), + text: tags, + icon: 'mdi:tag', }, ] : []), ...(seekString ? [ { - hint: localize('thumbnail.seek'), - title: seekString, - icon: { icon: 'mdi:clock-fast' }, + tooltip: localize('thumbnail.seek'), + text: seekString, + icon: 'mdi:clock-fast', }, ] : []), @@ -183,10 +188,10 @@ export class MediaDetailsController { ...(includeTitle && itemTitle ? [ { - title: itemTitle, + text: itemTitle, ...(details.length > 0 && { - icon: { icon: 'mdi:rename' }, - hint: localize('thumbnail.title'), + icon: 'mdi:rename', + tooltip: localize('thumbnail.title'), }), }, ] @@ -195,20 +200,15 @@ export class MediaDetailsController { ]; } - public getHeading(): MetadataField | null { + public getHeading(): NotificationDetail | null { return this._heading; } - public getDetails(): MetadataField[] { + public getDetails(): NotificationDetail[] { return this._details; } - /** - * Get an overlay message for the item. - * @param context Optional context to include controls. - * @returns An OverlayMessage. - */ - public getMessage(context?: OverlayControlsContext): OverlayMessage { + public getNotification(context?: NotificationControlsContext): Notification { return { heading: this._heading ?? undefined, controls: context ? this._getControls(context) : undefined, @@ -219,8 +219,8 @@ export class MediaDetailsController { }; } - private _getControls(context: OverlayControlsContext): OverlayMessageControl[] { - const controls: OverlayMessageControl[] = []; + private _getControls(context: NotificationControlsContext): NotificationControl[] { + const controls: NotificationControl[] = []; const item = this._item; if (!item) { @@ -230,57 +230,71 @@ export class MediaDetailsController { if (ViewItemClassifier.isReview(item)) { const isReviewed = item.isReviewed(); controls.push({ - title: isReviewed + tooltip: isReviewed ? localize('common.set_reviews.unreviewed') : localize('common.set_reviews.reviewed'), - icon: { icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline' }, - callback: async () => { - const success = await toggleReviewed( - item, - context.viewItemManager, - context.viewManagerEpoch, - context.filterReviewed, - ); - return success ? this.getMessage(context) : null; + icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline', + actions: { + tap_action: createInternalCallbackAction(async (api) => { + const success = await toggleReviewed( + item, + context.viewItemManager, + context.viewManagerEpoch, + context.filterReviewed, + ); + if (success) { + api + .getNotificationManager() + .setNotification(this.getNotification(context)); + } + }), }, + dismiss: false, }); } if (context.capabilities?.canFavorite && ViewItemClassifier.isMedia(item)) { const isFavorite = item.isFavorite(); controls.push({ - title: localize('thumbnail.retain_indefinitely'), - icon: { icon: isFavorite ? 'mdi:star' : 'mdi:star-outline' }, - emphasis: isFavorite ? 'medium' : undefined, - callback: async () => { - const success = await toggleFavorite(item, context.viewItemManager); - return success ? this.getMessage(context) : null; + tooltip: localize('thumbnail.retain_indefinitely'), + icon: isFavorite ? 'mdi:star' : 'mdi:star-outline', + severity: isFavorite ? 'medium' : undefined, + actions: { + tap_action: createInternalCallbackAction(async (api) => { + const success = await toggleFavorite(item, context.viewItemManager); + if (success) { + api + .getNotificationManager() + .setNotification(this.getNotification(context)); + } + }), }, + dismiss: false, }); } if (context.capabilities?.canDownload && item.getID()) { controls.push({ - title: localize('thumbnail.download'), - icon: { icon: 'mdi:download' }, - callback: async () => { - await downloadMedia(item, context.viewItemManager); - - // Close overlay message after download. - return null; + tooltip: localize('thumbnail.download'), + icon: 'mdi:download', + dismiss: true, + actions: { + tap_action: createInternalCallbackAction(async () => { + await downloadMedia(item, context.viewItemManager); + }), }, }); } if (ViewItemClassifier.supportsTimeline(item) && context.viewManagerEpoch) { controls.push({ - title: localize('thumbnail.timeline'), - icon: { icon: 'mdi:target' }, - callback: () => { - navigateToTimeline(item, context.viewManagerEpoch); - - // Close overlay after timeline navigation - return null; + tooltip: localize('thumbnail.timeline'), + icon: 'mdi:target', + dismiss: true, + actions: { + tap_action: createInternalCallbackAction(async () => { + navigateToTimeline(item, context.viewManagerEpoch); + }), }, }); } diff --git a/src/components/icon.ts b/src/components/icon.ts index d38fb136..efce690d 100644 --- a/src/components/icon.ts +++ b/src/components/icon.ts @@ -8,9 +8,9 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { IconController } from '../components-lib/icon-controller'; +import { InternalIcon } from '../config/schema/common/icon.js'; import { HomeAssistant } from '../ha/types'; import iconStyle from '../scss/icon.scss'; -import { Icon } from '../types.js'; import { contentsChanged } from '../utils/basic.js'; @customElement('advanced-camera-card-icon') @@ -19,7 +19,7 @@ export class AdvancedCameraCardIcon extends LitElement { public hass?: HomeAssistant; @property({ attribute: false, hasChanged: contentsChanged }) - public icon?: Icon; + public icon?: InternalIcon; // Note: This attribute will allow non-active entity state styles (e.g. 'off', // 'unavailable') to be overriden from outside the icon itself. This is useful diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts index dd9fa52c..ec9e5d19 100644 --- a/src/components/next-prev-control.ts +++ b/src/components/next-prev-control.ts @@ -2,9 +2,9 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { NextPreviousControlConfig } from '../config/schema/common/controls/next-previous.js'; +import { Icon } from '../config/schema/common/icon.js'; import { HomeAssistant } from '../ha/types.js'; import controlStyle from '../scss/next-previous-control.scss'; -import { Icon } from '../types.js'; import { renderTask } from '../utils/task.js'; import { createFetchThumbnailTask } from '../utils/thumbnail.js'; diff --git a/src/components/overlay-message.ts b/src/components/notification.ts similarity index 52% rename from src/components/overlay-message.ts rename to src/components/notification.ts index c2563325..7b892c40 100644 --- a/src/components/overlay-message.ts +++ b/src/components/notification.ts @@ -2,17 +2,29 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; -import overlayMessageStyle from '../scss/overlay-message.scss'; -import { MetadataField, OverlayMessage, OverlayMessageControl } from '../types.js'; -import { dispatchDismissOverlayMessageEvent } from '../utils/overlay-message.js'; +import { actionHandler } from '../action-handler-directive.js'; +import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request'; +import { + Notification, + NotificationControl, + NotificationDetail, +} from '../config/schema/actions/types.js'; +import notificationStyle from '../scss/notification.scss'; +import { + getActionConfigGivenAction, + hasAction, + stopEventFromActivatingCardWideActions, +} from '../utils/action.js'; +import { arrayify } from '../utils/basic.js'; +import { dispatchDismissNotificationEvent } from '../utils/notification.js'; import './icon.js'; -@customElement('advanced-camera-card-overlay-message') -export class AdvancedCameraCardOverlayMessage extends LitElement { +@customElement('advanced-camera-card-notification') +export class AdvancedCameraCardNotification extends LitElement { @property({ attribute: false }) - public message: OverlayMessage | null = null; + public notification: Notification | null = null; - private _refMessage: Ref = createRef(); + private _refNotification: Ref = createRef(); public connectedCallback(): void { super.connectedCallback(); @@ -29,20 +41,20 @@ export class AdvancedCameraCardOverlayMessage extends LitElement { } protected render(): TemplateResult | void { - if (!this.message) { + if (!this.notification) { return; } - const heading = this.message.heading; - const details = this.message.details ?? []; - const text = this.message.text; - const controls = this.message.controls ?? []; + const heading = this.notification.heading; + const details = this.notification.details ?? []; + const text = this.notification.text; + const controls = this.notification.controls ?? []; return html`
@@ -64,60 +76,70 @@ export class AdvancedCameraCardOverlayMessage extends LitElement { `; } - private _renderControl(control: OverlayMessageControl): TemplateResult { - const emphasisClass = control.emphasis ? `emphasis-${control.emphasis}` : ''; + private _renderControl(control: NotificationControl): TemplateResult { + const severityClass = control.severity ? `severity-${control.severity}` : ''; return html`
this._handleControlClick(control)} + class="control ${severityClass}" + title=${control.tooltip ?? ''} + .actionHandler=${actionHandler({ + hasHold: hasAction(control.actions?.hold_action), + hasDoubleClick: hasAction(control.actions?.double_tap_action), + })} + @action=${(ev: CustomEvent) => this._handleControlAction(ev, control)} > ${control.icon ? html`` : ''}
`; } - private async _handleControlClick(control: OverlayMessageControl): Promise { - const result = await control.callback(); - if (result === null) { - // null = close the message + private _handleControlAction( + ev: CustomEvent<{ action: string }>, + control: NotificationControl, + ): void { + stopEventFromActivatingCardWideActions(ev); + + const action = getActionConfigGivenAction(ev.detail.action, control.actions); + if (action) { + dispatchActionExecutionRequest(this, { + actions: arrayify(action), + }); + } + if (control.dismiss !== false) { this._dismiss(); - } else { - // Updated message = keep open and refresh - this.message = result; } } - private _renderDetail(detail: MetadataField, isHeading = false): TemplateResult { + private _renderDetail(detail: NotificationDetail, isHeading = false): TemplateResult { const classes = { detail: true, heading: isHeading, - [`emphasis-${detail.emphasis}`]: !!detail.emphasis, + [`severity-${detail.severity}`]: !!detail.severity, }; return html`
${detail.icon ? html`` : ''} - ${detail.title} + ${detail.text}
`; } private _dismiss = (): void => { - this._refMessage.value?.classList.add('exiting'); + this._refNotification.value?.classList.add('exiting'); }; private _handleAnimationEnd = (ev: AnimationEvent): void => { if (ev.animationName === 'slideDown') { - dispatchDismissOverlayMessageEvent(this); + dispatchDismissNotificationEvent(this); } }; @@ -136,12 +158,12 @@ export class AdvancedCameraCardOverlayMessage extends LitElement { }; static get styles(): CSSResultGroup { - return unsafeCSS(overlayMessageStyle); + return unsafeCSS(notificationStyle); } } declare global { interface HTMLElementTagNameMap { - 'advanced-camera-card-overlay-message': AdvancedCameraCardOverlayMessage; + 'advanced-camera-card-notification': AdvancedCameraCardNotification; } } diff --git a/src/components/submenu/select-button.ts b/src/components/submenu/select-button.ts index c6417dfb..42e99638 100644 --- a/src/components/submenu/select-button.ts +++ b/src/components/submenu/select-button.ts @@ -8,6 +8,7 @@ import { } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; +import { InternalIcon } from '../../config/schema/common/icon.js'; import { MenuSubmenuSelect } from '../../config/schema/elements/custom/menu/submenu-select.js'; import { MenuSubmenuItem } from '../../config/schema/elements/custom/menu/submenu.js'; import { computeDomain } from '../../ha/compute-domain.js'; @@ -17,7 +18,6 @@ import { isHassDifferent } from '../../ha/is-hass-different.js'; import { EntityRegistryManager } from '../../ha/registry/entity/types.js'; import { HomeAssistant } from '../../ha/types.js'; import menuButtonStyle from '../../scss/menu-button.scss'; -import { Icon } from '../../types.js'; import { createSelectOptionAction } from '../../utils/action.js'; import '../icon.js'; import './index.js'; @@ -37,7 +37,7 @@ export class AdvancedCameraCardSubmenuSelectButton extends LitElement { private _optionTitles?: Record; private _generatedSubmenuItems?: MenuSubmenuItem[]; - private _generatedIcon?: Icon; + private _generatedIcon?: InternalIcon; protected shouldUpdate(changedProps: PropertyValues): boolean { // No need to update the submenu unless the select entity has changed. diff --git a/src/components/thumbnail/details.ts b/src/components/thumbnail/details.ts index f8d05b90..dc74dfca 100644 --- a/src/components/thumbnail/details.ts +++ b/src/components/thumbnail/details.ts @@ -8,11 +8,12 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; import { CameraManager } from '../../camera-manager/manager'; import { MediaDetailsController } from '../../components-lib/media/details-controller'; +import { NotificationDetail } from '../../config/schema/actions/types'; import { HomeAssistant } from '../../ha/types'; import thumbnailDetailsStyle from '../../scss/thumbnail-details.scss'; -import { MetadataField } from '../../types'; import { ViewItem } from '../../view/item'; import '../icon'; @@ -42,7 +43,10 @@ export class AdvancedCameraCardThumbnailDetails extends LitElement { const heading = this._controller.getHeading(); const details = this._controller.getDetails(); - const renderDetail = (detail: MetadataField, heading = false): TemplateResult => { + const renderDetail = ( + detail: NotificationDetail, + heading = false, + ): TemplateResult => { return html`
${detail.icon ? html` ` : ''} - ${detail.title} + ${detail.text}
`; }; diff --git a/src/components/thumbnail/feature/feature.ts b/src/components/thumbnail/feature/feature.ts index e73d90a4..ba6b6d80 100644 --- a/src/components/thumbnail/feature/feature.ts +++ b/src/components/thumbnail/feature/feature.ts @@ -9,24 +9,27 @@ import { import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { CameraManager } from '../../../camera-manager/manager'; +import { dispatchActionExecutionRequest } from '../../../card-controller/actions/utils/execution-request'; import { ViewItemManager } from '../../../card-controller/view/item-manager'; import { ViewManagerEpoch } from '../../../card-controller/view/types'; import { MediaDetailsController, - OverlayControlsContext, + NotificationControlsContext, } from '../../../components-lib/media/details-controller'; import { ThumbnailFeatureController } from '../../../components-lib/thumbnail/feature/controller'; import { HomeAssistant } from '../../../ha/types'; import { localize } from '../../../localize/localize'; import thumbnailFeatureStyle from '../../../scss/thumbnail-feature.scss'; -import { stopEventFromActivatingCardWideActions } from '../../../utils/action'; +import { + createNotificationAction, + stopEventFromActivatingCardWideActions, +} from '../../../utils/action'; import { downloadMedia, navigateToTimeline, toggleFavorite, toggleReviewed, } from '../../../utils/media-actions'; -import { dispatchShowOverlayMessageEvent } from '../../../utils/overlay-message'; import { ViewItem } from '../../../view/item'; import { ViewItemClassifier } from '../../../view/item-classifier'; import '../../icon.js'; @@ -80,7 +83,7 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement { } } - private _getControlContext(): OverlayControlsContext { + private _getControlContext(): NotificationControlsContext { return { hass: this.hass, viewItemManager: this.viewItemManager, @@ -207,10 +210,13 @@ export class AdvancedCameraCardThumbnailFeature extends LitElement { stopEventFromActivatingCardWideActions(ev); const detailsController = new MediaDetailsController(); detailsController.calculate(this.cameraManager, this.item); - dispatchShowOverlayMessageEvent( - this, - detailsController.getMessage(this._getControlContext()), - ); + dispatchActionExecutionRequest(this, { + actions: [ + createNotificationAction( + detailsController.getNotification(this._getControlContext()), + ), + ], + }); }} >` : ''} diff --git a/src/config/schema/actions/types.ts b/src/config/schema/actions/types.ts index bd97afae..96fe014e 100644 --- a/src/config/schema/actions/types.ts +++ b/src/config/schema/actions/types.ts @@ -19,9 +19,26 @@ import { substreamSelectActionConfigSchema } from './custom/substream-select'; import { viewActionConfigSchema } from './custom/view'; import { stockActionSchema } from './stock/types'; -// Provide a manual type definition to avoid the `any` that would be created by -// the lazy() evaluation below. +// ============================================================================ +// Notification and Status Bar action schemas are co-located here because their +// content schemas reference actionConfigSchema (creating a circular dep). +// Each uses z.lazy + a manual type annotation to break the cycle and preserve +// correct type inference. // See: https://zod.dev/?id=recursive-types +// ============================================================================ + +export type NotificationActionConfig = z.infer< + typeof advancedCameraCardCustomActionsBaseSchema +> & { + advanced_camera_card_action: 'notification'; + notification: Notification; +}; +export const notificationActionConfigSchema: z.ZodSchema = + advancedCameraCardCustomActionsBaseSchema.extend({ + advanced_camera_card_action: z.literal('notification'), + notification: z.lazy(() => notificationSchema), + }); + export type StatusBarActionConfig = z.infer< typeof advancedCameraCardCustomActionsBaseSchema > & { @@ -46,6 +63,7 @@ const advancedCameraCardCustomActionSchema = z.union([ internalCallbackActionConfigSchema, logActionConfigSchema, mediaPlayerActionConfigSchema, + notificationActionConfigSchema, ptzActionConfigSchema, ptzControlsActionConfigSchema, ptzDigitalActionConfigSchema, @@ -66,6 +84,7 @@ export const actionConfigSchema = z.union([ advancedCameraCardCustomActionSchema, ]); export type ActionConfig = z.infer; + export const actionsBaseSchema = z .object({ tap_action: actionConfigSchema.or(actionConfigSchema.array()).optional(), @@ -90,6 +109,38 @@ export const actionsSchema = z.object({ actions: actionsBaseSchema.optional(), }); +// ============================================================================ +// Notification Elements +// +// Note: Notification schemas are defined here (after actionsBaseSchema) so +// controls can directly reference actionsBaseSchema without z.lazy. +// ============================================================================ + +const notificationBaseSchema = z.object({ + icon: z.string().optional(), + tooltip: z.string().optional(), + severity: severitySchema.optional(), +}); + +export const notificationDetailSchema = notificationBaseSchema.extend({ + text: z.string(), +}); +export type NotificationDetail = z.infer; + +export const notificationControlSchema = notificationBaseSchema.extend({ + actions: actionsBaseSchema.optional(), + dismiss: z.boolean().default(true), +}); +export type NotificationControl = z.infer; + +export const notificationSchema = z.object({ + heading: notificationDetailSchema.optional(), + controls: notificationControlSchema.array().optional(), + details: notificationDetailSchema.array().optional(), + text: z.string().optional(), +}); +export type Notification = z.infer; + // ============================================================================ // Status Bar Elements // diff --git a/src/config/schema/common/icon.ts b/src/config/schema/common/icon.ts new file mode 100644 index 00000000..58080513 --- /dev/null +++ b/src/config/schema/common/icon.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const iconSchema = z.object({ + // MDI icon name (e.g. 'mdi:star'). + icon: z.string().optional(), + + // HA entity whose icon will be used when `icon` is not set. + entity: z.string().optional(), + + // Whether to tint the icon color based on the entity's state. + stateColor: z.boolean().optional(), +}); +export type Icon = z.infer; + +// Extended internally to include a fallback icon that is not user-configurable. +export interface InternalIcon extends Icon { + fallback?: string; +} diff --git a/src/config/schema/status-bar.ts b/src/config/schema/status-bar.ts index c3440d79..41de0825 100644 --- a/src/config/schema/status-bar.ts +++ b/src/config/schema/status-bar.ts @@ -26,6 +26,7 @@ export const statusBarConfigDefault = { severity: statusBarItemDefault, technology: statusBarItemDefault, title: statusBarItemDefault, + upgrade: statusBarItemDefault, }, position: 'bottom' as const, style: 'popup' as const, @@ -53,6 +54,7 @@ export const statusBarConfigSchema = z ), severity: statusBarItemBaseSchema.default(statusBarConfigDefault.items.severity), title: statusBarItemBaseSchema.default(statusBarConfigDefault.items.title), + upgrade: statusBarItemBaseSchema.default(statusBarConfigDefault.items.upgrade), }) .default(statusBarConfigDefault.items), }) diff --git a/src/editor.ts b/src/editor.ts index 7eb4c4a4..7e125cfe 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -3110,6 +3110,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard ${this._renderStatusBarItem('resolution') /* */} ${this._renderStatusBarItem('technology') /* */} ${this._renderStatusBarItem('engine') /* */} + ${this._renderStatusBarItem('upgrade') /* */}
` : ''} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 47682b44..c9af10d8 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -6,6 +6,7 @@ "common": { "advanced_camera_card": "Advanced Camera Card", "advanced_camera_card_description": "An Advanced Camera Card", + "dismiss": "Dismiss", "folder": "Folder", "live": "Live", "no_folder": "No folder to display", @@ -557,7 +558,8 @@ "priority": "Item priority", "resolution": "Resolution", "technology": "Technology", - "title": "Title" + "title": "Title", + "upgrade": "Configuration upgrade notification" }, "popup_seconds": "Status bar popup seconds", "position": "Status bar position", @@ -821,6 +823,12 @@ "media_viewer": { "unseekable": "Seek time not found in media" }, + "notification": { + "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." + } + }, "thumbnail": { "camera": "Camera", "download": "Download media", diff --git a/src/scss/overlay-message.scss b/src/scss/notification.scss similarity index 72% rename from src/scss/overlay-message.scss rename to src/scss/notification.scss index 290c6c6a..05292864 100644 --- a/src/scss/overlay-message.scss +++ b/src/scss/notification.scss @@ -8,7 +8,7 @@ align-items: flex-end; padding: 24px; pointer-events: none; - z-index: $z-index-overlay-message; + z-index: $z-index-notification; } .backdrop { @@ -17,7 +17,7 @@ pointer-events: auto; } -.message { +.notification { position: relative; display: flex; flex-direction: row; @@ -30,23 +30,22 @@ padding-right: 48px; overflow-y: auto; scrollbar-width: thin; - scrollbar-color: var(--advanced-camera-card-overlay-message-scrollbar-color) - transparent; + scrollbar-color: var(--advanced-camera-card-notification-scrollbar-color) transparent; // Glassmorphism background - background: var(--advanced-camera-card-overlay-message-background); - backdrop-filter: var(--advanced-camera-card-overlay-message-backdrop-filter); - -webkit-backdrop-filter: var(--advanced-camera-card-overlay-message-backdrop-filter); + background: var(--advanced-camera-card-notification-background); + backdrop-filter: var(--advanced-camera-card-notification-backdrop-filter); + -webkit-backdrop-filter: var(--advanced-camera-card-notification-backdrop-filter); // Borders & Edge highlights - border: var(--advanced-camera-card-overlay-message-border); - border-top: var(--advanced-camera-card-overlay-message-border-top); + border: var(--advanced-camera-card-notification-border); + border-top: var(--advanced-camera-card-notification-border-top); border-radius: var(--advanced-camera-card-border-radius-final); // Shadows box-shadow: var( --advanced-camera-card-box-shadow-override, - var(--advanced-camera-card-overlay-message-box-shadow) + var(--advanced-camera-card-notification-box-shadow) ); pointer-events: auto; @@ -56,7 +55,7 @@ } // Exit animation -.message.exiting { +.notification.exiting { animation: slideDown 0.25s ease-out forwards; } @@ -99,12 +98,12 @@ display: flex; align-items: center; justify-content: center; - color: var(--advanced-camera-card-overlay-message-icon-color); + color: var(--advanced-camera-card-notification-icon-color); filter: drop-shadow( 0 0 8px color-mix( in oklab, - var(--advanced-camera-card-overlay-message-icon-color), + var(--advanced-camera-card-notification-icon-color), transparent 60% ) ); @@ -119,7 +118,7 @@ font-size: 15px; font-weight: 400; line-height: 1.5; - color: var(--advanced-camera-card-overlay-message-text-color); + color: var(--advanced-camera-card-notification-text-color); word-break: break-word; } @@ -135,7 +134,7 @@ background: color-mix( in srgb, - var(--advanced-camera-card-overlay-message-close-color), + var(--advanced-camera-card-notification-close-color), transparent 92% ); } @@ -150,7 +149,7 @@ color: color-mix( in srgb, - var(--advanced-camera-card-overlay-message-close-color), + var(--advanced-camera-card-notification-close-color), transparent 40% ); @@ -166,17 +165,17 @@ } // Base control hover color transition (from dimmed to full). - &:not([class^='emphasis-']):hover { - color: var(--advanced-camera-card-overlay-message-close-color); + &:not([class^='severity-']):hover { + color: var(--advanced-camera-card-notification-close-color); } - &.emphasis-low { + &.severity-low { color: var(--advanced-camera-card-foreground-severity-low-color); } - &.emphasis-medium { + &.severity-medium { color: var(--advanced-camera-card-foreground-severity-medium-color); } - &.emphasis-high { + &.severity-high { color: var(--advanced-camera-card-foreground-severity-high-color); } } @@ -194,12 +193,12 @@ background: color-mix( in srgb, - var(--advanced-camera-card-overlay-message-close-color), + var(--advanced-camera-card-notification-close-color), transparent 95% ); color: color-mix( in srgb, - var(--advanced-camera-card-overlay-message-close-color), + var(--advanced-camera-card-notification-close-color), transparent 40% ); @@ -210,10 +209,10 @@ .close:hover { background: color-mix( in srgb, - var(--advanced-camera-card-overlay-message-close-color), + var(--advanced-camera-card-notification-close-color), transparent 90% ); - color: var(--advanced-camera-card-overlay-message-close-color); + color: var(--advanced-camera-card-notification-close-color); } .close advanced-camera-card-icon { @@ -233,7 +232,7 @@ display: flex; align-items: center; gap: 8px; - color: var(--advanced-camera-card-overlay-message-text-color); + color: var(--advanced-camera-card-notification-text-color); font-size: 14px; line-height: 1.4; @@ -261,15 +260,15 @@ } } - &.emphasis-low advanced-camera-card-icon { + &.severity-low advanced-camera-card-icon { color: var(--advanced-camera-card-foreground-severity-low-color); opacity: 1; } - &.emphasis-medium advanced-camera-card-icon { + &.severity-medium advanced-camera-card-icon { color: var(--advanced-camera-card-foreground-severity-medium-color); opacity: 1; } - &.emphasis-high advanced-camera-card-icon { + &.severity-high advanced-camera-card-icon { color: var(--advanced-camera-card-foreground-severity-high-color); opacity: 1; } @@ -282,10 +281,10 @@ border-top: 1px solid color-mix( in srgb, - var(--advanced-camera-card-overlay-message-text-color), + var(--advanced-camera-card-notification-text-color), transparent 80% ); - color: var(--advanced-camera-card-overlay-message-text-color); + color: var(--advanced-camera-card-notification-text-color); font-size: 14px; line-height: 1.5; white-space: pre-wrap; diff --git a/src/scss/status.scss b/src/scss/status.scss index 2570e839..d671c927 100644 --- a/src/scss/status.scss +++ b/src/scss/status.scss @@ -64,6 +64,7 @@ .item.action { cursor: pointer; + pointer-events: auto; } img.item, diff --git a/src/scss/themes/base.scss b/src/scss/themes/base.scss index 02339617..fcfab7cc 100644 --- a/src/scss/themes/base.scss +++ b/src/scss/themes/base.scss @@ -286,24 +286,24 @@ transparent 20% ); - /****************** - * Overlay Message - ******************/ - --advanced-camera-card-overlay-message-background: linear-gradient( + /************** + * Notification + **************/ + --advanced-camera-card-notification-background: linear-gradient( 135deg, rgba(35, 35, 35, 0.75) 0%, rgba(15, 15, 15, 0.65) 100% ); - --advanced-camera-card-overlay-message-backdrop-filter: blur(20px) saturate(180%); - --advanced-camera-card-overlay-message-border: 1px solid rgba(255, 255, 255, 0.1); - --advanced-camera-card-overlay-message-border-top: 1px solid rgba(255, 255, 255, 0.15); - --advanced-camera-card-overlay-message-box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5), + --advanced-camera-card-notification-backdrop-filter: blur(20px) saturate(180%); + --advanced-camera-card-notification-border: 1px solid rgba(255, 255, 255, 0.1); + --advanced-camera-card-notification-border-top: 1px solid rgba(255, 255, 255, 0.15); + --advanced-camera-card-notification-box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5), 0 2px 8px 0 rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(255, 255, 255, 0.05); - --advanced-camera-card-overlay-message-text-color: #ffffff; - --advanced-camera-card-overlay-message-icon-color: var( + --advanced-camera-card-notification-text-color: #ffffff; + --advanced-camera-card-notification-icon-color: var( --advanced-camera-card-foreground-primary ); - --advanced-camera-card-overlay-message-scrollbar-color: rgba(255, 255, 255, 0.2); - --advanced-camera-card-overlay-message-close-color: #ffffff; + --advanced-camera-card-notification-scrollbar-color: rgba(255, 255, 255, 0.2); + --advanced-camera-card-notification-close-color: #ffffff; } diff --git a/src/scss/themes/light.scss b/src/scss/themes/light.scss index 1f7df76c..6dc6d16f 100644 --- a/src/scss/themes/light.scss +++ b/src/scss/themes/light.scss @@ -522,20 +522,20 @@ // Menu border colors need to re-adjust based on redefined variable. --wa-color-surface-border: var(--ha-color-border-neutral-quiet); - /****************** - * Overlay Message - ******************/ - --advanced-camera-card-overlay-message-background: linear-gradient( + /************** + * Notification + **************/ + --advanced-camera-card-notification-background: linear-gradient( 135deg, rgba(255, 255, 255, 0.85) 0%, rgba(235, 235, 235, 0.75) 100% ); - --advanced-camera-card-overlay-message-text-color: var(--primary-text-color); - --advanced-camera-card-overlay-message-border: 1px solid rgba(0, 0, 0, 0.3); - --advanced-camera-card-overlay-message-border-top: 1px solid rgba(0, 0, 0, 0.2); - --advanced-camera-card-overlay-message-box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3), + --advanced-camera-card-notification-text-color: var(--primary-text-color); + --advanced-camera-card-notification-border: 1px solid rgba(0, 0, 0, 0.3); + --advanced-camera-card-notification-border-top: 1px solid rgba(0, 0, 0, 0.2); + --advanced-camera-card-notification-box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3), 0 2px 8px 0 rgba(0, 0, 0, 0.2), inset 0 0 0 1px rgba(255, 255, 255, 0.8); - --advanced-camera-card-overlay-message-scrollbar-color: rgba(0, 0, 0, 0.5); - --advanced-camera-card-overlay-message-close-color: #000000; + --advanced-camera-card-notification-scrollbar-color: rgba(0, 0, 0, 0.5); + --advanced-camera-card-notification-close-color: #000000; --app-header-text-color: white; } diff --git a/src/scss/z-index.scss b/src/scss/z-index.scss index 1243f02b..1c34617e 100644 --- a/src/scss/z-index.scss +++ b/src/scss/z-index.scss @@ -15,6 +15,6 @@ $z-index-loading: 6; // Need menu to render above drawer (so the drawer button is below menu-submenus) $z-index-menu: 4; -$z-index-overlay-message: 4; +$z-index-notification: 4; $z-index-drawer: 3; $z-index-status-bar: 2; diff --git a/src/types.ts b/src/types.ts index 8b75ba82..116db2a1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,6 @@ import { z } from 'zod'; import type { EffectOptions } from './card-controller/effects/types'; import type { LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from './ha/types'; -import { Severity } from './severity'; // UI-facing media types for galleries and views. export const VIEW_MEDIA_TYPES = ['clips', 'snapshots', 'recordings', 'reviews'] as const; @@ -64,24 +63,6 @@ export interface Message { url?: MessageURL; } -export interface MetadataField { - title: string; - icon?: Icon; - hint?: string; - emphasis?: Severity; -} - -export interface OverlayMessageControl extends MetadataField { - callback: () => OverlayMessage | null | Promise; -} - -export interface OverlayMessage { - heading?: MetadataField; - controls?: OverlayMessageControl[]; - details?: MetadataField[]; - text?: string; -} - export type WebkitHTMLVideoElement = HTMLVideoElement & { webkitDisplayingFullscreen: boolean; webkitSupportsFullscreen: boolean; @@ -182,21 +163,6 @@ export const capabilityKeys: readonly [CapabilityKey, ...CapabilityKey[]] = [ 'trigger', ] as const; -export interface Icon { - // If set, this icon will be used. - icon?: string; - - // If icon is not set, this entity's icon will be used (and HA will be asked - // to render it). - entity?: string; - - // Whether or not to change the icon color depending on entity state. - stateColor?: boolean; - - // If an icon is not otherwise resolved / available, this will be used instead. - fallback?: string; -} - export interface Interaction { action: string; } diff --git a/src/utils/action.ts b/src/utils/action.ts index 767aad22..dd316b51 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -28,10 +28,12 @@ import { SetReviewActionConfig } from '../config/schema/actions/custom/set-revie import { SubstreamSelectActionConfig } from '../config/schema/actions/custom/substream-select.js'; import { ViewActionConfig } from '../config/schema/actions/custom/view.js'; import { PerformActionActionConfig } from '../config/schema/actions/stock/perform-action.js'; +import type { Notification } from '../config/schema/actions/types.js'; import { ActionConfig, - ActionsConfig, + Actions, AdvancedCameraCardCustomActionConfig, + NotificationActionConfig, } from '../config/schema/actions/types.js'; import { AdvancedCameraCardUserSpecifiedView } from '../config/schema/common/const.js'; import { PTZControlType } from '../config/schema/common/controls/ptz.js'; @@ -270,6 +272,20 @@ export function createSetReviewAction(reviewed?: boolean): SetReviewActionConfig }; } +export function createNotificationAction( + notification: Notification, + options?: { + cardID?: string; + }, +): NotificationActionConfig { + return { + action: 'fire-dom-event', + advanced_camera_card_action: 'notification', + notification, + ...(options?.cardID && { card_id: options.cardID }), + }; +} + /** * Get an action configuration given a config and an interaction (e.g. 'tap'). * @param interaction The interaction: `tap`, `hold` or `double_tap` @@ -278,7 +294,7 @@ export function createSetReviewAction(reviewed?: boolean): SetReviewActionConfig */ export function getActionConfigGivenAction( interaction?: string, - config?: ActionsConfig | null, + config?: Actions | null, ): ActionConfig | ActionConfig[] | null { if (!interaction || !config) { return null; diff --git a/src/utils/notification.ts b/src/utils/notification.ts new file mode 100644 index 00000000..727ac933 --- /dev/null +++ b/src/utils/notification.ts @@ -0,0 +1,5 @@ +import { fireAdvancedCameraCardEvent } from './fire-advanced-camera-card-event.js'; + +export function dispatchDismissNotificationEvent(element: HTMLElement): void { + fireAdvancedCameraCardEvent(element, 'notification:dismiss'); +} diff --git a/src/utils/overlay-message.ts b/src/utils/overlay-message.ts deleted file mode 100644 index e91c81a9..00000000 --- a/src/utils/overlay-message.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { OverlayMessage } from '../types.js'; -import { fireAdvancedCameraCardEvent } from './fire-advanced-camera-card-event.js'; - -export function dispatchShowOverlayMessageEvent( - element: HTMLElement, - message: OverlayMessage, -): void { - fireAdvancedCameraCardEvent(element, 'overlay-message:show', message); -} - -export function dispatchDismissOverlayMessageEvent(element: HTMLElement): void { - fireAdvancedCameraCardEvent(element, 'overlay-message:dismiss'); -} diff --git a/tests/card-controller/actions/actions/info.test.ts b/tests/card-controller/actions/actions/info.test.ts index 5373c89f..44d804a0 100644 --- a/tests/card-controller/actions/actions/info.test.ts +++ b/tests/card-controller/actions/actions/info.test.ts @@ -28,7 +28,7 @@ describe('InfoAction', () => { await action.execute(api); - expect(api.getOverlayMessageManager().setMessage).toBeCalled(); + expect(api.getNotificationManager().setNotification).toBeCalled(); }); it('should not handle info action without media', async () => { @@ -45,6 +45,6 @@ describe('InfoAction', () => { await action.execute(api); - expect(api.getOverlayMessageManager().setMessage).not.toBeCalled(); + expect(api.getNotificationManager().setNotification).not.toBeCalled(); }); }); diff --git a/tests/card-controller/actions/actions/notification.test.ts b/tests/card-controller/actions/actions/notification.test.ts new file mode 100644 index 00000000..ad32d57d --- /dev/null +++ b/tests/card-controller/actions/actions/notification.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { NotificationAction } from '../../../../src/card-controller/actions/actions/notification'; +import { createCardAPI } from '../../../test-utils'; + +describe('NotificationAction', () => { + it('should set notification on manager', async () => { + const api = createCardAPI(); + const notification = { + heading: { text: 'Test Heading' }, + text: 'Test text', + }; + + const action = new NotificationAction( + {}, + { + action: 'fire-dom-event', + advanced_camera_card_action: 'notification', + notification, + }, + ); + + await action.execute(api); + + expect(api.getNotificationManager().setNotification).toHaveBeenCalledWith( + notification, + ); + }); +}); diff --git a/tests/card-controller/actions/factory.test.ts b/tests/card-controller/actions/factory.test.ts index bc9226b2..07243bf2 100644 --- a/tests/card-controller/actions/factory.test.ts +++ b/tests/card-controller/actions/factory.test.ts @@ -22,9 +22,10 @@ import { MoreInfoAction } from '../../../src/card-controller/actions/actions/mor import { MuteAction } from '../../../src/card-controller/actions/actions/mute'; import { NavigateAction } from '../../../src/card-controller/actions/actions/navigate'; import { NoneAction } from '../../../src/card-controller/actions/actions/none'; +import { NotificationAction } from '../../../src/card-controller/actions/actions/notification'; import { PauseAction } from '../../../src/card-controller/actions/actions/pause'; -import { PIPAction } from '../../../src/card-controller/actions/actions/pip'; import { PerformActionAction } from '../../../src/card-controller/actions/actions/perform-action'; +import { PIPAction } from '../../../src/card-controller/actions/actions/pip'; import { PlayAction } from '../../../src/card-controller/actions/actions/play'; import { PTZAction } from '../../../src/card-controller/actions/actions/ptz'; import { PTZControlsAction } from '../../../src/card-controller/actions/actions/ptz-controls'; @@ -126,6 +127,13 @@ describe('ActionFactory', () => { }, LogAction, ], + [ + { + advanced_camera_card_action: 'notification' as const, + notification: { text: 'test' }, + }, + NotificationAction, + ], [ { advanced_camera_card_action: 'media_player' as const, diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index 69412377..c06230f4 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -121,6 +121,27 @@ describe('ConfigManager', () => { expect(manager.getConfig()).toBeNull(); expect(manager.getNonOverriddenConfig()).toBeNull(); expect(manager.getRawConfig()).toBeNull(); + expect(manager.isUpgradeable()).toBe(false); + }); + + describe('isUpgradeable', () => { + it('should return true for upgradeable config', () => { + const manager = new ConfigManager(createCardAPI()); + manager.setConfig({ + type: 'custom:frigate-card', + cameras: [TEST_CAMERAS.OFFICE], + }); + expect(manager.isUpgradeable()).toBe(true); + }); + + it('should return false for non-upgradeable config', () => { + const manager = new ConfigManager(createCardAPI()); + manager.setConfig({ + type: 'custom:advanced-camera-card', + cameras: [TEST_CAMERAS.OFFICE], + }); + expect(manager.isUpgradeable()).toBe(false); + }); }); it('should successfully parse basic config', () => { diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index def9f49c..6cf480f4 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -21,7 +21,7 @@ import { MediaLoadedInfoManager } from '../../src/card-controller/media-info-man import { MediaPlayerManager } from '../../src/card-controller/media-player-manager'; import { MessageManager } from '../../src/card-controller/message-manager'; import { MicrophoneManager } from '../../src/card-controller/microphone-manager'; -import { OverlayMessageManager } from '../../src/card-controller/overlay-message-manager'; +import { NotificationManager } from '../../src/card-controller/notification-manager'; import { PIPManager } from '../../src/card-controller/pip-manager'; import { QueryStringManager } from '../../src/card-controller/query-string-manager'; import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager'; @@ -54,7 +54,7 @@ vi.mock('../../src/card-controller/media-info-manager'); vi.mock('../../src/card-controller/media-player-manager'); vi.mock('../../src/card-controller/message-manager'); vi.mock('../../src/card-controller/microphone-manager'); -vi.mock('../../src/card-controller/overlay-message-manager'); +vi.mock('../../src/card-controller/notification-manager'); vi.mock('../../src/card-controller/pip-manager'); vi.mock('../../src/card-controller/query-string-manager'); vi.mock('../../src/card-controller/status-bar-item-manager'); @@ -226,9 +226,9 @@ describe('CardController', () => { ); }); - it('getOverlayMessageManager', () => { - expect(createController().getOverlayMessageManager()).toBe( - vi.mocked(OverlayMessageManager).mock.instances[0], + it('getNotificationManager', () => { + expect(createController().getNotificationManager()).toBe( + vi.mocked(NotificationManager).mock.instances[0], ); }); diff --git a/tests/card-controller/notification-manager.test.ts b/tests/card-controller/notification-manager.test.ts new file mode 100644 index 00000000..6de3eae2 --- /dev/null +++ b/tests/card-controller/notification-manager.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CardElementManager } from '../../src/card-controller/card-element-manager'; +import { NotificationManager } from '../../src/card-controller/notification-manager'; +import { CardNotificationAPI } from '../../src/card-controller/types'; + +describe('NotificationManager', () => { + const cardElementManager = mock(); + const api = mock(); + + beforeEach(() => { + vi.clearAllMocks(); + api.getCardElementManager.mockReturnValue(cardElementManager); + }); + + it('should be constructed', () => { + const manager = new NotificationManager(api); + expect(manager).toBeDefined(); + expect(manager.getNotification()).toBeNull(); + expect(manager.hasNotification()).toBeFalsy(); + }); + + it('should set and get notification', () => { + const manager = new NotificationManager(api); + const notification = { text: 'foo' }; + manager.setNotification(notification); + + expect(manager.getNotification()).toBe(notification); + expect(manager.hasNotification()).toBeTruthy(); + expect(cardElementManager.update).toHaveBeenCalled(); + }); + + it('should reset notification', () => { + const manager = new NotificationManager(api); + manager.setNotification({ text: 'foo' }); + vi.clearAllMocks(); + + manager.reset(); + + expect(manager.getNotification()).toBeNull(); + expect(manager.hasNotification()).toBeFalsy(); + expect(cardElementManager.update).toHaveBeenCalled(); + }); + + it('should not update if reset is called with no notification', () => { + const manager = new NotificationManager(api); + manager.reset(); + + expect(cardElementManager.update).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/card-controller/overlay-message-manager.test.ts b/tests/card-controller/overlay-message-manager.test.ts deleted file mode 100644 index 8b9bbcb7..00000000 --- a/tests/card-controller/overlay-message-manager.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import { CardElementManager } from '../../src/card-controller/card-element-manager'; -import { OverlayMessageManager } from '../../src/card-controller/overlay-message-manager'; -import { CardOverlayMessageAPI } from '../../src/card-controller/types'; - -describe('OverlayMessageManager', () => { - const cardElementManager = mock(); - const api = mock(); - - beforeEach(() => { - vi.clearAllMocks(); - api.getCardElementManager.mockReturnValue(cardElementManager); - }); - - it('should be constructed', () => { - const manager = new OverlayMessageManager(api); - expect(manager).toBeDefined(); - expect(manager.getMessage()).toBeNull(); - expect(manager.hasMessage()).toBeFalsy(); - }); - - it('should set and get message', () => { - const manager = new OverlayMessageManager(api); - const message = { text: 'foo' }; - manager.setMessage(message); - - expect(manager.getMessage()).toBe(message); - expect(manager.hasMessage()).toBeTruthy(); - expect(cardElementManager.update).toHaveBeenCalled(); - }); - - it('should reset message', () => { - const manager = new OverlayMessageManager(api); - manager.setMessage({ text: 'foo' }); - vi.clearAllMocks(); - - manager.reset(); - - expect(manager.getMessage()).toBeNull(); - expect(manager.hasMessage()).toBeFalsy(); - expect(cardElementManager.update).toHaveBeenCalled(); - }); - - it('should not update if reset is called with no message', () => { - const manager = new OverlayMessageManager(api); - manager.reset(); - - expect(cardElementManager.update).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/card-controller/status-bar-item-manager.test.ts b/tests/card-controller/status-bar-item-manager.test.ts index c0296005..24cdc587 100644 --- a/tests/card-controller/status-bar-item-manager.test.ts +++ b/tests/card-controller/status-bar-item-manager.test.ts @@ -241,6 +241,56 @@ describe('StatusBarItemManager', () => { }); }); + describe('upgrade', () => { + it('should show upgrade item when upgradeable', () => { + const manager = new StatusBarItemManager(createCardAPI()); + + const items = manager.calculateItems({ + isUpgradeable: true, + }); + + expect(items).toContainEqual( + expect.objectContaining({ + type: 'custom:advanced-camera-card-status-bar-icon' as const, + icon: 'mdi:update', + severity: 'medium', + actions: expect.objectContaining({ + tap_action: expect.objectContaining({ + action: 'fire-dom-event', + advanced_camera_card_action: 'notification', + }), + }), + }), + ); + }); + + it('should not show upgrade item when not upgradeable', () => { + const manager = new StatusBarItemManager(createCardAPI()); + + const items = manager.calculateItems({ + isUpgradeable: false, + }); + + expect(items).not.toContainEqual( + expect.objectContaining({ + icon: 'mdi:update', + }), + ); + }); + + it('should not show upgrade item by default', () => { + const manager = new StatusBarItemManager(createCardAPI()); + + const items = manager.calculateItems(); + + expect(items).not.toContainEqual( + expect.objectContaining({ + icon: 'mdi:update', + }), + ); + }); + }); + describe('severity', () => { it('should have severity in a viewer view', () => { const manager = new StatusBarItemManager(createCardAPI()); diff --git a/tests/components-lib/media/details-controller.test.ts b/tests/components-lib/media/details-controller.test.ts index e94d538e..bba8c5bc 100644 --- a/tests/components-lib/media/details-controller.test.ts +++ b/tests/components-lib/media/details-controller.test.ts @@ -1,18 +1,39 @@ import { format } from 'date-fns'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, assert, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CameraManager } from '../../../src/camera-manager/manager'; +import { ActionFactory } from '../../../src/card-controller/actions/factory'; +import { CardController } from '../../../src/card-controller/controller'; import { ViewItemManager } from '../../../src/card-controller/view/item-manager'; import { ViewManagerEpoch } from '../../../src/card-controller/view/types'; import { MediaDetailsController, - OverlayControlsContext, + NotificationControlsContext, } from '../../../src/components-lib/media/details-controller'; -import { OverlayMessageControl } from '../../../src/types'; +import { NotificationControl } from '../../../src/config/schema/actions/types'; import { formatDateAndTime } from '../../../src/utils/basic'; +import { downloadMedia, navigateToTimeline } from '../../../src/utils/media-actions'; import { ViewFolder, ViewMediaType } from '../../../src/view/item'; import { createCardAPI, createFolder, TestViewMedia } from '../../test-utils'; +vi.mock('../../../src/utils/media-actions', async (importOriginal) => ({ + ...((await importOriginal()) as object), + downloadMedia: vi.fn(), + navigateToTimeline: vi.fn(), +})); + +async function executeControlAction( + control: NotificationControl, + api: CardController, +): Promise { + const tapAction = control.actions?.tap_action; + + assert(tapAction && !Array.isArray(tapAction)); + const action = new ActionFactory().createAction({}, tapAction); + + await action?.execute(api); +} + describe('MediaDetailsController', () => { describe('should set heading', () => { it('should set heading on event with what, tags and score', () => { @@ -24,7 +45,7 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); - expect(controller.getHeading()?.title).toBe('Person, Car: Tag1, Tag2 50.00%'); + expect(controller.getHeading()?.text).toBe('Person, Car: Tag1, Tag2 50.00%'); }); it('should set heading on event with tags', () => { @@ -34,7 +55,7 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); - expect(controller.getHeading()?.title).toBe('Tag1, Tag2'); + expect(controller.getHeading()?.text).toBe('Tag1, Tag2'); }); it('should set heading on event with what', () => { @@ -44,7 +65,7 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); - expect(controller.getHeading()?.title).toBe('Person, Car'); + expect(controller.getHeading()?.text).toBe('Person, Car'); }); it('should set null heading on event with no other information', () => { @@ -73,7 +94,7 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(cameraManager, item); - expect(controller.getHeading()?.title).toBe('Camera Title'); + expect(controller.getHeading()?.text).toBe('Camera Title'); }); it('should set heading on recording without camera metadata', () => { @@ -106,9 +127,9 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); expect(controller.getDetails()).toContainEqual({ - title: 'Test Event', - icon: { icon: 'mdi:rename' }, - hint: 'Title', + text: 'Test Event', + icon: 'mdi:rename', + tooltip: 'Title', }); }); @@ -121,7 +142,7 @@ describe('MediaDetailsController', () => { controller.calculate(null, item); expect(controller.getDetails()).toEqual([ { - title: 'Test Event', + text: 'Test Event', }, ]); }); @@ -136,7 +157,7 @@ describe('MediaDetailsController', () => { controller.calculate(null, item); expect(controller.getDetails()).not.toContainEqual( expect.objectContaining({ - title: 'Test Event', + text: 'Test Event', }), ); }); @@ -153,9 +174,9 @@ describe('MediaDetailsController', () => { // Use formatDateAndTime to generate expected value (formats in local time with seconds) expect(controller.getDetails()).toContainEqual({ - title: formatDateAndTime(startTime, true), - hint: 'Start', - icon: { icon: 'mdi:calendar-clock-outline' }, + text: formatDateAndTime(startTime, true), + tooltip: 'Start', + icon: 'mdi:calendar-clock-outline', }); }); @@ -169,9 +190,9 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); expect(controller.getDetails()).toContainEqual({ - title: '1m 0s', - hint: 'Duration', - icon: { icon: 'mdi:clock-outline' }, + text: '1m 0s', + tooltip: 'Duration', + icon: 'mdi:clock-outline', }); }); @@ -185,9 +206,9 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); expect(controller.getDetails()).toContainEqual({ - title: 'In Progress', - hint: 'Duration', - icon: { icon: 'mdi:clock-outline' }, + text: 'In Progress', + tooltip: 'Duration', + icon: 'mdi:clock-outline', }); }); @@ -201,9 +222,9 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); expect(controller.getDetails()).toContainEqual({ - title: '1m 0s In Progress', - hint: 'Duration', - icon: { icon: 'mdi:clock-outline' }, + text: '1m 0s In Progress', + tooltip: 'Duration', + icon: 'mdi:clock-outline', }); }); }); @@ -222,9 +243,9 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(cameraManager, item); expect(controller.getDetails()).toContainEqual({ - title: 'Camera Title', - hint: 'Camera', - icon: { icon: 'mdi:cctv' }, + text: 'Camera Title', + tooltip: 'Camera', + icon: 'mdi:cctv', }); }); @@ -237,9 +258,9 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); expect(controller.getDetails()).toContainEqual({ - title: 'Where1, Where2', - hint: 'Where', - icon: { icon: 'mdi:map-marker-outline' }, + text: 'Where1, Where2', + tooltip: 'Where', + icon: 'mdi:map-marker-outline', }); }); @@ -252,9 +273,9 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); expect(controller.getDetails()).toContainEqual({ - title: 'Tag1, Tag2', - hint: 'Tag', - icon: { icon: 'mdi:tag' }, + text: 'Tag1, Tag2', + tooltip: 'Tag', + icon: 'mdi:tag', }); }); @@ -267,9 +288,9 @@ describe('MediaDetailsController', () => { // Use format() to generate expected value (formats in local time) expect(controller.getDetails()).toContainEqual({ - title: format(seekTime, 'HH:mm:ss'), - hint: 'Seek', - icon: { icon: 'mdi:clock-fast' }, + text: format(seekTime, 'HH:mm:ss'), + tooltip: 'Seek', + icon: 'mdi:clock-fast', }); }); it('should set heading on review', () => { @@ -282,10 +303,10 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); const heading = controller.getHeading(); - expect(heading?.title).toBe('Review Title'); - expect(heading?.emphasis).toBe('high'); - expect(heading?.icon).toEqual({ icon: 'mdi:circle-medium' }); - expect(heading?.hint).toBe('Severity: High'); + expect(heading?.text).toBe('Review Title'); + expect(heading?.severity).toBe('high'); + expect(heading?.icon).toBe('mdi:circle-medium'); + expect(heading?.tooltip).toBe('Severity: High'); }); it('should set heading on review without severity', () => { @@ -298,8 +319,8 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); const heading = controller.getHeading(); - expect(heading?.title).toBe('Review Title'); - expect(heading?.emphasis).toBeUndefined(); + expect(heading?.text).toBe('Review Title'); + expect(heading?.severity).toBeUndefined(); }); it('should set null heading on review with no title', () => { @@ -321,12 +342,12 @@ describe('MediaDetailsController', () => { }); }); - describe('should get message', () => { + describe('should get notification', () => { afterEach(() => { vi.restoreAllMocks(); }); - it('should get message', () => { + it('should get notification', () => { const item = new TestViewMedia({ title: 'Test Title', what: ['person'], @@ -336,25 +357,25 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); - const message = controller.getMessage(); - expect(message.heading?.title).toBe('Person'); - expect(message.details).toContainEqual({ - title: 'Test Title', + const notification = controller.getNotification(); + expect(notification.heading?.text).toBe('Person'); + expect(notification.details).toContainEqual({ + text: 'Test Title', }); - expect(message.text).toBe('Test Description'); + expect(notification.text).toBe('Test Description'); }); - it('should get message without media', () => { + it('should get notification without media', () => { const item = new ViewFolder(createFolder(), []); const controller = new MediaDetailsController(); controller.calculate(null, item); - const message = controller.getMessage(); - expect(message.text).toBeUndefined(); + const notification = controller.getNotification(); + expect(notification.text).toBeUndefined(); }); - it('should get message with null description', () => { + it('should get notification with null description', () => { const item = new TestViewMedia({ description: null, }); @@ -362,11 +383,11 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); - const message = controller.getMessage(); - expect(message.text).toBeUndefined(); + const notification = controller.getNotification(); + expect(notification.text).toBeUndefined(); }); - it('should get message with controls', async () => { + it('should get notification with controls', async () => { const item = new TestViewMedia({ title: 'Test Title', mediaType: ViewMediaType.Review, @@ -390,45 +411,52 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); - const message = controller.getMessage(context); - const controls = message.controls; + const notification = controller.getNotification(context); + const controls = notification.controls; + assert(controls); expect(controls).toHaveLength(4); vi.spyOn(console, 'warn').mockImplementation(() => {}); // 1. Review control - expect(controls?.[0].title).toBe('Mark as reviewed'); - const reviewResult = await controls?.[0].callback?.(); - expect(reviewResult).not.toBeNull(); + expect(controls?.[0].tooltip).toBe('Mark as reviewed'); + expect(controls?.[0].dismiss).toBe(false); + await executeControlAction(controls[0], cardAPI); + expect(cardAPI.getNotificationManager().setNotification).toHaveBeenCalled(); // 1b. Review control (failure) + vi.mocked(cardAPI.getNotificationManager().setNotification).mockClear(); viewItemManager.reviewMedia.mockRejectedValue(new Error('fail')); - - const reviewFailureResult = await controls?.[0].callback?.(); - expect(reviewFailureResult).toBeNull(); + await executeControlAction(controls[0], cardAPI); + expect(cardAPI.getNotificationManager().setNotification).not.toHaveBeenCalled(); // 2. Favorite control - expect(controls?.[1].title).toBe('Media will be indefinitely retained'); - const favoriteResult = await controls?.[1].callback?.(); - expect(favoriteResult).not.toBeNull(); + expect(controls?.[1].tooltip).toBe('Media will be indefinitely retained'); + expect(controls?.[1].dismiss).toBe(false); + await executeControlAction(controls[1], cardAPI); + expect(cardAPI.getNotificationManager().setNotification).toHaveBeenCalled(); // 2b. Favorite control (failure) + vi.mocked(cardAPI.getNotificationManager().setNotification).mockClear(); viewItemManager.favorite.mockRejectedValue(new Error('fail')); - const favoriteFailureResult = await controls?.[1].callback?.(); - expect(favoriteFailureResult).toBeNull(); + await executeControlAction(controls[1], cardAPI); + expect(cardAPI.getNotificationManager().setNotification).not.toHaveBeenCalled(); // 3. Download control - expect(controls?.[2].title).toBe('Download media'); - const downloadResult = await controls?.[2].callback?.(); - expect(downloadResult).toBeNull(); + expect(controls?.[2].tooltip).toBe('Download media'); + expect(controls?.[2].dismiss).toBe(true); + vi.mocked(downloadMedia).mockResolvedValue(true); + await executeControlAction(controls[2], cardAPI); + expect(downloadMedia).toHaveBeenCalledWith(item, viewItemManager); // 4. Timeline control - expect(controls?.[3].title).toBe('See media in timeline'); - const timelineResult = await controls?.[3].callback?.(); - expect(timelineResult).toBeNull(); + expect(controls?.[3].tooltip).toBe('See media in timeline'); + expect(controls?.[3].dismiss).toBe(true); + await executeControlAction(controls[3], cardAPI); + expect(navigateToTimeline).toHaveBeenCalledWith(item, viewManagerEpoch); }); - it('should get message with controls for already reviewed/favorited items', () => { + it('should get notification with controls for already reviewed/favorited items', () => { const item = new TestViewMedia({ mediaType: ViewMediaType.Review, reviewed: true, @@ -444,18 +472,18 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); - const message = controller.getMessage(context); - const controls = message.controls; + const notification = controller.getNotification(context); + const controls = notification.controls; expect(controls).toHaveLength(2); - expect(controls?.[0].title).toBe('Mark as unreviewed'); - expect(controls?.[0].icon).toEqual({ icon: 'mdi:check-circle' }); + expect(controls?.[0].tooltip).toBe('Mark as unreviewed'); + expect(controls?.[0].icon).toBe('mdi:check-circle'); - expect(controls?.[1].emphasis).toBe('medium'); - expect(controls?.[1].icon).toEqual({ icon: 'mdi:star' }); + expect(controls?.[1].severity).toBe('medium'); + expect(controls?.[1].icon).toBe('mdi:star'); }); - it('should get message with controls when item has no ID', () => { + it('should get notification with controls when item has no ID', () => { const item = new TestViewMedia({ id: null, }); @@ -469,11 +497,11 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); - const message = controller.getMessage(context); - expect(message.controls).toHaveLength(0); + const notification = controller.getNotification(context); + expect(notification.controls).toHaveLength(0); }); - it('should get message with controls when context has no capabilities', () => { + it('should get notification with controls when context has no capabilities', () => { const item = new TestViewMedia({ id: 'id', }); @@ -482,8 +510,8 @@ describe('MediaDetailsController', () => { const controller = new MediaDetailsController(); controller.calculate(null, item); - const message = controller.getMessage(context); - expect(message.controls).toHaveLength(0); + const notification = controller.getNotification(context); + expect(notification.controls).toHaveLength(0); }); it('should get empty controls when item is null', () => { @@ -492,7 +520,7 @@ describe('MediaDetailsController', () => { // Use cast to unknown first to avoid any-related lint errors. const controls = ( controller as unknown as { - _getControls: (context: OverlayControlsContext) => OverlayMessageControl[]; + _getControls: (context: NotificationControlsContext) => NotificationControl[]; } )._getControls({}); expect(controls).toEqual([]); diff --git a/tests/config/schema/common/icon.test.ts b/tests/config/schema/common/icon.test.ts new file mode 100644 index 00000000..f412f6c6 --- /dev/null +++ b/tests/config/schema/common/icon.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { iconSchema } from '../../../../src/config/schema/common/icon'; + +describe('iconSchema', () => { + it('should parse icon with all fields', () => { + expect( + iconSchema.parse({ + icon: 'mdi:star', + entity: 'light.office', + stateColor: true, + }), + ).toEqual({ + icon: 'mdi:star', + entity: 'light.office', + stateColor: true, + }); + }); + + it('should parse empty object', () => { + expect(iconSchema.parse({})).toEqual({}); + }); +}); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index f58ff869..02953e9c 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -441,6 +441,10 @@ describe('config defaults', () => { enabled: true, priority: 50, }, + upgrade: { + enabled: true, + priority: 50, + }, }, popup_seconds: 3, position: 'bottom', @@ -1066,6 +1070,30 @@ describe('config defaults', () => { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'mute', }, + { + action: 'custom:advanced-camera-card-action', + advanced_camera_card_action: 'notification', + notification: { + heading: { + text: 'Attention', + icon: 'mdi:alert', + severity: 'high', + }, + text: 'Something happened.', + details: [{ text: 'Detail 1', icon: 'mdi:info' }], + controls: [ + { + icon: 'mdi:check', + tooltip: 'Acknowledge', + dismiss: true, + }, + { + icon: 'mdi:eye', + dismiss: false, + }, + ], + }, + }, { action: 'custom:advanced-camera-card-action', advanced_camera_card_action: 'pause', diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 51d5e672..57574008 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -45,7 +45,7 @@ import { MediaLoadedInfoManager } from '../src/card-controller/media-info-manage import { MediaPlayerManager } from '../src/card-controller/media-player-manager'; import { MessageManager } from '../src/card-controller/message-manager'; import { MicrophoneManager } from '../src/card-controller/microphone-manager'; -import { OverlayMessageManager } from '../src/card-controller/overlay-message-manager'; +import { NotificationManager } from '../src/card-controller/notification-manager'; import { PIPManager } from '../src/card-controller/pip-manager'; import { QueryStringManager } from '../src/card-controller/query-string-manager'; import { StatusBarItemManager } from '../src/card-controller/status-bar-item-manager'; @@ -678,7 +678,7 @@ export const createCardAPI = (): CardController => { api.getMediaPlayerManager.mockReturnValue(mock()); api.getMessageManager.mockReturnValue(mock()); api.getMicrophoneManager.mockReturnValue(mock()); - api.getOverlayMessageManager.mockReturnValue(mock()); + api.getNotificationManager.mockReturnValue(mock()); api.getPIPManager.mockReturnValue(mock()); api.getQueryStringManager.mockReturnValue(mock()); api.getStatusBarItemManager.mockReturnValue(mock()); diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index f53e75ca..ff07153d 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -10,6 +10,7 @@ import { createInternalCallbackAction, createLogAction, createMediaPlayerAction, + createNotificationAction, createPerformAction, createPTZAction, createPTZControlsAction, @@ -357,6 +358,27 @@ describe('createSetReviewAction', () => { }); }); +describe('createNotificationAction', () => { + it('should create notification action', () => { + const notification = { text: 'test' }; + expect(createNotificationAction(notification)).toEqual({ + action: 'fire-dom-event', + advanced_camera_card_action: 'notification', + notification, + }); + }); + + it('should create notification action with cardID', () => { + const notification = { text: 'test' }; + expect(createNotificationAction(notification, { cardID: 'card_id' })).toEqual({ + action: 'fire-dom-event', + advanced_camera_card_action: 'notification', + notification, + card_id: 'card_id', + }); + }); +}); + describe('getActionConfigGivenAction', () => { const action = createViewAction('clips'); diff --git a/tests/utils/notification.test.ts b/tests/utils/notification.test.ts new file mode 100644 index 00000000..747a85c3 --- /dev/null +++ b/tests/utils/notification.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from 'vitest'; +import { dispatchDismissNotificationEvent } from '../../src/utils/notification'; + +// @vitest-environment jsdom +describe('notification utils', () => { + it('should dispatch dismiss notification event', () => { + const element = document.createElement('div'); + const handler = vi.fn(); + element.addEventListener('advanced-camera-card:notification:dismiss', handler); + + dispatchDismissNotificationEvent(element); + + expect(handler).toHaveBeenCalled(); + }); +}); diff --git a/tests/utils/overlay-message.test.ts b/tests/utils/overlay-message.test.ts deleted file mode 100644 index 4a80e802..00000000 --- a/tests/utils/overlay-message.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - dispatchDismissOverlayMessageEvent, - dispatchShowOverlayMessageEvent, -} from '../../src/utils/overlay-message'; - -// @vitest-environment jsdom -describe('overlay-message utils', () => { - it('should dispatch show overlay message event', () => { - const element = document.createElement('div'); - const message = { text: 'test' }; - const handler = vi.fn(); - element.addEventListener('advanced-camera-card:overlay-message:show', handler); - - dispatchShowOverlayMessageEvent(element, message); - - expect(handler).toHaveBeenCalled(); - const event = handler.mock.calls[0][0]; - expect(event.detail).toBe(message); - }); - - it('should dispatch dismiss overlay message event', () => { - const element = document.createElement('div'); - const handler = vi.fn(); - element.addEventListener('advanced-camera-card:overlay-message:dismiss', handler); - - dispatchDismissOverlayMessageEvent(element); - - expect(handler).toHaveBeenCalled(); - }); -});