From 15a2bab1cd31a3b64844d3feb8b8835c4de3d871 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 26 Feb 2026 02:42:34 +1100 Subject: [PATCH] feat: migrate from `` to `sonner` (#22258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces our custom `` (MUI Snackbar + event emitter) with [`sonner`](https://github.com/emilkowalski/sonner). Deletes `GlobalSnackbar/`, the custom event emitter infra, and migrates ~80 source files to `toast.success()` / `toast.error()` from `sonner`. - ~47 error toasts now surface API error detail via `getErrorDetail(error)` in the toast description, not just a generic message. Coincides with #22229. - Toast messages follow an `{Action} "{entity}" {result}.` format (e.g. `User "alice" suspended successfully.`) since toasts persist across navigation now. - 17 uses of `toast.promise()` for loading → success → error lifecycle. - Some toasts include action buttons for quick navigation (e.g. "View task", "View template"). - Multiple toasts can stack and display simultaneously. --------- Co-authored-by: Kayla はな --- site/e2e/helpers.ts | 4 +- site/e2e/setup/addUsersAndLicense.spec.ts | 2 +- site/e2e/tests/groups/removeGroup.spec.ts | 2 +- site/e2e/tests/groups/removeMember.spec.ts | 4 +- site/e2e/tests/organizationGroups.spec.ts | 2 +- site/e2e/tests/organizations.spec.ts | 6 +- .../customRoles/customRoles.spec.ts | 4 +- .../tests/organizations/idpGroupSync.spec.ts | 8 +- .../templates/updateTemplateSchedule.spec.ts | 2 +- site/e2e/tests/updateTemplate.spec.ts | 2 +- site/e2e/tests/users/removeUser.spec.ts | 2 +- site/package.json | 1 + site/pnpm-lock.yaml | 14 ++ site/src/App.tsx | 4 +- .../EnterpriseSnackbar.stories.tsx | 34 ----- .../GlobalSnackbar/EnterpriseSnackbar.tsx | 79 ----------- .../GlobalSnackbar/GlobalSnackbar.tsx | 116 --------------- .../components/GlobalSnackbar/utils.test.ts | 132 ------------------ site/src/components/GlobalSnackbar/utils.ts | 78 ----------- site/src/components/Icons/ErrorIcon.tsx | 13 -- site/src/components/Toaster/Toaster.tsx | 51 +++++++ site/src/contexts/auth/AuthProvider.tsx | 10 +- site/src/hooks/events.test.ts | 17 --- site/src/hooks/events.ts | 25 ---- site/src/hooks/useClipboard.jest.tsx | 25 +--- site/src/hooks/useClipboard.ts | 11 +- site/src/hooks/useWorkspaceBuildLogs.ts | 5 +- site/src/modules/apps/useAppLink.ts | 24 ++-- .../modules/dashboard/Navbar/MobileMenu.tsx | 6 +- .../modules/dashboard/Navbar/ProxyMenu.tsx | 10 +- .../NotificationsInbox.stories.tsx | 6 +- .../NotificationsInbox/NotificationsInbox.tsx | 27 ++-- .../resources/AgentDevcontainerCard.tsx | 6 +- site/src/modules/resources/AgentMetadata.tsx | 21 +-- .../resources/AppLink/AppLink.stories.tsx | 4 +- .../resources/DownloadAgentLogsButton.tsx | 11 +- .../resources/useAgentContainers.jest.tsx | 23 ++- .../modules/resources/useAgentContainers.ts | 16 +-- .../modules/resources/useAgentLogs.jest.ts | 14 +- site/src/modules/resources/useAgentLogs.ts | 13 +- .../TaskDeleteDialog.stories.tsx | 6 +- .../TaskDeleteDialog/TaskDeleteDialog.tsx | 26 ++-- .../TaskFeedbackDialog.stories.tsx | 6 +- .../TaskFeedbackDialog/TaskFeedbackDialog.tsx | 4 +- .../tasks/TaskPrompt/TaskPrompt.stories.tsx | 16 +-- .../modules/tasks/TaskPrompt/TaskPrompt.tsx | 17 ++- .../tasks/TasksSidebar/TasksSidebar.tsx | 18 ++- .../DownloadLogsDialog.tsx | 7 +- .../WorkspaceMoreActions.tsx | 19 ++- .../WorkspaceOutdatedTooltip.tsx | 10 +- .../useWorkspaceSharing.ts | 20 +-- .../CreateTemplatePage/UploadTemplateView.tsx | 8 +- .../pages/CreateTokenPage/CreateTokenPage.tsx | 9 +- .../CreateUserPage/CreateUserPage.jest.tsx | 2 +- .../pages/CreateUserPage/CreateUserPage.tsx | 39 ++++-- .../AppearanceSettingsPage.tsx | 30 ++-- .../IdpOrgSyncPage/ExportPolicyButton.tsx | 11 +- .../IdpOrgSyncPage/IdpOrgSyncPage.tsx | 17 ++- .../AddNewLicensePage.tsx | 17 ++- .../AddNewLicensePageView.tsx | 9 +- .../LicensesSettingsPage.tsx | 25 ++-- .../NotificationEvents.stories.tsx | 4 +- .../NotificationsPage/NotificationEvents.tsx | 13 +- .../Troubleshooting.stories.tsx | 2 +- .../NotificationsPage/Troubleshooting.tsx | 10 +- .../NotificationsPage/storybookUtils.ts | 4 +- .../CreateOAuth2AppPage.tsx | 28 ++-- .../EditOAuth2AppPage.tsx | 97 ++++++++----- site/src/pages/GroupsPage/GroupPage.tsx | 49 +++++-- .../pages/GroupsPage/GroupSettingsPage.tsx | 31 ++-- site/src/pages/GroupsPage/GroupsPage.tsx | 14 +- .../pages/HealthPage/DismissWarningButton.tsx | 6 +- .../CreateOrganizationPage.tsx | 13 +- .../CustomRolesPage/CreateEditRolePage.tsx | 53 +++++-- .../CustomRolesPage/CustomRolesPage.tsx | 31 ++-- .../IdpSyncPage/ExportPolicyButton.tsx | 11 +- .../IdpSyncPage/IdpSyncPage.tsx | 33 +++-- .../OrganizationMembersPage.test.tsx | 6 +- .../OrganizationMembersPage.tsx | 33 +++-- .../OrganizationMembersPageView.tsx | 20 ++- .../CancelJobConfirmationDialog.stories.tsx | 10 +- .../CancelJobConfirmationDialog.tsx | 23 +-- .../OrganizationSettingsPage.tsx | 54 ++++--- .../ChangePasswordPage.stories.tsx | 6 +- .../ResetPasswordPage/ChangePasswordPage.tsx | 26 ++-- .../RequestOTPPage.stories.tsx | 4 +- site/src/pages/TaskPage/TaskPage.stories.tsx | 6 +- site/src/pages/TaskPage/TaskPage.tsx | 10 +- site/src/pages/TasksPage/TasksTable.tsx | 10 +- site/src/pages/TasksPage/batchActions.ts | 4 +- .../pages/TemplatePage/TemplatePageHeader.tsx | 6 +- .../TemplatePrebuildsPage.tsx | 8 +- .../TemplatePrebuildsPageView.stories.tsx | 4 +- .../TemplateVersionsPage.tsx | 60 +++++++- .../useDeletionDialogState.jest.ts | 26 ++-- .../TemplatePage/useDeletionDialogState.ts | 41 +++--- .../TemplateSettingsPage.tsx | 13 +- .../TemplatePermissionsPage.tsx | 127 +++++++++++++---- .../TemplateSchedulePage.test.tsx | 2 +- .../TemplateSchedulePage.tsx | 6 +- .../TemplateVariablesPage.jest.tsx | 14 +- .../TemplateVariablesPage.tsx | 8 +- .../TemplateVersionEditor.tsx | 31 ++-- .../TemplateVersionEditorPage.tsx | 7 +- .../AccountPage/AccountPage.test.tsx | 4 +- .../ExternalAuthPage/ExternalAuthPage.tsx | 28 ++-- .../NotificationsPage.stories.tsx | 8 +- .../NotificationsPage/NotificationsPage.tsx | 37 +++-- .../OAuth2ProviderPage/OAuth2ProviderPage.tsx | 13 +- .../SSHKeysPage/SSHKeysPage.test.tsx | 3 +- .../SSHKeysPage/SSHKeysPage.tsx | 10 +- .../SchedulePage/SchedulePage.test.tsx | 3 +- .../SchedulePage/SchedulePage.tsx | 4 +- .../SecurityPage/SecurityPage.tsx | 4 +- .../TokensPage/ConfirmDeleteDialog.tsx | 12 +- .../src/pages/UsersPage/UsersPage.stories.tsx | 28 ++-- site/src/pages/UsersPage/UsersPage.tsx | 65 +++++++-- .../src/pages/WorkspacePage/WorkspacePage.tsx | 17 ++- .../WorkspacePage/WorkspaceReadyPage.tsx | 30 +++- .../WorkspaceScheduleControls.test.tsx | 8 +- .../WorkspaceScheduleControls.tsx | 15 +- .../WorkspaceSchedulePage.test.tsx | 4 +- .../WorkspaceSchedulePage.tsx | 15 +- .../WorkspaceSettingsPage.tsx | 4 +- .../pages/WorkspacesPage/WorkspacesPage.tsx | 9 +- site/src/pages/WorkspacesPage/batchActions.ts | 39 ++++-- site/src/testHelpers/storybook.tsx | 6 +- site/src/utils/events.test.ts | 20 --- site/src/utils/events.ts | 32 ----- 129 files changed, 1292 insertions(+), 1240 deletions(-) delete mode 100644 site/src/components/GlobalSnackbar/EnterpriseSnackbar.stories.tsx delete mode 100644 site/src/components/GlobalSnackbar/EnterpriseSnackbar.tsx delete mode 100644 site/src/components/GlobalSnackbar/GlobalSnackbar.tsx delete mode 100644 site/src/components/GlobalSnackbar/utils.test.ts delete mode 100644 site/src/components/GlobalSnackbar/utils.ts delete mode 100644 site/src/components/Icons/ErrorIcon.tsx create mode 100644 site/src/components/Toaster/Toaster.tsx delete mode 100644 site/src/hooks/events.test.ts delete mode 100644 site/src/hooks/events.ts delete mode 100644 site/src/utils/events.test.ts delete mode 100644 site/src/utils/events.ts diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 5f85005507..884aa2944a 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -1251,7 +1251,7 @@ export async function createUser( const passwordField = page.locator("input[name=password]"); await passwordField.fill(password); await page.getByRole("button", { name: /save/i }).click(); - await expect(page.getByText("Successfully created user.")).toBeVisible(); + await expect(page.getByText(/created successfully/)).toBeVisible(); await expect(page).toHaveTitle("Users - Coder"); const addedRow = page.locator("tr", { hasText: email }); @@ -1285,7 +1285,7 @@ export async function createOrganization(page: Page): Promise<{ await page.getByRole("button", { name: /save/i }).click(); await expectUrl(page).toHavePathName(`/organizations/${name}`); - await expect(page.getByText("Organization created.")).toBeVisible(); + await expect(page.getByText(/created successfully/)).toBeVisible(); return { name, displayName, description }; } diff --git a/site/e2e/setup/addUsersAndLicense.spec.ts b/site/e2e/setup/addUsersAndLicense.spec.ts index f59d081dfb..79f5a2edf7 100644 --- a/site/e2e/setup/addUsersAndLicense.spec.ts +++ b/site/e2e/setup/addUsersAndLicense.spec.ts @@ -47,7 +47,7 @@ test("setup deployment", async ({ page }) => { await page.getByText("Upload License").click(); await expect( - page.getByText("You have successfully added a license"), + page.getByText("You have successfully added a license."), ).toBeVisible(); } }); diff --git a/site/e2e/tests/groups/removeGroup.spec.ts b/site/e2e/tests/groups/removeGroup.spec.ts index 7caec10d60..8cd838fae9 100644 --- a/site/e2e/tests/groups/removeGroup.spec.ts +++ b/site/e2e/tests/groups/removeGroup.spec.ts @@ -26,7 +26,7 @@ test("remove group", async ({ page, baseURL }) => { const dialog = page.getByTestId("dialog"); await dialog.getByLabel("Name of the group to delete").fill(group.name); await dialog.getByRole("button", { name: "Delete" }).click(); - await expect(page.getByText("Group deleted successfully.")).toBeVisible(); + await expect(page.getByText(/deleted successfully/)).toBeVisible(); await expect(page).toHaveTitle("Groups - Coder"); }); diff --git a/site/e2e/tests/groups/removeMember.spec.ts b/site/e2e/tests/groups/removeMember.spec.ts index c699255892..1462fe6919 100644 --- a/site/e2e/tests/groups/removeMember.spec.ts +++ b/site/e2e/tests/groups/removeMember.spec.ts @@ -37,5 +37,7 @@ test("remove member", async ({ page, baseURL }) => { const menu = page.getByRole("menu"); await menu.getByText("Remove").click({ timeout: 1_000 }); - await expect(page.getByText("Member removed successfully.")).toBeVisible(); + await expect( + page.getByText(/has been removed from .* successfully/), + ).toBeVisible(); }); diff --git a/site/e2e/tests/organizationGroups.spec.ts b/site/e2e/tests/organizationGroups.spec.ts index 14741bdf38..662752b81f 100644 --- a/site/e2e/tests/organizationGroups.spec.ts +++ b/site/e2e/tests/organizationGroups.spec.ts @@ -90,7 +90,7 @@ test("create group", async ({ page }) => { const dialog = page.getByTestId("dialog"); await dialog.getByLabel("Name of the group to delete").fill(name); await dialog.getByRole("button", { name: "Delete" }).click(); - await expect(page.getByText("Group deleted successfully.")).toBeVisible(); + await expect(page.getByText(/deleted successfully/)).toBeVisible(); await expectUrl(page).toHavePathName(`/organizations/${org.name}/groups`); await expect(page).toHaveTitle("Groups - Coder"); diff --git a/site/e2e/tests/organizations.spec.ts b/site/e2e/tests/organizations.spec.ts index ff4f5ad993..79b9c081e3 100644 --- a/site/e2e/tests/organizations.spec.ts +++ b/site/e2e/tests/organizations.spec.ts @@ -27,7 +27,7 @@ test("create and delete organization", async ({ page }) => { // Expect to be redirected to the new organization await expectUrl(page).toHavePathName(`/organizations/${name}`); - await expect(page.getByText("Organization created.")).toBeVisible(); + await expect(page.getByText(/created successfully/)).toBeVisible(); await page.goto(`/organizations/${name}/settings`, { waitUntil: "domcontentloaded", @@ -40,7 +40,7 @@ test("create and delete organization", async ({ page }) => { // Expect to be redirected when renaming the organization await expectUrl(page).toHavePathName(`/organizations/${newName}/settings`); - await expect(page.getByText("Organization settings updated.")).toBeVisible(); + await expect(page.getByText(/settings updated successfully/)).toBeVisible(); await page.goto(`/organizations/${newName}/settings`, { waitUntil: "domcontentloaded", @@ -53,5 +53,5 @@ test("create and delete organization", async ({ page }) => { await dialog.getByLabel("Name").fill(newName); await dialog.getByRole("button", { name: "Delete" }).click(); await page.waitForTimeout(1000); - await expect(page.getByText("Organization deleted")).toBeVisible(); + await expect(page.getByText(/deleted successfully/)).toBeVisible(); }); diff --git a/site/e2e/tests/organizations/customRoles/customRoles.spec.ts b/site/e2e/tests/organizations/customRoles/customRoles.spec.ts index 1f55e87de8..305e5bca9f 100644 --- a/site/e2e/tests/organizations/customRoles/customRoles.spec.ts +++ b/site/e2e/tests/organizations/customRoles/customRoles.spec.ts @@ -184,9 +184,7 @@ test.describe("CustomRolesPage", () => { await input.fill(customRole.name); await page.getByRole("button", { name: "Delete" }).click(); - await expect( - page.getByText("Custom role deleted successfully!"), - ).toBeVisible(); + await expect(page.getByText(/deleted successfully/)).toBeVisible(); await deleteOrganization(org.name); }); diff --git a/site/e2e/tests/organizations/idpGroupSync.spec.ts b/site/e2e/tests/organizations/idpGroupSync.spec.ts index c8fbf7fffa..4d2ab86ec9 100644 --- a/site/e2e/tests/organizations/idpGroupSync.spec.ts +++ b/site/e2e/tests/organizations/idpGroupSync.spec.ts @@ -78,7 +78,7 @@ test.describe("IdpGroupSyncPage", () => { row.getByRole("cell", { name: "idp-group-1" }), ).not.toBeVisible(); await expect( - page.getByText("IdP Group sync settings updated."), + page.getByText("IdP group sync settings updated."), ).toBeVisible(); }); @@ -102,7 +102,7 @@ test.describe("IdpGroupSyncPage", () => { await page.getByRole("button", { name: /save/i }).click(); await expect( - page.getByText("IdP Group sync settings updated."), + page.getByText("IdP group sync settings updated."), ).toBeVisible(); }); @@ -119,7 +119,7 @@ test.describe("IdpGroupSyncPage", () => { await toggle.click(); await expect( - page.getByText("IdP Group sync settings updated."), + page.getByText("IdP group sync settings updated."), ).toBeVisible(); await expect(toggle).toBeChecked(); @@ -184,7 +184,7 @@ test.describe("IdpGroupSyncPage", () => { await expect(newRow.getByRole("cell", { name: "Everyone" })).toBeVisible(); await expect( - page.getByText("IdP Group sync settings updated."), + page.getByText("IdP group sync settings updated."), ).toBeVisible(); await deleteOrganization(orgName); diff --git a/site/e2e/tests/templates/updateTemplateSchedule.spec.ts b/site/e2e/tests/templates/updateTemplateSchedule.spec.ts index b9552f85ae..21de419251 100644 --- a/site/e2e/tests/templates/updateTemplateSchedule.spec.ts +++ b/site/e2e/tests/templates/updateTemplateSchedule.spec.ts @@ -39,7 +39,7 @@ test("update template schedule settings without override other settings", async }); await page.getByLabel("Default autostop (hours)").fill("48"); await page.getByRole("button", { name: /save/i }).click(); - await expect(page.getByText("Template updated successfully")).toBeVisible(); + await expect(page.getByText(/schedule updated successfully/)).toBeVisible(); const updatedTemplate = await API.getTemplate(template.id); // Validate that the template data remains consistent, with the exception of diff --git a/site/e2e/tests/updateTemplate.spec.ts b/site/e2e/tests/updateTemplate.spec.ts index 43dd392443..9f694c4fd2 100644 --- a/site/e2e/tests/updateTemplate.spec.ts +++ b/site/e2e/tests/updateTemplate.spec.ts @@ -57,7 +57,7 @@ test("add and remove a group", async ({ page }) => { const menu = page.getByRole("menu"); await menu.getByText("Remove").click(); - await expect(page.getByText("Group removed successfully!")).toBeVisible(); + await expect(page.getByText(/removed successfully/)).toBeVisible(); await expect(row).not.toBeVisible(); }); diff --git a/site/e2e/tests/users/removeUser.spec.ts b/site/e2e/tests/users/removeUser.spec.ts index 92aa3efaa8..2ec8b5bab3 100644 --- a/site/e2e/tests/users/removeUser.spec.ts +++ b/site/e2e/tests/users/removeUser.spec.ts @@ -25,5 +25,5 @@ test("remove user", async ({ page, baseURL }) => { await dialog.getByLabel("Name of the user to delete").fill(user.username); await dialog.getByRole("button", { name: "Delete" }).click(); - await expect(page.getByText("Successfully deleted the user.")).toBeVisible(); + await expect(page.getByText(/deleted successfully/)).toBeVisible(); }); diff --git a/site/package.json b/site/package.json index 8a3694a825..f8cc45b057 100644 --- a/site/package.json +++ b/site/package.json @@ -109,6 +109,7 @@ "remark-gfm": "4.0.1", "resize-observer-polyfill": "1.5.1", "semver": "7.7.3", + "sonner": "2.0.7", "tailwind-merge": "2.6.0", "tailwindcss-animate": "1.0.7", "tzdata": "1.0.46", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 9799a2b3ed..dc24197641 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -241,6 +241,9 @@ importers: semver: specifier: 7.7.3 version: 7.7.3 + sonner: + specifier: 2.0.7 + version: 2.0.7(react-dom@19.2.2(react@19.2.2))(react@19.2.2) tailwind-merge: specifier: 2.6.0 version: 2.6.0 @@ -5567,6 +5570,12 @@ packages: resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==, tarball: https://registry.npmjs.org/smol-toml/-/smol-toml-1.5.2.tgz} engines: {node: '>= 18'} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==, tarball: https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, tarball: https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz} engines: {node: '>=0.10.0'} @@ -12206,6 +12215,11 @@ snapshots: smol-toml@1.5.2: {} + sonner@2.0.7(react-dom@19.2.2(react@19.2.2))(react@19.2.2): + dependencies: + react: 19.2.2 + react-dom: 19.2.2(react@19.2.2) + source-map-js@1.2.1: {} source-map-support@0.5.13: diff --git a/site/src/App.tsx b/site/src/App.tsx index a4fad65a3d..0573af9be3 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -10,7 +10,7 @@ import { } from "react"; import { QueryClient, QueryClientProvider } from "react-query"; import { RouterProvider } from "react-router"; -import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar"; +import { Toaster } from "./components/Toaster/Toaster"; import { AuthProvider } from "./contexts/auth/AuthProvider"; import { ThemeProvider } from "./contexts/ThemeProvider"; import { router } from "./router"; @@ -56,7 +56,7 @@ export const AppProviders: FC = ({ {children} - + diff --git a/site/src/components/GlobalSnackbar/EnterpriseSnackbar.stories.tsx b/site/src/components/GlobalSnackbar/EnterpriseSnackbar.stories.tsx deleted file mode 100644 index d2f50edcc7..0000000000 --- a/site/src/components/GlobalSnackbar/EnterpriseSnackbar.stories.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { EnterpriseSnackbar } from "./EnterpriseSnackbar"; - -const meta: Meta = { - title: "components/EnterpriseSnackbar", - component: EnterpriseSnackbar, -}; - -export default meta; -type Story = StoryObj; - -export const WithError: Story = { - args: { - variant: "error", - open: true, - message: "Oops, something wrong happened.", - }, -}; - -export const Info: Story = { - args: { - variant: "info", - open: true, - message: "Hey, something happened.", - }, -}; - -export const Success: Story = { - args: { - variant: "success", - open: true, - message: "Hey, something good happened.", - }, -}; diff --git a/site/src/components/GlobalSnackbar/EnterpriseSnackbar.tsx b/site/src/components/GlobalSnackbar/EnterpriseSnackbar.tsx deleted file mode 100644 index 04b214995c..0000000000 --- a/site/src/components/GlobalSnackbar/EnterpriseSnackbar.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import IconButton from "@mui/material/IconButton"; -import Snackbar, { - type SnackbarProps as MuiSnackbarProps, -} from "@mui/material/Snackbar"; -import { X as XIcon } from "lucide-react"; -import type { FC } from "react"; -import { cn } from "utils/cn"; - -type EnterpriseSnackbarVariant = "error" | "info" | "success"; - -interface EnterpriseSnackbarProps extends MuiSnackbarProps { - /** Called when the snackbar should close, either from timeout or clicking close */ - onClose: () => void; - /** Variant of snackbar, for theming */ - variant?: EnterpriseSnackbarVariant; -} - -/** - * Wrapper around Material UI's Snackbar component, provides pre-configured - * themes and convenience props. Coder UI's Snackbars require a close handler, - * since they always render a close button. - * - * Snackbars do _not_ automatically appear in the top-level position when - * rendered, you'll need to use ReactDom portals or the Material UI Portal - * component for that. - * - * See original component's Material UI documentation here: https://material-ui.com/components/snackbars/ - */ -export const EnterpriseSnackbar: FC = ({ - children, - onClose, - variant = "info", - ContentProps = {}, - action, - ...snackbarProps -}) => { - return ( - - {action} - - - - - } - ContentProps={{ - ...ContentProps, - className: cn( - "rounded-lg bg-surface-secondary text-content-primary shadow", - "py-2 pl-6 pr-4 items-[inherit] border-0 border-l-[4px]", - variantColor(variant), - ), - }} - onClose={onClose} - {...snackbarProps} - > - {children} - - ); -}; - -const variantColor = (variant: EnterpriseSnackbarVariant) => { - switch (variant) { - case "error": - return "border-border-destructive"; - case "info": - return "border-highlight-sky"; - case "success": - return "border-border-success"; - } -}; diff --git a/site/src/components/GlobalSnackbar/GlobalSnackbar.tsx b/site/src/components/GlobalSnackbar/GlobalSnackbar.tsx deleted file mode 100644 index 081bdf7f3a..0000000000 --- a/site/src/components/GlobalSnackbar/GlobalSnackbar.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import type { Interpolation, Theme } from "@emotion/react"; -import { useCustomEvent } from "hooks/events"; -import { type FC, useState } from "react"; -import { ErrorIcon } from "../Icons/ErrorIcon"; -import { EnterpriseSnackbar } from "./EnterpriseSnackbar"; -import { - type AdditionalMessage, - isNotificationList, - isNotificationText, - isNotificationTextPrefixed, - MsgType, - type NotificationMsg, - SnackbarEventType, -} from "./utils"; - -const variantFromMsgType = (type: MsgType) => { - if (type === MsgType.Error) { - return "error"; - } - - if (type === MsgType.Success) { - return "success"; - } - return "info"; -}; - -export const GlobalSnackbar: FC = () => { - const [notificationMsg, setNotificationMsg] = useState(); - useCustomEvent(SnackbarEventType, (event) => { - setNotificationMsg(event.detail); - }); - - const hasNotification = notificationMsg !== undefined; - if (!hasNotification) { - return null; - } - - return ( - setNotificationMsg(undefined)} - autoHideDuration={ - notificationMsg.msgType === MsgType.Error ? 22000 : 6000 - } - anchorOrigin={{ vertical: "bottom", horizontal: "right" }} - message={ -
- {notificationMsg.msgType === MsgType.Error && ( - - )} - -
- {notificationMsg.msg} - - {notificationMsg.additionalMsgs?.map((msg, index) => ( - - ))} -
-
- } - /> - ); -}; - -interface AdditionalMessageDisplayProps { - message: AdditionalMessage; -} - -const AdditionalMessageDisplay: FC = ({ - message, -}) => { - if (isNotificationText(message)) { - return {message}; - } - - if (isNotificationTextPrefixed(message)) { - return ( - - {message.prefix}: {message.text} - - ); - } - - if (isNotificationList(message)) { - return ( -
    - {message.map((item, idx) => ( -
  • - {item} -
  • - ))} -
- ); - } - - return null; -}; - -const styles = { - messageTitle: { - fontSize: 14, - fontWeight: 600, - }, - messageSubtitle: { - marginTop: 4, - "&:first-letter": { - textTransform: "uppercase", - }, - }, - errorIcon: (theme) => ({ - color: theme.palette.error.contrastText, - marginRight: 16, - }), -} satisfies Record>; diff --git a/site/src/components/GlobalSnackbar/utils.test.ts b/site/src/components/GlobalSnackbar/utils.test.ts deleted file mode 100644 index 3ce76c33d6..0000000000 --- a/site/src/components/GlobalSnackbar/utils.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { Mock } from "vitest"; -import { - displayError, - displaySuccess, - isNotificationTextPrefixed, - MsgType, - type NotificationMsg, - type NotificationTextPrefixed, - SnackbarEventType, -} from "./utils"; - -describe("Snackbar", () => { - describe("isNotificationTextPrefixed", () => { - // Regression test for case found in #10436 - it("does not crash on null values", () => { - // Given - const msg = null; - - // When - const isTextPrefixed = isNotificationTextPrefixed(msg); - - // Then - expect(isTextPrefixed).toBe(false); - }); - it("returns true if prefixed", () => { - // Given - const msg: NotificationTextPrefixed = { - prefix: "warning", - text: "careful with this workspace", - }; - - // When - const isTextPrefixed = isNotificationTextPrefixed(msg); - - // Then - expect(isTextPrefixed).toBe(true); - }); - it("returns false if not prefixed", () => { - // Given - const msg = "plain ol' message"; - - // When - const isTextPrefixed = isNotificationTextPrefixed(msg); - - // Then - expect(isTextPrefixed).toBe(false); - }); - }); - - describe("displaySuccess", () => { - const originalWindowDispatchEvent = window.dispatchEvent; - type TDispatchEventMock = Mock< - (msg: CustomEvent) => boolean - >; - let dispatchEventMock: TDispatchEventMock; - - // Helper function to extract the notification event - // that was sent to `dispatchEvent`. This lets us validate - // the contents of the notification event are what we expect. - const extractNotificationEvent = ( - dispatchEventMock: TDispatchEventMock, - ): NotificationMsg => { - // calls[0] is the first call made to the mock (this is reset in `beforeEach`) - // calls[0][0] is the first argument of the first call - // calls[0][0].detail is the 'detail' argument passed to the `CustomEvent` - - // this is the `NotificationMsg` object that gets sent to `dispatchEvent` - return dispatchEventMock.mock.calls[0][0].detail; - }; - - beforeEach(() => { - dispatchEventMock = vi.fn(); - window.dispatchEvent = - dispatchEventMock as unknown as typeof window.dispatchEvent; - }); - - afterEach(() => { - window.dispatchEvent = originalWindowDispatchEvent; - }); - - it("can be called with only a title", () => { - // Given - const expected: NotificationMsg = { - msgType: MsgType.Success, - msg: "Test", - additionalMsgs: undefined, - }; - - // When - displaySuccess("Test"); - - // Then - expect(dispatchEventMock).toBeCalledTimes(1); - expect(extractNotificationEvent(dispatchEventMock)).toStrictEqual( - expected, - ); - }); - - it("can be called with a title and additional message", () => { - // Given - const expected: NotificationMsg = { - msgType: MsgType.Success, - msg: "Test", - additionalMsgs: ["additional message"], - }; - - // When - displaySuccess("Test", "additional message"); - - // Then - expect(dispatchEventMock).toBeCalledTimes(1); - expect(extractNotificationEvent(dispatchEventMock)).toStrictEqual( - expected, - ); - }); - }); - - describe("displayError", () => { - it("shows the title and the message", () => { - const message = "Some error happened"; - - return new Promise((resolve) => { - window.addEventListener(SnackbarEventType, (event) => { - const notificationEvent = event as CustomEvent; - expect(notificationEvent.detail.msg).toEqual(message); - resolve(); - }); - - displayError(message); - }); - }); - }); -}); diff --git a/site/src/components/GlobalSnackbar/utils.ts b/site/src/components/GlobalSnackbar/utils.ts deleted file mode 100644 index cc6d37504a..0000000000 --- a/site/src/components/GlobalSnackbar/utils.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { dispatchCustomEvent } from "utils/events"; - -/////////////////////////////////////////////////////////////////////////////// -// Notification Types -/////////////////////////////////////////////////////////////////////////////// - -export enum MsgType { - Info = 0, - Success = 1, - Error = 2, -} - -/** - * Display a prefixed paragraph inside a notification. - */ -export type NotificationTextPrefixed = { - prefix: string; - text: string; -}; - -export type AdditionalMessage = NotificationTextPrefixed | string[] | string; - -export const isNotificationText = (msg: AdditionalMessage): msg is string => { - return !Array.isArray(msg) && typeof msg === "string"; -}; - -export const isNotificationTextPrefixed = ( - msg: AdditionalMessage | null, -): msg is NotificationTextPrefixed => { - if (msg) { - return typeof msg !== "string" && Object.hasOwn(msg, "prefix"); - } - return false; -}; - -export const isNotificationList = (msg: AdditionalMessage): msg is string[] => { - return Array.isArray(msg); -}; - -export interface NotificationMsg { - msgType: MsgType; - msg: string; - additionalMsgs?: AdditionalMessage[]; -} - -export const SnackbarEventType = "coder:notification"; - -/////////////////////////////////////////////////////////////////////////////// -// Notification Functions -/////////////////////////////////////////////////////////////////////////////// - -function dispatchNotificationEvent( - msgType: MsgType, - msg: string, - additionalMsgs?: AdditionalMessage[], -) { - dispatchCustomEvent(SnackbarEventType, { - msgType, - msg, - additionalMsgs, - }); -} - -export const displaySuccess = (msg: string, additionalMsg?: string): void => { - dispatchNotificationEvent( - MsgType.Success, - msg, - additionalMsg ? [additionalMsg] : undefined, - ); -}; - -export const displayError = (msg: string, additionalMsg?: string): void => { - dispatchNotificationEvent( - MsgType.Error, - msg, - additionalMsg ? [additionalMsg] : undefined, - ); -}; diff --git a/site/src/components/Icons/ErrorIcon.tsx b/site/src/components/Icons/ErrorIcon.tsx deleted file mode 100644 index 0e8352be79..0000000000 --- a/site/src/components/Icons/ErrorIcon.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import SvgIcon, { type SvgIconProps } from "@mui/material/SvgIcon"; - -import type { JSX } from "react"; -export const ErrorIcon = (props: SvgIconProps): JSX.Element => ( - - - -); diff --git a/site/src/components/Toaster/Toaster.tsx b/site/src/components/Toaster/Toaster.tsx new file mode 100644 index 0000000000..45796f4cfe --- /dev/null +++ b/site/src/components/Toaster/Toaster.tsx @@ -0,0 +1,51 @@ +import { Spinner } from "components/Spinner/Spinner"; +import { + CheckIcon, + InfoIcon, + OctagonXIcon, + TriangleAlertIcon, + XIcon, +} from "lucide-react"; +import { Toaster as Sonner, type ToasterProps as SonnerProps } from "sonner"; +import { cn } from "utils/cn"; + +export const Toaster = ({ ...props }: SonnerProps) => { + return ( + , + info: , + warning: , + error: , + loading: , + close: , + }} + toastOptions={{ + unstyled: true, + closeButton: true, + classNames: { + toast: cn( + "bg-surface-secondary text-content-secondary border border-solid text-sm p-3 pr-12", + "shadow rounded-md grid grid-cols-[auto_1fr] w-96 gap-2", + "data-[expanded=false]:data-[front=false]:overflow-hidden", + "[&[data-expanded=false][data-front=false]>*]:opacity-0", + ), + title: "text-content-primary", + description: "mt-1", + icon: "pt-1 [&_svg]:size-icon-sm flex flex-col", + actionButton: cn( + "border border-solid bg-transparent text-xs rounded-md cursor-pointer", + "flex items-center gap-2 mt-1 py-1.5 px-2 col-start-2 justify-self-start", + "[&_svg]:size-icon-xs [&_span]:text-xs text-content-primary", + ), + closeButton: + "absolute top-4 right-3 bg-transparent border-none p-0 text-content-primary", + // Loading styles require a bit more love, the icon doesn't render inline. + loader: "!left-5 !top-7 !-translate-x-[none]", + loading: "!pl-[30px]", + }, + }} + {...props} + /> + ); +}; diff --git a/site/src/contexts/auth/AuthProvider.tsx b/site/src/contexts/auth/AuthProvider.tsx index ae06c67e5c..718a8a356d 100644 --- a/site/src/contexts/auth/AuthProvider.tsx +++ b/site/src/contexts/auth/AuthProvider.tsx @@ -8,7 +8,6 @@ import { updateProfile as updateProfileOptions, } from "api/queries/users"; import type { UpdateUserProfileRequest, User } from "api/typesGenerated"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { type Permissions, permissionChecks } from "modules/permissions"; import { @@ -19,6 +18,7 @@ import { useContext, } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { toast } from "sonner"; export type AuthContextValue = { isLoading: boolean; @@ -64,7 +64,6 @@ export const AuthProvider: FC = ({ children }) => { ...updateProfileOptions("me"), onSuccess: (user) => { queryClient.setQueryData(meOptions.queryKey, user); - displaySuccess("Updated settings."); }, }); @@ -96,7 +95,12 @@ export const AuthProvider: FC = ({ children }) => { const updateProfile = useCallback( (req: UpdateUserProfileRequest) => { - updateProfileMutation.mutate(req); + const mutation = updateProfileMutation.mutateAsync(req); + toast.promise(mutation, { + loading: "Updating profile...", + success: "Profile updated successfully.", + error: "Failed to update profile.", + }); }, [updateProfileMutation], ); diff --git a/site/src/hooks/events.test.ts b/site/src/hooks/events.test.ts deleted file mode 100644 index b7cc284572..0000000000 --- a/site/src/hooks/events.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { dispatchCustomEvent } from "utils/events"; -import { useCustomEvent } from "./events"; - -describe(useCustomEvent.name, () => { - it("Should receive custom events dispatched by the dispatchCustomEvent function", async () => { - const mockCallback = vi.fn(); - const eventType = "testEvent"; - const detail = { title: "We have a new event!" }; - - renderHook(() => useCustomEvent(eventType, mockCallback)); - dispatchCustomEvent(eventType, detail); - - await waitFor(() => expect(mockCallback).toHaveBeenCalledTimes(1)); - expect(mockCallback.mock.calls[0]?.[0]?.detail).toBe(detail); - }); -}); diff --git a/site/src/hooks/events.ts b/site/src/hooks/events.ts deleted file mode 100644 index 802c3c4faa..0000000000 --- a/site/src/hooks/events.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useEffect } from "react"; -import type { CustomEventListener } from "utils/events"; -import { useEffectEvent } from "./hookPolyfills"; - -/** - * Handles a custom event with descriptive type information. - * - * @param eventType a unique name defining the type of the event. e.g. `"coder:workspace:ready"` - * @param listener a custom event listener. - */ -export const useCustomEvent = ( - eventType: E, - listener: CustomEventListener, -): void => { - // Ensures that the useEffect call only re-syncs when the eventType changes, - // without needing parent component to memoize via useCallback - const stableListener = useEffectEvent(listener); - - useEffect(() => { - window.addEventListener(eventType, stableListener as EventListener); - return () => { - window.removeEventListener(eventType, stableListener as EventListener); - }; - }, [stableListener, eventType]); -}; diff --git a/site/src/hooks/useClipboard.jest.tsx b/site/src/hooks/useClipboard.jest.tsx index 156d70ab31..6307c16507 100644 --- a/site/src/hooks/useClipboard.jest.tsx +++ b/site/src/hooks/useClipboard.jest.tsx @@ -10,11 +10,9 @@ * fail, it won't. */ -import { renderHook, screen } from "@testing-library/react"; -import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar"; -import { ThemeOverride } from "contexts/ThemeProvider"; +import { renderHook } from "@testing-library/react"; import { act } from "react"; -import themes, { DEFAULT_THEME } from "theme"; +import { toast } from "sonner"; import { COPY_FAILED_MESSAGE, HTTP_FALLBACK_DATA_ID, @@ -122,13 +120,6 @@ function renderUseClipboard(inputs?: UseClipboardInput) { (props) => useClipboard(props), { initialProps: inputs, - wrapper: ({ children }) => ( - // Need ThemeProvider because GlobalSnackbar uses theme - - {children} - - - ), }, ); } @@ -232,21 +223,15 @@ describe.each(secureContextValues)("useClipboard - secure: %j", (isSecure) => { }); it("Should dispatch a new toast message to the global snackbar when errors happen while no error callback is provided to the hook", async () => { + const toastErrorSpy = jest.spyOn(toast, "error"); const textToCopy = "crow"; const { result } = renderUseClipboard(); - /** - * @todo Look into why deferring error-based state updates to the global - * snackbar still kicks up act warnings, even after wrapping copyToClipboard - * in act. copyToClipboard should be the main source of the state - * transitions, but it looks like extra state changes are still getting - * flushed through the GlobalSnackbar component afterwards - */ setSimulateFailure(true); await act(() => result.current.copyToClipboard(textToCopy)); - const errorMessageNode = screen.queryByText(COPY_FAILED_MESSAGE); - expect(errorMessageNode).not.toBeNull(); + expect(toastErrorSpy).toHaveBeenCalledWith(COPY_FAILED_MESSAGE); + toastErrorSpy.mockRestore(); }); it("Should expose the error as a value when a copy fails", async () => { diff --git a/site/src/hooks/useClipboard.ts b/site/src/hooks/useClipboard.ts index 88a57a61fc..fd80d65c9c 100644 --- a/site/src/hooks/useClipboard.ts +++ b/site/src/hooks/useClipboard.ts @@ -1,5 +1,5 @@ -import { displayError } from "components/GlobalSnackbar/utils"; import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import { useEffectEvent } from "./hookPolyfills"; const CLIPBOARD_TIMEOUT_MS = 1_000; @@ -36,8 +36,13 @@ export type UseClipboardResult = Readonly<{ showCopiedSuccess: boolean; }>; -export const useClipboard = (input?: UseClipboardInput): UseClipboardResult => { - const { onError = displayError, clearErrorOnSuccess = true } = input ?? {}; +export const useClipboard = ( + input: UseClipboardInput = {}, +): UseClipboardResult => { + const { + onError = (msg: string) => toast.error(msg), + clearErrorOnSuccess = true, + } = input; const [showCopiedSuccess, setShowCopiedSuccess] = useState(false); const [error, setError] = useState(); diff --git a/site/src/hooks/useWorkspaceBuildLogs.ts b/site/src/hooks/useWorkspaceBuildLogs.ts index 8ef249ef24..14cac81023 100644 --- a/site/src/hooks/useWorkspaceBuildLogs.ts +++ b/site/src/hooks/useWorkspaceBuildLogs.ts @@ -1,7 +1,8 @@ import { watchBuildLogsByBuildId } from "api/api"; import type { ProvisionerJobLog } from "api/typesGenerated"; -import { displayError } from "components/GlobalSnackbar/utils"; import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; + export const useWorkspaceBuildLogs = ( // buildId is optional because sometimes the build is not loaded yet buildId: string | undefined, @@ -31,7 +32,7 @@ export const useWorkspaceBuildLogs = ( }); }, onError: () => { - displayError("Error on getting the build logs"); + toast.error(`Error on getting "${buildId}" build logs.`); }, }); diff --git a/site/src/modules/apps/useAppLink.ts b/site/src/modules/apps/useAppLink.ts index c51989bd16..c3d0a550eb 100644 --- a/site/src/modules/apps/useAppLink.ts +++ b/site/src/modules/apps/useAppLink.ts @@ -4,10 +4,10 @@ import type { WorkspaceAgent, WorkspaceApp, } from "api/typesGenerated"; -import { displayError } from "components/GlobalSnackbar/utils"; import { useProxy } from "contexts/ProxyContext"; import type React from "react"; import { useQuery } from "react-query"; +import { toast } from "sonner"; import { getAppHref, isExternalApp, @@ -72,19 +72,21 @@ export const useAppLink = ( const isCoderApp = app.url?.startsWith("coder:"); if (isJetBrainsGateway) { - displayError( - `To use ${label}, you need to have JetBrains Gateway installed.`, - ); + toast.error(`Failed to open "${label}".`, { + description: "JetBrains Gateway must be installed.", + }); } else if (isJetBrainsToolbox) { - displayError( - `To use ${label}, you need to have JetBrains Toolbox installed.`, - ); + toast.error(`Failed to open "${label}".`, { + description: "JetBrains Toolbox must be installed.", + }); } else if (isCoderApp) { - displayError( - `To use ${label} you need to have Coder Desktop installed`, - ); + toast.error(`Failed to open "${label}".`, { + description: "Coder Desktop must be installed.", + }); } else { - displayError(`${label} must be installed first.`); + toast.error(`Failed to open "${label}".`, { + description: "The app must be installed first.", + }); } }, openAppExternallyFailedTimeout); window.addEventListener("blur", () => { diff --git a/site/src/modules/dashboard/Navbar/MobileMenu.tsx b/site/src/modules/dashboard/Navbar/MobileMenu.tsx index e81a4db6eb..af6121b975 100644 --- a/site/src/modules/dashboard/Navbar/MobileMenu.tsx +++ b/site/src/modules/dashboard/Navbar/MobileMenu.tsx @@ -13,7 +13,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "components/DropdownMenu/DropdownMenu"; -import { displayError } from "components/GlobalSnackbar/utils"; import { Latency } from "components/Latency/Latency"; import type { ProxyContextValue } from "contexts/ProxyContext"; import { @@ -24,6 +23,7 @@ import { } from "lucide-react"; import { type FC, useState } from "react"; import { Link } from "react-router"; +import { toast } from "sonner"; import { cn } from "utils/cn"; import { sortProxiesByLatency } from "./proxyUtils"; @@ -151,7 +151,9 @@ const ProxySettingsSub: FC = ({ proxyContextValue }) => { e.preventDefault(); if (!p.healthy) { - displayError("Please select a healthy workspace proxy."); + toast.error("Failed to select proxy.", { + description: "Please select a healthy workspace proxy.", + }); return; } diff --git a/site/src/modules/dashboard/Navbar/ProxyMenu.tsx b/site/src/modules/dashboard/Navbar/ProxyMenu.tsx index d69ad199c3..7f2be688ff 100644 --- a/site/src/modules/dashboard/Navbar/ProxyMenu.tsx +++ b/site/src/modules/dashboard/Navbar/ProxyMenu.tsx @@ -12,12 +12,12 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "components/DropdownMenu/DropdownMenu"; -import { displayError } from "components/GlobalSnackbar/utils"; import { Latency } from "components/Latency/Latency"; import type { ProxyContextValue } from "contexts/ProxyContext"; import { useAuthenticated } from "hooks"; import { type FC, useState } from "react"; import { Link } from "react-router"; +import { toast } from "sonner"; import { sortProxiesByLatency } from "./proxyUtils"; interface ProxyMenuProps { @@ -133,7 +133,13 @@ export const ProxyMenu: FC = ({ proxyContextValue }) => { onClick={(e) => { e.preventDefault(); if (!proxy.healthy) { - displayError("Please select a healthy workspace proxy."); + toast.error( + `Failed to select proxy "${proxy.display_name}".`, + { + description: + "Please select a healthy workspace proxy.", + }, + ); closeMenu(); return; } diff --git a/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx index d9f356a491..056a493565 100644 --- a/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx +++ b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.stories.tsx @@ -1,5 +1,5 @@ import { MockNotifications, mockApiError } from "testHelpers/entities"; -import { withGlobalSnackbar } from "testHelpers/storybook"; +import { withToaster } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { NotificationsInbox } from "./NotificationsInbox"; @@ -104,7 +104,7 @@ export const MarkAllAsRead: Story = { }; export const MarkAllAsReadFailure: Story = { - decorators: [withGlobalSnackbar], + decorators: [withToaster], args: { defaultOpen: true, fetchNotifications: fn(async () => ({ @@ -158,7 +158,7 @@ export const MarkNotificationAsRead: Story = { }; export const MarkNotificationAsReadFailure: Story = { - decorators: [withGlobalSnackbar], + decorators: [withToaster], args: { defaultOpen: true, fetchNotifications: fn(async () => ({ diff --git a/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx index 05f959f5a1..8937796c6e 100644 --- a/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx +++ b/site/src/modules/notifications/NotificationsInbox/NotificationsInbox.tsx @@ -4,10 +4,10 @@ import type { ListInboxNotificationsResponse, UpdateInboxNotificationReadStatusResponse, } from "api/typesGenerated"; -import { displayError } from "components/GlobalSnackbar/utils"; import { useEffectEvent } from "hooks/hookPolyfills"; import { type FC, useEffect } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { toast } from "sonner"; import { InboxPopover } from "./InboxPopover"; const NOTIFICATIONS_QUERY_KEY = ["notifications"]; @@ -79,9 +79,9 @@ export const NotificationsInbox: FC = ({ }); socket.addEventListener("error", () => { - displayError( - "Unable to retrieve latest inbox notifications. Please try refreshing the browser.", - ); + toast.error("Unable to retrieve latest inbox notifications.", { + description: "Please try refreshing the browser.", + }); socket.close(); }); @@ -107,10 +107,9 @@ export const NotificationsInbox: FC = ({ }); }, onError: (error) => { - displayError( - getErrorMessage(error, "Error loading more notifications"), - getErrorDetail(error), - ); + toast.error(getErrorMessage(error, "Error loading more notifications."), { + description: getErrorDetail(error), + }); }, }); @@ -128,9 +127,9 @@ export const NotificationsInbox: FC = ({ }); }, onError: (error) => { - displayError( - getErrorMessage(error, "Error on marking all notifications as read"), - getErrorDetail(error), + toast.error( + getErrorMessage(error, "Error on marking all notifications as read."), + { description: getErrorDetail(error) }, ); }, }); @@ -151,9 +150,9 @@ export const NotificationsInbox: FC = ({ }); }, onError: (error) => { - displayError( - getErrorMessage(error, "Error on marking notification as read"), - getErrorDetail(error), + toast.error( + getErrorMessage(error, "Error on marking notification as read."), + { description: getErrorDetail(error) }, ); }, }); diff --git a/site/src/modules/resources/AgentDevcontainerCard.tsx b/site/src/modules/resources/AgentDevcontainerCard.tsx index 5fecb2d80e..01255b80d3 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.tsx @@ -22,7 +22,6 @@ import { DialogHeader, DialogTitle, } from "components/Dialog/Dialog"; -import { displayError } from "components/GlobalSnackbar/utils"; import { Spinner } from "components/Spinner/Spinner"; import { Tooltip, @@ -35,6 +34,7 @@ import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { AppStatuses } from "pages/WorkspacePage/AppStatuses"; import type { FC } from "react"; import { useMutation, useQueryClient } from "react-query"; +import { toast } from "sonner"; import { cn } from "utils/cn"; import { portForwardURL } from "utils/portForward"; import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps"; @@ -144,7 +144,9 @@ export const AgentDevcontainerCard: FC = ({ const errorMessage = error instanceof Error ? error.message : "An unknown error occurred."; - displayError(`Failed to rebuild devcontainer: ${errorMessage}`); + toast.error(`Failed to rebuild devcontainer "${devcontainer.name}".`, { + description: errorMessage, + }); console.error("Failed to rebuild devcontainer:", error); }, }); diff --git a/site/src/modules/resources/AgentMetadata.tsx b/site/src/modules/resources/AgentMetadata.tsx index dcdcf3fb32..7dc70435f7 100644 --- a/site/src/modules/resources/AgentMetadata.tsx +++ b/site/src/modules/resources/AgentMetadata.tsx @@ -5,7 +5,6 @@ import type { WorkspaceAgent, WorkspaceAgentMetadata, } from "api/typesGenerated"; -import { displayError } from "components/GlobalSnackbar/utils"; import { Stack } from "components/Stack/Stack"; import { Tooltip, @@ -21,6 +20,7 @@ import { useRef, useState, } from "react"; +import { toast } from "sonner"; import { cn } from "utils/cn"; import type { OneWayWebSocket } from "utils/OneWayWebSocket"; @@ -87,15 +87,18 @@ export const AgentMetadata: FC = ({ retries++; if (retries >= maxSocketErrorRetryCount) { - displayError( - "Unexpected disconnect while watching Metadata changes. Please try refreshing the page.", + toast.error( + "Unexpected disconnect while watching Metadata changes.", + { + description: "Please try refreshing the page.", + }, ); return; } - displayError( - "Unexpected disconnect while watching Metadata changes. Creating new connection...", - ); + toast.error("Unexpected disconnect while watching Metadata changes.", { + description: "Creating new connection...", + }); timeoutId = window.setTimeout(() => { createNewConnection(); }, 3_000); @@ -103,9 +106,9 @@ export const AgentMetadata: FC = ({ socket.addEventListener("message", (e) => { if (e.parseError) { - displayError( - "Unable to process newest response from server. Please try refreshing the page.", - ); + toast.error("Unable to process newest response from server.", { + description: "Please try refreshing the page.", + }); return; } diff --git a/site/src/modules/resources/AppLink/AppLink.stories.tsx b/site/src/modules/resources/AppLink/AppLink.stories.tsx index e094c6a32b..49051b692f 100644 --- a/site/src/modules/resources/AppLink/AppLink.stories.tsx +++ b/site/src/modules/resources/AppLink/AppLink.stories.tsx @@ -5,7 +5,7 @@ import { MockWorkspaceApp, MockWorkspaceProxies, } from "testHelpers/entities"; -import { withGlobalSnackbar, withProxyProvider } from "testHelpers/storybook"; +import { withProxyProvider, withToaster } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { getPreferredProxy } from "contexts/ProxyContext"; import { AppLink } from "./AppLink"; @@ -65,7 +65,7 @@ export const ExternalApp: Story = { }; export const ExternalAppNotInstalled: Story = { - decorators: [withGlobalSnackbar], + decorators: [withToaster], args: { workspace: MockWorkspace, app: { diff --git a/site/src/modules/resources/DownloadAgentLogsButton.tsx b/site/src/modules/resources/DownloadAgentLogsButton.tsx index 69a29f810a..b2ebe2139f 100644 --- a/site/src/modules/resources/DownloadAgentLogsButton.tsx +++ b/site/src/modules/resources/DownloadAgentLogsButton.tsx @@ -1,11 +1,12 @@ +import { getErrorDetail } from "api/errors"; import { agentLogs } from "api/queries/workspaces"; import type { WorkspaceAgent, WorkspaceAgentLog } from "api/typesGenerated"; import { Button } from "components/Button/Button"; -import { displayError } from "components/GlobalSnackbar/utils"; import { saveAs } from "file-saver"; import { DownloadIcon } from "lucide-react"; import { type FC, useState } from "react"; import { useQueryClient } from "react-query"; +import { toast } from "sonner"; type DownloadAgentLogsButtonProps = { agent: Pick; @@ -46,9 +47,11 @@ export const DownloadAgentLogsButton: FC = ({ const text = logs.map((l) => l.output).join("\n"); const file = new Blob([text], { type: "text/plain" }); download(file, `${agent.name}-logs.txt`); - } catch (e) { - console.error(e); - displayError("Failed to download logs"); + } catch (error) { + console.error(error); + toast.error(`Failed to download "${agent.name}" logs.`, { + description: getErrorDetail(error), + }); } finally { setIsDownloading(false); } diff --git a/site/src/modules/resources/useAgentContainers.jest.tsx b/site/src/modules/resources/useAgentContainers.jest.tsx index f00f7b242b..51925862c6 100644 --- a/site/src/modules/resources/useAgentContainers.jest.tsx +++ b/site/src/modules/resources/useAgentContainers.jest.tsx @@ -7,11 +7,11 @@ import { server } from "testHelpers/server"; import { renderHook, waitFor } from "@testing-library/react"; import * as API from "api/api"; import type { WorkspaceAgentListContainersResponse } from "api/typesGenerated"; -import * as GlobalSnackbar from "components/GlobalSnackbar/utils"; import { HttpResponse, http } from "msw"; import type { FC, PropsWithChildren } from "react"; import { act } from "react"; import { QueryClientProvider } from "react-query"; +import { toast } from "sonner"; import type { OneWayWebSocket } from "utils/OneWayWebSocket"; import { useAgentContainers } from "./useAgentContainers"; @@ -84,7 +84,7 @@ describe("useAgentContainers", () => { }); it("handles parsing errors from WebSocket", async () => { - const displayErrorSpy = jest.spyOn(GlobalSnackbar, "displayError"); + const toastErrorSpy = jest.spyOn(toast, "error"); const watchAgentContainersSpy = jest.spyOn(API, "watchAgentContainers"); const mockSocket = { @@ -134,19 +134,19 @@ describe("useAgentContainers", () => { } await waitFor(() => { - expect(displayErrorSpy).toHaveBeenCalledWith( - "Failed to update containers", - "Please try refreshing the page", + expect(toastErrorSpy).toHaveBeenCalledWith( + "Failed to update containers.", + { description: "Please try refreshing the page." }, ); }); unmount(); - displayErrorSpy.mockRestore(); + toastErrorSpy.mockRestore(); watchAgentContainersSpy.mockRestore(); }); it("handles WebSocket errors", async () => { - const displayErrorSpy = jest.spyOn(GlobalSnackbar, "displayError"); + const toastErrorSpy = jest.spyOn(toast, "error"); const watchAgentContainersSpy = jest.spyOn(API, "watchAgentContainers"); const mockSocket = { @@ -193,14 +193,13 @@ describe("useAgentContainers", () => { } await waitFor(() => { - expect(displayErrorSpy).toHaveBeenCalledWith( - "Failed to load containers", - "Please try refreshing the page", - ); + expect(toastErrorSpy).toHaveBeenCalledWith("Failed to load containers.", { + description: "Please try refreshing the page.", + }); }); unmount(); - displayErrorSpy.mockRestore(); + toastErrorSpy.mockRestore(); watchAgentContainersSpy.mockRestore(); }); diff --git a/site/src/modules/resources/useAgentContainers.ts b/site/src/modules/resources/useAgentContainers.ts index e986ef8feb..16f49da019 100644 --- a/site/src/modules/resources/useAgentContainers.ts +++ b/site/src/modules/resources/useAgentContainers.ts @@ -8,10 +8,10 @@ import type { WorkspaceAgentDevcontainer, WorkspaceAgentListContainersResponse, } from "api/typesGenerated"; -import { displayError } from "components/GlobalSnackbar/utils"; import { useEffectEvent } from "hooks/hookPolyfills"; import { useEffect } from "react"; import { useQuery, useQueryClient } from "react-query"; +import { toast } from "sonner"; export function useAgentContainers( agent: WorkspaceAgent, @@ -43,10 +43,9 @@ export function useAgentContainers( socket.addEventListener("message", (event) => { if (event.parseError) { - displayError( - "Failed to update containers", - "Please try refreshing the page", - ); + toast.error("Failed to update containers.", { + description: "Please try refreshing the page.", + }); return; } @@ -54,10 +53,9 @@ export function useAgentContainers( }); socket.addEventListener("error", () => { - displayError( - "Failed to load containers", - "Please try refreshing the page", - ); + toast.error("Failed to load containers.", { + description: "Please try refreshing the page.", + }); }); return () => socket.close(); diff --git a/site/src/modules/resources/useAgentLogs.jest.ts b/site/src/modules/resources/useAgentLogs.jest.ts index 27281d51a2..6e5b09136d 100644 --- a/site/src/modules/resources/useAgentLogs.jest.ts +++ b/site/src/modules/resources/useAgentLogs.jest.ts @@ -6,8 +6,8 @@ import { import { renderHook, waitFor } from "@testing-library/react"; import * as apiModule from "api/api"; import type { WorkspaceAgentLog } from "api/typesGenerated"; -import * as snackbarUtils from "components/GlobalSnackbar/utils"; import { act } from "react"; +import { toast } from "sonner"; import { OneWayWebSocket } from "utils/OneWayWebSocket"; import { useAgentLogs } from "./useAgentLogs"; @@ -45,7 +45,7 @@ type MountHookOptions = Readonly<{ type MountHookResult = Readonly<{ serverResult: ServerResult; rerender: (props: { agentId: string; enabled: boolean }) => void; - displayError: jest.SpyInstance; + toastError: jest.SpyInstance; // Note: the `current` property is only "halfway" readonly; the value is // readonly, but the key is still mutable @@ -74,14 +74,14 @@ function mountHook(options: MountHookOptions): MountHookResult { }); void jest.spyOn(console, "error").mockImplementation(() => {}); - const displayError = jest.spyOn(snackbarUtils, "displayError"); + const toastError = jest.spyOn(toast, "error"); const { result: hookResult, rerender } = renderHook( (props) => useAgentLogs(props), { initialProps: { enabled, agentId: initialAgentId } }, ); - return { rerender, serverResult, hookResult, displayError }; + return { rerender, serverResult, hookResult, toastError }; } describe("useAgentLogs", () => { @@ -144,7 +144,7 @@ describe("useAgentLogs", () => { }); it("Calls error callback when error is received (but only while hook is enabled)", async () => { - const { serverResult, rerender, displayError } = mountHook({ + const { serverResult, rerender, toastError } = mountHook({ initialAgentId: MockWorkspaceAgent.id, // Start off disabled so that we can check that the callback is // never called when there is no connection @@ -153,11 +153,11 @@ describe("useAgentLogs", () => { const errorEvent = new Event("error"); await act(async () => serverResult.current?.publishError(errorEvent)); - expect(displayError).not.toHaveBeenCalled(); + expect(toastError).not.toHaveBeenCalled(); rerender({ agentId: MockWorkspaceAgent.id, enabled: true }); await act(async () => serverResult.current?.publishError(errorEvent)); - expect(displayError).toHaveBeenCalledTimes(1); + expect(toastError).toHaveBeenCalledTimes(1); }); // This is a protection to avoid duplicate logs when the hook goes back to diff --git a/site/src/modules/resources/useAgentLogs.ts b/site/src/modules/resources/useAgentLogs.ts index 300bedb9f7..399fed7f11 100644 --- a/site/src/modules/resources/useAgentLogs.ts +++ b/site/src/modules/resources/useAgentLogs.ts @@ -1,7 +1,7 @@ import { watchWorkspaceAgentLogs } from "api/api"; import type { WorkspaceAgentLog } from "api/typesGenerated"; -import { displayError } from "components/GlobalSnackbar/utils"; import { useEffect, useState } from "react"; +import { toast } from "sonner"; type UseAgentLogsOptions = Readonly<{ agentId: string; @@ -57,12 +57,11 @@ export function useAgentLogs( }); }); - socket.addEventListener("error", (e) => { - console.error("Error in agent log socket: ", e); - displayError( - "Unable to watch agent logs", - "Please try refreshing the browser", - ); + socket.addEventListener("error", (error) => { + console.error("Error in agent log socket: ", error); + toast.error(`Unable to watch "${agentId}" agent logs.`, { + description: "Please try refreshing the browser.", + }); socket.close(); }); diff --git a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx index 4cd119db5c..dd1d6a2d0c 100644 --- a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx +++ b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx @@ -1,5 +1,5 @@ import { MockTask } from "testHelpers/entities"; -import { withGlobalSnackbar } from "testHelpers/storybook"; +import { withToaster } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; @@ -8,14 +8,14 @@ import { TaskDeleteDialog } from "./TaskDeleteDialog"; const meta: Meta = { title: "modules/tasks/TaskDeleteDialog", component: TaskDeleteDialog, - decorators: [withGlobalSnackbar], + decorators: [withToaster], }; export default meta; type Story = StoryObj; export const DeleteTaskSuccess: Story = { - decorators: [withGlobalSnackbar], + decorators: [withToaster], args: { open: true, task: MockTask, diff --git a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx index 4e6f526581..a2a230bab5 100644 --- a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx +++ b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx @@ -2,9 +2,9 @@ import { API } from "api/api"; import { getErrorDetail, getErrorMessage } from "api/errors"; import type { Task } from "api/typesGenerated"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import type { FC } from "react"; import { QueryClient, useMutation } from "react-query"; +import { toast } from "sonner"; type TaskDeleteDialogProps = { open: boolean; @@ -32,19 +32,17 @@ export const TaskDeleteDialog: FC = ({ type="delete" confirmLoading={deleteTaskMutation.isPending} title="Delete task" - onConfirm={async () => { - try { - await deleteTaskMutation.mutateAsync(); - displaySuccess("Task deleted successfully"); - onSuccess?.(); - } catch (error) { - displayError( - getErrorMessage(error, "Failed to delete task"), - getErrorDetail(error), - ); - } finally { - props.onClose(); - } + onConfirm={() => { + const mutation = deleteTaskMutation.mutateAsync(); + toast.promise(mutation, { + loading: `Deleting "${task.name}"...`, + success: `"${task.name}" was deleted successfully.`, + error: (e) => ({ + message: getErrorMessage(e, `Failed to delete ${task.name}.`), + description: getErrorDetail(e), + }), + }); + mutation.then(() => onSuccess?.()).finally(() => props.onClose()); }} description={

diff --git a/site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.stories.tsx b/site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.stories.tsx index 32a6055e35..f651fd6706 100644 --- a/site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.stories.tsx +++ b/site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.stories.tsx @@ -1,5 +1,5 @@ import { MockTask, mockApiError } from "testHelpers/entities"; -import { withGlobalSnackbar } from "testHelpers/storybook"; +import { withToaster } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; import { expect, spyOn, userEvent, within } from "storybook/test"; @@ -51,7 +51,7 @@ export const Success: Story = { args: { open: true, }, - decorators: [withGlobalSnackbar], + decorators: [withToaster], beforeEach: async () => { spyOn(API, "createTaskFeedback").mockResolvedValue(); }, @@ -76,7 +76,7 @@ export const Success: Story = { }); step("submitted successfully", async () => { - await body.findByText("Feedback submitted successfully"); + await body.findByText("Feedback submitted successfully."); expect(API.createTaskFeedback).toHaveBeenCalledWith(MockTask.id, { rate: "regular", comment: "This is my comment", diff --git a/site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.tsx b/site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.tsx index f8599334fb..b4b0e7ac69 100644 --- a/site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.tsx +++ b/site/src/modules/tasks/TaskFeedbackDialog/TaskFeedbackDialog.tsx @@ -15,13 +15,13 @@ import { DialogTitle, } from "components/Dialog/Dialog"; import type { DialogProps } from "components/Dialogs/Dialog"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; import { Spinner } from "components/Spinner/Spinner"; import { Textarea } from "components/Textarea/Textarea"; import { useFormik } from "formik"; import { FrownIcon, MehIcon, SmileIcon } from "lucide-react"; import type { FC, HTMLProps, ReactNode } from "react"; import { useMutation } from "react-query"; +import { toast } from "sonner"; type TaskFeedbackFormValues = { rate: TaskFeedbackRating | null; @@ -44,7 +44,7 @@ export const TaskFeedbackDialog: FC = ({ mutationFn: (req: CreateTaskFeedbackRequest) => API.createTaskFeedback(taskId, req), onSuccess: () => { - displaySuccess("Feedback submitted successfully"); + toast.success("Feedback submitted successfully."); }, }); diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx index 57eea64ef3..3eca101e70 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.stories.tsx @@ -10,7 +10,7 @@ import { MockUserOwner, mockApiError, } from "testHelpers/entities"; -import { withAuthProvider, withGlobalSnackbar } from "testHelpers/storybook"; +import { withAuthProvider, withToaster } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; import type { Task } from "api/typesGenerated"; @@ -110,7 +110,7 @@ export const SubmitDisabledWhenPromptEmpty: Story = { }; export const Submitting: Story = { - decorators: [withGlobalSnackbar], + decorators: [withToaster], beforeEach: () => { spyOn(API, "createTask").mockImplementation( () => @@ -139,7 +139,7 @@ export const Submitting: Story = { }; export const OnSuccess: Story = { - decorators: [withGlobalSnackbar], + decorators: [withToaster], parameters: { permissions: { updateTemplates: false, @@ -174,7 +174,7 @@ export const OnSuccess: Story = { await step("Displays success message", async () => { const body = within(canvasElement.ownerDocument.body); - const successMessage = await body.findByText(/task created/i); + const successMessage = await body.findByText(/created successfully/i); expect(successMessage).toBeInTheDocument(); }); @@ -186,7 +186,7 @@ export const OnSuccess: Story = { }; export const ChangeTemplate: Story = { - decorators: [withGlobalSnackbar], + decorators: [withToaster], args: { templates: [ { @@ -250,7 +250,7 @@ export const ChangeTemplate: Story = { }; export const SelectTemplateVersion: Story = { - decorators: [withGlobalSnackbar], + decorators: [withToaster], beforeEach: () => { spyOn(API, "getTemplateVersions").mockResolvedValue([ { @@ -299,14 +299,14 @@ export const SelectTemplateVersion: Story = { await step("Displays success message", async () => { const body = within(canvasElement.ownerDocument.body); - const successMessage = await body.findByText(/task created/i); + const successMessage = await body.findByText(/created successfully/i); expect(successMessage).toBeInTheDocument(); }); }, }; export const OnError: Story = { - decorators: [withGlobalSnackbar], + decorators: [withToaster], beforeEach: () => { spyOn(API, "getTemplate").mockResolvedValue(MockTemplate); spyOn(API, "getTasks").mockResolvedValue(MockTasks); diff --git a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx index 569dac5c43..850c938735 100644 --- a/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx +++ b/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx @@ -11,7 +11,6 @@ import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Badge } from "components/Badge/Badge"; import { Button } from "components/Button/Button"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Kbd, KbdGroup } from "components/Kbd/Kbd"; import { Link } from "components/Link/Link"; import { @@ -32,9 +31,11 @@ import { useExternalAuth } from "hooks/useExternalAuth"; import { ArrowUpIcon, InfoIcon, RedoIcon, RotateCcwIcon } from "lucide-react"; import { type FC, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useNavigate } from "react-router"; import TextareaAutosize, { type TextareaAutosizeProps, } from "react-textarea-autosize"; +import { toast } from "sonner"; import { docs } from "utils/docs"; import { getOSKey } from "utils/platform"; import { PromptSelectTrigger } from "./PromptSelectTrigger"; @@ -51,6 +52,8 @@ export const TaskPrompt: FC = ({ error, onRetry, }) => { + const navigate = useNavigate(); + if (error) { return ; } @@ -63,8 +66,14 @@ export const TaskPrompt: FC = ({ return ( { - displaySuccess("Task created successfully"); + onSuccess={(task) => { + toast.success(`Task "${task.name}" created successfully.`, { + description: `"${task.initial_prompt}"`, + action: { + label: "View task", + onClick: () => navigate(`/tasks/${task.owner_name}/${task.id}`), + }, + }); }} /> ); @@ -216,7 +225,7 @@ const CreateTaskForm: FC = ({ templates, onSuccess }) => { } catch (error) { const message = getErrorMessage(error, "Error creating task"); const detail = getErrorDetail(error) ?? "Please try again"; - displayError(message, detail); + toast.error(message, { description: detail }); } }; diff --git a/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx b/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx index 0f2001fd42..aa920cd60d 100644 --- a/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx +++ b/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx @@ -1,5 +1,5 @@ import { API } from "api/api"; -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { pauseTask, resumeTask } from "api/queries/tasks"; import type { Task, TasksFilter } from "api/typesGenerated"; import { Button } from "components/Button/Button"; @@ -11,7 +11,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "components/DropdownMenu/DropdownMenu"; -import { displayError } from "components/GlobalSnackbar/utils"; import { CoderIcon } from "components/Icons/CoderIcon"; import { ScrollArea } from "components/ScrollArea/ScrollArea"; import { Skeleton } from "components/Skeleton/Skeleton"; @@ -36,6 +35,7 @@ import { import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { Link as RouterLink, useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; import { cn } from "utils/cn"; import { TaskDeleteDialog } from "../TaskDeleteDialog/TaskDeleteDialog"; import { taskStatusToStatusIndicatorVariant } from "../TaskStatus/TaskStatus"; @@ -196,13 +196,23 @@ const TaskSidebarMenuItem: FC = ({ task }) => { const pauseMutation = useMutation({ ...pauseTask(task, queryClient), onError: (error: unknown) => { - displayError(getErrorMessage(error, "Failed to pause task.")); + toast.error( + getErrorMessage(error, `Failed to pause task "${task.name}".`), + { + description: getErrorDetail(error), + }, + ); }, }); const resumeMutation = useMutation({ ...resumeTask(task, queryClient), onError: (error: unknown) => { - displayError(getErrorMessage(error, "Failed to resume task.")); + toast.error( + getErrorMessage(error, `Failed to resume task "${task.name}".`), + { + description: getErrorDetail(error), + }, + ); }, }); diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/DownloadLogsDialog.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/DownloadLogsDialog.tsx index 95d5455559..cea9de7996 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/DownloadLogsDialog.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/DownloadLogsDialog.tsx @@ -1,5 +1,6 @@ import { type Interpolation, type Theme, useTheme } from "@emotion/react"; import Skeleton from "@mui/material/Skeleton"; +import { getErrorDetail } from "api/errors"; import { agentLogs, buildLogs } from "api/queries/workspaces"; import type { Workspace, WorkspaceAgent } from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; @@ -7,12 +8,12 @@ import { ConfirmDialog, type ConfirmDialogProps, } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; -import { displayError } from "components/GlobalSnackbar/utils"; import { Stack } from "components/Stack/Stack"; import { saveAs } from "file-saver"; import JSZip from "jszip"; import { type FC, useEffect, useMemo, useRef, useState } from "react"; import { useQueries, useQuery } from "react-query"; +import { toast } from "sonner"; type DownloadLogsDialogProps = Pick< ConfirmDialogProps, @@ -134,7 +135,9 @@ export const DownloadLogsDialog: FC = ({ }, theme.transitions.duration.leavingScreen); } catch (error) { setIsDownloading(false); - displayError("Error downloading workspace logs"); + toast.error(`Error downloading workspace "${workspace.name}" logs.`, { + description: getErrorDetail(error), + }); console.error(error); } }} diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx index d9418db618..66fb4f30f1 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx @@ -1,5 +1,10 @@ import { MissingBuildParameters, ParameterValidationError } from "api/api"; -import { type ApiError, getErrorMessage, isApiError } from "api/errors"; +import { + type ApiError, + getErrorDetail, + getErrorMessage, + isApiError, +} from "api/errors"; import { changeVersion, deleteWorkspace, @@ -14,7 +19,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "components/DropdownMenu/DropdownMenu"; -import { displayError } from "components/GlobalSnackbar/utils"; import { CopyIcon, DownloadIcon, @@ -27,6 +31,7 @@ import { import { type FC, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { Link as RouterLink } from "react-router"; +import { toast } from "sonner"; import { WorkspaceErrorDialog } from "../ErrorDialog/WorkspaceErrorDialog"; import { ChangeWorkspaceVersionDialog } from "./ChangeWorkspaceVersionDialog"; import { DownloadLogsDialog } from "./DownloadLogsDialog"; @@ -78,7 +83,15 @@ export const WorkspaceMoreActions: FC = ({ error: error, }); } else { - displayError(getErrorMessage(error, "Failed to delete workspace.")); + toast.error( + getErrorMessage( + error, + `Failed to delete workspace "${workspace.name}".`, + ), + { + description: getErrorDetail(error), + }, + ); } }; diff --git a/site/src/modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx b/site/src/modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx index 5f6a415220..dc6d543efb 100644 --- a/site/src/modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx +++ b/site/src/modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx @@ -4,7 +4,6 @@ import Skeleton from "@mui/material/Skeleton"; import { getErrorDetail, getErrorMessage } from "api/errors"; import { templateVersion } from "api/queries/templates"; import type { Workspace } from "api/typesGenerated"; -import { displayError } from "components/GlobalSnackbar/utils"; import { HelpTooltip, HelpTooltipAction, @@ -19,6 +18,7 @@ import { InfoIcon, RotateCcwIcon } from "lucide-react"; import { linkToTemplate, useLinks } from "modules/navigation"; import { type FC, type ReactNode, useState } from "react"; import { useQuery } from "react-query"; +import { toast } from "sonner"; import { useWorkspaceUpdate, WorkspaceUpdateDialogs, @@ -71,9 +71,11 @@ const WorkspaceOutdatedTooltipContent: FC = ({ workspace, latestVersion: activeVersion, onError: (error) => { - displayError( - getErrorMessage(error, "Error updating workspace"), - getErrorDetail(error), + toast.error( + getErrorMessage(error, `Error updating workspace "${workspace.name}".`), + { + description: getErrorDetail(error), + }, ); }, }); diff --git a/site/src/modules/workspaces/WorkspaceSharingForm/useWorkspaceSharing.ts b/site/src/modules/workspaces/WorkspaceSharingForm/useWorkspaceSharing.ts index 6c32420138..e339fe34e6 100644 --- a/site/src/modules/workspaces/WorkspaceSharingForm/useWorkspaceSharing.ts +++ b/site/src/modules/workspaces/WorkspaceSharingForm/useWorkspaceSharing.ts @@ -10,9 +10,9 @@ import type { WorkspaceRole, WorkspaceUser, } from "api/typesGenerated"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { toast } from "sonner"; /** * Encapsulates all data fetching and mutations for workspace sharing. @@ -38,13 +38,15 @@ export function useWorkspaceSharing(workspace: Workspace) { role: WorkspaceRole, reset: () => void, ) => { - await addUserMutation.mutateAsync({ + const mutation = addUserMutation.mutateAsync({ workspaceId: workspace.id, userId: user.id, role, }); - setHasRemovedMember(false); - displaySuccess("User added to workspace successfully!"); + toast.promise(mutation, { + loading: `Adding ${user.username} to workspace...`, + success: `"${user.username}" added to workspace successfully.`, + }); reset(); }; @@ -54,7 +56,7 @@ export function useWorkspaceSharing(workspace: Workspace) { userId: user.id, role, }); - displaySuccess("User role updated successfully!"); + toast.success(`"${user.username}" role updated successfully.`); }; const removeUser = async (user: WorkspaceUser) => { @@ -64,7 +66,7 @@ export function useWorkspaceSharing(workspace: Workspace) { role: "", }); setHasRemovedMember(true); - displaySuccess("User removed successfully!"); + toast.success(`"${user.username}" removed successfully.`); }; const addGroup = async ( @@ -78,7 +80,7 @@ export function useWorkspaceSharing(workspace: Workspace) { role, }); setHasRemovedMember(false); - displaySuccess("Group added to workspace successfully!"); + toast.success(`Group "${group.name}" added to workspace successfully.`); reset(); }; @@ -88,7 +90,7 @@ export function useWorkspaceSharing(workspace: Workspace) { groupId: group.id, role, }); - displaySuccess("Group role updated successfully!"); + toast.success(`Group role "${role}" updated successfully.`); }; const removeGroup = async (group: Group) => { @@ -98,7 +100,7 @@ export function useWorkspaceSharing(workspace: Workspace) { role: "", }); setHasRemovedMember(true); - displaySuccess("Group removed successfully!"); + toast.success(`Group "${group.name}" removed successfully.`); }; const mutationError = diff --git a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx index ccc44d879c..fe859eb83f 100644 --- a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx @@ -1,15 +1,15 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { uploadFile } from "api/queries/files"; import { JobError, templateVersionLogs, templateVersionVariables, } from "api/queries/templates"; -import { displayError } from "components/GlobalSnackbar/utils"; import { useDashboard } from "modules/dashboard/useDashboard"; import type { FC } from "react"; import { useMutation, useQuery } from "react-query"; import { useNavigate } from "react-router"; +import { toast } from "sonner"; import { CreateTemplateForm } from "./CreateTemplateForm"; import type { CreateTemplatePageViewProps } from "./types"; import { firstVersionFromFile, getFormPermissions, newTemplate } from "./utils"; @@ -56,7 +56,9 @@ export const UploadTemplateView: FC = ({ try { await uploadFileMutation.mutateAsync(file); } catch (error) { - displayError(getErrorMessage(error, "Failed to upload file")); + toast.error(getErrorMessage(error, "Failed to upload file."), { + description: getErrorDetail(error), + }); uploadFileMutation.reset(); } }, diff --git a/site/src/pages/CreateTokenPage/CreateTokenPage.tsx b/site/src/pages/CreateTokenPage/CreateTokenPage.tsx index 5184efb908..297714839b 100644 --- a/site/src/pages/CreateTokenPage/CreateTokenPage.tsx +++ b/site/src/pages/CreateTokenPage/CreateTokenPage.tsx @@ -1,14 +1,15 @@ import { API } from "api/api"; +import { getErrorDetail } from "api/errors"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { CodeExample } from "components/CodeExample/CodeExample"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { useFormik } from "formik"; import { type FC, useState } from "react"; import { useMutation, useQuery } from "react-query"; import { useNavigate } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { CreateTokenForm } from "./CreateTokenForm"; import { type CreateTokenData, NANO_HOUR } from "./utils"; @@ -41,13 +42,15 @@ const CreateTokenPage: FC = () => { const [formError, setFormError] = useState(undefined); const onCreateSuccess = () => { - displaySuccess("Token has been created"); + toast.success("Token has been created."); navigate("/settings/tokens"); }; const onCreateError = (error: unknown) => { setFormError(error); - displayError("Failed to create token"); + toast.error("Failed to create token.", { + description: getErrorDetail(error), + }); }; const form = useFormik({ diff --git a/site/src/pages/CreateUserPage/CreateUserPage.jest.tsx b/site/src/pages/CreateUserPage/CreateUserPage.jest.tsx index 271376b3a2..592316d28b 100644 --- a/site/src/pages/CreateUserPage/CreateUserPage.jest.tsx +++ b/site/src/pages/CreateUserPage/CreateUserPage.jest.tsx @@ -47,7 +47,7 @@ describe("Create User Page", () => { await renderCreateUserPage(); await fillForm({}); const successMessage = await screen.findByText( - "Successfully created user.", + 'User "someuser" created successfully.', ); expect(successMessage).toBeDefined(); }); diff --git a/site/src/pages/CreateUserPage/CreateUserPage.tsx b/site/src/pages/CreateUserPage/CreateUserPage.tsx index e05ae65adb..7f0044fb89 100644 --- a/site/src/pages/CreateUserPage/CreateUserPage.tsx +++ b/site/src/pages/CreateUserPage/CreateUserPage.tsx @@ -1,10 +1,11 @@ +import { getErrorDetail, getErrorMessage } from "api/errors"; import { authMethods, createUser } from "api/queries/users"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; import { Margins } from "components/Margins/Margins"; import { useDashboard } from "modules/dashboard/useDashboard"; import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { CreateUserForm } from "./CreateUserForm"; @@ -27,17 +28,33 @@ const CreateUserPage: FC = () => { error={createUserMutation.error} isLoading={createUserMutation.isPending} onSubmit={async (user) => { - await createUserMutation.mutateAsync({ - username: user.username, - name: user.name, - email: user.email, - organization_ids: [user.organization], - login_type: user.login_type, - password: user.password, - user_status: null, + const mutation = createUserMutation.mutateAsync( + { + username: user.username, + name: user.name, + email: user.email, + organization_ids: [user.organization], + login_type: user.login_type, + password: user.password, + user_status: null, + }, + { + onSuccess: () => { + navigate("..", { relative: "path" }); + }, + }, + ); + toast.promise(mutation, { + loading: `Creating user "${user.username}"...`, + success: `User "${user.username}" created successfully.`, + error: (e) => ({ + message: getErrorMessage( + e, + `Failed to create user "${user.username}".`, + ), + description: getErrorDetail(e), + }), }); - displaySuccess("Successfully created user."); - navigate("..", { relative: "path" }); }} onCancel={() => { navigate("..", { relative: "path" }); diff --git a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx index 014184bdf7..7dc8e6caeb 100644 --- a/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/AppearanceSettingsPage/AppearanceSettingsPage.tsx @@ -1,11 +1,11 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { appearanceConfigKey, updateAppearance } from "api/queries/appearance"; import type { UpdateAppearanceConfig } from "api/typesGenerated"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { useDashboard } from "modules/dashboard/useDashboard"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import type { FC } from "react"; import { useMutation, useQueryClient } from "react-query"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { AppearanceSettingsPageView } from "./AppearanceSettingsPageView"; @@ -24,15 +24,23 @@ const AppearanceSettingsPage: FC = () => { ) => { const newAppearance = { ...appearance, ...newConfig }; - try { - await updateAppearanceMutation.mutateAsync(newAppearance); - await queryClient.invalidateQueries({ queryKey: appearanceConfigKey }); - displaySuccess("Successfully updated appearance settings!"); - } catch (error) { - displayError( - getErrorMessage(error, "Failed to update appearance settings."), - ); - } + const mutation = updateAppearanceMutation.mutateAsync(newAppearance, { + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: appearanceConfigKey }); + }, + }); + + toast.promise(mutation, { + loading: "Updating appearance settings...", + success: "Appearance settings updated successfully.", + error: (error) => ({ + message: getErrorMessage( + error, + "Failed to update appearance settings.", + ), + description: getErrorDetail(error), + }), + }); }; return ( diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.tsx index 3ba3a88897..e4125869cf 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/ExportPolicyButton.tsx @@ -1,9 +1,10 @@ +import { getErrorDetail } from "api/errors"; import type { OrganizationSyncSettings } from "api/typesGenerated"; import { Button } from "components/Button/Button"; -import { displayError } from "components/GlobalSnackbar/utils"; import { saveAs } from "file-saver"; import { Download } from "lucide-react"; import { type FC, useState } from "react"; +import { toast } from "sonner"; interface ExportPolicyButtonProps { syncSettings: OrganizationSyncSettings | undefined; @@ -31,9 +32,11 @@ export const ExportPolicyButton: FC = ({ type: "application/json", }); download(file, "organizations_policy.json"); - } catch (e) { - console.error(e); - displayError("Failed to export organizations policy json"); + } catch (error) { + console.error(error); + toast.error("Failed to export organizations policy JSON.", { + description: getErrorDetail(error), + }); } finally { setIsDownloading(false); } diff --git a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx index e5f6d56bd5..a5f64c6bb4 100644 --- a/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/IdpOrgSyncPage/IdpOrgSyncPage.tsx @@ -1,11 +1,10 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { deploymentIdpSyncFieldValues } from "api/queries/deployment"; import { organizationIdpSyncSettings, patchOrganizationSyncSettings, } from "api/queries/idpsync"; import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Link } from "components/Link/Link"; import { Loader } from "components/Loader/Loader"; import { PaywallPremium } from "components/Paywall/PaywallPremium"; @@ -13,6 +12,7 @@ import { useDashboard } from "modules/dashboard/useDashboard"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { type FC, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { toast } from "sonner"; import { docs } from "utils/docs"; import { pageTitle } from "utils/page"; import { ExportPolicyButton } from "./ExportPolicyButton"; @@ -45,10 +45,10 @@ const IdpOrgSyncPage: FC = () => { useEffect(() => { if (patchOrganizationSyncSettingsMutation.error) { - displayError( + toast.error( getErrorMessage( patchOrganizationSyncSettingsMutation.error, - "Error updating organization idp sync settings.", + "Error updating organization IdP sync settings.", ), ); } @@ -93,13 +93,16 @@ const IdpOrgSyncPage: FC = () => { onSubmit={async (data) => { try { await patchOrganizationSyncSettingsMutation.mutateAsync(data); - displaySuccess("Organization sync settings updated."); + toast.success("Organization sync settings updated."); } catch (error) { - displayError( + toast.error( getErrorMessage( error, - "Failed to update organization IdP sync settings", + "Failed to update organization IdP sync settings.", ), + { + description: getErrorDetail(error), + }, ); } }} diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AddNewLicensePage.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AddNewLicensePage.tsx index af9bfb413f..6e1a925639 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AddNewLicensePage.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AddNewLicensePage.tsx @@ -1,8 +1,9 @@ import { API } from "api/api"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import { getErrorDetail } from "api/errors"; import type { FC } from "react"; import { useMutation } from "react-query"; import { useNavigate } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { AddNewLicensePageView } from "./AddNewLicensePageView"; @@ -16,10 +17,13 @@ const AddNewLicensePage: FC = () => { } = useMutation({ mutationFn: API.createLicense, onSuccess: () => { - displaySuccess("You have successfully added a license"); + toast.success("You have successfully added a license."); navigate("/deployment/licenses?success=true"); }, - onError: () => displayError("Failed to save license key"), + onError: (error) => + toast.error("Failed to save license key.", { + description: getErrorDetail(error), + }), }); function saveLicenseKey(licenseKey: string) { @@ -27,10 +31,13 @@ const AddNewLicensePage: FC = () => { { license: licenseKey }, { onSuccess: () => { - displaySuccess("You have successfully added a license"); + toast.success("You have successfully added a license."); navigate("/deployment/licenses?success=true"); }, - onError: () => displayError("Failed to save license key"), + onError: (error) => + toast.error("Failed to save license key.", { + description: getErrorDetail(error), + }), }, ); } diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AddNewLicensePageView.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AddNewLicensePageView.tsx index 2815bfe15a..7639948dc8 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AddNewLicensePageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AddNewLicensePageView.tsx @@ -1,8 +1,8 @@ import TextField from "@mui/material/TextField"; +import { getErrorDetail } from "api/errors"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Button } from "components/Button/Button"; import { FileUpload } from "components/FileUpload/FileUpload"; -import { displayError } from "components/GlobalSnackbar/utils"; import { SettingsHeader, SettingsHeaderDescription, @@ -12,6 +12,7 @@ import { Stack } from "components/Stack/Stack"; import { ChevronLeftIcon } from "lucide-react"; import type { FC } from "react"; import { Link as RouterLink } from "react-router"; +import { toast } from "sonner"; import { Fieldset } from "../Fieldset"; import { DividerWithText } from "./DividerWithText"; @@ -33,8 +34,10 @@ export const AddNewLicensePageView: FC = ({ onSaveLicenseKey(licenseKey); - fileReader.onerror = () => { - displayError("Failed to read file"); + fileReader.onerror = (error) => { + toast.error("Failed to read file.", { + description: getErrorDetail(error), + }); }; }; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx index 8f151cdf15..5469a73d0d 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx @@ -1,12 +1,12 @@ import { API } from "api/api"; -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { entitlements, refreshEntitlements } from "api/queries/entitlements"; import { insightsUserStatusCounts } from "api/queries/insights"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { type FC, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useSearchParams } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import LicensesSettingsPageView from "./LicensesSettingsPageView"; @@ -27,11 +27,14 @@ const LicensesSettingsPage: FC = () => { useEffect(() => { if (entitlementsQuery.error) { - displayError( + toast.error( getErrorMessage( entitlementsQuery.error, - "Failed to fetch entitlements", + "Failed to fetch entitlements.", ), + { + description: getErrorDetail(entitlementsQuery.error), + }, ); } }, [entitlementsQuery.error]); @@ -40,11 +43,13 @@ const LicensesSettingsPage: FC = () => { useMutation({ mutationFn: API.removeLicense, onSuccess: () => { - displaySuccess("Successfully removed license"); + toast.success("Successfully removed license."); void queryClient.invalidateQueries({ queryKey: ["licenses"] }); }, - onError: () => { - displayError("Failed to remove license"); + onError: (error) => { + toast.error("Failed to remove license.", { + description: getErrorDetail(error), + }); }, }); @@ -92,9 +97,11 @@ const LicensesSettingsPage: FC = () => { refreshEntitlements={async () => { try { await refreshEntitlementsMutation.mutateAsync(); - displaySuccess("Successfully removed license"); + toast.success("Successfully removed license."); } catch (error) { - displayError(getErrorMessage(error, "Failed to remove license")); + toast.error(getErrorMessage(error, "Failed to remove license."), { + description: getErrorDetail(error), + }); } }} /> diff --git a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.stories.tsx b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.stories.tsx index e9f955b1f9..7d72d3b308 100644 --- a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.stories.tsx @@ -70,7 +70,7 @@ export const Toggle: Story = { name: "Webhook", }); await user.click(toggleButton); - await within(document.body).findByText("Notification method updated"); + await within(document.body).findByText("Notification method updated."); }, }; @@ -90,7 +90,7 @@ export const ToggleError: Story = { }); await user.click(toggleButton); await within(document.body).findByText( - "Failed to update notification method", + "Failed to update notification method.", ); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.tsx b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.tsx index 95fe96a2ac..87d41afa64 100644 --- a/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.tsx +++ b/site/src/pages/DeploymentSettingsPage/NotificationsPage/NotificationEvents.tsx @@ -6,7 +6,7 @@ import ListItem from "@mui/material/ListItem"; import ListItemText, { listItemTextClasses } from "@mui/material/ListItemText"; import ToggleButton from "@mui/material/ToggleButton"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { type selectTemplatesByGroup, updateNotificationTemplateMethod, @@ -14,7 +14,6 @@ import { import type { DeploymentValues } from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; import { Button } from "components/Button/Button"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Stack } from "components/Stack/Stack"; import { Tooltip, @@ -29,6 +28,7 @@ import { } from "modules/notifications/utils"; import { type FC, Fragment } from "react"; import { useMutation, useQueryClient } from "react-query"; +import { toast } from "sonner"; import { docs } from "utils/docs"; type NotificationEventsProps = { @@ -180,10 +180,13 @@ const MethodToggleGroup: FC = ({ await updateMethodMutation.mutateAsync({ method, }); - displaySuccess("Notification method updated"); + toast.success("Notification method updated."); } catch (error) { - displayError( - getErrorMessage(error, "Failed to update notification method"), + toast.error( + getErrorMessage(error, "Failed to update notification method."), + { + description: getErrorDetail(error), + }, ); } }} diff --git a/site/src/pages/DeploymentSettingsPage/NotificationsPage/Troubleshooting.stories.tsx b/site/src/pages/DeploymentSettingsPage/NotificationsPage/Troubleshooting.stories.tsx index a2afce8d7f..5923ea0ba2 100644 --- a/site/src/pages/DeploymentSettingsPage/NotificationsPage/Troubleshooting.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/NotificationsPage/Troubleshooting.stories.tsx @@ -26,6 +26,6 @@ export const TestNotification: Story = { name: "Send notification", }); await user.click(sendButton); - await within(document.body).findByText("Test notification sent"); + await within(document.body).findByText("Test notification sent."); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/NotificationsPage/Troubleshooting.tsx b/site/src/pages/DeploymentSettingsPage/NotificationsPage/Troubleshooting.tsx index 8d773942c1..52e6c90092 100644 --- a/site/src/pages/DeploymentSettingsPage/NotificationsPage/Troubleshooting.tsx +++ b/site/src/pages/DeploymentSettingsPage/NotificationsPage/Troubleshooting.tsx @@ -1,16 +1,20 @@ import { useTheme } from "@emotion/react"; import { API } from "api/api"; +import { getErrorDetail } from "api/errors"; import { Button } from "components/Button/Button"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Spinner } from "components/Spinner/Spinner"; import type { FC } from "react"; import { useMutation } from "react-query"; +import { toast } from "sonner"; export const Troubleshooting: FC = () => { const { mutate: sendTestNotificationApi, isPending } = useMutation({ mutationFn: API.postTestNotification, - onSuccess: () => displaySuccess("Test notification sent"), - onError: () => displayError("Failed to send test notification"), + onSuccess: () => toast.success("Test notification sent."), + onError: (error) => + toast.error("Failed to send test notification.", { + description: getErrorDetail(error), + }), }); const theme = useTheme(); diff --git a/site/src/pages/DeploymentSettingsPage/NotificationsPage/storybookUtils.ts b/site/src/pages/DeploymentSettingsPage/NotificationsPage/storybookUtils.ts index 31ed4904d5..1cbebec410 100644 --- a/site/src/pages/DeploymentSettingsPage/NotificationsPage/storybookUtils.ts +++ b/site/src/pages/DeploymentSettingsPage/NotificationsPage/storybookUtils.ts @@ -7,8 +7,8 @@ import { import { withAuthProvider, withDashboardProvider, - withGlobalSnackbar, withOrganizationSettingsProvider, + withToaster, } from "testHelpers/storybook"; import type { Meta } from "@storybook/react-vite"; import { @@ -219,7 +219,7 @@ export const baseMeta = { } as DeploymentValues, }, decorators: [ - withGlobalSnackbar, + withToaster, withAuthProvider, withDashboardProvider, withOrganizationSettingsProvider, diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPage.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPage.tsx index 85cc14a198..3296cd9e95 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/CreateOAuth2AppPage.tsx @@ -1,9 +1,10 @@ +import { getErrorDetail } from "api/errors"; import { postApp } from "api/queries/oauth2"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { useAuthenticated } from "hooks"; import type { FC } from "react"; import { useMutation, useQueryClient } from "react-query"; import { useNavigate, useSearchParams } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { CreateOAuth2AppPageView } from "./CreateOAuth2AppPageView"; @@ -30,15 +31,22 @@ const CreateOAuth2AppPage: FC = () => { error={postAppMutation.error} defaultValues={defaultValues} createApp={async (req) => { - try { - const app = await postAppMutation.mutateAsync(req); - displaySuccess( - `Successfully added the OAuth2 application "${app.name}".`, - ); - navigate(`/deployment/oauth2-provider/apps/${app.id}?created=true`); - } catch { - displayError("Failed to create OAuth2 application"); - } + const mutation = postAppMutation.mutateAsync(req, { + onSuccess: (app) => { + navigate( + `/deployment/oauth2-provider/apps/${app.id}?created=true`, + ); + }, + }); + toast.promise(mutation, { + loading: `Creating OAuth2 application "${req.name}"...`, + success: (app) => + `OAuth2 application "${app.name}" created successfully.`, + error: (error) => ({ + message: `Failed to create "${req.name}" OAuth2 application.`, + description: getErrorDetail(error), + }), + }); }} canCreateApp={canCreateApp} /> diff --git a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPage.tsx b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPage.tsx index 1fddf238ec..f0d3c11c12 100644 --- a/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/OAuth2AppsSettingsPage/EditOAuth2AppPage.tsx @@ -1,10 +1,11 @@ +import { getErrorDetail } from "api/errors"; import * as oauth2 from "api/queries/oauth2"; import type * as TypesGen from "api/typesGenerated"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { useAuthenticated } from "hooks"; import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { EditOAuth2AppPageView } from "./EditOAuth2AppPageView"; @@ -57,47 +58,71 @@ const EditOAuth2AppPage: FC = () => { deleteSecretMutation.error } updateApp={async (req) => { - try { - await putAppMutation.mutateAsync({ id: appId, req }); - // REVIEW: Maybe it is better to stay on the same page? - displaySuccess( - `Successfully updated the OAuth2 application "${req.name}".`, - ); - navigate("/deployment/oauth2-provider/apps?updated=true"); - } catch { - displayError("Failed to update OAuth2 application"); - } + const mutation = putAppMutation.mutateAsync( + { id: appId, req }, + { + onSuccess: () => { + navigate("/deployment/oauth2-provider/apps?updated=true"); + }, + }, + ); + toast.promise(mutation, { + success: `Successfully updated the OAuth2 application "${req.name}".`, + error: (error) => ({ + message: `Failed to update "${req.name}" OAuth2 application.`, + description: getErrorDetail(error), + }), + }); }} deleteApp={async (name) => { - try { - await deleteAppMutation.mutateAsync(appId); - displaySuccess( - `You have successfully deleted the OAuth2 application "${name}"`, - ); - navigate("/deployment/oauth2-provider/apps?deleted=true"); - } catch { - displayError("Failed to delete OAuth2 application"); - } + const mutation = deleteAppMutation.mutateAsync(appId, { + onSuccess: () => { + toast.success( + `You have successfully deleted the "${name}" OAuth2 application.`, + ); + navigate("/deployment/oauth2-provider/apps?deleted=true"); + }, + }); + toast.promise(mutation, { + success: `You have successfully deleted the "${name}" OAuth2 application.`, + error: (error) => ({ + message: `Failed to delete "${name}" OAuth2 application.`, + description: getErrorDetail(error), + }), + }); }} generateAppSecret={async () => { - try { - const secret = await postSecretMutation.mutateAsync(appId); - displaySuccess("Successfully generated OAuth2 client secret"); - setFullNewSecret(secret); - } catch { - displayError("Failed to generate OAuth2 client secret"); - } + const mutation = postSecretMutation.mutateAsync(appId, { + onSuccess: (secret) => { + setFullNewSecret(secret); + }, + }); + toast.promise(mutation, { + success: "Successfully generated OAuth2 client secret.", + error: (error) => ({ + message: "Failed to generate OAuth2 client secret.", + description: getErrorDetail(error), + }), + }); }} deleteAppSecret={async (secretId: string) => { - try { - await deleteSecretMutation.mutateAsync({ appId, secretId }); - displaySuccess("Successfully deleted an OAuth2 client secret"); - if (fullNewSecret?.id === secretId) { - setFullNewSecret(undefined); - } - } catch { - displayError("Failed to delete OAuth2 client secret"); - } + const mutation = deleteSecretMutation.mutateAsync( + { appId, secretId }, + { + onSuccess: () => { + if (fullNewSecret?.id === secretId) { + setFullNewSecret(undefined); + } + }, + }, + ); + toast.promise(mutation, { + success: "Successfully deleted an OAuth2 client secret.", + error: (error) => ({ + message: "Failed to delete OAuth2 client secret.", + description: getErrorDetail(error), + }), + }); }} canEditApp={permissions.editOAuth2App} canDeleteApp={permissions.deleteOAuth2App} diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx index 061061bb29..322c3e5ac9 100644 --- a/site/src/pages/GroupsPage/GroupPage.tsx +++ b/site/src/pages/GroupsPage/GroupPage.tsx @@ -1,5 +1,5 @@ import type { Interpolation, Theme } from "@emotion/react"; -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { addMember, deleteGroup, @@ -24,7 +24,6 @@ import { DropdownMenuTrigger, } from "components/DropdownMenu/DropdownMenu"; import { EmptyState } from "components/EmptyState/EmptyState"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { LastSeen } from "components/LastSeen/LastSeen"; import { Loader } from "components/Loader/Loader"; import { @@ -57,6 +56,7 @@ import { isEveryoneGroup } from "modules/groups"; import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { Link as RouterLink, useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; const GroupPage: FC = () => { @@ -149,7 +149,9 @@ const GroupPage: FC = () => { reset(); await groupQuery.refetch(); } catch (error) { - displayError(getErrorMessage(error, "Failed to add member.")); + toast.error(getErrorMessage(error, "Failed to add member."), { + description: getErrorDetail(error), + }); } }} /> @@ -190,18 +192,25 @@ const GroupPage: FC = () => { key={member.id} canUpdate={canUpdateGroup} onRemove={async () => { - try { - await removeMemberMutation.mutateAsync({ + const mutation = removeMemberMutation.mutateAsync( + { groupId: groupData.id, userId: member.id, - }); - await groupQuery.refetch(); - displaySuccess("Member removed successfully."); - } catch (error) { - displayError( - getErrorMessage(error, "Failed to remove member."), - ); - } + }, + { + onSuccess: () => { + groupQuery.refetch(); + }, + }, + ); + toast.promise(mutation, { + loading: `Removing member "${member.username}" from "${groupData.name}"...`, + success: `Member "${member.username}" has been removed from "${groupData.name}" successfully.`, + error: (error) => ({ + message: `Failed to remove member "${member.username}" from "${groupData.name}".`, + description: getErrorDetail(error), + }), + }); }} /> )) @@ -219,10 +228,20 @@ const GroupPage: FC = () => { onConfirm={async () => { try { await deleteGroupMutation.mutateAsync(groupId); - displaySuccess("Group deleted successfully."); + toast.success( + `Group "${groupQuery.data.name}" deleted successfully.`, + ); navigate(".."); } catch (error) { - displayError(getErrorMessage(error, "Failed to delete group.")); + toast.error( + getErrorMessage( + error, + `Failed to delete group "${groupQuery.data.name}".`, + ), + { + description: getErrorDetail(error), + }, + ); } }} onCancel={() => { diff --git a/site/src/pages/GroupsPage/GroupSettingsPage.tsx b/site/src/pages/GroupsPage/GroupSettingsPage.tsx index 4e17fd7af9..0098a9bff3 100644 --- a/site/src/pages/GroupsPage/GroupSettingsPage.tsx +++ b/site/src/pages/GroupsPage/GroupSettingsPage.tsx @@ -1,11 +1,11 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { group, patchGroup } from "api/queries/groups"; import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { displayError } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import GroupSettingsPageView from "./GroupSettingsPageView"; @@ -46,17 +46,30 @@ const GroupSettingsPage: FC = () => { { - try { - await patchGroupMutation.mutateAsync({ + await patchGroupMutation.mutateAsync( + { groupId, ...data, add_users: [], remove_users: [], - }); - navigate(`../${data.name}`); - } catch (error) { - displayError(getErrorMessage(error, "Failed to update group")); - } + }, + { + onSuccess: () => { + navigate(`../${data.name}`); + }, + onError: (error) => { + toast.error( + getErrorMessage( + error, + `Failed to update group "${groupName}".`, + ), + { + description: getErrorDetail(error), + }, + ); + }, + }, + ); }} group={groupQuery.data} formErrors={groupQuery.error} diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index f908ad9f83..39fdf2e57a 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -1,9 +1,8 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { groupsByOrganization } from "api/queries/groups"; import { organizationsPermissions } from "api/queries/organizations"; import { Button } from "components/Button/Button"; import { EmptyState } from "components/EmptyState/EmptyState"; -import { displayError } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { SettingsHeader, @@ -17,6 +16,7 @@ import { RequirePermission } from "modules/permissions/RequirePermission"; import { type FC, useEffect } from "react"; import { useQuery } from "react-query"; import { Link as RouterLink } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { useGroupsSettings } from "./GroupsPageProvider"; import { GroupsPageView } from "./GroupsPageView"; @@ -35,16 +35,22 @@ const GroupsPage: FC = () => { useEffect(() => { if (groupsQuery.error) { - displayError( + toast.error( getErrorMessage(groupsQuery.error, "Unable to load groups."), + { + description: getErrorDetail(groupsQuery.error), + }, ); } }, [groupsQuery.error]); useEffect(() => { if (permissionsQuery.error) { - displayError( + toast.error( getErrorMessage(permissionsQuery.error, "Unable to load permissions."), + { + description: getErrorDetail(permissionsQuery.error), + }, ); } }, [permissionsQuery.error]); diff --git a/site/src/pages/HealthPage/DismissWarningButton.tsx b/site/src/pages/HealthPage/DismissWarningButton.tsx index b1d04d2859..c8a8c536cc 100644 --- a/site/src/pages/HealthPage/DismissWarningButton.tsx +++ b/site/src/pages/HealthPage/DismissWarningButton.tsx @@ -2,10 +2,10 @@ import Skeleton from "@mui/material/Skeleton"; import { healthSettings, updateHealthSettings } from "api/queries/debug"; import type { HealthSection } from "api/typesGenerated"; import { Button } from "components/Button/Button"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; import { Spinner } from "components/Spinner/Spinner"; import { BellIcon, BellOffIcon } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { toast } from "sonner"; export const DismissWarningButton = (props: { healthcheck: HealthSection }) => { const queryClient = useQueryClient(); @@ -44,7 +44,7 @@ export const DismissWarningButton = (props: { healthcheck: HealthSection }) => { await enableMutation.mutateAsync({ dismissed_healthchecks: updatedSettings, }); - displaySuccess("Warnings enabled successfully!"); + toast.success("Warnings enabled successfully."); }} > @@ -64,7 +64,7 @@ export const DismissWarningButton = (props: { healthcheck: HealthSection }) => { await dismissMutation.mutateAsync({ dismissed_healthchecks: updatedSettings, }); - displaySuccess("Warnings dismissed successfully!"); + toast.success("Dismissed warnings successfully."); }} > diff --git a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx index bd84205bef..a9ab41f3a1 100644 --- a/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CreateOrganizationPage.tsx @@ -1,11 +1,11 @@ import { createOrganization } from "api/queries/organizations"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; import { useAuthenticated } from "hooks"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { RequirePermission } from "modules/permissions/RequirePermission"; import type { FC } from "react"; import { useMutation, useQueryClient } from "react-query"; import { useNavigate } from "react-router"; +import { toast } from "sonner"; import { CreateOrganizationPageView } from "./CreateOrganizationPageView"; const CreateOrganizationPage: FC = () => { @@ -27,9 +27,14 @@ const CreateOrganizationPage: FC = () => { error={error} isEntitled={feats.multiple_organizations} onSubmit={async (values) => { - await createOrganizationMutation.mutateAsync(values); - displaySuccess("Organization created."); - navigate(`/organizations/${values.name}`); + await createOrganizationMutation.mutateAsync(values, { + onSuccess: () => { + toast.success( + `Organization "${values.name}" created successfully.`, + ); + navigate(`/organizations/${values.name}`); + }, + }); }} /> diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx index 5255d945ba..562f0d4d8c 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { createOrganizationRole, organizationRoles, @@ -6,13 +6,13 @@ import { } from "api/queries/roles"; import type { CustomRoleRequest } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { displayError } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; import { RequirePermission } from "modules/permissions/RequirePermission"; import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import CreateEditRolePageView from "./CreateEditRolePageView"; @@ -61,18 +61,43 @@ const CreateEditRolePage: FC = () => { { - try { - if (role) { - await updateOrganizationRoleMutation.mutateAsync(data); - } else { - await createOrganizationRoleMutation.mutateAsync(data); - } - navigate(`/organizations/${organizationName}/roles`); - } catch (error) { - displayError( - getErrorMessage(error, "Failed to update custom role"), - ); - } + const mutation = role + ? updateOrganizationRoleMutation.mutateAsync(data, { + onSuccess: () => { + navigate(`/organizations/${organizationName}/roles`); + }, + }) + : createOrganizationRoleMutation.mutateAsync(data, { + onSuccess: () => { + navigate(`/organizations/${organizationName}/roles`); + }, + }); + toast.promise( + mutation, + role + ? { + loading: `Updating custom role "${data.name}"...`, + success: `Custom role "${data.name}" updated successfully.`, + error: (error) => ({ + message: getErrorMessage( + error, + `Failed to update custom role "${data.name}".`, + ), + description: getErrorDetail(error), + }), + } + : { + loading: `Creating custom role "${data.name}"...`, + success: `Custom role "${data.name}" created successfully.`, + error: (error) => ({ + message: getErrorMessage( + error, + `Failed to create custom role "${data.name}".`, + ), + description: getErrorDetail(error), + }), + }, + ); }} error={ role diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx index 2f18b252af..31c9cc5055 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -1,9 +1,8 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { deleteOrganizationRole, organizationRoles } from "api/queries/roles"; import type { Role } from "api/typesGenerated"; import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; import { EmptyState } from "components/EmptyState/EmptyState"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { SettingsHeader, SettingsHeaderDescription, @@ -16,6 +15,7 @@ import { RequirePermission } from "modules/permissions/RequirePermission"; import { type FC, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useParams } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { CustomRolesPageView } from "./CustomRolesPageView"; @@ -43,11 +43,14 @@ const CustomRolesPage: FC = () => { useEffect(() => { if (organizationRolesQuery.error) { - displayError( + toast.error( getErrorMessage( organizationRolesQuery.error, "Error loading custom roles.", ), + { + description: getErrorDetail(organizationRolesQuery.error), + }, ); } }, [organizationRolesQuery.error]); @@ -101,14 +104,24 @@ const CustomRolesPage: FC = () => { onConfirm={async () => { try { if (roleToDelete) { - await deleteRoleMutation.mutateAsync(roleToDelete.name); + await deleteRoleMutation.mutateAsync(roleToDelete.name, { + onSuccess: () => { + setRoleToDelete(undefined); + organizationRolesQuery.refetch(); + }, + }); } - setRoleToDelete(undefined); - await organizationRolesQuery.refetch(); - displaySuccess("Custom role deleted successfully!"); + toast.success( + roleToDelete + ? `Custom role "${roleToDelete.name}" deleted successfully.` + : "Custom role deleted successfully.", + ); } catch (error) { - displayError( - getErrorMessage(error, "Failed to delete custom role"), + toast.error( + getErrorMessage(error, "Failed to delete custom role."), + { + description: getErrorDetail(error), + }, ); } }} diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.tsx index 54b50aaf7b..03a21f87e3 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/ExportPolicyButton.tsx @@ -1,13 +1,14 @@ +import { getErrorDetail } from "api/errors"; import type { GroupSyncSettings, Organization, RoleSyncSettings, } from "api/typesGenerated"; import { Button } from "components/Button/Button"; -import { displayError } from "components/GlobalSnackbar/utils"; import { saveAs } from "file-saver"; import { DownloadIcon } from "lucide-react"; import { type FC, useState } from "react"; +import { toast } from "sonner"; interface DownloadPolicyButtonProps { syncSettings: RoleSyncSettings | GroupSyncSettings | undefined; @@ -40,9 +41,11 @@ export const ExportPolicyButton: FC = ({ type: "application/json", }); download(file, `${organization.name}_${type}-policy.json`); - } catch (e) { - console.error(e); - displayError("Failed to export policy json"); + } catch (error) { + console.error(error); + toast.error("Failed to export policy JSON.", { + description: getErrorDetail(error), + }); } finally { setIsDownloading(false); } diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx index 4e3156ceb8..f40a8dd0dd 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpSyncPage.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { groupsByOrganization } from "api/queries/groups"; import { groupIdpSyncSettings, @@ -10,7 +10,6 @@ import { import { organizationRoles } from "api/queries/roles"; import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; import { EmptyState } from "components/EmptyState/EmptyState"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Link } from "components/Link/Link"; import { PaywallPremium } from "components/Paywall/PaywallPremium"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; @@ -19,6 +18,7 @@ import { RequirePermission } from "modules/permissions/RequirePermission"; import { type FC, useEffect, useState } from "react"; import { useMutation, useQueries, useQuery, useQueryClient } from "react-query"; import { useParams, useSearchParams } from "react-router"; +import { toast } from "sonner"; import { docs } from "utils/docs"; import { pageTitle } from "utils/page"; import IdpSyncPageView from "./IdpSyncPageView"; @@ -150,28 +150,33 @@ const IdpSyncPage: FC = () => { onRoleSyncFieldChange={setRoleField} error={error} onSubmitGroupSyncSettings={async (data) => { - try { - await patchGroupSyncSettingsMutation.mutateAsync(data); - displaySuccess("IdP Group sync settings updated."); - } catch (error) { - displayError( - getErrorMessage( + const mutation = + patchGroupSyncSettingsMutation.mutateAsync(data); + toast.promise(mutation, { + loading: "Updating IdP group sync settings...", + success: "IdP group sync settings updated.", + error: (error) => ({ + message: getErrorMessage( error, - "Failed to update IdP group sync settings", + "Failed to update IdP group sync settings.", ), - ); - } + description: getErrorDetail(error), + }), + }); }} onSubmitRoleSyncSettings={async (data) => { try { await patchRoleSyncSettingsMutation.mutateAsync(data); - displaySuccess("IdP Role sync settings updated."); + toast.success("IdP Role sync settings updated."); } catch (error) { - displayError( + toast.error( getErrorMessage( error, - "Failed to update IdP role sync settings", + "Failed to update IdP role sync settings.", ), + { + description: getErrorDetail(error), + }, ); } }} diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx index d7d33c72d7..6e4ac6533f 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.test.tsx @@ -92,7 +92,9 @@ describe("OrganizationMembersPage", () => { it("shows a success message", async () => { await renderPage(); await removeMember(); - await screen.findByText("User removed from organization successfully!"); + await screen.findByText( + /User "TestUser2" removed from organization "My Organization" successfully\./, + ); }); }); }); @@ -114,7 +116,7 @@ describe("OrganizationMembersPage", () => { await renderPage(); await updateUserRole(MockOrganizationAuditorRole); - await screen.findByText("Roles updated successfully."); + await screen.findByText(/Roles of "TestUser" updated successfully\./); }); }); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index 5006380c17..498ae250f7 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -10,7 +10,6 @@ import { organizationRoles } from "api/queries/roles"; import type { OrganizationMemberWithUserData, User } from "api/typesGenerated"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { EmptyState } from "components/EmptyState/EmptyState"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Stack } from "components/Stack/Stack"; import { useAuthenticated } from "hooks"; import { usePaginatedQuery } from "hooks/usePaginatedQuery"; @@ -19,6 +18,7 @@ import { RequirePermission } from "modules/permissions/RequirePermission"; import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useParams, useSearchParams } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { OrganizationMembersPageView } from "./OrganizationMembersPageView"; @@ -121,20 +121,25 @@ const OrganizationMembersPage: FC = () => { onClose={() => setMemberToDelete(undefined)} title="Remove member" confirmText="Remove" - onConfirm={async () => { - try { - if (memberToDelete) { - await removeMemberMutation.mutateAsync(memberToDelete?.user_id); - } - setMemberToDelete(undefined); - await membersQuery.refetch(); - displaySuccess("User removed from organization successfully!"); - } catch (error) { - setMemberToDelete(undefined); - displayError( - getErrorMessage(error, "Failed to remove user from organization"), + onConfirm={() => { + if (memberToDelete) { + const mutation = removeMemberMutation.mutateAsync( + memberToDelete.user_id, + { + onSuccess: () => { + membersQuery.refetch(); + }, + }, ); - } finally { + toast.promise(mutation, { + loading: `Removing member "${memberToDelete.username}" from organization "${organization.display_name}"...`, + success: `User "${memberToDelete.username}" removed from organization "${organization.display_name}" successfully.`, + error: (error) => + getErrorMessage( + error, + `Failed to remove user "${memberToDelete.username}" from organization "${organization.display_name}".`, + ), + }); setMemberToDelete(undefined); } }} diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx index 106f2d17d4..ba6e80e2ed 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPageView.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import type { Group, OrganizationMemberWithUserData, @@ -15,7 +15,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "components/DropdownMenu/DropdownMenu"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { PaginationContainer } from "components/PaginationWidget/PaginationContainer"; import { @@ -37,6 +36,7 @@ import type { PaginationResultInfo } from "hooks/usePaginatedQuery"; import { EllipsisVertical, TriangleAlert, UserPlusIcon } from "lucide-react"; import { UserGroupsCell } from "pages/UsersPage/UsersTable/UserGroupsCell"; import { type FC, useState } from "react"; +import { toast } from "sonner"; import { TableColumnHelpTooltip } from "./UserTable/TableColumnHelpTooltip"; import { UserRoleCell } from "./UserTable/UserRoleCell"; @@ -153,7 +153,9 @@ export const OrganizationMembersPageView: FC< // but testing-library does. try { await updateMemberRoles(member, roles); - displaySuccess("Roles updated successfully."); + toast.success( + `Roles of "${member.username}" updated successfully.`, + ); } catch {} }} /> @@ -220,7 +222,17 @@ const AddOrganizationMember: FC = ({ await onSubmit(selectedUser); setSelectedUser(null); } catch (error) { - displayError(getErrorMessage(error, "Failed to add member.")); + toast.error( + getErrorMessage( + error, + selectedUser + ? `Failed to add "${selectedUser.username}" as a member.` + : "Failed to add member.", + ), + { + description: getErrorDetail(error), + }, + ); } } }} diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/CancelJobConfirmationDialog.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/CancelJobConfirmationDialog.stories.tsx index 82c49511a1..dac7f9c04a 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/CancelJobConfirmationDialog.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/CancelJobConfirmationDialog.stories.tsx @@ -1,5 +1,5 @@ import { MockProvisionerJob } from "testHelpers/entities"; -import { withGlobalSnackbar } from "testHelpers/storybook"; +import { withToaster } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Response } from "api/typesGenerated"; import { expect, fn, userEvent, waitFor, within } from "storybook/test"; @@ -43,7 +43,7 @@ export const OnConfirmSuccess: Story = { parameters: { chromatic: { disableSnapshot: true }, }, - decorators: [withGlobalSnackbar], + decorators: [withToaster], play: async ({ canvasElement, args }) => { const user = userEvent.setup(); const body = within(canvasElement.ownerDocument.body); @@ -51,7 +51,7 @@ export const OnConfirmSuccess: Story = { user.click(confirmButton); await waitFor(() => { - body.getByText("Provisioner job canceled successfully"); + body.getByText(/canceled successfully/); }); expect(args.cancelProvisionerJob).toHaveBeenCalledTimes(1); expect(args.cancelProvisionerJob).toHaveBeenCalledWith(args.job); @@ -63,7 +63,7 @@ export const OnConfirmFailure: Story = { parameters: { chromatic: { disableSnapshot: true }, }, - decorators: [withGlobalSnackbar], + decorators: [withToaster], args: { cancelProvisionerJob: fn(() => { throw new Error("API Error"); @@ -76,7 +76,7 @@ export const OnConfirmFailure: Story = { user.click(confirmButton); await waitFor(() => { - body.getByText("Failed to cancel provisioner job"); + body.getByText(/Failed to cancel provisioner job/); }); expect(args.cancelProvisionerJob).toHaveBeenCalledTimes(1); expect(args.cancelProvisionerJob).toHaveBeenCalledWith(args.job); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/CancelJobConfirmationDialog.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/CancelJobConfirmationDialog.tsx index e1555c5b40..775ab060b9 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/CancelJobConfirmationDialog.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionerJobsPage/CancelJobConfirmationDialog.tsx @@ -1,13 +1,14 @@ import { API } from "api/api"; +import { getErrorDetail } from "api/errors"; import { getProvisionerDaemonsKey, provisionerJobsQueryKey, } from "api/queries/organizations"; import type { ProvisionerJob } from "api/typesGenerated"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import type { FC } from "react"; import { useMutation, useQueryClient } from "react-query"; +import { toast } from "sonner"; type CancelJobConfirmationDialogProps = { open: boolean; @@ -46,13 +47,19 @@ export const CancelJobConfirmationDialog: FC< cancelText="Discard" confirmLoading={cancelMutation.isPending} onConfirm={async () => { - try { - await cancelMutation.mutateAsync(job); - displaySuccess("Provisioner job canceled successfully"); - dialogProps.onClose(); - } catch { - displayError("Failed to cancel provisioner job"); - } + const mutation = cancelMutation.mutateAsync(job, { + onSuccess: () => { + dialogProps.onClose(); + }, + }); + toast.promise(mutation, { + loading: `Canceling provisioner job "${job.id}"...`, + success: `Provisioner job "${job.id}" canceled successfully.`, + error: (error) => ({ + message: `Failed to cancel provisioner job "${job.id}".`, + description: getErrorDetail(error), + }), + }); }} /> ); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx index 223e2d0825..a17fcf7a50 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { deleteOrganization, patchWorkspaceSharingSettings, @@ -6,12 +6,12 @@ import { workspaceSharingSettings, } from "api/queries/organizations"; import { EmptyState } from "components/EmptyState/EmptyState"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; import { RequirePermission } from "modules/permissions/RequirePermission"; import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView"; @@ -59,23 +59,21 @@ const OrganizationSettingsPage: FC = () => { updateOrganizationMutation.error ?? deleteOrganizationMutation.error; const handleToggleWorkspaceSharing = async (enabled: boolean) => { - try { - await patchSharingSettingsMutation.mutateAsync({ - sharing_disabled: !enabled, - }); - displaySuccess( - enabled ? "Workspace sharing enabled." : "Workspace sharing disabled.", - ); - } catch (error) { - displayError( - getErrorMessage( - error, - enabled - ? "Failed to enable workspace sharing" - : "Failed to disable workspace sharing", - ), - ); - } + const mutation = patchSharingSettingsMutation.mutateAsync({ + sharing_disabled: !enabled, + }); + toast.promise(mutation, { + loading: "Toggling workspace sharing...", + success: enabled + ? "Workspace sharing enabled." + : "Workspace sharing disabled.", + error: (error) => ({ + message: enabled + ? "Failed to enable workspace sharing." + : "Failed to disable workspace sharing.", + description: getErrorDetail(error), + }), + }); }; return ( @@ -91,16 +89,26 @@ const OrganizationSettingsPage: FC = () => { req: values, }); navigate(`/organizations/${updatedOrganization.name}/settings`); - displaySuccess("Organization settings updated."); + toast.success( + `Organization "${updatedOrganization.name}" settings updated successfully.`, + ); }} onDeleteOrganization={async () => { try { await deleteOrganizationMutation.mutateAsync(organization.id); - displaySuccess("Organization deleted"); + toast.success( + `Organization "${organization.display_name || organization.name}" deleted successfully.`, + ); navigate("/organizations"); } catch (error) { - displayError( - getErrorMessage(error, "Failed to delete organization"), + toast.error( + getErrorMessage( + error, + `Failed to delete organization "${organization.name}".`, + ), + { + description: getErrorDetail(error), + }, ); } }} diff --git a/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx b/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx index 83e506ae41..cd8fedfdd2 100644 --- a/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx +++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.stories.tsx @@ -1,5 +1,5 @@ import { mockApiError } from "testHelpers/entities"; -import { withGlobalSnackbar } from "testHelpers/storybook"; +import { withToaster } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; import { spyOn, userEvent, within } from "storybook/test"; @@ -9,7 +9,7 @@ const meta: Meta = { title: "pages/ResetPasswordPage/ChangePasswordPage", component: ChangePasswordPage, args: { redirect: false }, - decorators: [withGlobalSnackbar], + decorators: [withToaster], }; export default meta; @@ -36,7 +36,7 @@ export const Success: Story = { await canvas.findByLabelText("Confirm password *"); await user.type(confirmPasswordInput, "password"); await user.click(canvas.getByRole("button", { name: /reset password/i })); - await canvas.findByText("Password reset successfully"); + await canvas.findByText("Password reset successfully."); }, }; diff --git a/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx index e789142133..450f0fd250 100644 --- a/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx +++ b/site/src/pages/ResetPasswordPage/ChangePasswordPage.tsx @@ -3,7 +3,6 @@ import { changePasswordWithOTP } from "api/queries/users"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Button } from "components/Button/Button"; import { CustomLogo } from "components/CustomLogo/CustomLogo"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; import { Input } from "components/Input/Input"; import { Label } from "components/Label/Label"; import { Spinner } from "components/Spinner/Spinner"; @@ -11,6 +10,7 @@ import { useFormik } from "formik"; import type { FC } from "react"; import { useMutation } from "react-query"; import { Link as RouterLink, useNavigate, useSearchParams } from "react-router"; +import { toast } from "sonner"; import { getApplicationName } from "utils/appearance"; import { getFormHelpers } from "utils/formUtils"; import { pageTitle } from "utils/page"; @@ -49,15 +49,21 @@ const ChangePasswordPage: FC = ({ redirect }) => { const email = searchParams.get("email") ?? ""; const otp = searchParams.get("otp") ?? ""; - await changePasswordMutation.mutateAsync({ - email, - one_time_passcode: otp, - password: values.password, - }); - displaySuccess("Password reset successfully"); - if (redirect) { - navigate("/login"); - } + await changePasswordMutation.mutateAsync( + { + email, + one_time_passcode: otp, + password: values.password, + }, + { + onSuccess: () => { + toast.success("Password reset successfully."); + if (redirect) { + navigate("/login"); + } + }, + }, + ); }, }); const getFieldHelpers = getFormHelpers(form, changePasswordMutation.error); diff --git a/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx b/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx index 4ec83d881e..93bcb1ce90 100644 --- a/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx +++ b/site/src/pages/ResetPasswordPage/RequestOTPPage.stories.tsx @@ -1,5 +1,5 @@ import { mockApiError } from "testHelpers/entities"; -import { withGlobalSnackbar } from "testHelpers/storybook"; +import { withToaster } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; import { spyOn, userEvent, within } from "storybook/test"; @@ -8,7 +8,7 @@ import RequestOTPPage from "./RequestOTPPage"; const meta: Meta = { title: "pages/ResetPasswordPage/RequestOTPPage", component: RequestOTPPage, - decorators: [withGlobalSnackbar], + decorators: [withToaster], }; export default meta; diff --git a/site/src/pages/TaskPage/TaskPage.stories.tsx b/site/src/pages/TaskPage/TaskPage.stories.tsx index 5e0c348ec9..40eb9d9477 100644 --- a/site/src/pages/TaskPage/TaskPage.stories.tsx +++ b/site/src/pages/TaskPage/TaskPage.stories.tsx @@ -25,8 +25,8 @@ import { import { withAuthProvider, withDashboardProvider, - withGlobalSnackbar, withProxyProvider, + withToaster, withWebSocket, } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -730,7 +730,7 @@ export const ActivePreview: Story = { }; export const TaskResuming: Story = { - decorators: [withGlobalSnackbar], + decorators: [withToaster], beforeEach: () => { spyOn(API, "getTask").mockResolvedValue({ ...MockTask, @@ -772,7 +772,7 @@ export const TaskResuming: Story = { }; export const TaskResumeFailure: Story = { - decorators: [withGlobalSnackbar], + decorators: [withToaster], beforeEach: () => { spyOn(API, "getTask").mockResolvedValue({ ...MockTask, diff --git a/site/src/pages/TaskPage/TaskPage.tsx b/site/src/pages/TaskPage/TaskPage.tsx index 1cbd435f0b..aab6717668 100644 --- a/site/src/pages/TaskPage/TaskPage.tsx +++ b/site/src/pages/TaskPage/TaskPage.tsx @@ -12,7 +12,6 @@ import type { } from "api/typesGenerated"; import isChromatic from "chromatic/isChromatic"; import { Button } from "components/Button/Button"; -import { displayError } from "components/GlobalSnackbar/utils"; import { InfoTooltip } from "components/InfoTooltip/InfoTooltip"; import { Loader } from "components/Loader/Loader"; import { Margins } from "components/Margins/Margins"; @@ -46,6 +45,7 @@ import { useMutation, useQuery, useQueryClient } from "react-query"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { Link as RouterLink, useParams } from "react-router"; import type { FixedSizeList } from "react-window"; +import { toast } from "sonner"; import { cn } from "utils/cn"; import { pageTitle } from "utils/page"; import { relativeTime } from "utils/time"; @@ -448,7 +448,9 @@ const TaskPaused: FC = ({ task, workspace, onEditPrompt }) => { ...resumeTask(task, queryClient), onError: (error: unknown) => { if (!isApiError(error)) { - displayError(getErrorMessage(error, "Failed to resume task.")); + toast.error(getErrorMessage(error, "Failed to resume task."), { + description: getErrorDetail(error), + }); } }, }); @@ -616,7 +618,9 @@ const TaskStartingAgent: FC = ({ task, agent }) => { const pauseMutation = useMutation({ ...pauseTask(task, queryClient), onError: (error: unknown) => { - displayError(getErrorMessage(error, "Failed to pause task.")); + toast.error(getErrorMessage(error, "Failed to pause task."), { + description: getErrorDetail(error), + }); }, }); const pauseDisabled = isPauseDisabled(task.status); diff --git a/site/src/pages/TasksPage/TasksTable.tsx b/site/src/pages/TasksPage/TasksTable.tsx index 06f05203a0..0f1eb79a76 100644 --- a/site/src/pages/TasksPage/TasksTable.tsx +++ b/site/src/pages/TasksPage/TasksTable.tsx @@ -12,7 +12,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "components/DropdownMenu/DropdownMenu"; -import { displayError } from "components/GlobalSnackbar/utils"; import { Skeleton } from "components/Skeleton/Skeleton"; import { Table, @@ -39,6 +38,7 @@ import { import { type FC, type ReactNode, useState } from "react"; import { useMutation, useQueryClient } from "react-query"; import { useNavigate } from "react-router"; +import { toast } from "sonner"; import { relativeTime } from "utils/time"; type TasksTableProps = { @@ -192,13 +192,17 @@ const TaskRow: FC = ({ task, checked, onCheckChange }) => { const pauseMutation = useMutation({ ...pauseTask(task, queryClient), onError: (error: unknown) => { - displayError(getErrorMessage(error, "Failed to pause task.")); + toast.error(getErrorMessage(error, "Failed to pause task."), { + description: getErrorDetail(error), + }); }, }); const resumeMutation = useMutation({ ...resumeTask(task, queryClient), onError: (error: unknown) => { - displayError(getErrorMessage(error, "Failed to resume task.")); + toast.error(getErrorMessage(error, "Failed to resume task."), { + description: getErrorDetail(error), + }); }, }); diff --git a/site/src/pages/TasksPage/batchActions.ts b/site/src/pages/TasksPage/batchActions.ts index dfd06b903f..42a3462827 100644 --- a/site/src/pages/TasksPage/batchActions.ts +++ b/site/src/pages/TasksPage/batchActions.ts @@ -1,7 +1,7 @@ import { API } from "api/api"; import type { Task } from "api/typesGenerated"; -import { displayError } from "components/GlobalSnackbar/utils"; import { useMutation } from "react-query"; +import { toast } from "sonner"; interface UseBatchTaskActionsOptions { onSuccess: () => Promise; @@ -25,7 +25,7 @@ export function useBatchTaskActions( }, onSuccess, onError: () => { - displayError("Failed to delete some tasks"); + toast.error("Failed to delete some tasks."); }, }); diff --git a/site/src/pages/TemplatePage/TemplatePageHeader.tsx b/site/src/pages/TemplatePage/TemplatePageHeader.tsx index 8db6afcd07..9363b0acb4 100644 --- a/site/src/pages/TemplatePage/TemplatePageHeader.tsx +++ b/site/src/pages/TemplatePage/TemplatePageHeader.tsx @@ -59,7 +59,11 @@ const TemplateMenu: FC = ({ fileId, onDelete, }) => { - const dialogState = useDeletionDialogState(templateId, onDelete); + const dialogState = useDeletionDialogState( + templateId, + onDelete, + templateName, + ); const navigate = useNavigate(); const getLink = useLinks(); const queryText = `template:${templateName}`; diff --git a/site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPage.tsx b/site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPage.tsx index 38392762d0..100dba9e4a 100644 --- a/site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPage.tsx +++ b/site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPage.tsx @@ -2,11 +2,11 @@ import { API } from "api/api"; import type { InvalidatePresetsResponse } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Button } from "components/Button/Button"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; import { RefreshCw } from "lucide-react"; import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout"; import type { FC } from "react"; import { useMutation } from "react-query"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; const TemplatePrebuildsPage: FC = () => { @@ -31,7 +31,7 @@ export const TemplatePrebuildsPageView: FC = ({ mutationFn: () => API.invalidateTemplatePresets(templateId), onSuccess: (data: InvalidatePresetsResponse) => { if (data.invalidated.length === 0) { - displaySuccess("No template presets required invalidation."); + toast.success("No template presets required invalidation."); return; } @@ -39,8 +39,8 @@ export const TemplatePrebuildsPageView: FC = ({ const { template_version_name } = data.invalidated[0]; const count = data.invalidated.length; - displaySuccess( - `Invalidated ${count} ${count === 1 ? "preset" : "presets"} for version ${template_version_name}.`, + toast.success( + `Invalidated ${count} ${count === 1 ? "preset" : "presets"} for version "${template_version_name}".`, ); }, }); diff --git a/site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPageView.stories.tsx b/site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPageView.stories.tsx index ac47880259..d6a9537443 100644 --- a/site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPageView.stories.tsx +++ b/site/src/pages/TemplatePage/TemplatePrebuildsPage/TemplatePrebuildsPageView.stories.tsx @@ -1,5 +1,5 @@ import { MockTemplate } from "testHelpers/entities"; -import { withGlobalSnackbar } from "testHelpers/storybook"; +import { withToaster } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; import { spyOn, userEvent, within } from "storybook/test"; @@ -11,7 +11,7 @@ const meta: Meta = { args: { templateId: MockTemplate.id, }, - decorators: [withGlobalSnackbar], + decorators: [withToaster], }; export default meta; diff --git a/site/src/pages/TemplatePage/TemplateVersionsPage/TemplateVersionsPage.tsx b/site/src/pages/TemplatePage/TemplateVersionsPage/TemplateVersionsPage.tsx index 0ba303f04b..1970efe650 100644 --- a/site/src/pages/TemplatePage/TemplateVersionsPage/TemplateVersionsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateVersionsPage/TemplateVersionsPage.tsx @@ -1,15 +1,22 @@ import { API } from "api/api"; -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import { linkToTemplate, useLinks } from "modules/navigation"; import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout"; import { useState } from "react"; import { useMutation, useQuery } from "react-query"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; import { getTemplatePageTitle } from "../utils"; import { VersionsTable } from "./VersionsTable"; const TemplateVersionsPage = () => { + const navigate = useNavigate(); + const getLink = useLinks(); const { template, permissions } = useTemplateLayoutContext(); + const templateLink = getLink( + linkToTemplate(template.organization_name, template.name), + ); const { data } = useQuery({ queryKey: ["template", "versions", template.id], queryFn: () => API.getTemplateVersions(template.id), @@ -25,12 +32,38 @@ const TemplateVersionsPage = () => { }); }, onSuccess: async () => { + const versionName = data?.find( + (v) => v.id === selectedVersionIdToPromote, + )?.name; setLatestActiveVersion(selectedVersionIdToPromote as string); setSelectedVersionIdToPromote(undefined); - displaySuccess("Version promoted successfully"); + toast.success( + versionName + ? `Version "${versionName}" promoted successfully.` + : "Version promoted successfully.", + { + action: { + label: "View template", + onClick: () => navigate(templateLink), + }, + }, + ); }, onError: (error) => { - displayError(getErrorMessage(error, "Failed to promote version")); + const versionName = data?.find( + (v) => v.id === selectedVersionIdToPromote, + )?.name; + toast.error( + getErrorMessage( + error, + versionName + ? `Failed to promote version "${versionName}".` + : "Failed to promote version.", + ), + { + description: getErrorDetail(error), + }, + ); }, }); @@ -38,17 +71,30 @@ const TemplateVersionsPage = () => { mutationFn: (templateVersionId: string) => { return API.archiveTemplateVersion(templateVersionId); }, - onSuccess: async () => { + onSuccess: async (data) => { // The reload is unfortunate. When a version is archived, we should hide // the row. I do not know an easy way to do that, so a reload makes the API call // resend and now the version is omitted. // TODO: Improve this to not reload the page. location.reload(); setSelectedVersionIdToArchive(undefined); - displaySuccess("Version archived successfully"); + toast.success(`Version "${data.name}" archived successfully.`); }, onError: (error) => { - displayError(getErrorMessage(error, "Failed to archive version")); + const versionName = data?.find( + (v) => v.id === selectedVersionIdToArchive, + )?.name; + toast.error( + getErrorMessage( + error, + versionName + ? `Failed to archive version "${versionName}".` + : "Failed to archive version.", + ), + { + description: getErrorDetail(error), + }, + ); }, }); diff --git a/site/src/pages/TemplatePage/useDeletionDialogState.jest.ts b/site/src/pages/TemplatePage/useDeletionDialogState.jest.ts index 5be7910092..8164fb7a39 100644 --- a/site/src/pages/TemplatePage/useDeletionDialogState.jest.ts +++ b/site/src/pages/TemplatePage/useDeletionDialogState.jest.ts @@ -1,46 +1,50 @@ import { MockTemplate } from "testHelpers/entities"; import { act, renderHook, waitFor } from "@testing-library/react"; import { API } from "api/api"; +import { createElement } from "react"; +import { QueryClient, QueryClientProvider } from "react-query"; import { useDeletionDialogState } from "./useDeletionDialogState"; +const wrapper = ({ children }: { children: React.ReactNode }) => + createElement(QueryClientProvider, { client: new QueryClient() }, children); + test("delete dialog starts closed", () => { - const { result } = renderHook(() => - useDeletionDialogState(MockTemplate.id, jest.fn()), + const { result } = renderHook( + () => useDeletionDialogState(MockTemplate.id, jest.fn()), + { wrapper }, ); expect(result.current.isDeleteDialogOpen).toBeFalsy(); }); test("confirm template deletion", async () => { const onDeleteTemplate = jest.fn(); - const { result } = renderHook(() => - useDeletionDialogState(MockTemplate.id, onDeleteTemplate), + const { result } = renderHook( + () => useDeletionDialogState(MockTemplate.id, onDeleteTemplate), + { wrapper }, ); - //Open delete confirmation act(() => { result.current.openDeleteConfirmation(); }); expect(result.current.isDeleteDialogOpen).toBeTruthy(); - // Confirm delete jest.spyOn(API, "deleteTemplate"); await act(async () => result.current.confirmDelete()); await waitFor(() => expect(API.deleteTemplate).toBeCalledTimes(1)); - expect(onDeleteTemplate).toBeCalledTimes(1); + await waitFor(() => expect(onDeleteTemplate).toBeCalledTimes(1)); }); test("cancel template deletion", () => { - const { result } = renderHook(() => - useDeletionDialogState(MockTemplate.id, jest.fn()), + const { result } = renderHook( + () => useDeletionDialogState(MockTemplate.id, jest.fn()), + { wrapper }, ); - //Open delete confirmation act(() => { result.current.openDeleteConfirmation(); }); expect(result.current.isDeleteDialogOpen).toBeTruthy(); - // Cancel deletion act(() => { result.current.cancelDeleteConfirmation(); }); diff --git a/site/src/pages/TemplatePage/useDeletionDialogState.ts b/site/src/pages/TemplatePage/useDeletionDialogState.ts index b715791345..b28e2fddc1 100644 --- a/site/src/pages/TemplatePage/useDeletionDialogState.ts +++ b/site/src/pages/TemplatePage/useDeletionDialogState.ts @@ -1,42 +1,41 @@ import { API } from "api/api"; import { getErrorMessage } from "api/errors"; -import { displayError } from "components/GlobalSnackbar/utils"; import { useState } from "react"; - -type DeleteTemplateState = - | { status: "idle" } - | { status: "confirming" } - | { status: "deleting" }; +import { useMutation } from "react-query"; +import { toast } from "sonner"; export const useDeletionDialogState = ( templateId: string, onDelete: () => void, + templateName?: string, ) => { - const [state, setState] = useState({ status: "idle" }); - const isDeleteDialogOpen = - state.status === "confirming" || state.status === "deleting"; + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const deleteMutation = useMutation({ + mutationFn: () => API.deleteTemplate(templateId), + }); const openDeleteConfirmation = () => { - setState({ status: "confirming" }); + setIsDeleteDialogOpen(true); }; const cancelDeleteConfirmation = () => { - setState({ status: "idle" }); + setIsDeleteDialogOpen(false); }; - const confirmDelete = async () => { - try { - setState({ status: "deleting" }); - await API.deleteTemplate(templateId); - onDelete(); - } catch (e) { - setState({ status: "confirming" }); - displayError(getErrorMessage(e, "Failed to delete template")); - } + const confirmDelete = () => { + const label = templateName ? ` "${templateName}"` : ""; + const mutation = deleteMutation.mutateAsync(); + toast.promise(mutation, { + loading: `Deleting template${label}...`, + success: `Template${label} deleted successfully.`, + error: (error) => + getErrorMessage(error, `Failed to delete template${label}.`), + }); + mutation.then(() => onDelete()); }; return { - state, isDeleteDialogOpen, openDeleteConfirmation, cancelDeleteConfirmation, diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx index a52a4b7db7..4ce22b8409 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx @@ -1,13 +1,13 @@ import { API } from "api/api"; -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { templateByNameKey } from "api/queries/templates"; import type { UpdateTemplateMeta } from "api/typesGenerated"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { useDashboard } from "modules/dashboard/useDashboard"; import { linkToTemplate, useLinks } from "modules/navigation"; import type { FC } from "react"; import { useMutation, useQueryClient } from "react-query"; import { useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { useTemplateSettings } from "../TemplateSettingsLayout"; import { TemplateSettingsPageView } from "./TemplateSettingsPageView"; @@ -47,11 +47,16 @@ const TemplateSettingsPage: FC = () => { queryKey: templateByNameKey(template.organization_name, data.name), }); } - displaySuccess("Template updated successfully"); + toast.success(`Template "${data.name}" updated successfully.`); navigate(getLink(linkToTemplate(data.organization_name, data.name))); }, onError: (error) => { - displayError(getErrorMessage(error, "Failed to update template")); + toast.error( + getErrorMessage(error, `Failed to update template "${template.name}".`), + { + description: getErrorDetail(error), + }, + ); }, }); diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx index 5f1ea940f8..afa93017fa 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx @@ -1,9 +1,10 @@ +import { getErrorDetail, getErrorMessage } from "api/errors"; import { setGroupRole, setUserRole, templateACL } from "api/queries/templates"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; import { PaywallPremium } from "components/Paywall/PaywallPremium"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { toast } from "sonner"; import { docs } from "utils/docs"; import { pageTitle } from "utils/page"; import { useTemplateSettings } from "../TemplateSettingsLayout"; @@ -48,12 +49,31 @@ const TemplatePermissionsPage: FC = () => { }} isAddingUser={addUserMutation.isPending} onUpdateUser={async (user, role) => { - await updateUserMutation.mutateAsync({ - templateId: template.id, - userId: user.id, - role, - }); - displaySuccess("User role updated successfully!"); + await updateUserMutation.mutateAsync( + { + templateId: template.id, + userId: user.id, + role, + }, + { + onSuccess: () => { + toast.success( + `Role for "${user.username}" updated to "${role}" successfully.`, + ); + }, + onError: (error) => { + toast.error( + getErrorMessage( + error, + `Failed to update role for "${user.username}".`, + ), + { + description: getErrorDetail(error), + }, + ); + }, + }, + ); }} updatingUserId={ updateUserMutation.isPending @@ -61,12 +81,31 @@ const TemplatePermissionsPage: FC = () => { : undefined } onRemoveUser={async (user) => { - await removeUserMutation.mutateAsync({ - templateId: template.id, - userId: user.id, - role: "", - }); - displaySuccess("User removed successfully!"); + await removeUserMutation.mutateAsync( + { + templateId: template.id, + userId: user.id, + role: "", + }, + { + onSuccess: () => { + toast.success( + `User "${user.username}" removed successfully.`, + ); + }, + onError: (error) => { + toast.error( + getErrorMessage( + error, + `Failed to remove user "${user.username}".`, + ), + { + description: getErrorDetail(error), + }, + ); + }, + }, + ); }} onAddGroup={async (group, role, reset) => { await addGroupMutation.mutateAsync({ @@ -78,12 +117,31 @@ const TemplatePermissionsPage: FC = () => { }} isAddingGroup={addGroupMutation.isPending} onUpdateGroup={async (group, role) => { - await updateGroupMutation.mutateAsync({ - templateId: template.id, - groupId: group.id, - role, - }); - displaySuccess("Group role updated successfully!"); + await updateGroupMutation.mutateAsync( + { + templateId: template.id, + groupId: group.id, + role, + }, + { + onSuccess: () => { + toast.success( + `Role for "${group.display_name || group.name}" updated to "${role}" successfully.`, + ); + }, + onError: (error) => { + toast.error( + getErrorMessage( + error, + `Failed to update role for "${group.display_name || group.name}".`, + ), + { + description: getErrorDetail(error), + }, + ); + }, + }, + ); }} updatingGroupId={ updateGroupMutation.isPending @@ -91,12 +149,31 @@ const TemplatePermissionsPage: FC = () => { : undefined } onRemoveGroup={async (group) => { - await removeGroupMutation.mutateAsync({ - groupId: group.id, - templateId: template.id, - role: "", - }); - displaySuccess("Group removed successfully!"); + await removeGroupMutation.mutateAsync( + { + groupId: group.id, + templateId: template.id, + role: "", + }, + { + onSuccess: () => { + toast.success( + `Group "${group.display_name || group.name}" removed successfully.`, + ); + }, + onError: (error) => { + toast.error( + getErrorMessage( + error, + `Failed to remove group "${group.display_name || group.name}".`, + ), + { + description: getErrorDetail(error), + }, + ); + }, + }, + ); }} /> )} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx index c270ddb10b..c90540dd7a 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -136,7 +136,7 @@ describe("TemplateSchedulePage", () => { await waitForWithCutoff(() => expect(API.updateTemplateMeta).toBeCalledTimes(1), ); - }); + }, 15_000); test("default is converted to and from hours", async () => { await renderTemplateSchedulePage(); diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx index 3fb27c6773..5b80122097 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx @@ -1,12 +1,12 @@ import { API } from "api/api"; import { templateByNameKey } from "api/queries/templates"; import type { UpdateTemplateMeta } from "api/typesGenerated"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; import { useDashboard } from "modules/dashboard/useDashboard"; import { linkToTemplate, useLinks } from "modules/navigation"; import type { FC } from "react"; import { useMutation, useQueryClient } from "react-query"; import { useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { useTemplateSettings } from "../TemplateSettingsLayout"; import { TemplateSchedulePageView } from "./TemplateSchedulePageView"; @@ -33,7 +33,9 @@ const TemplateSchedulePage: FC = () => { await queryClient.invalidateQueries({ queryKey: templateByNameKey(organizationName, templateName), }); - displaySuccess("Template updated successfully"); + toast.success( + `Template "${template.name}" schedule updated successfully.`, + ); // clear browser storage of workspaces impending deletion localStorage.removeItem("dismissedWorkspaceList"); // workspaces page localStorage.removeItem("dismissedWorkspace"); // workspace page diff --git a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.jest.tsx b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.jest.tsx index 2f26b64a1f..129b65f730 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.jest.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.jest.tsx @@ -9,10 +9,10 @@ import { renderWithTemplateSettingsLayout, waitForLoaderToBeRemoved, } from "testHelpers/renderHelpers"; -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { API } from "api/api"; -import { delay } from "utils/delay"; +import { toast } from "sonner"; import TemplateVariablesPage from "./TemplateVariablesPage"; const validFormValues = { @@ -99,11 +99,15 @@ describe("TemplateVariablesPage", () => { await userEvent.type(secondVariableField, validFormValues.second_variable); // Submit the form + const toastSuccessSpy = jest.spyOn(toast, "success"); const submitButton = await screen.findByText(/save/i); await userEvent.click(submitButton); - // Wait for the success message - await delay(1500); - await screen.findByText("Template updated successfully"); + await waitFor(() => { + expect(toastSuccessSpy).toHaveBeenCalledWith( + `Template "test-template" variables updated successfully.`, + ); + }); + toastSuccessSpy.mockRestore(); }); }); diff --git a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx index 81d474eae6..5ef0af998c 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateVariablesPage/TemplateVariablesPage.tsx @@ -10,7 +10,6 @@ import type { VariableValue, } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { linkToTemplate, useLinks } from "modules/navigation"; import { type FC, useCallback } from "react"; @@ -21,6 +20,7 @@ import { useQueryClient, } from "react-query"; import { useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { useTemplateSettings } from "../TemplateSettingsLayout"; import { TemplateVariablesPageView } from "./TemplateVariablesPageView"; @@ -67,9 +67,11 @@ const TemplateVariablesPage: FC = () => { const publishVersion = useCallback( async (versionId: string) => { await sendUpdateActiveTemplateVersion(versionId); - displaySuccess("Template updated successfully"); + toast.success( + `Template "${template.name}" variables updated successfully.`, + ); }, - [sendUpdateActiveTemplateVersion], + [sendUpdateActiveTemplateVersion, template.name], ); const buildVersion = useCallback( diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx index aa936aeab8..863fb7c741 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx @@ -19,7 +19,6 @@ import { TopbarDivider, TopbarIconButton, } from "components/FullPageLayout/Topbar"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { Tooltip, @@ -49,8 +48,10 @@ import type { PublishVersionData } from "pages/TemplateVersionEditorPage/types"; import { type FC, useCallback, useEffect, useRef, useState } from "react"; import { Link as RouterLink, + useNavigate, unstable_usePrompt as usePrompt, } from "react-router"; +import { toast } from "sonner"; import { cn } from "utils/cn"; import { createFile, @@ -130,6 +131,7 @@ export const TemplateVersionEditor: FC = ({ activePath, onActivePathChange, }) => { + const navigate = useNavigate(); const getLink = useLinks(); const [selectedTab, setSelectedTab] = useState(defaultTab); const [fileTree, setFileTree] = useState(defaultFileTree); @@ -145,10 +147,9 @@ export const TemplateVersionEditor: FC = ({ await onPreview(fileTree); setSelectedTab("logs"); } catch (error) { - displayError( - getErrorMessage(error, "Error on previewing the template"), - getErrorDetail(error), - ); + toast.error(getErrorMessage(error, "Error on previewing the template."), { + description: getErrorDetail(error), + }); } }, [fileTree, onPreview]); @@ -175,6 +176,11 @@ export const TemplateVersionEditor: FC = ({ }; }, [triggerPreview]); + const canBuild = !isBuilding; + const templateLink = getLink( + linkToTemplate(template.organization_name, template.name), + ); + // Automatically switch to the template preview tab when the build succeeds. const previousVersion = useRef(undefined); useEffect(() => { @@ -188,12 +194,18 @@ export const TemplateVersionEditor: FC = ({ templateVersion.job.status === "succeeded" ) { setDirty(false); - displaySuccess( + toast.success( `Template version "${previousVersion.current.name}" built successfully.`, + { + action: { + label: "View template", + onClick: () => navigate(templateLink), + }, + }, ); } previousVersion.current = templateVersion; - }, [templateVersion]); + }, [templateVersion, navigate, templateLink]); const editorValue = activePath ? getFileText(activePath, fileTree) : ""; const isEditorValueBinary = @@ -201,11 +213,6 @@ export const TemplateVersionEditor: FC = ({ useLeaveSiteWarning(dirty); - const canBuild = !isBuilding; - const templateLink = getLink( - linkToTemplate(template.organization_name, template.name), - ); - const gotBuildLogs = buildLogs && buildLogs.length > 0; return ( diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index 52d52ad0e1..b1b1e602f0 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -1,4 +1,5 @@ import { API } from "api/api"; +import { getErrorDetail } from "api/errors"; import { file, uploadFile } from "api/queries/files"; import { createTemplateVersion, @@ -12,7 +13,6 @@ import type { PatchTemplateVersionRequest, TemplateVersion, } from "api/typesGenerated"; -import { displayError } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { linkToTemplate, useLinks } from "modules/navigation"; import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs"; @@ -24,6 +24,7 @@ import { useQueryClient, } from "react-query"; import { useNavigate, useParams, useSearchParams } from "react-router"; +import { toast } from "sonner"; import { existsFile, type FileTree, traverse } from "utils/filetree"; import { pageTitle } from "utils/page"; import { TarReader, TarWriter } from "utils/tar"; @@ -267,7 +268,9 @@ const useFileTree = (templateVersion: TemplateVersion | undefined) => { setState({ fileTree, tarFile }); } catch (error) { console.error(error); - displayError("Error on initializing the editor"); + toast.error("Error on initializing the editor.", { + description: getErrorDetail(error), + }); } }; diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx index f0a5514d0b..95f80aa5e8 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx @@ -44,7 +44,9 @@ describe("AccountPage", () => { renderWithAuth(); await fillAndSubmitForm(); - const successMessage = await screen.findByText("Updated settings."); + const successMessage = await screen.findByText( + "Profile updated successfully.", + ); expect(successMessage).toBeDefined(); expect(API.updateProfile).toBeCalledTimes(1); expect(API.updateProfile).toBeCalledWith("me", newData); diff --git a/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPage.tsx b/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPage.tsx index 976b923f16..7fa1aa4aca 100644 --- a/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPage.tsx +++ b/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPage.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { externalAuths, unlinkExternalAuths, @@ -6,9 +6,9 @@ import { } from "api/queries/externalAuth"; import type { ExternalAuthLinkProvider } from "api/typesGenerated"; import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { toast } from "sonner"; import { Section } from "../Section"; import { ExternalAuthPageView } from "./ExternalAuthPageView"; @@ -37,15 +37,19 @@ const ExternalAuthPage: FC = () => { try { const data = await validateAppMutation.mutateAsync(providerID); if (data.authenticated) { - displaySuccess("Application link is valid."); + toast.success("Application link is valid."); } else { - displayError( - "Application link is not valid. Please unlink the application and reauthenticate.", - ); + toast.error("Application link is not valid.", { + description: + "Please unlink the application and reauthenticate.", + }); } - } catch (e) { - displayError( - getErrorMessage(e, "Error validating application link."), + } catch (error) { + toast.error( + getErrorMessage(error, "Error validating application link."), + { + description: getErrorDetail(error), + }, ); } }} @@ -77,13 +81,15 @@ const ExternalAuthPage: FC = () => { // this tells our child components to refetch their data // as at least 1 provider was unlinked. setUnlinked(unlinked + 1); - displaySuccess( + toast.success( unlinkResp.token_revoked ? "Successfully deleted external auth link and revoked token from the OAuth2 provider." : "Successfully deleted external auth link. Token has NOT been revoked from the OAuth2 provider.", ); } catch (e) { - displayError(getErrorMessage(e, "Error unlinking application.")); + toast.error(getErrorMessage(e, "Error unlinking application."), { + description: getErrorDetail(e), + }); } }} /> diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx index de11223a94..3006beef2f 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx @@ -8,7 +8,7 @@ import { import { withAuthProvider, withDashboardProvider, - withGlobalSnackbar, + withToaster, } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; @@ -48,7 +48,7 @@ const meta = { user: MockUserOwner, permissions: { createTemplates: true, createUser: true }, }, - decorators: [withGlobalSnackbar, withAuthProvider, withDashboardProvider], + decorators: [withToaster, withAuthProvider, withDashboardProvider], } satisfies Meta; export default meta; @@ -156,7 +156,7 @@ export const DisableValidTemplate: Story = { }, ], play: async ({ canvasElement }) => { - await within(document.body).findByText("Notification has been disabled"); + await within(document.body).findByText("Notification has been disabled."); const switchEl = await within(canvasElement).findByLabelText( templateToDisable.name, ); @@ -182,7 +182,7 @@ export const DisableInvalidTemplate: Story = { }, ], play: async () => { - await within(document.body).findByText("Error disabling notification"); + await within(document.body).findByText("Error disabling notification."); }, }; diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx index 5c006a31c6..68d6545368 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -1,3 +1,4 @@ +import { getErrorDetail } from "api/errors"; import { customNotificationTemplates, disableNotification, @@ -12,7 +13,6 @@ import { updatePreferenceSettings, } from "api/queries/users"; import type { NotificationTemplate } from "api/typesGenerated"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { Switch } from "components/Switch/Switch"; import { @@ -33,6 +33,7 @@ import type { Permissions } from "modules/permissions"; import { type FC, Fragment, useEffect } from "react"; import { useMutation, useQueries, useQuery, useQueryClient } from "react-query"; import { useSearchParams } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { Section } from "../Section"; @@ -81,10 +82,12 @@ const NotificationsPage: FC = () => { disableMutation .mutateAsync(disabledId) .then(() => { - displaySuccess("Notification has been disabled"); + toast.success("Notification has been disabled."); }) - .catch(() => { - displayError("Error disabling notification"); + .catch((error) => { + toast.error("Error disabling notification.", { + description: getErrorDetail(error), + }); }); }, [searchParams.delete, disabledId, disableMutation]); @@ -146,13 +149,16 @@ const NotificationsPage: FC = () => { }, { onSuccess: () => { - displaySuccess( - "Notification preferences updated", + toast.success( + "Notification preferences updated.", ); }, - onError: () => { - displayError( - "Error updating notification preferences", + onError: (error) => { + toast.error( + "Error updating notification preferences.", + { + description: getErrorDetail(error), + }, ); }, }, @@ -193,13 +199,16 @@ const NotificationsPage: FC = () => { }, { onSuccess: () => { - displaySuccess( - "Notification preferences updated", + toast.success( + "Notification preferences updated.", ); }, - onError: () => { - displayError( - "Error updating notification preferences", + onError: (error) => { + toast.error( + "Error updating notification preferences.", + { + description: getErrorDetail(error), + }, ); }, }, diff --git a/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage.tsx b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage.tsx index 77ee124777..f60e3fe46f 100644 --- a/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage.tsx +++ b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage.tsx @@ -1,10 +1,10 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { getApps, revokeApp } from "api/queries/oauth2"; import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { useAuthenticated } from "hooks"; import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { toast } from "sonner"; import { Section } from "../Section"; import OAuth2ProviderPageView from "./OAuth2ProviderPageView"; @@ -42,13 +42,16 @@ const OAuth2ProviderPage: FC = () => { onConfirm={async () => { try { await revokeAppMutation.mutateAsync(appToRevoke.id); - displaySuccess( - `You have successfully revoked the OAuth2 application "${appToRevoke.name}"`, + toast.success( + `OAuth2 application "${appToRevoke.name}" revoked successfully.`, ); setAppIdToRevoke(undefined); } catch (error) { - displayError( + toast.error( getErrorMessage(error, "Failed to revoke application."), + { + description: getErrorDetail(error), + }, ); } }} diff --git a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.test.tsx b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.test.tsx index 4ef4ac318e..61b977bfb8 100644 --- a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.test.tsx +++ b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.test.tsx @@ -78,8 +78,7 @@ describe("SSH keys Page", () => { fireEvent.click(confirmButton); // Check if the error message is displayed - const alert = await screen.findByRole("alert"); - expect(alert).toHaveTextContent(SSHKeysPageLanguage.regenerationError); + await screen.findByText(SSHKeysPageLanguage.regenerationError); // Check if the API was called correctly expect(API.regenerateUserSSHKey).toBeCalledTimes(1); diff --git a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx index ae259b657a..e7ef0dfef0 100644 --- a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx +++ b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx @@ -1,9 +1,9 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { regenerateUserSSHKey, userSSHKey } from "api/queries/sshKeys"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { toast } from "sonner"; import { Section } from "../Section"; import { SSHKeysPageView } from "./SSHKeysPageView"; @@ -51,9 +51,11 @@ const SSHKeysPage: FC = () => { onConfirm={async () => { try { await regenerateSSHKeyMutation.mutateAsync(); - displaySuccess(Language.regenerationSuccess); + toast.success(Language.regenerationSuccess); } catch (error) { - displayError(getErrorMessage(error, Language.regenerationError)); + toast.error(getErrorMessage(error, Language.regenerationError), { + description: getErrorDetail(error), + }); } finally { setIsConfirmingRegeneration(false); } diff --git a/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.test.tsx b/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.test.tsx index 6d63489397..52beb8de3a 100644 --- a/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.test.tsx +++ b/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.test.tsx @@ -88,10 +88,11 @@ describe("SchedulePage", () => { await fillForm(test); await submitForm(); const successMessage = await screen.findByText( - "Schedule updated successfully", + "Schedule updated successfully.", ); expect(successMessage).toBeDefined(); }, + 15_000, ); }); diff --git a/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.tsx b/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.tsx index 9e06a75646..c21fa0557b 100644 --- a/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.tsx +++ b/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.tsx @@ -4,11 +4,11 @@ import { } from "api/queries/settings"; import type { UserQuietHoursScheduleResponse } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { useAuthenticated } from "hooks"; import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { toast } from "sonner"; import { Section } from "../Section"; import { ScheduleForm } from "./ScheduleForm"; @@ -50,7 +50,7 @@ const SchedulePage: FC = () => { onSubmit={(values) => { onSubmit(values, { onSuccess: () => { - displaySuccess("Schedule updated successfully"); + toast.success("Schedule updated successfully."); }, }); }} diff --git a/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.tsx b/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.tsx index 3be9808822..dcaac5634a 100644 --- a/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.tsx +++ b/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.tsx @@ -1,11 +1,11 @@ import { API } from "api/api"; import { authMethods, updatePassword } from "api/queries/users"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { Stack } from "components/Stack/Stack"; import { useAuthenticated } from "hooks"; import type { ComponentProps, FC } from "react"; import { useMutation, useQuery } from "react-query"; +import { toast } from "sonner"; import { Section } from "../Section"; import { SecurityForm } from "./SecurityForm"; import { @@ -39,7 +39,7 @@ const SecurityPage: FC = () => { userId: me.id, ...data, }); - displaySuccess("Updated password."); + toast.success("Updated password."); // Refresh the browser session. We need to improve the AuthProvider // to include better API to handle these scenarios window.location.href = location.origin; diff --git a/site/src/pages/UserSettingsPage/TokensPage/ConfirmDeleteDialog.tsx b/site/src/pages/UserSettingsPage/TokensPage/ConfirmDeleteDialog.tsx index ec0ac1575c..c68522e79c 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/ConfirmDeleteDialog.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/ConfirmDeleteDialog.tsx @@ -1,8 +1,8 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import type { APIKeyWithOwner } from "api/typesGenerated"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import type { FC } from "react"; +import { toast } from "sonner"; import { useDeleteToken } from "./hooks"; interface ConfirmDeleteDialogProps { @@ -22,13 +22,15 @@ export const ConfirmDeleteDialog: FC = ({ useDeleteToken(queryKey); const onDeleteSuccess = () => { - displaySuccess("Token has been deleted"); + toast.success("Token has been deleted."); setToken(undefined); }; - const onDeleteError = (error: unknown) => { + const onDeleteError = (error: Error) => { const message = getErrorMessage(error, "Failed to delete token"); - displayError(message); + toast.error(message, { + description: getErrorDetail(error), + }); setToken(undefined); }; diff --git a/site/src/pages/UsersPage/UsersPage.stories.tsx b/site/src/pages/UsersPage/UsersPage.stories.tsx index 3802a1968e..8a2a299bcb 100644 --- a/site/src/pages/UsersPage/UsersPage.stories.tsx +++ b/site/src/pages/UsersPage/UsersPage.stories.tsx @@ -2,7 +2,7 @@ import { MockAuthMethodsAll, MockUserOwner } from "testHelpers/entities"; import { withAuthProvider, withDashboardProvider, - withGlobalSnackbar, + withToaster, } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; @@ -71,7 +71,7 @@ const meta: Meta = { title: "pages/UsersPage", component: UsersPage, parameters, - decorators: [withGlobalSnackbar, withAuthProvider, withDashboardProvider], + decorators: [withToaster, withAuthProvider, withDashboardProvider], args: { defaultNewPassword: "edWbqYiaVpEiEWwI", }, @@ -105,7 +105,7 @@ export const SuspendUserSuccess: Story = { const dialog = await within(document.body).findByRole("dialog"); await user.click(within(dialog).getByRole("button", { name: "Suspend" })); - await within(document.body).findByText("Successfully suspended the user."); + await within(document.body).findByText(/suspended successfully/); }, }; @@ -124,7 +124,7 @@ export const SuspendUserError: Story = { const dialog = await within(document.body).findByRole("dialog"); await user.click(within(dialog).getByRole("button", { name: "Suspend" })); - await within(document.body).findByText("Error suspending user."); + await within(document.body).findByText(/Error suspending user/); }, }; @@ -153,7 +153,7 @@ export const DeleteUserSuccess: Story = { const input = within(dialog).getByLabelText("Name of the user to delete"); await user.type(input, MockUsers[0].username); await user.click(within(dialog).getByRole("button", { name: "Delete" })); - await within(document.body).findByText("Successfully deleted the user."); + await within(document.body).findByText(/deleted successfully/); }, }; @@ -174,7 +174,7 @@ export const DeleteUserError: Story = { const input = within(dialog).getByLabelText("Name of the user to delete"); await user.type(input, MockUsers[0].username); await user.click(within(dialog).getByRole("button", { name: "Delete" })); - await within(document.body).findByText("Error deleting user."); + await within(document.body).findByText(/Error deleting user/); }, }; @@ -218,7 +218,7 @@ export const ActivateUserSuccess: Story = { const dialog = await within(document.body).findByRole("dialog"); await user.click(within(dialog).getByRole("button", { name: "Activate" })); - await within(document.body).findByText("Successfully activated the user."); + await within(document.body).findByText(/activated successfully/); }, }; @@ -238,7 +238,7 @@ export const ActivateUserError: Story = { const dialog = await within(document.body).findByRole("dialog"); await user.click(within(dialog).getByRole("button", { name: "Activate" })); - await within(document.body).findByText("Error activating user."); + await within(document.body).findByText(/Error activating user/); }, }; @@ -277,9 +277,7 @@ export const ResetUserPasswordSuccess: Story = { await user.click( within(dialog).getByRole("button", { name: "Reset password" }), ); - await within(document.body).findByText( - "Successfully updated the user password.", - ); + await within(document.body).findByText(/password .* updated successfully/i); }, }; @@ -303,9 +301,7 @@ export const ResetUserPasswordError: Story = { await user.click( within(dialog).getByRole("button", { name: "Reset password" }), ); - await within(document.body).findByText( - "Error on resetting the user password.", - ); + await within(document.body).findByText(/Error resetting password/i); }, }; @@ -355,7 +351,7 @@ export const UpdateUserRoleSuccess: Story = { await user.click(within(userRow).getByLabelText("Edit user roles")); await user.click(screen.getByLabelText("Auditor", { exact: false })); - await screen.findByText("Successfully updated the user roles."); + await screen.findByText(/roles updated successfully/); }, }; @@ -371,7 +367,7 @@ export const UpdateUserRoleError: Story = { await user.click(within(userRow).getByLabelText("Edit user roles")); await user.click(screen.getByLabelText("Auditor", { exact: false })); - await screen.findByText("Error on updating the user roles."); + await screen.findByText(/Error updating user roles/); }, }; diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index bea3cb2e9f..8cddfa5878 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -1,4 +1,4 @@ -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { deploymentConfig } from "api/queries/deployment"; import { groupsByUserId } from "api/queries/groups"; import { roles } from "api/queries/roles"; @@ -15,7 +15,6 @@ import type { User } from "api/typesGenerated"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; import { useFilter } from "components/Filter/Filter"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { isNonInitialPage } from "components/PaginationWidget/utils"; import { useAuthenticated } from "hooks"; import { usePaginatedQuery } from "hooks/usePaginatedQuery"; @@ -23,6 +22,7 @@ import { useDashboard } from "modules/dashboard/useDashboard"; import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useSearchParams } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { generateRandomString } from "utils/random"; import { ResetPasswordDialog } from "./ResetPasswordDialog"; @@ -133,11 +133,11 @@ const UsersPage: FC = ({ defaultNewPassword }) => { onUpdateUserRoles={async (userId, roles) => { try { await updateRolesMutation.mutateAsync({ userId, roles }); - displaySuccess("Successfully updated the user roles."); + toast.success("User roles updated successfully."); } catch (e) { - displayError( - getErrorMessage(e, "Error on updating the user roles."), - ); + toast.error(getErrorMessage(e, "Error updating user roles."), { + description: getErrorDetail(e), + }); } }} isUpdatingUserRoles={updateRolesMutation.isPending} @@ -169,9 +169,19 @@ const UsersPage: FC = ({ defaultNewPassword }) => { try { await deleteUserMutation.mutateAsync(userToDelete.id); setUserToDelete(undefined); - displaySuccess("Successfully deleted the user."); + toast.success( + `User "${userToDelete.username}" deleted successfully.`, + ); } catch (e) { - displayError(getErrorMessage(e, "Error deleting user.")); + toast.error( + getErrorMessage( + e, + `Error deleting user "${userToDelete.username}".`, + ), + { + description: getErrorDetail(e), + }, + ); } }} /> @@ -191,9 +201,19 @@ const UsersPage: FC = ({ defaultNewPassword }) => { try { await suspendUserMutation.mutateAsync(userToSuspend.id); setUserToSuspend(undefined); - displaySuccess("Successfully suspended the user."); + toast.success( + `User "${userToSuspend.username}" suspended successfully.`, + ); } catch (e) { - displayError(getErrorMessage(e, "Error suspending user.")); + toast.error( + getErrorMessage( + e, + `Error suspending user "${userToSuspend.username}".`, + ), + { + description: getErrorDetail(e), + }, + ); } }} description={ @@ -219,9 +239,19 @@ const UsersPage: FC = ({ defaultNewPassword }) => { try { await activateUserMutation.mutateAsync(userToActivate.id); setUserToActivate(undefined); - displaySuccess("Successfully activated the user."); + toast.success( + `User "${userToActivate.username}" activated successfully.`, + ); } catch (e) { - displayError(getErrorMessage(e, "Error activating user.")); + toast.error( + getErrorMessage( + e, + `Error activating user "${userToActivate.username}".`, + ), + { + description: getErrorDetail(e), + }, + ); } }} description={ @@ -252,10 +282,15 @@ const UsersPage: FC = ({ defaultNewPassword }) => { old_password: "", }); setConfirmResetPassword(undefined); - displaySuccess("Successfully updated the user password."); + toast.success( + `Password for "${confirmResetPassword.user.username}" updated successfully.`, + ); } catch (e) { - displayError( - getErrorMessage(e, "Error on resetting the user password."), + toast.error( + getErrorMessage( + e, + `Error resetting password for "${confirmResetPassword.user.username}".`, + ), ); } }} diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index e5b6613103..70393eaee0 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -8,13 +8,13 @@ import { } from "api/queries/workspaces"; import type { Workspace } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { displayError } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { Margins } from "components/Margins/Margins"; import { useEffectEvent } from "hooks/hookPolyfills"; import { type FC, useEffect } from "react"; import { useQuery, useQueryClient } from "react-query"; import { useParams } from "react-router"; +import { toast } from "sonner"; import { WorkspaceReadyPage } from "./WorkspaceReadyPage"; const WorkspacePage: FC = () => { @@ -86,8 +86,11 @@ const WorkspacePage: FC = () => { const socket = watchWorkspace(workspaceId); socket.addEventListener("message", (event) => { if (event.parseError) { - displayError( - "Unable to process latest data from the server. Please try refreshing the page.", + toast.error( + `Unable to process latest data for workspace "${workspaceName}".`, + { + description: "Please try refreshing the page.", + }, ); return; } @@ -97,13 +100,13 @@ const WorkspacePage: FC = () => { } }); socket.addEventListener("error", () => { - displayError( - "Unable to get workspace changes. Connection has been closed.", - ); + toast.error(`Unable to get changes for workspace "${workspaceName}".`, { + description: "Connection has been closed.", + }); }); return () => socket.close(); - }, [updateWorkspaceData, workspaceId]); + }, [updateWorkspaceData, workspaceId, workspaceName]); // Page statuses const pageError = diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 654c2e317a..739ced4467 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -1,5 +1,10 @@ import { API } from "api/api"; -import { type ApiError, getErrorMessage, isApiError } from "api/errors"; +import { + type ApiError, + getErrorDetail, + getErrorMessage, + isApiError, +} from "api/errors"; import { templateVersion } from "api/queries/templates"; import { workspaceBuildTimings } from "api/queries/workspaceBuilds"; import { @@ -15,7 +20,6 @@ import { ConfirmDialog, type ConfirmDialogProps, } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; -import { displayError } from "components/GlobalSnackbar/utils"; import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs"; import { EphemeralParametersDialog } from "modules/workspaces/EphemeralParametersDialog/EphemeralParametersDialog"; import { WorkspaceErrorDialog } from "modules/workspaces/ErrorDialog/WorkspaceErrorDialog"; @@ -27,6 +31,7 @@ import { } from "modules/workspaces/WorkspaceUpdateDialogs"; import { type FC, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { Workspace } from "./Workspace"; @@ -70,7 +75,15 @@ export const WorkspaceReadyPage: FC = ({ error: error, }); } else { - displayError(getErrorMessage(error, "Failed to build workspace.")); + toast.error( + getErrorMessage( + error, + `Failed to build workspace "${workspace.name}".`, + ), + { + description: getErrorDetail(error), + }, + ); } }; @@ -325,8 +338,15 @@ export const WorkspaceReadyPage: FC = ({ try { await activateWorkspaceMutation.mutateAsync(); } catch (e) { - const message = getErrorMessage(e, "Error activate workspace."); - displayError(message); + toast.error( + getErrorMessage( + e, + `Error activating workspace "${workspace.name}".`, + ), + { + description: getErrorDetail(e), + }, + ); } }} handleToggleFavorite={() => { diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx index 2f4d0f96df..f2098250d1 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx @@ -63,7 +63,7 @@ test("add 3 hours to deadline", async () => { await user.click(addButton); await user.click(addButton); await screen.findByText( - "Workspace shutdown time has been successfully updated.", + `Shutdown time for "Test-Workspace" updated successfully.`, ); expect(await screen.findByText("Stop in 6 hours")).toBeInTheDocument(); @@ -91,7 +91,7 @@ test("remove 2 hours to deadline", async () => { await user.click(subButton); await user.click(subButton); await screen.findByText( - "Workspace shutdown time has been successfully updated.", + `Shutdown time for "Test-Workspace" updated successfully.`, ); expect(await screen.findByText("Stop in an hour")).toBeInTheDocument(); @@ -119,7 +119,7 @@ test("rollback to previous deadline on error", async () => { await user.click(addButton); await user.click(addButton); await screen.findByText( - "We couldn't update your workspace shutdown time. Please try again.", + `Failed to update shutdown time for "Test-Workspace". Please try again.`, ); // In case of an error, the schedule message should remain unchanged expect(screen.getByText(initialScheduleMessage)).toBeInTheDocument(); @@ -140,7 +140,7 @@ test("request is only sent once when clicking multiple times", async () => { await user.click(addButton); await user.click(addButton); await screen.findByText( - "Workspace shutdown time has been successfully updated.", + `Shutdown time for "Test-Workspace" updated successfully.`, ); expect(updateDeadlineSpy).toHaveBeenCalledTimes(1); }); diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx index ee2a15c681..23b4c00476 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx @@ -1,6 +1,6 @@ import type { Interpolation, Theme } from "@emotion/react"; import Link, { type LinkProps } from "@mui/material/Link"; -import { getErrorMessage } from "api/errors"; +import { getErrorDetail, getErrorMessage } from "api/errors"; import { updateDeadline, workspaceByOwnerAndNameKey, @@ -8,7 +8,6 @@ import { import type { Template, Workspace } from "api/typesGenerated"; import { Button } from "components/Button/Button"; import { TopbarData, TopbarIcon } from "components/FullPageLayout/Topbar"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Tooltip, TooltipContent, @@ -21,6 +20,7 @@ import { getWorkspaceActivityStatus } from "modules/workspaces/activity"; import { type FC, type ReactNode, useRef, useState } from "react"; import { useMutation, useQueryClient } from "react-query"; import { Link as RouterLink } from "react-router"; +import { toast } from "sonner"; import { autostartDisplay, autostopDisplay, @@ -143,15 +143,20 @@ const AutostopDisplay: FC = ({ const updateDeadlineMutation = useMutation({ ...updateDeadline(workspace), onSuccess: (_, updatedDeadline) => { - displaySuccess("Workspace shutdown time has been successfully updated."); + toast.success( + `Shutdown time for "${workspace.name}" updated successfully.`, + ); lastStableDeadline.current = updatedDeadline; }, onError: (error) => { - displayError( + toast.error( getErrorMessage( error, - "We couldn't update your workspace shutdown time. Please try again.", + `Failed to update shutdown time for "${workspace.name}". Please try again.`, ), + { + description: getErrorDetail(error), + }, ); updateWorkspaceDeadlineQueryData(lastStableDeadline.current); }, diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx index f125071ebe..7b7d4d9f50 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.test.tsx @@ -293,7 +293,7 @@ describe("WorkspaceSchedulePage", () => { await user.click(submitButton); const notification = await screen.findByText( - "Workspace schedule updated", + `Schedule for workspace "Test-Workspace" updated successfully.`, ); expect(notification).toBeInTheDocument(); @@ -320,7 +320,7 @@ describe("WorkspaceSchedulePage", () => { await user.click(submitButton); const notification = await screen.findByText( - "Workspace schedule updated", + `Schedule for workspace "Test-Workspace" updated successfully.`, ); expect(notification).toBeInTheDocument(); diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index f801dfa279..05d9bdfaac 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -1,4 +1,5 @@ import { API } from "api/api"; +import { getErrorDetail } from "api/errors"; import { checkAuthorization } from "api/queries/authCheck"; import { templateByName } from "api/queries/templates"; import { workspaceByOwnerAndNameKey } from "api/queries/workspaces"; @@ -6,7 +7,6 @@ import type * as TypesGen from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Link } from "components/Link/Link"; import { Loader } from "components/Loader/Loader"; import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; @@ -20,6 +20,7 @@ import { useWorkspaceSettings } from "pages/WorkspaceSettingsPage/WorkspaceSetti import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; import { docs } from "utils/docs"; import { pageTitle } from "utils/page"; import { @@ -62,9 +63,17 @@ const WorkspaceSchedulePage: FC = () => { params.workspace, ), }); - displaySuccess("Workspace schedule updated"); + toast.success( + `Schedule for workspace "${workspaceName}" updated successfully.`, + ); }, - onError: () => displayError("Failed to update workspace schedule"), + onError: (error) => + toast.error( + `Failed to update schedule for workspace "${workspaceName}".`, + { + description: getErrorDetail(error), + }, + ), }); const error = checkPermissionsError || getTemplateError; const isLoading = !template || !permissions; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx index d4a9e96905..83050692f9 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx @@ -1,8 +1,8 @@ import { API } from "api/api"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; import type { FC } from "react"; import { useMutation } from "react-query"; import { useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import type { WorkspaceSettingsFormValues } from "./WorkspaceSettingsForm"; import { useWorkspaceSettings } from "./WorkspaceSettingsLayout"; @@ -29,7 +29,7 @@ const WorkspaceSettingsPage: FC = () => { ]); }, onSuccess: (_, formValues) => { - displaySuccess("Workspace updated successfully"); + toast.success(`Workspace "${formValues.name}" updated successfully.`); navigate(`/@${username}/${formValues.name}/settings`); }, }); diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 7e69a52140..e594338bd7 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -4,7 +4,6 @@ import { templates, templateVersionRoot } from "api/queries/templates"; import { workspaces } from "api/queries/workspaces"; import { useFilter } from "components/Filter/Filter"; import { useUserFilterMenu } from "components/Filter/UserFilter"; -import { displayError } from "components/GlobalSnackbar/utils"; import { useAuthenticated } from "hooks"; import { useEffectEvent } from "hooks/hookPolyfills"; import { usePagination } from "hooks/usePagination"; @@ -14,6 +13,7 @@ import { ACTIVE_BUILD_STATUSES } from "modules/workspaces/status"; import { type FC, useMemo, useState } from "react"; import { useQuery, useQueryClient } from "react-query"; import { useSearchParams } from "react-router"; +import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { BatchDeleteConfirmation } from "./BatchDeleteConfirmation"; import { BatchUpdateModalForm } from "./BatchUpdateModalForm"; @@ -197,10 +197,9 @@ const WorkspacesPage: FC = () => { }); }} onActionError={(error) => { - displayError( - getErrorMessage(error, "Failed to perform action"), - getErrorDetail(error), - ); + toast.error(getErrorMessage(error, "Failed to perform action."), { + description: getErrorDetail(error), + }); }} /> diff --git a/site/src/pages/WorkspacesPage/batchActions.ts b/site/src/pages/WorkspacesPage/batchActions.ts index 1674fb9cbe..d4ec273a90 100644 --- a/site/src/pages/WorkspacesPage/batchActions.ts +++ b/site/src/pages/WorkspacesPage/batchActions.ts @@ -1,7 +1,8 @@ import { API } from "api/api"; +import { getErrorDetail } from "api/errors"; import type { Workspace, WorkspaceBuild } from "api/typesGenerated"; -import { displayError } from "components/GlobalSnackbar/utils"; import { useMutation } from "react-query"; +import { toast } from "sonner"; interface UseBatchActionsOptions { onSuccess: () => Promise; @@ -38,8 +39,10 @@ export function useBatchActions( ); }, onSuccess, - onError: () => { - displayError("Failed to start workspaces"); + onError: (error) => { + toast.error("Failed to start workspaces.", { + description: getErrorDetail(error), + }); }, }); @@ -48,8 +51,10 @@ export function useBatchActions( return Promise.all(workspaces.map((w) => API.stopWorkspace(w.id))); }, onSuccess, - onError: () => { - displayError("Failed to stop workspaces"); + onError: (error) => { + toast.error("Failed to stop workspaces.", { + description: getErrorDetail(error), + }); }, }); @@ -58,8 +63,10 @@ export function useBatchActions( return Promise.all(workspaces.map((w) => API.deleteWorkspace(w.id))); }, onSuccess, - onError: () => { - displayError("Failed to delete some workspaces"); + onError: (error) => { + toast.error("Failed to delete some workspaces.", { + description: getErrorDetail(error), + }); }, }); @@ -73,8 +80,10 @@ export function useBatchActions( ); }, onSuccess, - onError: () => { - displayError("Failed to update some workspaces"); + onError: (error) => { + toast.error("Failed to update some workspaces.", { + description: getErrorDetail(error), + }); }, }); @@ -92,8 +101,10 @@ export function useBatchActions( ); }, onSuccess, - onError: () => { - displayError("Failed to favorite some workspaces"); + onError: (error) => { + toast.error("Failed to favorite some workspaces.", { + description: getErrorDetail(error), + }); }, }); @@ -106,8 +117,10 @@ export function useBatchActions( ); }, onSuccess, - onError: () => { - displayError("Failed to unfavorite some workspaces"); + onError: (error) => { + toast.error("Failed to unfavorite some workspaces.", { + description: getErrorDetail(error), + }); }, }); diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index 4561d7b734..40201923e7 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -3,7 +3,7 @@ import { withDefaultFeatures } from "api/api"; import { getAuthorizationKey } from "api/queries/authCheck"; import { hasFirstUserKey, meKey } from "api/queries/users"; import type { Entitlements } from "api/typesGenerated"; -import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar"; +import { Toaster } from "components/Toaster/Toaster"; import { AuthProvider } from "contexts/auth/AuthProvider"; import { getPreferredProxy, @@ -139,10 +139,10 @@ export const withAuthProvider = (Story: FC, { parameters }: StoryContext) => { ); }; -export const withGlobalSnackbar = (Story: FC) => ( +export const withToaster = (Story: FC) => ( <> - + ); diff --git a/site/src/utils/events.test.ts b/site/src/utils/events.test.ts deleted file mode 100644 index f2fe5936f4..0000000000 --- a/site/src/utils/events.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { dispatchCustomEvent, isCustomEvent } from "./events"; - -describe("events", () => { - describe("dispatchCustomEvent", () => { - it("dispatch a custom event", () => { - const eventDetail = { title: "Event title" }; - - return new Promise((resolve) => { - window.addEventListener("eventType", (event) => { - if (isCustomEvent(event)) { - expect(event.detail).toEqual(eventDetail); - resolve(); - } - }); - - dispatchCustomEvent("eventType", eventDetail); - }); - }); - }); -}); diff --git a/site/src/utils/events.ts b/site/src/utils/events.ts deleted file mode 100644 index 450084985c..0000000000 --- a/site/src/utils/events.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Dispatches a custom event with descriptive type information. - * - * @param eventType a unique name defining the type of the event. e.g. `"coder:workspace:ready"` - * @param detail an optional payload accessible to an event listener. - * @param target an optional event target. Defaults to current `window`. - */ -export const dispatchCustomEvent = ( - eventType: string, - detail?: D, - target: EventTarget = window, -): CustomEvent => { - const event = new CustomEvent(eventType, { detail }); - - target.dispatchEvent(event); - - return event; -}; -/** Annotates a custom event listener with descriptive type information. */ -export type CustomEventListener = (event: CustomEvent) => void; - -/** - * Determines if an Event object is a CustomEvent. - * - * @remark this is especially necessary when an event originates from an iframe - * as `instanceof` will not match against another origin's prototype chain. - */ -export const isCustomEvent = ( - event: CustomEvent | Event, -): event is CustomEvent => { - return Boolean((event as CustomEvent).detail); -};