integrationExternalTeamMappings.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import {Fragment} from 'react';
  2. import {WithRouterProps} from 'react-router';
  3. import uniqBy from 'lodash/uniqBy';
  4. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  5. import {openModal} from 'sentry/actionCreators/modal';
  6. import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
  7. import {t} from 'sentry/locale';
  8. import {
  9. ExternalActorMapping,
  10. ExternalActorMappingOrSuggestion,
  11. Integration,
  12. Organization,
  13. Team,
  14. } from 'sentry/types';
  15. import {sentryNameToOption} from 'sentry/utils/integrationUtil';
  16. import withOrganization from 'sentry/utils/withOrganization';
  17. // eslint-disable-next-line no-restricted-imports
  18. import withSentryRouter from 'sentry/utils/withSentryRouter';
  19. import IntegrationExternalMappingForm from './integrationExternalMappingForm';
  20. import IntegrationExternalMappings from './integrationExternalMappings';
  21. type Props = DeprecatedAsyncComponent['props'] &
  22. WithRouterProps & {
  23. integration: Integration;
  24. organization: Organization;
  25. };
  26. type State = DeprecatedAsyncComponent['state'] & {
  27. initialResults: Team[];
  28. queryResults: {
  29. // For inline forms, the mappingKey will be the external name (since multiple will be rendered at one time)
  30. // For the modal form, the mappingKey will be this.modalMappingKey (since only one modal form is rendered at any time)
  31. [mappingKey: string]: Team[];
  32. };
  33. teams: Team[];
  34. };
  35. class IntegrationExternalTeamMappings extends DeprecatedAsyncComponent<Props, State> {
  36. getDefaultState() {
  37. return {
  38. ...super.getDefaultState(),
  39. teams: [],
  40. initialResults: [],
  41. queryResults: {},
  42. };
  43. }
  44. getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
  45. const {organization, location} = this.props;
  46. return [
  47. // We paginate on this query, since we're filtering by hasExternalTeams:true
  48. [
  49. 'teams',
  50. `/organizations/${organization.slug}/teams/`,
  51. {query: {...location?.query, query: 'hasExternalTeams:true'}},
  52. ],
  53. // We use this query as defaultOptions to reduce identical API calls
  54. ['initialResults', `/organizations/${organization.slug}/teams/`],
  55. ];
  56. }
  57. handleDelete = async (mapping: ExternalActorMapping) => {
  58. try {
  59. const {organization} = this.props;
  60. const {teams} = this.state;
  61. const team = teams.find(item => item.id === mapping.teamId);
  62. if (!team) {
  63. throw new Error('Cannot find correct team slug.');
  64. }
  65. const endpoint = `/teams/${organization.slug}/${team.slug}/external-teams/${mapping.id}/`;
  66. await this.api.requestPromise(endpoint, {
  67. method: 'DELETE',
  68. });
  69. // remove config and update state
  70. addSuccessMessage(t('Deletion successful'));
  71. this.fetchData();
  72. } catch {
  73. // no 4xx errors should happen on delete
  74. addErrorMessage(t('An error occurred'));
  75. }
  76. };
  77. handleSubmitSuccess = () => {
  78. this.fetchData();
  79. };
  80. get mappings() {
  81. const {integration} = this.props;
  82. const {teams} = this.state;
  83. const externalTeamMappings = teams.reduce<ExternalActorMapping[]>((acc, team) => {
  84. const {externalTeams} = team;
  85. acc.push(
  86. ...externalTeams
  87. .filter(externalTeam => externalTeam.provider === integration.provider.key)
  88. .map(externalTeam => ({...externalTeam, sentryName: team.slug}))
  89. );
  90. return acc;
  91. }, []);
  92. return externalTeamMappings.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
  93. }
  94. modalMappingKey = '__MODAL_RESULTS__';
  95. get dataEndpoint() {
  96. const {organization} = this.props;
  97. return `/organizations/${organization.slug}/teams/`;
  98. }
  99. get defaultTeamOptions() {
  100. const {initialResults} = this.state;
  101. return this.sentryNamesMapper(initialResults).map(sentryNameToOption);
  102. }
  103. getBaseFormEndpoint(mapping?: ExternalActorMappingOrSuggestion) {
  104. if (!mapping) {
  105. return '';
  106. }
  107. const {organization} = this.props;
  108. const {queryResults, initialResults} = this.state;
  109. const fieldResults =
  110. queryResults[mapping.externalName] ?? queryResults[this.modalMappingKey];
  111. const team =
  112. // First, search for the team in the query results...
  113. fieldResults?.find(item => item.id === mapping.teamId) ??
  114. // Then in the initial results, if nothing was found.
  115. initialResults?.find(item => item.id === mapping.teamId);
  116. return `/teams/${organization.slug}/${team?.slug ?? ''}/external-teams/`;
  117. }
  118. sentryNamesMapper(teams: Team[]) {
  119. return teams.map(({id, slug}) => ({id, name: slug}));
  120. }
  121. /**
  122. * This method combines the results from searches made on a form dropping repeated entries
  123. * that have identical 'id's. This is because we need the result of the the search query when
  124. * the user submits to get the team slug, but it won't always be the last query they've made.
  125. *
  126. * If they search (but not select) after making a selection, and we didn't keep a running collection of results,
  127. * we wouldn't have the team to generate the endpoint from.
  128. */
  129. combineResultsById = (resultList1, resultList2) => {
  130. return uniqBy([...resultList1, ...resultList2], 'id');
  131. };
  132. handleResults = (results, mappingKey?: string) => {
  133. if (mappingKey) {
  134. const {queryResults} = this.state;
  135. this.setState({
  136. queryResults: {
  137. ...queryResults,
  138. // Ensure we always have a team to pull the slug from
  139. [mappingKey]: this.combineResultsById(results, queryResults[mappingKey] ?? []),
  140. },
  141. });
  142. }
  143. };
  144. openModal = (mapping?: ExternalActorMappingOrSuggestion) => {
  145. const {integration} = this.props;
  146. openModal(({Body, Header, closeModal}) => (
  147. <Fragment>
  148. <Header closeButton>{t('Configure External Team Mapping')}</Header>
  149. <Body>
  150. <IntegrationExternalMappingForm
  151. type="team"
  152. integration={integration}
  153. dataEndpoint={this.dataEndpoint}
  154. getBaseFormEndpoint={map => this.getBaseFormEndpoint(map)}
  155. defaultOptions={this.defaultTeamOptions}
  156. mapping={mapping}
  157. mappingKey={this.modalMappingKey}
  158. sentryNamesMapper={this.sentryNamesMapper}
  159. onCancel={closeModal}
  160. onResults={this.handleResults}
  161. onSubmitSuccess={() => {
  162. this.handleSubmitSuccess();
  163. closeModal();
  164. }}
  165. />
  166. </Body>
  167. </Fragment>
  168. ));
  169. };
  170. renderBody() {
  171. const {integration, organization} = this.props;
  172. const {teamsPageLinks} = this.state;
  173. return (
  174. <IntegrationExternalMappings
  175. type="team"
  176. integration={integration}
  177. organization={organization}
  178. mappings={this.mappings}
  179. dataEndpoint={this.dataEndpoint}
  180. getBaseFormEndpoint={mapping => this.getBaseFormEndpoint(mapping)}
  181. defaultOptions={this.defaultTeamOptions}
  182. sentryNamesMapper={this.sentryNamesMapper}
  183. onCreate={this.openModal}
  184. onDelete={this.handleDelete}
  185. pageLinks={teamsPageLinks}
  186. onResults={this.handleResults}
  187. />
  188. );
  189. }
  190. }
  191. export default withSentryRouter(withOrganization(IntegrationExternalTeamMappings));