group.tsx 12 KB

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