pageFilters.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. import {InjectedRouter} from 'react-router';
  2. import * as Sentry from '@sentry/react';
  3. import {Location} from 'history';
  4. import isInteger from 'lodash/isInteger';
  5. import omit from 'lodash/omit';
  6. import pick from 'lodash/pick';
  7. import * as qs from 'query-string';
  8. import PageFiltersActions from 'sentry/actions/pageFiltersActions';
  9. import {
  10. getDatetimeFromState,
  11. getStateFromQuery,
  12. } from 'sentry/components/organizations/pageFilters/parse';
  13. import {
  14. getPageFilterStorage,
  15. setPageFiltersStorage,
  16. } from 'sentry/components/organizations/pageFilters/persistence';
  17. import {PageFiltersStringified} from 'sentry/components/organizations/pageFilters/types';
  18. import {
  19. getDefaultSelection,
  20. getPathsWithNewFilters,
  21. } from 'sentry/components/organizations/pageFilters/utils';
  22. import {DATE_TIME_KEYS, URL_PARAM} from 'sentry/constants/pageFilters';
  23. import OrganizationStore from 'sentry/stores/organizationStore';
  24. import {
  25. DateString,
  26. Environment,
  27. MinimalProject,
  28. Organization,
  29. PageFilters,
  30. PinnedPageFilter,
  31. Project,
  32. } from 'sentry/types';
  33. import {defined} from 'sentry/utils';
  34. import {getUtcDateString} from 'sentry/utils/dates';
  35. /**
  36. * NOTE: this is the internal project.id, NOT the slug
  37. */
  38. type ProjectId = string | number;
  39. type EnvironmentId = Environment['id'];
  40. type Options = {
  41. /**
  42. * Do not reset the `cursor` query parameter when updating page filters
  43. */
  44. keepCursor?: boolean;
  45. /**
  46. * Use Location.replace instead of push when updating the URL query state
  47. */
  48. replace?: boolean;
  49. /**
  50. * List of parameters to remove when changing URL params
  51. */
  52. resetParams?: string[];
  53. /**
  54. * Persist changes to the page filter selection into local storage
  55. */
  56. save?: boolean;
  57. };
  58. /**
  59. * This is the 'update' object used for updating the page filters. The types
  60. * here are a bit wider to allow for easy updates.
  61. */
  62. type PageFiltersUpdate = {
  63. end?: DateString;
  64. environment?: string[] | null;
  65. period?: string | null;
  66. project?: Array<string | number> | null;
  67. start?: DateString;
  68. utc?: string | boolean | null;
  69. };
  70. /**
  71. * Represents the input for updating the date time of page filters
  72. */
  73. type DateTimeUpdate = Pick<PageFiltersUpdate, 'start' | 'end' | 'period' | 'utc'>;
  74. /**
  75. * Output object used for updating query parameters
  76. */
  77. type PageFilterQuery = PageFiltersStringified & Record<string, Location['query'][string]>;
  78. /**
  79. * This can be null which will not perform any router side effects, and instead updates store.
  80. */
  81. type Router = InjectedRouter | null | undefined;
  82. /**
  83. * Reset values in the page filters store
  84. */
  85. export function resetPageFilters() {
  86. PageFiltersActions.reset();
  87. }
  88. function getProjectIdFromProject(project: MinimalProject) {
  89. return parseInt(project.id, 10);
  90. }
  91. /**
  92. * Merges two date time objects, where the `base` object takes presidence, and
  93. * the `fallback` values are used when the base values are null or undefined.
  94. */
  95. function mergeDatetime(
  96. base: PageFilters['datetime'],
  97. fallback?: Partial<PageFilters['datetime']>
  98. ) {
  99. const datetime: PageFilters['datetime'] = {
  100. start: base.start ?? fallback?.start ?? null,
  101. end: base.end ?? fallback?.end ?? null,
  102. period: base.period ?? fallback?.period ?? null,
  103. utc: base.utc ?? fallback?.utc ?? null,
  104. };
  105. return datetime;
  106. }
  107. type InitializeUrlStateParams = {
  108. memberProjects: Project[];
  109. organization: Organization;
  110. pathname: Location['pathname'];
  111. queryParams: Location['query'];
  112. router: InjectedRouter;
  113. shouldEnforceSingleProject: boolean;
  114. defaultSelection?: Partial<PageFilters>;
  115. forceProject?: MinimalProject | null;
  116. shouldForceProject?: boolean;
  117. showAbsolute?: boolean;
  118. /**
  119. * If true, do not load from local storage
  120. */
  121. skipLoadLastUsed?: boolean;
  122. };
  123. export function initializeUrlState({
  124. organization,
  125. queryParams,
  126. pathname,
  127. router,
  128. memberProjects,
  129. skipLoadLastUsed,
  130. shouldForceProject,
  131. shouldEnforceSingleProject,
  132. defaultSelection,
  133. forceProject,
  134. showAbsolute = true,
  135. }: InitializeUrlStateParams) {
  136. const orgSlug = organization.slug;
  137. const parsed = getStateFromQuery(queryParams, {
  138. allowAbsoluteDatetime: showAbsolute,
  139. allowEmptyPeriod: true,
  140. });
  141. const {datetime: defaultDatetime, ...defaultFilters} = getDefaultSelection();
  142. const {datetime: customDatetime, ...customDefaultFilters} = defaultSelection ?? {};
  143. const pageFilters: PageFilters = {
  144. ...defaultFilters,
  145. ...customDefaultFilters,
  146. datetime: mergeDatetime(parsed, customDatetime),
  147. };
  148. // Use period from default if we don't have a period set
  149. pageFilters.datetime.period ??= defaultDatetime.period;
  150. // Do not set a period if we have absolute start and end
  151. if (pageFilters.datetime.start && pageFilters.datetime.end) {
  152. pageFilters.datetime.period = null;
  153. }
  154. const hasDatetimeInUrl = Object.keys(pick(queryParams, DATE_TIME_KEYS)).length > 0;
  155. const hasProjectOrEnvironmentInUrl =
  156. Object.keys(pick(queryParams, [URL_PARAM.PROJECT, URL_PARAM.ENVIRONMENT])).length > 0;
  157. if (hasProjectOrEnvironmentInUrl) {
  158. pageFilters.projects = parsed.project || [];
  159. pageFilters.environments = parsed.environment || [];
  160. }
  161. const storedPageFilters = skipLoadLastUsed ? null : getPageFilterStorage(orgSlug);
  162. // We may want to restore some page filters from local storage. In the new
  163. // world when they are pinned, and in the old world as long as
  164. // skipLoadLastUsed is not set to true.
  165. if (storedPageFilters) {
  166. const {state: storedState, pinnedFilters} = storedPageFilters;
  167. const pageHasPinning = getPathsWithNewFilters(organization).includes(pathname);
  168. const filtersToRestore = pageHasPinning
  169. ? pinnedFilters
  170. : new Set<PinnedPageFilter>(['projects', 'environments']);
  171. if (!hasProjectOrEnvironmentInUrl && filtersToRestore.has('projects')) {
  172. pageFilters.projects = storedState.project ?? [];
  173. }
  174. if (!hasProjectOrEnvironmentInUrl && filtersToRestore.has('environments')) {
  175. pageFilters.environments = storedState.environment ?? [];
  176. }
  177. if (!hasDatetimeInUrl && filtersToRestore.has('datetime')) {
  178. const storedDatetime = getDatetimeFromState(storedState);
  179. pageFilters.datetime = mergeDatetime(pageFilters.datetime, storedDatetime);
  180. }
  181. }
  182. const {projects, environments: environment, datetime} = pageFilters;
  183. let newProject: number[] | null = null;
  184. let project = projects;
  185. // Skip enforcing a single project if `shouldForceProject` is true, since a
  186. // component is controlling what that project needs to be. This is true
  187. // regardless if user has access to multi projects
  188. if (shouldForceProject && forceProject) {
  189. newProject = [getProjectIdFromProject(forceProject)];
  190. } else if (shouldEnforceSingleProject && !shouldForceProject) {
  191. // If user does not have access to `global-views` (e.g. multi project
  192. // select) *and* there is no `project` URL parameter, then we update URL
  193. // params with:
  194. //
  195. // 1) the first project from the list of requested projects from URL params
  196. // 2) first project user is a member of from org
  197. //
  198. // Note this is intentionally skipped if `shouldForceProject == true` since
  199. // we want to initialize store and wait for the forced project
  200. //
  201. if (projects && projects.length > 0) {
  202. // If there is a list of projects from URL params, select first project
  203. // from that list
  204. newProject = typeof projects === 'string' ? [Number(projects)] : [projects[0]];
  205. } else {
  206. // When we have finished loading the organization into the props, i.e.
  207. // the organization slug is consistent with the URL param--Sentry will
  208. // get the first project from the organization that the user is a member
  209. // of.
  210. newProject = [...memberProjects].slice(0, 1).map(getProjectIdFromProject);
  211. }
  212. }
  213. if (newProject) {
  214. pageFilters.projects = newProject;
  215. project = newProject;
  216. }
  217. const pinnedFilters = storedPageFilters?.pinnedFilters ?? new Set();
  218. PageFiltersActions.initializeUrlState(pageFilters, pinnedFilters);
  219. const newDatetime = {
  220. ...datetime,
  221. period: !parsed.start && !parsed.end && !parsed.period ? null : datetime.period,
  222. utc: !parsed.utc ? null : datetime.utc,
  223. };
  224. updateParams({project, environment, ...newDatetime}, router, {
  225. replace: true,
  226. keepCursor: true,
  227. });
  228. }
  229. function isProjectsValid(projects: ProjectId[]) {
  230. return Array.isArray(projects) && projects.every(isInteger);
  231. }
  232. /**
  233. * Updates store and selection URL param if `router` is supplied
  234. *
  235. * This accepts `environments` from `options` to also update environments
  236. * simultaneously as environments are tied to a project, so if you change
  237. * projects, you may need to clear environments.
  238. */
  239. export function updateProjects(
  240. projects: ProjectId[],
  241. router?: Router,
  242. options?: Options & {environments?: EnvironmentId[]}
  243. ) {
  244. if (!isProjectsValid(projects)) {
  245. Sentry.withScope(scope => {
  246. scope.setExtra('projects', projects);
  247. Sentry.captureException(new Error('Invalid projects selected'));
  248. });
  249. return;
  250. }
  251. PageFiltersActions.updateProjects(projects, options?.environments);
  252. updateParams({project: projects, environment: options?.environments}, router, options);
  253. persistPageFilters(options);
  254. }
  255. /**
  256. * Updates store and updates global environment selection URL param if `router` is supplied
  257. *
  258. * @param {String[]} environments List of environments
  259. * @param {Object} [router] Router object
  260. * @param {Object} [options] Options object
  261. * @param {String[]} [options.resetParams] List of parameters to remove when changing URL params
  262. */
  263. export function updateEnvironments(
  264. environment: EnvironmentId[] | null,
  265. router?: Router,
  266. options?: Options
  267. ) {
  268. PageFiltersActions.updateEnvironments(environment);
  269. updateParams({environment}, router, options);
  270. persistPageFilters(options);
  271. }
  272. /**
  273. * Updates store and global datetime selection URL param if `router` is supplied
  274. *
  275. * @param {Object} datetime Object with start, end, range keys
  276. * @param {Object} [router] Router object
  277. * @param {Object} [options] Options object
  278. * @param {String[]} [options.resetParams] List of parameters to remove when changing URL params
  279. */
  280. export function updateDateTime(
  281. datetime: DateTimeUpdate,
  282. router?: Router,
  283. options?: Options
  284. ) {
  285. PageFiltersActions.updateDateTime(datetime);
  286. updateParams(datetime, router, options);
  287. persistPageFilters(options);
  288. }
  289. /**
  290. * Pins a particular filter so that it is read out of local storage
  291. */
  292. export function pinFilter(filter: PinnedPageFilter, pin: boolean) {
  293. PageFiltersActions.pin(filter, pin);
  294. persistPageFilters({save: true});
  295. }
  296. /**
  297. * Updates router/URL with new query params
  298. *
  299. * @param obj New query params
  300. * @param [router] React router object
  301. * @param [options] Options object
  302. */
  303. function updateParams(obj: PageFiltersUpdate, router?: Router, options?: Options) {
  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 has changed because this will cause a heavy re-render
  310. if (qs.stringify(newQuery) === qs.stringify(router.location.query)) {
  311. return;
  312. }
  313. const routerAction = options?.replace ? router.replace : router.push;
  314. routerAction({pathname: router.location.pathname, query: newQuery});
  315. }
  316. /**
  317. * Save the current page filters to local storage
  318. */
  319. async function persistPageFilters(options?: Options) {
  320. if (!options?.save) {
  321. return;
  322. }
  323. // XXX(epurkhiser): Since this is called immediately after updating the
  324. // store, wait for a tick since stores are not updated fully synchronously.
  325. // A bit goofy, but it works fine.
  326. await new Promise(resolve => setTimeout(resolve, 0));
  327. const {organization} = OrganizationStore.getState();
  328. const orgSlug = organization?.slug ?? null;
  329. // Can't do anything if we don't have an organization
  330. if (orgSlug === null) {
  331. return;
  332. }
  333. setPageFiltersStorage(orgSlug);
  334. }
  335. /**
  336. * Merges an UpdateParams object into a Location['query'] object. Results in a
  337. * PageFilterQuery
  338. *
  339. * Preserves the old query params, except for `cursor` (can be overriden with
  340. * keepCursor option)
  341. *
  342. * @param obj New query params
  343. * @param currentQuery The current query parameters
  344. * @param [options] Options object
  345. */
  346. function getNewQueryParams(
  347. obj: PageFiltersUpdate,
  348. currentQuery: Location['query'],
  349. options: Options = {}
  350. ) {
  351. const {resetParams, keepCursor} = options;
  352. const cleanCurrentQuery = !!resetParams?.length
  353. ? omit(currentQuery, resetParams)
  354. : currentQuery;
  355. // Normalize existing query parameters
  356. const currentQueryState = getStateFromQuery(cleanCurrentQuery, {
  357. allowEmptyPeriod: true,
  358. allowAbsoluteDatetime: true,
  359. });
  360. // Extract non page filter parameters.
  361. const cursorParam = !keepCursor ? 'cursor' : null;
  362. const omittedParameters = [...Object.values(URL_PARAM), cursorParam].filter(defined);
  363. const extraParams = omit(cleanCurrentQuery, omittedParameters);
  364. // Override parameters
  365. const {project, environment, start, end, utc} = {
  366. ...currentQueryState,
  367. ...obj,
  368. };
  369. // Only set a stats period if we don't have an absolute date
  370. const statsPeriod = !start && !end ? obj.period || currentQueryState.period : null;
  371. const newQuery: PageFilterQuery = {
  372. project: project?.map(String),
  373. environment,
  374. start: statsPeriod ? null : start instanceof Date ? getUtcDateString(start) : start,
  375. end: statsPeriod ? null : end instanceof Date ? getUtcDateString(end) : end,
  376. utc: utc ? 'true' : null,
  377. statsPeriod,
  378. ...extraParams,
  379. };
  380. const paramEntries = Object.entries(newQuery).filter(([_, value]) => defined(value));
  381. return Object.fromEntries(paramEntries) as PageFilterQuery;
  382. }