mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Refine channel lifecycle settings (#2427)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user