projects.tsx 12 KB

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