Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
32 changes: 32 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 Expand Up @@ -281,6 +297,22 @@ class MappableTraits extends mixTraits(AttributionTraits) {
'The maximum number of "feature infos" that can be displayed in feature info panel.'
})
maximumShownFeatureInfos?: number;

@primitiveTrait({
type: "string",
name: "Preferred viewer mode",
description:
"The preferred viewer mode for this item - either '2d' '3d' or '3dsmooth'. If this dataset is used as a basemap then we automatically switch the viewer to the preferred mode. However the user can still switch to another mode, so this preference is not strongly enforced."
})
preferredViewerMode?: string;

@primitiveTrait({
type: "string",
name: "Preview caption",
description:
"Caption text for the preview map shown in bottom left corner of the preview map."
})
previewCaption?: string;
}

/* 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