123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407 |
- 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<GlobalSelection>;
- 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<GlobalSelection, 'datetime'> & {
- 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))
- );
- }
|