Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ export function ChannelItemsPane({
cap,
channelIdFor,
emptyState,
overlay,
searchLabel = "Search sessions",
}: {
items: readonly ChannelItemModel[];
Expand All @@ -147,7 +146,6 @@ export function ChannelItemsPane({
cap?: number;
channelIdFor?: (item: ChannelItemModel) => string | undefined;
emptyState: ReactNode;
overlay?: ReactNode;
searchLabel?: string;
}) {
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
Expand Down Expand Up @@ -415,7 +413,6 @@ export function ChannelItemsPane({
</Empty>
))}
</div>
{overlay}
<MarqueeOverlay rect={marquee} />

<SidebarBulkActionBar
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,6 @@ vi.mock("@tanstack/react-router", () => ({
vi.mock("@posthog/ui/features/canvas/components/ChannelBackRow", () => ({
ChannelBackRow: () => null,
}));
vi.mock("@posthog/ui/features/canvas/components/ChannelsFab", () => ({
ChannelsFab: () => null,
}));

// The row menu's spaces list reaches for a QueryClient the unit test has no
// stack for. Stubbed at the module boundary, as ShellLayout.test.tsx does for
// the same reason.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
import { LOOPS_FLAG } from "@posthog/shared";
import { ChannelBackRow } from "@posthog/ui/features/canvas/components/ChannelBackRow";
import { ChannelItemsPane } from "@posthog/ui/features/canvas/components/ChannelItemsPane";
import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab";
import {
type ChannelPageKey,
channelPageLabel,
Expand Down Expand Up @@ -243,7 +242,6 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
cap={RECENTS_CAP}
channelIdFor={() => channelId}
emptyState={<TabEmptyState tab={tab} />}
overlay={<ChannelsFab channelId={channelId} />}
searchLabel={
tab === "canvas" ? "Search canvases" : "Search sessions"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,22 @@ import { track } from "@posthog/ui/shell/analytics";
import { useState } from "react";

/**
* The create affordance for the Channels space, floated over the bottom-right
* of whichever sidebar pane is showing.
* The create button. Given a channel it creates inside it (task, canvas); from
* the list it creates a channel, which has no other entry point.
*
* The same button on both panes, so "create" is always the same corner: given a
* channel it creates inside it (task, canvas); from the list it creates a
* channel, which has no other entry point.
* On the spaces layout it sits in the nav rail (`placement="rail"`), which
* every destination keeps on screen whether or not it draws a sidebar. Off the
* layout it floats over the bottom-right of the channel list.
*/
export function ChannelsFab({ channelId }: { channelId?: string }) {
export function ChannelsFab({
channelId,
placement = "floating",
}: {
channelId?: string;
placement?: "floating" | "rail";
}) {
const channelsLayout = useChannelsLayout();
const inRail = placement === "rail";
const [modalOpen, setModalOpen] = useState(false);
const hasDraft = useDraftStore((state) =>
Object.entries(state.drafts).some(
Expand All @@ -51,7 +58,7 @@ export function ChannelsFab({ channelId }: { channelId?: string }) {
const newTask = () => {
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
action_type: "new_task_open",
surface: "sidebar",
surface: inRail ? "nav" : "sidebar",
channel_id: channelId,
});
// In a channel the task is filed there; from the list it's whatever the
Expand Down Expand Up @@ -86,18 +93,22 @@ export function ChannelsFab({ channelId }: { channelId?: string }) {
const trigger = (
<Button
variant="primary"
size="icon-lg"
size={inRail ? "icon" : "icon-lg"}
aria-label={label}
className="absolute right-3 bottom-3 z-10 rounded-full"
className={
inRail
? "shrink-0 rounded-full"
: "absolute right-3 bottom-3 z-10 rounded-full"
}
onClick={newTaskOnly ? newTask : undefined}
>
<PlusIcon size={20} weight="bold" />
<PlusIcon size={inRail ? 16 : 20} weight="bold" />
{draftDot}
</Button>
);

const tooltip = (
<TooltipContent side="top" align="center">
<TooltipContent side={inRail ? "right" : "top"} align="center">
{channelsLayout ? (
<>
{/* The draft dot needs saying out loud, and the button is where
Expand Down Expand Up @@ -128,8 +139,8 @@ export function ChannelsFab({ channelId }: { channelId?: string }) {
{tooltip}
</Tooltip>
<DropdownMenuContent
align={channelId ? "end" : "center"}
side="top"
align={inRail || channelId ? "end" : "center"}
side={inRail ? "right" : "top"}
sideOffset={6}
>
{/* Off the layout this is the list's only menu, and "New channel"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const mocks = vi.hoisted(() => ({
}));

vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() }));
vi.mock("@posthog/ui/features/canvas/components/CreateChannelModal", () => ({
CreateChannelModal: ({ open }: { open: boolean }) =>
open ? <div role="dialog">New space dialog</div> : null,
}));
vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({
useChannelsLayout: () => mocks.channelsLayout,
}));
Expand Down Expand Up @@ -303,6 +307,19 @@ describe("ChannelsList", () => {
expect(screen.queryByRole("heading", { name: "Spaces" })).toBeNull();
expect(screen.getByText("Channels")).toBeTruthy();
});

// The heading's "+" is the list's way to a new space now that nothing
// floats over it; off the layout the floating button still offers one.
it("starts a new space from the Spaces heading on the layout only", async () => {
const view = renderList();
await userEvent.click(screen.getByRole("button", { name: "New space" }));
expect(screen.getByRole("dialog")).toHaveTextContent("New space dialog");

view.unmount();
mocks.channelsLayout = false;
renderList();
expect(screen.queryByRole("button", { name: "New space" })).toBeNull();
});
});

describe("search", () => {
Expand Down Expand Up @@ -344,6 +361,16 @@ describe("ChannelsList", () => {
);
});

it("offers a new space when nothing matches", async () => {
const user = userEvent.setup();
renderList();

await user.type(screen.getByLabelText("Search spaces"), "zzz");
await user.click(screen.getByRole("button", { name: "New space" }));

expect(screen.getByRole("dialog")).toHaveTextContent("New space dialog");
});

it("says so when nothing matches", async () => {
const user = userEvent.setup();
renderList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
ChannelItemHoverCard,
SpaceHoverCard,
} from "@posthog/ui/features/canvas/components/ChannelItemHoverCard";
import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal";
import type { ChannelActionItem } from "@posthog/ui/features/canvas/components/channelActions";
import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph";
import { PresenceAvatars } from "@posthog/ui/features/canvas/components/PresenceAvatars";
Expand Down Expand Up @@ -1689,6 +1690,50 @@ const CHANNELS_SECTION_ID = "channels:all";
/** A heading's identity in the flat list, kept clear of any channel's id. */
const sectionValue = (sectionId: string) => `section:${sectionId}`;

// Starts a new space. On the Spaces heading it is the same hover-revealed
// plus a space row shows; inline (the no-match state) it is a labelled button.
function NewSpaceButton({
appearance = "heading",
}: {
appearance?: "heading" | "inline";
}) {
const [open, setOpen] = useState(false);

return (
<>
{appearance === "inline" ? (
<Button
variant="outline"
size="sm"
className="self-start"
onClick={() => setOpen(true)}
>
<PlusIcon size={12} weight="bold" />
New space
</Button>
) : (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-xs"
aria-label="New space"
className="opacity-0 transition-opacity focus-visible:opacity-100 group-hover/group-row:opacity-100"
onClick={() => setOpen(true)}
>
<PlusIcon size={12} weight="bold" />
</Button>
}
/>
<TooltipContent side="top">New space</TooltipContent>
</Tooltip>
)}
<CreateChannelModal open={open} onOpenChange={setOpen} />
</>
);
}

// A collapsible sidebar group ("Starred" / "Channels"). Base UI directly rather
// than quill's Collapsible: quill styles its trigger as a button (which fought
// the label styling) and animates the panel height (which janked on a list this
Expand All @@ -1704,11 +1749,15 @@ function ChannelGroup({
flat,
keepMounted = true,
asOption = false,
trailing,
children,
}: {
sectionId: string;
label: string;
className?: string;
/** A control on the heading's right. Beside the trigger, not in it: the
* heading is a button, and a button can't hold another. */
trailing?: ReactNode;
/** Layout-only: removes the legacy tree indent; rows apply their own inset. */
flat?: boolean;
/**
Expand Down Expand Up @@ -1740,41 +1789,48 @@ function ChannelGroup({
}}
className={cn(className, "mb-2")}
>
{/* MenuLabel carries the sidebar's label styling; `render` keeps it a
<div className="group/group-row relative">
{/* MenuLabel carries the sidebar's label styling; `render` keeps it a
real button so the whole row is clickable. Wrapped in an option when
the keyboard walks the list, so the heading is a stop on the way down
rather than a gap the highlight jumps over. */}
<Collapsible.Trigger
className={cn(
"group/group-trigger flex w-full items-center gap-2 py-1",
// quill wraps an option's children in its own flex row, so the caret's
// `ml-auto` has nothing to push against until that row is full width.
// The highlight is the rows' own hover fill rather than quill's focus
// ring, for the reason SpaceRowSurface gives.
asOption &&
"rounded-sm ring-offset-0 data-highlighted:bg-fill-hover data-highlighted:ring-0 [&>span]:w-full [&>span]:items-center",
)}
render={
asOption ? (
<AutocompleteItem
value={sectionValue(sectionId)}
render={<MenuLabel render={<button type="button" />} />}
/>
) : (
<MenuLabel render={<button type="button" />} />
)
}
>
{label}
{/* On the right, because the heading's name is the left edge every row
<Collapsible.Trigger
className={cn(
"group/group-trigger flex w-full items-center gap-2 py-1",
// quill wraps an option's children in its own flex row, so the caret's
// `ml-auto` has nothing to push against until that row is full width.
// The highlight is the rows' own hover fill rather than quill's focus
// ring, for the reason SpaceRowSurface gives.
asOption &&
"rounded-sm ring-offset-0 data-highlighted:bg-fill-hover data-highlighted:ring-0 [&>span]:w-full [&>span]:items-center",
)}
render={
asOption ? (
<AutocompleteItem
value={sectionValue(sectionId)}
render={<MenuLabel render={<button type="button" />} />}
/>
) : (
<MenuLabel render={<button type="button" />} />
)
}
>
{label}
{/* On the right, because the heading's name is the left edge every row
beneath it lines up to. Always drawn: which way the section is, is
the one thing this row has to say. */}
{isOpen ? (
<CaretDownIcon size={12} className="shrink-0" />
) : (
<CaretRightIcon size={12} className="shrink-0" />
{isOpen ? (
<CaretDownIcon size={12} className="shrink-0" />
) : (
<CaretRightIcon size={12} className="shrink-0" />
)}
</Collapsible.Trigger>
{trailing && (
<div className="-translate-y-1/2 absolute top-1/2 right-1">
{trailing}
</div>
)}
</Collapsible.Trigger>
</div>
{/* Stay mounted while collapsed. Every row builds a context menu, a
dropdown, a tooltip and two dialogs up front, so unmounting on close
makes each expand rebuild the lot (~940ms for 46 channels, vs ~80ms
Expand All @@ -1789,8 +1845,8 @@ function ChannelGroup({
// The channel list is the list pane of the sidebar slider. The personal channel
// is pinned at the top; starred channels surface in their own section
// so the ones you use most stay in reach; the rest sit under a "Channels"
// label. Creating anything goes through the floating ChannelsFab, mounted by
// the sidebar outside this scroll region.
// label. Creating a task goes through the create button in the nav rail (off
// the layout, the floating ChannelsFab the sidebar mounts outside this list).
export function ChannelsList() {
const { channels: allChannels, isLoading } = useChannels();
// ChannelHotkeys owns the keys these slots describe; sharing the derivation
Expand Down Expand Up @@ -2045,10 +2101,13 @@ export function ChannelsList() {
/>
))}
{noMatches && (
<Empty className="px-2 py-1 text-subtle-foreground text-xs">
<Empty className="items-start gap-2 px-2 py-1 text-subtle-foreground text-xs">
<EmptyHeader className="text-left">
No {channelsLayout ? "spaces" : "channels"} match “{query.trim()}”.
</EmptyHeader>
{/* Filtering hides the Spaces heading and its "+", so the space you
searched for and didn't find can still be made from here. */}
<NewSpaceButton appearance="inline" />
</Empty>
)}
</>
Expand Down Expand Up @@ -2090,6 +2149,8 @@ export function ChannelsList() {
flat={channelsLayout}
keepMounted={!channelsLayout}
asOption={channelsLayout}
// Off the layout the floating create button already offers a channel.
trailing={channelsLayout ? <NewSpaceButton /> : undefined}
>
{!isLoading && channels.length === 0 && (
<Empty className="px-2 py-1 text-subtle-foreground text-xs">
Expand Down Expand Up @@ -2125,7 +2186,9 @@ export function ChannelsList() {
// own padding has to win: `!` is what outranks an unlayered rule.
const listClass = cn(
"sidebar-autocomplete-tree flex flex-col gap-px",
"!max-h-none !px-2 !pt-2 !pb-16 scroll-py-8",
// The layout keeps the create button in the rail, so nothing floats over
// the list's end and it needs no clearance there.
"!max-h-none !px-2 !pt-2 !pb-2 scroll-py-8",
scrollClass,
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,6 @@ function ChannelPanes({
>
<div className="relative h-full w-1/2 min-w-0" inert={!showList}>
<ChannelsList />
<ChannelsFab />
</div>
<div className="h-full w-1/2 min-w-0" inert={showList}>
{channelId && (
Expand Down
Loading
Loading