teamNotifications.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  4. import {hasEveryAccess} from 'sentry/components/acl/access';
  5. import {Button} from 'sentry/components/button';
  6. import Confirm from 'sentry/components/confirm';
  7. import EmptyMessage from 'sentry/components/emptyMessage';
  8. import TextField from 'sentry/components/forms/fields/textField';
  9. import ExternalLink from 'sentry/components/links/externalLink';
  10. import LoadingError from 'sentry/components/loadingError';
  11. import LoadingIndicator from 'sentry/components/loadingIndicator';
  12. import Panel from 'sentry/components/panels/panel';
  13. import PanelBody from 'sentry/components/panels/panelBody';
  14. import PanelHeader from 'sentry/components/panels/panelHeader';
  15. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  16. import {Tooltip} from 'sentry/components/tooltip';
  17. import {IconDelete} from 'sentry/icons';
  18. import {t, tct} from 'sentry/locale';
  19. import {space} from 'sentry/styles/space';
  20. import type {ExternalTeam, Integration} from 'sentry/types/integrations';
  21. import type {Team} from 'sentry/types/organization';
  22. import {useApiQuery} from 'sentry/utils/queryClient';
  23. import {toTitleCase} from 'sentry/utils/string/toTitleCase';
  24. import useApi from 'sentry/utils/useApi';
  25. import useOrganization from 'sentry/utils/useOrganization';
  26. import {useParams} from 'sentry/utils/useParams';
  27. import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
  28. const DOCS_LINK =
  29. 'https://docs.sentry.io/product/integrations/notification-incidents/slack/#team-notifications';
  30. const NOTIFICATION_PROVIDERS = ['slack'];
  31. function TeamNotificationSettingsPanel({
  32. team,
  33. integrations,
  34. onDelete,
  35. }: {
  36. integrations: Integration[];
  37. onDelete: (externalTeam: ExternalTeam) => void;
  38. team: Team;
  39. }) {
  40. const organization = useOrganization();
  41. const notificationIntegrations = integrations.filter(integration =>
  42. NOTIFICATION_PROVIDERS.includes(integration.provider.key)
  43. );
  44. if (!notificationIntegrations.length) {
  45. return (
  46. <EmptyMessage>
  47. {t('No Notification Integrations have been installed yet.')}
  48. </EmptyMessage>
  49. );
  50. }
  51. const externalTeams = (team.externalTeams ?? []).filter(externalTeam =>
  52. NOTIFICATION_PROVIDERS.includes(externalTeam.provider)
  53. );
  54. if (!externalTeams.length) {
  55. return (
  56. <EmptyMessage>
  57. <div>{t('No teams have been linked yet.')}</div>
  58. <NotDisabledSubText>
  59. {tct('Head over to Slack and type [code] to get started. [link].', {
  60. code: <code>/sentry link team</code>,
  61. link: <ExternalLink href={DOCS_LINK}>{t('Learn more')}</ExternalLink>,
  62. })}
  63. </NotDisabledSubText>
  64. </EmptyMessage>
  65. );
  66. }
  67. const integrationsById = Object.fromEntries(
  68. notificationIntegrations.map(integration => [integration.id, integration])
  69. );
  70. const hasWriteAccess = hasEveryAccess(['team:write'], {organization, team});
  71. return externalTeams.map(externalTeam => (
  72. <FormFieldWrapper key={externalTeam.id}>
  73. <StyledFormField
  74. disabled
  75. label={
  76. <div>
  77. <NotDisabledText>
  78. {toTitleCase(externalTeam.provider)}:
  79. {integrationsById[externalTeam.integrationId]!.name}
  80. </NotDisabledText>
  81. <NotDisabledSubText>
  82. {tct('Unlink this channel in Slack with [code]. [link].', {
  83. code: <code>/sentry unlink team</code>,
  84. link: <ExternalLink href={DOCS_LINK}>{t('Learn more')}</ExternalLink>,
  85. })}
  86. </NotDisabledSubText>
  87. </div>
  88. }
  89. labelText={t('Unlink this channel in slack with `/slack unlink team`')}
  90. name="externalName"
  91. value={externalTeam.externalName}
  92. />
  93. <DeleteButtonWrapper>
  94. <Tooltip
  95. title={t(
  96. 'You must be an organization owner, manager or admin to remove a Slack team link'
  97. )}
  98. disabled={hasWriteAccess}
  99. >
  100. <Confirm
  101. disabled={!hasWriteAccess}
  102. onConfirm={() => onDelete(externalTeam)}
  103. message={t('Are you sure you want to remove this Slack team link?')}
  104. >
  105. <Button icon={<IconDelete />} disabled={!hasWriteAccess}>
  106. {t('Unlink')}
  107. </Button>
  108. </Confirm>
  109. </Tooltip>
  110. </DeleteButtonWrapper>
  111. </FormFieldWrapper>
  112. ));
  113. }
  114. function TeamNotificationSettings() {
  115. const api = useApi();
  116. const params = useParams<{teamId: string}>();
  117. const organization = useOrganization();
  118. const {
  119. data: team,
  120. isPending: isTeamPending,
  121. isError: isTeamError,
  122. refetch: refetchTeam,
  123. } = useApiQuery<Team>(
  124. [
  125. `/teams/${organization.slug}/${params.teamId}/`,
  126. {
  127. query: {expand: ['externalTeams']},
  128. },
  129. ],
  130. {
  131. staleTime: 0,
  132. }
  133. );
  134. const {
  135. data: integrations,
  136. isPending: isIntegrationsPending,
  137. isError: isIntegrationsError,
  138. refetch: refetchIntegrations,
  139. } = useApiQuery<Integration[]>(
  140. [
  141. `/organizations/${organization.slug}/integrations/`,
  142. {
  143. query: {includeConfig: '0'},
  144. },
  145. ],
  146. {
  147. staleTime: 0,
  148. }
  149. );
  150. if (isTeamPending || isIntegrationsPending) {
  151. return <LoadingIndicator />;
  152. }
  153. if (isTeamError || isIntegrationsError) {
  154. return (
  155. <LoadingError
  156. onRetry={() => {
  157. refetchTeam();
  158. refetchIntegrations();
  159. }}
  160. />
  161. );
  162. }
  163. const handleDelete = async (externalTeam: ExternalTeam) => {
  164. try {
  165. await api.requestPromise(
  166. `/teams/${organization.slug}/${team.slug}/external-teams/${externalTeam.id}/`,
  167. {
  168. method: 'DELETE',
  169. }
  170. );
  171. addSuccessMessage(t('Deletion successful'));
  172. } catch {
  173. addErrorMessage(t('An error occurred'));
  174. }
  175. refetchTeam();
  176. refetchIntegrations();
  177. };
  178. return (
  179. <Fragment>
  180. <SentryDocumentTitle
  181. title={t('%s Team Notification Settings', `#${params.teamId}`)}
  182. />
  183. <PermissionAlert access={['team:write']} team={team} />
  184. <Panel>
  185. <PanelHeader>{t('Notifications')}</PanelHeader>
  186. <PanelBody>
  187. <TeamNotificationSettingsPanel
  188. team={team}
  189. integrations={integrations}
  190. onDelete={handleDelete}
  191. />
  192. </PanelBody>
  193. </Panel>
  194. </Fragment>
  195. );
  196. }
  197. export default TeamNotificationSettings;
  198. const NotDisabledText = styled('div')`
  199. color: ${p => p.theme.textColor};
  200. line-height: ${space(2)};
  201. `;
  202. const NotDisabledSubText = styled('div')`
  203. color: ${p => p.theme.subText};
  204. font-size: ${p => p.theme.fontSizeRelativeSmall};
  205. line-height: 1.4;
  206. margin-top: ${space(1)};
  207. `;
  208. const FormFieldWrapper = styled('div')`
  209. display: flex;
  210. align-items: center;
  211. justify-content: flex-start;
  212. `;
  213. const StyledFormField = styled(TextField)`
  214. flex: 1;
  215. `;
  216. const DeleteButtonWrapper = styled('div')`
  217. margin-right: ${space(2)};
  218. `;