Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions packages/ui/src/features/canvas/components/ChannelPicker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Theme } from "@radix-ui/themes";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ChannelPicker } from "./ChannelPicker";

// Parent-ordered: me first, then the rest.
const CHANNEL_NAMES = ["me", "marketing", "support"];

function renderPicker(props?: Partial<Parameters<typeof ChannelPicker>[0]>): {
onChange: ReturnType<typeof vi.fn>;
} {
const onChange = vi.fn();
render(
<Theme>
<ChannelPicker
value={null}
onChange={onChange}
channelNames={CHANNEL_NAMES}
isLoading={false}
{...props}
/>
</Theme>,
);
return { onChange };
}

describe("ChannelPicker", () => {
it("defaults to 'No channel'", () => {
renderPicker();
expect(screen.getByRole("combobox", { name: "Channel" })).toHaveTextContent(
"No channel",
);
});

it("shows the selected channel's name (incl. the personal 'me')", () => {
renderPicker({ value: "me" });
expect(screen.getByRole("combobox", { name: "Channel" })).toHaveTextContent(
"me",
);
});

it("lists 'No channel' first, then the channels in the given order", async () => {
const user = userEvent.setup();
// Deliberately not alphabetical: the parent orders the list and the picker
// must preserve that order (No channel → me → starred → rest).
renderPicker({ channelNames: ["me", "zulu", "alpha"] });

await user.click(screen.getByRole("combobox", { name: "Channel" }));
await screen.findByRole("option", { name: "No channel" });

expect(screen.getAllByRole("option").map((el) => el.textContent)).toEqual([
"No channel",
"me",
"zulu",
"alpha",
]);
});

it("emits the channel name when a channel is picked", async () => {
const user = userEvent.setup();
const { onChange } = renderPicker();

await user.click(screen.getByRole("combobox", { name: "Channel" }));
await user.click(await screen.findByRole("option", { name: "support" }));

await waitFor(() => expect(onChange).toHaveBeenCalledWith("support"));
});

it("emits null when 'No channel' is picked", async () => {
const user = userEvent.setup();
const { onChange } = renderPicker({ value: "me" });

await user.click(screen.getByRole("combobox", { name: "Channel" }));
await user.click(await screen.findByRole("option", { name: "No channel" }));

await waitFor(() => expect(onChange).toHaveBeenCalledWith(null));
});

// Type-to-filter is the shared Combobox's own behavior (same as the repo
// picker) and its popup search input doesn't mount as a queryable field in
// jsdom, so it isn't asserted here — see BranchSelector.test.tsx, which drives
// that combobox via props for the same reason.
});
135 changes: 135 additions & 0 deletions packages/ui/src/features/canvas/components/ChannelPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { CaretDown, HashIcon, Prohibit } from "@phosphor-icons/react";
import {
Button,
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from "@posthog/quill";
import { useRef, useState } from "react";

// The opt-out option: run the task in the normal repo flow, not bound to a
// channel. Maps to a null value. The label can't collide with a channel name —
// those are lowercase/hyphen with no spaces.
const NO_CHANNEL_LABEL = "No channel";

interface ChannelPickerProps {
/** Selected channel name, or `null` for "No channel" (normal repo flow). */
value: string | null;
onChange: (channelName: string | null) => void;
/**
* Channel names to list, already ordered by the parent (me → starred → rest).
* The "No channel" option is prepended here.
*/
channelNames: string[];
isLoading: boolean;
disabled?: boolean;
// Accepted for API parity with the sibling pills; the button renders size="sm".
size?: "1" | "2";
}

// A searchable pill for choosing which channel a new task runs in, sitting
// alongside the workspace-mode and repo pills on the new-task composer. Dumb +
// presentational: the parent owns the channels query, ordering, and selection.
// "No channel" keeps the normal repo flow; picking any channel (including the
// personal "me") makes the task run repo-less. Built on the same Combobox as the
// repo picker so you can type to filter a long list.
export function ChannelPicker({
value,
onChange,
channelNames,
isLoading,
disabled,
}: ChannelPickerProps) {
const triggerRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");

const items = [NO_CHANNEL_LABEL, ...channelNames];
const selectedLabel = value ?? NO_CHANNEL_LABEL;

return (
<Combobox
items={items}
value={selectedLabel}
onValueChange={(name) =>
onChange(!name || name === NO_CHANNEL_LABEL ? null : name)
}
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearch("");
}}
inputValue={search}
onInputValueChange={setSearch}
disabled={disabled}
>
<ComboboxTrigger
render={
<Button
ref={triggerRef}
type="button"
variant="outline"
size="sm"
disabled={disabled}
aria-label="Channel"
>
{value === null ? (
<Prohibit
size={14}
weight="regular"
className="shrink-0 text-muted-foreground"
/>
) : (
<HashIcon
size={14}
weight="regular"
className="shrink-0 text-muted-foreground"
/>
)}
<span className="min-w-0 truncate">{selectedLabel}</span>
<CaretDown
size={10}
weight="bold"
className="text-muted-foreground"
/>
</Button>
}
/>
<ComboboxContent
anchor={triggerRef}
side="bottom"
sideOffset={6}
className="min-w-[240px]"
>
<ComboboxInput placeholder="Search channels..." />
<ComboboxEmpty>
{isLoading ? "Loading channels…" : "No channels found."}
</ComboboxEmpty>
<ComboboxList>
{(name: string) => (
<ComboboxItem key={name} value={name}>
{name === NO_CHANNEL_LABEL ? (
<Prohibit
size={14}
weight="regular"
className="shrink-0 text-muted-foreground"
/>
) : (
<HashIcon
size={14}
weight="regular"
className="shrink-0 text-muted-foreground"
/>
)}
<span className="min-w-0 truncate">{name}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
Loading
Loading