import {InjectedRouter} from 'react-router'; import * as Sentry from '@sentry/react'; import isInteger from 'lodash/isInteger'; import omit from 'lodash/omit'; import pick from 'lodash/pick'; import * as qs from 'query-string'; import GlobalSelectionActions from 'app/actions/globalSelectionActions'; import { getDefaultSelection, getStateFromQuery, } from 'app/components/organizations/globalSelectionHeader/utils'; import { DATE_TIME, LOCAL_STORAGE_KEY, URL_PARAM, } from 'app/constants/globalSelectionHeader'; import { Environment, GlobalSelection, MinimalProject, Organization, Project, } from 'app/types'; import {defined} from 'app/utils'; import {getUtcDateString} from 'app/utils/dates'; import localStorage from 'app/utils/localStorage'; /** * Note this is the internal project.id, NOT the slug, but it is the stringified version of it */ type ProjectId = string | number; type EnvironmentId = Environment['id']; type Options = { /** * List of parameters to remove when changing URL params */ resetParams?: string[]; save?: boolean; keepCursor?: boolean; }; /** * Can be relative time string or absolute (using start and end dates) */ type DateTimeObject = { start?: Date | string | null; end?: Date | string | null; statsPeriod?: string | null; utc?: string | boolean | null; /** * @deprecated */ period?: string | null; }; /** * Cast project ids to strings, as everything is assumed to be a string in URL params * * We also handle internal types so Dates and booleans can show up in the start/end/utc * keys. Long term it would be good to narrow down these types. */ type UrlParams = { project?: ProjectId[] | null; environment?: EnvironmentId[] | null; } & DateTimeObject & { [others: string]: any; }; /** * This can be null which will not perform any router side effects, and instead updates store. */ type Router = InjectedRouter | null | undefined; // Reset values in global selection store export function resetGlobalSelection() { GlobalSelectionActions.reset(); } function getProjectIdFromProject(project: MinimalProject) { return parseInt(project.id, 10); } type InitializeUrlStateParams = { organization: Organization; queryParams: InjectedRouter['location']['query']; router: InjectedRouter; memberProjects: Project[]; shouldForceProject?: boolean; shouldEnforceSingleProject: boolean; /** * If true, do not load from local storage */ skipLoadLastUsed?: boolean; defaultSelection?: Partial; forceProject?: MinimalProject | null; showAbsolute?: boolean; }; export function initializeUrlState({ organization, queryParams, router, memberProjects, skipLoadLastUsed, shouldForceProject, shouldEnforceSingleProject, defaultSelection, forceProject, showAbsolute = true, }: InitializeUrlStateParams) { const orgSlug = organization.slug; const query = pick(queryParams, [URL_PARAM.PROJECT, URL_PARAM.ENVIRONMENT]); const hasProjectOrEnvironmentInUrl = Object.keys(query).length > 0; const parsed = getStateFromQuery(queryParams, { allowAbsoluteDatetime: showAbsolute, allowEmptyPeriod: true, }); const {datetime: defaultDateTime, ...retrievedDefaultSelection} = getDefaultSelection(); const {datetime: customizedDefaultDateTime, ...customizedDefaultSelection} = defaultSelection || {}; let globalSelection: Omit & { datetime: { [K in keyof GlobalSelection['datetime']]: GlobalSelection['datetime'][K] | null; }; } = { ...retrievedDefaultSelection, ...customizedDefaultSelection, datetime: { [DATE_TIME.START as 'start']: parsed.start || customizedDefaultDateTime?.start || null, [DATE_TIME.END as 'end']: parsed.end || customizedDefaultDateTime?.end || null, [DATE_TIME.PERIOD as 'period']: parsed.period || customizedDefaultDateTime?.period || defaultDateTime.period, [DATE_TIME.UTC as 'utc']: parsed.utc || customizedDefaultDateTime?.utc || null, }, }; if (globalSelection.datetime.start && globalSelection.datetime.end) { globalSelection.datetime.period = null; } // We only save environment and project, so if those exist in // URL, do not touch local storage if (hasProjectOrEnvironmentInUrl) { globalSelection.projects = parsed.project || []; globalSelection.environments = parsed.environment || []; } else if (!skipLoadLastUsed) { try { const localStorageKey = `${LOCAL_STORAGE_KEY}:${orgSlug}`; const storedValue = localStorage.getItem(localStorageKey); if (storedValue) { globalSelection = { datetime: globalSelection.datetime, ...JSON.parse(storedValue), }; } } catch (err) { // use default if invalid Sentry.captureException(err); console.error(err); // eslint-disable-line no-console } } const {projects, environments: environment, datetime} = globalSelection; 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) { globalSelection.projects = newProject; project = newProject; } GlobalSelectionActions.initializeUrlState(globalSelection); GlobalSelectionActions.setOrganization(organization); // To keep URLs clean, don't push default period if url params are empty const parsedWithNoDefaultPeriod = getStateFromQuery(queryParams, { allowEmptyPeriod: true, allowAbsoluteDatetime: showAbsolute, }); const newDatetime = { ...datetime, period: !parsedWithNoDefaultPeriod.start && !parsedWithNoDefaultPeriod.end && !parsedWithNoDefaultPeriod.period ? null : datetime.period, utc: !parsedWithNoDefaultPeriod.utc ? null : datetime.utc, }; updateParamsWithoutHistory({project, environment, ...newDatetime}, router, { keepCursor: true, }); } /** * Updates store and global project 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; } GlobalSelectionActions.updateProjects(projects, options?.environments); updateParams({project: projects, environment: options?.environments}, router, options); } function isProjectsValid(projects: ProjectId[]) { return Array.isArray(projects) && projects.every(project => isInteger(project)); } /** * 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: DateTimeObject, router?: Router, options?: Options ) { GlobalSelectionActions.updateDateTime(datetime); // We only save projects/environments to local storage, do not // save anything when date changes. updateParams(datetime, router, {...options, save: false}); } /** * 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 ) { GlobalSelectionActions.updateEnvironments(environment); updateParams({environment}, router, options); } /** * Updates router/URL with new query params * * @param obj New query params * @param [router] React router object * @param [options] Options object */ export function updateParams(obj: UrlParams, 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; } if (options?.save) { GlobalSelectionActions.save(newQuery); } router.push({ pathname: router.location.pathname, query: newQuery, }); } /** * Like updateParams but just replaces the current URL and does not create a * new browser history entry * * @param obj New query params * @param [router] React router object * @param [options] Options object */ export function updateParamsWithoutHistory( obj: UrlParams, 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 have changed because this will cause a heavy re-render if (qs.stringify(newQuery) === qs.stringify(router.location.query)) { return; } router.replace({ pathname: router.location.pathname, query: newQuery, }); } /** * Creates a new query parameter object given new params and old params * Preserves the old query params, except for `cursor` (can be overriden with keepCursor option) * * @param obj New query params * @param oldQueryParams Old query params * @param [options] Options object */ function getNewQueryParams( obj: UrlParams, oldQueryParams: UrlParams, {resetParams, keepCursor}: Options = {} ) { const {cursor, statsPeriod, ...oldQuery} = oldQueryParams; const oldQueryWithoutResetParams = !!resetParams?.length ? omit(oldQuery, resetParams) : oldQuery; const newQuery = getParams({ ...oldQueryWithoutResetParams, // Some views update using `period`, and some `statsPeriod`, we should make this uniform period: !obj.start && !obj.end ? obj.period || statsPeriod : null, ...obj, }); if (newQuery.start) { newQuery.start = getUtcDateString(newQuery.start); } if (newQuery.end) { newQuery.end = getUtcDateString(newQuery.end); } if (keepCursor) { newQuery.cursor = cursor; } return newQuery; } function getParams(params: UrlParams): UrlParams { const {start, end, period, statsPeriod, ...otherParams} = params; // `statsPeriod` takes precedence for now const coercedPeriod = statsPeriod || period; // Filter null values return Object.fromEntries( Object.entries({ statsPeriod: coercedPeriod, start: coercedPeriod ? null : start, end: coercedPeriod ? null : end, ...otherParams, }).filter(([, value]) => defined(value)) ); }