projects.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. import {useCallback} from 'react';
  2. import type {Query} from 'history';
  3. import chunk from 'lodash/chunk';
  4. import debounce from 'lodash/debounce';
  5. import {
  6. addErrorMessage,
  7. addLoadingMessage,
  8. addSuccessMessage,
  9. } from 'sentry/actionCreators/indicator';
  10. import type {Client} from 'sentry/api';
  11. import {t, tct} from 'sentry/locale';
  12. import LatestContextStore from 'sentry/stores/latestContextStore';
  13. import ProjectsStatsStore from 'sentry/stores/projectsStatsStore';
  14. import ProjectsStore from 'sentry/stores/projectsStore';
  15. import type {Team} from 'sentry/types/organization';
  16. import type {Project} from 'sentry/types/project';
  17. import type {ApiQueryKey} from 'sentry/utils/queryClient';
  18. import {setApiQueryData, useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
  19. import useApi from 'sentry/utils/useApi';
  20. type UpdateParams = {
  21. orgId: string;
  22. projectId: string;
  23. data?: {[key: string]: any};
  24. query?: Query;
  25. };
  26. export function update(api: Client, params: UpdateParams) {
  27. ProjectsStatsStore.onUpdate(params.projectId, params.data as Partial<Project>);
  28. const endpoint = `/projects/${params.orgId}/${params.projectId}/`;
  29. return api
  30. .requestPromise(endpoint, {
  31. method: 'PUT',
  32. data: params.data,
  33. })
  34. .then(
  35. data => {
  36. ProjectsStore.onUpdateSuccess(data);
  37. return data;
  38. },
  39. err => {
  40. ProjectsStatsStore.onUpdateError(err, params.projectId);
  41. throw err;
  42. }
  43. );
  44. }
  45. type StatsParams = Pick<UpdateParams, 'orgId' | 'data' | 'query'>;
  46. export function loadStats(api: Client, params: StatsParams) {
  47. const endpoint = `/organizations/${params.orgId}/stats/`;
  48. api.request(endpoint, {
  49. query: params.query,
  50. success: data => ProjectsStore.onStatsLoadSuccess(data),
  51. });
  52. }
  53. // This is going to queue up a list of project ids we need to fetch stats for
  54. // Will be cleared when debounced function fires
  55. export const _projectStatsToFetch: Set<string> = new Set();
  56. // Max projects to query at a time, otherwise if we fetch too many in the same request
  57. // it can timeout
  58. const MAX_PROJECTS_TO_FETCH = 10;
  59. const _queryForStats = (
  60. api: Client,
  61. projects: string[],
  62. orgId: string,
  63. additionalQuery: Query | undefined
  64. ) => {
  65. const idQueryParams = projects.map(project => `id:${project}`).join(' ');
  66. const endpoint = `/organizations/${orgId}/projects/`;
  67. const query: Query = {
  68. statsPeriod: '24h',
  69. query: idQueryParams,
  70. ...additionalQuery,
  71. };
  72. return api.requestPromise(endpoint, {
  73. query,
  74. });
  75. };
  76. export const _debouncedLoadStats = debounce(
  77. (api: Client, projectSet: Set<string>, params: UpdateParams) => {
  78. const storedProjects: {[key: string]: Project} = ProjectsStatsStore.getAll();
  79. const existingProjectStats = Object.values(storedProjects).map(({id}) => id);
  80. const projects = Array.from(projectSet).filter(
  81. project => !existingProjectStats.includes(project)
  82. );
  83. if (!projects.length) {
  84. _projectStatsToFetch.clear();
  85. return;
  86. }
  87. // Split projects into more manageable chunks to query, otherwise we can
  88. // potentially face server timeouts
  89. const queries = chunk(projects, MAX_PROJECTS_TO_FETCH).map(chunkedProjects =>
  90. _queryForStats(api, chunkedProjects, params.orgId, params.query)
  91. );
  92. Promise.all(queries)
  93. .then(results => {
  94. ProjectsStatsStore.onStatsLoadSuccess(
  95. results.reduce((acc, result) => acc.concat(result), [])
  96. );
  97. })
  98. .catch(() => {
  99. addErrorMessage(t('Unable to fetch all project stats'));
  100. });
  101. // Reset projects list
  102. _projectStatsToFetch.clear();
  103. },
  104. 50
  105. );
  106. export function loadStatsForProject(api: Client, project: string, params: UpdateParams) {
  107. // Queue up a list of projects that we need stats for
  108. // and call a debounced function to fetch stats for list of projects
  109. _projectStatsToFetch.add(project);
  110. _debouncedLoadStats(api, _projectStatsToFetch, params);
  111. }
  112. export function setActiveProject(project: Project | null) {
  113. LatestContextStore.onSetActiveProject(project);
  114. }
  115. export function transferProject(
  116. api: Client,
  117. orgId: string,
  118. project: Project,
  119. email: string
  120. ) {
  121. const endpoint = `/projects/${orgId}/${project.slug}/transfer/`;
  122. return api
  123. .requestPromise(endpoint, {
  124. method: 'POST',
  125. data: {
  126. email,
  127. },
  128. })
  129. .then(
  130. () => {
  131. addSuccessMessage(
  132. tct('A request was sent to move [project] to a different organization', {
  133. project: project.slug,
  134. })
  135. );
  136. },
  137. err => {
  138. let message = '';
  139. // Handle errors with known failures
  140. if (err.status >= 400 && err.status < 500 && err.responseJSON) {
  141. message = err.responseJSON?.detail;
  142. }
  143. if (message) {
  144. addErrorMessage(
  145. tct('Error transferring [project]. [message]', {
  146. project: project.slug,
  147. message,
  148. })
  149. );
  150. } else {
  151. addErrorMessage(
  152. tct('Error transferring [project]', {
  153. project: project.slug,
  154. })
  155. );
  156. }
  157. throw err;
  158. }
  159. );
  160. }
  161. /**
  162. * Associate a team with a project
  163. */
  164. /**
  165. * Adds a team to a project
  166. *
  167. * @param api API Client
  168. * @param orgSlug Organization Slug
  169. * @param projectSlug Project Slug
  170. * @param team Team data object
  171. */
  172. export function addTeamToProject(
  173. api: Client,
  174. orgSlug: string,
  175. projectSlug: string,
  176. team: Team
  177. ) {
  178. const endpoint = `/projects/${orgSlug}/${projectSlug}/teams/${team.slug}/`;
  179. addLoadingMessage();
  180. return api
  181. .requestPromise(endpoint, {
  182. method: 'POST',
  183. })
  184. .then(
  185. project => {
  186. addSuccessMessage(
  187. tct('[team] has been added to the [project] project', {
  188. team: `#${team.slug}`,
  189. project: projectSlug,
  190. })
  191. );
  192. ProjectsStore.onAddTeam(team, projectSlug);
  193. ProjectsStore.onUpdateSuccess(project);
  194. },
  195. err => {
  196. addErrorMessage(
  197. tct('Unable to add [team] to the [project] project', {
  198. team: `#${team.slug}`,
  199. project: projectSlug,
  200. })
  201. );
  202. throw err;
  203. }
  204. );
  205. }
  206. /**
  207. * Removes a team from a project
  208. *
  209. * @param api API Client
  210. * @param orgSlug Organization Slug
  211. * @param projectSlug Project Slug
  212. * @param teamSlug Team Slug
  213. */
  214. export function removeTeamFromProject(
  215. api: Client,
  216. orgSlug: string,
  217. projectSlug: string,
  218. teamSlug: string
  219. ) {
  220. const endpoint = `/projects/${orgSlug}/${projectSlug}/teams/${teamSlug}/`;
  221. addLoadingMessage();
  222. return api
  223. .requestPromise(endpoint, {
  224. method: 'DELETE',
  225. })
  226. .then(
  227. project => {
  228. addSuccessMessage(
  229. tct('[team] has been removed from the [project] project', {
  230. team: `#${teamSlug}`,
  231. project: projectSlug,
  232. })
  233. );
  234. ProjectsStore.onRemoveTeam(teamSlug, projectSlug);
  235. ProjectsStore.onUpdateSuccess(project);
  236. },
  237. err => {
  238. addErrorMessage(
  239. tct('Unable to remove [team] from the [project] project', {
  240. team: `#${teamSlug}`,
  241. project: projectSlug,
  242. })
  243. );
  244. throw err;
  245. }
  246. );
  247. }
  248. /**
  249. * Change a project's slug
  250. *
  251. * @param prev Previous slug
  252. * @param next New slug
  253. */
  254. export function changeProjectSlug(prev: string, next: string) {
  255. ProjectsStore.onChangeSlug(prev, next);
  256. }
  257. /**
  258. * Send a sample event
  259. *
  260. * @param api API Client
  261. * @param orgSlug Organization Slug
  262. * @param projectSlug Project Slug
  263. */
  264. export function sendSampleEvent(api: Client, orgSlug: string, projectSlug: string) {
  265. const endpoint = `/projects/${orgSlug}/${projectSlug}/create-sample/`;
  266. return api.requestPromise(endpoint, {
  267. method: 'POST',
  268. });
  269. }
  270. /**
  271. * Creates a project
  272. *
  273. * @param api API Client
  274. * @param orgSlug Organization Slug
  275. * @param team The team slug to assign the project to
  276. * @param name Name of the project
  277. * @param platform The platform key of the project
  278. * @param options Additional options such as creating default alert rules
  279. */
  280. export function createProject({
  281. api,
  282. name,
  283. options = {},
  284. orgSlug,
  285. platform,
  286. team,
  287. }: {
  288. api: Client;
  289. name: string;
  290. options: {defaultRules?: boolean};
  291. orgSlug: string;
  292. platform: string;
  293. team: string;
  294. }) {
  295. return api.requestPromise(`/teams/${orgSlug}/${team}/projects/`, {
  296. method: 'POST',
  297. data: {name, platform, default_rules: options.defaultRules},
  298. });
  299. }
  300. /**
  301. * Deletes a project
  302. *
  303. * @param api API Client
  304. * @param orgSlug Organization Slug
  305. * @param projectSlug Project Slug
  306. */
  307. export function removeProject({
  308. api,
  309. orgSlug,
  310. projectSlug,
  311. origin,
  312. }: {
  313. api: Client;
  314. orgSlug: string;
  315. origin: 'onboarding' | 'settings' | 'getting_started';
  316. projectSlug: Project['slug'];
  317. }) {
  318. return api.requestPromise(`/projects/${orgSlug}/${projectSlug}/`, {
  319. method: 'DELETE',
  320. data: {origin},
  321. });
  322. }
  323. /**
  324. * Load the counts of my projects and all projects for the current user
  325. *
  326. * @param api API Client
  327. * @param orgSlug Organization Slug
  328. */
  329. export function fetchProjectsCount(api: Client, orgSlug: string) {
  330. return api.requestPromise(`/organizations/${orgSlug}/projects-count/`);
  331. }
  332. /**
  333. * Check if there are any releases in the last 90 days.
  334. * Used for checking if project is using releases.
  335. *
  336. * @param api API Client
  337. * @param orgSlug Organization Slug
  338. * @param projectId Project Id
  339. */
  340. export async function fetchAnyReleaseExistence(
  341. api: Client,
  342. orgSlug: string,
  343. projectId: number | string
  344. ) {
  345. const data = await api.requestPromise(`/organizations/${orgSlug}/releases/stats/`, {
  346. method: 'GET',
  347. query: {
  348. statsPeriod: '90d',
  349. project: projectId,
  350. per_page: 1,
  351. },
  352. });
  353. return data.length > 0;
  354. }
  355. function makeProjectTeamsQueryKey({
  356. orgSlug,
  357. projectSlug,
  358. }: {
  359. orgSlug: string;
  360. projectSlug: string;
  361. }): ApiQueryKey {
  362. return [`/projects/${orgSlug}/${projectSlug}/teams/`];
  363. }
  364. export function useFetchProjectTeams({
  365. orgSlug,
  366. projectSlug,
  367. }: {
  368. orgSlug: string;
  369. projectSlug: string;
  370. }) {
  371. return useApiQuery<Team[]>(makeProjectTeamsQueryKey({orgSlug, projectSlug}), {
  372. staleTime: 0,
  373. retry: false,
  374. enabled: Boolean(orgSlug && projectSlug),
  375. });
  376. }
  377. export function useAddTeamToProject({
  378. orgSlug,
  379. projectSlug,
  380. }: {
  381. orgSlug: string;
  382. projectSlug: string;
  383. }) {
  384. const api = useApi();
  385. const queryClient = useQueryClient();
  386. return useCallback(
  387. async (team: Team) => {
  388. await addTeamToProject(api, orgSlug, projectSlug, team);
  389. setApiQueryData<Team[]>(
  390. queryClient,
  391. makeProjectTeamsQueryKey({orgSlug, projectSlug}),
  392. prevData => (Array.isArray(prevData) ? [...prevData, team] : [team])
  393. );
  394. },
  395. [api, orgSlug, projectSlug, queryClient]
  396. );
  397. }
  398. export function useRemoveTeamFromProject({
  399. orgSlug,
  400. projectSlug,
  401. }: {
  402. orgSlug: string;
  403. projectSlug: string;
  404. }) {
  405. const api = useApi();
  406. const queryClient = useQueryClient();
  407. return useCallback(
  408. async (teamSlug: string) => {
  409. await removeTeamFromProject(api, orgSlug, projectSlug, teamSlug);
  410. setApiQueryData<Team[]>(
  411. queryClient,
  412. makeProjectTeamsQueryKey({orgSlug, projectSlug}),
  413. prevData =>
  414. Array.isArray(prevData) ? prevData.filter(team => team?.slug !== teamSlug) : []
  415. );
  416. },
  417. [api, orgSlug, projectSlug, queryClient]
  418. );
  419. }