Skip to content

Commit 9a06399

Browse files
authored
fix: support Slack workspaces in loop notifications (#3721)
1 parent 57708ad commit 9a06399

5 files changed

Lines changed: 199 additions & 14 deletions

File tree

packages/ui/src/features/loops/components/LoopNotificationsFields.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
import { Switch } from "@posthog/quill";
88
import { useIntegrationSelectors } from "@posthog/ui/features/integrations/store";
99
import { useSlackConnect } from "@posthog/ui/features/integrations/useSlackConnect";
10-
import { SlackChannelCombobox } from "@posthog/ui/features/settings/components/SlackChannelCombobox";
10+
import { SlackWorkspaceChannelPicker } from "@posthog/ui/features/settings/components/SlackWorkspaceChannelPicker";
1111
import { Button } from "@posthog/ui/primitives/Button";
1212
import { Checkbox, Flex, Text } from "@radix-ui/themes";
1313

@@ -211,13 +211,22 @@ function SlackNotificationRow({
211211
: "Connect Slack workspace"}
212212
</Button>
213213
) : (
214-
<SlackChannelCombobox
214+
<SlackWorkspaceChannelPicker
215+
integrations={slackIntegrations}
215216
integrationId={integrationId}
216-
value={channelTarget}
217-
ariaLabel="Slack channel"
218-
offLabel="No channel selected"
217+
channelValue={channelTarget}
218+
channelAriaLabel="Slack channel"
219219
disabled={disabled}
220-
onChange={(target) => {
220+
onIntegrationChange={(nextIntegrationId) => {
221+
const next: SlackChannelParams = {
222+
...params,
223+
integration_id: nextIntegrationId,
224+
};
225+
delete next.channel_id;
226+
delete next.channel_name;
227+
onChange({ params: next });
228+
}}
229+
onChannelChange={(target) => {
221230
if (!target || !integrationId) {
222231
const next: SlackChannelParams = { ...params };
223232
delete next.channel_id;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { SlackChannelOption } from "@posthog/shared/domain-types";
2+
import { render, screen } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { useState } from "react";
5+
import { describe, expect, it, vi } from "vitest";
6+
import { SlackChannelCombobox } from "./SlackChannelCombobox";
7+
8+
const generalChannel: SlackChannelOption = {
9+
id: "C_GENERAL",
10+
name: "general",
11+
is_private: false,
12+
is_member: true,
13+
is_ext_shared: false,
14+
is_private_without_access: false,
15+
};
16+
17+
const analyticsChannel: SlackChannelOption = {
18+
...generalChannel,
19+
id: "C_ANALYTICS",
20+
name: "analytics-platform",
21+
};
22+
23+
vi.mock("@posthog/ui/features/inbox/hooks/useSlackChannels", () => ({
24+
useSlackChannels: (
25+
_integrationId: number,
26+
options?: { search?: string },
27+
) => ({
28+
data: {
29+
channels: options?.search ? [analyticsChannel] : [generalChannel],
30+
},
31+
isFetching: false,
32+
}),
33+
}));
34+
35+
vi.mock("@posthog/ui/primitives/hooks/useDebouncedValue", () => ({
36+
useDebouncedValue: <T,>(value: T) => ({
37+
debounced: value,
38+
isPending: false,
39+
}),
40+
}));
41+
42+
describe("SlackChannelCombobox", () => {
43+
it("updates server search results without replacing the focused input", async () => {
44+
const user = userEvent.setup();
45+
46+
function TestPicker() {
47+
const [value, setValue] = useState<string | null>(null);
48+
return (
49+
<SlackChannelCombobox
50+
integrationId={123}
51+
value={value}
52+
onChange={setValue}
53+
ariaLabel="Slack channel"
54+
/>
55+
);
56+
}
57+
58+
render(<TestPicker />);
59+
60+
const trigger = screen.getByRole("combobox", { name: "Slack channel" });
61+
trigger.focus();
62+
await user.keyboard("{ArrowDown}");
63+
expect(screen.getByText("general")).toBeInTheDocument();
64+
expect(screen.queryByText("No channel selected")).not.toBeInTheDocument();
65+
66+
const input = screen.getByPlaceholderText("Search channels…");
67+
await user.type(input, "analytics");
68+
69+
expect(input).toHaveFocus();
70+
expect(screen.getByText("analytics-platform")).toBeInTheDocument();
71+
expect(screen.queryByText("general")).not.toBeInTheDocument();
72+
});
73+
});

packages/ui/src/features/settings/components/SlackChannelCombobox.tsx

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ interface SlackChannelComboboxProps {
3131
value: string | null;
3232
/** Fires with a new target, or null when "off" is chosen. */
3333
onChange: (channelTarget: string | null) => void;
34-
/** Label for the "off" item, e.g. "Off — don't notify me" or "No default channel". */
35-
offLabel: string;
34+
/** When set, includes an option that clears the selected channel. */
35+
offLabel?: string;
3636
ariaLabel: string;
3737
modal?: boolean;
3838
disabled?: boolean;
@@ -59,8 +59,8 @@ export function SlackChannelCombobox({
5959

6060
const { data: channelsData, isFetching } = useSlackChannels(integrationId, {
6161
search: debouncedSearch || undefined,
62-
enabled: open,
6362
});
63+
const initialLoading = !!integrationId && !channelsData && isFetching;
6464
const searchPending = open && (isFetching || searchDebouncing);
6565

6666
const visibleChannels = useMemo(
@@ -74,17 +74,27 @@ export function SlackChannelCombobox({
7474
);
7575

7676
const comboboxItems = useMemo(
77-
() => [OFF_VALUE, ...visibleChannels.map((c) => c.id)],
78-
[visibleChannels],
77+
() => [
78+
...(offLabel ? [OFF_VALUE] : []),
79+
...visibleChannels.map((c) => c.id),
80+
],
81+
[offLabel, visibleChannels],
7982
);
80-
8183
const triggerLabel = (() => {
82-
if (searchPending && !hasChannel) return "Loading channels…";
84+
if ((initialLoading || searchPending) && !hasChannel) {
85+
return "Loading channels…";
86+
}
8387
if (selectedChannelName) return selectedChannelName;
8488
if (selectedChannelId) return selectedChannelId;
8589
return "Pick a channel";
8690
})();
8791

92+
const comboboxValue = (() => {
93+
if (hasChannel && selectedChannelId) return selectedChannelId;
94+
if (offLabel) return OFF_VALUE;
95+
return null;
96+
})();
97+
8898
const onComboboxChange = (rawValue: string | null) => {
8999
setOpen(false);
90100
setSearchQuery("");
@@ -110,6 +120,7 @@ export function SlackChannelCombobox({
110120
<ComboboxList className="max-h-[min(18rem,calc(var(--available-height,18rem)-5rem))]">
111121
{(itemValue: string) => {
112122
if (itemValue === OFF_VALUE) {
123+
if (!offLabel) return null;
113124
return (
114125
<ComboboxItem key={OFF_VALUE} value={OFF_VALUE} title={offLabel}>
115126
{offLabel}
@@ -151,7 +162,7 @@ export function SlackChannelCombobox({
151162
<Combobox
152163
items={comboboxItems}
153164
filter={null}
154-
value={hasChannel && selectedChannelId ? selectedChannelId : OFF_VALUE}
165+
value={comboboxValue}
155166
onValueChange={(v) => onComboboxChange(v as string | null)}
156167
open={open}
157168
onOpenChange={(next) => {
@@ -170,6 +181,7 @@ export function SlackChannelCombobox({
170181
size="sm"
171182
disabled={disabled || !integrationId}
172183
aria-label={ariaLabel}
184+
aria-busy={initialLoading || searchPending}
173185
className={`${CONTROL_CLASS} justify-between`}
174186
>
175187
<span className="flex min-w-0 items-center gap-1">
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { Integration } from "@posthog/core/integrations/selectors";
2+
import { SlackChannelCombobox } from "@posthog/ui/features/settings/components/SlackChannelCombobox";
3+
import { SlackWorkspaceSelect } from "@posthog/ui/features/settings/components/SlackWorkspaceSelect";
4+
import { Flex } from "@radix-ui/themes";
5+
6+
interface SlackWorkspaceChannelPickerProps {
7+
integrations: Integration[];
8+
integrationId: number | null;
9+
channelValue: string | null;
10+
onIntegrationChange: (integrationId: number) => void;
11+
onChannelChange: (channelTarget: string | null) => void;
12+
channelAriaLabel: string;
13+
offLabel?: string;
14+
disabled?: boolean;
15+
modal?: boolean;
16+
}
17+
18+
export function SlackWorkspaceChannelPicker({
19+
integrations,
20+
integrationId,
21+
channelValue,
22+
onIntegrationChange,
23+
onChannelChange,
24+
channelAriaLabel,
25+
offLabel,
26+
disabled,
27+
modal,
28+
}: SlackWorkspaceChannelPickerProps) {
29+
return (
30+
<Flex align="center" gap="2" wrap="wrap">
31+
{integrations.length > 1 ? (
32+
<SlackWorkspaceSelect
33+
integrations={integrations}
34+
value={integrationId}
35+
disabled={disabled}
36+
className="min-w-[200px] max-w-[240px]"
37+
onValueChange={onIntegrationChange}
38+
/>
39+
) : null}
40+
<SlackChannelCombobox
41+
key={integrationId}
42+
integrationId={integrationId}
43+
value={channelValue}
44+
onChange={onChannelChange}
45+
offLabel={offLabel}
46+
ariaLabel={channelAriaLabel}
47+
disabled={disabled}
48+
modal={modal}
49+
/>
50+
</Flex>
51+
);
52+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { Integration } from "@posthog/core/integrations/selectors";
2+
import { getSlackIntegrationLabel } from "@posthog/core/settings/slackNotificationTarget";
3+
import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect";
4+
5+
interface SlackWorkspaceSelectProps {
6+
integrations: Integration[];
7+
value: number | null;
8+
onValueChange: (integrationId: number) => void;
9+
disabled?: boolean;
10+
className?: string;
11+
}
12+
13+
export function SlackWorkspaceSelect({
14+
integrations,
15+
value,
16+
onValueChange,
17+
disabled,
18+
className,
19+
}: SlackWorkspaceSelectProps) {
20+
return (
21+
<SettingsOptionSelect
22+
value={value ? String(value) : ""}
23+
options={integrations.map((integration) => ({
24+
value: String(integration.id),
25+
label: getSlackIntegrationLabel(integration),
26+
}))}
27+
ariaLabel="Slack workspace"
28+
placeholder="Select workspace"
29+
disabled={disabled}
30+
className={className}
31+
onValueChange={(nextValue) => {
32+
const integrationId = Number(nextValue);
33+
if (Number.isFinite(integrationId)) {
34+
onValueChange(integrationId);
35+
}
36+
}}
37+
/>
38+
);
39+
}

0 commit comments

Comments
 (0)