group.tsx 12 KB

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