Files
2024-02-15 17:01:50 +00:00

364 lines
11 KiB
TypeScript

import {Fragment, useState} from 'react';
import {css} from '@emotion/react';
import styled from '@emotion/styled';
import type {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 {StatusMessage} from 'sentry/components/modals/inviteMembersModal/inviteStatusMessage';
import type {InviteStatus} from 'sentry/components/modals/inviteMembersModal/types';
import type {MissingMemberInvite} from 'sentry/components/modals/inviteMissingMembersModal/types';
import type {InviteModalRenderFunc} from 'sentry/components/modals/memberInviteModalCustomization';
import {InviteModalHook} from 'sentry/components/modals/memberInviteModalCustomization';
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 type {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<string>(),
externalId: member.externalId,
selected: true,
}));
const [memberInvites, setMemberInvites] =
useState<MissingMemberInvite[]>(initialMemberInvites);
const referrer = 'github_nudge_invite';
const [inviteStatus, setInviteStatus] = useState<InviteStatus>({});
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 (
<StatusMessage>
<LoadingIndicator mini relative hideMessage size={16} />
{t('Sending organization invitations\u2026')}
</StatusMessage>
);
}
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 = <strong>{tn('%s invite', '%s invites', sentCount)}</strong>;
const tctComponents = {
invites,
failed: errorCount,
};
return (
<StatusMessage status="success">
<IconCheckmark size="sm" />
{errorCount > 0
? tct('Sent [invites], [failed] failed to send.', tctComponents)
: tct('Sent [invites]', tctComponents)}
</StatusMessage>
);
}
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}) => (
<Fragment>
<h4>{t('Invite Your Dev Team')}</h4>
{headerInfo}
<StyledPanelTable
headers={[
<Checkbox
key={0}
aria-label={selectedAll ? t('Deselect All') : t('Select All')}
onChange={() => selectAll(!selectedAll)}
checked={selectedAll}
/>,
t('User Information'),
<StyledHeader key={1}>
{t('Recent Commits')}
<Tooltip title={t('Based on the last 30 days of commit data')}>
<IconInfo size="xs" />
</Tooltip>
</StyledHeader>,
t('Role'),
t('Team'),
]}
stickyHeaders
>
{memberInvites?.map((member, i) => {
const checked = memberInvites[i].selected;
const username = member.externalId.split(':').pop();
return (
<Fragment key={i}>
<div>
<Checkbox
aria-label={t('Select %s', member.email)}
checked={checked}
onChange={() => toggleCheckbox(!checked, i)}
/>
</div>
<StyledPanelItem>
<ContentRow>
<IconGithub size="sm" />
<StyledExternalLink href={`https://github.com/${username}`}>
@{username}
</StyledExternalLink>
</ContentRow>
<MemberEmail>{member.email}</MemberEmail>
</StyledPanelItem>
<ContentRow>
<IconCommit size="sm" />
{member.commitCount}
</ContentRow>
<RoleSelectControl
aria-label={t('Role')}
data-test-id="select-role"
disabled={false}
value={member.role}
roles={allowedRoles}
disableUnallowed
onChange={value => setRole(value?.value, i)}
menuPortalTarget={modalContainerRef?.current}
isInsideModal
/>
<TeamSelector
organization={organization}
aria-label={t('Add to Team')}
data-test-id="select-teams"
disabled={false}
placeholder={t('None')}
onChange={opts => setTeams(opts ? opts.map(v => v.value) : [], i)}
multiple
clearable
menuPortalTarget={modalContainerRef?.current}
isInsideModal
/>
</Fragment>
);
})}
</StyledPanelTable>
<Footer>
<div>{renderStatusMessage()}</div>
<ButtonBar gap={1}>
<Button
size="sm"
onClick={() => {
closeModal();
}}
>
{t('Cancel')}
</Button>
<Button
size="sm"
priority="primary"
aria-label={t('Send Invites')}
onClick={sendInvites}
disabled={!canSend || selectedCount === 0}
analyticsEventName="Github Invite Modal: Invite"
analyticsEventKey="github_invite_modal.invite"
analyticsParams={{
invited_all: memberInvites.length === selectedCount,
invited_count: selectedCount,
}}
>
{inviteButtonLabel()}
</Button>
</ButtonBar>
</Footer>
</Fragment>
);
return (
<InviteModalHook
organization={organization}
willInvite
onSendInvites={sendMemberInvites}
>
{hookRenderer}
</InviteModalHook>
);
}
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;
`;