mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
fix(site): replace inline delete confirmations with modals (#23710)
This commit is contained in:
+20
-24
@@ -776,14 +776,15 @@ export const ModelDeleteConfirmation: Story = {
|
||||
const deleteButton = await body.findByRole("button", { name: "Delete" });
|
||||
await expect(deleteButton).toBeInTheDocument();
|
||||
|
||||
// Click Delete to show the inline confirmation.
|
||||
// Click Delete to show the confirmation dialog.
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// The confirmation strip should appear — leave it visible
|
||||
// The confirmation dialog should appear — leave it visible
|
||||
// so the Chromatic snapshot captures this state.
|
||||
await expect(
|
||||
await body.findByText(/Are you sure\? This action is irreversible/i),
|
||||
await body.findByText(/Are you sure you want to delete this model/i),
|
||||
).toBeInTheDocument();
|
||||
await expect(body.getByRole("dialog")).toBeInTheDocument();
|
||||
await expect(
|
||||
body.getByRole("button", { name: "Delete model" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -820,19 +821,16 @@ export const ModelDeleteCancelled: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
// Navigate to edit form, trigger confirmation, then cancel.
|
||||
// Navigate to edit form, open delete dialog, then cancel.
|
||||
await userEvent.click(await body.findByText("GPT-4o"));
|
||||
await userEvent.click(await body.findByRole("button", { name: "Delete" }));
|
||||
await body.findByText(/Are you sure/i);
|
||||
await userEvent.click(body.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
// Normal footer should be restored.
|
||||
await expect(
|
||||
await body.findByRole("button", { name: "Delete" }),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
await body.findByRole("button", { name: "Save" }),
|
||||
).toBeInTheDocument();
|
||||
// The dialog should be closed.
|
||||
await waitFor(() => {
|
||||
expect(body.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -863,7 +861,7 @@ export const ModelDeleteConfirmed: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
// Navigate to edit form, trigger delete confirmation, then confirm.
|
||||
// Navigate to edit form, open delete dialog, then confirm.
|
||||
await userEvent.click(await body.findByText("GPT-4o"));
|
||||
await userEvent.click(await body.findByRole("button", { name: "Delete" }));
|
||||
await userEvent.click(
|
||||
@@ -903,15 +901,16 @@ export const ProviderDeleteConfirmation: Story = {
|
||||
// Navigate to the provider detail view.
|
||||
await userEvent.click(await body.findByRole("button", { name: /OpenAI/i }));
|
||||
|
||||
// Click Delete to show the inline confirmation.
|
||||
// Click Delete to show the confirmation dialog.
|
||||
const deleteButton = await body.findByRole("button", { name: "Delete" });
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// The confirmation strip should appear — leave it visible
|
||||
// The confirmation dialog should appear — leave it visible
|
||||
// so the Chromatic snapshot captures this state.
|
||||
await expect(
|
||||
await body.findByText(/Are you sure\? This action is irreversible/i),
|
||||
await body.findByText(/Are you sure you want to delete this provider/i),
|
||||
).toBeInTheDocument();
|
||||
await expect(body.getByRole("dialog")).toBeInTheDocument();
|
||||
await expect(
|
||||
body.getByRole("button", { name: "Delete provider" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -941,19 +940,16 @@ export const ProviderDeleteCancelled: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
// Navigate to provider detail, trigger confirmation, then cancel.
|
||||
// Navigate to provider detail, open delete dialog, then cancel.
|
||||
await userEvent.click(await body.findByRole("button", { name: /OpenAI/i }));
|
||||
await userEvent.click(await body.findByRole("button", { name: "Delete" }));
|
||||
await body.findByText(/Are you sure/i);
|
||||
await userEvent.click(body.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
// Normal footer should be restored.
|
||||
await expect(
|
||||
await body.findByRole("button", { name: "Delete" }),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
await body.findByRole("button", { name: "Save changes" }),
|
||||
).toBeInTheDocument();
|
||||
// The dialog should be closed.
|
||||
await waitFor(() => {
|
||||
expect(body.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -977,7 +973,7 @@ export const ProviderDeleteConfirmed: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
// Navigate to provider detail, trigger delete, then confirm.
|
||||
// Navigate to provider detail, open delete dialog, then confirm.
|
||||
await userEvent.click(await body.findByRole("button", { name: /OpenAI/i }));
|
||||
await userEvent.click(await body.findByRole("button", { name: "Delete" }));
|
||||
await userEvent.click(
|
||||
|
||||
@@ -8,6 +8,14 @@ import { type FC, useState } from "react";
|
||||
import * as Yup from "yup";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "#/components/Dialog/Dialog";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import {
|
||||
@@ -307,7 +315,6 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
|
||||
{/* Header — editable display name */}
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedProviderState && (
|
||||
@@ -352,7 +359,6 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||
|
||||
{/* Form body */}
|
||||
<form className="flex flex-1 flex-col" onSubmit={form.handleSubmit}>
|
||||
<div className="space-y-5">
|
||||
@@ -539,68 +545,72 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
{/* Footer — pushed to bottom */}
|
||||
<div className="mt-auto py-6">
|
||||
<hr className="mb-4 border-0 border-t border-solid border-border" />
|
||||
{confirmingDelete && onDeleteModel && editingModel ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="m-0 flex-1 text-sm text-content-secondary">
|
||||
Are you sure? This action is irreversible.
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
type="button"
|
||||
disabled={isDeleting}
|
||||
onClick={() => void onDeleteModel(editingModel.id)}
|
||||
>
|
||||
{isDeleting && <Spinner className="h-4 w-4" loading />}
|
||||
Delete model
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
{isEditing && editingModel && onDeleteModel ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
className="text-content-secondary hover:text-content-destructive hover:border-border-destructive"
|
||||
disabled={isSaving}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
{isEditing && editingModel && onDeleteModel ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="submit"
|
||||
disabled={isSaving || !form.isValid || hasFieldErrors}
|
||||
type="button"
|
||||
className="text-content-secondary hover:text-content-destructive hover:border-border-destructive"
|
||||
disabled={isSaving}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
{isSaving && <Spinner className="h-4 w-4" loading />}{" "}
|
||||
{isEditing ? "Save" : "Add model"}
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="lg"
|
||||
type="submit"
|
||||
disabled={isSaving || !form.isValid || hasFieldErrors}
|
||||
>
|
||||
{isSaving && <Spinner className="h-4 w-4" loading />}{" "}
|
||||
{isEditing ? "Save" : "Add model"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{editingModel && onDeleteModel && (
|
||||
<Dialog
|
||||
open={confirmingDelete}
|
||||
onOpenChange={(open) => !open && setConfirmingDelete(false)}
|
||||
>
|
||||
<DialogContent variant="destructive">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete model</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this model? This action is
|
||||
irreversible.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmingDelete(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void onDeleteModel(editingModel.id)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting && <Spinner className="h-4 w-4" loading />}
|
||||
Delete model
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}{" "}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,14 @@ import { type FC, type FormEvent, useId, useState } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "#/components/Dialog/Dialog";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import {
|
||||
@@ -267,62 +275,67 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
{/* Footer — pushed to bottom */}
|
||||
<div className="mt-auto pt-6">
|
||||
<hr className="mb-4 border-0 border-t border-solid border-border" />
|
||||
{confirmingDelete && providerConfig ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="m-0 flex-1 text-sm text-content-secondary">
|
||||
Are you sure? This action is irreversible.
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(false)}
|
||||
disabled={isProviderMutationPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
type="button"
|
||||
disabled={isProviderMutationPending}
|
||||
onClick={() => void onDeleteProvider(providerConfig.id)}
|
||||
>
|
||||
{isProviderMutationPending && (
|
||||
<Spinner className="h-4 w-4" loading />
|
||||
)}
|
||||
Delete provider
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
{providerConfig ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
className="text-content-secondary hover:text-content-destructive hover:border-border-destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button size="lg" type="submit" disabled={!canSave}>
|
||||
{isProviderMutationPending && (
|
||||
<Spinner className="h-4 w-4" loading />
|
||||
)}
|
||||
{providerConfig ? "Save changes" : "Create provider config"}
|
||||
<div className="flex items-center justify-between">
|
||||
{providerConfig ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
className="text-content-secondary hover:text-content-destructive hover:border-border-destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button size="lg" type="submit" disabled={!canSave}>
|
||||
{isProviderMutationPending && (
|
||||
<Spinner className="h-4 w-4" loading />
|
||||
)}
|
||||
{providerConfig ? "Save changes" : "Create provider config"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{providerConfig && (
|
||||
<Dialog
|
||||
open={confirmingDelete}
|
||||
onOpenChange={(open) => !open && setConfirmingDelete(false)}
|
||||
>
|
||||
<DialogContent variant="destructive">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this provider? This action is
|
||||
irreversible.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmingDelete(false)}
|
||||
disabled={isProviderMutationPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void onDeleteProvider(providerConfig.id)}
|
||||
disabled={isProviderMutationPending}
|
||||
>
|
||||
{isProviderMutationPending && (
|
||||
<Spinner className="h-4 w-4" loading />
|
||||
)}
|
||||
Delete provider
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import type { Group } from "#/api/typesGenerated";
|
||||
import { GroupLimitsSection } from "./GroupLimitsSection";
|
||||
|
||||
@@ -131,3 +131,46 @@ export const EditForm: Story = {
|
||||
expect(nameElements.length).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
};
|
||||
|
||||
export const DeleteGroupOverride: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
// Click the Delete button for the first group override.
|
||||
const deleteButtons = await body.findAllByRole("button", {
|
||||
name: "Delete",
|
||||
});
|
||||
await userEvent.click(deleteButtons[0]);
|
||||
|
||||
// The confirmation dialog should appear.
|
||||
const dialog = await body.findByRole("dialog");
|
||||
await expect(dialog).toBeInTheDocument();
|
||||
await expect(
|
||||
body.getByText(
|
||||
/Are you sure you want to delete this group limit override/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const DeleteGroupOverrideCancelled: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
// Click the Delete button for the first group override.
|
||||
const deleteButtons = await body.findAllByRole("button", {
|
||||
name: "Delete",
|
||||
});
|
||||
await userEvent.click(deleteButtons[0]);
|
||||
|
||||
// Cancel the dialog.
|
||||
await body.findByRole("dialog");
|
||||
await userEvent.click(body.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
// The dialog should be closed and the callback should not have been called.
|
||||
await waitFor(() => {
|
||||
expect(body.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(args.onDeleteGroupOverride).not.toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { Check } from "lucide-react";
|
||||
import { type FC, useId } from "react";
|
||||
import { type FC, useId, useState } from "react";
|
||||
import { getErrorMessage } from "#/api/errors";
|
||||
import type { Group } from "#/api/typesGenerated";
|
||||
import { Autocomplete } from "#/components/Autocomplete/Autocomplete";
|
||||
import { AvatarData } from "#/components/Avatar/AvatarData";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "#/components/Dialog/Dialog";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
@@ -84,6 +92,9 @@ export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
|
||||
const groupAutocompleteId = useId();
|
||||
const groupAmountId = useId();
|
||||
const isEditing = editingGroupOverride !== null;
|
||||
const [pendingDeleteGroupId, setPendingDeleteGroupId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
@@ -91,7 +102,6 @@ export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
|
||||
label="Group Limits"
|
||||
description="Override the default limit for specific groups. When a user belongs to multiple groups, the lowest group limit applies."
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{groupOverrides.length > 0 ? (
|
||||
<Table>
|
||||
@@ -136,7 +146,7 @@ export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void onDeleteGroupOverride(override.group_id)
|
||||
setPendingDeleteGroupId(override.group_id)
|
||||
}
|
||||
disabled={deletePending || upsertPending || isEditing}
|
||||
>
|
||||
@@ -287,6 +297,42 @@ export const GroupLimitsSection: FC<GroupLimitsSectionProps> = ({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{pendingDeleteGroupId && (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => !open && setPendingDeleteGroupId(null)}
|
||||
>
|
||||
<DialogContent variant="destructive">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete group override</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this group limit override? This
|
||||
action is irreversible.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPendingDeleteGroupId(null)}
|
||||
disabled={deletePending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
void onDeleteGroupOverride(pendingDeleteGroupId);
|
||||
setPendingDeleteGroupId(null);
|
||||
}}
|
||||
disabled={deletePending}
|
||||
>
|
||||
{deletePending && <Spinner className="h-4 w-4" loading />}
|
||||
Delete override
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}{" "}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "react-query";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
||||
import { UserOverridesSection } from "./UserOverridesSection";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -108,3 +108,46 @@ export const EditForm: Story = {
|
||||
expect(nameElements.length).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
};
|
||||
|
||||
export const DeleteUserOverride: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
// Click the Delete button for the first user override.
|
||||
const deleteButtons = await body.findAllByRole("button", {
|
||||
name: "Delete",
|
||||
});
|
||||
await userEvent.click(deleteButtons[0]);
|
||||
|
||||
// The confirmation dialog should appear.
|
||||
const dialog = await body.findByRole("dialog");
|
||||
await expect(dialog).toBeInTheDocument();
|
||||
await expect(
|
||||
body.getByText(
|
||||
/Are you sure you want to delete this user limit override/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const DeleteUserOverrideCancelled: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
// Click the Delete button for the first user override.
|
||||
const deleteButtons = await body.findAllByRole("button", {
|
||||
name: "Delete",
|
||||
});
|
||||
await userEvent.click(deleteButtons[0]);
|
||||
|
||||
// Cancel the dialog.
|
||||
await body.findByRole("dialog");
|
||||
await userEvent.click(body.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
// The dialog should be closed and the callback should not have been called.
|
||||
await waitFor(() => {
|
||||
expect(body.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(args.onDeleteOverride).not.toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { type FC, useId } from "react";
|
||||
import { type FC, useId, useState } from "react";
|
||||
import { getErrorMessage } from "#/api/errors";
|
||||
import type { User } from "#/api/typesGenerated";
|
||||
import { AvatarData } from "#/components/Avatar/AvatarData";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "#/components/Dialog/Dialog";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
@@ -73,6 +81,9 @@ export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
|
||||
}) => {
|
||||
const userOverrideAmountId = useId();
|
||||
const isEditing = editingUserOverride !== null;
|
||||
const [pendingDeleteUserId, setPendingDeleteUserId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
@@ -80,7 +91,6 @@ export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
|
||||
label="Per-User Overrides"
|
||||
description="Override the deployment default spend limit for specific users. User overrides take highest priority, followed by group limits, then the deployment default."
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{overrides.length > 0 ? (
|
||||
<Table>
|
||||
@@ -122,7 +132,7 @@ export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => void onDeleteOverride(override.user_id)}
|
||||
onClick={() => setPendingDeleteUserId(override.user_id)}
|
||||
disabled={deletePending || upsertPending || isEditing}
|
||||
>
|
||||
Delete
|
||||
@@ -246,6 +256,42 @@ export const UserOverridesSection: FC<UserOverridesSectionProps> = ({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{pendingDeleteUserId && (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => !open && setPendingDeleteUserId(null)}
|
||||
>
|
||||
<DialogContent variant="destructive">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete user override</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this user limit override? This
|
||||
action is irreversible.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPendingDeleteUserId(null)}
|
||||
disabled={deletePending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
void onDeleteOverride(pendingDeleteUserId);
|
||||
setPendingDeleteUserId(null);
|
||||
}}
|
||||
disabled={deletePending}
|
||||
>
|
||||
{deletePending && <Spinner className="h-4 w-4" loading />}
|
||||
Delete override
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}{" "}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -480,7 +480,7 @@ export const EditServerWithCustomHeaders: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Delete a server with confirmation step. */
|
||||
/** Delete a server shows confirmation dialog. */
|
||||
export const DeleteServerConfirmation: Story = {
|
||||
beforeEach: () => {
|
||||
setupMCPSpies({
|
||||
@@ -501,14 +501,15 @@ export const DeleteServerConfirmation: Story = {
|
||||
// Click Delete.
|
||||
await userEvent.click(await body.findByRole("button", { name: "Delete" }));
|
||||
|
||||
// Confirmation should appear.
|
||||
// Confirmation dialog should appear.
|
||||
await expect(
|
||||
await body.findByText(/Are you sure\? This action is irreversible/i),
|
||||
await body.findByText(/Are you sure you want to delete this MCP server/i),
|
||||
).toBeInTheDocument();
|
||||
await expect(body.getByRole("dialog")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** Cancel delete returns to normal form footer. */
|
||||
/** Cancel delete closes the dialog. */
|
||||
export const DeleteServerCancelled: Story = {
|
||||
beforeEach: () => {
|
||||
setupMCPSpies({
|
||||
@@ -526,20 +527,17 @@ export const DeleteServerCancelled: Story = {
|
||||
|
||||
await userEvent.click(await body.findByRole("button", { name: /Sentry/ }));
|
||||
await userEvent.click(await body.findByRole("button", { name: "Delete" }));
|
||||
await body.findByText(/Are you sure/i);
|
||||
await body.findByText(/Are you sure you want to delete this MCP server/i);
|
||||
await userEvent.click(body.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
// Normal footer should be restored.
|
||||
await expect(
|
||||
await body.findByRole("button", { name: "Delete" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
body.getByRole("button", { name: /Save changes/i }),
|
||||
).toBeInTheDocument();
|
||||
// The dialog should be closed.
|
||||
await waitFor(() => {
|
||||
expect(body.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** Confirm delete calls the API. */
|
||||
/** Confirm delete in dialog calls the API. */
|
||||
export const DeleteServerConfirmed: Story = {
|
||||
beforeEach: () => {
|
||||
setupMCPSpies({
|
||||
@@ -557,7 +555,6 @@ export const DeleteServerConfirmed: Story = {
|
||||
|
||||
await userEvent.click(await body.findByRole("button", { name: /Sentry/ }));
|
||||
await userEvent.click(await body.findByRole("button", { name: "Delete" }));
|
||||
await body.findByText(/Are you sure/i);
|
||||
await userEvent.click(body.getByRole("button", { name: /Delete server/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -20,6 +20,14 @@ import {
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "#/components/Dialog/Dialog";
|
||||
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
|
||||
import { IconField } from "#/components/IconField/IconField";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
@@ -361,7 +369,6 @@ const ServerForm: FC<ServerFormProps> = ({
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</button>
|
||||
|
||||
{/* Header with icon + editable name + enabled toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<MCPServerIcon
|
||||
@@ -402,7 +409,6 @@ const ServerForm: FC<ServerFormProps> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={form.handleSubmit}
|
||||
@@ -826,57 +832,61 @@ const ServerForm: FC<ServerFormProps> = ({
|
||||
{/* Footer — pushed to bottom, matches ProviderForm */}
|
||||
<div className="mt-auto pt-6">
|
||||
<hr className="mb-4 border-0 border-t border-solid border-border" />
|
||||
{confirmingDelete && server ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="m-0 flex-1 text-sm text-content-secondary">
|
||||
Are you sure? This action is irreversible.
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(false)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
onClick={() => void onDelete(server.id)}
|
||||
>
|
||||
{isDeleting && <Spinner className="h-4 w-4" loading />}
|
||||
Delete server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
{isEditing ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
className="text-content-secondary hover:text-content-destructive hover:border-border-destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button size="lg" type="submit" disabled={!canSubmit}>
|
||||
{isSaving && <Spinner className="h-4 w-4" loading />}
|
||||
{isEditing ? "Save changes" : "Create server"}
|
||||
<div className="flex items-center justify-between">
|
||||
{isEditing ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
className="text-content-secondary hover:text-content-destructive hover:border-border-destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button size="lg" type="submit" disabled={!canSubmit}>
|
||||
{isSaving && <Spinner className="h-4 w-4" loading />}
|
||||
{isEditing ? "Save changes" : "Create server"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{server && (
|
||||
<Dialog
|
||||
open={confirmingDelete}
|
||||
onOpenChange={(open) => !open && setConfirmingDelete(false)}
|
||||
>
|
||||
<DialogContent variant="destructive">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this MCP server? This action is
|
||||
irreversible.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmingDelete(false)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void onDelete(server.id)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{isDeleting && <Spinner className="h-4 w-4" loading />}
|
||||
Delete server
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}{" "}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user