committed by
dermotduffy
parent
3fe5e4004a
commit
d833edb65b
@@ -354,15 +354,15 @@ advanced_camera_card_action: notification
|
|||||||
|
|
||||||
### `notification`
|
### `notification`
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| ------------- | ---------------------------------------------------------------------------------------------------------- |
|
| ------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `heading` | An optional heading. Uses the same format as [`metadata`](README.md?id=metadata) below. |
|
| `heading` | An optional heading. Uses the same format as [`metadata`](README.md?id=metadata) below. |
|
||||||
| `body` | An optional body. Uses the same format as [`metadata`](README.md?id=metadata) below. |
|
| `body` | An optional body. Uses the same format as [`metadata`](README.md?id=metadata) below. |
|
||||||
| `metadata` | An optional list of metadata to show with the notification. See [`metadata`](README.md?id=metadata) below. |
|
| `metadata` | An optional list of metadata to show with the notification. See [`metadata`](README.md?id=metadata) below. |
|
||||||
| `context` | An optional list of preformatted text strings shown as a technical detail block (e.g. YAML). |
|
| `context` | An optional list of items shown as a technical detail block: strings are shown as-is, objects are rendered as YAML. |
|
||||||
| `link` | An optional link displayed below the body. See [`link`](#link) below. |
|
| `link` | An optional link displayed below the body. See [`link`](#link) below. |
|
||||||
| `in_progress` | If `true`, shows a loading indicator. |
|
| `in_progress` | If `true`, shows a loading indicator. |
|
||||||
| `controls` | An optional list of controls. See [`controls`](README.md?id=controls) below. |
|
| `controls` | An optional list of controls. See [`controls`](README.md?id=controls) below. |
|
||||||
|
|
||||||
### Metadata
|
### Metadata
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import yaml from 'js-yaml';
|
|
||||||
|
|
||||||
// Converts a structured data object into preformatted text strings for a
|
|
||||||
// notification's context section. Arrays produce one string per item (strings
|
|
||||||
// pass through; objects are YAML-dumped); all other objects produce a single
|
|
||||||
// YAML-dumped string.
|
|
||||||
export const dataToContext = (data: object): string[] => {
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return data.map((item) => (typeof item === 'string' ? item : yaml.dump(item)));
|
|
||||||
}
|
|
||||||
return [yaml.dump(data)];
|
|
||||||
};
|
|
||||||
@@ -4,7 +4,6 @@ import type {
|
|||||||
} from '../../config/schema/actions/types.js';
|
} from '../../config/schema/actions/types.js';
|
||||||
import type { Link } from '../../config/schema/common/link.js';
|
import type { Link } from '../../config/schema/common/link.js';
|
||||||
import { getContextFromError } from '../../utils/error-context.js';
|
import { getContextFromError } from '../../utils/error-context.js';
|
||||||
import { dataToContext } from './data-to-context.js';
|
|
||||||
|
|
||||||
const DEFAULT_ERROR_ICON = 'mdi:alert';
|
const DEFAULT_ERROR_ICON = 'mdi:alert';
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ export const createNotificationFromText = (
|
|||||||
...(options?.metadata && { metadata: options.metadata }),
|
...(options?.metadata && { metadata: options.metadata }),
|
||||||
...(options?.link && { link: options.link }),
|
...(options?.link && { link: options.link }),
|
||||||
...(options?.context && {
|
...(options?.context && {
|
||||||
context: dataToContext(options.context),
|
context: [options.context],
|
||||||
}),
|
}),
|
||||||
...(options?.in_progress !== undefined && { in_progress: options.in_progress }),
|
...(options?.in_progress !== undefined && { in_progress: options.in_progress }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type * as JsYaml from 'js-yaml';
|
||||||
|
import type { ReactiveControllerHost } from 'lit';
|
||||||
|
|
||||||
|
import type { Notification } from '../../config/schema/actions/types.js';
|
||||||
|
|
||||||
|
// `js-yaml` (~107KB) is only needed to render a notification's diagnostic
|
||||||
|
// context when that context contains a structured object, which most cards
|
||||||
|
// never show. It is imported on demand and shared across every notification.
|
||||||
|
export class NotificationContextController {
|
||||||
|
private static jsYamlDump: typeof JsYaml.dump | null = null;
|
||||||
|
|
||||||
|
private _host: ReactiveControllerHost;
|
||||||
|
|
||||||
|
constructor(host: ReactiveControllerHost) {
|
||||||
|
this._host = host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a notification's context into display strings. String items pass
|
||||||
|
* through unchanged; object items are YAML-dumped, loading `js-yaml` on
|
||||||
|
* demand and re-rendering once it is ready. Returns an empty array while a
|
||||||
|
* load needed for an object item is still in flight.
|
||||||
|
*/
|
||||||
|
public getContext(notification: Notification): string[] {
|
||||||
|
const context = notification.context ?? [];
|
||||||
|
const dump = NotificationContextController.jsYamlDump;
|
||||||
|
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const item of context) {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
result.push(item);
|
||||||
|
} else if (dump) {
|
||||||
|
result.push(dump(item));
|
||||||
|
} else {
|
||||||
|
// Object item needs js-yaml; load it and re-render once ready. A failed
|
||||||
|
// load is swallowed: the notification renders without the dumped
|
||||||
|
// context.
|
||||||
|
this._loadDumper().catch(() => {});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _loadDumper(): Promise<void> {
|
||||||
|
// `import()` is module-cached, so repeat/concurrent calls share one
|
||||||
|
// download; a failed load is not cached, so a later render retries.
|
||||||
|
const module = await import('js-yaml');
|
||||||
|
NotificationContextController.jsYamlDump = module.dump;
|
||||||
|
this._host.requestUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||||
|
|
||||||
|
import { hasPopOutAnimationEnded } from '../../utils/animation.js';
|
||||||
|
import { dispatchDismissNotificationEvent } from '../../utils/notification.js';
|
||||||
|
|
||||||
|
// Manages the popup notification's modal interaction: dismiss on outside
|
||||||
|
// interaction or Escape, and emit the dismiss event once the pop-out animation
|
||||||
|
// finishes.
|
||||||
|
export class NotificationPopupController implements ReactiveController {
|
||||||
|
private _host: ReactiveControllerHost & HTMLElement;
|
||||||
|
private _getNotificationElement: () => HTMLElement | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
host: ReactiveControllerHost & HTMLElement,
|
||||||
|
getNotificationElement: () => HTMLElement | null,
|
||||||
|
) {
|
||||||
|
this._host = host;
|
||||||
|
this._getNotificationElement = getNotificationElement;
|
||||||
|
host.addController(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public hostConnected(): void {
|
||||||
|
window.addEventListener('click', this._handleOutsideInteraction);
|
||||||
|
window.addEventListener('focusin', this._handleOutsideInteraction);
|
||||||
|
|
||||||
|
// Escape is claimed in the capture phase: the popup is a modal surface and
|
||||||
|
// must consume Escape before non-modal background controls (e.g. the call
|
||||||
|
// controls) that also listen on `window`.
|
||||||
|
window.addEventListener('keydown', this._handleKeyDown, { capture: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
public hostDisconnected(): void {
|
||||||
|
window.removeEventListener('click', this._handleOutsideInteraction);
|
||||||
|
window.removeEventListener('focusin', this._handleOutsideInteraction);
|
||||||
|
window.removeEventListener('keydown', this._handleKeyDown, { capture: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
public dismiss = (): void => {
|
||||||
|
this._getNotificationElement()?.classList.add('exiting');
|
||||||
|
};
|
||||||
|
|
||||||
|
public handleAnimationEnd = (ev: AnimationEvent): void => {
|
||||||
|
if (hasPopOutAnimationEnded(ev)) {
|
||||||
|
dispatchDismissNotificationEvent(this._host);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private _handleOutsideInteraction = (ev: Event): void => {
|
||||||
|
if (!ev.composedPath().includes(this._host)) {
|
||||||
|
this.dismiss();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private _handleKeyDown = (ev: KeyboardEvent): void => {
|
||||||
|
if (ev.key === 'Escape') {
|
||||||
|
this.dismiss();
|
||||||
|
|
||||||
|
// `stopImmediatePropagation()` (not `stopPropagation()`) is required to
|
||||||
|
// block sibling `window` listeners -- `stopPropagation()` only stops
|
||||||
|
// propagation to other targets, not other listeners on `window` itself.
|
||||||
|
ev.stopImmediatePropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ import type { IssueTriggerEventData } from '../card-controller/issues/types.js';
|
|||||||
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
||||||
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
|
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
|
||||||
import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
|
import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
|
||||||
import { dataToContext } from '../components-lib/notification/data-to-context.js';
|
|
||||||
import { SignedURLController } from '../components-lib/signed-url-controller.js';
|
import { SignedURLController } from '../components-lib/signed-url-controller.js';
|
||||||
import type { Notification } from '../config/schema/actions/types.js';
|
import type { Notification } from '../config/schema/actions/types.js';
|
||||||
import type { CameraConfig } from '../config/schema/cameras.js';
|
import type { CameraConfig } from '../config/schema/cameras.js';
|
||||||
@@ -418,7 +417,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
icon: 'mdi:alert-circle',
|
icon: 'mdi:alert-circle',
|
||||||
},
|
},
|
||||||
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
|
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
|
||||||
context: this.proxyConfig ? dataToContext(this.proxyConfig) : undefined,
|
context: this.proxyConfig ? [this.proxyConfig] : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (this._imageLoadError) {
|
if (this._imageLoadError) {
|
||||||
@@ -428,7 +427,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
icon: 'mdi:alert-circle',
|
icon: 'mdi:alert-circle',
|
||||||
},
|
},
|
||||||
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
|
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
|
||||||
context: this.imageConfig ? dataToContext(this.imageConfig) : undefined,
|
context: this.imageConfig ? [this.imageConfig] : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
createNotificationFromText,
|
createNotificationFromText,
|
||||||
type NotificationOptions,
|
type NotificationOptions,
|
||||||
} from '../../components-lib/notification/factory.js';
|
} from '../../components-lib/notification/factory.js';
|
||||||
|
import { NotificationContextController } from '../../components-lib/notification/notification-context-controller.js';
|
||||||
import type { Notification } from '../../config/schema/actions/types.js';
|
import type { Notification } from '../../config/schema/actions/types.js';
|
||||||
import { localize } from '../../localize/localize.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
import notificationBlockStyle from '../../scss/notification-block.scss';
|
import notificationBlockStyle from '../../scss/notification-block.scss';
|
||||||
@@ -41,11 +42,14 @@ export class AdvancedCameraCardNotificationBlock extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public notification: Notification | null = null;
|
public notification: Notification | null = null;
|
||||||
|
|
||||||
|
private _contextController = new NotificationContextController(this);
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.notification) {
|
if (!this.notification) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const context = this._contextController.getContext(this.notification);
|
||||||
const { heading, in_progress } = this.notification;
|
const { heading, in_progress } = this.notification;
|
||||||
const controls = this.notification.controls ?? [];
|
const controls = this.notification.controls ?? [];
|
||||||
|
|
||||||
@@ -78,7 +82,11 @@ export class AdvancedCameraCardNotificationBlock extends LitElement {
|
|||||||
: ''}
|
: ''}
|
||||||
</div>`
|
</div>`
|
||||||
: ''}
|
: ''}
|
||||||
${renderNotificationBody(this.notification, spinnerInBody ? spinner : undefined)}
|
${renderNotificationBody(
|
||||||
|
this.notification,
|
||||||
|
context,
|
||||||
|
spinnerInBody ? spinner : undefined,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,11 +67,11 @@ export function renderControl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderNotificationBody(
|
export function renderNotificationBody(
|
||||||
notification: Notification,
|
notification: Omit<Notification, 'context'>,
|
||||||
|
context: string[],
|
||||||
bodyIconOverride?: TemplateResult,
|
bodyIconOverride?: TemplateResult,
|
||||||
): TemplateResult {
|
): TemplateResult {
|
||||||
const { body, link } = notification;
|
const { body, link } = notification;
|
||||||
const context = notification.context ?? [];
|
|
||||||
const metadata = notification.metadata ?? [];
|
const metadata = notification.metadata ?? [];
|
||||||
return html`
|
return html`
|
||||||
${metadata.map((detail) => renderDetail(detail, 'metadata'))}
|
${metadata.map((detail) => renderDetail(detail, 'metadata'))}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import { customElement, property } from 'lit/decorators.js';
|
|||||||
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
||||||
|
|
||||||
import { handleControlAction } from '../../components-lib/notification/action.js';
|
import { handleControlAction } from '../../components-lib/notification/action.js';
|
||||||
|
import { NotificationContextController } from '../../components-lib/notification/notification-context-controller.js';
|
||||||
|
import { NotificationPopupController } from '../../components-lib/notification/notification-popup-controller.js';
|
||||||
import type { Notification } from '../../config/schema/actions/types.js';
|
import type { Notification } from '../../config/schema/actions/types.js';
|
||||||
import { localize } from '../../localize/localize.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
import notificationPopupStyle from '../../scss/notification-popup.scss';
|
import notificationPopupStyle from '../../scss/notification-popup.scss';
|
||||||
import { hasPopOutAnimationEnded } from '../../utils/animation.js';
|
|
||||||
import { dispatchDismissNotificationEvent } from '../../utils/notification.js';
|
|
||||||
import {
|
import {
|
||||||
renderControl,
|
renderControl,
|
||||||
renderDetail,
|
renderDetail,
|
||||||
@@ -26,39 +26,27 @@ export class AdvancedCameraCardNotification extends LitElement {
|
|||||||
public notification: Notification | null = null;
|
public notification: Notification | null = null;
|
||||||
|
|
||||||
private _refNotification: Ref<HTMLElement> = createRef();
|
private _refNotification: Ref<HTMLElement> = createRef();
|
||||||
|
private _popupController = new NotificationPopupController(
|
||||||
public connectedCallback(): void {
|
this,
|
||||||
super.connectedCallback();
|
() => this._refNotification.value ?? null,
|
||||||
window.addEventListener('click', this._handleOutsideInteraction);
|
);
|
||||||
window.addEventListener('focusin', this._handleOutsideInteraction);
|
private _contextController = new NotificationContextController(this);
|
||||||
|
|
||||||
// Escape is claimed in the capture phase: the popup is a modal surface and
|
|
||||||
// must consume Escape before non-modal background controls (e.g. the call
|
|
||||||
// controls) that also listen on `window`.
|
|
||||||
window.addEventListener('keydown', this._handleKeyDown, { capture: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
public disconnectedCallback(): void {
|
|
||||||
window.removeEventListener('click', this._handleOutsideInteraction);
|
|
||||||
window.removeEventListener('focusin', this._handleOutsideInteraction);
|
|
||||||
window.removeEventListener('keydown', this._handleKeyDown, { capture: true });
|
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.notification) {
|
if (!this.notification) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const context = this._contextController.getContext(this.notification);
|
||||||
const { heading, in_progress } = this.notification;
|
const { heading, in_progress } = this.notification;
|
||||||
const controls = this.notification.controls ?? [];
|
const controls = this.notification.controls ?? [];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="backdrop" @click=${this._dismiss}></div>
|
<div class="backdrop" @click=${this._popupController.dismiss}></div>
|
||||||
<div
|
<div
|
||||||
class="notification"
|
class="notification"
|
||||||
${ref(this._refNotification)}
|
${ref(this._refNotification)}
|
||||||
@animationend=${this._handleAnimationEnd}
|
@animationend=${this._popupController.handleAnimationEnd}
|
||||||
>
|
>
|
||||||
${controls.length || in_progress
|
${controls.length || in_progress
|
||||||
? html`<div class="controls">
|
? html`<div class="controls">
|
||||||
@@ -69,52 +57,24 @@ export class AdvancedCameraCardNotification extends LitElement {
|
|||||||
: ''}
|
: ''}
|
||||||
${controls.map((control) =>
|
${controls.map((control) =>
|
||||||
renderControl(control, (ev, c) =>
|
renderControl(control, (ev, c) =>
|
||||||
handleControlAction(ev, c, this, this._dismiss),
|
handleControlAction(ev, c, this, this._popupController.dismiss),
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>`
|
</div>`
|
||||||
: ''}
|
: ''}
|
||||||
<div class="close" @click=${this._dismiss}>
|
<div class="close" @click=${this._popupController.dismiss}>
|
||||||
<advanced-camera-card-icon
|
<advanced-camera-card-icon
|
||||||
.icon=${{ icon: 'mdi:close' }}
|
.icon=${{ icon: 'mdi:close' }}
|
||||||
></advanced-camera-card-icon>
|
></advanced-camera-card-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
${heading ? renderDetail(heading, 'heading') : ''}
|
${heading ? renderDetail(heading, 'heading') : ''}
|
||||||
${renderNotificationBody(this.notification)}
|
${renderNotificationBody(this.notification, context)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _dismiss = (): void => {
|
|
||||||
this._refNotification.value?.classList.add('exiting');
|
|
||||||
};
|
|
||||||
|
|
||||||
private _handleAnimationEnd = (ev: AnimationEvent): void => {
|
|
||||||
if (hasPopOutAnimationEnded(ev)) {
|
|
||||||
dispatchDismissNotificationEvent(this);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private _handleOutsideInteraction = (ev: Event): void => {
|
|
||||||
if (!ev.composedPath().includes(this)) {
|
|
||||||
this._dismiss();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private _handleKeyDown = (ev: KeyboardEvent): void => {
|
|
||||||
if (ev.key === 'Escape') {
|
|
||||||
this._dismiss();
|
|
||||||
|
|
||||||
// `stopImmediatePropagation()` (not `stopPropagation()`) is required to
|
|
||||||
// block sibling `window` listeners -- `stopPropagation()` only stops
|
|
||||||
// propagation to other targets, not other listeners on `window` itself.
|
|
||||||
ev.stopImmediatePropagation();
|
|
||||||
ev.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return unsafeCSS(notificationPopupStyle);
|
return unsafeCSS(notificationPopupStyle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { isRecord } from '../../../utils/basic';
|
||||||
import { linkSchema } from '../common/link';
|
import { linkSchema } from '../common/link';
|
||||||
import { preprocessToArray } from '../common/preprocess-to-array';
|
import { preprocessToArray } from '../common/preprocess-to-array';
|
||||||
import { severitySchema } from '../common/severity';
|
import { severitySchema } from '../common/severity';
|
||||||
@@ -187,11 +188,15 @@ const notificationControlSchema = notificationBaseSchema.extend({
|
|||||||
});
|
});
|
||||||
export type NotificationControl = z.infer<typeof notificationControlSchema>;
|
export type NotificationControl = z.infer<typeof notificationControlSchema>;
|
||||||
|
|
||||||
|
// A context item is a preformatted string or a structured object that is
|
||||||
|
// YAML-dumped at render time (see NotificationContextController).
|
||||||
|
const notificationContextItemSchema = z.union([z.string(), z.custom<object>(isRecord)]);
|
||||||
|
|
||||||
const notificationSchema = z.object({
|
const notificationSchema = z.object({
|
||||||
heading: notificationDetailSchema.optional(),
|
heading: notificationDetailSchema.optional(),
|
||||||
body: notificationDetailSchema.optional(),
|
body: notificationDetailSchema.optional(),
|
||||||
metadata: notificationDetailSchema.array().optional(),
|
metadata: notificationDetailSchema.array().optional(),
|
||||||
context: z.string().array().optional(),
|
context: notificationContextItemSchema.array().optional(),
|
||||||
link: linkSchema.optional(),
|
link: linkSchema.optional(),
|
||||||
in_progress: z.boolean().optional(),
|
in_progress: z.boolean().optional(),
|
||||||
controls: notificationControlSchema.array().optional(),
|
controls: notificationControlSchema.array().optional(),
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { AdvancedCameraCardError } from '../types.js';
|
import { AdvancedCameraCardError } from '../types.js';
|
||||||
|
import { isRecord } from './basic.js';
|
||||||
|
|
||||||
// Narrows an unknown error to its structured object `context` -- non-null and
|
// Narrows an unknown error to its structured object `context` -- a non-null
|
||||||
// of object type -- or null if the error is not an AdvancedCameraCardError or
|
// record -- or null if the error is not an AdvancedCameraCardError or has no
|
||||||
// has no usable context. Consolidates the instanceof + typeof + null-guard
|
// usable context. Consolidates the instanceof + null-guard dance that
|
||||||
// dance that notification builders and error handlers would otherwise repeat.
|
// notification builders and error handlers would otherwise repeat.
|
||||||
export const getContextFromError = (error: unknown): object | null =>
|
export const getContextFromError = (error: unknown): Record<string, unknown> | null =>
|
||||||
error instanceof AdvancedCameraCardError &&
|
error instanceof AdvancedCameraCardError && isRecord(error.context)
|
||||||
typeof error.context === 'object' &&
|
|
||||||
error.context !== null
|
|
||||||
? error.context
|
? error.context
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||||
|
|
||||||
import { CustomTone } from '../../../../src/card-controller/call/tones/custom';
|
import { CustomTone } from '../../../../src/card-controller/call/tones/custom';
|
||||||
|
import { flushPromises } from '../../../test-utils';
|
||||||
|
|
||||||
interface AudioMocks {
|
interface AudioMocks {
|
||||||
// Each call to `new Audio(...)` is delegated to a real jsdom Audio element
|
// Each call to `new Audio(...)` is delegated to a real jsdom Audio element
|
||||||
@@ -109,8 +110,8 @@ describe('start', () => {
|
|||||||
const onFinished = vi.fn();
|
const onFinished = vi.fn();
|
||||||
|
|
||||||
new CustomTone('http://example/ring.mp3', 0).start(onFinished);
|
new CustomTone('http://example/ring.mp3', 0).start(onFinished);
|
||||||
// Let the rejected promise settle.
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await flushPromises();
|
||||||
|
|
||||||
expect(onFinished).toBeCalled();
|
expect(onFinished).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ describe('ViewIncompatibleIssue', () => {
|
|||||||
|
|
||||||
const result = issue.getIssue();
|
const result = issue.getIssue();
|
||||||
expect(result?.notification.context).toEqual([
|
expect(result?.notification.context).toEqual([
|
||||||
expect.stringContaining('view: snapshot'),
|
{ view: 'snapshot', camera: 'cam.office' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { dataToContext } from '../../../src/components-lib/notification/data-to-context';
|
|
||||||
|
|
||||||
describe('dataToContext', () => {
|
|
||||||
it('should return an array of string items unchanged when input is an array of strings', () => {
|
|
||||||
expect(dataToContext(['line one', 'line two'])).toEqual(['line one', 'line two']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should YAML-dump object items when input is an array containing objects', () => {
|
|
||||||
const result = dataToContext([{ key: 'value' }]);
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0]).toContain('key: value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle a mixed array of strings and objects', () => {
|
|
||||||
const result = dataToContext(['plain string', { foo: 'bar' }]);
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result[0]).toBe('plain string');
|
|
||||||
expect(result[1]).toContain('foo: bar');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return a single YAML-dumped string for a plain object', () => {
|
|
||||||
const result = dataToContext({ error: 'something went wrong', code: 42 });
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0]).toContain('error: something went wrong');
|
|
||||||
expect(result[0]).toContain('code: 42');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return an empty array for an empty array input', () => {
|
|
||||||
expect(dataToContext([])).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -67,8 +67,7 @@ describe('createNotificationFromText', () => {
|
|||||||
const notification = createNotificationFromText('oops', {
|
const notification = createNotificationFromText('oops', {
|
||||||
context: { detail: 'extra info' },
|
context: { detail: 'extra info' },
|
||||||
});
|
});
|
||||||
expect(notification.context).toBeDefined();
|
expect(notification.context).toEqual([{ detail: 'extra info' }]);
|
||||||
expect(notification.context?.join(' ')).toContain('detail: extra info');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should omit context when not provided', () => {
|
it('should omit context when not provided', () => {
|
||||||
@@ -136,8 +135,7 @@ describe('createNotificationFromError', () => {
|
|||||||
const error = new AdvancedCameraCardError('boom', { reason: 'network' });
|
const error = new AdvancedCameraCardError('boom', { reason: 'network' });
|
||||||
const notification = createNotificationFromError(error);
|
const notification = createNotificationFromError(error);
|
||||||
assert(notification);
|
assert(notification);
|
||||||
expect(notification.context).toBeDefined();
|
expect(notification.context).toEqual([{ reason: 'network' }]);
|
||||||
expect(notification.context?.join(' ')).toContain('reason: network');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use explicit context option over AdvancedCameraCardError context', () => {
|
it('should use explicit context option over AdvancedCameraCardError context', () => {
|
||||||
@@ -146,8 +144,7 @@ describe('createNotificationFromError', () => {
|
|||||||
context: { override: 'explicit' },
|
context: { override: 'explicit' },
|
||||||
});
|
});
|
||||||
assert(notification);
|
assert(notification);
|
||||||
expect(notification.context?.join(' ')).toContain('override: explicit');
|
expect(notification.context).toEqual([{ override: 'explicit' }]);
|
||||||
expect(notification.context?.join(' ')).not.toContain('reason: network');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not include context when AdvancedCameraCardError has a non-object context', () => {
|
it('should not include context when AdvancedCameraCardError has a non-object context', () => {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { Notification } from '../../../src/config/schema/actions/types';
|
||||||
|
import { createLitElement, flushPromises } from '../../test-utils';
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('NotificationContextController', () => {
|
||||||
|
// Each test re-imports the controller after resetting the module registry so its
|
||||||
|
// module-level `js-yaml` singleton starts unloaded.
|
||||||
|
const loadController = async () => {
|
||||||
|
const module = await import(
|
||||||
|
'../../../src/components-lib/notification/notification-context-controller'
|
||||||
|
);
|
||||||
|
return module.NotificationContextController;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNotification = (context?: Notification['context']): Notification => ({
|
||||||
|
body: { text: 'oops' },
|
||||||
|
...(context && { context }),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.doUnmock('js-yaml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an empty array when the notification has no context', async () => {
|
||||||
|
const NotificationContextController = await loadController();
|
||||||
|
const controller = new NotificationContextController(createLitElement());
|
||||||
|
|
||||||
|
expect(controller.getContext(createNotification())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return string context items unchanged without loading the library', async () => {
|
||||||
|
const NotificationContextController = await loadController();
|
||||||
|
const host = createLitElement();
|
||||||
|
const controller = new NotificationContextController(host);
|
||||||
|
|
||||||
|
expect(controller.getContext(createNotification(['line one', 'line two']))).toEqual([
|
||||||
|
'line one',
|
||||||
|
'line two',
|
||||||
|
]);
|
||||||
|
expect(host.requestUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should YAML-dump object context items once the library has loaded', async () => {
|
||||||
|
const NotificationContextController = await loadController();
|
||||||
|
const host = createLitElement();
|
||||||
|
const controller = new NotificationContextController(host);
|
||||||
|
const notification = createNotification([{ foo: 'bar' }]);
|
||||||
|
|
||||||
|
// The library is not yet loaded, so the first render defers.
|
||||||
|
expect(controller.getContext(notification)).toEqual([]);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(host.requestUpdate).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const result = controller.getContext(notification);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toContain('foo: bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should defer the whole context until the library loads when any item is an object', async () => {
|
||||||
|
const NotificationContextController = await loadController();
|
||||||
|
const host = createLitElement();
|
||||||
|
const controller = new NotificationContextController(host);
|
||||||
|
const notification = createNotification(['plain string', { foo: 'bar' }]);
|
||||||
|
|
||||||
|
expect(controller.getContext(notification)).toEqual([]);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(host.requestUpdate).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const result = controller.getContext(notification);
|
||||||
|
expect(result[0]).toBe('plain string');
|
||||||
|
expect(result[1]).toContain('foo: bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should swallow a failed library load and render without the dumped context', async () => {
|
||||||
|
vi.doMock('js-yaml', () => {
|
||||||
|
throw new Error('chunk load failed');
|
||||||
|
});
|
||||||
|
const NotificationContextController = await loadController();
|
||||||
|
const host = createLitElement();
|
||||||
|
const controller = new NotificationContextController(host);
|
||||||
|
const notification = createNotification([{ foo: 'bar' }]);
|
||||||
|
|
||||||
|
expect(controller.getContext(notification)).toEqual([]);
|
||||||
|
|
||||||
|
// Allow the rejected dynamic import to settle.
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(host.requestUpdate).not.toHaveBeenCalled();
|
||||||
|
expect(controller.getContext(notification)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, expect, it, onTestFinished, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { NotificationPopupController } from '../../../src/components-lib/notification/notification-popup-controller';
|
||||||
|
import { POP_OUT_ANIMATION_NAME } from '../../../src/const';
|
||||||
|
import { createLitElement } from '../../test-utils';
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('NotificationPopupController', () => {
|
||||||
|
const create = (getNotificationElement?: () => HTMLElement | null) => {
|
||||||
|
const host = createLitElement();
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const popup = document.createElement('div');
|
||||||
|
|
||||||
|
const controller = new NotificationPopupController(
|
||||||
|
host,
|
||||||
|
getNotificationElement ?? (() => popup),
|
||||||
|
);
|
||||||
|
controller.hostConnected();
|
||||||
|
|
||||||
|
// Cleanup (disconnecting the window listeners and clearing the DOM) is
|
||||||
|
// registered per test, so leaked listeners cannot bleed into later tests.
|
||||||
|
onTestFinished(() => {
|
||||||
|
controller.hostDisconnected();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { host, popup, controller };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should add itself to the host', () => {
|
||||||
|
const { host, controller } = create();
|
||||||
|
expect(host.addController).toHaveBeenCalledWith(controller);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dismiss', () => {
|
||||||
|
it('should mark the notification element as exiting', () => {
|
||||||
|
const { popup, controller } = create();
|
||||||
|
controller.dismiss();
|
||||||
|
expect(popup.classList.contains('exiting')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when there is no notification element', () => {
|
||||||
|
const controller = new NotificationPopupController(createLitElement(), () => null);
|
||||||
|
expect(() => controller.dismiss()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('outside interaction', () => {
|
||||||
|
it('should dismiss on a click outside the host', () => {
|
||||||
|
const { popup } = create();
|
||||||
|
const outside = document.createElement('div');
|
||||||
|
document.body.appendChild(outside);
|
||||||
|
outside.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||||
|
expect(popup.classList.contains('exiting')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should dismiss on a focus outside the host', () => {
|
||||||
|
const { popup } = create();
|
||||||
|
const outside = document.createElement('div');
|
||||||
|
document.body.appendChild(outside);
|
||||||
|
outside.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
|
||||||
|
expect(popup.classList.contains('exiting')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not dismiss on an interaction inside the host', () => {
|
||||||
|
const { host, popup } = create();
|
||||||
|
host.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||||
|
expect(popup.classList.contains('exiting')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop listening once disconnected', () => {
|
||||||
|
const { popup, controller } = create();
|
||||||
|
controller.hostDisconnected();
|
||||||
|
|
||||||
|
const outside = document.createElement('div');
|
||||||
|
document.body.appendChild(outside);
|
||||||
|
outside.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||||
|
expect(popup.classList.contains('exiting')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keydown', () => {
|
||||||
|
it('should dismiss and consume the Escape key', () => {
|
||||||
|
const { popup } = create();
|
||||||
|
const ev = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Escape',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
document.body.dispatchEvent(ev);
|
||||||
|
expect(popup.classList.contains('exiting')).toBe(true);
|
||||||
|
expect(ev.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore other keys', () => {
|
||||||
|
const { popup } = create();
|
||||||
|
const ev = new KeyboardEvent('keydown', {
|
||||||
|
key: 'a',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
document.body.dispatchEvent(ev);
|
||||||
|
expect(popup.classList.contains('exiting')).toBe(false);
|
||||||
|
expect(ev.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('animation end', () => {
|
||||||
|
// Dispatch a real `animationend` event on the element the handler is bound
|
||||||
|
// to, so `target` and `currentTarget` are genuinely the same node.
|
||||||
|
const dispatchAnimationEnd = (
|
||||||
|
controller: NotificationPopupController,
|
||||||
|
animationName: string,
|
||||||
|
): void => {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.addEventListener('animationend', controller.handleAnimationEnd);
|
||||||
|
|
||||||
|
const ev = new Event('animationend');
|
||||||
|
Object.defineProperty(ev, 'animationName', { value: animationName });
|
||||||
|
element.dispatchEvent(ev);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should dispatch the dismiss event when the pop-out animation ends', () => {
|
||||||
|
const { host, controller } = create();
|
||||||
|
const dismissed = vi.fn();
|
||||||
|
host.addEventListener('advanced-camera-card:notification:dismiss', dismissed);
|
||||||
|
dispatchAnimationEnd(controller, POP_OUT_ANIMATION_NAME);
|
||||||
|
expect(dismissed).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore other animations ending', () => {
|
||||||
|
const { host, controller } = create();
|
||||||
|
const dismissed = vi.fn();
|
||||||
|
host.addEventListener('advanced-camera-card:notification:dismiss', dismissed);
|
||||||
|
dispatchAnimationEnd(controller, 'pop-in');
|
||||||
|
expect(dismissed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,12 +4,14 @@ import { ResolvedMediaCache, resolveMedia } from '../../src/ha/resolved-media';
|
|||||||
import { resolvedMediaSchema, type ResolvedMedia } from '../../src/ha/types';
|
import { resolvedMediaSchema, type ResolvedMedia } from '../../src/ha/types';
|
||||||
import { homeAssistantWSRequest } from '../../src/ha/ws-request';
|
import { homeAssistantWSRequest } from '../../src/ha/ws-request';
|
||||||
import { errorToConsole } from '../../src/utils/basic';
|
import { errorToConsole } from '../../src/utils/basic';
|
||||||
|
import type * as UtilsBasic from '../../src/utils/basic';
|
||||||
import { createHASS } from '../test-utils';
|
import { createHASS } from '../test-utils';
|
||||||
|
|
||||||
vi.mock('../../src/ha/ws-request', () => ({
|
vi.mock('../../src/ha/ws-request', () => ({
|
||||||
homeAssistantWSRequest: vi.fn(),
|
homeAssistantWSRequest: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock('../../src/utils/basic', () => ({
|
vi.mock('../../src/utils/basic', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof UtilsBasic>()),
|
||||||
errorToConsole: vi.fn(),
|
errorToConsole: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user