Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: move collapsible forms to sidepanel #5263

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { HTMLProps, ReactNode } from "react";

import { Col, Row } from "@canonical/react-components";
import type { ColSize } from "@canonical/react-components";
import classNames from "classnames";

import NotificationList from "@/app/base/components/NotificationList";
import { COL_SIZES } from "@/app/base/constants";
Expand Down Expand Up @@ -35,7 +36,12 @@ const MainContentSection = ({
{sidebar}
</Col>
)}
<Col size={(sidebar ? TOTAL - SIDEBAR : TOTAL) as ColSize}>
<Col
className={classNames({
"u-nudge-down": !isNotificationListHidden,
})}
size={(sidebar ? TOTAL - SIDEBAR : TOTAL) as ColSize}
>
{!isNotificationListHidden && <NotificationList />}
{children}
</Col>
Expand Down
49 changes: 49 additions & 0 deletions src/app/base/components/ModelDeleteForm/ModelDeleteForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import ModelDeleteForm from "./ModelDeleteForm";

import { renderWithBrowserRouter, screen, userEvent } from "@/testing/utils";

it("renders", () => {
renderWithBrowserRouter(
<ModelDeleteForm
initialValues={{}}
modelType="machine"
onSubmit={vi.fn()}
submitLabel="Delete"
/>
);
expect(
screen.getByText("Are you sure you want to delete this machine?")
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
});

it("can confirm", async () => {
const onSubmit = vi.fn();
renderWithBrowserRouter(
<ModelDeleteForm
initialValues={{}}
modelType="machine"
onSubmit={onSubmit}
submitLabel="Delete"
/>
);
const submitBtn = screen.getByRole("button", { name: /delete/i });
await userEvent.click(submitBtn);
expect(onSubmit).toHaveBeenCalled();
});

it("can cancel", async () => {
const onCancel = vi.fn();
renderWithBrowserRouter(
<ModelDeleteForm
cancelLabel="Cancel"
initialValues={{}}
modelType="machine"
onCancel={onCancel}
onSubmit={vi.fn()}
/>
);
const cancelBtn = screen.getByRole("button", { name: /cancel/i });
await userEvent.click(cancelBtn);
expect(onCancel).toHaveBeenCalled();
});
43 changes: 43 additions & 0 deletions src/app/base/components/ModelDeleteForm/ModelDeleteForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Col, Row } from "@canonical/react-components";

import type { Props as FormikFormProps } from "@/app/base/components/FormikForm/FormikForm";
import FormikForm from "@/app/base/components/FormikForm/FormikForm";
import type { EmptyObject } from "@/app/base/types";

type Props = {
modelType: string;
message?: string;
} & FormikFormProps<EmptyObject>;

const ModelDeleteForm = ({
modelType,
message,
submitAppearance = "negative",
submitLabel = "Delete",
initialValues = {},
...props
}: Props) => {
return (
<FormikForm
initialValues={initialValues}
submitAppearance={submitAppearance}
submitLabel={submitLabel}
{...props}
>
<Row>
<Col size={12}>
<p className="u-nudge-down--small">
{message
? message
: `Are you sure you want to delete this ${modelType}?`}
</p>
<span className="u-text--light">
This action is permanent and can not be undone.
</span>
</Col>
</Row>
</FormikForm>
);
};

