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; +}