Browse Source

feat(codeowners): Use a new UI for user/team mappings (#31287)

This PR will introduce a new user interface to the existing user/team mappings page. This new UI removes the need for edits in the modal and instead lets users perform edits in line with the changes not requiring a page refresh.
Leander Rodrigues 3 years ago
parent
commit
13d1e4dd17

+ 89 - 75
static/app/components/integrationExternalMappingForm.tsx

@@ -1,122 +1,136 @@
 import {Component} from 'react';
 import styled from '@emotion/styled';
 import capitalize from 'lodash/capitalize';
-import pick from 'lodash/pick';
 
 import {t, tct} from 'sentry/locale';
-import {ExternalActorMapping, Integration, Organization} from 'sentry/types';
+import {ExternalActorMapping, Integration} from 'sentry/types';
+import {getExternalActorEndpointDetails} from 'sentry/utils/integrationUtil';
 import {FieldFromConfig} from 'sentry/views/settings/components/forms';
 import Form from 'sentry/views/settings/components/forms/form';
+import FormModel from 'sentry/views/settings/components/forms/model';
 import {Field} from 'sentry/views/settings/components/forms/type';
 
-type Props = Pick<Form['props'], 'onSubmitSuccess' | 'onCancel'> &
-  Partial<Pick<Form['props'], 'onSubmit'>> & {
-    organization: Organization;
-    integration: Integration;
-    mapping?: ExternalActorMapping;
-    type: 'user' | 'team';
-    baseEndpoint?: string;
-    sentryNamesMapper: (v: any) => {id: string; name: string}[];
-    url: string;
-    onResults?: (data: any) => void;
-  };
+type Props = Pick<Form['props'], 'onCancel' | 'onSubmitSuccess' | 'onSubmitError'> & {
+  integration: Integration;
+  mapping?: ExternalActorMapping;
+  type: 'user' | 'team';
+  getBaseFormEndpoint: (mapping?: ExternalActorMapping) => string;
+  sentryNamesMapper: (v: any) => {id: string; name: string}[];
+  dataEndpoint: string;
+  onResults?: (data: any, mappingKey?: string) => void;
+  isInline?: boolean;
+  mappingKey?: string;
+};
 
 export default class IntegrationExternalMappingForm extends Component<Props> {
+  model = new FormModel();
+
   get initialData() {
     const {integration, mapping} = this.props;
-
     return {
-      externalName: '',
-      userId: '',
-      teamId: '',
-      sentryName: '',
       provider: integration.provider.key,
       integrationId: integration.id,
-      ...pick(mapping, ['externalName', 'userId', 'sentryName', 'teamId']),
+      ...mapping,
     };
   }
 
   get formFields(): Field[] {
-    const {type, sentryNamesMapper, url, mapping} = this.props;
+    const {
+      dataEndpoint,
+      isInline,
+      mapping,
+      mappingKey,
+      onResults,
+      sentryNamesMapper,
+      type,
+    } = this.props;
     const optionMapper = sentryNames =>
       sentryNames.map(({name, id}) => ({value: id, label: name}));
-
-    const fields: any[] = [
+    const fields: Field[] = [
       {
-        name: 'externalName',
-        type: 'string',
-        required: true,
-        label: tct('External [type]', {type: capitalize(type)}),
-        placeholder: t(`${type === 'team' ? '@org/teamname' : '@username'}`),
-      },
-    ];
-    if (type === 'user') {
-      fields.push({
-        name: 'userId',
+        name: `${type}Id`,
         type: 'select_async',
         required: true,
-        label: tct('Sentry [type]', {type: capitalize(type)}),
-        placeholder: t(`Choose your Sentry User`),
-        url,
+        label: isInline ? undefined : tct('Sentry [type]', {type: capitalize(type)}),
+        placeholder: t(`Select Sentry ${capitalize(type)}`),
+        url: dataEndpoint,
         onResults: result => {
-          // For organizations with >100 users, we want to make sure their
+          onResults?.(result, isInline ? mapping?.externalName : mappingKey);
+          // TODO(Leander): The code below only fixes the problem when viewed, not when edited
+          // Pagination still has bugs for results not on initial return of the query
+
+          // For organizations with >100 entries, we want to make sure their
           // saved mapping gets populated in the results if it wouldn't have
           // been in the initial 100 API results, which is why we add it here
-          if (mapping && !result.find(({user}) => user.id === mapping.userId)) {
-            result = [{id: mapping.userId, name: mapping.sentryName}, ...result];
+          if (
+            mapping &&
+            !result.find(entry => {
+              const id = type === 'user' ? entry.user.id : entry.id;
+              return id === mapping[`${type}Id`];
+            })
+          ) {
+            return optionMapper([
+              {id: mapping[`${type}Id`], name: mapping.sentryName},
+              ...sentryNamesMapper(result),
+            ]);
           }
-          this.props.onResults?.(result);
           return optionMapper(sentryNamesMapper(result));
         },
-      });
-    }
-    if (type === 'team') {
-      fields.push({
-        name: 'teamId',
-        type: 'select_async',
+      },
+    ];
+    // We only add the field for externalName if it's the full (not inline) form
+    if (!isInline) {
+      fields.unshift({
+        name: 'externalName',
+        type: 'string',
         required: true,
-        label: tct('Sentry [type]', {type: capitalize(type)}),
-        placeholder: t(`Choose your Sentry Team`),
-        url,
-        onResults: result => {
-          // For organizations with >100 teams, we want to make sure their
-          // saved mapping gets populated in the results if it wouldn't have
-          // been in the initial 100 API results, which is why we add it here
-          if (mapping && !result.find(({id}) => id === mapping.teamId)) {
-            result = [{id: mapping.teamId, name: mapping.sentryName}, ...result];
-          }
-          // The team needs `this.props.onResults` so that we have team slug
-          // when a user submits a team mapping, the endpoint needs the slug
-          // as a path param: /teams/${organization.slug}/${team.slug}/external-teams/
-          this.props.onResults?.(result);
-          return optionMapper(sentryNamesMapper(result));
-        },
+        label: isInline ? undefined : tct('External [type]', {type: capitalize(type)}),
+        placeholder: type === 'user' ? t('@username') : t('@org/teamname'),
       });
     }
     return fields;
   }
 
-  render() {
-    const {onSubmitSuccess, onCancel, mapping, baseEndpoint, onSubmit} = this.props;
+  get extraFormFieldProps() {
+    const {isInline} = this.props;
+    return isInline
+      ? {
+          // We need to submit the entire model since it could be a new one or an update
+          getData: () => this.model.getData(),
+          // We need to update the model onBlur for inline forms since the model's 'onPreSubmit' hook
+          // does NOT run when using `saveOnBlur`.
+          onBlur: () => this.updateModel(),
+        }
+      : {flexibleControlStateSize: true};
+  }
 
-    // endpoint changes if we are making a new row or updating an existing one
-    const endpoint = !baseEndpoint
-      ? undefined
-      : mapping
-      ? `${baseEndpoint}${mapping.id}/`
-      : baseEndpoint;
-    const apiMethod = !baseEndpoint ? undefined : mapping ? 'PUT' : 'POST';
+  // This function is necessary since the endpoint we submit to changes depending on the value selected
+  updateModel() {
+    const mapping = this.model.getData() as ExternalActorMapping;
+    const {getBaseFormEndpoint} = this.props;
+    if (mapping) {
+      const endpointDetails = getExternalActorEndpointDetails(
+        getBaseFormEndpoint(mapping),
+        mapping
+      );
+      this.model.setFormOptions({...this.model.options, ...endpointDetails});
+    }
+  }
 
+  render() {
+    const {isInline, onCancel, onSubmitError, onSubmitSuccess} = this.props;
     return (
       <FormWrapper>
         <Form
           requireChanges
-          onSubmitSuccess={onSubmitSuccess}
+          model={this.model}
           initialData={this.initialData}
-          apiEndpoint={endpoint}
-          apiMethod={apiMethod}
           onCancel={onCancel}
-          onSubmit={onSubmit}
+          onSubmitSuccess={onSubmitSuccess}
+          onSubmitError={onSubmitError}
+          saveOnBlur={isInline}
+          allowUndo={isInline}
+          onPreSubmit={() => this.updateModel()}
         >
           {this.formFields.map(field => (
             <FieldFromConfig
@@ -124,7 +138,7 @@ export default class IntegrationExternalMappingForm extends Component<Props> {
               field={field}
               inline={false}
               stacked
-              flexibleControlStateSize
+              {...this.extraFormFieldProps}
             />
           ))}
         </Form>

+ 121 - 51
static/app/components/integrationExternalMappings.tsx

@@ -3,39 +3,109 @@ import styled from '@emotion/styled';
 import capitalize from 'lodash/capitalize';
 
 import Access from 'sentry/components/acl/access';
+import MenuItemActionLink from 'sentry/components/actions/menuItemActionLink';
 import Button from 'sentry/components/button';
-import Confirm from 'sentry/components/confirm';
+import DropdownLink from 'sentry/components/dropdownLink';
+import IntegrationExternalMappingForm from 'sentry/components/integrationExternalMappingForm';
 import Pagination from 'sentry/components/pagination';
 import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels';
 import Tooltip from 'sentry/components/tooltip';
-import {IconAdd, IconDelete, IconEdit} from 'sentry/icons';
+import {IconAdd, IconArrow, IconEllipsis} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
+import PluginIcon from 'sentry/plugins/components/pluginIcon';
 import space from 'sentry/styles/space';
-import {ExternalActorMapping, Integration} from 'sentry/types';
+import {ExternalActorMapping, Integration, Organization} from 'sentry/types';
 import {getIntegrationIcon} from 'sentry/utils/integrationUtil';
 import EmptyMessage from 'sentry/views/settings/components/emptyMessage';
 
-type Props = {
+type Props = Pick<
+  IntegrationExternalMappingForm['props'],
+  'dataEndpoint' | 'getBaseFormEndpoint' | 'sentryNamesMapper' | 'onResults'
+> & {
+  organization: Organization;
   integration: Integration;
-  mappings: {id: string; externalName: string; sentryName: string}[];
+  mappings: ExternalActorMapping[];
   type: 'team' | 'user';
-  onCreateOrEdit: (mapping?: ExternalActorMapping) => void;
+  onCreate: (mapping?: ExternalActorMapping) => void;
   onDelete: (mapping: ExternalActorMapping) => void;
   pageLinks?: string;
 };
 
 type State = {};
-
 class IntegrationExternalMappings extends Component<Props, State> {
-  render() {
-    const {integration, mappings, type, onCreateOrEdit, onDelete, pageLinks} = this.props;
+  renderMappingName(mapping: ExternalActorMapping, hasAccess: boolean) {
+    const {
+      type,
+      getBaseFormEndpoint,
+      integration,
+      dataEndpoint,
+      sentryNamesMapper,
+      onResults,
+    } = this.props;
+    const mappingName = mapping.sentryName ?? '';
+    return hasAccess ? (
+      <IntegrationExternalMappingForm
+        type={type}
+        integration={integration}
+        dataEndpoint={dataEndpoint}
+        getBaseFormEndpoint={getBaseFormEndpoint}
+        mapping={mapping}
+        sentryNamesMapper={sentryNamesMapper}
+        onResults={onResults}
+        isInline
+      />
+    ) : (
+      mappingName
+    );
+  }
 
+  renderMappingOptions(mapping: ExternalActorMapping, hasAccess: boolean) {
+    const {type, onDelete} = this.props;
+    return (
+      <Tooltip
+        title={t(
+          'You must be an organization owner, manager or admin to make changes to an external user mapping.'
+        )}
+        disabled={hasAccess}
+      >
+        <DropdownLink
+          anchorRight
+          customTitle={
+            <Button
+              borderless
+              size="small"
+              icon={<IconEllipsisVertical size="sm" />}
+              disabled={!hasAccess}
+            />
+          }
+        >
+          <MenuItemActionLink
+            shouldConfirm
+            message={t(`Are you sure you want to remove this external ${type} mapping?`)}
+            disabled={!hasAccess}
+            onAction={() => onDelete(mapping)}
+            title={t(`Delete External ${capitalize(type)}`)}
+          >
+            <RedText>{t('Delete')}</RedText>
+          </MenuItemActionLink>
+        </DropdownLink>
+      </Tooltip>
+    );
+  }
+
+  render() {
+    const {integration, mappings, type, onCreate, pageLinks} = this.props;
     return (
       <Fragment>
         <Panel>
           <PanelHeader disablePadding hasButtons>
             <HeaderLayout>
-              <ExternalNameColumn>{tct('External [type]', {type})}</ExternalNameColumn>
+              <ExternalNameColumn header>
+                {tct('External [type]', {type})}
+              </ExternalNameColumn>
+              <ArrowColumn>
+                <IconArrow direction="right" size="md" />
+              </ArrowColumn>
               <SentryNameColumn>{tct('Sentry [type]', {type})}</SentryNameColumn>
               <Access access={['org:integrations']}>
                 {({hasAccess}) => (
@@ -49,7 +119,7 @@ class IntegrationExternalMappings extends Component<Props, State> {
                     >
                       <AddButton
                         data-test-id="add-mapping-button"
-                        onClick={() => onCreateOrEdit()}
+                        onClick={() => onCreate()}
                         size="xsmall"
                         icon={<IconAdd size="xs" isCircled />}
                         disabled={!hasAccess}
@@ -68,42 +138,23 @@ class IntegrationExternalMappings extends Component<Props, State> {
                 {tct('Set up External [type] Mappings.', {type: capitalize(type)})}
               </EmptyMessage>
             )}
-            {mappings.map(item => (
-              <Access access={['org:integrations']} key={item.id}>
+            {mappings.map((mapping, index) => (
+              <Access access={['org:integrations']} key={index}>
                 {({hasAccess}) => (
                   <ConfigPanelItem>
                     <Layout>
-                      <ExternalNameColumn>{item.externalName}</ExternalNameColumn>
-                      <SentryNameColumn>{item.sentryName}</SentryNameColumn>
+                      <ExternalNameColumn>
+                        <StyledPluginIcon pluginId={integration.provider.key} size={19} />
+                        <span>{mapping.externalName}</span>
+                      </ExternalNameColumn>
+                      <ArrowColumn>
+                        <IconArrow direction="right" size="md" />
+                      </ArrowColumn>
+                      <SentryNameColumn>
+                        {this.renderMappingName(mapping, hasAccess)}
+                      </SentryNameColumn>
                       <ButtonColumn>
-                        <Tooltip
-                          title={t(
-                            'You must be an organization owner, manager or admin to edit or remove an external user mapping.'
-                          )}
-                          disabled={hasAccess}
-                        >
-                          <StyledButton
-                            size="small"
-                            icon={<IconEdit size="sm" />}
-                            aria-label={t('edit')}
-                            disabled={!hasAccess}
-                            onClick={() => onCreateOrEdit(item)}
-                          />
-                          <Confirm
-                            disabled={!hasAccess}
-                            onConfirm={() => onDelete(item)}
-                            message={t(
-                              'Are you sure you want to remove this external user mapping?'
-                            )}
-                          >
-                            <StyledButton
-                              size="small"
-                              icon={<IconDelete size="sm" />}
-                              aria-label={t('delete')}
-                              disabled={!hasAccess}
-                            />
-                          </Confirm>
-                        </Tooltip>
+                        {this.renderMappingOptions(mapping, hasAccess)}
                       </ButtonColumn>
                     </Layout>
                   </ConfigPanelItem>
@@ -127,23 +178,29 @@ const AddButton = styled(Button)`
 const Layout = styled('div')`
   display: grid;
   grid-column-gap: ${space(1)};
+  padding: ${space(1)};
   width: 100%;
   align-items: center;
-  grid-template-columns: 2.5fr 2.5fr 1fr;
-  grid-template-areas: 'external-name sentry-name button';
+  grid-template-columns: 2.5fr 50px 2.5fr 1fr;
+  grid-template-areas: 'external-name arrow sentry-name button';
 `;
 
 const HeaderLayout = styled(Layout)`
   align-items: center;
-  margin: 0;
-  margin-left: ${space(2)};
+  padding: 0 ${space(1)} 0 ${space(2)};
   text-transform: uppercase;
 `;
 
-const ConfigPanelItem = styled(PanelItem)``;
+const ConfigPanelItem = styled(PanelItem)`
+  padding: 0 ${space(1)};
+`;
+
+const IconEllipsisVertical = styled(IconEllipsis)`
+  transform: rotate(90deg);
+`;
 
-const StyledButton = styled(Button)`
-  margin: ${space(0.5)};
+const StyledPluginIcon = styled(PluginIcon)`
+  margin-right: ${space(2)};
 `;
 
 // Columns below
@@ -152,15 +209,28 @@ const Column = styled('span')`
   overflow-wrap: break-word;
 `;
 
-const ExternalNameColumn = styled(Column)`
+const ExternalNameColumn = styled(Column)<{header?: boolean}>`
   grid-area: external-name;
+  display: flex;
+  align-items: center;
+  font-family: ${p => (p.header ? 'inherit' : p.theme.text.familyMono)};
+`;
+
+const ArrowColumn = styled(Column)`
+  grid-area: arrow;
 `;
 
 const SentryNameColumn = styled(Column)`
   grid-area: sentry-name;
+  overflow: visible;
 `;
 
 const ButtonColumn = styled(Column)`
   grid-area: button;
   text-align: right;
+  overflow: visible;
+`;
+
+const RedText = styled('span')`
+  color: ${p => p.theme.red300};
 `;

+ 18 - 0
static/app/utils/integrationUtil.tsx

@@ -14,6 +14,7 @@ import HookStore from 'sentry/stores/hookStore';
 import {
   AppOrProviderOrPlugin,
   DocIntegration,
+  ExternalActorMapping,
   Integration,
   IntegrationFeature,
   IntegrationInstallationStatus,
@@ -228,3 +229,20 @@ export const getAlertText = (integrations?: Integration[]): string | undefined =
         'Update to the latest version of our Slack app to get access to personal and team notifications.'
       );
 };
+
+/**
+ * Uses the mapping and baseEndpoint to derive the details for the mappings request.
+ * @param baseEndpoint Must have a trailing slash, since the id is appended for PUT requests!
+ * @param mapping The mapping being sent to the endpoint
+ * @returns An object containing the request method (apiMethod), and final endpoint (apiEndpoint)
+ */
+export const getExternalActorEndpointDetails = (
+  baseEndpoint: string,
+  mapping?: ExternalActorMapping
+): {apiMethod: 'POST' | 'PUT'; apiEndpoint: string} => {
+  const isValidMapping = !!mapping?.id;
+  return {
+    apiMethod: isValidMapping ? 'PUT' : 'POST',
+    apiEndpoint: isValidMapping ? `${baseEndpoint}${mapping.id}/` : baseEndpoint,
+  };
+};

+ 49 - 47
static/app/views/organizationIntegrations/integrationExternalTeamMappings.tsx

@@ -9,7 +9,6 @@ import IntegrationExternalMappings from 'sentry/components/integrationExternalMa
 import {t} from 'sentry/locale';
 import {ExternalActorMapping, Integration, Organization, Team} from 'sentry/types';
 import withOrganization from 'sentry/utils/withOrganization';
-import FormModel from 'sentry/views/settings/components/forms/model';
 
 type Props = AsyncComponent['props'] &
   WithRouterProps & {
@@ -19,7 +18,11 @@ type Props = AsyncComponent['props'] &
 
 type State = AsyncComponent['state'] & {
   teams: Team[];
-  queryResults: Team[];
+  queryResults: {
+    // For inline forms, the mappingKey will be the external name (since multiple will be rendered at one time)
+    // For the modal form, the mappingKey will be this.modalMappingKey (since only one modal form is rendered at any time)
+    [mappingKey: string]: Team[];
+  };
 };
 
 class IntegrationExternalTeamMappings extends AsyncComponent<Props, State> {
@@ -27,7 +30,7 @@ class IntegrationExternalTeamMappings extends AsyncComponent<Props, State> {
     return {
       ...super.getDefaultState(),
       teams: [],
-      queryResults: [],
+      queryResults: {},
     };
   }
 
@@ -83,66 +86,60 @@ class IntegrationExternalTeamMappings extends AsyncComponent<Props, State> {
     return externalTeamMappings.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
   }
 
-  sentryNamesMapper(teams: Team[]) {
-    return teams.map(({id, slug}) => ({id, name: slug}));
-  }
+  modalMappingKey = 'MODAL_RESULTS';
 
-  handleSubmit = (
-    data: Record<string, any>,
-    onSubmitSuccess: (data: Record<string, any>) => void,
-    onSubmitError: (error: any) => void,
-    _: React.FormEvent<Element>,
-    model: FormModel,
-    mapping?: ExternalActorMapping
-  ) => {
-    // We need to dynamically set the endpoint bc it requires the slug of the selected team in the form.
-    try {
-      const {organization} = this.props;
-      const {queryResults} = this.state;
-      const team = queryResults.find(item => item.id === data.teamId);
+  get dataEndpoint() {
+    const {organization} = this.props;
+    return `/organizations/${organization.slug}/teams/`;
+  }
 
-      if (!team) {
-        throw new Error('Cannot find team slug.');
-      }
+  getBaseFormEndpoint(mapping?: ExternalActorMapping) {
+    if (!mapping) {
+      return '';
+    }
+    const {organization} = this.props;
+    const {queryResults} = this.state;
+    const mappingResults =
+      queryResults[mapping.externalName] ?? queryResults[this.modalMappingKey];
+    const team = mappingResults?.find(item => item.id === mapping.teamId);
+    return `/teams/${organization.slug}/${team?.slug ?? ''}/external-teams/`;
+  }
 
-      const baseEndpoint = `/teams/${organization.slug}/${team.slug}/external-teams/`;
-      const apiEndpoint = mapping ? `${baseEndpoint}${mapping.id}/` : baseEndpoint;
-      const apiMethod = mapping ? 'PUT' : 'POST';
+  sentryNamesMapper(teams: Team[]) {
+    return teams.map(({id, slug}) => ({id, name: slug}));
+  }
 
-      model.setFormOptions({
-        onSubmitSuccess,
-        onSubmitError,
-        apiEndpoint,
-        apiMethod,
+  handleResults = (results, mappingKey?: string) => {
+    if (mappingKey) {
+      this.setState({
+        queryResults: {
+          ...this.state.queryResults,
+          [mappingKey]: results,
+        },
       });
-
-      model.saveForm();
-    } catch {
-      // no 4xx errors should happen on delete
-      addErrorMessage(t('An error occurred'));
     }
   };
 
   openModal = (mapping?: ExternalActorMapping) => {
-    const {organization, integration} = this.props;
+    const {integration} = this.props;
     openModal(({Body, Header, closeModal}) => (
       <Fragment>
         <Header closeButton>{t('Configure External Team Mapping')}</Header>
         <Body>
           <IntegrationExternalMappingForm
-            organization={organization}
+            type="team"
             integration={integration}
+            dataEndpoint={this.dataEndpoint}
+            getBaseFormEndpoint={map => this.getBaseFormEndpoint(map)}
+            mapping={mapping}
+            mappingKey={this.modalMappingKey}
+            sentryNamesMapper={this.sentryNamesMapper}
+            onCancel={closeModal}
+            onResults={this.handleResults}
             onSubmitSuccess={() => {
               this.handleSubmitSuccess();
               closeModal();
             }}
-            mapping={mapping}
-            sentryNamesMapper={this.sentryNamesMapper}
-            type="team"
-            url={`/organizations/${organization.slug}/teams/`}
-            onCancel={closeModal}
-            onSubmit={(...args) => this.handleSubmit(...args, mapping)}
-            onResults={results => this.setState({queryResults: results})}
           />
         </Body>
       </Fragment>
@@ -150,16 +147,21 @@ class IntegrationExternalTeamMappings extends AsyncComponent<Props, State> {
   };
 
   renderBody() {
-    const {integration} = this.props;
+    const {integration, organization} = this.props;
     const {teamsPageLinks} = this.state;
     return (
       <IntegrationExternalMappings
-        integration={integration}
         type="team"
+        integration={integration}
+        organization={organization}
         mappings={this.mappings}
-        onCreateOrEdit={this.openModal}
+        dataEndpoint={this.dataEndpoint}
+        getBaseFormEndpoint={mapping => this.getBaseFormEndpoint(mapping)}
+        sentryNamesMapper={this.sentryNamesMapper}
+        onCreate={this.openModal}
         onDelete={this.handleDelete}
         pageLinks={teamsPageLinks}
+        onResults={this.handleResults}
       />
     );
   }

+ 30 - 15
static/app/views/organizationIntegrations/integrationExternalUserMappings.tsx

@@ -40,11 +40,13 @@ class IntegrationExternalUserMappings extends AsyncComponent<Props, State> {
 
   handleDelete = async (mapping: ExternalActorMapping) => {
     const {organization} = this.props;
-    const endpoint = `/organizations/${organization.slug}/external-users/${mapping.id}/`;
     try {
-      await this.api.requestPromise(endpoint, {
-        method: 'DELETE',
-      });
+      await this.api.requestPromise(
+        `/organizations/${organization.slug}/external-users/${mapping.id}/`,
+        {
+          method: 'DELETE',
+        }
+      );
       // remove config and update state
       addSuccessMessage(t('Deletion successful'));
       this.fetchData();
@@ -76,6 +78,16 @@ class IntegrationExternalUserMappings extends AsyncComponent<Props, State> {
     return externalUserMappings.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
   }
 
+  get dataEndpoint() {
+    const {organization} = this.props;
+    return `/organizations/${organization.slug}/members/`;
+  }
+
+  get baseFormEndpoint() {
+    const {organization} = this.props;
+    return `/organizations/${organization.slug}/external-users/`;
+  }
+
   sentryNamesMapper(members: Member[]) {
     return members
       .filter(member => member.user)
@@ -86,24 +98,23 @@ class IntegrationExternalUserMappings extends AsyncComponent<Props, State> {
   }
 
   openModal = (mapping?: ExternalActorMapping) => {
-    const {organization, integration} = this.props;
+    const {integration} = this.props;
     openModal(({Body, Header, closeModal}) => (
       <Fragment>
         <Header closeButton>{t('Configure External User Mapping')}</Header>
         <Body>
           <IntegrationExternalMappingForm
-            organization={organization}
+            type="user"
             integration={integration}
+            dataEndpoint={this.dataEndpoint}
+            getBaseFormEndpoint={() => this.baseFormEndpoint}
+            mapping={mapping}
+            sentryNamesMapper={this.sentryNamesMapper}
+            onCancel={closeModal}
             onSubmitSuccess={() => {
               this.handleSubmitSuccess();
               closeModal();
             }}
-            mapping={mapping}
-            sentryNamesMapper={this.sentryNamesMapper}
-            type="user"
-            url={`/organizations/${organization.slug}/members/`}
-            onCancel={closeModal}
-            baseEndpoint={`/organizations/${organization.slug}/external-users/`}
           />
         </Body>
       </Fragment>
@@ -111,15 +122,19 @@ class IntegrationExternalUserMappings extends AsyncComponent<Props, State> {
   };
 
   renderBody() {
-    const {integration} = this.props;
+    const {integration, organization} = this.props;
     const {membersPageLinks} = this.state;
     return (
       <Fragment>
         <IntegrationExternalMappings
-          integration={integration}
           type="user"
+          integration={integration}
+          organization={organization}
           mappings={this.mappings}
-          onCreateOrEdit={this.openModal}
+          dataEndpoint={this.dataEndpoint}
+          getBaseFormEndpoint={() => this.baseFormEndpoint}
+          sentryNamesMapper={this.sentryNamesMapper}
+          onCreate={this.openModal}
           onDelete={this.handleDelete}
           pageLinks={membersPageLinks}
         />

+ 1 - 13
static/app/views/settings/organizationIntegrations/configureIntegration.tsx

@@ -30,7 +30,6 @@ import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHea
 
 type RouteParams = {
   orgId: string;
-  providerKey: string;
   integrationId: string;
 };
 type Props = RouteComponentProps<RouteParams, {}> & {
@@ -55,18 +54,7 @@ class ConfigureIntegration extends AsyncView<Props, State> {
   }
 
   componentDidMount() {
-    const {
-      location,
-      router,
-      organization,
-      params: {orgId, providerKey},
-    } = this.props;
-    // This page should not be accessible by members
-    if (!organization.access.includes('org:integrations')) {
-      router.push({
-        pathname: `/settings/${orgId}/integrations/${providerKey}/`,
-      });
-    }
+    const {location} = this.props;
     const value =
       (['codeMappings', 'userMappings', 'teamMappings'] as const).find(
         tab => tab === location.query.tab