Просмотр исходного кода

feat(github-growth): invite members modal (#55003)

Cathy Teng 1 год назад
Родитель
Сommit
843104ea45

+ 5 - 5
fixtures/js-stubs/missingMembers.tsx

@@ -5,27 +5,27 @@ export function MissingMembers(params = []): MissingMember[] {
     {
       commitCount: 6,
       email: 'hello@sentry.io',
-      externalId: 'hello',
+      externalId: 'github:hello',
     },
     {
       commitCount: 5,
       email: 'abcd@sentry.io',
-      externalId: 'abcd',
+      externalId: 'github:abcd',
     },
     {
       commitCount: 4,
       email: 'hola@sentry.io',
-      externalId: 'hola',
+      externalId: 'github:hola',
     },
     {
       commitCount: 3,
       email: 'test@sentry.io',
-      externalId: 'test',
+      externalId: 'github:test',
     },
     {
       commitCount: 2,
       email: 'five@sentry.io',
-      externalId: 'five',
+      externalId: 'github:five',
     },
     ...params,
   ];

+ 19 - 0
static/app/actionCreators/modal.tsx

@@ -11,7 +11,9 @@ import type {
   Event,
   Group,
   IssueOwnership,
+  MissingMember,
   Organization,
+  OrgRole,
   Project,
   SentryApp,
   Team,
@@ -246,6 +248,23 @@ export async function openInviteMembersModal({
   openModal(deps => <Modal {...deps} {...args} />, {modalCss, onClose});
 }
 
+type InviteMissingMembersModalOptions = {
+  allowedRoles: OrgRole[];
+  missingMembers: {integration: string; users: MissingMember[]};
+  onClose: () => void;
+  organization: Organization;
+};
+
+export async function openInviteMissingMembersModal({
+  onClose,
+  ...args
+}: InviteMissingMembersModalOptions) {
+  const mod = await import('sentry/components/modals/inviteMissingMembersModal');
+  const {default: Modal, modalCss} = mod;
+
+  openModal(deps => <Modal {...deps} {...args} />, {modalCss, onClose});
+}
+
 export async function openWidgetBuilderOverwriteModal(
   options: OverwriteWidgetModalProps
 ) {

+ 5 - 3
static/app/components/modals/inviteMembersModal/index.tsx

@@ -41,13 +41,15 @@ interface State extends AsyncComponentState {
 
 const DEFAULT_ROLE = 'member';
 
-const InviteModalHook = HookOrDefault({
+export const InviteModalHook = HookOrDefault({
   hookName: 'member-invite-modal:customization',
   defaultComponent: ({onSendInvites, children}) =>
     children({sendInvites: onSendInvites, canSend: true}),
 });
 
-type InviteModalRenderFunc = React.ComponentProps<typeof InviteModalHook>['children'];
+export type InviteModalRenderFunc = React.ComponentProps<
+  typeof InviteModalHook
+>['children'];
 
 class InviteMembersModal extends DeprecatedAsyncComponent<
   InviteMembersModalProps,
@@ -508,7 +510,7 @@ const FooterContent = styled('div')`
   flex: 1;
 `;
 
-const StatusMessage = styled('div')<{status?: 'success' | 'error'}>`
+export const StatusMessage = styled('div')<{status?: 'success' | 'error'}>`
   display: flex;
   gap: ${space(1)};
   align-items: center;

+ 209 - 0
static/app/components/modals/inviteMissingMembersModal/index.spec.tsx

@@ -0,0 +1,209 @@
+import selectEvent from 'react-select-event';
+import styled from '@emotion/styled';
+
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {makeCloseButton} from 'sentry/components/globalModal/components';
+import InviteMissingMembersModal, {
+  InviteMissingMembersModalProps,
+} from 'sentry/components/modals/inviteMissingMembersModal';
+import TeamStore from 'sentry/stores/teamStore';
+import {OrgRole} from 'sentry/types';
+
+const roles = [
+  {
+    id: 'admin',
+    name: 'Admin',
+    desc: 'This is the admin role',
+    allowed: true,
+  },
+  {
+    id: 'member',
+    name: 'Member',
+    desc: 'This is the member role',
+    allowed: true,
+  },
+] as OrgRole[];
+
+describe('InviteMissingMembersModal', function () {
+  const team = TestStubs.Team();
+  const org = TestStubs.Organization({access: ['member:write'], teams: [team]});
+  TeamStore.loadInitialData([team]);
+  const missingMembers = {integration: 'github', users: TestStubs.MissingMembers()};
+
+  const styledWrapper = styled(c => c.children);
+  const modalProps: InviteMissingMembersModalProps = {
+    Body: styledWrapper(),
+    Header: p => <span>{p.children}</span>,
+    Footer: styledWrapper(),
+    closeModal: () => {},
+    CloseButton: makeCloseButton(() => {}),
+    organization: TestStubs.Organization(),
+    missingMembers: {integration: 'github', users: []},
+    allowedRoles: [],
+  };
+
+  beforeEach(function () {
+    MockApiClient.clearMockResponses();
+    MockApiClient.addMockResponse({
+      url: `/organizations/${org.slug}/members/me/`,
+      method: 'GET',
+      body: {roles},
+    });
+  });
+
+  it('renders with empty table when no missing members', function () {
+    render(<InviteMissingMembersModal {...modalProps} />);
+
+    expect(
+      screen.getByRole('heading', {name: 'Invite Your Dev Team'})
+    ).toBeInTheDocument();
+
+    // 1 checkbox column + 4 content columns
+    expect(screen.queryAllByTestId('table-header')).toHaveLength(5);
+  });
+
+  it('does not render without org:write', function () {
+    const organization = TestStubs.Organization({access: []});
+    render(<InviteMissingMembersModal {...modalProps} organization={organization} />);
+
+    expect(
+      screen.queryByRole('heading', {name: 'Invite Your Dev Team'})
+    ).not.toBeInTheDocument();
+  });
+
+  it('disables invite button if no members selected', function () {
+    render(<InviteMissingMembersModal {...modalProps} missingMembers={missingMembers} />);
+
+    expect(
+      screen.getByRole('heading', {name: 'Invite Your Dev Team'})
+    ).toBeInTheDocument();
+
+    expect(screen.getByLabelText('Send Invites')).toBeDisabled();
+    expect(screen.getByText('Invite missing members')).toBeInTheDocument();
+  });
+
+  it('enables and disables invite button when toggling one checkbox', async function () {
+    render(<InviteMissingMembersModal {...modalProps} missingMembers={missingMembers} />);
+
+    expect(
+      screen.getByRole('heading', {name: 'Invite Your Dev Team'})
+    ).toBeInTheDocument();
+
+    await userEvent.click(screen.getByLabelText('Select hello@sentry.io'));
+
+    expect(screen.getByLabelText('Send Invites')).toBeEnabled();
+    expect(screen.getByText('Invite 1 missing member')).toBeInTheDocument();
+
+    await userEvent.click(screen.getByLabelText('Select hello@sentry.io'));
+
+    expect(screen.getByLabelText('Send Invites')).toBeDisabled();
+    expect(screen.getByText('Invite missing members')).toBeInTheDocument();
+  });
+
+  it('can select and deselect all rows', async function () {
+    render(<InviteMissingMembersModal {...modalProps} missingMembers={missingMembers} />);
+
+    expect(
+      screen.getByRole('heading', {name: 'Invite Your Dev Team'})
+    ).toBeInTheDocument();
+
+    await userEvent.click(screen.getByLabelText('Select All'));
+
+    expect(screen.getByLabelText('Send Invites')).toBeEnabled();
+    expect(screen.getByText('Invite all 5 missing members')).toBeInTheDocument();
+
+    await userEvent.click(screen.getByLabelText('Deselect All'));
+
+    expect(screen.getByLabelText('Send Invites')).toBeDisabled();
+    expect(screen.getByText('Invite missing members')).toBeInTheDocument();
+  });
+
+  it('can invite all members', async function () {
+    render(
+      <InviteMissingMembersModal
+        {...modalProps}
+        organization={TestStubs.Organization({defaultRole: 'member'})}
+        missingMembers={missingMembers}
+        allowedRoles={roles}
+      />
+    );
+
+    const createMemberMock = MockApiClient.addMockResponse({
+      url: `/organizations/${org.slug}/members/`,
+      method: 'POST',
+      body: {},
+    });
+
+    expect(
+      screen.getByRole('heading', {name: 'Invite Your Dev Team'})
+    ).toBeInTheDocument();
+
+    await userEvent.click(screen.getByLabelText('Select All'));
+    await userEvent.click(screen.getByLabelText('Send Invites'));
+
+    // Verify data sent to the backend
+    expect(createMemberMock).toHaveBeenCalledTimes(5);
+
+    missingMembers.users.forEach((member, i) => {
+      expect(createMemberMock).toHaveBeenNthCalledWith(
+        i + 1,
+        `/organizations/${org.slug}/members/`,
+        expect.objectContaining({
+          data: {email: member.email, role: 'member', teams: []},
+        })
+      );
+    });
+  });
+
+  it('can invite multiple members', async function () {
+    render(
+      <InviteMissingMembersModal
+        {...modalProps}
+        organization={TestStubs.Organization({defaultRole: 'member', teams: [team]})}
+        missingMembers={missingMembers}
+        allowedRoles={roles}
+      />
+    );
+
+    const createMemberMock = MockApiClient.addMockResponse({
+      url: `/organizations/${org.slug}/members/`,
+      method: 'POST',
+      body: {},
+    });
+
+    expect(
+      screen.getByRole('heading', {name: 'Invite Your Dev Team'})
+    ).toBeInTheDocument();
+
+    const roleInputs = screen.getAllByRole('textbox', {name: 'Role'});
+    const teamInputs = screen.getAllByRole('textbox', {name: 'Add to Team'});
+
+    await userEvent.click(screen.getByLabelText('Select hello@sentry.io'));
+    await selectEvent.select(roleInputs[0], 'Admin');
+
+    await userEvent.click(screen.getByLabelText('Select abcd@sentry.io'));
+    await selectEvent.select(teamInputs[1], '#team-slug');
+
+    await userEvent.click(screen.getByLabelText('Send Invites'));
+
+    // Verify data sent to the backend
+    expect(createMemberMock).toHaveBeenCalledTimes(2);
+
+    expect(createMemberMock).toHaveBeenNthCalledWith(
+      1,
+      `/organizations/${org.slug}/members/`,
+      expect.objectContaining({
+        data: {email: 'hello@sentry.io', role: 'admin', teams: []},
+      })
+    );
+
+    expect(createMemberMock).toHaveBeenNthCalledWith(
+      2,
+      `/organizations/${org.slug}/members/`,
+      expect.objectContaining({
+        data: {email: 'abcd@sentry.io', role: 'member', teams: [team.slug]},
+      })
+    );
+  });
+});

+ 340 - 0
static/app/components/modals/inviteMissingMembersModal/index.tsx

@@ -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;
+`;

+ 8 - 0
static/app/components/modals/inviteMissingMembersModal/types.tsx

@@ -0,0 +1,8 @@
+export type MissingMemberInvite = {
+  commitCount: number;
+  email: string;
+  externalId: string;
+  role: string;
+  selected: boolean;
+  teamSlugs: Set<string>;
+};

+ 18 - 6
static/app/views/settings/organizationMembers/inviteBanner.spec.tsx

@@ -41,8 +41,10 @@ describe('inviteBanner', function () {
     render(
       <InviteBanner
         missingMembers={missingMembers}
-        onSendInvite={() => undefined}
+        onSendInvite={() => {}}
         organization={org}
+        allowedRoles={[]}
+        onModalClose={() => {}}
       />
     );
 
@@ -63,8 +65,10 @@ describe('inviteBanner', function () {
     render(
       <InviteBanner
         missingMembers={missingMembers}
-        onSendInvite={() => undefined}
+        onSendInvite={() => {}}
         organization={org}
+        allowedRoles={[]}
+        onModalClose={() => {}}
       />
     );
 
@@ -83,8 +87,10 @@ describe('inviteBanner', function () {
     render(
       <InviteBanner
         missingMembers={noMissingMembers}
-        onSendInvite={() => undefined}
+        onSendInvite={() => {}}
         organization={org}
+        allowedRoles={[]}
+        onModalClose={() => {}}
       />
     );
 
@@ -104,8 +110,10 @@ describe('inviteBanner', function () {
     render(
       <InviteBanner
         missingMembers={noMissingMembers}
-        onSendInvite={() => undefined}
+        onSendInvite={() => {}}
         organization={org}
+        allowedRoles={[]}
+        onModalClose={() => {}}
       />
     );
 
@@ -136,8 +144,10 @@ describe('inviteBanner', function () {
     render(
       <InviteBanner
         missingMembers={missingMembers}
-        onSendInvite={() => undefined}
+        onSendInvite={() => {}}
         organization={org}
+        allowedRoles={[]}
+        onModalClose={() => {}}
       />
     );
 
@@ -168,8 +178,10 @@ describe('inviteBanner', function () {
     render(
       <InviteBanner
         missingMembers={missingMembers}
-        onSendInvite={() => undefined}
+        onSendInvite={() => {}}
         organization={org}
+        allowedRoles={[]}
+        onModalClose={() => {}}
       />
     );
 

+ 83 - 37
static/app/views/settings/organizationMembers/inviteBanner.tsx

@@ -1,6 +1,7 @@
 import {useCallback, useEffect, useState} from 'react';
 import styled from '@emotion/styled';
 
+import {openInviteMissingMembersModal} from 'sentry/actionCreators/modal';
 import {promptsCheck, promptsUpdate} from 'sentry/actionCreators/prompts';
 import {Button} from 'sentry/components/button';
 import Card from 'sentry/components/card';
@@ -12,18 +13,26 @@ 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 {MissingMember, Organization} from 'sentry/types';
+import {MissingMember, Organization, OrgRole} from 'sentry/types';
 import {promptIsDismissed} from 'sentry/utils/promptIsDismissed';
 import useApi from 'sentry/utils/useApi';
 import withOrganization from 'sentry/utils/withOrganization';
 
 type Props = {
+  allowedRoles: OrgRole[];
   missingMembers: {integration: string; users: MissingMember[]};
+  onModalClose: () => void;
   onSendInvite: (email: string) => void;
   organization: Organization;
 };
 
-export function InviteBanner({missingMembers, onSendInvite, organization}: Props) {
+export function InviteBanner({
+  missingMembers,
+  onSendInvite,
+  organization,
+  allowedRoles,
+  onModalClose,
+}: Props) {
   // NOTE: this is currently used for Github only
 
   const hideBanner =
@@ -88,34 +97,48 @@ export function InviteBanner({missingMembers, onSendInvite, organization}: Props
 
   const users = missingMembers.users;
 
-  const cards = users.slice(0, 5).map(member => (
-    <MemberCard key={member.externalId} data-test-id={`member-card-${member.externalId}`}>
-      <MemberCardContent>
-        <MemberCardContentRow>
-          <IconGithub size="sm" />
-          {/* TODO: create mapping from integration to lambda external link function */}
-          <StyledExternalLink href={`http://github.com/${member.externalId}`}>
-            {tct('@[externalId]', {externalId: member.externalId})}
-          </StyledExternalLink>
-        </MemberCardContentRow>
-        <MemberCardContentRow>
-          <IconCommit size="xs" />
-          {tct('[commitCount] Recent Commits', {commitCount: member.commitCount})}
-        </MemberCardContentRow>
-        <Subtitle>{member.email}</Subtitle>
-      </MemberCardContent>
-      <Button
-        size="sm"
-        onClick={() => handleSendInvite(member.email)}
-        data-test-id="invite-missing-member"
-        icon={<IconMail />}
+  const cards = users.slice(0, 5).map(member => {
+    const username = member.externalId.split(':').pop();
+    return (
+      <MemberCard
+        key={member.externalId}
+        data-test-id={`member-card-${member.externalId}`}
       >
-        {t('Invite')}
-      </Button>
-    </MemberCard>
-  ));
+        <MemberCardContent>
+          <MemberCardContentRow>
+            <IconGithub size="sm" />
+            {/* TODO(cathy): create mapping from integration to lambda external link function */}
+            <StyledExternalLink href={`https://github.com/${username}`}>
+              @{username}
+            </StyledExternalLink>
+          </MemberCardContentRow>
+          <MemberCardContentRow>
+            <IconCommit size="xs" />
+            {tct('[commitCount] Recent Commits', {commitCount: member.commitCount})}
+          </MemberCardContentRow>
+          <Subtitle>{member.email}</Subtitle>
+        </MemberCardContent>
+        <Button
+          size="sm"
+          onClick={() => handleSendInvite(member.email)}
+          data-test-id="invite-missing-member"
+          icon={<IconMail />}
+        >
+          {t('Invite')}
+        </Button>
+      </MemberCard>
+    );
+  });
 
-  cards.push(<SeeMoreCard key="see-more" missingUsers={users} />);
+  cards.push(
+    <SeeMoreCard
+      key="see-more"
+      missingMembers={missingMembers}
+      allowedRoles={allowedRoles}
+      onModalClose={onModalClose}
+      organization={organization}
+    />
+  );
 
   return (
     <StyledCard>
@@ -138,7 +161,14 @@ export function InviteBanner({missingMembers, onSendInvite, organization}: Props
           <Button
             priority="primary"
             size="xs"
-            // TODO(cathy): open up invite modal
+            onClick={() =>
+              openInviteMissingMembersModal({
+                allowedRoles,
+                missingMembers,
+                onClose: onModalClose,
+                organization,
+              })
+            }
           >
             {t('View All')}
           </Button>
@@ -161,30 +191,47 @@ export function InviteBanner({missingMembers, onSendInvite, organization}: Props
 export default withOrganization(InviteBanner);
 
 type SeeMoreCardProps = {
-  missingUsers: MissingMember[];
+  allowedRoles: OrgRole[];
+  missingMembers: {integration: string; users: MissingMember[]};
+  onModalClose: () => void;
+  organization: Organization;
 };
 
-function SeeMoreCard({missingUsers}: SeeMoreCardProps) {
+function SeeMoreCard({
+  missingMembers,
+  allowedRoles,
+  onModalClose,
+  organization,
+}: SeeMoreCardProps) {
+  const {users} = missingMembers;
+
   return (
     <MemberCard data-test-id="see-more-card">
       <MemberCardContent>
         <MemberCardContentRow>
           <SeeMore>
             {tct('See all [missingMembersCount] missing members', {
-              missingMembersCount: missingUsers.length,
+              missingMembersCount: users.length,
             })}
           </SeeMore>
         </MemberCardContentRow>
         <Subtitle>
           {tct('Accounting for [totalCommits] recent commits', {
-            totalCommits: missingUsers.reduce((acc, curr) => acc + curr.commitCount, 0),
+            totalCommits: users.reduce((acc, curr) => acc + curr.commitCount, 0),
           })}
         </Subtitle>
       </MemberCardContent>
       <Button
         size="sm"
         priority="primary"
-        // TODO(cathy): open up invite modal
+        onClick={() =>
+          openInviteMissingMembersModal({
+            allowedRoles,
+            missingMembers,
+            organization,
+            onClose: onModalClose,
+          })
+        }
       >
         {t('View All')}
       </Button>
@@ -202,7 +249,6 @@ const StyledCard = styled(Card)`
 const CardTitleContainer = styled('div')`
   display: flex;
   justify-content: space-between;
-  margin-bottom: ${space(1)};
 `;
 
 const CardTitleContent = styled('div')`
@@ -217,7 +263,7 @@ const CardTitle = styled('h6')`
   color: ${p => p.theme.gray400};
 `;
 
-const Subtitle = styled('div')`
+export const Subtitle = styled('div')`
   display: flex;
   align-items: center;
   font-size: ${p => p.theme.fontSizeSmall};
@@ -264,7 +310,7 @@ const MemberCardContentRow = styled('div')`
   }
 `;
 
-const StyledExternalLink = styled(ExternalLink)`
+export const StyledExternalLink = styled(ExternalLink)`
   font-size: ${p => p.theme.fontSizeMedium};
 `;
 

+ 2 - 0
static/app/views/settings/organizationMembers/organizationMembersList.tsx

@@ -340,6 +340,8 @@ class OrganizationMembersList extends DeprecatedAsyncView<Props, State> {
         <InviteBanner
           missingMembers={githubMissingMembers}
           onSendInvite={this.handleInviteMissingMember}
+          onModalClose={this.fetchMembersList}
+          allowedRoles={currentMember ? currentMember.roles : ORG_ROLES}
         />
         <ClassNames>
           {({css}) =>