Merge pull request #18172 from umbraco/v15/feature/reworking-error-notifications

Feature: reworking error toast notifications
This commit is contained in:
Niels Lyngsø
2025-02-12 11:39:34 +01:00
committed by GitHub
24 changed files with 286 additions and 100 deletions

View File

@@ -584,6 +584,8 @@ export default {
deleteLayout: 'You are deleting the layout',
deletingALayout:
'Modifying layout will result in loss of data for any existing content that is based on this configuration.',
seeErrorAction: 'Se fejlen',
seeErrorDialogHeadline: 'Fejl detaljer',
},
dictionary: {
noItems: 'Der er ingen ordbogselementer.',

View File

@@ -614,6 +614,8 @@ export default {
deleteLayout: 'You are deleting the layout',
deletingALayout:
'Modifying layout will result in loss of data for any existing content that is based on this configuration.',
seeErrorAction: 'See error',
seeErrorDialogHeadline: 'Error details',
},
dictionary: {
importDictionaryItemHelp:

View File

@@ -610,6 +610,8 @@ export default {
deletingALayout:
'Modifying layout will result in loss of data for any existing content that is based on this configuration.',
selectEditorConfiguration: 'Select configuration',
seeErrorAction: 'See error',
seeErrorDialogHeadline: 'Error details',
},
dictionary: {
importDictionaryItemHelp:

View File

@@ -68,7 +68,6 @@ export class UmbCodeBlockElement extends LitElement {
}
uui-scroll-container {
max-height: 500px;
overflow-y: auto;
overflow-wrap: anywhere;
}

View File

@@ -11,6 +11,7 @@ import { manifests as iconRegistryManifests } from './icon-registry/manifests.js
import { manifests as localizationManifests } from './localization/manifests.js';
import { manifests as menuManifests } from './menu/manifests.js';
import { manifests as modalManifests } from './modal/manifests.js';
import { manifests as notificationManifests } from './notification/manifests.js';
import { manifests as pickerManifests } from './picker/manifests.js';
import { manifests as propertyActionManifests } from './property-action/manifests.js';
import { manifests as propertyEditorManifests } from './property-editor/manifests.js';
@@ -40,6 +41,7 @@ export const manifests: Array<UmbExtensionManifest | UmbExtensionManifestKind> =
...localizationManifests,
...menuManifests,
...modalManifests,
...notificationManifests,
...pickerManifests,
...propertyActionManifests,
...propertyEditorManifests,

View File

@@ -0,0 +1 @@
export * from './peek-error.controller.js';

View File

@@ -0,0 +1,43 @@
import { customElement, html, ifDefined, nothing, property } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbNotificationHandler } from '../../notification-handler.js';
import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal';
import type { UmbPeekErrorArgs } from '../../types.js';
import { UMB_ERROR_VIEWER_MODAL } from '../../index.js';
@customElement('umb-peek-error-notification')
export class UmbPeekErrorNotificationElement extends UmbLitElement {
@property({ attribute: false })
public data?: UmbPeekErrorArgs;
public notificationHandler!: UmbNotificationHandler;
async #onClick() {
const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT);
modalManager.open(this, UMB_ERROR_VIEWER_MODAL, { data: this.data?.details });
this.notificationHandler.close();
}
protected override render() {
return this.data
? html`<uui-toast-notification-layout headline=${ifDefined(this.data.headline)}
>${this.data.message}${this.data.details
? html`<uui-button
slot="actions"
look="primary"
color="danger"
label=${this.localize.term('defaultdialogs_seeErrorAction')}
@click=${this.#onClick}></uui-button>`
: nothing}</uui-toast-notification-layout
>`
: nothing;
}
}
declare global {
interface HTMLElementTagNameMap {
'umb-peek-error-notification': UmbPeekErrorNotificationElement;
}
}

View File

@@ -0,0 +1,32 @@
import { UMB_NOTIFICATION_CONTEXT } from '../../notification.context.js';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { UmbPeekErrorArgs } from '../../types.js';
import './peek-error-notification.element.js';
export class UmbPeekErrorController extends UmbControllerBase {
async open(args: UmbPeekErrorArgs): Promise<void> {
const context = await this.getContext(UMB_NOTIFICATION_CONTEXT);
context.peek('danger', {
elementName: 'umb-peek-error-notification',
data: args,
});
// This is a one time off, so we can destroy our selfs.
this.destroy();
return;
}
}
/**
*
* @param host {UmbControllerHost} - The host controller
* @param args {UmbPeekErrorArgs} - The data to pass to the notification
* @returns {UmbPeekErrorController} The notification peek controller instance
*/
export function umbPeekError(host: UmbControllerHost, args: UmbPeekErrorArgs) {
return new UmbPeekErrorController(host).open(args);
}

View File

@@ -1,5 +1,5 @@
import type { UmbNotificationColor } from './notification.context.js';
import { EventMessageTypeModel } from '@umbraco-cms/backoffice/external/backend-api';
import type { UmbNotificationColor } from './types.js';
/**
*

View File

@@ -1,7 +1,9 @@
import './layouts/default/index.js';
export * from './notification.context.js';
export * from './notification-handler.js';
export * from './isUmbNotifications.function.js';
export * from './controllers/peek-error/index.js';
export * from './extractUmbNotificationColor.function.js';
export * from './isUmbNotifications.function.js';
export * from './modals/error-viewer/index.js';
export * from './notification-handler.js';
export * from './notification.context.js';
export type * from './types.js';

View File

@@ -0,0 +1,3 @@
import { manifest } from './modals/error-viewer/manifest.js';
export const manifests = [manifest];

View File

@@ -0,0 +1,77 @@
import type { UmbErrorViewerModalData, UmbErrorViewerModalValue } from './error-viewer-modal.token.js';
import { css, customElement, html, nothing, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal';
@customElement('umb-error-viewer-modal')
export class UmbErrorViewerModalElement extends UmbModalBaseElement<UmbErrorViewerModalData, UmbErrorViewerModalValue> {
@state()
_displayError?: string;
@state()
_displayLang?: string;
// Code adapted from https://stackoverflow.com/a/57668208/12787
// Licensed under the permissions of the CC BY-SA 4.0 DEED
#stringify(obj: any): string {
let output = '{';
for (const key in obj) {
let value = obj[key];
if (typeof value === 'function') {
value = value.toString();
} else if (value instanceof Array) {
value = JSON.stringify(value);
} else if (typeof value === 'object') {
value = this.#stringify(value);
} else {
value = `"${value}"`;
}
output += `\n ${key}: ${value},`;
}
return output + '\n}';
}
public override set data(value: UmbErrorViewerModalData | undefined) {
super.data = value;
// is JSON:
if (typeof value === 'string') {
this._displayLang = 'String';
this._displayError = value;
} else {
this._displayLang = 'JSON';
this._displayError = this.#stringify(value);
}
}
public override get data(): UmbErrorViewerModalData | undefined {
return super.data;
}
override render() {
return html`
<umb-body-layout headline=${this.localize.term('defaultdialogs_seeErrorDialogHeadline')} main-no-padding>
${this.data
? html`<umb-code-block language=${this._displayLang ?? ''} copy>${this._displayError}</umb-code-block>`
: nothing}
<div slot="actions">
<uui-button label=${this.localize.term('general_close')} @click=${this._rejectModal}></uui-button>
</div>
</umb-body-layout>
`;
}
static override styles = [
css`
umb-code-block {
border: none;
height: 100%;
}
`,
];
}
export default UmbErrorViewerModalElement;
declare global {
interface HTMLElementTagNameMap {
'umb-error-viewer-modal': UmbErrorViewerModalElement;
}
}

View File

@@ -0,0 +1,17 @@
import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
import type { UmbPeekErrorArgs } from '../../types.js';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface UmbErrorViewerModalData extends UmbPeekErrorArgs {}
export type UmbErrorViewerModalValue = undefined;
export const UMB_ERROR_VIEWER_MODAL = new UmbModalToken<UmbErrorViewerModalData, UmbErrorViewerModalValue>(
'Umb.Modal.ErrorViewer',
{
modal: {
type: 'sidebar',
size: 'medium',
},
},
);

View File

@@ -0,0 +1,2 @@
export * from './error-viewer-modal.token.js';
export * from './manifest.js';

View File

@@ -0,0 +1,8 @@
import type { ManifestModal } from '@umbraco-cms/backoffice/modal';
export const manifest: ManifestModal = {
type: 'modal',
alias: 'Umb.Modal.ErrorViewer',
name: 'Error Viewer Modal',
element: () => import('./error-viewer-modal.element.js'),
};

View File

@@ -1,5 +1,5 @@
import { UmbNotificationHandler } from './notification-handler.js';
import type { UmbNotificationOptions } from './notification.context.js';
import type { UmbNotificationOptions } from './types.js';
import { assert, expect } from '@open-wc/testing';
import { UmbId } from '@umbraco-cms/backoffice/id';

View File

@@ -1,11 +1,9 @@
import type {
UmbNotificationOptions,
UmbNotificationColor,
UmbNotificationDefaultData,
} from './notification.context.js';
import type { UmbNotificationOptions, UmbNotificationColor, UmbNotificationDefaultData } from './types.js';
import type { UUIToastNotificationElement } from '@umbraco-cms/backoffice/external/uui';
import { UmbId } from '@umbraco-cms/backoffice/id';
const DEFAULT_LAYOUT = 'umb-notification-layout-default';
/**
* @class UmbNotificationHandler
*/
@@ -17,10 +15,9 @@ export class UmbNotificationHandler {
private _defaultColor: UmbNotificationColor = 'default';
private _defaultDuration = 6000;
private _defaultLayout = 'umb-notification-layout-default';
public key: string;
public element: any;
public element!: UUIToastNotificationElement;
public color: UmbNotificationColor;
public duration: number | null;
@@ -34,23 +31,13 @@ export class UmbNotificationHandler {
this.color = options.color || this._defaultColor;
this.duration = options.duration !== undefined ? options.duration : this._defaultDuration;
this._elementName = options.elementName || this._defaultLayout;
this._elementName = options.elementName || DEFAULT_LAYOUT;
this._data = options.data;
this._closePromise = new Promise((res) => {
this._closeResolver = res;
});
this._createElement();
}
/**
* @private
* @memberof UmbNotificationHandler
*/
private _createElement() {
if (!this._elementName) return;
const notification: UUIToastNotificationElement = document.createElement('uui-toast-notification');
notification.color = this.color;

View File

@@ -3,29 +3,7 @@ import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import { UmbBasicState } from '@umbraco-cms/backoffice/observable-api';
/**
* The default data of notifications
* @interface UmbNotificationDefaultData
*/
export interface UmbNotificationDefaultData {
message: string;
headline?: string;
structuredList?: Record<string, Array<unknown>>;
}
/**
* @interface UmbNotificationOptions
* @template UmbNotificationData
*/
export interface UmbNotificationOptions<UmbNotificationData = UmbNotificationDefaultData> {
color?: UmbNotificationColor;
duration?: number | null;
elementName?: string;
data?: UmbNotificationData;
}
export type UmbNotificationColor = '' | 'default' | 'positive' | 'warning' | 'danger';
import type { UmbNotificationColor, UmbNotificationOptions } from './types.js';
export class UmbNotificationContext extends UmbContextBase<UmbNotificationContext> {
// Notice this cannot use UniqueBehaviorSubject as it holds a HTML Element. which cannot be Serialized to JSON (it has some circular references)
@@ -42,9 +20,9 @@ export class UmbNotificationContext extends UmbContextBase<UmbNotificationContex
* @returns {*} {UmbNotificationHandler}
* @memberof UmbNotificationContext
*/
private _open(options: UmbNotificationOptions): UmbNotificationHandler {
#open<T extends UmbNotificationOptions = UmbNotificationOptions>(options: T): UmbNotificationHandler {
const notificationHandler = new UmbNotificationHandler(options);
notificationHandler.element.addEventListener('closed', () => this._handleClosed(notificationHandler));
notificationHandler.element?.addEventListener('closed', () => this._handleClosed(notificationHandler));
this._notifications.setValue([...this._notifications.getValue(), notificationHandler]);
@@ -78,8 +56,11 @@ export class UmbNotificationContext extends UmbContextBase<UmbNotificationContex
* @returns {*}
* @memberof UmbNotificationContext
*/
public peek(color: UmbNotificationColor, options: UmbNotificationOptions): UmbNotificationHandler {
return this._open({ color, ...options });
public peek<T extends UmbNotificationOptions = UmbNotificationOptions>(
color: UmbNotificationColor,
options: T,
): UmbNotificationHandler {
return this.#open({ color, ...options });
}
/**
@@ -89,8 +70,11 @@ export class UmbNotificationContext extends UmbContextBase<UmbNotificationContex
* @returns {*}
* @memberof UmbNotificationContext
*/
public stay(color: UmbNotificationColor, options: UmbNotificationOptions): UmbNotificationHandler {
return this._open({ ...options, color, duration: null });
public stay<T extends UmbNotificationOptions = UmbNotificationOptions>(
color: UmbNotificationColor,
options: T,
): UmbNotificationHandler {
return this.#open({ ...options, color, duration: null });
}
}

View File

@@ -0,0 +1,30 @@
/**
* The default data of notifications
* @interface UmbNotificationDefaultData
*/
export interface UmbNotificationDefaultData {
message: string;
headline?: string;
/**
* @deprecated, do not use this. It will be removed in v.16 — Use UmbPeekError instead
*/
structuredList?: Record<string, Array<unknown>>;
whitespace?: 'normal' | 'pre-line' | 'pre-wrap' | 'nowrap' | 'pre';
}
/**
* @interface UmbNotificationOptions
* @template UmbNotificationData
*/
export interface UmbNotificationOptions<UmbNotificationData = UmbNotificationDefaultData> {
color?: UmbNotificationColor;
duration?: number | null;
elementName?: string;
data?: UmbNotificationData;
}
export type UmbNotificationColor = '' | 'default' | 'positive' | 'warning' | 'danger';
export interface UmbPeekErrorArgs extends UmbNotificationDefaultData {
details?: any;
}

View File

@@ -4,8 +4,7 @@ import { isApiError, isCancelError, isCancelablePromise } from './apiTypeValidat
import type { XhrRequestOptions } from './types.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import { UmbContextConsumerController } from '@umbraco-cms/backoffice/context-api';
import { UMB_NOTIFICATION_CONTEXT, type UmbNotificationOptions } from '@umbraco-cms/backoffice/notification';
import { umbPeekError, type UmbNotificationOptions } from '@umbraco-cms/backoffice/notification';
import type { UmbDataSourceResponse } from '@umbraco-cms/backoffice/repository';
import {
ApiError,
@@ -13,33 +12,19 @@ import {
CancelError,
type ProblemDetails,
} from '@umbraco-cms/backoffice/external/backend-api';
import { UmbDeprecation } from '../utils/deprecation/deprecation.js';
export class UmbResourceController extends UmbControllerBase {
#promise: Promise<any>;
#notificationContext?: typeof UMB_NOTIFICATION_CONTEXT.TYPE;
#authContext?: typeof UMB_AUTH_CONTEXT.TYPE;
constructor(host: UmbControllerHost, promise: Promise<any>, alias?: string) {
super(host, alias);
this.#promise = promise;
new UmbContextConsumerController(host, UMB_NOTIFICATION_CONTEXT, (_instance) => {
this.#notificationContext = _instance;
});
new UmbContextConsumerController(host, UMB_AUTH_CONTEXT, (_instance) => {
this.#authContext = _instance;
});
}
override hostConnected(): void {
// Do nothing
}
override hostDisconnected(): void {
super.hostDisconnected();
this.cancel();
}
@@ -48,6 +33,7 @@ export class UmbResourceController extends UmbControllerBase {
* @param promise
*/
static async tryExecute<T>(promise: Promise<T>): Promise<UmbDataSourceResponse<T>> {
// TODO: tryExecute should not take a promise as argument, but should utilize the class property `#promise` instead. (In this way the promise can be cancelled when disconnected)
try {
return { data: await promise };
} catch (error) {
@@ -63,11 +49,20 @@ export class UmbResourceController extends UmbControllerBase {
/**
* Wrap the {tryExecute} function in a try/catch block and return the result.
* If the executor function throws an error, then show the details in a notification.
* @param options
* @param _options
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async tryExecuteAndNotify<T>(options?: UmbNotificationOptions): Promise<UmbDataSourceResponse<T>> {
const { data, error } = await UmbResourceController.tryExecute<T>(this.#promise);
if (options) {
new UmbDeprecation({
deprecated: 'tryExecuteAndNotify `options` argument is deprecated.',
removeInVersion: '17.0.0',
solution: 'Use the method without arguments.',
}).warn();
}
if (error) {
/**
* Determine if we want to show a notification or just log the error to the console.
@@ -82,6 +77,7 @@ export class UmbResourceController extends UmbControllerBase {
console.error('Request failed', error.request);
console.error('Request body', error.body);
console.error('Error', error);
console.groupEnd();
let problemDetails: ProblemDetails | null = null;
@@ -111,23 +107,14 @@ export class UmbResourceController extends UmbControllerBase {
switch (error.status ?? 0) {
case 401: {
// See if we can get the UmbAuthContext and let it know the user is timed out
if (this.#authContext) {
this.#authContext.timeOut();
} else {
// If we can't get the auth context, show a notification
this.#notificationContext?.peek('warning', {
data: {
headline: 'Session Expired',
message: 'Your session has expired. Please refresh the page.',
},
});
}
const authContext = await this.getContext(UMB_AUTH_CONTEXT);
authContext.timeOut();
break;
}
case 500:
// Server Error
if (!isCancelledByNotification && this.#notificationContext) {
if (!isCancelledByNotification) {
let headline = problemDetails?.title ?? error.name ?? 'Server Error';
let message = 'A fatal server error occurred. If this continues, please reach out to your administrator.';
@@ -141,19 +128,19 @@ export class UmbResourceController extends UmbControllerBase {
'The Umbraco object cache is corrupt, but your action may still have been executed. Please restart the server to reset the cache. This is a work in progress.';
}
this.#notificationContext.peek('danger', {
data: {
headline,
message,
},
...options,
umbPeekError(this, {
headline: headline,
message: message,
details: problemDetails?.errors ?? problemDetails?.detail,
});
}
break;
default:
// Other errors
if (!isCancelledByNotification && this.#notificationContext) {
this.#notificationContext.peek('danger', {
if (!isCancelledByNotification) {
/*
const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT);
notificationContext.peek('danger', {
data: {
headline: problemDetails?.title ?? error.name ?? 'Server Error',
message: problemDetails?.detail ?? error.message ?? 'Something went wrong',
@@ -163,10 +150,14 @@ export class UmbResourceController extends UmbControllerBase {
},
...options,
});
*/
const headline = problemDetails?.title ?? error.name ?? 'Server Error';
umbPeekError(this, {
message: headline,
details: problemDetails?.errors ?? problemDetails?.detail,
});
}
}
console.groupEnd();
}
}

