diff --git a/src/app/base/components/ModelDeleteForm/ModelDeleteForm.test.tsx b/src/app/base/components/ModelActionForm/ModelActionForm.test.tsx similarity index 81% rename from src/app/base/components/ModelDeleteForm/ModelDeleteForm.test.tsx rename to src/app/base/components/ModelActionForm/ModelActionForm.test.tsx index b5f45b4cfb..71dfc6913a 100644 --- a/src/app/base/components/ModelDeleteForm/ModelDeleteForm.test.tsx +++ b/src/app/base/components/ModelActionForm/ModelActionForm.test.tsx @@ -1,10 +1,10 @@ -import ModelDeleteForm from "./ModelDeleteForm"; +import ModelActionForm from "./ModelActionForm"; import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils"; it("renders", () => { renderWithBrowserRouter( - { /> ); expect( - screen.getByText("Are you sure you want to delete this machine?") + screen.getByText( + "Are you sure you want to delete this machine? This action is permanent and can not be undone." + ) ).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); }); @@ -20,7 +22,7 @@ it("renders", () => { it("can confirm", async () => { const onSubmit = vi.fn(); renderWithBrowserRouter( - { it("can cancel", async () => { const onCancel = vi.fn(); renderWithBrowserRouter( - ; -const ModelDeleteForm = ({ +const ModelActionForm = ({ modelType, message, submitAppearance = "negative", @@ -29,15 +29,12 @@ const ModelDeleteForm = ({

{message ? message - : `Are you sure you want to delete this ${modelType}?`} + : `Are you sure you want to delete this ${modelType}? This action is permanent and can not be undone.`}