export default ModelDeleteForm;
1 change: 1 addition & 0 deletions src/app/base/components/ModelDeleteForm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./ModelDeleteForm";
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
rootState as rootStateFactory,
} from "@/testing/factories";
import {
userEvent,
screen,
renderWithBrowserRouter,
expectTooltipOnHover,
Expand All @@ -37,7 +36,6 @@ describe("NetworkActionRow", () => {
const store = mockStore(state);
renderWithBrowserRouter(
<NetworkActionRow
expanded={null}
extraActions={[
{
disabled: [[false]],
Expand All @@ -46,42 +44,18 @@ describe("NetworkActionRow", () => {
},
]}
node={state.machine.items[0]}
setExpanded={vi.fn()}
/>,
{ route: "/machine/abc123", store }
);
expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument();
});

describe("add physical", () => {
it("sets the state to show the form when clicking the button", async () => {
const store = mockStore(state);
const setExpanded = vi.fn();
renderWithBrowserRouter(
<NetworkActionRow
expanded={null}
node={state.machine.items[0]}
setExpanded={setExpanded}
/>,
{ route: "/machine/abc123", store }
);
await userEvent.click(
screen.getByRole("button", { name: "Add interface" })
);
expect(setExpanded).toHaveBeenCalledWith({
content: ExpandedState.ADD_PHYSICAL,
});
});

it("disables the button when networking is disabled", async () => {
state.machine.items[0].status = NodeStatus.DEPLOYED;
const store = mockStore(state);
renderWithBrowserRouter(
<NetworkActionRow
expanded={null}
node={state.machine.items[0]}
setExpanded={vi.fn()}
/>,
<NetworkActionRow node={state.machine.items[0]} />,
{ route: "/machine/abc123", store }
);
const addInterfaceButton = screen.getByRole("button", {
Expand All @@ -93,21 +67,5 @@ describe("NetworkActionRow", () => {
"Network can't be modified for this machine."
);
});

it("disables the button when the form is expanded", () => {
state.machine.items[0].status = NodeStatus.DEPLOYED;
const store = mockStore(state);
renderWithBrowserRouter(
<NetworkActionRow
expanded={{ content: ExpandedState.ADD_PHYSICAL }}
node={state.machine.items[0]}
setExpanded={vi.fn()}
/>,
{ route: "/machine/abc123", store }
);
expect(
screen.getByRole("button", { name: "Add interface" })
).toBeDisabled();
});
});
});
54 changes: 41 additions & 13 deletions src/app/base/components/NetworkActionRow/NetworkActionRow.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import type { ReactNode } from "react";

import { Button, Col, List, Row, Tooltip } from "@canonical/react-components";
import { useLocation } from "react-router-dom";

import type { Expanded, SetExpanded } from "../NodeNetworkTab/NodeNetworkTab";
import { ExpandedState } from "../NodeNetworkTab/NodeNetworkTab";

import type {
Selected,
SetSelected,
} from "@/app/base/components/node/networking/types";
import { useIsAllNetworkingDisabled } from "@/app/base/hooks";
import { useSidePanel } from "@/app/base/side-panel-context";
import { DeviceSidePanelViews } from "@/app/devices/constants";
import { MachineSidePanelViews } from "@/app/machines/constants";
import type { Node } from "@/app/store/types/node";

type Action = {
Expand All @@ -15,38 +22,61 @@ type Action = {
};

type Props = {
expanded: Expanded | null;
extraActions?: Action[];
node: Node;
rightContent?: ReactNode;
setExpanded: SetExpanded;
selected?: Selected[];
setSelected?: SetSelected;
};

export const NETWORK_DISABLED_MESSAGE =
"Network can't be modified for this machine.";

const NetworkActionRow = ({
expanded,
extraActions,
node,
rightContent,
setExpanded,
selected,
setSelected,
}: Props): JSX.Element | null => {
const isAllNetworkingDisabled = useIsAllNetworkingDisabled(node);
const { setSidePanelContent } = useSidePanel();
const { pathname } = useLocation();
const isMachinesPage = pathname.startsWith("/machine");

const actions: Action[] = [
{
disabled: [
[isAllNetworkingDisabled, NETWORK_DISABLED_MESSAGE],
// Disable the button when the form is visible.
[expanded?.content === ExpandedState.ADD_PHYSICAL],
],
disabled: [[isAllNetworkingDisabled, NETWORK_DISABLED_MESSAGE]],
label: "Add interface",
state: ExpandedState.ADD_PHYSICAL,
},
...(extraActions || []),
];

const handleButtonClick = (state: ExpandedState) => {
const expandedStateMap: Partial<Record<ExpandedState, () => void>> = {
[ExpandedState.ADD_PHYSICAL]: isMachinesPage
? () =>
setSidePanelContent({
view: MachineSidePanelViews.ADD_INTERFACE,
extras: { systemId: node.system_id },
})
: () =>
setSidePanelContent({ view: DeviceSidePanelViews.ADD_INTERFACE }),
[ExpandedState.ADD_BOND]: () =>
setSidePanelContent({
view: MachineSidePanelViews.ADD_BOND,
extras: { systemId: node.system_id, selected: selected, setSelected },
}),
[ExpandedState.ADD_BRIDGE]: () =>
setSidePanelContent({
view: MachineSidePanelViews.ADD_BRIDGE,
extras: { systemId: node.system_id, selected: selected, setSelected },
}),
};
return expandedStateMap[state]?.();
};

const buttons = actions.map((item) => {
// Check if there is any reason to disable the button.
const [disabled, tooltip] =
Expand All @@ -55,9 +85,7 @@ const NetworkActionRow = ({
<Button
data-testid={item.state}
disabled={disabled}
onClick={() => {
setExpanded({ content: item.state });
}}
onClick={() => handleButtonClick(item.state)}
>
{item.label}
</Button>
Expand Down
2 changes: 1 addition & 1 deletion src/app/base/components/SSHKeyForm/SSHKeyForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const SSHKeyForm = ({ cols, ...props }: Props): JSX.Element => {
validationSchema={SSHKeySchema}
{...props}
>
<SSHKeyFormFields cols={cols} />
<SSHKeyFormFields />
</FormikForm>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,22 @@
import { ExternalLink } from "@canonical/maas-react-components";
import { Col, Row, Select, Textarea } from "@canonical/react-components";
import type { ColSize } from "@canonical/react-components";
import { useFormikContext } from "formik";

import type { SSHKeyFormValues } from "../types";

import FormikField from "@/app/base/components/FormikField";
import TooltipButton from "@/app/base/components/TooltipButton";
import { COL_SIZES } from "@/app/base/constants";
import docsUrls from "@/app/base/docsUrls";

type Props = {
cols?: number;
};

export const SSHKeyFormFields = ({
cols = COL_SIZES.TOTAL,
}: Props): JSX.Element => {
export const SSHKeyFormFields = (): JSX.Element => {
const { values } = useFormikContext<SSHKeyFormValues>();
const { protocol } = values;
const uploadSelected = protocol === "upload";
const colSize = cols / 2;

return (
<>
<Row>
<Col size={Math.ceil(colSize) as ColSize}>
<Col size={12}>
<FormikField
component={Select}
label="Source"
Expand Down Expand Up @@ -67,7 +58,7 @@ export const SSHKeyFormFields = ({
/>
)}
</Col>
<Col size={Math.floor(colSize) as ColSize}>
<Col size={12}>
<p className="form-card__help">
Before you can deploy a machine you must import at least one public
SSH key into MAAS, so the deployed machine can be accessed.
Expand Down
10 changes: 7 additions & 3 deletions src/app/base/components/TableActions/TableActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type Props = {
copyValue?: string;
deleteDisabled?: boolean;
deleteTooltip?: string | null;
deletePath?: string;
editDisabled?: boolean;
editPath?: string;
editTooltip?: string | null;
Expand All @@ -22,6 +23,7 @@ const TableActions = ({
clearTooltip,
copyValue,
deleteDisabled,
deletePath,
deleteTooltip,
editDisabled,
editPath,
Expand All @@ -48,16 +50,18 @@ const TableActions = ({
</Button>
</Tooltip>
)}
{onDelete && (
{(onDelete || deletePath) && (
<Tooltip message={deleteTooltip} position="left">
<Button
appearance="base"
className="is-dense u-table-cell-padding-overlap"
data-testid="table-actions-delete"
disabled={deleteDisabled}
element={deletePath ? Link : undefined}
hasIcon
onClick={() => onDelete()}
type="button"
onClick={() => (onDelete ? onDelete() : null)}
to={deletePath || ""}
type={deletePath ? undefined : "button"}
>
<i className="p-icon--delete">Delete</i>
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,6 @@ const AvailableStorageTable = ({
)}
{isMachine && canEditStorage && (
<BulkActions
bulkAction={bulkAction}
selected={selected}
setBulkAction={setBulkAction}
systemId={node.system_id}
Expand Down
Loading