123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509 |
- import {Fragment} from 'react';
- import {css} from '@emotion/react';
- import styled from '@emotion/styled';
- import {ModalRenderProps} from 'sentry/actionCreators/modal';
- import Alert from 'sentry/components/alert';
- import AsyncComponent from 'sentry/components/asyncComponent';
- import {Button} from 'sentry/components/button';
- import ButtonBar from 'sentry/components/buttonBar';
- import HookOrDefault from 'sentry/components/hookOrDefault';
- import LoadingIndicator from 'sentry/components/loadingIndicator';
- import {ORG_ROLES} from 'sentry/constants';
- import {IconAdd, IconCheckmark, IconWarning} from 'sentry/icons';
- import {t, tct, tn} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import {Organization} from 'sentry/types';
- import {trackAnalytics} from 'sentry/utils/analytics';
- import {uniqueId} from 'sentry/utils/guid';
- import withLatestContext from 'sentry/utils/withLatestContext';
- import InviteRowControl from './inviteRowControl';
- import {InviteRow, InviteStatus, NormalizedInvite} from './types';
- type Props = AsyncComponent['props'] &
- ModalRenderProps & {
- organization: Organization;
- initialData?: Partial<InviteRow>[];
- source?: string;
- };
- type State = AsyncComponent['state'] & {
- complete: boolean;
- inviteStatus: InviteStatus;
- pendingInvites: InviteRow[];
- sendingInvites: boolean;
- };
- const DEFAULT_ROLE = 'member';
- const InviteModalHook = HookOrDefault({
- hookName: 'member-invite-modal:customization',
- defaultComponent: ({onSendInvites, children}) =>
- children({sendInvites: onSendInvites, canSend: true}),
- });
- type InviteModalRenderFunc = React.ComponentProps<typeof InviteModalHook>['children'];
- class InviteMembersModal extends AsyncComponent<Props, State> {
- get inviteTemplate(): InviteRow {
- return {
- emails: new Set(),
- teams: new Set(),
- role: DEFAULT_ROLE,
- };
- }
- /**
- * Used for analytics tracking of the modals usage.
- */
- sessionId = '';
- componentDidMount() {
- this.sessionId = uniqueId();
- const {organization, source} = this.props;
- trackAnalytics('invite_modal.opened', {
- organization,
- modal_session: this.sessionId,
- can_invite: this.willInvite,
- source,
- });
- }
- getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
- const orgId = this.props.organization.slug;
- return [['member', `/organizations/${orgId}/members/me/`]];
- }
- getDefaultState() {
- const state = super.getDefaultState();
- const {initialData} = this.props;
- const pendingInvites = initialData
- ? initialData.map(initial => ({
- ...this.inviteTemplate,
- ...initial,
- }))
- : [this.inviteTemplate];
- return {
- ...state,
- pendingInvites,
- inviteStatus: {},
- complete: false,
- sendingInvites: false,
- };
- }
- reset = () => {
- this.setState({
- pendingInvites: [this.inviteTemplate],
- inviteStatus: {},
- complete: false,
- sendingInvites: false,
- });
- trackAnalytics('invite_modal.add_more', {
- organization: this.props.organization,
- modal_session: this.sessionId,
- });
- };
- sendInvite = async (invite: NormalizedInvite) => {
- const {slug} = this.props.organization;
- const data = {
- email: invite.email,
- teams: [...invite.teams],
- role: invite.role,
- };
- this.setState(state => ({
- inviteStatus: {...state.inviteStatus, [invite.email]: {sent: false}},
- }));
- const endpoint = this.willInvite
- ? `/organizations/${slug}/members/`
- : `/organizations/${slug}/invite-requests/`;
- try {
- await this.api.requestPromise(endpoint, {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');
- this.setState(state => ({
- inviteStatus: {...state.inviteStatus, [invite.email]: {sent: false, error}},
- }));
- return;
- }
- this.setState(state => ({
- inviteStatus: {...state.inviteStatus, [invite.email]: {sent: true}},
- }));
- };
- sendInvites = async () => {
- this.setState({sendingInvites: true});
- await Promise.all(this.invites.map(this.sendInvite));
- this.setState({sendingInvites: false, complete: true});
- trackAnalytics(
- this.willInvite ? 'invite_modal.invites_sent' : 'invite_modal.requests_sent',
- {
- organization: this.props.organization,
- modal_session: this.sessionId,
- }
- );
- };
- addInviteRow = () =>
- this.setState(state => ({
- pendingInvites: [...state.pendingInvites, this.inviteTemplate],
- }));
- setEmails(emails: string[], index: number) {
- this.setState(state => {
- const pendingInvites = [...state.pendingInvites];
- pendingInvites[index] = {...pendingInvites[index], emails: new Set(emails)};
- return {pendingInvites};
- });
- }
- setTeams(teams: string[], index: number) {
- this.setState(state => {
- const pendingInvites = [...state.pendingInvites];
- pendingInvites[index] = {...pendingInvites[index], teams: new Set(teams)};
- return {pendingInvites};
- });
- }
- setRole(role: string, index: number) {
- this.setState(state => {
- const pendingInvites = [...state.pendingInvites];
- pendingInvites[index] = {...pendingInvites[index], role};
- return {pendingInvites};
- });
- }
- removeInviteRow(index: number) {
- this.setState(state => {
- const pendingInvites = [...state.pendingInvites];
- pendingInvites.splice(index, 1);
- return {pendingInvites};
- });
- }
- get invites(): NormalizedInvite[] {
- return this.state.pendingInvites.reduce<NormalizedInvite[]>(
- (acc, row) => [
- ...acc,
- ...[...row.emails].map(email => ({email, teams: row.teams, role: row.role})),
- ],
- []
- );
- }
- get hasDuplicateEmails() {
- const emails = this.invites.map(inv => inv.email);
- return emails.length !== new Set(emails).size;
- }
- get isValidInvites() {
- return this.invites.length > 0 && !this.hasDuplicateEmails;
- }
- get statusMessage() {
- const {sendingInvites, complete, inviteStatus} = this.state;
- if (sendingInvites) {
- return (
- <StatusMessage>
- <LoadingIndicator mini relative hideMessage size={16} />
- {this.willInvite
- ? t('Sending organization invitations\u2026')
- : t('Sending invite requests\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;
- if (this.willInvite) {
- 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>
- );
- }
- const inviteRequests = (
- <strong>{tn('%s invite request', '%s invite requests', sentCount)}</strong>
- );
- const tctComponents = {
- inviteRequests,
- failed: errorCount,
- };
- return (
- <StatusMessage status="success">
- <IconCheckmark size="sm" />
- {errorCount > 0
- ? tct(
- '[inviteRequests] pending approval, [failed] failed to send.',
- tctComponents
- )
- : tct('[inviteRequests] pending approval', tctComponents)}
- </StatusMessage>
- );
- }
- if (this.hasDuplicateEmails) {
- return (
- <StatusMessage status="error">
- <IconWarning size="sm" />
- {t('Duplicate emails between invite rows.')}
- </StatusMessage>
- );
- }
- return null;
- }
- get willInvite() {
- return this.props.organization.access?.includes('member:write');
- }
- get inviteButtonLabel() {
- if (this.invites.length > 0) {
- const numberInvites = this.invites.length;
- // Note we use `t()` here because `tn()` expects the same # of string formatters
- const inviteText =
- numberInvites === 1 ? t('Send invite') : t('Send invites (%s)', numberInvites);
- const requestText =
- numberInvites === 1
- ? t('Send invite request')
- : t('Send invite requests (%s)', numberInvites);
- return this.willInvite ? inviteText : requestText;
- }
- return this.willInvite ? t('Send invite') : t('Send invite request');
- }
- render() {
- const {Footer, closeModal, organization} = this.props;
- const {pendingInvites, sendingInvites, complete, inviteStatus, member} = this.state;
- const disableInputs = sendingInvites || complete;
- // eslint-disable-next-line react/prop-types
- const hookRenderer: InviteModalRenderFunc = ({sendInvites, canSend, headerInfo}) => (
- <Fragment>
- <Heading>{t('Invite New Members')}</Heading>
- {this.willInvite ? (
- <Subtext>{t('Invite new members by email to join your organization.')}</Subtext>
- ) : (
- <Alert type="warning" showIcon>
- {t(
- 'You can’t invite users directly, but we’ll forward your request to an org owner or manager for approval.'
- )}
- </Alert>
- )}
- {headerInfo}
- <InviteeHeadings>
- <div>{t('Email addresses')}</div>
- <div>{t('Role')}</div>
- <div>{t('Add to team')}</div>
- </InviteeHeadings>
- <Rows>
- {pendingInvites.map(({emails, role, teams}, i) => (
- <StyledInviteRow
- key={i}
- disabled={disableInputs}
- emails={[...emails]}
- role={role}
- teams={[...teams]}
- roleOptions={member ? member.roles : ORG_ROLES}
- roleDisabledUnallowed={this.willInvite}
- inviteStatus={inviteStatus}
- onRemove={() => this.removeInviteRow(i)}
- onChangeEmails={opts => this.setEmails(opts?.map(v => v.value) ?? [], i)}
- onChangeRole={value => this.setRole(value?.value, i)}
- onChangeTeams={opts => this.setTeams(opts ? opts.map(v => v.value) : [], i)}
- disableRemove={disableInputs || pendingInvites.length === 1}
- />
- ))}
- </Rows>
- <AddButton
- disabled={disableInputs}
- size="sm"
- borderless
- onClick={this.addInviteRow}
- icon={<IconAdd size="xs" isCircled />}
- >
- {t('Add another')}
- </AddButton>
- <Footer>
- <FooterContent>
- <div>{this.statusMessage}</div>
- <ButtonBar gap={1}>
- {complete ? (
- <Fragment>
- <Button data-test-id="send-more" size="sm" onClick={this.reset}>
- {t('Send more invites')}
- </Button>
- <Button
- data-test-id="close"
- priority="primary"
- size="sm"
- onClick={() => {
- trackAnalytics('invite_modal.closed', {
- organization: this.props.organization,
- modal_session: this.sessionId,
- });
- closeModal();
- }}
- >
- {t('Close')}
- </Button>
- </Fragment>
- ) : (
- <Fragment>
- <Button
- data-test-id="cancel"
- size="sm"
- onClick={closeModal}
- disabled={disableInputs}
- >
- {t('Cancel')}
- </Button>
- <Button
- size="sm"
- data-test-id="send-invites"
- priority="primary"
- disabled={!canSend || !this.isValidInvites || disableInputs}
- onClick={sendInvites}
- >
- {this.inviteButtonLabel}
- </Button>
- </Fragment>
- )}
- </ButtonBar>
- </FooterContent>
- </Footer>
- </Fragment>
- );
- return (
- <InviteModalHook
- organization={organization}
- willInvite={this.willInvite}
- onSendInvites={this.sendInvites}
- >
- {hookRenderer}
- </InviteModalHook>
- );
- }
- }
- const Heading = styled('h1')`
- font-weight: 400;
- font-size: ${p => p.theme.headerFontSize};
- margin-top: 0;
- margin-bottom: ${space(0.75)};
- `;
- const Subtext = styled('p')`
- color: ${p => p.theme.subText};
- margin-bottom: ${space(3)};
- `;
- const inviteRowGrid = css`
- display: grid;
- gap: ${space(1.5)};
- grid-template-columns: 3fr 180px 2fr max-content;
- align-items: start;
- `;
- const InviteeHeadings = styled('div')`
- ${inviteRowGrid};
- margin-bottom: ${space(1)};
- font-weight: 600;
- text-transform: uppercase;
- font-size: ${p => p.theme.fontSizeSmall};
- `;
- const Rows = styled('ul')`
- list-style: none;
- padding: 0;
- margin: 0;
- `;
- const StyledInviteRow = styled(InviteRowControl)`
- ${inviteRowGrid};
- margin-bottom: ${space(1.5)};
- `;
- const AddButton = styled(Button)`
- margin-top: ${space(3)};
- `;
- const FooterContent = styled('div')`
- display: flex;
- gap: ${space(1)};
- align-items: center;
- justify-content: space-between;
- flex: 1;
- `;
- const StatusMessage = styled('div')<{status?: 'success' | 'error'}>`
- display: flex;
- gap: ${space(1)};
- align-items: center;
- font-size: ${p => p.theme.fontSizeMedium};
- color: ${p => (p.status === 'error' ? p.theme.errorText : p.theme.textColor)};
- > :first-child {
- ${p => p.status === 'success' && `color: ${p.theme.successText}`};
- }
- `;
- export const modalCss = css`
- width: 100%;
- max-width: 900px;
- margin: 50px auto;
- `;
- export default withLatestContext(InviteMembersModal);
|