From dc1a1988b5e6212c16439ef38cc0c9be92dc8740 Mon Sep 17 00:00:00 2001 From: gabalafou Date: Wed, 16 Oct 2024 11:24:05 -0400 Subject: [PATCH 1/5] Memory routing to fix JupyterLab extension (#429) --- .env.example | 1 + src/App.tsx | 15 ++++++++++++--- src/preferences.tsx | 20 +++++++++++++++++++- src/routes.tsx | 6 +++--- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 9f8e51bc..2ae183ae 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,7 @@ REACT_APP_STYLE_TYPE=green-accent REACT_APP_CONTEXT=webapp REACT_APP_SHOW_AUTH_BUTTON=true REACT_APP_LOGOUT_PAGE_URL=http://localhost:8080/conda-store/logout?next=/ +REACT_APP_ROUTER_TYPE=browser # If you want to use a version other than the pinned conda-store-server version # Set the CONDA_STORE_SERVER_VERSION to the package version that you want diff --git a/src/App.tsx b/src/App.tsx index f469c10e..e62cec36 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,19 @@ import { ThemeProvider } from "@mui/material"; import React from "react"; import { Provider } from "react-redux"; -import { RouterProvider } from "react-router-dom"; - +import { + RouterProvider, + createBrowserRouter, + createMemoryRouter +} from "react-router-dom"; import { IPreferences, PrefContext, prefDefault, prefGlobal } from "./preferences"; -import { router } from "./routes"; +import { routes } from "./routes"; + import { store } from "./store"; import { condaStoreTheme, grayscaleTheme } from "./theme"; @@ -64,6 +68,11 @@ export class App< // } render(): React.ReactNode { + const router = + this.state.pref.routerType === "memory" + ? createMemoryRouter(routes, { initialEntries: ["/"] }) + : createBrowserRouter(routes); + return ( = { logoutUrl: process.env.REACT_APP_LOGOUT_PAGE_URL ?? condaStoreConfig.REACT_APP_LOGOUT_PAGE_URL ?? - "http://localhost:8080/conda-store/logout?next=/" + "http://localhost:8080/conda-store/logout?next=/", + + routerType: + process.env.REACT_APP_ROUTER_TYPE ?? + condaStoreConfig.REACT_APP_ROUTER_TYPE ?? + "browser" }; export class Preferences implements IPreferences { @@ -85,6 +97,10 @@ export class Preferences implements IPreferences { return this._logoutUrl; } + get routerType() { + return this._routerType; + } + set(pref: IPreferences) { this._apiUrl = pref.apiUrl; this._authMethod = pref.authMethod; @@ -93,6 +109,7 @@ export class Preferences implements IPreferences { this._styleType = pref.styleType; this._showAuthButton = pref.showAuthButton; this._logoutUrl = pref.logoutUrl; + this._routerType = pref.routerType; } private _apiUrl: IPreferences["apiUrl"]; @@ -102,6 +119,7 @@ export class Preferences implements IPreferences { private _styleType: IPreferences["styleType"]; private _showAuthButton: IPreferences["showAuthButton"]; private _logoutUrl: IPreferences["logoutUrl"]; + private _routerType: IPreferences["routerType"]; } export const prefGlobal = new Preferences(); diff --git a/src/routes.tsx b/src/routes.tsx index c5c41db9..96919fb7 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { createBrowserRouter } from "react-router-dom"; import { PageLayout } from "./layouts"; import { EnvironmentDetails } from "./features/environmentDetails"; @@ -8,7 +7,8 @@ import { EnvironmentCreate } from "./features/environmentCreate"; /** * Define URL routes for the single page app */ -export const router = createBrowserRouter([ + +export const routes = [ { path: "/", element: , @@ -23,4 +23,4 @@ export const router = createBrowserRouter([ } ] } -]); +]; From 337d63f49c7a0d3e3c0db5a4b24dd2bb628f112e Mon Sep 17 00:00:00 2001 From: gabalafou Date: Tue, 15 Oct 2024 17:06:30 +0300 Subject: [PATCH 2/5] Add React router tests --- src/layouts/PageLayout.tsx | 1 + src/preferences.tsx | 30 ++++----- test/playwright/memory-router-test.html | 12 ++++ test/playwright/test_react_router.py | 88 +++++++++++++++++++++++++ webpack.config.js | 10 ++- 5 files changed, 123 insertions(+), 18 deletions(-) create mode 100644 test/playwright/memory-router-test.html create mode 100644 test/playwright/test_react_router.py diff --git a/src/layouts/PageLayout.tsx b/src/layouts/PageLayout.tsx index f9ab730f..12f5599d 100644 --- a/src/layouts/PageLayout.tsx +++ b/src/layouts/PageLayout.tsx @@ -57,6 +57,7 @@ export const PageLayout = () => { justifyContent: "center", height: "100%" }} + data-testid="no-environment-selected" > Select an environment to show details diff --git a/src/preferences.tsx b/src/preferences.tsx index 34d0ba94..07a1efc2 100644 --- a/src/preferences.tsx +++ b/src/preferences.tsx @@ -17,50 +17,50 @@ export interface IPreferences { routerType: "browser" | "memory"; } -const { condaStoreConfig = {} } = - typeof window !== "undefined" && (window as any); +let condaStoreConfig: any = {}; +if (typeof window !== "undefined" && "condaStoreConfig" in window) { + condaStoreConfig = window.condaStoreConfig; +} export const prefDefault: Readonly = { apiUrl: - process.env.REACT_APP_API_URL ?? condaStoreConfig.REACT_APP_API_URL ?? + process.env.REACT_APP_API_URL ?? "http://localhost:8080/conda-store/", authMethod: - (process.env.REACT_APP_AUTH_METHOD as IPreferences["authMethod"]) ?? (condaStoreConfig.REACT_APP_AUTH_METHOD as IPreferences["authMethod"]) ?? + (process.env.REACT_APP_AUTH_METHOD as IPreferences["authMethod"]) ?? "cookie", authToken: - process.env.REACT_APP_AUTH_TOKEN ?? condaStoreConfig.REACT_APP_AUTH_TOKEN ?? + process.env.REACT_APP_AUTH_TOKEN ?? "", loginUrl: - process.env.REACT_APP_LOGIN_PAGE_URL ?? condaStoreConfig.REACT_APP_LOGIN_PAGE_URL ?? + process.env.REACT_APP_LOGIN_PAGE_URL ?? "http://localhost:8080/conda-store/login?next=", styleType: - process.env.REACT_APP_STYLE_TYPE ?? condaStoreConfig.REACT_APP_STYLE_TYPE ?? + process.env.REACT_APP_STYLE_TYPE ?? "green-accent", - showAuthButton: process.env.REACT_APP_SHOW_AUTH_BUTTON - ? JSON.parse(process.env.REACT_APP_SHOW_AUTH_BUTTON) - : condaStoreConfig !== undefined && - condaStoreConfig.REACT_APP_SHOW_AUTH_BUTTON !== undefined - ? JSON.parse(condaStoreConfig.REACT_APP_SHOW_AUTH_BUTTON) - : true, + showAuthButton: + (condaStoreConfig.REACT_APP_SHOW_AUTH_BUTTON ?? + process.env.REACT_APP_SHOW_AUTH_BUTTON ?? + "true") === "true", logoutUrl: - process.env.REACT_APP_LOGOUT_PAGE_URL ?? condaStoreConfig.REACT_APP_LOGOUT_PAGE_URL ?? + process.env.REACT_APP_LOGOUT_PAGE_URL ?? "http://localhost:8080/conda-store/logout?next=/", routerType: - process.env.REACT_APP_ROUTER_TYPE ?? condaStoreConfig.REACT_APP_ROUTER_TYPE ?? + process.env.REACT_APP_ROUTER_TYPE ?? "browser" }; diff --git a/test/playwright/memory-router-test.html b/test/playwright/memory-router-test.html new file mode 100644 index 00000000..3439116a --- /dev/null +++ b/test/playwright/memory-router-test.html @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/test/playwright/test_react_router.py b/test/playwright/test_react_router.py new file mode 100644 index 00000000..e2108e6e --- /dev/null +++ b/test/playwright/test_react_router.py @@ -0,0 +1,88 @@ +"""Test app with different types of React routers + +- browser router (uses the history API) +- memory router (uses in-app memory) + +Ref: https://reactrouter.com/en/main/routers/create-memory-router +""" + +import pytest +import re +from playwright.sync_api import Page, expect + + +@pytest.fixture +def test_config(): + return {"base_url": "http://localhost:8000"} + + +def test_browser_router_200_ok(page: Page, test_config): + """With browser router, a known route should show the corresponding view + """ + # Check that when going to a known route (in this case, the route to create + # a new environment), the app loads the view for that route. + page.goto(test_config["base_url"] + "/default/new-environment") + + # We know we are at the correct view (i.e., new environment form) if there + # is a textbox to enter the name of the new environment. + expect(page.get_by_role("textbox", name="environment name")).to_be_visible() + + +def test_memory_router_200_ok(): + """With memory router, all routes are 200 (OK) so there's nothing to test there + """ + pass + + +def test_browser_router_404_not_found(page: Page, test_config): + """With browser router, an unknown route should result in a 404 not found error + """ + page.goto(test_config["base_url"] + "/this-is-not-an-app-route") + expect(page.get_by_text("404")).to_be_visible() + + +def test_memory_router_404_not_found(page: Page, test_config): + """The memory router has been configured to load the root view at any route + """ + # The route `/memory-router-test.html` is not a route recognized by the + # React app. With the browser router, an unknown route would give a 404. + page.goto(test_config["base_url"] + "/memory-router-test.html") + expect(page.get_by_test_id("no-environment-selected")).to_be_visible() + + +def test_browser_router_updates_location(page: Page, test_config): + """With browser router, following a link should update browser URL + """ + # Go to root view and verify that it loaded + page.goto(test_config["base_url"]) + expect(page.get_by_test_id("no-environment-selected")).to_be_visible() + + # Get and click link to "create new environment" + page.get_by_role("button", name="default").get_by_role( + "link", + # Note the accessible name is determined by the aria-label, + # not the link text + name=re.compile("new.*environment", re.IGNORECASE), + ).click() + + # With browser router, the window location should update in response to + # clicking an app link + expect(page).to_have_url(re.compile("/default/new-environment")) + + +def test_memory_router_does_not_update_location(page: Page, test_config): + """With memory router, following a link should NOT update browser URL + """ + page.goto(test_config["base_url"] + "/memory-router-test.html") + + # Get and click link to "create new environment" + page.get_by_role("button", name="default").get_by_role( + "link", + # Note the accessible name is determined by the aria-label, + # not the link text + name=re.compile("new.*environment", re.IGNORECASE), + ).click() + + # With memory router, the window location should **not** update in response + # to clicking an app link + expect(page).to_have_url(re.compile("/memory-router-test.html")) diff --git a/webpack.config.js b/webpack.config.js index d97a2b34..6cc21c38 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,7 +1,7 @@ /* * Copyright (c) 2020, conda-store development team * - * This file is distributed under the terms of the BSD 3 Clause license. + * This file is distributed under the terms of the BSD 3 Clause license. * The full license can be found in the LICENSE file. */ @@ -18,7 +18,7 @@ const isProd = process.env.NODE_ENV === "production"; const ASSET_PATH = isProd ? "" : "/"; const version = packageJson.version; -// Calculate hash based on content, will be used when generating production +// Calculate hash based on content, will be used when generating production // bundles const cssLoader = { loader: "css-loader", @@ -92,6 +92,10 @@ module.exports = { }, plugins: [ new HtmlWebpackPlugin({ title: "conda-store" }), + new HtmlWebpackPlugin({ + filename: "memory-router-test.html", + template: "test/playwright/memory-router-test.html", + }), new MiniCssExtractPlugin({ filename: "[name].css", }), @@ -101,7 +105,7 @@ module.exports = { new webpack.DefinePlugin({ 'process.env.VERSION': JSON.stringify(version), }), - // Add comment to generated files indicating the hash and ui version + // Add comment to generated files indicating the hash and ui version // this is helpful for vendoring with server new webpack.BannerPlugin({ banner: `file: [file], fullhash:[fullhash] - ui version: ${version}`, From 0cb7011b83f3b1fa4303f1896d183d5f4366a6a4 Mon Sep 17 00:00:00 2001 From: gabalafou Date: Mon, 21 Oct 2024 06:30:43 -0400 Subject: [PATCH 3/5] Add config option REACT_APP_URL_BASENAME (#431) * Add config option REACT_APP_URL_BASENAME * update preferences class --- .env.example | 5 +++++ src/App.tsx | 6 ++++-- src/preferences.tsx | 17 ++++++++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 2ae183ae..06203a2f 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,11 @@ REACT_APP_SHOW_AUTH_BUTTON=true REACT_APP_LOGOUT_PAGE_URL=http://localhost:8080/conda-store/logout?next=/ REACT_APP_ROUTER_TYPE=browser +# If you need to mount the React app at some URL path other than "/". This value +# is passed directly to React Router, see: +# https://reactrouter.com/en/main/routers/create-browser-router#optsbasename +# REACT_APP_URL_BASENAME="/conda-store" + # If you want to use a version other than the pinned conda-store-server version # Set the CONDA_STORE_SERVER_VERSION to the package version that you want # CONDA_STORE_SERVER_VERSION="2024.3.1" diff --git a/src/App.tsx b/src/App.tsx index e62cec36..7d534c14 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -68,10 +68,12 @@ export class App< // } render(): React.ReactNode { + const { routerType, urlBasename: basename } = this.state.pref; + const router = - this.state.pref.routerType === "memory" + routerType === "memory" ? createMemoryRouter(routes, { initialEntries: ["/"] }) - : createBrowserRouter(routes); + : createBrowserRouter(routes, { basename }); return ( diff --git a/src/preferences.tsx b/src/preferences.tsx index 34d0ba94..6365c40c 100644 --- a/src/preferences.tsx +++ b/src/preferences.tsx @@ -15,6 +15,10 @@ export interface IPreferences { // the URL routes in the browser address bar are for JupyterLab, not for // conda-store-ui. routerType: "browser" | "memory"; + + // urlBasename - Defaults to "/" but can be changed if the app needs to be + // mounted at a different URL path, such as "/conda-store" + urlBasename: string; } const { condaStoreConfig = {} } = @@ -61,7 +65,12 @@ export const prefDefault: Readonly = { routerType: process.env.REACT_APP_ROUTER_TYPE ?? condaStoreConfig.REACT_APP_ROUTER_TYPE ?? - "browser" + "browser", + + urlBasename: + process.env.REACT_APP_URL_BASENAME ?? + condaStoreConfig.REACT_APP_URL_BASENAME ?? + "/" }; export class Preferences implements IPreferences { @@ -101,6 +110,10 @@ export class Preferences implements IPreferences { return this._routerType; } + get urlBasename() { + return this._urlBasename; + } + set(pref: IPreferences) { this._apiUrl = pref.apiUrl; this._authMethod = pref.authMethod; @@ -110,6 +123,7 @@ export class Preferences implements IPreferences { this._showAuthButton = pref.showAuthButton; this._logoutUrl = pref.logoutUrl; this._routerType = pref.routerType; + this._urlBasename = pref.urlBasename; } private _apiUrl: IPreferences["apiUrl"]; @@ -120,6 +134,7 @@ export class Preferences implements IPreferences { private _showAuthButton: IPreferences["showAuthButton"]; private _logoutUrl: IPreferences["logoutUrl"]; private _routerType: IPreferences["routerType"]; + private _urlBasename: IPreferences["urlBasename"]; } export const prefGlobal = new Preferences(); From 3b6a1d7bbff39c7a91607f6baedc97cf306ef6a4 Mon Sep 17 00:00:00 2001 From: gabalafou Date: Mon, 21 Oct 2024 19:32:18 +0300 Subject: [PATCH 4/5] fix bad merge --- src/preferences.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/preferences.tsx b/src/preferences.tsx index be350179..e792103e 100644 --- a/src/preferences.tsx +++ b/src/preferences.tsx @@ -68,8 +68,8 @@ export const prefDefault: Readonly = { "browser", urlBasename: - process.env.REACT_APP_URL_BASENAME ?? condaStoreConfig.REACT_APP_URL_BASENAME ?? + process.env.REACT_APP_URL_BASENAME ?? "/" }; From 91b84c5371aea04d22efd2d98fefd020f61b5fc5 Mon Sep 17 00:00:00 2001 From: gabalafou Date: Thu, 24 Oct 2024 11:04:18 +0200 Subject: [PATCH 5/5] case-insensitive true --- src/preferences.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/preferences.tsx b/src/preferences.tsx index e792103e..12706f88 100644 --- a/src/preferences.tsx +++ b/src/preferences.tsx @@ -33,8 +33,8 @@ export const prefDefault: Readonly = { "http://localhost:8080/conda-store/", authMethod: - (condaStoreConfig.REACT_APP_AUTH_METHOD as IPreferences["authMethod"]) ?? - (process.env.REACT_APP_AUTH_METHOD as IPreferences["authMethod"]) ?? + condaStoreConfig.REACT_APP_AUTH_METHOD ?? + process.env.REACT_APP_AUTH_METHOD ?? "cookie", authToken: @@ -52,10 +52,11 @@ export const prefDefault: Readonly = { process.env.REACT_APP_STYLE_TYPE ?? "green-accent", - showAuthButton: - (condaStoreConfig.REACT_APP_SHOW_AUTH_BUTTON ?? + showAuthButton: /true/i.test( + condaStoreConfig.REACT_APP_SHOW_AUTH_BUTTON ?? process.env.REACT_APP_SHOW_AUTH_BUTTON ?? - "true") === "true", + "true" + ), logoutUrl: condaStoreConfig.REACT_APP_LOGOUT_PAGE_URL ??