diff --git a/packages/ui/src/features/loops/components/LoopNotificationsFields.tsx b/packages/ui/src/features/loops/components/LoopNotificationsFields.tsx index b880ab234b..13e09c0088 100644 --- a/packages/ui/src/features/loops/components/LoopNotificationsFields.tsx +++ b/packages/ui/src/features/loops/components/LoopNotificationsFields.tsx @@ -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"; @@ -211,13 +211,22 @@ function SlackNotificationRow({ : "Connect Slack workspace"} ) : ( - { + 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; diff --git a/packages/ui/src/features/settings/components/SlackChannelCombobox.test.tsx b/packages/ui/src/features/settings/components/SlackChannelCombobox.test.tsx new file mode 100644 index 0000000000..1be1419173 --- /dev/null +++ b/packages/ui/src/features/settings/components/SlackChannelCombobox.test.tsx @@ -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: (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(null); + return ( + + ); + } + + render(); + + 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(); + }); +}); diff --git a/packages/ui/src/features/settings/components/SlackChannelCombobox.tsx b/packages/ui/src/features/settings/components/SlackChannelCombobox.tsx index 957d54a55c..32660e61ab 100644 --- a/packages/ui/src/features/settings/components/SlackChannelCombobox.tsx +++ b/packages/ui/src/features/settings/components/SlackChannelCombobox.tsx @@ -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; @@ -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( @@ -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(""); @@ -110,6 +120,7 @@ export function SlackChannelCombobox({ {(itemValue: string) => { if (itemValue === OFF_VALUE) { + if (!offLabel) return null; return ( {offLabel} @@ -151,7 +162,7 @@ export function SlackChannelCombobox({ onComboboxChange(v as string | null)} open={open} onOpenChange={(next) => { @@ -170,6 +181,7 @@ export function SlackChannelCombobox({ size="sm" disabled={disabled || !integrationId} aria-label={ariaLabel} + aria-busy={initialLoading || searchPending} className={`${CONTROL_CLASS} justify-between`} > diff --git a/packages/ui/src/features/settings/components/SlackWorkspaceChannelPicker.tsx b/packages/ui/src/features/settings/components/SlackWorkspaceChannelPicker.tsx new file mode 100644 index 0000000000..960e6accff --- /dev/null +++ b/packages/ui/src/features/settings/components/SlackWorkspaceChannelPicker.tsx @@ -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 ( + + {integrations.length > 1 ? ( + + ) : null} + + + ); +} diff --git a/packages/ui/src/features/settings/components/SlackWorkspaceSelect.tsx b/packages/ui/src/features/settings/components/SlackWorkspaceSelect.tsx new file mode 100644 index 0000000000..d4e98a068f --- /dev/null +++ b/packages/ui/src/features/settings/components/SlackWorkspaceSelect.tsx @@ -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 ( + ({ + 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); + } + }} + /> + ); +}