committed by
dermotduffy
parent
3fe5e4004a
commit
d833edb65b
@@ -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';
|
||||
import type { Link } from '../../config/schema/common/link.js';
|
||||
import { getContextFromError } from '../../utils/error-context.js';
|
||||
import { dataToContext } from './data-to-context.js';
|
||||
|
||||
const DEFAULT_ERROR_ICON = 'mdi:alert';
|
||||
|
||||
@@ -31,7 +30,7 @@ export const createNotificationFromText = (
|
||||
...(options?.metadata && { metadata: options.metadata }),
|
||||
...(options?.link && { link: options.link }),
|
||||
...(options?.context && {
|
||||
context: dataToContext(options.context),
|
||||
context: [options.context],
|
||||
}),
|
||||
...(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 { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.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 type { Notification } from '../config/schema/actions/types.js';
|
||||
import type { CameraConfig } from '../config/schema/cameras.js';
|
||||
@@ -418,7 +417,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
icon: 'mdi:alert-circle',
|
||||
},
|
||||
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
|
||||
context: this.proxyConfig ? dataToContext(this.proxyConfig) : undefined,
|
||||
context: this.proxyConfig ? [this.proxyConfig] : undefined,
|
||||
};
|
||||
}
|
||||
if (this._imageLoadError) {
|
||||
@@ -428,7 +427,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
icon: 'mdi:alert-circle',
|
||||
},
|
||||
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
|
||||
context: this.imageConfig ? dataToContext(this.imageConfig) : undefined,
|
||||
context: this.imageConfig ? [this.imageConfig] : undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createNotificationFromText,
|
||||
type NotificationOptions,
|
||||
} 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 { localize } from '../../localize/localize.js';
|
||||
import notificationBlockStyle from '../../scss/notification-block.scss';
|
||||
@@ -41,11 +42,14 @@ export class AdvancedCameraCardNotificationBlock extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public notification: Notification | null = null;
|
||||
|
||||
private _contextController = new NotificationContextController(this);
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.notification) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this._contextController.getContext(this.notification);
|
||||
const { heading, in_progress } = this.notification;
|
||||
const controls = this.notification.controls ?? [];
|
||||
|
||||
@@ -78,7 +82,11 @@ export class AdvancedCameraCardNotificationBlock extends LitElement {
|
||||
: ''}
|
||||
</div>`
|
||||
: ''}
|
||||
${renderNotificationBody(this.notification, spinnerInBody ? spinner : undefined)}
|
||||
${renderNotificationBody(
|
||||
this.notification,
|
||||
context,
|
||||
spinnerInBody ? spinner : undefined,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -67,11 +67,11 @@ export function renderControl(
|
||||
}
|
||||
|
||||
export function renderNotificationBody(
|
||||
notification: Notification,
|
||||
notification: Omit<Notification, 'context'>,
|
||||
context: string[],
|
||||
bodyIconOverride?: TemplateResult,
|
||||
): TemplateResult {
|
||||
const { body, link } = notification;
|
||||
const context = notification.context ?? [];
|
||||
const metadata = notification.metadata ?? [];
|
||||
return html`
|
||||
${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 { 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 { localize } from '../../localize/localize.js';
|
||||
import notificationPopupStyle from '../../scss/notification-popup.scss';
|
||||
import { hasPopOutAnimationEnded } from '../../utils/animation.js';
|
||||
import { dispatchDismissNotificationEvent } from '../../utils/notification.js';
|
||||
import {
|
||||
renderControl,
|
||||
renderDetail,
|
||||
@@ -26,39 +26,27 @@ export class AdvancedCameraCardNotification extends LitElement {
|
||||
public notification: Notification | null = null;
|
||||
|
||||
private _refNotification: Ref<HTMLElement> = createRef();
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
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 disconnectedCallback(): void {
|
||||
window.removeEventListener('click', this._handleOutsideInteraction);
|
||||
window.removeEventListener('focusin', this._handleOutsideInteraction);
|
||||
window.removeEventListener('keydown', this._handleKeyDown, { capture: true });
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
private _popupController = new NotificationPopupController(
|
||||
this,
|
||||
() => this._refNotification.value ?? null,
|
||||
);
|
||||
private _contextController = new NotificationContextController(this);
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.notification) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this._contextController.getContext(this.notification);
|
||||
const { heading, in_progress } = this.notification;
|
||||
const controls = this.notification.controls ?? [];
|
||||
|
||||
return html`
|
||||
<div class="backdrop" @click=${this._dismiss}></div>
|
||||
<div class="backdrop" @click=${this._popupController.dismiss}></div>
|
||||
<div
|
||||
class="notification"
|
||||
${ref(this._refNotification)}
|
||||
@animationend=${this._handleAnimationEnd}
|
||||
@animationend=${this._popupController.handleAnimationEnd}
|
||||
>
|
||||
${controls.length || in_progress
|
||||
? html`<div class="controls">
|
||||
@@ -69,52 +57,24 @@ export class AdvancedCameraCardNotification extends LitElement {
|
||||
: ''}
|
||||
${controls.map((control) =>
|
||||
renderControl(control, (ev, c) =>
|
||||
handleControlAction(ev, c, this, this._dismiss),
|
||||
handleControlAction(ev, c, this, this._popupController.dismiss),
|
||||
),
|
||||
)}
|
||||
</div>`
|
||||
: ''}
|
||||
<div class="close" @click=${this._dismiss}>
|
||||
<div class="close" @click=${this._popupController.dismiss}>
|
||||
<advanced-camera-card-icon
|
||||
.icon=${{ icon: 'mdi:close' }}
|
||||
></advanced-camera-card-icon>
|
||||
</div>
|
||||
<div class="details">
|
||||
${heading ? renderDetail(heading, 'heading') : ''}
|
||||
${renderNotificationBody(this.notification)}
|
||||
${renderNotificationBody(this.notification, context)}
|
||||
</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 {
|
||||
return unsafeCSS(notificationPopupStyle);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isRecord } from '../../../utils/basic';
|
||||
import { linkSchema } from '../common/link';
|
||||
import { preprocessToArray } from '../common/preprocess-to-array';
|
||||
import { severitySchema } from '../common/severity';
|
||||
@@ -187,11 +188,15 @@ const notificationControlSchema = notificationBaseSchema.extend({
|
||||
});
|
||||
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({
|
||||
heading: notificationDetailSchema.optional(),
|
||||
body: notificationDetailSchema.optional(),
|
||||
metadata: notificationDetailSchema.array().optional(),
|
||||
context: z.string().array().optional(),
|
||||
context: notificationContextItemSchema.array().optional(),
|
||||
link: linkSchema.optional(),
|
||||
in_progress: z.boolean().optional(),
|
||||
controls: notificationControlSchema.array().optional(),
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { AdvancedCameraCardError } from '../types.js';
|
||||
import { isRecord } from './basic.js';
|
||||
|
||||
// Narrows an unknown error to its structured object `context` -- non-null and
|
||||
// of object type -- or null if the error is not an AdvancedCameraCardError or
|
||||
// has no usable context. Consolidates the instanceof + typeof + null-guard
|
||||
// dance that notification builders and error handlers would otherwise repeat.
|
||||
export const getContextFromError = (error: unknown): object | null =>
|
||||
error instanceof AdvancedCameraCardError &&
|
||||
typeof error.context === 'object' &&
|
||||
error.context !== null
|
||||
// Narrows an unknown error to its structured object `context` -- a non-null
|
||||
// record -- or null if the error is not an AdvancedCameraCardError or has no
|
||||
// usable context. Consolidates the instanceof + null-guard dance that
|
||||
// notification builders and error handlers would otherwise repeat.
|
||||
export const getContextFromError = (error: unknown): Record<string, unknown> | null =>
|
||||
error instanceof AdvancedCameraCardError && isRecord(error.context)
|
||||
? error.context
|
||||
: null;
|
||||
|
||||
Reference in New Issue
Block a user