|
@@ -0,0 +1,340 @@
|
|
|
+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,
|
|
|
+ StatusMessage,
|
|
|
+} from 'sentry/components/modals/inviteMembersModal';
|
|
|
+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 withOrganization from 'sentry/utils/withOrganization';
|
|
|
+import {
|
|
|
+ StyledExternalLink,
|
|
|
+ Subtitle,
|
|
|
+} from 'sentry/views/settings/organizationMembers/inviteBanner';
|
|
|
+
|
|
|
+export interface InviteMissingMembersModalProps extends ModalRenderProps {
|
|
|
+ allowedRoles: OrgRole[];
|
|
|
+ missingMembers: {integration: string; users: MissingMember[]};
|
|
|
+ organization: Organization;
|
|
|
+}
|
|
|
+
|
|
|
+export function InviteMissingMembersModal({
|
|
|
+ missingMembers,
|
|
|
+ organization,
|
|
|
+ allowedRoles,
|
|
|
+ closeModal,
|
|
|
+}: InviteMissingMembersModalProps) {
|
|
|
+ const initialMemberInvites = (missingMembers.users || []).map(member => ({
|
|
|
+ email: member.email,
|
|
|
+ commitCount: member.commitCount,
|
|
|
+ role: organization.defaultRole,
|
|
|
+ teamSlugs: new Set<string>(),
|
|
|
+ externalId: member.externalId,
|
|
|
+ selected: false,
|
|
|
+ }));
|
|
|
+ const [memberInvites, setMemberInvites] =
|
|
|
+ useState<MissingMemberInvite[]>(initialMemberInvites);
|
|
|
+ const [inviteStatus, setInviteStatus] = useState<InviteStatus>({});
|
|
|
+ const [sendingInvites, setSendingInvites] = useState(false);
|
|
|
+ const [complete, setComplete] = useState(false);
|
|
|
+
|
|
|
+ const api = useApi();
|
|
|
+
|
|
|
+ if (!memberInvites || !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/`, {
|
|
|
+ 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'),
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ {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>
|
|
|
+ <Subtitle>{member.email}</Subtitle>
|
|
|
+ </StyledPanelItem>
|
|
|
+ <ContentRow>
|
|
|
+ <IconCommit size="sm" />
|
|
|
+ {member.commitCount}
|
|
|
+ </ContentRow>
|
|
|
+ <RoleSelectControl
|
|
|
+ aria-label={t('Role')}
|
|
|
+ data-test-id="select-role"
|
|
|
+ disabled={false}
|
|
|
+ roles={allowedRoles}
|
|
|
+ disableUnallowed
|
|
|
+ onChange={value => setRole(value?.value, i)}
|
|
|
+ />
|
|
|
+ <TeamSelector
|
|
|
+ organization={organization}
|
|
|
+ aria-label={t('Add to Team')}
|
|
|
+ data-test-id="select-teams"
|
|
|
+ disabled={false}
|
|
|
+ placeholder={t('Add to teams\u2026')}
|
|
|
+ onChange={opts => setTeams(opts ? opts.map(v => v.value) : [], i)}
|
|
|
+ multiple
|
|
|
+ clearable
|
|
|
+ />
|
|
|
+ </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}
|
|
|
+ >
|
|
|
+ {inviteButtonLabel()}
|
|
|
+ </Button>
|
|
|
+ </ButtonBar>
|
|
|
+ </Footer>
|
|
|
+ </Fragment>
|
|
|
+ );
|
|
|
+
|
|
|
+ return (
|
|
|
+ <InviteModalHook
|
|
|
+ organization={organization}
|
|
|
+ willInvite
|
|
|
+ onSendInvites={sendMemberInvites}
|
|
|
+ >
|
|
|
+ {hookRenderer}
|
|
|
+ </InviteModalHook>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+export default withOrganization(InviteMissingMembersModal);
|
|
|
+
|
|
|
+const StyledPanelTable = styled(PanelTable)`
|
|
|
+ grid-template-columns: max-content 1fr max-content 1fr 1fr;
|
|
|
+ overflow: visible;
|
|
|
+`;
|
|
|
+
|
|
|
+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};
|
|
|
+ & > *:first-child {
|
|
|
+ margin-right: ${space(0.75)};
|
|
|
+ }
|
|
|
+`;
|
|
|
+
|
|
|
+export const modalCss = css`
|
|
|
+ width: 80%;
|
|
|
+ max-width: 870px;
|
|
|
+`;
|