diff --git a/.env.example b/.env.example index 9f8e51bc..06203a2f 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,12 @@ 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 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 diff --git a/src/App.tsx b/src/App.tsx index f469c10e..7d534c14 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,13 @@ export class App< // } render(): React.ReactNode { + const { routerType, urlBasename: basename } = this.state.pref; + + const router = + routerType === "memory" + ? createMemoryRouter(routes, { initialEntries: ["/"] }) + : createBrowserRouter(routes, { basename }); + return ( { 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 835575ea..12706f88 100644 --- a/src/preferences.tsx +++ b/src/preferences.tsx @@ -8,48 +8,70 @@ export interface IPreferences { styleType: string; showAuthButton: boolean; logoutUrl: string; + + // routerType - Should the app use the browser's history API for routing, or + // should app routes be handled internally in memory? This is needed for the + // JupyterLab extension because when conda-store-ui is embedded in JupyterLab, + // 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 = {} } = - 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"]) ?? + condaStoreConfig.REACT_APP_AUTH_METHOD ?? + process.env.REACT_APP_AUTH_METHOD ?? "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: /true/i.test( + condaStoreConfig.REACT_APP_SHOW_AUTH_BUTTON ?? + process.env.REACT_APP_SHOW_AUTH_BUTTON ?? + "true" + ), logoutUrl: - process.env.REACT_APP_LOGOUT_PAGE_URL ?? condaStoreConfig.REACT_APP_LOGOUT_PAGE_URL ?? - "http://localhost:8080/conda-store/logout?next=/" + process.env.REACT_APP_LOGOUT_PAGE_URL ?? + "http://localhost:8080/conda-store/logout?next=/", + + routerType: + condaStoreConfig.REACT_APP_ROUTER_TYPE ?? + process.env.REACT_APP_ROUTER_TYPE ?? + "browser", + + urlBasename: + condaStoreConfig.REACT_APP_URL_BASENAME ?? + process.env.REACT_APP_URL_BASENAME ?? + "/" }; export class Preferences implements IPreferences { @@ -85,6 +107,14 @@ export class Preferences implements IPreferences { return this._logoutUrl; } + get routerType() { + return this._routerType; + } + + get urlBasename() { + return this._urlBasename; + } + set(pref: IPreferences) { this._apiUrl = pref.apiUrl; this._authMethod = pref.authMethod; @@ -93,6 +123,8 @@ export class Preferences implements IPreferences { this._styleType = pref.styleType; this._showAuthButton = pref.showAuthButton; this._logoutUrl = pref.logoutUrl; + this._routerType = pref.routerType; + this._urlBasename = pref.urlBasename; } private _apiUrl: IPreferences["apiUrl"]; @@ -102,6 +134,8 @@ export class Preferences implements IPreferences { private _styleType: IPreferences["styleType"]; private _showAuthButton: IPreferences["showAuthButton"]; private _logoutUrl: IPreferences["logoutUrl"]; + private _routerType: IPreferences["routerType"]; + private _urlBasename: IPreferences["urlBasename"]; } 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([ } ] } -]); +]; 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}`,