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";