-
-
+ return (
+
+ cleanup={machineActions.cleanup}
+ errors={errors}
+ initialValues={{}}
+ onCancel={clearSidePanelContent}
+ onSaveAnalytics={{
+ action: `Change storage layout${
+ selectedLayout ? ` to ${selectedLayout?.sentenceLabel}` : ""
+ }`,
+ category: "Machine storage",
+ label: "Change storage layout",
+ }}
+ onSubmit={() => {
+ dispatch(machineActions.cleanup());
+ dispatch(
+ machineActions.applyStorageLayout({
+ systemId,
+ storageLayout: selectedLayout.value,
+ })
+ );
+ }}
+ saved={saved}
+ saving={saving}
+ submitAppearance="negative"
+ submitLabel="Change storage layout"
+ >
+
+
+
+
+
+
+
+ Are you sure you want to change the storage layout to{" "}
+ {selectedLayout.sentenceLabel}?
+
+
+ Any changes done already will be lost.
+
+ {selectedLayout.value === StorageLayout.BLANK && (
+ <>
+ Used disks will be returned to available, and any volume groups,
+ raid sets, caches, and filesystems removed.
+
+ >
+ )}
+ {isVMWareLayout(selectedLayout.value) && (
+ <>
+ This layout allows only for the deployment of{" "}
+ VMware ESXi images.
+
+ >
+ )}
+ The storage layout will be applied to a node when it is deployed.
-
-
-
- Are you sure you want to change the storage layout to{" "}
- {selectedLayout.sentenceLabel}?
-
-
- Any changes done already will be lost.
-
- {selectedLayout.value === StorageLayout.BLANK && (
- <>
- Used disks will be returned to available, and any volume
- groups, raid sets, caches, and filesystems removed.
-
- >
- )}
- {isVMWareLayout(selectedLayout.value) && (
- <>
- This layout allows only for the deployment of{" "}
- VMware ESXi images.
-
- >
- )}
- The storage layout will be applied to a node when it is deployed.
-
-
-
-
+
+
);
};
diff --git a/src/app/machines/views/MachineDetails/MachineStorage/ChangeStorageLayoutMenu/ChangeStorageLayoutMenu.test.tsx b/src/app/machines/views/MachineDetails/MachineStorage/ChangeStorageLayoutMenu/ChangeStorageLayoutMenu.test.tsx
new file mode 100644
index 0000000000..41863ff371
--- /dev/null
+++ b/src/app/machines/views/MachineDetails/MachineStorage/ChangeStorageLayoutMenu/ChangeStorageLayoutMenu.test.tsx
@@ -0,0 +1,57 @@
+import configureStore from "redux-mock-store";
+
+import ChangeStorageLayoutMenu, {
+ storageLayoutOptions,
+} from "./ChangeStorageLayoutMenu";
+
+import type { RootState } from "@/app/store/root/types";
+import {
+ machineDetails as machineDetailsFactory,
+ machineState as machineStateFactory,
+ machineStatus as machineStatusFactory,
+ machineStatuses as machineStatusesFactory,
+ rootState as rootStateFactory,
+} from "@/testing/factories";
+import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils";
+
+const mockStore = configureStore();
+
+let state: RootState;
+
+beforeAll(() => {
+ state = rootStateFactory({
+ machine: machineStateFactory({
+ items: [machineDetailsFactory({ system_id: "abc123" })],
+ statuses: machineStatusesFactory({
+ abc123: machineStatusFactory(),
+ }),
+ }),
+ });
+});
+
+it("renders", () => {
+ const store = mockStore(state);
+ renderWithBrowserRouter(, {
+ store,
+ });
+
+ expect(
+ screen.getByRole("button", { name: "Change storage layout" })
+ ).toBeInTheDocument();
+});
+
+it("displays sub options when clicked", async () => {
+ const store = mockStore(state);
+ const testStorageOptions = storageLayoutOptions[0];
+ renderWithBrowserRouter(, {
+ store,
+ });
+
+ const storageBtn = screen.getByRole("button", {
+ name: "Change storage layout",
+ });
+ await userEvent.click(storageBtn);
+ testStorageOptions.forEach((option) => {
+ expect(screen.getByRole("button", { name: option.label }));
+ });
+});
diff --git a/src/app/machines/views/MachineDetails/MachineStorage/ChangeStorageLayoutMenu/ChangeStorageLayoutMenu.tsx b/src/app/machines/views/MachineDetails/MachineStorage/ChangeStorageLayoutMenu/ChangeStorageLayoutMenu.tsx
new file mode 100644
index 0000000000..0efe769025
--- /dev/null
+++ b/src/app/machines/views/MachineDetails/MachineStorage/ChangeStorageLayoutMenu/ChangeStorageLayoutMenu.tsx
@@ -0,0 +1,70 @@
+import { ContextualMenu } from "@canonical/react-components";
+
+import { useSidePanel } from "@/app/base/side-panel-context";
+import { MachineSidePanelViews } from "@/app/machines/constants";
+import type { Machine, StorageLayoutOption } from "@/app/store/machine/types";
+import { StorageLayout } from "@/app/store/types/enum";
+
+// TODO: Once the API returns a list of allowed storage layouts for a given
+// machine we should either filter this list, or add a boolean e.g. "allowable"
+// to each layout.
+// https://github.com/canonical/maas-ui/issues/3258
+export const storageLayoutOptions: StorageLayoutOption[][] = [
+ [
+ { label: "Flat", sentenceLabel: "flat", value: StorageLayout.FLAT },
+ { label: "LVM", sentenceLabel: "LVM", value: StorageLayout.LVM },
+ { label: "bcache", sentenceLabel: "bcache", value: StorageLayout.BCACHE },
+ { label: "Custom", sentenceLabel: "custom", value: StorageLayout.CUSTOM },
+ ],
+ [
+ {
+ label: "VMFS6",
+ sentenceLabel: "VMFS6",
+ value: StorageLayout.VMFS6,
+ },
+ {
+ label: "VMFS7",
+ sentenceLabel: "VMFS7",
+ value: StorageLayout.VMFS7,
+ },
+ ],
+ [
+ {
+ label: "No storage (blank) layout",
+ sentenceLabel: "blank",
+ value: StorageLayout.BLANK,
+ },
+ ],
+];
+
+type Props = {
+ systemId: Machine["system_id"];
+};
+
+const ChangeStorageLayoutMenu = ({ systemId }: Props) => {
+ const { setSidePanelContent } = useSidePanel();
+ return (
+
+
+ group.map((option) => ({
+ children: option.label,
+ onClick: () =>
+ setSidePanelContent({
+ view: MachineSidePanelViews.CHANGE_STORAGE_LAYOUT,
+ extras: {
+ systemId,
+ selectedLayout: option,
+ },
+ }),
+ }))
+ )}
+ position="right"
+ toggleLabel="Change storage layout"
+ />
+
+ );
+};
+
+export default ChangeStorageLayoutMenu;
diff --git a/src/app/machines/views/MachineDetails/MachineStorage/ChangeStorageLayoutMenu/index.ts b/src/app/machines/views/MachineDetails/MachineStorage/ChangeStorageLayoutMenu/index.ts
new file mode 100644
index 0000000000..f132979b64
--- /dev/null
+++ b/src/app/machines/views/MachineDetails/MachineStorage/ChangeStorageLayoutMenu/index.ts
@@ -0,0 +1 @@
+export { default } from "./ChangeStorageLayoutMenu";
diff --git a/src/app/machines/views/MachineDetails/MachineStorage/MachineStorage.test.tsx b/src/app/machines/views/MachineDetails/MachineStorage/MachineStorage.test.tsx
index aeec58ac11..1fe3f38504 100644
--- a/src/app/machines/views/MachineDetails/MachineStorage/MachineStorage.test.tsx
+++ b/src/app/machines/views/MachineDetails/MachineStorage/MachineStorage.test.tsx
@@ -1,6 +1,6 @@
import { Route, Routes } from "react-router-dom-v5-compat";
-import { storageLayoutOptions } from "./ChangeStorageLayout/ChangeStorageLayout";
+import { storageLayoutOptions } from "./ChangeStorageLayoutMenu/ChangeStorageLayoutMenu";
import MachineStorage from "./MachineStorage";
import * as hooks from "@/app/base/hooks/analytics";
diff --git a/src/app/machines/views/MachineDetails/MachineStorage/MachineStorage.tsx b/src/app/machines/views/MachineDetails/MachineStorage/MachineStorage.tsx
index 023c81c984..123bcda1fe 100644
--- a/src/app/machines/views/MachineDetails/MachineStorage/MachineStorage.tsx
+++ b/src/app/machines/views/MachineDetails/MachineStorage/MachineStorage.tsx
@@ -3,7 +3,7 @@ import { Spinner, Strip } from "@canonical/react-components";
import { useSelector } from "react-redux";
import { Link } from "react-router-dom-v5-compat";
-import ChangeStorageLayout from "./ChangeStorageLayout";
+import ChangeStorageLayoutMenu from "./ChangeStorageLayoutMenu";
import StorageTables from "@/app/base/components/node/StorageTables";
import docsUrls from "@/app/base/docsUrls";
@@ -29,7 +29,7 @@ const MachineStorage = (): JSX.Element => {
if (isId(id) && isMachineDetails(machine)) {
return (
<>
- {canEditStorage && }
+ {canEditStorage && }
diff --git a/src/app/pools/components/PoolDeleteForm/PoolDeleteForm.test.tsx b/src/app/pools/components/PoolDeleteForm/PoolDeleteForm.test.tsx
new file mode 100644
index 0000000000..dc2a239f52
--- /dev/null
+++ b/src/app/pools/components/PoolDeleteForm/PoolDeleteForm.test.tsx
@@ -0,0 +1,11 @@
+import PoolDeleteForm from "./PoolDeleteForm";
+
+import { renderWithBrowserRouter, screen } from "@/testing/utils";
+
+it("renders", () => {
+ renderWithBrowserRouter();
+
+ expect(
+ screen.getByRole("form", { name: /Confirm pool deletion/i })
+ ).toBeInTheDocument();
+});
diff --git a/src/app/pools/components/PoolDeleteForm/PoolDeleteForm.tsx b/src/app/pools/components/PoolDeleteForm/PoolDeleteForm.tsx
new file mode 100644
index 0000000000..22fd25f8a5
--- /dev/null
+++ b/src/app/pools/components/PoolDeleteForm/PoolDeleteForm.tsx
@@ -0,0 +1,44 @@
+import { useOnEscapePressed } from "@canonical/react-components";
+import { useDispatch, useSelector } from "react-redux";
+import { useNavigate } from "react-router-dom-v5-compat";
+
+import ModelDeleteForm from "@/app/base/components/ModelDeleteForm";
+import { useAddMessage } from "@/app/base/hooks";
+import urls from "@/app/base/urls";
+import { actions as resourcePoolActions } from "@/app/store/resourcepool";
+import resourcePoolSelectors from "@/app/store/resourcepool/selectors";
+import type { RootState } from "@/app/store/root/types";
+
+const PoolDeleteForm = ({ id }: { id: number }) => {
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+ const pool = useSelector((state: RootState) =>
+ resourcePoolSelectors.getById(state, id)
+ );
+ const saved = useSelector(resourcePoolSelectors.saved);
+ const saving = useSelector(resourcePoolSelectors.saving);
+ const onCancel = () => navigate({ pathname: urls.pools.index });
+ useOnEscapePressed(() => onCancel());
+ useAddMessage(
+ saved,
+ resourcePoolActions.cleanup,
+ `${pool?.name} removed successfully.`
+ );
+
+ return (
+ {
+ dispatch(resourcePoolActions.delete(id));
+ }}
+ saved={saved}
+ savedRedirect={urls.pools.index}
+ saving={saving}
+ />
+ );
+};
+
+export default PoolDeleteForm;
diff --git a/src/app/pools/components/PoolDeleteForm/index.ts b/src/app/pools/components/PoolDeleteForm/index.ts
new file mode 100644
index 0000000000..aa5a655eea
--- /dev/null
+++ b/src/app/pools/components/PoolDeleteForm/index.ts
@@ -0,0 +1 @@
+export { default } from "./PoolDeleteForm";
diff --git a/src/app/pools/components/PoolForm/PoolForm.tsx b/src/app/pools/components/PoolForm/PoolForm.tsx
index ee9fe01d50..45fdc4456b 100644
--- a/src/app/pools/components/PoolForm/PoolForm.tsx
+++ b/src/app/pools/components/PoolForm/PoolForm.tsx
@@ -1,13 +1,11 @@
import { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
-import { useNavigate } from "react-router-dom-v5-compat";
import * as Yup from "yup";
-import FormCard from "@/app/base/components/FormCard";
import FormikField from "@/app/base/components/FormikField";
import FormikForm from "@/app/base/components/FormikForm";
-import { useAddMessage, useWindowTitle } from "@/app/base/hooks";
+import { useAddMessage } from "@/app/base/hooks";
import urls from "@/app/base/urls";
import { actions as poolActions } from "@/app/store/resourcepool";
import poolSelectors from "@/app/store/resourcepool/selectors";
@@ -15,6 +13,7 @@ import type { ResourcePool } from "@/app/store/resourcepool/types";
type Props = {
pool?: ResourcePool | null;
+ onClose?: () => void;
};
type PoolFormValues = {
@@ -35,9 +34,8 @@ const PoolSchema = Yup.object().shape({
description: Yup.string(),
});
-export const PoolForm = ({ pool, ...props }: Props): JSX.Element => {
+export const PoolForm = ({ pool, onClose, ...props }: Props): JSX.Element => {
const dispatch = useDispatch();
- const navigate = useNavigate();
const saved = useSelector(poolSelectors.saved);
const saving = useSelector(poolSelectors.saving);
const errors = useSelector(poolSelectors.errors);
@@ -66,50 +64,46 @@ export const PoolForm = ({ pool, ...props }: Props): JSX.Element => {
};
}
- useWindowTitle(title);
-
return (
-
- navigate({ pathname: urls.pools.index })}
- onSaveAnalytics={{
- action: "Saved",
- category: "Resource pool",
- label: "Add pool form",
- }}
- onSubmit={(values) => {
- dispatch(poolActions.cleanup());
- if (pool) {
- dispatch(
- poolActions.update({
- ...values,
- id: pool.id,
- })
- );
- } else {
- dispatch(poolActions.create(values));
- }
- setSaving(values.name);
- }}
- saved={saved}
- savedRedirect={urls.pools.index}
- saving={saving}
- submitLabel={Labels.SubmitLabel}
- validationSchema={PoolSchema}
- {...props}
- >
-
-
-
-
+ {
+ dispatch(poolActions.cleanup());
+ if (pool) {
+ dispatch(
+ poolActions.update({
+ ...values,
+ id: pool.id,
+ })
+ );
+ } else {
+ dispatch(poolActions.create(values));
+ }
+ setSaving(values.name);
+ }}
+ saved={saved}
+ savedRedirect={urls.pools.index}
+ saving={saving}
+ submitLabel={Labels.SubmitLabel}
+ validationSchema={PoolSchema}
+ {...props}
+ >
+
+
+
);
};
diff --git a/src/app/pools/urls.ts b/src/app/pools/urls.ts
index 61a22dd324..9b96238709 100644
--- a/src/app/pools/urls.ts
+++ b/src/app/pools/urls.ts
@@ -4,6 +4,7 @@ import { argPath } from "@/app/utils";
const urls = {
add: "/pools/add",
edit: argPath<{ id: ResourcePool["id"] }>("/pools/:id/edit"),
+ delete: argPath<{ id: ResourcePool["id"] }>("/pools/:id/delete"),
index: "/pools",
};
diff --git a/src/app/pools/views/PoolAdd/PoolAdd.tsx b/src/app/pools/views/PoolAdd/PoolAdd.tsx
index 963f7e5e77..e3d891c075 100644
--- a/src/app/pools/views/PoolAdd/PoolAdd.tsx
+++ b/src/app/pools/views/PoolAdd/PoolAdd.tsx
@@ -1,3 +1,7 @@
+import { useOnEscapePressed } from "@canonical/react-components";
+import { useNavigate } from "react-router-dom-v5-compat";
+
+import urls from "@/app/base/urls";
import PoolForm from "@/app/pools/components/PoolForm";
export enum Label {
@@ -5,7 +9,10 @@ export enum Label {
}
export const PoolAdd = (): JSX.Element => {
- return ;
+ const navigate = useNavigate();
+ const onCancel = () => navigate({ pathname: urls.pools.index });
+ useOnEscapePressed(() => onCancel());
+ return ;
};
export default PoolAdd;
diff --git a/src/app/pools/views/PoolDelete/PoolDelete.test.tsx b/src/app/pools/views/PoolDelete/PoolDelete.test.tsx
new file mode 100644
index 0000000000..09ebe88850
--- /dev/null
+++ b/src/app/pools/views/PoolDelete/PoolDelete.test.tsx
@@ -0,0 +1,48 @@
+import configureStore from "redux-mock-store";
+
+import PoolDelete from "./PoolDelete";
+
+import { actions } from "@/app/store/resourcepool";
+import type { RootState } from "@/app/store/root/types";
+import {
+ resourcePool as resourcePoolFactory,
+ resourcePoolState as resourcePoolStateFactory,
+ rootState as rootStateFactory,
+} from "@/testing/factories";
+import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils";
+
+const mockStore = configureStore();
+let state: RootState;
+beforeEach(() => {
+ state = rootStateFactory({
+ resourcepool: resourcePoolStateFactory({
+ loaded: true,
+ items: [
+ resourcePoolFactory({ id: 1 }),
+ resourcePoolFactory({ name: "default", is_default: true }),
+ resourcePoolFactory({ name: "backup", is_default: false }),
+ ],
+ }),
+ });
+});
+
+it("can delete a resource pool", async () => {
+ const store = mockStore(state);
+
+ renderWithBrowserRouter(, {
+ store,
+ route: "/pools/1/delete",
+ routePattern: "/pools/:id/delete",
+ });
+
+ expect(
+ screen.getByRole("form", { name: /Confirm pool deletion/i })
+ ).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole("button", { name: /delete/i }));
+
+ const action = store
+ .getActions()
+ .find((action) => action.type === "resourcepool/delete");
+ expect(action).toEqual(actions.delete(1));
+});
diff --git a/src/app/pools/views/PoolDelete/PoolDelete.tsx b/src/app/pools/views/PoolDelete/PoolDelete.tsx
new file mode 100644
index 0000000000..cf17859fd2
--- /dev/null
+++ b/src/app/pools/views/PoolDelete/PoolDelete.tsx
@@ -0,0 +1,21 @@
+import { useOnEscapePressed } from "@canonical/react-components";
+import { useNavigate } from "react-router-dom-v5-compat";
+
+import { useGetURLId } from "@/app/base/hooks";
+import urls from "@/app/base/urls";
+import PoolDeleteForm from "@/app/pools/components/PoolDeleteForm";
+
+const PoolDelete = () => {
+ const id = useGetURLId("id");
+ const navigate = useNavigate();
+ const onCancel = () => navigate({ pathname: urls.pools.index });
+ useOnEscapePressed(() => onCancel());
+
+ if (!id) {
+ return Resource pool not found
;
+ }
+
+ return ;
+};
+
+export default PoolDelete;
diff --git a/src/app/pools/views/PoolDelete/index.ts b/src/app/pools/views/PoolDelete/index.ts
new file mode 100644
index 0000000000..a3fa8b57e4
--- /dev/null
+++ b/src/app/pools/views/PoolDelete/index.ts
@@ -0,0 +1 @@
+export { default } from "./PoolDelete";
diff --git a/src/app/pools/views/PoolEdit/PoolEdit.tsx b/src/app/pools/views/PoolEdit/PoolEdit.tsx
index da697c868e..ad4bfc229d 100644
--- a/src/app/pools/views/PoolEdit/PoolEdit.tsx
+++ b/src/app/pools/views/PoolEdit/PoolEdit.tsx
@@ -1,8 +1,10 @@
-import { Spinner } from "@canonical/react-components";
+import { Spinner, useOnEscapePressed } from "@canonical/react-components";
import { useSelector } from "react-redux";
+import { useNavigate } from "react-router-dom-v5-compat";
import ModelNotFound from "@/app/base/components/ModelNotFound";
import { useGetURLId } from "@/app/base/hooks/urls";
+import urls from "@/app/base/urls";
import PoolForm from "@/app/pools/components/PoolForm";
import poolURLs from "@/app/pools/urls";
import poolSelectors from "@/app/store/resourcepool/selectors";
@@ -16,6 +18,9 @@ export enum Label {
export const PoolEdit = (): JSX.Element => {
const id = useGetURLId(ResourcePoolMeta.PK);
const loading = useSelector(poolSelectors.loading);
+ const navigate = useNavigate();
+ const onCancel = () => navigate({ pathname: urls.pools.index });
+ useOnEscapePressed(() => onCancel());
const pool = useSelector((state: RootState) =>
poolSelectors.getById(state, id)
);
@@ -32,7 +37,7 @@ export const PoolEdit = (): JSX.Element => {
/>
);
}
- return ;
+ return ;
};
export default PoolEdit;
diff --git a/src/app/pools/views/PoolList/PoolList.test.tsx b/src/app/pools/views/PoolList/PoolList.test.tsx
index 2510be8263..9222392729 100644
--- a/src/app/pools/views/PoolList/PoolList.test.tsx
+++ b/src/app/pools/views/PoolList/PoolList.test.tsx
@@ -1,7 +1,5 @@
-import { Provider } from "react-redux";
import { MemoryRouter } from "react-router-dom";
import { CompatRouter } from "react-router-dom-v5-compat";
-import configureStore from "redux-mock-store";
import PoolList from "./PoolList";
@@ -12,16 +10,12 @@ import {
rootState as rootStateFactory,
} from "@/testing/factories";
import {
- userEvent,
screen,
- render,
within,
renderWithMockStore,
renderWithBrowserRouter,
} from "@/testing/utils";
-const mockStore = configureStore();
-
describe("PoolList", () => {
let state: RootState;
@@ -80,7 +74,7 @@ describe("PoolList", () => {
);
});
- it("can show a delete confirmation", async () => {
+ it("displays a link to delete confirmation", async () => {
state.resourcepool.items = [
resourcePoolFactory({
id: 0,
@@ -104,69 +98,11 @@ describe("PoolList", () => {
expect(row).not.toHaveClass("is-active");
- // Click on the delete button:
- await userEvent.click(within(row).getByRole("button", { name: "Delete" }));
-
- expect(row).toHaveClass("is-active");
expect(
- screen.getByText(
- 'Are you sure you want to delete resourcepool "squambo"?'
- )
+ within(row).getByRole("link", { name: "Delete" })
).toBeInTheDocument();
});
- it("can delete a pool", async () => {
- state.resourcepool.items = [
- resourcePoolFactory({
- id: 2,
- name: "squambo",
- description: "a pool",
- is_default: false,
- machine_total_count: 0,
- permissions: ["delete"],
- }),
- ];
- const store = mockStore(state);
-
- render(
-
-
-
-
-
-
-
- );
- const row = screen.getByRole("row", { name: "squambo" });
-
- // Click on the delete button:
- await userEvent.click(within(row).getByRole("button", { name: "Delete" }));
-
- // Click on the delete confirm button
- await userEvent.click(
- within(
- within(row).getByRole("gridcell", {
- name: 'Are you sure you want to delete resourcepool "squambo"? This action is permanent and can not be undone. Cancel Delete',
- })
- ).getByRole("button", { name: "Delete" })
- );
-
- expect(
- store.getActions().find(({ type }) => type === "resourcepool/delete")
- ).toStrictEqual({
- type: "resourcepool/delete",
- payload: {
- params: {
- id: 2,
- },
- },
- meta: {
- model: "resourcepool",
- method: "delete",
- },
- });
- });
-
it("disables the delete button for default pools", () => {
state.resourcepool.items = [
resourcePoolFactory({
@@ -185,7 +121,10 @@ describe("PoolList", () => {
,
{ state }
);
- expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
+ expect(screen.getByRole("link", { name: "Delete" })).toHaveAttribute(
+ "aria-disabled",
+ "true"
+ );
});
it("disables the delete button for pools that contain machines", () => {
@@ -207,7 +146,10 @@ describe("PoolList", () => {
,
{ state }
);
- expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
+ expect(screen.getByRole("link", { name: "Delete" })).toHaveAttribute(
+ "aria-disabled",
+ "true"
+ );
});
it("does not show a machine link for empty pools", () => {
diff --git a/src/app/pools/views/PoolList/PoolList.tsx b/src/app/pools/views/PoolList/PoolList.tsx
index 7eb46a2ae8..fe7c71c913 100644
--- a/src/app/pools/views/PoolList/PoolList.tsx
+++ b/src/app/pools/views/PoolList/PoolList.tsx
@@ -1,5 +1,3 @@
-import { useState } from "react";
-
import {
Col,
Spinner,
@@ -9,23 +7,14 @@ import {
} from "@canonical/react-components";
import { useDispatch, useSelector } from "react-redux";
import { Link } from "react-router-dom-v5-compat";
-import type { Dispatch } from "redux";
import TableActions from "@/app/base/components/TableActions";
-import TableDeleteConfirm from "@/app/base/components/TableDeleteConfirm";
-import {
- useFetchActions,
- useAddMessage,
- useWindowTitle,
-} from "@/app/base/hooks";
+import { useFetchActions, useWindowTitle } from "@/app/base/hooks";
import urls from "@/app/base/urls";
import { FilterMachines } from "@/app/store/machine/utils";
import { actions as resourcePoolActions } from "@/app/store/resourcepool";
import resourcePoolSelectors from "@/app/store/resourcepool/selectors";
-import type {
- ResourcePool,
- ResourcePoolState,
-} from "@/app/store/resourcepool/types";
+import type { ResourcePool } from "@/app/store/resourcepool/types";
import { formatErrors } from "@/app/utils";
export enum Label {
@@ -46,20 +35,10 @@ const getMachinesLabel = (row: ResourcePool) => {
);
};
-const generateRows = (
- rows: ResourcePool[],
- expandedId: ResourcePool["id"] | null,
- setExpandedId: (expandedId: ResourcePool["id"] | null) => void,
- dispatch: Dispatch,
- setDeleting: (deleting: ResourcePool["name"] | null) => void,
- saved: ResourcePoolState["saved"],
- saving: ResourcePoolState["saving"]
-) =>
+const generateRows = (rows: ResourcePool[]) =>
rows.map((row) => {
- const expanded = expandedId === row.id;
return {
"aria-label": row.name,
- className: expanded ? "p-table__row is-active" : null,
columns: [
{
content: row.name,
@@ -78,6 +57,7 @@ const generateRows = (
row.is_default ||
row.machine_total_count > 0
}
+ deletePath={urls.pools.delete({ id: row.id })}
deleteTooltip={
(row.is_default && "The default pool may not be deleted.") ||
(row.machine_total_count > 0 &&
@@ -86,28 +66,11 @@ const generateRows = (
}
editDisabled={!row.permissions.includes("edit")}
editPath={urls.pools.edit({ id: row.id })}
- onDelete={() => setExpandedId(row.id)}
/>
),
className: "u-align--right",
},
],
- expanded: expanded,
- expandedContent: expanded && (
- setExpandedId(null)}
- onConfirm={() => {
- dispatch(resourcePoolActions.delete(row.id));
- setDeleting(row.name);
- }}
- sidebar={false}
- />
- ),
key: row.name,
sortData: {
name: row.name,
@@ -121,25 +84,11 @@ const Pools = (): JSX.Element => {
useWindowTitle("Pools");
const dispatch = useDispatch();
- const [expandedId, setExpandedId] = useState(null);
- const [deletingPool, setDeleting] = useState(
- null
- );
-
const poolsLoaded = useSelector(resourcePoolSelectors.loaded);
const poolsLoading = useSelector(resourcePoolSelectors.loading);
- const saved = useSelector(resourcePoolSelectors.saved);
- const saving = useSelector(resourcePoolSelectors.saving);
const errors = useSelector(resourcePoolSelectors.errors);
const errorMessage = formatErrors(errors);
- useAddMessage(
- saved,
- resourcePoolActions.cleanup,
- `${deletingPool} removed successfully.`,
- () => setDeleting(null)
- );
-
useFetchActions([resourcePoolActions.fetch]);
const resourcePools = useSelector(resourcePoolSelectors.all);
@@ -191,15 +140,7 @@ const Pools = (): JSX.Element => {
},
]}
paginate={50}
- rows={generateRows(
- resourcePools,
- expandedId,
- setExpandedId,
- dispatch,
- setDeleting,
- saved,
- saving
- )}
+ rows={generateRows(resourcePools)}
sortable
/>
)}
diff --git a/src/app/pools/views/Pools.tsx b/src/app/pools/views/Pools.tsx
index 6a15594712..1c92e45b7f 100644
--- a/src/app/pools/views/Pools.tsx
+++ b/src/app/pools/views/Pools.tsx
@@ -4,6 +4,7 @@ import pluralize from "pluralize";
import { useSelector } from "react-redux";
import { Link, Route, Routes } from "react-router-dom-v5-compat";
+import PoolDelete from "./PoolDelete";
import PoolList from "./PoolList";
import PageContent from "@/app/base/components/PageContent";
@@ -26,40 +27,83 @@ const Pools = (): JSX.Element => {
const resourcePoolsCount = useSelector(resourcePoolSelectors.count);
+ const PoolsHeader = () => (
+
+
+ {machineCount} machines
+ in {resourcePoolsCount} {pluralize("pool", resourcePoolsCount)}
+
+
+
+
+
+ );
+
return (
-
-
- {machineCount} machines
- in {resourcePoolsCount} {pluralize("pool", resourcePoolsCount)}
-
-
-
-
-
- }
- sidePanelContent={null}
- sidePanelTitle={null}
- >
-
- }
- path={getRelativeRoute(urls.pools.index, base)}
- />
- }
- path={getRelativeRoute(urls.pools.add, base)}
- />
- }
- path={getRelativeRoute(urls.pools.edit(null), base)}
- />
- } path="*" />
-
-
+
+ }
+ sidePanelContent={null}
+ sidePanelTitle={null}
+ >
+
+
+ }
+ path={getRelativeRoute(urls.pools.index, base)}
+ />
+ }
+ sidePanelContent={}
+ sidePanelTitle="Add pool"
+ >
+
+
+ }
+ path={getRelativeRoute(urls.pools.add, base)}
+ />
+ }
+ sidePanelContent={}
+ sidePanelTitle="Edit pool"
+ >
+
+
+ }
+ path={getRelativeRoute(urls.pools.edit(null), base)}
+ />
+ }
+ sidePanelContent={}
+ sidePanelTitle="Delete pool"
+ >
+
+
+ }
+ path={getRelativeRoute(urls.pools.delete(null), base)}
+ />
+ }
+ sidePanelContent={null}
+ sidePanelTitle={null}
+ >
+
+
+ }
+ path="*"
+ />
+
);
};
diff --git a/src/app/preferences/components/Routes/Routes.tsx b/src/app/preferences/components/Routes/Routes.tsx
index 543f831aba..5aa4523990 100644
--- a/src/app/preferences/components/Routes/Routes.tsx
+++ b/src/app/preferences/components/Routes/Routes.tsx
@@ -1,12 +1,15 @@
import { Redirect } from "react-router";
import { Route, Routes as ReactRouterRoutes } from "react-router-dom-v5-compat";
+import PageContent from "@/app/base/components/PageContent";
import urls from "@/app/base/urls";
import NotFound from "@/app/base/views/NotFound";
import APIKeyAdd from "@/app/preferences/views/APIKeys/APIKeyAdd";
+import APIKeyDelete from "@/app/preferences/views/APIKeys/APIKeyDelete";
import APIKeyEdit from "@/app/preferences/views/APIKeys/APIKeyEdit";
import APIKeyList from "@/app/preferences/views/APIKeys/APIKeyList";
import Details from "@/app/preferences/views/Details";
+import { Labels as PreferenceLabels } from "@/app/preferences/views/Preferences";
import AddSSHKey from "@/app/preferences/views/SSHKeys/AddSSHKey";
import SSHKeyList from "@/app/preferences/views/SSHKeys/SSHKeyList";
import AddSSLKey from "@/app/preferences/views/SSLKeys/AddSSLKey";
@@ -19,35 +22,94 @@ const Routes = (): JSX.Element => {
} path="/" />
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.preferences.details, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.preferences.apiKeys.index, base)}
/>
}
+ element={
+ }
+ sidePanelTitle="Generate MAAS API key"
+ >
+
+
+ }
path={getRelativeRoute(urls.preferences.apiKeys.add, base)}
/>
}
+ element={
+ }
+ sidePanelTitle="Edit MAAS API key"
+ >
+
+
+ }
path={getRelativeRoute(urls.preferences.apiKeys.edit(null), base)}
/>
}
+ element={
+ }
+ sidePanelTitle="Delete MAAS API key"
+ >
+
+
+ }
+ path={getRelativeRoute(urls.preferences.apiKeys.delete(null), base)}
+ />
+
+
+
+ }
path={getRelativeRoute(urls.preferences.sshKeys.index, base)}
/>
}
+ element={
+ }
+ sidePanelTitle="Add SSH key"
+ >
+
+
+ }
path={getRelativeRoute(urls.preferences.sshKeys.add, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.preferences.sslKeys.index, base)}
/>
}
+ element={
+ }
+ sidePanelTitle="Add SSL key"
+ >
+
+
+ }
path={getRelativeRoute(urls.preferences.sslKeys.add, base)}
/>
} path="*" />
diff --git a/src/app/preferences/urls.ts b/src/app/preferences/urls.ts
index 633caf843d..ca8d9d629f 100644
--- a/src/app/preferences/urls.ts
+++ b/src/app/preferences/urls.ts
@@ -5,6 +5,7 @@ const urls = {
apiKeys: {
add: "/account/prefs/api-keys/add",
edit: argPath<{ id: Token["id"] }>("/account/prefs/api-keys/:id/edit"),
+ delete: argPath<{ id: Token["id"] }>("/account/prefs/api-keys/:id/delete"),
index: "/account/prefs/api-keys",
},
details: "/account/prefs/details",
diff --git a/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.test.tsx b/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.test.tsx
new file mode 100644
index 0000000000..c9b4642a0f
--- /dev/null
+++ b/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.test.tsx
@@ -0,0 +1,38 @@
+import APIKeyDelete from "./APIKeyDelete";
+
+import type { RootState } from "@/app/store/root/types";
+import {
+ token as tokenFactory,
+ tokenState as tokenStateFactory,
+ rootState as rootStateFactory,
+} from "@/testing/factories";
+import { renderWithBrowserRouter, screen } from "@/testing/utils";
+
+let state: RootState;
+rootStateFactory({
+ token: tokenStateFactory({
+ items: [
+ tokenFactory({
+ id: 1,
+ key: "ssh-rsa aabb",
+ consumer: { key: "abc", name: "Name" },
+ }),
+ tokenFactory({
+ id: 2,
+ key: "ssh-rsa ccdd",
+ consumer: { key: "abc", name: "Name" },
+ }),
+ ],
+ }),
+});
+
+it("renders", () => {
+ renderWithBrowserRouter(, {
+ state,
+ route: "/account/prefs/api-keys/1/delete",
+ routePattern: "/account/prefs/api-keys/:id/delete",
+ });
+ expect(
+ screen.getByRole("form", { name: "Delete API Key" })
+ ).toBeInTheDocument();
+});
diff --git a/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.tsx b/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.tsx
new file mode 100644
index 0000000000..3d2c0f7615
--- /dev/null
+++ b/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.tsx
@@ -0,0 +1,21 @@
+import { useOnEscapePressed } from "@canonical/react-components";
+import { useNavigate } from "react-router-dom-v5-compat";
+
+import { useGetURLId } from "@/app/base/hooks";
+import urls from "@/app/base/urls";
+import APIKeyDeleteForm from "@/app/preferences/views/APIKeys/APIKeyDeleteForm/APIKeyDeleteForm";
+
+const APIKeyDelete = () => {
+ const id = useGetURLId("id");
+ const navigate = useNavigate();
+ const onCancel = () => navigate({ pathname: urls.preferences.apiKeys.index });
+ useOnEscapePressed(() => onCancel());
+
+ if (!id) {
+ return API Key not found
;
+ }
+
+ return ;
+};
+
+export default APIKeyDelete;
diff --git a/src/app/preferences/views/APIKeys/APIKeyDelete/index.ts b/src/app/preferences/views/APIKeys/APIKeyDelete/index.ts
new file mode 100644
index 0000000000..4b3fdab73e
--- /dev/null
+++ b/src/app/preferences/views/APIKeys/APIKeyDelete/index.ts
@@ -0,0 +1 @@
+export { default } from "./APIKeyDelete";
diff --git a/src/app/preferences/views/APIKeys/APIKeyDeleteForm/APIKeyDeleteForm.tsx b/src/app/preferences/views/APIKeys/APIKeyDeleteForm/APIKeyDeleteForm.tsx
new file mode 100644
index 0000000000..305168877c
--- /dev/null
+++ b/src/app/preferences/views/APIKeys/APIKeyDeleteForm/APIKeyDeleteForm.tsx
@@ -0,0 +1,33 @@
+import { useDispatch, useSelector } from "react-redux";
+import { useNavigate } from "react-router-dom-v5-compat";
+
+import ModelDeleteForm from "@/app/base/components/ModelDeleteForm";
+import urls from "@/app/base/urls";
+import { actions as tokenActions } from "@/app/store/token";
+import tokenSelectors from "@/app/store/token/selectors";
+
+const APIKeyDeleteForm = ({ id }: { id: number }) => {
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+ const saved = useSelector(tokenSelectors.saved);
+ const saving = useSelector(tokenSelectors.saving);
+
+ return (
+ navigate({ pathname: urls.preferences.apiKeys.index })}
+ onSubmit={() => {
+ dispatch(tokenActions.delete(id));
+ }}
+ saved={saved}
+ savedRedirect={urls.preferences.apiKeys.index}
+ saving={saving}
+ submitAppearance="negative"
+ submitLabel="Delete"
+ />
+ );
+};
+
+export default APIKeyDeleteForm;
diff --git a/src/app/preferences/views/APIKeys/APIKeyDeleteForm/index.ts b/src/app/preferences/views/APIKeys/APIKeyDeleteForm/index.ts
new file mode 100644
index 0000000000..fa3274b35f
--- /dev/null
+++ b/src/app/preferences/views/APIKeys/APIKeyDeleteForm/index.ts
@@ -0,0 +1 @@
+export { default } from "./APIKeyDeleteForm";
diff --git a/src/app/preferences/views/APIKeys/APIKeyForm/APIKeyForm.tsx b/src/app/preferences/views/APIKeys/APIKeyForm/APIKeyForm.tsx
index b1c6263425..31e4487999 100644
--- a/src/app/preferences/views/APIKeys/APIKeyForm/APIKeyForm.tsx
+++ b/src/app/preferences/views/APIKeys/APIKeyForm/APIKeyForm.tsx
@@ -1,12 +1,11 @@
-import { Col, Row } from "@canonical/react-components";
+import { Col, Row, useOnEscapePressed } from "@canonical/react-components";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom-v5-compat";
import * as Yup from "yup";
-import FormCard from "@/app/base/components/FormCard";
import FormikField from "@/app/base/components/FormikField";
import FormikForm from "@/app/base/components/FormikForm";
-import { useAddMessage, useWindowTitle } from "@/app/base/hooks";
+import { useAddMessage } from "@/app/base/hooks";
import urls from "@/app/base/urls";
import { actions as tokenActions } from "@/app/store/token";
import tokenSelectors from "@/app/store/token/selectors";
@@ -42,9 +41,9 @@ export const APIKeyForm = ({ token }: Props): JSX.Element => {
const errors = useSelector(tokenSelectors.errors);
const saved = useSelector(tokenSelectors.saved);
const saving = useSelector(tokenSelectors.saving);
- const title = editing ? Label.EditTitle : Label.AddTitle;
+ const onCancel = () => navigate({ pathname: urls.preferences.apiKeys.index });
+ useOnEscapePressed(() => onCancel());
- useWindowTitle(title);
useAddMessage(
saved,
tokenActions.cleanup,
@@ -52,59 +51,57 @@ export const APIKeyForm = ({ token }: Props): JSX.Element => {
);
return (
-
- navigate({ pathname: urls.preferences.apiKeys.index })}
- onSaveAnalytics={{
- action: "Saved",
- category: "API keys preferences",
- label: "Generate API key form",
- }}
- onSubmit={(values) => {
- if (editing) {
- if (token) {
- dispatch(
- tokenActions.update({
- id: token.id,
- name: values.name,
- })
- );
- }
- } else {
- dispatch(tokenActions.create(values));
+ {
+ if (editing) {
+ if (token) {
+ dispatch(
+ tokenActions.update({
+ id: token.id,
+ name: values.name,
+ })
+ );
}
- }}
- saved={saved}
- savedRedirect={urls.preferences.apiKeys.index}
- saving={saving}
- submitLabel={editing ? Label.EditSubmit : Label.AddSubmit}
- validationSchema={editing ? APIKeyEditSchema : APIKeyAddSchema}
- >
-
-
-
-
-
-
- The API key is used to log in to the API from the MAAS CLI and by
- other services connecting to MAAS, such as Juju.
-
-
-
-
-
+ } else {
+ dispatch(tokenActions.create(values));
+ }
+ }}
+ saved={saved}
+ savedRedirect={urls.preferences.apiKeys.index}
+ saving={saving}
+ submitLabel={editing ? Label.EditSubmit : Label.AddSubmit}
+ validationSchema={editing ? APIKeyEditSchema : APIKeyAddSchema}
+ >
+
+
+
+
+
+
+ The API key is used to log in to the API from the MAAS CLI and by
+ other services connecting to MAAS, such as Juju.
+
+
+
+
);
};
diff --git a/src/app/preferences/views/APIKeys/APIKeyList/APIKeyList.tsx b/src/app/preferences/views/APIKeys/APIKeyList/APIKeyList.tsx
index 65f4c6aa42..59d2606b87 100644
--- a/src/app/preferences/views/APIKeys/APIKeyList/APIKeyList.tsx
+++ b/src/app/preferences/views/APIKeys/APIKeyList/APIKeyList.tsx
@@ -1,11 +1,7 @@
-import { useState } from "react";
-
import { Notification } from "@canonical/react-components";
-import { useDispatch, useSelector } from "react-redux";
-import type { Dispatch } from "redux";
+import { useSelector } from "react-redux";
import TableActions from "@/app/base/components/TableActions";
-import TableDeleteConfirm from "@/app/base/components/TableDeleteConfirm";
import {
useFetchActions,
useAddMessage,
@@ -15,28 +11,18 @@ import urls from "@/app/base/urls";
import SettingsTable from "@/app/settings/components/SettingsTable";
import { actions as tokenActions } from "@/app/store/token";
import tokenSelectors from "@/app/store/token/selectors";
-import type { Token, TokenMeta, TokenState } from "@/app/store/token/types";
+import type { Token } from "@/app/store/token/types";
export enum Label {
Title = "API keys",
EmptyList = "No API keys available.",
}
-const generateRows = (
- tokens: Token[],
- expandedId: Token[TokenMeta.PK] | null,
- setExpandedId: (id: Token[TokenMeta.PK] | null) => void,
- hideExpanded: () => void,
- dispatch: Dispatch,
- saved: TokenState["saved"],
- saving: TokenState["saving"]
-) =>
+const generateRows = (tokens: Token[]) =>
tokens.map(({ consumer, id, key, secret }) => {
const { name } = consumer;
- const expanded = expandedId === id;
const token = `${consumer.key}:${key}:${secret}`;
return {
- className: expanded ? "p-table__row is-active" : null,
columns: [
{
content: name,
@@ -49,26 +35,13 @@ const generateRows = (
content: (
setExpandedId(id)}
/>
),
className: "u-align--right",
},
],
- expanded: expanded,
- expandedContent: expanded && (
- {
- dispatch(tokenActions.delete(id));
- }}
- />
- ),
key: id,
sortData: {
name: name,
@@ -77,20 +50,11 @@ const generateRows = (
});
const APIKeyList = (): JSX.Element => {
- const [expandedId, setExpandedId] = useState(
- null
- );
const errors = useSelector(tokenSelectors.errors);
const loading = useSelector(tokenSelectors.loading);
const loaded = useSelector(tokenSelectors.loaded);
const tokens = useSelector(tokenSelectors.all);
const saved = useSelector(tokenSelectors.saved);
- const saving = useSelector(tokenSelectors.saving);
- const dispatch = useDispatch();
-
- const hideExpanded = () => {
- setExpandedId(null);
- };
useAddMessage(saved, tokenActions.cleanup, "API key deleted successfully.");
@@ -129,15 +93,7 @@ const APIKeyList = (): JSX.Element => {
]}
loaded={loaded}
loading={loading}
- rows={generateRows(
- tokens,
- expandedId,
- setExpandedId,
- hideExpanded,
- dispatch,
- saved,
- saving
- )}
+ rows={generateRows(tokens)}
tableClassName="apikey-list"
/>
>
diff --git a/src/app/preferences/views/Preferences.test.tsx b/src/app/preferences/views/Preferences.test.tsx
deleted file mode 100644
index aafe1d0295..0000000000
--- a/src/app/preferences/views/Preferences.test.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import Preferences, { Labels as PreferencesLabels } from "./Preferences";
-
-import { screen, renderWithBrowserRouter, getTestState } from "@/testing/utils";
-
-describe("Preferences", () => {
- it("renders", () => {
- const state = getTestState();
- renderWithBrowserRouter(, { route: "/preferences", state });
-
- expect(screen.getByLabelText(PreferencesLabels.Title)).toBeInTheDocument();
- });
-});
diff --git a/src/app/preferences/views/Preferences.tsx b/src/app/preferences/views/Preferences.tsx
index 19318af1bf..764ccc606b 100644
--- a/src/app/preferences/views/Preferences.tsx
+++ b/src/app/preferences/views/Preferences.tsx
@@ -1,18 +1,9 @@
-import PageContent from "@/app/base/components/PageContent/PageContent";
import Routes from "@/app/preferences/components/Routes";
export enum Labels {
Title = "My preferences",
}
-const Preferences = (): JSX.Element => (
-
-
-
-);
+const Preferences = (): JSX.Element => ;
export default Preferences;
diff --git a/src/app/preferences/views/SSHKeys/AddSSHKey/AddSSHKey.tsx b/src/app/preferences/views/SSHKeys/AddSSHKey/AddSSHKey.tsx
index 617ac0f7c7..b5b489b3e5 100644
--- a/src/app/preferences/views/SSHKeys/AddSSHKey/AddSSHKey.tsx
+++ b/src/app/preferences/views/SSHKeys/AddSSHKey/AddSSHKey.tsx
@@ -1,9 +1,7 @@
+import { useOnEscapePressed } from "@canonical/react-components";
import { useNavigate } from "react-router-dom-v5-compat";
-import FormCard from "@/app/base/components/FormCard";
import SSHKeyForm from "@/app/base/components/SSHKeyForm";
-import { COL_SIZES } from "@/app/base/constants";
-import { useWindowTitle } from "@/app/base/hooks";
import urls from "@/app/base/urls";
export enum Label {
@@ -11,26 +9,23 @@ export enum Label {
FormLabel = "Add SSH key form",
}
-const { CARD_TITLE, SIDEBAR, TOTAL } = COL_SIZES;
-
export const AddSSHKey = (): JSX.Element => {
const navigate = useNavigate();
- useWindowTitle(Label.Title);
+ const onCancel = () => navigate({ pathname: urls.preferences.sshKeys.index });
+ useOnEscapePressed(() => onCancel());
return (
-
- navigate({ pathname: urls.preferences.sshKeys.index })}
- onSaveAnalytics={{
- action: "Saved",
- category: "SSH keys preferences",
- label: "Import SSH key form",
- }}
- savedRedirect={urls.preferences.sshKeys.index}
- />
-
+
);
};
diff --git a/src/app/preferences/views/SSLKeys/AddSSLKey/AddSSLKey.tsx b/src/app/preferences/views/SSLKeys/AddSSLKey/AddSSLKey.tsx
index 9874b6a2fa..40adc27d8a 100644
--- a/src/app/preferences/views/SSLKeys/AddSSLKey/AddSSLKey.tsx
+++ b/src/app/preferences/views/SSLKeys/AddSSLKey/AddSSLKey.tsx
@@ -1,13 +1,17 @@
-import { Col, Row, Textarea } from "@canonical/react-components";
+import {
+ Col,
+ Row,
+ Textarea,
+ useOnEscapePressed,
+} from "@canonical/react-components";
import type { TextareaProps } from "@canonical/react-components";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom-v5-compat";
import * as Yup from "yup";
-import FormCard from "@/app/base/components/FormCard";
import FormikField from "@/app/base/components/FormikField";
import FormikForm from "@/app/base/components/FormikForm";
-import { useAddMessage, useWindowTitle } from "@/app/base/hooks";
+import { useAddMessage } from "@/app/base/hooks";
import urls from "@/app/base/urls";
import { actions as sslkeyActions } from "@/app/store/sslkey";
import sslkeySelectors from "@/app/store/sslkey/selectors";
@@ -35,55 +39,53 @@ export const AddSSLKey = (): JSX.Element => {
const saving = useSelector(sslkeySelectors.saving);
const saved = useSelector(sslkeySelectors.saved);
const errors = useSelector(sslkeySelectors.errors);
-
- useWindowTitle(Label.Title);
+ const onCancel = () => navigate({ pathname: urls.preferences.sslKeys.index });
+ useOnEscapePressed(() => onCancel());
useAddMessage(saved, sslkeyActions.cleanup, "SSL key successfully added.");
return (
-
- navigate({ pathname: urls.preferences.sslKeys.index })}
- onSaveAnalytics={{
- action: "Saved",
- category: "SSL keys preferences",
- label: "Add SSL key form",
- }}
- onSubmit={(values) => {
- dispatch(sslkeyActions.create(values));
- }}
- saved={saved}
- savedRedirect={urls.preferences.sslKeys.index}
- saving={saving}
- submitLabel={Label.SubmitLabel}
- validationSchema={SSLKeySchema}
- >
-
-
-
-
-
-
- You will be able to access Windows winrm service with a registered
- key.
-
-
-
-
-
+ {
+ dispatch(sslkeyActions.create(values));
+ }}
+ saved={saved}
+ savedRedirect={urls.preferences.sslKeys.index}
+ saving={saving}
+ submitLabel={Label.SubmitLabel}
+ validationSchema={SSLKeySchema}
+ >
+
+
+
+
+
+
+ You will be able to access Windows winrm service with a registered
+ key.
+
+
+
+
);
};
diff --git a/src/app/settings/components/Routes/Routes.test.tsx b/src/app/settings/components/Routes/Routes.test.tsx
index 8d373109ed..7015605571 100644
--- a/src/app/settings/components/Routes/Routes.test.tsx
+++ b/src/app/settings/components/Routes/Routes.test.tsx
@@ -89,29 +89,10 @@ const routes = [
title: "Users",
path: urls.settings.users.index,
},
- {
- title: "Add user",
- path: urls.settings.users.add,
- },
- {
- title: `Editing \`${user.username}\``,
- path: urls.settings.users.edit({ id: user.id }),
- },
{
title: "License keys",
path: urls.settings.licenseKeys.index,
},
- {
- title: "Add license key",
- path: urls.settings.licenseKeys.add,
- },
- {
- title: "Update license key",
- path: urls.settings.licenseKeys.edit({
- osystem: licensekey.osystem,
- distro_series: licensekey.distro_series,
- }),
- },
{
title: "Storage",
path: urls.settings.storage,
@@ -140,45 +121,18 @@ const routes = [
title: "Commissioning scripts",
path: urls.settings.scripts.commissioning.index,
},
- {
- title: "Upload commissioning script",
- path: urls.settings.scripts.commissioning.upload,
- },
{
title: "Testing scripts",
path: urls.settings.scripts.testing.index,
},
- {
- title: "Upload testing script",
- path: urls.settings.scripts.testing.upload,
- },
{
title: "DHCP snippets",
path: urls.settings.dhcp.index,
},
- {
- title: "Add DHCP snippet",
- path: urls.settings.dhcp.add,
- },
- {
- title: `Editing \`${dhcpSnippet.name}\``,
- path: urls.settings.dhcp.edit({ id: dhcpSnippet.id }),
- },
{
title: "Package repos",
path: urls.settings.repositories.index,
},
- {
- title: "Add PPA",
- path: urls.settings.repositories.add({ type: "ppa" }),
- },
- {
- title: "Edit PPA",
- path: urls.settings.repositories.edit({
- id: packageRepository.id,
- type: "ppa",
- }),
- },
{
title: "Windows",
path: urls.settings.images.windows,
diff --git a/src/app/settings/components/Routes/Routes.tsx b/src/app/settings/components/Routes/Routes.tsx
index b5b36541a5..19fa409e5a 100644
--- a/src/app/settings/components/Routes/Routes.tsx
+++ b/src/app/settings/components/Routes/Routes.tsx
@@ -1,6 +1,7 @@
import { Redirect } from "react-router-dom";
import { Route, Routes as ReactRouterRoutes } from "react-router-dom-v5-compat";
+import PageContent from "@/app/base/components/PageContent";
import urls from "@/app/base/urls";
import NotFound from "@/app/base/views/NotFound";
import Commissioning from "@/app/settings/views/Configuration/Commissioning";
@@ -32,6 +33,7 @@ import SecurityProtocols from "@/app/settings/views/Security/SecurityProtocols";
import SessionTimeout from "@/app/settings/views/Security/SessionTimeout";
import StorageForm from "@/app/settings/views/Storage/StorageForm";
import UserAdd from "@/app/settings/views/Users/UserAdd";
+import UserDelete from "@/app/settings/views/Users/UserDelete";
import UserEdit from "@/app/settings/views/Users/UserEdit";
import UsersList from "@/app/settings/views/Users/UsersList";
import { getRelativeRoute } from "@/app/utils";
@@ -41,22 +43,38 @@ const Routes = (): JSX.Element => {
return (
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.configuration.general, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.configuration.commissioning, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(
urls.settings.configuration.kernelParameters,
base
)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.configuration.deploy, base)}
/>
{
path={getRelativeRoute(urls.settings.configuration.index, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.security.securityProtocols, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.security.secretStorage, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.security.sessionTimeout, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.security.ipmiSettings, base)}
/>
{
path={getRelativeRoute(urls.settings.security.index, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.users.index, base)}
/>
}
+ element={
+ } sidePanelTitle="Add User">
+
+
+ }
path={getRelativeRoute(urls.settings.users.add, base)}
/>
}
+ element={
+ }
+ sidePanelTitle="Edit User"
+ >
+
+
+ }
path={getRelativeRoute(urls.settings.users.edit(null), base)}
/>
}
+ element={
+ }
+ sidePanelTitle="Delete User"
+ >
+
+
+ }
+ path={getRelativeRoute(urls.settings.users.delete(null), base)}
+ />
+
+
+
+ }
path={getRelativeRoute(urls.settings.licenseKeys.index, base)}
/>
}
+ element={
+ }
+ sidePanelTitle="Add license key"
+ >
+
+
+ }
path={getRelativeRoute(urls.settings.licenseKeys.add, base)}
/>
}
+ element={
+ }
+ sidePanelTitle="Update license key"
+ >
+
+
+ }
path={getRelativeRoute(urls.settings.licenseKeys.edit(null), base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.storage, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.network.proxy, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.network.dns, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.network.ntp, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.network.syslog, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.network.networkDiscovery, base)}
/>
{
path={getRelativeRoute(urls.settings.network.index, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.scripts.commissioning.index, base)}
/>
}
+ element={
+ }
+ sidePanelTitle="Upload commissioning script"
+ >
+
+
+ }
path={getRelativeRoute(
urls.settings.scripts.commissioning.upload,
base
)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.scripts.testing.index, base)}
/>
}
+ element={
+ }
+ sidePanelTitle="Upload testing script"
+ >
+
+
+ }
path={getRelativeRoute(urls.settings.scripts.testing.upload, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.dhcp.index, base)}
/>
}
+ element={
+ }
+ sidePanelTitle="Add DHCP snippet"
+ >
+
+
+ }
path={getRelativeRoute(urls.settings.dhcp.add, base)}
/>
}
+ element={
+ }
+ sidePanelTitle="Edit DHCP snippet"
+ >
+
+
+ }
path={getRelativeRoute(urls.settings.dhcp.edit(null), base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.repositories.index, base)}
/>
}
+ element={
+ }
+ sidePanelTitle="Add Repository"
+ >
+
+
+ }
path={getRelativeRoute(urls.settings.repositories.add(null), base)}
/>
}
+ element={
+ }
+ sidePanelTitle={"Edit Repository"}
+ >
+
+
+ }
path={getRelativeRoute(urls.settings.repositories.edit(null), base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.images.windows, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.images.vmware, base)}
/>
}
+ element={
+
+
+
+ }
path={getRelativeRoute(urls.settings.images.ubuntu, base)}
/>
- } path="*" />
+
+
+
+ }
+ path="*"
+ />
);
};
diff --git a/src/app/settings/urls.ts b/src/app/settings/urls.ts
index 6d4253fb76..8f58ebe505 100644
--- a/src/app/settings/urls.ts
+++ b/src/app/settings/urls.ts
@@ -69,6 +69,7 @@ const urls = {
users: {
add: "/settings/users/add",
edit: argPath<{ id: User["id"] }>("/settings/users/:id/edit"),
+ delete: argPath<{ id: User["id"] }>("/settings/users/:id/delete"),
index: "/settings/users",
},
} as const;
diff --git a/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.test.tsx b/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.test.tsx
index 4af3de4bfd..0975132717 100644
--- a/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.test.tsx
+++ b/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.test.tsx
@@ -80,7 +80,7 @@ describe("DhcpForm", () => {
);
expect(
- screen.getByRole("heading", { name: "Editing `lease`" })
+ screen.getByRole("form", { name: "Editing `lease`" })
).toBeInTheDocument();
});
});
diff --git a/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.tsx b/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.tsx
index a28e158b00..f2f95e602d 100644
--- a/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.tsx
+++ b/src/app/settings/views/Dhcp/DhcpForm/DhcpForm.tsx
@@ -4,8 +4,6 @@ import { useNavigate } from "react-router-dom-v5-compat";
import BaseDhcpForm from "@/app/base/components/DhcpForm";
import type { DHCPFormValues } from "@/app/base/components/DhcpForm/types";
-import FormCard from "@/app/base/components/FormCard";
-import { useWindowTitle } from "@/app/base/hooks";
import settingsURLs from "@/app/settings/urls";
import type { DHCPSnippet } from "@/app/store/dhcpsnippet/types";
@@ -19,21 +17,17 @@ export const DhcpForm = ({ dhcpSnippet }: Props): JSX.Element => {
const editing = !!dhcpSnippet;
const title = editing ? `Editing \`${name}\`` : "Add DHCP snippet";
- useWindowTitle(title);
-
return (
-
- navigate({ pathname: settingsURLs.dhcp.index })}
- onValuesChanged={(values) => {
- setName(values.name);
- }}
- savedRedirect={settingsURLs.dhcp.index}
- />
-
+ navigate({ pathname: settingsURLs.dhcp.index })}
+ onValuesChanged={(values) => {
+ setName(values.name);
+ }}
+ savedRedirect={settingsURLs.dhcp.index}
+ />
);
};
diff --git a/src/app/settings/views/LicenseKeys/LicenseKeyForm/LicenseKeyForm.tsx b/src/app/settings/views/LicenseKeys/LicenseKeyForm/LicenseKeyForm.tsx
index fd2a8028e9..ea0ce46cb7 100644
--- a/src/app/settings/views/LicenseKeys/LicenseKeyForm/LicenseKeyForm.tsx
+++ b/src/app/settings/views/LicenseKeys/LicenseKeyForm/LicenseKeyForm.tsx
@@ -9,9 +9,8 @@ import LicenseKeyFormFields from "../LicenseKeyFormFields";
import type { LicenseKeyFormValues } from "./types";
-import FormCard from "@/app/base/components/FormCard";
import FormikForm from "@/app/base/components/FormikForm";
-import { useAddMessage, useWindowTitle } from "@/app/base/hooks";
+import { useAddMessage } from "@/app/base/hooks";
import settingsURLs from "@/app/settings/urls";
import { actions as generalActions } from "@/app/store/general";
import { osInfo as osInfoSelectors } from "@/app/store/general/selectors";
@@ -50,8 +49,6 @@ export const LicenseKeyForm = ({ licenseKey }: Props): JSX.Element => {
const title = licenseKey ? "Update license key" : "Add license key";
- useWindowTitle(title);
-
const editing = !!licenseKey;
useAddMessage(
@@ -71,7 +68,7 @@ export const LicenseKeyForm = ({ licenseKey }: Props): JSX.Element => {
}, [dispatch, osInfoLoaded, licenseKeysLoaded]);
return (
-
+ <>
{!isLoaded ? (
) : osystems.length > 0 ? (
@@ -125,7 +122,7 @@ export const LicenseKeyForm = ({ licenseKey }: Props): JSX.Element => {
) : (
No available licensed operating systems.
)}
-
+ >
);
};
diff --git a/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.test.tsx b/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.test.tsx
index 069e995bac..c0988b4287 100644
--- a/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.test.tsx
+++ b/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.test.tsx
@@ -120,7 +120,9 @@ describe("RepositoryForm", () => {
);
- expect(screen.getByText("Add repository")).toBeInTheDocument();
+ expect(
+ screen.getByRole("form", { name: "Add repository" })
+ ).toBeInTheDocument();
rerender(
@@ -133,7 +135,7 @@ describe("RepositoryForm", () => {
);
- expect(screen.getByText("Add PPA")).toBeInTheDocument();
+ expect(screen.getByRole("form", { name: "Add PPA" })).toBeInTheDocument();
rerender(
@@ -149,7 +151,9 @@ describe("RepositoryForm", () => {
);
- expect(screen.getByText("Edit repository")).toBeInTheDocument();
+ expect(
+ screen.getByRole("form", { name: "Edit repository" })
+ ).toBeInTheDocument();
rerender(
@@ -165,7 +169,7 @@ describe("RepositoryForm", () => {
);
- expect(screen.getByText("Edit PPA")).toBeInTheDocument();
+ expect(screen.getByRole("form", { name: "Edit PPA" })).toBeInTheDocument();
});
it("cleans up when unmounting", async () => {
diff --git a/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.tsx b/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.tsx
index c6ae6a8b20..c88dc2913e 100644
--- a/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.tsx
+++ b/src/app/settings/views/Repositories/RepositoryForm/RepositoryForm.tsx
@@ -9,9 +9,8 @@ import RepositoryFormFields from "../RepositoryFormFields";
import type { RepositoryFormValues } from "./types";
-import FormCard from "@/app/base/components/FormCard";
import FormikForm from "@/app/base/components/FormikForm";
-import { useAddMessage, useWindowTitle } from "@/app/base/hooks";
+import { useAddMessage } from "@/app/base/hooks";
import settingsURLs from "@/app/settings/urls";
import { actions as generalActions } from "@/app/store/general";
import {
@@ -119,14 +118,12 @@ export const RepositoryForm = ({ type, repository }: Props): JSX.Element => {
};
}
- useWindowTitle(title);
-
return (
<>
{!allLoaded ? (
) : (
-
+ <>
aria-label={title}
cleanup={repositoryActions.cleanup}
@@ -183,7 +180,7 @@ export const RepositoryForm = ({ type, repository }: Props): JSX.Element => {
>
-
+ >
)}
>
);
diff --git a/src/app/settings/views/Repositories/RepositoryFormFields/RepositoryFormFields.tsx b/src/app/settings/views/Repositories/RepositoryFormFields/RepositoryFormFields.tsx
index ecf3743078..560c7975da 100644
--- a/src/app/settings/views/Repositories/RepositoryFormFields/RepositoryFormFields.tsx
+++ b/src/app/settings/views/Repositories/RepositoryFormFields/RepositoryFormFields.tsx
@@ -75,7 +75,7 @@ const RepositoryFormFields = ({ type }: Props): JSX.Element => {
return (
-
+
{
>
)}
-
+
{
const [script, setScript] = useState(null);
const dispatch = useDispatch();
const navigate = useNavigate();
- const title = `Upload ${type} script`;
const listLocation = `/settings/scripts/${type}`;
- useWindowTitle(title);
-
useEffect(() => {
if (hasErrors && errors && typeof errors === "object") {
Object.values(errors).forEach((error) => {
@@ -127,7 +122,7 @@ const ScriptsUpload = ({ type }: Props): JSX.Element => {
const uploadedFile: FileWithPath = acceptedFiles[0];
return (
-
+
{
) : null}
-
+
);
};
diff --git a/src/app/settings/views/Settings.tsx b/src/app/settings/views/Settings.tsx
index 99ef84e755..d8df0d5fb1 100644
--- a/src/app/settings/views/Settings.tsx
+++ b/src/app/settings/views/Settings.tsx
@@ -24,11 +24,7 @@ const Settings = (): JSX.Element => {
);
}
- return (
-
-
-
- );
+ return ;
};
export default Settings;
diff --git a/src/app/settings/views/Users/UserAdd/UserAdd.test.tsx b/src/app/settings/views/Users/UserAdd/UserAdd.test.tsx
index ef51fc8ae7..d4563786ff 100644
--- a/src/app/settings/views/Users/UserAdd/UserAdd.test.tsx
+++ b/src/app/settings/views/Users/UserAdd/UserAdd.test.tsx
@@ -32,8 +32,6 @@ describe("UserAdd", () => {
,
{ state }
);
- expect(
- screen.getByRole("heading", { name: "Add user" })
- ).toBeInTheDocument();
+ expect(screen.getByRole("form", { name: "Add user" })).toBeInTheDocument();
});
});
diff --git a/src/app/settings/views/Users/UserDelete/UserDelete.test.tsx b/src/app/settings/views/Users/UserDelete/UserDelete.test.tsx
new file mode 100644
index 0000000000..be1b48b9ad
--- /dev/null
+++ b/src/app/settings/views/Users/UserDelete/UserDelete.test.tsx
@@ -0,0 +1,43 @@
+import UserDelete from "./UserDelete";
+
+import type { RootState } from "@/app/store/root/types";
+import {
+ rootState as rootStateFactory,
+ statusState as statusStateFactory,
+ user as userFactory,
+ userState as userStateFactory,
+} from "@/testing/factories";
+import { renderWithBrowserRouter, screen } from "@/testing/utils";
+
+let state: RootState;
+
+beforeEach(() => {
+ state = rootStateFactory({
+ status: statusStateFactory({
+ externalAuthURL: null,
+ }),
+ user: userStateFactory({
+ loaded: true,
+ items: [
+ userFactory({
+ email: "admin@example.com",
+ global_permissions: ["machine_create"],
+ id: 1,
+ is_superuser: true,
+ last_name: "",
+ sshkeys_count: 0,
+ username: "admin",
+ }),
+ ],
+ }),
+ });
+});
+
+it("renders", () => {
+ renderWithBrowserRouter(, {
+ state,
+ route: "/settings/users/1/edit",
+ routePattern: "/settings/users/:id/edit",
+ });
+ expect(screen.getByRole("form", { name: "Delete user" }));
+});
diff --git a/src/app/settings/views/Users/UserDelete/UserDelete.tsx b/src/app/settings/views/Users/UserDelete/UserDelete.tsx
new file mode 100644
index 0000000000..e7471bc73f
--- /dev/null
+++ b/src/app/settings/views/Users/UserDelete/UserDelete.tsx
@@ -0,0 +1,22 @@
+import { useSelector } from "react-redux";
+
+import { useGetURLId } from "@/app/base/hooks";
+import UserDeleteForm from "@/app/settings/views/Users/UserDeleteForm";
+import type { RootState } from "@/app/store/root/types";
+import userSelectors from "@/app/store/user/selectors";
+import { UserMeta } from "@/app/store/user/types";
+
+const UserDelete = () => {
+ const id = useGetURLId(UserMeta.PK);
+ const user = useSelector((state: RootState) =>
+ userSelectors.getById(state, id)
+ );
+
+ if (!user) {
+ return User not found
;
+ }
+
+ return ;
+};
+
+export default UserDelete;
diff --git a/src/app/settings/views/Users/UserDelete/index.ts b/src/app/settings/views/Users/UserDelete/index.ts
new file mode 100644
index 0000000000..5ec02b0eb6
--- /dev/null
+++ b/src/app/settings/views/Users/UserDelete/index.ts
@@ -0,0 +1 @@
+export { default } from "./UserDelete";
diff --git a/src/app/settings/views/Users/UserDeleteForm/UserDeleteForm.tsx b/src/app/settings/views/Users/UserDeleteForm/UserDeleteForm.tsx
new file mode 100644
index 0000000000..59dda011a8
--- /dev/null
+++ b/src/app/settings/views/Users/UserDeleteForm/UserDeleteForm.tsx
@@ -0,0 +1,62 @@
+import { useState } from "react";
+
+import { Col, Row } from "@canonical/react-components";
+import { useDispatch, useSelector } from "react-redux";
+import { useNavigate } from "react-router-dom-v5-compat";
+
+import FormikForm from "@/app/base/components/FormikForm";
+import { useAddMessage } from "@/app/base/hooks";
+import type { EmptyObject } from "@/app/base/types";
+import settingsURLs from "@/app/settings/urls";
+import { actions as userActions } from "@/app/store/user";
+import userSelectors from "@/app/store/user/selectors";
+import type { User } from "@/app/store/user/types";
+
+type UserDeleteProps = {
+ user: User;
+};
+
+const UserDeleteForm = ({ user }: UserDeleteProps) => {
+ const [deletedUser, setDeletedUser] = useState(null);
+ const navigate = useNavigate();
+ const saved = useSelector(userSelectors.saved);
+ const saving = useSelector(userSelectors.saving);
+ const errors = useSelector(userSelectors.errors);
+ const dispatch = useDispatch();
+
+ useAddMessage(
+ saved && !errors,
+ userActions.cleanup,
+ `Deleted ${deletedUser} from list`
+ );
+
+ return (
+
+ aria-label="Delete user"
+ initialValues={{}}
+ onCancel={() => navigate({ pathname: settingsURLs.users.index })}
+ onSubmit={() => {
+ dispatch(userActions.delete(user.id));
+ setDeletedUser(user.username);
+ }}
+ saved={saved}
+ savedRedirect={settingsURLs.users.index}
+ saving={saving}
+ submitAppearance="negative"
+ submitLabel="Delete"
+ >
+
+
+
+ {`Are you sure you want to delete \`${user.username}\`?`}
+
+
+ This action is permanent and can not be undone.
+
+
+
+
+ );
+};
+
+export default UserDeleteForm;
diff --git a/src/app/settings/views/Users/UserDeleteForm/index.ts b/src/app/settings/views/Users/UserDeleteForm/index.ts
new file mode 100644
index 0000000000..39e0db39a2
--- /dev/null
+++ b/src/app/settings/views/Users/UserDeleteForm/index.ts
@@ -0,0 +1 @@
+export { default } from "./UserDeleteForm";
diff --git a/src/app/settings/views/Users/UserForm/UserForm.tsx b/src/app/settings/views/Users/UserForm/UserForm.tsx
index 2fff1f62e4..b667dcc6d2 100644
--- a/src/app/settings/views/Users/UserForm/UserForm.tsx
+++ b/src/app/settings/views/Users/UserForm/UserForm.tsx
@@ -3,10 +3,9 @@ import { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom-v5-compat";
-import FormCard from "@/app/base/components/FormCard";
import BaseUserForm from "@/app/base/components/UserForm";
import type { Props as UserFormProps } from "@/app/base/components/UserForm/UserForm";
-import { useAddMessage, useWindowTitle } from "@/app/base/hooks";
+import { useAddMessage } from "@/app/base/hooks";
import settingsURLs from "@/app/settings/urls";
import { actions as authActions } from "@/app/store/auth";
import { actions as userActions } from "@/app/store/user";
@@ -30,8 +29,6 @@ export const UserForm = ({ user }: PropTypes): JSX.Element => {
const editing = !!user;
const title = editing ? `Editing \`${name}\`` : "Add user";
- useWindowTitle(title);
-
useAddMessage(
saved,
userActions.cleanup,
@@ -40,55 +37,53 @@ export const UserForm = ({ user }: PropTypes): JSX.Element => {
);
return (
-
- navigate(-1)}
- onSave={(values) => {
- const params = {
- email: values.email,
- is_superuser: values.isSuperuser,
- last_name: values.fullName,
- username: values.username,
- };
- if (editing && user) {
- dispatch(userActions.update({ ...params, id: user.id }));
- if (values.password && values.passwordConfirm) {
- dispatch(
- authActions.adminChangePassword({
- ...params,
- id: user.id,
- password1: values.password,
- password2: values.passwordConfirm,
- })
- );
- }
- } else if (!editing && values.password && values.passwordConfirm) {
+ navigate(-1)}
+ onSave={(values) => {
+ const params = {
+ email: values.email,
+ is_superuser: values.isSuperuser,
+ last_name: values.fullName,
+ username: values.username,
+ };
+ if (editing && user) {
+ dispatch(userActions.update({ ...params, id: user.id }));
+ if (values.password && values.passwordConfirm) {
dispatch(
- userActions.create({
+ authActions.adminChangePassword({
...params,
+ id: user.id,
password1: values.password,
password2: values.passwordConfirm,
})
);
}
- setSaving(values.username);
- }}
- onSaveAnalytics={{
- action: "Saved",
- category: "Users settings",
- label: `${editing ? "Edit" : "Add"} user form`,
- }}
- onUpdateFields={(values) => {
- setName(values.username);
- }}
- savedRedirect={settingsURLs.users.index}
- submitLabel="Save user"
- user={user}
- />
-
+ } else if (!editing && values.password && values.passwordConfirm) {
+ dispatch(
+ userActions.create({
+ ...params,
+ password1: values.password,
+ password2: values.passwordConfirm,
+ })
+ );
+ }
+ setSaving(values.username);
+ }}
+ onSaveAnalytics={{
+ action: "Saved",
+ category: "Users settings",
+ label: `${editing ? "Edit" : "Add"} user form`,
+ }}
+ onUpdateFields={(values) => {
+ setName(values.username);
+ }}
+ savedRedirect={settingsURLs.users.index}
+ submitLabel="Save user"
+ user={user}
+ />
);
};
diff --git a/src/app/settings/views/Users/UsersList/UsersList.test.tsx b/src/app/settings/views/Users/UsersList/UsersList.test.tsx
index 7dc22c00ae..b30842e856 100644
--- a/src/app/settings/views/Users/UsersList/UsersList.test.tsx
+++ b/src/app/settings/views/Users/UsersList/UsersList.test.tsx
@@ -62,90 +62,6 @@ describe("UsersList", () => {
});
});
- it("can show a delete confirmation", async () => {
- const store = mockStore(state);
- const { rerender } = render(
-
-
-
-
-
-
-
- );
- let row = screen.getAllByTestId("user-row")[1];
- expect(row).not.toHaveClass("is-active");
-
- // Click on the delete button:
- await userEvent.click(within(row).getByTestId("table-actions-delete"));
-
- rerender(
-
-
-
-
-
-
-
- );
-
- row = screen.getAllByTestId("user-row")[1];
- expect(row).toHaveClass("is-active");
- });
-
- it("can delete a user", async () => {
- const store = mockStore(state);
- const { rerender } = render(
-
-
-
-
-
-
-
- );
- let row = screen.getAllByTestId("user-row")[1];
-
- // Click on the delete button:
- await userEvent.click(within(row).getByTestId("table-actions-delete"));
-
- rerender(
-
-
-
-
-
-
-
- );
-
- row = screen.getAllByTestId("user-row")[1];
-
- // Click on the delete confirm button
- await userEvent.click(within(row).getByTestId("action-confirm"));
-
- expect(store.getActions()[1]).toEqual({
- type: "user/delete",
- payload: {
- params: {
- id: 2,
- },
- },
- meta: {
- model: "user",
- method: "delete",
- },
- });
- });
-
it("disables delete for the current user", () => {
renderWithMockStore(
{
{ state }
);
let row = screen.getAllByTestId("user-row")[0];
- expect(within(row).getByTestId("table-actions-delete")).toBeDisabled();
+ expect(within(row).getByRole("link", { name: /delete/i })).toHaveAttribute(
+ "aria-disabled",
+ "true"
+ );
});
it("links to preferences for the current user", () => {
diff --git a/src/app/settings/views/Users/UsersList/UsersList.tsx b/src/app/settings/views/Users/UsersList/UsersList.tsx
index 40143650c5..2b2b00aa37 100644
--- a/src/app/settings/views/Users/UsersList/UsersList.tsx
+++ b/src/app/settings/views/Users/UsersList/UsersList.tsx
@@ -4,10 +4,8 @@ import { ContentSection } from "@canonical/maas-react-components";
import { Notification } from "@canonical/react-components";
import { format, parse } from "date-fns";
import { useDispatch, useSelector } from "react-redux";
-import type { Dispatch } from "redux";
import TableActions from "@/app/base/components/TableActions";
-import TableDeleteConfirm from "@/app/base/components/TableDeleteConfirm";
import TableHeader from "@/app/base/components/TableHeader";
import {
useFetchActions,
@@ -24,7 +22,7 @@ import type { RootState } from "@/app/store/root/types";
import statusSelectors from "@/app/store/status/selectors";
import { actions as userActions } from "@/app/store/user";
import userSelectors from "@/app/store/user/selectors";
-import type { User, UserMeta, UserState } from "@/app/store/user/types";
+import type { User } from "@/app/store/user/types";
import { isComparable } from "@/app/utils";
type SortKey = keyof User;
@@ -32,16 +30,9 @@ type SortKey = keyof User;
const generateUserRows = (
users: User[],
authUser: User | null,
- expandedId: User[UserMeta.PK] | null,
- setExpandedId: (expandedId: User[UserMeta.PK] | null) => void,
- dispatch: Dispatch,
- displayUsername: boolean,
- setDeleting: (deletingUser: User["username"] | null) => void,
- saved: UserState["saved"],
- saving: UserState["saving"]
+ displayUsername: boolean
) =>
users.map((user) => {
- const expanded = expandedId === user.id;
const isAuthUser = user.id === authUser?.id;
// Dates are in the format: Thu, 15 Aug. 2019 06:21:39.
const last_login = user.last_login
@@ -52,7 +43,7 @@ const generateUserRows = (
: "Never";
const fullName = user.last_name;
return {
- className: expanded ? "p-table__row is-active" : "p-table__row",
+ className: "p-table__row",
columns: [
{
content: displayUsername ? user.username : fullName || <>—>,
@@ -73,6 +64,7 @@ const generateUserRows = (
content: (
setExpandedId(user.id)}
/>
),
className: "u-align--right",
},
],
"data-testid": "user-row",
- expanded: expanded,
- expandedContent: expanded && (
- setExpandedId(null)}
- onConfirm={() => {
- dispatch(userActions.delete(user.id));
- setDeleting(user.username);
- }}
- />
- ),
key: user.username,
sortData: {
username: user.username,
@@ -122,7 +99,6 @@ const getSortValue = (sortKey: SortKey, user: User) => {
};
const UsersList = (): JSX.Element => {
- const [expandedId, setExpandedId] = useState(null);
const [searchText, setSearchText] = useState("");
const [displayUsername, setDisplayUsername] = useState(true);
const [deletingUser, setDeleting] = useState(null);
@@ -133,7 +109,6 @@ const UsersList = (): JSX.Element => {
const loaded = useSelector(userSelectors.loaded);
const authUser = useSelector(authSelectors.get);
const saved = useSelector(userSelectors.saved);
- const saving = useSelector(userSelectors.saving);
const externalAuthURL = useSelector(statusSelectors.externalAuthURL);
const dispatch = useDispatch();
@@ -254,17 +229,7 @@ const UsersList = (): JSX.Element => {
]}
loaded={loaded}
loading={loading}
- rows={generateUserRows(
- sortedUsers,
- authUser,
- expandedId,
- setExpandedId,
- dispatch,
- displayUsername,
- setDeleting,
- saved,
- saving
- )}
+ rows={generateUserRows(sortedUsers, authUser, displayUsername)}
searchOnChange={setSearchText}
searchPlaceholder="Search users"
searchText={searchText}
diff --git a/src/app/store/machine/types/base.ts b/src/app/store/machine/types/base.ts
index 73ab35de76..23f0c91de1 100644
--- a/src/app/store/machine/types/base.ts
+++ b/src/app/store/machine/types/base.ts
@@ -371,3 +371,9 @@ export type MachineState = {
selected: SelectedMachines | null;
statuses: MachineStatuses;
} & GenericState;
+
+export type StorageLayoutOption = {
+ label: string;
+ sentenceLabel: string;
+ value: StorageLayout;
+};
diff --git a/src/app/store/machine/types/index.ts b/src/app/store/machine/types/index.ts
index 8ba79ba7eb..42a082c018 100644
--- a/src/app/store/machine/types/index.ts
+++ b/src/app/store/machine/types/index.ts
@@ -67,6 +67,7 @@ export type {
MachineStatus,
MachineStatuses,
SelectedMachines,
+ StorageLayoutOption,
} from "./base";
export { FilterGroupKey, FilterGroupType } from "./base";
diff --git a/src/app/store/utils/node/base.ts b/src/app/store/utils/node/base.ts
index 2a6088145a..0c148c1022 100644
--- a/src/app/store/utils/node/base.ts
+++ b/src/app/store/utils/node/base.ts
@@ -190,24 +190,54 @@ export const getSidePanelTitle = (
if (sidePanelContent) {
const [, name] = sidePanelContent.view;
switch (name) {
+ case SidePanelViews.ADD_ALIAS[1]:
+ return "Add alias";
+ case SidePanelViews.ADD_BOND[1]:
+ return "Create bond";
+ case SidePanelViews.ADD_BRIDGE[1]:
+ return "Create bridge";
case SidePanelViews.ADD_CONTROLLER[1]:
return "Add controller";
case SidePanelViews.ADD_CHASSIS[1]:
return "Add chassis";
+ case SidePanelViews.ADD_INTERFACE[1]:
+ return "Add interface";
case SidePanelViews.ADD_MACHINE[1]:
return "Add machine";
case SidePanelViews.ADD_DEVICE[1]:
return "Add device";
+ case SidePanelViews.ADD_SPECIAL_FILESYSTEM[1]:
+ return "Add special filesystem";
case SidePanelViews.AddTag[1]:
return "Create new tag";
+ case SidePanelViews.ADD_VLAN[1]:
+ return "Add VLAN";
+ case SidePanelViews.CHANGE_SOURCE[1]:
+ return "Change source";
+ case SidePanelViews.CREATE_DATASTORE[1]:
+ return "Create datastore";
+ case SidePanelViews.CREATE_RAID[1]:
+ return "Create raid";
+ case SidePanelViews.CREATE_VOLUME_GROUP[1]:
+ return "Create volume group";
case SidePanelViews.DeleteTag[1]:
return "Delete tag";
+ case SidePanelViews.EDIT_INTERFACE[1]:
+ return "Edit interface";
case SidePanelViews.CREATE_ZONE[1]:
return "Add AZ";
+ case SidePanelViews.DELETE_IMAGE[1]:
+ return "Delete image";
case SidePanelViews.DELETE_SPACE[1]:
return "Delete space";
case SidePanelViews.DELETE_FABRIC[1]:
return "Delete fabric";
+ case SidePanelViews.REMOVE_INTERFACE[1]:
+ return "Remove interface";
+ case SidePanelViews.UPDATE_DATASTORE[1]:
+ return "Update datastore";
+ case SidePanelViews.UpdateTag[1]:
+ return "Update Tag";
default:
return name ? getNodeActionTitle(name as NodeActions) : defaultTitle;
}
diff --git a/src/app/subnets/components/ReservedRangeDeleteForm/ReservedRangeDeleteForm.tsx b/src/app/subnets/components/ReservedRangeDeleteForm/ReservedRangeDeleteForm.tsx
new file mode 100644
index 0000000000..ed6340dd1e
--- /dev/null
+++ b/src/app/subnets/components/ReservedRangeDeleteForm/ReservedRangeDeleteForm.tsx
@@ -0,0 +1,45 @@
+import { useDispatch, useSelector } from "react-redux";
+
+import ModelDeleteForm from "@/app/base/components/ModelDeleteForm";
+import {
+ useSidePanel,
+ type SetSidePanelContent,
+} from "@/app/base/side-panel-context";
+import { actions as ipRangeActions } from "@/app/store/iprange";
+import ipRangeSelectors from "@/app/store/iprange/selectors";
+
+type Props = {
+ setActiveForm: SetSidePanelContent;
+};
+
+const ReservedRangeDeleteForm = ({ setActiveForm }: Props) => {
+ const dispatch = useDispatch();
+ const { sidePanelContent } = useSidePanel();
+ const saved = useSelector(ipRangeSelectors.saved);
+ const saving = useSelector(ipRangeSelectors.saving);
+ const ipRangeId =
+ sidePanelContent?.extras && "ipRangeId" in sidePanelContent.extras
+ ? sidePanelContent?.extras?.ipRangeId
+ : null;
+
+ if (!ipRangeId && ipRangeId !== 0) {
+ return IP range not provided
;
+ }
+
+ return (
+ setActiveForm(null)}
+ onSubmit={() => {
+ dispatch(ipRangeActions.delete(ipRangeId));
+ }}
+ saved={saved}
+ saving={saving}
+ />
+ );
+};
+
+export default ReservedRangeDeleteForm;
diff --git a/src/app/subnets/components/ReservedRangeDeleteForm/index.ts b/src/app/subnets/components/ReservedRangeDeleteForm/index.ts
new file mode 100644
index 0000000000..98e3b4dd47
--- /dev/null
+++ b/src/app/subnets/components/ReservedRangeDeleteForm/index.ts
@@ -0,0 +1 @@
+export { default } from "./ReservedRangeDeleteForm";
diff --git a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx
index c68ae65908..269098a942 100644
--- a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx
+++ b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx
@@ -45,7 +45,7 @@ describe("ReservedRangeForm", () => {
initialEntries={[{ pathname: "/machines", key: "testKey" }]}
>
-
+
@@ -61,7 +61,7 @@ describe("ReservedRangeForm", () => {
initialEntries={[{ pathname: "/machines", key: "testKey" }]}
>
-
+
@@ -77,7 +77,7 @@ describe("ReservedRangeForm", () => {
initialEntries={[{ pathname: "/machines", key: "testKey" }]}
>
-
+
@@ -104,7 +104,7 @@ describe("ReservedRangeForm", () => {
initialEntries={[{ pathname: "/machines", key: "testKey" }]}
>
-
+
@@ -127,8 +127,8 @@ describe("ReservedRangeForm", () => {
@@ -169,7 +169,7 @@ describe("ReservedRangeForm", () => {
initialEntries={[{ pathname: "/machines", key: "testKey" }]}
>
-
+
@@ -201,7 +201,7 @@ describe("ReservedRangeForm", () => {
initialEntries={[{ pathname: "/machines", key: "testKey" }]}
>
-
+
@@ -236,7 +236,7 @@ describe("ReservedRangeForm", () => {
diff --git a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx
index 01e01f8166..e2a054a188 100644
--- a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx
+++ b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx
@@ -6,6 +6,10 @@ import * as Yup from "yup";
import FormikField from "@/app/base/components/FormikField";
import FormikForm from "@/app/base/components/FormikForm";
+import {
+ useSidePanel,
+ type SetSidePanelContent,
+} from "@/app/base/side-panel-context";
import { actions as ipRangeActions } from "@/app/store/iprange";
import ipRangeSelectors from "@/app/store/iprange/selectors";
import type { IPRange } from "@/app/store/iprange/types";
@@ -16,9 +20,9 @@ import { isId } from "@/app/utils";
type Props = {
createType?: IPRangeType;
- id?: IPRange[IPRangeMeta.PK] | null;
- onClose: () => void;
- subnetId?: Subnet[SubnetMeta.PK] | null;
+ ipRangeId?: IPRange[IPRangeMeta.PK] | null;
+ setActiveForm: SetSidePanelContent;
+ id?: Subnet[SubnetMeta.PK] | null;
};
export type FormValues = {
@@ -43,20 +47,36 @@ const Schema = Yup.object().shape({
const ReservedRangeForm = ({
createType,
+ ipRangeId,
+ setActiveForm,
id,
- onClose,
- subnetId,
...props
}: Props): JSX.Element | null => {
const dispatch = useDispatch();
+ const { sidePanelContent } = useSidePanel();
+ let computedIpRangeId = ipRangeId;
+ if (!ipRangeId) {
+ computedIpRangeId =
+ sidePanelContent?.extras && "ipRangeId" in sidePanelContent.extras
+ ? sidePanelContent?.extras?.ipRangeId
+ : undefined;
+ }
const ipRange = useSelector((state: RootState) =>
- ipRangeSelectors.getById(state, id)
+ ipRangeSelectors.getById(state, computedIpRangeId)
);
const saved = useSelector(ipRangeSelectors.saved);
const saving = useSelector(ipRangeSelectors.saving);
const errors = useSelector(ipRangeSelectors.errors);
const cleanup = useCallback(() => ipRangeActions.cleanup(), []);
- const isEditing = isId(id);
+ const isEditing = isId(computedIpRangeId);
+ const onClose = () => setActiveForm(null);
+ let computedCreateType = createType;
+ if (!createType) {
+ computedCreateType =
+ sidePanelContent?.extras && "createType" in sidePanelContent.extras
+ ? sidePanelContent?.extras?.createType
+ : undefined;
+ }
if (isEditing && !ipRange) {
return (
@@ -92,11 +112,11 @@ const ReservedRangeForm = ({
onSubmit={(values) => {
// Clear the errors from the previous submission.
dispatch(cleanup());
- if (!isEditing && createType) {
+ if (!isEditing && computedCreateType) {
dispatch(
ipRangeActions.create({
- subnet: subnetId,
- type: createType,
+ subnet: id,
+ type: computedCreateType,
...values,
})
);
@@ -120,7 +140,7 @@ const ReservedRangeForm = ({
{...props}
>
-
+
-
+
- {isEditing || createType === IPRangeType.Reserved ? (
-
+ {isEditing || computedCreateType === IPRangeType.Reserved ? (
+
{
).toHaveTextContent("what a beaut");
});
-it("displays an edit form", async () => {
- const store = mockStore(state);
- render(
-
-
-
-
-
-
-
- );
- await userEvent.click(screen.getByRole("button", { name: "Edit" }));
-
- await waitFor(() =>
- expect(
- screen.getByRole("form", { name: ReservedRangeFormLabels.EditRange })
- ).toBeInTheDocument()
- );
-});
-
-it("displays confirm delete message", async () => {
- const store = mockStore(state);
- render(
-
-
-
-
-
-
-
- );
- await userEvent.click(screen.getByRole("button", { name: "Delete" }));
-
- await waitFor(() => {
- expect(
- screen.getByText(
- new RegExp("Are you sure you want to remove this IP range?")
- )
- ).toBeInTheDocument();
- });
-});
-
-it("dispatches an action to delete a reserved range", async () => {
- const store = mockStore(state);
- render(
-
-
-
-
-
-
-
- );
- await userEvent.click(screen.getByTestId("table-actions-delete"));
- await userEvent.click(screen.getByTestId("action-confirm"));
-
- const expectedAction = ipRangeActions.delete(ipRange.id);
-
- await waitFor(() => {
- const actualAction = store
- .getActions()
- .find((action) => action.type === expectedAction.type);
- expect(actualAction).toStrictEqual(expectedAction);
- });
-});
-
it("displays an add button when it is reserved", () => {
ipRange.type = IPRangeType.Reserved;
state.iprange.items = [ipRange];
@@ -375,33 +306,6 @@ it("disables the add button if there are no subnets in a VLAN", () => {
).toHaveAttribute("disabled");
});
-it("can display an add form", async () => {
- const store = mockStore(state);
- render(
-
-
-
-
-
-
-
- );
- await userEvent.click(
- screen.queryAllByRole("button", {
- name: Labels.ReserveRange,
- })[0]
- );
- await userEvent.click(screen.getByTestId("reserve-range-menu-item"));
-
- await waitFor(() => {
- expect(
- screen.getByRole("form", {
- name: ReservedRangeFormLabels.CreateRange,
- })
- ).toBeInTheDocument();
- });
-});
-
it("displays the subnet column when the table is for a VLAN", () => {
state.iprange.items = [
ipRangeFactory({ start_ip: "11.1.1.1", vlan: vlan.id }),
diff --git a/src/app/subnets/components/ReservedRanges/ReservedRanges.tsx b/src/app/subnets/components/ReservedRanges/ReservedRanges.tsx
index 486faba939..dd08441425 100644
--- a/src/app/subnets/components/ReservedRanges/ReservedRanges.tsx
+++ b/src/app/subnets/components/ReservedRanges/ReservedRanges.tsx
@@ -9,21 +9,18 @@ import {
} from "@canonical/react-components";
import type { MainTableCell } from "@canonical/react-components/dist/components/MainTable/MainTable";
import classNames from "classnames";
-import { useDispatch, useSelector } from "react-redux";
-import type { Dispatch } from "redux";
+import { useSelector } from "react-redux";
-import ReservedRangeForm from "../ReservedRangeForm";
-
-import FormCard from "@/app/base/components/FormCard";
import SubnetLink from "@/app/base/components/SubnetLink";
import TableActions from "@/app/base/components/TableActions";
-import TableDeleteConfirm from "@/app/base/components/TableDeleteConfirm";
import TitledSection from "@/app/base/components/TitledSection";
import docsUrls from "@/app/base/docsUrls";
import { useFetchActions } from "@/app/base/hooks";
+import type { SetSidePanelContent } from "@/app/base/side-panel-context";
+import { useSidePanel } from "@/app/base/side-panel-context";
import { actions as ipRangeActions } from "@/app/store/iprange";
import ipRangeSelectors from "@/app/store/iprange/selectors";
-import type { IPRange, IPRangeMeta } from "@/app/store/iprange/types";
+import type { IPRange } from "@/app/store/iprange/types";
import { IPRangeType } from "@/app/store/iprange/types";
import {
getCommentDisplay,
@@ -33,6 +30,10 @@ import {
import type { RootState } from "@/app/store/root/types";
import type { Subnet, SubnetMeta } from "@/app/store/subnet/types";
import type { VLAN, VLANMeta } from "@/app/store/vlan/types";
+import {
+ SubnetActionTypes,
+ SubnetDetailsSidePanelViews,
+} from "@/app/subnets/views/SubnetDetails/constants";
import { generateEmptyStateMsg, getTableStatus, isId } from "@/app/utils";
export type SubnetProps = {
@@ -68,58 +69,17 @@ export enum ExpandedType {
Update,
}
-type Expanded = {
- id?: IPRange[IPRangeMeta.PK];
- type: ExpandedType;
-};
-
-const toggleExpanded = (
- id: IPRange[IPRangeMeta.PK],
- expanded: Expanded | null,
- expandedType: ExpandedType,
- setExpanded: (expanded: Expanded | null) => void
-) =>
- setExpanded(
- expanded?.id === id && expanded.type === expandedType
- ? null
- : {
- id,
- type: expandedType,
- }
- );
-
const generateRows = (
- dispatch: Dispatch,
ipRanges: IPRange[],
- expanded: Expanded | null,
- setExpanded: (expanded: Expanded | null) => void,
- saved: boolean,
- saving: boolean,
- showSubnetColumn: boolean
+ showSubnetColumn: boolean,
+ setSidePanelContent: SetSidePanelContent
) =>
ipRanges.map((ipRange: IPRange) => {
- const isExpanded = expanded?.id === ipRange.id;
const comment = getCommentDisplay(ipRange);
const owner = getOwnerDisplay(ipRange);
const type = getTypeDisplay(ipRange);
let expandedContent: ReactNode | null = null;
- const onClose = () => setExpanded(null);
- if (expanded?.type === ExpandedType.Delete) {
- expandedContent = (
- {
- dispatch(ipRangeActions.delete(ipRange.id));
- }}
- sidebar={false}
- />
- );
- } else if (expanded?.type === ExpandedType.Update) {
- expandedContent = ;
- }
+
const columns: MainTableCell[] = [
{
"aria-label": Labels.StartIP,
@@ -151,22 +111,26 @@ const generateRows = (
className: "actions-col u-align--right",
content: (
{
- toggleExpanded(
- ipRange.id,
- expanded,
- ExpandedType.Delete,
- setExpanded
- );
- }}
- onEdit={() => {
- toggleExpanded(
- ipRange.id,
- expanded,
- ExpandedType.Update,
- setExpanded
- );
- }}
+ onDelete={() =>
+ setSidePanelContent({
+ view: SubnetDetailsSidePanelViews[
+ SubnetActionTypes.DeleteReservedRange
+ ],
+ extras: {
+ ipRangeId: ipRange.id,
+ },
+ })
+ }
+ onEdit={() =>
+ setSidePanelContent({
+ view: SubnetDetailsSidePanelViews[
+ SubnetActionTypes.ReserveRange
+ ],
+ extras: {
+ ipRangeId: ipRange.id,
+ },
+ })
+ }
/>
),
},
@@ -179,9 +143,7 @@ const generateRows = (
});
}
return {
- className: isExpanded ? "p-table__row is-active" : null,
columns,
- expanded: isExpanded,
expandedContent: expandedContent,
key: ipRange.id,
sortData: {
@@ -199,19 +161,15 @@ const ReservedRanges = ({
subnetId,
vlanId,
}: Props): JSX.Element | null => {
- const dispatch = useDispatch();
- const [expanded, setExpanded] = useState(null);
+ const [isAddingDynamic, setIsAddingDynamic] = useState(false);
+ const { setSidePanelContent } = useSidePanel();
const isSubnet = isId(subnetId);
const ipRangeLoading = useSelector(ipRangeSelectors.loading);
- const saved = useSelector(ipRangeSelectors.saved);
- const saving = useSelector(ipRangeSelectors.saving);
const ipRanges = useSelector((state: RootState) =>
isSubnet
? ipRangeSelectors.getBySubnet(state, subnetId)
: ipRangeSelectors.getByVLAN(state, vlanId)
);
- const isAddingDynamic = expanded?.type === ExpandedType.CreateDynamic;
- const isAdding = expanded?.type === ExpandedType.Create || isAddingDynamic;
const isDisabled = isId(vlanId) && !hasVLANSubnets;
const showSubnetColumn = isId(vlanId);
@@ -268,12 +226,32 @@ const ReservedRanges = ({
{
children: Labels.ReserveRange,
"data-testid": "reserve-range-menu-item",
- onClick: () => setExpanded({ type: ExpandedType.Create }),
+ onClick: () => {
+ setSidePanelContent({
+ view: SubnetDetailsSidePanelViews[
+ SubnetActionTypes.ReserveRange
+ ],
+ extras: {
+ createType: IPRangeType.Reserved,
+ },
+ });
+ setIsAddingDynamic(false);
+ },
},
{
children: Labels.ReserveDynamicRange,
"data-testid": "reserve-dynamic-range-menu-item",
- onClick: () => setExpanded({ type: ExpandedType.CreateDynamic }),
+ onClick: () => {
+ setSidePanelContent({
+ view: SubnetDetailsSidePanelViews[
+ SubnetActionTypes.ReserveRange
+ ],
+ extras: {
+ createType: IPRangeType.Dynamic,
+ },
+ });
+ setIsAddingDynamic(true);
+ },
},
]}
position="right"
@@ -309,28 +287,9 @@ const ReservedRanges = ({
expanding
headers={headers}
responsive
- rows={generateRows(
- dispatch,
- ipRanges,
- expanded,
- setExpanded,
- saved,
- saving,
- showSubnetColumn
- )}
+ rows={generateRows(ipRanges, showSubnetColumn, setSidePanelContent)}
sortable
/>
- {isAdding ? (
-
- setExpanded(null)}
- subnetId={subnetId}
- />
-
- ) : null}
About IP ranges
);
diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.test.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.test.tsx
index 9645367527..1261bb75e0 100644
--- a/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.test.tsx
+++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.test.tsx
@@ -46,7 +46,7 @@ it("dispatches a correct action on add static route form submit", async () => {
-
+
diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.tsx
index e2d9fd34c4..492225472f 100644
--- a/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.tsx
+++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/AddStaticRouteForm/AddStaticRouteForm.tsx
@@ -8,6 +8,7 @@ import FormikField from "@/app/base/components/FormikField";
import FormikForm from "@/app/base/components/FormikForm";
import SubnetSelect from "@/app/base/components/SubnetSelect";
import { useFetchActions } from "@/app/base/hooks";
+import type { SetSidePanelContent } from "@/app/base/side-panel-context";
import type { RootState } from "@/app/store/root/types";
import { actions as staticRouteActions } from "@/app/store/staticroute";
import staticRouteSelectors from "@/app/store/staticroute/selectors";
@@ -37,22 +38,23 @@ const addStaticRouteSchema = Yup.object().shape({
});
export type Props = {
- subnetId: Subnet[SubnetMeta.PK];
- handleDismiss: () => void;
+ id: Subnet[SubnetMeta.PK];
+ setActiveForm: SetSidePanelContent;
};
const AddStaticRouteForm = ({
- subnetId,
- handleDismiss,
+ id,
+ setActiveForm,
}: Props): JSX.Element | null => {
const staticRouteErrors = useSelector(staticRouteSelectors.errors);
const saving = useSelector(staticRouteSelectors.saving);
const saved = useSelector(staticRouteSelectors.saved);
const dispatch = useDispatch();
+ const handleClose = () => setActiveForm(null);
const staticRoutesLoading = useSelector(staticRouteSelectors.loading);
const subnetsLoading = useSelector(subnetSelectors.loading);
const loading = staticRoutesLoading || subnetsLoading;
const source = useSelector((state: RootState) =>
- subnetSelectors.getById(state, subnetId)
+ subnetSelectors.getById(state, id)
);
useFetchActions([subnetActions.fetch]);
@@ -67,12 +69,12 @@ const AddStaticRouteForm = ({
cleanup={staticRouteActions.cleanup}
errors={staticRouteErrors}
initialValues={{
- source: subnetId,
+ source: id,
gateway_ip: "",
destination: "",
metric: "0",
}}
- onCancel={handleDismiss}
+ onCancel={handleClose}
onSaveAnalytics={{
action: AddStaticRouteFormLabels.Save,
category: "Subnet",
@@ -82,7 +84,7 @@ const AddStaticRouteForm = ({
dispatch(staticRouteActions.cleanup());
dispatch(
staticRouteActions.create({
- source: subnetId,
+ source: id,
gateway_ip,
destination: toFormikNumber(destination) as number,
metric: toFormikNumber(metric),
@@ -90,7 +92,7 @@ const AddStaticRouteForm = ({
);
}}
onSuccess={() => {
- handleDismiss();
+ handleClose();
}}
resetOnSave
saved={saved}
@@ -99,10 +101,10 @@ const AddStaticRouteForm = ({
validationSchema={addStaticRouteSchema}
>
-
+
-
+
-
+
diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.test.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.test.tsx
new file mode 100644
index 0000000000..0d7508f76e
--- /dev/null
+++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.test.tsx
@@ -0,0 +1,63 @@
+import configureStore from "redux-mock-store";
+
+import DeleteStaticRouteForm from "./DeleteStaticRouteForm";
+
+import type { RootState } from "@/app/store/root/types";
+import { actions as staticRouteActions } from "@/app/store/staticroute";
+import {
+ rootState as rootStateFactory,
+ staticRouteState as staticRouteStateFactory,
+ subnet as subnetFactory,
+ subnetState as subnetStateFactory,
+ authState as authStateFactory,
+ user as userFactory,
+ userState as userStateFactory,
+} from "@/testing/factories";
+import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils";
+
+let state: RootState;
+const mockStore = configureStore();
+
+const subnet = subnetFactory({ id: 1, cidr: "172.16.1.0/24" });
+const destinationSubnet = subnetFactory({ id: 2, cidr: "223.16.1.0/24" });
+state = rootStateFactory({
+ user: userStateFactory({
+ auth: authStateFactory({
+ user: userFactory(),
+ }),
+ items: [userFactory(), userFactory(), userFactory()],
+ }),
+ staticroute: staticRouteStateFactory({
+ loaded: true,
+ items: [],
+ }),
+ subnet: subnetStateFactory({
+ loaded: true,
+ items: [subnet, destinationSubnet],
+ }),
+});
+
+it("renders", () => {
+ renderWithBrowserRouter(
+ ,
+ { state }
+ );
+
+ expect(screen.getByRole("form", { name: "Confirm static route deletion" }));
+});
+
+it("dispatches the correct action to delete a static route", async () => {
+ const store = mockStore(state);
+ renderWithBrowserRouter(
+ ,
+ { store }
+ );
+
+ await userEvent.click(screen.getByRole("button", { name: /delete/i }));
+
+ const action = store
+ .getActions()
+ .find((action) => action.type === staticRouteActions.delete.type);
+
+ expect(action).toStrictEqual(staticRouteActions.delete(subnet.id));
+});
diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.tsx
new file mode 100644
index 0000000000..a61d59f6f2
--- /dev/null
+++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/DeleteStaticRouteForm.tsx
@@ -0,0 +1,33 @@
+import { useDispatch, useSelector } from "react-redux";
+
+import ModelDeleteForm from "@/app/base/components/ModelDeleteForm";
+import type { SetSidePanelContent } from "@/app/base/side-panel-context";
+import { actions as staticRouteActions } from "@/app/store/staticroute";
+import staticRouteSelectors from "@/app/store/staticroute/selectors";
+import type { Subnet, SubnetMeta } from "@/app/store/subnet/types";
+
+type Props = {
+ id: Subnet[SubnetMeta.PK];
+ setActiveForm: SetSidePanelContent;
+};
+
+const DeleteStaticRouteForm = ({ id, setActiveForm }: Props) => {
+ const dispatch = useDispatch();
+ const saved = useSelector(staticRouteSelectors.saved);
+ const saving = useSelector(staticRouteSelectors.saving);
+ return (
+ setActiveForm(null)}
+ onSubmit={() => {
+ dispatch(staticRouteActions.delete(id));
+ }}
+ saved={saved}
+ saving={saving}
+ />
+ );
+};
+
+export default DeleteStaticRouteForm;
diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/index.ts b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/index.ts
new file mode 100644
index 0000000000..2c376627a6
--- /dev/null
+++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/DeleteStaticRouteform/index.ts
@@ -0,0 +1 @@
+export { default } from "./DeleteStaticRouteForm";
diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.test.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.test.tsx
index ceb8b9dea5..b6454c129e 100644
--- a/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.test.tsx
+++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.test.tsx
@@ -36,7 +36,7 @@ it("displays loading text on load", async () => {
-
+
@@ -79,10 +79,7 @@ it("dispatches a correct action on edit static route form submit", async () => {
-
+
diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.tsx
index 80ebc82eeb..51a3cf6b55 100644
--- a/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.tsx
+++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/EditStaticRouteForm/EditStaticRouteForm.tsx
@@ -8,6 +8,7 @@ import FormikField from "@/app/base/components/FormikField";
import FormikForm from "@/app/base/components/FormikForm";
import SubnetSelect from "@/app/base/components/SubnetSelect";
import { useFetchActions } from "@/app/base/hooks";
+import type { SetSidePanelContent } from "@/app/base/side-panel-context";
import type { RootState } from "@/app/store/root/types";
import { actions as staticRouteActions } from "@/app/store/staticroute";
import staticRouteSelectors from "@/app/store/staticroute/selectors";
@@ -38,22 +39,23 @@ const editStaticRouteSchema = Yup.object().shape({
});
export type Props = {
- staticRouteId: StaticRoute[StaticRouteMeta.PK];
- handleDismiss: () => void;
+ id: StaticRoute[StaticRouteMeta.PK];
+ setActiveForm: SetSidePanelContent;
};
const EditStaticRouteForm = ({
- staticRouteId,
- handleDismiss,
+ id,
+ setActiveForm,
}: Props): JSX.Element | null => {
const staticRouteErrors = useSelector(staticRouteSelectors.errors);
const saving = useSelector(staticRouteSelectors.saving);
const saved = useSelector(staticRouteSelectors.saved);
const dispatch = useDispatch();
+ const handleClose = () => setActiveForm(null);
const staticRoutesLoading = useSelector(staticRouteSelectors.loading);
const subnetsLoading = useSelector(subnetSelectors.loading);
const loading = staticRoutesLoading || subnetsLoading;
const staticRoute = useSelector((state: RootState) =>
- staticRouteSelectors.getById(state, staticRouteId)
+ staticRouteSelectors.getById(state, id)
);
const source = useSelector((state: RootState) =>
subnetSelectors.getById(state, staticRoute?.source)
@@ -78,7 +80,7 @@ const EditStaticRouteForm = ({
destination: staticRoute.destination,
metric: staticRoute.metric,
}}
- onCancel={handleDismiss}
+ onCancel={handleClose}
onSaveAnalytics={{
action: EditStaticRouteFormLabels.Save,
category: "Subnet",
@@ -88,7 +90,7 @@ const EditStaticRouteForm = ({
dispatch(staticRouteActions.cleanup());
dispatch(
staticRouteActions.update({
- id: staticRouteId,
+ id: id,
source: staticRoute.source,
gateway_ip,
destination: toFormikNumber(destination) as number,
@@ -97,7 +99,7 @@ const EditStaticRouteForm = ({
);
}}
onSuccess={() => {
- handleDismiss();
+ handleClose();
}}
resetOnSave
saved={saved}
@@ -106,10 +108,10 @@ const EditStaticRouteForm = ({
validationSchema={editStaticRouteSchema}
>
-
+
-
+
-
+
diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.test.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.test.tsx
index 68b63d44f1..3e0e3539d0 100644
--- a/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.test.tsx
+++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.test.tsx
@@ -4,7 +4,6 @@ import { CompatRouter } from "react-router-dom-v5-compat";
import configureStore from "redux-mock-store";
import { AddStaticRouteFormLabels } from "./AddStaticRouteForm/AddStaticRouteForm";
-import { EditStaticRouteFormLabels } from "./EditStaticRouteForm/EditStaticRouteForm";
import StaticRoutes, { Labels } from "./StaticRoutes";
import {
@@ -17,7 +16,7 @@ import {
user as userFactory,
userState as userStateFactory,
} from "@/testing/factories";
-import { userEvent, render, screen, waitFor, within } from "@/testing/utils";
+import { render, screen, waitFor } from "@/testing/utils";
const mockStore = configureStore();
@@ -72,7 +71,7 @@ it("renders for a subnet", () => {
).toBeInTheDocument();
});
-it("can open and close the add static route form", async () => {
+it("has a button to open the static route form", async () => {
const subnet = subnetFactory({ id: 1 });
const state = rootStateFactory({
user: userStateFactory({
@@ -106,37 +105,9 @@ it("can open and close the add static route form", async () => {
})
).toBeInTheDocument()
);
- await userEvent.click(
- screen.getByRole("button", {
- name: AddStaticRouteFormLabels.AddStaticRoute,
- })
- );
- await waitFor(() =>
- expect(
- screen.getByRole("form", {
- name: AddStaticRouteFormLabels.AddStaticRoute,
- })
- )
- );
-
- await userEvent.click(
- within(
- screen.getByRole("form", {
- name: AddStaticRouteFormLabels.AddStaticRoute,
- })
- ).getByRole("button", { name: "Cancel" })
- );
-
- await waitFor(() =>
- expect(
- screen.queryByRole("form", {
- name: AddStaticRouteFormLabels.AddStaticRoute,
- })
- ).not.toBeInTheDocument()
- );
});
-it("can open and close the edit static route form", async () => {
+it("has a button to open the edit static route form", async () => {
const subnet = subnetFactory({ id: 1 });
const state = rootStateFactory({
staticroute: staticRouteStateFactory({
@@ -163,33 +134,9 @@ it("can open and close the edit static route form", async () => {
);
- await userEvent.click(
+ expect(
screen.getByRole("button", {
name: "Edit",
})
- );
-
- await waitFor(() =>
- expect(
- screen.getByRole("form", {
- name: EditStaticRouteFormLabels.EditStaticRoute,
- })
- ).toBeInTheDocument()
- );
-
- await userEvent.click(
- within(
- screen.getByRole("form", {
- name: EditStaticRouteFormLabels.EditStaticRoute,
- })
- ).getByRole("button", { name: "Cancel" })
- );
-
- await waitFor(() =>
- expect(
- screen.queryByRole("form", {
- name: EditStaticRouteFormLabels.EditStaticRoute,
- })
- ).not.toBeInTheDocument()
- );
+ ).toBeInTheDocument();
});
diff --git a/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.tsx b/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.tsx
index 3cb4d308c3..06c1d51b92 100644
--- a/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.tsx
+++ b/src/app/subnets/views/SubnetDetails/StaticRoutes/StaticRoutes.tsx
@@ -1,26 +1,18 @@
-import type { ReactNode } from "react";
-import { useState } from "react";
-
import { Button, MainTable, Spinner } from "@canonical/react-components";
-import { useDispatch, useSelector } from "react-redux";
-import type { Dispatch } from "redux";
+import { useSelector } from "react-redux";
-import AddStaticRouteForm from "./AddStaticRouteForm";
-import EditStaticRouteForm from "./EditStaticRouteForm";
+import { SubnetActionTypes, SubnetDetailsSidePanelViews } from "../constants";
-import FormCard from "@/app/base/components/FormCard";
import SubnetLink from "@/app/base/components/SubnetLink";
import TableActions from "@/app/base/components/TableActions";
-import TableDeleteConfirm from "@/app/base/components/TableDeleteConfirm";
import TitledSection from "@/app/base/components/TitledSection";
import { useFetchActions } from "@/app/base/hooks";
+import type { SetSidePanelContent } from "@/app/base/side-panel-context";
+import { useSidePanel } from "@/app/base/side-panel-context";
import authSelectors from "@/app/store/auth/selectors";
import { actions as staticRouteActions } from "@/app/store/staticroute";
import staticRouteSelectors from "@/app/store/staticroute/selectors";
-import type {
- StaticRoute,
- StaticRouteMeta,
-} from "@/app/store/staticroute/types";
+import type { StaticRoute } from "@/app/store/staticroute/types";
import { actions as subnetActions } from "@/app/store/subnet";
import subnetSelectors from "@/app/store/subnet/selectors";
import type { Subnet, SubnetMeta } from "@/app/store/subnet/types";
@@ -43,72 +35,16 @@ export enum ExpandedType {
Update,
}
-type Expanded = {
- id?: StaticRoute[StaticRouteMeta.PK];
- type: ExpandedType;
-};
-
-const toggleExpanded = (
- id: StaticRoute[StaticRouteMeta.PK],
- expanded: Expanded | null,
- expandedType: ExpandedType,
- setExpanded: (expanded: Expanded | null) => void
-) =>
- setExpanded(
- expanded?.id === id && expanded.type === expandedType
- ? null
- : {
- id,
- type: expandedType,
- }
- );
-
const generateRows = (
- dispatch: Dispatch,
staticRoutes: StaticRoute[],
subnets: Subnet[],
- expanded: Expanded | null,
- setExpanded: (expanded: Expanded | null) => void,
- saved: boolean,
- saving: boolean
+ setSidePanelContent: SetSidePanelContent
) =>
staticRoutes.map((staticRoute: StaticRoute) => {
const subnet = subnets.find(
(subnet) => subnet.id === staticRoute.destination
);
- const isExpanded = expanded?.id === staticRoute.id;
- let expandedContent: ReactNode | null = null;
- const onClose = () => setExpanded(null);
- if (expanded?.type === ExpandedType.Delete) {
- expandedContent = (
- {
- dispatch(staticRouteActions.delete(staticRoute.id));
- }}
- sidebar={false}
- />
- );
- } else if (expanded?.type === ExpandedType.Update) {
- expandedContent = (
-
- toggleExpanded(
- staticRoute.id,
- expanded,
- ExpandedType.Update,
- setExpanded
- )
- }
- staticRouteId={staticRoute.id}
- />
- );
- }
return {
- className: isExpanded ? "p-table__row is-active" : null,
columns: [
{
"aria-label": Labels.GatewayIp,
@@ -127,28 +63,24 @@ const generateRows = (
content: (
{
- toggleExpanded(
- staticRoute.id,
- expanded,
- ExpandedType.Delete,
- setExpanded
- );
+ setSidePanelContent({
+ view: SubnetDetailsSidePanelViews[
+ SubnetActionTypes.DeleteStaticRoute
+ ],
+ });
}}
onEdit={() => {
- toggleExpanded(
- staticRoute.id,
- expanded,
- ExpandedType.Update,
- setExpanded
- );
+ setSidePanelContent({
+ view: SubnetDetailsSidePanelViews[
+ SubnetActionTypes.EditStaticRoute
+ ],
+ });
}}
/>
),
className: "u-align--right",
},
],
- expanded: isExpanded,
- expandedContent: expandedContent,
key: staticRoute.id,
sortData: {
destination: getSubnetDisplay(subnet),
@@ -159,11 +91,8 @@ const generateRows = (
});
const StaticRoutes = ({ subnetId }: Props): JSX.Element | null => {
- const dispatch = useDispatch();
- const [expanded, setExpanded] = useState(null);
+ const { sidePanelContent, setSidePanelContent } = useSidePanel();
const staticRoutesLoading = useSelector(staticRouteSelectors.loading);
- const saved = useSelector(staticRouteSelectors.saved);
- const saving = useSelector(staticRouteSelectors.saving);
const staticRoutes = useSelector(staticRouteSelectors.all).filter(
(staticRoute) => staticRoute.source === subnetId
);
@@ -171,7 +100,9 @@ const StaticRoutes = ({ subnetId }: Props): JSX.Element | null => {
const subnetsLoading = useSelector(subnetSelectors.loading);
const isAdmin = useSelector(authSelectors.isAdmin);
const loading = staticRoutesLoading || subnetsLoading;
- const isAddStaticRouteOpen = expanded?.type === ExpandedType.Create;
+ const isAddStaticRouteOpen =
+ sidePanelContent?.view ===
+ SubnetDetailsSidePanelViews[SubnetActionTypes.AddStaticRoute];
useFetchActions([staticRouteActions.fetch, subnetActions.fetch]);
@@ -182,7 +113,11 @@ const StaticRoutes = ({ subnetId }: Props): JSX.Element | null => {