diff --git a/reactapp/__tests__/components/visualizations/LiveChat.test.js b/reactapp/__tests__/components/visualizations/LiveChat.test.js
index 161baeca..911df80e 100644
--- a/reactapp/__tests__/components/visualizations/LiveChat.test.js
+++ b/reactapp/__tests__/components/visualizations/LiveChat.test.js
@@ -124,6 +124,72 @@ describe("LiveChat", () => {
);
});
+ it("updates the log when a re-fetch delivers new history", () => {
+ const { rerender } = renderWithContexts();
+ expect(screen.getByText("Hello world!")).toBeInTheDocument();
+
+ const refetched = [
+ ...chatHistory,
+ {
+ message: "Fetched later",
+ sessionId: "session-3",
+ sender: "Carol",
+ timestamp: Date.now(),
+ messageId: "msg-3",
+ edited: false,
+ },
+ ];
+ rerender(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByText("Fetched later")).toBeInTheDocument();
+ expect(screen.getByText("Hello world!")).toBeInTheDocument();
+ });
+
+ it("renders with no chat history", () => {
+ renderWithContexts({ chatHistory: undefined });
+ expect(
+ screen.getByPlaceholderText("Type a message..."),
+ ).toBeInTheDocument();
+ });
+
+ it("does not take focus on mount", () => {
+ // Focusing an input scrolls its scrollable ancestors to reveal it, which
+ // on the dashboard scrolls the page down to this widget and hides the
+ // chat history above it.
+ const focusSpy = jest.spyOn(HTMLElement.prototype, "focus");
+ renderWithContexts();
+ expect(focusSpy).not.toHaveBeenCalled();
+ focusSpy.mockRestore();
+ });
+
+ it("focuses without scrolling when the username input opens", () => {
+ renderWithContexts();
+ const focusSpy = jest.spyOn(HTMLElement.prototype, "focus");
+ fireEvent.click(screen.getByLabelText("Change Username"));
+ expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
+ focusSpy.mockRestore();
+ });
+
+ it("focuses the message input without scrolling after the username is set", () => {
+ renderWithContexts();
+ fireEvent.click(screen.getByLabelText("Change Username"));
+ const usernameInput = screen.getByPlaceholderText(/enter your username/i);
+
+ const focusSpy = jest.spyOn(HTMLElement.prototype, "focus");
+ fireEvent.change(usernameInput, { target: { value: "NewUser" } });
+ fireEvent.click(screen.getByLabelText("Set Username"));
+
+ expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
+ expect(screen.getByPlaceholderText("Type a message...")).toHaveFocus();
+ focusSpy.mockRestore();
+ });
+
it("allows username change", () => {
renderWithContexts();
fireEvent.click(screen.getByLabelText("Change Username"));
diff --git a/reactapp/__tests__/index.test.js b/reactapp/__tests__/index.test.js
index 5befcceb..952a6d17 100644
--- a/reactapp/__tests__/index.test.js
+++ b/reactapp/__tests__/index.test.js
@@ -48,6 +48,14 @@ describe("index.js", () => {
expect(mockGetTethysAppRoot).toHaveBeenCalledTimes(1);
});
+ test("should disable browser scroll restoration", () => {
+ window.history.scrollRestoration = "auto";
+
+ require("../index.js");
+
+ expect(window.history.scrollRestoration).toBe("manual");
+ });
+
test("should register DOMContentLoaded event listener", () => {
// Test that the module loads without errors and sets up the listener
expect(() => {
diff --git a/reactapp/__tests__/services/utilities.test.js b/reactapp/__tests__/services/utilities.test.js
index f8df6e3c..18837c50 100644
--- a/reactapp/__tests__/services/utilities.test.js
+++ b/reactapp/__tests__/services/utilities.test.js
@@ -2,6 +2,7 @@ import {
getTethysPortalHost,
getPublicUrl,
getTethysAppRoot,
+ getWebsocketUrl,
} from "services/utilities";
// Mock window.location
@@ -286,4 +287,80 @@ describe("utilities", () => {
);
});
});
+
+ describe("getWebsocketUrl", () => {
+ test("should return null when the url is not configured", () => {
+ delete process.env.REDIS_WS_URL;
+
+ expect(getWebsocketUrl()).toBe(null);
+ });
+
+ test("should return null when the url is empty", () => {
+ process.env.REDIS_WS_URL = " ";
+
+ expect(getWebsocketUrl()).toBe(null);
+ });
+
+ test("should use an absolute websocket url as-is", () => {
+ process.env.REDIS_WS_URL =
+ "ws://localhost:8000/apps/tethysdash/visualizations/notifications/ws/";
+
+ expect(getWebsocketUrl()).toBe(
+ "ws://localhost:8000/apps/tethysdash/visualizations/notifications/ws/",
+ );
+ });
+
+ test("should derive a wss url from an https page", () => {
+ process.env.REDIS_WS_URL = "visualizations/notifications/ws/";
+ process.env.TETHYS_PORTAL_HOST = "";
+ process.env.TETHYS_PREFIX_URL = "";
+ process.env.TETHYS_APP_ROOT_URL = "/apps/tethysdash/";
+ mockLocation(
+ "https://mysite.com/apps/tethysdash/dashboard/abc",
+ "https://mysite.com",
+ );
+
+ expect(getWebsocketUrl()).toBe(
+ "wss://mysite.com/apps/tethysdash/visualizations/notifications/ws/",
+ );
+ });
+
+ test("should derive a ws url from an http page", () => {
+ process.env.REDIS_WS_URL = "visualizations/notifications/ws/";
+ process.env.TETHYS_PORTAL_HOST = "";
+ process.env.TETHYS_PREFIX_URL = "";
+ process.env.TETHYS_APP_ROOT_URL = "/apps/tethysdash/";
+ mockLocation("http://mysite.com/apps/tethysdash/", "http://mysite.com");
+
+ expect(getWebsocketUrl()).toBe(
+ "ws://mysite.com/apps/tethysdash/visualizations/notifications/ws/",
+ );
+ });
+
+ test("should include the portal prefix url", () => {
+ process.env.REDIS_WS_URL = "visualizations/notifications/ws/";
+ process.env.TETHYS_PORTAL_HOST = "";
+ process.env.TETHYS_PREFIX_URL = "/tethys/";
+ process.env.TETHYS_APP_ROOT_URL = "/apps/tethysdash/";
+ mockLocation(
+ "https://mysite.com/tethys/apps/tethysdash/",
+ "https://mysite.com",
+ );
+
+ expect(getWebsocketUrl()).toBe(
+ "wss://mysite.com/tethys/apps/tethysdash/visualizations/notifications/ws/",
+ );
+ });
+
+ test("should honor a configured portal host with a trailing slash", () => {
+ process.env.REDIS_WS_URL = "/visualizations/notifications/ws/";
+ process.env.TETHYS_PORTAL_HOST = "https://example.com/";
+ process.env.TETHYS_PREFIX_URL = "";
+ process.env.TETHYS_APP_ROOT_URL = "/apps/tethysdash/";
+
+ expect(getWebsocketUrl()).toBe(
+ "wss://example.com/apps/tethysdash/visualizations/notifications/ws/",
+ );
+ });
+ });
});
diff --git a/reactapp/components/contexts/WebSocketContext.js b/reactapp/components/contexts/WebSocketContext.js
index 7d1db3c4..099407aa 100644
--- a/reactapp/components/contexts/WebSocketContext.js
+++ b/reactapp/components/contexts/WebSocketContext.js
@@ -8,6 +8,7 @@ import {
} from "react";
import LoadingAnimation from "components/loader/LoadingAnimation";
import PropTypes from "prop-types";
+import { getWebsocketUrl } from "services/utilities";
export const WebsocketContext = createContext();
@@ -18,12 +19,13 @@ const WebsocketProvider = ({ children }) => {
const [timeoutReached, setTimeoutReached] = useState(false);
const ws = useRef(null);
- const hasWebSocketUrl = Boolean(process.env.REDIS_WS_URL);
+ const websocketUrl = useMemo(() => getWebsocketUrl(), []);
+ const hasWebSocketUrl = Boolean(websocketUrl);
useEffect(() => {
if (!hasWebSocketUrl) return;
- const socket = new WebSocket(process.env.REDIS_WS_URL);
+ const socket = new WebSocket(websocketUrl);
socket.onopen = () => setWebsocketReady(true);
socket.onclose = () => setWebsocketReady(false);
diff --git a/reactapp/components/visualizations/LiveChat.js b/reactapp/components/visualizations/LiveChat.js
index e2f34f1e..0db88089 100644
--- a/reactapp/components/visualizations/LiveChat.js
+++ b/reactapp/components/visualizations/LiveChat.js
@@ -11,11 +11,20 @@ const PaddedContainer = styled.div`
padding: 16px;
display: flex;
height: 100%;
+ /* The padding has to come out of the grid item's height rather than add to
+ it, or the input row below the log is pushed past the bottom of the tile. */
+ box-sizing: border-box;
flex-direction: column;
`;
const ChatLogArea = styled.div`
flex: 1 1 0%;
+ /* A flex item's automatic minimum size is its content size, so without this
+ the log refuses to shrink below the full height of the message list: it
+ grows instead of scrolling, pushing the input row out of the grid item and
+ down the page (and leaving the messages above the fold). min-height: 0 lets
+ it shrink so overflow-y actually scrolls inside the tile. */
+ min-height: 0;
overflow-y: auto;
margin-bottom: 8px;
`;
@@ -337,7 +346,9 @@ const LiveChat = ({ requestId, chatHistory }) => {
);
const [input, setInput] = useState("");
const messageInputRef = useRef(null);
- const [chatLog, setChatLog] = useState(chatHistory);
+ const usernameInputRef = useRef(null);
+ const hasMountedRef = useRef(false);
+ const [chatLog, setChatLog] = useState(chatHistory ?? []);
const chatLogRef = useRef(null);
const [rateLimited, setRateLimited] = useState(false);
const [rateLimitCountdown, setRateLimitCountdown] = useState(0);
@@ -349,6 +360,21 @@ const LiveChat = ({ requestId, chatHistory }) => {
const sessionIdKey = `livechat_sessionid_${requestId}`;
const sessionId = getOrCreateSessionId(sessionIdKey);
+ // getVisualization hands down a fresh chatHistory on every re-fetch (refresh
+ // interval, manual retry, arg change). useState reads its argument only on
+ // mount, so without this the log would stay frozen on whatever history it
+ // mounted with. Messages that arrived over the websocket after the server
+ // took its snapshot are carried over rather than dropped.
+ useEffect(() => {
+ const history = chatHistory ?? [];
+ setChatLog((prev) => {
+ if (valuesEqual(prev, history)) return prev;
+ const historyIds = new Set(history.map((msg) => msg.messageId));
+ const liveOnly = prev.filter((msg) => !historyIds.has(msg.messageId));
+ return [...history, ...liveOnly];
+ });
+ }, [chatHistory]);
+
// Listen for new successful messages for this requestId
useEffect(() => {
const messageData = messagesByRequestId[requestId];
@@ -544,10 +570,22 @@ const LiveChat = ({ requestId, chatHistory }) => {
}
};
- // Autofocus message input when username is set or updated
+ // Move focus to whichever input just became active when the username is set
+ // or updated. Skipped on the initial mount, and focused with preventScroll:
+ // focusing an element scrolls its scrollable ancestors to reveal it, so an
+ // on-load focus drags the dashboard page down to this widget and hides
+ // everything above it -- including this chat's own message history.
useEffect(() => {
- if (customUsername && !editingUsername && messageInputRef.current) {
- messageInputRef.current.focus();
+ if (!hasMountedRef.current) {
+ hasMountedRef.current = true;
+ return;
+ }
+ const activeInput =
+ !customUsername || editingUsername
+ ? usernameInputRef.current
+ : messageInputRef.current;
+ if (activeInput) {
+ activeInput.focus({ preventScroll: true });
}
}, [customUsername, editingUsername]);
@@ -608,13 +646,13 @@ const LiveChat = ({ requestId, chatHistory }) => {
{/* If username is not set, use input for username entry */}
{!customUsername || editingUsername ? (
setInput(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder="Enter your username..."
maxLength={32}
- autoFocus
disabled={false}
/>
) : (
diff --git a/reactapp/config/development.env b/reactapp/config/development.env
index 00dbb592..c2d1ddf7 100644
--- a/reactapp/config/development.env
+++ b/reactapp/config/development.env
@@ -10,4 +10,8 @@ TETHYS_PORTAL_HOST = ""
TETHYS_PREFIX_URL = ""
TETHYSDASH_SUPPORT_EMAIL = "ckrewson@aquaveo.com"
TETHYSDASH_SUPPORT_GITHUB = "https://github.com/tethysplatform/tethysapp-tethys_dash/issues"
-REDIS_WS_URL = "ws://localhost:8000/apps/tethysdash/visualizations/notifications/ws/"
\ No newline at end of file
+# Path under the app root. The origin and ws/wss scheme are derived from the
+# page at runtime, so nothing is pinned to a host. Set an absolute ws:// or
+# wss:// url to point at a separate notification host, or leave empty to
+# disable websockets.
+REDIS_WS_URL = "visualizations/notifications/ws/"
\ No newline at end of file
diff --git a/reactapp/config/webpack.config.js b/reactapp/config/webpack.config.js
index c0a52c83..86c42539 100644
--- a/reactapp/config/webpack.config.js
+++ b/reactapp/config/webpack.config.js
@@ -118,9 +118,16 @@ module.exports = (env, argv) => {
devServer: {
proxy: [
{
- context: ["!/static/tethysdash/frontend/**"],
+ // "!/ws" keeps webpack-dev-server's own HMR socket local. Without it
+ // the ws:true upgrade below would hand the HMR connection to Django,
+ // which has no consumer at that path, and hot reload would die.
+ context: ["!/static/tethysdash/frontend/**", "!/ws"],
target: "http://localhost:8000", // points to django dev server
changeOrigin: true,
+ // Proxy websocket upgrades too, so the app's notification socket can
+ // use a same-origin relative URL in dev exactly as it does in a
+ // deployed build, instead of hardcoding the django host.
+ ws: true,
// Lets Django detect that this request was proxied through
// webpack-dev-server so it renders the unhashed main.js URL
// (served from memory) instead of the on-disk hashed bundle.
diff --git a/reactapp/index.js b/reactapp/index.js
index 96677622..d10aff68 100644
--- a/reactapp/index.js
+++ b/reactapp/index.js
@@ -6,6 +6,15 @@ import App from "App";
const APP_ROOT_URL = getTethysAppRoot();
+// Dashboard widgets fetch their data after mount, so the page is short when it
+// first paints and only reaches full height once they resolve. The browser's
+// default scroll restoration re-applies the pre-reload offset at that point,
+// which reads as the page spontaneously scrolling away from the top a moment
+// after load. Own the scroll position instead: a refresh starts at the top.
+if ("scrollRestoration" in window.history) {
+ window.history.scrollRestoration = "manual";
+}
+
let container = null;
document.addEventListener("DOMContentLoaded", () => {
diff --git a/reactapp/services/utilities.js b/reactapp/services/utilities.js
index e25c9ea2..724d3092 100644
--- a/reactapp/services/utilities.js
+++ b/reactapp/services/utilities.js
@@ -42,3 +42,27 @@ export function getTethysAppRoot() {
let fp = `/${tethys_prefix_url}/${tethys_app_root_url}`;
return fp.replace(/\/{2,}/g, "/");
}
+
+export function getWebsocketUrl() {
+ let configured = (process.env.REDIS_WS_URL || "").trim();
+
+ // An empty value disables websocket usage entirely.
+ if (!configured) {
+ return null;
+ }
+
+ // An absolute websocket url is used as-is. This covers an external
+ // notification host and the django dev server, which the webpack dev
+ // server does not proxy websocket upgrades to.
+ if (/^wss?:\/\//i.test(configured)) {
+ return configured;
+ }
+
+ // Otherwise the value is a path under the app root and the origin is
+ // derived from the current page, so deployed bundles never point the
+ // visitor's browser at localhost.
+ let host = getTethysPortalHost().replace(/\/+$/, "").replace(/^http/i, "ws");
+ let path = `${getTethysAppRoot()}/${configured}`.replace(/\/{2,}/g, "/");
+
+ return host + path;
+}