Refine channel lifecycle settings (#2427)

This commit is contained in:
klopez4212
2026-07-23 13:50:29 -06:00
committed by GitHub
parent 80244f8231
commit daeaf7c33d
14 changed files with 876 additions and 507 deletions
+5 -8
View File
@@ -348,13 +348,10 @@ export function useUpdateChannelMutation(channelId: string | null) {
return updateChannel({ ...input, channelId });
},
onMutate: () => ({ channelId }),
onSuccess: (updatedChannel) => {
if (!channelId) {
return;
}
queryClient.setQueryData<ChannelDetail>(
channelDetailQueryKey(channelId),
channelDetailQueryKey(updatedChannel.id),
updatedChannel,
);
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current = []) =>
@@ -365,7 +362,7 @@ export function useUpdateChannelMutation(channelId: string | null) {
),
);
},
onSettled: () => {
onSettled: (_data, _error, _variables, context) => {
// refetchType "none": onSuccess already cached the relay-returned detail;
// awaiting the full channel-list refetch kept the edit dialog stuck on
// "Saving..." (same failure #1360 fixed for create).
@@ -373,9 +370,9 @@ export function useUpdateChannelMutation(channelId: string | null) {
queryKey: channelsQueryKey,
refetchType: "none",
});
if (channelId) {
if (context?.channelId) {
void queryClient.invalidateQueries({
queryKey: channelDetailQueryKey(channelId),
queryKey: channelDetailQueryKey(context.channelId),
refetchType: "none",
});
}
@@ -27,8 +27,6 @@ import {
useDeleteChannelMutation,
useJoinChannelMutation,
useLeaveChannelMutation,
useSetChannelPurposeMutation,
useSetChannelTopicMutation,
useUnarchiveChannelMutation,
useUpdateChannelMutation,
} from "@/features/channels/hooks";
@@ -36,7 +34,6 @@ import { compareMembersByRole } from "@/features/channels/lib/memberUtils";
import {
DEFAULT_EPHEMERAL_TTL_SECONDS,
formatTtlDuration,
parseTtlDuration,
} from "@/features/channels/lib/ephemeralChannel";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
@@ -45,7 +42,6 @@ import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
@@ -68,6 +64,12 @@ import {
PANEL_OVERLAY_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
import { ChannelCanvas } from "./ChannelCanvas";
import {
CHANNEL_FORM_FIELD_CONTROL_CLASS,
CHANNEL_FORM_FIELD_SHELL_CLASS,
} from "./channelFormStyles";
import { ChannelTypeSettings } from "./ChannelTypeSettings";
import { ChannelPermissionsSettings } from "./ChannelPermissionsSettings";
import {
ChannelHero,
ChannelQuickAction,
@@ -78,7 +80,6 @@ import {
IngressRow,
NarrativeField,
NarrativeGroup,
ToggleRow,
} from "./ChannelManagementSheetRows";
import {
ChannelManagementModerationActions,
@@ -118,13 +119,13 @@ export function ChannelManagementSheet({
const membersQuery = useChannelMembersQuery(channelId, open);
const canvasQuery = useCanvasQuery(channelId, channelId !== null && open);
const updateChannelDetailsMutation = useUpdateChannelMutation(channelId);
const setTopicMutation = useSetChannelTopicMutation(channelId);
const setPurposeMutation = useSetChannelPurposeMutation(channelId);
const archiveChannelMutation = useArchiveChannelMutation(channelId);
const unarchiveChannelMutation = useUnarchiveChannelMutation(channelId);
const deleteChannelMutation = useDeleteChannelMutation(channelId);
const joinChannelMutation = useJoinChannelMutation(channelId);
const leaveChannelMutation = useLeaveChannelMutation(channelId);
const channelIdRef = React.useRef(channelId);
channelIdRef.current = channelId;
const detail = detailsQuery.data ?? channel;
const members = React.useMemo(() => {
@@ -159,13 +160,17 @@ export function ChannelManagementSheet({
const [nameDraft, setNameDraft] = React.useState("");
const [descriptionDraft, setDescriptionDraft] = React.useState("");
const [topicDraft, setTopicDraft] = React.useState("");
const [purposeDraft, setPurposeDraft] = React.useState("");
const [isPrivateDraft, setIsPrivateDraft] = React.useState(false);
const [isEphemeralDraft, setIsEphemeralDraft] = React.useState(false);
const [ttlDraft, setTtlDraft] = React.useState("");
const [ttlSecondsDraft, setTtlSecondsDraft] = React.useState(
DEFAULT_EPHEMERAL_TTL_SECONDS,
);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false);
const [isConvertingVisibility, setIsConvertingVisibility] =
React.useState(false);
const [hasUserEditedChannelDraft, setHasUserEditedChannelDraft] =
React.useState(false);
const [activeView, setActiveView] = React.useState<"summary" | "canvas">(
"summary",
);
@@ -194,13 +199,10 @@ export function ChannelManagementSheet({
setNameDraft(detail.name);
setDescriptionDraft(detail.description);
setTopicDraft(detail.topic ?? "");
setPurposeDraft(detail.purpose ?? "");
setIsPrivateDraft(detail.visibility === "private");
setIsEphemeralDraft(detail.ttlSeconds !== null);
setTtlDraft(
detail.ttlSeconds !== null ? formatTtlDuration(detail.ttlSeconds) : "",
);
setTtlSecondsDraft(detail.ttlSeconds ?? DEFAULT_EPHEMERAL_TTL_SECONDS);
setHasUserEditedChannelDraft(false);
setActiveView("summary");
}, [detail, open]);
@@ -232,19 +234,13 @@ export function ChannelManagementSheet({
onOpenChange(next);
}
// Parsed seconds for the ephemeral TTL field. `null` when the field is empty
// or malformed; the form blocks saving on a non-empty malformed value.
const parsedTtlSeconds = parseTtlDuration(ttlDraft);
const ttlInvalid =
isEphemeralDraft && ttlDraft.trim() !== "" && parsedTtlSeconds === null;
const currentVisibility = detail?.visibility ?? channel.visibility;
const currentTtlSeconds = detail?.ttlSeconds ?? null;
const nextVisibility: "open" | "private" = isPrivateDraft
? "private"
: "open";
const nextTtlSeconds: number | null = isEphemeralDraft
? (parsedTtlSeconds ?? DEFAULT_EPHEMERAL_TTL_SECONDS)
? ttlSecondsDraft
: null;
const lifecycleDirty =
nextVisibility !== currentVisibility ||
@@ -254,22 +250,11 @@ export function ChannelManagementSheet({
const nameDirty = nameDraft.trim() !== resolvedChannel.name.trim();
const descriptionDirty =
descriptionDraft.trim() !== resolvedChannel.description.trim();
const topicDirty = topicDraft.trim() !== (resolvedChannel.topic ?? "").trim();
const purposeDirty =
purposeDraft.trim() !== (resolvedChannel.purpose ?? "").trim();
const isSavingChannelEdits =
updateChannelDetailsMutation.isPending ||
setTopicMutation.isPending ||
setPurposeMutation.isPending;
const hasChannelEditChanges =
nameDirty ||
descriptionDirty ||
lifecycleDirty ||
topicDirty ||
purposeDirty;
const isSavingChannelEdits = updateChannelDetailsMutation.isPending;
const hasChannelEditChanges = nameDirty || descriptionDirty || lifecycleDirty;
const canSaveChannelEdits =
nameDraft.trim().length > 0 &&
!ttlInvalid &&
hasUserEditedChannelDraft &&
hasChannelEditChanges &&
!isSavingChannelEdits;
const canvasContent = canvasQuery.data?.content?.trim() ?? "";
@@ -279,6 +264,18 @@ export function ChannelManagementSheet({
: undefined;
const canOpenCanvas = hasCanvas || canEditNarrative;
function handleEditDialogOpenChange(next: boolean) {
if (!next) {
setNameDraft(resolvedChannel.name);
setDescriptionDraft(resolvedChannel.description);
setIsEphemeralDraft(currentTtlSeconds !== null);
setTtlSecondsDraft(currentTtlSeconds ?? DEFAULT_EPHEMERAL_TTL_SECONDS);
setHasUserEditedChannelDraft(false);
}
setIsEditDialogOpen(next);
}
async function handleSaveChannelEdits() {
try {
if (nameDirty || descriptionDirty || lifecycleDirty) {
@@ -294,20 +291,32 @@ export function ChannelManagementSheet({
});
}
if (topicDirty) {
await setTopicMutation.mutateAsync({ topic: topicDraft.trim() });
}
if (purposeDirty) {
await setPurposeMutation.mutateAsync({ purpose: purposeDraft.trim() });
}
setHasUserEditedChannelDraft(false);
setIsEditDialogOpen(false);
} catch {
// React Query stores mutation errors; keep the dialog open and render them.
}
}
async function handleConvertVisibility(visibility: "open" | "private") {
if (visibility === currentVisibility) {
return;
}
setIsConvertingVisibility(true);
try {
const updatedChannel = await updateChannelDetailsMutation.mutateAsync({
visibility,
});
if (channelIdRef.current === updatedChannel.id) {
setIsPrivateDraft(visibility === "private");
}
} catch {
// React Query stores mutation errors; keep the dialog open and render them.
} finally {
setIsConvertingVisibility(false);
}
}
return (
<DialogPrimitive.Root
modal={!isSplitLayout}
@@ -421,153 +430,108 @@ export function ChannelManagementSheet({
)}
{canManageChannel ? (
<Dialog onOpenChange={setIsEditDialogOpen} open={isEditDialogOpen}>
<DialogContent className="max-w-lg overflow-hidden p-0">
<Dialog
onOpenChange={handleEditDialogOpenChange}
open={isEditDialogOpen}
>
<DialogContent
aria-describedby={undefined}
className="max-w-lg overflow-hidden p-0"
>
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Edit channel</DialogTitle>
<DialogDescription>
Update settings for{" "}
<span className="font-medium">{resolvedChannel.name}</span>.
</DialogDescription>
<DialogTitle>
Edit {currentVisibility === "private" ? "private" : "public"}{" "}
channel
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5">
<div className="space-y-3">
<div className="space-y-5">
<div className="space-y-1.5">
<label
className="text-sm font-medium"
className="text-sm font-medium text-foreground"
htmlFor="channel-name"
>
Name
</label>
<Input
data-testid="channel-management-name"
disabled={isSavingChannelEdits}
id="channel-name"
onChange={(event) => setNameDraft(event.target.value)}
value={nameDraft}
/>
<div
className={cn(
"flex min-h-11 items-center px-3",
CHANNEL_FORM_FIELD_SHELL_CLASS,
)}
>
<Input
className={cn(
"h-8 px-0 py-0 leading-6",
CHANNEL_FORM_FIELD_CONTROL_CLASS,
)}
data-testid="channel-management-name"
disabled={isSavingChannelEdits}
id="channel-name"
onChange={(event) => {
setNameDraft(event.target.value);
setHasUserEditedChannelDraft(true);
}}
value={nameDraft}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
className="text-sm font-medium text-foreground"
htmlFor="channel-description"
>
Description
</label>
<Textarea
className="min-h-24"
data-testid="channel-management-description"
disabled={isSavingChannelEdits}
id="channel-description"
onChange={(event) =>
setDescriptionDraft(event.target.value)
}
value={descriptionDraft}
/>
<div className={CHANNEL_FORM_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-20 resize-none px-3 py-3 leading-5",
CHANNEL_FORM_FIELD_CONTROL_CLASS,
)}
data-testid="channel-management-description"
disabled={isSavingChannelEdits}
id="channel-description"
onChange={(event) => {
setDescriptionDraft(event.target.value);
setHasUserEditedChannelDraft(true);
}}
rows={2}
value={descriptionDraft}
/>
</div>
</div>
</div>
{resolvedChannel.channelType !== "dm" ? (
<div
className="space-y-3"
className="space-y-5"
data-testid="channel-management-lifecycle"
>
<FieldGroup>
<ToggleRow
checked={isPrivateDraft}
description="Only members can find and join this channel."
disabled={isSavingChannelEdits}
label="Private"
onCheckedChange={setIsPrivateDraft}
testId="channel-management-private-toggle"
/>
<ToggleRow
checked={isEphemeralDraft}
description="Automatically delete this channel after a set time."
disabled={isSavingChannelEdits}
label="Ephemeral"
onCheckedChange={setIsEphemeralDraft}
testId="channel-management-ephemeral-toggle"
/>
</FieldGroup>
{isEphemeralDraft ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="channel-ttl"
>
Timeout
</label>
<Input
aria-invalid={ttlInvalid}
data-testid="channel-management-ttl"
disabled={isSavingChannelEdits}
id="channel-ttl"
onChange={(event) => setTtlDraft(event.target.value)}
placeholder="e.g. 1d, 12h, 30m"
value={ttlDraft}
/>
<p
className={cn(
"text-xs",
ttlInvalid
? "text-destructive"
: "text-muted-foreground",
)}
>
{ttlInvalid
? "Enter a duration like 1d, 12h, or 30m."
: "Defaults to 7d when left empty. Resets the deletion countdown from now whenever changed."}
</p>
</div>
) : null}
</div>
) : null}
{canEditNarrative ? (
<div className="space-y-5">
<div className="space-y-3">
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="channel-topic"
>
Topic
</label>
<Input
data-testid="channel-management-topic"
disabled={isSavingChannelEdits}
id="channel-topic"
onChange={(event) =>
setTopicDraft(event.target.value)
}
value={topicDraft}
/>
</div>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="channel-purpose"
>
Purpose
</label>
<Input
data-testid="channel-management-purpose"
disabled={isSavingChannelEdits}
id="channel-purpose"
onChange={(event) =>
setPurposeDraft(event.target.value)
}
value={purposeDraft}
/>
</div>
</div>
<ChannelTypeSettings
disabled={isSavingChannelEdits}
onTemporaryChange={(temporary) => {
setIsEphemeralDraft(temporary);
setHasUserEditedChannelDraft(true);
}}
onTtlSecondsChange={(ttlSeconds) => {
setTtlSecondsDraft(ttlSeconds);
setHasUserEditedChannelDraft(true);
}}
temporary={isEphemeralDraft}
testIdPrefix="channel-management"
ttlSeconds={ttlSecondsDraft}
/>
<ChannelPermissionsSettings
disabled={isSavingChannelEdits}
isPending={isConvertingVisibility}
onVisibilityChange={(visibility) =>
void handleConvertVisibility(visibility)
}
testIdPrefix="channel-management"
visibility={isPrivateDraft ? "private" : "open"}
/>
</div>
) : null}
@@ -576,21 +540,11 @@ export function ChannelManagementSheet({
{updateChannelDetailsMutation.error.message}
</p>
) : null}
{setTopicMutation.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{setTopicMutation.error.message}
</p>
) : null}
{setPurposeMutation.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{setPurposeMutation.error.message}
</p>
) : null}
</div>
<div className="flex shrink-0 justify-end gap-2 border-t border-border/60 px-6 py-4">
<Button
onClick={() => setIsEditDialogOpen(false)}
onClick={() => handleEditDialogOpenChange(false)}
size="sm"
type="button"
variant="outline"
@@ -11,7 +11,6 @@ import { toast } from "sonner";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Switch } from "@/shared/ui/switch";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
function getChannelIcon(channelType: Channel["channelType"]): LucideIcon {
@@ -268,38 +267,3 @@ export function IngressRow({
</button>
);
}
export function ToggleRow({
checked,
description,
disabled,
label,
onCheckedChange,
testId,
}: {
checked: boolean;
description: string;
disabled?: boolean;
label: string;
onCheckedChange: (checked: boolean) => void;
testId: string;
}) {
return (
<div className="flex items-center gap-3 px-4 py-3">
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
{label}
</span>
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
{description}
</span>
</span>
<Switch
checked={checked}
data-testid={testId}
disabled={disabled}
onCheckedChange={onCheckedChange}
/>
</div>
);
}
@@ -202,7 +202,7 @@ export function ChannelMembersBar({
type="button"
variant="outline"
>
<Settings2 />
<EllipsisVertical />
</Button>
</TooltipTrigger>
<TooltipContent>Channel settings</TooltipContent>
@@ -0,0 +1,98 @@
import { ChevronDown, LoaderCircle } from "lucide-react";
import type { ChannelVisibility } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { cn } from "@/shared/lib/cn";
export function ChannelPermissionsSettings({
disabled,
isPending = false,
onVisibilityChange,
testIdPrefix,
visibility,
}: {
disabled?: boolean;
isPending?: boolean;
onVisibilityChange: (visibility: ChannelVisibility) => void;
testIdPrefix: string;
visibility: ChannelVisibility;
}) {
const visibilityLabel = visibility === "private" ? "Private" : "Public";
return (
<div
className={cn(
"flex min-h-12 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3 py-3",
disabled && "opacity-50",
)}
data-testid={`${testIdPrefix}-permissions-container`}
>
<span className="text-sm font-medium text-foreground">Visibility</span>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-busy={isPending}
aria-label={
isPending
? "Updating visibility"
: `Visibility: ${visibilityLabel}`
}
className="-mr-2.5 ml-auto h-9 w-fit justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
data-testid={`${testIdPrefix}-permissions`}
disabled={disabled}
type="button"
variant="ghost"
>
<span aria-live="polite" className="text-right">
{isPending ? "Updating…" : visibilityLabel}
</span>
{isPending ? (
<LoaderCircle
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground/70 motion-safe:animate-spin"
/>
) : (
<ChevronDown className="size-4 shrink-0 text-muted-foreground/70" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
style={{
minWidth: "var(--radix-dropdown-menu-trigger-width)",
}}
>
<DropdownMenuRadioGroup
onValueChange={(nextVisibility) =>
onVisibilityChange(
nextVisibility === "private" ? "private" : "open",
)
}
value={visibility}
>
<DropdownMenuRadioItem
data-testid={`${testIdPrefix}-permissions-option-open`}
value="open"
>
Public
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
data-testid={`${testIdPrefix}-permissions-option-private`}
value="private"
>
Private
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,91 @@
import { ChevronDown, ClockFading, Hash } from "lucide-react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
export function ChannelTypePicker({
align = "start",
ariaLabel,
className,
disabled,
onOpenChange,
onTemporaryChange,
open,
temporary,
temporaryOptionAriaLabel = "Temporary channel",
testId,
}: {
align?: React.ComponentProps<typeof DropdownMenuContent>["align"];
ariaLabel?: string;
className?: string;
disabled?: boolean;
onOpenChange?: (open: boolean) => void;
onTemporaryChange: (temporary: boolean) => void;
open?: boolean;
temporary: boolean;
temporaryOptionAriaLabel?: string;
testId?: string;
}) {
const [internalOpen, setInternalOpen] = React.useState(false);
const pickerOpen = open ?? internalOpen;
const setPickerOpen = onOpenChange ?? setInternalOpen;
const label = temporary ? "Temporary" : "Ongoing";
const Icon = temporary ? ClockFading : Hash;
function selectType(nextType: string) {
onTemporaryChange(nextType === "temporary");
setPickerOpen(false);
}
return (
<DropdownMenu modal={false} onOpenChange={setPickerOpen} open={pickerOpen}>
<DropdownMenuTrigger asChild>
<Button
aria-label={ariaLabel ?? `Channel type: ${label}`}
className={cn(
"h-9 w-fit px-2.5 text-sm font-medium text-foreground hover:bg-muted/50",
className,
)}
data-testid={testId}
disabled={disabled}
type="button"
variant="ghost"
>
<Icon className="h-4 w-4" />
{label}
<ChevronDown className="h-4 w-4 text-muted-foreground/70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
onCloseAutoFocus={(event) => event.preventDefault()}
style={{
minWidth: "var(--radix-dropdown-menu-trigger-width)",
}}
>
<DropdownMenuRadioGroup
onValueChange={selectType}
value={temporary ? "temporary" : "ongoing"}
>
<DropdownMenuRadioItem aria-label="Ongoing channel" value="ongoing">
Ongoing
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
aria-label={temporaryOptionAriaLabel}
value="temporary"
>
Temporary
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,161 @@
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
DEFAULT_EPHEMERAL_TTL_SECONDS,
formatTtlDuration,
} from "@/features/channels/lib/ephemeralChannel";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { ChannelTypePicker } from "./ChannelTypePicker";
const EPHEMERAL_TIMEOUT_OPTIONS = [
{ label: "30 minutes", seconds: 30 * 60 },
{ label: "1 hour", seconds: 60 * 60 },
{ label: "6 hours", seconds: 6 * 60 * 60 },
{ label: "12 hours", seconds: 12 * 60 * 60 },
{ label: "1 day", seconds: 24 * 60 * 60 },
{ label: "3 days", seconds: 3 * 24 * 60 * 60 },
{ label: "7 days", seconds: DEFAULT_EPHEMERAL_TTL_SECONDS },
{ label: "14 days", seconds: 14 * 24 * 60 * 60 },
{ label: "30 days", seconds: 30 * 24 * 60 * 60 },
] as const;
const CHANNEL_TYPE_RESIZE_TRANSITION = {
duration: 0.22,
ease: [0.23, 1, 0.32, 1],
} as const;
export function ChannelTypeSettings({
disabled,
onOpenChange,
onTemporaryChange,
onTtlSecondsChange,
open,
temporary,
testIdPrefix,
ttlSeconds,
}: {
disabled?: boolean;
onOpenChange?: (open: boolean) => void;
onTemporaryChange: (temporary: boolean) => void;
onTtlSecondsChange: (ttlSeconds: number) => void;
open?: boolean;
temporary: boolean;
testIdPrefix: string;
ttlSeconds: number;
}) {
const shouldReduceMotion = useReducedMotion();
const channelTypeResizeTransition = shouldReduceMotion
? { duration: 0 }
: CHANNEL_TYPE_RESIZE_TRANSITION;
const selectedTimeoutOption = EPHEMERAL_TIMEOUT_OPTIONS.find(
(option) => option.seconds === ttlSeconds,
);
const timeoutOptions = selectedTimeoutOption
? EPHEMERAL_TIMEOUT_OPTIONS
: [
{
label: `Current (${formatTtlDuration(ttlSeconds)})`,
seconds: ttlSeconds,
},
...EPHEMERAL_TIMEOUT_OPTIONS,
];
return (
<div
className="overflow-hidden rounded-xl border border-input bg-background"
data-testid={`${testIdPrefix}-channel-type-container`}
>
<div
className="flex items-center justify-between gap-3 px-3 py-3"
data-testid={`${testIdPrefix}-channel-type-row`}
>
<span className="text-sm font-medium text-foreground">
Channel type
</span>
<ChannelTypePicker
align="end"
className="-mr-2.5"
disabled={disabled}
onOpenChange={onOpenChange}
onTemporaryChange={onTemporaryChange}
open={open}
temporary={temporary}
testId={`${testIdPrefix}-channel-type`}
/>
</div>
<AnimatePresence initial={false}>
{temporary ? (
<motion.div
animate={{ height: "auto", opacity: 1 }}
className="overflow-hidden"
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
key={`${testIdPrefix}-ephemeral-settings`}
transition={channelTypeResizeTransition}
>
<div
className="relative flex items-center justify-between gap-3 px-3 py-3 before:absolute before:inset-x-3 before:top-0 before:border-t before:border-border/70"
data-testid={`${testIdPrefix}-ephemeral-settings`}
>
<label
className="text-sm font-medium"
htmlFor={`${testIdPrefix}-ttl`}
>
Expires after
</label>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Expires after"
className="-mr-2.5 ml-auto h-9 w-fit justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
data-testid={`${testIdPrefix}-ttl`}
disabled={disabled}
id={`${testIdPrefix}-ttl`}
type="button"
variant="ghost"
>
<span className="text-right">
{selectedTimeoutOption?.label ??
`Current (${formatTtlDuration(ttlSeconds)})`}
</span>
<ChevronDown className="size-4 shrink-0 text-muted-foreground/70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
style={{
minWidth: "var(--radix-dropdown-menu-trigger-width)",
}}
>
<DropdownMenuRadioGroup
onValueChange={(value) => onTtlSecondsChange(Number(value))}
value={String(ttlSeconds)}
>
{timeoutOptions.map((option) => (
<DropdownMenuRadioItem
data-testid={`${testIdPrefix}-ttl-option-${option.seconds}`}
key={option.seconds}
value={String(option.seconds)}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,5 @@
export const CHANNEL_FORM_FIELD_SHELL_CLASS =
"rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50";
export const CHANNEL_FORM_FIELD_CONTROL_CLASS =
"border-0 bg-transparent text-muted-foreground/55 shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus:outline-hidden focus-visible:ring-0";
@@ -1,4 +1,3 @@
import { ClockFading, Hash, type LucideIcon } from "lucide-react";
import * as React from "react";
import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks";
@@ -40,8 +39,8 @@ export type CreateChannelFormState = {
setVisibility: (value: ChannelVisibility) => void;
ephemeral: boolean;
setEphemeral: (value: boolean) => void;
durationLabel: string;
DurationIcon: LucideIcon;
ttlSeconds: number;
setTtlSeconds: (value: number) => void;
typePopoverOpen: boolean;
setTypePopoverOpen: (open: boolean) => void;
errorMessage: string | null;
@@ -72,6 +71,9 @@ export function useCreateChannelForm({
const [description, setDescription] = React.useState("");
const [visibility, setVisibility] = React.useState<ChannelVisibility>("open");
const [ephemeral, setEphemeral] = React.useState(false);
const [ttlSeconds, setTtlSeconds] = React.useState(
DEFAULT_EPHEMERAL_TTL_SECONDS,
);
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [selectedTemplateId, setSelectedTemplateId] = React.useState<
string | null
@@ -84,9 +86,6 @@ export function useCreateChannelForm({
const templates = templatesQuery.data ?? [];
const kindLabel = channelKind === "forum" ? "forum" : "channel";
const durationLabel = ephemeral ? "Temporary" : "Ongoing";
const DurationIcon = ephemeral ? ClockFading : Hash;
React.useEffect(() => {
if (!active) return;
@@ -94,6 +93,7 @@ export function useCreateChannelForm({
setDescription("");
setVisibility("open");
setEphemeral(false);
setTtlSeconds(DEFAULT_EPHEMERAL_TTL_SECONDS);
setErrorMessage(null);
setSelectedTemplateId(null);
setTypePopoverOpen(false);
@@ -158,7 +158,7 @@ export function useCreateChannelForm({
name: trimmedName,
description: description.trim() || undefined,
visibility,
ttlSeconds: ephemeral ? DEFAULT_EPHEMERAL_TTL_SECONDS : undefined,
ttlSeconds: ephemeral ? ttlSeconds : undefined,
templateId: selectedTemplateId ?? undefined,
});
onCreated?.();
@@ -179,6 +179,7 @@ export function useCreateChannelForm({
onCreate,
onCreated,
selectedTemplateId,
ttlSeconds,
visibility,
],
);
@@ -203,8 +204,8 @@ export function useCreateChannelForm({
},
ephemeral,
setEphemeral,
durationLabel,
DurationIcon,
ttlSeconds,
setTtlSeconds,
typePopoverOpen,
setTypePopoverOpen,
errorMessage,
@@ -1,26 +1,24 @@
import { ChevronDown, ClockFading, Hash } from "lucide-react";
import type { ChannelTemplate } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Switch } from "@/shared/ui/switch";
import { Textarea } from "@/shared/ui/textarea";
import {
CHANNEL_FORM_FIELD_CONTROL_CLASS,
CHANNEL_FORM_FIELD_SHELL_CLASS,
} from "@/features/channels/ui/channelFormStyles";
import { ChannelPermissionsSettings } from "@/features/channels/ui/ChannelPermissionsSettings";
import { ChannelTypeSettings } from "@/features/channels/ui/ChannelTypeSettings";
import type { CreateChannelFormState } from "@/features/sidebar/lib/useCreateChannelForm";
const CREATE_FIELD_SHELL_CLASS =
"rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50";
const CREATE_FIELD_CONTROL_CLASS =
"border-0 bg-transparent text-muted-foreground/55 shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus:outline-hidden focus-visible:ring-0";
const CREATE_LABEL_OPTIONAL_CLASS =
"ml-1 text-xs font-normal text-muted-foreground/50";
export const CREATE_CHANNEL_FORM_ID = "create-channel-form";
/**
* The body of the create-channel form (name, description, private toggle,
* The body of the create-channel form (name, description, visibility,
* optional template). Rendered inside both the standalone dialog and the
* "Add channel" browser's create mode. Wrap in a `<form>` with
* `id={CREATE_CHANNEL_FORM_ID}` and hook up `form.handleSubmit`.
@@ -44,7 +42,7 @@ export function CreateChannelFormFields({
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
CHANNEL_FORM_FIELD_SHELL_CLASS,
)}
>
<Input
@@ -53,7 +51,7 @@ export function CreateChannelFormFields({
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
CREATE_FIELD_CONTROL_CLASS,
CHANNEL_FORM_FIELD_CONTROL_CLASS,
)}
data-testid="create-channel-name"
disabled={isCreating}
@@ -77,11 +75,11 @@ export function CreateChannelFormFields({
Description
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div className={CREATE_FIELD_SHELL_CLASS}>
<div className={CHANNEL_FORM_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-20 resize-none px-3 py-3 leading-5",
CREATE_FIELD_CONTROL_CLASS,
CHANNEL_FORM_FIELD_CONTROL_CLASS,
)}
data-testid="create-channel-description"
disabled={isCreating}
@@ -94,39 +92,16 @@ export function CreateChannelFormFields({
</div>
</div>
<div
className={cn(
"flex min-h-12 items-center justify-between gap-4 rounded-xl py-1",
isCreating && "opacity-50",
)}
data-testid="create-channel-visibility"
>
<label
className="min-w-0 cursor-pointer space-y-0.5"
htmlFor="create-channel-private"
>
<span className="block text-sm font-medium text-foreground">
Private
</span>
<span
className="block text-xs leading-4 text-muted-foreground/65"
id="create-channel-private-description"
>
Only members can invite people to this {kindLabel}.
</span>
</label>
<Switch
aria-describedby="create-channel-private-description"
checked={form.visibility === "private"}
className="shrink-0 shadow-none [&>span]:shadow-none"
data-testid="create-channel-private-toggle"
disabled={isCreating}
id="create-channel-private"
onCheckedChange={(checked) =>
form.setVisibility(checked ? "private" : "open")
}
/>
</div>
<ChannelTypeSettings
disabled={isCreating}
onOpenChange={form.setTypePopoverOpen}
onTemporaryChange={form.setEphemeral}
onTtlSecondsChange={form.setTtlSeconds}
open={form.typePopoverOpen}
temporary={form.ephemeral}
testIdPrefix="create-channel"
ttlSeconds={form.ttlSeconds}
/>
{form.templates.length > 0 ? (
<div className="space-y-1.5">
@@ -155,6 +130,13 @@ export function CreateChannelFormFields({
</div>
) : null}
<ChannelPermissionsSettings
disabled={isCreating}
onVisibilityChange={form.setVisibility}
testIdPrefix="create-channel"
visibility={form.visibility}
/>
{form.errorMessage ? (
<p className="text-sm text-destructive">{form.errorMessage}</p>
) : null}
@@ -163,9 +145,8 @@ export function CreateChannelFormFields({
}
/**
* Footer for the create-channel form: the Ongoing/Temporary duration picker on
* the left and the submit button on the right. The submit button is bound to
* the form via `form={CREATE_CHANNEL_FORM_ID}`.
* Footer for the create-channel form. The submit button is bound to the form
* via `form={CREATE_CHANNEL_FORM_ID}`.
*/
export function CreateChannelFormFooter({
form,
@@ -174,58 +155,10 @@ export function CreateChannelFormFooter({
form: CreateChannelFormState;
submitLabel?: string;
}) {
const { DurationIcon, durationLabel, isCreating, kindLabel } = form;
const { isCreating, kindLabel } = form;
return (
<div className="flex w-full items-center justify-between gap-3">
<Popover
onOpenChange={form.setTypePopoverOpen}
open={form.typePopoverOpen}
>
<PopoverTrigger asChild>
<Button
aria-label={`Channel duration: ${durationLabel}`}
className="-ml-2.5 h-9 px-2.5 text-sm font-medium text-foreground hover:bg-muted/50"
disabled={isCreating}
type="button"
variant="ghost"
>
<DurationIcon className="h-4 w-4" />
{durationLabel}
<ChevronDown className="h-4 w-4 text-muted-foreground/70" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-1">
<div className="px-3 pb-1.5 pt-2 text-xs font-medium text-muted-foreground/70">
Channel type
</div>
<fieldset className="space-y-1">
<legend className="sr-only">Channel type</legend>
<ChannelDurationOption
ariaLabel="Ongoing channel"
checked={!form.ephemeral}
description="For projects, teams, and recurring conversations."
icon={Hash}
label="Ongoing"
onSelect={() => {
form.setEphemeral(false);
form.setTypePopoverOpen(false);
}}
/>
<ChannelDurationOption
ariaLabel="Ephemeral - auto-archives after 7 days of inactivity"
checked={form.ephemeral}
description="For quick discussions that archive automatically when inactive."
icon={ClockFading}
label="Temporary"
onSelect={() => {
form.setEphemeral(true);
form.setTypePopoverOpen(false);
}}
/>
</fieldset>
</PopoverContent>
</Popover>
<div className="flex w-full items-center justify-end gap-3">
<Button
data-testid="create-channel-submit"
disabled={!form.canSubmit}
@@ -237,65 +170,3 @@ export function CreateChannelFormFooter({
</div>
);
}
function ChannelDurationOption({
ariaLabel,
checked,
description,
icon: Icon,
label,
onSelect,
}: {
ariaLabel: string;
checked: boolean;
description: string;
icon: typeof Hash;
label: string;
onSelect: () => void;
}) {
return (
<label
className={cn(
"relative flex min-h-16 cursor-pointer items-start gap-3 rounded-lg px-3 py-2.5 text-left text-muted-foreground/75 transition-colors duration-150 ease-out hover:bg-muted/50 hover:text-foreground has-[:focus-visible]:outline-hidden has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-ring",
checked && "text-foreground",
)}
>
<input
aria-label={ariaLabel}
checked={checked}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
name="create-channel-duration"
onChange={onSelect}
type="radio"
/>
<span
className={cn(
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-muted-foreground/40",
checked && "border-foreground",
)}
aria-hidden="true"
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full bg-foreground transition-opacity duration-150",
checked ? "opacity-100" : "opacity-0",
)}
/>
</span>
<span className="grid min-w-0 flex-1 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 gap-y-1">
<Icon className="h-4 w-4 shrink-0 text-current" />
<span className="block text-sm font-medium leading-4 text-current">
{label}
</span>
<span
className={cn(
"col-span-2 block text-xs leading-4 text-muted-foreground/70",
checked && "text-muted-foreground/65",
)}
>
{description}
</span>
</span>
</label>
);
}
+228 -53
View File
@@ -15,7 +15,9 @@ async function openManagementSheet(page: import("@playwright/test").Page) {
async function openEditDialog(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByRole("dialog", { name: "Edit channel" }),
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toBeVisible();
}
@@ -25,80 +27,155 @@ async function settle(page: import("@playwright/test").Page) {
);
}
async function selectTemporaryChannelType(
page: import("@playwright/test").Page,
) {
await page.getByTestId("channel-management-channel-type").click();
await page.getByLabel("Temporary channel").click();
}
test.describe("channel controls", () => {
test("01 — lifecycle section: Private + Ephemeral switches", async ({
test("01 — lifecycle section: visibility + channel type", async ({
page,
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
await expect(page.getByText(/Update settings for/)).toHaveCount(0);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await expect(
page.getByTestId("channel-management-private-toggle"),
page.getByTestId("channel-management-permissions-container"),
).toBeVisible();
await expect(
page.getByTestId("channel-management-ephemeral-toggle"),
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Public");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
await expect(
page.getByTestId("channel-management-channel-type"),
).toBeVisible();
await expect(
page.getByTestId("channel-management-channel-type"),
).toContainText("Ongoing");
await expect(
page.getByTestId("channel-management-ephemeral-settings"),
).toHaveCount(0);
await expect(
page
.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
})
.getByTestId("channel-management-topic"),
).toHaveCount(0);
await expect(
page
.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
})
.getByTestId("channel-management-purpose"),
).toHaveCount(0);
await settle(page);
});
test("02 — Private toggled on", async ({ page }) => {
await installMockBridge(page);
test("02 — visibility updates immediately", async ({ page }) => {
await installMockBridge(page, { updateChannelDelayMs: 500 });
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await page.getByTestId("channel-management-private-toggle").click();
const permissions = page.getByTestId("channel-management-permissions");
await permissions.click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(permissions).toHaveAttribute("aria-busy", "true");
await expect(permissions).toContainText("Updating…");
await expect(
page.getByTestId("channel-management-private-toggle"),
).toBeChecked();
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
await expect(permissions).toHaveAccessibleName("Visibility: Private");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await settle(page);
});
).toBeDisabled();
test("03 — Ephemeral on with friendly timeout field", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await page.getByTestId("channel-management-ephemeral-toggle").click();
const ttl = page.getByTestId("channel-management-ttl");
await expect(ttl).toBeVisible();
await ttl.fill("1d12h");
await permissions.click();
await page
.getByTestId("channel-management-permissions-option-open")
.click();
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await settle(page);
});
test("04 — invalid timeout blocks save with inline error", async ({
page,
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await page.getByTestId("channel-management-ephemeral-toggle").click();
const ttl = page.getByTestId("channel-management-ttl");
await ttl.fill("soon");
await expect(ttl).toHaveAttribute("aria-invalid", "true");
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
await expect(permissions).toHaveAccessibleName("Visibility: Public");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
await settle(page);
});
test("03 — Temporary type reveals expiration presets", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await selectTemporaryChannelType(page);
const ephemeralSettings = page.getByTestId(
"channel-management-ephemeral-settings",
);
await expect(ephemeralSettings).toBeVisible();
const ttl = ephemeralSettings.getByTestId("channel-management-ttl");
await expect(ttl).toBeVisible();
await expect(ttl).toHaveAttribute("aria-label", "Expires after");
await expect(ttl).toContainText("7 days");
await ttl.click();
await expect(
page.getByTestId("channel-management-ttl-option-1800"),
).toHaveText("30 minutes");
await expect(
page.getByTestId("channel-management-ttl-option-2592000"),
).toHaveText("30 days");
await page.getByTestId("channel-management-ttl-option-86400").click();
await expect(ttl).toContainText("1 day");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await page.getByTestId("channel-management-channel-type").click();
await page.getByLabel("Ongoing channel").click();
await expect(ephemeralSettings).toHaveCount(0);
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
});
test("04 — changing the timeout preset keeps save enabled", async ({
page,
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await selectTemporaryChannelType(page);
const ttl = page.getByTestId("channel-management-ttl");
await ttl.click();
await page.getByTestId("channel-management-ttl-option-21600").click();
await expect(ttl).toContainText("6 hours");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
});
test("05 — sticky footer pins lifecycle buttons", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
@@ -123,7 +200,7 @@ test.describe("channel controls", () => {
await openManagementSheet(page);
await openEditDialog(page);
await page.getByTestId("channel-management-ephemeral-toggle").click();
await selectTemporaryChannelType(page);
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
@@ -134,7 +211,9 @@ test.describe("channel controls", () => {
).toHaveText("Saving...");
await expect(
page.getByRole("dialog", { name: "Edit channel" }),
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
});
@@ -145,11 +224,19 @@ test.describe("channel controls", () => {
await openManagementSheet(page);
await openEditDialog(page);
await page.getByTestId("channel-management-private-toggle").click();
await page.getByTestId("channel-management-ephemeral-toggle").click();
await page.getByTestId("channel-management-permissions").click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
await selectTemporaryChannelType(page);
await page.getByTestId("channel-management-save-changes").click();
await expect(
page.getByRole("dialog", { name: "Edit channel" }),
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
await page.getByTestId("auxiliary-panel-close").click();
@@ -158,16 +245,104 @@ test.describe("channel controls", () => {
).not.toBeVisible();
await page.getByTestId("channel-management-trigger").click();
await openEditDialog(page);
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await expect(
page.getByTestId("channel-management-private-toggle"),
).toHaveAttribute("data-state", "checked");
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Private");
await expect(
page.getByTestId("channel-management-ephemeral-toggle"),
).toHaveAttribute("data-state", "checked");
await expect(page.getByTestId("channel-management-ttl")).toHaveValue("7d");
page.getByTestId("channel-management-channel-type"),
).toContainText("Temporary");
await expect(page.getByTestId("channel-management-ttl")).toContainText(
"7 days",
);
await settle(page);
});
test("09 — cancel discards unsaved channel drafts", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
await page.getByTestId("channel-management-name").fill("discarded-name");
await page
.getByRole("textbox", { name: "Description" })
.fill("This description should be discarded");
await selectTemporaryChannelType(page);
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
await openEditDialog(page);
await expect(page.getByTestId("channel-management-name")).toHaveValue(
"general",
);
await expect(
page.getByRole("textbox", { name: "Description" }),
).toHaveValue("General discussion for everyone");
await expect(
page.getByTestId("channel-management-channel-type"),
).toContainText("Ongoing");
await expect(
page.getByTestId("channel-management-ephemeral-settings"),
).toHaveCount(0);
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
});
test("10 — stale visibility updates do not affect a new channel", async ({
page,
}) => {
await installMockBridge(page, { updateChannelDelayMs: 1_500 });
await openManagementSheet(page);
await openEditDialog(page);
const permissions = page.getByTestId("channel-management-permissions");
await permissions.click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(permissions).toHaveAttribute("aria-busy", "true");
const agentsChannelId = await page
.getByTestId("channel-agents")
.getAttribute("data-channel-id");
if (!agentsChannelId) {
throw new Error("Expected the agents channel id.");
}
await page.evaluate((channelId) => {
const hash = window.location.hash.replace(/^#/, "") || "/";
const [, query = ""] = hash.split("?");
const nextHash = `#/channels/${channelId}${query ? `?${query}` : ""}`;
window.history.pushState(
{},
"",
`${window.location.pathname}${nextHash}`,
);
window.dispatchEvent(new HashChangeEvent("hashchange"));
window.dispatchEvent(new PopStateEvent("popstate"));
}, agentsChannelId);
await expect(page.getByTestId("chat-title")).toHaveText("agents");
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
await expect(permissions).toHaveAttribute("aria-busy", "false");
await expect(permissions).toHaveAccessibleName("Visibility: Public");
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
});
});
+93 -46
View File
@@ -144,7 +144,9 @@ async function closeChannelManagement(page: import("@playwright/test").Page) {
async function openChannelEditDialog(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByRole("dialog", { name: "Edit channel" }),
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toBeVisible();
}
@@ -1303,10 +1305,37 @@ test("create ephemeral stream shows sidebar and header affordances", async ({
await page
.getByTestId("create-channel-description")
.fill("Auto-cleaned test stream");
await page.getByRole("button", { name: "Channel duration: Ongoing" }).click();
await page
.getByLabel("Ephemeral - auto-archives after 7 days of inactivity")
.click();
await page.getByTestId("create-channel-channel-type").click();
await page.getByLabel("Temporary channel").click();
const channelTypeContainer = page.getByTestId(
"create-channel-channel-type-container",
);
await expect(
channelTypeContainer.getByTestId("create-channel-permissions-container"),
).toHaveCount(0);
await expect(
page.getByTestId("create-channel-permissions-container"),
).toBeVisible();
await expect(page.getByTestId("create-channel-permissions")).toContainText(
"Public",
);
await page.getByTestId("create-channel-permissions").click();
await page.getByTestId("create-channel-permissions-option-private").click();
await expect(page.getByTestId("create-channel-permissions")).toContainText(
"Private",
);
await expect(
page.getByTestId("create-channel-permissions-option-private"),
).toHaveCount(0);
await page.getByTestId("create-channel-permissions").click();
await expect(
page.getByTestId("create-channel-permissions-option-open"),
).toBeVisible();
await page.getByTestId("create-channel-permissions-option-open").click();
await expect(page.getByTestId("create-channel-ttl")).toContainText("7 days");
await page.getByTestId("create-channel-ttl").click();
await page.getByTestId("create-channel-ttl-option-1209600").click();
await expect(page.getByTestId("create-channel-ttl")).toContainText("14 days");
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
@@ -1317,7 +1346,7 @@ test("create ephemeral stream shows sidebar and header affordances", async ({
await expect(page.getByTestId("chat-ephemeral-badge")).toBeVisible();
await expect(page.getByTestId("chat-ephemeral-badge")).toHaveAttribute(
"aria-label",
/Ephemeral channel\. Cleans up in 7 days\./,
/Ephemeral channel\. Cleans up in 14 days\./,
);
await page
@@ -1345,12 +1374,8 @@ test("ephemeral countdown refreshes when switching channels after a clock jump",
await page
.getByTestId("create-channel-description")
.fill("Auto-cleaned test stream");
await page
.getByRole("button", { name: "Channel duration: Ongoing" })
.click();
await page
.getByLabel("Ephemeral - auto-archives after 7 days of inactivity")
.click();
await page.getByTestId("create-channel-channel-type").click();
await page.getByLabel("Temporary channel").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toContainText(channelName);
}
@@ -2202,24 +2227,28 @@ test("sidebar persists after channel switch", async ({ page }) => {
await expect(page.getByTestId("app-sidebar")).toBeVisible();
});
test("manage channel updates details and context", async ({ page }) => {
test("manage channel updates details", async ({ page }) => {
const stamp = Date.now();
const newName = `release-hub-${stamp}`;
const newDescription = `Release coordination ${stamp}`;
const newTopic = `Launch plan ${stamp}`;
const newPurpose = `Track blockers and owners ${stamp}`;
await page.goto("/");
await openChannelManagement(page, "general");
await openChannelEditDialog(page);
const editDialog = page.getByRole("dialog", { name: "Edit channel" });
const editDialog = page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
});
await editDialog.getByTestId("channel-management-name").fill(newName);
await editDialog
.getByTestId("channel-management-description")
.fill(newDescription);
await editDialog.getByTestId("channel-management-topic").fill(newTopic);
await editDialog.getByTestId("channel-management-purpose").fill(newPurpose);
await expect(editDialog.getByTestId("channel-management-topic")).toHaveCount(
0,
);
await expect(
editDialog.getByTestId("channel-management-purpose"),
).toHaveCount(0);
await editDialog.getByTestId("channel-management-save-changes").click();
await expect(editDialog).toHaveCount(0);
@@ -2231,12 +2260,6 @@ test("manage channel updates details and context", async ({ page }) => {
await expect(
page.getByTestId("channel-management-description"),
).toContainText(newDescription);
await expect(page.getByTestId("channel-management-topic")).toContainText(
newTopic,
);
await expect(page.getByTestId("channel-management-purpose")).toContainText(
newPurpose,
);
await closeChannelManagement(page);
@@ -2249,7 +2272,7 @@ test("manage channel updates details and context", async ({ page }) => {
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await openChannelEditDialog(page);
const reopenedEditDialog = page.getByRole("dialog", {
name: "Edit channel",
name: /Edit (?:public|private) channel/,
});
await expect(
@@ -2260,10 +2283,10 @@ test("manage channel updates details and context", async ({ page }) => {
).toHaveValue(newDescription);
await expect(
reopenedEditDialog.getByTestId("channel-management-topic"),
).toHaveValue(newTopic);
).toHaveCount(0);
await expect(
reopenedEditDialog.getByTestId("channel-management-purpose"),
).toHaveValue(newPurpose);
).toHaveCount(0);
});
test("manage channel updates visibility and ephemeral lifecycle independently", async ({
@@ -2277,8 +2300,18 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
await expect(saveChangesButton).toBeDisabled();
await page.getByTestId("channel-management-private-toggle").click();
await page.getByTestId("channel-management-ephemeral-toggle").click();
await page.getByTestId("channel-management-permissions").click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
await expect(
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Private");
await page.getByTestId("channel-management-channel-type").click();
await page.getByLabel("Temporary channel").click();
await expect(page.getByTestId("channel-management-ttl")).toBeVisible();
await expect(saveChangesButton).toBeEnabled();
@@ -2296,9 +2329,11 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
}),
}),
);
await expect(page.getByRole("dialog", { name: "Edit channel" })).toHaveCount(
0,
);
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
const channelAfterEnable = await invokeMockCommand<{
ttl_seconds: number | null;
@@ -2317,15 +2352,25 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
saveChangesButton = page.getByTestId("channel-management-save-changes");
await expect(
page.getByTestId("channel-management-private-toggle"),
).toHaveAttribute("data-state", "checked");
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Private");
await expect(
page.getByTestId("channel-management-ephemeral-toggle"),
).toHaveAttribute("data-state", "checked");
await expect(page.getByTestId("channel-management-ttl")).toHaveValue("7d");
page.getByTestId("channel-management-channel-type"),
).toContainText("Temporary");
await expect(page.getByTestId("channel-management-ttl")).toContainText(
"7 days",
);
await page.getByTestId("channel-management-private-toggle").click();
await page.getByTestId("channel-management-ephemeral-toggle").click();
await page.getByTestId("channel-management-permissions").click();
await page.getByTestId("channel-management-permissions-option-open").click();
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
await expect(
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Public");
await page.getByTestId("channel-management-channel-type").click();
await page.getByLabel("Ongoing channel").click();
await expect(saveChangesButton).toBeEnabled();
const commandCountBeforeDisable = (await readCommandPayloadLog(page)).length;
@@ -2342,9 +2387,11 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
}),
}),
);
await expect(page.getByRole("dialog", { name: "Edit channel" })).toHaveCount(
0,
);
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
const channelAfterDisable = await invokeMockCommand<{
ttl_seconds: number | null;
@@ -2362,11 +2409,11 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
await openChannelEditDialog(page);
await expect(
page.getByTestId("channel-management-private-toggle"),
).toHaveAttribute("data-state", "unchecked");
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Public");
await expect(
page.getByTestId("channel-management-ephemeral-toggle"),
).toHaveAttribute("data-state", "unchecked");
page.getByTestId("channel-management-channel-type"),
).toContainText("Ongoing");
await expect(page.getByTestId("channel-management-ttl")).toHaveCount(0);
});
+17 -14
View File
@@ -36,7 +36,9 @@ async function openChannelManagement(page: import("@playwright/test").Page) {
async function openChannelEditDialog(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByRole("dialog", { name: "Edit channel" }),
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toBeVisible();
}
@@ -454,7 +456,7 @@ test("multiple channels independent", async ({ page }) => {
);
});
test("manage sheet updates channel details and context through the relay", async ({
test("manage sheet updates channel details through the relay", async ({
page,
}) => {
const stamp = Date.now();
@@ -462,8 +464,6 @@ test("manage sheet updates channel details and context through the relay", async
const renamedChannel = `manage-renamed-${stamp}`;
const initialDescription = `Initial description ${stamp}`;
const updatedDescription = `Updated description ${stamp}`;
const updatedTopic = `Updated topic ${stamp}`;
const updatedPurpose = `Updated purpose ${stamp}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
@@ -471,16 +471,20 @@ test("manage sheet updates channel details and context through the relay", async
await openChannelManagement(page);
await openChannelEditDialog(page);
const editDialog = page.getByRole("dialog", { name: "Edit channel" });
const editDialog = page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
});
await editDialog.getByTestId("channel-management-name").fill(renamedChannel);
await editDialog
.getByTestId("channel-management-description")
.fill(updatedDescription);
await editDialog.getByTestId("channel-management-topic").fill(updatedTopic);
await editDialog
.getByTestId("channel-management-purpose")
.fill(updatedPurpose);
await expect(editDialog.getByTestId("channel-management-topic")).toHaveCount(
0,
);
await expect(
editDialog.getByTestId("channel-management-purpose"),
).toHaveCount(0);
await editDialog.getByTestId("channel-management-save-changes").click();
await expect(editDialog).toHaveCount(0);
@@ -492,16 +496,15 @@ test("manage sheet updates channel details and context through the relay", async
await page.getByTestId(`channel-${renamedChannel}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(renamedChannel);
// channelDescription deduplicates by showing only the first non-empty field
await expect(page.getByTestId("chat-title")).toHaveAttribute(
"title",
updatedTopic,
updatedDescription,
);
await openChannelManagement(page);
await openChannelEditDialog(page);
const reopenedEditDialog = page.getByRole("dialog", {
name: "Edit channel",
name: /Edit (?:public|private) channel/,
});
await expect(
@@ -512,10 +515,10 @@ test("manage sheet updates channel details and context through the relay", async
).toHaveValue(updatedDescription);
await expect(
reopenedEditDialog.getByTestId("channel-management-topic"),
).toHaveValue(updatedTopic);
).toHaveCount(0);
await expect(
reopenedEditDialog.getByTestId("channel-management-purpose"),
).toHaveValue(updatedPurpose);
).toHaveCount(0);
});
test("manage sheet archive and unarchive survives a reload through the relay", async ({
@@ -69,7 +69,8 @@ test.describe("welcome and channel agent entry points", () => {
await page
.getByTestId("create-channel-description")
.fill("A private channel for getting oriented in this workspace.");
await page.getByTestId("create-channel-private-toggle").click();
await page.getByTestId("create-channel-permissions").click();
await page.getByTestId("create-channel-permissions-option-private").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText("Welcome");
await expect(
@@ -108,7 +109,8 @@ test.describe("welcome and channel agent entry points", () => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill("Welcome");
await page.getByTestId("create-channel-private-toggle").click();
await page.getByTestId("create-channel-permissions").click();
await page.getByTestId("create-channel-permissions-option-private").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText("Welcome");
await page.getByTestId("welcome-intro-action-create-agent").click();