- - This action is permanent and can not be undone. - ); }; -export default ModelDeleteForm; +export default ModelActionForm; diff --git a/src/app/base/components/ModelActionForm/index.ts b/src/app/base/components/ModelActionForm/index.ts new file mode 100644 index 0000000000..b9cb229245 --- /dev/null +++ b/src/app/base/components/ModelActionForm/index.ts @@ -0,0 +1 @@ +export { default } from "./ModelActionForm"; diff --git a/src/app/base/components/ModelDeleteForm/index.ts b/src/app/base/components/ModelDeleteForm/index.ts deleted file mode 100644 index 1717de3f26..0000000000 --- a/src/app/base/components/ModelDeleteForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./ModelDeleteForm"; diff --git a/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/RemoveInterface/RemoveInterface.tsx b/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/RemoveInterface/RemoveInterface.tsx index f79e36e8d1..59cbae5d3d 100644 --- a/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/RemoveInterface/RemoveInterface.tsx +++ b/src/app/devices/views/DeviceDetails/DeviceNetwork/DeviceNetworkTable/RemoveInterface/RemoveInterface.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { Notification } from "@canonical/react-components"; import { useDispatch, useSelector } from "react-redux"; -import ModelDeleteForm from "@/app/base/components/ModelDeleteForm"; +import ModelActionForm from "@/app/base/components/ModelActionForm"; import { useCycled, useSendAnalyticsWhen } from "@/app/base/hooks"; import { actions as deviceActions } from "@/app/store/device"; import deviceSelectors from "@/app/store/device/selectors"; @@ -57,7 +57,7 @@ const RemoveInterface = ({ ) : null} - { + const sidePanelContent: DomainListSidePanelContent = { + view: DomainListSidePanelViews.ADD_DOMAIN, + }; + renderWithBrowserRouter( + , + { state } + ); + expect(screen.getByRole("form", { name: AddDomainLabels.FormLabel })); +}); + +it("can render the SetDefault form", () => { + const sidePanelContent: DomainListSidePanelContent = { + view: DomainListSidePanelViews.SET_DEFAULT, + extras: { + id: domain.id, + }, + }; + renderWithBrowserRouter( + , + { state } + ); + expect(screen.getByRole("form", { name: DomainTableLabels.FormTitle })); +}); diff --git a/src/app/domains/components/DomainForm/DomainForm.tsx b/src/app/domains/components/DomainForm/DomainForm.tsx new file mode 100644 index 0000000000..e33c25a633 --- /dev/null +++ b/src/app/domains/components/DomainForm/DomainForm.tsx @@ -0,0 +1,41 @@ +import { useCallback } from "react"; + +import SetDefaultForm from "../SetDefaultForm"; + +import type { SidePanelContentTypes } from "@/app/base/side-panel-context"; +import DomainListHeaderForm from "@/app/domains/views/DomainsList/DomainListHeaderForm"; +import { DomainListSidePanelViews } from "@/app/domains/views/DomainsList/constants"; +import { isId } from "@/app/utils"; + +type Props = SidePanelContentTypes & {}; + +const DomainForm = ({ + sidePanelContent, + setSidePanelContent, +}: Props): JSX.Element | null => { + const clearSidePanelContent = useCallback( + () => setSidePanelContent(null), + [setSidePanelContent] + ); + + if (!sidePanelContent) { + return null; + } + + switch (sidePanelContent.view) { + case DomainListSidePanelViews.ADD_DOMAIN: + return ; + case DomainListSidePanelViews.SET_DEFAULT: { + const id = + sidePanelContent.extras && "id" in sidePanelContent.extras + ? sidePanelContent.extras.id + : null; + if (!isId(id)) return null; + return ; + } + default: + return null; + } +}; + +export default DomainForm; diff --git a/src/app/domains/components/DomainForm/index.ts b/src/app/domains/components/DomainForm/index.ts new file mode 100644 index 0000000000..b8585e752f --- /dev/null +++ b/src/app/domains/components/DomainForm/index.ts @@ -0,0 +1 @@ +export { default } from "./DomainForm"; diff --git a/src/app/domains/components/SetDefaultForm/SetDefaultForm.test.tsx b/src/app/domains/components/SetDefaultForm/SetDefaultForm.test.tsx new file mode 100644 index 0000000000..6ce201c2fc --- /dev/null +++ b/src/app/domains/components/SetDefaultForm/SetDefaultForm.test.tsx @@ -0,0 +1,41 @@ +import configureStore from "redux-mock-store"; + +import SetDefaultForm from "./SetDefaultForm"; + +import { Labels as DomainTableLabels } from "@/app/domains/views/DomainsList/DomainsTable/DomainsTable"; +import type { RootState } from "@/app/store/root/types"; +import { + domain as domainFactory, + domainState as domainStateFactory, + rootState as rootStateFactory, +} from "@/testing/factories"; +import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils"; + +const mockStore = configureStore(); +const domain = domainFactory({ name: "test" }); +const state = rootStateFactory({ + domain: domainStateFactory({ + items: [domain], + }), +}); + +it("renders", () => { + renderWithBrowserRouter(, { + state, + }); + expect(screen.getByRole("form", { name: DomainTableLabels.FormTitle })); + expect(screen.getByText(DomainTableLabels.AreYouSure)).toBeInTheDocument(); +}); + +it("dispatches the set default action", async () => { + const store = mockStore(state); + renderWithBrowserRouter(, { + store, + }); + await userEvent.click( + screen.getByRole("button", { name: DomainTableLabels.ConfirmSetDefault }) + ); + expect( + store.getActions().some((action) => action.type === "domain/setDefault") + ).toBe(true); +}); diff --git a/src/app/domains/components/SetDefaultForm/SetDefaultForm.tsx b/src/app/domains/components/SetDefaultForm/SetDefaultForm.tsx new file mode 100644 index 0000000000..3f352771a0 --- /dev/null +++ b/src/app/domains/components/SetDefaultForm/SetDefaultForm.tsx @@ -0,0 +1,44 @@ +import { useDispatch, useSelector } from "react-redux"; + +import ModelActionForm from "@/app/base/components/ModelActionForm"; +import { Labels } from "@/app/domains/views/DomainsList/DomainsTable/DomainsTable"; +import { actions as domainActions } from "@/app/store/domain"; +import domainSelectors from "@/app/store/domain/selectors"; +import type { Domain, DomainMeta } from "@/app/store/domain/types"; + +type Props = { + id: Domain[DomainMeta.PK]; + onClose: () => void; +}; +const SetDefaultForm = ({ id, onClose }: Props) => { + const dispatch = useDispatch(); + const errors = useSelector(domainSelectors.errors); + const saving = useSelector(domainSelectors.saving); + const saved = useSelector(domainSelectors.saved); + return ( + { + dispatch(domainActions.cleanup()); + onClose(); + }} + onSubmit={() => { + dispatch(domainActions.setDefault(id)); + }} + onSuccess={() => { + dispatch(domainActions.cleanup()); + onClose(); + }} + saved={saved} + saving={saving} + submitAppearance="positive" + submitLabel={Labels.ConfirmSetDefault} + /> + ); +}; + +export default SetDefaultForm; diff --git a/src/app/domains/components/SetDefaultForm/index.ts b/src/app/domains/components/SetDefaultForm/index.ts new file mode 100644 index 0000000000..2711288274 --- /dev/null +++ b/src/app/domains/components/SetDefaultForm/index.ts @@ -0,0 +1 @@ +export { default } from "./SetDefaultForm"; diff --git a/src/app/domains/views/DomainsList/DomainsList.tsx b/src/app/domains/views/DomainsList/DomainsList.tsx index 503dea9c14..96e07e80b0 100644 --- a/src/app/domains/views/DomainsList/DomainsList.tsx +++ b/src/app/domains/views/DomainsList/DomainsList.tsx @@ -1,32 +1,20 @@ import { useSelector } from "react-redux"; import DomainListHeader from "./DomainListHeader"; -import DomainListHeaderForm from "./DomainListHeaderForm"; import DomainsTable from "./DomainsTable"; -import { DomainListSidePanelViews } from "./constants"; import PageContent from "@/app/base/components/PageContent"; import { useFetchActions, useWindowTitle } from "@/app/base/hooks"; import { useSidePanel } from "@/app/base/side-panel-context"; +import DomainForm from "@/app/domains/components/DomainForm"; import { actions } from "@/app/store/domain"; import domainsSelectors from "@/app/store/domain/selectors"; +import { getSidePanelTitle } from "@/app/store/utils/node/base"; const DomainsList = (): JSX.Element => { const domains = useSelector(domainsSelectors.all); const { sidePanelContent, setSidePanelContent } = useSidePanel(); - let content: JSX.Element | null = null; - - if (sidePanelContent?.view === DomainListSidePanelViews.ADD_DOMAIN) { - content = ( - { - setSidePanelContent(null); - }} - /> - ); - } - useWindowTitle("DNS"); useFetchActions([actions.fetch]); @@ -34,8 +22,15 @@ const DomainsList = (): JSX.Element => { return ( } - sidePanelContent={content} - sidePanelTitle={"Add domains"} + sidePanelContent={ + sidePanelContent && ( + + ) + } + sidePanelTitle={getSidePanelTitle("Domains", sidePanelContent)} > {domains.length > 0 && } diff --git a/src/app/domains/views/DomainsList/DomainsTable/DomainsTable.test.tsx b/src/app/domains/views/DomainsList/DomainsTable/DomainsTable.test.tsx index 687df90bb1..251714bc36 100644 --- a/src/app/domains/views/DomainsList/DomainsTable/DomainsTable.test.tsx +++ b/src/app/domains/views/DomainsList/DomainsTable/DomainsTable.test.tsx @@ -3,8 +3,11 @@ import { MemoryRouter } from "react-router-dom"; import { CompatRouter } from "react-router-dom-v5-compat"; import configureStore from "redux-mock-store"; +import { DomainListSidePanelViews } from "../constants"; + import DomainsTable, { Labels as DomainsTableLabels } from "./DomainsTable"; +import * as sidePanelHooks from "@/app/base/side-panel-context"; import type { RootState } from "@/app/store/root/types"; import { domain as domainFactory, @@ -23,7 +26,14 @@ const mockStore = configureStore(); describe("DomainsTable", () => { let state: RootState; + const setSidePanelContent = vi.fn(); beforeEach(() => { + vi.spyOn(sidePanelHooks, "useSidePanel").mockReturnValue({ + setSidePanelContent, + sidePanelContent: null, + setSidePanelSize: vi.fn(), + sidePanelSize: "regular", + }); state = rootStateFactory({ domain: domainStateFactory({ items: [ @@ -87,7 +97,7 @@ describe("DomainsTable", () => { ).toBeInTheDocument(); }); - it("calls the setDefault action if set default is clicked", async () => { + it("triggers the setDefault sidepanel if set default is clicked", async () => { const store = mockStore(state); render( @@ -116,29 +126,9 @@ describe("DomainsTable", () => { screen.getByRole("button", { name: DomainsTableLabels.SetDefault }) ); - row = screen.getByRole("row", { name: "a" }); - await userEvent.click( - within( - within(row).getByRole("gridcell", { - name: DomainsTableLabels.TableAction, - }) - ).getByRole("button", { name: DomainsTableLabels.ConfirmSetDefault }) + expect(setSidePanelContent).toHaveBeenCalledWith( + expect.objectContaining({ view: DomainListSidePanelViews.SET_DEFAULT }) ); - - expect( - store.getActions().find((action) => action.type === "domain/setDefault") - ).toStrictEqual({ - type: "domain/setDefault", - meta: { - method: "set_default", - model: "domain", - }, - payload: { - params: { - domain: 3, - }, - }, - }); }); it("displays an empty table message", () => { diff --git a/src/app/domains/views/DomainsList/DomainsTable/DomainsTable.tsx b/src/app/domains/views/DomainsList/DomainsTable/DomainsTable.tsx index 291f0aece5..d07f61c724 100644 --- a/src/app/domains/views/DomainsList/DomainsTable/DomainsTable.tsx +++ b/src/app/domains/views/DomainsList/DomainsTable/DomainsTable.tsx @@ -1,15 +1,13 @@ -import { useState } from "react"; - import { MainTable, ContextualMenu } from "@canonical/react-components"; -import classNames from "classnames"; import { useDispatch, useSelector } from "react-redux"; import { Link } from "react-router-dom-v5-compat"; -import TableConfirm from "@/app/base/components/TableConfirm"; +import { DomainListSidePanelViews } from "../constants"; + +import { useSidePanel } from "@/app/base/side-panel-context"; import urls from "@/app/base/urls"; import { actions as domainActions } from "@/app/store/domain"; import domainSelectors from "@/app/store/domain/selectors"; -import type { Domain, DomainMeta } from "@/app/store/domain/types"; export enum Labels { Domain = "Domain", @@ -24,15 +22,13 @@ export enum Labels { ContextualMenu = "Actions", TableLable = "Domains table", EmptyList = "No domains available.", + FormTitle = "Set default", } const DomainsTable = (): JSX.Element => { const dispatch = useDispatch(); + const { setSidePanelContent } = useSidePanel(); const domains = useSelector(domainSelectors.all); - const errors = useSelector(domainSelectors.errors); - const saving = useSelector(domainSelectors.saving); - const saved = useSelector(domainSelectors.saved); - const [expandedID, setExpandedID] = useState(); const headers = [ { content: "Domain", @@ -60,13 +56,12 @@ const DomainsTable = (): JSX.Element => { ]; const rows = domains.map((domain) => { - const isActive = expandedID === domain.id; return { // making sure we don't pass id directly as a key because of // https://github.com/canonical/react-components/issues/476 key: `domain-row-${domain.id}`, "aria-label": domain.name, - className: classNames("p-table__row", { "is-active": isActive }), + className: "p-table__row", columns: [ { content: ( @@ -97,7 +92,12 @@ const DomainsTable = (): JSX.Element => { children: Labels.SetDefault, onClick: () => { dispatch(domainActions.cleanup()); - setExpandedID(domain.id); + setSidePanelContent({ + view: DomainListSidePanelViews.SET_DEFAULT, + extras: { + id: domain.id, + }, + }); }, }, ]} @@ -110,28 +110,6 @@ const DomainsTable = (): JSX.Element => { className: "u-align--right", }, ], - expanded: isActive, - expandedContent: ( -
- {Labels.AreYouSure}} - onClose={() => { - dispatch(domainActions.cleanup()); - setExpandedID(null); - }} - onConfirm={() => { - dispatch(domainActions.setDefault(domain.id)); - }} - sidebar={false} - /> -
- ), sortData: { name: domain.name, authoritative: domain.authoritative, @@ -149,7 +127,6 @@ const DomainsTable = (): JSX.Element => { defaultSort="name" defaultSortDirection="ascending" emptyStateMsg={Labels.EmptyList} - expanding={true} headers={headers} paginate={50} rows={rows} diff --git a/src/app/domains/views/DomainsList/constants.ts b/src/app/domains/views/DomainsList/constants.ts index b12d6a1e91..316fe82c33 100644 --- a/src/app/domains/views/DomainsList/constants.ts +++ b/src/app/domains/views/DomainsList/constants.ts @@ -4,8 +4,10 @@ import type { SidePanelContent } from "@/app/base/types"; export const DomainListSidePanelViews = { ADD_DOMAIN: ["", "addDomain"], + SET_DEFAULT: ["", "setDefault"], } as const; export type DomainListSidePanelContent = SidePanelContent< - ValueOf + ValueOf, + { id?: number } >; diff --git a/src/app/images/components/ImagesTable/DeleteImageConfirm/DeleteImageConfirm.tsx b/src/app/images/components/ImagesTable/DeleteImageConfirm/DeleteImageConfirm.tsx index 58844414fd..7ea32739c8 100644 --- a/src/app/images/components/ImagesTable/DeleteImageConfirm/DeleteImageConfirm.tsx +++ b/src/app/images/components/ImagesTable/DeleteImageConfirm/DeleteImageConfirm.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { usePrevious } from "@canonical/react-components/dist/hooks"; import { useDispatch, useSelector } from "react-redux"; -import ModelDeleteForm from "@/app/base/components/ModelDeleteForm"; +import ModelActionForm from "@/app/base/components/ModelActionForm"; import { actions as bootResourceActions } from "@/app/store/bootresource"; import bootResourceSelectors from "@/app/store/bootresource/selectors"; import type { BootResource } from "@/app/store/bootresource/types"; @@ -38,7 +38,7 @@ const DeleteImageConfirm = ({ }, [dispatch]); return ( - { const saving = useSelector(discoverySelectors.saving); const saved = useSelector(discoverySelectors.saved); return ( - { ); return ( - { const id = useGetURLId("id"); @@ -11,7 +12,7 @@ const PoolDelete = () => { const onCancel = () => navigate({ pathname: urls.pools.index }); useOnEscapePressed(() => onCancel()); - if (!id) { + if (!isId(id)) { return

Resource pool not found

; } diff --git a/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.tsx b/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.tsx index 3d2c0f7615..db038a125c 100644 --- a/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.tsx +++ b/src/app/preferences/views/APIKeys/APIKeyDelete/APIKeyDelete.tsx @@ -4,6 +4,7 @@ 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"; +import { isId } from "@/app/utils"; const APIKeyDelete = () => { const id = useGetURLId("id"); @@ -11,7 +12,7 @@ const APIKeyDelete = () => { const onCancel = () => navigate({ pathname: urls.preferences.apiKeys.index }); useOnEscapePressed(() => onCancel()); - if (!id) { + if (!isId(id)) { return

API Key not found

; } diff --git a/src/app/preferences/views/APIKeys/APIKeyDeleteForm/APIKeyDeleteForm.tsx b/src/app/preferences/views/APIKeys/APIKeyDeleteForm/APIKeyDeleteForm.tsx index 305168877c..eecca4fe82 100644 --- a/src/app/preferences/views/APIKeys/APIKeyDeleteForm/APIKeyDeleteForm.tsx +++ b/src/app/preferences/views/APIKeys/APIKeyDeleteForm/APIKeyDeleteForm.tsx @@ -1,7 +1,7 @@ import { useDispatch, useSelector } from "react-redux"; import { useNavigate } from "react-router-dom-v5-compat"; -import ModelDeleteForm from "@/app/base/components/ModelDeleteForm"; +import ModelActionForm from "@/app/base/components/ModelActionForm"; import urls from "@/app/base/urls"; import { actions as tokenActions } from "@/app/store/token"; import tokenSelectors from "@/app/store/token/selectors"; @@ -13,7 +13,7 @@ const APIKeyDeleteForm = ({ id }: { id: number }) => { const saving = useSelector(tokenSelectors.saving); return ( - { } return ( - { const saved = useSelector(staticRouteSelectors.saved); const saving = useSelector(staticRouteSelectors.saving); return ( -