View File

@@ -407,7 +407,7 @@ export abstract class UmbEntityDetailWorkspaceContextBase<
this,
umbExtensionsRegistry,
repositoryAlias,
[this._host],
[],
(permitted, ctrl) => {
this._detailRepository = permitted ? ctrl.api : undefined;
this.#checkIfInitialized();

View File

@@ -95,7 +95,7 @@ export class UmbModelsBuilderDashboardElement extends UmbLitElement {
</p>
${this._modelsBuilder?.lastError
? html`<p class="error">Last generation failed with the following error:</p>
<umb-code-block>${this._modelsBuilder.lastError}</umb-code-block>`
<umb-code-block style="max-height:500px;">${this._modelsBuilder.lastError}</umb-code-block>`
: nothing}
</uui-box>
`;

View File

@@ -94,7 +94,7 @@ export class UmbTemplatingPageFieldBuilderModalElement extends UmbModalBaseEleme
?disabled=${this._field ? false : true}></uui-checkbox>
<uui-label><umb-localize key="templateEditor_outputSample">Output sample</umb-localize></uui-label>
<umb-code-block language="C#" copy
<umb-code-block style="max-height:500px;" language="C#" copy
>${this._field ? getUmbracoFieldSnippet(this._field, this._default, this._recursive) : ''}</umb-code-block
>
</div>

View File

@@ -254,7 +254,9 @@ export default class UmbTemplateQueryBuilderModalElement extends UmbModalBaseEle
(sample) => html`<span><umb-icon name=${sample.icon}></umb-icon>${sample.name}</span>`,
) ?? ''}
</div>
<umb-code-block language="C#" copy>${this._templateQuery?.queryExpression ?? ''}</umb-code-block>
<umb-code-block style="max-height:500px;" language="C#" copy
>${this._templateQuery?.queryExpression ?? ''}</umb-code-block
>
</uui-box>
</div>