Skip to content

Commit

Permalink
feat(cdp): Native slack integration (#23103)
Browse files Browse the repository at this point in the history
  • Loading branch information
benjackwhite authored and thmsobrmlr committed Jun 24, 2024
1 parent 99762c7 commit 80847e1
Show file tree
Hide file tree
Showing 25 changed files with 800 additions and 346 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 1 addition & 9 deletions frontend/src/lib/components/Subscriptions/subscriptionLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { dayjs } from 'lib/dayjs'
import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
import { isEmail, isURL } from 'lib/utils'
import { getInsightId } from 'scenes/insights/utils'
import { integrationsLogic } from 'scenes/settings/project/integrationsLogic'

import { SubscriptionType } from '~/types'

Expand All @@ -33,7 +32,6 @@ export const subscriptionLogic = kea<subscriptionLogicType>([
key(({ id, insightShortId, dashboardId }) => `${insightShortId || dashboardId}-${id ?? 'new'}`),
connect(({ insightShortId, dashboardId }: SubscriptionsLogicProps) => ({
actions: [subscriptionsLogic({ insightShortId, dashboardId }), ['loadSubscriptions']],
values: [integrationsLogic, ['isMemberOfSlackChannel']],
})),

loaders(({ props }) => ({
Expand All @@ -48,7 +46,7 @@ export const subscriptionLogic = kea<subscriptionLogicType>([
},
})),

forms(({ props, actions, values }) => ({
forms(({ props, actions }) => ({
subscription: {
defaults: {} as unknown as SubscriptionType,
errors: ({ frequency, interval, target_value, target_type, title, start_date }) => ({
Expand Down Expand Up @@ -76,12 +74,6 @@ export const subscriptionLogic = kea<subscriptionLogicType>([
? 'Must be a valid URL'
: undefined
: undefined,
memberOfSlackChannel:
target_type == 'slack'
? target_value && !values.isMemberOfSlackChannel(target_value)
? 'Please add the PostHog Slack App to the selected channel'
: undefined
: undefined,
}),
submit: async (subscription, breakpoint) => {
const insightId = props.insightShortId ? await getInsightId(props.insightShortId) : undefined
Expand Down
30 changes: 2 additions & 28 deletions frontend/src/lib/components/Subscriptions/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { IconLetter } from '@posthog/icons'
import { LemonSelectOptions } from '@posthog/lemon-ui'
import { IconSlack, IconSlackExternal } from 'lib/lemon-ui/icons'
import { LemonInputSelectOption } from 'lib/lemon-ui/LemonInputSelect/LemonInputSelect'
import { IconSlack } from 'lib/lemon-ui/icons'
import { range } from 'lib/utils'
import { urls } from 'scenes/urls'

import { InsightShortId, SlackChannelType } from '~/types'
import { InsightShortId } from '~/types'

export interface SubscriptionBaseProps {
dashboardId?: number
Expand Down Expand Up @@ -80,28 +79,3 @@ export const timeOptions: LemonSelectOptions<string> = range(0, 24).map((x) => (
value: String(x),
label: `${String(x).padStart(2, '0')}:00`,
}))

export const getSlackChannelOptions = (
value: string,
slackChannels?: SlackChannelType[] | null
): LemonInputSelectOption[] => {
return slackChannels
? slackChannels.map((x) => ({
key: `${x.id}|#${x.name}`,
labelComponent: (
<span className="flex items-center">
{x.is_private ? `🔒${x.name}` : `#${x.name}`}
{x.is_ext_shared ? <IconSlackExternal className="ml-2" /> : null}
</span>
),
label: `${x.id} #${x.name}`,
}))
: value
? [
{
key: value,
label: value?.split('|')?.pop() || value,
},
]
: []
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@ import { Form } from 'kea-forms'
import { UserActivityIndicator } from 'lib/components/UserActivityIndicator/UserActivityIndicator'
import { usersLemonSelectOptions } from 'lib/components/UserSelectItem'
import { dayjs } from 'lib/dayjs'
import { integrationsLogic } from 'lib/integrations/integrationsLogic'
import { SlackChannelPicker } from 'lib/integrations/SlackIntegrationHelpers'
import { IconChevronLeft } from 'lib/lemon-ui/icons'
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LemonField } from 'lib/lemon-ui/LemonField'
import { LemonInputSelect, LemonInputSelectOption } from 'lib/lemon-ui/LemonInputSelect/LemonInputSelect'
import { LemonInputSelect } from 'lib/lemon-ui/LemonInputSelect/LemonInputSelect'
import { LemonLabel } from 'lib/lemon-ui/LemonLabel/LemonLabel'
import { LemonModal } from 'lib/lemon-ui/LemonModal'
import { LemonSelect } from 'lib/lemon-ui/LemonSelect'
import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton'
import { useEffect, useMemo } from 'react'
import { membersLogic } from 'scenes/organization/membersLogic'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
import { integrationsLogic } from 'scenes/settings/project/integrationsLogic'
import { urls } from 'scenes/urls'

import { subscriptionLogic } from '../subscriptionLogic'
Expand All @@ -25,7 +25,6 @@ import {
bysetposOptions,
frequencyOptionsPlural,
frequencyOptionsSingular,
getSlackChannelOptions,
intervalOptions,
monthlyWeekdayOptions,
SubscriptionBaseProps,
Expand Down Expand Up @@ -59,15 +58,14 @@ export function EditSubscription({
})

const { meFirstMembers, membersLoading } = useValues(membersLogic)
const { subscription, subscriptionLoading, isSubscriptionSubmitting, subscriptionChanged, isMemberOfSlackChannel } =
useValues(logic)
const { subscription, subscriptionLoading, isSubscriptionSubmitting, subscriptionChanged } = useValues(logic)
const { preflight, siteUrlMisconfigured } = useValues(preflightLogic)
const { deleteSubscription } = useActions(subscriptionslogic)
const { slackChannels, slackChannelsLoading, slackIntegration, addToSlackButtonUrl } = useValues(integrationsLogic)
const { loadSlackChannels } = useActions(integrationsLogic)
const { slackIntegrations, addToSlackButtonUrl } = useValues(integrationsLogic)
// TODO: Fix this so that we use the appropriate config...
const firstSlackIntegration = slackIntegrations?.[0]

const emailDisabled = !preflight?.email_service_available
const slackDisabled = !slackIntegration

const _onDelete = (): void => {
if (id !== 'new') {
Expand All @@ -76,23 +74,6 @@ export function EditSubscription({
}
}

useEffect(() => {
if (subscription?.target_type === 'slack' && slackIntegration) {
loadSlackChannels()
}
}, [subscription?.target_type, slackIntegration])

// If slackChannels aren't loaded, make sure we display only the channel name and not the actual underlying value
const slackChannelOptions: LemonInputSelectOption[] = useMemo(
() => getSlackChannelOptions(subscription?.target_value, slackChannels),
[slackChannels, subscription?.target_value]
)

const showSlackMembershipWarning =
subscription.target_value &&
subscription.target_type === 'slack' &&
!isMemberOfSlackChannel(subscription.target_value)

const formatter = new Intl.DateTimeFormat('en-US', { timeZoneName: 'shortGeneric' })
const parts = formatter.formatToParts(new Date())
const currentTimezone = parts?.find((part) => part.type === 'timeZoneName')?.value
Expand Down Expand Up @@ -222,7 +203,7 @@ export function EditSubscription({

{subscription.target_type === 'slack' ? (
<>
{slackDisabled ? (
{!firstSlackIntegration ? (
<>
{addToSlackButtonUrl() ? (
<LemonBanner type="info">
Expand Down Expand Up @@ -278,45 +259,13 @@ export function EditSubscription({
}
>
{({ value, onChange }) => (
<LemonInputSelect
onChange={(val) => onChange(val[0] ?? null)}
value={value ? [value] : []}
disabled={slackDisabled}
mode="single"
data-attr="select-slack-channel"
placeholder="Select a channel..."
options={slackChannelOptions}
loading={slackChannelsLoading}
<SlackChannelPicker
value={value}
onChange={onChange}
integration={firstSlackIntegration}
/>
)}
</LemonField>

{showSlackMembershipWarning ? (
<LemonField name="memberOfSlackChannel">
<LemonBanner type="info">
<div className="flex gap-2 items-center">
<span>
The PostHog Slack App is not in this channel. Please add it
to the channel otherwise Subscriptions will fail to be
delivered.{' '}
<Link
to="https://posthog.com/docs/webhooks/slack"
target="_blank"
>
See the Docs for more information
</Link>
</span>
<LemonButton
type="secondary"
onClick={loadSlackChannels}
loading={slackChannelsLoading}
>
Check again
</LemonButton>
</div>
</LemonBanner>
</LemonField>
) : null}
</>
)}
</>
Expand Down
130 changes: 130 additions & 0 deletions frontend/src/lib/integrations/SlackIntegrationHelpers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { LemonBanner, LemonButton, LemonInputSelect, LemonInputSelectOption, Link } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { UserActivityIndicator } from 'lib/components/UserActivityIndicator/UserActivityIndicator'
import { IconSlack, IconSlackExternal } from 'lib/lemon-ui/icons'
import { useMemo } from 'react'

import { IntegrationType, SlackChannelType } from '~/types'

import { slackIntegrationLogic } from './slackIntegrationLogic'

const getSlackChannelOptions = (slackChannels?: SlackChannelType[] | null): LemonInputSelectOption[] | null => {
return slackChannels
? slackChannels.map((x) => ({
key: `${x.id}|#${x.name}`,
labelComponent: (
<span className="flex items-center">
{x.is_private ? `🔒${x.name}` : `#${x.name}`}
{x.is_ext_shared ? <IconSlackExternal className="ml-2" /> : null}
</span>
),
label: `${x.id} #${x.name}`,
}))
: null
}

export type SlackChannelPickerProps = {
integration: IntegrationType
value?: string
onChange?: (value: string | null) => void
disabled?: boolean
}

export function SlackChannelPicker({ onChange, value, integration, disabled }: SlackChannelPickerProps): JSX.Element {
const { slackChannels, slackChannelsLoading, isMemberOfSlackChannel } = useValues(
slackIntegrationLogic({ id: integration.id })
)
const { loadSlackChannels } = useActions(slackIntegrationLogic({ id: integration.id }))

// If slackChannels aren't loaded, make sure we display only the channel name and not the actual underlying value
const slackChannelOptions = useMemo(() => getSlackChannelOptions(slackChannels), [slackChannels])
const showSlackMembershipWarning = value && isMemberOfSlackChannel(value) === false

// Sometimes the parent will only store the channel ID and not the name, so we need to handle that

const modifiedValue = useMemo(() => {
if (value?.split('|').length === 1) {
const channel = slackChannels?.find((x) => x.id === value)

if (channel) {
return `${channel.id}|#${channel.name}`
}
}

return value
}, [value, slackChannels])

return (
<>
<LemonInputSelect
onChange={(val) => onChange?.(val[0] ?? null)}
value={modifiedValue ? [modifiedValue] : []}
onFocus={() => !slackChannels && !slackChannelsLoading && loadSlackChannels()}
disabled={disabled}
mode="single"
data-attr="select-slack-channel"
placeholder="Select a channel..."
options={
slackChannelOptions ??
(modifiedValue
? [
{
key: modifiedValue,
label: modifiedValue?.split('|')[1] ?? modifiedValue,
},
]
: [])
}
loading={slackChannelsLoading}
/>

{showSlackMembershipWarning ? (
<LemonBanner type="info">
<div className="flex gap-2 items-center">
<span>
The PostHog Slack App is not in this channel. Please add it to the channel otherwise
Subscriptions will fail to be delivered.{' '}
<Link to="https://posthog.com/docs/webhooks/slack" target="_blank">
See the Docs for more information
</Link>
</span>
<LemonButton type="secondary" onClick={loadSlackChannels} loading={slackChannelsLoading}>
Check again
</LemonButton>
</div>
</LemonBanner>
) : null}
</>
)
}

export function SlackIntegrationView({
integration,
suffix,
}: {
integration: IntegrationType
suffix?: JSX.Element
}): JSX.Element {
return (
<div className="rounded border flex justify-between items-center p-2 bg-bg-light">
<div className="flex items-center gap-4 ml-2">
<IconSlack className="text-2xl" />
<div>
<div>
Connected to <strong>{integration.config.team.name}</strong> workspace
</div>
{integration.created_by ? (
<UserActivityIndicator
at={integration.created_at}
by={integration.created_by}
prefix="Updated"
className="text-muted"
/>
) : null}
</div>
</div>

{suffix}
</div>
)
}
Loading

0 comments on commit 80847e1

Please sign in to comment.