Fix channel delete confirmation flow (#96)

This commit is contained in:
Wes
2026-03-17 15:58:21 -07:00
committed by GitHub
parent 9728fa0c12
commit d4ca0ccd69
5 changed files with 286 additions and 28 deletions
+1
View File
@@ -20,6 +20,7 @@
"test:e2e:report": "playwright show-report"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
+30
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@radix-ui/react-alert-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -470,6 +473,19 @@ packages:
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-alert-dialog@1.1.15':
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
@@ -2235,6 +2251,20 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -31,6 +31,17 @@ import {
import { usePresenceQuery } from "@/features/presence/hooks";
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
import type { Channel, ChannelMember } from "@/shared/api/types";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Separator } from "@/shared/ui/separator";
@@ -190,6 +201,7 @@ export function ChannelManagementSheet({
const [descriptionDraft, setDescriptionDraft] = React.useState("");
const [topicDraft, setTopicDraft] = React.useState("");
const [purposeDraft, setPurposeDraft] = React.useState("");
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
// Sync drafts from server only when the sheet opens or the channel changes —
// not on every background refetch, which would clobber in-flight edits.
@@ -198,6 +210,7 @@ export function ChannelManagementSheet({
if (!open) {
// Reset on close so the next open re-syncs from server.
syncedForRef.current = null;
setIsDeleteDialogOpen(false);
return;
}
if (!detail) {
@@ -220,10 +233,34 @@ export function ChannelManagementSheet({
return null;
}
function handleDeleteDialogOpenChange(next: boolean) {
deleteChannelMutation.reset();
setIsDeleteDialogOpen(next);
}
async function handleDeleteChannel() {
try {
await deleteChannelMutation.mutateAsync();
handleDeleteDialogOpenChange(false);
onOpenChange(false);
onDeleted?.();
} catch {
// The mutation error is rendered inline in the confirmation dialog.
}
}
function handleSheetOpenChange(next: boolean) {
if (!next) {
handleDeleteDialogOpenChange(false);
}
onOpenChange(next);
}
const resolvedChannel = detail ?? channel;
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<Sheet onOpenChange={handleSheetOpenChange} open={open}>
<SheetContent
className="flex w-full flex-col gap-0 overflow-hidden border-l border-border/80 bg-background p-0 sm:max-w-xl"
data-testid="channel-management-sheet"
@@ -643,32 +680,64 @@ export function ChannelManagementSheet({
description="Deleting removes the channel from the workspace list."
title="Danger zone"
>
<Button
data-testid="channel-management-delete"
disabled={deleteChannelMutation.isPending}
onClick={() => {
if (!window.confirm(`Delete ${resolvedChannel.name}?`)) {
return;
}
void deleteChannelMutation.mutateAsync().then(() => {
onOpenChange(false);
onDeleted?.();
});
}}
size="sm"
type="button"
variant="destructive"
<AlertDialog
onOpenChange={handleDeleteDialogOpenChange}
open={isDeleteDialogOpen}
>
{deleteChannelMutation.isPending
? "Deleting..."
: "Delete channel"}
</Button>
{deleteChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{deleteChannelMutation.error.message}
</p>
) : null}
<AlertDialogTrigger asChild>
<Button
data-testid="channel-management-delete"
disabled={deleteChannelMutation.isPending}
size="sm"
type="button"
variant="destructive"
>
Delete channel
</Button>
</AlertDialogTrigger>
<AlertDialogContent data-testid="channel-delete-confirmation-dialog">
<AlertDialogHeader>
<AlertDialogTitle>Delete channel?</AlertDialogTitle>
<AlertDialogDescription>
Delete {resolvedChannel.name} from the workspace list.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
{deleteChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{deleteChannelMutation.error.message}
</p>
) : null}
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button
data-testid="channel-delete-cancel"
disabled={deleteChannelMutation.isPending}
type="button"
variant="outline"
>
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
data-testid="channel-delete-confirm"
disabled={deleteChannelMutation.isPending}
onClick={(event) => {
event.preventDefault();
void handleDeleteChannel();
}}
type="button"
variant="destructive"
>
{deleteChannelMutation.isPending
? "Deleting..."
: "Delete channel"}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Section>
</>
) : null}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/shared/lib/cn";
import { buttonVariants } from "@/shared/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
ref={ref}
{...props}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<div className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4 pointer-events-none">
<AlertDialogPrimitive.Content
className={cn(
"pointer-events-auto grid w-[calc(100vw-2rem)] max-w-md gap-4 rounded-3xl border border-border bg-background p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className,
)}
ref={ref}
{...props}
/>
</div>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col space-y-2 text-left", className)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
className={cn("text-lg font-semibold tracking-tight", className)}
ref={ref}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
className={cn("text-sm text-muted-foreground", className)}
ref={ref}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
ref={ref}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
ref={ref}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};
+28 -2
View File
@@ -401,12 +401,38 @@ test("manage channel can delete an owned stream", async ({ page }) => {
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
page.once("dialog", (dialog) => dialog.accept());
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await page.getByTestId("channel-management-delete").click();
await expect(
page.getByTestId("channel-delete-confirmation-dialog"),
).toBeVisible();
await page.getByTestId("channel-delete-confirm").click();
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
});
test("canceling channel deletion keeps the owned stream", async ({ page }) => {
const channelName = `keep-me-${Date.now()}`;
await page.goto("/");
await page.getByRole("button", { name: "Create a stream" }).click();
await page.getByTestId("create-stream-name").fill(channelName);
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await page.getByTestId("channel-management-delete").click();
await expect(
page.getByTestId("channel-delete-confirmation-dialog"),
).toBeVisible();
await page.getByTestId("channel-delete-cancel").click();
await expect(
page.getByTestId("channel-delete-confirmation-dialog"),
).not.toBeVisible();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await expect(page.getByTestId("stream-list")).toContainText(channelName);
});