globalSelection.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. import {InjectedRouter} from 'react-router';
  2. import * as Sentry from '@sentry/react';
  3. import isInteger from 'lodash/isInteger';
  4. import omit from 'lodash/omit';
  5. import pick from 'lodash/pick';
  6. import * as qs from 'query-string';
  7. import GlobalSelectionActions from 'sentry/actions/globalSelectionActions';
  8. import {
  9. getDefaultSelection,
  10. getStateFromQuery,
  11. } from 'sentry/components/organizations/globalSelectionHeader/utils';
  12. import {
  13. DATE_TIME,
  14. LOCAL_STORAGE_KEY,
  15. URL_PARAM,
  16. } from 'sentry/constants/globalSelectionHeader';
  17. import {
  18. Environment,
  19. GlobalSelection,
  20. MinimalProject,
  21. Organization,
  22. Project,
  23. } from 'sentry/types';
  24. import {defined} from 'sentry/utils';
  25. import {getUtcDateString} from 'sentry/utils/dates';
  26. import localStorage from 'sentry/utils/localStorage';
  27. /**
  28. * Note this is the internal project.id, NOT the slug, but it is the stringified version of it
  29. */
  30. type ProjectId = string | number;
  31. type EnvironmentId = Environment['id'];
  32. type Options = {
  33. /**
  34. * List of parameters to remove when changing URL params
  35. */
  36. resetParams?: string[];
  37. save?: boolean;
  38. keepCursor?: boolean;
  39. };
  40. /**
  41. * Can be relative time string or absolute (using start and end dates)
  42. */
  43. type DateTimeObject = {
  44. start?: Date | string | null;
  45. end?: Date | string | null;
  46. statsPeriod?: string | null;
  47. utc?: string | boolean | null;
  48. /**
  49. * @deprecated
  50. */
  51. period?: string | null;
  52. };
  53. /**
  54. * Cast project ids to strings, as everything is assumed to be a string in URL params
  55. *
  56. * We also handle internal types so Dates and booleans can show up in the start/end/utc
  57. * keys. Long term it would be good to narrow down these types.
  58. */
  59. type UrlParams = {
  60. project?: ProjectId[] | null;
  61. environment?: EnvironmentId[] | null;
  62. } & DateTimeObject & {
  63. [others: string]: any;
  64. };
  65. /**
  66. * This can be null which will not perform any router side effects, and instead updates store.
  67. */
  68. type Router = InjectedRouter | null | undefined;
  69. // Reset values in global selection store
  70. export function resetGlobalSelection() {
  71. GlobalSelectionActions.reset();
  72. }
  73. function getProjectIdFromProject(project: MinimalProject) {
  74. return parseInt(project.id, 10);
  75. }
  76. type InitializeUrlStateParams = {
  77. organization: Organization;
  78. queryParams: InjectedRouter['location']['query'];
  79. router: InjectedRouter;
  80. memberProjects: Project[];
  81. shouldForceProject?: boolean;
  82. shouldEnforceSingleProject: boolean;
  83. /**
  84. * If true, do not load from local storage
  85. */
  86. skipLoadLastUsed?: boolean;
  87. defaultSelection?: Partial<GlobalSelection>;
  88. forceProject?: MinimalProject | null;
  89. showAbsolute?: boolean;
  90. };
  91. export function initializeUrlState({
  92. organization,
  93. queryParams,
  94. router,
  95. memberProjects,
  96. skipLoadLastUsed,
  97. shouldForceProject,
  98. shouldEnforceSingleProject,
  99. defaultSelection,
  100. forceProject,
  101. showAbsolute = true,
  102. }: InitializeUrlStateParams) {
  103. const orgSlug = organization.slug;
  104. const query = pick(queryParams, [URL_PARAM.PROJECT, URL_PARAM.ENVIRONMENT]);
  105. const hasProjectOrEnvironmentInUrl = Object.keys(query).length > 0;
  106. const parsed = getStateFromQuery(queryParams, {
  107. allowAbsoluteDatetime: showAbsolute,
  108. allowEmptyPeriod: true,
  109. });
  110. const {datetime: defaultDateTime, ...retrievedDefaultSelection} = getDefaultSelection();
  111. const {datetime: customizedDefaultDateTime, ...customizedDefaultSelection} =
  112. defaultSelection || {};
  113. let globalSelection: Omit<GlobalSelection, 'datetime'> & {
  114. datetime: {
  115. [K in keyof GlobalSelection['datetime']]: GlobalSelection['datetime'][K] | null;
  116. };
  117. } = {
  118. ...retrievedDefaultSelection,
  119. ...customizedDefaultSelection,
  120. datetime: {
  121. [DATE_TIME.START as 'start']:
  122. parsed.start || customizedDefaultDateTime?.start || null,
  123. [DATE_TIME.END as 'end']: parsed.end || customizedDefaultDateTime?.end || null,
  124. [DATE_TIME.PERIOD as 'period']:
  125. parsed.period || customizedDefaultDateTime?.period || defaultDateTime.period,
  126. [DATE_TIME.UTC as 'utc']: parsed.utc || customizedDefaultDateTime?.utc || null,
  127. },
  128. };
  129. if (globalSelection.datetime.start && globalSelection.datetime.end) {
  130. globalSelection.datetime.period = null;
  131. }
  132. // We only save environment and project, so if those exist in
  133. // URL, do not touch local storage
  134. if (hasProjectOrEnvironmentInUrl) {
  135. globalSelection.projects = parsed.project || [];
  136. globalSelection.environments = parsed.environment || [];
  137. } else if (!skipLoadLastUsed) {
  138. try {
  139. const localStorageKey = `${LOCAL_STORAGE_KEY}:${orgSlug}`;
  140. const storedValue = localStorage.getItem(localStorageKey);
  141. if (storedValue) {
  142. globalSelection = {
  143. datetime: globalSelection.datetime,
  144. ...JSON.parse(storedValue),
  145. };
  146. }
  147. } catch (err) {
  148. // use default if invalid
  149. Sentry.captureException(err);
  150. console.error(err); // eslint-disable-line no-console
  151. }
  152. }
  153. const {projects, environments: environment, datetime} = globalSelection;
  154. let newProject: number[] | null = null;
  155. let project = projects;
  156. /**
  157. * Skip enforcing a single project if `shouldForceProject` is true,
  158. * since a component is controlling what that project needs to be.
  159. * This is true regardless if user has access to multi projects
  160. */
  161. if (shouldForceProject && forceProject) {
  162. newProject = [getProjectIdFromProject(forceProject)];
  163. } else if (shouldEnforceSingleProject && !shouldForceProject) {
  164. /**
  165. * If user does not have access to `global-views` (e.g. multi project select) *and* there is no
  166. * `project` URL parameter, then we update URL params with:
  167. * 1) the first project from the list of requested projects from URL params,
  168. * 2) first project user is a member of from org
  169. *
  170. * Note this is intentionally skipped if `shouldForceProject == true` since we want to initialize store
  171. * and wait for the forced project
  172. */
  173. if (projects && projects.length > 0) {
  174. // If there is a list of projects from URL params, select first project from that list
  175. newProject = typeof projects === 'string' ? [Number(projects)] : [projects[0]];
  176. } else {
  177. // When we have finished loading the organization into the props, i.e. the organization slug is consistent with
  178. // the URL param--Sentry will get the first project from the organization that the user is a member of.
  179. newProject = [...memberProjects].slice(0, 1).map(getProjectIdFromProject);
  180. }
  181. }
  182. if (newProject) {
  183. globalSelection.projects = newProject;
  184. project = newProject;
  185. }
  186. GlobalSelectionActions.initializeUrlState(globalSelection);
  187. GlobalSelectionActions.setOrganization(organization);
  188. // To keep URLs clean, don't push default period if url params are empty
  189. const parsedWithNoDefaultPeriod = getStateFromQuery(queryParams, {
  190. allowEmptyPeriod: true,
  191. allowAbsoluteDatetime: showAbsolute,
  192. });
  193. const newDatetime = {
  194. ...datetime,
  195. period:
  196. !parsedWithNoDefaultPeriod.start &&
  197. !parsedWithNoDefaultPeriod.end &&
  198. !parsedWithNoDefaultPeriod.period
  199. ? null
  200. : datetime.period,
  201. utc: !parsedWithNoDefaultPeriod.utc ? null : datetime.utc,
  202. };
  203. updateParamsWithoutHistory({project, environment, ...newDatetime}, router, {
  204. keepCursor: true,
  205. });
  206. }
  207. /**
  208. * Updates store and global project selection URL param if `router` is supplied
  209. *
  210. * This accepts `environments` from `options` to also update environments simultaneously
  211. * as environments are tied to a project, so if you change projects, you may need
  212. * to clear environments.
  213. */
  214. export function updateProjects(
  215. projects: ProjectId[],
  216. router?: Router,
  217. options?: Options & {environments?: EnvironmentId[]}
  218. ) {
  219. if (!isProjectsValid(projects)) {
  220. Sentry.withScope(scope => {
  221. scope.setExtra('projects', projects);
  222. Sentry.captureException(new Error('Invalid projects selected'));
  223. });
  224. return;
  225. }
  226. GlobalSelectionActions.updateProjects(projects, options?.environments);
  227. updateParams({project: projects, environment: options?.environments}, router, options);
  228. }
  229. function isProjectsValid(projects: ProjectId[]) {
  230. return Array.isArray(projects) && projects.every(project => isInteger(project));
  231. }
  232. /**
  233. * Updates store and global datetime selection URL param if `router` is supplied
  234. *
  235. * @param {Object} datetime Object with start, end, range keys
  236. * @param {Object} [router] Router object
  237. * @param {Object} [options] Options object
  238. * @param {String[]} [options.resetParams] List of parameters to remove when changing URL params
  239. */
  240. export function updateDateTime(
  241. datetime: DateTimeObject,
  242. router?: Router,
  243. options?: Options
  244. ) {
  245. GlobalSelectionActions.updateDateTime(datetime);
  246. // We only save projects/environments to local storage, do not
  247. // save anything when date changes.
  248. updateParams(datetime, router, {...options, save: false});
  249. }
  250. /**
  251. * Updates store and updates global environment selection URL param if `router` is supplied
  252. *
  253. * @param {String[]} environments List of environments
  254. * @param {Object} [router] Router object
  255. * @param {Object} [options] Options object
  256. * @param {String[]} [options.resetParams] List of parameters to remove when changing URL params
  257. */
  258. export function updateEnvironments(
  259. environment: EnvironmentId[] | null,
  260. router?: Router,
  261. options?: Options
  262. ) {
  263. GlobalSelectionActions.updateEnvironments(environment);
  264. updateParams({environment}, router, options);
  265. }
  266. /**
  267. * Updates router/URL with new query params
  268. *
  269. * @param obj New query params
  270. * @param [router] React router object
  271. * @param [options] Options object
  272. */
  273. export function updateParams(obj: UrlParams, router?: Router, options?: Options) {
  274. // Allow another component to handle routing
  275. if (!router) {
  276. return;
  277. }
  278. const newQuery = getNewQueryParams(obj, router.location.query, options);
  279. // Only push new location if query params has changed because this will cause a heavy re-render
  280. if (qs.stringify(newQuery) === qs.stringify(router.location.query)) {
  281. return;
  282. }
  283. if (options?.save) {
  284. GlobalSelectionActions.save(newQuery);
  285. }
  286. router.push({
  287. pathname: router.location.pathname,
  288. query: newQuery,
  289. });
  290. }
  291. /**
  292. * Like updateParams but just replaces the current URL and does not create a
  293. * new browser history entry
  294. *
  295. * @param obj New query params
  296. * @param [router] React router object
  297. * @param [options] Options object
  298. */
  299. export function updateParamsWithoutHistory(
  300. obj: UrlParams,
  301. router?: Router,
  302. options?: Options
  303. ) {
  304. // Allow another component to handle routing
  305. if (!router) {
  306. return;
  307. }
  308. const newQuery = getNewQueryParams(obj, router.location.query, options);
  309. // Only push new location if query params have changed because this will cause a heavy re-render
  310. if (qs.stringify(newQuery) === qs.stringify(router.location.query)) {
  311. return;
  312. }
  313. router.replace({
  314. pathname: router.location.pathname,
  315. query: newQuery,
  316. });
  317. }
  318. /**
  319. * Creates a new query parameter object given new params and old params
  320. * Preserves the old query params, except for `cursor` (can be overriden with keepCursor option)
  321. *
  322. * @param obj New query params
  323. * @param oldQueryParams Old query params
  324. * @param [options] Options object
  325. */
  326. function getNewQueryParams(
  327. obj: UrlParams,
  328. oldQueryParams: UrlParams,
  329. {resetParams, keepCursor}: Options = {}
  330. ) {
  331. const {cursor, statsPeriod, ...oldQuery} = oldQueryParams;
  332. const oldQueryWithoutResetParams = !!resetParams?.length
  333. ? omit(oldQuery, resetParams)
  334. : oldQuery;
  335. const newQuery = getParams({
  336. ...oldQueryWithoutResetParams,
  337. // Some views update using `period`, and some `statsPeriod`, we should make this uniform
  338. period: !obj.start && !obj.end ? obj.period || statsPeriod : null,
  339. ...obj,
  340. });
  341. if (newQuery.start) {
  342. newQuery.start = getUtcDateString(newQuery.start);
  343. }
  344. if (newQuery.end) {
  345. newQuery.end = getUtcDateString(newQuery.end);
  346. }
  347. if (keepCursor) {
  348. newQuery.cursor = cursor;
  349. }
  350. return newQuery;
  351. }
  352. function getParams(params: UrlParams): UrlParams {
  353. const {start, end, period, statsPeriod, ...otherParams} = params;
  354. // `statsPeriod` takes precedence for now
  355. const coercedPeriod = statsPeriod || period;
  356. // Filter null values
  357. return Object.fromEntries(
  358. Object.entries({
  359. statsPeriod: coercedPeriod,
  360. start: coercedPeriod ? null : start,
  361. end: coercedPeriod ? null : end,
  362. ...otherParams,
  363. }).filter(([, value]) => defined(value))
  364. );
  365. }