123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442 |
- import {InjectedRouter} from 'react-router';
- import * as Sentry from '@sentry/react';
- import {Location} from 'history';
- import isInteger from 'lodash/isInteger';
- import omit from 'lodash/omit';
- import pick from 'lodash/pick';
- import * as qs from 'query-string';
- import PageFiltersActions from 'sentry/actions/pageFiltersActions';
- import {
- getDatetimeFromState,
- getStateFromQuery,
- } from 'sentry/components/organizations/pageFilters/parse';
- import {
- getPageFilterStorage,
- setPageFiltersStorage,
- } from 'sentry/components/organizations/pageFilters/persistence';
- import {PageFiltersStringified} from 'sentry/components/organizations/pageFilters/types';
- import {
- getDefaultSelection,
- getPathsWithNewFilters,
- } from 'sentry/components/organizations/pageFilters/utils';
- import {DATE_TIME_KEYS, URL_PARAM} from 'sentry/constants/pageFilters';
- import OrganizationStore from 'sentry/stores/organizationStore';
- import {
- DateString,
- Environment,
- MinimalProject,
- Organization,
- PageFilters,
- PinnedPageFilter,
- Project,
- } from 'sentry/types';
- import {defined} from 'sentry/utils';
- import {getUtcDateString} from 'sentry/utils/dates';
- /**
- * NOTE: this is the internal project.id, NOT the slug
- */
- type ProjectId = string | number;
- type EnvironmentId = Environment['id'];
- type Options = {
- /**
- * Do not reset the `cursor` query parameter when updating page filters
- */
- keepCursor?: boolean;
- /**
- * Use Location.replace instead of push when updating the URL query state
- */
- replace?: boolean;
- /**
- * List of parameters to remove when changing URL params
- */
- resetParams?: string[];
- /**
- * Persist changes to the page filter selection into local storage
- */
- save?: boolean;
- };
- /**
- * This is the 'update' object used for updating the page filters. The types
- * here are a bit wider to allow for easy updates.
- */
- type PageFiltersUpdate = {
- end?: DateString;
- environment?: string[] | null;
- period?: string | null;
- project?: Array<string | number> | null;
- start?: DateString;
- utc?: string | boolean | null;
- };
- /**
- * Represents the input for updating the date time of page filters
- */
- type DateTimeUpdate = Pick<PageFiltersUpdate, 'start' | 'end' | 'period' | 'utc'>;
- /**
- * Output object used for updating query parameters
- */
- type PageFilterQuery = PageFiltersStringified & Record<string, Location['query'][string]>;
- /**
- * This can be null which will not perform any router side effects, and instead updates store.
- */
- type Router = InjectedRouter | null | undefined;
- /**
- * Reset values in the page filters store
- */
- export function resetPageFilters() {
- PageFiltersActions.reset();
- }
- function getProjectIdFromProject(project: MinimalProject) {
- return parseInt(project.id, 10);
- }
- /**
- * Merges two date time objects, where the `base` object takes presidence, and
- * the `fallback` values are used when the base values are null or undefined.
- */
- function mergeDatetime(
- base: PageFilters['datetime'],
- fallback?: Partial<PageFilters['datetime']>
- ) {
- const datetime: PageFilters['datetime'] = {
- start: base.start ?? fallback?.start ?? null,
- end: base.end ?? fallback?.end ?? null,
- period: base.period ?? fallback?.period ?? null,
- utc: base.utc ?? fallback?.utc ?? null,
- };
- return datetime;
- }
- type InitializeUrlStateParams = {
- memberProjects: Project[];
- organization: Organization;
- pathname: Location['pathname'];
- queryParams: Location['query'];
- router: InjectedRouter;
- shouldEnforceSingleProject: boolean;
- defaultSelection?: Partial<PageFilters>;
- forceProject?: MinimalProject | null;
- shouldForceProject?: boolean;
- showAbsolute?: boolean;
- /**
- * If true, do not load from local storage
- */
- skipLoadLastUsed?: boolean;
- };
- export function initializeUrlState({
- organization,
- queryParams,
- pathname,
- router,
- memberProjects,
- skipLoadLastUsed,
- shouldForceProject,
- shouldEnforceSingleProject,
- defaultSelection,
- forceProject,
- showAbsolute = true,
- }: InitializeUrlStateParams) {
- const orgSlug = organization.slug;
- const parsed = getStateFromQuery(queryParams, {
- allowAbsoluteDatetime: showAbsolute,
- allowEmptyPeriod: true,
- });
- const {datetime: defaultDatetime, ...defaultFilters} = getDefaultSelection();
- const {datetime: customDatetime, ...customDefaultFilters} = defaultSelection ?? {};
- const pageFilters: PageFilters = {
- ...defaultFilters,
- ...customDefaultFilters,
- datetime: mergeDatetime(parsed, customDatetime),
- };
- // Use period from default if we don't have a period set
- pageFilters.datetime.period ??= defaultDatetime.period;
- // Do not set a period if we have absolute start and end
- if (pageFilters.datetime.start && pageFilters.datetime.end) {
- pageFilters.datetime.period = null;
- }
- const hasDatetimeInUrl = Object.keys(pick(queryParams, DATE_TIME_KEYS)).length > 0;
- const hasProjectOrEnvironmentInUrl =
- Object.keys(pick(queryParams, [URL_PARAM.PROJECT, URL_PARAM.ENVIRONMENT])).length > 0;
- if (hasProjectOrEnvironmentInUrl) {
- pageFilters.projects = parsed.project || [];
- pageFilters.environments = parsed.environment || [];
- }
- const storedPageFilters = skipLoadLastUsed ? null : getPageFilterStorage(orgSlug);
- // We may want to restore some page filters from local storage. In the new
- // world when they are pinned, and in the old world as long as
- // skipLoadLastUsed is not set to true.
- if (storedPageFilters) {
- const {state: storedState, pinnedFilters} = storedPageFilters;
- const pageHasPinning = getPathsWithNewFilters(organization).includes(pathname);
- const filtersToRestore = pageHasPinning
- ? pinnedFilters
- : new Set<PinnedPageFilter>(['projects', 'environments']);
- if (!hasProjectOrEnvironmentInUrl && filtersToRestore.has('projects')) {
- pageFilters.projects = storedState.project ?? [];
- }
- if (!hasProjectOrEnvironmentInUrl && filtersToRestore.has('environments')) {
- pageFilters.environments = storedState.environment ?? [];
- }
- if (!hasDatetimeInUrl && filtersToRestore.has('datetime')) {
- const storedDatetime = getDatetimeFromState(storedState);
- pageFilters.datetime = mergeDatetime(pageFilters.datetime, storedDatetime);
- }
- }
- const {projects, environments: environment, datetime} = pageFilters;
- let newProject: number[] | null = null;
- let project = projects;
- // Skip enforcing a single project if `shouldForceProject` is true, since a
- // component is controlling what that project needs to be. This is true
- // regardless if user has access to multi projects
- if (shouldForceProject && forceProject) {
- newProject = [getProjectIdFromProject(forceProject)];
- } else if (shouldEnforceSingleProject && !shouldForceProject) {
- // If user does not have access to `global-views` (e.g. multi project
- // select) *and* there is no `project` URL parameter, then we update URL
- // params with:
- //
- // 1) the first project from the list of requested projects from URL params
- // 2) first project user is a member of from org
- //
- // Note this is intentionally skipped if `shouldForceProject == true` since
- // we want to initialize store and wait for the forced project
- //
- if (projects && projects.length > 0) {
- // If there is a list of projects from URL params, select first project
- // from that list
- newProject = typeof projects === 'string' ? [Number(projects)] : [projects[0]];
- } else {
- // When we have finished loading the organization into the props, i.e.
- // the organization slug is consistent with the URL param--Sentry will
- // get the first project from the organization that the user is a member
- // of.
- newProject = [...memberProjects].slice(0, 1).map(getProjectIdFromProject);
- }
- }
- if (newProject) {
- pageFilters.projects = newProject;
- project = newProject;
- }
- const pinnedFilters = storedPageFilters?.pinnedFilters ?? new Set();
- PageFiltersActions.initializeUrlState(pageFilters, pinnedFilters);
- const newDatetime = {
- ...datetime,
- period: !parsed.start && !parsed.end && !parsed.period ? null : datetime.period,
- utc: !parsed.utc ? null : datetime.utc,
- };
- updateParams({project, environment, ...newDatetime}, router, {
- replace: true,
- keepCursor: true,
- });
- }
- function isProjectsValid(projects: ProjectId[]) {
- return Array.isArray(projects) && projects.every(isInteger);
- }
- /**
- * Updates store and selection URL param if `router` is supplied
- *
- * This accepts `environments` from `options` to also update environments
- * simultaneously as environments are tied to a project, so if you change
- * projects, you may need to clear environments.
- */
- export function updateProjects(
- projects: ProjectId[],
- router?: Router,
- options?: Options & {environments?: EnvironmentId[]}
- ) {
- if (!isProjectsValid(projects)) {
- Sentry.withScope(scope => {
- scope.setExtra('projects', projects);
- Sentry.captureException(new Error('Invalid projects selected'));
- });
- return;
- }
- PageFiltersActions.updateProjects(projects, options?.environments);
- updateParams({project: projects, environment: options?.environments}, router, options);
- persistPageFilters(options);
- }
- /**
- * Updates store and updates global environment selection URL param if `router` is supplied
- *
- * @param {String[]} environments List of environments
- * @param {Object} [router] Router object
- * @param {Object} [options] Options object
- * @param {String[]} [options.resetParams] List of parameters to remove when changing URL params
- */
- export function updateEnvironments(
- environment: EnvironmentId[] | null,
- router?: Router,
- options?: Options
- ) {
- PageFiltersActions.updateEnvironments(environment);
- updateParams({environment}, router, options);
- persistPageFilters(options);
- }
- /**
- * Updates store and global datetime selection URL param if `router` is supplied
- *
- * @param {Object} datetime Object with start, end, range keys
- * @param {Object} [router] Router object
- * @param {Object} [options] Options object
- * @param {String[]} [options.resetParams] List of parameters to remove when changing URL params
- */
- export function updateDateTime(
- datetime: DateTimeUpdate,
- router?: Router,
- options?: Options
- ) {
- PageFiltersActions.updateDateTime(datetime);
- updateParams(datetime, router, options);
- persistPageFilters(options);
- }
- /**
- * Pins a particular filter so that it is read out of local storage
- */
- export function pinFilter(filter: PinnedPageFilter, pin: boolean) {
- PageFiltersActions.pin(filter, pin);
- persistPageFilters({save: true});
- }
- /**
- * Updates router/URL with new query params
- *
- * @param obj New query params
- * @param [router] React router object
- * @param [options] Options object
- */
- function updateParams(obj: PageFiltersUpdate, router?: Router, options?: Options) {
- // Allow another component to handle routing
- if (!router) {
- return;
- }
- const newQuery = getNewQueryParams(obj, router.location.query, options);
- // Only push new location if query params has changed because this will cause a heavy re-render
- if (qs.stringify(newQuery) === qs.stringify(router.location.query)) {
- return;
- }
- const routerAction = options?.replace ? router.replace : router.push;
- routerAction({pathname: router.location.pathname, query: newQuery});
- }
- /**
- * Save the current page filters to local storage
- */
- async function persistPageFilters(options?: Options) {
- if (!options?.save) {
- return;
- }
- // XXX(epurkhiser): Since this is called immediately after updating the
- // store, wait for a tick since stores are not updated fully synchronously.
- // A bit goofy, but it works fine.
- await new Promise(resolve => setTimeout(resolve, 0));
- const {organization} = OrganizationStore.getState();
- const orgSlug = organization?.slug ?? null;
- // Can't do anything if we don't have an organization
- if (orgSlug === null) {
- return;
- }
- setPageFiltersStorage(orgSlug);
- }
- /**
- * Merges an UpdateParams object into a Location['query'] object. Results in a
- * PageFilterQuery
- *
- * Preserves the old query params, except for `cursor` (can be overriden with
- * keepCursor option)
- *
- * @param obj New query params
- * @param currentQuery The current query parameters
- * @param [options] Options object
- */
- function getNewQueryParams(
- obj: PageFiltersUpdate,
- currentQuery: Location['query'],
- options: Options = {}
- ) {
- const {resetParams, keepCursor} = options;
- const cleanCurrentQuery = !!resetParams?.length
- ? omit(currentQuery, resetParams)
- : currentQuery;
- // Normalize existing query parameters
- const currentQueryState = getStateFromQuery(cleanCurrentQuery, {
- allowEmptyPeriod: true,
- allowAbsoluteDatetime: true,
- });
- // Extract non page filter parameters.
- const cursorParam = !keepCursor ? 'cursor' : null;
- const omittedParameters = [...Object.values(URL_PARAM), cursorParam].filter(defined);
- const extraParams = omit(cleanCurrentQuery, omittedParameters);
- // Override parameters
- const {project, environment, start, end, utc} = {
- ...currentQueryState,
- ...obj,
- };
- // Only set a stats period if we don't have an absolute date
- const statsPeriod = !start && !end ? obj.period || currentQueryState.period : null;
- const newQuery: PageFilterQuery = {
- project: project?.map(String),
- environment,
- start: statsPeriod ? null : start instanceof Date ? getUtcDateString(start) : start,
- end: statsPeriod ? null : end instanceof Date ? getUtcDateString(end) : end,
- utc: utc ? 'true' : null,
- statsPeriod,
- ...extraParams,
- };
- const paramEntries = Object.entries(newQuery).filter(([_, value]) => defined(value));
- return Object.fromEntries(paramEntries) as PageFilterQuery;
- }
|