diff --git a/src/App.tsx b/src/App.tsx
index dae49ed7..f469c10e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,32 +1,20 @@
import { ThemeProvider } from "@mui/material";
import React from "react";
import { Provider } from "react-redux";
-import { BrowserRouter as Router } from "react-router-dom";
-import { Route, Routes } from "react-router";
+import { RouterProvider } from "react-router-dom";
-import { PageLayout } from "./layouts";
import {
IPreferences,
PrefContext,
prefDefault,
prefGlobal
} from "./preferences";
+import { router } from "./routes";
import { store } from "./store";
import { condaStoreTheme, grayscaleTheme } from "./theme";
import "../style/index.css";
-const AppRouter = () => {
- // for now, trivial routing is sufficient
- return (
-
-
- } />
-
-
- );
-};
-
export interface IAppProps {
pref?: Partial;
}
@@ -86,7 +74,7 @@ export class App<
}
>
-
+
diff --git a/src/features/environmentCreate/components/EnvironmentCreate.tsx b/src/features/environmentCreate/components/EnvironmentCreate.tsx
index f369202c..a23306fb 100644
--- a/src/features/environmentCreate/components/EnvironmentCreate.tsx
+++ b/src/features/environmentCreate/components/EnvironmentCreate.tsx
@@ -1,4 +1,5 @@
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
import Box from "@mui/material/Box";
import Alert from "@mui/material/Alert";
import { stringify } from "yaml";
@@ -16,23 +17,38 @@ import {
} from "../../../features/metadata";
import {
environmentOpened,
- closeCreateNewEnvironmentTab
+ closeCreateNewEnvironmentTab,
+ openCreateNewEnvironmentTab
} from "../../../features/tabs";
import { useAppDispatch, useAppSelector } from "../../../hooks";
import { SpecificationCreate, SpecificationReadOnly } from "./Specification";
import { descriptionChanged, nameChanged } from "../environmentCreateSlice";
import createLabel from "../../../common/config/labels";
-
-export interface IEnvCreate {
- environmentNotification: (notification: any) => void;
-}
+import { showNotification } from "../../notification/notificationSlice";
interface ICreateEnvironmentArgs {
code: { channels: string[]; dependencies: string[] };
}
-export const EnvironmentCreate = ({ environmentNotification }: IEnvCreate) => {
+export const EnvironmentCreate = () => {
const dispatch = useAppDispatch();
+
+ // Url routing params
+ // If user loads the app at //new-environment
+ // This will put the app in the correct state
+ const { namespaceName } = useParams<{
+ namespaceName: string;
+ }>();
+
+ useEffect(() => {
+ if (namespaceName) {
+ dispatch(modeChanged(EnvironmentDetailsModes.CREATE));
+ dispatch(openCreateNewEnvironmentTab(namespaceName));
+ }
+ }, [namespaceName]);
+
+ const navigate = useNavigate();
+
const { mode } = useAppSelector(state => state.environmentDetails);
const { name, description } = useAppSelector(
state => state.environmentCreate
@@ -84,17 +100,13 @@ export const EnvironmentCreate = ({ environmentNotification }: IEnvCreate) => {
dispatch(
environmentOpened({
environment,
- selectedEnvironmentId: newEnvId,
canUpdate: true
})
);
+ // After new environment has been created, navigate to the new environment's web page
+ navigate(`/${namespace}/${name}`);
dispatch(currentBuildIdChanged(data.build_id));
- environmentNotification({
- data: {
- show: true,
- description: createLabel(name, "create")
- }
- });
+ dispatch(showNotification(createLabel(name, "create")));
} catch (e) {
setError({
message: e?.data?.message ?? createLabel(undefined, "error"),
diff --git a/src/features/environmentDetails/components/EnvironmentDetails.tsx b/src/features/environmentDetails/components/EnvironmentDetails.tsx
index 7b8e690e..21d1ed37 100644
--- a/src/features/environmentDetails/components/EnvironmentDetails.tsx
+++ b/src/features/environmentDetails/components/EnvironmentDetails.tsx
@@ -1,4 +1,6 @@
import React, { useEffect, useState } from "react";
+import { skipToken } from "@reduxjs/toolkit/query/react";
+import { useNavigate, useParams } from "react-router-dom";
import Box from "@mui/material/Box";
import Alert from "@mui/material/Alert";
import { stringify } from "yaml";
@@ -8,7 +10,9 @@ import { SpecificationEdit, SpecificationReadOnly } from "./Specification";
import { useGetBuildQuery } from "../environmentDetailsApiSlice";
import {
updateEnvironmentBuildId,
- environmentClosed
+ environmentClosed,
+ environmentOpened,
+ toggleNewEnvironmentView
} from "../../../features/tabs";
import {
useGetBuildPackagesQuery,
@@ -34,13 +38,15 @@ import artifactList from "../../../utils/helpers/artifact";
import createLabel from "../../../common/config/labels";
import { AlertDialog } from "../../../components/Dialog";
import { useAppDispatch, useAppSelector } from "../../../hooks";
-import { CondaSpecificationPip } from "../../../common/models";
+import {
+ CondaSpecificationPip,
+ Environment,
+ Namespace
+} from "../../../common/models";
import { useInterval } from "../../../utils/helpers";
+import { showNotification } from "../../notification/notificationSlice";
+import { useScrollRef } from "../../../layouts/PageLayout";
-interface IEnvDetails {
- scrollRef: any;
- environmentNotification: (notification: any) => void;
-}
interface IUpdateEnvironmentArgs {
dependencies: (string | CondaSpecificationPip)[];
channels: string[];
@@ -48,16 +54,65 @@ interface IUpdateEnvironmentArgs {
const INTERVAL_REFRESHING = 5000;
-export const EnvironmentDetails = ({
- scrollRef,
- environmentNotification
-}: IEnvDetails) => {
+export const EnvironmentDetails = () => {
const dispatch = useAppDispatch();
+
+ // Url routing params
+ // If user loads the app at //
+ // This will put the app in the correct state
+ const { namespaceName, environmentName } = useParams<{
+ namespaceName: string;
+ environmentName: string;
+ }>();
+ const namespaces: Namespace[] = useAppSelector(
+ state => state.namespaces.data
+ );
+ const namespace = namespaces.find(({ name }) => name === namespaceName);
+ const environments: Environment[] = useAppSelector(
+ state => state.environments.data
+ );
+ const environment = environments.find(
+ environment =>
+ environment.namespace.name === namespaceName &&
+ environment.name === environmentName
+ );
+ useEffect(() => {
+ if (namespace && environment) {
+ dispatch(
+ environmentOpened({
+ environment,
+ canUpdate: namespace.canUpdate
+ })
+ );
+ dispatch(modeChanged(EnvironmentDetailsModes.READ));
+ dispatch(toggleNewEnvironmentView(false));
+ }
+ }, [
+ // We only want to run this effect when:
+ //
+ // 1. User navigates to different environment = change of
+ // (namespaceName, environmentName) in the URL
+ // 2. The corresponding (namespace, environment) data have been fetched
+ //
+ // We cannot pass [namespace, environment] as the dependencies to
+ // useEffect() because whenever an environment is created or updated, a
+ // refetch of the data is triggered, which creates new data objects for
+ // [namespace, environment] in the Redux store, which would cause this
+ // effect to rerun, but, again, we only want to run this effect when the
+ // user navigates to a new (namespaceName, environmentName), hence the `&&
+ // namespace.name` and `&& environment.name`.
+ namespace && namespace.name,
+ environment && environment.name
+ ]);
+
+ const navigate = useNavigate();
+
const { mode } = useAppSelector(state => state.environmentDetails);
const { page, dependencies } = useAppSelector(state => state.dependencies);
const { selectedEnvironment } = useAppSelector(state => state.tabs);
const { currentBuild } = useAppSelector(state => state.enviroments);
const [name, setName] = useState(selectedEnvironment?.name || "");
+ const scrollRef = useScrollRef();
const [descriptionIsUpdated, setDescriptionIsUpdated] = useState(false);
const [description, setDescription] = useState(
@@ -81,7 +136,7 @@ export const EnvironmentDetails = ({
const [updateBuildId] = useUpdateBuildIdMutation();
const [deleteEnvironment] = useDeleteEnvironmentMutation();
- useGetEnviromentBuildsQuery(selectedEnvironment, {
+ useGetEnviromentBuildsQuery(selectedEnvironment ?? skipToken, {
pollingInterval: INTERVAL_REFRESHING
});
@@ -112,7 +167,7 @@ export const EnvironmentDetails = ({
};
const loadArtifacts = async () => {
- if (artifactType.includes("DOCKER_MANIFEST")) {
+ if (!currentBuildId || artifactType.includes("DOCKER_MANIFEST")) {
return;
}
@@ -122,7 +177,7 @@ export const EnvironmentDetails = ({
};
const loadDependencies = async () => {
- if (dependencies.length) {
+ if (!currentBuildId || dependencies.length) {
return;
}
@@ -171,12 +226,7 @@ export const EnvironmentDetails = ({
dispatch(modeChanged(EnvironmentDetailsModes.READ));
setCurrentBuildId(data.build_id);
dispatch(currentBuildIdChanged(data.build_id));
- environmentNotification({
- data: {
- show: true,
- description: createLabel(environment, "update")
- }
- });
+ dispatch(showNotification(createLabel(environment, "update")));
} catch (e) {
setError({
message:
@@ -187,7 +237,7 @@ export const EnvironmentDetails = ({
visible: true
});
}
- scrollRef.current.scrollTo(0, 0);
+ scrollRef.current?.scrollTo(0, 0);
};
const updateBuild = async (buildId: number) => {
@@ -201,12 +251,9 @@ export const EnvironmentDetails = ({
buildId
}).unwrap();
dispatch(updateEnvironmentBuildId(buildId));
- environmentNotification({
- data: {
- show: true,
- description: createLabel(selectedEnvironment.name, "updateBuild")
- }
- });
+ dispatch(
+ showNotification(createLabel(selectedEnvironment.name, "updateBuild"))
+ );
} catch (e) {
setError({
message: createLabel(undefined, "error"),
@@ -232,19 +279,17 @@ export const EnvironmentDetails = ({
selectedEnvironmentId: selectedEnvironment.id
})
);
- environmentNotification({
- data: {
- show: true,
- description: createLabel(selectedEnvironment.name, "delete")
- }
- });
+ dispatch(
+ showNotification(createLabel(selectedEnvironment.name, "delete"))
+ );
+ navigate("/");
} catch (e) {
setError({
message: createLabel(undefined, "error"),
visible: true
});
}
- scrollRef.current.scrollTo(0, 0);
+ scrollRef.current?.scrollTo(0, 0);
setShowDialog(false);
};
@@ -255,10 +300,15 @@ export const EnvironmentDetails = ({
})();
}, INTERVAL_REFRESHING);
+ if (!selectedEnvironment) {
+ return null;
+ }
+
return (
diff --git a/src/features/environments/components/Environment.tsx b/src/features/environments/components/Environment.tsx
index 1f35642b..31dfc65d 100644
--- a/src/features/environments/components/Environment.tsx
+++ b/src/features/environments/components/Environment.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import { Link } from "react-router-dom";
import CircleIcon from "@mui/icons-material/Circle";
import ListItemIcon from "@mui/material/ListItemIcon";
import Button from "@mui/material/Button";
@@ -12,13 +13,11 @@ interface IEnvironmentProps {
* @param selectedEnvironmentId id of the currently selected environment
*/
environment: EnvironmentModel;
- onClick: () => void;
selectedEnvironmentId: number | undefined;
}
export const Environment = ({
environment,
- onClick,
selectedEnvironmentId
}: IEnvironmentProps) => {
const isSelected = selectedEnvironmentId === environment.id;
@@ -44,7 +43,8 @@ export const Environment = ({
/>
diff --git a/src/features/environments/reducer.ts b/src/features/environments/reducer.ts
index caca44b9..9cac4540 100644
--- a/src/features/environments/reducer.ts
+++ b/src/features/environments/reducer.ts
@@ -1,52 +1,46 @@
+import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Environment } from "../../common/models";
-export enum ActionTypes {
- DATA_FETCHED = "environments/data_fetched",
- SEARCHED = "environments/searched",
- NEXT_FETCHED = "environments/next_fetched"
-}
-
-interface IInitialState {
+export interface IEnvironmentsState {
page: number;
data: Environment[];
count: number;
search: string;
}
-type Action =
- | {
- type: ActionTypes.DATA_FETCHED;
- payload: { data: Environment[]; count: number };
- }
- | {
- type: ActionTypes.SEARCHED;
- payload: { data: Environment[]; count: number; search: string };
- }
- | {
- type: ActionTypes.NEXT_FETCHED;
- payload: { data: Environment[]; count: number };
- };
-
-export const initialState: IInitialState = {
+export const initialState: IEnvironmentsState = {
page: 1,
data: [],
count: 0,
search: ""
};
-export const environmentsReducer = (state: IInitialState, action: Action) => {
- switch (action.type) {
- case ActionTypes.DATA_FETCHED: {
+export const environmentsSlice = createSlice({
+ name: "environments",
+ initialState,
+ reducers: {
+ dataFetched: (
+ state: IEnvironmentsState,
+ action: PayloadAction<{ data: Environment[]; count: number }>
+ ) => {
const { count, data } = action.payload;
return { ...state, count: count, data: data };
- }
-
- case ActionTypes.SEARCHED: {
+ },
+ searched: (
+ state: IEnvironmentsState,
+ action: PayloadAction<{
+ data: Environment[];
+ count: number;
+ search: string;
+ }>
+ ) => {
return { ...action.payload, page: 1 };
- }
-
- case ActionTypes.NEXT_FETCHED: {
+ },
+ nextFetched: (
+ state: IEnvironmentsState,
+ action: PayloadAction<{ data: Environment[]; count: number }>
+ ) => {
const { data, count } = action.payload;
const newData = state.data?.concat(data);
@@ -60,4 +54,6 @@ export const environmentsReducer = (state: IInitialState, action: Action) => {
};
}
}
-};
+});
+
+export const { dataFetched, searched, nextFetched } = environmentsSlice.actions;
diff --git a/src/features/namespaces/reducer.ts b/src/features/namespaces/reducer.ts
index e5b5a05c..623daffa 100644
--- a/src/features/namespaces/reducer.ts
+++ b/src/features/namespaces/reducer.ts
@@ -1,32 +1,30 @@
+import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Namespace } from "../../common/models";
-export enum ActionTypes {
- DATA_FETCHED = "namespaces/data_fetched"
-}
-
-interface IInitialState {
+export interface INamespacesState {
page: number;
data: Namespace[];
count: number;
}
-type Action = {
- type: ActionTypes.DATA_FETCHED;
- payload: { data: Namespace[]; count: number };
-};
-
-export const initialState: IInitialState = {
+export const initialState: INamespacesState = {
page: 1,
data: [],
count: 0
};
-export const namespacesReducer = (state: IInitialState, action: Action) => {
- switch (action.type) {
- case ActionTypes.DATA_FETCHED: {
+export const namespacesSlice = createSlice({
+ name: "namespaces",
+ initialState,
+ reducers: {
+ dataFetched: (
+ state: INamespacesState,
+ action: PayloadAction<{ data: Namespace[]; count: number }>
+ ) => {
const { count, data } = action.payload;
-
return { ...state, count: count, data: data };
}
}
-};
+});
+
+export const { dataFetched } = namespacesSlice.actions;
diff --git a/src/features/notification/index.ts b/src/features/notification/index.ts
new file mode 100644
index 00000000..4226099c
--- /dev/null
+++ b/src/features/notification/index.ts
@@ -0,0 +1 @@
+export * from "./notificationSlice";
diff --git a/src/features/notification/notificationSlice.ts b/src/features/notification/notificationSlice.ts
new file mode 100644
index 00000000..6c1de6f0
--- /dev/null
+++ b/src/features/notification/notificationSlice.ts
@@ -0,0 +1,30 @@
+import { createSlice } from "@reduxjs/toolkit";
+
+export interface INotificationState {
+ show: boolean;
+ description: string;
+}
+
+const initialState: INotificationState = {
+ show: false,
+ description: ""
+};
+
+export const notificationSlice = createSlice({
+ name: "notification",
+ initialState,
+ reducers: {
+ showNotification: (state, action) => ({
+ ...state,
+ show: true,
+ description: action.payload
+ }),
+ closeNotification: state => ({
+ ...state,
+ show: false
+ })
+ }
+});
+
+export const { showNotification, closeNotification } =
+ notificationSlice.actions;
diff --git a/src/features/requestedPackages/components/AddRequestedPackage.tsx b/src/features/requestedPackages/components/AddRequestedPackage.tsx
index f75d2e6d..2f757b09 100644
--- a/src/features/requestedPackages/components/AddRequestedPackage.tsx
+++ b/src/features/requestedPackages/components/AddRequestedPackage.tsx
@@ -178,7 +178,6 @@ export const AddRequestedPackage = ({
onCancel(false)}
data-testid="cancelIcon"
- theme={theme}
>
diff --git a/src/features/tabs/components/PageTabs.tsx b/src/features/tabs/components/PageTabs.tsx
deleted file mode 100644
index d316bc8c..00000000
--- a/src/features/tabs/components/PageTabs.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import React from "react";
-import { StyledTab } from "../../../styles";
-import { StyledTabs } from "../../../styles/StyledTabs";
-import CloseIcon from "@mui/icons-material/Close";
-import { useAppDispatch, useAppSelector } from "../../../hooks";
-import {
- modeChanged,
- EnvironmentDetailsModes
-} from "../../../features/environmentDetails";
-import {
- environmentClosed,
- tabChanged,
- closeCreateNewEnvironmentTab,
- toggleNewEnvironmentView
-} from "../tabsSlice";
-import { currentBuildIdChanged } from "../../metadata";
-
-export const PageTabs = () => {
- const { selectedEnvironments, value, selectedEnvironment, newEnvironment } =
- useAppSelector(state => state.tabs);
-
- const dispatch = useAppDispatch();
-
- const handleChange = (event: React.SyntheticEvent, newValue: number) => {
- if (typeof newValue === "number") {
- dispatch(toggleNewEnvironmentView(false));
- dispatch(modeChanged(EnvironmentDetailsModes.READ));
- dispatch(tabChanged(newValue));
- } else {
- dispatch(toggleNewEnvironmentView(true));
- dispatch(modeChanged(EnvironmentDetailsModes.CREATE));
- }
- };
-
- const handleClick = (
- e: React.MouseEvent,
- envId: number
- ) => {
- e.stopPropagation();
- dispatch(modeChanged(EnvironmentDetailsModes.READ));
- dispatch(
- environmentClosed({
- envId,
- selectedEnvironmentId: selectedEnvironment
- ? selectedEnvironment.id
- : envId
- })
- );
- dispatch(currentBuildIdChanged(undefined));
- if (selectedEnvironments.length === 1 && newEnvironment.isOpen) {
- dispatch(modeChanged(EnvironmentDetailsModes.CREATE));
- }
- };
-
- const closeNewEnvironment = (
- e: React.MouseEvent
- ) => {
- e.stopPropagation();
- dispatch(modeChanged(EnvironmentDetailsModes.READ));
- dispatch(closeCreateNewEnvironmentTab());
- };
-
- return (
-
- {selectedEnvironments.map(env => (
- handleClick(e, env.id)}
- data-testid="closeTab"
- >
-
-
- }
- iconPosition="end"
- />
- ))}
- {newEnvironment.isOpen && (
- closeNewEnvironment(e)}
- data-testid="closeNewTab"
- >
-
-
- }
- iconPosition="end"
- />
- )}
-
- );
-};
diff --git a/src/features/tabs/components/index.tsx b/src/features/tabs/components/index.tsx
deleted file mode 100644
index 676a8ad0..00000000
--- a/src/features/tabs/components/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./PageTabs";
diff --git a/src/features/tabs/index.tsx b/src/features/tabs/index.tsx
index 749a2190..99beef53 100644
--- a/src/features/tabs/index.tsx
+++ b/src/features/tabs/index.tsx
@@ -1,2 +1 @@
-export * from "./components";
export * from "./tabsSlice";
diff --git a/src/features/tabs/tabsSlice.ts b/src/features/tabs/tabsSlice.ts
index 924722df..f28b4cdb 100644
--- a/src/features/tabs/tabsSlice.ts
+++ b/src/features/tabs/tabsSlice.ts
@@ -32,13 +32,11 @@ export const tabsSlice = createSlice({
state,
action: PayloadAction<{
environment: Environment;
- selectedEnvironmentId: number | undefined;
canUpdate?: boolean;
}>
) => {
const environments = state.selectedEnvironments;
const openedEnvironment = action.payload.environment;
- const isSingleTabMode = process.env.REACT_APP_CONTEXT === "jupyterlab";
state.selectedEnvironment = {
...openedEnvironment,
@@ -47,11 +45,7 @@ export const tabsSlice = createSlice({
state.value = openedEnvironment.id;
if (!environments.some(env => env.id === openedEnvironment.id)) {
- if (!isSingleTabMode) {
- state.selectedEnvironments.push(openedEnvironment);
- } else {
- state.selectedEnvironments[0] = openedEnvironment;
- }
+ state.selectedEnvironments[0] = openedEnvironment;
}
},
environmentClosed: (
diff --git a/src/layouts/PageLayout.tsx b/src/layouts/PageLayout.tsx
index f62c3b01..f9ab730f 100644
--- a/src/layouts/PageLayout.tsx
+++ b/src/layouts/PageLayout.tsx
@@ -1,36 +1,27 @@
-import React, { useState, useRef } from "react";
+import React, { useState, useRef, useEffect, RefObject } from "react";
+import { Outlet, useOutletContext, useParams } from "react-router-dom";
import Box from "@mui/material/Box";
import { Typography } from "@mui/material";
import { Popup } from "../components";
import { Environments } from "../features/environments";
-import { EnvironmentCreate } from "../features/environmentCreate";
-import { EnvironmentDetails } from "../features/environmentDetails";
-import { PageTabs } from "../features/tabs";
import { StyledScrollContainer } from "../styles";
-import { useAppSelector } from "../hooks";
-
-interface IUpdateEnvironment {
- data: {
- show: boolean;
- description: string;
- };
-}
+import { useAppDispatch, useAppSelector } from "../hooks";
+import { closeNotification } from "../features/notification/notificationSlice";
export const PageLayout = () => {
- const { selectedEnvironment, newEnvironment } = useAppSelector(
- state => state.tabs
- );
+ const dispatch = useAppDispatch();
const [refreshEnvironments, setRefreshEnvironments] = useState(false);
- const [notification, setNotification] = useState({
- show: false,
- description: ""
- });
- const scrollRef = useRef(null);
+ const notification = useAppSelector(state => state.notification);
+ const scrollRef = useRef(null);
- const onUpdateOrCreateEnv = ({ data }: IUpdateEnvironment) => {
+ // TODO remove this coupling between notifications and environment refreshes.
+ // This use of state to refetch the environment is an anti-pattern. We should
+ // leverage RTK's cache invalidation features instead.
+ useEffect(() => {
setRefreshEnvironments(true);
- setNotification(data);
- };
+ }, [notification]);
+
+ const { namespaceName } = useParams();
return (
{
overflowY: "scroll"
}}
>
- {(selectedEnvironment || newEnvironment.isActive) && (
- <>
-
-
- {selectedEnvironment && !newEnvironment.isActive && (
-
-
-
- )}
-
- {!selectedEnvironment && newEnvironment.isActive && (
-
-
-
- )}
- >
- )}
- {!selectedEnvironment && !newEnvironment.isActive && (
+ {namespaceName ? (
+
+ ) : (
{
dispatch(closeNotification())}
/>
);
};
+
+// TypeScript-friendly version of the useOutletContext() hook
+export function useScrollRef() {
+ return useOutletContext>();
+}
diff --git a/src/AppExample.tsx b/src/main.tsx
similarity index 100%
rename from src/AppExample.tsx
rename to src/main.tsx
diff --git a/src/routes.tsx b/src/routes.tsx
new file mode 100644
index 00000000..c5c41db9
--- /dev/null
+++ b/src/routes.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+import { createBrowserRouter } from "react-router-dom";
+
+import { PageLayout } from "./layouts";
+import { EnvironmentDetails } from "./features/environmentDetails";
+import { EnvironmentCreate } from "./features/environmentCreate";
+
+/**
+ * Define URL routes for the single page app
+ */
+export const router = createBrowserRouter([
+ {
+ path: "/",
+ element: ,
+ children: [
+ {
+ path: ":namespaceName/new-environment",
+ element:
+ },
+ {
+ path: ":namespaceName/:environmentName",
+ element:
+ }
+ ]
+ }
+]);
diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts
index d0a427ef..241e608f 100644
--- a/src/store/rootReducer.ts
+++ b/src/store/rootReducer.ts
@@ -7,6 +7,10 @@ import { environmentVariablesSlice } from "../features/environmentVariables";
import { tabsSlice } from "../features/tabs";
import { enviromentsSlice } from "../features/metadata";
import { environmentCreateSlice } from "../features/environmentCreate/environmentCreateSlice";
+// TODO rename */reducer.ts files to match */*slice.ts pattern
+import { namespacesSlice } from "../features/namespaces/reducer";
+import { environmentsSlice } from "../features/environments/reducer";
+import { notificationSlice } from "../features/notification/notificationSlice";
export const rootReducer = {
[apiSlice.reducerPath]: apiSlice.reducer,
@@ -14,8 +18,12 @@ export const rootReducer = {
requestedPackages: requestedPackagesSlice.reducer,
environmentVariables: environmentVariablesSlice.reducer,
tabs: tabsSlice.reducer,
+ // TODO: rename (fix spelling) and consolidate with environments slice
enviroments: enviromentsSlice.reducer,
environmentDetails: environmentDetailsSlice.reducer,
dependencies: dependenciesSlice.reducer,
- environmentCreate: environmentCreateSlice.reducer
+ environmentCreate: environmentCreateSlice.reducer,
+ namespaces: namespacesSlice.reducer,
+ environments: environmentsSlice.reducer,
+ notification: notificationSlice.reducer
};
diff --git a/src/styles/StyledIconButton.tsx b/src/styles/StyledIconButton.tsx
index e4a39753..833471c0 100644
--- a/src/styles/StyledIconButton.tsx
+++ b/src/styles/StyledIconButton.tsx
@@ -23,4 +23,4 @@ export const StyledIconButton = styled(Button)(({ theme }) => ({
border: "none",
color: theme.palette.secondary[300]
}
-}));
+})) as typeof Button;
diff --git a/src/styles/StyledTab.tsx b/src/styles/StyledTab.tsx
deleted file mode 100644
index a1b81f3f..00000000
--- a/src/styles/StyledTab.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import Tab from "@mui/material/Tab";
-import { styled } from "@mui/system";
-
-export const StyledTab = styled(Tab, {
- shouldForwardProp: prop => prop !== "styleType"
-})<{ styleType?: string }>(({ theme: { palette } }) => ({
- border: "transparent",
- padding: "7px 12px",
- textTransform: "none",
- fontSize: "14px",
- minHeight: "48px",
- maxHeight: "48px",
- maxWidth: "340px",
- justifyContent: "space-between",
- "&.Mui-selected": {
- color: "#333",
- fontWeight: 400,
- border: "1px solid #E0E0E0",
- borderTop: "none",
- borderBottom: "none",
- background: "#F9F9F9"
- }
-}));
diff --git a/src/styles/StyledTabs.tsx b/src/styles/StyledTabs.tsx
deleted file mode 100644
index 7400e831..00000000
--- a/src/styles/StyledTabs.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import Tabs from "@mui/material/Tabs";
-import { styled } from "@mui/system";
-
-export const StyledTabs = styled(Tabs, {
- shouldForwardProp: prop => prop !== "styleType"
-})<{ styleType?: string }>(({ theme: { palette } }) => ({
- backgroundColor: "#FFF",
- "& .MuiTabs-indicator": {
- display: "flex",
- justifyContent: "center",
- backgroundColor: "#FFF"
- }
-}));
diff --git a/src/styles/index.tsx b/src/styles/index.tsx
index e16a568f..17e478a6 100644
--- a/src/styles/index.tsx
+++ b/src/styles/index.tsx
@@ -7,8 +7,6 @@ export * from "./StyledRequestedPackagesTableCell";
export * from "./StyledIconButton";
export * from "./StyledAccordionExpandIcon";
export * from "./StyledScrollContainer";
-export * from "./StyledTab";
-export * from "./StyledTabs";
export * from "./StyledBox";
export * from "./StyledMetadataItem";
export * from "./StyledSwitchButton";
diff --git a/src/utils/helpers/namespaces.ts b/src/utils/helpers/namespaces.ts
index 5b08a3bf..12fd79ff 100644
--- a/src/utils/helpers/namespaces.ts
+++ b/src/utils/helpers/namespaces.ts
@@ -143,12 +143,16 @@ export const namespacesPermissionsMapper = (
return {
id: namespace.id,
name: namespace.name,
- canCreate: allPermissions
- ? true
- : namespacePermissions.includes(PERMISSIONS.namespaceCreate),
- canUpdate: allPermissions
- ? true
- : namespacePermissions.includes(PERMISSIONS.environmentUpdate)
+ canCreate: Boolean(
+ allPermissions
+ ? true
+ : namespacePermissions.includes(PERMISSIONS.namespaceCreate)
+ ),
+ canUpdate: Boolean(
+ allPermissions
+ ? true
+ : namespacePermissions.includes(PERMISSIONS.environmentUpdate)
+ )
};
});
};
diff --git a/test/PageTabs.test.tsx b/test/PageTabs.test.tsx
deleted file mode 100644
index a525ccef..00000000
--- a/test/PageTabs.test.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import React from "react";
-import { Provider } from "react-redux";
-import { act, fireEvent, render, RenderResult } from "@testing-library/react";
-import {
- environmentOpened,
- openCreateNewEnvironmentTab,
- PageTabs
-} from "../src/features/tabs";
-import { store } from "../src/store";
-import { ENVIRONMENTS } from "./testutils";
-
-describe("", () => {
- let component: RenderResult;
-
- beforeEach(() => {
- component = render(
-
-
-
- );
- });
-
- it("should render component with default tab", () => {
- act(() => {
- store.dispatch(
- environmentOpened({
- environment: ENVIRONMENTS[0],
- selectedEnvironmentId: 1
- })
- );
- });
-
- expect(component.container).toHaveTextContent("python-flask-env-2");
- const closeIcon = component.getByTestId("closeTab");
- fireEvent.click(closeIcon);
-
- expect(component.container).not.toHaveTextContent("python-flask-env-2");
- });
-
- it("should open create environment tab", () => {
- act(() => {
- store.dispatch(openCreateNewEnvironmentTab("default"));
- });
- expect(component.container).toHaveTextContent("Create Environment");
-
- const closeIcon = component.getByTestId("closeNewTab");
- fireEvent.click(closeIcon);
- expect(component.container).not.toHaveTextContent("Create Environment");
- });
-
- it("should open multiple environments", () => {
- act(() => {
- store.dispatch(
- environmentOpened({
- environment: ENVIRONMENTS[0],
- selectedEnvironmentId: 1
- })
- );
- store.dispatch(
- environmentOpened({
- environment: ENVIRONMENTS[1],
- selectedEnvironmentId: 2
- })
- );
- });
- const firstTab = component.getByText(ENVIRONMENTS[0].name);
- fireEvent.click(firstTab);
-
- expect(component.container).toHaveTextContent(
- `${ENVIRONMENTS[0].name}${ENVIRONMENTS[1].name}`
- );
-
- const [envTabCloseIcon] = component.getAllByTestId("closeTab");
- fireEvent.click(envTabCloseIcon);
- });
-
- it("should maintain the new environment tab openĀ ", () => {
- act(() => {
- store.dispatch(openCreateNewEnvironmentTab("default"));
- });
-
- const envTab = component.getByText(ENVIRONMENTS[1].name);
- fireEvent.click(envTab);
-
- const newEnvTab = component.getByText("Create Environment");
- fireEvent.click(newEnvTab);
-
- const envTabCloseIcon = component.getByTestId("closeTab");
- fireEvent.click(envTabCloseIcon);
-
- expect(component.container).toHaveTextContent("Create Environment");
- });
-});
diff --git a/test/environmentCreate/EnvironmentCreate.test.tsx b/test/environmentCreate/EnvironmentCreate.test.tsx
index 16f36115..c29ca6b2 100644
--- a/test/environmentCreate/EnvironmentCreate.test.tsx
+++ b/test/environmentCreate/EnvironmentCreate.test.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import { BrowserRouter } from "react-router-dom";
import { Provider } from "react-redux";
import {
render,
@@ -17,18 +18,16 @@ import { mockTheme } from "../testutils";
describe("", () => {
window.HTMLElement.prototype.scrollTo = jest.fn();
- const mockEnvironmentNotification = jest.fn();
let component: RenderResult;
beforeEach(() => {
component = render(
mockTheme(
-
+
- )
+ ),
+ { wrapper: BrowserRouter }
);
});
diff --git a/test/environmentDetails/EnvironmentDetails.test.tsx b/test/environmentDetails/EnvironmentDetails.test.tsx
index dfb094b2..bf9303e0 100644
--- a/test/environmentDetails/EnvironmentDetails.test.tsx
+++ b/test/environmentDetails/EnvironmentDetails.test.tsx
@@ -1,5 +1,6 @@
import React from "react";
import { Provider } from "react-redux";
+import { BrowserRouter } from "react-router-dom";
import { act, fireEvent, render, waitFor } from "@testing-library/react";
import { ENVIRONMENTS, mockTheme } from "../testutils";
import {
@@ -35,16 +36,16 @@ describe("", () => {
const component = render(
mockTheme(
-
+
- )
+ ),
+ { wrapper: BrowserRouter }
);
act(() => {
store.dispatch(
environmentOpened({
- environment: ENVIRONMENTS[0],
- selectedEnvironmentId: 1
+ environment: ENVIRONMENTS[0]
})
);
});
@@ -58,16 +59,16 @@ describe("", () => {
const component = render(
mockTheme(
-
+
- )
+ ),
+ { wrapper: BrowserRouter }
);
act(() => {
store.dispatch(
environmentOpened({
- environment: ENVIRONMENTS[0],
- selectedEnvironmentId: 1
+ environment: ENVIRONMENTS[0]
})
);
store.dispatch(modeChanged(EnvironmentDetailsModes.EDIT));
diff --git a/test/environmentDetails/EnvironmentDetailsHeader.test.tsx b/test/environmentDetails/EnvironmentDetailsHeader.test.tsx
index f7b7469d..37ecab57 100644
--- a/test/environmentDetails/EnvironmentDetailsHeader.test.tsx
+++ b/test/environmentDetails/EnvironmentDetailsHeader.test.tsx
@@ -8,6 +8,7 @@ import {
} from "../../src/features/environmentDetails";
import { store } from "../../src/store";
import { mockTheme } from "../testutils";
+import { BrowserRouter } from "react-router-dom";
describe("", () => {
it("should render component in read mode", () => {
@@ -20,7 +21,8 @@ describe("", () => {
showEditButton={true}
/>
- )
+ ),
+ { wrapper: BrowserRouter }
);
act(() => {
@@ -28,9 +30,10 @@ describe("", () => {
});
expect(component.container).toHaveTextContent("Environment name");
- const editButton = component.getByText("Edit");
+ const editButton = component.getByRole("button", { name: "Edit" });
+ expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
- expect(store.getState().environmentDetails.mode).toEqual("edit");
+ expect(editButton).not.toBeInTheDocument();
});
it("should render component in edit mode", async () => {
@@ -43,7 +46,8 @@ describe("", () => {
showEditButton={true}
/>
- )
+ ),
+ { wrapper: BrowserRouter }
);
act(() => {
store.dispatch(modeChanged(EnvironmentDetailsModes.EDIT));
@@ -64,7 +68,8 @@ describe("", () => {
showEditButton={true}
/>
- )
+ ),
+ { wrapper: BrowserRouter }
);
act(() => {
store.dispatch(modeChanged(EnvironmentDetailsModes.CREATE));
@@ -89,7 +94,8 @@ describe("", () => {
showEditButton={true}
/>
- )
+ ),
+ { wrapper: BrowserRouter }
);
act(() => {
store.dispatch(modeChanged(EnvironmentDetailsModes.CREATE));
diff --git a/test/environmentDetails/SpecificationEdit.test.tsx b/test/environmentDetails/SpecificationEdit.test.tsx
index 8862b81b..ab730065 100644
--- a/test/environmentDetails/SpecificationEdit.test.tsx
+++ b/test/environmentDetails/SpecificationEdit.test.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import { BrowserRouter } from "react-router-dom";
import { Provider } from "react-redux";
import {
act,
@@ -32,7 +33,8 @@ describe("", () => {
onUpdateEnvironment={jest.fn()}
/>
- )
+ ),
+ { wrapper: BrowserRouter }
);
act(() => {
@@ -50,7 +52,8 @@ describe("", () => {
onUpdateEnvironment={jest.fn()}
/>
- )
+ ),
+ { wrapper: BrowserRouter }
);
const switchButton = component.getByLabelText("YAML", { exact: false });
fireEvent.click(switchButton);
@@ -73,7 +76,8 @@ describe("", () => {
onSpecificationIsChanged={jest.fn()}
/>
- )
+ ),
+ { wrapper: BrowserRouter }
);
const cancelButton = component.getByText("Cancel");
fireEvent.click(cancelButton);
@@ -90,7 +94,8 @@ describe("", () => {
onUpdateEnvironment={jest.fn()}
/>
- )
+ ),
+ { wrapper: BrowserRouter }
);
const switchButton = component.getByLabelText("YAML", { exact: false });
fireEvent.click(switchButton);
diff --git a/test/environments/Environment.test.tsx b/test/environments/Environment.test.tsx
index 706bcf4e..b5ac9f0f 100644
--- a/test/environments/Environment.test.tsx
+++ b/test/environments/Environment.test.tsx
@@ -1,32 +1,22 @@
import React from "react";
-import { fireEvent, render, RenderResult } from "@testing-library/react";
+import { BrowserRouter } from "react-router-dom";
+import { render, RenderResult } from "@testing-library/react";
import { Environment } from "../../src/features/environments";
import { ENVIRONMENT, mockTheme } from "../testutils";
describe("", () => {
- const mockOnClick = jest.fn();
let component: RenderResult;
beforeEach(() => {
component = render(
mockTheme(
-
- )
+
+ ),
+ { wrapper: BrowserRouter }
);
});
it("should render with correct text content", () => {
expect(component.container).toHaveTextContent(ENVIRONMENT.name);
});
-
- it("should fire onClick event correctly", () => {
- const btn = component.getByText(ENVIRONMENT.name);
- fireEvent.click(btn);
-
- expect(mockOnClick).toHaveBeenCalled();
- });
});
diff --git a/test/environments/EnvironmentDropdown.test.tsx b/test/environments/EnvironmentDropdown.test.tsx
index 9c7b0d7e..f6a0138f 100644
--- a/test/environments/EnvironmentDropdown.test.tsx
+++ b/test/environments/EnvironmentDropdown.test.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import { BrowserRouter } from "react-router-dom";
import { Provider } from "react-redux";
import { fireEvent, render } from "@testing-library/react";
import { ENVIRONMENT, mockTheme } from "../testutils";
@@ -17,21 +18,24 @@ const mountEnvironmentDropdownComponent = (props: any) => {
}}
/>
- )
+ ),
+ { wrapper: BrowserRouter }
);
};
describe("", () => {
- it("should not open a new tab environment", () => {
+ it("should not open a new environment", () => {
const component = mountEnvironmentDropdownComponent({
canCreate: false,
canUpdate: false
});
const namespaceButton = component.getByTestId("AddIcon");
+
+ expect(window.location.pathname).toBe("/");
fireEvent.click(namespaceButton);
- expect(store.getState().environmentDetails.mode).toBe("read-only");
+ expect(window.location.pathname).toBe("/");
});
- it("should open a new tab environment", () => {
+ it("should open a new environment", () => {
const component = mountEnvironmentDropdownComponent({
canCreate: true,
canUpdate: true
@@ -40,8 +44,9 @@ describe("", () => {
expect(component.container).toHaveTextContent("default");
const namespaceButton = component.getByTestId("AddIcon");
+
fireEvent.click(namespaceButton);
- expect(store.getState().environmentDetails.mode).toBe("create");
+ expect(window.location.pathname).toBe("/default/new-environment");
});
it("should open selected environment", () => {
@@ -50,9 +55,8 @@ describe("", () => {
canUpdate: true
});
const environmentButton = component.getByText(ENVIRONMENT.name);
+
fireEvent.click(environmentButton);
- expect(store.getState().tabs.selectedEnvironment?.name).toBe(
- ENVIRONMENT.name
- );
+ expect(window.location.pathname).toBe("/default/python-flask-env-2");
});
});
diff --git a/test/environments/EnvironmentList.test.tsx b/test/environments/EnvironmentList.test.tsx
index b22c5821..b287abcb 100644
--- a/test/environments/EnvironmentList.test.tsx
+++ b/test/environments/EnvironmentList.test.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import { BrowserRouter } from "react-router-dom";
import { Provider } from "react-redux";
import { render } from "@testing-library/react";
import { EnvironmentsList } from "../../src/features/environments";
@@ -31,7 +32,8 @@ describe("", () => {
search={""}
>
- )
+ ),
+ { wrapper: BrowserRouter }
);
expect(component.container).toHaveTextContent("python-flask-env");
@@ -54,7 +56,8 @@ describe("", () => {
search={""}
>
- )
+ ),
+ { wrapper: BrowserRouter }
);
expect(component.container).toHaveTextContent("Shared Environments");
diff --git a/test/environments/Environments.test.tsx b/test/environments/Environments.test.tsx
index d8101f0a..dcfb067c 100644
--- a/test/environments/Environments.test.tsx
+++ b/test/environments/Environments.test.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import { BrowserRouter } from "react-router-dom";
import { Provider } from "react-redux";
import {
fireEvent,
@@ -70,7 +71,8 @@ describe("", () => {
onUpdateRefreshEnvironments={mockOnUpdateRefreshEnvironments}
/>
- )
+ ),
+ { wrapper: BrowserRouter }
);
await waitFor(() => {
expect(component.queryByText("filesystem")).toBeInTheDocument();
@@ -91,7 +93,10 @@ describe("", () => {
const searchInput = component.getByPlaceholderText(
"Search for environment"
);
- fireEvent.change(searchInput, { target: { value: "python-flask-env-2" } });
+
+ fireEvent.change(searchInput, {
+ target: { value: "python-flask-env-2" }
+ });
await waitFor(() => {
expect(screen.getByText("python-flask-env-2")).not.toBeNull();
diff --git a/test/playwright/test_ux.py b/test/playwright/test_ux.py
index 765c7953..4df6785d 100644
--- a/test/playwright/test_ux.py
+++ b/test/playwright/test_ux.py
@@ -119,20 +119,6 @@ def _create_new_environment(page, screenshot=False):
return new_env_name
-def _close_environment_tabs(page):
- """Close any open tabs in the UI. This will continue closing tabs
- until no tabs remain open.
-
- Paramaters
- ----------
- page: playwright.Page
- page object for the current test being run
- """
- close_tab = page.get_by_test_id("closeTab")
- while close_tab.count() > 0:
- close_tab.first.click()
-
-
def _existing_environment_interactions(
page, env_name, time_to_build_env=3 * 60 * 1000, screenshot=False
):
@@ -156,9 +142,12 @@ def _existing_environment_interactions(
grab screenshots
"""
+ env_link = page.get_by_role("link", name=env_name)
+ edit_button = page.get_by_role("button", name="Edit")
+
# edit existing environment throught the YAML editor
- page.get_by_role("button", name=env_name).click()
- page.get_by_role("button", name="Edit").click()
+ env_link.click()
+ edit_button.click()
page.get_by_label("YAML").check()
if screenshot:
page.screenshot(path="test-results/conda-store-yaml-editor.png")
@@ -169,13 +158,15 @@ def _existing_environment_interactions(
"channels:\n - conda-forge\ndependencies:\n - rich\n - python\n - pip:\n - nothing\n - ipykernel\n\n"
)
page.get_by_role("button", name="Save").click()
+ edit_button.wait_for(state="attached")
+
# wait until the status is `Completed`
completed = page.get_by_text("Completed", exact=False)
completed.wait_for(state="attached", timeout=time_to_build_env)
# ensure the namespace is expanded
try:
- expect(page.get_by_role("button", name=env_name)).to_be_visible()
+ expect(env_link).to_be_visible()
except Exception as e:
# click to expand the `username` name space (but not click the +)
page.get_by_role(
@@ -183,8 +174,8 @@ def _existing_environment_interactions(
).click()
# edit existing environment
- page.get_by_role("button", name=env_name).click()
- page.get_by_role("button", name="Edit").click()
+ env_link.click()
+ edit_button.click()
# page.get_by_placeholder("Enter here the description of your environment").click()
# change the description
page.get_by_placeholder("Enter here the description of your environment").fill(
@@ -221,22 +212,22 @@ def _existing_environment_interactions(
page.get_by_label("Enter channel").press("Enter")
# click save to start the new env build
page.get_by_role("button", name="Save").click()
+ edit_button.wait_for(state="attached")
# wait until the status is `Completed`
completed = page.get_by_text("Completed", exact=False)
completed.wait_for(state="attached", timeout=time_to_build_env)
# Edit -> Cancel editing
- page.get_by_role("button", name=env_name).click()
- page.get_by_role("button", name="Edit").click()
+ edit_button.click()
page.get_by_role("button", name="Cancel").click()
# Edit -> Delete environment
- page.get_by_role("button", name="Edit").click()
+ edit_button.click()
page.get_by_text("Delete environment").click()
page.get_by_role("button", name="Delete").click()
- expect(page.get_by_role("button", name=env_name)).not_to_be_visible()
+ expect(env_link).not_to_be_visible()
def test_integration(page: Page, test_config, screenshot):
@@ -288,9 +279,6 @@ def test_integration(page: Page, test_config, screenshot):
# create a new environment
env_name = _create_new_environment(page, screenshot=screenshot)
- # close any open tabs on the conda-store ui
- _close_environment_tabs(page)
-
# interact with an existing environment
_existing_environment_interactions(page, env_name, screenshot=screenshot)
@@ -323,9 +311,6 @@ def test_integration(page: Page, test_config, screenshot):
# create a new environment
env_name = _create_new_environment(page, screenshot=screenshot)
- # close any open tabs on the conda-store ui
- _close_environment_tabs(page)
-
# interact with an existing environment
_existing_environment_interactions(page, env_name, screenshot=screenshot)
diff --git a/test/requestedPackages/AddRequestedPackage.test.tsx b/test/requestedPackages/AddRequestedPackage.test.tsx
index bd1e8dcd..f9d32db0 100644
--- a/test/requestedPackages/AddRequestedPackage.test.tsx
+++ b/test/requestedPackages/AddRequestedPackage.test.tsx
@@ -1,6 +1,7 @@
import React from "react";
import { Provider } from "react-redux";
import { fireEvent, render, RenderResult } from "@testing-library/react";
+import { mockTheme } from "../testutils";
import { AddRequestedPackage } from "../../src/features/requestedPackages/components/AddRequestedPackage";
import { store } from "../../src/store";
@@ -11,13 +12,15 @@ describe("", () => {
beforeEach(() => {
component = render(
-
-
-
+ mockTheme(
+
+
+
+ )
);
});
diff --git a/webpack.config.js b/webpack.config.js
index a73ad539..0e7fe2f0 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -28,16 +28,17 @@ const isProd = process.env.NODE_ENV === "production";
const basicConfig = {
devServer: {
- port: 8000
+ port: 8000,
},
devtool: isProd ? false : "source-map",
- entry: ["src/index.tsx", "src/AppExample.tsx"],
+ entry: ["src/index.tsx", "src/main.tsx"],
watch: false,
...getContext(__dirname),
output: {
path: path.resolve(__dirname, "dist"),
- filename: "[name].js"
+ filename: "[name].js",
+ publicPath: "/"
},
module: {