import {Fragment, useState} from 'react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {ModalRenderProps} from 'sentry/actionCreators/modal'; import {Button} from 'sentry/components/button'; import ButtonBar from 'sentry/components/buttonBar'; import Checkbox from 'sentry/components/checkbox'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import { InviteModalHook, InviteModalRenderFunc, } from 'sentry/components/modals/inviteMembersModal'; import {StatusMessage} from 'sentry/components/modals/inviteMembersModal/inviteStatusMessage'; import {InviteStatus} from 'sentry/components/modals/inviteMembersModal/types'; import {MissingMemberInvite} from 'sentry/components/modals/inviteMissingMembersModal/types'; import PanelItem from 'sentry/components/panels/panelItem'; import PanelTable from 'sentry/components/panels/panelTable'; import RoleSelectControl from 'sentry/components/roleSelectControl'; import TeamSelector from 'sentry/components/teamSelector'; import {Tooltip} from 'sentry/components/tooltip'; import {IconCheckmark, IconCommit, IconGithub, IconInfo} from 'sentry/icons'; import {t, tct, tn} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {MissingMember, Organization, OrgRole} from 'sentry/types'; import {trackAnalytics} from 'sentry/utils/analytics'; import useApi from 'sentry/utils/useApi'; import {StyledExternalLink} from 'sentry/views/settings/organizationMembers/inviteBanner'; export interface InviteMissingMembersModalProps extends ModalRenderProps { allowedRoles: OrgRole[]; // the API response returns {integration: "github", users: []} // but we only ever return Github missing members at the moment // so we can simplify the props and state to only store the users (missingMembers) missingMembers: MissingMember[]; organization: Organization; } export function InviteMissingMembersModal({ missingMembers, organization, allowedRoles, closeModal, modalContainerRef, }: InviteMissingMembersModalProps) { const initialMemberInvites = (missingMembers || []).map(member => ({ email: member.email, commitCount: member.commitCount, role: organization.defaultRole, teamSlugs: new Set(), externalId: member.externalId, selected: true, })); const [memberInvites, setMemberInvites] = useState(initialMemberInvites); const referrer = 'github_nudge_invite'; const [inviteStatus, setInviteStatus] = useState({}); const [sendingInvites, setSendingInvites] = useState(false); const [complete, setComplete] = useState(false); const api = useApi(); if (memberInvites.length === 0 || !organization.access.includes('org:write')) { return null; } const setRole = (role: string, index: number) => { setMemberInvites(currentMemberInvites => currentMemberInvites.map((member, i) => { if (i === index) { member.role = role; } return member; }) ); }; const setTeams = (teamSlugs: string[], index: number) => { setMemberInvites(currentMemberInvites => currentMemberInvites.map((member, i) => { if (i === index) { member.teamSlugs = new Set(teamSlugs); } return member; }) ); }; const selectAll = (checked: boolean) => { const selectedMembers = memberInvites.map(m => ({...m, selected: checked})); setMemberInvites(selectedMembers); }; const toggleCheckbox = (checked: boolean, index: number) => { const selectedMembers = [...memberInvites]; selectedMembers[index].selected = checked; setMemberInvites(selectedMembers); }; const renderStatusMessage = () => { if (sendingInvites) { return ( {t('Sending organization invitations\u2026')} ); } if (complete) { const statuses = Object.values(inviteStatus); const sentCount = statuses.filter(i => i.sent).length; const errorCount = statuses.filter(i => i.error).length; const invites = {tn('%s invite', '%s invites', sentCount)}; const tctComponents = { invites, failed: errorCount, }; return ( {errorCount > 0 ? tct('Sent [invites], [failed] failed to send.', tctComponents) : tct('Sent [invites]', tctComponents)} ); } return null; }; const sendMemberInvite = async (invite: MissingMemberInvite) => { const data = { email: invite.email, teams: [...invite.teamSlugs], role: invite.role, }; try { await api.requestPromise( `/organizations/${organization?.slug}/members/?referrer=${referrer}`, { method: 'POST', data, } ); } catch (err) { const errorResponse = err.responseJSON; // Use the email error message if available. This inconsistently is // returned as either a list of errors for the field, or a single error. const emailError = !errorResponse || !errorResponse.email ? false : Array.isArray(errorResponse.email) ? errorResponse.email[0] : errorResponse.email; const error = emailError || t('Could not invite user'); setInviteStatus(prevInviteStatus => { return {...prevInviteStatus, [invite.email]: {sent: false, error}}; }); } setInviteStatus(prevInviteStatus => { return {...prevInviteStatus, [invite.email]: {sent: true}}; }); }; const sendMemberInvites = async () => { setSendingInvites(true); await Promise.all(memberInvites.filter(i => i.selected).map(sendMemberInvite)); setSendingInvites(false); setComplete(true); if (organization) { trackAnalytics( 'missing_members_invite_modal.requests_sent', { organization, }, {startSession: true} ); } }; const selectedCount = memberInvites.filter(i => i.selected).length; const selectedAll = memberInvites.length === selectedCount; const inviteButtonLabel = () => { return tct('Invite [memberCount] missing member[isPlural]', { memberCount: memberInvites.length === selectedCount ? `all ${selectedCount}` : selectedCount === 0 ? '' : selectedCount, isPlural: selectedCount !== 1 ? 's' : '', }); }; const hookRenderer: InviteModalRenderFunc = ({sendInvites, canSend, headerInfo}) => (

{t('Invite Your Dev Team')}

{headerInfo} selectAll(!selectedAll)} checked={selectedAll} />, t('User Information'), {t('Recent Commits')} , t('Role'), t('Team'), ]} stickyHeaders > {memberInvites?.map((member, i) => { const checked = memberInvites[i].selected; const username = member.externalId.split(':').pop(); return (
toggleCheckbox(!checked, i)} />
@{username} {member.email} {member.commitCount} setRole(value?.value, i)} menuPortalTarget={modalContainerRef?.current} isInsideModal /> setTeams(opts ? opts.map(v => v.value) : [], i)} multiple clearable menuPortalTarget={modalContainerRef?.current} isInsideModal />
); })}
{renderStatusMessage()}
); return ( {hookRenderer} ); } export default InviteMissingMembersModal; const StyledPanelTable = styled(PanelTable)` grid-template-columns: max-content 1fr max-content 1fr 1fr; overflow: scroll; max-height: 475px; `; const StyledHeader = styled('div')` display: flex; gap: ${space(0.5)}; `; const StyledPanelItem = styled(PanelItem)` flex-direction: column; `; const Footer = styled('div')` display: flex; justify-content: space-between; `; const ContentRow = styled('div')` display: flex; align-items: center; font-size: ${p => p.theme.fontSizeMedium}; gap: ${space(0.75)}; `; const MemberEmail = styled('div')` display: block; max-width: 150px; font-size: ${p => p.theme.fontSizeSmall}; font-weight: 400; color: ${p => p.theme.gray300}; text-overflow: ellipsis; overflow: hidden; `; export const modalCss = css` width: 80%; max-width: 870px; `;