From 977d8e8d732acd59a557cbadc7edef99412eae73 Mon Sep 17 00:00:00 2001 From: Marko Lisica <83164494+marko-lisica@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:26:02 +0200 Subject: [PATCH] New `ToastNotification` component to replace `FlashMessage` component (#47508) **Related issue:** Resolves # # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Summary by CodeRabbit * **New Features** * Redesigned in-app notifications with a new toast-based UI style. * Error messages are now dismissible and expandable to reveal detailed server response information. * Success and error notifications now appear consistently across the application with improved visibility. --- changes/47508-toast-notifications | 1 + .../PlatformWrapper/PlatformWrapper.tsx | 9 +- frontend/components/App/App.tsx | 20 +- .../EmailTokenRedirect/EmailTokenRedirect.tsx | 5 +- .../FlashMessage/FlashMessage.stories.tsx | 33 -- .../components/FlashMessage/FlashMessage.tsx | 181 ----------- frontend/components/FlashMessage/_styles.scss | 118 ------- frontend/components/FlashMessage/index.ts | 1 - .../ToastNotification/ToastCard.tsx | 202 ++++++++++++ .../ToastNotification.stories.tsx | 244 +++++++++++++++ .../ToastNotification/ToastNotification.tsx | 294 ++++++++++++++++++ .../components/ToastNotification/_styles.scss | 251 +++++++++++++++ .../components/ToastNotification/index.ts | 12 + .../components/top_nav/UserMenu/UserMenu.tsx | 18 +- frontend/context/notification.tsx | 170 ---------- frontend/interfaces/notification.ts | 18 -- frontend/layouts/CoreLayout/CoreLayout.tsx | 37 +-- frontend/layouts/GatedLayout/GatedLayout.tsx | 17 +- frontend/pages/AccountPage/AccountPage.tsx | 15 +- .../ConfirmInvitePage/ConfirmInvitePage.tsx | 10 +- .../ConfirmSSOInvitePage.tsx | 7 +- .../pages/DashboardPage/DashboardPage.tsx | 17 +- .../cards/WelcomeHost/WelcomeHost.tsx | 15 +- frontend/pages/LoginPage/LoginPage.tsx | 6 +- frontend/pages/LogoutPage/LogoutPage.tsx | 5 +- .../AddCertificateModal.tsx | 12 +- .../DeleteCertificateModal.tsx | 11 +- .../ConfigurationProfiles.tsx | 7 +- .../AddProfileModal/AddProfileModal.tsx | 16 +- .../ResendConfigProfileModal.tsx | 14 +- .../cards/DiskEncryption/DiskEncryption.tsx | 25 +- .../OSSettings/cards/Passwords/Passwords.tsx | 14 +- .../AppleOSTargetForm/AppleOSTargetForm.tsx | 9 +- .../WindowsTargetForm/WindowsTargetForm.tsx | 9 +- .../ScriptBatchDetailsPage.tsx | 20 +- .../DeleteScriptModal/DeleteScriptModal.tsx | 13 +- .../EditScriptModal/EditScriptModal.tsx | 7 +- .../ScriptListItem/ScriptListItem.tsx | 14 +- .../ScriptUploadModal/ScriptUploadModal.tsx | 9 +- .../BootstrapPackage/BootstrapPackage.tsx | 11 +- .../BootstrapAdvancedOptions.tsx | 13 +- .../BootstrapPackageUploader.tsx | 11 +- .../InstallSoftwareForm.tsx | 31 +- .../DeleteSetupExperienceScriptModal.tsx | 14 +- .../SetupExperienceScriptCard.tsx | 10 +- .../SetupExperienceScriptUploader.tsx | 9 +- .../AdvancedOptionsForm.tsx | 13 +- .../DeleteAutoEnrollmentProfile.tsx | 12 +- .../SetupAssistantProfileUploader.tsx | 9 +- .../Users/components/UsersForm/UsersForm.tsx | 11 +- .../AddCustomVariableModal.tsx | 24 +- .../DeleteCustomVariableModal.tsx | 10 +- .../RegistrationPage/RegistrationPage.tsx | 22 +- .../SoftwareAppStoreAndroid.tsx | 11 +- .../SoftwareAppStoreVpp.tsx | 11 +- .../SoftwareCustomPackage.tsx | 13 +- .../FleetMaintainedAppDetailsPage.tsx | 8 +- .../DeleteCategoryModal.tsx | 7 +- .../SelfServiceCategoriesPage.tests.tsx | 51 +-- .../SelfServiceCategoriesPage.tsx | 9 +- frontend/pages/SoftwarePage/SoftwarePage.tsx | 12 +- .../AddPatchPolicyModal.tsx | 16 +- .../DeleteSoftwareModal.tsx | 23 +- .../EditAutoUpdateConfigModal.tsx | 14 +- .../EditConfigurationModal.tsx | 12 +- .../EditIconModal/EditIconModal.tsx | 44 ++- .../EditSoftwareModal/EditSoftwareModal.tsx | 25 +- .../SoftwareInstallerCard.tsx | 8 +- .../forms/PackageForm/PackageForm.tsx | 9 +- .../IntegrationsPage/IntegrationsPage.tsx | 9 +- .../cards/Calendars/Calendars.tsx | 12 +- .../AddCertAuthorityModal.tsx | 10 +- .../DeleteCertificateAuthorityModal.tsx | 16 +- .../EditCertAuthorityModal.tsx | 9 +- .../ChangeManagement.tests.tsx | 10 +- .../ChangeManagement/ChangeManagement.tsx | 7 +- .../ConditionalAccess.stories.tsx | 40 +-- .../ConditionalAccess/ConditionalAccess.tsx | 39 +-- .../EntraConditionalAccessModal.tsx | 12 +- .../OktaConditionalAccessModal.tsx | 27 +- .../EndUserAuthSection.tests.tsx | 12 +- .../EndUserAuthSection/EndUserAuthSection.tsx | 16 +- .../cards/Integrations/TicketDestinations.tsx | 38 +-- .../AndroidMdmPage/AndroidMdmPage.tsx | 30 +- .../TurnOffAndroidMdmModal.tsx | 13 +- .../components/AddAbmModal/AddAbmModal.tsx | 14 +- .../DeleteAbmModal/DeleteAbmModal.tsx | 17 +- .../EditTeamsAbmModal/EditTeamsAbmModal.tsx | 16 +- .../RenewAbmModal/RenewAbmModal.tsx | 14 +- .../MdmSettings/AppleMdmPage/AppleMdmPage.tsx | 11 +- .../components/content/ApplePushCertSetup.tsx | 68 ++-- .../modals/RenewCertModal/RenewCertModal.tsx | 64 ++-- .../components/AddVppModal/AddVppModal.tsx | 14 +- .../DeleteVppModal/DeleteVppModal.tsx | 12 +- .../EditTeamsVppModal/EditTeamsVppModal.tsx | 9 +- .../RenewVppModal/RenewVppModal.tsx | 15 +- .../WindowsMdmPage/WindowsMdmPage.tsx | 11 +- .../AddEntraClientIDModal.tests.tsx | 17 +- .../AddEntraClientIDModal.tsx | 11 +- .../AddEntraTenantModal.tsx | 11 +- .../DeleteEntraClientIDModal.tsx | 9 +- .../DeleteEntraTenantModal.tsx | 9 +- .../EndUserMigrationSection.tests.tsx | 12 +- .../EndUserMigrationSection.tsx | 7 +- .../components/EulaSection/EulaSection.tsx | 12 +- .../components/EulaUploader/EulaUploader.tsx | 11 +- .../ManageFleetsPage/ManageFleetsPage.tsx | 28 +- .../AgentOptionsPage/AgentOptionsPage.tsx | 14 +- .../TeamDetailsWrapper/TeamDetailsWrapper.tsx | 32 +- .../TeamSettings/TeamSettings.tsx | 29 +- .../UsersPage/UsersPage.tsx | 54 ++-- .../CreateApiUserPage/CreateApiUserPage.tsx | 10 +- .../CreateUserPage/CreateUserPage.tsx | 15 +- .../EditUserPage/EditUserPage.tsx | 27 +- .../components/UserForm/UserForm.tsx | 9 +- .../components/UsersTable/UsersTable.tsx | 24 +- .../admin/OrgSettingsPage/OrgSettingsPage.tsx | 20 +- .../admin/OrgSettingsPage/cards/Info/Info.tsx | 19 +- .../DownloadFileButtons/DownloadABMKey.tsx | 13 +- .../hosts/ManageHostsPage/ManageHostsPage.tsx | 45 +-- .../RunScriptBatchModal.tsx | 16 +- .../ScriptDetailsModal/ScriptDetailsModal.tsx | 6 +- .../details/DeviceUserPage/DeviceUserPage.tsx | 44 +-- .../HostDetailsPage/HostDetailsPage.tsx | 46 +-- .../CancelActivityModal.tsx | 9 +- .../ClearPasscodeModal/ClearPasscodeModal.tsx | 14 +- .../modals/LockModal/LockModal.tsx | 14 +- .../ManagedAccountModal.tsx | 14 +- .../RecoveryLockPasswordModal.tsx | 10 +- .../ScriptModalGroup/ScriptModalGroup.tsx | 12 +- .../UnenrollMdmModal/UnenrollMdmModal.tsx | 10 +- .../modals/UnlockModal/UnlockModal.tsx | 12 +- .../modals/WipeModal/WipeModal.tsx | 14 +- .../OSSettingsResendCell.tsx | 17 +- .../HostSoftwareLibrary.tsx | 25 +- .../Software/SelfService/SelfService.tsx | 64 ++-- .../InstallAllInCategoryButton.tsx | 9 +- .../UninstallSoftwareModal.tsx | 11 +- .../labels/EditLabelPage/EditLabelPage.tsx | 16 +- .../ManageLabelsPage/ManageLabelsPage.tsx | 9 +- .../labels/NewLabelPage/NewLabelPage.tsx | 7 +- .../pages/packs/EditPackPage/EditPackPage.tsx | 35 ++- .../packs/ManagePacksPage/ManagePacksPage.tsx | 25 +- .../PackComposerPage/PackComposerPage.tsx | 17 +- .../ManagePoliciesPage/ManagePoliciesPage.tsx | 12 +- .../AutomationsModal/AutomationsModal.tsx | 9 +- .../ManageAutomationsModal.tsx | 10 +- .../PolicyAutomationActivityDetailsModal.tsx | 9 +- .../PolicyAutomationsActivitiesTable.tsx | 13 +- .../edit/components/PolicyForm/PolicyForm.tsx | 13 +- .../policies/edit/screens/QueryEditor.tsx | 39 +-- .../pages/policies/live/screens/RunQuery.tsx | 23 +- .../ManageQueriesPage/ManageQueriesPage.tsx | 21 +- frontend/pages/queries/edit/EditQueryPage.tsx | 27 +- .../SaveAsNewQueryModal.tsx | 7 +- .../pages/queries/live/screens/RunQuery.tsx | 28 +- frontend/test/test-utils.tsx | 8 +- frontend/utilities/campaign_helpers/index.ts | 7 +- package.json | 1 + yarn.lock | 5 + 160 files changed, 2093 insertions(+), 1957 deletions(-) create mode 100644 changes/47508-toast-notifications delete mode 100644 frontend/components/FlashMessage/FlashMessage.stories.tsx delete mode 100644 frontend/components/FlashMessage/FlashMessage.tsx delete mode 100644 frontend/components/FlashMessage/_styles.scss delete mode 100644 frontend/components/FlashMessage/index.ts create mode 100644 frontend/components/ToastNotification/ToastCard.tsx create mode 100644 frontend/components/ToastNotification/ToastNotification.stories.tsx create mode 100644 frontend/components/ToastNotification/ToastNotification.tsx create mode 100644 frontend/components/ToastNotification/_styles.scss create mode 100644 frontend/components/ToastNotification/index.ts delete mode 100644 frontend/context/notification.tsx delete mode 100644 frontend/interfaces/notification.ts diff --git a/changes/47508-toast-notifications b/changes/47508-toast-notifications new file mode 100644 index 0000000000..1239f1618f --- /dev/null +++ b/changes/47508-toast-notifications @@ -0,0 +1 @@ +- Redesigned in-app success and error notifications as toasts. Error notifications now persist until dismissed and can be expanded to show the server's raw response. diff --git a/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx b/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx index d3b745d2c3..55836caa5b 100644 --- a/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx +++ b/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx @@ -1,8 +1,8 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; import FileSaver from "file-saver"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import { IConfig } from "interfaces/config"; import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants"; @@ -76,8 +76,6 @@ const PlatformWrapper = ({ fetchCertificateError, config, }: IPlatformWrapperProps): JSX.Element => { - const { renderFlash } = useContext(NotificationContext); - const [hostType, setHostType] = useState<"workstation" | "server">( "workstation" ); @@ -159,8 +157,7 @@ const PlatformWrapper = ({ FileSaver.saveAs(file); } else { - renderFlash( - "error", + notify.error( "Your certificate could not be downloaded. Please check your Fleet configuration." ); } diff --git a/frontend/components/App/App.tsx b/frontend/components/App/App.tsx index 2816e9704e..6b62e5281d 100644 --- a/frontend/components/App/App.tsx +++ b/frontend/components/App/App.tsx @@ -9,7 +9,6 @@ import page_titles from "router/page_titles"; import TableProvider from "context/table"; import QueryProvider from "context/query"; import PolicyProvider from "context/policy"; -import NotificationProvider from "context/notification"; import { AppContext } from "context/app"; import authToken from "utilities/auth_token"; import useDeepEffect from "hooks/useDeepEffect"; @@ -34,6 +33,7 @@ import Fleet404 from "pages/errors/Fleet404"; import Fleet500 from "pages/errors/Fleet500"; import Spinner from "components/Spinner"; +import ToastNotification from "components/ToastNotification"; interface IAppProps { children: JSX.Element; @@ -292,14 +292,16 @@ const App = ({ children, location }: IAppProps): JSX.Element => { - - -
{children}
-
-
+ {/* Sonner toaster — single global mount; renders toasts + dispatched from `notify.*` anywhere in the app. Outside the + ErrorBoundary so toasts survive page-level error overlays. */} + + +
{children}
+
diff --git a/frontend/components/EmailTokenRedirect/EmailTokenRedirect.tsx b/frontend/components/EmailTokenRedirect/EmailTokenRedirect.tsx index 881daee7e2..c87b5878cf 100644 --- a/frontend/components/EmailTokenRedirect/EmailTokenRedirect.tsx +++ b/frontend/components/EmailTokenRedirect/EmailTokenRedirect.tsx @@ -4,7 +4,7 @@ import { Params } from "react-router/lib/Router"; import PATHS from "router/paths"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import useDeepEffect from "hooks/useDeepEffect"; import usersAPI from "services/entities/users"; @@ -18,7 +18,6 @@ const EmailTokenRedirect = ({ params: { token }, }: IEmailTokenRedirectProps) => { const { currentUser } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); useDeepEffect(() => { const confirmEmailChange = async () => { @@ -26,7 +25,7 @@ const EmailTokenRedirect = ({ try { await usersAPI.confirmEmailChange(currentUser, token); router.push(PATHS.ACCOUNT); - renderFlash("success", "Email updated successfully."); + notify.success("Email updated successfully."); } catch (error) { console.log(error); router.push(PATHS.LOGIN); diff --git a/frontend/components/FlashMessage/FlashMessage.stories.tsx b/frontend/components/FlashMessage/FlashMessage.stories.tsx deleted file mode 100644 index 5947b6a949..0000000000 --- a/frontend/components/FlashMessage/FlashMessage.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react"; - -import FlashMessage from "."; - -import "../../index.scss"; - -const meta: Meta = { - component: FlashMessage, - title: "Components/FlashMessage", - argTypes: { - fullWidth: { - control: "boolean", - }, - isPersistent: { - control: "boolean", - }, - }, - args: { - fullWidth: true, - isPersistent: true, - notification: { - message: "I am a message. Hear me roar!", - alertType: "success", - isVisible: true, - }, - }, -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/frontend/components/FlashMessage/FlashMessage.tsx b/frontend/components/FlashMessage/FlashMessage.tsx deleted file mode 100644 index 49762e1bb6..0000000000 --- a/frontend/components/FlashMessage/FlashMessage.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import React, { useEffect, useState, useRef } from "react"; -import classnames from "classnames"; - -import { INotification } from "interfaces/notification"; -import Icon from "components/Icon/Icon"; - -const baseClass = "flash-message"; - -export interface IFlashMessage { - fullWidth: boolean; - notification: INotification | INotification[] | null; // Handles single or multiple notifications - isPersistent?: boolean; - className?: string; - onRemoveFlash: (id?: string) => void; // Accepts an id for removing specific notifications - pathname?: string; -} - -type ISingleFlashMessage = Omit & { - notification: INotification; -}; - -// Component to render a single flash message -const SingleFlashMessage = ({ - notification, - fullWidth, - isPersistent, - className, - onRemoveFlash, - pathname, -}: ISingleFlashMessage) => { - const { - alertType, - isVisible, - message, - persistOnPageChange, - id, - } = notification; - const baseClasses = classnames( - baseClass, - className, - `${baseClass}--${alertType}`, - { - [`${baseClass}--full-width`]: fullWidth, - } - ); - - const [hide, setHide] = useState(false); - - // This useEffect handles hiding successful flash messages after a 4s timeout. - // By putting the notification in the dependency array, we can properly reset whenever a new flash message comes through. - useEffect(() => { - // Any time this hook runs, we reset the hide to false (so that subsequent messages that will be using this same component instance will be visible). - setHide(false); - - if (!isPersistent && alertType === "success" && isVisible) { - // After 4 seconds, set hide to true. - const timer = setTimeout(() => { - setHide(true); - onRemoveFlash(); // This function resets notifications which allows CoreLayout reset of selected rows - }, 4000); - // Return a cleanup function that will clear this reset, in case another render happens after this. - return () => clearTimeout(timer); - } - - return undefined; // No cleanup when we don't set a timeout. - }, [ - id, - notification, - alertType, - isVisible, - setHide, - isPersistent, - onRemoveFlash, - ]); - - const isFirstRender = useRef(true); - - useEffect(() => { - if (isFirstRender.current) { - isFirstRender.current = false; - return; - } - - if (!persistOnPageChange) { - setHide(true); - } - }, [pathname, persistOnPageChange]); - - if (hide || !isVisible) { - return null; - } - - // Three cases needed here (warning-filled, success, error) so we can't use a - // simple ternary like the close button below — no-nested-ternary lint rule. - let iconColor: "static-black" | "ui-success" | "ui-error"; - if (alertType === "warning-filled") { - iconColor = "static-black"; - } else if (alertType === "success") { - iconColor = "ui-success"; - } else { - iconColor = "ui-error"; - } - - return ( -
-
- - {message} -
-
-
- -
-
-
- ); -}; - -const FlashMessage = ({ - fullWidth, - notification, - isPersistent, - className, - onRemoveFlash, - pathname, -}: IFlashMessage): JSX.Element | null => { - if (!notification) { - return null; // Return null if there are no notifications - } - - // Check if notification is an array and render accordingly - if (Array.isArray(notification)) { - const displayNotifications = notification.slice(0, 5); // Limit to 5 notifications - return ( -
- {displayNotifications.map((n) => ( - - ))} -
- ); - } - - // Render a single notification if it's not an array - return ( -
- -
- ); -}; - -export default FlashMessage; diff --git a/frontend/components/FlashMessage/_styles.scss b/frontend/components/FlashMessage/_styles.scss deleted file mode 100644 index 78b45060c9..0000000000 --- a/frontend/components/FlashMessage/_styles.scss +++ /dev/null @@ -1,118 +0,0 @@ -// Allows for centering flash message for short and long, wrapped messages -.flash-message-container { - @include position(fixed); - top: 80px; - left: 0; - right: 0; - display: flex; - flex-direction: column; - gap: $pad-small; - justify-content: center; - align-items: center; - z-index: 999; - pointer-events: none; -} - -.flash-message { - display: flex; - align-items: center; - justify-content: center; - color: $core-fleet-black; - padding: $pad-small $pad-medium; - z-index: 999; - border: 1px solid; - box-sizing: border-box; - border-radius: 8px; - max-width: calc(100% - 64px); // Same horizontal margin as .main-content - pointer-events: auto; - - &--success { - background-color: $ui-success-flash-bg; - border-color: $ui-success; - } - - &--error { - background-color: $ui-error-flash-bg; - border-color: $ui-error; - } - - &--warning-filled { - background-color: $ui-warning; - // Yellow is light enough that foreground should be dark in BOTH modes. - // Use static (un-themed) tokens so dark mode doesn't flip to light text. - color: $static-black; - - span { - margin-left: 15px; - margin-right: 15px; - font-size: $x-small; - color: $static-black; - } - - .flash-message__remove .fleeticon, - .flash-message__remove .fleeticon:hover { - color: $static-black; - } - - .flash-message__undo { - color: $static-black; - } - } - - &__content { - display: flex; - align-items: center; - - span { - margin-left: 15px; - margin-right: 15px; - font-size: $x-small; - } - - .fleeticon { - font-size: $small; - } - - img { - width: 16px; - height: 16px; - } - - a { - color: inherit; - } - } - - &__undo { - color: $core-fleet-black; - cursor: pointer; - font-size: $small; - text-decoration: underline; - text-transform: uppercase; - margin-right: 15px; - } - - &__remove { - height: auto; - cursor: pointer; - - .fleeticon { - transition: color 150ms ease-in-out; - color: $core-fleet-black; - font-size: $small; - - &:hover { - color: $core-fleet-black; - } - } - } - - &__ex { - text-decoration: none; - - button { - display: flex; - align-items: center; - } - } -} diff --git a/frontend/components/FlashMessage/index.ts b/frontend/components/FlashMessage/index.ts deleted file mode 100644 index c422aa0e43..0000000000 --- a/frontend/components/FlashMessage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./FlashMessage"; diff --git a/frontend/components/ToastNotification/ToastCard.tsx b/frontend/components/ToastNotification/ToastCard.tsx new file mode 100644 index 0000000000..c8a00d46b0 --- /dev/null +++ b/frontend/components/ToastNotification/ToastCard.tsx @@ -0,0 +1,202 @@ +import React, { useState } from "react"; +import classnames from "classnames"; +import { toast } from "sonner"; + +import Icon from "components/Icon"; +import { Colors } from "styles/var/colors"; +import { syntaxHighlight } from "utilities/helpers"; +import { stringToClipboard } from "utilities/copy_text"; + +const baseClass = "toast-notification"; + +export type ToastVariant = "success" | "error"; + +export interface IToastCardProps { + /* Success or error. */ + variant: ToastVariant; + /* Success or error message in the toast. Accepts JSX for rich + formatting (e.g. bolded entity names). */ + message: React.ReactNode; + /** + * Optional raw payload (e.g. API error response). When provided on an + * error toast, the card renders a chevron toggle that reveals a formatted + * JSON panel below the message. + */ + detail?: unknown; + /** + * Label shown above the error response (detail). Defaults to "Raw response". + */ + detailLabel?: string; + toastId: string | number; +} + +const variantIcon: Record< + ToastVariant, + { name: "success-outline" | "error-outline"; color: Colors } +> = { + success: { name: "success-outline", color: "status-success" }, + error: { name: "error-outline", color: "status-error" }, +}; + +/** + * `ToastCard` is the single source of truth for every toast variant. It is + * rendered inside Sonner's headless `toast.custom()` wrapper — Sonner + * provides positioning, stacking, and lifecycle; every pixel of the card + * itself (surface, icon, actions, expandable panel) is ours, so the design + * does not depend on Sonner's built-in themes. + * + * Internal-only — not exported from `./index.ts`. + */ +const ToastCard = ({ + variant, + message, + detail, + detailLabel = "Raw response", + toastId, +}: IToastCardProps): JSX.Element => { + const [isOpen, setIsOpen] = useState(false); + const hasDetail = detail !== undefined; + const icon = variantIcon[variant]; + + const toggle = (): void => { + setIsOpen((prev) => !prev); + }; + + const handleClose = (): void => { + toast.dismiss(toastId); + }; + + // Fleet's shared helper stringifies + escapes + wraps tokens in + // ``. The global `pre` + // rule in `styles/global/_global.scss` then colours each class — + // identical to the "Manage activity automations" modal's payload. + let detailHtml = ""; + let detailText = ""; + if (hasDetail) { + try { + detailText = JSON.stringify(detail, null, 2); + detailHtml = syntaxHighlight(detail); + } catch { + // Circular refs / non-serializable values — fall back to safe text. + detailText = String(detail); + detailHtml = detailText + .replace(/&/g, "&") + .replace(//g, ">"); + } + } + + const [copied, setCopied] = useState(false); + + // Capture when the toast first rendered. Snapshotted once via the lazy + // initializer so the timestamp stays stable across re-renders (toggling + // the panel, clicking copy, etc.). Not shown in the UI — only included + // in the clipboard payload for reporting / pasting into tickets. + const [timestamp] = useState(() => new Date().toISOString()); + + // Composed clipboard payload: + // Status: 409 Conflict ← detailLabel (if set) + // Timestamp: 2026-04-15T…Z ← when the toast fired + // + // { ...pretty-printed JSON... } + const copyText = [detailLabel, `Timestamp: ${timestamp}`, "", detailText] + .filter((line) => line !== undefined) + .join("\n"); + + const handleCopy = (): void => { + stringToClipboard(copyText) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }) + .catch(() => { + // Clipboard API may reject in insecure contexts — stay silent. + }); + }; + + const panelId = `${baseClass}__panel-${toastId}`; + + return ( +
+
+
+ + + + {message} +
+
+ {hasDetail && ( + + )} + +
+
+ {hasDetail && isOpen && ( +
+
+ {detailLabel} + +
+
+        
+ )} +
+ ); +}; + +export default ToastCard; diff --git a/frontend/components/ToastNotification/ToastNotification.stories.tsx b/frontend/components/ToastNotification/ToastNotification.stories.tsx new file mode 100644 index 0000000000..5d9bec16cd --- /dev/null +++ b/frontend/components/ToastNotification/ToastNotification.stories.tsx @@ -0,0 +1,244 @@ +import React from "react"; +import { Meta, StoryObj } from "@storybook/react"; + +import ToastNotification, { notify } from "."; + +import "../../index.scss"; + +const meta: Meta = { + component: ToastNotification, + title: "Components/ToastNotification", + // Opt out of the global `autodocs` tag. Sonner's `toast.xxx()` dispatches to + // every `` mounted on the page, so rendering all stories together + // on a docs page would fire the same toast in every story box. + tags: ["!autodocs"], + parameters: { + layout: "fullscreen", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +const TriggerButton = ({ + label, + onClick, +}: { + label: string; + onClick: () => void; +}): JSX.Element => ( + +); + +/** + * Default — renders the Toaster alone, no toasts visible. + */ +export const Default: Story = { + render: () => , +}; + +/** + * Success — click the button to fire a success toast. + */ +export const Success: Story = { + render: () => ( + <> + + notify.success("Successfully added script.")} + /> + + ), +}; + +/** + * Error — click the button to fire a plain error toast (no detail payload). + */ +export const Error: Story = { + render: () => ( + <> + + + notify.error("Failed to save settings. Please try again.") + } + /> + + ), +}; + +/** + * AllVariants — trigger each variant side by side for quick visual comparison. + */ +export const AllVariants: Story = { + render: () => ( + <> + +
+ notify.success("Successfully added script.")} + /> + + notify.error("Failed to save settings. Please try again.") + } + /> + notify.dismiss()} /> +
+ + ), +}; + +/** + * ExpandableError — error toast with an HTTP response. The chevron expands + * a panel showing the response body as syntax-highlighted JSON; the label + * above it auto-derives from the status code ("Status: 422 Unprocessable + * Entity"). + */ +export const ExpandableError: Story = { + render: () => ( + <> + + + notify.error("Failed to save policy.", { + response: { + status: 422, + statusText: "Unprocessable Entity", + data: { + error: "violates foreign key constraint", + code: 422, + resource: "policy", + }, + }, + }) + } + /> + + ), +}; + +/** + * Batch — one toast per failed item in a bulk operation (the + * `renderMultiFlash` replacement). Each item supports the same `response` + * option as `notify.error`, so every failure exposes its raw API response. + */ +export const Batch: Story = { + render: () => ( + <> + + + notify.batch([ + { + variant: "error", + message: "Couldn't update Firefox. Please try again.", + options: { + response: { + status: 409, + statusText: "Conflict", + data: { error: "install already pending", software_id: 1 }, + }, + }, + }, + { + variant: "error", + message: "Couldn't update Slack. Please try again.", + options: { + response: { + status: 500, + statusText: "Internal Server Error", + data: { error: "installer not found", software_id: 2 }, + }, + }, + }, + { + variant: "success", + message: "Zoom updated.", + }, + ]) + } + /> + + ), +}; + +/** + * ExpandableErrorLargePayload — verifies the JSON panel scrolls internally + * when the payload exceeds the panel's `max-height`. + */ +export const ExpandableErrorLargePayload: Story = { + render: () => { + const largeBody = { + error: "Request validation failed", + code: 422, + timestamp: "2026-04-15T12:34:56Z", + resource: "policy", + request_id: "req_9f3b2a1e-7c4d-4e5f-a1b2-c3d4e5f6a7b8", + errors: Array.from({ length: 15 }, (_, i) => ({ + field: `rules[${i}].query`, + message: + "Query contains an unsupported table reference and must be rewritten against the approved schema.", + severity: i % 2 === 0 ? "error" : "warning", + suggestion: { + replace: "osquery_info", + with: "fleet_info", + docs: + "https://fleetdm.com/docs/using-fleet/example-queries#fleet-info", + }, + })), + metadata: { + environment: "production", + region: "us-east-1", + tenant: "acme-corp", + user_id: 42, + }, + }; + + return ( + <> + + + notify.error("Failed to validate policy rules.", { + response: { + status: 422, + statusText: "Unprocessable Entity", + data: largeBody, + }, + }) + } + /> + + ); + }, +}; diff --git a/frontend/components/ToastNotification/ToastNotification.tsx b/frontend/components/ToastNotification/ToastNotification.tsx new file mode 100644 index 0000000000..f2c1d46c88 --- /dev/null +++ b/frontend/components/ToastNotification/ToastNotification.tsx @@ -0,0 +1,294 @@ +import React, { useEffect } from "react"; +import { browserHistory } from "react-router"; +import { Toaster, toast, ExternalToast } from "sonner"; + +import ToastCard, { ToastVariant } from "./ToastCard"; + +const baseClass = "toast-notification"; + +// Auto close duration (error toast is never closed automatically) +const SUCCESS_DURATION = 5000; +const ERROR_DURATION = Infinity; + +// Fallback copy for error toasts called with an empty message. Error helpers +// (getErrorReason/getErrorMessage) return "" for errors they can't parse — e.g. +// a network error — so without this the toast would render with no message, +// just the raw-response panel. Keeps every error toast meaningful. +const GENERIC_ERROR_MESSAGE = "Something went wrong. Please try again."; + +// Max number of visible toasts at the same time. +const VISIBLE_TOASTS = 10; + +export interface IToastNotificationProps { + className?: string; +} + +const ToastNotification = ({ + className, +}: IToastNotificationProps): JSX.Element => { + const classes = className + ? `${baseClass} ${baseClass}__wrapper ${className}` + : `${baseClass} ${baseClass}__wrapper`; + + // Dismiss visible toasts on route change, matching 4.86 flash behavior. + // The listener fires synchronously during router.push; because `notify` + // defers creation by a tick (see below), a toast triggered alongside a + // navigation is created afterward and lands on the destination page. + useEffect(() => { + const unlisten = browserHistory.listen(() => { + toast.dismiss(); + }); + return unlisten; + }, []); + + return ( + + ); +}; + +/** + * Minimal shape of an HTTP response as surfaced by Fleet's API wrapper + * (`frontend/services/index.ts`). Used by `notify.error(..., { response })` + * to auto-derive the expandable panel's label and body. + */ +export interface INotifyResponse { + status?: number; + statusText?: string; + data?: unknown; +} + +/** + * Options accepted by `notify.*`. This is Fleet's own curated surface — NOT + * Sonner's. We expose only the keys we explicitly support and forward; callers + * should program against this interface rather than reaching for Sonner + * options. To allow a new Sonner passthrough, add the field here AND copy it + * through in `toSonnerOptions` below. + */ +export interface INotifyOptions { + /** + * Auto-dismiss after N ms. Defaults: 5000 (success), Infinity (error). + * Forwarded to Sonner. + */ + duration?: number; + /** + * Reuse an id to replace/update an existing toast in place instead of + * stacking a new one (e.g. a "Saving…" toast that becomes "Saved"). + * Forwarded to Sonner. + */ + id?: ToastId; + /** + * Pass the API response object (or the value a rejected promise + * yielded from Fleet's `sendRequest` — typically the axios response, + * which already has the `INotifyResponse` shape). The toast + * auto-populates the expandable panel with `response.data` as the body + * and `"Status: {status} {statusText}"` as the label above it. + * + * Typed `unknown` so caught errors can be passed directly from + * `catch (e)` blocks; values without a response shape render as the raw + * payload with the default label. + * + * For non-HTTP payloads, construct a minimal response-shaped object: + * `{ response: { data: anyObject } }`. + */ + response?: unknown; + /** + * Override the label shown above the payload. Defaults to the + * auto-derived `"Status: {status} {statusText}"` when `response` is + * provided, otherwise `"Raw response"`. + */ + detailLabel?: string; +} + +export type ToastId = string | number; + +/** + * One toast in a `notify.batch` call. Mirrors the single-toast API: + * `variant` picks `notify.success`/`notify.error` behavior and `options` + * supports the same fields, including `response` for the expandable + * raw-response panel on error toasts. + */ +export interface INotifyBatchItem { + variant: ToastVariant; + message: React.ReactNode; + options?: INotifyOptions; +} + +/** + * Imperative API for triggering toasts. Intended to be called from anywhere + * in the app — handlers, effects, services — once `` + * is mounted at the root. + */ +export interface INotify { + success: (message: React.ReactNode, options?: INotifyOptions) => ToastId; + error: (message: React.ReactNode, options?: INotifyOptions) => ToastId; + /** + * Render several toasts at once — e.g. one error per failed item in a + * bulk operation. Items render in array order; the Toaster caps how many + * are visible at a time (`VISIBLE_TOASTS`), stacking the rest. + * Returns the ids in the same order as the input for selective `dismiss`. + */ + batch: (items: INotifyBatchItem[]) => ToastId[]; + dismiss: (id?: ToastId) => void; +} + +/** + * Build the Sonner options object from Fleet's curated `INotifyOptions`, + * copying through ONLY the keys we choose to forward (the rest of + * `INotifyOptions` — `response`, `detailLabel` — is consumed by us and never + * reaches Sonner). The caller's `duration` wins over the per-variant default. + * To forward a new Sonner option, add it to `INotifyOptions` and copy it here. + */ +const toSonnerOptions = ( + variantDuration: number, + options?: INotifyOptions +): ExternalToast => ({ + duration: options?.duration ?? variantDuration, + ...(options?.id !== undefined && { id: options.id }), +}); + +/** + * Friendly names for HTTP status codes most commonly returned by Fleet's + * API, used as a fallback when axios doesn't provide `statusText` (e.g. + * under HTTP/2 where browsers leave it empty). + */ +const HTTP_STATUS_MEANINGS: Record = { + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 409: "Conflict", + 422: "Unprocessable Entity", + 429: "Too Many Requests", + 500: "Internal Server Error", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", +}; + +/** + * Derive the body (`detail`) and heading (`detailLabel`) of the expandable + * panel from the `notify.error` options. `response.data` populates the + * body; `"Status: {status} {statusText}"` populates the label. An explicit + * `detailLabel` wins over the auto-derived one. + */ +const isObject = (v: unknown): v is Record => + typeof v === "object" && v !== null; + +// A value carries an HTTP response if it exposes a body or a status code. +const looksLikeResponse = (v: unknown): boolean => + isObject(v) && ("data" in v || "status" in v); + +const resolveDetailProps = ( + options?: INotifyOptions +): { detail: unknown; detailLabel: string | undefined } => { + if (!options || options.response === undefined) { + return { detail: undefined, detailLabel: options?.detailLabel }; + } + + // Caught errors are passed as `unknown`. Fleet's `sendRequest` rejects with + // two shapes: usually the axios *response* (`{ status, data }` at the top + // level), but for `skipParseError` endpoints (e.g. MDM config) it rejects + // with the bare AxiosError, whose body lives one level down at + // `.response.data`. Unwrap that case so the panel reads the real body + // instead of the AxiosError's (empty) top-level `.data`. + let resp: unknown = options.response; + if ( + isObject(resp) && + "response" in resp && + looksLikeResponse(resp.response) + ) { + resp = resp.response; + } + + if (!looksLikeResponse(resp)) { + // Non-response payload (e.g. a plain string) — show it raw. + return { detail: resp, detailLabel: options.detailLabel }; + } + + const fromResponse = resp as INotifyResponse; + + // Prefer the server-supplied status text; fall back to a client-side map + // of common status codes (statusText is often empty over HTTP/2). + let autoLabel: string | undefined; + if (fromResponse.status) { + const meaning = + fromResponse.statusText || HTTP_STATUS_MEANINGS[fromResponse.status]; + autoLabel = meaning + ? `Status: ${fromResponse.status} ${meaning}` + : `Status: ${fromResponse.status}`; + } + + return { + detail: fromResponse.data, + detailLabel: options.detailLabel ?? autoLabel, + }; +}; + +// Monotonic id source so we can return a toast id synchronously while +// deferring the actual creation (below). Session-unique is sufficient. +let toastSeq = 0; +const nextToastId = (): ToastId => { + toastSeq += 1; + return `fleet-toast-${toastSeq}`; +}; + +export const notify: INotify = { + success: (message, options) => { + const id = options?.id ?? nextToastId(); + // Defer one tick: + // Toast fired in the same handler as a router.push is then created AFTER the + // route change — and after the route-change dismiss — so it lands on the + // destination page whether the caller notifies before or after navigating. + setTimeout(() => { + toast.custom( + (sonnerId) => ( + + ), + toSonnerOptions(SUCCESS_DURATION, { ...options, id }) + ); + }); + return id; + }, + error: (message, options) => { + const id = options?.id ?? nextToastId(); + // Fall back to generic copy when the caller passes an empty/blank message + // so the toast is never just an icon + raw-response panel. + const resolvedMessage = + message === null || + message === undefined || + (typeof message === "string" && message.trim() === "") + ? GENERIC_ERROR_MESSAGE + : message; + const { detail, detailLabel } = resolveDetailProps(options); + setTimeout(() => { + toast.custom( + (sonnerId) => ( + + ), + toSonnerOptions(ERROR_DURATION, { ...options, id }) + ); + }); + return id; + }, + batch: (items) => + items.map((item) => + item.variant === "success" + ? notify.success(item.message, item.options) + : notify.error(item.message, item.options) + ), + dismiss: (id) => { + toast.dismiss(id); + }, +}; + +export default ToastNotification; diff --git a/frontend/components/ToastNotification/_styles.scss b/frontend/components/ToastNotification/_styles.scss new file mode 100644 index 0000000000..d01a5971ac --- /dev/null +++ b/frontend/components/ToastNotification/_styles.scss @@ -0,0 +1,251 @@ +// ToastNotification — Sonner headless toast. Each toast renders our own +// via toast.custom(), so all card styling below is ours. + +$toast-notification-shadow: 0px 2px 6px 0px rgba(25, 33, 71, 0.1); +$toast-notification-action-size: 36px; +$toast-notification-width: 500px; + +.toast-notification { + // Sonner sizes each toast
  • from --width on the wrapper. + --width: #{$toast-notification-width}; + + // Sonner sets an inline height at mount and never re-measures, so the card + // can't grow when our panel expands. Force auto so it grows with content. + [data-sonner-toast] { + height: auto !important; + } + + &__card { + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + width: 100%; + min-width: $toast-notification-width; + padding: $pad-smedium $pad-medium; + // Faint accent wash over white, fading out by the midpoint. + background: linear-gradient( + 145deg, + rgba(var(--card-accent-rgb), 0.05), + transparent 50% + ), + $core-fleet-white; + color: $core-fleet-black; + border: 0; + border-radius: $border-radius-large; + box-shadow: $toast-notification-shadow; + box-sizing: border-box; + font-size: $x-small; + font-family: "Inter", sans-serif; + line-height: $line-height; + + gap: 0; + &--open { + gap: $pad-smedium; + } + + // Full colour drives the border gradient; the raw RGB channels feed rgba() + // in the background (CSS can't extract channels from a hex custom property). + --card-accent: #{$ui-fleet-black-25}; + --card-accent-rgb: 197, 199, 209; // $ui-fleet-black-25 + + &--success { + --card-accent: #{$ui-success}; + --card-accent-rgb: 61, 182, 123; // $ui-success + } + + &--error { + --card-accent: #{$ui-error}; + --card-accent-rgb: 214, 108, 123; // $ui-error + } + + // Gradient border via a masked pseudo-element (border-image ignores radius). + &::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; // border thickness + background: linear-gradient( + 145deg, + rgba(var(--card-accent-rgb), 0.4), + $ui-fleet-black-10 + ), + $core-fleet-white; + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask-composite: exclude; + pointer-events: none; + } + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $pad-small; + width: 100%; + } + + &__icon-message { + display: flex; + // Top-align so the icon tracks the first line when the message wraps; the + // one-line-tall icon box below keeps a single-line message centered. + align-items: flex-start; + gap: $pad-small; + min-width: 0; // allow the message to shrink / wrap + } + + &__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 16px; + // One line tall with the glyph centered: reads as centered on a single + // line, pins to the first line when the message wraps. + height: calc(#{$x-small} * #{$line-height}); + + // Fleet's wraps the SVG in
    . + .icon { + width: 16px; + height: 16px; + display: inline-flex; + } + } + + &__message { + margin: 0; + font-size: $x-small; + font-weight: $regular; + line-height: $line-height; + color: $ui-fleet-black-75; + word-break: break-word; + } + + &__actions { + display: flex; + align-items: center; + gap: $pad-small; + flex-shrink: 0; + } + + &__action-button { + appearance: none; + background: transparent; + border: 0; + margin: 0; + padding: $pad-small; + width: $toast-notification-action-size; + height: $toast-notification-action-size; + cursor: pointer; + color: $ui-fleet-black-75; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: $border-radius; + transition: background-color 150ms ease-in-out, color 150ms ease-in-out; + + &:hover, + &:focus-visible { + background-color: $ui-fleet-black-5; + color: $core-fleet-black; + outline: none; + } + } + + &__chevron { + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 200ms ease-in-out; + + &--open { + transform: rotate(180deg); + } + } + + &__panel { + display: flex; + flex-direction: column; + gap: $pad-small; + width: 100%; + border-top: 1px solid $ui-fleet-black-5; + padding-top: $pad-medium; + } + + &__panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $pad-small; + } + + &__panel-label { + font-size: $x-small; + font-weight: $bold; + color: $core-fleet-black; + } + + &__copy-button { + appearance: none; + background: transparent; + border: 0; + padding: $pad-xsmall; + margin: 0; + cursor: pointer; + color: $ui-fleet-black-75; + display: inline-flex; + align-items: center; + gap: $pad-xsmall; + border-radius: $border-radius; + transition: background-color 150ms ease-in-out, color 150ms ease-in-out; + + &:hover, + &:focus-visible { + background-color: $ui-fleet-black-5; + color: $core-fleet-black; + outline: none; + } + } + + &__copy-confirmation { + font-size: $xx-small; + color: $ui-fleet-black-75; + } + + // Light JSON block — overrides the global dark `pre` to match the ace-fleet + // editor theme. + &__json-block { + margin: 0; + max-height: 200px; + overflow: auto; + box-sizing: border-box; + background-color: $ui-off-white; + color: $core-fleet-black; + border: 1px solid $ui-fleet-black-10; + border-radius: $border-radius; + padding: $pad-smedium; + font-size: $xx-small; + + // Token colours darkened from the ace-fleet theme for light-bg contrast. + .key { + color: $ui-fleet-black-75; + } + + .string { + color: #2e8b57; + } + + .number { + color: #b5651d; + } + + .boolean { + color: #4271ae; + } + + .null { + color: #6b4290; + } + } +} diff --git a/frontend/components/ToastNotification/index.ts b/frontend/components/ToastNotification/index.ts new file mode 100644 index 0000000000..1c5fdacda8 --- /dev/null +++ b/frontend/components/ToastNotification/index.ts @@ -0,0 +1,12 @@ +export { + default, + default as ToastNotification, + notify, +} from "./ToastNotification"; +export type { + IToastNotificationProps, + INotify, + INotifyOptions, + INotifyBatchItem, + ToastId, +} from "./ToastNotification"; diff --git a/frontend/components/top_nav/UserMenu/UserMenu.tsx b/frontend/components/top_nav/UserMenu/UserMenu.tsx index 60be9bac30..245a6d65f9 100644 --- a/frontend/components/top_nav/UserMenu/UserMenu.tsx +++ b/frontend/components/top_nav/UserMenu/UserMenu.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import Select, { components, DropdownIndicatorProps, @@ -6,7 +6,6 @@ import Select, { OptionProps, StylesConfig, } from "react-select-5"; -import { NotificationContext } from "context/notification"; import { IUser } from "interfaces/user"; import { ITeam, ITeamSummary } from "interfaces/team"; @@ -91,8 +90,6 @@ const UserMenu = ({ // Work around preventing react-select-5 from auto focusing first option unless using keyboard const [isKeyboardFocus, setIsKeyboardFocus] = useState(false); - const { renderFlash } = useContext(NotificationContext); - useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Tab") { @@ -161,18 +158,9 @@ const UserMenu = ({ if (currentTeamIsAdmin) { onUserMenuItemClick(PATHS.FLEET_DETAILS_USERS(currentTeam.id)); } else { - // Sort and pick the first team the user is admin of to display. + // Not an admin of the current team — redirect to the first team the + // user is an admin of. const targetTeam = getSortedTeamOptions(userAdminTeams)[0]; - if (currentTeam) { - const msg = ( - <> - You're not authorized to view this page for{" "} - {currentTeam.name}. Now viewing {targetTeam.label} - . - - ); - renderFlash("warning-filled", msg); - } onUserMenuItemClick(PATHS.FLEET_DETAILS_USERS(targetTeam.value)); } }; diff --git a/frontend/context/notification.tsx b/frontend/context/notification.tsx deleted file mode 100644 index 4ff9b913db..0000000000 --- a/frontend/context/notification.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import React, { - createContext, - useReducer, - ReactNode, - useCallback, - useMemo, -} from "react"; -import { INotification } from "interfaces/notification"; -import { noop } from "lodash"; - -type Props = { - children: ReactNode; -}; - -type FlashOptions = { - /** `persistOnPageChange` is used to keep the flash message showing after a - * router change if set to `true`. - * - * @default undefined - * */ - persistOnPageChange?: boolean; -}; - -type MultiFlashOptions = FlashOptions & { - notifications?: INotification[]; -}; - -type InitialStateType = { - notification: INotification | INotification[] | null; - renderFlash: ( - alertType: "success" | "error" | "warning-filled" | null, - message: JSX.Element | string | null, - options?: FlashOptions - ) => void; - renderMultiFlash: (options?: MultiFlashOptions) => void; - hideFlash: (id?: string) => void; -}; - -export type INotificationContext = InitialStateType; - -const initialState = { - notification: null, - renderFlash: noop, - renderMultiFlash: noop, - hideFlash: noop, -}; - -const actionTypes = { - RENDER_FLASH: "RENDER_FLASH", - RENDER_MULTIFLASH: "RENDER_MULTIFLASH", - HIDE_FLASH: "HIDE_FLASH", -} as const; - -type State = { - notification: INotification | INotification[] | null; -}; - -type Action = - | { - type: typeof actionTypes.RENDER_MULTIFLASH; - notifications: INotification[]; - } - | { - type: typeof actionTypes.RENDER_FLASH; - alertType: "success" | "error" | "warning-filled" | null; - message: JSX.Element | string | null; - options?: FlashOptions; - } - | { - type: typeof actionTypes.HIDE_FLASH; - id?: string; - }; - -const reducer = (state: State, action: Action) => { - switch (action.type) { - case actionTypes.RENDER_FLASH: - return { - ...state, - notification: { - alertType: action.alertType, - isVisible: true, - message: action.message, - persistOnPageChange: action.options?.persistOnPageChange ?? false, - }, - }; - case actionTypes.RENDER_MULTIFLASH: { - const multiNotifications = action.notifications; - - return { - ...state, - notification: multiNotifications, - }; - } - case actionTypes.HIDE_FLASH: - if (Array.isArray(state.notification)) { - return { - ...state, - notification: state.notification.filter( - (n: INotification) => n.id !== action.id - ), - }; - } - return initialState; - default: - return state; - } -}; - -export const NotificationContext = createContext( - initialState -); - -const NotificationProvider = ({ children }: Props) => { - const [state, dispatch] = useReducer(reducer, initialState); - const renderFlash = useCallback( - ( - alertType: "success" | "error" | "warning-filled" | null, - message: JSX.Element | string | null, - options?: { - persistOnPageChange?: boolean; - } - ) => { - // wrapping the dispatch in a timeout ensures it is evaluated on the next event loop, - // preventing bugs related to the FlashMessage's self-hiding behavior on URL changes. - // react router v3 router.push is asynchronous - setTimeout(() => { - dispatch({ - type: actionTypes.RENDER_FLASH, - alertType, - message, - options, - }); - }); - }, - [] - ); - - const renderMultiFlash = useCallback((options?: MultiFlashOptions) => { - setTimeout(() => { - if (options?.notifications) { - dispatch({ - type: actionTypes.RENDER_MULTIFLASH, - notifications: options.notifications, - }); - } - }); - }, []); - - const hideFlash = useCallback((id?: string) => { - dispatch({ type: actionTypes.HIDE_FLASH, id }); - }, []); - - const value = useMemo( - () => ({ - notification: state.notification, - renderFlash, - renderMultiFlash, - hideFlash, - }), - [state.notification, renderFlash, renderMultiFlash, hideFlash] - ); - - return ( - - {children} - - ); -}; - -export default NotificationProvider; diff --git a/frontend/interfaces/notification.ts b/frontend/interfaces/notification.ts deleted file mode 100644 index 59d1bc6de9..0000000000 --- a/frontend/interfaces/notification.ts +++ /dev/null @@ -1,18 +0,0 @@ -import PropTypes from "prop-types"; - -export default PropTypes.shape({ - alertType: PropTypes.string, - isVisible: PropTypes.bool, - message: PropTypes.string, - persistOnPageChange: PropTypes.bool, -}); - -export type IAlertType = "success" | "error" | "warning-filled"; - -export interface INotification { - alertType: IAlertType | null; - isVisible: boolean; - message: JSX.Element | string | null; - persistOnPageChange?: boolean; - id?: string; -} diff --git a/frontend/layouts/CoreLayout/CoreLayout.tsx b/frontend/layouts/CoreLayout/CoreLayout.tsx index 0dbb668cb8..f4dc3f9000 100644 --- a/frontend/layouts/CoreLayout/CoreLayout.tsx +++ b/frontend/layouts/CoreLayout/CoreLayout.tsx @@ -4,14 +4,9 @@ import { InjectedRouter } from "react-router"; import UnsupportedScreenSize from "layouts/UnsupportedScreenSize"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; -import { TableContext } from "context/table"; -import { INotification } from "interfaces/notification"; import classNames from "classnames"; import paths from "router/paths"; -import useDeepEffect from "hooks/useDeepEffect"; -import FlashMessage from "components/FlashMessage"; import SiteTopNav from "components/top_nav/SiteTopNav"; import CommandPalette from "components/CommandPalette"; import { QueryParams } from "utilities/url"; @@ -31,25 +26,6 @@ interface ICoreLayoutProps { const CoreLayout = ({ children, router, location }: ICoreLayoutProps) => { const { config, currentUser } = useContext(AppContext); - const { notification, hideFlash } = useContext(NotificationContext); - const { setResetSelectedRows } = useContext(TableContext); - - // on success of an action, the table will reset its checkboxes. - // setTimeout is to help with race conditions as table reloads - // in some instances (i.e. Manage Hosts) - useDeepEffect(() => { - if ( - notification && - (notification as INotification).alertType === "success" - ) { - setTimeout(() => { - setResetSelectedRows(true); - setTimeout(() => { - setResetSelectedRows(false); - }, 300); - }, 0); - } - }, [notification]); const onLogoutUser = async () => { const { LOGOUT } = paths; @@ -60,8 +36,6 @@ const CoreLayout = ({ children, router, location }: ICoreLayoutProps) => { router.push(path); }; - const fullWidthFlash = !currentUser; - if (!currentUser || !config) { return null; } @@ -85,16 +59,7 @@ const CoreLayout = ({ children, router, location }: ICoreLayoutProps) => { onUserMenuItemClick={onUserMenuItemClick} /> -
    - - - {children} -
    +
    {children}
    ); }; diff --git a/frontend/layouts/GatedLayout/GatedLayout.tsx b/frontend/layouts/GatedLayout/GatedLayout.tsx index 9dbf25b2c5..99a81b24e3 100644 --- a/frontend/layouts/GatedLayout/GatedLayout.tsx +++ b/frontend/layouts/GatedLayout/GatedLayout.tsx @@ -1,24 +1,11 @@ -import React, { useContext } from "react"; -import { NotificationContext } from "context/notification"; -import FlashMessage from "components/FlashMessage"; +import React from "react"; interface IGatedLayoutProps { children: React.ReactNode; } const GatedLayout = ({ children }: IGatedLayoutProps): JSX.Element => { - const { notification, hideFlash } = useContext(NotificationContext); - - return ( -
    - - {children} -
    - ); + return
    {children}
    ; }; export default GatedLayout; diff --git a/frontend/pages/AccountPage/AccountPage.tsx b/frontend/pages/AccountPage/AccountPage.tsx index f2c670ef40..b20200126f 100644 --- a/frontend/pages/AccountPage/AccountPage.tsx +++ b/frontend/pages/AccountPage/AccountPage.tsx @@ -2,7 +2,7 @@ import React, { useState, useContext } from "react"; import { InjectedRouter } from "react-router"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import { IUser } from "interfaces/user"; import usersAPI from "services/entities/users"; import authToken from "utilities/auth_token"; @@ -37,7 +37,6 @@ interface IAccountPageProps { const AccountPage = ({ router }: IAccountPageProps): JSX.Element | null => { const { config, currentUser } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); const [pendingEmail, setPendingEmail] = useState(""); const [showEmailModal, setShowEmailModal] = useState(false); @@ -100,16 +99,16 @@ const AccountPage = ({ router }: IAccountPageProps): JSX.Element | null => { setPendingEmail(updated.email); } - renderFlash("success", accountUpdatedFlashMessage); + notify.success(accountUpdatedFlashMessage); return true; } catch (response) { const errorObject = formatErrorResponse(response); setErrors(errorObject); - renderFlash( - "error", + notify.error( errorObject.base.includes("already exists") ? "A user with this email address already exists." - : "Could not edit user. Please try again." + : "Could not edit user. Please try again.", + { response } ); setShowEmailModal(false); @@ -123,10 +122,10 @@ const AccountPage = ({ router }: IAccountPageProps): JSX.Element | null => { }) => { try { await usersAPI.changePassword(formData); - renderFlash("success", "Password changed successfully"); + notify.success("Password changed successfully"); setShowPasswordModal(false); } catch (e) { - renderFlash("error", getErrorMessage(e)); + notify.error(getErrorMessage(e), { response: e }); } }; diff --git a/frontend/pages/ConfirmInvitePage/ConfirmInvitePage.tsx b/frontend/pages/ConfirmInvitePage/ConfirmInvitePage.tsx index 3a982c3c8b..730960b1a5 100644 --- a/frontend/pages/ConfirmInvitePage/ConfirmInvitePage.tsx +++ b/frontend/pages/ConfirmInvitePage/ConfirmInvitePage.tsx @@ -3,7 +3,7 @@ import { InjectedRouter } from "react-router"; import { Params } from "react-router/lib/Router"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import { ICreateUserWithInvitationFormData } from "interfaces/user"; import paths from "router/paths"; import usersAPI from "services/entities/users"; @@ -28,7 +28,6 @@ const baseClass = "confirm-invite-page"; const ConfirmInvitePage = ({ router, params }: IConfirmInvitePageProps) => { const { currentUser } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); const { invite_token } = params; @@ -58,17 +57,16 @@ const ConfirmInvitePage = ({ router, params }: IConfirmInvitePageProps) => { try { await usersAPI.create(dataForAPI); router.push(paths.LOGIN); - renderFlash( - "success", + notify.success( "Registration successful! For security purposes, please log in." ); } catch (error) { const reason = getErrorReason(error); console.error(reason); - renderFlash("error", reason); + notify.error(reason, { response: error }); } }, - [invite_token, renderFlash, router, validInvite?.email] + [invite_token, router, validInvite?.email] ); if (currentUser) { diff --git a/frontend/pages/ConfirmSSOInvitePage/ConfirmSSOInvitePage.tsx b/frontend/pages/ConfirmSSOInvitePage/ConfirmSSOInvitePage.tsx index 98aa987afc..7a937018ba 100644 --- a/frontend/pages/ConfirmSSOInvitePage/ConfirmSSOInvitePage.tsx +++ b/frontend/pages/ConfirmSSOInvitePage/ConfirmSSOInvitePage.tsx @@ -6,7 +6,7 @@ import { AxiosError } from "axios"; import paths from "router/paths"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import usersAPI from "services/entities/users"; import sessionsAPI from "services/entities/sessions"; import inviteAPI, { IValidateInviteResponse } from "services/entities/invites"; @@ -31,7 +31,6 @@ const ConfirmSSOInvitePage = ({ }: IConfirmSSOInvitePageProps) => { const { invite_token } = params; const { currentUser } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); useEffect(() => { if (currentUser) { @@ -69,10 +68,10 @@ const ConfirmSSOInvitePage = ({ const { url } = await sessionsAPI.initializeSSO(paths.DASHBOARD); window.location.href = url; } catch (error) { - renderFlash("error", getErrorReason(error)); + notify.error(getErrorReason(error), { response: error }); } }, - [invite_token, renderFlash, validInvite] + [invite_token, validInvite] ); const isInvalidInvite = diff --git a/frontend/pages/DashboardPage/DashboardPage.tsx b/frontend/pages/DashboardPage/DashboardPage.tsx index bc47c0c20a..4b8d4dfc99 100644 --- a/frontend/pages/DashboardPage/DashboardPage.tsx +++ b/frontend/pages/DashboardPage/DashboardPage.tsx @@ -10,7 +10,7 @@ import { InjectedRouter } from "react-router"; import { useQuery } from "react-query"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import paths from "router/paths"; @@ -110,7 +110,6 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { isPremiumTier, isOnGlobalTeam, } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); const { currentTeamId, @@ -596,15 +595,12 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { }, }); } - renderFlash( - "success", - "Successfully updated activity feed automations." - ); + notify.success("Successfully updated activity feed automations."); setShowActivityFeedAutomationsModal(false); - } catch { - renderFlash( - "error", - "Couldn't update activity feed automations. Please try again." + } catch (e) { + notify.error( + "Couldn't update activity feed automations. Please try again.", + { response: e } ); } finally { setUpdatingActivityFeedAutomations(false); @@ -616,7 +612,6 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { config?.webhook_settings.activities_webhook.destination_url, config?.webhook_settings.activities_webhook.enable_activities_webhook, refetchConfig, - renderFlash, ] ); diff --git a/frontend/pages/DashboardPage/cards/WelcomeHost/WelcomeHost.tsx b/frontend/pages/DashboardPage/cards/WelcomeHost/WelcomeHost.tsx index 69a82e4550..b45692fb17 100644 --- a/frontend/pages/DashboardPage/cards/WelcomeHost/WelcomeHost.tsx +++ b/frontend/pages/DashboardPage/cards/WelcomeHost/WelcomeHost.tsx @@ -1,9 +1,9 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import PATHS from "router/paths"; import { useQuery } from "react-query"; import { formatDistanceToNow } from "date-fns"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import { IHost, IHostResponse } from "interfaces/host"; import { IHostPolicy } from "interfaces/policy"; import hostAPI from "services/entities/hosts"; @@ -30,7 +30,6 @@ const WelcomeHost = ({ totalsHostsCount, toggleAddHostsModal, }: IWelcomeHostCardProps): JSX.Element => { - const { renderFlash } = useContext(NotificationContext); const [refetchStartTime, setRefetchStartTime] = useState(null); const [currentPolicyShown, setCurrentPolicyShown] = useState(); const [showPolicyModal, setShowPolicyModal] = useState(false); @@ -77,15 +76,13 @@ const WelcomeHost = ({ fullyReloadHost(); }, 1000); } else { - renderFlash( - "error", + notify.error( `This host is offline. Please try refetching host vitals later.` ); setShowRefetchLoadingSpinner(false); } } else { - renderFlash( - "error", + notify.error( `We're having trouble fetching fresh vitals for this host. Please try again later.` ); setShowRefetchLoadingSpinner(false); @@ -110,7 +107,9 @@ const WelcomeHost = ({ }); } catch (error) { console.error(error); - renderFlash("error", `Host "${host.display_name}" refetch error`); + notify.error(`Host "${host.display_name}" refetch error`, { + response: error, + }); setShowRefetchLoadingSpinner(false); } } diff --git a/frontend/pages/LoginPage/LoginPage.tsx b/frontend/pages/LoginPage/LoginPage.tsx index c3df0e3c87..b7f23e537e 100644 --- a/frontend/pages/LoginPage/LoginPage.tsx +++ b/frontend/pages/LoginPage/LoginPage.tsx @@ -5,7 +5,7 @@ import { AxiosError } from "axios"; import paths from "router/paths"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import { RoutingContext } from "context/routing"; import { ISSOSettings } from "interfaces/ssoSettings"; import { ILoginUserData } from "interfaces/user"; @@ -57,7 +57,6 @@ const LoginPage = ({ router, location }: ILoginPageProps) => { setCurrentUser, setCurrentTeam, } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); const { redirectLocation } = useContext(RoutingContext); const [errors, setErrors] = useState>({}); @@ -104,12 +103,11 @@ const LoginPage = ({ router, location }: ILoginPageProps) => { } }, [availableTeams, config, currentUser, redirectLocation, router]); - // TODO: Fix this. If renderFlash is added as a dependency it causes infinite re-renders. useEffect(() => { let status = new URLSearchParams(location.search).get("status"); status = status && statusMessages[status as keyof IStatusMessages]; if (status) { - renderFlash("error", status); + notify.error(status); } }, [location?.search]); diff --git a/frontend/pages/LogoutPage/LogoutPage.tsx b/frontend/pages/LogoutPage/LogoutPage.tsx index 112390d01a..4caae805cc 100644 --- a/frontend/pages/LogoutPage/LogoutPage.tsx +++ b/frontend/pages/LogoutPage/LogoutPage.tsx @@ -3,7 +3,7 @@ import { InjectedRouter } from "react-router"; import PATHS from "router/paths"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import sessionsAPI from "services/entities/sessions"; import authToken from "utilities/auth_token"; @@ -13,7 +13,6 @@ interface ILogoutPageProps { const LogoutPage = ({ router }: ILogoutPageProps) => { const { isSandboxMode } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); useEffect(() => { const logoutUser = async () => { @@ -28,7 +27,7 @@ const LogoutPage = ({ router }: ILogoutPageProps) => { } catch (response) { console.error(response); router.goBack(); - renderFlash("error", "Unable to log out of your account"); + notify.error("Unable to log out of your account", { response }); } }; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tsx index 698a898a8f..a361ce815c 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/AddCertificateModal/AddCertificateModal.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useMemo, useState } from "react"; +import React, { useMemo, useState } from "react"; import { useQuery } from "react-query"; import { SingleValue } from "react-select-5"; @@ -6,7 +6,7 @@ import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; import paths from "router/paths"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import certificatesAPI from "services/entities/certificates"; import { getErrorReason } from "interfaces/errors"; @@ -45,8 +45,6 @@ const AddCertModal = ({ onSuccess, currentTeamId, }: IAddCertModalProps) => { - const { renderFlash } = useContext(NotificationContext); - const [isUpdating, setIsUpdating] = useState(false); const [attemptedSubmit, setAttemptedSubmit] = useState(false); const [formData, setFormData] = useState({ @@ -136,7 +134,7 @@ const AddCertModal = ({ subjectAlternativeName: formData.subjectAlternativeName, teamId: currentTeamId, }); - renderFlash("success", "Successfully added your certificate."); + notify.success("Successfully added your certificate."); onSuccess(); onExit(); } catch (e) { @@ -153,7 +151,9 @@ const AddCertModal = ({ name: "Name is already used by another certificate.", }); } else { - renderFlash("error", "Couldn't add certificate. Please try again."); + notify.error("Couldn't add certificate. Please try again.", { + response: e, + }); } } finally { setIsUpdating(false); diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/DeleteCertificateModal/DeleteCertificateModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/DeleteCertificateModal/DeleteCertificateModal.tsx index 556509cda6..2387058d14 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/DeleteCertificateModal/DeleteCertificateModal.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/Certificates/components/DeleteCertificateModal/DeleteCertificateModal.tsx @@ -1,7 +1,7 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import certAPI, { ICertificate } from "services/entities/certificates"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import Button from "components/buttons/Button"; import Modal from "components/Modal"; @@ -19,7 +19,6 @@ const DeleteCertificateModal = ({ onSuccess, onExit, }: IDeleteCertModalProps) => { - const { renderFlash } = useContext(NotificationContext); const [isUpdating, setIsUpdating] = useState(false); const { name, id } = cert; @@ -28,13 +27,15 @@ const DeleteCertificateModal = ({ setIsUpdating(true); try { await certAPI.deleteCert(id); - renderFlash("success", "Successfully deleted certificate."); + notify.success("Successfully deleted certificate."); setIsUpdating(false); onSuccess(); onExit(); } catch (e) { setIsUpdating(false); - renderFlash("error", "Couldn't delete certificate. Please try again."); + notify.error("Couldn't delete certificate. Please try again.", { + response: e, + }); } }; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/ConfigurationProfiles.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/ConfigurationProfiles.tsx index e45efd6adc..431f28566f 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/ConfigurationProfiles.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/ConfigurationProfiles.tsx @@ -4,7 +4,7 @@ import { useQuery } from "react-query"; import PATHS from "router/paths"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import { IMdmProfile } from "interfaces/mdm"; @@ -47,7 +47,6 @@ const ConfigurationProfiles = ({ currentPage = 0, onMutation, }: IConfigurationProfilesProps) => { - const { renderFlash } = useContext(NotificationContext); const { config, isPremiumTier, @@ -130,9 +129,9 @@ const ConfigurationProfiles = ({ await mdmAPI.deleteProfile(profileId); refetchProfiles(); onMutation(); - renderFlash("success", "Successfully deleted."); + notify.success("Successfully deleted."); } catch (e) { - renderFlash("error", "Couldn't delete. Please try again."); + notify.error("Couldn't delete. Please try again.", { response: e }); } finally { selectedProfile.current = null; setShowDeleteProfileModal(false); diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx index df6e4bcdba..b0a33c613c 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ProfileUploader/components/AddProfileModal/AddProfileModal.tsx @@ -1,9 +1,9 @@ -import React, { useCallback, useContext, useRef, useState } from "react"; +import React, { useCallback, useRef, useState } from "react"; import { useQuery } from "react-query"; import { AxiosResponse } from "axios"; import PATHS from "router/paths"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import { IApiError } from "interfaces/errors"; import { ILabelSummary } from "interfaces/label"; @@ -113,8 +113,6 @@ const AddProfileModal = ({ onUpload, setShowModal, }: IAddProfileModalProps) => { - const { renderFlash } = useContext(NotificationContext); - const [isLoading, setIsLoading] = useState(false); const [fileDetails, setFileDetails] = useState(null); const [selectedTargetType, setSelectedTargetType] = useState( @@ -162,7 +160,7 @@ const AddProfileModal = ({ const onFileUpload = async () => { if (!fileRef.current) { - renderFlash("error", DEFAULT_ERROR_MESSAGE); + notify.error(DEFAULT_ERROR_MESSAGE); return; } const file = fileRef.current; @@ -180,10 +178,12 @@ const AddProfileModal = ({ teamId: currentTeamId, ...labelKey, }); - renderFlash("success", "Successfully uploaded."); + notify.success("Successfully uploaded."); onUpload(); } catch (e) { - renderFlash("error", getErrorMessage(e as AxiosResponse)); + notify.error(getErrorMessage(e as AxiosResponse), { + response: e, + }); } finally { setIsLoading(false); onDone(); @@ -204,7 +204,7 @@ const AddProfileModal = ({ const details = await parseFile(file); setFileDetails(details); } catch (e) { - renderFlash("error", "Invalid file type"); + notify.error("Invalid file type", { response: e }); } finally { setIsLoading(false); } diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ResendConfigProfileModal/ResendConfigProfileModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ResendConfigProfileModal/ResendConfigProfileModal.tsx index d1e96724a8..124b98887d 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ResendConfigProfileModal/ResendConfigProfileModal.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/ConfigurationProfiles/components/ResendConfigProfileModal/ResendConfigProfileModal.tsx @@ -1,10 +1,10 @@ -import React, { useContext } from "react"; +import React from "react"; import configProfilesAPI from "services/entities/config_profiles"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; const baseClass = "resend-config-profile-modal"; @@ -21,7 +21,6 @@ const ResendConfigProfileModal = ({ count, onExit, }: IResendConfigProfileModalProps) => { - const { renderFlash } = useContext(NotificationContext); const [isResending, setIsResending] = React.useState(false); const countText = `${count} ${count === 1 ? "host" : "hosts"}`; @@ -30,17 +29,16 @@ const ResendConfigProfileModal = ({ setIsResending(true); try { await configProfilesAPI.batchResendConfigProfile(uuid); - renderFlash( - "success", + notify.success( <> Resent the {name} configuration profile. ); onExit(); } catch (error) { - renderFlash( - "error", - "Couldn't resend the configuration profile. Please try again." + notify.error( + "Couldn't resend the configuration profile. Please try again.", + { response: error } ); } setIsResending(false); diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/DiskEncryption.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/DiskEncryption.tsx index e1d688a9a9..83c1ae2d19 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/DiskEncryption.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/DiskEncryption/DiskEncryption.tsx @@ -2,7 +2,7 @@ import React, { useContext, useState } from "react"; import { useQuery } from "react-query"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import { ITeamConfig } from "interfaces/team"; import { getErrorReason } from "interfaces/errors"; @@ -42,7 +42,6 @@ const DiskEncryption = ({ isTeamTechnician, isGlobalTechnician, } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); const isTechnician = isTeamTechnician || isGlobalTechnician; @@ -71,11 +70,10 @@ const DiskEncryption = ({ try { const updatedConfig = await configAPI.loadAll(); setConfig(updatedConfig); - } catch { - renderFlash( - "error", - "Could not retrieve updated app config. Please try again." - ); + } catch (err) { + notify.error("Could not retrieve updated app config. Please try again.", { + response: err, + }); } }; @@ -113,10 +111,7 @@ const DiskEncryption = ({ requireBitLockerPIN, currentTeamId ); - renderFlash( - "success", - "Successfully updated disk encryption enforcement." - ); + notify.success("Successfully updated disk encryption enforcement."); onMutation(); setShowAggregate(diskEncryptionEnabled); if (currentTeamId === 0) { @@ -126,8 +121,7 @@ const DiskEncryption = ({ if (getErrorReason(e).includes("Missing required private key")) { const link = "https://fleetdm.com/learn-more-about/fleet-server-private-key"; - renderFlash( - "error", + notify.error( <> Couldn't enable disk encryption. Please configure a private key.{" "} @@ -137,13 +131,14 @@ const DiskEncryption = ({ newTab variant="flash-message-link" /> - + , + { response: e } ); } else { const errorMsg = getErrorReason(e) ?? "Could not update the disk encryption enforcement. Please try again."; - renderFlash("error", errorMsg); + notify.error(errorMsg, { response: e }); } } }; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/Passwords/Passwords.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/Passwords/Passwords.tsx index 67e7e3ab91..7cd1c769bc 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/Passwords/Passwords.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/Passwords/Passwords.tsx @@ -2,7 +2,7 @@ import React, { useContext, useEffect, useState } from "react"; import { useQuery } from "react-query"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import { API_NO_TEAM_ID, ITeamConfig } from "interfaces/team"; import { getErrorReason } from "interfaces/errors"; @@ -55,7 +55,6 @@ const Passwords = ({ isTeamTechnician, isGlobalTechnician, } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); const isTechnician = isTeamTechnician || isGlobalTechnician; @@ -83,8 +82,10 @@ const Passwords = ({ res.mdm?.enable_recovery_lock_password ?? false ); }, - onError: () => { - renderFlash("error", "Couldn't load team settings. Please try again."); + onError: (err) => { + notify.error("Couldn't load team settings. Please try again.", { + response: err, + }); }, } ); @@ -119,8 +120,7 @@ const Passwords = ({ currentTeamId ); } - renderFlash( - "success", + notify.success( "Successfully updated Recovery Lock password enforcement." ); onMutation(); @@ -128,7 +128,7 @@ const Passwords = ({ const errorMsg = getErrorReason(e) ?? "Couldn't update Recovery Lock password enforcement. Please try again."; - renderFlash("error", errorMsg); + notify.error(errorMsg, { response: e }); } finally { setUpdating(false); } diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx index da7b75e33c..e8d48789d5 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/AppleOSTargetForm/AppleOSTargetForm.tsx @@ -4,7 +4,7 @@ import { AxiosResponse } from "axios"; import { IApiError } from "interfaces/errors"; import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import configAPI from "services/entities/config"; import teamsAPI from "services/entities/teams"; import { ApplePlatform } from "interfaces/platform"; @@ -126,7 +126,6 @@ const AppleOSTargetForm = ({ refetchAppConfig, refetchTeamConfig, }: IAppleOSTargetFormProps) => { - const { renderFlash } = useContext(NotificationContext); const gitOpsModeEnabled = useContext(AppContext).config?.gitops .gitops_mode_enabled; @@ -165,9 +164,11 @@ const AppleOSTargetForm = ({ currentTeamId === APP_CONTEXT_NO_TEAM_ID ? await configAPI.update(updateData) : await teamsAPI.update(updateData, currentTeamId); - renderFlash("success", "Successfully updated."); + notify.success("Successfully updated."); } catch (err) { - renderFlash("error", getErrorMessage(err as AxiosResponse)); + notify.error(getErrorMessage(err as AxiosResponse), { + response: err, + }); } finally { currentTeamId === APP_CONTEXT_NO_TEAM_ID ? refetchAppConfig() diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/WindowsTargetForm.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/WindowsTargetForm.tsx index 068454f1d2..871f23e4c7 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/WindowsTargetForm.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/WindowsTargetForm.tsx @@ -5,7 +5,7 @@ import { AxiosResponse } from "axios"; import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import configAPI from "services/entities/config"; import teamsAPI from "services/entities/teams"; @@ -110,7 +110,6 @@ const WindowsTargetForm = ({ refetchAppConfig, refetchTeamConfig, }: IWindowsTargetFormProps) => { - const { renderFlash } = useContext(NotificationContext); const gitOpsModeEnabled = useContext(AppContext).config?.gitops .gitops_mode_enabled; @@ -140,9 +139,11 @@ const WindowsTargetForm = ({ currentTeamId === APP_CONTEXT_NO_TEAM_ID ? await configAPI.update(updateData) : await teamsAPI.update(updateData, currentTeamId); - renderFlash("success", "Successfully updated Windows OS update options."); + notify.success("Successfully updated Windows OS update options."); } catch (err) { - renderFlash("error", getErrorMessage(err as AxiosResponse)); + notify.error(getErrorMessage(err as AxiosResponse), { + response: err, + }); } finally { currentTeamId === APP_CONTEXT_NO_TEAM_ID ? refetchAppConfig() diff --git a/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/ScriptBatchDetailsPage.tsx b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/ScriptBatchDetailsPage.tsx index 93fac9d810..813e3d75a7 100644 --- a/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/ScriptBatchDetailsPage.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/ScriptBatchDetailsPage/ScriptBatchDetailsPage.tsx @@ -1,10 +1,4 @@ -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useQuery } from "react-query"; import { RouteComponentProps } from "react-router"; import { AxiosError } from "axios"; @@ -14,7 +8,7 @@ import EmptyState from "components/EmptyState"; import { buildQueryStringFromParams } from "utilities/url"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import scriptsAPI, { IScriptBatchSummaryQueryKey, @@ -110,8 +104,6 @@ const ScriptBatchDetailsPage = ({ null ); - const { renderFlash } = useContext(NotificationContext); - const { data: batchDetails, isLoading, @@ -142,15 +134,17 @@ const ScriptBatchDetailsPage = ({ try { await scriptsAPI.cancelScriptBatch(batchExecutionId); - renderFlash("success", "Successfully canceled script."); + notify.success("Successfully canceled script."); setShowCancelModal(false); router.push(pathToProgress); } catch (error) { - renderFlash("error", "Could not cancel script. Please try again."); + notify.error("Could not cancel script. Please try again.", { + response: error, + }); } finally { setIsCanceling(false); } - }, [batchExecutionId, pathToProgress, renderFlash, router]); + }, [batchExecutionId, pathToProgress, router]); const handleTabChange = useCallback( (index: number) => { diff --git a/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx b/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx index 34981918f3..f64718144d 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/DeleteScriptModal/DeleteScriptModal.tsx @@ -1,7 +1,7 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import scriptAPI from "services/entities/scripts"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; @@ -26,22 +26,21 @@ const DeleteScriptModal = ({ afterDelete, isHidden = false, }: IDeleteScriptModalProps) => { - const { renderFlash } = useContext(NotificationContext); const [isDeleting, setIsDeleting] = useState(false); const onClickDelete = async (id: number) => { setIsDeleting(true); try { await scriptAPI.deleteScript(id); - renderFlash("success", "Successfully deleted."); + notify.success("Successfully deleted."); } catch (e) { const error = e as AxiosResponse; const apiErrMessage = getErrorMessage(error); - renderFlash( - "error", + notify.error( apiErrMessage.includes("Policy automation") ? apiErrMessage - : "Couldn’t delete. Please try again." + : "Couldn’t delete. Please try again.", + { response: e } ); } setIsDeleting(false); diff --git a/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx b/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx index 241256679d..11a285a8d7 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/EditScriptModal/EditScriptModal.tsx @@ -3,7 +3,7 @@ import { useQuery } from "react-query"; import classnames from "classnames"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import { AppContext } from "context/app"; import RunScriptHelpText from "pages/hosts/components/ScriptDetailsModal/RunScriptHelpText"; import scriptAPI from "services/entities/scripts"; @@ -85,7 +85,6 @@ const EditScriptModal = ({ scriptName, onExit, }: IEditScriptModal) => { - const { renderFlash } = useContext(NotificationContext); const { currentTeam, isGlobalAdmin, @@ -155,10 +154,10 @@ const EditScriptModal = ({ try { setIsSubmitting(true); await scriptAPI.updateScript(scriptId, scriptFormData, scriptName); - renderFlash("success", "Successfully saved script."); + notify.success("Successfully saved script."); onExit(); } catch (e) { - renderFlash("error", getErrorMessage(e)); + notify.error(getErrorMessage(e), { response: e }); } finally { setIsSubmitting(false); setShowConfirmChanges(false); diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx index ea37e20a56..d254f150ab 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptListItem/ScriptListItem.tsx @@ -1,8 +1,8 @@ import { format } from "date-fns"; import FileSaver from "file-saver"; -import React, { useContext } from "react"; +import React from "react"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import { IScript } from "interfaces/script"; import scriptAPI from "services/entities/scripts"; @@ -47,15 +47,15 @@ interface IScriptListItemDetailsProps { createdAt: string; } -const onDownload = async (script: IScript, renderFlash: any) => { +const onDownload = async (script: IScript) => { try { const content = await scriptAPI.downloadScript(script.id); const formatDate = format(new Date(), "yyyy-MM-dd"); const filename = `${formatDate} ${script.name}`; const file = new File([content], filename); FileSaver.saveAs(file); - } catch { - renderFlash("error", "Couldn’t Download. Please try again."); + } catch (e) { + notify.error("Couldn’t Download. Please try again.", { response: e }); } }; @@ -83,8 +83,6 @@ const ScriptListItem = ({ onEdit, isTechnician, }: IScriptListItemProps) => { - const { renderFlash } = useContext(NotificationContext); - const { graphicName, platform } = getFileRenderDetails(script.name); const onClickEdit = () => { @@ -92,7 +90,7 @@ const ScriptListItem = ({ }; const onClickDownload = () => { - onDownload(script, renderFlash); + onDownload(script); }; const onClickDelete = () => { diff --git a/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploadModal/ScriptUploadModal.tsx b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploadModal/ScriptUploadModal.tsx index f37bfb37c2..c10f0aaa64 100644 --- a/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploadModal/ScriptUploadModal.tsx +++ b/frontend/pages/ManageControlsPage/Scripts/components/ScriptUploadModal/ScriptUploadModal.tsx @@ -1,5 +1,5 @@ -import React, { useContext, useState } from "react"; -import { NotificationContext } from "context/notification"; +import React, { useState } from "react"; +import { notify } from "components/ToastNotification"; import scriptAPI from "services/entities/scripts"; import Button from "components/buttons/Button"; @@ -20,7 +20,6 @@ const ScriptUploadModal = ({ onExit, currentTeamId, }: IScriptUploadModal) => { - const { renderFlash } = useContext(NotificationContext); const [selectedFile, setSelectedFile] = useState(null); const [showLoading, setShowLoading] = useState(false); @@ -31,10 +30,10 @@ const ScriptUploadModal = ({ setShowLoading(true); try { await scriptAPI.uploadScript(selectedFile, currentTeamId); - renderFlash("success", "Successfully uploaded."); + notify.success("Successfully uploaded."); onSubmit(); } catch (e) { - renderFlash("error", getErrorMessage(e)); + notify.error(getErrorMessage(e), { response: e }); } finally { setShowLoading(false); } diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx index e28108d118..411b82bf9a 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import { useQuery } from "react-query"; import { AxiosError, AxiosResponse } from "axios"; @@ -14,7 +14,7 @@ import mdmAPI, { } from "services/entities/mdm"; import configAPI from "services/entities/config"; import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import { DEFAULT_USE_QUERY_OPTIONS, LEARN_MORE_ABOUT_BASE_LINK, @@ -46,7 +46,6 @@ const BootstrapPackage = ({ currentTeamId, router, }: ISetupExperienceCardProps) => { - const { renderFlash } = useContext(NotificationContext); const [ selectedManualAgentInstall, setSelectedManualAgentInstall, @@ -145,9 +144,9 @@ const BootstrapPackage = ({ fleet_id: currentTeamId, macos_manual_agent_install: false, }); - renderFlash("success", "Successfully deleted."); - } catch { - renderFlash("error", "Couldn't delete. Please try again."); + notify.success("Successfully deleted."); + } catch (err) { + notify.error("Couldn't delete. Please try again.", { response: err }); } finally { setShowDeleteBootstrapPackageModal(false); refretchBootstrapMetadata(); diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapAdvancedOptions/BootstrapAdvancedOptions.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapAdvancedOptions/BootstrapAdvancedOptions.tsx index 31e47ffa2a..7dec83e21e 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapAdvancedOptions/BootstrapAdvancedOptions.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapAdvancedOptions/BootstrapAdvancedOptions.tsx @@ -1,7 +1,7 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import mdmAPI from "services/entities/mdm"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import Button from "components/buttons/Button"; import RevealButton from "components/buttons/RevealButton"; @@ -24,7 +24,6 @@ const BootstrapAdvancedOptions = ({ selectManualAgentInstall, onChange, }: IBootstrapAdvancedOptionsProps) => { - const { renderFlash } = useContext(NotificationContext); const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); const [isSaving, setIsSaving] = useState(false); @@ -36,9 +35,11 @@ const BootstrapAdvancedOptions = ({ fleet_id: currentTeamId, macos_manual_agent_install: selectManualAgentInstall, }); - renderFlash("success", "Successfully updated."); - } catch { - renderFlash("error", "Something went wrong. Please try again."); + notify.success("Successfully updated."); + } catch (err) { + notify.error("Something went wrong. Please try again.", { + response: err, + }); } setIsSaving(false); }; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackageUploader/BootstrapPackageUploader.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackageUploader/BootstrapPackageUploader.tsx index 8b2c2874a4..75bab7d150 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackageUploader/BootstrapPackageUploader.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackageUploader/BootstrapPackageUploader.tsx @@ -1,8 +1,8 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import { AxiosResponse } from "axios"; import { IApiError } from "interfaces/errors"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import mdmAPI from "services/entities/mdm"; import FileUploader from "components/FileUploader"; @@ -18,7 +18,6 @@ const BootstrapPackageUploader = ({ currentTeamId, onUpload, }: IBootstrapPackageUploaderProps) => { - const { renderFlash } = useContext(NotificationContext); const [showLoading, setShowLoading] = useState(false); const onUploadFile = async (files: FileList | null) => { @@ -33,19 +32,19 @@ const BootstrapPackageUploader = ({ // quick exit if the file type is incorrect if (!file.name.includes(".pkg")) { - renderFlash("error", UPLOAD_ERROR_MESSAGES.wrongType.message); + notify.error(UPLOAD_ERROR_MESSAGES.wrongType.message); setShowLoading(false); return; } try { await mdmAPI.uploadBootstrapPackage(file, currentTeamId); - renderFlash("success", "Successfully uploaded."); + notify.success("Successfully uploaded."); onUpload(); } catch (e) { const error = e as AxiosResponse; const errMessage = getErrorMessage(error); - renderFlash("error", errMessage); + notify.error(errMessage, { response: e }); } finally { setShowLoading(false); } diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareForm/InstallSoftwareForm.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareForm/InstallSoftwareForm.tsx index 474fe6a306..5fb0041420 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareForm/InstallSoftwareForm.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareForm/InstallSoftwareForm.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useState, useMemo } from "react"; +import React, { useCallback, useState, useMemo } from "react"; import { isEqual } from "lodash"; import { InjectedRouter } from "react-router"; @@ -6,9 +6,8 @@ import PATHS from "router/paths"; import { buildQueryStringFromParams } from "utilities/url"; import { isMacOS, SetupExperiencePlatform } from "interfaces/platform"; import { ISoftwareTitle } from "interfaces/software"; -import { INotification } from "interfaces/notification"; +import { notify, INotifyBatchItem } from "components/ToastNotification"; -import { NotificationContext } from "context/notification"; import mdmAPI from "services/entities/mdm"; import Button from "components/buttons/Button"; @@ -92,7 +91,6 @@ const InstallSoftwareForm = ({ refetchSoftwareTitles, }: IInstallSoftwareFormProps) => { const noSoftwareUploaded = hasNoSoftwareUploaded(softwareTitles); - const { renderFlash, renderMultiFlash } = useContext(NotificationContext); const [requireAllSoftwareMacOS, setRequireAllSoftwareMacOS] = useState( savedRequireAllSoftwareMacOS ?? false ); @@ -156,7 +154,7 @@ const InstallSoftwareForm = ({ setIsSaving(true); - const errorNotifications: INotification[] = []; + const errorToasts: INotifyBatchItem[] = []; let hadSuccess = false; // 1. Software selection update @@ -170,13 +168,10 @@ const InstallSoftwareForm = ({ hadSuccess = true; // Still let parent refetch even if the macOS call later fails } catch (e) { - errorNotifications.push({ - id: "update-software", - alertType: "error", - isVisible: true, - // You can make this more specific if you want to inspect `e` + errorToasts.push({ + variant: "error", message: "Couldn't save software. Please try again.", - persistOnPageChange: false, + options: { response: e }, }); } } @@ -198,22 +193,20 @@ const InstallSoftwareForm = ({ hadSuccess = true; setTouchedRequireAll(false); } catch (e) { - errorNotifications.push({ - id: "update-require-all", - alertType: "error", - isVisible: true, + errorToasts.push({ + variant: "error", message: "Couldn't update 'Cancel setup if software fails'. Please try again.", - persistOnPageChange: false, + options: { response: e }, }); } } // 3. Render flashes - if (errorNotifications.length > 0) { - renderMultiFlash({ notifications: errorNotifications }); + if (errorToasts.length > 0) { + notify.batch(errorToasts); } else if (hadSuccess) { - renderFlash("success", "Successfully updated."); + notify.success("Successfully updated."); } refetchSoftwareTitles(); diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/components/DeleteSetupExperienceScriptModal/DeleteSetupExperienceScriptModal.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/components/DeleteSetupExperienceScriptModal/DeleteSetupExperienceScriptModal.tsx index 11d570c9f0..0df2535968 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/components/DeleteSetupExperienceScriptModal/DeleteSetupExperienceScriptModal.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/components/DeleteSetupExperienceScriptModal/DeleteSetupExperienceScriptModal.tsx @@ -1,7 +1,7 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import mdmAPI from "services/entities/mdm"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import Button from "components/buttons/Button"; import Modal from "components/Modal"; @@ -21,19 +21,17 @@ const DeleteSetupExperienceScriptModal = ({ onExit, onDeleted, }: IDeleteSetupExperienceScriptModalProps) => { - const { renderFlash } = useContext(NotificationContext); const [isDeleting, setIsDeleting] = useState(false); const onDelete = async () => { setIsDeleting(true); try { await mdmAPI.deleteSetupExperienceScript(currentTeamId); - renderFlash("success", "Successfully deleted setup script."); + notify.success("Successfully deleted setup script."); } catch (error) { - renderFlash( - "error", - "Couldn't delete the setup script. Please try again." - ); + notify.error("Couldn't delete the setup script. Please try again.", { + response: error, + }); console.error(error); } setIsDeleting(false); diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/components/SetupExperienceScriptCard/SetupExperienceScriptCard.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/components/SetupExperienceScriptCard/SetupExperienceScriptCard.tsx index d5a2e3234f..cab9b957de 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/components/SetupExperienceScriptCard/SetupExperienceScriptCard.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/components/SetupExperienceScriptCard/SetupExperienceScriptCard.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from "react"; +import React from "react"; import FileSaver from "file-saver"; import mdmAPI, { @@ -11,7 +11,7 @@ import Button from "components/buttons/Button"; import Card from "components/Card"; import Graphic from "components/Graphic"; import Icon from "components/Icon"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import { API_NO_TEAM_ID } from "interfaces/team"; const baseClass = "setup-experience-script-card"; @@ -25,8 +25,6 @@ const SetupExperienceScriptCard = ({ script, onDelete, }: ISetupExperienceScriptCardProps) => { - const { renderFlash } = useContext(NotificationContext); - const onDownload = async () => { try { const teamId = script.team_id ?? API_NO_TEAM_ID; @@ -39,7 +37,9 @@ const SetupExperienceScriptCard = ({ FileSaver.saveAs(file); } catch (e) { - renderFlash("error", "Couldn't download script. Please try again."); + notify.error("Couldn't download script. Please try again.", { + response: e, + }); } }; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/components/SetupExperienceScriptUploader/SetupExperienceScriptUploader.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/components/SetupExperienceScriptUploader/SetupExperienceScriptUploader.tsx index 82e97b2bc4..5dcf13742c 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/components/SetupExperienceScriptUploader/SetupExperienceScriptUploader.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/RunScript/components/SetupExperienceScriptUploader/SetupExperienceScriptUploader.tsx @@ -1,9 +1,9 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import classnames from "classnames"; import mdmAPI from "services/entities/mdm"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import FileUploader from "components/FileUploader"; import { getErrorReason } from "interfaces/errors"; @@ -22,7 +22,6 @@ const SetupExperienceScriptUploader = ({ onUpload, className, }: ISetupExperienceScriptUploaderProps) => { - const { renderFlash } = useContext(NotificationContext); const [showLoading, setShowLoading] = useState(false); const classNames = classnames(baseClass, className); @@ -39,11 +38,11 @@ const SetupExperienceScriptUploader = ({ try { await mdmAPI.uploadSetupExperienceScript(file, currentTeamId); - renderFlash("success", "Successfully uploaded."); + notify.success("Successfully uploaded."); onUpload(); } catch (e) { // TODO: what errors? - renderFlash("error", getErrorReason(e)); + notify.error(getErrorReason(e), { response: e }); } setShowLoading(false); diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/AdvancedOptionsForm.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/AdvancedOptionsForm.tsx index 0303a69737..7242d9bc4c 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/AdvancedOptionsForm.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/AdvancedOptionsForm.tsx @@ -1,11 +1,11 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import mdmAPI from "services/entities/mdm"; import TooltipWrapper from "components/TooltipWrapper"; import Checkbox from "components/forms/fields/Checkbox"; import Button from "components/buttons/Button"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import RevealButton from "components/buttons/RevealButton"; const baseClass = "advanced-options-form"; @@ -21,16 +21,17 @@ const AdvancedOptionsForm = ({ }: IAdvancedOptionsFormProps) => { const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); const [releaseDevice, setReleaseDevice] = useState(defaultReleaseDevice); - const { renderFlash } = useContext(NotificationContext); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { await mdmAPI.updateReleaseDeviceSetting(currentTeamId, releaseDevice); - renderFlash("success", "Successfully updated."); - } catch { - renderFlash("error", "Something went wrong. Please try again."); + notify.success("Successfully updated."); + } catch (err) { + notify.error("Something went wrong. Please try again.", { + response: err, + }); } }; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/DeleteAutoEnrollmentProfile.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/DeleteAutoEnrollmentProfile.tsx index 0a929d3207..1eff019fcb 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/DeleteAutoEnrollmentProfile.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/DeleteAutoEnrollmentProfile.tsx @@ -1,10 +1,10 @@ -import React, { useContext } from "react"; +import React from "react"; import mdmAPI from "services/entities/mdm"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; interface DeleteAutoEnrollProfileProps { currentTeamId: number; @@ -19,14 +19,12 @@ const DeleteAutoEnrollProfile = ({ onCancel, onDelete, }: DeleteAutoEnrollProfileProps) => { - const { renderFlash } = useContext(NotificationContext); - const handleDelete = async () => { try { await mdmAPI.deleteSetupEnrollmentProfile(currentTeamId); - renderFlash("success", "Successfully deleted."); - } catch { - renderFlash("error", "Couldn’t delete. Please try again."); + notify.success("Successfully deleted."); + } catch (err) { + notify.error("Couldn’t delete. Please try again.", { response: err }); } onDelete(); }; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx index 8b99785b34..7126fd3a05 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx @@ -1,8 +1,8 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import { AxiosResponse } from "axios"; import { IApiError } from "interfaces/errors"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import mdmAPI from "services/entities/mdm"; import CustomLink from "components/CustomLink"; @@ -21,7 +21,6 @@ const SetupAssistantProfileUploader = ({ currentTeamId, onUpload, }: ISetupAssistantProfileUploaderProps) => { - const { renderFlash } = useContext(NotificationContext); const [showLoading, setShowLoading] = useState(false); const onUploadFile = async (files: FileList | null) => { @@ -36,7 +35,7 @@ const SetupAssistantProfileUploader = ({ try { await mdmAPI.uploadSetupEnrollmentProfile(file, currentTeamId); - renderFlash("success", "Successfully uploaded."); + notify.success("Successfully uploaded."); onUpload(); } catch (e) { const error = e as AxiosResponse; @@ -56,7 +55,7 @@ const SetupAssistantProfileUploader = ({ ); } - renderFlash("error", errComponent); + notify.error(errComponent, { response: e }); } finally { setShowLoading(false); } diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/Users/components/UsersForm/UsersForm.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/Users/components/UsersForm/UsersForm.tsx index 4ecab1a6be..39b6d9f4ad 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/Users/components/UsersForm/UsersForm.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/Users/components/UsersForm/UsersForm.tsx @@ -1,7 +1,7 @@ import React, { useContext, useEffect, useState } from "react"; import mdmAPI from "services/entities/mdm"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import { AppContext } from "context/app"; import Button from "components/buttons/Button"; @@ -41,7 +41,6 @@ const UsersForm = ({ defaultLocalAccountType = EndUserLocalAccountType.ADMIN, isIdPConfigured, }: IUsersFormProps) => { - const { renderFlash } = useContext(NotificationContext); const { config, isMacMdmEnabledAndConfigured } = useContext(AppContext); const gitOpsModeEnabled = !!config?.gitops.gitops_mode_enabled; @@ -115,9 +114,11 @@ const UsersForm = ({ end_user_local_account_type: formData.localAccountType, }), }); - renderFlash("success", "Successfully updated."); - } catch { - renderFlash("error", "Couldn't update settings. Please try again."); + notify.success("Successfully updated."); + } catch (err) { + notify.error("Couldn't update settings. Please try again.", { + response: err, + }); } setIsUpdating(false); diff --git a/frontend/pages/ManageControlsPage/Variables/components/AddCustomVariableModal/AddCustomVariableModal.tsx b/frontend/pages/ManageControlsPage/Variables/components/AddCustomVariableModal/AddCustomVariableModal.tsx index 1060aeadd2..c3b19b8b0c 100644 --- a/frontend/pages/ManageControlsPage/Variables/components/AddCustomVariableModal/AddCustomVariableModal.tsx +++ b/frontend/pages/ManageControlsPage/Variables/components/AddCustomVariableModal/AddCustomVariableModal.tsx @@ -1,11 +1,11 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; import { IVariableFormData } from "interfaces/variables"; import { hasStatusKey, getErrorReason } from "interfaces/errors"; import variablesAPI from "services/entities/variables"; import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import CustomLink from "components/CustomLink"; import InputField from "components/forms/fields/InputField"; import { validateFormData, IAddCustomVariableFormValidation } from "./helpers"; @@ -30,8 +30,6 @@ const AddCustomVariableModal = ({ const [variableValue, setVariableValue] = useState(""); const [isSaving, setIsSaving] = useState(false); - const { renderFlash } = useContext(NotificationContext); - const [ formValidation, setFormValidation, @@ -67,16 +65,17 @@ const AddCustomVariableModal = ({ }; try { await variablesAPI.addVariable(newVariable); - renderFlash("success", "Variable created."); + notify.success("Variable created."); onSave(); } catch (error) { if (hasStatusKey(error) && error.status === 409) { - renderFlash("error", "A variable with this name already exists."); + notify.error("A variable with this name already exists.", { + response: error, + }); } else if ( getErrorReason(error).includes("Missing required private key") ) { - renderFlash( - "error", + notify.error( <> Couldn't save. Please configure a private key.{" "} - + , + { response: error } ); } else { - renderFlash( - "error", - "An error occurred while saving the variable. Please try again." + notify.error( + "An error occurred while saving the variable. Please try again.", + { response: error } ); } } finally { diff --git a/frontend/pages/ManageControlsPage/Variables/components/DeleteCustomVariableModal/DeleteCustomVariableModal.tsx b/frontend/pages/ManageControlsPage/Variables/components/DeleteCustomVariableModal/DeleteCustomVariableModal.tsx index 254d7ebf78..1c405c34a5 100644 --- a/frontend/pages/ManageControlsPage/Variables/components/DeleteCustomVariableModal/DeleteCustomVariableModal.tsx +++ b/frontend/pages/ManageControlsPage/Variables/components/DeleteCustomVariableModal/DeleteCustomVariableModal.tsx @@ -1,9 +1,9 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; import TooltipTruncatedText from "components/TooltipTruncatedText"; import { IVariable } from "interfaces/variables"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import formatErrorResponse from "utilities/format_error_response"; import variablesAPI from "services/entities/variables"; @@ -23,8 +23,6 @@ const DeleteCustomVariableModal = ({ }: DeleteCustomVariableModalProps) => { const [isDeleting, setIsDeleting] = useState(false); - const { renderFlash } = useContext(NotificationContext); - const onClickDelete = async () => { if (!variable) { return; @@ -32,7 +30,7 @@ const DeleteCustomVariableModal = ({ setIsDeleting(true); try { await variablesAPI.deleteVariable(variable.id); - renderFlash("success", "Variable successfully deleted."); + notify.success("Variable successfully deleted."); onDeleteVariable(); } catch (error) { const errorObject = formatErrorResponse(error); @@ -43,7 +41,7 @@ const DeleteCustomVariableModal = ({ isInUseError && typeof errorObject?.base === "string" ? errorObject.base : "An error occurred while deleting the custom variable. Please try again."; - renderFlash("error", message); + notify.error(message, { response: error }); onExit(); } finally { setIsDeleting(false); diff --git a/frontend/pages/RegistrationPage/RegistrationPage.tsx b/frontend/pages/RegistrationPage/RegistrationPage.tsx index d0e6de9f99..01be79030f 100644 --- a/frontend/pages/RegistrationPage/RegistrationPage.tsx +++ b/frontend/pages/RegistrationPage/RegistrationPage.tsx @@ -8,8 +8,7 @@ import usersAPI from "services/entities/users"; import logoAPI from "services/entities/logo"; import authToken from "utilities/auth_token"; -import FlashMessage from "components/FlashMessage"; -import { INotification } from "interfaces/notification"; +import { notify } from "components/ToastNotification"; import type { IRegistrationFormData } from "interfaces/registration_form_data"; import AuthenticationFormWrapper from "components/AuthenticationFormWrapper"; @@ -18,12 +17,8 @@ import RegistrationForm from "components/forms/RegistrationForm"; // @ts-ignore import Breadcrumbs from "./Breadcrumbs"; -const ERROR_NOTIFICATION: INotification = { - alertType: "error", - isVisible: true, - message: - "We were unable to configure Fleet. If your Fleet server is behind a proxy, please ensure the server can be reached.", -}; +const SETUP_ERROR_MESSAGE = + "We were unable to configure Fleet. If your Fleet server is behind a proxy, please ensure the server can be reached."; interface IRegistrationPageProps { router: InjectedRouter; @@ -40,7 +35,6 @@ const RegistrationPage = ({ router }: IRegistrationPageProps) => { } = useContext(AppContext); const [page, setPage] = useState(1); const [pageProgress, setPageProgress] = useState(1); - const [showSetupError, setShowSetupError] = useState(false); const [isLoading, setIsLoading] = useState(false); useEffect(() => { @@ -83,7 +77,7 @@ const RegistrationPage = ({ router }: IRegistrationPageProps) => { setIsLoading(false); setPage(1); setPageProgress(1); - setShowSetupError(true); + notify.error(SETUP_ERROR_MESSAGE, { response: error }); } }; @@ -121,14 +115,6 @@ const RegistrationPage = ({ router }: IRegistrationPageProps) => { onSubmit={onRegistrationFormSubmit} isLoading={isLoading} /> - {showSetupError && ( - setShowSetupError(false)} - /> - )} ); }; diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreAndroid/SoftwareAppStoreAndroid.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreAndroid/SoftwareAppStoreAndroid.tsx index 9506d90cb2..e63e2414c4 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreAndroid/SoftwareAppStoreAndroid.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreAndroid/SoftwareAppStoreAndroid.tsx @@ -2,10 +2,10 @@ import React, { useContext, useState } from "react"; import { InjectedRouter } from "react-router"; import PATHS from "router/paths"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; import softwareAPI from "services/entities/software"; +import { notify } from "components/ToastNotification"; import PremiumFeatureMessage from "components/PremiumFeatureMessage"; import EmptyState from "components/EmptyState"; import Button from "components/buttons/Button"; @@ -51,7 +51,6 @@ const SoftwareAppStoreAndroid = ({ currentTeamId, router, }: ISoftwareAppStoreProps) => { - const { renderFlash } = useContext(NotificationContext); const { isPremiumTier, isAndroidMdmEnabledAndConfigured, @@ -85,13 +84,11 @@ const SoftwareAppStoreAndroid = ({ name: softwareTitleName, } = await softwareAPI.addAppStoreApp(currentTeamId, formData); - renderFlash( - "success", + notify.success( <> {softwareTitleName || "Android app"} successfully added. - , - { persistOnPageChange: true } + ); router.push( @@ -101,7 +98,7 @@ const SoftwareAppStoreAndroid = ({ ) ); } catch (e) { - renderFlash("error", getErrorMessage(e)); + notify.error(getErrorMessage(e), { response: e }); } setIsLoading(false); diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreVpp/SoftwareAppStoreVpp.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreVpp/SoftwareAppStoreVpp.tsx index e546682f7a..f235775147 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreVpp/SoftwareAppStoreVpp.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareAppStore/SoftwareAppStoreVpp/SoftwareAppStoreVpp.tsx @@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from "react-query"; import { AxiosError } from "axios"; import PATHS from "router/paths"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; import { ILabelSummary } from "interfaces/label"; import mdmAppleAPI, { @@ -17,6 +16,7 @@ import { LEARN_MORE_ABOUT_BASE_LINK, } from "utilities/constants"; +import { notify } from "components/ToastNotification"; import EmptyState from "components/EmptyState"; import CustomLink from "components/CustomLink"; import DataError from "components/DataError"; @@ -109,7 +109,6 @@ const SoftwareAppStoreVpp = ({ currentTeamId, router, }: ISoftwareAppStoreProps) => { - const { renderFlash } = useContext(NotificationContext); const { isPremiumTier, isGlobalAdmin } = useContext(AppContext); const queryClient = useQueryClient(); @@ -195,12 +194,10 @@ const SoftwareAppStoreVpp = ({ software_title_id: softwareVppTitleId, } = await softwareAPI.addAppStoreApp(currentTeamId, formData); - renderFlash( - "success", + notify.success( <> {formData.selectedApp.name} successfully added. - , - { persistOnPageChange: true } + ); queryClient.invalidateQueries({ @@ -220,7 +217,7 @@ const SoftwareAppStoreVpp = ({ ) ); } catch (e) { - renderFlash("error", getErrorMessage(e)); + notify.error(getErrorMessage(e), { response: e }); } setIsLoading(false); diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx index c24986c56f..6ad47ddb1a 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx @@ -9,11 +9,11 @@ import { getPathWithQueryParams, QueryParams } from "utilities/url"; import softwareAPI from "services/entities/software"; import labelsAPI, { getCustomLabels } from "services/entities/labels"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; import useGitOpsMode from "hooks/useGitOpsMode"; import { ILabelSummary } from "interfaces/label"; +import { notify } from "components/ToastNotification"; import FileProgressModal from "components/FileProgressModal"; import PremiumFeatureMessage from "components/PremiumFeatureMessage"; import Spinner from "components/Spinner"; @@ -41,7 +41,6 @@ const SoftwareCustomPackage = ({ isSidePanelOpen, setSidePanelOpen, }: ISoftwarePackageProps) => { - const { renderFlash } = useContext(NotificationContext); const { isPremiumTier } = useContext(AppContext); const queryClient = useQueryClient(); const { gitOpsModeEnabled } = useGitOpsMode("software"); @@ -109,10 +108,7 @@ const SoftwareCustomPackage = ({ const onSubmit = async (formData: IPackageFormData) => { if (!formData.software) { - renderFlash( - "error", - `Couldn't add. Please refresh the page and try again.` - ); + notify.error(`Couldn't add. Please refresh the page and try again.`); return; } @@ -135,8 +131,7 @@ const SoftwareCustomPackage = ({ }); if (!gitOpsModeEnabled) { - renderFlash( - "success", + notify.success( <> {formData.software?.name} successfully added. {formData.selfService @@ -164,7 +159,7 @@ const SoftwareCustomPackage = ({ ) ); } catch (e) { - renderFlash("error", getErrorMessage(e)); + notify.error(getErrorMessage(e), { response: e }); } setUploadDetails(null); }; diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx index 27d586f358..fb65787c22 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetMaintainedAppDetailsPage.tsx @@ -10,9 +10,9 @@ import { getPathWithQueryParams } from "utilities/url"; import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; import softwareAPI from "services/entities/software"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import { Platform, PLATFORM_DISPLAY_NAMES } from "interfaces/platform"; +import { notify } from "components/ToastNotification"; import SidePanelPage from "components/SidePanelPage"; import BackButton from "components/BackButton"; import MainContent from "components/MainContent"; @@ -131,7 +131,6 @@ const FleetMaintainedAppDetailsPage = ({ router.push(PATHS.SOFTWARE_ADD_FLEET_MAINTAINED); } - const { renderFlash } = useContext(NotificationContext); const queryClient = useQueryClient(); const handlePageError = useErrorHandler(); @@ -205,8 +204,7 @@ const FleetMaintainedAppDetailsPage = ({ ) ); - renderFlash( - "success", + notify.success( <> {fleetApp?.name} successfully added. @@ -214,7 +212,7 @@ const FleetMaintainedAppDetailsPage = ({ } catch (error) { const ae = (typeof error === "object" ? error : {}) as AxiosResponse; - renderFlash("error", getErrorMessage(ae)); + notify.error(getErrorMessage(ae), { response: error }); } setShowAddFleetAppSoftwareModal(false); diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/DeleteCategoryModal.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/DeleteCategoryModal.tsx index a05a7e7e6d..b835e2904d 100644 --- a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/DeleteCategoryModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/DeleteCategoryModal/DeleteCategoryModal.tsx @@ -1,9 +1,9 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import selfServiceCategoriesAPI from "services/entities/self_service_categories"; -import { NotificationContext } from "context/notification"; import { ISelfServiceCategory } from "interfaces/self_service_category"; +import { notify } from "components/ToastNotification"; import Button from "components/buttons/Button"; import Modal from "components/Modal"; @@ -20,7 +20,6 @@ const DeleteCategoryModal = ({ onExit, onSuccess, }: IDeleteCategoryModalProps) => { - const { renderFlash } = useContext(NotificationContext); const [isDeleting, setIsDeleting] = useState(false); const onDelete = async () => { @@ -30,7 +29,7 @@ const DeleteCategoryModal = ({ await selfServiceCategoriesAPI.deleteCategory(category.id); onSuccess(); } catch (e) { - renderFlash("error", "Couldn't delete self-service category."); + notify.error("Couldn't delete self-service category.", { response: e }); setIsDeleting(false); } }; diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tests.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tests.tsx index d912da3546..c1025c6aff 100644 --- a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tests.tsx +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tests.tsx @@ -18,8 +18,19 @@ import { listSelfServiceCategoriesHandler, } from "test/handlers/self-service-categories-handlers"; +import { notify } from "components/ToastNotification"; + import SelfServiceCategoriesPage from "./SelfServiceCategoriesPage"; +jest.mock("components/ToastNotification", () => ({ + notify: { + success: jest.fn(), + error: jest.fn(), + batch: jest.fn(), + dismiss: jest.fn(), + }, +})); + const baseProps = { router: createMockRouter(), location: { @@ -30,8 +41,6 @@ const baseProps = { }, }; -const renderFlash = jest.fn(); - const mockTeam = createMockTeamSummary({ id: 1, name: "Workstations" }); const premiumAdminContext = { @@ -42,7 +51,6 @@ const premiumAdminContext = { availableTeams: [mockTeam], setCurrentTeam: jest.fn(), }, - notification: { renderFlash, hideFlash: jest.fn() }, }; // Returns the currently open modal element scoped for `within(...)` queries. @@ -61,7 +69,7 @@ const getOpenModal = async () => { describe("SelfServiceCategoriesPage", () => { beforeEach(() => { - renderFlash.mockClear(); + jest.clearAllMocks(); }); it("renders the premium gate on Fleet Free", () => { @@ -73,7 +81,6 @@ describe("SelfServiceCategoriesPage", () => { isGlobalAdmin: true, currentUser: createMockUser({ global_role: "admin" }), }, - notification: { renderFlash, hideFlash: jest.fn() }, }, }); @@ -129,7 +136,6 @@ describe("SelfServiceCategoriesPage", () => { availableTeams: [mockTeam], setCurrentTeam: jest.fn(), }, - notification: { renderFlash, hideFlash: jest.fn() }, }, }); @@ -175,7 +181,6 @@ describe("SelfServiceCategoriesPage", () => { availableTeams: [mockTeam], setCurrentTeam: jest.fn(), }, - notification: { renderFlash, hideFlash: jest.fn() }, }, }); @@ -214,8 +219,7 @@ describe("SelfServiceCategoriesPage", () => { await user.click(within(modal).getByRole("button", { name: /^Add$/ })); await waitFor(() => { - expect(renderFlash).toHaveBeenCalledWith( - "success", + expect(notify.success).toHaveBeenCalledWith( "Successfully added self-service category." ); }); @@ -246,7 +250,8 @@ describe("SelfServiceCategoriesPage", () => { "A self-service category with this name already exists in this fleet." ) ).toBeInTheDocument(); - expect(renderFlash).not.toHaveBeenCalled(); + expect(notify.success).not.toHaveBeenCalled(); + expect(notify.error).not.toHaveBeenCalled(); }); it("shows inline generic error when add fails", async () => { @@ -272,7 +277,8 @@ describe("SelfServiceCategoriesPage", () => { expect( await within(modal).findByText("Couldn't add self-service category.") ).toBeInTheDocument(); - expect(renderFlash).not.toHaveBeenCalled(); + expect(notify.success).not.toHaveBeenCalled(); + expect(notify.error).not.toHaveBeenCalled(); }); it("shows inline 409 error on duplicate name when editing", async () => { @@ -301,7 +307,8 @@ describe("SelfServiceCategoriesPage", () => { "A self-service category with this name already exists in this fleet." ) ).toBeInTheDocument(); - expect(renderFlash).not.toHaveBeenCalled(); + expect(notify.success).not.toHaveBeenCalled(); + expect(notify.error).not.toHaveBeenCalled(); }); it("shows inline generic error when edit fails", async () => { @@ -328,7 +335,8 @@ describe("SelfServiceCategoriesPage", () => { expect( await within(modal).findByText("Couldn't update self-service category.") ).toBeInTheDocument(); - expect(renderFlash).not.toHaveBeenCalled(); + expect(notify.success).not.toHaveBeenCalled(); + expect(notify.error).not.toHaveBeenCalled(); }); it("flashes an error and re-enables the Delete button when delete fails", async () => { @@ -353,9 +361,11 @@ describe("SelfServiceCategoriesPage", () => { await user.click(deleteBtn); await waitFor(() => { - expect(renderFlash).toHaveBeenCalledWith( - "error", - "Couldn't delete self-service category." + expect(notify.error).toHaveBeenCalledWith( + "Couldn't delete self-service category.", + { + response: expect.anything(), + } ); }); expect(deleteBtn).not.toBeDisabled(); @@ -471,8 +481,7 @@ describe("SelfServiceCategoriesPage", () => { await user.click(within(modal).getByRole("button", { name: /Save/ })); await waitFor(() => { - expect(renderFlash).toHaveBeenCalledWith( - "success", + expect(notify.success).toHaveBeenCalledWith( "Successfully updated self-service category." ); }); @@ -505,8 +514,7 @@ describe("SelfServiceCategoriesPage", () => { await user.click(within(modal).getByRole("button", { name: /^Delete$/ })); await waitFor(() => { - expect(renderFlash).toHaveBeenCalledWith( - "success", + expect(notify.success).toHaveBeenCalledWith( "Successfully deleted self-service category." ); }); @@ -525,7 +533,7 @@ describe("SelfServiceCategoriesPage ?add_category=1 deep-link", () => { }); beforeEach(() => { - renderFlash.mockClear(); + jest.clearAllMocks(); }); it("opens the Add category modal for managers and strips the param", async () => { @@ -600,7 +608,6 @@ describe("SelfServiceCategoriesPage ?add_category=1 deep-link", () => { availableTeams: [mockTeam], setCurrentTeam: jest.fn(), }, - notification: { renderFlash, hideFlash: jest.fn() }, }, }); diff --git a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tsx b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tsx index fef00e90c3..5988624ad7 100644 --- a/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareLibrary/SelfServiceCategoriesPage/SelfServiceCategoriesPage.tsx @@ -4,7 +4,6 @@ import { InjectedRouter } from "react-router"; import PATHS from "router/paths"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import useTeamIdParam from "hooks/useTeamIdParam"; import { getPathWithQueryParams } from "utilities/url"; import selfServiceCategoriesAPI, { @@ -12,6 +11,7 @@ import selfServiceCategoriesAPI, { } from "services/entities/self_service_categories"; import { ISelfServiceCategory } from "interfaces/self_service_category"; +import { notify } from "components/ToastNotification"; import BackButton from "components/BackButton"; import Button from "components/buttons/Button"; import CustomLink from "components/CustomLink"; @@ -53,7 +53,6 @@ const SelfServiceCategoriesPage = ({ isGlobalMaintainer, } = useContext(AppContext); const isPrimoMode = config?.partnerships?.enable_primo || false; - const { renderFlash } = useContext(NotificationContext); const queryClient = useQueryClient(); const { @@ -124,19 +123,19 @@ const SelfServiceCategoriesPage = ({ const onAddSuccess = () => { invalidateList(); setShowAddModal(false); - renderFlash("success", "Successfully added self-service category."); + notify.success("Successfully added self-service category."); }; const onEditSuccess = () => { invalidateList(); setCategoryToEdit(null); - renderFlash("success", "Successfully updated self-service category."); + notify.success("Successfully updated self-service category."); }; const onDeleteSuccess = () => { invalidateList(); setCategoryToDelete(null); - renderFlash("success", "Successfully deleted self-service category."); + notify.success("Successfully deleted self-service category."); }; const renderHeader = () => ( diff --git a/frontend/pages/SoftwarePage/SoftwarePage.tsx b/frontend/pages/SoftwarePage/SoftwarePage.tsx index 1cffd8198f..5c96e0b649 100644 --- a/frontend/pages/SoftwarePage/SoftwarePage.tsx +++ b/frontend/pages/SoftwarePage/SoftwarePage.tsx @@ -13,7 +13,6 @@ import configAPI from "services/entities/config"; import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; import { ISoftwareApiParams } from "services/entities/software"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import useTeamIdParam from "hooks/useTeamIdParam"; import { convertParamsToSnakeCase, @@ -21,6 +20,7 @@ import { } from "utilities/url"; import { getNextLocationPath } from "utilities/helpers"; +import { notify } from "components/ToastNotification"; import Button from "components/buttons/Button"; import AutomationsButton from "components/buttons/AutomationsButton"; import MainContent from "components/MainContent"; @@ -148,8 +148,6 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { const isPrimoMode = globalConfigFromContext?.partnerships?.enable_primo || false; - const { renderFlash } = useContext(NotificationContext); - const queryParams = location.query; // initial values for query params used on this page @@ -282,15 +280,11 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { try { const request = configAPI.update(configSoftwareAutomations); await request.then(() => { - renderFlash( - "success", - "Successfully updated vulnerability automations." - ); + notify.success("Successfully updated vulnerability automations."); refetchSoftwareConfig(); }); } catch { - renderFlash( - "error", + notify.error( "Could not update vulnerability automations. Please try again." ); } finally { diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/AddPatchPolicyModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/AddPatchPolicyModal.tsx index 7e48e7ee69..0ecc0fa251 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/AddPatchPolicyModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/AddPatchPolicyModal.tsx @@ -1,10 +1,10 @@ -import React, { useCallback, useContext, useState } from "react"; +import React, { useCallback, useState } from "react"; import teamPoliciesAPI from "services/entities/team_policies"; -import { NotificationContext } from "context/notification"; import { getErrorReason } from "interfaces/errors"; +import { notify } from "components/ToastNotification"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; @@ -26,7 +26,6 @@ const AddPatchPolicyModal = ({ onExit, onSuccess, }: IAddPatchPolicyModal) => { - const { renderFlash } = useContext(NotificationContext); const [isAddingPatchPolicy, setIsAddingPatchPolicy] = useState(false); const onAddPatchPolicy = useCallback(async () => { @@ -37,18 +36,21 @@ const AddPatchPolicyModal = ({ patch_software_title_id: softwareId, team_id: teamId, }); - renderFlash("success", "Successfully added patch policy."); + notify.success("Successfully added patch policy."); onSuccess(); } catch (error) { const reason = getErrorReason(error); if (reason.includes("already has a policy")) { - renderFlash("error", EXISTING_PATCH_POLICY_ERROR_MSG); + notify.error(EXISTING_PATCH_POLICY_ERROR_MSG, { response: error }); + } else { + notify.error("Couldn't add patch policy. Please try again.", { + response: error, + }); } - renderFlash("error", "Couldn't add patch policy. Please try again."); } setIsAddingPatchPolicy(false); onExit(); - }, [softwareId, teamId, renderFlash, onSuccess, onExit]); + }, [softwareId, teamId, onSuccess, onExit]); return ( { - const { renderFlash } = useContext(NotificationContext); const [isDeleting, setIsDeleting] = useState(false); const onDeleteSoftware = useCallback(async () => { setIsDeleting(true); try { await softwareAPI.deleteSoftwareInstaller(softwareId, teamId); - renderFlash("success", "Successfully deleted software."); + notify.success("Successfully deleted software."); onSuccess(); } catch (error) { const reason = getErrorReason(error); if (reason.includes("This software has a patch policy")) { - renderFlash("error", DELETE_SW_USED_BY_PATCH_POLICY_ERROR_MSG); + notify.error(DELETE_SW_USED_BY_PATCH_POLICY_ERROR_MSG, { + response: error, + }); } else if (reason.includes("Policy automation uses this software")) { - renderFlash("error", DELETE_SW_USED_BY_POLICY_ERROR_MSG); + notify.error(DELETE_SW_USED_BY_POLICY_ERROR_MSG, { response: error }); } else if (reason.includes("This software is installed during")) { - renderFlash("error", DELETE_SW_INSTALLED_DURING_SETUP_ERROR_MSG); + notify.error(DELETE_SW_INSTALLED_DURING_SETUP_ERROR_MSG, { + response: error, + }); } else { - renderFlash("error", "Couldn't delete. Please try again."); + notify.error("Couldn't delete. Please try again.", { + response: error, + }); } } setIsDeleting(false); onExit(); - }, [softwareId, teamId, renderFlash, onSuccess, onExit]); + }, [softwareId, teamId, onSuccess, onExit]); return ( { - const { renderFlash } = useContext(NotificationContext); const { gitOpsModeEnabled } = useGitOpsMode("software"); const formClassNames = classnames(formClass, { @@ -117,8 +116,7 @@ const EditAutoUpdateConfigModal = ({ try { await softwareAPI.editAppStoreApp(softwareTitle.id, teamId, formData); - renderFlash( - "success", + notify.success( <> {getDisplayedSoftwareName( @@ -133,9 +131,9 @@ const EditAutoUpdateConfigModal = ({ refetchSoftwareTitle(); onExit(); } catch (e) { - renderFlash( - "error", - "An error occurred while updating the configuration. Please try again." + notify.error( + "An error occurred while updating the configuration. Please try again.", + { response: e } ); } setIsUpdatingConfiguration(false); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/EditConfigurationModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/EditConfigurationModal.tsx index 2815e3b7fe..c9ff5f6022 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/EditConfigurationModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditConfigurationModal/EditConfigurationModal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useState } from "react"; +import React, { useCallback, useState } from "react"; import { Ace } from "ace-builds"; import { IAppStoreApp, @@ -6,10 +6,9 @@ import { isSoftwarePackage, } from "interfaces/software"; -import { NotificationContext } from "context/notification"; - import softwareAPI from "services/entities/software"; +import { notify } from "components/ToastNotification"; import Modal from "components/Modal"; import ModalFooter from "components/ModalFooter"; import Editor from "components/Editor"; @@ -50,8 +49,6 @@ const EditConfigurationModal = ({ refetchSoftwareTitle, onExit, }: IEditConfigurationModalProps) => { - const { renderFlash } = useContext(NotificationContext); - const isInHouseApp = isSoftwarePackage(softwareInstaller); const XML_EMPTY = "\n \n"; @@ -135,8 +132,7 @@ const EditConfigurationModal = ({ ); } - renderFlash( - "success", + notify.success( <> {getDisplayedSoftwareName( @@ -151,7 +147,7 @@ const EditConfigurationModal = ({ refetchSoftwareTitle(); onExit(); } catch (e) { - renderFlash("error", getErrorMessage(e, isApplePlatform)); + notify.error(getErrorMessage(e, isApplePlatform), { response: e }); } setIsUpdatingConfiguration(false); }; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx index 276145eb32..63e9a609fa 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditIconModal/EditIconModal.tsx @@ -11,9 +11,8 @@ import { import { IInputFieldParseTarget } from "interfaces/form_field"; import { ISelfServiceCategory } from "interfaces/self_service_category"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; -import { INotification } from "interfaces/notification"; +import { notify, INotifyBatchItem } from "components/ToastNotification"; import { getErrorReason } from "interfaces/errors"; import softwareAPI from "services/entities/software"; import selfServiceCategoriesAPI, { @@ -156,7 +155,6 @@ const EditIconModal = ({ installerType, previewInfo, }: IEditIconModalProps) => { - const { renderFlash, renderMultiFlash } = useContext(NotificationContext); const { config } = useContext(AppContext); const queryClient = useQueryClient(); @@ -304,13 +302,13 @@ const EditIconModal = ({ // Enforce filesize limit if (file.size > MAX_FILE_SIZE) { - renderFlash("error", "Couldn't edit. Icon must be 100KB or less."); + notify.error("Couldn't edit. Icon must be 100KB or less."); return; } // Enforce PNG MIME type, even though FileUploader also enforces by extension if (file.type !== "image/png") { - renderFlash("error", "Couldn't edit. Must be a PNG file."); + notify.error("Couldn't edit. Must be a PNG file."); return; } @@ -324,8 +322,7 @@ const EditIconModal = ({ width < MIN_DIMENSION || width > MAX_DIMENSION ) { - renderFlash( - "error", + notify.error( `Couldn't edit. Icon must be square, between ${MIN_DIMENSION}x${MIN_DIMENSION}px and ${MAX_DIMENSION}x${MAX_DIMENSION}px.` ); return; @@ -336,7 +333,7 @@ const EditIconModal = ({ if (e.target && typeof e.target.result === "string") { img.src = e.target.result; } else { - renderFlash("error", "FileReader result was not a string."); + notify.error("FileReader result was not a string."); } }; reader.readAsDataURL(file); @@ -635,7 +632,7 @@ const EditIconModal = ({ const onClickSave = async () => { setIsUpdatingSoftwareInfo(true); - const notifications: INotification[] = []; + const errorToasts: INotifyBatchItem[] = []; let iconSucceeded = false; let nameSucceeded = false; let iconSuccessMessage: React.ReactElement | null = null; @@ -670,12 +667,10 @@ const EditIconModal = ({ } } catch (e) { const errorMessage = getErrorReason(e) || DEFAULT_ERROR_MESSAGE; - notifications.push({ - id: "icon-error", - alertType: "error", - isVisible: true, + errorToasts.push({ + variant: "error", message: errorMessage, - persistOnPageChange: false, + options: { response: e }, }); } @@ -705,22 +700,19 @@ const EditIconModal = ({ ); } catch (e) { const errorMessage = getErrorReason(e) || DEFAULT_ERROR_MESSAGE; - notifications.push({ - id: "name-error", - alertType: "error", - isVisible: true, + errorToasts.push({ + variant: "error", message: errorMessage, - persistOnPageChange: false, + options: { response: e }, }); } } - if (notifications.length > 0) { - renderMultiFlash({ notifications }); + if (errorToasts.length > 0) { + notify.batch(errorToasts); } else if (iconSucceeded && nameSucceeded) { // Both changed - show generic message to avoid double toast - renderFlash( - "success", + notify.success( <> Successfully edited{" "} {displayName === "" ? previewInfo.name : displayName}. @@ -738,7 +730,7 @@ const EditIconModal = ({ setIconUploadedAt(new Date().toISOString()); onExitEditIconModal(); } else if (iconSucceeded && iconSuccessMessage) { - renderFlash("success", iconSuccessMessage); + notify.success(iconSuccessMessage); queryClient.invalidateQueries({ queryKey: [{ scope: "software-titles" }], }); @@ -749,7 +741,7 @@ const EditIconModal = ({ setIconUploadedAt(new Date().toISOString()); onExitEditIconModal(); } else if (nameSucceeded && nameSuccessMessage) { - renderFlash("success", nameSuccessMessage); + notify.success(nameSuccessMessage); queryClient.invalidateQueries({ queryKey: [{ scope: "software-titles" }], }); @@ -762,7 +754,7 @@ const EditIconModal = ({ } } catch (e) { const errorMessage = getErrorReason(e) || DEFAULT_ERROR_MESSAGE; - renderFlash("error", errorMessage); + notify.error(errorMessage, { response: e }); } finally { setIsUpdatingSoftwareInfo(false); } diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx index d3e868483f..82caaebdd5 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState, useEffect } from "react"; +import React, { useState, useEffect } from "react"; import { useQuery, useQueryClient } from "react-query"; import classnames from "classnames"; @@ -9,7 +9,6 @@ import { isSoftwarePackage, InstallerType, } from "interfaces/software"; -import { NotificationContext } from "context/notification"; import useGitOpsMode from "hooks/useGitOpsMode"; import softwareAPI from "services/entities/software"; import labelsAPI, { getCustomLabels } from "services/entities/labels"; @@ -18,6 +17,7 @@ import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; import deepDifference from "utilities/deep_difference"; import { getFileDetails } from "utilities/file/fileUtils"; +import { notify } from "components/ToastNotification"; import Modal from "components/Modal"; import FileProgressModal from "components/FileProgressModal"; import CategoriesEndUserExperienceModal from "pages/SoftwarePage/components/modals/CategoriesEndUserExperienceModal"; @@ -72,7 +72,6 @@ const EditSoftwareModal = ({ source, iconUrl = undefined, }: IEditSoftwareModalProps) => { - const { renderFlash } = useContext(NotificationContext); const queryClient = useQueryClient(); const { gitOpsModeEnabled } = useGitOpsMode("software"); // Viewing an FMA in GitOps mode only allows viewing options, not editing @@ -226,8 +225,7 @@ const EditSoftwareModal = ({ // No longer flash message, we open YAML modal if editing with gitOpsModeEnabled openViewYamlModal(); } else { - renderFlash( - "success", + notify.success( <> Successfully edited {formData.software?.name}. {formData.selfService @@ -247,10 +245,9 @@ const EditSoftwareModal = ({ refetchSoftwareTitle(); onExit(); } catch (e) { - renderFlash( - "error", - getErrorMessage(e, softwareInstaller as IAppStoreApp) - ); + notify.error(getErrorMessage(e, softwareInstaller as IAppStoreApp), { + response: e, + }); } setIsUpdatingSoftware(false); }; @@ -300,8 +297,7 @@ const EditSoftwareModal = ({ try { await softwareAPI.editAppStoreApp(softwareId, teamId, formData); - renderFlash( - "success", + notify.success( <> Successfully edited {softwareInstaller.name}. {formData.selfService @@ -320,10 +316,9 @@ const EditSoftwareModal = ({ onExit(); refetchSoftwareTitle(); } catch (e) { - renderFlash( - "error", - getErrorMessage(e, softwareInstaller as IAppStoreApp) - ); + notify.error(getErrorMessage(e, softwareInstaller as IAppStoreApp), { + response: e, + }); } setIsUpdatingSoftware(false); }; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx index 94fca8a18f..063e1a79cd 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useContext, useState } from "react"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import { ISoftwareTitleDetails, ISoftwarePackage, @@ -19,6 +18,7 @@ import { mergePolicies, } from "pages/SoftwarePage/helpers"; +import { notify } from "components/ToastNotification"; import Card from "components/Card"; import TooltipWrapper from "components/TooltipWrapper"; @@ -225,8 +225,6 @@ const SoftwareInstallerCard = ({ isTeamTechnician, } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); - const [showDeleteModal, setShowDeleteModal] = useState(false); const onDeleteClick = () => { @@ -255,9 +253,9 @@ const SoftwareInstallerCard = ({ // The download occurs without any additional authentication. downloadFile(url, name); } catch (e) { - renderFlash("error", "Couldn't download. Please try again."); + notify.error("Couldn't download. Please try again.", { response: e }); } - }, [renderFlash, softwareId, name, teamId]); + }, [softwareId, name, teamId]); const showActions = isGlobalAdmin || diff --git a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx index e9b6542156..b87318e8d0 100644 --- a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx +++ b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx @@ -1,9 +1,8 @@ // Used in AddPackageModal.tsx and EditSoftwareModal.tsx -import React, { useContext, useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import classnames from "classnames"; import useGitOpsMode from "hooks/useGitOpsMode"; -import { NotificationContext } from "context/notification"; import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants"; import { getExtensionFromFileName, @@ -16,6 +15,7 @@ import { ILabelSummary } from "interfaces/label"; import { ISoftwareVersion, SoftwareCategory } from "interfaces/software"; import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper"; +import { notify } from "components/ToastNotification"; import Button from "components/buttons/Button"; import TooltipWrapper from "components/TooltipWrapper"; import FileUploader from "components/FileUploader"; @@ -179,7 +179,6 @@ const PackageForm = ({ gitopsCompatible = false, teamId, }: IPackageFormProps) => { - const { renderFlash } = useContext(NotificationContext); const { gitOpsModeEnabled, repoURL } = useGitOpsMode("software"); const initialFormData: IPackageFormData = { @@ -218,7 +217,7 @@ const PackageForm = ({ try { newDefaultInstallScript = getDefaultInstallScript(file.name); } catch (e) { - renderFlash("error", `${e}`); + notify.error(`${e}`, { response: e }); return; } @@ -226,7 +225,7 @@ const PackageForm = ({ try { newDefaultUninstallScript = getDefaultUninstallScript(file.name); } catch (e) { - renderFlash("error", `${e}`); + notify.error(`${e}`, { response: e }); return; } diff --git a/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx b/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx index 272ebbfb6b..5ca18296f0 100644 --- a/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx +++ b/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx @@ -5,7 +5,6 @@ import { useQuery } from "react-query"; import deepDifference from "utilities/deep_difference"; import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; import configAPI from "services/entities/config"; @@ -13,6 +12,7 @@ import configAPI from "services/entities/config"; import { IConfig } from "interfaces/config"; import Spinner from "components/Spinner"; +import { notify } from "components/ToastNotification"; import SideNav from "../components/SideNav"; import getIntegrationSettingsNavItems from "./IntegrationNavItems"; @@ -29,7 +29,6 @@ const IntegrationsPage = ({ router, params, }: IIntegrationSettingsPageProps) => { - const { renderFlash } = useContext(NotificationContext); const { isPremiumTier } = useContext(AppContext); let { section } = params; @@ -74,17 +73,17 @@ const IntegrationsPage = ({ try { await configAPI.update(diff); - renderFlash("success", "Successfully updated settings."); + notify.success("Successfully updated settings."); refetchConfig(); return true; } catch (err: unknown) { - renderFlash("error", "Could not update settings"); + notify.error("Could not update settings", { response: err }); return false; } finally { setIsUpdatingSettings(false); } }, - [appConfig, refetchConfig, renderFlash] + [appConfig, refetchConfig] ); if (!appConfig) return <>; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx b/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx index b03370f2e5..063eb14a84 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx @@ -2,7 +2,6 @@ import React, { useState, useContext, useCallback, useEffect } from "react"; import { useQueryClient } from "react-query"; import { IInputFieldParseTarget } from "interfaces/form_field"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; import configAPI from "services/entities/config"; import paths from "router/paths"; @@ -15,6 +14,7 @@ import PremiumFeatureMessage from "components/PremiumFeatureMessage/PremiumFeatu import PageDescription from "components/PageDescription"; import Card from "components/Card"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; +import { notify } from "components/ToastNotification"; import { getPathWithQueryParams } from "utilities/url"; import SettingsSection from "pages/admin/components/SettingsSection"; @@ -79,7 +79,6 @@ const isErrorWithMessage = (error: unknown): error is ErrorWithMessage => { const baseClass = "calendars-integration"; const Calendars = ({ appConfig }: IAppConfigFormProps): JSX.Element => { - const { renderFlash } = useContext(NotificationContext); const { currentTeam, isPremiumTier } = useContext(AppContext); const queryClient = useQueryClient(); @@ -204,13 +203,12 @@ const Calendars = ({ appConfig }: IAppConfigFormProps): JSX.Element => { try { await configAPI.update({ integrations: destination }); - renderFlash( - "success", - "Successfully saved calendar integration settings." - ); + notify.success("Successfully saved calendar integration settings."); await queryClient.invalidateQueries(["config"]); } catch (e) { - renderFlash("error", "Could not save calendar integration settings."); + notify.error("Could not save calendar integration settings.", { + response: e, + }); } finally { setIsUpdatingSettings(false); } diff --git a/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/AddCertAuthorityModal/AddCertAuthorityModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/AddCertAuthorityModal/AddCertAuthorityModal.tsx index 25175d52a1..d4d53bda82 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/AddCertAuthorityModal/AddCertAuthorityModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/AddCertAuthorityModal/AddCertAuthorityModal.tsx @@ -1,6 +1,5 @@ -import React, { useContext, useMemo, useState } from "react"; +import React, { useMemo, useState } from "react"; -import { NotificationContext } from "context/notification"; import certificatesAPI from "services/entities/certificates"; import { ICertificateAuthorityPartial, @@ -10,6 +9,7 @@ import { // @ts-ignore import Dropdown from "components/forms/fields/Dropdown"; import Modal from "components/Modal"; +import { notify } from "components/ToastNotification"; import { generateAddCertAuthorityData, @@ -51,8 +51,6 @@ const AddCertAuthorityModal = ({ certAuthorities, onExit, }: IAddCertAuthorityModalProps) => { - const { renderFlash } = useContext(NotificationContext); - const dropdownOptions = useMemo(() => { return generateDropdownOptions( certAuthorities.some((cert) => cert.type === "ndes_scep_proxy") @@ -193,10 +191,10 @@ const AddCertAuthorityModal = ({ setIsAdding(true); try { await certificatesAPI.addCertificateAuthority(addCertAuthorityData); - renderFlash("success", "Successfully added your certificate authority."); + notify.success("Successfully added your certificate authority."); onExit(); } catch (e) { - renderFlash("error", getErrorMessage(e)); + notify.error(getErrorMessage(e), { response: e }); } setIsAdding(false); }; diff --git a/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/DeleteCertificateAuthorityModal/DeleteCertificateAuthorityModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/DeleteCertificateAuthorityModal/DeleteCertificateAuthorityModal.tsx index 830ab8d350..a6bf5ede61 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/DeleteCertificateAuthorityModal/DeleteCertificateAuthorityModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/DeleteCertificateAuthorityModal/DeleteCertificateAuthorityModal.tsx @@ -1,12 +1,12 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import { ICertificateAuthorityPartial } from "interfaces/certificates"; import { getErrorReason } from "interfaces/errors"; import certificatesAPI from "services/entities/certificates"; -import { NotificationContext } from "context/notification"; import Button from "components/buttons/Button"; import Modal from "components/Modal"; +import { notify } from "components/ToastNotification"; const baseClass = "delete-certificate-authority-modal"; @@ -19,26 +19,22 @@ const DeleteCertificateAuthorityModal = ({ certAuthority, onExit, }: IDeleteCertificateAuthorityModalProps) => { - const { renderFlash } = useContext(NotificationContext); const [isUpdating, setIsUpdating] = useState(false); const onDeleteCertAuthority = async () => { setIsUpdating(true); try { await certificatesAPI.deleteCertificateAuthority(certAuthority.id); - renderFlash( - "success", - "Successfully deleted your certificate authority." - ); + notify.success("Successfully deleted your certificate authority."); setIsUpdating(false); onExit(); } catch (e) { setIsUpdating(false); const status = (e as { status?: number })?.status; const reason = status === 409 ? getErrorReason(e) : ""; - renderFlash( - "error", - reason || "Couldn't delete certificate authority. Please try again." + notify.error( + reason || "Couldn't delete certificate authority. Please try again.", + { response: e } ); } }; diff --git a/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/EditCertAuthorityModal/EditCertAuthorityModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/EditCertAuthorityModal/EditCertAuthorityModal.tsx index 779ea21363..392eeb0d9f 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/EditCertAuthorityModal/EditCertAuthorityModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/EditCertAuthorityModal/EditCertAuthorityModal.tsx @@ -1,14 +1,14 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import { useQuery } from "react-query"; import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; -import { NotificationContext } from "context/notification"; import { ICertificateAuthorityPartial } from "interfaces/certificates"; import certificatesAPI from "services/entities/certificates"; import Modal from "components/Modal"; import Spinner from "components/Spinner"; import DataError from "components/DataError"; +import { notify } from "components/ToastNotification"; import { generateDefaultFormData, @@ -36,7 +36,6 @@ const EditCertAuthorityModal = ({ certAuthority, onExit, }: IEditCertAuthorityModalProps) => { - const { renderFlash } = useContext(NotificationContext); const [isUpdating, setIsUpdating] = useState(false); const [isDirty, setIsDirty] = useState(false); const [formData, setFormData] = useState(); @@ -76,10 +75,10 @@ const EditCertAuthorityModal = ({ certAuthority.id, editPatchData ); - renderFlash("success", "Successfully edited certificate authority."); + notify.success("Successfully edited certificate authority."); onExit(); } catch (e) { - renderFlash("error", getErrorMessage(e)); + notify.error(getErrorMessage(e), { response: e }); } setIsUpdating(false); }; diff --git a/frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tests.tsx b/frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tests.tsx index c85daa4246..af5069beb9 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tests.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tests.tsx @@ -9,6 +9,15 @@ import { IConfig } from "interfaces/config"; import ChangeManagement from "./ChangeManagement"; +jest.mock("components/ToastNotification", () => ({ + notify: { + success: jest.fn(), + error: jest.fn(), + batch: jest.fn(), + dismiss: jest.fn(), + }, +})); + const configUrl = baseUrl("/config"); const createGetConfigHandler = (overrides?: Partial) => { @@ -33,7 +42,6 @@ describe("ChangeManagement", () => { withBackendMock: true, context: { app: { isPremiumTier: true, setConfig: jest.fn() }, - notification: { renderFlash: jest.fn() }, }, }); diff --git a/frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tsx b/frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tsx index c53817791f..388a75dd0e 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tsx @@ -5,7 +5,6 @@ import { useQuery } from "react-query"; import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import configAPI from "services/entities/config"; @@ -24,6 +23,7 @@ import PageDescription from "components/PageDescription"; import Spinner from "components/Spinner"; import DataError from "components/DataError"; import PremiumFeatureMessage from "components/PremiumFeatureMessage"; +import { notify } from "components/ToastNotification"; import SettingsSection from "pages/admin/components/SettingsSection"; const baseClass = "change-management"; @@ -57,7 +57,6 @@ const validate = (formData: IChangeManagementFormData) => { const ChangeManagement = () => { const { setConfig } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); const [formData, setFormData] = useState({ // dummy values, will be populated with fresh config API response @@ -151,10 +150,10 @@ const ChangeManagement = () => { setConfig(updatedConfig); - renderFlash("success", "Successfully updated settings"); + notify.success("Successfully updated settings"); } catch (e) { const message = getErrorReason(e); - renderFlash("error", message || "Failed to update settings"); + notify.error(message || "Failed to update settings", { response: e }); } finally { setIsUpdating(false); } diff --git a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.stories.tsx b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.stories.tsx index 42516a6688..a1ee0e4be7 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.stories.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.stories.tsx @@ -9,7 +9,6 @@ import { import createMockConfig from "__mocks__/configMock"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import ConditionalAccess from "./ConditionalAccess"; @@ -26,15 +25,6 @@ const queryClient = new QueryClient({ type CustomQueryClientProviderProps = React.PropsWithChildren; const CustomQueryClientProvider: React.FC = QueryClientProvider; -const mockNotificationContext = { - renderFlash: () => { - // Mock function for stories - }, - hideFlash: () => { - // Mock function for stories - }, -}; - const meta: Meta = { title: "Components/ConditionalAccess", component: ConditionalAccess, @@ -68,11 +58,7 @@ export const NotConfigured: Story = { return ( - - - + ); @@ -104,11 +90,7 @@ export const EntraConfigured: Story = { return ( - - - + ); @@ -142,11 +124,7 @@ export const OktaConfigured: Story = { return ( - - - + ); @@ -180,11 +158,7 @@ export const BothConfigured: Story = { return ( - - - + ); @@ -207,11 +181,7 @@ export const FreeTier: Story = { return ( - - - + ); diff --git a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tsx b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tsx index 866037c23e..0f1451b53c 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/ConditionalAccess.tsx @@ -2,8 +2,6 @@ import React, { useContext, useEffect, useState } from "react"; import paths from "router/paths"; -import { NotificationContext } from "context/notification"; - import conditionalAccessAPI, { ConfirmMSConditionalAccessResponse, } from "services/entities/conditional_access"; @@ -13,6 +11,7 @@ import CustomLink from "components/CustomLink"; import SectionHeader from "components/SectionHeader"; import Icon from "components/Icon"; import { IconNames } from "components/icons"; +import { notify } from "components/ToastNotification"; import { DEFAULT_USE_QUERY_OPTIONS, @@ -50,7 +49,6 @@ const DeleteConditionalAccessModal = ({ provider, config, }: IDeleteConditionalAccessModal) => { - const { renderFlash } = useContext(NotificationContext); const [isDeleting, setIsDeleting] = useState(false); const providerName = @@ -80,13 +78,13 @@ const DeleteConditionalAccessModal = ({ }, }); } - renderFlash("success", `Successfully disconnected from ${providerName}.`); + notify.success(`Successfully disconnected from ${providerName}.`); toggleDeleteConditionalAccessModal(); onDelete(updatedConfig); - } catch { - renderFlash( - "error", - `Could not disconnect from ${providerName}, please try again.` + } catch (e) { + notify.error( + `Could not disconnect from ${providerName}, please try again.`, + { response: e } ); } setIsDeleting(false); @@ -165,8 +163,6 @@ enum EntraPhase { const ConditionalAccess = () => { // HOOKS - const { renderFlash } = useContext(NotificationContext); - const { isPremiumTier, setConfig, config } = useContext(AppContext); const [entraPhase, setEntraPhase] = useState( @@ -201,8 +197,7 @@ const ConditionalAccess = () => { onSuccess: ({ configuration_completed, setup_error }) => { if (configuration_completed) { setEntraPhase(EntraPhase.Configured); - renderFlash( - "success", + notify.success( "Successfully verified Microsoft Entra conditional access integration" ); } else { @@ -215,8 +210,7 @@ const ConditionalAccess = () => { "A Microsoft Entra admin did not consent to the permissions requested by the conditional access integration" ) ) { - renderFlash( - "error", + notify.error( "Couldn't update. Fleet didn't get permissions for Entra. Please try again and accept the permissions." ); } else if ( @@ -224,8 +218,7 @@ const ConditionalAccess = () => { 'No "Fleet conditional access" Entra ID group was found' ) ) { - renderFlash( - "error", + notify.error( `Couldn't connect. The "Fleet conditional access" group doesn't exist in Entra. Please create the group and try again.` ); } else { @@ -237,8 +230,7 @@ const ConditionalAccess = () => { // - The API response contains the setup_error. // - The Fleet server logs the error. // - The MS proxy stores the error in its database. - renderFlash( - "error", + notify.error( "Couldn't connect. Please contact your Fleet administrator." ); } @@ -362,12 +354,11 @@ const ConditionalAccess = () => { }, }); setConfig(updatedConfig); - renderFlash( - "success", - "Successfully updated conditional access settings." - ); - } catch { - renderFlash("error", "Could not update conditional access settings."); + notify.success("Successfully updated conditional access settings."); + } catch (e) { + notify.error("Could not update conditional access settings.", { + response: e, + }); } setIsUpdatingBypass(false); }; diff --git a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/EntraConditionalAccessModal/EntraConditionalAccessModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/EntraConditionalAccessModal/EntraConditionalAccessModal.tsx index 24fc6f3022..ac279316e3 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/EntraConditionalAccessModal/EntraConditionalAccessModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/EntraConditionalAccessModal/EntraConditionalAccessModal.tsx @@ -1,13 +1,13 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import { size } from "lodash"; -import { NotificationContext } from "context/notification"; import conditionalAccessAPI from "services/entities/conditional_access"; import InputField from "components/forms/fields/InputField"; import CustomLink from "components/CustomLink"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; +import { notify } from "components/ToastNotification"; import { IInputFieldParseTarget } from "interfaces/form_field"; import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants"; @@ -40,8 +40,6 @@ const EntraConditionalAccessModal = ({ onCancel, onSuccess, }: IEntraConditionalAccessModalProps) => { - const { renderFlash } = useContext(NotificationContext); - const [isUpdating, setIsUpdating] = useState(false); const [formData, setFormData] = useState({ [MSETID]: "", @@ -68,9 +66,9 @@ const EntraConditionalAccessModal = ({ // Close modal and show banner on main page onSuccess(); } catch (e) { - renderFlash( - "error", - "Could not update conditional access integration settings." + notify.error( + "Could not update conditional access integration settings.", + { response: e } ); setIsUpdating(false); } diff --git a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/OktaConditionalAccessModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/OktaConditionalAccessModal.tsx index f0ce880bb3..1dc7e9fa37 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/OktaConditionalAccessModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/ConditionalAccess/components/OktaConditionalAccessModal/OktaConditionalAccessModal.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useContext, useState } from "react"; import { size } from "lodash"; import { useQuery } from "react-query"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; import configAPI from "services/entities/config"; import conditionalAccessAPI from "services/entities/conditional_access"; @@ -22,6 +21,7 @@ import { } from "utilities/constants"; import FileUploader from "components/FileUploader"; import valid_url from "components/forms/validators/valid_url"; +import { notify } from "components/ToastNotification"; const baseClass = "okta-conditional-access-modal"; @@ -102,7 +102,6 @@ const OktaConditionalAccessModal = ({ onCancel, onSuccess, }: IOktaConditionalAccessModalProps) => { - const { renderFlash } = useContext(NotificationContext); const { config } = useContext(AppContext); const [isUpdating, setIsUpdating] = useState(false); @@ -139,7 +138,7 @@ const OktaConditionalAccessModal = ({ const message = errorReason ? `Failed to load Apple profile: ${errorReason}` : "Failed to load Apple profile."; - renderFlash("error", message); + notify.error(message, { response: e }); }, } ); @@ -158,11 +157,11 @@ const OktaConditionalAccessModal = ({ downloadLink.remove(); URL.revokeObjectURL(url); } catch (e: unknown) { - renderFlash("error", "Failed to download signing certificate."); + notify.error("Failed to download signing certificate.", { response: e }); } finally { setIsDownloadingCert(false); } - }, [renderFlash]); + }, []); const onSubmit = async (evt: React.FormEvent) => { evt.preventDefault(); @@ -188,13 +187,13 @@ const OktaConditionalAccessModal = ({ config.conditional_access?.microsoft_entra_tenant_id || "", }, }); - renderFlash("success", "Successfully configured Okta conditional access"); + notify.success("Successfully configured Okta conditional access"); setIsUpdating(false); onSuccess(updatedConfig); } catch (e) { - renderFlash( - "error", - "Could not update conditional access integration settings." + notify.error( + "Could not update conditional access integration settings.", + { response: e } ); setIsUpdating(false); } @@ -237,8 +236,7 @@ const OktaConditionalAccessModal = ({ // Validate file extension if (!file.name.match(/\.(pem|crt|cer|cert)$/i)) { - renderFlash( - "error", + notify.error( "Invalid file type. Please upload a .pem, .crt, .cer, or .cert file." ); return; @@ -255,8 +253,7 @@ const OktaConditionalAccessModal = ({ !content.includes("-----BEGIN CERTIFICATE-----") || !content.includes("-----END CERTIFICATE-----") ) { - renderFlash( - "error", + notify.error( "Invalid certificate format. The file must be a valid PEM-encoded certificate." ); return; @@ -273,10 +270,10 @@ const OktaConditionalAccessModal = ({ }); reader.addEventListener("error", () => { - renderFlash("error", "Failed to read the certificate file."); + notify.error("Failed to read the certificate file."); }); }, - [formData, renderFlash] + [formData] ); return ( diff --git a/frontend/pages/admin/IntegrationsPage/cards/IdentityProviders/components/EndUserAuthSection/EndUserAuthSection.tests.tsx b/frontend/pages/admin/IntegrationsPage/cards/IdentityProviders/components/EndUserAuthSection/EndUserAuthSection.tests.tsx index 44c4c4b264..b1a1a179dd 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/IdentityProviders/components/EndUserAuthSection/EndUserAuthSection.tests.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/IdentityProviders/components/EndUserAuthSection/EndUserAuthSection.tests.tsx @@ -10,6 +10,15 @@ import EndUserAuthSection, { } from "./EndUserAuthSection"; import { IFormDataIdp } from "./helpers"; +jest.mock("components/ToastNotification", () => ({ + notify: { + success: jest.fn(), + error: jest.fn(), + batch: jest.fn(), + dismiss: jest.fn(), + }, +})); + const EMPTY_FORM_DATA: IFormDataIdp = { idp_name: "", entity_id: "", @@ -31,9 +40,6 @@ const createTestRenderer = () => { isPremiumTier: true, config: createMockConfig(), }, - notification: { - renderFlash: jest.fn(), - }, }, }); }; diff --git a/frontend/pages/admin/IntegrationsPage/cards/IdentityProviders/components/EndUserAuthSection/EndUserAuthSection.tsx b/frontend/pages/admin/IntegrationsPage/cards/IdentityProviders/components/EndUserAuthSection/EndUserAuthSection.tsx index 2096e324ad..3c88a8052e 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/IdentityProviders/components/EndUserAuthSection/EndUserAuthSection.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/IdentityProviders/components/EndUserAuthSection/EndUserAuthSection.tsx @@ -8,7 +8,6 @@ import { AxiosResponse } from "axios"; import { expandErrorReasonRequired } from "interfaces/errors"; import configAPI from "services/entities/config"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; import InputField from "components/forms/fields/InputField"; @@ -17,6 +16,7 @@ import TooltipWrapper from "components/TooltipWrapper"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; import PremiumFeatureMessage from "components/PremiumFeatureMessage"; import CustomLink from "components/CustomLink"; +import { notify } from "components/ToastNotification"; import { IFormDataIdp, @@ -49,7 +49,6 @@ const EndUserAuthSection = ({ const { config, isPremiumTier } = useContext(AppContext); const gitOpsModeEnabled = config?.gitops.gitops_mode_enabled; - const { renderFlash } = useContext(NotificationContext); const [formErrors, setFormErrors] = useState(null); const isFormCleared = @@ -107,7 +106,7 @@ const EndUserAuthSection = ({ }, }, }); - renderFlash("success", "Successfully updated end user authentication."); + notify.success("Successfully updated end user authentication."); originalFormData.current = { ...formData }; setDirty(false); // Notify parent component of changes, since we're calling our own API @@ -116,16 +115,15 @@ const EndUserAuthSection = ({ } catch (err) { const ae = (typeof err === "object" ? err : {}) as AxiosResponse; if (ae.status === 422) { - renderFlash( - "error", - `Couldn't update: ${expandErrorReasonRequired(err)}.` - ); + notify.error(`Couldn't update: ${expandErrorReasonRequired(err)}.`, { + response: err, + }); return; } - renderFlash("error", "Couldn't update. Please try again."); + notify.error("Couldn't update. Please try again.", { response: err }); } }, - [formData, setFormData, renderFlash, setDirty] + [formData, setFormData, setDirty] ); const renderContent = () => { diff --git a/frontend/pages/admin/IntegrationsPage/cards/Integrations/TicketDestinations.tsx b/frontend/pages/admin/IntegrationsPage/cards/Integrations/TicketDestinations.tsx index c4f3df46ea..cc6c41f53d 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Integrations/TicketDestinations.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/Integrations/TicketDestinations.tsx @@ -1,7 +1,6 @@ -import React, { useState, useContext, useCallback, useMemo } from "react"; +import React, { useState, useCallback, useMemo } from "react"; import { useQuery } from "react-query"; -import { NotificationContext } from "context/notification"; import { IConfig } from "interfaces/config"; import { IJiraIntegration, @@ -21,6 +20,7 @@ import EmptyState from "components/EmptyState"; import TableContainer from "components/TableContainer"; import TableDataError from "components/DataError"; import Spinner from "components/Spinner"; +import { notify } from "components/ToastNotification"; import SettingsSection from "pages/admin/components/SettingsSection"; import PageDescription from "components/PageDescription"; import AddTicketDestinationModal from "./components/AddIntegrationModal"; @@ -41,8 +41,6 @@ const UNKNOWN_ERROR = "We experienced an error when attempting to connect. Please try again later."; const TicketDestinations = (): JSX.Element => { - const { renderFlash } = useContext(NotificationContext); - const [ showAddTicketDestinationModal, setShowAddTicketDestinationModal, @@ -126,8 +124,7 @@ const TicketDestinations = (): JSX.Element => { configAPI .update({ integrations: destination() }) .then(() => { - renderFlash( - "success", + notify.success( <> Successfully added{" "} @@ -149,8 +146,7 @@ const TicketDestinations = (): JSX.Element => { "duplicate Jira integration" ) ) { - renderFlash( - "error", + notify.error( <> Could not add{" "} @@ -165,25 +161,26 @@ const TicketDestinations = (): JSX.Element => { .group_id} . This integration already exists - + , + { response: addError } ); } else { - renderFlash("error", VALIDATION_FAILED_ERROR); + notify.error(VALIDATION_FAILED_ERROR, { response: addError }); } } else if (addError.data?.message.includes("Bad request")) { - renderFlash("error", BAD_REQUEST_ERROR); + notify.error(BAD_REQUEST_ERROR, { response: addError }); } else if (addError.data?.message.includes("Unknown Error")) { - renderFlash("error", UNKNOWN_ERROR); + notify.error(UNKNOWN_ERROR, { response: addError }); } else { - renderFlash( - "error", + notify.error( <> Could not add{" "} {integrationSubmitData[integrationSubmitData.length - 1].url} . Please try again. - + , + { response: addError } ); } }) @@ -217,8 +214,7 @@ const TicketDestinations = (): JSX.Element => { setIsUpdatingIntegration(true); deleteIntegrationDestination() .then(() => { - renderFlash( - "success", + notify.success( <> Successfully deleted{" "} @@ -230,9 +226,8 @@ const TicketDestinations = (): JSX.Element => { ); refetchIntegrations(); }) - .catch(() => { - renderFlash( - "error", + .catch((deleteError: unknown) => { + notify.error( <> Could not delete{" "} @@ -241,7 +236,8 @@ const TicketDestinations = (): JSX.Element => { integrationEditing.groupId?.toString()} . Please try again. - + , + { response: deleteError } ); }) .finally(() => { diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AndroidMdmPage/AndroidMdmPage.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AndroidMdmPage/AndroidMdmPage.tsx index d64eaec1ed..8e754eef08 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AndroidMdmPage/AndroidMdmPage.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AndroidMdmPage/AndroidMdmPage.tsx @@ -10,7 +10,6 @@ import { useQuery, useQueryClient } from "react-query"; import PATHS from "router/paths"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import { IConfig } from "interfaces/config"; import { getErrorReason } from "interfaces/errors"; import mdmAndroidAPI from "services/entities/mdm_android"; @@ -24,6 +23,7 @@ import TooltipWrapper from "components/TooltipWrapper"; import CustomLink from "components/CustomLink"; import Spinner from "components/Spinner"; import DataError from "components/DataError"; +import { notify } from "components/ToastNotification"; import TurnOffAndroidMdmModal from "./components/TurnOffAndroidMdmModal"; @@ -37,7 +37,6 @@ interface ITurnOnAndroidMdmProps { } const TurnOnAndroidMdm = ({ router }: ITurnOnAndroidMdmProps) => { - const { renderFlash } = useContext(NotificationContext); const { setConfig } = useContext(AppContext); const queryClient = useQueryClient(); @@ -51,8 +50,10 @@ const TurnOnAndroidMdm = ({ router }: ITurnOnAndroidMdmProps) => { async (abortController: AbortController) => { try { await mdmAndroidAPI.startSSE(abortController.signal); - } catch { - renderFlash("error", "Couldn't turn on Android MDM. Please try again."); + } catch (e) { + notify.error("Couldn't turn on Android MDM. Please try again.", { + response: e, + }); setSetupSse(false); return; } @@ -71,13 +72,11 @@ const TurnOnAndroidMdm = ({ router }: ITurnOnAndroidMdmProps) => { setConfig(patched); queryClient.setQueryData(["config"], patched); } - renderFlash("success", "Android MDM turned on successfully.", { - persistOnPageChange: true, - }); + notify.success("Android MDM turned on successfully."); setSetupSse(false); router.push(PATHS.ADMIN_INTEGRATIONS_MDM); }, - [queryClient, renderFlash, router, setConfig] + [queryClient, router, setConfig] ); useEffect(() => { @@ -92,7 +91,7 @@ const TurnOnAndroidMdm = ({ router }: ITurnOnAndroidMdmProps) => { } return undefined; - }, [setupSse, router, renderFlash, handleSSE]); + }, [setupSse, router, handleSSE]); const onConnectMdm = async () => { setFetchingSignupUrl(true); @@ -113,8 +112,7 @@ const TurnOnAndroidMdm = ({ router }: ITurnOnAndroidMdmProps) => { } catch (e) { const reason = getErrorReason(e); if (reason.includes("android enterprise already exists")) { - renderFlash( - "error", + notify.error( <> Couldn't connect. Android enterprise already exists for this Fleet server. For help, please contact{" "} @@ -124,13 +122,13 @@ const TurnOnAndroidMdm = ({ router }: ITurnOnAndroidMdmProps) => { newTab variant="flash-message-link" /> - + , + { response: e } ); } else { - renderFlash( - "error", - `Couldn't connect. ${reason || "Please try again."}` - ); + notify.error(`Couldn't connect. ${reason || "Please try again."}`, { + response: e, + }); } } setFetchingSignupUrl(false); diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AndroidMdmPage/components/TurnOffAndroidMdmModal/TurnOffAndroidMdmModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AndroidMdmPage/components/TurnOffAndroidMdmModal/TurnOffAndroidMdmModal.tsx index c48dba51ed..4ebabf9e6f 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AndroidMdmPage/components/TurnOffAndroidMdmModal/TurnOffAndroidMdmModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AndroidMdmPage/components/TurnOffAndroidMdmModal/TurnOffAndroidMdmModal.tsx @@ -5,11 +5,11 @@ import { useQueryClient } from "react-query"; import PATHS from "router/paths"; import mdmAndroidAPI from "services/entities/mdm_android"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import { IConfig } from "interfaces/config"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; +import { notify } from "components/ToastNotification"; const baseClass = "turn-off-android-mdm-modal"; @@ -22,7 +22,6 @@ const TurnOffAndroidMdmModal = ({ onExit, router, }: ITurnOffAndroidMdmModalProps) => { - const { renderFlash } = useContext(NotificationContext); const { setConfig } = useContext(AppContext); const queryClient = useQueryClient(); @@ -34,7 +33,9 @@ const TurnOffAndroidMdmModal = ({ await mdmAndroidAPI.turnOffAndroidMdm(); } catch (e) { onExit(); - renderFlash("error", "Couldn't turn off Android MDM. Please try again."); + notify.error("Couldn't turn off Android MDM. Please try again.", { + response: e, + }); return; } // DELETE success means the backend has already cleared @@ -49,11 +50,9 @@ const TurnOffAndroidMdmModal = ({ setConfig(patched); queryClient.setQueryData(["config"], patched); } - renderFlash("success", "Android MDM turned off successfully.", { - persistOnPageChange: true, - }); + notify.success("Android MDM turned off successfully."); router.push(PATHS.ADMIN_INTEGRATIONS_MDM); - }, [onExit, queryClient, renderFlash, router, setConfig]); + }, [onExit, queryClient, router, setConfig]); return ( diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/AddAbmModal/AddAbmModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/AddAbmModal/AddAbmModal.tsx index 8216187f7f..bda22396b1 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/AddAbmModal/AddAbmModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/AddAbmModal/AddAbmModal.tsx @@ -1,12 +1,12 @@ -import React, { useCallback, useContext, useState } from "react"; +import React, { useCallback, useState } from "react"; -import { NotificationContext } from "context/notification"; import mdmAbmAPI from "services/entities/mdm_apple_bm"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; import FileUploader from "components/FileUploader"; import CustomLink from "components/CustomLink"; +import { notify } from "components/ToastNotification"; import DownloadABMKey from "pages/admin/components/DownloadFileButtons/DownloadABMKey"; import { getErrorMessage } from "./helpers"; @@ -18,8 +18,6 @@ interface IAddAbmModalProps { } const AddAbmModal = ({ onCancel, onAdded }: IAddAbmModalProps) => { - const { renderFlash } = useContext(NotificationContext); - const [tokenFile, setTokenFile] = useState(null); const [isUploading, setIsUploading] = useState(false); @@ -34,21 +32,21 @@ const AddAbmModal = ({ onCancel, onAdded }: IAddAbmModalProps) => { setIsUploading(true); if (!tokenFile) { setIsUploading(false); - renderFlash("error", "No token selected."); + notify.error("No token selected."); return; } try { await mdmAbmAPI.uploadToken(tokenFile); - renderFlash("success", "Added successfully."); + notify.success("Added successfully."); onAdded(); } catch (e) { - renderFlash("error", getErrorMessage(e)); + notify.error(getErrorMessage(e), { response: e }); onCancel(); } finally { setIsUploading(false); } - }, [tokenFile, renderFlash, onAdded, onCancel]); + }, [tokenFile, onAdded, onCancel]); return ( diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/DeleteAbmModal/DeleteAbmModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/DeleteAbmModal/DeleteAbmModal.tsx index 74ddfc9a7b..c02a9320b3 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/DeleteAbmModal/DeleteAbmModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/DeleteAbmModal/DeleteAbmModal.tsx @@ -1,10 +1,10 @@ -import React, { useCallback, useContext, useState } from "react"; +import React, { useCallback, useState } from "react"; import mdmAbmAPI from "services/entities/mdm_apple_bm"; -import { NotificationContext } from "context/notification"; import Button from "components/buttons/Button"; import Modal from "components/Modal"; +import { notify } from "components/ToastNotification"; const baseClass = "delete-abm-modal"; @@ -21,8 +21,6 @@ const DeleteAbmModal = ({ onCancel, onDeletedToken, }: IDeleteAbmModalProps) => { - const { renderFlash } = useContext(NotificationContext); - const [isDeleting, setIsDeleting] = useState(false); const onDeleteToken = useCallback(async () => { @@ -30,17 +28,16 @@ const DeleteAbmModal = ({ try { await mdmAbmAPI.deleteToken(tokenId); - renderFlash("success", "Deleted successfully."); + notify.success("Deleted successfully."); onDeletedToken(); } catch (e) { // TODO: Check API sends back correct error messages - renderFlash( - "error", - "Couldn’t disable automatic enrollment. Please try again." - ); + notify.error("Couldn’t disable automatic enrollment. Please try again.", { + response: e, + }); onCancel(); } - }, [onCancel, onDeletedToken, renderFlash, tokenId]); + }, [onCancel, onDeletedToken, tokenId]); return ( { - const { renderFlash } = useContext(NotificationContext); const { availableTeams } = useContext(AppContext); const [isSaving, setIsSaving] = useState(false); @@ -112,21 +111,14 @@ const EditTeamsAbmModal = ({ tokenId: token.id, teams: getSelectedTeamIds(selectedTeamNames, availableTeams), }); - renderFlash("success", "Successfully updated fleets for AB token."); + notify.success("Successfully updated fleets for AB token."); onSuccess(); } catch (e) { - renderFlash("error", "Couldn’t edit. Please try again."); + notify.error("Couldn’t edit. Please try again.", { response: e }); onCancel(); } }, - [ - token.id, - selectedTeamNames, - availableTeams, - renderFlash, - onSuccess, - onCancel, - ] + [token.id, selectedTeamNames, availableTeams, onSuccess, onCancel] ); return ( diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/RenewAbmModal/RenewAbmModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/RenewAbmModal/RenewAbmModal.tsx index 4b35d634b5..8ffaaa2d88 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/RenewAbmModal/RenewAbmModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleBusinessManagerPage/components/RenewAbmModal/RenewAbmModal.tsx @@ -1,12 +1,12 @@ -import React, { useState, useContext, useCallback } from "react"; +import React, { useState, useCallback } from "react"; -import { NotificationContext } from "context/notification"; import mdmAppleBmAPI from "services/entities/mdm_apple_bm"; import Button from "components/buttons/Button"; import CustomLink from "components/CustomLink"; import { FileUploader } from "components/FileUploader/FileUploader"; import Modal from "components/Modal"; +import { notify } from "components/ToastNotification"; import { getErrorMessage } from "./helpers"; @@ -23,8 +23,6 @@ const RenewAbmModal = ({ onCancel, onRenewedToken, }: IRenewAbmModalProps) => { - const { renderFlash } = useContext(NotificationContext); - const [isUploading, setIsUploading] = useState(false); const [tokenFile, setTokenFile] = useState(null); @@ -38,21 +36,21 @@ const RenewAbmModal = ({ const onRenewToken = useCallback(async () => { if (!tokenFile) { // this shouldn't happen, but just in case - renderFlash("error", "Please provide a token file."); + notify.error("Please provide a token file."); return; } setIsUploading(true); try { await mdmAppleBmAPI.renewToken(tokenId, tokenFile); - renderFlash("success", "Renewed successfully."); + notify.success("Renewed successfully."); setIsUploading(false); onRenewedToken(); } catch (e) { - renderFlash("error", getErrorMessage(e)); + notify.error(getErrorMessage(e), { response: e }); onCancel(); setIsUploading(false); } - }, [tokenFile, renderFlash, tokenId, onRenewedToken, onCancel]); + }, [tokenFile, tokenId, onRenewedToken, onCancel]); return ( { const queryClient = useQueryClient(); const { config } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); const [isUpdating, setIsUpdating] = useState(false); const [showRenewCertModal, setShowRenewCertModal] = useState(false); @@ -71,12 +70,14 @@ const AppleMdmPage = ({ router }: { router: InjectedRouter }) => { await mdmAppleAPI.deleteApplePushCertificate(); await queryClient.invalidateQueries(["config"]); router.push(PATHS.ADMIN_INTEGRATIONS_MDM); - renderFlash("success", "MDM turned off successfully."); + notify.success("MDM turned off successfully."); } catch (e) { - renderFlash("error", "Couldn't turn off MDM. Please try again."); + notify.error("Couldn't turn off MDM. Please try again.", { + response: e, + }); setIsUpdating(false); } - }, [queryClient, renderFlash, router]); + }, [queryClient, router]); const onRenewCert = useCallback(() => { refetch(); diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/components/content/ApplePushCertSetup.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/components/content/ApplePushCertSetup.tsx index 8d52f0238f..836ab8861d 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/components/content/ApplePushCertSetup.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/components/content/ApplePushCertSetup.tsx @@ -1,11 +1,11 @@ -import React, { useCallback, useContext, useState } from "react"; +import React, { useCallback, useState } from "react"; -import { NotificationContext } from "context/notification"; import { getErrorReason } from "interfaces/errors"; import mdmAppleApi from "services/entities/mdm_apple"; import CustomLink from "components/CustomLink"; import FileUploader from "components/FileUploader"; +import { notify } from "components/ToastNotification"; import DownloadCSR from "../../../../../../components/DownloadFileButtons/DownloadCSR"; interface IApplePushCertSetupProps { @@ -16,25 +16,23 @@ const ApplePushCertSetup = ({ baseClass, onSetupSuccess, }: IApplePushCertSetupProps) => { - const { renderFlash } = useContext(NotificationContext); const [isUploading, setIsUploading] = useState(false); const onFileUpload = useCallback( async (files: FileList | null) => { if (!files?.length) { - renderFlash("error", "No file selected"); + notify.error("No file selected"); return; } setIsUploading(true); try { await mdmAppleApi.uploadApplePushCertificate(files[0]); - renderFlash("success", "MDM turned on successfully."); + notify.success("MDM turned on successfully."); onSetupSuccess(); } catch (e) { const msg = getErrorReason(e); if (msg.toLowerCase().includes("required private key")) { - renderFlash( - "error", + notify.error( <> Couldn't add APNs certificate. Please configure a private key.{" "} @@ -44,41 +42,43 @@ const ApplePushCertSetup = ({ newTab variant="flash-message-link" /> - + , + { response: e } ); } else { - renderFlash("error", msg || "Couldn’t connect. Please try again."); + notify.error(msg || "Couldn’t connect. Please try again.", { + response: e, + }); } setIsUploading(false); } }, - [renderFlash, onSetupSuccess] + [onSetupSuccess] ); - const onDownloadError = useCallback( - (e: unknown) => { - const msg = getErrorReason(e); - if (msg.includes("is not permitted for APNS certificate signing.")) { - renderFlash("error", msg); - } else if (msg.toLowerCase().includes("required private key")) { - renderFlash( - "error", - <> - Couldn't download. Please configure a private key.{" "} - - - ); - } else { - renderFlash("error", "Something's gone wrong. Please try again."); - } - }, - [renderFlash] - ); + const onDownloadError = useCallback((e: unknown) => { + const msg = getErrorReason(e); + if (msg.includes("is not permitted for APNS certificate signing.")) { + notify.error(msg, { response: e }); + } else if (msg.toLowerCase().includes("required private key")) { + notify.error( + <> + Couldn't download. Please configure a private key.{" "} + + , + { response: e } + ); + } else { + notify.error("Something's gone wrong. Please try again.", { + response: e, + }); + } + }, []); return (
    diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/components/modals/RenewCertModal/RenewCertModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/components/modals/RenewCertModal/RenewCertModal.tsx index 8f83516c9b..a0483b4470 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/components/modals/RenewCertModal/RenewCertModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/AppleMdmPage/components/modals/RenewCertModal/RenewCertModal.tsx @@ -1,6 +1,4 @@ -import React, { useState, useContext, useCallback } from "react"; - -import { NotificationContext } from "context/notification"; +import React, { useState, useCallback } from "react"; import mdmAppleApi from "services/entities/mdm_apple"; import { getErrorReason } from "interfaces/errors"; @@ -9,6 +7,7 @@ import Button from "components/buttons/Button"; import CustomLink from "components/CustomLink"; import { FileUploader } from "components/FileUploader/FileUploader"; import Modal from "components/Modal"; +import { notify } from "components/ToastNotification"; import DownloadCSR from "../../../../../../../components/DownloadFileButtons/DownloadCSR"; const baseClass = "modal renew-cert-modal"; @@ -22,8 +21,6 @@ const RenewCertModal = ({ onCancel, onRenew, }: IRenewCertModalProps): JSX.Element => { - const { renderFlash } = useContext(NotificationContext); - const [isUploading, setIsUploading] = useState(false); const [certFile, setCertFile] = useState(null); @@ -37,48 +34,49 @@ const RenewCertModal = ({ const onRenewClick = useCallback(async () => { if (!certFile) { // this shouldn't happen, but just in case - renderFlash("error", "Please provide a certificate file."); + notify.error("Please provide a certificate file."); return; } setIsUploading(true); try { await mdmAppleApi.uploadApplePushCertificate(certFile); - renderFlash("success", "APNs certificate renewed successfully."); + notify.success("APNs certificate renewed successfully."); setIsUploading(false); onRenew(); } catch (e) { console.error(e); const msg = getErrorReason(e); - renderFlash("error", msg || "Couldn’t renew. Please try again."); + notify.error(msg || "Couldn’t renew. Please try again.", { + response: e, + }); setIsUploading(false); onCancel(); } - }, [certFile, renderFlash, onCancel, onRenew]); + }, [certFile, onCancel, onRenew]); - const onDownloadError = useCallback( - (e: unknown) => { - const msg = getErrorReason(e); - if (msg.includes("is not permitted for APNS certificate signing.")) { - renderFlash("error", msg); - } else if (msg.toLowerCase().includes("required private key")) { - renderFlash( - "error", - <> - Couldn't download. Please configure a private key.{" "} - - - ); - } else { - renderFlash("error", "Something's gone wrong. Please try again."); - } - }, - [renderFlash] - ); + const onDownloadError = useCallback((e: unknown) => { + const msg = getErrorReason(e); + if (msg.includes("is not permitted for APNS certificate signing.")) { + notify.error(msg, { response: e }); + } else if (msg.toLowerCase().includes("required private key")) { + notify.error( + <> + Couldn't download. Please configure a private key.{" "} + + , + { response: e } + ); + } else { + notify.error("Something's gone wrong. Please try again.", { + response: e, + }); + } + }, []); return ( diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/AddVppModal/AddVppModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/AddVppModal/AddVppModal.tsx index 91928f0f61..e1c3445f67 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/AddVppModal/AddVppModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/AddVppModal/AddVppModal.tsx @@ -1,12 +1,12 @@ -import React, { useCallback, useContext, useState } from "react"; +import React, { useCallback, useState } from "react"; -import { NotificationContext } from "context/notification"; import mdmAppleAPI from "services/entities/mdm_apple"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; import CustomLink from "components/CustomLink"; import FileUploader from "components/FileUploader"; +import { notify } from "components/ToastNotification"; import { getErrorMessage } from "./helpers"; @@ -18,8 +18,6 @@ interface IAddVppModalProps { } const AddVppModal = ({ onCancel, onAdded }: IAddVppModalProps) => { - const { renderFlash } = useContext(NotificationContext); - const [tokenFile, setTokenFile] = useState(null); const [isUploading, setIsUploading] = useState(false); @@ -34,21 +32,21 @@ const AddVppModal = ({ onCancel, onAdded }: IAddVppModalProps) => { setIsUploading(true); if (!tokenFile) { setIsUploading(false); - renderFlash("error", "No token selected."); + notify.error("No token selected."); return; } try { await mdmAppleAPI.uploadVppToken(tokenFile); - renderFlash("success", "Added successfully."); + notify.success("Added successfully."); onAdded(); } catch (e) { - renderFlash("error", getErrorMessage(e)); + notify.error(getErrorMessage(e), { response: e }); onCancel(); } finally { setIsUploading(false); } - }, [tokenFile, renderFlash, onAdded, onCancel]); + }, [tokenFile, onAdded, onCancel]); return ( { - const { renderFlash } = useContext(NotificationContext); - const [isDeleting, setIsDeleting] = useState(false); const onDeleteToken = useCallback(async () => { @@ -30,14 +28,14 @@ const DeleteVppModal = ({ try { await mdmAppleAPI.deleteVppToken(tokenId); - renderFlash("success", "Deleted successfully."); + notify.success("Deleted successfully."); onDeletedToken(); } catch (e) { // TODO: Check API sends back correct error messages - renderFlash("error", "Couldn’t delete. Please try again."); + notify.error("Couldn’t delete. Please try again.", { response: e }); onCancel(); } - }, [onCancel, onDeletedToken, renderFlash, tokenId]); + }, [onCancel, onDeletedToken, tokenId]); return ( { - const { renderFlash } = useContext(NotificationContext); const { availableTeams } = useContext(AppContext); // react-select uses a string of comma-separated values for multi-select so we're using a string @@ -225,15 +224,15 @@ const EditTeamsVppModal = ({ tokenId: currentToken.id, teamIds: teamIdsFromSelectedValue(selectedValue), }); - renderFlash("success", "Edited successfully."); + notify.success("Edited successfully."); onSuccess(); } catch (e) { - renderFlash("error", "Couldn’t edit. Please try again."); + notify.error("Couldn’t edit. Please try again.", { response: e }); } finally { setIsSaving(false); } }, - [currentToken.id, selectedValue, renderFlash, onSuccess] + [currentToken.id, selectedValue, onSuccess] ); const isDropdownDisabled = options.length === 0 && isAnyTokenAllTeams; diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/RenewVppModal/RenewVppModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/RenewVppModal/RenewVppModal.tsx index c568db5d9f..7829dc6d1d 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/RenewVppModal/RenewVppModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/VppPage/components/RenewVppModal/RenewVppModal.tsx @@ -1,6 +1,4 @@ -import React, { useState, useContext, useCallback } from "react"; - -import { NotificationContext } from "context/notification"; +import React, { useState, useCallback } from "react"; import mdmAppleAPI from "services/entities/mdm_apple"; @@ -8,6 +6,7 @@ import Button from "components/buttons/Button"; import CustomLink from "components/CustomLink"; import { FileUploader } from "components/FileUploader/FileUploader"; import Modal from "components/Modal"; +import { notify } from "components/ToastNotification"; import { getErrorMessage } from "./helpers"; const baseClass = "modal renew-vpp-modal"; @@ -23,7 +22,6 @@ const RenewVppModal = ({ onCancel, onRenewedToken, }: IRenewVppModalProps) => { - const { renderFlash } = useContext(NotificationContext); const [isRenewing, setIsRenewing] = useState(false); const [tokenFile, setTokenFile] = useState(null); @@ -39,23 +37,22 @@ const RenewVppModal = ({ if (!tokenFile) { setIsRenewing(false); - renderFlash("error", "No token selected."); + notify.error("No token selected."); return; } try { await mdmAppleAPI.renewVppToken(tokenId, tokenFile); - renderFlash( - "success", + notify.success( "Volume Purchasing Program (VPP) integration enabled successfully." ); onRenewedToken(); } catch (e) { - renderFlash("error", getErrorMessage(e)); + notify.error(getErrorMessage(e), { response: e }); onCancel(); } setIsRenewing(false); - }, [onCancel, onRenewedToken, renderFlash, tokenFile, tokenId]); + }, [onCancel, onRenewedToken, tokenFile, tokenId]); return ( { const { setConfig } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); const turnOnWindowsMdm = async () => { try { @@ -47,13 +46,9 @@ const useSetWindowsMdm = ({ true ); setConfig(updatedConfig); - renderFlash("success", "Windows MDM settings successfully updated.", { - persistOnPageChange: true, - }); + notify.success("Windows MDM settings successfully updated."); } catch (e) { - renderFlash("error", getErrorMessage(e), { - persistOnPageChange: true, - }); + notify.error(getErrorMessage(e), { response: e }); } router.push(PATHS.ADMIN_INTEGRATIONS_MDM); diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AddEntraClientIDModal/AddEntraClientIDModal.tests.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AddEntraClientIDModal/AddEntraClientIDModal.tests.tsx index 421d1390d5..b6e4cf2446 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AddEntraClientIDModal/AddEntraClientIDModal.tests.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AddEntraClientIDModal/AddEntraClientIDModal.tests.tsx @@ -5,10 +5,19 @@ import { createMockConfig, createMockMdmConfig } from "__mocks__/configMock"; import { IConfig } from "interfaces/config"; import { createCustomRenderer } from "test/test-utils"; import configAPI from "services/entities/config"; +import { notify } from "components/ToastNotification"; import AddEntraClientIdModal from "./AddEntraClientIDModal"; jest.mock("services/entities/config"); +jest.mock("components/ToastNotification", () => ({ + notify: { + success: jest.fn(), + error: jest.fn(), + batch: jest.fn(), + dismiss: jest.fn(), + }, +})); // A valid (version 4) UUID stored in upper-case, as it might be after being added via GitOps or the API. const EXISTING_UPPERCASE_ID = "6D8769E6-0F8B-418D-B385-1A53968781C9"; @@ -20,9 +29,6 @@ const createTestMockData = (configOverrides: Partial) => ({ config: createMockConfig(configOverrides), setConfig: jest.fn(), }, - notification: { - renderFlash: jest.fn(), - }, }, }); @@ -32,13 +38,11 @@ describe("AddEntraClientIdModal", () => { }); it("rejects a case-insensitive duplicate of an existing client ID without calling the API", async () => { - const renderFlash = jest.fn(); const mockData = createTestMockData({ mdm: createMockMdmConfig({ windows_entra_client_ids: [EXISTING_UPPERCASE_ID], }), }); - mockData.context.notification.renderFlash = renderFlash; const render = createCustomRenderer(mockData); const { user } = render(); @@ -50,8 +54,7 @@ describe("AddEntraClientIdModal", () => { await user.click(screen.getByRole("button", { name: "Add" })); await waitFor(() => { - expect(renderFlash).toHaveBeenCalledWith( - "error", + expect(notify.error).toHaveBeenCalledWith( "Couldn't add client ID. Client ID already exists." ); }); diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AddEntraClientIDModal/AddEntraClientIDModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AddEntraClientIDModal/AddEntraClientIDModal.tsx index 7f9d2ff4df..02962c0113 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AddEntraClientIDModal/AddEntraClientIDModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AddEntraClientIDModal/AddEntraClientIDModal.tsx @@ -1,6 +1,5 @@ import React, { useState, useContext } from "react"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; import configAPI from "services/entities/config"; @@ -8,6 +7,7 @@ import InputField from "components/forms/fields/InputField"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; import CustomLink from "components/CustomLink"; +import { notify } from "components/ToastNotification"; import { IAddClientIdFormValidation, validateFormData } from "./helpers"; @@ -22,7 +22,6 @@ interface IAddEntraClientIdModalProps { } const AddEntraClientIdModal = ({ onExit }: IAddEntraClientIdModalProps) => { - const { renderFlash } = useContext(NotificationContext); const { setConfig, config } = useContext(AppContext); const [isAdding, setIsAdding] = React.useState(false); @@ -57,7 +56,7 @@ const AddEntraClientIdModal = ({ onExit }: IAddEntraClientIdModalProps) => { (id) => id.toLowerCase() === clientId ) ?? false; if (clientIdExists) { - renderFlash("error", "Couldn't add client ID. Client ID already exists."); + notify.error("Couldn't add client ID. Client ID already exists."); return; } @@ -71,10 +70,12 @@ const AddEntraClientIdModal = ({ onExit }: IAddEntraClientIdModalProps) => { }, }); setConfig(updateData); - renderFlash("success", "Successfully added client ID"); + notify.success("Successfully added client ID"); onExit(); } catch (error) { - renderFlash("error", "Couldn't add client ID. Please try again"); + notify.error("Couldn't add client ID. Please try again", { + response: error, + }); } finally { setIsAdding(false); } diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AddEntraTenantModal/AddEntraTenantModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AddEntraTenantModal/AddEntraTenantModal.tsx index 5932b44e34..0991a9bb93 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AddEntraTenantModal/AddEntraTenantModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/AddEntraTenantModal/AddEntraTenantModal.tsx @@ -1,6 +1,5 @@ import React, { useState, useContext } from "react"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; import configAPI from "services/entities/config"; @@ -8,6 +7,7 @@ import InputField from "components/forms/fields/InputField"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; import CustomLink from "components/CustomLink"; +import { notify } from "components/ToastNotification"; import { IAddTenantFormValidation, validateFormData } from "./helpers"; @@ -22,7 +22,6 @@ interface IAddEntraTenantModalProps { } const AddEntraTenantModal = ({ onExit }: IAddEntraTenantModalProps) => { - const { renderFlash } = useContext(NotificationContext); const { setConfig, config } = useContext(AppContext); const [isAdding, setIsAdding] = React.useState(false); @@ -54,7 +53,7 @@ const AddEntraTenantModal = ({ onExit }: IAddEntraTenantModalProps) => { const tenantIdExists = config?.mdm.windows_entra_tenant_ids?.includes(tenantId ?? "") ?? false; if (tenantIdExists) { - renderFlash("error", "Couldn't add tenant. Tenant ID already exists."); + notify.error("Couldn't add tenant. Tenant ID already exists."); return; } @@ -68,10 +67,12 @@ const AddEntraTenantModal = ({ onExit }: IAddEntraTenantModalProps) => { }, }); setConfig(updateData); - renderFlash("success", "Successfully added tenant"); + notify.success("Successfully added tenant"); onExit(); } catch (error) { - renderFlash("error", "Couldn't add tenant. Please try again"); + notify.error("Couldn't add tenant. Please try again", { + response: error, + }); } finally { setIsAdding(false); } diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/DeleteEntraClientIDModal/DeleteEntraClientIDModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/DeleteEntraClientIDModal/DeleteEntraClientIDModal.tsx index fb985b1de2..3f2daf82a7 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/DeleteEntraClientIDModal/DeleteEntraClientIDModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/DeleteEntraClientIDModal/DeleteEntraClientIDModal.tsx @@ -1,11 +1,11 @@ import React, { useContext, useState } from "react"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; import configAPI from "services/entities/config"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; +import { notify } from "components/ToastNotification"; const baseClass = "delete-entra-client-id-modal"; @@ -18,7 +18,6 @@ const DeleteEntraClientIdModal = ({ clientId, onExit, }: IDeleteEntraClientIdModalProps) => { - const { renderFlash } = useContext(NotificationContext); const { setConfig, config } = useContext(AppContext); const [isDeleting, setIsDeleting] = useState(false); @@ -35,10 +34,12 @@ const DeleteEntraClientIdModal = ({ }, }); setConfig(updateData); - renderFlash("success", "Client ID deleted successfully."); + notify.success("Client ID deleted successfully."); onExit(); } catch (err) { - renderFlash("error", "Couldn't delete client ID. Please try again."); + notify.error("Couldn't delete client ID. Please try again.", { + response: err, + }); } finally { setIsDeleting(false); } diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/DeleteEntraTenantModal/DeleteEntraTenantModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/DeleteEntraTenantModal/DeleteEntraTenantModal.tsx index 12289d9501..8cfb22a8a1 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/DeleteEntraTenantModal/DeleteEntraTenantModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/DeleteEntraTenantModal/DeleteEntraTenantModal.tsx @@ -1,11 +1,11 @@ import React, { useContext, useState } from "react"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; import configAPI from "services/entities/config"; import Modal from "components/Modal"; import Button from "components/buttons/Button"; +import { notify } from "components/ToastNotification"; const baseClass = "delete-entra-tenant-modal"; @@ -18,7 +18,6 @@ const DeleteEntraTenantModal = ({ tenantId, onExit, }: IDeleteEntraTenantModalProps) => { - const { renderFlash } = useContext(NotificationContext); const { setConfig, config } = useContext(AppContext); const [isDeleting, setIsDeleting] = useState(false); @@ -35,10 +34,12 @@ const DeleteEntraTenantModal = ({ }, }); setConfig(updateData); - renderFlash("success", "Tenant deleted successfully."); + notify.success("Tenant deleted successfully."); onExit(); } catch (err) { - renderFlash("error", "Couldn't delete tenant. Please try again."); + notify.error("Couldn't delete tenant. Please try again.", { + response: err, + }); } finally { setIsDeleting(false); } diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EndUserMigrationSection/EndUserMigrationSection.tests.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EndUserMigrationSection/EndUserMigrationSection.tests.tsx index af620d4093..e95475d2b3 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EndUserMigrationSection/EndUserMigrationSection.tests.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EndUserMigrationSection/EndUserMigrationSection.tests.tsx @@ -7,6 +7,15 @@ import { createCustomRenderer, createMockRouter } from "test/test-utils"; import EndUserMigrationSection from "./EndUserMigrationSection"; +jest.mock("components/ToastNotification", () => ({ + notify: { + success: jest.fn(), + error: jest.fn(), + batch: jest.fn(), + dismiss: jest.fn(), + }, +})); + const createTestMockData = ( configOverrides: Partial, isPremiumTier = true @@ -20,9 +29,6 @@ const createTestMockData = ( }), setConfig: jest.fn(), }, - notification: { - renderFlash: jest.fn(), - }, }, }; }; diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EndUserMigrationSection/EndUserMigrationSection.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EndUserMigrationSection/EndUserMigrationSection.tsx index 0887a5a581..8a2754bf3f 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EndUserMigrationSection/EndUserMigrationSection.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EndUserMigrationSection/EndUserMigrationSection.tsx @@ -7,7 +7,6 @@ import isURL from "validator/lib/isURL"; import PATHS from "router/paths"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import { getErrorReason } from "interfaces/errors"; @@ -23,6 +22,7 @@ import SectionHeader from "components/SectionHeader"; import PremiumFeatureMessage from "components/PremiumFeatureMessage/PremiumFeatureMessage"; import EmptyState from "components/EmptyState"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; +import { notify } from "components/ToastNotification"; import CustomLink from "components/CustomLink"; @@ -57,7 +57,6 @@ const validateWebhookUrl = (val: string) => { const EndUserMigrationSection = ({ router }: IEndUserMigrationSectionProps) => { const { config, isPremiumTier, setConfig } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); const [formData, setFormData] = useState({ isEnabled: config?.mdm.macos_migration.enable || false, @@ -117,7 +116,7 @@ const EndUserMigrationSection = ({ router }: IEndUserMigrationSectionProps) => { }, }, }); - renderFlash("success", "Successfully updated end user migration."); + notify.success("Successfully updated end user migration."); setConfig(updatedConfig); } catch (err) { if ( @@ -128,7 +127,7 @@ const EndUserMigrationSection = ({ router }: IEndUserMigrationSectionProps) => { setIsValidWebhookUrl(false); return; } - renderFlash("error", "Could not update. Please try again."); + notify.error("Could not update. Please try again.", { response: err }); } }; diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EulaSection/EulaSection.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EulaSection/EulaSection.tsx index 3d9d61634c..48c9a51e40 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EulaSection/EulaSection.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EulaSection/EulaSection.tsx @@ -1,7 +1,8 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import mdmAPI, { IEulaMetadataResponse } from "services/entities/mdm"; -import { NotificationContext } from "context/notification"; + +import { notify } from "components/ToastNotification"; import SettingsSection from "pages/admin/components/SettingsSection"; @@ -24,7 +25,6 @@ const EulaSection = ({ onUpload, onDelete, }: IEulaSectionProps) => { - const { renderFlash } = useContext(NotificationContext); const [showDeleteEulaModal, setShowDeleteEulaModal] = useState(false); const onDeleteEula = async () => { @@ -32,9 +32,9 @@ const EulaSection = ({ try { await mdmAPI.deleteEULA(eulaMetadata.token); - renderFlash("success", "Successfully deleted."); - } catch { - renderFlash("error", "Couldn’t delete. Please try again."); + notify.success("Successfully deleted."); + } catch (e) { + notify.error("Couldn’t delete. Please try again.", { response: e }); } finally { setShowDeleteEulaModal(false); onDelete(); diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EulaSection/components/EulaUploader/EulaUploader.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EulaSection/components/EulaUploader/EulaUploader.tsx index d9f15300f9..a94f4cd41e 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EulaSection/components/EulaUploader/EulaUploader.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/components/EulaSection/components/EulaUploader/EulaUploader.tsx @@ -1,12 +1,12 @@ -import React, { useContext, useState } from "react"; +import React, { useState } from "react"; import { AxiosResponse } from "axios"; import { IApiError } from "interfaces/errors"; import mdmAPI from "services/entities/mdm"; -import { NotificationContext } from "context/notification"; import FileUploader from "components/FileUploader/FileUploader"; import CustomLink from "components/CustomLink"; +import { notify } from "components/ToastNotification"; import { UPLOAD_ERROR_MESSAGES, getErrorMessage } from "./helpers"; @@ -17,7 +17,6 @@ interface IEulaUploaderProps { } const EulaUploader = ({ onUpload }: IEulaUploaderProps) => { - const { renderFlash } = useContext(NotificationContext); const [showLoading, setShowLoading] = useState(false); const onUploadFile = async (files: FileList | null) => { @@ -32,19 +31,19 @@ const EulaUploader = ({ onUpload }: IEulaUploaderProps) => { // quick exit if the file type is incorrect if (!file.name.includes(".pdf")) { - renderFlash("error", UPLOAD_ERROR_MESSAGES.wrongType.message); + notify.error(UPLOAD_ERROR_MESSAGES.wrongType.message); setShowLoading(false); return; } try { await mdmAPI.uploadEULA(file); - renderFlash("success", "Successfully updated end user authentication."); + notify.success("Successfully updated end user authentication."); onUpload(); } catch (e) { const error = e as AxiosResponse; const errMessage = getErrorMessage(error); - renderFlash("error", errMessage); + notify.error(errMessage, { response: e }); } finally { setShowLoading(false); } diff --git a/frontend/pages/admin/ManageFleetsPage/ManageFleetsPage.tsx b/frontend/pages/admin/ManageFleetsPage/ManageFleetsPage.tsx index 1733a1456d..2353af7e75 100644 --- a/frontend/pages/admin/ManageFleetsPage/ManageFleetsPage.tsx +++ b/frontend/pages/admin/ManageFleetsPage/ManageFleetsPage.tsx @@ -12,7 +12,6 @@ import { InjectedRouter } from "react-router"; import { LEARN_MORE_ABOUT_BASE_LINK, PRIMO_TOOLTIP } from "utilities/constants"; import { getGitOpsModeTipContent } from "utilities/helpers"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; import { ITeam as IFleet } from "interfaces/team"; import { IApiError } from "interfaces/errors"; @@ -29,6 +28,7 @@ import Button from "components/buttons/Button"; import CustomLink from "components/CustomLink"; import EmptyState from "components/EmptyState"; import PageDescription from "components/PageDescription"; +import { notify } from "components/ToastNotification"; import TooltipWrapper from "components/TooltipWrapper"; import CreateFleetModal from "./components/CreateFleetModal"; @@ -52,7 +52,6 @@ const ManageFleetsPage = ({ router, location, }: IManageFleetsPageProps): JSX.Element => { - const { renderFlash } = useContext(NotificationContext); const { currentTeam, setCurrentTeam, @@ -159,7 +158,7 @@ const ManageFleetsPage = ({ teamsAPI .create(formData) .then(() => { - renderFlash("success", `Successfully created ${formData.name}.`); + notify.success(`Successfully created ${formData.name}.`); setBackendValidators({}); toggleCreateFleetModal(); refetchMe(); @@ -184,7 +183,9 @@ const ManageFleetsPage = ({ name: `"${formData.name}" is a reserved fleet name. Please try another name.`, }); } else { - renderFlash("error", "Could not create fleet. Please try again."); + notify.error("Could not create fleet. Please try again.", { + response: createError, + }); toggleCreateFleetModal(); } }) @@ -192,7 +193,7 @@ const ManageFleetsPage = ({ setIsUpdatingFleets(false); }); }, - [toggleCreateFleetModal, refetchMe, refetchFleets, renderFlash] + [toggleCreateFleetModal, refetchMe, refetchFleets] ); const onDeleteSubmit = useCallback(() => { @@ -201,14 +202,13 @@ const ManageFleetsPage = ({ teamsAPI .destroy(fleetEditing.id) .then(() => { - renderFlash("success", `Successfully deleted ${fleetEditing.name}.`); + notify.success(`Successfully deleted ${fleetEditing.name}.`); if (currentTeam?.id === fleetEditing.id) { setCurrentTeam(undefined); } }) .catch(() => { - renderFlash( - "error", + notify.error( `Could not delete ${fleetEditing.name}. Please try again.` ); }) @@ -224,7 +224,6 @@ const ManageFleetsPage = ({ fleetEditing, refetchMe, refetchFleets, - renderFlash, setCurrentTeam, toggleDeleteFleetModal, ]); @@ -238,8 +237,7 @@ const ManageFleetsPage = ({ teamsAPI .update(formData, fleetEditing.id) .then(() => { - renderFlash( - "success", + notify.success( `Successfully updated fleet name to ${formData.name}.` ); setBackendValidators({}); @@ -271,9 +269,9 @@ const ManageFleetsPage = ({ name: `"Unassigned" is a reserved fleet name. Please try another name.`, }); } else { - renderFlash( - "error", - `Could not rename ${fleetEditing.name}. Please try again.` + notify.error( + `Could not rename ${fleetEditing.name}. Please try again.`, + { response: updateError } ); } }) @@ -282,7 +280,7 @@ const ManageFleetsPage = ({ }); } }, - [fleetEditing, toggleRenameFleetModal, refetchFleets, renderFlash] + [fleetEditing, toggleRenameFleetModal, refetchFleets] ); const onActionSelection = useCallback( diff --git a/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/AgentOptionsPage/AgentOptionsPage.tsx b/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/AgentOptionsPage/AgentOptionsPage.tsx index e36d5f7d51..6324403a83 100644 --- a/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/AgentOptionsPage/AgentOptionsPage.tsx +++ b/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/AgentOptionsPage/AgentOptionsPage.tsx @@ -5,7 +5,6 @@ import yaml from "js-yaml"; import { constructErrorString, agentOptionsToYaml } from "utilities/yaml"; import { EMPTY_AGENT_OPTIONS } from "utilities/constants"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; import useTeamIdParam from "hooks/useTeamIdParam"; @@ -22,6 +21,7 @@ import Spinner from "components/Spinner"; import CustomLink from "components/CustomLink"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; import PageDescription from "components/PageDescription"; +import { notify } from "components/ToastNotification"; // @ts-ignore import YamlAce from "components/YamlAce"; import { ITeamSubnavProps } from "interfaces/team_subnav"; @@ -32,7 +32,6 @@ const AgentOptionsPage = ({ location, router, }: ITeamSubnavProps): JSX.Element => { - const { renderFlash } = useContext(NotificationContext); const gitOpsModeEnabled = useContext(AppContext).config?.gitops .gitops_mode_enabled; @@ -110,10 +109,7 @@ const AgentOptionsPage = ({ osqueryOptionsAPI .updateTeam(teamIdForApi, formDataToSubmit) .then(() => { - renderFlash( - "success", - `Successfully updated ${teamName} fleet agent options.` - ); + notify.success(`Successfully updated ${teamName} fleet agent options.`); refetchTeamOptions(); }) .catch((response: { data: IApiError }) => { @@ -123,8 +119,7 @@ const AgentOptionsPage = ({ reason.includes("unsupported key provided") || reason.includes("invalid value type"); - renderFlash( - "error", + notify.error( <> Couldn't update {teamName} fleet agent options: {reason} @@ -135,7 +130,8 @@ const AgentOptionsPage = ({ apply --force command to override validation. )} - + , + { response } ); }) .finally(() => { diff --git a/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx b/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx index 2d68d20f15..bf4ffa8935 100644 --- a/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx +++ b/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/TeamDetailsWrapper.tsx @@ -4,7 +4,6 @@ import { useErrorHandler } from "react-error-boundary"; import { InjectedRouter } from "react-router"; import { Tab, TabList, Tabs } from "react-tabs"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; import useTeamIdParam from "hooks/useTeamIdParam"; import { @@ -29,6 +28,7 @@ import TabText from "components/TabText"; import BackButton from "components/BackButton"; import TeamsDropdown from "components/TeamsDropdown"; import MainContent from "components/MainContent"; +import { notify } from "components/ToastNotification"; import DeleteFleetModal from "../components/DeleteFleetModal"; import RenameFleetModal from "../components/RenameFleetModal"; import DeleteSecretModal from "../../../../components/EnrollSecrets/DeleteSecretModal"; @@ -92,7 +92,6 @@ const TeamDetailsWrapper = ({ children, location, }: ITeamDetailsPageProps): JSX.Element => { - const { renderFlash } = useContext(NotificationContext); const handlePageError = useErrorHandler(); const { isGlobalAdmin, @@ -252,17 +251,16 @@ const TeamDetailsWrapper = ({ toggleSecretEditorModal(); isPremiumTier && refetchTeams(); - renderFlash( - "success", + notify.success( `Successfully ${selectedSecret ? "edited" : "added"} enroll secret.` ); } catch (error) { console.error(error); - renderFlash( - "error", + notify.error( `Could not ${ selectedSecret ? "edit" : "add" - } enroll secret. Please try again.` + } enroll secret. Please try again.`, + { response: error } ); } finally { setIsUpdatingSecret(false); @@ -282,10 +280,12 @@ const TeamDetailsWrapper = ({ refetchTeamSecrets(); toggleDeleteSecretModal(); refetchTeams(); - renderFlash("success", `Successfully deleted enroll secret.`); + notify.success(`Successfully deleted enroll secret.`); } catch (error) { console.error(error); - renderFlash("error", "Could not delete enroll secret. Please try again."); + notify.error("Could not delete enroll secret. Please try again.", { + response: error, + }); } finally { setIsUpdatingSecret(false); } @@ -301,15 +301,15 @@ const TeamDetailsWrapper = ({ try { await teamsAPI.destroy(teamIdForApi); router.push(PATHS.ADMIN_FLEETS); - renderFlash("success", "Fleet removed"); + notify.success("Fleet removed"); } catch (response) { - renderFlash("error", "Something went wrong removing the fleet"); + notify.error("Something went wrong removing the fleet", { response }); console.error(response); } finally { toggleDeleteFleetModal(); setIsUpdatingTeams(false); } - }, [teamIdForApi, renderFlash, router, toggleDeleteFleetModal]); + }, [teamIdForApi, router, toggleDeleteFleetModal]); const onEditSubmit = useCallback( async (formData: ITeamFormData) => { @@ -326,8 +326,7 @@ const TeamDetailsWrapper = ({ setIsUpdatingTeams(true); try { await teamsAPI.update(updatedAttrs, teamIdForApi); - renderFlash( - "success", + notify.success( `Successfully updated fleet name to ${updatedAttrs?.name}` ); setBackendValidators({}); @@ -350,7 +349,9 @@ const TeamDetailsWrapper = ({ name: `"Unassigned" is a reserved fleet name. Please try another name.`, }); } else { - renderFlash("error", "Could not create fleet. Please try again."); + notify.error("Could not create fleet. Please try again.", { + response, + }); } } finally { setIsUpdatingTeams(false); @@ -360,7 +361,6 @@ const TeamDetailsWrapper = ({ currentTeamDetails, toggleRenameFleetModal, teamIdForApi, - renderFlash, refetchTeams, refetchMe, ] diff --git a/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/TeamSettings/TeamSettings.tsx b/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/TeamSettings/TeamSettings.tsx index b7a54caa36..5cab59dfcb 100644 --- a/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/TeamSettings/TeamSettings.tsx +++ b/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/TeamSettings/TeamSettings.tsx @@ -1,15 +1,7 @@ -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useQuery } from "react-query"; -import { NotificationContext } from "context/notification"; - import useTeamIdParam from "hooks/useTeamIdParam"; import { @@ -38,6 +30,7 @@ import DataError from "components/DataError"; import InputField from "components/forms/fields/InputField"; import Spinner from "components/Spinner"; import SectionHeader from "components/SectionHeader"; +import { notify } from "components/ToastNotification"; // @ts-ignore import Dropdown from "components/forms/fields/Dropdown"; import Checkbox from "components/forms/fields/Checkbox"; @@ -139,8 +132,6 @@ const TeamSettings = ({ location, router }: ITeamSubnavProps) => { setShowHostStatusWebhookPreviewModal(!showHostStatusWebhookPreviewModal); }; - const { renderFlash } = useContext(NotificationContext); - const { isRouteOk, teamIdForApi } = useTeamIdParam({ location, router, @@ -321,27 +312,21 @@ const TeamSettings = ({ location, router }: ITeamSubnavProps) => { teamIdForApi ) .then(() => { - renderFlash("success", "Successfully updated settings."); + notify.success("Successfully updated settings."); refetchTeamConfig(); setIsInitialTeamConfig(false); setConfirmModalOpen(false); }) .catch((errorResponse: { data: IApiError }) => { - renderFlash( - "error", - `Could not update fleet settings. ${errorResponse.data.errors[0].reason}` + notify.error( + `Could not update fleet settings. ${errorResponse.data.errors[0].reason}`, + { response: errorResponse } ); }) .finally(() => { setUpdatingTeamSettings(false); }); - }, [ - formData, - globalHostExpiryEnabled, - refetchTeamConfig, - renderFlash, - teamIdForApi, - ]); + }, [formData, globalHostExpiryEnabled, refetchTeamConfig, teamIdForApi]); const updateTeamSettings = useCallback( (evt: React.MouseEvent) => { diff --git a/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx b/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx index 6cc9b9f9d4..0a82079cd4 100644 --- a/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx +++ b/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/UsersPage/UsersPage.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useContext, useMemo, useState } from "react"; import { useQuery } from "react-query"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import useTeamIdParam from "hooks/useTeamIdParam"; import { IApiError } from "interfaces/errors"; import { INewTeamUsersFormData, ITeam } from "interfaces/team"; @@ -19,6 +18,7 @@ import Spinner from "components/Spinner"; import PageDescription from "components/PageDescription"; import CustomLink from "components/CustomLink"; import TableCount from "components/TableContainer/TableCount"; +import { notify } from "components/ToastNotification"; import AddUserModal from "pages/admin/ManageUsersPage/components/AddUserModal"; import EditUserModal from "../../../ManageUsersPage/components/EditUserModal"; import { @@ -40,7 +40,6 @@ const baseClass = "team-users"; const noUsersClass = "no-team-users"; const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { - const { renderFlash } = useContext(NotificationContext); const { config, currentUser, isGlobalAdmin, isPremiumTier } = useContext( AppContext ); @@ -144,18 +143,13 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { teamsAPI .removeUsers(teamIdForApi, removedUsers) .then(() => { - renderFlash( - "success", - `Successfully removed ${userEditing?.name || "user"}` - ); + notify.success(`Successfully removed ${userEditing?.name || "user"}`); // If user removes self from team, redirect to home if (currentUser && currentUser.id === removedUsers.users[0].id) { window.location.href = PATHS.ROOT; } }) - .catch(() => - renderFlash("error", "Unable to remove users. Please try again.") - ) + .catch(() => notify.error("Unable to remove users. Please try again.")) .finally(() => { setIsUpdatingUsers(false); toggleRemoveUserModal(); @@ -165,7 +159,6 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { userEditing?.id, userEditing?.name, teamIdForApi, - renderFlash, currentUser, toggleRemoveUserModal, refetchUsers, @@ -177,16 +170,13 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { .addUsers(currentTeamDetails?.id, newUsers) .then(() => { const count = newUsers.users.length; - renderFlash( - "success", + notify.success( `${count} ${count === 1 ? "user" : "users"} successfully added to ${ currentTeamDetails?.name }.` ); }) - .catch(() => - renderFlash("error", "Could not add users. Please try again.") - ) + .catch(() => notify.error("Could not add users. Please try again.")) .finally(() => { toggleAddUserModal(); refetchUsers(); @@ -195,7 +185,6 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { [ currentTeamDetails?.id, currentTeamDetails?.name, - renderFlash, toggleAddUserModal, refetchUsers, ] @@ -218,8 +207,7 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { const senderAddressMessage = config?.smtp_settings?.sender_address ? ` from ${config?.smtp_settings?.sender_address}` : ""; - renderFlash( - "success", + notify.success( `An invitation email was sent${senderAddressMessage} to ${formData.email}.` ); refetchUsers(); @@ -242,7 +230,9 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { email: "A user with this email address has already been invited", }); } else { - renderFlash("error", "Could not invite user. Please try again."); + notify.error("Could not invite user. Please try again.", { + response: userErrors, + }); } }) .finally(() => { @@ -257,7 +247,7 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { usersAPI .createUserWithoutInvitation(requestData) .then(() => { - renderFlash("success", `Successfully created ${requestData.name}.`); + notify.success(`Successfully created ${requestData.name}.`); refetchUsers(); toggleCreateUserModal(); }) @@ -279,7 +269,9 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { password: "Password is over the character limit.", }); } else { - renderFlash("error", "Could not create user. Please try again."); + notify.error("Could not create user. Please try again.", { + response: userErrors, + }); } }) .finally(() => { @@ -303,10 +295,7 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { usersAPI .update(userEditing.id, updatedAttrs) .then(() => { - renderFlash( - "success", - `Successfully edited ${userName || "user"}.` - ); + notify.success(`Successfully edited ${userName || "user"}.`); if ( currentUser && @@ -332,9 +321,9 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { email: "A user with this email address already exists", }); } else { - renderFlash( - "error", - `Could not edit ${userName || "user"}. Please try again.` + notify.error( + `Could not edit ${userName || "user"}. Please try again.`, + { response: userErrors } ); } }) @@ -342,14 +331,7 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => { setIsUpdatingUsers(false); }); }, - [ - userEditing, - renderFlash, - currentUser, - toggleEditUserModal, - teamIdForApi, - refetchUsers, - ] + [userEditing, currentUser, toggleEditUserModal, teamIdForApi, refetchUsers] ); const onActionSelection = useCallback( diff --git a/frontend/pages/admin/ManageUsersPage/CreateApiUserPage/CreateApiUserPage.tsx b/frontend/pages/admin/ManageUsersPage/CreateApiUserPage/CreateApiUserPage.tsx index 55c1e01ee1..5079543aa2 100644 --- a/frontend/pages/admin/ManageUsersPage/CreateApiUserPage/CreateApiUserPage.tsx +++ b/frontend/pages/admin/ManageUsersPage/CreateApiUserPage/CreateApiUserPage.tsx @@ -4,7 +4,6 @@ import { useQuery } from "react-query"; import PATHS from "router/paths"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import { ITeam } from "interfaces/team"; import teamsAPI, { ILoadTeamsResponse } from "services/entities/teams"; import usersAPI from "services/entities/users"; @@ -12,6 +11,7 @@ import usersAPI from "services/entities/users"; import BackButton from "components/BackButton"; import MainContent from "components/MainContent"; import PageDescription from "components/PageDescription"; +import { notify } from "components/ToastNotification"; import ApiUserForm from "../components/ApiUserForm"; import { IApiUserFormData } from "../components/ApiUserForm/ApiUserForm"; import ApiKeyDisplay from "../components/ApiKeyDisplay"; @@ -24,7 +24,6 @@ interface ICreateApiUserPageProps { const CreateApiUserPage = ({ router }: ICreateApiUserPageProps) => { const { isPremiumTier } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); const [isSubmitting, setIsSubmitting] = useState(false); const [apiKey, setApiKey] = useState(null); @@ -57,15 +56,14 @@ const CreateApiUserPage = ({ router }: ICreateApiUserPageProps) => { if (response.token) { setApiKey(response.token); } else { - renderFlash( - "warning-filled", + notify.error( `${formData.name} has been created, but the API key could not be retrieved. Contact your administrator.` ); router.push(PATHS.ADMIN_USERS); } }) .catch(() => { - renderFlash("error", "Could not create user. Please try again."); + notify.error("Could not create user. Please try again."); }) .finally(() => { setIsSubmitting(false); @@ -73,7 +71,7 @@ const CreateApiUserPage = ({ router }: ICreateApiUserPageProps) => { }; const handleDone = () => { - renderFlash("success", `${createdUserName} has been created!`); + notify.success(`${createdUserName} has been created!`); router.push(PATHS.ADMIN_USERS); }; diff --git a/frontend/pages/admin/ManageUsersPage/CreateUserPage/CreateUserPage.tsx b/frontend/pages/admin/ManageUsersPage/CreateUserPage/CreateUserPage.tsx index ebced20239..90487a691b 100644 --- a/frontend/pages/admin/ManageUsersPage/CreateUserPage/CreateUserPage.tsx +++ b/frontend/pages/admin/ManageUsersPage/CreateUserPage/CreateUserPage.tsx @@ -4,7 +4,6 @@ import { useQuery } from "react-query"; import PATHS from "router/paths"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import { IApiError } from "interfaces/errors"; import { ITeam } from "interfaces/team"; import { IUserFormErrors } from "interfaces/user"; @@ -14,6 +13,7 @@ import invitesAPI from "services/entities/invites"; import BackButton from "components/BackButton"; import MainContent from "components/MainContent"; +import { notify } from "components/ToastNotification"; import UserForm from "../components/UserForm"; import { IUserFormData, NewUserType } from "../components/UserForm/UserForm"; @@ -25,7 +25,6 @@ interface ICreateUserPageProps { const CreateUserPage = ({ router }: ICreateUserPageProps) => { const { config, currentUser, isPremiumTier } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); const [formErrors, setFormErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); @@ -53,7 +52,7 @@ const CreateUserPage = ({ router }: ICreateUserPageProps) => { invitesAPI .create(requestData) .then(() => { - renderFlash("success", `${formData.name} has been invited!`); + notify.success(`${formData.name} has been invited!`); router.push(PATHS.ADMIN_USERS); }) .catch((userErrors: { data: IApiError }) => { @@ -74,7 +73,9 @@ const CreateUserPage = ({ router }: ICreateUserPageProps) => { password: "Password is over the character limit.", }); } else { - renderFlash("error", "Could not create user. Please try again."); + notify.error("Could not create user. Please try again.", { + response: userErrors, + }); } }) .finally(() => { @@ -89,7 +90,7 @@ const CreateUserPage = ({ router }: ICreateUserPageProps) => { usersAPI .createUserWithoutInvitation(requestData) .then(() => { - renderFlash("success", `${requestData.name} has been created!`); + notify.success(`${requestData.name} has been created!`); router.push(PATHS.ADMIN_USERS); }) .catch((userErrors: { data: IApiError }) => { @@ -110,7 +111,9 @@ const CreateUserPage = ({ router }: ICreateUserPageProps) => { password: "Password is over the character limit.", }); } else { - renderFlash("error", "Could not create user. Please try again."); + notify.error("Could not create user. Please try again.", { + response: userErrors, + }); } }) .finally(() => { diff --git a/frontend/pages/admin/ManageUsersPage/EditUserPage/EditUserPage.tsx b/frontend/pages/admin/ManageUsersPage/EditUserPage/EditUserPage.tsx index 504de6f396..5de454eb59 100644 --- a/frontend/pages/admin/ManageUsersPage/EditUserPage/EditUserPage.tsx +++ b/frontend/pages/admin/ManageUsersPage/EditUserPage/EditUserPage.tsx @@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from "react-query"; import PATHS from "router/paths"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import { IApiError } from "interfaces/errors"; import { ITeam } from "interfaces/team"; import { IInvite, IEditInviteFormData } from "interfaces/invite"; @@ -15,6 +14,7 @@ import invitesAPI from "services/entities/invites"; import BackButton from "components/BackButton"; import MainContent from "components/MainContent"; +import { notify } from "components/ToastNotification"; import Spinner from "components/Spinner"; import DataError from "components/DataError"; import UserForm from "../components/UserForm"; @@ -36,7 +36,6 @@ const EditUserPage = ({ router, params, location }: IEditUserPageProps) => { const entityId = parseInt(params.user_id, 10); const isInvite = location.query?.type === "invite"; const { config, isPremiumTier } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); const queryClient = useQueryClient(); const [formErrors, setFormErrors] = useState({}); @@ -92,7 +91,7 @@ const EditUserPage = ({ router, params, location }: IEditUserPageProps) => { if (entityData.email !== formData.email) { msg += `. A confirmation email was sent to ${formData.email}.`; } - renderFlash("success", msg); + notify.success(msg); router.push(PATHS.ADMIN_USERS); }) .catch((inviteErrors: { data: IApiError }) => { @@ -101,9 +100,9 @@ const EditUserPage = ({ router, params, location }: IEditUserPageProps) => { email: "A user with this email address already exists", }); } else { - renderFlash( - "error", - `Could not edit ${entityData.name}. Please try again.` + notify.error( + `Could not edit ${entityData.name}. Please try again.`, + { response: inviteErrors } ); } }) @@ -141,7 +140,7 @@ const EditUserPage = ({ router, params, location }: IEditUserPageProps) => { .update(entityId, requestData) .then(() => { queryClient.invalidateQueries(["user", entityId]); - renderFlash("success", successMessage); + notify.success(successMessage); router.push(PATHS.ADMIN_USERS); }) .catch((userErrors: { data: IApiError }) => { @@ -156,10 +155,9 @@ const EditUserPage = ({ router, params, location }: IEditUserPageProps) => { password: "Password must meet the criteria below", }); } else { - renderFlash( - "error", - `Could not edit ${entityData.name}. Please try again.` - ); + notify.error(`Could not edit ${entityData.name}. Please try again.`, { + response: userErrors, + }); } }) .finally(() => { @@ -184,14 +182,11 @@ const EditUserPage = ({ router, params, location }: IEditUserPageProps) => { }) .then(() => { queryClient.invalidateQueries(["user", entityId]); - renderFlash("success", `Successfully edited ${formData.name}.`); + notify.success(`Successfully edited ${formData.name}.`); router.push(PATHS.ADMIN_USERS); }) .catch(() => { - renderFlash( - "error", - `Could not edit ${entityData.name}. Please try again.` - ); + notify.error(`Could not edit ${entityData.name}. Please try again.`); }) .finally(() => { setIsSubmitting(false); diff --git a/frontend/pages/admin/ManageUsersPage/components/UserForm/UserForm.tsx b/frontend/pages/admin/ManageUsersPage/components/UserForm/UserForm.tsx index b57f4e91f9..8ac9ebd2ee 100644 --- a/frontend/pages/admin/ManageUsersPage/components/UserForm/UserForm.tsx +++ b/frontend/pages/admin/ManageUsersPage/components/UserForm/UserForm.tsx @@ -3,7 +3,6 @@ import PATHS from "router/paths"; import { PRIMO_TOOLTIP } from "utilities/constants"; -import { NotificationContext } from "context/notification"; import { AppContext } from "context/app"; import { ITeam } from "interfaces/team"; @@ -15,6 +14,7 @@ import Button from "components/buttons/Button"; import DropdownWrapper from "components/forms/fields/DropdownWrapper"; import { CustomOptionType } from "components/forms/fields/DropdownWrapper/DropdownWrapper"; import ModalFooter from "components/ModalFooter"; +import { notify } from "components/ToastNotification"; import validatePresence from "components/forms/validators/validate_presence"; import validEmail from "components/forms/validators/valid_email"; // @ts-ignore @@ -150,7 +150,6 @@ const UserForm = ({ ancestorErrors, isUpdatingUsers, }: IUserFormProps): JSX.Element => { - const { renderFlash } = useContext(NotificationContext); const { config } = useContext(AppContext); const priMode = config?.partnerships?.enable_primo; @@ -319,10 +318,10 @@ const UserForm = ({ const onFormSubmit = (evt: FormEvent): void => { evt.preventDefault(); - // separate from `validate` function as it uses `renderFlash` hook, incompatible with pure - // `validate` function + // separate from `validate` function as it renders a toast notification, incompatible with + // pure `validate` function if (!formData.global_role && !formData.teams.length) { - renderFlash("error", `Please select at least one fleet for this user.`); + notify.error(`Please select at least one fleet for this user.`); return; } const errs = validate( diff --git a/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTable.tsx b/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTable.tsx index 15680102e8..b29d8de214 100644 --- a/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTable.tsx +++ b/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTable.tsx @@ -9,7 +9,6 @@ import { IDropdownOption } from "interfaces/dropdownOption"; import authToken from "utilities/auth_token"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import usersAPI from "services/entities/users"; import invitesAPI from "services/entities/invites"; @@ -19,6 +18,7 @@ import TableCount from "components/TableContainer/TableCount"; import TableDataError from "components/DataError"; import ActionsDropdown from "components/ActionsDropdown"; import EmptyState from "components/EmptyState"; +import { notify } from "components/ToastNotification"; import { generateTableHeaders, combineDataSets, @@ -53,7 +53,6 @@ interface IUsersTableProps { } const UsersTable = ({ router }: IUsersTableProps): JSX.Element => { const { currentUser, isPremiumTier } = useContext(AppContext); - const { renderFlash } = useContext(NotificationContext); // STATES const [showDeleteUserModal, setShowDeleteUserModal] = useState(false); @@ -174,11 +173,10 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => { invitesAPI .destroy(userEditing.apiId) .then(() => { - renderFlash("success", `Successfully deleted ${userEditing?.name}.`); + notify.success(`Successfully deleted ${userEditing?.name}.`); }) .catch(() => { - renderFlash( - "error", + notify.error( `Could not delete ${userEditing?.name}. Please try again.` ); }) @@ -191,11 +189,10 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => { usersAPI .destroy(userEditing.apiId) .then(() => { - renderFlash("success", `Successfully deleted ${userEditing?.name}.`); + notify.success(`Successfully deleted ${userEditing?.name}.`); }) .catch(() => { - renderFlash( - "error", + notify.error( `Could not delete ${userEditing?.name}. Please try again.` ); }) @@ -222,10 +219,10 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => { }, 500); return; } - renderFlash("success", "Successfully reset sessions."); + notify.success("Successfully reset sessions."); }) .catch(() => { - renderFlash("error", "Could not reset sessions. Please try again."); + notify.error("Could not reset sessions. Please try again."); }) .finally(() => { toggleResetSessionsUserModal(); @@ -237,13 +234,10 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => { usersAPI .requirePasswordReset(userEditing.apiId, { require: true }) .then(() => { - renderFlash("success", "Successfully required a password reset."); + notify.success("Successfully required a password reset."); }) .catch(() => { - renderFlash( - "error", - "Could not require a password reset. Please try again." - ); + notify.error("Could not require a password reset. Please try again."); }) .finally(() => { toggleResetPasswordUserModal(); diff --git a/frontend/pages/admin/OrgSettingsPage/OrgSettingsPage.tsx b/frontend/pages/admin/OrgSettingsPage/OrgSettingsPage.tsx index 6afb6d41e7..e96c7952a4 100644 --- a/frontend/pages/admin/OrgSettingsPage/OrgSettingsPage.tsx +++ b/frontend/pages/admin/OrgSettingsPage/OrgSettingsPage.tsx @@ -7,9 +7,9 @@ import { IConfig } from "interfaces/config"; import { IApiError } from "interfaces/errors"; import configAPI from "services/entities/config"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import deepDifference from "utilities/deep_difference"; import Spinner from "components/Spinner"; +import { notify } from "components/ToastNotification"; import paths from "router/paths"; import SideNav from "../components/SideNav"; @@ -36,7 +36,6 @@ const OrgSettingsPage = ({ params, router }: IOrgSettingsPageProps) => { // redirect to Integrations page in sandbox mode router.push(paths.ADMIN_INTEGRATIONS); } - const { renderFlash } = useContext(NotificationContext); const handlePageError = useErrorHandler(); const { @@ -64,17 +63,16 @@ const OrgSettingsPage = ({ params, router }: IOrgSettingsPageProps) => { try { await configAPI.update(diff); - renderFlash("success", "Successfully updated settings."); + notify.success("Successfully updated settings."); refetchConfig(); return true; } catch (response) { const resp = response as undefined | { data: IApiError }; if (resp?.data.errors[0].reason.includes("could not dial smtp host")) { - renderFlash( - "error", - "Could not connect to SMTP server. Please try again." - ); + notify.error("Could not connect to SMTP server. Please try again.", { + response, + }); } else if (resp?.data.errors) { const reason = resp?.data.errors[0].reason; const agentOptionsInvalid = @@ -83,8 +81,7 @@ const OrgSettingsPage = ({ params, router }: IOrgSettingsPageProps) => { const isAgentOptionsError = agentOptionsInvalid || reason.includes("script_execution_timeout' value exceeds limit."); - renderFlash( - "error", + notify.error( <> Couldn't update{" "} {isAgentOptionsError ? "agent options" : "settings"}: {reason} @@ -95,7 +92,8 @@ const OrgSettingsPage = ({ params, router }: IOrgSettingsPageProps) => { apply --force command to override validation. )} - + , + { response } ); } return false; @@ -103,7 +101,7 @@ const OrgSettingsPage = ({ params, router }: IOrgSettingsPageProps) => { setIsUpdatingSettings(false); } }, - [appConfig, refetchConfig, renderFlash] + [appConfig, refetchConfig] ); // filter out non-premium options diff --git a/frontend/pages/admin/OrgSettingsPage/cards/Info/Info.tsx b/frontend/pages/admin/OrgSettingsPage/cards/Info/Info.tsx index e15439621e..96e41895e9 100644 --- a/frontend/pages/admin/OrgSettingsPage/cards/Info/Info.tsx +++ b/frontend/pages/admin/OrgSettingsPage/cards/Info/Info.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useQueryClient } from "react-query"; import { IInputFieldParseTarget } from "interfaces/form_field"; @@ -15,9 +15,9 @@ import OrgLogoIcon from "components/icons/OrgLogoIcon"; import validUrl from "components/forms/validators/valid_url"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; import TooltipWrapper from "components/TooltipWrapper"; +import { notify } from "components/ToastNotification"; import logoAPI from "services/entities/logo"; -import { NotificationContext } from "context/notification"; import { ORG_LOGO_ACCEPT, validateOrgLogoFile, @@ -132,7 +132,6 @@ const Info = ({ handleSubmit, isUpdatingSettings, }: IAppConfigFormProps): JSX.Element => { - const { renderFlash } = useContext(NotificationContext); const queryClient = useQueryClient(); const gitOpsModeEnabled = appConfig.gitops.gitops_mode_enabled; @@ -233,7 +232,7 @@ const Info = ({ if (!file) return; const result = await validateOrgLogoFile(file); if (!result.valid) { - renderFlash("error", result.error || "Invalid logo file."); + notify.error(result.error || "Invalid logo file."); return; } setLogoFile(mode === "light" ? setLightLogo : setDarkLogo, file); @@ -275,10 +274,9 @@ const Info = ({ }; orgInfoOk = await handleSubmit(formDataToSubmit); } catch (e) { - renderFlash( - "error", - "Couldn't save organization info. Please try again." - ); + notify.error("Couldn't save organization info. Please try again.", { + response: e, + }); return; } if (!orgInfoOk) return; @@ -340,10 +338,7 @@ const Info = ({ if (failedModes.length > 0) { const label = failedModes.map((m) => `${m} mode`).join(" and "); - renderFlash( - "error", - `Couldn't update the ${label} logo. Please try again.` - ); + notify.error(`Couldn't update the ${label} logo. Please try again.`); } } finally { setIsSaving(false); diff --git a/frontend/pages/admin/components/DownloadFileButtons/DownloadABMKey.tsx b/frontend/pages/admin/components/DownloadFileButtons/DownloadABMKey.tsx index 1014739663..a4db97a7b5 100644 --- a/frontend/pages/admin/components/DownloadFileButtons/DownloadABMKey.tsx +++ b/frontend/pages/admin/components/DownloadFileButtons/DownloadABMKey.tsx @@ -1,17 +1,11 @@ -import React, { - FormEvent, - useCallback, - useMemo, - useState, - useContext, -} from "react"; +import React, { FormEvent, useCallback, useMemo, useState } from "react"; import mdmAppleBusinessManagerApi from "services/entities/mdm_apple_bm"; -import { NotificationContext } from "context/notification"; import { getErrorReason } from "interfaces/errors"; import Icon from "components/Icon"; import Button from "components/buttons/Button"; +import { notify } from "components/ToastNotification"; import { downloadBase64ToFile, RequestState } from "./helpers"; interface IDownloadABMKeyProps { @@ -31,7 +25,6 @@ const useDownloadABMKey = ({ onError, }: Omit) => { const [downloadState, setDownloadState] = useState(undefined); - const { renderFlash } = useContext(NotificationContext); const handleDownload = useCallback( async (evt: FormEvent) => { @@ -44,7 +37,7 @@ const useDownloadABMKey = ({ onSuccess && onSuccess(); } catch (e) { const msg = getErrorReason(e); - renderFlash("error", msg); + notify.error(msg, { response: e }); setDownloadState("error"); onError && onError(e); } diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index a7207cfce7..aca3fb6fd7 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -48,7 +48,6 @@ import { import PATHS from "router/paths"; import { AppContext } from "context/app"; import { TableContext } from "context/table"; -import { NotificationContext } from "context/notification"; import useTeamIdParam from "hooks/useTeamIdParam"; @@ -84,6 +83,7 @@ import { getNextLocationPath } from "utilities/helpers"; import getDeleteLabelErrorMessages from "pages/labels/helpers"; import { strToBool } from "utilities/strings/stringUtils"; +import { notify } from "components/ToastNotification"; import Button from "components/buttons/Button"; import Icon from "components/Icon/Icon"; import { SingleValue } from "react-select-5"; @@ -171,7 +171,6 @@ const ManageHostsPage = ({ setFilteredSoftwarePath, } = useContext(AppContext); const isPrimoMode = config?.partnerships?.enable_primo; - const { renderFlash } = useContext(NotificationContext); const { setResetSelectedRows } = useContext(TableContext); @@ -1031,14 +1030,16 @@ const ManageHostsPage = ({ await usersAPI.update(currentUser.id, { settings: { ...userSettings, hidden_host_columns: newHiddenColumns }, }); - // No success renderFlash, to make column setting more seamless + // No success toast, to make column setting more seamless // only set state and close modal if server persist succeeds, keeping UI and server state in // sync. // Can also add local storage fallback behavior in next iteration if we want. setHiddenColumns(newHiddenColumns); setShowEditColumnsModal(false); } catch (response) { - renderFlash("error", "Couldn't save column settings. Please try again."); + notify.error("Couldn't save column settings. Please try again.", { + response, + }); } }; @@ -1269,17 +1270,16 @@ const ManageHostsPage = ({ queryParams, }) ); - renderFlash( - "success", + notify.success( `Successfully ${selectedSecret ? "edited" : "added"} enroll secret.` ); } catch (error) { console.error(error); - renderFlash( - "error", + notify.error( `Could not ${ selectedSecret ? "edit" : "add" - } enroll secret. Please try again.` + } enroll secret. Please try again.`, + { response: error } ); } finally { setIsUpdating(false); @@ -1321,10 +1321,12 @@ const ManageHostsPage = ({ queryParams, }) ); - renderFlash("success", `Successfully deleted enroll secret.`); + notify.success(`Successfully deleted enroll secret.`); } catch (error) { console.error(error); - renderFlash("error", "Could not delete enroll secret. Please try again."); + notify.error("Could not delete enroll secret. Please try again.", { + response: error, + }); } finally { setIsUpdating(false); } @@ -1351,9 +1353,9 @@ const ManageHostsPage = ({ queryParams, }) ); - renderFlash("success", "Successfully deleted label."); + notify.success("Successfully deleted label."); } catch (error) { - renderFlash("error", getDeleteLabelErrorMessages(error)); + notify.error(getDeleteLabelErrorMessages(error), { response: error }); } finally { setIsUpdating(false); } @@ -1418,14 +1420,16 @@ const ManageHostsPage = ({ ? `Hosts successfully removed from fleets.` : `Hosts successfully transferred to ${transferTeam.name}.`; - renderFlash("success", successMessage); + notify.success(successMessage); setResetSelectedRows(true); refetchHosts(); toggleTransferHostModal(); setSelectedHostIds([]); setIsAllMatchingHostsSelected(false); } catch (error) { - renderFlash("error", "Could not transfer hosts. Please try again."); + notify.error("Could not transfer hosts. Please try again.", { + response: error, + }); } finally { setIsUpdating(false); } @@ -1465,7 +1469,7 @@ const ManageHostsPage = ({ const successMessage = "Hosts successfully deleted."; - renderFlash("success", successMessage); + notify.success(successMessage); setResetSelectedRows(true); refetchHosts(); refetchLabels(); @@ -1473,7 +1477,9 @@ const ManageHostsPage = ({ setSelectedHostIds([]); setIsAllMatchingHostsSelected(false); } catch (error) { - renderFlash("error", "Could not delete hosts. Please try again."); + notify.error("Could not delete hosts. Please try again.", { + response: error, + }); } finally { setIsUpdating(false); } @@ -1697,7 +1703,9 @@ const ManageHostsPage = ({ FileSaver.saveAs(file); } catch (error) { console.error(error); - renderFlash("error", "Could not export hosts. Please try again."); + notify.error("Could not export hosts. Please try again.", { + response: error, + }); } }, [ @@ -1736,7 +1744,6 @@ const ManageHostsPage = ({ depAssignProfileResponse, hiddenColumns, queryParams.fleet_id, - renderFlash, ] ); diff --git a/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/RunScriptBatchModal.tsx b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/RunScriptBatchModal.tsx index f4b7286d37..8109a5916d 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/RunScriptBatchModal.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/RunScriptBatchModal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { useQuery } from "react-query"; import PATHS from "router/paths"; @@ -11,7 +11,7 @@ import InputField from "components/forms/fields/InputField"; import TooltipWrapper from "components/TooltipWrapper"; import CustomLink from "components/CustomLink"; -import { NotificationContext } from "context/notification"; +import { notify } from "components/ToastNotification"; import { addTeamIdCriteria, IScript } from "interfaces/script"; import { getErrorReason } from "interfaces/errors"; @@ -66,8 +66,6 @@ const RunScriptBatchModal = ({ isFreeTier, onCancel, }: IRunScriptBatchModal) => { - const { renderFlash } = useContext(NotificationContext); - const [currentTimeUTC, setCurrentTimeUTC] = useState(""); useEffect(() => { const intervalId = setInterval(() => { @@ -172,8 +170,7 @@ const RunScriptBatchModal = ({ try { await scriptsAPI.runScriptBatch(body); if (runMode === "schedule") { - renderFlash( - "success", + notify.success( <> Successfully scheduled script.{" "} ); } else { - renderFlash( - "success", + notify.success( <> Successfully ran script.{" "} { diff --git a/frontend/pages/hosts/components/ScriptDetailsModal/ScriptDetailsModal.tsx b/frontend/pages/hosts/components/ScriptDetailsModal/ScriptDetailsModal.tsx index eadd1c86e6..8a35ba23fe 100644 --- a/frontend/pages/hosts/components/ScriptDetailsModal/ScriptDetailsModal.tsx +++ b/frontend/pages/hosts/components/ScriptDetailsModal/ScriptDetailsModal.tsx @@ -10,10 +10,10 @@ import { useQuery } from "react-query"; import FileSaver from "file-saver"; import { AppContext } from "context/app"; -import { NotificationContext } from "context/notification"; import scriptAPI from "services/entities/scripts"; import { IHostScript } from "interfaces/script"; +import { notify } from "components/ToastNotification"; import Modal from "components/Modal"; import ModalFooter from "components/ModalFooter"; import Button from "components/buttons/Button"; @@ -104,8 +104,6 @@ const ScriptDetailsModal = ({ isAnyTeamMaintainer ); - const { renderFlash } = useContext(NotificationContext); - // handle multiple possibilities for `selectedScriptDetails` let scriptId: number | null = null; if (selectedScriptId) { @@ -152,7 +150,7 @@ const ScriptDetailsModal = ({ const file = new File([content], filename); FileSaver.saveAs(file); } catch { - renderFlash("error", "Couldn’t Download. Please try again."); + notify.error("Couldn’t download. Please try again."); } }; diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index 0ddf229ed9..3e1d3b6b85 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useContext, useCallback, useEffect } from "react"; +import React, { useState, useCallback, useEffect } from "react"; import { InjectedRouter, Params } from "react-router/lib/Router"; import { useQuery } from "react-query"; import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; @@ -7,7 +7,6 @@ import { AxiosError } from "axios"; import { pick } from "lodash"; -import { NotificationContext } from "context/notification"; import classNames from "classnames"; import deviceUserAPI, { @@ -41,7 +40,7 @@ import OrgLogoIcon from "components/icons/OrgLogoIcon"; import Spinner from "components/Spinner"; import TabNav from "components/TabNav"; import TabText from "components/TabText"; -import FlashMessage from "components/FlashMessage"; +import { notify } from "components/ToastNotification"; import CustomLink from "components/CustomLink"; import { normalizeEmptyValues } from "utilities/helpers"; @@ -139,10 +138,6 @@ const DeviceUserPage = ({ const isMobileView = useIsMobileWidth(); const isMobileDevice = isIPhone(navigator) || isIPad(navigator); - const { renderFlash, notification, hideFlash } = useContext( - NotificationContext - ); - const [showBypassModal, setShowBypassModal] = useState(false); const [showBitLockerPINModal, setShowBitLockerPINModal] = useState(false); const [showInfoModal, setShowInfoModal] = useState(false); @@ -316,8 +311,7 @@ const DeviceUserPage = ({ }, REFETCH_HOST_DETAILS_POLLING_INTERVAL); } else { resetHostRefetchStates(); - renderFlash( - "error", + notify.error( `This host is offline. Please try refetching host vitals later.` ); } @@ -338,8 +332,7 @@ const DeviceUserPage = ({ }, REFETCH_HOST_DETAILS_POLLING_INTERVAL); } else { resetHostRefetchStates(); - renderFlash( - "error", + notify.error( `This host is offline. Please try refetching host vitals later.` ); } @@ -350,8 +343,7 @@ const DeviceUserPage = ({ responseHost.platform === "ios" || responseHost.platform === "ipados"; if (!isIOSOrIPadOS) { - renderFlash( - "error", + notify.error( "We're having trouble fetching fresh vitals for this host. Please try again later." ); } @@ -524,16 +516,12 @@ const DeviceUserPage = ({ refetchExtensions(); }, REFETCH_HOST_DETAILS_POLLING_INTERVAL); } catch (error) { - renderFlash("error", getErrorMessage(error, host.display_name)); + notify.error(getErrorMessage(error, host.display_name), { + response: error, + }); resetHostRefetchStates(); } - }, [ - host, - deviceAuthToken, - refetchDupDetails, - refetchExtensions, - renderFlash, - ]); + }, [host, deviceAuthToken, refetchDupDetails, refetchExtensions]); // Handles the queue: If there's a queued refetch and not actively refetching, run refetch useEffect(() => { @@ -579,7 +567,7 @@ const DeviceUserPage = ({ deviceAuthToken ); } catch (e) { - renderFlash("error", "Failed to trigger key creation."); + notify.error("Failed to trigger key creation.", { response: e }); setShowCreateLinuxKeyModal(false); } finally { setIsTriggeringCreateLinuxKey(false); @@ -958,12 +946,6 @@ const DeviceUserPage = ({ {shouldShowUnsupportedScreen(location.pathname) && ( )} -