Skip to content
Merged
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
66 changes: 66 additions & 0 deletions reactapp/__tests__/components/visualizations/LiveChat.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<AppContext.Provider value={mockAppContext}>
<WebsocketContext.Provider value={mockWebsocketContext}>
<LiveChat requestId="req-1" chatHistory={refetched} />
</WebsocketContext.Provider>
</AppContext.Provider>,
);

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"));
Expand Down
8 changes: 8 additions & 0 deletions reactapp/__tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
77 changes: 77 additions & 0 deletions reactapp/__tests__/services/utilities.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
getTethysPortalHost,
getPublicUrl,
getTethysAppRoot,
getWebsocketUrl,
} from "services/utilities";

// Mock window.location
Expand Down Expand Up @@ -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/",
);
});
});
});
6 changes: 4 additions & 2 deletions reactapp/components/contexts/WebSocketContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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);
Expand Down
48 changes: 43 additions & 5 deletions reactapp/components/visualizations/LiveChat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
`;
Expand Down Expand Up @@ -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);
Expand All @@ -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];
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -608,13 +646,13 @@ const LiveChat = ({ requestId, chatHistory }) => {
{/* If username is not set, use input for username entry */}
{!customUsername || editingUsername ? (
<UsernameInput
ref={usernameInputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder="Enter your username..."
maxLength={32}
autoFocus
disabled={false}
/>
) : (
Expand Down
6 changes: 5 additions & 1 deletion reactapp/config/development.env
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
# 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/"
9 changes: 8 additions & 1 deletion reactapp/config/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions reactapp/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
24 changes: 24 additions & 0 deletions reactapp/services/utilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading