diff --git a/src/app/base/components/SSHKeyList/SSHKeyList.test.tsx b/src/app/base/components/SSHKeyList/SSHKeyList.test.tsx
index 570a46f668..1965326be7 100644
--- a/src/app/base/components/SSHKeyList/SSHKeyList.test.tsx
+++ b/src/app/base/components/SSHKeyList/SSHKeyList.test.tsx
@@ -2,6 +2,8 @@ import configureStore from "redux-mock-store";
import SSHKeyList from "./SSHKeyList";
+import * as sidePanelHooks from "@/app/base/side-panel-context";
+import urls from "@/app/preferences/urls";
import type { RootState } from "@/app/store/root/types";
import {
sshKey as sshKeyFactory,
@@ -19,8 +21,15 @@ const mockStore = configureStore();
describe("SSHKeyList", () => {
let state: RootState;
+ const setSidePanelContent = vi.fn();
beforeEach(() => {
+ vi.spyOn(sidePanelHooks, "useSidePanel").mockReturnValue({
+ setSidePanelContent,
+ sidePanelContent: null,
+ setSidePanelSize: vi.fn(),
+ sidePanelSize: "regular",
+ });
state = rootStateFactory({
sshkey: sshKeyStateFactory({
loading: false,
@@ -116,7 +125,7 @@ describe("SSHKeyList", () => {
).toBeInTheDocument();
});
- it("can show a delete confirmation", async () => {
+ it("can trigger a delete confirmation form", async () => {
renderWithBrowserRouter(, {
route: "/account/prefs/ssh-keys",
state,
@@ -125,71 +134,7 @@ describe("SSHKeyList", () => {
expect(row).not.toHaveClass("is-active");
// Click on the delete button:
await userEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]);
- row = screen.getAllByTestId("sshkey-row")[0];
- expect(row).toHaveClass("is-active");
- expect(
- screen.getByText("Are you sure you want to delete this SSH key?")
- ).toBeInTheDocument();
- });
-
- it("can delete a SSH key", async () => {
- const store = mockStore(state);
- renderWithBrowserRouter(, {
- route: "/account/prefs/ssh-keys",
- store,
- });
- // Click on the delete button:
- await userEvent.click(screen.getAllByText("Delete")[0]);
- // Click on the delete confirm button
- await userEvent.click(screen.getByTestId("action-confirm"));
- expect(
- store.getActions().find((action) => action.type === "sshkey/delete")
- ).toEqual({
- type: "sshkey/delete",
- payload: {
- params: {
- id: 1,
- },
- },
- meta: {
- model: "sshkey",
- method: "delete",
- },
- });
- });
-
- it("can delete a group of SSH keys", async () => {
- const store = mockStore(state);
- renderWithBrowserRouter(, {
- route: "/account/prefs/ssh-keys",
- store,
- });
- // Click on the delete button:
- await userEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]);
- // Click on the delete confirm button
- await userEvent.click(screen.getByTestId("action-confirm"));
- expect(
- store.getActions().filter((action) => action.type === "sshkey/delete")
- .length
- ).toEqual(2);
- });
-
- it("can add a message when a SSH key is deleted", async () => {
- state.sshkey.saved = true;
- const store = mockStore(state);
- renderWithBrowserRouter(, {
- route: "/account/prefs/ssh-keys",
- store,
- });
- // Click on the delete button:
- await userEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]);
- // Simulate clicking on the delete confirm button.
- await userEvent.click(screen.getByTestId("action-confirm"));
- const actions = store.getActions();
- expect(actions.some((action) => action.type === "sshkey/cleanup")).toBe(
- true
- );
- expect(actions.some((action) => action.type === "message/add")).toBe(true);
+ expect(window.location.pathname).toBe(urls.sshKeys.delete);
});
it("displays a message if there are no SSH keys", () => {
diff --git a/src/app/base/components/SSHKeyList/SSHKeyList.tsx b/src/app/base/components/SSHKeyList/SSHKeyList.tsx
index cbc0c97487..17fed08ad3 100644
--- a/src/app/base/components/SSHKeyList/SSHKeyList.tsx
+++ b/src/app/base/components/SSHKeyList/SSHKeyList.tsx
@@ -1,26 +1,19 @@
import type { ReactNode } from "react";
-import { useState } from "react";
import { Button, Notification } from "@canonical/react-components";
-import { useDispatch, useSelector } from "react-redux";
-import type { Dispatch } from "redux";
+import { useSelector } from "react-redux";
+import type { NavigateFunction } from "react-router-dom-v5-compat";
+import { useNavigate } from "react-router-dom-v5-compat";
-import TableDeleteConfirm from "@/app/base/components/TableDeleteConfirm";
-import { useFetchActions, useAddMessage } from "@/app/base/hooks";
+import { useFetchActions } from "@/app/base/hooks";
+import urls from "@/app/preferences/urls";
import SettingsTable from "@/app/settings/components/SettingsTable";
import type { Props as SettingsTableProps } from "@/app/settings/components/SettingsTable/SettingsTable";
import { actions as sshkeyActions } from "@/app/store/sshkey";
import sshkeySelectors from "@/app/store/sshkey/selectors";
-import type {
- KeySource,
- SSHKey,
- SSHKeyMeta,
- SSHKeyState,
-} from "@/app/store/sshkey/types";
+import type { KeySource, SSHKey } from "@/app/store/sshkey/types";
-type Props = {
- sidebar?: boolean;
-} & Partial;
+type Props = Partial;
const formatKey = (key: SSHKey["key"]) => {
const parts = key.split(" ");
@@ -79,21 +72,10 @@ const generateKeyCols = (keys: SSHKey[], deleteButton: ReactNode) => {
);
};
-const generateRows = (
- sshkeys: SSHKey[],
- expandedId: SSHKey[SSHKeyMeta.PK] | null,
- setExpandedId: (id: SSHKey[SSHKeyMeta.PK] | null) => void,
- hideExpanded: () => void,
- dispatch: Dispatch,
- saved: SSHKeyState["saved"],
- saving: SSHKeyState["saving"],
- sidebar: boolean,
- setDeleting: (ids: SSHKey[SSHKeyMeta.PK][]) => void
-) =>
+const generateRows = (sshkeys: SSHKey[], navigate: NavigateFunction) =>
groupBySource(sshkeys).map(([id, group]) => {
- const expanded = expandedId === id;
+ const ids: number[] = group.keys.map((key: SSHKey) => key.id);
return {
- className: expanded ? "p-table__row is-active" : null,
columns: [
{
content: group.source,
@@ -108,9 +90,12 @@ const generateRows = (
appearance="base"
className="is-dense u-table-cell-padding-overlap"
hasIcon
- onClick={() => {
- setExpandedId(id);
- }}
+ onClick={() =>
+ navigate({
+ pathname: urls.sshKeys.delete,
+ search: `?ids=${ids.join()}`,
+ })
+ }
>
Delete
@@ -118,24 +103,6 @@ const generateRows = (
},
],
"data-testid": "sshkey-row",
- expanded: expanded,
- expandedContent: expanded && (
- 1 ? "these SSH keys" : "this SSH key"
- }?`}
- onClose={hideExpanded}
- onConfirm={() => {
- group.keys.forEach((key: SSHKey) => {
- dispatch(sshkeyActions.delete(key.id));
- });
- setDeleting(group.keys.map(({ id }: SSHKey) => id));
- }}
- sidebar={sidebar}
- />
- ),
key: id,
sortData: {
source: group.source,
@@ -144,32 +111,12 @@ const generateRows = (
};
});
-const SSHKeyList = ({ sidebar = true, ...tableProps }: Props): JSX.Element => {
- const [expandedId, setExpandedId] = useState(
- null
- );
- const [deleting, setDeleting] = useState([]);
+const SSHKeyList = ({ ...tableProps }: Props): JSX.Element => {
const sshkeyErrors = useSelector(sshkeySelectors.errors);
const sshkeyLoading = useSelector(sshkeySelectors.loading);
const sshkeyLoaded = useSelector(sshkeySelectors.loaded);
const sshkeys = useSelector(sshkeySelectors.all);
- const saved = useSelector(sshkeySelectors.saved);
- const saving = useSelector(sshkeySelectors.saving);
- const dispatch = useDispatch();
- const sshKeysDeleted =
- deleting.length > 0 &&
- !deleting.some((id) => !sshkeys.find((key) => key.id === id));
-
- useAddMessage(
- saved && sshKeysDeleted,
- sshkeyActions.cleanup,
- "SSH key removed successfully.",
- () => setDeleting([])
- );
-
- const hideExpanded = () => {
- setExpandedId(null);
- };
+ const navigate = useNavigate();
useFetchActions([sshkeyActions.fetch]);
@@ -203,17 +150,7 @@ const SSHKeyList = ({ sidebar = true, ...tableProps }: Props): JSX.Element => {
]}
loaded={sshkeyLoaded}
loading={sshkeyLoading}
- rows={generateRows(
- sshkeys,
- expandedId,
- setExpandedId,
- hideExpanded,
- dispatch,
- saved,
- saving,
- sidebar,
- setDeleting
- )}
+ rows={generateRows(sshkeys, navigate)}
tableClassName="sshkey-list"
{...tableProps}
/>
diff --git a/src/app/base/side-panel-context.tsx b/src/app/base/side-panel-context.tsx
index 801a41e240..01f4aee304 100644
--- a/src/app/base/side-panel-context.tsx
+++ b/src/app/base/side-panel-context.tsx
@@ -23,6 +23,8 @@ import {
NetworkDiscoverySidePanelViews,
type NetworkDiscoverySidePanelContent,
} from "@/app/networkDiscovery/views/constants";
+import { PreferenceSidePanelViews } from "@/app/preferences/constants";
+import type { PreferenceSidePanelContent } from "@/app/preferences/types";
import {
SubnetSidePanelViews,
type SubnetSidePanelContent,
@@ -64,6 +66,7 @@ export type SidePanelContent =
| VLANDetailsSidePanelContent
| FabricDetailsSidePanelContent
| ImageSidePanelContent
+ | PreferenceSidePanelContent
| SubnetDetailsSidePanelContent
| SpaceDetailsSidePanelContent
| null;
@@ -93,6 +96,7 @@ export const SidePanelViews = {
...VLANDetailsSidePanelViews,
...FabricDetailsSidePanelViews,
...ImageSidePanelViews,
+ ...PreferenceSidePanelViews,
...SubnetDetailsSidePanelViews,
...SpaceDetailsSidePanelViews,
} as const;
diff --git a/src/app/intro/views/UserIntro/UserIntro.tsx b/src/app/intro/views/UserIntro/UserIntro.tsx
index 79104fbe78..ddfea35ae7 100644
--- a/src/app/intro/views/UserIntro/UserIntro.tsx
+++ b/src/app/intro/views/UserIntro/UserIntro.tsx
@@ -55,7 +55,7 @@ const UserIntro = (): JSX.Element => {
Add multiple keys from Launchpad and Github or enter them manually.
Keys
- {hasSSHKeys ? : null}
+ {hasSSHKeys ? : null}
{
}
path={getRelativeRoute(urls.preferences.sshKeys.add, base)}
/>
+ }
+ sidePanelTitle="Delete SSH key"
+ >
+
+
+ }
+ path={getRelativeRoute(urls.preferences.sshKeys.delete, base)}
+ />
diff --git a/src/app/preferences/constants.ts b/src/app/preferences/constants.ts
index f33a74ccc0..b5b1d67c86 100644
--- a/src/app/preferences/constants.ts
+++ b/src/app/preferences/constants.ts
@@ -19,3 +19,7 @@ export const preferencesNavItems: NavItem[] = [
label: "SSL keys",
},
];
+
+export const PreferenceSidePanelViews = {
+ DELETE_SSH_KEYS: ["", "deleteSSHKeys"],
+} as const;
diff --git a/src/app/preferences/types.ts b/src/app/preferences/types.ts
new file mode 100644
index 0000000000..a14e4468ac
--- /dev/null
+++ b/src/app/preferences/types.ts
@@ -0,0 +1,17 @@
+import type { ValueOf } from "@canonical/react-components";
+
+import type { PreferenceSidePanelViews } from "./constants";
+
+import type { SidePanelContent, SetSidePanelContent } from "@/app/base/types";
+import type { SSHKey } from "@/app/store/sshkey/types";
+
+type SSHKeyGroup = {
+ keys: SSHKey[];
+};
+export type PreferenceSidePanelContent = SidePanelContent<
+ ValueOf,
+ { group?: SSHKeyGroup }
+>;
+
+export type PreferenceSetSidePanelContent =
+ SetSidePanelContent;
diff --git a/src/app/preferences/urls.ts b/src/app/preferences/urls.ts
index ca8d9d629f..d0b9b4d6b9 100644
--- a/src/app/preferences/urls.ts
+++ b/src/app/preferences/urls.ts
@@ -12,6 +12,7 @@ const urls = {
index: "/account/prefs",
sshKeys: {
add: "/account/prefs/ssh-keys/add",
+ delete: "/account/prefs/ssh-keys/delete",
index: "/account/prefs/ssh-keys",
},
sslKeys: {
diff --git a/src/app/preferences/views/SSHKeys/DeleteSSHKey/DeleteSSHKey.test.tsx b/src/app/preferences/views/SSHKeys/DeleteSSHKey/DeleteSSHKey.test.tsx
new file mode 100644
index 0000000000..d719415440
--- /dev/null
+++ b/src/app/preferences/views/SSHKeys/DeleteSSHKey/DeleteSSHKey.test.tsx
@@ -0,0 +1,99 @@
+import configureStore from "redux-mock-store";
+
+import DeleteSSHKey from "./DeleteSSHKey";
+
+import * as sidePanelHooks from "@/app/base/side-panel-context";
+import { PreferenceSidePanelViews } from "@/app/preferences/constants";
+import type { RootState } from "@/app/store/root/types";
+import {
+ sshKey as sshKeyFactory,
+ sshKeyState as sshKeyStateFactory,
+ rootState as rootStateFactory,
+} from "@/testing/factories";
+import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils";
+
+let state: RootState;
+const mockStore = configureStore();
+
+beforeEach(() => {
+ const keys = [
+ sshKeyFactory({
+ id: 1,
+ key: "ssh-rsa aabb",
+ keysource: { protocol: "lp", auth_id: "koalaparty" },
+ }),
+ sshKeyFactory({
+ id: 2,
+ key: "ssh-rsa ccdd",
+ keysource: { protocol: "gh", auth_id: "koalaparty" },
+ }),
+ sshKeyFactory({
+ id: 3,
+ key: "ssh-rsa eeff",
+ keysource: { protocol: "lp", auth_id: "maaate" },
+ }),
+ sshKeyFactory({
+ id: 4,
+ key: "ssh-rsa gghh",
+ keysource: { protocol: "gh", auth_id: "koalaparty" },
+ }),
+ sshKeyFactory({ id: 5, key: "ssh-rsa gghh" }),
+ ];
+ vi.spyOn(sidePanelHooks, "useSidePanel").mockReturnValue({
+ setSidePanelContent: vi.fn(),
+ sidePanelContent: {
+ view: PreferenceSidePanelViews.DELETE_SSH_KEYS,
+ extras: { group: { keys } },
+ },
+ setSidePanelSize: vi.fn(),
+ sidePanelSize: "regular",
+ });
+ state = rootStateFactory({
+ sshkey: sshKeyStateFactory({
+ loading: false,
+ loaded: true,
+ items: keys,
+ }),
+ });
+});
+
+it("renders", () => {
+ renderWithBrowserRouter(, {
+ state,
+ route: "/account/prefs/ssh-keys/delete?ids=2,3",
+ });
+ expect(
+ screen.getByRole("form", { name: "Delete SSH key confirmation" })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("Are you sure you want to delete these SSH keys?")
+ ).toBeInTheDocument();
+});
+
+it("can delete a group of SSH keys", async () => {
+ const store = mockStore(state);
+ renderWithBrowserRouter(, {
+ route: "/account/prefs/ssh-keys/delete?ids=2,3",
+ store,
+ });
+ await userEvent.click(screen.getByRole("button", { name: /delete/i }));
+
+ expect(
+ store.getActions().some((action) => action.type === "sshkey/delete")
+ ).toBe(true);
+});
+
+it("can add a message when a SSH key is deleted", async () => {
+ state.sshkey.saved = true;
+ const store = mockStore(state);
+ renderWithBrowserRouter(, {
+ route: "/account/prefs/ssh-keys/delete?ids=2,3",
+ store,
+ });
+ // Click on the delete button:
+ await userEvent.click(screen.getByRole("button", { name: /delete/i }));
+
+ const actions = store.getActions();
+ expect(actions.some((action) => action.type === "sshkey/cleanup")).toBe(true);
+ expect(actions.some((action) => action.type === "message/add")).toBe(true);
+});
diff --git a/src/app/preferences/views/SSHKeys/DeleteSSHKey/DeleteSSHKey.tsx b/src/app/preferences/views/SSHKeys/DeleteSSHKey/DeleteSSHKey.tsx
new file mode 100644
index 0000000000..62776bc680
--- /dev/null
+++ b/src/app/preferences/views/SSHKeys/DeleteSSHKey/DeleteSSHKey.tsx
@@ -0,0 +1,68 @@
+import { useState } from "react";
+
+import { useOnEscapePressed } from "@canonical/react-components";
+import { useDispatch, useSelector } from "react-redux";
+import { useNavigate, useSearchParams } from "react-router-dom-v5-compat";
+
+import ModelActionForm from "@/app/base/components/ModelActionForm";
+import { useAddMessage } from "@/app/base/hooks";
+import urls from "@/app/preferences/urls";
+import { actions as sshkeyActions } from "@/app/store/sshkey";
+import sshkeySelectors from "@/app/store/sshkey/selectors";
+import type { SSHKey, SSHKeyMeta } from "@/app/store/sshkey/types";
+
+const DeleteSSHKey = () => {
+ const [deleting, setDeleting] = useState([]);
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const saved = useSelector(sshkeySelectors.saved);
+ const saving = useSelector(sshkeySelectors.saving);
+ const sshkeys = useSelector(sshkeySelectors.all);
+ const dispatch = useDispatch();
+ const onClose = () => navigate({ pathname: urls.sshKeys.index });
+ useOnEscapePressed(() => onClose());
+ const sshKeysDeleted =
+ deleting.length > 0 &&
+ !deleting.some((id) => !sshkeys.find((key) => key.id === id));
+ useAddMessage(
+ saved && sshKeysDeleted,
+ sshkeyActions.cleanup,
+ "SSH key removed successfully.",
+ () => setDeleting([])
+ );
+
+ const ids = searchParams
+ .get("ids")
+ ?.split(",")
+ .map((id) => Number(id));
+
+ if (!ids || ids?.length === 0) {
+ return SSH key not found
;
+ }
+
+ return (
+ 1 ? "these SSH keys" : "this SSH key"
+ }?`}
+ modelType="SSH key"
+ onCancel={onClose}
+ onSubmit={() => {
+ ids.forEach((id) => {
+ dispatch(sshkeyActions.delete(id));
+ });
+ setDeleting(ids);
+ }}
+ onSuccess={() => {
+ dispatch(sshkeyActions.cleanup());
+ onClose();
+ }}
+ saved={saved}
+ saving={saving}
+ />
+ );
+};
+
+export default DeleteSSHKey;
diff --git a/src/app/preferences/views/SSHKeys/DeleteSSHKey/index.ts b/src/app/preferences/views/SSHKeys/DeleteSSHKey/index.ts
new file mode 100644
index 0000000000..d12c289300
--- /dev/null
+++ b/src/app/preferences/views/SSHKeys/DeleteSSHKey/index.ts
@@ -0,0 +1 @@
+export { default } from "./DeleteSSHKey";