pageFilters.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  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 {
  9. getDatetimeFromState,
  10. getStateFromQuery,
  11. } from 'sentry/components/organizations/pageFilters/parse';
  12. import {
  13. getPageFilterStorage,
  14. setPageFiltersStorage,
  15. } from 'sentry/components/organizations/pageFilters/persistence';
  16. import {PageFiltersStringified} from 'sentry/components/organizations/pageFilters/types';
  17. import {getDefaultSelection} from 'sentry/components/organizations/pageFilters/utils';
  18. import {DATE_TIME_KEYS, URL_PARAM} from 'sentry/constants/pageFilters';
  19. import OrganizationStore from 'sentry/stores/organizationStore';
  20. import PageFiltersStore from 'sentry/stores/pageFiltersStore';
  21. import {
  22. DateString,
  23. Environment,
  24. MinimalProject,
  25. Organization,
  26. PageFilters,
  27. PinnedPageFilter,
  28. Project,
  29. } from 'sentry/types';
  30. import {defined, valueIsEqual} from 'sentry/utils';
  31. import {getUtcDateString} from 'sentry/utils/dates';
  32. type EnvironmentId = Environment['id'];
  33. type Options = {
  34. /**
  35. * Do not reset the `cursor` query parameter when updating page filters
  36. */
  37. keepCursor?: boolean;
  38. /**
  39. * Use Location.replace instead of push when updating the URL query state
  40. */
  41. replace?: boolean;
  42. /**
  43. * List of parameters to remove when changing URL params
  44. */
  45. resetParams?: string[];
  46. /**
  47. * Persist changes to the page filter selection into local storage
  48. */
  49. save?: boolean;
  50. /**
  51. * Optional prefix for the storage key, for areas of the app that need separate pagefilters (i.e Starfish)
  52. */
  53. storageNamespace?: string;
  54. };
  55. /**
  56. * This is the 'update' object used for updating the page filters. The types
  57. * here are a bit wider to allow for easy updates.
  58. */
  59. type PageFiltersUpdate = {
  60. end?: DateString;
  61. environment?: string[] | null;
  62. period?: string | null;
  63. project?: number[] | null;
  64. start?: DateString;
  65. utc?: boolean | null;
  66. };
  67. /**
  68. * Represents the input for updating the date time of page filters
  69. */
  70. type DateTimeUpdate = Pick<PageFiltersUpdate, 'start' | 'end' | 'period' | 'utc'>;
  71. /**
  72. * Output object used for updating query parameters
  73. */
  74. type PageFilterQuery = PageFiltersStringified & Record<string, Location['query'][string]>;
  75. /**
  76. * This can be null which will not perform any router side effects, and instead updates store.
  77. */
  78. type Router = InjectedRouter | null | undefined;
  79. /**
  80. * Reset values in the page filters store
  81. */
  82. export function resetPageFilters() {
  83. PageFiltersStore.onReset();
  84. }
  85. function getProjectIdFromProject(project: MinimalProject) {
  86. return parseInt(project.id, 10);
  87. }
  88. /**
  89. * Merges two date time objects, where the `base` object takes precedence, and
  90. * the `fallback` values are used when the base values are null or undefined.
  91. */
  92. function mergeDatetime(
  93. base: PageFilters['datetime'],
  94. fallback?: Partial<PageFilters['datetime']>
  95. ) {
  96. const datetime: PageFilters['datetime'] = {
  97. start: base.start ?? fallback?.start ?? null,
  98. end: base.end ?? fallback?.end ?? null,
  99. period: base.period ?? fallback?.period ?? null,
  100. utc: base.utc ?? fallback?.utc ?? null,
  101. };
  102. return datetime;
  103. }
  104. export type InitializeUrlStateParams = {
  105. memberProjects: Project[];
  106. organization: Organization;
  107. queryParams: Location['query'];
  108. router: InjectedRouter;
  109. shouldEnforceSingleProject: boolean;
  110. defaultSelection?: Partial<PageFilters>;
  111. forceProject?: MinimalProject | null;
  112. shouldForceProject?: boolean;
  113. /**
  114. * Whether to save changes to local storage. This setting should be page-specific:
  115. * most pages should have it on (default) and some, like Dashboard Details, need it
  116. * off.
  117. */
  118. shouldPersist?: boolean;
  119. showAbsolute?: boolean;
  120. /**
  121. * When used with shouldForceProject it will not persist the project id
  122. * to url query parameters on load. This is useful when global selection header
  123. * is used for display purposes rather than selection.
  124. */
  125. skipInitializeUrlParams?: boolean;
  126. /**
  127. * Skip loading from local storage
  128. * An example is Issue Details, in the case where it is accessed directly (e.g. from email).
  129. * We do not want to load the user's last used env/project in this case, otherwise will
  130. * lead to very confusing behavior.
  131. */
  132. skipLoadLastUsed?: boolean;
  133. /**
  134. * Skip loading last used environment from local storage
  135. * An example is Starfish, which doesn't support environments.
  136. */
  137. skipLoadLastUsedEnvironment?: boolean;
  138. /**
  139. * Optional prefix for the storage key, for areas of the app that need separate pagefilters (i.e Starfish)
  140. */
  141. storageNamespace?: string;
  142. };
  143. export function initializeUrlState({
  144. organization,
  145. queryParams,
  146. router,
  147. memberProjects,
  148. skipLoadLastUsed,
  149. skipLoadLastUsedEnvironment,
  150. shouldPersist = true,
  151. shouldForceProject,
  152. shouldEnforceSingleProject,
  153. defaultSelection,
  154. forceProject,
  155. showAbsolute = true,
  156. skipInitializeUrlParams = false,
  157. storageNamespace,
  158. }: InitializeUrlStateParams) {
  159. const orgSlug = organization.slug;
  160. const parsed = getStateFromQuery(queryParams, {
  161. allowAbsoluteDatetime: showAbsolute,
  162. allowEmptyPeriod: true,
  163. });
  164. const {datetime: defaultDatetime, ...defaultFilters} = getDefaultSelection();
  165. const {datetime: customDatetime, ...customDefaultFilters} = defaultSelection ?? {};
  166. const pageFilters: PageFilters = {
  167. ...defaultFilters,
  168. ...customDefaultFilters,
  169. datetime: mergeDatetime(parsed, customDatetime),
  170. };
  171. // Use period from default if we don't have a period set
  172. pageFilters.datetime.period ??= defaultDatetime.period;
  173. // Do not set a period if we have absolute start and end
  174. if (pageFilters.datetime.start && pageFilters.datetime.end) {
  175. pageFilters.datetime.period = null;
  176. }
  177. const hasDatetimeInUrl = Object.keys(pick(queryParams, DATE_TIME_KEYS)).length > 0;
  178. const hasProjectOrEnvironmentInUrl =
  179. Object.keys(pick(queryParams, [URL_PARAM.PROJECT, URL_PARAM.ENVIRONMENT])).length > 0;
  180. if (hasProjectOrEnvironmentInUrl) {
  181. pageFilters.projects = parsed.project || [];
  182. pageFilters.environments = parsed.environment || [];
  183. }
  184. const storedPageFilters = skipLoadLastUsed
  185. ? null
  186. : getPageFilterStorage(orgSlug, storageNamespace);
  187. let shouldUsePinnedDatetime = false;
  188. // We may want to restore some page filters from local storage. In the new
  189. // world when they are pinned, and in the old world as long as
  190. // skipLoadLastUsed is not set to true.
  191. if (storedPageFilters) {
  192. const {state: storedState, pinnedFilters} = storedPageFilters;
  193. if (!hasProjectOrEnvironmentInUrl && pinnedFilters.has('projects')) {
  194. pageFilters.projects = storedState.project ?? [];
  195. }
  196. if (
  197. !skipLoadLastUsedEnvironment &&
  198. !hasProjectOrEnvironmentInUrl &&
  199. pinnedFilters.has('environments')
  200. ) {
  201. pageFilters.environments = storedState.environment ?? [];
  202. }
  203. if (!hasDatetimeInUrl && pinnedFilters.has('datetime')) {
  204. pageFilters.datetime = getDatetimeFromState(storedState);
  205. shouldUsePinnedDatetime = true;
  206. }
  207. }
  208. const {projects, environments: environment, datetime} = pageFilters;
  209. let newProject: number[] | null = null;
  210. let project = projects;
  211. // Skip enforcing a single project if `shouldForceProject` is true, since a
  212. // component is controlling what that project needs to be. This is true
  213. // regardless if user has access to multi projects
  214. if (shouldForceProject && forceProject) {
  215. newProject = [getProjectIdFromProject(forceProject)];
  216. } else if (shouldEnforceSingleProject && !shouldForceProject) {
  217. // If user does not have access to `global-views` (e.g. multi project
  218. // select) *and* there is no `project` URL parameter, then we update URL
  219. // params with:
  220. //
  221. // 1) the first project from the list of requested projects from URL params
  222. // 2) first project user is a member of from org
  223. //
  224. // Note this is intentionally skipped if `shouldForceProject == true` since
  225. // we want to initialize store and wait for the forced project
  226. //
  227. if (projects && projects.length > 0) {
  228. // If there is a list of projects from URL params, select first project
  229. // from that list
  230. newProject = typeof projects === 'string' ? [Number(projects)] : [projects[0]];
  231. } else {
  232. // When we have finished loading the organization into the props, i.e.
  233. // the organization slug is consistent with the URL param--Sentry will
  234. // get the first project from the organization that the user is a member
  235. // of.
  236. newProject = [...memberProjects].slice(0, 1).map(getProjectIdFromProject);
  237. }
  238. }
  239. if (newProject) {
  240. pageFilters.projects = newProject;
  241. project = newProject;
  242. }
  243. const pinnedFilters = organization.features.includes('new-page-filter')
  244. ? new Set<PinnedPageFilter>(['projects', 'environments', 'datetime'])
  245. : storedPageFilters?.pinnedFilters ?? new Set();
  246. // We should only check and update the desync state if the site has just been loaded
  247. // (not counting route changes). To check this, we can use the `isReady` state: if it's
  248. // false, then the site was just loaded. Once it's true, `isReady` stays true
  249. // through route changes.
  250. const shouldCheckDesyncedURLState = !PageFiltersStore.getState().isReady;
  251. PageFiltersStore.onInitializeUrlState(pageFilters, pinnedFilters, shouldPersist);
  252. if (shouldCheckDesyncedURLState) {
  253. checkDesyncedUrlState(router, shouldForceProject);
  254. } else {
  255. // Clear desync state on route changes
  256. PageFiltersStore.updateDesyncedFilters(new Set());
  257. }
  258. const newDatetime = {
  259. ...datetime,
  260. period:
  261. parsed.start || parsed.end || parsed.period || shouldUsePinnedDatetime
  262. ? datetime.period
  263. : null,
  264. utc: parsed.utc || shouldUsePinnedDatetime ? datetime.utc : null,
  265. };
  266. if (!skipInitializeUrlParams) {
  267. updateParams({project, environment, ...newDatetime}, router, {
  268. replace: true,
  269. keepCursor: true,
  270. });
  271. }
  272. }
  273. function isProjectsValid(projects: number[]) {
  274. return Array.isArray(projects) && projects.every(isInteger);
  275. }
  276. /**
  277. * Updates store and selection URL param if `router` is supplied
  278. *
  279. * This accepts `environments` from `options` to also update environments
  280. * simultaneously as environments are tied to a project, so if you change
  281. * projects, you may need to clear environments.
  282. */
  283. export function updateProjects(
  284. projects: number[],
  285. router?: Router,
  286. options?: Options & {environments?: EnvironmentId[]}
  287. ) {
  288. if (!isProjectsValid(projects)) {
  289. Sentry.withScope(scope => {
  290. scope.setExtra('projects', projects);
  291. Sentry.captureException(new Error('Invalid projects selected'));
  292. });
  293. return;
  294. }
  295. PageFiltersStore.updateProjects(projects, options?.environments ?? null);
  296. updateParams({project: projects, environment: options?.environments}, router, options);
  297. persistPageFilters('projects', options);
  298. if (options?.environments) {
  299. persistPageFilters('environments', options);
  300. }
  301. }
  302. /**
  303. * Updates store and updates global environment selection URL param if `router` is supplied
  304. *
  305. * @param {String[]} environments List of environments
  306. * @param {Object} [router] Router object
  307. * @param {Object} [options] Options object
  308. * @param {String[]} [options.resetParams] List of parameters to remove when changing URL params
  309. */
  310. export function updateEnvironments(
  311. environment: EnvironmentId[] | null,
  312. router?: Router,
  313. options?: Options
  314. ) {
  315. PageFiltersStore.updateEnvironments(environment);
  316. updateParams({environment}, router, options);
  317. persistPageFilters('environments', options);
  318. }
  319. /**
  320. * Updates store and global datetime selection URL param if `router` is supplied
  321. *
  322. * @param {Object} datetime Object with start, end, range keys
  323. * @param {Object} [router] Router object
  324. * @param {Object} [options] Options object
  325. * @param {String[]} [options.resetParams] List of parameters to remove when changing URL params
  326. */
  327. export function updateDateTime(
  328. datetime: DateTimeUpdate,
  329. router?: Router,
  330. options?: Options
  331. ) {
  332. const {selection} = PageFiltersStore.getState();
  333. PageFiltersStore.updateDateTime({...selection.datetime, ...datetime});
  334. updateParams(datetime, router, options);
  335. persistPageFilters('datetime', options);
  336. }
  337. /**
  338. * Pins a particular filter so that it is read out of local storage
  339. */
  340. export function pinFilter(filter: PinnedPageFilter, pin: boolean) {
  341. PageFiltersStore.pin(filter, pin);
  342. persistPageFilters(null, {save: true});
  343. }
  344. /**
  345. * Changes whether any value updates will be persisted into local storage.
  346. */
  347. export function updatePersistence(shouldPersist: boolean) {
  348. PageFiltersStore.updatePersistence(shouldPersist);
  349. }
  350. /**
  351. * Updates router/URL with new query params
  352. *
  353. * @param obj New query params
  354. * @param [router] React router object
  355. * @param [options] Options object
  356. */
  357. function updateParams(obj: PageFiltersUpdate, router?: Router, options?: Options) {
  358. // Allow another component to handle routing
  359. if (!router) {
  360. return;
  361. }
  362. const newQuery = getNewQueryParams(obj, router.location.query, options);
  363. // Only push new location if query params has changed because this will cause a heavy re-render
  364. if (qs.stringify(newQuery) === qs.stringify(router.location.query)) {
  365. return;
  366. }
  367. const routerAction = options?.replace ? router.replace : router.push;
  368. routerAction({pathname: router.location.pathname, query: newQuery});
  369. }
  370. /**
  371. * Save a specific page filter to local storage.
  372. *
  373. * Pinned state is always persisted.
  374. */
  375. async function persistPageFilters(filter: PinnedPageFilter | null, options?: Options) {
  376. if (!options?.save || !PageFiltersStore.shouldPersist) {
  377. return;
  378. }
  379. // XXX(epurkhiser): Since this is called immediately after updating the
  380. // store, wait for a tick since stores are not updated fully synchronously.
  381. // A bit goofy, but it works fine.
  382. await new Promise(resolve => window.setTimeout(resolve, 0));
  383. const {organization} = OrganizationStore.getState();
  384. const orgSlug = organization?.slug ?? null;
  385. // Can't do anything if we don't have an organization
  386. if (orgSlug === null) {
  387. return;
  388. }
  389. const targetFilter = filter !== null ? [filter] : [];
  390. setPageFiltersStorage(
  391. orgSlug,
  392. new Set<PinnedPageFilter>(targetFilter),
  393. options.storageNamespace
  394. );
  395. }
  396. /**
  397. * Checks if the URL state has changed in synchronization from the local
  398. * storage state, and persists that check into the store.
  399. *
  400. * If shouldForceProject is enabled, then we do not record any url desync
  401. * for the project.
  402. */
  403. async function checkDesyncedUrlState(router?: Router, shouldForceProject?: boolean) {
  404. // Cannot compare URL state without the router
  405. if (!router || !PageFiltersStore.shouldPersist) {
  406. return;
  407. }
  408. const {query} = router.location;
  409. // XXX(epurkhiser): Since this is called immediately after updating the
  410. // store, wait for a tick since stores are not updated fully synchronously.
  411. // This function *should* be called only after persistPageFilters has been
  412. // called as well This function *should* be called only after
  413. // persistPageFilters has been called as well
  414. await new Promise(resolve => window.setTimeout(resolve, 0));
  415. const {organization} = OrganizationStore.getState();
  416. // Can't do anything if we don't have an organization
  417. if (organization === null) {
  418. return;
  419. }
  420. const storedPageFilters = getPageFilterStorage(organization.slug);
  421. // If we don't have any stored page filters then we do not check desynced state
  422. if (!storedPageFilters) {
  423. PageFiltersStore.updateDesyncedFilters(new Set<PinnedPageFilter>());
  424. return;
  425. }
  426. const currentQuery = getStateFromQuery(query, {
  427. allowAbsoluteDatetime: true,
  428. allowEmptyPeriod: true,
  429. });
  430. const differingFilters = new Set<PinnedPageFilter>();
  431. const {pinnedFilters, state: storedState} = storedPageFilters;
  432. // Are selected projects different?
  433. if (
  434. pinnedFilters.has('projects') &&
  435. currentQuery.project !== null &&
  436. !valueIsEqual(currentQuery.project, storedState.project) &&
  437. !shouldForceProject
  438. ) {
  439. differingFilters.add('projects');
  440. }
  441. // Are selected environments different?
  442. if (
  443. pinnedFilters.has('environments') &&
  444. currentQuery.environment !== null &&
  445. !valueIsEqual(currentQuery.environment, storedState.environment)
  446. ) {
  447. differingFilters.add('environments');
  448. }
  449. const dateTimeInQuery =
  450. currentQuery.end !== null ||
  451. currentQuery.start !== null ||
  452. currentQuery.utc !== null ||
  453. currentQuery.period !== null;
  454. // Is the datetime filter different?
  455. if (
  456. pinnedFilters.has('datetime') &&
  457. dateTimeInQuery &&
  458. (currentQuery.period !== storedState.period ||
  459. currentQuery.start?.getTime() !== storedState.start?.getTime() ||
  460. currentQuery.end?.getTime() !== storedState.end?.getTime() ||
  461. currentQuery.utc !== storedState.utc)
  462. ) {
  463. differingFilters.add('datetime');
  464. }
  465. PageFiltersStore.updateDesyncedFilters(differingFilters);
  466. }
  467. /**
  468. * Commits the new desynced filter values and clears the desynced filters list.
  469. */
  470. export function saveDesyncedFilters() {
  471. const {desyncedFilters} = PageFiltersStore;
  472. [...desyncedFilters].forEach(filter => persistPageFilters(filter, {save: true}));
  473. PageFiltersStore.updateDesyncedFilters(new Set());
  474. }
  475. /**
  476. * Merges an UpdateParams object into a Location['query'] object. Results in a
  477. * PageFilterQuery
  478. *
  479. * Preserves the old query params, except for `cursor` (can be overridden with
  480. * keepCursor option)
  481. *
  482. * @param obj New query params
  483. * @param currentQuery The current query parameters
  484. * @param [options] Options object
  485. */
  486. function getNewQueryParams(
  487. obj: PageFiltersUpdate,
  488. currentQuery: Location['query'],
  489. options: Options = {}
  490. ) {
  491. const {resetParams, keepCursor} = options;
  492. const cleanCurrentQuery = resetParams?.length
  493. ? omit(currentQuery, resetParams)
  494. : currentQuery;
  495. // Normalize existing query parameters
  496. const currentQueryState = getStateFromQuery(cleanCurrentQuery, {
  497. allowEmptyPeriod: true,
  498. allowAbsoluteDatetime: true,
  499. });
  500. // Extract non page filter parameters.
  501. const cursorParam = !keepCursor ? 'cursor' : null;
  502. const omittedParameters = [...Object.values(URL_PARAM), cursorParam].filter(defined);
  503. const extraParams = omit(cleanCurrentQuery, omittedParameters);
  504. // Override parameters
  505. const {project, environment, start, end, utc} = {
  506. ...currentQueryState,
  507. ...obj,
  508. };
  509. // Only set a stats period if we don't have an absolute date
  510. const statsPeriod = !start && !end ? obj.period || currentQueryState.period : null;
  511. const newQuery: PageFilterQuery = {
  512. project: project?.map(String),
  513. environment,
  514. start: statsPeriod ? null : start instanceof Date ? getUtcDateString(start) : start,
  515. end: statsPeriod ? null : end instanceof Date ? getUtcDateString(end) : end,
  516. utc: utc ? 'true' : null,
  517. statsPeriod,
  518. ...extraParams,
  519. };
  520. const paramEntries = Object.entries(newQuery).filter(([_, value]) => defined(value));
  521. return Object.fromEntries(paramEntries) as PageFilterQuery;
  522. }
  523. export function revertToPinnedFilters(orgSlug: string, router: InjectedRouter) {
  524. const {selection, desyncedFilters} = PageFiltersStore.getState();
  525. const storedFilterState = getPageFilterStorage(orgSlug)?.state;
  526. if (!storedFilterState) {
  527. return;
  528. }
  529. const newParams = {
  530. project: desyncedFilters.has('projects')
  531. ? storedFilterState.project
  532. : selection.projects,
  533. environment: desyncedFilters.has('environments')
  534. ? storedFilterState.environment
  535. : selection.environments,
  536. ...(desyncedFilters.has('datetime')
  537. ? pick(storedFilterState, DATE_TIME_KEYS)
  538. : selection.datetime),
  539. };
  540. updateParams(newParams, router, {
  541. keepCursor: true,
  542. });
  543. PageFiltersStore.updateDesyncedFilters(new Set());
  544. }