import {Fragment, useCallback, useEffect, useState} from 'react'; import styled from '@emotion/styled'; import * as qs from 'query-string'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {openInviteMissingMembersModal} from 'sentry/actionCreators/modal'; import {promptsCheck, promptsUpdate} from 'sentry/actionCreators/prompts'; import {Button} from 'sentry/components/button'; import ButtonBar from 'sentry/components/buttonBar'; import Card from 'sentry/components/card'; import Carousel from 'sentry/components/carousel'; import {openConfirmModal} from 'sentry/components/confirm'; import type {MenuItemProps} from 'sentry/components/dropdownMenu'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; import FloatingFeedbackWidget from 'sentry/components/feedback/widget/floatingFeedbackWidget'; import ExternalLink from 'sentry/components/links/externalLink'; import QuestionTooltip from 'sentry/components/questionTooltip'; import {IconCommit, IconEllipsis, IconGithub, IconMail} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {MissingMember, Organization, OrgRole} from 'sentry/types'; import {trackAnalytics} from 'sentry/utils/analytics'; import {promptIsDismissed} from 'sentry/utils/promptIsDismissed'; import useApi from 'sentry/utils/useApi'; import {useLocation} from 'sentry/utils/useLocation'; import withOrganization from 'sentry/utils/withOrganization'; const MAX_MEMBERS_TO_SHOW = 5; type Props = { allowedRoles: OrgRole[]; onModalClose: () => void; onSendInvite: () => void; organization: Organization; }; export function InviteBanner({ organization, allowedRoles, onSendInvite, onModalClose, }: Props) { const isEligibleForBanner = organization.access.includes('org:write') && organization.githubNudgeInvite; const [sendingInvite, setSendingInvite] = useState(false); const [showBanner, setShowBanner] = useState(false); const [missingMembers, setMissingMembers] = useState( [] as MissingMember[] ); const api = useApi(); // NOTE: this is currently used for Github only const promptsFeature = `github_missing_members`; const location = useLocation(); const snoozePrompt = useCallback(async () => { trackAnalytics('github_invite_banner.snoozed', { organization, }); setShowBanner(false); await promptsUpdate(api, { organization, feature: promptsFeature, status: 'snoozed', }); }, [api, organization, promptsFeature]); const openInviteModal = useCallback(() => { openInviteMissingMembersModal({ allowedRoles, missingMembers, organization, onClose: onModalClose, }); }, [allowedRoles, missingMembers, organization, onModalClose]); const fetchMissingMembers = useCallback(async () => { try { const data = await api.requestPromise( `/organizations/${organization.slug}/missing-members/`, { method: 'GET', } ); const githubMissingMembers = data?.filter( integrationMissingMembers => integrationMissingMembers.integration === 'github' )[0]; setMissingMembers(githubMissingMembers?.users || []); } catch (err) { if (err.status !== 403) { addErrorMessage(t('Unable to fetching missing commit authors')); } } }, [api, organization]); useEffect(() => { if (!isEligibleForBanner) { return; } fetchMissingMembers(); promptsCheck(api, { organization, feature: promptsFeature, }).then(prompt => { setShowBanner(!promptIsDismissed(prompt)); }); }, [api, organization, promptsFeature, isEligibleForBanner, fetchMissingMembers]); useEffect(() => { const {inviteMissingMembers} = qs.parse(location.search); if (isEligibleForBanner && inviteMissingMembers) { openInviteModal(); } }, [openInviteModal, location, isEligibleForBanner]); if (isEligibleForBanner && showBanner && missingMembers.length > 0) { trackAnalytics('github_invite_banner.viewed', { organization, members_shown: missingMembers.slice(0, MAX_MEMBERS_TO_SHOW).length, total_members: missingMembers.length, }); } if (!isEligibleForBanner || !showBanner || missingMembers.length === 0) { return null; } // TODO(cathy): include docs link const menuItems: MenuItemProps[] = [ { key: 'invite-banner-snooze', label: t('Hide Missing Members'), onAction: () => { openConfirmModal({ message: t('Are you sure you want to snooze this banner?'), onConfirm: snoozePrompt, }); }, }, ]; const handleSendInvite = async (email: string) => { if (sendingInvite) { return; } setSendingInvite(true); try { await api.requestPromise( `/organizations/${organization.slug}/members/?referrer=github_nudge_invite`, { method: 'POST', data: {email}, } ); addSuccessMessage(tct('Sent invite to [email]', {email})); onSendInvite(); const updatedMissingMembers = missingMembers.filter( member => member.email !== email ); setMissingMembers(updatedMissingMembers); } catch { addErrorMessage(t('Error sending invite')); } setSendingInvite(false); }; return ( {/* this is temporary to collect feedback about the banner */} {t('Bring your full GitHub team on board in Sentry')} {tct('[missingMemberCount] missing members', { missingMemberCount: missingMembers.length, })} , 'aria-label': t('Actions'), }} /> ); } export default withOrganization(InviteBanner); type MemberCardsProps = { handleSendInvite: (email: string) => void; missingMembers: MissingMember[]; openInviteModal: () => void; }; function MemberCards({ missingMembers, handleSendInvite, openInviteModal, }: MemberCardsProps) { return ( {missingMembers.slice(0, MAX_MEMBERS_TO_SHOW).map(member => { const username = member.externalId.split(':').pop(); return ( {/* TODO(cathy): create mapping from integration to lambda external link function */} @{username} {tct('[commitCount] Recent Commits', {commitCount: member.commitCount})} {member.email} ); })} {tct('See all [missingMembersCount] missing members', { missingMembersCount: missingMembers.length, })} {tct('Accounting for [totalCommits] recent commits', { totalCommits: missingMembers.reduce( (acc, curr) => acc + curr.commitCount, 0 ), })} ); } const StyledCard = styled(Card)` display: flex; padding: ${space(2)}; padding-bottom: ${space(1.5)}; overflow: hidden; `; const CardTitleContainer = styled('div')` display: flex; justify-content: space-between; `; const CardTitleContent = styled('div')` display: flex; flex-direction: column; `; const CardTitle = styled('h6')` margin: 0; font-size: ${p => p.theme.fontSizeLarge}; font-weight: bold; color: ${p => p.theme.gray400}; `; const Subtitle = styled('div')` display: flex; align-items: center; font-size: ${p => p.theme.fontSizeSmall}; font-weight: 400; color: ${p => p.theme.gray300}; gap: ${space(0.5)}; `; const MemberEmail = styled('div')` display: block; max-width: 70%; font-size: ${p => p.theme.fontSizeSmall}; font-weight: 400; color: ${p => p.theme.gray300}; text-overflow: ellipsis; overflow: hidden; `; const MemberCard = styled(Card)` display: flex; flex-direction: row; flex-wrap: wrap; min-width: 30%; margin: ${space(1)} ${space(0.5)} 0 0; padding: ${space(2)} 18px; justify-content: center; align-items: center; `; const MemberCardContent = styled('div')` display: flex; flex-direction: column; flex: 1 1; min-width: 50%; max-width: 75%; `; const MemberCardContentRow = styled('div')` display: flex; align-items: center; margin-bottom: ${space(0.25)}; font-size: ${p => p.theme.fontSizeSmall}; gap: ${space(0.75)}; `; export const StyledExternalLink = styled(ExternalLink)` font-size: ${p => p.theme.fontSizeMedium}; `; const SeeMore = styled('div')` font-size: ${p => p.theme.fontSizeLarge}; `;