group.tsx 12 KB

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