From 053c19206cbc6725d8660afd8e2a54ec0fc5e56e Mon Sep 17 00:00:00 2001 From: Peter Makowski Date: Tue, 11 Jul 2023 15:36:34 +0200 Subject: [PATCH] feat: tag management panel MAASENG-1427 --- .../components/AppSidePanel/AppSidePanel.tsx | 6 +- .../components/PageContent/PageContent.tsx | 3 +- src/app/base/side-panel-context.tsx | 28 ++- .../TagForm/AddTagForm/AddTagForm.tsx | 3 + .../TagFormChanges/TagFormChanges.test.tsx | 19 +- .../TagForm/TagFormChanges/TagFormChanges.tsx | 233 +++++++++--------- .../TagFormFields/TagFormFields.test.tsx | 2 +- .../TagForm/TagFormFields/TagFormFields.tsx | 93 ++++--- src/scss/_patterns_application.scss | 15 ++ 9 files changed, 232 insertions(+), 170 deletions(-) diff --git a/src/app/base/components/AppSidePanel/AppSidePanel.tsx b/src/app/base/components/AppSidePanel/AppSidePanel.tsx index cc469e98eb2..66bf08b2b65 100644 --- a/src/app/base/components/AppSidePanel/AppSidePanel.tsx +++ b/src/app/base/components/AppSidePanel/AppSidePanel.tsx @@ -3,11 +3,12 @@ import type { ReactNode } from "react"; import { Col, Row, useOnEscapePressed } from "@canonical/react-components"; import classNames from "classnames"; +import type { SidePanelSize } from "app/base/side-panel-context"; import { useSidePanel } from "app/base/side-panel-context"; type Props = { title?: string | null; - size?: "wide" | "default" | "narrow"; + size?: SidePanelSize; content?: ReactNode; }; @@ -23,8 +24,9 @@ const AppSidePanel = ({ aria-label={title ?? undefined} className={classNames("l-aside", { "is-collapsed": !content, - "is-wide": size === "wide", "is-narrow": size === "narrow", + "is-large": size === "large", + "is-wide": size === "wide", })} data-testid="section-header-content" id="aside-panel" diff --git a/src/app/base/components/PageContent/PageContent.tsx b/src/app/base/components/PageContent/PageContent.tsx index eaa224f9b43..cb9c56d7e45 100644 --- a/src/app/base/components/PageContent/PageContent.tsx +++ b/src/app/base/components/PageContent/PageContent.tsx @@ -8,6 +8,7 @@ import Footer from "../Footer"; import MainContentSection from "../MainContentSection"; import SecondaryNavigation from "../SecondaryNavigation"; +import { useSidePanel } from "app/base/side-panel-context"; import { useThemeContext } from "app/base/theme-context"; import { preferencesNavItems } from "app/preferences/constants"; import { settingsNavItems } from "app/settings/constants"; @@ -28,7 +29,6 @@ const PageContent = ({ sidebar, isNotificationListHidden = false, sidePanelContent, - sidePanelSize, sidePanelTitle, ...props }: Props): JSX.Element => { @@ -37,6 +37,7 @@ const PageContent = ({ const isPreferencesPage = matchPath("account/prefs/*", pathname); const isSideNavVisible = isSettingsPage || isPreferencesPage; const { theme } = useThemeContext(); + const { sidePanelSize } = useSidePanel(); return ( <> diff --git a/src/app/base/side-panel-context.tsx b/src/app/base/side-panel-context.tsx index eb5ba0480b5..285d1fe1295 100644 --- a/src/app/base/side-panel-context.tsx +++ b/src/app/base/side-panel-context.tsx @@ -38,31 +38,38 @@ export type SidePanelContent = export type SetSidePanelContent = (sidePanelContent: SidePanelContent) => void; +export type SidePanelSize = "narrow" | "regular" | "large" | "wide"; export type SidePanelContextType = { sidePanelContent: T; + sidePanelSize: SidePanelSize; }; export type SetSidePanelContextType = { setSidePanelContent: SetSidePanelContent; + setSidePanelSize: (size: SidePanelSize) => void; }; const SidePanelContext = createContext({ sidePanelContent: null, + sidePanelSize: "regular", }); const SetSidePanelContext = createContext({ setSidePanelContent: () => {}, + setSidePanelSize: () => {}, }); const useSidePanelContext = (): SidePanelContextType => useContext(SidePanelContext); -const useSetSidePanelContext = (): SetSidePanelContextType => +// TODO: remove this export +export const useSetSidePanelContext = (): SetSidePanelContextType => useContext(SetSidePanelContext); +// TODO: move set side panel size to separate context export const useSidePanel = (): SidePanelContextType & SetSidePanelContextType => { - const { sidePanelContent } = useSidePanelContext(); - const { setSidePanelContent } = useSetSidePanelContext(); + const { sidePanelSize, sidePanelContent } = useSidePanelContext(); + const { setSidePanelContent, setSidePanelSize } = useSetSidePanelContext(); const { pathname } = useLocation(); const previousPathname = usePrevious(pathname); @@ -78,7 +85,17 @@ export const useSidePanel = (): SidePanelContextType & return () => setSidePanelContent(null); }, [setSidePanelContent]); - return { sidePanelContent, setSidePanelContent }; + // reset side panel size to defaul on unmount + useEffect(() => { + return () => setSidePanelSize("regular"); + }, [setSidePanelSize]); + + return { + sidePanelContent, + setSidePanelContent, + sidePanelSize, + setSidePanelSize, + }; }; const SidePanelContextProvider = ({ @@ -87,16 +104,19 @@ const SidePanelContextProvider = ({ }: PropsWithChildren<{ value?: SidePanelContent }>): React.ReactElement => { const [sidePanelContent, setSidePanelContent] = useState(value); + const [sidePanelSize, setSidePanelSize] = useState("regular"); return ( {children} diff --git a/src/app/machines/components/MachineForms/MachineActionFormWrapper/TagForm/AddTagForm/AddTagForm.tsx b/src/app/machines/components/MachineForms/MachineActionFormWrapper/TagForm/AddTagForm/AddTagForm.tsx index 7dd4177043e..f0f5bb7427d 100644 --- a/src/app/machines/components/MachineForms/MachineActionFormWrapper/TagForm/AddTagForm/AddTagForm.tsx +++ b/src/app/machines/components/MachineForms/MachineActionFormWrapper/TagForm/AddTagForm/AddTagForm.tsx @@ -8,6 +8,7 @@ export type Props = { onTagCreated: (tag: Tag) => void; viewingDetails?: boolean; viewingMachineConfig?: boolean; + onCancel?: () => void; } & Partial; export const AddTagForm = ({ @@ -17,6 +18,7 @@ export const AddTagForm = ({ searchFilter, viewingDetails, viewingMachineConfig, + onCancel, }: Props): JSX.Element => { let location = "list"; if (viewingMachineConfig) { @@ -37,6 +39,7 @@ export const AddTagForm = ({ : `${count} selected machines are deployed. The new kernel options will not be applied to these machines until they are redeployed.` } name={name} + onCancel={onCancel} onSaveAnalytics={{ action: "Manual tag created", category: `Machine ${location} create tag form`, diff --git a/src/app/machines/components/MachineForms/MachineActionFormWrapper/TagForm/TagFormChanges/TagFormChanges.test.tsx b/src/app/machines/components/MachineForms/MachineActionFormWrapper/TagForm/TagFormChanges/TagFormChanges.test.tsx index 74af4e3bfda..d5e173248f9 100644 --- a/src/app/machines/components/MachineForms/MachineActionFormWrapper/TagForm/TagFormChanges/TagFormChanges.test.tsx +++ b/src/app/machines/components/MachineForms/MachineActionFormWrapper/TagForm/TagFormChanges/TagFormChanges.test.tsx @@ -170,9 +170,11 @@ it("discards added tags", async () => { }); it("displays a tag details modal when chips are clicked", async () => { - tags[0].name = "tag1"; - tags[0].machine_count = 2; + const expectedTag = tags[0]; + expectedTag.name = "tag1"; + expectedTag.machine_count = 2; const store = mockStore(state); + const handleToggleTagDetails = jest.fn(); render( @@ -181,14 +183,21 @@ it("displays a tag details modal when chips are clicked", async () => { initialValues={{ added: [], removed: [] }} onSubmit={jest.fn()} > - + ); - await userEvent.click(screen.getByRole("button", { name: "tag1 (2/2)" })); - expect(screen.getByRole("dialog", { name: "tag1" })).toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { name: `${expectedTag.name} (2/2)` }) + ); + expect(handleToggleTagDetails).toHaveBeenCalledWith(expectedTag); }); it("can remove manual tags", async () => { diff --git a/src/app/machines/components/MachineForms/MachineActionFormWrapper/TagForm/TagFormChanges/TagFormChanges.tsx b/src/app/machines/components/MachineForms/MachineActionFormWrapper/TagForm/TagFormChanges/TagFormChanges.tsx index 19b6f664ad2..4204ca87573 100644 --- a/src/app/machines/components/MachineForms/MachineActionFormWrapper/TagForm/TagFormChanges/TagFormChanges.tsx +++ b/src/app/machines/components/MachineForms/MachineActionFormWrapper/TagForm/TagFormChanges/TagFormChanges.tsx @@ -1,10 +1,15 @@ import type { ReactNode } from "react"; -import { useState, useMemo } from "react"; +import { useMemo } from "react"; import type { ChipProps } from "@canonical/react-components"; -import { Modal, Button, Icon, ModularTable } from "@canonical/react-components"; +import { + Button, + Col, + Icon, + ModularTable, + Row, +} from "@canonical/react-components"; import { useFormikContext } from "formik"; -import { Portal } from "react-portal"; import { useSelector } from "react-redux"; import { Link } from "react-router-dom-v5-compat"; @@ -19,12 +24,12 @@ import type { RootState } from "app/store/root/types"; import tagSelectors from "app/store/tag/selectors"; import type { Tag, TagMeta } from "app/store/tag/types"; import { getTagCounts } from "app/store/tag/utils"; -import TagDetails from "app/tags/components/TagDetails"; import { toFormikNumber } from "app/utils"; type Props = { tags: Tag[]; newTags: Tag[TagMeta.PK][]; + toggleTagDetails: (tag: Tag | null) => void; } & Pick; export enum Label { @@ -114,18 +119,9 @@ export const TagFormChanges = ({ tags, selectedCount, newTags, + toggleTagDetails, }: Props): JSX.Element | null => { const { setFieldValue, values } = useFormikContext(); - const [tagDetails, setTagDetails] = useState(null); - const [isOpen, setIsOpen] = useState(false); - const toggleTagDetails = (tag: Tag | null) => { - setTagDetails(tag); - if (tag) { - setIsOpen(true); - } else { - setIsOpen(false); - } - }; const tagIdsAndCounts = getTagCounts(tags); const tagIds = tagIdsAndCounts ? Array.from(tagIdsAndCounts?.keys()) : []; const automaticTags = useSelector((state: RootState) => @@ -142,6 +138,7 @@ export const TagFormChanges = ({ const hasManualTags = manualTags.length > 0; const hasAddedTags = addedTags.length > 0; const hasRemovedTags = removedTags.length > 0; + const columns = useMemo( () => [ { @@ -173,120 +170,112 @@ export const TagFormChanges = ({ if (!hasAutomaticTags && !hasManualTags && !hasAddedTags && !hasRemovedTags) { return

{Label.NoTags}

; } + addedTags.forEach((tag) => { // Added tags will be applied to all machines. tagIdsAndCounts?.set(tag.id, machineCount); }); return ( - <> - { - setFieldValue( - "added", - values.added.filter((id) => tag.id !== toFormikNumber(id)) - ); - }, - <> - {Label.Discard} - - - ), - ...generateRows( - RowType.Removed, - removedTags, - machineCount, - tagIdsAndCounts, - Label.Removed, - toggleTagDetails, - newTags, - "negative", - (tag) => { - setFieldValue( - "removed", - values.removed.filter((id) => tag.id !== toFormikNumber(id)) - ); - }, - <> - {Label.Discard} -