Skip to content
Closed
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 @@ -7,7 +7,7 @@ import {
import { Switch } from "@posthog/quill";
import { useIntegrationSelectors } from "@posthog/ui/features/integrations/store";
import { useSlackConnect } from "@posthog/ui/features/integrations/useSlackConnect";
import { SlackChannelCombobox } from "@posthog/ui/features/settings/components/SlackChannelCombobox";
import { SlackWorkspaceChannelPicker } from "@posthog/ui/features/settings/components/SlackWorkspaceChannelPicker";
import { Button } from "@posthog/ui/primitives/Button";
import { Checkbox, Flex, Text } from "@radix-ui/themes";

Expand Down Expand Up @@ -211,13 +211,22 @@ function SlackNotificationRow({
: "Connect Slack workspace"}
</Button>
) : (
<SlackChannelCombobox
<SlackWorkspaceChannelPicker
integrations={slackIntegrations}
integrationId={integrationId}
value={channelTarget}
ariaLabel="Slack channel"
offLabel="No channel selected"
channelValue={channelTarget}
channelAriaLabel="Slack channel"
disabled={disabled}
onChange={(target) => {
onIntegrationChange={(nextIntegrationId) => {
const next: SlackChannelParams = {
...params,
integration_id: nextIntegrationId,
};
delete next.channel_id;
delete next.channel_name;
onChange({ params: next });
}}
onChannelChange={(target) => {
if (!target || !integrationId) {
const next: SlackChannelParams = { ...params };
delete next.channel_id;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { SlackChannelOption } from "@posthog/shared/domain-types";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { SlackChannelCombobox } from "./SlackChannelCombobox";

const generalChannel: SlackChannelOption = {
id: "C_GENERAL",
name: "general",
is_private: false,
is_member: true,
is_ext_shared: false,
is_private_without_access: false,
};

const analyticsChannel: SlackChannelOption = {
...generalChannel,
id: "C_ANALYTICS",
name: "analytics-platform",
};

vi.mock("@posthog/ui/features/inbox/hooks/useSlackChannels", () => ({
useSlackChannels: (
_integrationId: number,
options?: { search?: string },
) => ({
data: {
channels: options?.search ? [analyticsChannel] : [generalChannel],
},
isFetching: false,
}),
}));

vi.mock("@posthog/ui/primitives/hooks/useDebouncedValue", () => ({
useDebouncedValue: <T,>(value: T) => ({
debounced: value,
isPending: false,
}),
}));

describe("SlackChannelCombobox", () => {
it("updates server search results without replacing the focused input", async () => {
const user = userEvent.setup();

function TestPicker() {
const [value, setValue] = useState<string | null>(null);
return (
<SlackChannelCombobox
integrationId={123}
value={value}
onChange={setValue}
ariaLabel="Slack channel"
/>
);
}

render(<TestPicker />);

const trigger = screen.getByRole("combobox", { name: "Slack channel" });
trigger.focus();
await user.keyboard("{ArrowDown}");
expect(screen.getByText("general")).toBeInTheDocument();
expect(screen.queryByText("No channel selected")).not.toBeInTheDocument();

const input = screen.getByPlaceholderText("Search channels…");
await user.type(input, "analytics");

expect(input).toHaveFocus();
expect(screen.getByText("analytics-platform")).toBeInTheDocument();
expect(screen.queryByText("general")).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ interface SlackChannelComboboxProps {
value: string | null;
/** Fires with a new target, or null when "off" is chosen. */
onChange: (channelTarget: string | null) => void;
/** Label for the "off" item, e.g. "Off — don't notify me" or "No default channel". */
offLabel: string;
/** When set, includes an option that clears the selected channel. */
offLabel?: string;
ariaLabel: string;
modal?: boolean;
disabled?: boolean;
Expand All @@ -59,8 +59,8 @@ export function SlackChannelCombobox({

const { data: channelsData, isFetching } = useSlackChannels(integrationId, {
search: debouncedSearch || undefined,
enabled: open,
});
const initialLoading = !!integrationId && !channelsData && isFetching;
const searchPending = open && (isFetching || searchDebouncing);

const visibleChannels = useMemo(
Expand All @@ -74,17 +74,27 @@ export function SlackChannelCombobox({
);

const comboboxItems = useMemo(
() => [OFF_VALUE, ...visibleChannels.map((c) => c.id)],
[visibleChannels],
() => [
...(offLabel ? [OFF_VALUE] : []),
...visibleChannels.map((c) => c.id),
],
[offLabel, visibleChannels],
);

const triggerLabel = (() => {
if (searchPending && !hasChannel) return "Loading channels…";
if ((initialLoading || searchPending) && !hasChannel) {
return "Loading channels…";
}
if (selectedChannelName) return selectedChannelName;
if (selectedChannelId) return selectedChannelId;
return "Pick a channel";
})();

const comboboxValue = (() => {
if (hasChannel && selectedChannelId) return selectedChannelId;
if (offLabel) return OFF_VALUE;
return null;
})();

const onComboboxChange = (rawValue: string | null) => {
setOpen(false);
setSearchQuery("");
Expand All @@ -110,6 +120,7 @@ export function SlackChannelCombobox({
<ComboboxList className="max-h-[min(18rem,calc(var(--available-height,18rem)-5rem))]">
{(itemValue: string) => {
if (itemValue === OFF_VALUE) {
if (!offLabel) return null;
return (
<ComboboxItem key={OFF_VALUE} value={OFF_VALUE} title={offLabel}>
{offLabel}
Expand Down Expand Up @@ -151,7 +162,7 @@ export function SlackChannelCombobox({
<Combobox
items={comboboxItems}
filter={null}
value={hasChannel && selectedChannelId ? selectedChannelId : OFF_VALUE}
value={comboboxValue}
onValueChange={(v) => onComboboxChange(v as string | null)}
open={open}
onOpenChange={(next) => {
Expand All @@ -170,6 +181,7 @@ export function SlackChannelCombobox({
size="sm"
disabled={disabled || !integrationId}
aria-label={ariaLabel}
aria-busy={initialLoading || searchPending}
className={`${CONTROL_CLASS} justify-between`}
>
<span className="flex min-w-0 items-center gap-1">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Integration } from "@posthog/core/integrations/selectors";
import { SlackChannelCombobox } from "@posthog/ui/features/settings/components/SlackChannelCombobox";
import { SlackWorkspaceSelect } from "@posthog/ui/features/settings/components/SlackWorkspaceSelect";
import { Flex } from "@radix-ui/themes";

interface SlackWorkspaceChannelPickerProps {
integrations: Integration[];
integrationId: number | null;
channelValue: string | null;
onIntegrationChange: (integrationId: number) => void;
onChannelChange: (channelTarget: string | null) => void;
channelAriaLabel: string;
offLabel?: string;
disabled?: boolean;
modal?: boolean;
}

export function SlackWorkspaceChannelPicker({
integrations,
integrationId,
channelValue,
onIntegrationChange,
onChannelChange,
channelAriaLabel,
offLabel,
disabled,
modal,
}: SlackWorkspaceChannelPickerProps) {
return (
<Flex align="center" gap="2" wrap="wrap">
{integrations.length > 1 ? (
<SlackWorkspaceSelect
integrations={integrations}
value={integrationId}
disabled={disabled}
className="min-w-[200px] max-w-[240px]"
onValueChange={onIntegrationChange}
/>
) : null}
<SlackChannelCombobox
key={integrationId}
integrationId={integrationId}
value={channelValue}
onChange={onChannelChange}
offLabel={offLabel}
ariaLabel={channelAriaLabel}
disabled={disabled}
modal={modal}
/>
</Flex>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Integration } from "@posthog/core/integrations/selectors";
import { getSlackIntegrationLabel } from "@posthog/core/settings/slackNotificationTarget";
import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect";

interface SlackWorkspaceSelectProps {
integrations: Integration[];
value: number | null;
onValueChange: (integrationId: number) => void;
disabled?: boolean;
className?: string;
}

export function SlackWorkspaceSelect({
integrations,
value,
onValueChange,
disabled,
className,
}: SlackWorkspaceSelectProps) {
return (
<SettingsOptionSelect
value={value ? String(value) : ""}
options={integrations.map((integration) => ({
value: String(integration.id),
label: getSlackIntegrationLabel(integration),
}))}
ariaLabel="Slack workspace"
placeholder="Select workspace"
disabled={disabled}
className={className}
onValueChange={(nextValue) => {
const integrationId = Number(nextValue);
if (Number.isFinite(integrationId)) {
onValueChange(integrationId);
}
}}
/>
);
}
Loading