From f723ce24e0ae5d33dd20df9d0985b7b8db0bd322 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Wed, 17 Jan 2024 17:47:48 +0200 Subject: [PATCH] feat: add floating notifications for applicable events - setup floating notification provider - applied floating notifications to all relevant instance actions - applied floating notifications to all relevant cluster actions - applied floating notifications to all relevant profile actions - applied floating notifications to all relevant network actions - applied floating notifications to all relevant storage actions - applied floating notifications to all relevant image actions - applied floating notifications to all relevant operations actions - applied floating notifications to all relevant project actions - applied floating notifications to all relevant settings actions - removed NotificationRowLegacy component - removed NotificationRow from NetworkMap, not needed - added filtering by severity for toast notifications Signed-off-by: Mason Hu --- src/Root.tsx | 38 +-- src/components/Animate.tsx | 65 +++++ src/components/Navigation.tsx | 2 - src/components/NotificationRowLegacy.tsx | 63 ----- src/components/OperationStatus.tsx | 28 ++ src/components/ScrollableContainer.tsx | 4 +- src/components/ScrollableTable.tsx | 9 +- src/components/StatusBar.tsx | 67 +++++ src/components/ToastNotification.tsx | 58 +++++ src/components/ToastNotificationList.tsx | 239 ++++++++++++++++++ src/components/Version.tsx | 10 +- src/components/forms/FormFooterLayout.tsx | 1 - .../forms/ScrollableConfigurationTable.tsx | 2 +- src/context/CombinedNotificationProvider.tsx | 20 ++ src/context/operationsProvider.tsx | 90 +++++++ src/context/toastNotificationProvider.tsx | 199 +++++++++++++++ src/pages/cluster/ClusterGroupForm.tsx | 9 +- src/pages/cluster/ClusterList.tsx | 1 + .../cluster/actions/DeleteClusterGroupBtn.tsx | 14 +- .../actions/EvacuateClusterMemberBtn.tsx | 4 +- .../actions/RestoreClusterMemberBtn.tsx | 6 +- src/pages/images/ImageList.tsx | 6 +- .../images/actions/BulkDeleteImageBtn.tsx | 11 +- src/pages/images/actions/DeleteImageBtn.tsx | 17 +- .../images/actions/UploadCustomIsoBtn.tsx | 7 +- src/pages/instances/CreateInstance.tsx | 29 ++- src/pages/instances/EditInstance.tsx | 22 +- src/pages/instances/Events.tsx | 5 +- src/pages/instances/InstanceConsole.tsx | 29 +-- src/pages/instances/InstanceDetail.tsx | 18 +- src/pages/instances/InstanceDetailHeader.tsx | 29 ++- .../instances/InstanceGraphicConsole.tsx | 13 +- src/pages/instances/InstanceList.tsx | 1 + src/pages/instances/InstanceOverview.tsx | 23 +- src/pages/instances/InstanceSnapshots.tsx | 23 +- src/pages/instances/InstanceTerminal.tsx | 37 +-- src/pages/instances/InstanceTextConsole.tsx | 6 +- src/pages/instances/actions/AttachIsoBtn.tsx | 37 ++- .../instances/actions/DeleteInstanceBtn.tsx | 18 +- .../instances/actions/FreezeInstanceBtn.tsx | 13 +- .../instances/actions/InstanceBulkActions.tsx | 10 +- .../instances/actions/InstanceBulkDelete.tsx | 15 +- .../instances/actions/MigrateInstanceBtn.tsx | 11 +- .../instances/actions/RestartInstanceBtn.tsx | 13 +- .../instances/actions/StopInstanceBtn.tsx | 13 +- .../forms/CreateInstanceSnapshotForm.tsx | 10 +- .../forms/EditInstanceSnapshotForm.tsx | 18 +- src/pages/networks/CreateNetwork.tsx | 10 +- src/pages/networks/CreateNetworkForward.tsx | 10 +- src/pages/networks/EditNetwork.tsx | 5 +- src/pages/networks/EditNetworkForward.tsx | 10 +- src/pages/networks/NetworkDetailHeader.tsx | 10 +- src/pages/networks/NetworkMap.tsx | 2 - .../networks/actions/DeleteNetworkBtn.tsx | 14 +- .../actions/DeleteNetworkForwardBtn.tsx | 6 +- src/pages/operations/OperationList.tsx | 19 +- .../operations/actions/CancelOperationBtn.tsx | 4 +- src/pages/profiles/CreateProfile.tsx | 9 +- src/pages/profiles/EditProfile.tsx | 4 +- src/pages/profiles/ProfileDetailHeader.tsx | 10 +- src/pages/profiles/ProfileList.tsx | 1 + .../profiles/actions/DeleteProfileBtn.tsx | 9 +- src/pages/projects/CreateProject.tsx | 10 +- src/pages/projects/EditProject.tsx | 4 +- .../projects/ProjectConfigurationHeader.tsx | 16 +- .../projects/actions/DeleteProjectBtn.tsx | 9 +- src/pages/settings/SettingForm.tsx | 4 +- src/pages/settings/Settings.tsx | 1 + src/pages/storage/CreateStoragePool.tsx | 10 +- src/pages/storage/CustomIsoList.tsx | 12 +- src/pages/storage/CustomVolumeCreateModal.tsx | 2 +- src/pages/storage/CustomVolumeSelectModal.tsx | 2 +- src/pages/storage/EditStoragePool.tsx | 4 +- src/pages/storage/StoragePoolHeader.tsx | 10 +- src/pages/storage/StoragePools.tsx | 6 +- src/pages/storage/StorageVolumeHeader.tsx | 16 +- src/pages/storage/StorageVolumeSnapshots.tsx | 1 + src/pages/storage/StorageVolumes.tsx | 6 +- src/pages/storage/UploadCustomIso.tsx | 4 +- .../actions/CustomStorageVolumeActions.tsx | 7 +- .../storage/actions/DeleteStoragePoolBtn.tsx | 9 +- .../VolumeConfigureSnapshotModal.tsx | 6 +- .../snapshots/VolumeSnapshotActions.tsx | 12 +- .../snapshots/VolumeSnapshotBulkDelete.tsx | 15 +- .../forms/CreateVolumeSnapshotForm.tsx | 9 +- .../storage/forms/EditVolumeSnapshotForm.tsx | 9 +- .../storage/forms/StorageVolumeCreate.tsx | 10 +- src/pages/storage/forms/StorageVolumeEdit.tsx | 4 +- src/sass/_configuration_table.scss | 1 + src/sass/_pattern_navigation.scss | 17 +- src/sass/_status_bar.scss | 56 ++++ src/sass/_toast.scss | 103 ++++++++ src/sass/styles.scss | 12 +- src/util/helpers.tsx | 26 ++ src/util/instanceStart.tsx | 8 +- src/util/instances.tsx | 26 ++ src/util/notifications.tsx | 15 ++ src/util/operations.tsx | 4 +- src/util/updateMaxHeight.tsx | 10 +- src/util/usePreferReducedMotion.tsx | 22 ++ tests/devices.spec.ts | 6 +- tests/helpers/instances.ts | 57 ++++- tests/helpers/network.ts | 6 +- tests/helpers/notification.ts | 42 +++ tests/helpers/profile.ts | 10 +- tests/helpers/projects.ts | 2 +- tests/helpers/server.ts | 14 +- tests/helpers/snapshots.ts | 10 +- tests/helpers/storagePool.ts | 5 +- tests/helpers/storageVolume.ts | 5 +- tests/instances.spec.ts | 24 +- tests/networks.spec.ts | 2 +- tests/notification.spec.ts | 73 ++++++ tests/profiles.spec.ts | 22 +- tests/projects.spec.ts | 4 +- tests/snapshots.spec.ts | 2 +- tests/storage.spec.ts | 9 +- 117 files changed, 1732 insertions(+), 545 deletions(-) create mode 100644 src/components/Animate.tsx delete mode 100644 src/components/NotificationRowLegacy.tsx create mode 100644 src/components/OperationStatus.tsx create mode 100644 src/components/StatusBar.tsx create mode 100644 src/components/ToastNotification.tsx create mode 100644 src/components/ToastNotificationList.tsx create mode 100644 src/context/CombinedNotificationProvider.tsx create mode 100644 src/context/operationsProvider.tsx create mode 100644 src/context/toastNotificationProvider.tsx create mode 100644 src/sass/_status_bar.scss create mode 100644 src/sass/_toast.scss create mode 100644 src/util/instances.tsx create mode 100644 src/util/notifications.tsx create mode 100644 src/util/usePreferReducedMotion.tsx create mode 100644 tests/helpers/notification.ts create mode 100644 tests/notification.spec.ts diff --git a/src/Root.tsx b/src/Root.tsx index 532edf715a..acb4c92727 100644 --- a/src/Root.tsx +++ b/src/Root.tsx @@ -1,9 +1,6 @@ import React, { FC } from "react"; import Navigation from "components/Navigation"; -import { - NotificationProvider, - QueuedNotification, -} from "@canonical/react-components"; +import { QueuedNotification } from "@canonical/react-components"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import Panels from "components/Panels"; import { AuthProvider } from "context/auth"; @@ -15,6 +12,9 @@ import App from "./App"; import ErrorBoundary from "components/ErrorBoundary"; import ErrorPage from "components/ErrorPage"; import { useLocation } from "react-router-dom"; +import CombinedNotificationProvider from "context/CombinedNotificationProvider"; +import StatusBar from "components/StatusBar"; +import OperationsProvider from "context/operationsProvider"; const queryClient = new QueryClient(); @@ -23,26 +23,32 @@ const Root: FC = () => { return ( - + - -
- - - - - - -
-
+ + +
+ + + + + + + +
+
+
-
+
); }; diff --git a/src/components/Animate.tsx b/src/components/Animate.tsx new file mode 100644 index 0000000000..c265454154 --- /dev/null +++ b/src/components/Animate.tsx @@ -0,0 +1,65 @@ +import React, { + FC, + PropsWithChildren, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { usePrefersReducedMotion } from "util/usePreferReducedMotion"; + +interface Props { + show: boolean; + from: Keyframe; + to: Keyframe; + exitAnimation?: Keyframe[]; + options?: KeyframeAnimationOptions; + className?: string; +} + +const Animate: FC> = ({ + show, + children, + from, + to, + exitAnimation, + options = { duration: 500, fill: "forwards" }, + className, +}) => { + const containerRef = useRef(null); + const preferReducedMotion = usePrefersReducedMotion(); + + // This state is used so that we trigger a extra render cycle + // to animate the child component when it is being unmounted + const [removeState, setRemove] = useState(!show); + + useLayoutEffect(() => { + const element = containerRef.current; + if (show) { + setRemove(false); + if (!element || preferReducedMotion) return; + element.animate([from, to], options); + } else { + if (!element) return; + if (preferReducedMotion) { + setRemove(true); + return; + } + const animation = element.animate(exitAnimation || [to, from], options); + animation.onfinish = () => { + setRemove(true); + // This is important, else the next render cycle due to setRemove will cause flickering effect + element.style.display = "none"; + }; + } + }, [show, removeState]); + + return ( + !removeState && ( +
+ {children} +
+ ) + ); +}; + +export default Animate; diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 0e0c70474d..d2321da1d9 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -5,7 +5,6 @@ import { useAuth } from "context/auth"; import classnames from "classnames"; import Logo from "./Logo"; import ProjectSelector from "pages/projects/ProjectSelector"; -import Version from "components/Version"; import { isWidthBelow, logout } from "util/helpers"; import { useProject } from "context/project"; import { useMenuCollapsed } from "context/menuCollapsed"; @@ -314,7 +313,6 @@ const Navigation: FC = () => { Report a bug - diff --git a/src/components/NotificationRowLegacy.tsx b/src/components/NotificationRowLegacy.tsx deleted file mode 100644 index 1849ad3800..0000000000 --- a/src/components/NotificationRowLegacy.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React, { FC, useEffect, useRef } from "react"; -import { - Row, - Notification as NotificationComponent, - NotificationType, -} from "@canonical/react-components"; - -interface Props { - notification: NotificationType | null; - onDismiss: () => void; -} - -const NotificationRowLegacy: FC = ({ - notification = null, - onDismiss, -}) => { - const ref = useRef(null); - - useEffect(() => { - ref.current?.scrollIntoView({ - behavior: "smooth", - block: "center", - inline: "start", - }); - window.dispatchEvent(new Event("resize")); - }, [notification]); - - if (!notification) { - return null; - } - const { actions, title, type, message } = notification; - const figureTitle = () => { - if (title) { - return title; - } - switch (type) { - case "negative": - return "Error"; - case "positive": - return "Success"; - case "information": - return "Info"; - default: - return ""; - } - }; - return ( -
- - - {message} - - -
- ); -}; - -export default NotificationRowLegacy; diff --git a/src/components/OperationStatus.tsx b/src/components/OperationStatus.tsx new file mode 100644 index 0000000000..e63ed24b59 --- /dev/null +++ b/src/components/OperationStatus.tsx @@ -0,0 +1,28 @@ +import { Icon } from "@canonical/react-components"; +import { useOperations } from "context/operationsProvider"; +import React, { useState } from "react"; +import { isWidthBelow } from "util/helpers"; +import useEventListener from "@use-it/event-listener"; +import { Link } from "react-router-dom"; +import { pluralize } from "util/instanceBulkActions"; + +const OperationStatus = () => { + const [isSmallScreen, setIsSmallScreen] = useState(false); + const { runningOperations } = useOperations(); + + useEventListener("resize", () => setIsSmallScreen(isWidthBelow(620))); + + let operationsStatus = `${runningOperations.length} ${pluralize("operation", runningOperations.length)} in progress...`; + if (isSmallScreen) { + operationsStatus = `${runningOperations.length} Ops`; + } + + return runningOperations.length ? ( +
+ + {operationsStatus} +
+ ) : null; +}; + +export default OperationStatus; diff --git a/src/components/ScrollableContainer.tsx b/src/components/ScrollableContainer.tsx index 8cccc54925..254d32a364 100644 --- a/src/components/ScrollableContainer.tsx +++ b/src/components/ScrollableContainer.tsx @@ -22,7 +22,9 @@ const ScrollableContainer: FC = ({ } const above = childContainer.getBoundingClientRect().top + 1; const below = getAbsoluteHeightBelow(belowId); - const parentsBottomSpacing = getParentsBottomSpacing(childContainer); + const parentsBottomSpacing = + getParentsBottomSpacing(childContainer) + + getAbsoluteHeightBelow("status-bar"); const offset = Math.ceil(above + below + parentsBottomSpacing); const style = `height: calc(100vh - ${offset}px); min-height: calc(100vh - ${offset}px)`; childContainer.setAttribute("style", style); diff --git a/src/components/ScrollableTable.tsx b/src/components/ScrollableTable.tsx index 0c71d61fd3..29744c4b0a 100644 --- a/src/components/ScrollableTable.tsx +++ b/src/components/ScrollableTable.tsx @@ -5,14 +5,14 @@ import { getAbsoluteHeightBelow, getParentsBottomSpacing } from "util/helpers"; interface Props { children: ReactNode; dependencies: DependencyList; - belowId?: string; + belowIds?: string[]; tableId: string; } const ScrollableTable: FC = ({ dependencies, children, - belowId = "", + belowIds = [], tableId, }) => { const updateTBodyHeight = () => { @@ -22,7 +22,10 @@ const ScrollableTable: FC = ({ } const tBody = table.children[1]; const above = tBody.getBoundingClientRect().top + 1; - const below = getAbsoluteHeightBelow(belowId); + const below = belowIds.reduce( + (acc, belowId) => acc + getAbsoluteHeightBelow(belowId), + 0, + ); const parentsBottomSpacing = getParentsBottomSpacing(table); const offset = Math.ceil(above + below + parentsBottomSpacing); const style = `height: calc(100vh - ${offset}px); min-height: calc(100vh - ${offset}px)`; diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx new file mode 100644 index 0000000000..ad67478cd6 --- /dev/null +++ b/src/components/StatusBar.tsx @@ -0,0 +1,67 @@ +import React, { FC } from "react"; +import classnames from "classnames"; +import Version from "./Version"; +import OperationStatus from "./OperationStatus"; +import { useToastNotification } from "context/toastNotificationProvider"; +import { ICONS, Icon } from "@canonical/react-components"; +import { iconLookup, severityOrder } from "util/notifications"; +import useEventListener from "@use-it/event-listener"; + +interface Props { + className?: string; +} + +const StatusBar: FC = ({ className }) => { + const { toggleListView, notifications, countBySeverity, isListView } = + useToastNotification(); + + useEventListener("keydown", (e: KeyboardEvent) => { + // Close notifications list if Escape pressed + if (e.code === "Escape" && isListView) { + toggleListView(); + } + }); + + const notificationIcons = severityOrder.map((severity) => { + if (countBySeverity[severity]) { + return ( + + ); + } + }); + + const hasNotifications = !!notifications.length; + return ( + <> + + + ); +}; + +export default StatusBar; diff --git a/src/components/ToastNotification.tsx b/src/components/ToastNotification.tsx new file mode 100644 index 0000000000..54e9aa5eed --- /dev/null +++ b/src/components/ToastNotification.tsx @@ -0,0 +1,58 @@ +import { Notification } from "@canonical/react-components"; +import { DefaultTitles } from "@canonical/react-components/dist/components/Notification/Notification"; +import { ToastNotificationType } from "context/toastNotificationProvider"; +import React, { FC } from "react"; +import { createPortal } from "react-dom"; +import Animate from "./Animate"; + +interface Props { + notification: ToastNotificationType; + onDismiss: (notification?: ToastNotificationType[]) => void; + show: boolean; +} + +const ToastNotification: FC = ({ notification, onDismiss, show }) => { + if (!notification) { + return; + } + + return ( + <> + {createPortal( + +
+ onDismiss([notification])} + className="u-no-margin--bottom" + timestamp={notification.timestamp} + titleElement="div" + role="alert" + > + {notification.message} + +
+
, + document.body, + )} + + ); +}; + +export default ToastNotification; diff --git a/src/components/ToastNotificationList.tsx b/src/components/ToastNotificationList.tsx new file mode 100644 index 0000000000..13089d6e11 --- /dev/null +++ b/src/components/ToastNotificationList.tsx @@ -0,0 +1,239 @@ +import { Icon, Notification, ValueOf } from "@canonical/react-components"; +import { + DefaultTitles, + NotificationSeverity, +} from "@canonical/react-components/dist/components/Notification/Notification"; +import { + GroupedNotificationCount, + ToastNotificationType, +} from "context/toastNotificationProvider"; +import React, { FC, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { iconLookup, severityOrder } from "util/notifications"; +import Animate from "./Animate"; +import { usePrefersReducedMotion } from "util/usePreferReducedMotion"; + +export type FilterTypes = ValueOf; + +interface Props { + notifications: ToastNotificationType[]; + onDismiss: (notification?: ToastNotificationType[]) => void; + groupedCount: GroupedNotificationCount; + show: boolean; +} + +const ToastNotificationList: FC = ({ + notifications, + onDismiss, + groupedCount = {}, + show, +}) => { + const [filters, setFilters] = useState>(new Set()); + const prevNotificationsSize = useRef(notifications.length); + const containerRef = useRef(null); + const hasFilters = !!filters.size; + const preferReducedMotion = usePrefersReducedMotion(); + + useLayoutEffect(() => { + adjustScrollPosition(); + }, [notifications]); + + // this layout effect is used to maintain scroll position of the + // notification list when new notifications are added to the list + // for only when the scroll is at the top + const adjustScrollPosition = () => { + const notificationsRemoved = + notifications.length < prevNotificationsSize.current; + prevNotificationsSize.current = notifications.length; + if (!notifications.length || notificationsRemoved) { + return; + } + + const container = containerRef.current; + const lastNotification = notifications[notifications.length - 1]; + const notificationEl = document.getElementById(lastNotification.id); + if (container && notificationEl) { + const currentScrollY = container.scrollTop; + const offsetHeight = + notificationEl.getBoundingClientRect().height + + parseFloat(window.getComputedStyle(notificationEl).marginTop) + + parseFloat(window.getComputedStyle(notificationEl).marginBottom); + // only adjust the scroll height if the scroll is at the top + if (currentScrollY === 0) { + container.scrollTop = currentScrollY + offsetHeight; + } + } + }; + + const handleFilterSelect = (filter: FilterTypes) => { + setFilters((prevFilters) => { + const newFilters = new Set(prevFilters); + if (!newFilters.has(filter)) { + newFilters.add(filter); + } else { + newFilters.delete(filter); + } + return newFilters; + }); + }; + + const handleGroupedDismiss = () => { + if (hasFilters) { + const notificationsToClear = notifications.filter((notification) => + filters.has(notification.type), + ); + onDismiss(notificationsToClear); + setFilters(new Set()); + return; + } + + onDismiss(); + }; + + const getSeverityFilters = () => { + const filterButtons = severityOrder.map((severity) => { + if (groupedCount[severity]) { + return ( + + ); + } + }); + + return ( +
+ {filterButtons} + {hasFilters && ( + + )} +
+ ); + }; + + const getDismissText = () => { + if (hasFilters) { + const validFilters = Object.keys(groupedCount) as FilterTypes[]; + let totalCount = 0; + for (const filter of validFilters) { + if (filters.has(filter)) { + totalCount += groupedCount[filter] || 0; + } + } + + const dismissText = ( + Dismiss {totalCount} + ); + return dismissText; + } + + return Dismiss all; + }; + + const handleDismissNotification = (notification: ToastNotificationType) => { + if (preferReducedMotion) { + onDismiss([notification]); + return; + } + + // animate the notification dismissal before updating states to delay unmounting + const element = document.getElementById(`li-${notification.id}`); + if (element) { + element.style.transformOrigin = "center"; + element.style.overflow = "hidden"; + const animation = element.animate( + [ + { height: `${element.scrollHeight}px`, opacity: 1 }, + { height: "0px", opacity: 0 }, + ], + { + duration: 200, + easing: "linear", + fill: "forwards", + }, + ); + + animation.onfinish = () => { + element.style.display = "none"; + onDismiss([notification]); + }; + } + }; + + // Only filter input data if there are filters present + const filteredNotifications = hasFilters + ? notifications.filter((notification) => filters.has(notification.type)) + : notifications; + + // Don't assign alert role for notifications when expanded since we don't want + // screen readers to announce every existing notification + const notificationEls = filteredNotifications.map((_, index, array) => { + const lastNotificationIndex = array.length - 1; + // This will map notifications in reverse order + const notification = array[lastNotificationIndex - index]; + return ( +
  • + handleDismissNotification(notification)} + className={`u-no-margin--bottom individual-notification`} + timestamp={notification.timestamp} + titleElement="div" + > + {notification.message} + +
  • + ); + }); + + return createPortal( + +
      + {notificationEls} +
      + {getSeverityFilters()} + +
      +
    +
    , + document.body, + ); +}; + +export default ToastNotificationList; diff --git a/src/components/Version.tsx b/src/components/Version.tsx index 6772b86300..acb4c59098 100644 --- a/src/components/Version.tsx +++ b/src/components/Version.tsx @@ -20,20 +20,18 @@ const Version: FC = () => { return ( <> -
    -
  • + {isOutdated && ( - + )} Version {serverVersion}-ui-{UI_VERSION} -
  • + ); }; diff --git a/src/components/forms/FormFooterLayout.tsx b/src/components/forms/FormFooterLayout.tsx index c14d60bfb4..a084b0536b 100644 --- a/src/components/forms/FormFooterLayout.tsx +++ b/src/components/forms/FormFooterLayout.tsx @@ -5,7 +5,6 @@ interface Props {} const FormFooterLayout: FC = ({ children }) => { return ( ); diff --git a/src/components/forms/ScrollableConfigurationTable.tsx b/src/components/forms/ScrollableConfigurationTable.tsx index b96b1b56da..e960eb5ca8 100644 --- a/src/components/forms/ScrollableConfigurationTable.tsx +++ b/src/components/forms/ScrollableConfigurationTable.tsx @@ -20,7 +20,7 @@ const ScrollableConfigurationTable: FC = ({ return ( +> = ({ children, state, pathname }) => { + return ( + + + {children} + + + ); +}; + +export default CombinedNotificationProvider; diff --git a/src/context/operationsProvider.tsx b/src/context/operationsProvider.tsx new file mode 100644 index 0000000000..f7df5eb75f --- /dev/null +++ b/src/context/operationsProvider.tsx @@ -0,0 +1,90 @@ +import { RefetchOptions, useQuery } from "@tanstack/react-query"; +import { fetchAllOperations } from "api/operations"; +import React, { + FC, + ReactNode, + createContext, + useContext, + useEffect, + useRef, +} from "react"; +import { LxdOperation } from "types/operation"; +import { queryKeys } from "util/queryKeys"; + +type OperationsContextType = { + operations: LxdOperation[]; + runningOperations: LxdOperation[]; + error: Error | null; + isLoading: boolean; + refetchOperations: (options?: RefetchOptions) => void; +}; + +interface Props { + children: ReactNode; +} + +const OperationsContext = createContext({ + operations: [], + runningOperations: [], + error: null, + isLoading: false, + refetchOperations: () => null, +}); + +const OperationsProvider: FC = ({ children }) => { + const { + data: operationList, + error, + isLoading, + refetch, + } = useQuery({ + queryKey: [queryKeys.operations], + queryFn: () => fetchAllOperations(), + }); + const refetchTimerRef = useRef(null); + + useEffect(() => { + // Cleanup the previous timeout on re-render + return () => { + if (refetchTimerRef.current) { + clearTimeout(refetchTimerRef.current); + } + }; + }, []); + + const debouncedRefetch = (options?: RefetchOptions | undefined) => { + const delay = 2_000; + if (refetchTimerRef.current) { + clearTimeout(refetchTimerRef.current); + } + + refetchTimerRef.current = setTimeout(() => { + void refetch(options); + }, delay); + }; + + const failure = operationList?.failure ?? []; + const running = operationList?.running ?? []; + const success = operationList?.success ?? []; + const operations = failure.concat(running).concat(success); + + const ctx = { + operations, + runningOperations: running, + error, + isLoading, + refetchOperations: debouncedRefetch, + }; + + return ( + + {children} + + ); +}; + +export default OperationsProvider; + +export const useOperations = () => { + return useContext(OperationsContext); +}; diff --git a/src/context/toastNotificationProvider.tsx b/src/context/toastNotificationProvider.tsx new file mode 100644 index 0000000000..6a30489764 --- /dev/null +++ b/src/context/toastNotificationProvider.tsx @@ -0,0 +1,199 @@ +import { + NotificationAction, + NotificationType, + ValueOf, + failure, + info, + success, +} from "@canonical/react-components"; +import { NotificationSeverity } from "@canonical/react-components/dist/components/Notification/Notification"; +import ToastNotification from "components/ToastNotification"; +import ToastNotificationList from "components/ToastNotificationList"; +import React, { + FC, + PropsWithChildren, + ReactNode, + createContext, + useContext, + useEffect, + useState, +} from "react"; + +const HIDE_NOTIFICATION_DELAY = 5_000; + +export type ToastNotificationType = NotificationType & { + timestamp?: ReactNode; + id: string; +}; + +type ToastNotificationHelper = { + notifications: ToastNotificationType[]; + success: (message: ReactNode) => ToastNotificationType; + info: (message: ReactNode, title?: string) => ToastNotificationType; + failure: ( + title: string, + error: unknown, + message?: ReactNode, + actions?: NotificationAction[], + ) => ToastNotificationType; + clear: (notification?: ToastNotificationType[]) => void; + toggleListView: () => void; + isListView: boolean; + countBySeverity: GroupedNotificationCount; +}; + +export type GroupedNotificationCount = { + [key in ValueOf]?: number; +}; + +const initialNotification: ToastNotificationType = { + id: "", + message: "", + type: "positive", +}; +const ToastNotificationContext = createContext({ + notifications: [], + success: () => initialNotification, + info: () => initialNotification, + failure: () => initialNotification, + clear: () => null, + toggleListView: () => null, + isListView: false, + countBySeverity: {}, +}); + +const ToastNotificationProvider: FC = ({ children }) => { + const [notifications, setNotifications] = useState( + [], + ); + const [showList, setShowList] = useState(false); + const [notificationTimer, setNotificationTimer] = + useState(null); + + // cleanup on timer if unmounted + useEffect(() => { + return () => { + if (notificationTimer) { + clearTimeout(notificationTimer); + } + }; + }, []); + + const showNotificationWithDelay = () => { + setNotificationTimer((prevTimer) => { + if (prevTimer) { + clearTimeout(prevTimer); + } + + if (!showList) { + return setTimeout(() => { + setNotificationTimer(null); + }, HIDE_NOTIFICATION_DELAY); + } + + return null; + }); + }; + + const clearNotificationTimer = () => { + setNotificationTimer((prevTimer) => { + if (prevTimer) { + clearTimeout(prevTimer); + } + return null; + }); + }; + + const addNotification = ( + notification: NotificationType & { error?: unknown }, + ) => { + const notificationToAdd = { + ...notification, + timestamp: new Date().toLocaleString(), + id: Date.now().toString() + (Math.random() + 1).toString(36).substring(7), + }; + + setNotifications((prev) => { + return [...prev, notificationToAdd]; + }); + + showNotificationWithDelay(); + + return notificationToAdd; + }; + + const clear = (notifications?: ToastNotificationType[]) => { + if (!notifications) { + setNotifications([]); + setShowList(false); + clearNotificationTimer(); + return; + } + + setNotifications((prev) => { + const removeIdLookup = new Set(notifications); + const newNotifications = prev.filter((item) => !removeIdLookup.has(item)); + + // if we are clearing the last notification from an expanded list, + // then we want to collapse the list as well if all notifications has been cleared + if (!newNotifications.length) { + setShowList(false); + } + + return newNotifications; + }); + + clearNotificationTimer(); + }; + + const toggleListView = () => { + clearNotificationTimer(); + setShowList((prev) => !prev); + }; + + const countBySeverity = { + positive: 0, + negative: 0, + caution: 0, + information: 0, + }; + notifications.forEach((notification) => { + countBySeverity[notification.type] += 1; + }); + const helper: ToastNotificationHelper = { + notifications, + failure: (title, error, message, actions) => + addNotification(failure(title, error, message, actions)), + info: (message, title) => addNotification(info(message, title)), + success: (message) => addNotification(success(message)), + clear, + toggleListView, + isListView: showList, + countBySeverity, + }; + + const latestNotification = notifications[notifications.length - 1]; + const hasNotifications = !!notifications?.length; + const showNotification = hasNotifications && !showList && notificationTimer; + const showNotificationList = hasNotifications && showList; + return ( + + {children} + + + + ); +}; + +export default ToastNotificationProvider; + +export const useToastNotification = () => useContext(ToastNotificationContext); diff --git a/src/pages/cluster/ClusterGroupForm.tsx b/src/pages/cluster/ClusterGroupForm.tsx index 81d79c2eb3..ab4b5c2fdc 100644 --- a/src/pages/cluster/ClusterGroupForm.tsx +++ b/src/pages/cluster/ClusterGroupForm.tsx @@ -5,7 +5,6 @@ import { Form, Input, Row, - success, useNotify, } from "@canonical/react-components"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -28,6 +27,7 @@ import SelectableMainTable from "components/SelectableMainTable"; import NotificationRow from "components/NotificationRow"; import BaseLayout from "components/BaseLayout"; import AutoExpandingTextArea from "components/AutoExpandingTextArea"; +import { useToastNotification } from "context/toastNotificationProvider"; export interface ClusterGroupFormValues { description: string; @@ -42,6 +42,7 @@ interface Props { const ClusterGroupForm: FC = ({ group }) => { const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); const { group: activeGroup } = useParams<{ group: string }>(); const queryClient = useQueryClient(); const controllerState = useState(null); @@ -83,10 +84,8 @@ const ClusterGroupForm: FC = ({ group }) => { }) .then(() => { const verb = group ? "saved" : "created"; - navigate( - `/ui/cluster/groups/detail/${values.name}`, - notify.queue(success(`Cluster group ${values.name} ${verb}.`)), - ); + navigate(`/ui/cluster/groups/detail/${values.name}`); + toastNotify.success(`Cluster group ${values.name} ${verb}.`); }) .catch((e: Error) => { formik.setSubmitting(false); diff --git a/src/pages/cluster/ClusterList.tsx b/src/pages/cluster/ClusterList.tsx index 9656ae4b25..a3d459ef42 100644 --- a/src/pages/cluster/ClusterList.tsx +++ b/src/pages/cluster/ClusterList.tsx @@ -88,6 +88,7 @@ const ClusterList: FC = () => { = ({ group }) => { const notify = useNotify(); + const toastNotify = useToastNotification(); const [isLoading, setLoading] = useState(false); const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -24,10 +22,8 @@ const DeleteClusterGroupBtn: FC = ({ group }) => { setLoading(true); deleteClusterGroup(group) .then(() => { - navigate( - `/ui/cluster`, - notify.queue(success(`Cluster group ${group} deleted.`)), - ); + navigate(`/ui/cluster`); + toastNotify.success(`Cluster group ${group} deleted.`); }) .catch((e) => { setLoading(false); diff --git a/src/pages/cluster/actions/EvacuateClusterMemberBtn.tsx b/src/pages/cluster/actions/EvacuateClusterMemberBtn.tsx index a7e119a0ff..29afa58282 100644 --- a/src/pages/cluster/actions/EvacuateClusterMemberBtn.tsx +++ b/src/pages/cluster/actions/EvacuateClusterMemberBtn.tsx @@ -5,6 +5,7 @@ import { queryKeys } from "util/queryKeys"; import { useQueryClient } from "@tanstack/react-query"; import { LxdClusterMember } from "types/cluster"; import { ConfirmationButton, useNotify } from "@canonical/react-components"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { member: LxdClusterMember; @@ -12,6 +13,7 @@ interface Props { const EvacuateClusterMemberBtn: FC = ({ member }) => { const notify = useNotify(); + const toastNotify = useToastNotification(); const [isLoading, setLoading] = useState(false); const queryClient = useQueryClient(); @@ -19,7 +21,7 @@ const EvacuateClusterMemberBtn: FC = ({ member }) => { setLoading(true); postClusterMemberState(member, "evacuate") .then(() => { - notify.success( + toastNotify.success( `Cluster member ${member.server_name} evacuation started.`, ); }) diff --git a/src/pages/cluster/actions/RestoreClusterMemberBtn.tsx b/src/pages/cluster/actions/RestoreClusterMemberBtn.tsx index 7424f09da4..19947d009b 100644 --- a/src/pages/cluster/actions/RestoreClusterMemberBtn.tsx +++ b/src/pages/cluster/actions/RestoreClusterMemberBtn.tsx @@ -5,6 +5,7 @@ import { queryKeys } from "util/queryKeys"; import { useQueryClient } from "@tanstack/react-query"; import { LxdClusterMember } from "types/cluster"; import { ConfirmationButton, useNotify } from "@canonical/react-components"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { member: LxdClusterMember; @@ -12,6 +13,7 @@ interface Props { const RestoreClusterMemberBtn: FC = ({ member }) => { const notify = useNotify(); + const toastNotify = useToastNotification(); const [isLoading, setLoading] = useState(false); const queryClient = useQueryClient(); @@ -19,7 +21,9 @@ const RestoreClusterMemberBtn: FC = ({ member }) => { setLoading(true); postClusterMemberState(member, "restore") .then(() => { - notify.success(`Cluster member ${member.server_name} restore started.`); + toastNotify.success( + `Cluster member ${member.server_name} restore started.`, + ); }) .catch((e) => notify.failure("Cluster member restore failed", e)) .finally(() => { diff --git a/src/pages/images/ImageList.tsx b/src/pages/images/ImageList.tsx index a073ac78b6..37c5d53cd7 100644 --- a/src/pages/images/ImageList.tsx +++ b/src/pages/images/ImageList.tsx @@ -239,7 +239,11 @@ const ImageList: FC = () => { )} {images.length > 0 && ( - + = ({ onFinish, }) => { const eventQueue = useEventQueue(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const [isLoading, setLoading] = useState(false); const queryClient = useQueryClient(); @@ -34,14 +35,14 @@ const BulkDeleteImageBtn: FC = ({ const { fulfilledCount, rejectedCount } = getPromiseSettledCounts(results); if (fulfilledCount === count) { - notify.success( + toastNotify.success( <> {fingerprints.length}{" "} {pluralize("image", fingerprints.length)} deleted. , ); } else if (rejectedCount === count) { - notify.failure( + toastNotify.failure( "Image bulk deletion failed", undefined, <> @@ -49,7 +50,7 @@ const BulkDeleteImageBtn: FC = ({ , ); } else { - notify.failure( + toastNotify.failure( "Image bulk deletion partially failed", undefined, <> diff --git a/src/pages/images/actions/DeleteImageBtn.tsx b/src/pages/images/actions/DeleteImageBtn.tsx index ccec2b264a..bb3c869ff1 100644 --- a/src/pages/images/actions/DeleteImageBtn.tsx +++ b/src/pages/images/actions/DeleteImageBtn.tsx @@ -3,12 +3,9 @@ import { deleteImage } from "api/images"; import { LxdImage } from "types/image"; import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; -import { - ConfirmationButton, - Icon, - useNotify, -} from "@canonical/react-components"; +import { ConfirmationButton, Icon } from "@canonical/react-components"; import { useEventQueue } from "context/eventQueue"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { image: LxdImage; @@ -17,7 +14,7 @@ interface Props { const DeleteImageBtn: FC = ({ image, project }) => { const eventQueue = useEventQueue(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const [isLoading, setLoading] = useState(false); const queryClient = useQueryClient(); @@ -33,9 +30,13 @@ const DeleteImageBtn: FC = ({ image, project }) => { void queryClient.invalidateQueries({ queryKey: [queryKeys.projects, project], }); - notify.success(`Image ${image.properties.description} deleted.`); + toastNotify.success(`Image ${image.properties.description} deleted.`); }, - (msg) => notify.failure("Image deletion failed", new Error(msg)), + (msg) => + toastNotify.failure( + `Image ${image.properties.description} deletion failed`, + new Error(msg), + ), () => setLoading(false), ), ); diff --git a/src/pages/images/actions/UploadCustomIsoBtn.tsx b/src/pages/images/actions/UploadCustomIsoBtn.tsx index 07a790edf7..aac4d431de 100644 --- a/src/pages/images/actions/UploadCustomIsoBtn.tsx +++ b/src/pages/images/actions/UploadCustomIsoBtn.tsx @@ -1,23 +1,24 @@ import React, { FC } from "react"; -import { Button, Modal, useNotify } from "@canonical/react-components"; +import { Button, Modal } from "@canonical/react-components"; import UploadCustomIso from "pages/storage/UploadCustomIso"; import usePortal from "react-useportal"; import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { className?: string; } const UploadCustomIsoBtn: FC = ({ className }) => { - const notify = useNotify(); + const toastNotify = useToastNotification(); const { openPortal, closePortal, isOpen, Portal } = usePortal(); const queryClient = useQueryClient(); const handleCancel = () => closePortal(); const handleFinish = (name: string) => { - notify.success( + toastNotify.success( <> Image {name} uploaded successfully , diff --git a/src/pages/instances/CreateInstance.tsx b/src/pages/instances/CreateInstance.tsx index d31e530e2b..60b0ebf14c 100644 --- a/src/pages/instances/CreateInstance.tsx +++ b/src/pages/instances/CreateInstance.tsx @@ -73,6 +73,7 @@ import { hasNoRootDisk, } from "util/instanceValidation"; import FormFooterLayout from "components/forms/FormFooterLayout"; +import { useToastNotification } from "context/toastNotificationProvider"; export type CreateInstanceFormValues = InstanceDetailsFormValues & FormDeviceValues & @@ -92,6 +93,7 @@ const CreateInstance: FC = () => { const eventQueue = useEventQueue(); const location = useLocation() as Location; const navigate = useNavigate(); + const toastNotify = useToastNotification(); const notify = useNotify(); const { project } = useParams<{ project: string }>(); const queryClient = useQueryClient(); @@ -124,7 +126,7 @@ const CreateInstance: FC = () => { const updateFormHeight = () => { updateMaxHeight("form-contents", "p-bottom-controls"); }; - useEffect(updateFormHeight, [notify.notification?.message, section]); + useEffect(updateFormHeight, [section]); useEventListener("resize", updateFormHeight); const clearCache = () => { @@ -139,27 +141,31 @@ const CreateInstance: FC = () => { }); }; + const notifyCreationStarted = (instanceName: string) => { + toastNotify.info(<>Creation for instance {instanceName} started.); + }; + const notifyCreatedNowStarting = (instanceLink: ReactNode) => { - notify.info(<>Created instance {instanceLink}, now starting it.); + toastNotify.info(<>Created instance {instanceLink}, now starting it.); clearCache(); }; const notifyCreatedAndStarted = (instanceLink: ReactNode) => { - notify.success(<>Created and started instance {instanceLink}.); + toastNotify.success(<>Created and started instance {instanceLink}.); clearCache(); }; const notifyCreatedButStartFailed = (instanceLink: ReactNode, e: Error) => { - notify.failure( - "Error", + toastNotify.failure( + "The instance was created, but could not be started.", e, - <>The instance {instanceLink} was created, but could not be started., + instanceLink, ); clearCache(); }; const notifyCreated = (instanceLink: ReactNode, message?: ReactNode) => { - notify.success( + toastNotify.success( <> Created instance {instanceLink}.{message} , @@ -171,8 +177,10 @@ const CreateInstance: FC = () => { e: Error, formUrl: string, values: CreateInstanceFormValues, + notifierType?: "inline" | "toast", ) => { - notify.failure("Instance creation failed", e, null, [ + const notifier = notifierType === "toast" ? toastNotify : notify; + notifier.failure("Instance creation failed", e, null, [ { label: "Check configuration", onClick: () => @@ -245,11 +253,13 @@ const CreateInstance: FC = () => { if (!instanceName) { return; } + notifyCreationStarted(instanceName); const isIsoImage = values.image?.server === LOCAL_ISO; eventQueue.set( operation.metadata.id, () => creationCompletedHandler(instanceName, shouldStart, isIsoImage), - (msg) => notifyCreationFailed(new Error(msg), formUrl, values), + (msg) => + notifyCreationFailed(new Error(msg), formUrl, values, "toast"), ); }) .catch((e: Error) => { @@ -295,7 +305,6 @@ const CreateInstance: FC = () => { } else if (isContainerOnlyImage(image)) { void formik.setFieldValue("instanceType", "container"); } - notify.clear(); }; useEffect(() => { diff --git a/src/pages/instances/EditInstance.tsx b/src/pages/instances/EditInstance.tsx index 65920c0637..6f68cdf4bc 100644 --- a/src/pages/instances/EditInstance.tsx +++ b/src/pages/instances/EditInstance.tsx @@ -5,7 +5,6 @@ import { Form, Notification, Row, - useNotify, } from "@canonical/react-components"; import { useFormik } from "formik"; import { updateInstance } from "api/instances"; @@ -54,6 +53,8 @@ import { slugify } from "util/slugify"; import { useEventQueue } from "context/eventQueue"; import { hasDiskError, hasNetworkError } from "util/instanceValidation"; import FormFooterLayout from "components/forms/FormFooterLayout"; +import { useToastNotification } from "context/toastNotificationProvider"; +import { instanceLinkFromOperation } from "util/instances"; export interface InstanceEditDetailsFormValues { name: string; @@ -79,7 +80,7 @@ interface Props { const EditInstance: FC = ({ instance }) => { const eventQueue = useEventQueue(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const { project, section } = useParams<{ project: string; section?: string; @@ -95,7 +96,7 @@ const EditInstance: FC = ({ instance }) => { const updateFormHeight = () => { updateMaxHeight("form-contents", "p-bottom-controls"); }; - useEffect(updateFormHeight, [notify.notification?.message, section]); + useEffect(updateFormHeight, [section]); useEventListener("resize", updateFormHeight); const formik = useFormik({ @@ -112,13 +113,23 @@ const EditInstance: FC = ({ instance }) => { instancePayload.etag = instance.etag; void updateInstance(instancePayload, project).then((operation) => { + const instanceLink = instanceLinkFromOperation({ + operation, + project, + }); + if (!instanceLink) return; eventQueue.set( operation.metadata.id, () => { - notify.success("Instance updated."); + toastNotify.success(<>Instance {instanceLink} updated.); void formik.setValues(getInstanceEditValues(instancePayload)); }, - (msg) => notify.failure("Instance update failed", new Error(msg)), + (msg) => + toastNotify.failure( + "Instance update failed.", + new Error(msg), + instanceLink, + ), () => { formik.setSubmitting(false); void queryClient.invalidateQueries({ @@ -230,7 +241,6 @@ const EditInstance: FC = ({ instance }) => { appearance="positive" onClick={() => { void formik.setFieldValue("readOnly", false); - notify.clear(); }} > Edit instance diff --git a/src/pages/instances/Events.tsx b/src/pages/instances/Events.tsx index 5c3cff0910..c66e8bde32 100644 --- a/src/pages/instances/Events.tsx +++ b/src/pages/instances/Events.tsx @@ -4,12 +4,14 @@ import { useEventQueue } from "context/eventQueue"; import { useAuth } from "context/auth"; import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; +import { useOperations } from "context/operationsProvider"; const Events: FC = () => { const { isAuthenticated } = useAuth(); const eventQueue = useEventQueue(); const queryClient = useQueryClient(); const [eventWs, setEventWs] = useState(null); + const { refetchOperations } = useOperations(); const handleEvent = (event: LxdEvent) => { const eventCallback = eventQueue.get(event.metadata.id); @@ -47,8 +49,9 @@ const Events: FC = () => { void queryClient.invalidateQueries({ queryKey: [queryKeys.operations, event.project], }); + void refetchOperations(); } - setTimeout(() => handleEvent(event), 1000); // handle with delay to allow operations to vanish + handleEvent(event); }; }; diff --git a/src/pages/instances/InstanceConsole.tsx b/src/pages/instances/InstanceConsole.tsx index 5be1c4e29e..9dd59b45d3 100644 --- a/src/pages/instances/InstanceConsole.tsx +++ b/src/pages/instances/InstanceConsole.tsx @@ -4,12 +4,9 @@ import { ContextualMenu, EmptyState, Icon, - NotificationType, RadioInput, - failure, - info, + useNotify, } from "@canonical/react-components"; -import NotificationRowLegacy from "components/NotificationRowLegacy"; import InstanceGraphicConsole from "./InstanceGraphicConsole"; import { LxdInstance } from "types/instance"; import InstanceTextConsole from "./InstanceTextConsole"; @@ -21,29 +18,27 @@ import { sendCtrlAltDel, } from "../../lib/spice/src/inputs"; import AttachIsoBtn from "pages/instances/actions/AttachIsoBtn"; +import NotificationRow from "components/NotificationRow"; interface Props { instance: LxdInstance; } const InstanceConsole: FC = ({ instance }) => { - const [inTabNotification, setInTabNotification] = - useState(null); + const notify = useNotify(); const isVm = instance.type === "virtual-machine"; const [isGraphic, setGraphic] = useState(isVm); const isRunning = instance.status === "Running"; const onFailure = (title: string, e: unknown, message?: string) => { - setInTabNotification(failure(title, e, message)); + notify.failure(title, e, message); }; const showNotRunningInfo = () => { - setInTabNotification( - info( - "Start the instance to interact with the text console.", - "Instance not running", - ), + notify.info( + "Start the instance to interact with the text console.", + "Instance not running", ); }; @@ -56,7 +51,7 @@ const InstanceConsole: FC = ({ instance }) => { }; const setGraphicConsole = (isGraphic: boolean) => { - setInTabNotification(null); + notify.clear(); setGraphic(isGraphic); }; @@ -111,10 +106,7 @@ const InstanceConsole: FC = ({ instance }) => { )} )} - setInTabNotification(null)} - /> + {isGraphic && !isRunning && ( = ({ instance }) => { instance={instance} onMount={onChildMount} onFailure={onFailure} - inTabNotification={inTabNotification} - clearNotification={() => setInTabNotification(null)} /> )} @@ -146,7 +136,6 @@ const InstanceConsole: FC = ({ instance }) => { instance={instance} onFailure={onFailure} showNotRunningInfo={showNotRunningInfo} - clearNotification={() => setInTabNotification(null)} /> )} diff --git a/src/pages/instances/InstanceDetail.tsx b/src/pages/instances/InstanceDetail.tsx index 109674a186..89a6d7da1b 100644 --- a/src/pages/instances/InstanceDetail.tsx +++ b/src/pages/instances/InstanceDetail.tsx @@ -1,5 +1,5 @@ import React, { FC } from "react"; -import { Row, useNotify } from "@canonical/react-components"; +import { Notification, Row, Strip } from "@canonical/react-components"; import InstanceOverview from "./InstanceOverview"; import InstanceTerminal from "./InstanceTerminal"; import { useParams } from "react-router-dom"; @@ -12,7 +12,6 @@ import InstanceConsole from "pages/instances/InstanceConsole"; import InstanceLogs from "pages/instances/InstanceLogs"; import EditInstance from "./EditInstance"; import InstanceDetailHeader from "pages/instances/InstanceDetailHeader"; -import NotificationRow from "components/NotificationRow"; import CustomLayout from "components/CustomLayout"; import TabLinks from "components/TabLinks"; @@ -26,7 +25,6 @@ const tabs: string[] = [ ]; const InstanceDetail: FC = () => { - const notify = useNotify(); const { name, project, activeTab } = useParams<{ name: string; project: string; @@ -49,10 +47,6 @@ const InstanceDetail: FC = () => { queryFn: () => fetchInstance(name, project), }); - if (error) { - notify.failure("Loading instance failed", error); - } - return ( { } contentClassName="detail-page" > - {isLoading && } - {!isLoading && !instance && <>Loading instance failed} + {!isLoading && !instance && !error && <>Loading instance failed} + {error && ( + + + {error.message} + + + )} {!isLoading && instance && ( = ({ name, instance, project }) => { const eventQueue = useEventQueue(); const navigate = useNavigate(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const controllerState = useState(null); const RenameSchema = Yup.object().shape({ @@ -54,16 +59,28 @@ const InstanceDetailHeader: FC = ({ name, instance, project }) => { return; } void renameInstance(name, values.name, project).then((operation) => { + const instanceLink = instanceLinkFromName({ + instanceName: values.name, + project, + }); eventQueue.set( operation.metadata.id, () => { - navigate( - `/ui/project/${project}/instances/detail/${values.name}`, - notify.queue(success("Instance renamed.")), + navigate(`/ui/project/${project}/instances/detail/${values.name}`); + toastNotify.success( + <> + Instance {getInstanceName(operation.metadata)}{" "} + renamed to {instanceLink}. + , ); void formik.setFieldValue("isRenaming", false); }, - (msg) => notify.failure("Renaming failed", new Error(msg)), + (msg) => + toastNotify.failure( + "Renaming instance failed.", + new Error(msg), + instanceLinkFromOperation({ operation, project }), + ), () => formik.setSubmitting(false), ); }); diff --git a/src/pages/instances/InstanceGraphicConsole.tsx b/src/pages/instances/InstanceGraphicConsole.tsx index 14a95e4403..12da1ede45 100644 --- a/src/pages/instances/InstanceGraphicConsole.tsx +++ b/src/pages/instances/InstanceGraphicConsole.tsx @@ -7,7 +7,7 @@ import useEventListener from "@use-it/event-listener"; import Loader from "components/Loader"; import { updateMaxHeight } from "util/updateMaxHeight"; import { LxdInstance } from "types/instance"; -import { NotificationType, useNotify } from "@canonical/react-components"; +import { useNotify } from "@canonical/react-components"; declare global { // eslint-disable-next-line no-unused-vars @@ -20,16 +20,12 @@ interface Props { instance: LxdInstance; onMount: (handler: () => void) => void; onFailure: (title: string, e: unknown, message?: string) => void; - inTabNotification: NotificationType | null; - clearNotification: () => void; } const InstanceGraphicConsole: FC = ({ instance, onMount, onFailure, - inTabNotification, - clearNotification, }) => { const { name, project } = useParams<{ name: string; @@ -111,13 +107,10 @@ const InstanceGraphicConsole: FC = ({ }; useEventListener("resize", handleResize); - useEffect(handleResize, [ - notify.notification?.message, - inTabNotification?.message, - ]); + useEffect(handleResize, [notify.notification?.message]); useEffect(() => { - clearNotification(); + notify.clear(); const websocketPromise = openVgaConsole(); return () => { try { diff --git a/src/pages/instances/InstanceList.tsx b/src/pages/instances/InstanceList.tsx index 09d9c64d53..4f92b5d46b 100644 --- a/src/pages/instances/InstanceList.tsx +++ b/src/pages/instances/InstanceList.tsx @@ -548,6 +548,7 @@ const InstanceList: FC = () => { = ({ instance }) => { - const [inTabNotification, setInTabNotification] = - useState(null); + const notify = useNotify(); const { data: settings } = useSettings(); const onFailure = (title: string, e: unknown) => { - setInTabNotification(failure(title, e)); + notify.failure(title, e); }; const updateContentHeight = () => { updateMaxHeight("instance-overview-tab"); }; - useEffect(updateContentHeight, [inTabNotification]); + useEffect(updateContentHeight, [notify.notification?.message]); useEventListener("resize", updateContentHeight); const pid = @@ -41,10 +35,7 @@ const InstanceOverview: FC = ({ instance }) => { return (
    - setInTabNotification(null)} - /> +

    General

    diff --git a/src/pages/instances/InstanceSnapshots.tsx b/src/pages/instances/InstanceSnapshots.tsx index 986e22ae90..865c4d5166 100644 --- a/src/pages/instances/InstanceSnapshots.tsx +++ b/src/pages/instances/InstanceSnapshots.tsx @@ -2,15 +2,12 @@ import React, { ReactNode, useEffect, useState } from "react"; import { EmptyState, Icon, - NotificationType, SearchBox, TablePagination, - failure, - success, + useNotify, } from "@canonical/react-components"; import { isoTimeToString } from "util/helpers"; import { LxdInstance } from "types/instance"; -import NotificationRowLegacy from "components/NotificationRowLegacy"; import InstanceSnapshotActions from "./actions/snapshots/InstanceSnapshotActions"; import useEventListener from "@use-it/event-listener"; import ItemName from "components/ItemName"; @@ -25,6 +22,8 @@ import InstanceConfigureSnapshotsBtn from "./actions/snapshots/InstanceConfigure import InstanceAddSnapshotBtn from "./actions/snapshots/InstanceAddSnapshotBtn"; import { isSnapshotsDisabled } from "util/snapshots"; import useSortTableData from "util/useSortTableData"; +import { useToastNotification } from "context/toastNotificationProvider"; +import NotificationRow from "components/NotificationRow"; const collapsedViewMaxWidth = 1250; export const figureCollapsedScreen = (): boolean => @@ -38,18 +37,18 @@ const InstanceSnapshots = (props: Props) => { const { instance } = props; const docBaseLink = useDocs(); const [query, setQuery] = useState(""); - const [inTabNotification, setInTabNotification] = - useState(null); + const notify = useNotify(); + const toastNotify = useToastNotification(); const [selectedNames, setSelectedNames] = useState([]); const [processingNames, setProcessingNames] = useState([]); const [isSmallScreen, setSmallScreen] = useState(figureCollapsedScreen()); const onSuccess = (message: ReactNode) => { - setInTabNotification(success(message)); + toastNotify.success(message); }; const onFailure = (title: string, e: unknown, message?: ReactNode) => { - setInTabNotification(failure(title, e, message)); + notify.failure(title, e, message); }; const { project, isLoading } = useProject(); @@ -241,15 +240,13 @@ const InstanceSnapshots = (props: Props) => { )}
    )} - setInTabNotification(null)} - /> + {hasSnapshots ? ( <> = ({ instance }) => { project: string; }>(); const textEncoder = new TextEncoder(); - const [inTabNotification, setInTabNotification] = - useState(null); + const notify = useNotify(); const [isLoading, setLoading] = useState(false); const [dataWs, setDataWs] = useState(null); const [controlWs, setControlWs] = useState(null); @@ -79,11 +73,11 @@ const InstanceTerminal: FC = ({ instance }) => { const openWebsockets = async (payload: TerminalConnectPayload) => { if (!name) { - setInTabNotification(failure("Missing name", new Error())); + notify.failure("Missing name", new Error()); return; } if (!project) { - setInTabNotification(failure("Missing project", new Error())); + notify.failure("Missing project", new Error()); return; } @@ -91,7 +85,7 @@ const InstanceTerminal: FC = ({ instance }) => { const result = await connectInstanceExec(name, project, payload).catch( (e) => { setLoading(false); - setInTabNotification(failure("Connection failed", e)); + notify.failure("Connection failed", e); }, ); if (!result) { @@ -111,14 +105,12 @@ const InstanceTerminal: FC = ({ instance }) => { }; control.onerror = (e) => { - setInTabNotification(failure("Error", e)); + notify.failure("Error", e); }; control.onclose = (event) => { if (1005 !== event.code) { - setInTabNotification( - failure("Error", event.reason, getWsErrorMsg(event.code)), - ); + notify.failure("Error", event.reason, getWsErrorMsg(event.code)); } setControlWs(null); }; @@ -128,14 +120,12 @@ const InstanceTerminal: FC = ({ instance }) => { }; data.onerror = (e) => { - setInTabNotification(failure("Error", e)); + notify.failure("Error", e); }; data.onclose = (event) => { if (1005 !== event.code) { - setInTabNotification( - failure("Error", event.reason, getWsErrorMsg(event.code)), - ); + notify.failure("Error", event.reason, getWsErrorMsg(event.code)); } setDataWs(null); setUserInteracted(false); @@ -151,7 +141,7 @@ const InstanceTerminal: FC = ({ instance }) => { useEffect(() => { xtermRef.current?.clear(); - setInTabNotification(null); + notify.clear(); const websocketPromise = openWebsockets(payload); return () => { void websocketPromise.then((websockets) => { @@ -205,10 +195,7 @@ const InstanceTerminal: FC = ({ instance }) => {
    - setInTabNotification(null)} - /> + {isLoading && } {controlWs && ( void; showNotRunningInfo: () => void; - clearNotification: () => void; } const InstanceTextConsole: FC = ({ instance, onFailure, showNotRunningInfo, - clearNotification, }) => { const { name, project } = useParams<{ name: string; @@ -38,6 +37,7 @@ const InstanceTextConsole: FC = ({ const [fitAddon] = useState(new FitAddon()); const [userInteracted, setUserInteracted] = useState(false); const xtermRef = useRef(null); + const notify = useNotify(); usePrompt({ when: userInteracted, @@ -134,7 +134,7 @@ const InstanceTextConsole: FC = ({ if (dataWs) { return; } - clearNotification(); + notify.clear(); const websocketPromise = openWebsockets(); return () => { void websocketPromise.then((websockets) => { diff --git a/src/pages/instances/actions/AttachIsoBtn.tsx b/src/pages/instances/actions/AttachIsoBtn.tsx index b345862eea..f4003bf59b 100644 --- a/src/pages/instances/actions/AttachIsoBtn.tsx +++ b/src/pages/instances/actions/AttachIsoBtn.tsx @@ -2,7 +2,7 @@ import React, { FC, useState } from "react"; import { updateInstance } from "api/instances"; import { LxdInstance } from "types/instance"; import { useParams } from "react-router-dom"; -import { ActionButton, useNotify } from "@canonical/react-components"; +import { ActionButton } from "@canonical/react-components"; import { getInstanceEditValues, getInstancePayload } from "util/instanceEdit"; import { LxdIsoDevice } from "types/device"; import { useQueryClient } from "@tanstack/react-query"; @@ -12,6 +12,8 @@ import { RemoteImage } from "types/image"; import CustomIsoModal from "pages/images/CustomIsoModal"; import { remoteImageToIsoDevice } from "util/formDevices"; import { useEventQueue } from "context/eventQueue"; +import { useToastNotification } from "context/toastNotificationProvider"; +import { instanceLinkFromOperation } from "util/instances"; interface Props { instance: LxdInstance; @@ -20,7 +22,7 @@ interface Props { const AttachIsoBtn: FC = ({ instance }) => { const eventQueue = useEventQueue(); const { project } = useParams<{ project: string }>(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const { openPortal, closePortal, isOpen, Portal } = usePortal(); const [isLoading, setLoading] = useState(false); @@ -40,15 +42,25 @@ const AttachIsoBtn: FC = ({ instance }) => { values, ) as LxdInstance; void updateInstance(instanceMinusIso, project ?? "").then((operation) => { + const instanceLink = instanceLinkFromOperation({ + operation, + project, + }); eventQueue.set( operation.metadata.id, () => - notify.success( + toastNotify.success( <> - ISO {attachedIso?.source ?? ""} detached + ISO {attachedIso?.source ?? ""} detached from{" "} + {instanceLink} , ), - (msg) => notify.failure("Detach ISO failed", new Error(msg)), + (msg) => + toastNotify.failure( + "Detaching ISO failed.", + new Error(msg), + instanceLink, + ), () => { void queryClient.invalidateQueries({ queryKey: [queryKeys.instances, instance.name, project], @@ -67,15 +79,24 @@ const AttachIsoBtn: FC = ({ instance }) => { values.devices.push(isoDevice); const instancePlusIso = getInstancePayload(instance, values) as LxdInstance; void updateInstance(instancePlusIso, project ?? "").then((operation) => { + const instanceLink = instanceLinkFromOperation({ + operation, + project, + }); eventQueue.set( operation.metadata.id, () => - notify.success( + toastNotify.success( <> - ISO {image.aliases} attached + ISO {image.aliases} attached to {instanceLink} , ), - (msg) => notify.failure("Attaching ISO failed", new Error(msg)), + (msg) => + toastNotify.failure( + "Attaching ISO failed.", + new Error(msg), + instanceLink, + ), () => { void queryClient.invalidateQueries({ queryKey: [queryKeys.instances, instance.name, project], diff --git a/src/pages/instances/actions/DeleteInstanceBtn.tsx b/src/pages/instances/actions/DeleteInstanceBtn.tsx index 819814cf99..9e2a499436 100644 --- a/src/pages/instances/actions/DeleteInstanceBtn.tsx +++ b/src/pages/instances/actions/DeleteInstanceBtn.tsx @@ -5,16 +5,12 @@ import { useNavigate } from "react-router-dom"; import ItemName from "components/ItemName"; import { deletableStatuses } from "util/instanceDelete"; import { useDeleteIcon } from "context/useDeleteIcon"; -import { - ConfirmationButton, - Icon, - success, - useNotify, -} from "@canonical/react-components"; +import { ConfirmationButton, Icon } from "@canonical/react-components"; import classnames from "classnames"; import { useEventQueue } from "context/eventQueue"; import { queryKeys } from "util/queryKeys"; import { useQueryClient } from "@tanstack/react-query"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { instance: LxdInstance; @@ -23,7 +19,7 @@ interface Props { const DeleteInstanceBtn: FC = ({ instance }) => { const eventQueue = useEventQueue(); const isDeleteIcon = useDeleteIcon(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const [isLoading, setLoading] = useState(false); const navigate = useNavigate(); @@ -37,13 +33,11 @@ const DeleteInstanceBtn: FC = ({ instance }) => { void queryClient.invalidateQueries({ queryKey: [queryKeys.projects, instance.project], }); - navigate( - `/ui/project/${instance.project}/instances`, - notify.queue(success(`Instance ${instance.name} deleted.`)), - ); + navigate(`/ui/project/${instance.project}/instances`); + toastNotify.success(`Instance ${instance.name} deleted.`); }, (msg) => - notify.failure( + toastNotify.failure( "Instance deletion failed", new Error(msg), <> diff --git a/src/pages/instances/actions/FreezeInstanceBtn.tsx b/src/pages/instances/actions/FreezeInstanceBtn.tsx index 114772ac18..9e2307ed0b 100644 --- a/src/pages/instances/actions/FreezeInstanceBtn.tsx +++ b/src/pages/instances/actions/FreezeInstanceBtn.tsx @@ -6,12 +6,9 @@ import { freezeInstance } from "api/instances"; import { useInstanceLoading } from "context/instanceLoading"; import InstanceLink from "pages/instances/InstanceLink"; import ItemName from "components/ItemName"; -import { - ConfirmationButton, - Icon, - useNotify, -} from "@canonical/react-components"; +import { ConfirmationButton, Icon } from "@canonical/react-components"; import { useEventQueue } from "context/eventQueue"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { instance: LxdInstance; @@ -20,7 +17,7 @@ interface Props { const FreezeInstanceBtn: FC = ({ instance }) => { const eventQueue = useEventQueue(); const instanceLoading = useInstanceLoading(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const isLoading = instanceLoading.getType(instance) === "Freezing" || @@ -32,13 +29,13 @@ const FreezeInstanceBtn: FC = ({ instance }) => { eventQueue.set( operation.metadata.id, () => - notify.success( + toastNotify.success( <> Instance frozen. , ), (msg) => - notify.failure( + toastNotify.failure( "Instance freeze failed", new Error(msg), <> diff --git a/src/pages/instances/actions/InstanceBulkActions.tsx b/src/pages/instances/actions/InstanceBulkActions.tsx index 4be60d23ce..41ae915dbb 100644 --- a/src/pages/instances/actions/InstanceBulkActions.tsx +++ b/src/pages/instances/actions/InstanceBulkActions.tsx @@ -11,8 +11,8 @@ import { } from "util/instanceBulkActions"; import InstanceBulkAction from "pages/instances/actions/InstanceBulkAction"; import { getPromiseSettledCounts } from "util/helpers"; -import { useNotify } from "@canonical/react-components"; import { useEventQueue } from "context/eventQueue"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { instances: LxdInstance[]; @@ -22,7 +22,7 @@ interface Props { const InstanceBulkActions: FC = ({ instances, onStart, onFinish }) => { const eventQueue = useEventQueue(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const [activeAction, setActiveAction] = useState( null, @@ -41,13 +41,13 @@ const InstanceBulkActions: FC = ({ instances, onStart, onFinish }) => { const { fulfilledCount, rejectedCount } = getPromiseSettledCounts(results); if (fulfilledCount === count) { - notify.success( + toastNotify.success( <> {count} {pluralizeInstance(count)} {action}. , ); } else if (rejectedCount === count) { - notify.failure( + toastNotify.failure( `Instance ${desiredAction} failed`, undefined, <> @@ -55,7 +55,7 @@ const InstanceBulkActions: FC = ({ instances, onStart, onFinish }) => { , ); } else { - notify.failure( + toastNotify.failure( `Instance ${desiredAction} partially failed`, undefined, <> diff --git a/src/pages/instances/actions/InstanceBulkDelete.tsx b/src/pages/instances/actions/InstanceBulkDelete.tsx index edfb8defcc..47759d3b7a 100644 --- a/src/pages/instances/actions/InstanceBulkDelete.tsx +++ b/src/pages/instances/actions/InstanceBulkDelete.tsx @@ -6,12 +6,9 @@ import { queryKeys } from "util/queryKeys"; import { useQueryClient } from "@tanstack/react-query"; import { deletableStatuses } from "util/instanceDelete"; import { getPromiseSettledCounts } from "util/helpers"; -import { - ConfirmationButton, - Icon, - useNotify, -} from "@canonical/react-components"; +import { ConfirmationButton, Icon } from "@canonical/react-components"; import { useEventQueue } from "context/eventQueue"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { instances: LxdInstance[]; @@ -21,7 +18,7 @@ interface Props { const InstanceBulkDelete: FC = ({ instances, onStart, onFinish }) => { const eventQueue = useEventQueue(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const [isLoading, setLoading] = useState(false); @@ -39,11 +36,11 @@ const InstanceBulkDelete: FC = ({ instances, onStart, onFinish }) => { const { fulfilledCount, rejectedCount } = getPromiseSettledCounts(results); if (fulfilledCount === deleteCount) { - notify.success( + toastNotify.success( `${deleteCount} ${pluralizeInstance(deleteCount)} deleted`, ); } else if (rejectedCount === deleteCount) { - notify.failure( + toastNotify.failure( "Instance bulk deletion failed", undefined, <> @@ -52,7 +49,7 @@ const InstanceBulkDelete: FC = ({ instances, onStart, onFinish }) => { , ); } else { - notify.failure( + toastNotify.failure( "Instance bulk deletion partially failed", undefined, <> diff --git a/src/pages/instances/actions/MigrateInstanceBtn.tsx b/src/pages/instances/actions/MigrateInstanceBtn.tsx index 1b4637cffc..f55d5d4e7d 100644 --- a/src/pages/instances/actions/MigrateInstanceBtn.tsx +++ b/src/pages/instances/actions/MigrateInstanceBtn.tsx @@ -1,5 +1,5 @@ import React, { FC } from "react"; -import { Button, useNotify } from "@canonical/react-components"; +import { Button } from "@canonical/react-components"; import MigrateInstanceForm from "pages/instances/MigrateInstanceForm"; import usePortal from "react-useportal"; import { migrateInstance } from "api/instances"; @@ -9,6 +9,7 @@ import { fetchClusterMembers } from "api/cluster"; import Loader from "components/Loader"; import { useEventQueue } from "context/eventQueue"; import ItemName from "components/ItemName"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { instance: string; @@ -24,7 +25,7 @@ const MigrateInstanceBtn: FC = ({ onFinish, }) => { const eventQueue = useEventQueue(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const { openPortal, closePortal, isOpen, Portal } = usePortal(); const queryClient = useQueryClient(); @@ -38,7 +39,7 @@ const MigrateInstanceBtn: FC = ({ } const handleSuccess = (newTarget: string) => { - notify.success( + toastNotify.success( <> Migration finished for instance{" "} @@ -51,7 +52,7 @@ const MigrateInstanceBtn: FC = ({ }; const notifyFailure = (e: unknown) => { - notify.failure(`Migration failed on instance ${instance}`, e); + toastNotify.failure(`Migration failed on instance ${instance}`, e); }; const handleFailure = (msg: string) => { @@ -69,7 +70,7 @@ const MigrateInstanceBtn: FC = ({ () => handleSuccess(target), handleFailure, ); - notify.info("Migration started"); + toastNotify.info(`Migration started for instance ${instance}`); closePortal(); }) .catch((e) => { diff --git a/src/pages/instances/actions/RestartInstanceBtn.tsx b/src/pages/instances/actions/RestartInstanceBtn.tsx index dd0adfd83c..2182bb62cd 100644 --- a/src/pages/instances/actions/RestartInstanceBtn.tsx +++ b/src/pages/instances/actions/RestartInstanceBtn.tsx @@ -7,12 +7,9 @@ import { useInstanceLoading } from "context/instanceLoading"; import InstanceLink from "pages/instances/InstanceLink"; import ConfirmationForce from "components/ConfirmationForce"; import ItemName from "components/ItemName"; -import { - ConfirmationButton, - Icon, - useNotify, -} from "@canonical/react-components"; +import { ConfirmationButton, Icon } from "@canonical/react-components"; import { useEventQueue } from "context/eventQueue"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { instance: LxdInstance; @@ -21,7 +18,7 @@ interface Props { const RestartInstanceBtn: FC = ({ instance }) => { const eventQueue = useEventQueue(); const instanceLoading = useInstanceLoading(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const [isForce, setForce] = useState(false); const queryClient = useQueryClient(); const isLoading = @@ -34,13 +31,13 @@ const RestartInstanceBtn: FC = ({ instance }) => { eventQueue.set( operation.metadata.id, () => - notify.success( + toastNotify.success( <> Instance restarted. , ), (msg) => - notify.failure( + toastNotify.failure( "Instance restart failed", new Error(msg), <> diff --git a/src/pages/instances/actions/StopInstanceBtn.tsx b/src/pages/instances/actions/StopInstanceBtn.tsx index 7e9973d77b..5c0ee31a02 100644 --- a/src/pages/instances/actions/StopInstanceBtn.tsx +++ b/src/pages/instances/actions/StopInstanceBtn.tsx @@ -7,12 +7,9 @@ import { useInstanceLoading } from "context/instanceLoading"; import InstanceLink from "pages/instances/InstanceLink"; import ConfirmationForce from "components/ConfirmationForce"; import ItemName from "components/ItemName"; -import { - ConfirmationButton, - Icon, - useNotify, -} from "@canonical/react-components"; +import { ConfirmationButton, Icon } from "@canonical/react-components"; import { useEventQueue } from "context/eventQueue"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { instance: LxdInstance; @@ -21,7 +18,7 @@ interface Props { const StopInstanceBtn: FC = ({ instance }) => { const eventQueue = useEventQueue(); const instanceLoading = useInstanceLoading(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const [isForce, setForce] = useState(false); const queryClient = useQueryClient(); const isLoading = @@ -34,13 +31,13 @@ const StopInstanceBtn: FC = ({ instance }) => { eventQueue.set( operation.metadata.id, () => - notify.success( + toastNotify.success( <> Instance stopped. , ), (msg) => - notify.failure( + toastNotify.failure( "Instance stop failed", new Error(msg), <> diff --git a/src/pages/instances/forms/CreateInstanceSnapshotForm.tsx b/src/pages/instances/forms/CreateInstanceSnapshotForm.tsx index db7a56aabf..71126f7878 100644 --- a/src/pages/instances/forms/CreateInstanceSnapshotForm.tsx +++ b/src/pages/instances/forms/CreateInstanceSnapshotForm.tsx @@ -21,6 +21,7 @@ import { createInstanceSnapshot } from "api/instance-snapshots"; import { queryKeys } from "util/queryKeys"; import ItemName from "components/ItemName"; import { TOOLTIP_OVER_MODAL_ZINDEX } from "util/zIndex"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { close: () => void; @@ -35,6 +36,7 @@ const CreateInstanceSnapshotForm: FC = ({ }) => { const eventQueue = useEventQueue(); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const controllerState = useState(null); @@ -70,14 +72,18 @@ const CreateInstanceSnapshotForm: FC = ({ }); onSuccess( <> - Snapshot created. + Snapshot created for instance{" "} + {instance.name}. , ); resetForm(); close(); }, (msg) => { - notify.failure("Snapshot creation failed", new Error(msg)); + toastNotify.failure( + `Snapshot creation failed for instance ${instance.name}`, + new Error(msg), + ); formik.setSubmitting(false); close(); }, diff --git a/src/pages/instances/forms/EditInstanceSnapshotForm.tsx b/src/pages/instances/forms/EditInstanceSnapshotForm.tsx index c02cdfc790..ef2daabfbd 100644 --- a/src/pages/instances/forms/EditInstanceSnapshotForm.tsx +++ b/src/pages/instances/forms/EditInstanceSnapshotForm.tsx @@ -1,7 +1,6 @@ import React, { FC, ReactNode, useState } from "react"; import { LxdInstance, LxdInstanceSnapshot } from "types/instance"; import SnapshotForm from "components/forms/SnapshotForm"; -import { useNotify } from "@canonical/react-components"; import { useQueryClient } from "@tanstack/react-query"; import { renameInstanceSnapshot, @@ -18,6 +17,7 @@ import { import { getInstanceSnapshotSchema } from "util/instanceSnapshots"; import { queryKeys } from "util/queryKeys"; import { SnapshotFormValues, getExpiresAt } from "util/snapshots"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { instance: LxdInstance; @@ -33,7 +33,7 @@ const EditInstanceSnapshotForm: FC = ({ onSuccess, }) => { const eventQueue = useEventQueue(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const controllerState = useState(null); @@ -43,7 +43,8 @@ const EditInstanceSnapshotForm: FC = ({ }); onSuccess( <> - Snapshot saved. + Snapshot saved for instance{" "} + {instance.name}. , ); close(); @@ -61,7 +62,10 @@ const EditInstanceSnapshotForm: FC = ({ operation.metadata.id, () => notifyUpdateSuccess(newName ?? snapshot.name), (msg) => { - notify.failure("Snapshot update failed", new Error(msg)); + toastNotify.failure( + `Snapshot update failed for instance ${instance.name}`, + new Error(msg), + ); formik.setSubmitting(false); }, ), @@ -80,7 +84,10 @@ const EditInstanceSnapshotForm: FC = ({ } }, (msg) => { - notify.failure("Snapshot rename failed", new Error(msg)); + toastNotify.failure( + `Snapshot rename failed for instance ${instance.name}`, + new Error(msg), + ); formik.setSubmitting(false); }, ), @@ -108,7 +115,6 @@ const EditInstanceSnapshotForm: FC = ({ snapshot.name, ), onSubmit: (values) => { - notify.clear(); const newName = values.name; const expiresAt = values.expirationDate && values.expirationTime diff --git a/src/pages/networks/CreateNetwork.tsx b/src/pages/networks/CreateNetwork.tsx index 8a6dc55620..f89c8ad336 100644 --- a/src/pages/networks/CreateNetwork.tsx +++ b/src/pages/networks/CreateNetwork.tsx @@ -1,5 +1,5 @@ import React, { FC, useState } from "react"; -import { Button, success, useNotify } from "@canonical/react-components"; +import { Button, useNotify } from "@canonical/react-components"; import { useFormik } from "formik"; import * as Yup from "yup"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -24,10 +24,12 @@ import { MAIN_CONFIGURATION } from "pages/networks/forms/NetworkFormMenu"; import { slugify } from "util/slugify"; import { YAML_CONFIGURATION } from "pages/profiles/forms/ProfileFormMenu"; import FormFooterLayout from "components/forms/FormFooterLayout"; +import { useToastNotification } from "context/toastNotificationProvider"; const CreateNetwork: FC = () => { const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const { project } = useParams<{ project: string }>(); const [section, setSection] = useState(slugify(MAIN_CONFIGURATION)); @@ -89,10 +91,8 @@ const CreateNetwork: FC = () => { void queryClient.invalidateQueries({ queryKey: [queryKeys.projects, project, queryKeys.networks], }); - navigate( - `/ui/project/${project}/networks`, - notify.queue(success(`Network ${values.name} created.`)), - ); + navigate(`/ui/project/${project}/networks`); + toastNotify.success(`Network ${values.name} created.`); }) .catch((e) => { formik.setSubmitting(false); diff --git a/src/pages/networks/CreateNetworkForward.tsx b/src/pages/networks/CreateNetworkForward.tsx index 5aa77c2d5f..8b2ec4850a 100644 --- a/src/pages/networks/CreateNetworkForward.tsx +++ b/src/pages/networks/CreateNetworkForward.tsx @@ -15,11 +15,13 @@ import BaseLayout from "components/BaseLayout"; import { useDocs } from "context/useDocs"; import HelpLink from "components/HelpLink"; import FormFooterLayout from "components/forms/FormFooterLayout"; +import { useToastNotification } from "context/toastNotificationProvider"; const CreateNetworkForward: FC = () => { const docBaseLink = useDocs(); const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const { network, project } = useParams<{ network: string; @@ -47,11 +49,9 @@ const CreateNetworkForward: FC = () => { }); navigate( `/ui/project/${project}/networks/detail/${network}/forwards`, - notify.queue( - notify.success( - `Network forward ${forward.listen_address} created.`, - ), - ), + ); + toastNotify.success( + `Network forward ${forward.listen_address} created.`, ); }) .catch((e) => { diff --git a/src/pages/networks/EditNetwork.tsx b/src/pages/networks/EditNetwork.tsx index f4700fe593..f335fbbd3c 100644 --- a/src/pages/networks/EditNetwork.tsx +++ b/src/pages/networks/EditNetwork.tsx @@ -20,6 +20,7 @@ import { useNavigate, useParams } from "react-router-dom"; import { MAIN_CONFIGURATION } from "pages/networks/forms/NetworkFormMenu"; import { YAML_CONFIGURATION } from "pages/profiles/forms/ProfileFormMenu"; import FormFooterLayout from "components/forms/FormFooterLayout"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { network: LxdNetwork; @@ -29,6 +30,8 @@ interface Props { const EditNetwork: FC = ({ network, project }) => { const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); + const { section } = useParams<{ section?: string }>(); const queryClient = useQueryClient(); const controllerState = useState(null); @@ -69,7 +72,7 @@ const EditNetwork: FC = ({ network, project }) => { network.name, ], }); - notify.success("Network updated."); + toastNotify.success(`Network ${network.name} updated.`); }) .catch((e) => { notify.failure("Network update failed", e); diff --git a/src/pages/networks/EditNetworkForward.tsx b/src/pages/networks/EditNetworkForward.tsx index 8422304cd4..26f3cebc9b 100644 --- a/src/pages/networks/EditNetworkForward.tsx +++ b/src/pages/networks/EditNetworkForward.tsx @@ -18,11 +18,13 @@ import BaseLayout from "components/BaseLayout"; import HelpLink from "components/HelpLink"; import { useDocs } from "context/useDocs"; import FormFooterLayout from "components/forms/FormFooterLayout"; +import { useToastNotification } from "context/toastNotificationProvider"; const EditNetworkForward: FC = () => { const docBaseLink = useDocs(); const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const { network, project, forwardAddress } = useParams<{ network: string; @@ -74,11 +76,9 @@ const EditNetworkForward: FC = () => { }); navigate( `/ui/project/${project}/networks/detail/${network}/forwards`, - notify.queue( - notify.success( - `Network forward ${forward.listen_address} updated.`, - ), - ), + ); + toastNotify.success( + `Network forward ${forward.listen_address} updated.`, ); }) .catch((e) => { diff --git a/src/pages/networks/NetworkDetailHeader.tsx b/src/pages/networks/NetworkDetailHeader.tsx index e34ebac12a..011589419a 100644 --- a/src/pages/networks/NetworkDetailHeader.tsx +++ b/src/pages/networks/NetworkDetailHeader.tsx @@ -7,7 +7,8 @@ import { checkDuplicateName } from "util/helpers"; import { LxdNetwork } from "types/network"; import { renameNetwork } from "api/networks"; import DeleteNetworkBtn from "pages/networks/actions/DeleteNetworkBtn"; -import { success, useNotify } from "@canonical/react-components"; +import { useNotify } from "@canonical/react-components"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { name: string; @@ -18,6 +19,7 @@ interface Props { const NetworkDetailHeader: FC = ({ name, network, project }) => { const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); const controllerState = useState(null); const RenameSchema = Yup.object().shape({ @@ -46,10 +48,8 @@ const NetworkDetailHeader: FC = ({ name, network, project }) => { } renameNetwork(name, values.name, project) .then(() => { - navigate( - `/ui/project/${project}/networks/detail/${values.name}`, - notify.queue(success("Network renamed.")), - ); + navigate(`/ui/project/${project}/networks/detail/${values.name}`); + toastNotify.success(`Network ${name} renamed to ${values.name}.`); void formik.setFieldValue("isRenaming", false); }) .catch((e) => { diff --git a/src/pages/networks/NetworkMap.tsx b/src/pages/networks/NetworkMap.tsx index 00130a68a3..881aec1312 100644 --- a/src/pages/networks/NetworkMap.tsx +++ b/src/pages/networks/NetworkMap.tsx @@ -17,7 +17,6 @@ import MapTooltip, { mountElement, } from "pages/networks/MapTooltip"; import MapLegend from "pages/networks/MapLegend"; -import NotificationRow from "components/NotificationRow"; // eslint-disable-next-line @typescript-eslint/no-unsafe-argument Cytoscape.use(popper); @@ -129,7 +128,6 @@ const NetworkMap: FC = () => { return ( - diff --git a/src/pages/networks/actions/DeleteNetworkBtn.tsx b/src/pages/networks/actions/DeleteNetworkBtn.tsx index ad863dcf40..fbf793447a 100644 --- a/src/pages/networks/actions/DeleteNetworkBtn.tsx +++ b/src/pages/networks/actions/DeleteNetworkBtn.tsx @@ -5,11 +5,8 @@ import { LxdNetwork } from "types/network"; import { deleteNetwork } from "api/networks"; import { queryKeys } from "util/queryKeys"; import { useQueryClient } from "@tanstack/react-query"; -import { - ConfirmationButton, - success, - useNotify, -} from "@canonical/react-components"; +import { ConfirmationButton, useNotify } from "@canonical/react-components"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { network: LxdNetwork; @@ -18,6 +15,7 @@ interface Props { const DeleteNetworkBtn: FC = ({ network, project }) => { const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const [isLoading, setLoading] = useState(false); const navigate = useNavigate(); @@ -32,10 +30,8 @@ const DeleteNetworkBtn: FC = ({ network, project }) => { query.queryKey[1] === project && query.queryKey[2] === queryKeys.networks, }); - navigate( - `/ui/project/${project}/networks`, - notify.queue(success(`Network ${network.name} deleted.`)), - ); + navigate(`/ui/project/${project}/networks`); + toastNotify.success(`Network ${network.name} deleted.`); }) .catch((e) => { setLoading(false); diff --git a/src/pages/networks/actions/DeleteNetworkForwardBtn.tsx b/src/pages/networks/actions/DeleteNetworkForwardBtn.tsx index 14b7d42664..c431353fdb 100644 --- a/src/pages/networks/actions/DeleteNetworkForwardBtn.tsx +++ b/src/pages/networks/actions/DeleteNetworkForwardBtn.tsx @@ -8,6 +8,7 @@ import { useNotify, } from "@canonical/react-components"; import { deleteNetworkForward } from "api/network-forwards"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { network: LxdNetwork; @@ -17,6 +18,7 @@ interface Props { const DeleteNetworkForwardBtn: FC = ({ network, forward, project }) => { const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const [isLoading, setLoading] = useState(false); @@ -24,7 +26,9 @@ const DeleteNetworkForwardBtn: FC = ({ network, forward, project }) => { setLoading(true); deleteNetworkForward(network, forward, project) .then(() => { - notify.success(`Network forward for ${forward.listen_address} deleted`); + toastNotify.success( + `Network forward for ${forward.listen_address} deleted`, + ); void queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === queryKeys.projects && diff --git a/src/pages/operations/OperationList.tsx b/src/pages/operations/OperationList.tsx index 14323e013e..3275e70a4b 100644 --- a/src/pages/operations/OperationList.tsx +++ b/src/pages/operations/OperationList.tsx @@ -7,38 +7,23 @@ import { useNotify, } from "@canonical/react-components"; import BaseLayout from "components/BaseLayout"; -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "util/queryKeys"; import Loader from "components/Loader"; -import { fetchAllOperations } from "api/operations"; import CancelOperationBtn from "pages/operations/actions/CancelOperationBtn"; import { isoTimeToString } from "util/helpers"; import { LxdOperationStatus } from "types/operation"; import OperationInstanceName from "pages/operations/OperationInstanceName"; import NotificationRow from "components/NotificationRow"; import { getProjectName } from "util/operations"; +import { useOperations } from "context/operationsProvider"; const OperationList: FC = () => { const notify = useNotify(); - - const { - data: operationList, - error, - isLoading, - } = useQuery({ - queryKey: [queryKeys.operations], - queryFn: fetchAllOperations, - }); + const { operations, isLoading, error } = useOperations(); if (error) { notify.failure("Loading operations failed", error); } - const failure = operationList?.failure ?? []; - const running = operationList?.running ?? []; - const success = operationList?.success ?? []; - const operations = failure.concat(running).concat(success); - const headers = [ { content: "Time", className: "time", sortKey: "created_at" }, { content: "Action", className: "action", sortKey: "action" }, diff --git a/src/pages/operations/actions/CancelOperationBtn.tsx b/src/pages/operations/actions/CancelOperationBtn.tsx index 06f3ce9437..904bc4341a 100644 --- a/src/pages/operations/actions/CancelOperationBtn.tsx +++ b/src/pages/operations/actions/CancelOperationBtn.tsx @@ -4,6 +4,7 @@ import { cancelOperation } from "api/operations"; import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { ConfirmationButton, useNotify } from "@canonical/react-components"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { operation: LxdOperation; @@ -12,6 +13,7 @@ interface Props { const CancelOperationBtn: FC = ({ operation, project }) => { const notify = useNotify(); + const toastNotify = useToastNotification(); const [isLoading, setLoading] = useState(false); const queryClient = useQueryClient(); @@ -19,7 +21,7 @@ const CancelOperationBtn: FC = ({ operation, project }) => { setLoading(true); cancelOperation(operation.id) .then(() => { - notify.success("Operation cancelled"); + toastNotify.success(`Operation ${operation.description} cancelled`); }) .catch((e) => { notify.failure("Operation cancellation failed", e); diff --git a/src/pages/profiles/CreateProfile.tsx b/src/pages/profiles/CreateProfile.tsx index 952d6d0c2b..a1fb620e00 100644 --- a/src/pages/profiles/CreateProfile.tsx +++ b/src/pages/profiles/CreateProfile.tsx @@ -5,7 +5,6 @@ import { Form, Notification, Row, - success, useNotify, } from "@canonical/react-components"; import { useFormik } from "formik"; @@ -58,6 +57,7 @@ import NotificationRow from "components/NotificationRow"; import BaseLayout from "components/BaseLayout"; import { hasDiskError, hasNetworkError } from "util/instanceValidation"; import FormFooterLayout from "components/forms/FormFooterLayout"; +import { useToastNotification } from "context/toastNotificationProvider"; export type CreateProfileFormValues = ProfileDetailsFormValues & FormDeviceValues & @@ -70,6 +70,7 @@ export type CreateProfileFormValues = ProfileDetailsFormValues & const CreateProfile: FC = () => { const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); const { project } = useParams<{ project: string }>(); const queryClient = useQueryClient(); const controllerState = useState(null); @@ -109,10 +110,8 @@ const CreateProfile: FC = () => { createProfile(JSON.stringify(profilePayload), project) .then(() => { - navigate( - `/ui/project/${project}/profiles`, - notify.queue(success(`Profile ${values.name} created.`)), - ); + navigate(`/ui/project/${project}/profiles`); + toastNotify.success(`Profile ${values.name} created.`); }) .catch((e: Error) => { formik.setSubmitting(false); diff --git a/src/pages/profiles/EditProfile.tsx b/src/pages/profiles/EditProfile.tsx index 877677ae6e..a7f7707481 100644 --- a/src/pages/profiles/EditProfile.tsx +++ b/src/pages/profiles/EditProfile.tsx @@ -59,6 +59,7 @@ import { getProfileEditValues } from "util/instanceEdit"; import { slugify } from "util/slugify"; import { hasDiskError, hasNetworkError } from "util/instanceValidation"; import FormFooterLayout from "components/forms/FormFooterLayout"; +import { useToastNotification } from "context/toastNotificationProvider"; export type EditProfileFormValues = ProfileDetailsFormValues & FormDeviceValues & @@ -75,6 +76,7 @@ interface Props { const EditProfile: FC = ({ profile, featuresProfiles }) => { const notify = useNotify(); + const toastNotify = useToastNotification(); const { project, section } = useParams<{ project: string; section?: string; @@ -110,7 +112,7 @@ const EditProfile: FC = ({ profile, featuresProfiles }) => { updateProfile(profilePayload, project) .then(() => { - notify.success("Profile updated."); + toastNotify.success(`Profile ${profile.name} updated.`); void formik.setValues(getProfileEditValues(profilePayload)); }) .catch((e: Error) => { diff --git a/src/pages/profiles/ProfileDetailHeader.tsx b/src/pages/profiles/ProfileDetailHeader.tsx index c6e6333485..0e80d1763f 100644 --- a/src/pages/profiles/ProfileDetailHeader.tsx +++ b/src/pages/profiles/ProfileDetailHeader.tsx @@ -7,7 +7,8 @@ import { renameProfile } from "api/profiles"; import { useFormik } from "formik"; import * as Yup from "yup"; import { checkDuplicateName } from "util/helpers"; -import { success, useNotify } from "@canonical/react-components"; +import { useNotify } from "@canonical/react-components"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { name: string; @@ -24,6 +25,7 @@ const ProfileDetailHeader: FC = ({ }) => { const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); const controllerState = useState(null); const RenameSchema = Yup.object().shape({ @@ -52,10 +54,8 @@ const ProfileDetailHeader: FC = ({ } renameProfile(name, values.name, project) .then(() => { - navigate( - `/ui/project/${project}/profiles/detail/${values.name}`, - notify.queue(success("Profile renamed.")), - ); + navigate(`/ui/project/${project}/profiles/detail/${values.name}`); + toastNotify.success(`Profile ${name} renamed to ${values.name}.`); void formik.setFieldValue("isRenaming", false); }) .catch((e) => { diff --git a/src/pages/profiles/ProfileList.tsx b/src/pages/profiles/ProfileList.tsx index 7baf90ef6b..fea7b66e93 100644 --- a/src/pages/profiles/ProfileList.tsx +++ b/src/pages/profiles/ProfileList.tsx @@ -220,6 +220,7 @@ const ProfileList: FC = () => { = ({ }) => { const isDeleteIcon = useDeleteIcon(); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const [isLoading, setLoading] = useState(false); const navigate = useNavigate(); @@ -38,10 +39,8 @@ const DeleteProfileBtn: FC = ({ void queryClient.invalidateQueries({ queryKey: [queryKeys.projects, project], }); - navigate( - `/ui/project/${project}/profiles`, - notify.queue(success(`Profile ${profile.name} deleted.`)), - ); + navigate(`/ui/project/${project}/profiles`); + toastNotify.success(`Profile ${profile.name} deleted.`); }) .catch((e) => { setLoading(false); diff --git a/src/pages/projects/CreateProject.tsx b/src/pages/projects/CreateProject.tsx index 4e33ac1c8e..13854a3cba 100644 --- a/src/pages/projects/CreateProject.tsx +++ b/src/pages/projects/CreateProject.tsx @@ -1,5 +1,5 @@ import React, { FC, useEffect, useState } from "react"; -import { Button, success, useNotify } from "@canonical/react-components"; +import { Button, useNotify } from "@canonical/react-components"; import { useFormik } from "formik"; import * as Yup from "yup"; import { useQueryClient } from "@tanstack/react-query"; @@ -40,6 +40,7 @@ import ProjectForm from "pages/projects/forms/ProjectForm"; import BaseLayout from "components/BaseLayout"; import FormFooterLayout from "components/forms/FormFooterLayout"; import { slugify } from "util/slugify"; +import { useToastNotification } from "context/toastNotificationProvider"; export type ProjectFormValues = ProjectDetailsFormValues & ProjectResourceLimitsFormValues & @@ -51,6 +52,7 @@ export type ProjectFormValues = ProjectDetailsFormValues & const CreateProject: FC = () => { const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const controllerState = useState(null); const [section, setSection] = useState(slugify(PROJECT_DETAILS)); @@ -98,10 +100,8 @@ const CreateProject: FC = () => { }), ) .then(() => { - navigate( - `/ui/project/${values.name}/instances`, - notify.queue(success(`Project ${values.name} created.`)), - ); + navigate(`/ui/project/${values.name}/instances`); + toastNotify.success(`Project ${values.name} created.`); }) .catch((e: Error) => { formik.setSubmitting(false); diff --git a/src/pages/projects/EditProject.tsx b/src/pages/projects/EditProject.tsx index 89680754de..92c399f871 100644 --- a/src/pages/projects/EditProject.tsx +++ b/src/pages/projects/EditProject.tsx @@ -31,6 +31,7 @@ import CustomLayout from "components/CustomLayout"; import FormFooterLayout from "components/forms/FormFooterLayout"; import { useNavigate, useParams } from "react-router-dom"; import { slugify } from "util/slugify"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { project: LxdProject; @@ -40,6 +41,7 @@ const EditProject: FC = ({ project }) => { const navigate = useNavigate(); const { isRestricted } = useAuth(); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const { section } = useParams<{ section?: string }>(); @@ -65,7 +67,7 @@ const EditProject: FC = ({ project }) => { updateProject(projectPayload) .then(() => { - notify.success(`Project updated.`); + toastNotify.success(`Project ${project.name} updated.`); void formik.setFieldValue("readOnly", true); }) .catch((e: Error) => { diff --git a/src/pages/projects/ProjectConfigurationHeader.tsx b/src/pages/projects/ProjectConfigurationHeader.tsx index 83938960d4..c58cb8f93a 100644 --- a/src/pages/projects/ProjectConfigurationHeader.tsx +++ b/src/pages/projects/ProjectConfigurationHeader.tsx @@ -7,10 +7,10 @@ import * as Yup from "yup"; import { useFormik } from "formik"; import { checkDuplicateName } from "util/helpers"; import DeleteProjectBtn from "./actions/DeleteProjectBtn"; -import { success, useNotify } from "@canonical/react-components"; import HelpLink from "components/HelpLink"; import { useEventQueue } from "context/eventQueue"; import { useDocs } from "context/useDocs"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { project: LxdProject; @@ -20,7 +20,7 @@ const ProjectConfigurationHeader: FC = ({ project }) => { const docBaseLink = useDocs(); const eventQueue = useEventQueue(); const navigate = useNavigate(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const controllerState = useState(null); const RenameSchema = Yup.object().shape({ @@ -51,13 +51,17 @@ const ProjectConfigurationHeader: FC = ({ project }) => { eventQueue.set( operation.metadata.id, () => { - navigate( - `/ui/project/${values.name}/configuration`, - notify.queue(success("Project renamed.")), + navigate(`/ui/project/${values.name}/configuration`); + toastNotify.success( + `Project ${project.name} renamed to ${values.name}.`, ); void formik.setFieldValue("isRenaming", false); }, - (msg) => notify.failure("Renaming failed", new Error(msg)), + (msg) => + toastNotify.failure( + `Renaming project ${project.name} failed`, + new Error(msg), + ), () => formik.setSubmitting(false), ), ); diff --git a/src/pages/projects/actions/DeleteProjectBtn.tsx b/src/pages/projects/actions/DeleteProjectBtn.tsx index f42ba7622a..5238215833 100644 --- a/src/pages/projects/actions/DeleteProjectBtn.tsx +++ b/src/pages/projects/actions/DeleteProjectBtn.tsx @@ -10,10 +10,10 @@ import { useDeleteIcon } from "context/useDeleteIcon"; import { ConfirmationButton, Icon, - success, useNotify, } from "@canonical/react-components"; import classnames from "classnames"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { project: LxdProject; @@ -22,6 +22,7 @@ interface Props { const DeleteProjectBtn: FC = ({ project }) => { const isDeleteIcon = useDeleteIcon(); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const [isLoading, setLoading] = useState(false); const navigate = useNavigate(); @@ -42,10 +43,8 @@ const DeleteProjectBtn: FC = ({ project }) => { setLoading(true); deleteProject(project) .then(() => { - navigate( - `/ui/project/default/instances`, - notify.queue(success(`Project ${project.name} deleted.`)), - ); + navigate(`/ui/project/default/instances`); + toastNotify.success(`Project ${project.name} deleted.`); }) .catch((e) => { setLoading(false); diff --git a/src/pages/settings/SettingForm.tsx b/src/pages/settings/SettingForm.tsx index 03dce7914c..8815195b98 100644 --- a/src/pages/settings/SettingForm.tsx +++ b/src/pages/settings/SettingForm.tsx @@ -8,6 +8,7 @@ import { useAuth } from "context/auth"; import SettingFormCheckbox from "./SettingFormCheckbox"; import SettingFormInput from "./SettingFormInput"; import SettingFormPassword from "./SettingFormPassword"; +import { useToastNotification } from "context/toastNotificationProvider"; export const getConfigId = (key: string) => { return key.replace(".", "___"); @@ -23,6 +24,7 @@ const SettingForm: FC = ({ configField, value, isLast }) => { const { isRestricted } = useAuth(); const [isEditMode, setEditMode] = useState(false); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const editRef = useRef(null); @@ -38,7 +40,7 @@ const SettingForm: FC = ({ configField, value, isLast }) => { }; updateSettings(config) .then(() => { - notify.success("Setting updated."); + toastNotify.success(`Setting ${configField.key} updated.`); setEditMode(false); }) .catch((e) => { diff --git a/src/pages/settings/Settings.tsx b/src/pages/settings/Settings.tsx index 95aa828d44..7351bada84 100644 --- a/src/pages/settings/Settings.tsx +++ b/src/pages/settings/Settings.tsx @@ -169,6 +169,7 @@ const Settings: FC = () => { { const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const { project } = useParams<{ project: string }>(); const [section, setSection] = useState(slugify(MAIN_CONFIGURATION)); @@ -63,12 +65,8 @@ const CreateStoragePool: FC = () => { void queryClient.invalidateQueries({ queryKey: [queryKeys.storage], }); - navigate( - `/ui/project/${project}/storage`, - notify.queue( - notify.success(`Storage pool ${storagePool.name} created.`), - ), - ); + navigate(`/ui/project/${project}/storage`); + toastNotify.success(`Storage pool ${storagePool.name} created.`); }) .catch((e) => { formik.setSubmitting(false); diff --git a/src/pages/storage/CustomIsoList.tsx b/src/pages/storage/CustomIsoList.tsx index f06c03df1f..95dad997ef 100644 --- a/src/pages/storage/CustomIsoList.tsx +++ b/src/pages/storage/CustomIsoList.tsx @@ -6,7 +6,6 @@ import { MainTable, SearchBox, TablePagination, - useNotify, } from "@canonical/react-components"; import { humanFileSize, isoTimeToString } from "util/helpers"; import { useQuery } from "@tanstack/react-query"; @@ -20,6 +19,7 @@ import ScrollableTable from "components/ScrollableTable"; import { Link } from "react-router-dom"; import { useDocs } from "context/useDocs"; import useSortTableData from "util/useSortTableData"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { project: string; @@ -27,7 +27,7 @@ interface Props { const CustomIsoList: FC = ({ project }) => { const docBaseLink = useDocs(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const [query, setQuery] = useState(""); const { data: images = [], isLoading } = useQuery({ @@ -69,7 +69,7 @@ const CustomIsoList: FC = ({ project }) => { volume={image.volume} project={project} onFinish={() => - notify.success(`Custom iso ${image.aliases} deleted.`) + toastNotify.success(`Custom iso ${image.aliases} deleted.`) } />, ]} @@ -175,7 +175,11 @@ const CustomIsoList: FC = ({ project }) => { - + = ({ }); const updateFormHeight = () => { - updateMaxHeight("volume-create-form", "p-modal__footer", 32); + updateMaxHeight("volume-create-form", "p-modal__footer", 32, undefined, []); }; useEffect(updateFormHeight, [notify.notification?.message]); useEventListener("resize", updateFormHeight); diff --git a/src/pages/storage/CustomVolumeSelectModal.tsx b/src/pages/storage/CustomVolumeSelectModal.tsx index f43c0513c4..faa859f84b 100644 --- a/src/pages/storage/CustomVolumeSelectModal.tsx +++ b/src/pages/storage/CustomVolumeSelectModal.tsx @@ -115,7 +115,7 @@ const CustomVolumeSelectModal: FC = ({ = ({ pool }) => { const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const { project, section } = useParams<{ project: string; @@ -67,7 +69,7 @@ const EditStoragePool: FC = ({ pool }) => { mutation() .then(async () => { - notify.success("Storage pool updated."); + toastNotify.success(`Storage pool ${savedPool.name} updated.`); const member = clusterMembers[0]?.server_name ?? undefined; const updatedPool = await fetchStoragePool( values.name, diff --git a/src/pages/storage/StoragePoolHeader.tsx b/src/pages/storage/StoragePoolHeader.tsx index f5cff1f891..d6157a9c45 100644 --- a/src/pages/storage/StoragePoolHeader.tsx +++ b/src/pages/storage/StoragePoolHeader.tsx @@ -7,7 +7,8 @@ import { LxdStoragePool } from "types/storage"; import { renameStoragePool } from "api/storage-pools"; import DeleteStoragePoolBtn from "pages/storage/actions/DeleteStoragePoolBtn"; import { testDuplicateStoragePoolName } from "util/storagePool"; -import { success, useNotify } from "@canonical/react-components"; +import { useNotify } from "@canonical/react-components"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { name: string; @@ -18,6 +19,7 @@ interface Props { const StoragePoolHeader: FC = ({ name, pool, project }) => { const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); const controllerState = useState(null); const RenameSchema = Yup.object().shape({ @@ -40,9 +42,9 @@ const StoragePoolHeader: FC = ({ name, pool, project }) => { } renameStoragePool(name, values.name, project) .then(() => { - navigate( - `/ui/project/${project}/storage/detail/${values.name}`, - notify.queue(success("Storage pool renamed.")), + navigate(`/ui/project/${project}/storage/detail/${values.name}`); + toastNotify.success( + `Storage pool ${name} renamed to ${values.name}.`, ); void formik.setFieldValue("isRenaming", false); }) diff --git a/src/pages/storage/StoragePools.tsx b/src/pages/storage/StoragePools.tsx index b535d6b4f8..d048140514 100644 --- a/src/pages/storage/StoragePools.tsx +++ b/src/pages/storage/StoragePools.tsx @@ -162,7 +162,11 @@ const StoragePools: FC = () => {
    - + = ({ volume, project }) => { const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); const controllerState = useState(null); const RenameSchema = Yup.object().shape({ @@ -49,7 +51,9 @@ const StorageVolumeHeader: FC = ({ volume, project }) => { .then(() => { navigate( `/ui/project/${project}/storage/detail/${volume.pool}/volumes/${volume.type}/${values.name}`, - notify.queue(success("Storage volume renamed.")), + ); + toastNotify.success( + `Storage volume ${volume.name} renamed to ${values.name}.`, ); void formik.setFieldValue("isRenaming", false); }) @@ -78,12 +82,8 @@ const StorageVolumeHeader: FC = ({ volume, project }) => { appearance="" hasIcon={false} onFinish={() => { - navigate( - `/ui/project/${project}/storage/volumes`, - notify.queue( - notify.success(`Storage volume ${volume.name} deleted.`), - ), - ); + navigate(`/ui/project/${project}/storage/volumes`); + toastNotify.success(`Storage volume ${volume.name} deleted.`); }} /> } diff --git a/src/pages/storage/StorageVolumeSnapshots.tsx b/src/pages/storage/StorageVolumeSnapshots.tsx index 1d192abfda..ae8f9f92e5 100644 --- a/src/pages/storage/StorageVolumeSnapshots.tsx +++ b/src/pages/storage/StorageVolumeSnapshots.tsx @@ -244,6 +244,7 @@ const StorageVolumeSnapshots: FC = ({ volume }) => { { /> - + void; @@ -21,6 +22,7 @@ interface Props { const UploadCustomIso: FC = ({ onCancel, onFinish }) => { const eventQueue = useEventQueue(); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const { project } = useProject(); const [file, setFile] = useState(null); @@ -82,7 +84,7 @@ const UploadCustomIso: FC = ({ onCancel, onFinish }) => { eventQueue.set( operation.metadata.id, () => onFinish(name, pool), - (msg) => notify.failure("Image import failed", new Error(msg)), + (msg) => toastNotify.failure("Image import failed", new Error(msg)), () => { setLoading(false); setUploadState(null); diff --git a/src/pages/storage/actions/CustomStorageVolumeActions.tsx b/src/pages/storage/actions/CustomStorageVolumeActions.tsx index 0976279d7a..c3a907a6bc 100644 --- a/src/pages/storage/actions/CustomStorageVolumeActions.tsx +++ b/src/pages/storage/actions/CustomStorageVolumeActions.tsx @@ -1,9 +1,10 @@ import React, { FC } from "react"; import classnames from "classnames"; -import { List, useNotify } from "@canonical/react-components"; +import { List } from "@canonical/react-components"; import { LxdStorageVolume } from "types/storage"; import DeleteStorageVolumeBtn from "./DeleteStorageVolumeBtn"; import VolumeAddSnapshotBtn from "./snapshots/VolumeAddSnapshotBtn"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { volume: LxdStorageVolume; @@ -18,7 +19,7 @@ const CustomStorageVolumeActions: FC = ({ project, snapshotDisabled, }) => { - const notify = useNotify(); + const toastNotify = useToastNotification(); return ( = ({ volume={volume} project={project} onFinish={() => { - notify.success(`Storage volume ${volume.name} deleted.`); + toastNotify.success(`Storage volume ${volume.name} deleted.`); }} />, ]} diff --git a/src/pages/storage/actions/DeleteStoragePoolBtn.tsx b/src/pages/storage/actions/DeleteStoragePoolBtn.tsx index de07f2604b..630696141d 100644 --- a/src/pages/storage/actions/DeleteStoragePoolBtn.tsx +++ b/src/pages/storage/actions/DeleteStoragePoolBtn.tsx @@ -2,7 +2,6 @@ import React, { FC, useState } from "react"; import { ConfirmationButton, Icon, - success, useNotify, } from "@canonical/react-components"; import { useQueryClient } from "@tanstack/react-query"; @@ -13,6 +12,7 @@ import { useDeleteIcon } from "context/useDeleteIcon"; import { useNavigate } from "react-router-dom"; import { LxdStoragePool } from "types/storage"; import { queryKeys } from "util/queryKeys"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { pool: LxdStoragePool; @@ -28,6 +28,7 @@ const DeleteStoragePoolBtn: FC = ({ const isSmallScreen = useDeleteIcon(); const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); const [isLoading, setLoading] = useState(false); const queryClient = useQueryClient(); @@ -38,10 +39,8 @@ const DeleteStoragePoolBtn: FC = ({ void queryClient.invalidateQueries({ queryKey: [queryKeys.storage], }); - navigate( - `/ui/project/${project}/storage`, - notify.queue(success(`Storage pool ${pool.name} deleted.`)), - ); + navigate(`/ui/project/${project}/storage`); + toastNotify.success(`Storage pool ${pool.name} deleted.`); }) .catch((e) => { setLoading(false); diff --git a/src/pages/storage/actions/snapshots/VolumeConfigureSnapshotModal.tsx b/src/pages/storage/actions/snapshots/VolumeConfigureSnapshotModal.tsx index f1c2626664..7e5bd1298d 100644 --- a/src/pages/storage/actions/snapshots/VolumeConfigureSnapshotModal.tsx +++ b/src/pages/storage/actions/snapshots/VolumeConfigureSnapshotModal.tsx @@ -12,6 +12,7 @@ import { import { getStorageVolumeEditValues } from "util/storageVolumeEdit"; import { updateStorageVolume } from "api/storage-pools"; import StorageVolumeFormSnapshots from "pages/storage/forms/StorageVolumeFormSnapshots"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { volume: LxdStorageVolume; @@ -20,6 +21,7 @@ interface Props { const VolumeConfigureSnapshotModal: FC = ({ volume, close }) => { const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const formik = useFormik({ @@ -31,7 +33,9 @@ const VolumeConfigureSnapshotModal: FC = ({ volume, close }) => { etag: volume.etag, }) .then(() => { - notify.success("Configuration updated."); + toastNotify.success( + `Snapshot configuration updated for volume ${volume.name}.`, + ); void queryClient.invalidateQueries({ queryKey: [queryKeys.storage], predicate: (query) => diff --git a/src/pages/storage/actions/snapshots/VolumeSnapshotActions.tsx b/src/pages/storage/actions/snapshots/VolumeSnapshotActions.tsx index 016a912e8e..bb89f4ef0b 100644 --- a/src/pages/storage/actions/snapshots/VolumeSnapshotActions.tsx +++ b/src/pages/storage/actions/snapshots/VolumeSnapshotActions.tsx @@ -16,6 +16,7 @@ import classnames from "classnames"; import ItemName from "components/ItemName"; import { useEventQueue } from "context/eventQueue"; import VolumeEditSnapshotBtn from "./VolumeEditSnapshotBtn"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { volume: LxdStorageVolume; @@ -25,6 +26,7 @@ interface Props { const VolumeSnapshotActions: FC = ({ volume, snapshot }) => { const eventQueue = useEventQueue(); const notify = useNotify(); + const toastNotify = useToastNotification(); const [isDeleting, setDeleting] = useState(false); const [isRestoring, setRestoring] = useState(false); const queryClient = useQueryClient(); @@ -34,8 +36,12 @@ const VolumeSnapshotActions: FC = ({ volume, snapshot }) => { void deleteVolumeSnapshot(volume, snapshot).then((operation) => eventQueue.set( operation.metadata.id, - () => notify.success(`Snapshot ${snapshot.name} deleted`), - (msg) => notify.failure("Snapshot deletion failed", new Error(msg)), + () => toastNotify.success(`Snapshot ${snapshot.name} deleted`), + (msg) => + toastNotify.failure( + `Snapshot ${snapshot.name} deletion failed`, + new Error(msg), + ), () => { setDeleting(false); void queryClient.invalidateQueries({ @@ -52,7 +58,7 @@ const VolumeSnapshotActions: FC = ({ volume, snapshot }) => { setRestoring(true); void restoreVolumeSnapshot(volume, snapshot) .then(() => { - notify.success(`Snapshot ${snapshot.name} restored`); + toastNotify.success(`Snapshot ${snapshot.name} restored`); }) .catch((error: Error) => { notify.failure("Snapshot restore failed", error); diff --git a/src/pages/storage/actions/snapshots/VolumeSnapshotBulkDelete.tsx b/src/pages/storage/actions/snapshots/VolumeSnapshotBulkDelete.tsx index 5cbc1b3ea2..81767f012c 100644 --- a/src/pages/storage/actions/snapshots/VolumeSnapshotBulkDelete.tsx +++ b/src/pages/storage/actions/snapshots/VolumeSnapshotBulkDelete.tsx @@ -3,15 +3,12 @@ import { deleteVolumeSnapshotBulk } from "api/volume-snapshots"; import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { pluralizeSnapshot } from "util/instanceBulkActions"; -import { - ConfirmationButton, - Icon, - useNotify, -} from "@canonical/react-components"; +import { ConfirmationButton, Icon } from "@canonical/react-components"; import classnames from "classnames"; import { useEventQueue } from "context/eventQueue"; import { getPromiseSettledCounts } from "util/helpers"; import { LxdStorageVolume } from "types/storage"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { volume: LxdStorageVolume; @@ -27,7 +24,7 @@ const VolumeSnapshotBulkDelete: FC = ({ onFinish, }) => { const eventQueue = useEventQueue(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const [isLoading, setLoading] = useState(false); const queryClient = useQueryClient(); @@ -41,13 +38,13 @@ const VolumeSnapshotBulkDelete: FC = ({ const { fulfilledCount, rejectedCount } = getPromiseSettledCounts(results); if (fulfilledCount === count) { - notify.success( + toastNotify.success( `${snapshotNames.length} ${pluralizeSnapshot( snapshotNames.length, )} deleted`, ); } else if (rejectedCount === count) { - notify.failure( + toastNotify.failure( "Snapshot bulk deletion failed", undefined, <> @@ -55,7 +52,7 @@ const VolumeSnapshotBulkDelete: FC = ({ , ); } else { - notify.failure( + toastNotify.failure( "Snapshot bulk deletion partially failed", undefined, <> diff --git a/src/pages/storage/forms/CreateVolumeSnapshotForm.tsx b/src/pages/storage/forms/CreateVolumeSnapshotForm.tsx index b7c97423b1..2378e0a9dd 100644 --- a/src/pages/storage/forms/CreateVolumeSnapshotForm.tsx +++ b/src/pages/storage/forms/CreateVolumeSnapshotForm.tsx @@ -11,6 +11,7 @@ import { UNDEFINED_DATE, stringToIsoTime } from "util/helpers"; import { queryKeys } from "util/queryKeys"; import { SnapshotFormValues, getExpiresAt } from "util/snapshots"; import { getVolumeSnapshotSchema } from "util/storageVolumeSnapshots"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { close: () => void; @@ -20,6 +21,7 @@ interface Props { const CreateVolumeSnapshotForm: FC = ({ close, volume }) => { const eventQueue = useEventQueue(); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const controllerState = useState(null); @@ -53,12 +55,15 @@ const CreateVolumeSnapshotForm: FC = ({ close, volume }) => { query.queryKey[0] === queryKeys.volumes || query.queryKey[0] === queryKeys.storage, }); - notify.success(`Snapshot ${values.name} created.`); + toastNotify.success(`Snapshot ${values.name} created.`); close(); resetForm(); }, (msg) => { - notify.failure("Snapshot creation failed", new Error(msg)); + toastNotify.failure( + `Snapshot ${values.name} creation failed`, + new Error(msg), + ); formik.setSubmitting(false); }, ); diff --git a/src/pages/storage/forms/EditVolumeSnapshotForm.tsx b/src/pages/storage/forms/EditVolumeSnapshotForm.tsx index 043691e4e4..016a16d099 100644 --- a/src/pages/storage/forms/EditVolumeSnapshotForm.tsx +++ b/src/pages/storage/forms/EditVolumeSnapshotForm.tsx @@ -14,6 +14,7 @@ import { getBrowserFormatDate, stringToIsoTime } from "util/helpers"; import { queryKeys } from "util/queryKeys"; import { SnapshotFormValues, getExpiresAt } from "util/snapshots"; import { getVolumeSnapshotSchema } from "util/storageVolumeSnapshots"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { volume: LxdStorageVolume; @@ -24,6 +25,7 @@ interface Props { const EditVolumeSnapshotForm: FC = ({ volume, snapshot, close }) => { const eventQueue = useEventQueue(); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const controllerState = useState(null); @@ -33,7 +35,7 @@ const EditVolumeSnapshotForm: FC = ({ volume, snapshot, close }) => { query.queryKey[0] === queryKeys.volumes || query.queryKey[0] === queryKeys.storage, }); - notify.success(`Snapshot ${name} saved.`); + toastNotify.success(`Snapshot ${name} saved.`); formik.setSubmitting(false); close(); }; @@ -70,7 +72,10 @@ const EditVolumeSnapshotForm: FC = ({ volume, snapshot, close }) => { } }, (msg) => { - notify.failure("Snapshot rename failed", new Error(msg)); + toastNotify.failure( + `Snapshot ${snapshot.name} rename failed`, + new Error(msg), + ); formik.setSubmitting(false); }, ), diff --git a/src/pages/storage/forms/StorageVolumeCreate.tsx b/src/pages/storage/forms/StorageVolumeCreate.tsx index cfe46c2581..564d63c8c5 100644 --- a/src/pages/storage/forms/StorageVolumeCreate.tsx +++ b/src/pages/storage/forms/StorageVolumeCreate.tsx @@ -19,10 +19,12 @@ import { MAIN_CONFIGURATION } from "pages/storage/forms/StorageVolumeFormMenu"; import { slugify } from "util/slugify"; import { POOL } from "../StorageVolumesFilter"; import FormFooterLayout from "components/forms/FormFooterLayout"; +import { useToastNotification } from "context/toastNotificationProvider"; const StorageVolumeCreate: FC = () => { const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const [section, setSection] = useState(slugify(MAIN_CONFIGURATION)); const controllerState = useState(null); @@ -67,12 +69,8 @@ const StorageVolumeCreate: FC = () => { void queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === queryKeys.volumes, }); - navigate( - `/ui/project/${project}/storage/volumes`, - notify.queue( - notify.success(`Storage volume ${values.name} created.`), - ), - ); + navigate(`/ui/project/${project}/storage/volumes`); + toastNotify.success(`Storage volume ${values.name} created.`); }) .catch((e) => { formik.setSubmitting(false); diff --git a/src/pages/storage/forms/StorageVolumeEdit.tsx b/src/pages/storage/forms/StorageVolumeEdit.tsx index 01ad3eb51d..3d7812e56c 100644 --- a/src/pages/storage/forms/StorageVolumeEdit.tsx +++ b/src/pages/storage/forms/StorageVolumeEdit.tsx @@ -16,6 +16,7 @@ import { getStorageVolumeEditValues } from "util/storageVolumeEdit"; import { MAIN_CONFIGURATION } from "pages/storage/forms/StorageVolumeFormMenu"; import { slugify } from "util/slugify"; import FormFooterLayout from "components/forms/FormFooterLayout"; +import { useToastNotification } from "context/toastNotificationProvider"; interface Props { volume: LxdStorageVolume; @@ -24,6 +25,7 @@ interface Props { const StorageVolumeEdit: FC = ({ volume }) => { const navigate = useNavigate(); const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const { section } = useParams<{ section: string }>(); const { project } = useParams<{ project: string }>(); @@ -59,7 +61,7 @@ const StorageVolumeEdit: FC = ({ volume }) => { saveVolume.name, ], }); - notify.success(`Storage volume updated.`); + toastNotify.success(`Storage volume ${saveVolume.name} updated.`); }) .catch((e) => { notify.failure("Storage volume update failed", e); diff --git a/src/sass/_configuration_table.scss b/src/sass/_configuration_table.scss index f9a939942e..87661f2b11 100644 --- a/src/sass/_configuration_table.scss +++ b/src/sass/_configuration_table.scss @@ -1,6 +1,7 @@ .configuration-table { margin-bottom: 0; margin-top: -3px; + max-width: 100%; width: 54rem; td:last-child { diff --git a/src/sass/_pattern_navigation.scss b/src/sass/_pattern_navigation.scss index a224ef4fc5..92236ef1a6 100644 --- a/src/sass/_pattern_navigation.scss +++ b/src/sass/_pattern_navigation.scss @@ -72,26 +72,11 @@ z-index: 1001; } -.l-navigation .server-version { - color: $color-mid-light; -} - -.l-navigation .server-version:hover { +.l-navigation { background: $colors--dark-theme--background-default !important; color: $color-mid-light !important; } -.version-warning .p-tooltip__message { - bottom: 1rem; - left: -3rem; -} - -@include desktop { - .version-warning .p-tooltip__message { - left: -3.5rem; - } -} - .sidenav-top-ul::after { display: none; } diff --git a/src/sass/_status_bar.scss b/src/sass/_status_bar.scss new file mode 100644 index 0000000000..f713e7650e --- /dev/null +++ b/src/sass/_status_bar.scss @@ -0,0 +1,56 @@ +.status-bar { + align-items: center; + background-color: white; + display: flex; + height: 2.5rem; + justify-content: space-between; + padding: 0.5rem 1rem; + position: relative; + z-index: 103; + + .server-version { + color: $color-mid-dark; + margin-bottom: 0; + padding-top: 0; + } + + .version-warning .p-tooltip__message { + bottom: 1rem; + left: -3rem; + } + + .version-warning-icon { + margin-right: 0.5rem; + } + + .status-right-container { + display: flex; + gap: 2rem; + + .operation-status { + align-items: center; + display: flex; + } + + .expand-button { + align-items: center; + display: flex; + gap: 0.25rem; + padding: 0 0.5rem !important; + + .total-count { + margin-left: 0.2rem; + } + } + } + + .button-active { + background-color: #ebebeb; + } + + @include desktop { + .version-warning .p-tooltip__message { + left: -3.5rem; + } + } +} diff --git a/src/sass/_toast.scss b/src/sass/_toast.scss new file mode 100644 index 0000000000..bf8e16073a --- /dev/null +++ b/src/sass/_toast.scss @@ -0,0 +1,103 @@ +.toast-animate { + position: relative; + z-index: 101; +} + +.toast-notification, +.toast-notification-list { + bottom: 3.5rem; + margin: 0 1.5rem; + position: absolute; + right: 0; + + @include medium { + width: 500px; + } + + @include large { + width: 600px; + } +} + +.toast-notification { + box-shadow: 0 0 calc($sp-unit * 4) $sp-unit rgb(0 0 0 / 15%); + overflow: hidden; + z-index: 101; + + @include mobile { + left: 0; + margin: 1.5rem auto; + max-width: 400px; + width: 95vw; + } +} + +.toast-notification-list { + background-color: white; + box-shadow: 0 0 calc($sp-unit * 4) $sp-unit rgb(0 0 0 / 22%); + max-height: calc(100vh - 4.5rem); + overflow: auto; + padding: 0 0.5rem; + z-index: 102; + + .individual-notification { + margin: 0.5rem 0; + } + + .dismiss { + align-items: center; + background-color: white; + bottom: 0; + display: flex; + justify-content: space-between; + padding: 0.5rem 0; + padding-left: 0.8rem; + position: sticky; + z-index: 10; + + .dismiss-button { + align-items: center; + display: flex; + gap: 0.5rem; + + .dismiss-text { + align-items: center; + display: flex; + gap: $sph--x-small; + } + } + + .filters { + display: flex; + + .filter-button { + align-items: center; + display: flex; + gap: $sph--x-small; + justify-content: center; + margin-right: $sph--small !important; + padding: calc(0.4rem - 1px); + + span { + padding: 0; + text-align: center; + } + } + } + + @include mobile { + align-items: flex-start; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.5rem; + } + } + + @include mobile { + bottom: 2rem; + left: 0; + margin: 1.5rem auto; + max-width: 400px; + width: 95vw; + } +} diff --git a/src/sass/styles.scss b/src/sass/styles.scss index 63d9ec4c5d..43f63ae6e3 100644 --- a/src/sass/styles.scss +++ b/src/sass/styles.scss @@ -5,6 +5,7 @@ @import "./breakpoints"; @import "pattern_icon"; @include lxdui-p-icon; +@include vf-p-icon-add-canvas; @include vf-p-icon-applications; @include vf-p-icon-begin-downloading; @include vf-p-icon-canvas; @@ -16,6 +17,7 @@ @include vf-p-icon-disconnect; @include vf-p-icon-edit; @include vf-p-icon-export; +@include vf-p-icon-filter; @include vf-p-icon-fullscreen; @include vf-p-icon-get-link; @include vf-p-icon-import; @@ -31,19 +33,18 @@ @include vf-p-icon-restart; @include vf-p-icon-security; @include vf-p-icon-settings; -@include vf-p-icon-status; @include vf-p-icon-status-failed-small; @include vf-p-icon-status-in-progress-small; @include vf-p-icon-status-queued-small; @include vf-p-icon-status-succeeded-small; @include vf-p-icon-status-waiting-small; +@include vf-p-icon-status; @include vf-p-icon-stop; @include vf-p-icon-switcher-environments; @include vf-p-icon-task-outstanding; @include vf-p-icon-units; @include vf-p-icon-video-play; @include vf-p-icon-warning-grey; -@include vf-p-icon-add-canvas; $border-thin: 1px solid $color-mid-light !default; @@ -88,12 +89,19 @@ $border-thin: 1px solid $color-mid-light !default; @import "selectable_main_table"; @import "settings_page"; @import "snapshots"; +@import "status_bar"; @import "storage_detail_overview"; @import "storage_pool_form"; @import "storage_volume_form"; @import "storage"; +@import "toast"; @import "upper_controls_bar"; +body { + // needed for notification list expanding animation + overflow: hidden; +} + .p-heading--4 { padding: 0; } diff --git a/src/util/helpers.tsx b/src/util/helpers.tsx index 2f8dc3c51d..7b07dd682c 100644 --- a/src/util/helpers.tsx +++ b/src/util/helpers.tsx @@ -259,3 +259,29 @@ export const getAbsoluteHeightBelow = (belowId: string) => { parseFloat(style.paddingTop) + parseFloat(style.paddingBottom); return element.offsetHeight + margin + padding + 1; }; + +export const getElementAbsoluteHeight = (element: HTMLElement | null) => { + if (!element) { + return 0; + } + + const style = window.getComputedStyle(element); + const marginHeight = + parseFloat(style.marginTop) + parseFloat(style.marginBottom); + return element.offsetHeight + marginHeight; +}; + +export const getNegativeMargin = (element: HTMLElement | null) => { + if (!element) { + return 0; + } + + const style = window.getComputedStyle(element); + const marginHeight = + parseFloat(style.marginTop) + parseFloat(style.marginBottom); + return marginHeight < 0 ? marginHeight : 0; +}; + +export const getHeightOffsetStyle = (offset: number) => { + return `height: calc(100vh - ${offset}px); min-height: calc(100vh - ${offset}px)`; +}; diff --git a/src/util/instanceStart.tsx b/src/util/instanceStart.tsx index 06e546c4ae..4d80fa977a 100644 --- a/src/util/instanceStart.tsx +++ b/src/util/instanceStart.tsx @@ -5,14 +5,14 @@ import { useInstanceLoading } from "context/instanceLoading"; import InstanceLink from "pages/instances/InstanceLink"; import { LxdInstance } from "types/instance"; import { queryKeys } from "./queryKeys"; -import { useNotify } from "@canonical/react-components"; import { useEventQueue } from "context/eventQueue"; import ItemName from "components/ItemName"; +import { useToastNotification } from "context/toastNotificationProvider"; export const useInstanceStart = (instance: LxdInstance) => { const eventQueue = useEventQueue(); const instanceLoading = useInstanceLoading(); - const notify = useNotify(); + const toastNotify = useToastNotification(); const queryClient = useQueryClient(); const isLoading = @@ -30,13 +30,13 @@ export const useInstanceStart = (instance: LxdInstance) => { eventQueue.set( operation.metadata.id, () => - notify.success( + toastNotify.success( <> Instance started. , ), (msg) => - notify.failure( + toastNotify.failure( "Instance start failed", new Error(msg), <> diff --git a/src/util/instances.tsx b/src/util/instances.tsx new file mode 100644 index 0000000000..5ce2ffc6d8 --- /dev/null +++ b/src/util/instances.tsx @@ -0,0 +1,26 @@ +import { LxdOperationResponse } from "types/operation"; +import { getInstanceName } from "./operations"; +import InstanceLink from "pages/instances/InstanceLink"; +import React from "react"; + +export const instanceLinkFromName = (args: { + instanceName: string; + project?: string; +}) => { + const { project, instanceName } = args; + return ( + + ); +}; + +export const instanceLinkFromOperation = (args: { + operation?: LxdOperationResponse; + project?: string; +}) => { + const { operation, project } = args; + const linkText = getInstanceName(operation?.metadata); + if (!linkText) { + return; + } + return ; +}; diff --git a/src/util/notifications.tsx b/src/util/notifications.tsx new file mode 100644 index 0000000000..3a101aa4fd --- /dev/null +++ b/src/util/notifications.tsx @@ -0,0 +1,15 @@ +import { ICONS } from "@canonical/react-components"; + +export const severityOrder = [ + "positive", + "caution", + "negative", + "information", +] as const; + +export const iconLookup = { + positive: ICONS.success, + information: ICONS.information, + caution: ICONS.warning, + negative: ICONS.error, +} as const; diff --git a/src/util/operations.tsx b/src/util/operations.tsx index 97e74d9821..60a820c8ab 100644 --- a/src/util/operations.tsx +++ b/src/util/operations.tsx @@ -1,12 +1,12 @@ import { LxdOperation } from "types/operation"; -export const getInstanceName = (operation: LxdOperation): string => { +export const getInstanceName = (operation?: LxdOperation): string => { // the url can be one of below formats // /1.0/instances/ // /1.0/instances/?project= // /1.0/instances//snapshots/ return ( - operation.resources?.instances + operation?.resources?.instances ?.filter((item) => item.startsWith("/1.0/instances/")) .map((item) => item.split("/")[3]) .pop() diff --git a/src/util/updateMaxHeight.tsx b/src/util/updateMaxHeight.tsx index fe561519f5..00f0879e51 100644 --- a/src/util/updateMaxHeight.tsx +++ b/src/util/updateMaxHeight.tsx @@ -1,3 +1,5 @@ +import { getAbsoluteHeightBelow } from "./helpers"; + type HeightProperty = "height" | "max-height" | "min-height"; export const updateMaxHeight = ( @@ -5,6 +7,7 @@ export const updateMaxHeight = ( bottomClass?: string, additionalOffset = 0, targetProperty: HeightProperty = "height", + belowIds: string[] = ["status-bar"], ) => { const elements = document.getElementsByClassName(targetClass); const belowElements = bottomClass @@ -14,9 +17,14 @@ export const updateMaxHeight = ( return; } const above = elements[0].getBoundingClientRect().top + 1; - const below = belowElements + let below = belowElements ? belowElements[0].getBoundingClientRect().height + 1 : 0; + + below += belowIds.reduce( + (acc, belowId) => acc + getAbsoluteHeightBelow(belowId), + 0, + ); const offset = Math.ceil(above + below + additionalOffset); const style = `${targetProperty}: calc(100vh - ${offset}px)`; elements[0].setAttribute("style", style); diff --git a/src/util/usePreferReducedMotion.tsx b/src/util/usePreferReducedMotion.tsx new file mode 100644 index 0000000000..c2d0cd7eca --- /dev/null +++ b/src/util/usePreferReducedMotion.tsx @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; + +const QUERY = "(prefers-reduced-motion: reduce)"; +const getInitialState = () => window.matchMedia(QUERY).matches; + +export const usePrefersReducedMotion = () => { + const [prefersReducedMotion, setPrefersReducedMotion] = + useState(getInitialState); + + useEffect(() => { + const mediaQuery = window.matchMedia(QUERY); + const listener = (event: MediaQueryListEvent) => { + setPrefersReducedMotion(event.matches); + }; + mediaQuery.addEventListener("change", listener); + return () => { + mediaQuery.removeEventListener("change", listener); + }; + }, []); + + return prefersReducedMotion; +}; diff --git a/tests/devices.spec.ts b/tests/devices.spec.ts index 43bce003df..fde448fcf1 100644 --- a/tests/devices.spec.ts +++ b/tests/devices.spec.ts @@ -38,7 +38,7 @@ test("instance attach custom volumes and detach inherited volumes", async ({ await page.getByText("Disk devices").click(); await detachVolume(page, "volume-1"); await attachVolume(page, instanceVolume, "/baz"); - await saveInstance(page); + await saveInstance(page, instance); await page.getByRole("gridcell", { name: "Detached" }).click(); await page.getByText("/baz").click(); @@ -70,7 +70,7 @@ test("profile edit custom volumes", async ({ page }) => { await page.getByRole("button", { name: "Edit profile" }).click(); await detachVolume(page, "volume-1"); - await saveProfile(page); + await saveProfile(page, profile); await page.getByRole("row", { name: "Size 3GiB" }).click(); @@ -103,7 +103,7 @@ test("profile edit networks", async ({ page }) => { await page.getByRole("button", { name: "Attach network" }).click(); await page.locator("[id='devices.1.network']").selectOption({ index: 1 }); await page.locator("[id='devices.1.name']").fill("eth1"); - await saveProfile(page); + await saveProfile(page, profile); await page.getByRole("gridcell", { name: "eth0" }).click(); await page.getByRole("gridcell", { name: "eth1" }).click(); diff --git a/tests/helpers/instances.ts b/tests/helpers/instances.ts index 694bc5554c..ce5ec8d8dd 100644 --- a/tests/helpers/instances.ts +++ b/tests/helpers/instances.ts @@ -51,20 +51,19 @@ export const editInstance = async (page: Page, instance: string) => { await page.getByRole("button", { name: "Edit instance" }).click(); }; -export const saveInstance = async (page: Page) => { +export const saveInstance = async (page: Page, instance: string) => { await page.getByRole("button", { name: "Save changes" }).click(); - await page.waitForSelector(`text=Instance updated.`, TIMEOUT); + await page.waitForSelector(`text=Instance ${instance} updated.`, TIMEOUT); + await page.getByRole("button", { name: "Close notification" }).click(); }; export const deleteInstance = async (page: Page, instance: string) => { await visitInstance(page, instance); - const stopInstanceButton = page.getByRole("button", { - name: "Stop", - exact: true, - }); - if (await stopInstanceButton.isEnabled()) { - await stopInstanceButton.click(); - await page.getByText("Stop", { exact: true }).click(); + const stopButton = page.getByRole("button", { name: "Stop", exact: true }); + if (await stopButton.isEnabled()) { + await page.keyboard.down("Shift"); + await stopButton.click(); + await page.keyboard.up("Shift"); await page.waitForSelector(`text=Instance ${instance} stopped.`, TIMEOUT); } await page.getByRole("button", { name: "Delete" }).click(); @@ -95,7 +94,45 @@ export const renameInstance = async ( await page.getByRole("textbox").press("Control+a"); await page.getByRole("textbox").fill(newName); await page.getByRole("button", { name: "Save" }).click(); - await page.getByText("Instance renamed.").click(); + await page.waitForSelector( + `text=Instance ${oldName} renamed to ${newName}.`, + TIMEOUT, + ); +}; + +export const createAndStartInstance = async ( + page: Page, + instance: string, + type = "container", +) => { + await page.goto("/ui/"); + await page + .getByRole("link", { name: "Instances", exact: true }) + .first() + .click(); + await page.getByRole("button", { name: "Create instance" }).click(); + await page.getByLabel("Instance name").click(); + await page.getByLabel("Instance name").fill(instance); + await page.getByRole("button", { name: "Browse images" }).click(); + await page.getByPlaceholder("Search an image").click(); + await page.getByPlaceholder("Search an image").fill("jammy"); + await page + .getByRole("row") + .filter({ + hasText: "Ubuntu Minimal", + }) + .getByRole("button", { name: "Select" }) + .last() + .click(); + await page + .getByRole("combobox", { name: "Instance type" }) + .selectOption(type); + await page.getByRole("button", { name: "Create and start" }).first().click(); + + await page.waitForSelector( + `text=Created and started instance ${instance}.`, + TIMEOUT, + ); }; export const visitAndStartInstance = async (page: Page, instance: string) => { diff --git a/tests/helpers/network.ts b/tests/helpers/network.ts index 5bee5df8ad..00eaa97ea7 100644 --- a/tests/helpers/network.ts +++ b/tests/helpers/network.ts @@ -15,6 +15,7 @@ export const createNetwork = async (page: Page, network: string) => { await page.getByLabel("Name").fill(network); await page.getByRole("button", { name: "Create", exact: true }).click(); await page.waitForSelector(`text=Network ${network} created.`, TIMEOUT); + await page.getByRole("button", { name: "Close notification" }).click(); }; export const deleteNetwork = async (page: Page, network: string) => { @@ -32,9 +33,10 @@ export const visitNetwork = async (page: Page, network: string) => { await page.getByRole("link", { name: network }).first().click(); }; -export const saveNetwork = async (page: Page) => { +export const saveNetwork = async (page: Page, network: string) => { await page.getByRole("button", { name: "Save changes" }).click(); - await page.waitForSelector(`text=Network updated.`, TIMEOUT); + await page.waitForSelector(`text=Network ${network} updated.`, TIMEOUT); + await page.getByRole("button", { name: "Close notification" }).click(); }; export const editNetwork = async (page: Page, network: string) => { diff --git a/tests/helpers/notification.ts b/tests/helpers/notification.ts new file mode 100644 index 0000000000..af74bcd833 --- /dev/null +++ b/tests/helpers/notification.ts @@ -0,0 +1,42 @@ +import { Page, expect } from "@playwright/test"; +import { TIMEOUT } from "./constants"; + +export const checkNotificationExists = async (page: Page) => { + const notification = page.locator(".toast-notification"); + await expect(notification).toBeVisible(TIMEOUT); +}; + +export const checkNotificationHidden = async (page: Page) => { + const notification = page.locator(".toast-notification"); + await expect(notification).toBeHidden(TIMEOUT); +}; + +export const dismissNotification = async (page: Page) => { + const notification = page.locator(".toast-notification"); + await notification + .getByRole("button", { name: "Close notification" }) + .click(); + await expect(notification).toBeHidden(TIMEOUT); +}; + +export const toggleNotificationList = async (page: Page) => { + const listToggleButton = page.getByRole("button", { + name: "Expand notifications list", + }); + await listToggleButton.click(); +}; + +export const dismissFirstNotificationFromList = async (page: Page) => { + const notificationsList = page.getByRole("list", { + name: "Notifications list", + }); + const notificationsLocator = notificationsList.getByRole("listitem"); + const countBeforeDismissal = (await notificationsLocator.all()).length; + await notificationsLocator + .first() + .getByRole("button", { name: "Close notification" }) + .click(); + await page.waitForTimeout(500); // allow animation to finish + const countAfterDismissal = (await notificationsLocator.all()).length; + expect(countAfterDismissal).toBeLessThan(countBeforeDismissal); +}; diff --git a/tests/helpers/profile.ts b/tests/helpers/profile.ts index 018257d522..7f5f2e85b9 100644 --- a/tests/helpers/profile.ts +++ b/tests/helpers/profile.ts @@ -54,12 +54,16 @@ export const renameProfile = async ( await page.getByRole("textbox").press("Control+a"); await page.getByRole("textbox").fill(newName); await page.getByRole("button", { name: "Save" }).click(); - await page.getByText("Profile renamed.").click(); + await page.waitForSelector( + `text=Profile ${oldName} renamed to ${newName}.`, + TIMEOUT, + ); }; -export const saveProfile = async (page: Page) => { +export const saveProfile = async (page: Page, profile: string) => { await page.getByRole("button", { name: "Save changes" }).click(); - await page.waitForSelector(`text=Profile updated.`, TIMEOUT); + await page.waitForSelector(`text=Profile ${profile} updated.`, TIMEOUT); + await page.getByRole("button", { name: "Close notification" }).click(); }; export const editProfile = async (page: Page, profile: string) => { diff --git a/tests/helpers/projects.ts b/tests/helpers/projects.ts index bd55460594..a4c86504b8 100644 --- a/tests/helpers/projects.ts +++ b/tests/helpers/projects.ts @@ -14,6 +14,7 @@ export const createProject = async (page: Page, project: string) => { await page.getByPlaceholder("Enter name").fill(project); await page.getByRole("button", { name: "Create" }).click(); await page.waitForSelector(`text=Project ${project} created.`, TIMEOUT); + await page.getByRole("button", { name: "Close notification" }).click(); }; export const renameProject = async ( @@ -29,7 +30,6 @@ export const renameProject = async ( await page.getByRole("textbox").first().press("Control+a"); await page.getByRole("textbox").first().fill(newName); await page.getByRole("button", { name: "Save" }).click(); - await page.getByText("Project renamed.").click(); }; export const deleteProject = async (page: Page, project: string) => { diff --git a/tests/helpers/server.ts b/tests/helpers/server.ts index e3112146b1..55401dffc7 100644 --- a/tests/helpers/server.ts +++ b/tests/helpers/server.ts @@ -13,13 +13,17 @@ export const updateCheckbox = async (settingRow: Locator) => { await checkbox.click(); }; -export const removePassword = async (page: Page, settingRow: Locator) => { +export const removePassword = async ( + page: Page, + settingRow: Locator, + settingName: string, +) => { const removeButton = settingRow.getByRole("button", { name: "Remove", exact: true, }); await removeButton.click(); - await page.waitForSelector("text=Setting updated.", TIMEOUT); + await page.waitForSelector(`text=Setting ${settingName} updated.`, TIMEOUT); await page.getByRole("button", { name: "Close notification" }).click(); await validateSettingValue(settingRow, "not set"); }; @@ -40,7 +44,7 @@ export const updateSetting = async ( await settingInput.fill(content); } await settingRow.getByRole("button", { name: "Save", exact: true }).click(); - await page.waitForSelector("text=Setting updated.", TIMEOUT); + await page.waitForSelector(`text=Setting ${settingName} updated.`, TIMEOUT); await page.getByRole("button", { name: "Close notification" }).click(); await validateSettingValue( settingRow, @@ -66,7 +70,7 @@ export const resetSetting = async ( await settingRow.getByRole("button").click(); if (settingType === "password") { - await removePassword(page, settingRow); + await removePassword(page, settingRow, settingName); return; } @@ -74,7 +78,7 @@ export const resetSetting = async ( .getByRole("button", { name: "Reset to default", exact: true }) .click(); await settingRow.getByRole("button", { name: "Save", exact: true }).click(); - await page.waitForSelector("text=Setting updated.", TIMEOUT); + await page.waitForSelector(`text=Setting ${settingName} updated.`, TIMEOUT); await page.getByRole("button", { name: "Close notification" }).click(); await validateSettingValue(settingRow, defaultValue); }; diff --git a/tests/helpers/snapshots.ts b/tests/helpers/snapshots.ts index 7de9788427..88dbae23c2 100644 --- a/tests/helpers/snapshots.ts +++ b/tests/helpers/snapshots.ts @@ -26,7 +26,10 @@ export const createInstanceSnapshot = async ( .getByRole("button", { name: "Create" }) .click(); - await page.waitForSelector(`text=Snapshot ${snapshot} created.`, TIMEOUT); + await page.waitForSelector( + `text=Snapshot ${snapshot} created for instance ${instance}.`, + TIMEOUT, + ); }; export const restoreInstanceSnapshot = async (page: Page, snapshot: string) => { @@ -51,6 +54,7 @@ export const editInstanceSnapshot = async ( page: Page, oldName: string, newName: string, + instance: string, ) => { await page .getByRole("row", { name: "Name" }) @@ -68,7 +72,9 @@ export const editInstanceSnapshot = async ( await page.getByLabel("Expiry time").click(); await page.getByLabel("Expiry time").fill("12:23"); await page.getByRole("button", { name: "Save" }).click(); - await page.getByText(`Snapshot ${newName} saved.`).click(); + await page + .getByText(`Snapshot ${newName} saved for instance ${instance}.`) + .click(); await page.getByText("Apr 28, 2093, 12:23 PM").click(); }; diff --git a/tests/helpers/storagePool.ts b/tests/helpers/storagePool.ts index ccb7f6defa..2b5e5d37eb 100644 --- a/tests/helpers/storagePool.ts +++ b/tests/helpers/storagePool.ts @@ -38,7 +38,8 @@ export const editPool = async (page: Page, pool: string) => { await page.getByRole("button", { name: "Edit pool" }).click(); }; -export const savePool = async (page: Page) => { +export const savePool = async (page: Page, pool: string) => { await page.getByRole("button", { name: "Save changes" }).click(); - await page.waitForSelector(`text=Storage pool updated.`, TIMEOUT); + await page.waitForSelector(`text=Storage pool ${pool} updated.`, TIMEOUT); + await page.getByRole("button", { name: "Close notification" }).click(); }; diff --git a/tests/helpers/storageVolume.ts b/tests/helpers/storageVolume.ts index 705f575f73..d02833df40 100644 --- a/tests/helpers/storageVolume.ts +++ b/tests/helpers/storageVolume.ts @@ -15,6 +15,7 @@ export const createVolume = async (page: Page, volume: string) => { await page.getByPlaceholder("Enter value").fill("1"); await page.getByRole("button", { name: "Create", exact: true }).click(); await page.waitForSelector(`text=Storage volume ${volume} created.`, TIMEOUT); + await page.getByRole("button", { name: "Close notification" }).click(); }; export const deleteVolume = async (page: Page, volume: string) => { @@ -43,7 +44,7 @@ export const editVolume = async (page: Page, volume: string) => { await page.getByRole("button", { name: "Edit volume" }).click(); }; -export const saveVolume = async (page: Page) => { +export const saveVolume = async (page: Page, volume: string) => { await page.getByRole("button", { name: "Save changes" }).click(); - await page.waitForSelector(`text=Storage volume updated.`, TIMEOUT); + await page.waitForSelector(`text=Storage volume ${volume} updated.`, TIMEOUT); }; diff --git a/tests/instances.spec.ts b/tests/instances.spec.ts index 11c88c6bca..508c9111ec 100644 --- a/tests/instances.spec.ts +++ b/tests/instances.spec.ts @@ -55,7 +55,7 @@ test("instance edit basic details", async ({ page }) => { await page.locator("#profile-1").selectOption(profile); await page.getByRole("button", { name: "move profile up" }).last().click(); - await saveInstance(page); + await saveInstance(page, instance); await page.getByText("DescriptionA-new-description").click(); await expect(page.locator("#profile-0")).toHaveValue(profile); @@ -71,23 +71,23 @@ test("instance cpu and memory", async ({ page }) => { await visitInstance(page, instance); await setCpuLimit(page, "number", "42"); - await saveInstance(page); + await saveInstance(page, instance); await assertReadMode(page, "Exposed CPU limit", "42"); await setCpuLimit(page, "fixed", "1,2,3,4"); - await saveInstance(page); + await saveInstance(page, instance); await assertReadMode(page, "Exposed CPU limit", "1,2,3,4"); await setCpuLimit(page, "fixed", "1-23"); - await saveInstance(page); + await saveInstance(page, instance); await assertReadMode(page, "Exposed CPU limit", "1-23"); await setMemLimit(page, "percentage", "2"); - await saveInstance(page); + await saveInstance(page, instance); await assertReadMode(page, "Memory limit", "2%"); await setMemLimit(page, "absolute", "3"); - await saveInstance(page); + await saveInstance(page, instance); await assertReadMode(page, "Memory limit", "3GiB"); await deleteInstance(page, instance); @@ -103,7 +103,7 @@ test("instance edit resource limits", async ({ page }) => { await setOption(page, "Disk priority", "1"); await setInput(page, "Max number of processes", "Enter number", "2"); - await saveInstance(page); + await saveInstance(page, instance); await assertReadMode(page, "Memory swap (Containers only)", "Allow"); await assertReadMode(page, "Disk priority", "1"); @@ -127,7 +127,7 @@ test("instance edit security policies", async ({ page }) => { await setOption(page, "Allow /dev/lxd in the instance", "true"); await setOption(page, "Make /1.0/images API available", "true"); - await saveInstance(page); + await saveInstance(page, instance); await assertReadMode(page, "Protect deletion", "No"); await assertReadMode(page, "Privileged (Containers only)", "Allow"); @@ -163,7 +163,7 @@ test("instance edit snapshot configuration", async ({ page }) => { await setOption(page, "Snapshot stopped instances", "true"); await setSchedule(page, "@daily"); - await saveInstance(page); + await saveInstance(page, instance); await assertReadMode(page, "Snapshot name pattern", "snap123"); await assertReadMode(page, "Expire after", "3m"); @@ -183,7 +183,7 @@ test("instance edit cloud init configuration", async ({ page }) => { await setCodeInput(page, "User data", "bar:\n" + " - def"); await setCodeInput(page, "Vendor data", "baz:\n" + " - ghi"); - await saveInstance(page); + await saveInstance(page, instance); await assertCode(page, "Network config", "foo:"); await assertCode(page, "User data", "bar:"); @@ -201,7 +201,7 @@ test("instance create vm", async ({ page }) => { await page.getByText("Security policies").click(); await setOption(page, "Enable secureboot (VMs only)", "true"); - await saveInstance(page); + await saveInstance(page, instance); await assertReadMode(page, "Enable secureboot (VMs only)", "true"); @@ -223,7 +223,7 @@ test("instance yaml edit", async ({ page }) => { await page.keyboard.press("End"); await page.keyboard.press("ArrowLeft"); await page.keyboard.type("A-new-description"); - await saveInstance(page); + await saveInstance(page, instance); await page.getByText("Main configuration").click(); await page.getByText("DescriptionA-new-description").click(); diff --git a/tests/networks.spec.ts b/tests/networks.spec.ts index 5e27183375..5e5cba6332 100644 --- a/tests/networks.spec.ts +++ b/tests/networks.spec.ts @@ -62,7 +62,7 @@ test("network edit basic details", async ({ page }) => { await activateOverride(page, "IPv6 DHCP stateful"); await setOption(page, "IPv6 DHCP stateful", "true"); - await saveNetwork(page); + await saveNetwork(page, network); await visitNetwork(page, network); await page.getByRole("cell", { name: "A-new-description" }).click(); diff --git a/tests/notification.spec.ts b/tests/notification.spec.ts new file mode 100644 index 0000000000..d843744f16 --- /dev/null +++ b/tests/notification.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from "@playwright/test"; +import { + createAndStartInstance, + createInstance, + deleteInstance, + randomInstanceName, +} from "./helpers/instances"; +import { + checkNotificationExists, + dismissNotification, + checkNotificationHidden, + toggleNotificationList, + dismissFirstNotificationFromList, +} from "./helpers/notification"; + +test("show notification after user action", async ({ page }) => { + const instance = randomInstanceName(); + await createInstance(page, instance); + await checkNotificationExists(page); + await deleteInstance(page, instance); + await checkNotificationExists(page); +}); + +test("dismiss one notification", async ({ page }) => { + const instance = randomInstanceName(); + await createInstance(page, instance); + await dismissNotification(page); + await deleteInstance(page, instance); + await dismissNotification(page); +}); + +test("auto hide notification after a timeout", async ({ page }) => { + const instance = randomInstanceName(); + await createInstance(page, instance); + await page.waitForTimeout(5000); + await checkNotificationHidden(page); + await deleteInstance(page, instance); + await dismissNotification(page); +}); + +test("notifications list", async ({ page }) => { + const instance = randomInstanceName(); + await createAndStartInstance(page, instance); + await toggleNotificationList(page); // open list + + // check there are multiple notifications + const notificationsList = page.getByRole("list", { + name: "Notifications list", + }); + const notifications = await notificationsList.getByRole("listitem").all(); + expect(notifications.length).toBeGreaterThan(1); + + // set and clear filters + const notificationsLocator = notificationsList.getByRole("listitem"); + const countBeforeFilter = (await notificationsLocator.all()).length; + await notificationsList.locator(".filter-button").first().click(); + const countAfterFilter = (await notificationsLocator.all()).length; + expect(countAfterFilter).toBeLessThan(countBeforeFilter); + await notificationsList + .getByRole("button", { name: "Clear filters" }) + .click(); + const countAfterClearFilter = (await notificationsLocator.all()).length; + expect(countAfterClearFilter).toEqual(countBeforeFilter); + + // dismiss one notification from list + await dismissFirstNotificationFromList(page); + + // dismiss all notifications from list + await notificationsList.getByRole("button", { name: "Dismiss all" }).click(); + await expect(notificationsList).toBeHidden(); + + await deleteInstance(page, instance); +}); diff --git a/tests/profiles.spec.ts b/tests/profiles.spec.ts index 2260a0242e..9cd886d316 100644 --- a/tests/profiles.spec.ts +++ b/tests/profiles.spec.ts @@ -42,7 +42,7 @@ test("profile edit basic details", async ({ page }) => { await page.getByPlaceholder("Enter description").fill("A-new-description"); - await saveProfile(page); + await saveProfile(page, profile); await page.getByText("DescriptionA-new-description").click(); @@ -55,23 +55,23 @@ test("profile cpu and memory", async ({ page }) => { await visitProfile(page, profile); await setCpuLimit(page, "number", "42"); - await saveProfile(page); + await saveProfile(page, profile); await assertReadMode(page, "Exposed CPU limit", "42"); await setCpuLimit(page, "fixed", "1,2,3,4"); - await saveProfile(page); + await saveProfile(page, profile); await assertReadMode(page, "Exposed CPU limit", "1,2,3,4"); await setCpuLimit(page, "fixed", "1-23"); - await saveProfile(page); + await saveProfile(page, profile); await assertReadMode(page, "Exposed CPU limit", "1-23"); await setMemLimit(page, "percentage", "2"); - await saveProfile(page); + await saveProfile(page, profile); await assertReadMode(page, "Memory limit", "2%"); await setMemLimit(page, "absolute", "3"); - await saveProfile(page); + await saveProfile(page, profile); await assertReadMode(page, "Memory limit", "3GiB"); await deleteProfile(page, profile); @@ -86,7 +86,7 @@ test("profile resource limits", async ({ page }) => { await setOption(page, "Memory swap", "true"); await setOption(page, "Disk priority", "1"); await setInput(page, "Max number of processes", "Enter number", "2"); - await saveProfile(page); + await saveProfile(page, profile); await assertReadMode(page, "Memory swap (Containers only)", "Allow"); await assertReadMode(page, "Disk priority", "1"); @@ -110,7 +110,7 @@ test("profile security policies", async ({ page }) => { await setOption(page, "Allow /dev/lxd in the instance", "true"); await setOption(page, "Make /1.0/images API available", "true"); await setOption(page, "Enable secureboot", "true"); - await saveProfile(page); + await saveProfile(page, profile); await assertReadMode(page, "Protect deletion", "Yes"); await assertReadMode(page, "Privileged (Containers only)", "Allow"); @@ -144,7 +144,7 @@ test("profile snapshots", async ({ page }) => { await setOption(page, "Snapshot stopped instances", "true"); await setSchedule(page, "@daily"); - await saveProfile(page); + await saveProfile(page, profile); await assertReadMode(page, "Snapshot name pattern", "snap123"); await assertReadMode(page, "Expire after", "3m"); @@ -163,7 +163,7 @@ test("profile cloud init", async ({ page }) => { await setCodeInput(page, "Network config", "foo:\n" + " - abc"); await setCodeInput(page, "User data", "bar:\n" + " - def"); await setCodeInput(page, "Vendor data", "baz:\n" + " - ghi"); - await saveProfile(page); + await saveProfile(page, profile); await assertCode(page, "Network config", "foo:"); await assertCode(page, "User data", "bar:"); @@ -184,7 +184,7 @@ test("profile yaml edit", async ({ page }) => { description: 'A-new-description' devices: {} name: ${profile}`); - await saveProfile(page); + await saveProfile(page, profile); await page.getByText("Main configuration").click(); await page.getByText("DescriptionA-new-description").click(); diff --git a/tests/projects.spec.ts b/tests/projects.spec.ts index 48bda9e0d7..cad99646ce 100644 --- a/tests/projects.spec.ts +++ b/tests/projects.spec.ts @@ -11,6 +11,7 @@ import { randomProjectName, renameProject, } from "./helpers/projects"; +import { TIMEOUT } from "./helpers/constants"; test("project create and remove", async ({ page }) => { const project = randomProjectName(); @@ -95,7 +96,8 @@ test("project edit configuration", async ({ page }) => { await setTextarea(page, "Network zones", "Enter network zones", "foo,bar"); await page.getByRole("button", { name: "Save changes" }).click(); - await page.getByText("Project updated.").click(); + await page.waitForSelector(`text=Project ${project} updated.`, TIMEOUT); + await page.getByRole("button", { name: "Close notification" }).click(); await page.getByText("Project details").click(); diff --git a/tests/snapshots.spec.ts b/tests/snapshots.spec.ts index b016903444..bb26753c6d 100644 --- a/tests/snapshots.spec.ts +++ b/tests/snapshots.spec.ts @@ -30,7 +30,7 @@ test("instance snapshot create, restore, edit and remove", async ({ page }) => { await restoreInstanceSnapshot(page, snapshot); const newName = `${snapshot}-rename`; - await editInstanceSnapshot(page, snapshot, newName); + await editInstanceSnapshot(page, snapshot, newName, instance); await deleteInstanceSnapshot(page, newName); diff --git a/tests/storage.spec.ts b/tests/storage.spec.ts index 2c163a3db3..45d1b511f1 100644 --- a/tests/storage.spec.ts +++ b/tests/storage.spec.ts @@ -24,7 +24,7 @@ test("storage pool create, edit and remove", async ({ page }) => { await editPool(page, pool); await page.getByPlaceholder("Enter description").fill("A-new-description"); - await savePool(page); + await savePool(page, pool); await page.getByTestId("tab-link-Overview").click(); await page.getByText("DescriptionA-new-description").click(); @@ -39,7 +39,7 @@ test("storage volume create, edit and remove", async ({ page }) => { await editVolume(page, volume); await page.getByPlaceholder("Enter value").fill("2"); - await saveVolume(page); + await saveVolume(page, volume); await page.getByTestId("tab-link-Overview").click(); await page.getByText("size2GiB").click(); @@ -65,7 +65,10 @@ test("storage volume edit snapshot configuration", async ({ page }) => { await activateOverride(page, "Schedule Schedule for automatic"); await page.getByPlaceholder("Enter cron expression").last().fill("@daily"); await page.getByRole("button", { name: "Save" }).click(); - await page.waitForSelector(`text=Configuration updated.`, TIMEOUT); + await page.waitForSelector( + `text=Snapshot configuration updated for volume ${volume}.`, + TIMEOUT, + ); await deleteVolume(page, volume); });