diff --git a/CHANGES.md b/CHANGES.md index 06506573c7..0aa0355fd4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,6 +4,7 @@ - Update docs for Client-side config: change `searchBar` parameter to `searchBarConfig` - Fix to show preview map when used outside the explorer panel. +- Add UI to show toast messages. - [The next improvement] #### 8.11.0 - 2025-10-09 diff --git a/lib/ModelMixins/MappableMixin.ts b/lib/ModelMixins/MappableMixin.ts index 92409a403d..3233cb7a54 100644 --- a/lib/ModelMixins/MappableMixin.ts +++ b/lib/ModelMixins/MappableMixin.ts @@ -171,13 +171,6 @@ function MappableMixin>(Base: T) { */ async loadMapItems(force?: boolean): Promise> { try { - runInAction(() => { - if (this.shouldShowInitialMessage) { - // Don't await the initialMessage because this causes cyclic dependency between loading - // and user interaction (see https://github.com/TerriaJS/terriajs/issues/5528) - this.showInitialMessage(); - } - }); if (CatalogMemberMixin.isMixedInto(this)) (await this.loadMetadata()).throwIfError(); @@ -225,8 +218,15 @@ function MappableMixin>(Base: T) { : undefined, message: this.initialMessage.content ?? "", key: "initialMessage:" + this.initialMessage.key, - confirmAction: () => resolve() + confirmAction: () => resolve(), + showAsToast: this.initialMessage.showAsToast, + toastVisibleDuration: this.initialMessage.toastVisibleDuration }); + + // No need to wait for confirmation if the message is a toast + if (this.initialMessage.showAsToast) { + resolve(); + } }); } diff --git a/lib/Models/Workbench.ts b/lib/Models/Workbench.ts index b1957272c8..fffd76214f 100644 --- a/lib/Models/Workbench.ts +++ b/lib/Models/Workbench.ts @@ -211,6 +211,10 @@ export default class Workbench { }); } + if (MappableMixin.isMixedInto(item) && item.shouldShowInitialMessage) { + await item.showInitialMessage(); + } + this.insertItem(item); let error: TerriaError | undefined; diff --git a/lib/ReactViewModels/NotificationState.ts b/lib/ReactViewModels/NotificationState.ts index c70db71bd7..2f8d5877ca 100644 --- a/lib/ReactViewModels/NotificationState.ts +++ b/lib/ReactViewModels/NotificationState.ts @@ -14,9 +14,28 @@ export interface Notification { width?: number | string; height?: number | string; key?: string; - /** If notification should not be shown to the user */ + + /** Show notification as a toast instead of as a blocking message */ + showAsToast?: boolean; + + /** + * Duration in seconds after which the toast is dismissed. If undefined, the + * toast must be explicitly dismissed by the user. + */ + toastVisibleDuration?: number; + + /** + * True if notification should not be shown to the user. You can also pass a + * reactive function which will dismiss the message even if it is currently + * being shown to the user. The reactive behaviour is useful for dismissing + * notifications that becomes invalid because some state has changed. + */ ignore?: boolean | (() => boolean); - /** Called when notification is dismissed, this will also be triggered for confirm/deny actions */ + + /** + * Called when notification is dismissed, this will also be triggered for + * confirm/deny actions + */ onDismiss?: () => void; } @@ -76,4 +95,11 @@ export default class NotificationState { get currentNotification(): Notification | undefined { return this.notifications.length > 0 ? this.notifications[0] : undefined; } + + /* + * @private - used in specs + */ + getAllNotifications() { + return this.notifications; + } } diff --git a/lib/ReactViews/Notification/Notification.tsx b/lib/ReactViews/Notification/Notification.tsx index 36242bd944..3038dd672f 100644 --- a/lib/ReactViews/Notification/Notification.tsx +++ b/lib/ReactViews/Notification/Notification.tsx @@ -1,6 +1,8 @@ import { observer } from "mobx-react"; +import { useEffect } from "react"; import triggerResize from "../../Core/triggerResize"; import { useViewState } from "../Context"; +import NotificationToast from "./NotificationToast"; import NotificationWindow from "./NotificationWindow"; const Notification = observer(() => { @@ -8,6 +10,17 @@ const Notification = observer(() => { const notificationState = viewState?.terria.notificationState; const notification = notificationState?.currentNotification; + const ignore = + typeof notification?.ignore === "function" + ? notification.ignore() + : notification?.ignore ?? false; + + useEffect(() => { + if (ignore) { + notificationState.dismissCurrentNotification(); + } + }, [notificationState, ignore]); + if ( viewState === undefined || notificationState === undefined || @@ -38,7 +51,9 @@ const Notification = observer(() => { close(); }; - return ( + return notification.showAsToast ? ( + + ) : ( = ({ notification }) => { + const viewState = useViewState(); + const nodeRef = useRef(null); + + const notificationState = viewState.terria.notificationState; + const durationMsecs = notification.toastVisibleDuration + ? notification.toastVisibleDuration * 1000 + : undefined; + + const message = + typeof notification.message === "function" + ? notification.message(viewState) + : notification.message; + + useEffect(() => { + const timeout = setTimeout(() => { + if (notificationState.currentNotification === notification) { + notificationState.dismissCurrentNotification(); + } + }, durationMsecs); + return () => clearTimeout(timeout); + }, [notification, notificationState, durationMsecs]); + + return ( + + +
{message}
+ { + e.stopPropagation(); + notificationState.dismissCurrentNotification(); + }} + /> +
+ ); +}; + +const Wrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; + + position: fixed; + bottom: 70px; + left: 50%; + transform: translate(-35%); + border: 1px solid #ea580c; + border-radius: 6px; + z-index: ${(p) => p.theme.notificationWindowZIndex}; + + max-width: 50%; + padding: 16px; + gap: 16px; + background-color: #f2f2f2; +`; + +const CloseButton = styled(Button).attrs({ + styledWidth: "24px", + styledHeight: "24px", + renderIcon: () => ( + + ) +})` + background-color: transparent; + border: 0; + min-height: max-content; +`; + +export default NotificationToast; diff --git a/lib/Styled/Icon.tsx b/lib/Styled/Icon.tsx index 3518d51f56..0f038fa4d9 100644 --- a/lib/Styled/Icon.tsx +++ b/lib/Styled/Icon.tsx @@ -145,6 +145,7 @@ import minusList from "../../wwwroot/images/icons/dismiss-20.svg"; import switchOn from "../../wwwroot/images/icons/switch-on.svg"; import switchOff from "../../wwwroot/images/icons/switch-off.svg"; import dragDrop from "../../wwwroot/images/icons/drag-drop.svg"; +import warning from "../../wwwroot/images/icons/warning.svg"; // Icon export const GLYPHS = { @@ -289,7 +290,8 @@ export const GLYPHS = { minusList, switchOn, switchOff, - dragDrop + dragDrop, + warning }; export interface IconGlyph { diff --git a/lib/Traits/TraitsClasses/MappableTraits.ts b/lib/Traits/TraitsClasses/MappableTraits.ts index 1316c3e6b0..828e29abb2 100644 --- a/lib/Traits/TraitsClasses/MappableTraits.ts +++ b/lib/Traits/TraitsClasses/MappableTraits.ts @@ -146,6 +146,7 @@ export class IdealZoomTraits extends ModelTraits { }) camera?: CameraTraits; } + export class InitialMessageTraits extends ModelTraits { @primitiveTrait({ type: "string", @@ -197,6 +198,21 @@ export class InitialMessageTraits extends ModelTraits { description: "Height of the message." }) height?: number; + + @primitiveTrait({ + type: "boolean", + name: "Show as toast message", + description: "Show the initial message as a toast" + }) + showAsToast?: boolean = false; + + @primitiveTrait({ + type: "number", + name: "Toast visible duration", + description: + "Time in seconds after which the toast will be dismissed. If undefined, user must take action." + }) + toastVisibleDuration?: number; } /* eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging */ diff --git a/test/Models/WorkbenchSpec.ts b/test/Models/WorkbenchSpec.ts index 8fc42cd893..3d21279737 100644 --- a/test/Models/WorkbenchSpec.ts +++ b/test/Models/WorkbenchSpec.ts @@ -6,6 +6,7 @@ import Workbench from "../../lib/Models/Workbench"; import Result from "../../lib/Core/Result"; import TerriaError, { TerriaErrorSeverity } from "../../lib/Core/TerriaError"; import TerriaReference from "../../lib/Models/Catalog/CatalogReferences/TerriaReference"; +import updateModelFromJson from "../../lib/Models/Definition/updateModelFromJson"; describe("Workbench", function () { let terria: Terria; @@ -268,4 +269,46 @@ describe("Workbench", function () { await workbench.add(model.target!.sourceReference!); workbenchWithSingleModel(); }); + + describe("when adding items with initialMessage", function () { + let testItem: WebMapServiceCatalogItem; + + beforeEach(function () { + testItem = new WebMapServiceCatalogItem("test-item", terria); + }); + + it("triggers an initial message notification when an item is added to the workbench", function () { + updateModelFromJson(testItem, CommonStrata.user, { + initialMessage: { + title: "Hello, world", + content: "This is a test message", + showAsToast: true + } + }); + + const notifications = terria.notificationState.getAllNotifications(); + expect(notifications.length).toBe(0); + workbench.add(testItem); + expect(notifications.length).toBe(1); + expect(notifications[0].title).toBe("Hello, world"); + expect(notifications[0].message).toBe("This is a test message"); + expect(notifications[0].showAsToast).toBe(true); + }); + + it("triggers the initial message only once", function () { + updateModelFromJson(testItem, CommonStrata.user, { + initialMessage: { + title: "Hello, world", + content: "This is a test message" + } + }); + + const notifications = terria.notificationState.getAllNotifications(); + expect(notifications.length).toBe(0); + workbench.add(testItem); + workbench.remove(testItem); + workbench.add(testItem); + expect(notifications.length).toBe(1); + }); + }); }); diff --git a/test/ReactViews/Notification/NotificationSpec.tsx b/test/ReactViews/Notification/NotificationSpec.tsx new file mode 100644 index 0000000000..4c0d2adb30 --- /dev/null +++ b/test/ReactViews/Notification/NotificationSpec.tsx @@ -0,0 +1,80 @@ +import { screen } from "@testing-library/dom"; +import Terria from "../../../lib/Models/Terria"; +import ViewState from "../../../lib/ReactViewModels/ViewState"; +import { renderWithContexts } from "../withContext"; +import Notification from "../../../lib/ReactViews/Notification/Notification"; +import { observable, runInAction } from "mobx"; +import { act } from "@testing-library/react"; + +describe("Notification", function () { + let viewState: ViewState; + + beforeEach(function () { + const terria = new Terria({ + baseUrl: "./" + }); + viewState = new ViewState({ + terria: terria, + catalogSearchProvider: undefined + }); + }); + + it("renders the current notification", function () { + viewState.terria.notificationState.addNotificationToQueue({ + title: "Hello, world", + message: "this is a test message" + }); + viewState.terria.notificationState.addNotificationToQueue({ + title: "Hello, blue planet", + message: "this is another test message" + }); + renderWithContexts(, viewState); + const title = screen.getByText("Hello, world"); + expect(title).toBeVisible(); + const message = screen.getByText("this is a test message"); + expect(message).toBeVisible(); + }); + + describe("when ignore is true", function () { + it("the message should not be shown", function () { + viewState.terria.notificationState.addNotificationToQueue({ + title: "Hello, world", + message: "this is a test message", + ignore: true + }); + renderWithContexts(, viewState); + const title = screen.queryByText("Hello, world"); + expect(title).toBeNull(); + }); + + it("accepts an ignore fn", function () { + viewState.terria.notificationState.addNotificationToQueue({ + title: "Hello, world", + message: "this is a test message", + ignore: () => true + }); + renderWithContexts(, viewState); + const title = screen.queryByText("Hello, world"); + expect(title).toBeNull(); + }); + + it("auto-dismisses an active notification if ignore becomes true", function () { + const ignore = observable.box(false); + viewState.terria.notificationState.addNotificationToQueue({ + title: "Hello, world", + message: "this is a test message", + ignore: () => ignore.get() + }); + renderWithContexts(, viewState); + let title = screen.queryByText("Hello, world"); + expect(title).toBeVisible(); + act(() => { + runInAction(() => { + ignore.set(true); + }); + }); + title = screen.queryByText("Hello, world"); + expect(title).toBeNull("Message must be ignored when mobx value changes"); + }); + }); +}); diff --git a/wwwroot/images/icons/warning.svg b/wwwroot/images/icons/warning.svg new file mode 100644 index 0000000000..e5e50a86b9 --- /dev/null +++ b/wwwroot/images/icons/warning.svg @@ -0,0 +1,4 @@ + + + +