Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions lib/ModelMixins/MappableMixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,6 @@ function MappableMixin<T extends AbstractConstructor<BaseType>>(Base: T) {
*/
async loadMapItems(force?: boolean): Promise<Result<void>> {
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();

Expand Down Expand Up @@ -225,8 +218,15 @@ function MappableMixin<T extends AbstractConstructor<BaseType>>(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();
}
});
}

Expand Down
4 changes: 4 additions & 0 deletions lib/Models/Workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,10 @@ export default class Workbench {
});
}

if (MappableMixin.isMixedInto(item) && item.shouldShowInitialMessage) {
await item.showInitialMessage();
}

this.insertItem(item);

let error: TerriaError | undefined;
Expand Down
30 changes: 28 additions & 2 deletions lib/ReactViewModels/NotificationState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
}
17 changes: 16 additions & 1 deletion lib/ReactViews/Notification/Notification.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
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(() => {
const viewState = useViewState();
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 ||
Expand Down Expand Up @@ -38,7 +51,9 @@ const Notification = observer(() => {
close();
};

return (
return notification.showAsToast ? (
<NotificationToast notification={notification} />
) : (
<NotificationWindow
viewState={viewState}
title={notification.title}
Expand Down
88 changes: 88 additions & 0 deletions lib/ReactViews/Notification/NotificationToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { FC, useEffect, useRef } from "react";
import styled from "styled-components";
import { Notification } from "../../ReactViewModels/NotificationState";
import { Button } from "../../Styled/Button";
import Icon, { StyledIcon } from "../../Styled/Icon";
import { useViewState } from "../Context";

const NotificationToast: FC<{
notification: Notification;
}> = ({ 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 (
<Wrapper ref={nodeRef}>
<StyledIcon
styledWidth="24px"
styledHeight="24px"
glyph={Icon.GLYPHS.warning}
fillColor="#EA580C"
/>
<div>{message}</div>
<CloseButton
onClick={(e: MouseEvent) => {
e.stopPropagation();
notificationState.dismissCurrentNotification();
}}
/>
</Wrapper>
);
};

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: () => (
<StyledIcon
glyph={Icon.GLYPHS.closeLight}
styledWidth="16px"
styledHeight="16px"
dark
/>
)
})`
background-color: transparent;
border: 0;
min-height: max-content;
`;

export default NotificationToast;
4 changes: 3 additions & 1 deletion lib/Styled/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -289,7 +290,8 @@ export const GLYPHS = {
minusList,
switchOn,
switchOff,
dragDrop
dragDrop,
warning
};

export interface IconGlyph {
Expand Down
16 changes: 16 additions & 0 deletions lib/Traits/TraitsClasses/MappableTraits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export class IdealZoomTraits extends ModelTraits {
})
camera?: CameraTraits;
}

export class InitialMessageTraits extends ModelTraits {
@primitiveTrait({
type: "string",
Expand Down Expand Up @@ -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 */
Expand Down
43 changes: 43 additions & 0 deletions test/Models/WorkbenchSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
});
});
Loading
Loading