mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): use segmented controls for channel creation (#6845)
## Summary - replace the two-option Type and Visibility dropdowns in the Create channel dialog with single-click segmented controls - keep Expires after as a dropdown and preserve the existing dropdown controls in edit and management dialogs - update channel creation end-to-end coverage for the direct controls ## Before Default Ongoing/Public state:  Type dropdown open, showing the extra selection click:  ## After Default Ongoing/Public state:  Temporary/Private state with the Expires after row visible:  ## Verification - Biome and TypeScript checks pass - 5,508 desktop unit tests pass - 88 channel smoke tests pass - source guards pass --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, Globe, Lock } from "lucide-react";
|
||||
|
||||
import type { ChannelVisibility } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -10,17 +10,25 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { SegmentedControl } from "@/shared/ui/segmented-control";
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: "private", label: "Private", Icon: Lock },
|
||||
{ value: "open", label: "Public", Icon: Globe },
|
||||
] as const;
|
||||
|
||||
export function ChannelPermissionsSettings({
|
||||
disabled,
|
||||
onVisibilityChange,
|
||||
testIdPrefix,
|
||||
visibility,
|
||||
variant = "dropdown",
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onVisibilityChange: (visibility: ChannelVisibility) => void;
|
||||
testIdPrefix: string;
|
||||
visibility: ChannelVisibility;
|
||||
variant?: "dropdown" | "segmented";
|
||||
}) {
|
||||
const visibilityLabel = visibility === "private" ? "Private" : "Public";
|
||||
|
||||
@@ -28,57 +36,76 @@ export function ChannelPermissionsSettings({
|
||||
<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",
|
||||
disabled && variant === "dropdown" && "opacity-50",
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-permissions-container`}
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">Visibility</span>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={`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">
|
||||
{visibilityLabel}
|
||||
</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={(nextVisibility) =>
|
||||
onVisibilityChange(
|
||||
nextVisibility === "private" ? "private" : "open",
|
||||
)
|
||||
}
|
||||
value={visibility}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
data-testid={`${testIdPrefix}-permissions-option-open`}
|
||||
value="open"
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium text-foreground",
|
||||
disabled && variant === "segmented" && "opacity-50",
|
||||
)}
|
||||
>
|
||||
Visibility
|
||||
</span>
|
||||
{variant === "segmented" ? (
|
||||
<SegmentedControl
|
||||
disabled={disabled}
|
||||
legend="Visibility"
|
||||
onValueChange={onVisibilityChange}
|
||||
optionTestIdPrefix={`${testIdPrefix}-permissions-option`}
|
||||
options={VISIBILITY_OPTIONS}
|
||||
testId={`${testIdPrefix}-permissions`}
|
||||
value={visibility}
|
||||
/>
|
||||
) : (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={`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"
|
||||
>
|
||||
Public
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
data-testid={`${testIdPrefix}-permissions-option-private`}
|
||||
value="private"
|
||||
<span aria-live="polite" className="text-right">
|
||||
{visibilityLabel}
|
||||
</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={(nextVisibility) =>
|
||||
onVisibilityChange(
|
||||
nextVisibility === "private" ? "private" : "open",
|
||||
)
|
||||
}
|
||||
value={visibility}
|
||||
>
|
||||
Private
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, ClockFading, Hash } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
|
||||
import {
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/features/channels/lib/ephemeralChannel";
|
||||
import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -19,9 +20,15 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { SegmentedControl } from "@/shared/ui/segmented-control";
|
||||
import { EditableInfoFieldRow } from "./ChannelManagementSheetRows";
|
||||
import { ChannelTypePicker } from "./ChannelTypePicker";
|
||||
|
||||
const CHANNEL_TYPE_OPTIONS = [
|
||||
{ value: "temporary", label: "Temporary", Icon: ClockFading },
|
||||
{ value: "ongoing", label: "Ongoing", Icon: Hash },
|
||||
] as const;
|
||||
|
||||
const EPHEMERAL_TIMEOUT_OPTIONS = [
|
||||
{ label: "30 minutes", seconds: 30 * 60 },
|
||||
{ label: "1 hour", seconds: 60 * 60 },
|
||||
@@ -76,6 +83,7 @@ export function ChannelTypeSettings({
|
||||
temporary,
|
||||
testIdPrefix,
|
||||
ttlSeconds,
|
||||
variant = "dropdown",
|
||||
}: {
|
||||
channelId?: string | null;
|
||||
disabled?: boolean;
|
||||
@@ -87,6 +95,7 @@ export function ChannelTypeSettings({
|
||||
temporary: boolean;
|
||||
testIdPrefix: string;
|
||||
ttlSeconds: number;
|
||||
variant?: "dropdown" | "segmented";
|
||||
}) {
|
||||
const projectHome = useIsProjectHomeChannel(channelId);
|
||||
const lifecycle = channelLifecycle({ projectHome, temporary });
|
||||
@@ -116,18 +125,39 @@ export function ChannelTypeSettings({
|
||||
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">{label}</span>
|
||||
<ChannelTypePicker
|
||||
align="end"
|
||||
allowProject={projectHome}
|
||||
className="-mr-2.5"
|
||||
disabled={disabled}
|
||||
lifecycle={lifecycle}
|
||||
onLifecycleChange={(next) => onTemporaryChange(next === "temporary")}
|
||||
onOpenChange={onOpenChange}
|
||||
open={open}
|
||||
testId={`${testIdPrefix}-channel-type`}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium text-foreground",
|
||||
disabled && variant === "segmented" && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{variant === "segmented" ? (
|
||||
<SegmentedControl
|
||||
disabled={disabled}
|
||||
legend="Channel type"
|
||||
onValueChange={(value) => onTemporaryChange(value === "temporary")}
|
||||
optionTestIdPrefix={`${testIdPrefix}-channel-type-option`}
|
||||
options={CHANNEL_TYPE_OPTIONS}
|
||||
testId={`${testIdPrefix}-channel-type`}
|
||||
value={temporary ? "temporary" : "ongoing"}
|
||||
/>
|
||||
) : (
|
||||
<ChannelTypePicker
|
||||
align="end"
|
||||
allowProject={projectHome}
|
||||
className="-mr-2.5"
|
||||
disabled={disabled}
|
||||
lifecycle={lifecycle}
|
||||
onLifecycleChange={(next) =>
|
||||
onTemporaryChange(next === "temporary")
|
||||
}
|
||||
onOpenChange={onOpenChange}
|
||||
open={open}
|
||||
testId={`${testIdPrefix}-channel-type`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{temporary && !projectHome ? (
|
||||
@@ -144,7 +174,10 @@ export function ChannelTypeSettings({
|
||||
data-testid={`${testIdPrefix}-ephemeral-settings`}
|
||||
>
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
disabled && variant === "segmented" && "opacity-50",
|
||||
)}
|
||||
htmlFor={`${testIdPrefix}-ttl`}
|
||||
>
|
||||
Expires after
|
||||
|
||||
@@ -41,8 +41,6 @@ export type CreateChannelFormState = {
|
||||
setEphemeral: (value: boolean) => void;
|
||||
ttlSeconds: number;
|
||||
setTtlSeconds: (value: number) => void;
|
||||
typePopoverOpen: boolean;
|
||||
setTypePopoverOpen: (open: boolean) => void;
|
||||
errorMessage: string | null;
|
||||
selectedTemplateId: string | null;
|
||||
handleTemplateChange: (templateId: string) => void;
|
||||
@@ -79,7 +77,6 @@ export function useCreateChannelForm({
|
||||
const [selectedTemplateId, setSelectedTemplateId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [typePopoverOpen, setTypePopoverOpen] = React.useState(false);
|
||||
const nameInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const visibilityTouchedRef = React.useRef(false);
|
||||
|
||||
@@ -97,7 +94,6 @@ export function useCreateChannelForm({
|
||||
setTtlSeconds(DEFAULT_EPHEMERAL_TTL_SECONDS);
|
||||
setErrorMessage(null);
|
||||
setSelectedTemplateId(null);
|
||||
setTypePopoverOpen(false);
|
||||
visibilityTouchedRef.current = false;
|
||||
|
||||
if (!autoFocusName) return;
|
||||
@@ -211,8 +207,6 @@ export function useCreateChannelForm({
|
||||
setEphemeral,
|
||||
ttlSeconds,
|
||||
setTtlSeconds,
|
||||
typePopoverOpen,
|
||||
setTypePopoverOpen,
|
||||
errorMessage,
|
||||
selectedTemplateId,
|
||||
handleTemplateChange,
|
||||
|
||||
@@ -129,13 +129,12 @@ export function CreateChannelFormFields({
|
||||
<ChannelTypeSettings
|
||||
disabled={isCreating}
|
||||
label="Type"
|
||||
onOpenChange={form.setTypePopoverOpen}
|
||||
onTemporaryChange={form.setEphemeral}
|
||||
onTtlSecondsChange={form.setTtlSeconds}
|
||||
open={form.typePopoverOpen}
|
||||
temporary={form.ephemeral}
|
||||
testIdPrefix="create-channel"
|
||||
ttlSeconds={form.ttlSeconds}
|
||||
variant="segmented"
|
||||
/>
|
||||
|
||||
<ChannelPermissionsSettings
|
||||
@@ -143,16 +142,19 @@ export function CreateChannelFormFields({
|
||||
onVisibilityChange={form.setVisibility}
|
||||
testIdPrefix="create-channel"
|
||||
visibility={form.visibility}
|
||||
variant="segmented"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-12 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3 py-3",
|
||||
isCreating && "opacity-50",
|
||||
)}
|
||||
className="flex min-h-12 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3 py-3"
|
||||
data-testid="create-channel-template-container"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium text-foreground",
|
||||
isCreating && "opacity-50",
|
||||
)}
|
||||
>
|
||||
Template
|
||||
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
|
||||
</span>
|
||||
|
||||
@@ -19,6 +19,7 @@ const SIZE_CLASSES: Record<SegmentedControlSize, string> = {
|
||||
/** A mutually exclusive control with equal-width, optionally scrubbable options. */
|
||||
export function SegmentedControl<Value extends string>({
|
||||
className,
|
||||
disabled = false,
|
||||
indicatorTestId,
|
||||
legend,
|
||||
onPreviewChange,
|
||||
@@ -30,6 +31,7 @@ export function SegmentedControl<Value extends string>({
|
||||
value,
|
||||
}: {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
indicatorTestId?: string;
|
||||
legend: string;
|
||||
onPreviewChange?: (value: Value | null) => void;
|
||||
@@ -161,10 +163,12 @@ export function SegmentedControl<Value extends string>({
|
||||
"relative isolate h-8 max-w-full shrink-0 overflow-hidden rounded-md bg-muted/45 p-0.5",
|
||||
SIZE_CLASSES[size],
|
||||
onPreviewChange && "touch-none select-none cursor-ew-resize",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="segmented-control"
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
onLostPointerCapture={handleLostPointerCapture}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onPointerDown={handlePointerDown}
|
||||
|
||||
@@ -1472,10 +1472,9 @@ test("create channel template selector matches the lifecycle controls", async ({
|
||||
await expect(page.getByTestId("create-channel-description")).toHaveValue(
|
||||
"Coordinate a new project from planning through launch.",
|
||||
);
|
||||
await expect(page.getByTestId("create-channel-permissions")).toContainText(
|
||||
"Private",
|
||||
);
|
||||
await page.getByTestId("create-channel-permissions").click();
|
||||
await expect(
|
||||
page.getByTestId("create-channel-permissions-option-private"),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
await page.getByTestId("create-channel-permissions-option-open").click();
|
||||
await expect(page.getByTestId("create-channel-template-summary")).toHaveText(
|
||||
"Open · Canvas included · 1 agent · 1 team",
|
||||
@@ -1498,6 +1497,12 @@ test("create channel exposes templates when the library is empty", async ({
|
||||
const templateContainer = page.getByTestId(
|
||||
"create-channel-template-container",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("create-channel-channel-type").getByRole("button"),
|
||||
).toHaveText(["Temporary", "Ongoing"]);
|
||||
await expect(
|
||||
page.getByTestId("create-channel-permissions").getByRole("button"),
|
||||
).toHaveText(["Private", "Public"]);
|
||||
await expect(templateContainer).toContainText("TemplateOptional");
|
||||
const typeBox = await typeContainer.boundingBox();
|
||||
const visibilityBox = await visibilityContainer.boundingBox();
|
||||
@@ -1539,8 +1544,9 @@ test("create ephemeral stream shows sidebar and header affordances", async ({
|
||||
await page
|
||||
.getByTestId("create-channel-description")
|
||||
.fill("Auto-cleaned test stream");
|
||||
await page.getByTestId("create-channel-channel-type").click();
|
||||
await page.getByLabel("Temporary channel").click();
|
||||
await page
|
||||
.getByTestId("create-channel-channel-type-option-temporary")
|
||||
.click();
|
||||
const channelTypeContainer = page.getByTestId(
|
||||
"create-channel-channel-type-container",
|
||||
);
|
||||
@@ -1550,22 +1556,20 @@ test("create ephemeral stream shows sidebar and header affordances", async ({
|
||||
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();
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
await page.getByTestId("create-channel-permissions-option-private").click();
|
||||
await expect(
|
||||
page.getByTestId("create-channel-permissions-option-private"),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(
|
||||
page.getByTestId("create-channel-permissions-option-open"),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
await page.getByTestId("create-channel-permissions-option-open").click();
|
||||
await expect(
|
||||
page.getByTestId("create-channel-permissions-option-open"),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
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();
|
||||
@@ -1674,8 +1678,9 @@ test("ephemeral countdown refreshes when switching channels after a clock jump",
|
||||
await page
|
||||
.getByTestId("create-channel-description")
|
||||
.fill("Auto-cleaned test stream");
|
||||
await page.getByTestId("create-channel-channel-type").click();
|
||||
await page.getByLabel("Temporary channel").click();
|
||||
await page
|
||||
.getByTestId("create-channel-channel-type-option-temporary")
|
||||
.click();
|
||||
await page.getByTestId("create-channel-submit").click();
|
||||
await expect(page.getByTestId("chat-title")).toContainText(channelName);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,6 @@ 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-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");
|
||||
@@ -109,7 +108,6 @@ 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-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");
|
||||
|
||||
Reference in New Issue
Block a user