group.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. import * as Sentry from '@sentry/react';
  2. import {Tag} from 'sentry/actionCreators/events';
  3. import {Client, RequestCallbacks, RequestOptions} from 'sentry/api';
  4. import {getSampleEventQuery} from 'sentry/components/events/eventStatisticalDetector/eventComparison/eventDisplay';
  5. import GroupStore from 'sentry/stores/groupStore';
  6. import {Actor, Group, Member, Note, Tag as GroupTag, TagValue, User} from 'sentry/types';
  7. import {buildTeamId, buildUserId, defined} from 'sentry/utils';
  8. import {uniqueId} from 'sentry/utils/guid';
  9. import {ApiQueryKey, useApiQuery, UseApiQueryOptions} from 'sentry/utils/queryClient';
  10. type AssignedBy = 'suggested_assignee' | 'assignee_selector';
  11. type AssignToUserParams = {
  12. assignedBy: AssignedBy;
  13. /**
  14. * Issue id
  15. */
  16. id: string;
  17. orgSlug: string;
  18. user: User | Actor;
  19. member?: Member;
  20. };
  21. export function assignToUser(params: AssignToUserParams) {
  22. const api = new Client();
  23. const endpoint = `/organizations/${params.orgSlug}/issues/${params.id}/`;
  24. const id = uniqueId();
  25. GroupStore.onAssignTo(id, params.id, {
  26. email: (params.member && params.member.email) || '',
  27. });
  28. const request = api.requestPromise(endpoint, {
  29. method: 'PUT',
  30. // Sending an empty value to assignedTo is the same as "clear",
  31. // so if no member exists, that implies that we want to clear the
  32. // current assignee.
  33. data: {
  34. assignedTo: params.user ? buildUserId(params.user.id) : '',
  35. assignedBy: params.assignedBy,
  36. },
  37. });
  38. request
  39. .then(data => {
  40. GroupStore.onAssignToSuccess(id, params.id, data);
  41. })
  42. .catch(data => {
  43. GroupStore.onAssignToError(id, params.id, data);
  44. });
  45. return request;
  46. }
  47. export function clearAssignment(
  48. groupId: string,
  49. orgSlug: string,
  50. assignedBy: AssignedBy
  51. ) {
  52. const api = new Client();
  53. const endpoint = `/organizations/${orgSlug}/issues/${groupId}/`;
  54. const id = uniqueId();
  55. GroupStore.onAssignTo(id, groupId, {
  56. email: '',
  57. });
  58. const request = api.requestPromise(endpoint, {
  59. method: 'PUT',
  60. // Sending an empty value to assignedTo is the same as "clear"
  61. data: {
  62. assignedTo: '',
  63. assignedBy,
  64. },
  65. });
  66. request
  67. .then(data => {
  68. GroupStore.onAssignToSuccess(id, groupId, data);
  69. })
  70. .catch(data => {
  71. GroupStore.onAssignToError(id, groupId, data);
  72. });
  73. return request;
  74. }
  75. type AssignToActorParams = {
  76. actor: Pick<Actor, 'id' | 'type'>;
  77. assignedBy: AssignedBy;
  78. /**
  79. * Issue id
  80. */
  81. id: string;
  82. orgSlug: string;
  83. };
  84. export function assignToActor({id, actor, assignedBy, orgSlug}: AssignToActorParams) {
  85. const api = new Client();
  86. const endpoint = `/organizations/${orgSlug}/issues/${id}/`;
  87. const guid = uniqueId();
  88. let actorId = '';
  89. GroupStore.onAssignTo(guid, id, {email: ''});
  90. switch (actor.type) {
  91. case 'user':
  92. actorId = buildUserId(actor.id);
  93. break;
  94. case 'team':
  95. actorId = buildTeamId(actor.id);
  96. break;
  97. default:
  98. Sentry.withScope(scope => {
  99. scope.setExtra('actor', actor);
  100. Sentry.captureException('Unknown assignee type');
  101. });
  102. }
  103. return api
  104. .requestPromise(endpoint, {
  105. method: 'PUT',
  106. data: {assignedTo: actorId, assignedBy},
  107. })
  108. .then(data => {
  109. GroupStore.onAssignToSuccess(guid, id, data);
  110. })
  111. .catch(data => {
  112. GroupStore.onAssignToSuccess(guid, id, data);
  113. });
  114. }
  115. export function deleteNote(
  116. api: Client,
  117. orgSlug: string,
  118. group: Group,
  119. id: string,
  120. _oldText: string
  121. ) {
  122. const restore = group.activity.find(activity => activity.id === id);
  123. const index = GroupStore.removeActivity(group.id, id);
  124. if (index === -1 || restore === undefined) {
  125. // I dunno, the id wasn't found in the GroupStore
  126. return Promise.reject(new Error('Group was not found in store'));
  127. }
  128. const promise = api.requestPromise(
  129. `/organizations/${orgSlug}/issues/${group.id}/comments/${id}/`,
  130. {
  131. method: 'DELETE',
  132. }
  133. );
  134. promise.catch(() => GroupStore.addActivity(group.id, restore, index));
  135. return promise;
  136. }
  137. export function createNote(api: Client, orgSlug: string, group: Group, note: Note) {
  138. const promise = api.requestPromise(
  139. `/organizations/${orgSlug}/issues/${group.id}/comments/`,
  140. {
  141. method: 'POST',
  142. data: note,
  143. }
  144. );
  145. promise.then(data => GroupStore.addActivity(group.id, data));
  146. return promise;
  147. }
  148. export function updateNote(
  149. api: Client,
  150. orgSlug: string,
  151. group: Group,
  152. note: Note,
  153. id: string,
  154. oldText: string
  155. ) {
  156. GroupStore.updateActivity(group.id, id, {text: note.text});
  157. const promise = api.requestPromise(
  158. `/organizations/${orgSlug}/issues/${group.id}/comments/${id}/`,
  159. {
  160. method: 'PUT',
  161. data: note,
  162. }
  163. );
  164. promise.catch(() => GroupStore.updateActivity(group.id, id, {text: oldText}));
  165. return promise;
  166. }
  167. type ParamsType = {
  168. environment?: string | string[] | null;
  169. itemIds?: string[];
  170. project?: number[] | null;
  171. query?: string;
  172. };
  173. type UpdateParams = ParamsType & {
  174. orgId: string;
  175. projectId?: string;
  176. };
  177. type QueryArgs =
  178. | {
  179. query: string;
  180. environment?: string | Array<string>;
  181. project?: Array<number>;
  182. }
  183. | {
  184. id: Array<number> | Array<string>;
  185. environment?: string | Array<string>;
  186. project?: Array<number>;
  187. }
  188. | {
  189. environment?: string | Array<string>;
  190. project?: Array<number>;
  191. };
  192. /**
  193. * Converts input parameters to API-compatible query arguments
  194. */
  195. export function paramsToQueryArgs(params: ParamsType): QueryArgs {
  196. const p: QueryArgs = params.itemIds
  197. ? {id: params.itemIds} // items matching array of itemids
  198. : params.query
  199. ? {query: params.query} // items matching search query
  200. : {}; // all items
  201. // only include environment if it is not null/undefined
  202. if (params.query && params.environment !== null && params.environment !== undefined) {
  203. p.environment = params.environment;
  204. }
  205. // only include projects if it is not null/undefined/an empty array
  206. if (params.project?.length) {
  207. p.project = params.project;
  208. }
  209. // only include date filters if they are not null/undefined
  210. if (params.query) {
  211. ['start', 'end', 'period', 'utc'].forEach(prop => {
  212. if (params[prop] !== null && params[prop] !== undefined) {
  213. p[prop === 'period' ? 'statsPeriod' : prop] = params[prop];
  214. }
  215. });
  216. }
  217. return p;
  218. }
  219. function getUpdateUrl({projectId, orgId}: UpdateParams) {
  220. return projectId
  221. ? `/projects/${orgId}/${projectId}/issues/`
  222. : `/organizations/${orgId}/issues/`;
  223. }
  224. function chainUtil<Args extends any[]>(
  225. ...funcs: Array<((...args: Args) => any) | undefined>
  226. ) {
  227. const filteredFuncs = funcs.filter(
  228. (f): f is (...args: Args) => any => typeof f === 'function'
  229. );
  230. return (...args: Args): void => {
  231. filteredFuncs.forEach(func => {
  232. func.apply(funcs, args);
  233. });
  234. };
  235. }
  236. function wrapRequest(
  237. api: Client,
  238. path: string,
  239. options: RequestOptions,
  240. extraParams: RequestCallbacks = {}
  241. ) {
  242. options.success = chainUtil(options.success, extraParams.success);
  243. options.error = chainUtil(options.error, extraParams.error);
  244. options.complete = chainUtil(options.complete, extraParams.complete);
  245. return api.request(path, options);
  246. }
  247. type BulkDeleteParams = UpdateParams;
  248. export function bulkDelete(
  249. api: Client,
  250. params: BulkDeleteParams,
  251. options: RequestCallbacks
  252. ) {
  253. const {itemIds} = params;
  254. const path = getUpdateUrl(params);
  255. const query: QueryArgs = paramsToQueryArgs(params);
  256. const id = uniqueId();
  257. GroupStore.onDelete(id, itemIds);
  258. return wrapRequest(
  259. api,
  260. path,
  261. {
  262. query,
  263. method: 'DELETE',
  264. success: response => {
  265. GroupStore.onDeleteSuccess(id, itemIds, response);
  266. },
  267. error: error => {
  268. GroupStore.onDeleteError(id, itemIds, error);
  269. },
  270. },
  271. options
  272. );
  273. }
  274. type BulkUpdateParams = UpdateParams & {
  275. data?: any;
  276. failSilently?: boolean;
  277. };
  278. export function bulkUpdate(
  279. api: Client,
  280. params: BulkUpdateParams,
  281. options: RequestCallbacks
  282. ) {
  283. const {itemIds, failSilently, data} = params;
  284. const path = getUpdateUrl(params);
  285. const query: QueryArgs = paramsToQueryArgs(params);
  286. const id = uniqueId();
  287. GroupStore.onUpdate(id, itemIds, data);
  288. return wrapRequest(
  289. api,
  290. path,
  291. {
  292. query,
  293. method: 'PUT',
  294. data,
  295. success: response => {
  296. GroupStore.onUpdateSuccess(id, itemIds, response);
  297. },
  298. error: () => {
  299. GroupStore.onUpdateError(id, itemIds, !!failSilently);
  300. },
  301. },
  302. options
  303. );
  304. }
  305. type MergeGroupsParams = UpdateParams;
  306. export function mergeGroups(
  307. api: Client,
  308. params: MergeGroupsParams,
  309. options: RequestCallbacks
  310. ) {
  311. const {itemIds} = params;
  312. const path = getUpdateUrl(params);
  313. const query: QueryArgs = paramsToQueryArgs(params);
  314. const id = uniqueId();
  315. GroupStore.onMerge(id, itemIds);
  316. return wrapRequest(
  317. api,
  318. path,
  319. {
  320. query,
  321. method: 'PUT',
  322. data: {merge: 1},
  323. success: response => {
  324. GroupStore.onMergeSuccess(id, itemIds, response);
  325. },
  326. error: error => {
  327. GroupStore.onMergeError(id, itemIds, error);
  328. },
  329. },
  330. options
  331. );
  332. }
  333. export type GroupTagResponseItem = {
  334. key: string;
  335. name: string;
  336. topValues: Array<{
  337. count: number;
  338. firstSeen: string;
  339. lastSeen: string;
  340. name: string;
  341. value: string;
  342. readable?: boolean;
  343. }>;
  344. totalValues: number;
  345. };
  346. export type GroupTagsResponse = GroupTagResponseItem[];
  347. type FetchIssueTagsParameters = {
  348. environment: string[];
  349. orgSlug: string;
  350. groupId?: string;
  351. isStatisticalDetector?: boolean;
  352. limit?: number;
  353. readable?: boolean;
  354. statisticalDetectorParameters?: {
  355. durationBaseline: number;
  356. end: string;
  357. start: string;
  358. transaction: string;
  359. };
  360. };
  361. export const makeFetchIssueTagsQueryKey = ({
  362. groupId,
  363. orgSlug,
  364. environment,
  365. readable,
  366. limit,
  367. }: FetchIssueTagsParameters): ApiQueryKey => [
  368. `/organizations/${orgSlug}/issues/${groupId}/tags/`,
  369. {query: {environment, readable, limit}},
  370. ];
  371. const makeFetchStatisticalDetectorTagsQueryKey = ({
  372. orgSlug,
  373. environment,
  374. statisticalDetectorParameters,
  375. }: FetchIssueTagsParameters): ApiQueryKey => {
  376. const {transaction, durationBaseline, start, end} = statisticalDetectorParameters ?? {
  377. transaction: '',
  378. durationBaseline: 0,
  379. start: undefined,
  380. end: undefined,
  381. };
  382. return [
  383. `/organizations/${orgSlug}/events-facets/`,
  384. {
  385. query: {
  386. environment,
  387. transaction,
  388. includeAll: true,
  389. query: getSampleEventQuery({transaction, durationBaseline, addUpperBound: false}),
  390. start,
  391. end,
  392. },
  393. },
  394. ];
  395. };
  396. export const useFetchIssueTags = (
  397. parameters: FetchIssueTagsParameters,
  398. {
  399. enabled = true,
  400. ...options
  401. }: Partial<UseApiQueryOptions<GroupTagsResponse | Tag[]>> = {}
  402. ) => {
  403. let queryKey = makeFetchIssueTagsQueryKey(parameters);
  404. if (parameters.isStatisticalDetector) {
  405. // Statistical detector issues need to use a Discover query for tags
  406. queryKey = makeFetchStatisticalDetectorTagsQueryKey(parameters);
  407. }
  408. return useApiQuery<GroupTagsResponse | Tag[]>(queryKey, {
  409. staleTime: 30000,
  410. enabled: defined(parameters.groupId) && enabled,
  411. ...options,
  412. });
  413. };
  414. type FetchIssueTagValuesParameters = {
  415. groupId: string;
  416. orgSlug: string;
  417. tagKey: string;
  418. cursor?: string;
  419. environment?: string[];
  420. sort?: string | string[];
  421. };
  422. export const makeFetchIssueTagValuesQueryKey = ({
  423. orgSlug,
  424. groupId,
  425. tagKey,
  426. environment,
  427. sort,
  428. cursor,
  429. }: FetchIssueTagValuesParameters): ApiQueryKey => [
  430. `/organizations/${orgSlug}/issues/${groupId}/tags/${tagKey}/values/`,
  431. {query: {environment, sort, cursor}},
  432. ];
  433. export function useFetchIssueTagValues(
  434. parameters: FetchIssueTagValuesParameters,
  435. options: Partial<UseApiQueryOptions<TagValue[]>> = {}
  436. ) {
  437. return useApiQuery<TagValue[]>(makeFetchIssueTagValuesQueryKey(parameters), {
  438. staleTime: 0,
  439. retry: false,
  440. ...options,
  441. });
  442. }
  443. type FetchIssueTagParameters = {
  444. groupId: string;
  445. orgSlug: string;
  446. tagKey: string;
  447. };
  448. export const makeFetchIssueTagQueryKey = ({
  449. orgSlug,
  450. groupId,
  451. tagKey,
  452. environment,
  453. sort,
  454. }: FetchIssueTagValuesParameters): ApiQueryKey => [
  455. `/organizations/${orgSlug}/issues/${groupId}/tags/${tagKey}/`,
  456. {query: {environment, sort}},
  457. ];
  458. export function useFetchIssueTag(
  459. parameters: FetchIssueTagParameters,
  460. options: Partial<UseApiQueryOptions<GroupTag>> = {}
  461. ) {
  462. return useApiQuery<GroupTag>(makeFetchIssueTagQueryKey(parameters), {
  463. staleTime: 0,
  464. retry: false,
  465. ...options,
  466. });
  467. }