mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: migrate from <GlobalSnackbar /> to sonner (#22258)
Replaces our custom `<GlobalSnackbar />` (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 はな <mckayla@hey.com>
This commit is contained in:
+2
-2
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+14
@@ -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:
|
||||
|
||||
+2
-2
@@ -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<AppProvidersProps> = ({
|
||||
<ThemeProvider>
|
||||
<TooltipProvider delayDuration={100}>
|
||||
{children}
|
||||
<GlobalSnackbar />
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { EnterpriseSnackbar } from "./EnterpriseSnackbar";
|
||||
|
||||
const meta: Meta<typeof EnterpriseSnackbar> = {
|
||||
title: "components/EnterpriseSnackbar",
|
||||
component: EnterpriseSnackbar,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EnterpriseSnackbar>;
|
||||
|
||||
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.",
|
||||
},
|
||||
};
|
||||
@@ -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<EnterpriseSnackbarProps> = ({
|
||||
children,
|
||||
onClose,
|
||||
variant = "info",
|
||||
ContentProps = {},
|
||||
action,
|
||||
...snackbarProps
|
||||
}) => {
|
||||
return (
|
||||
<Snackbar
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
}}
|
||||
action={
|
||||
<div className="flex items-center">
|
||||
{action}
|
||||
<IconButton onClick={onClose} className="p-0">
|
||||
<XIcon
|
||||
aria-label="close"
|
||||
className="size-icon-sm text-content-primary"
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
}
|
||||
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}
|
||||
</Snackbar>
|
||||
);
|
||||
};
|
||||
|
||||
const variantColor = (variant: EnterpriseSnackbarVariant) => {
|
||||
switch (variant) {
|
||||
case "error":
|
||||
return "border-border-destructive";
|
||||
case "info":
|
||||
return "border-highlight-sky";
|
||||
case "success":
|
||||
return "border-border-success";
|
||||
}
|
||||
};
|
||||
@@ -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<NotificationMsg>();
|
||||
useCustomEvent<NotificationMsg>(SnackbarEventType, (event) => {
|
||||
setNotificationMsg(event.detail);
|
||||
});
|
||||
|
||||
const hasNotification = notificationMsg !== undefined;
|
||||
if (!hasNotification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EnterpriseSnackbar
|
||||
key={notificationMsg.msg}
|
||||
open={hasNotification}
|
||||
variant={variantFromMsgType(notificationMsg.msgType)}
|
||||
onClose={() => setNotificationMsg(undefined)}
|
||||
autoHideDuration={
|
||||
notificationMsg.msgType === MsgType.Error ? 22000 : 6000
|
||||
}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
message={
|
||||
<div css={{ display: "flex" }}>
|
||||
{notificationMsg.msgType === MsgType.Error && (
|
||||
<ErrorIcon css={styles.errorIcon} />
|
||||
)}
|
||||
|
||||
<div className="max-w-[670px] flex flex-col">
|
||||
<span css={styles.messageTitle}>{notificationMsg.msg}</span>
|
||||
|
||||
{notificationMsg.additionalMsgs?.map((msg, index) => (
|
||||
<AdditionalMessageDisplay key={index} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface AdditionalMessageDisplayProps {
|
||||
message: AdditionalMessage;
|
||||
}
|
||||
|
||||
const AdditionalMessageDisplay: FC<AdditionalMessageDisplayProps> = ({
|
||||
message,
|
||||
}) => {
|
||||
if (isNotificationText(message)) {
|
||||
return <span css={styles.messageSubtitle}>{message}</span>;
|
||||
}
|
||||
|
||||
if (isNotificationTextPrefixed(message)) {
|
||||
return (
|
||||
<span css={styles.messageSubtitle}>
|
||||
<strong>{message.prefix}:</strong> {message.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (isNotificationList(message)) {
|
||||
return (
|
||||
<ul css={{ paddingLeft: 0 }}>
|
||||
{message.map((item, idx) => (
|
||||
<li key={idx}>
|
||||
<span css={styles.messageSubtitle}>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, Interpolation<Theme>>;
|
||||
@@ -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<NotificationMsg>) => 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<void>((resolve) => {
|
||||
window.addEventListener(SnackbarEventType, (event) => {
|
||||
const notificationEvent = event as CustomEvent<NotificationMsg>;
|
||||
expect(notificationEvent.detail.msg).toEqual(message);
|
||||
resolve();
|
||||
});
|
||||
|
||||
displayError(message);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<NotificationMsg>(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,
|
||||
);
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import SvgIcon, { type SvgIconProps } from "@mui/material/SvgIcon";
|
||||
|
||||
import type { JSX } from "react";
|
||||
export const ErrorIcon = (props: SvgIconProps): JSX.Element => (
|
||||
<SvgIcon {...props} viewBox="0 0 24 24">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.59354 2.26627C7.76403 2.09578 7.99526 2 8.23637 2H15.7636C16.0047 2 16.236 2.09578 16.4065 2.26627L21.7337 7.59354C21.9042 7.76403 22 7.99526 22 8.23636V15.7636C22 16.0047 21.9042 16.236 21.7337 16.4065L16.4065 21.7337C16.236 21.9042 16.0047 22 15.7636 22H8.23637C7.99526 22 7.76403 21.9042 7.59354 21.7337L2.26627 16.4065C2.09578 16.236 2 16.0047 2 15.7636V8.23636C2 7.99526 2.09578 7.76403 2.26627 7.59354L7.59354 2.26627ZM8.61293 3.81818L3.81819 8.61292V15.3871L8.61293 20.1818H15.3871L20.1818 15.3871V8.61292L15.3871 3.81818H8.61293ZM12 7.45455C12.5021 7.45455 12.9091 7.86156 12.9091 8.36364V12C12.9091 12.5021 12.5021 12.9091 12 12.9091C11.4979 12.9091 11.0909 12.5021 11.0909 12V8.36364C11.0909 7.86156 11.4979 7.45455 12 7.45455ZM12 14.7273C11.4979 14.7273 11.0909 15.1343 11.0909 15.6364C11.0909 16.1384 11.4979 16.5455 12 16.5455H12.0091C12.5112 16.5455 12.9182 16.1384 12.9182 15.6364C12.9182 15.1343 12.5112 14.7273 12.0091 14.7273H12Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SvgIcon>
|
||||
);
|
||||
@@ -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 (
|
||||
<Sonner
|
||||
icons={{
|
||||
success: <CheckIcon className="text-content-success" />,
|
||||
info: <InfoIcon className="text-content-primary" />,
|
||||
warning: <TriangleAlertIcon className="text-content-warning" />,
|
||||
error: <OctagonXIcon className="text-content-destructive" />,
|
||||
loading: <Spinner size="sm" loading />,
|
||||
close: <XIcon className="size-icon-sm" />,
|
||||
}}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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<PropsWithChildren> = ({ children }) => {
|
||||
...updateProfileOptions("me"),
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(meOptions.queryKey, user);
|
||||
displaySuccess("Updated settings.");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -96,7 +95,12 @@ export const AuthProvider: FC<PropsWithChildren> = ({ 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],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = <T, E extends string = string>(
|
||||
eventType: E,
|
||||
listener: CustomEventListener<T>,
|
||||
): 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]);
|
||||
};
|
||||
@@ -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
|
||||
<ThemeOverride theme={themes[DEFAULT_THEME]}>
|
||||
{children}
|
||||
<GlobalSnackbar />
|
||||
</ThemeOverride>
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<Error>();
|
||||
|
||||
@@ -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.`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<ProxySettingsSubProps> = ({ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ProxyMenuProps> = ({ 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;
|
||||
}
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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<NotificationsInboxProps> = ({
|
||||
});
|
||||
|
||||
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<NotificationsInboxProps> = ({
|
||||
});
|
||||
},
|
||||
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<NotificationsInboxProps> = ({
|
||||
});
|
||||
},
|
||||
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<NotificationsInboxProps> = ({
|
||||
});
|
||||
},
|
||||
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) },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<AgentDevcontainerCardProps> = ({
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<AgentMetadataProps> = ({
|
||||
|
||||
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<AgentMetadataProps> = ({
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<WorkspaceAgent, "id" | "name" | "status" | "lifecycle_state">;
|
||||
@@ -46,9 +47,11 @@ export const DownloadAgentLogsButton: FC<DownloadAgentLogsButtonProps> = ({
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<void, [s1: string, s2?: string], unknown>;
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<typeof TaskDeleteDialog> = {
|
||||
title: "modules/tasks/TaskDeleteDialog",
|
||||
component: TaskDeleteDialog,
|
||||
decorators: [withGlobalSnackbar],
|
||||
decorators: [withToaster],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TaskDeleteDialog>;
|
||||
|
||||
export const DeleteTaskSuccess: Story = {
|
||||
decorators: [withGlobalSnackbar],
|
||||
decorators: [withToaster],
|
||||
args: {
|
||||
open: true,
|
||||
task: MockTask,
|
||||
|
||||
@@ -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<TaskDeleteDialogProps> = ({
|
||||
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={
|
||||
<p>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<TaskFeedbackDialogProps> = ({
|
||||
mutationFn: (req: CreateTaskFeedbackRequest) =>
|
||||
API.createTaskFeedback(taskId, req),
|
||||
onSuccess: () => {
|
||||
displaySuccess("Feedback submitted successfully");
|
||||
toast.success("Feedback submitted successfully.");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<TaskPromptProps> = ({
|
||||
error,
|
||||
onRetry,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (error) {
|
||||
return <TaskPromptLoadingError error={error} onRetry={onRetry} />;
|
||||
}
|
||||
@@ -63,8 +66,14 @@ export const TaskPrompt: FC<TaskPromptProps> = ({
|
||||
return (
|
||||
<CreateTaskForm
|
||||
templates={templates}
|
||||
onSuccess={() => {
|
||||
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<CreateTaskFormProps> = ({ 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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<TaskSidebarMenuItemProps> = ({ 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),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<DownloadLogsDialogProps> = ({
|
||||
}, 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);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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<WorkspaceMoreActionsProps> = ({
|
||||
error: error,
|
||||
});
|
||||
} else {
|
||||
displayError(getErrorMessage(error, "Failed to delete workspace."));
|
||||
toast.error(
|
||||
getErrorMessage(
|
||||
error,
|
||||
`Failed to delete workspace "${workspace.name}".`,
|
||||
),
|
||||
{
|
||||
description: getErrorDetail(error),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<TooltipContentProps> = ({
|
||||
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),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<CreateTemplatePageViewProps> = ({
|
||||
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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<unknown>(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<CreateTokenData>({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
+19
-11
@@ -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 (
|
||||
|
||||
@@ -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<ExportPolicyButtonProps> = ({
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+6
-3
@@ -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<AddNewLicenseProps> = ({
|
||||
|
||||
onSaveLicenseKey(licenseKey);
|
||||
|
||||
fileReader.onerror = () => {
|
||||
displayError("Failed to read file");
|
||||
fileReader.onerror = (error) => {
|
||||
toast.error("Failed to read file.", {
|
||||
description: getErrorDetail(error),
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+16
-9
@@ -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),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -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.",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<MethodToggleGroupProps> = ({
|
||||
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),
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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.");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
+18
-10
@@ -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}
|
||||
/>
|
||||
|
||||
+61
-36
@@ -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}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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 = () => {
|
||||
<GroupSettingsPageView
|
||||
onCancel={navigateToGroup}
|
||||
onSubmit={async (data) => {
|
||||
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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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.");
|
||||
}}
|
||||
>
|
||||
<Spinner loading={enableMutation.isPending}>
|
||||
@@ -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.");
|
||||
}}
|
||||
>
|
||||
<Spinner loading={dismissMutation.isPending}>
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</RequirePermission>
|
||||
|
||||
@@ -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 = () => {
|
||||
<CreateEditRolePageView
|
||||
role={role}
|
||||
onSubmit={async (data: CustomRoleRequest) => {
|
||||
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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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<DownloadPolicyButtonProps> = ({
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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\./);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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<AddOrganizationMemberProps> = ({
|
||||
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),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
+5
-5
@@ -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);
|
||||
|
||||
+15
-8
@@ -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),
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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<typeof ChangePasswordPage> = {
|
||||
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.");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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<ChangePasswordChangeProps> = ({ 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);
|
||||
|
||||
@@ -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<typeof RequestOTPPage> = {
|
||||
title: "pages/ResetPasswordPage/RequestOTPPage",
|
||||
component: RequestOTPPage,
|
||||
decorators: [withGlobalSnackbar],
|
||||
decorators: [withToaster],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TaskPausedProps> = ({ 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<TaskStartingAgentProps> = ({ 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);
|
||||
|
||||
@@ -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<TaskRowProps> = ({ 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),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
@@ -25,7 +25,7 @@ export function useBatchTaskActions(
|
||||
},
|
||||
onSuccess,
|
||||
onError: () => {
|
||||
displayError("Failed to delete some tasks");
|
||||
toast.error("Failed to delete some tasks.");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -59,7 +59,11 @@ const TemplateMenu: FC<TemplateMenuProps> = ({
|
||||
fileId,
|
||||
onDelete,
|
||||
}) => {
|
||||
const dialogState = useDeletionDialogState(templateId, onDelete);
|
||||
const dialogState = useDeletionDialogState(
|
||||
templateId,
|
||||
onDelete,
|
||||
templateName,
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
const getLink = useLinks();
|
||||
const queryText = `template:${templateName}`;
|
||||
|
||||
@@ -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<TemplatePrebuildsPageViewProps> = ({
|
||||
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<TemplatePrebuildsPageViewProps> = ({
|
||||
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}".`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
+2
-2
@@ -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<typeof TemplatePrebuildsPageView> = {
|
||||
args: {
|
||||
templateId: MockTemplate.id,
|
||||
},
|
||||
decorators: [withGlobalSnackbar],
|
||||
decorators: [withToaster],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<DeleteTemplateState>({ 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,
|
||||
|
||||
+9
-4
@@ -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),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+102
-25
@@ -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),
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user