123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685 |
- import type {InjectedRouter} from 'react-router';
- import * as Sentry from '@sentry/react';
- import type {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 {
- getDatetimeFromState,
- getStateFromQuery,
- } from 'sentry/components/organizations/pageFilters/parse';
- import {
- getPageFilterStorage,
- setPageFiltersStorage,
- } from 'sentry/components/organizations/pageFilters/persistence';
- import type {PageFiltersStringified} from 'sentry/components/organizations/pageFilters/types';
- import {getDefaultSelection} from 'sentry/components/organizations/pageFilters/utils';
- import {
- ALL_ACCESS_PROJECTS,
- DATE_TIME_KEYS,
- URL_PARAM,
- } from 'sentry/constants/pageFilters';
- import OrganizationStore from 'sentry/stores/organizationStore';
- import PageFiltersStore from 'sentry/stores/pageFiltersStore';
- import type {
- DateString,
- Environment,
- MinimalProject,
- Organization,
- PageFilters,
- PinnedPageFilter,
- Project,
- } from 'sentry/types';
- import {defined, valueIsEqual} from 'sentry/utils';
- import {getUtcDateString} from 'sentry/utils/dates';
- 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;
- /**
- * Optional prefix for the storage key, for areas of the app that need separate pagefilters (i.e Starfish)
- */
- storageNamespace?: string;
- };
- /**
- * 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?: number[] | null;
- start?: DateString;
- utc?: 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() {
- PageFiltersStore.onReset();
- }
- function getProjectIdFromProject(project: MinimalProject) {
- return parseInt(project.id, 10);
- }
- /**
- * Merges two date time objects, where the `base` object takes precedence, 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;
- }
- export type InitializeUrlStateParams = {
- memberProjects: Project[];
- nonMemberProjects: Project[];
- organization: Organization;
- queryParams: Location['query'];
- router: InjectedRouter;
- shouldEnforceSingleProject: boolean;
- defaultSelection?: Partial<PageFilters>;
- forceProject?: MinimalProject | null;
- shouldForceProject?: boolean;
- /**
- * Whether to save changes to local storage. This setting should be page-specific:
- * most pages should have it on (default) and some, like Dashboard Details, need it
- * off.
- */
- shouldPersist?: boolean;
- showAbsolute?: boolean;
- /**
- * When used with shouldForceProject it will not persist the project id
- * to url query parameters on load. This is useful when global selection header
- * is used for display purposes rather than selection.
- */
- skipInitializeUrlParams?: boolean;
- /**
- * Skip loading from local storage
- * An example is Issue Details, in the case where it is accessed directly (e.g. from email).
- * We do not want to load the user's last used env/project in this case, otherwise will
- * lead to very confusing behavior.
- */
- skipLoadLastUsed?: boolean;
- /**
- * Skip loading last used environment from local storage
- * An example is Starfish, which doesn't support environments.
- */
- skipLoadLastUsedEnvironment?: boolean;
- /**
- * Optional prefix for the storage key, for areas of the app that need separate pagefilters (i.e Starfish)
- */
- storageNamespace?: string;
- };
- export function initializeUrlState({
- organization,
- queryParams,
- router,
- memberProjects,
- nonMemberProjects,
- skipLoadLastUsed,
- skipLoadLastUsedEnvironment,
- shouldPersist = true,
- shouldForceProject,
- shouldEnforceSingleProject,
- defaultSelection,
- forceProject,
- showAbsolute = true,
- skipInitializeUrlParams = false,
- storageNamespace,
- }: 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;
- // We should only check and update the desync state if the site has just been loaded
- // (not counting route changes). To check this, we can use the `isReady` state: if it's
- // false, then the site was just loaded. Once it's true, `isReady` stays true
- // through route changes.
- let shouldCheckDesyncedURLState = !PageFiltersStore.getState().isReady;
- /**
- * Check to make sure that the project ID exists in the projects list. Invalid project
- * IDs (project was deleted/moved to another org) can still exist in local storage or
- * shared links.
- */
- function validateProjectId(projectId: number): boolean {
- if (projectId === ALL_ACCESS_PROJECTS) {
- return !shouldEnforceSingleProject;
- }
- return (
- !!memberProjects?.some(mp => String(mp.id) === String(projectId)) ||
- !!nonMemberProjects?.some(nmp => String(nmp.id) === String(projectId))
- );
- }
- /**
- * Check to make sure that the environment exists. Invalid environments (due to being
- * hidden) can still exist in local storage or shared links.
- */
- function validateEnvironment(env: string): boolean {
- return (
- !!memberProjects?.some(mp => mp.environments.includes(env)) ||
- !!nonMemberProjects?.some(nmp => nmp.environments.includes(env))
- );
- }
- if (hasProjectOrEnvironmentInUrl) {
- pageFilters.projects = parsed.project?.filter(validateProjectId) || [];
- pageFilters.environments = parsed.environment?.filter(validateEnvironment) || [];
- if (
- pageFilters.projects.length < (parsed.project?.length ?? 0) ||
- pageFilters.environments.length < (parsed.environment?.length ?? 0)
- ) {
- // don't check desync state since we're going to remove invalid projects/envs from
- // the URL query
- shouldCheckDesyncedURLState = false;
- }
- }
- const storedPageFilters = skipLoadLastUsed
- ? null
- : getPageFilterStorage(orgSlug, storageNamespace);
- let shouldUsePinnedDatetime = false;
- let shouldUpdateLocalStorage = false;
- // 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;
- if (!hasProjectOrEnvironmentInUrl && pinnedFilters.has('projects')) {
- pageFilters.projects = storedState.project?.filter(validateProjectId) ?? [];
- if (pageFilters.projects.length < (storedState.project?.length ?? 0)) {
- shouldUpdateLocalStorage = true; // update storage to remove invalid projects
- shouldCheckDesyncedURLState = false;
- }
- }
- if (
- !skipLoadLastUsedEnvironment &&
- !hasProjectOrEnvironmentInUrl &&
- pinnedFilters.has('environments')
- ) {
- pageFilters.environments =
- storedState.environment?.filter(validateEnvironment) ?? [];
- if (pageFilters.environments.length < (storedState.environment?.length ?? 0)) {
- shouldUpdateLocalStorage = true; // update storage to remove invalid environments
- shouldCheckDesyncedURLState = false;
- }
- }
- if (!hasDatetimeInUrl && pinnedFilters.has('datetime')) {
- pageFilters.datetime = getDatetimeFromState(storedState);
- shouldUsePinnedDatetime = true;
- }
- }
- 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 = organization.features.includes('new-page-filter')
- ? new Set<PinnedPageFilter>(['projects', 'environments', 'datetime'])
- : storedPageFilters?.pinnedFilters ?? new Set();
- PageFiltersStore.onInitializeUrlState(pageFilters, pinnedFilters, shouldPersist);
- if (shouldUpdateLocalStorage) {
- setPageFiltersStorage(organization.slug, new Set(['projects', 'environments']));
- }
- if (shouldCheckDesyncedURLState) {
- checkDesyncedUrlState(router, shouldForceProject);
- } else {
- // Clear desync state on route changes
- PageFiltersStore.updateDesyncedFilters(new Set());
- }
- const newDatetime = {
- ...datetime,
- period:
- parsed.start || parsed.end || parsed.period || shouldUsePinnedDatetime
- ? datetime.period
- : null,
- utc: parsed.utc || shouldUsePinnedDatetime ? datetime.utc : null,
- };
- if (!skipInitializeUrlParams) {
- updateParams({project, environment, ...newDatetime}, router, {
- replace: true,
- keepCursor: true,
- });
- }
- }
- function isProjectsValid(projects: number[]) {
- 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: number[],
- router?: Router,
- options?: Options & {environments?: EnvironmentId[]}
- ) {
- if (!isProjectsValid(projects)) {
- Sentry.withScope(scope => {
- scope.setExtra('projects', projects);
- Sentry.captureException(new Error('Invalid projects selected'));
- });
- return;
- }
- PageFiltersStore.updateProjects(projects, options?.environments ?? null);
- updateParams({project: projects, environment: options?.environments}, router, options);
- persistPageFilters('projects', options);
- if (options?.environments) {
- persistPageFilters('environments', 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
- ) {
- PageFiltersStore.updateEnvironments(environment);
- updateParams({environment}, router, options);
- persistPageFilters('environments', 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
- ) {
- const {selection} = PageFiltersStore.getState();
- PageFiltersStore.updateDateTime({...selection.datetime, ...datetime});
- updateParams(datetime, router, options);
- persistPageFilters('datetime', options);
- }
- /**
- * Pins a particular filter so that it is read out of local storage
- */
- export function pinFilter(filter: PinnedPageFilter, pin: boolean) {
- PageFiltersStore.pin(filter, pin);
- persistPageFilters(null, {save: true});
- }
- /**
- * Changes whether any value updates will be persisted into local storage.
- */
- export function updatePersistence(shouldPersist: boolean) {
- PageFiltersStore.updatePersistence(shouldPersist);
- }
- /**
- * 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 a specific page filter to local storage.
- *
- * Pinned state is always persisted.
- */
- async function persistPageFilters(filter: PinnedPageFilter | null, options?: Options) {
- if (!options?.save || !PageFiltersStore.shouldPersist) {
- 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 => window.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;
- }
- const targetFilter = filter !== null ? [filter] : [];
- setPageFiltersStorage(
- orgSlug,
- new Set<PinnedPageFilter>(targetFilter),
- options.storageNamespace
- );
- }
- /**
- * Checks if the URL state has changed in synchronization from the local
- * storage state, and persists that check into the store.
- *
- * If shouldForceProject is enabled, then we do not record any url desync
- * for the project.
- */
- async function checkDesyncedUrlState(router?: Router, shouldForceProject?: boolean) {
- // Cannot compare URL state without the router
- if (!router || !PageFiltersStore.shouldPersist) {
- return;
- }
- const {query} = router.location;
- // XXX(epurkhiser): Since this is called immediately after updating the
- // store, wait for a tick since stores are not updated fully synchronously.
- // This function *should* be called only after persistPageFilters has been
- // called as well This function *should* be called only after
- // persistPageFilters has been called as well
- await new Promise(resolve => window.setTimeout(resolve, 0));
- const {organization} = OrganizationStore.getState();
- // Can't do anything if we don't have an organization
- if (organization === null) {
- return;
- }
- const storedPageFilters = getPageFilterStorage(organization.slug);
- // If we don't have any stored page filters then we do not check desynced state
- if (!storedPageFilters) {
- PageFiltersStore.updateDesyncedFilters(new Set<PinnedPageFilter>());
- return;
- }
- const currentQuery = getStateFromQuery(query, {
- allowAbsoluteDatetime: true,
- allowEmptyPeriod: true,
- });
- const differingFilters = new Set<PinnedPageFilter>();
- const {pinnedFilters, state: storedState} = storedPageFilters;
- // Are selected projects different?
- if (
- pinnedFilters.has('projects') &&
- currentQuery.project !== null &&
- !valueIsEqual(currentQuery.project, storedState.project) &&
- !shouldForceProject
- ) {
- differingFilters.add('projects');
- }
- // Are selected environments different?
- if (
- pinnedFilters.has('environments') &&
- currentQuery.environment !== null &&
- !valueIsEqual(currentQuery.environment, storedState.environment)
- ) {
- differingFilters.add('environments');
- }
- const dateTimeInQuery =
- currentQuery.end !== null ||
- currentQuery.start !== null ||
- currentQuery.utc !== null ||
- currentQuery.period !== null;
- // Is the datetime filter different?
- if (
- pinnedFilters.has('datetime') &&
- dateTimeInQuery &&
- (currentQuery.period !== storedState.period ||
- currentQuery.start?.getTime() !== storedState.start?.getTime() ||
- currentQuery.end?.getTime() !== storedState.end?.getTime() ||
- currentQuery.utc !== storedState.utc)
- ) {
- differingFilters.add('datetime');
- }
- PageFiltersStore.updateDesyncedFilters(differingFilters);
- }
- /**
- * Commits the new desynced filter values and clears the desynced filters list.
- */
- export function saveDesyncedFilters() {
- const {desyncedFilters} = PageFiltersStore;
- [...desyncedFilters].forEach(filter => persistPageFilters(filter, {save: true}));
- PageFiltersStore.updateDesyncedFilters(new Set());
- }
- /**
- * Merges an UpdateParams object into a Location['query'] object. Results in a
- * PageFilterQuery
- *
- * Preserves the old query params, except for `cursor` (can be overridden 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;
- }
- export function revertToPinnedFilters(orgSlug: string, router: InjectedRouter) {
- const {selection, desyncedFilters} = PageFiltersStore.getState();
- const storedFilterState = getPageFilterStorage(orgSlug)?.state;
- if (!storedFilterState) {
- return;
- }
- const newParams = {
- project: desyncedFilters.has('projects')
- ? storedFilterState.project
- : selection.projects,
- environment: desyncedFilters.has('environments')
- ? storedFilterState.environment
- : selection.environments,
- ...(desyncedFilters.has('datetime')
- ? pick(storedFilterState, DATE_TIME_KEYS)
- : selection.datetime),
- };
- updateParams(newParams, router, {
- keepCursor: true,
- });
- PageFiltersStore.updateDesyncedFilters(new Set());
- }
|