Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions src/Apps/NetPad.Apps.App/App/src/core/@application/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8023,10 +8023,10 @@ export class AppStatusMessage implements IAppStatusMessage {
scriptId?: string | undefined;
/** The text of this message. */
text!: string;
/** The priority of this message. */
priority!: AppStatusMessagePriority;
/** Whether this status message should be persistent or if it should be cleared after a timeout. */
persistent!: boolean;
/** The semantic kind of this message. See AppStatusMessageKind. */
kind!: AppStatusMessageKind;
/** The severity of this message. See AppStatusMessageSeverity. */
severity!: AppStatusMessageSeverity;
/** The date and time when this message was created. */
createdDate!: Date;

Expand All @@ -8043,8 +8043,8 @@ export class AppStatusMessage implements IAppStatusMessage {
if (_data) {
this.scriptId = _data["scriptId"];
this.text = _data["text"];
this.priority = _data["priority"];
this.persistent = _data["persistent"];
this.kind = _data["kind"];
this.severity = _data["severity"];
this.createdDate = _data["createdDate"] ? new Date(_data["createdDate"].toString()) : <any>undefined;
}
}
Expand All @@ -8060,8 +8060,8 @@ export class AppStatusMessage implements IAppStatusMessage {
data = typeof data === 'object' ? data : {};
data["scriptId"] = this.scriptId;
data["text"] = this.text;
data["priority"] = this.priority;
data["persistent"] = this.persistent;
data["kind"] = this.kind;
data["severity"] = this.severity;
data["createdDate"] = this.createdDate ? this.createdDate.toISOString() : <any>undefined;
return data;
}
Expand All @@ -8080,15 +8080,19 @@ export interface IAppStatusMessage {
scriptId?: string | undefined;
/** The text of this message. */
text: string;
/** The priority of this message. */
priority: AppStatusMessagePriority;
/** Whether this status message should be persistent or if it should be cleared after a timeout. */
persistent: boolean;
/** The semantic kind of this message. See AppStatusMessageKind. */
kind: AppStatusMessageKind;
/** The severity of this message. See AppStatusMessageSeverity. */
severity: AppStatusMessageSeverity;
/** The date and time when this message was created. */
createdDate: Date;
}

export type AppStatusMessagePriority = "Normal" | "High";
/** The semantic kind of an AppStatusMessage: how long the message stays relevant and how much attention it demands. The UI derives how a message is surfaced from its kind. */
export type AppStatusMessageKind = "Transient" | "Notice" | "Alert";

/** The severity of an AppStatusMessage. */
export type AppStatusMessageSeverity = "Info" | "Success" | "Warning" | "Error";

export abstract class PropertyChangedEvent implements IPropertyChangedEvent {
propertyName!: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<div class="buttons">
<button repeat.for="btn of input.buttons"
class="btn ${btn.isPrimary ? 'btn-primary' : 'btn-secondary'}"
class="btn ${btn.isPrimary ? 'btn-primary' : 'btn-secondary'} ${btn.cssClasses}"
click.trigger="ok(btn.value === undefined ? btn.text : btn.value)"
tabindex="0"
data-is-primary="${btn.isPrimary}">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,14 @@ export interface IAskDialogButton {
*/
text: string;

/**
* The value of the result if the button is selected.
*/
/** The value of the result if the button is selected. */
value?: string | null;

/**
* Whether the button is a primary button.
*/
/** Whether the button is a primary button. */
isPrimary?: boolean;

/** Additional CSS classes to add to the button. */
cssClasses?: string;
}

export class AskDialog extends Dialog<IAskDialogModel> {
Expand Down
8 changes: 8 additions & 0 deletions src/Apps/NetPad.Apps.App/App/src/core/@application/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export * from "./windows/window-state";
export * from "./windows/iwindow-service";
export * from "./user-secrets/iuser-secret-service";

// Notifications
export * from "./notifications/inotification";
export * from "./notifications/inotification-service";
export * from "./notifications/notification-service";
export * from "./notifications/notification-appearance";
export * from "./notifications/notification-toasts";

// Logging
export * from "./logging/console-log-sink";
export * from "./logging/remote-log-sink";
Expand All @@ -45,6 +52,7 @@ export * from "./value-converters/sanitize-html-value-converter";
export * from "./value-converters/sort-value-converter";
export * from "./value-converters/take-value-converter";
export * from "./value-converters/text-to-html-value-converter";
export * from "./value-converters/time-value-converter";
export * from "./value-converters/truncate-value-converter";
export * from "./value-converters/yes-no-value-converter";

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {DI} from "aurelia";
import {INotification} from "./inotification";
import {AppStatusMessageKind, AppStatusMessageSeverity} from "@application/api";

export const INotificationService = DI.createInterface<INotificationService>();

/** Central store of the app's notifications. */
export interface INotificationService {
/** Newest-first record of notifications kept for later review. */
readonly history: ReadonlyArray<INotification>;

/** Toasts currently on screen. */
readonly toasts: ReadonlyArray<INotification>;

/** The message currently shown in the status bar. */
readonly statusBarMessage: INotification | null;

/** Number of history items the user has not yet seen. */
readonly unreadCount: number;

/** Publishes a notification. */
notify(text: string, kind: AppStatusMessageKind, severity?: AppStatusMessageSeverity, scriptId?: string): void;

/** Removes a toast. Does not remove it from the history. */
dismissToast(notification: INotification): void;

/** Clears the current status bar message. */
dismissStatusBarMessage(): void;

/** Removes a single item from the history. */
removeFromHistory(notification: INotification): void;

/** Clears the entire history. */
clearHistory(): void;

/** Tracks whether the history is currently visible to the user. While visible, items aren't counted as unread. */
setPaneOpen(open: boolean): void;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {AppStatusMessageSeverity} from "../api";

/**
* A notification displayed by the UI, originating from a server AppStatusMessage
* or raised client-side via INotificationService.notify().
*/
export interface INotification {
readonly scriptId?: string;
scriptName?: string;
readonly text: string;
readonly severity: AppStatusMessageSeverity;
readonly createdDate: Date;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {AppStatusMessageSeverity} from "../api";

interface SeverityAppearance {
readonly icon: string;
readonly text: string;
}

/**
* Single source of truth mapping a message severity to the icon and text-color classes used to
* represent it on the UI.
*/
const SEVERITY_APPEARANCE: Record<AppStatusMessageSeverity, SeverityAppearance> = {
Info: {icon: "info-icon", text: "text-blue"},
Success: {icon: "check-circle-icon", text: "text-success"},
Warning: {icon: "warning-icon", text: "text-warning"},
Error: {icon: "error-icon", text: "text-danger"},
};

/**
* Maps a message severity to the icon class used to represent it on the UI.
*/
export function severityIconClass(severity: AppStatusMessageSeverity): string {
return (SEVERITY_APPEARANCE[severity] ?? SEVERITY_APPEARANCE.Info).icon;
}

/**
* Maps a message severity to the text-color class used to represent it on the UI.
*/
export function severityTextClass(severity: AppStatusMessageSeverity): string {
return (SEVERITY_APPEARANCE[severity] ?? SEVERITY_APPEARANCE.Info).text;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {PLATFORM} from "aurelia";
import {WithDisposables} from "@common";
import {AppStatusMessage, AppStatusMessageKind, AppStatusMessagePublishedEvent, AppStatusMessageSeverity} from "../api";
import {IEventBus} from "../events/ievent-bus";
import {ISession} from "../sessions/isession";
import {INotificationService} from "./inotification-service";
import {INotification} from "./inotification";

const STATUS_BAR_CLEAR_MS = 15000;
const MAX_HISTORY = 200;

/**
* Routes each notification by its kind to the status bar, a toast, and/or the history.
*/
export class NotificationService extends WithDisposables implements INotificationService {
private readonly _history: INotification[] = [];
private readonly _toasts: INotification[] = [];
private _statusBarMessage: INotification | null = null;
private _unreadCount = 0;
private paneOpen = false;

private statusBarClearHandle: number | null = null;

constructor(
@IEventBus eventBus: IEventBus,
@ISession private readonly session: ISession) {
super();
this.addDisposable(eventBus.subscribeToServer(
AppStatusMessagePublishedEvent,
ev => this.handle(ev.message.kind, this.toNotification(ev.message))));
this.addDisposable(() => this.clearStatusBarTimer());
}

public get history(): ReadonlyArray<INotification> {
return this._history;
}

public get toasts(): ReadonlyArray<INotification> {
return this._toasts;
}

public get statusBarMessage(): INotification | null {
return this._statusBarMessage;
}

public get unreadCount(): number {
return this._unreadCount;
}

private handle(kind: AppStatusMessageKind, notification: INotification) {
switch (kind) {
case "Transient":
this.setStatusBarMessage(notification);
break;
case "Notice":
this.addToHistory(notification);
this.setStatusBarMessage(notification);
break;
case "Alert":
this.addToHistory(notification);
this.showToast(notification);
break;
}
}

private addToHistory(notification: INotification) {
this._history.unshift(notification);
if (this._history.length > MAX_HISTORY) {
// Remove the oldest which are at the end.
this._history.splice(MAX_HISTORY);
}
if (!this.paneOpen) {
this._unreadCount++;
}
}

private toNotification(message: AppStatusMessage): INotification {
return {
scriptId: message.scriptId,
scriptName: message.scriptId ? this.session.getScriptName(message.scriptId) : undefined,
text: message.text,
severity: message.severity,
// SignalR server events bypass NSwag's fromJS, so createdDate arrives as a raw ISO string.
createdDate: new Date(message.createdDate),
};
}

private clearStatusBarTimer() {
if (this.statusBarClearHandle !== null) {
PLATFORM.clearTimeout(this.statusBarClearHandle);
this.statusBarClearHandle = null;
}
}

private setStatusBarMessage(notification: INotification) {
this._statusBarMessage = notification;
this.clearStatusBarTimer();

this.statusBarClearHandle = PLATFORM.setTimeout(() => {
this.statusBarClearHandle = null;
this._statusBarMessage = null;
}, STATUS_BAR_CLEAR_MS);
}

public dismissStatusBarMessage(): void {
this.clearStatusBarTimer();
this._statusBarMessage = null;
}

private showToast(notification: INotification) {
// Alerts dwell until explicitly dismissed (there is intentionally no auto-dismiss timer).
this._toasts.push(notification);
}

public notify(text: string, kind: AppStatusMessageKind, severity: AppStatusMessageSeverity = "Info", scriptId?: string): void {
this.handle(kind, {
scriptId: scriptId,
scriptName: scriptId ? this.session.getScriptName(scriptId) : undefined,
text: text,
severity: severity,
createdDate: new Date(),
});
}

public dismissToast(notification: INotification): void {
const ix = this._toasts.indexOf(notification);
if (ix >= 0) {
this._toasts.splice(ix, 1);
}
}

public removeFromHistory(notification: INotification): void {
const ix = this._history.indexOf(notification);
if (ix >= 0) {
this._history.splice(ix, 1);
}
}

public clearHistory(): void {
this._history.splice(0, this._history.length);
this._unreadCount = 0;
}

public setPaneOpen(open: boolean): void {
this.paneOpen = open;
if (open) {
this._unreadCount = 0;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<div class="notification-toasts">
<div repeat.for="toast of notificationService.toasts"
class="notification-toast severity-${toast.severity.toLowerCase()}">
<i class="notification-toast-icon ${iconClass(toast.severity)} ${textClass(toast.severity)}"></i>
<div class="notification-toast-body min-w-0">
<span class="notification-toast-script" if.bind="toast.scriptName">${toast.scriptName}</span>
<span class="notification-toast-text">${toast.text}</span>
</div>
<i class="notification-toast-close close-icon icon-button"
click.trigger="dismiss(toast)"
title="Dismiss"></i>
</div>
</div>
Loading
Loading