mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
1532 lines
44 KiB
TypeScript
1532 lines
44 KiB
TypeScript
import type {Location, Query} from 'history';
|
|
import cloneDeep from 'lodash/cloneDeep';
|
|
import isEqual from 'lodash/isEqual';
|
|
import omit from 'lodash/omit';
|
|
import pick from 'lodash/pick';
|
|
import uniqBy from 'lodash/uniqBy';
|
|
import moment from 'moment';
|
|
|
|
import type {EventQuery} from 'sentry/actionCreators/events';
|
|
import {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
|
|
import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
|
|
import {DEFAULT_PER_PAGE} from 'sentry/constants';
|
|
import {ALL_ACCESS_PROJECTS, URL_PARAM} from 'sentry/constants/pageFilters';
|
|
import {t} from 'sentry/locale';
|
|
import type {
|
|
NewQuery,
|
|
PageFilters,
|
|
Project,
|
|
SavedQuery,
|
|
SelectValue,
|
|
User,
|
|
} from 'sentry/types';
|
|
import type {Column, ColumnType, Field, Sort} from 'sentry/utils/discover/fields';
|
|
import {
|
|
aggregateOutputType,
|
|
generateFieldAsString,
|
|
getAggregateAlias,
|
|
getEquation,
|
|
isAggregateEquation,
|
|
isAggregateField,
|
|
isEquation,
|
|
isLegalYAxisType,
|
|
} from 'sentry/utils/discover/fields';
|
|
import {
|
|
CHART_AXIS_OPTIONS,
|
|
DiscoverDatasets,
|
|
DISPLAY_MODE_FALLBACK_OPTIONS,
|
|
DISPLAY_MODE_OPTIONS,
|
|
DisplayModes,
|
|
TOP_N,
|
|
} from 'sentry/utils/discover/types';
|
|
import {decodeList, decodeScalar} from 'sentry/utils/queryString';
|
|
import toArray from 'sentry/utils/toArray';
|
|
import {normalizeUrl} from 'sentry/utils/withDomainRequired';
|
|
import type {TableColumn, TableColumnSort} from 'sentry/views/discover/table/types';
|
|
import {FieldValueKind} from 'sentry/views/discover/table/types';
|
|
import {decodeColumnOrder} from 'sentry/views/discover/utils';
|
|
import type {SpanOperationBreakdownFilter} from 'sentry/views/performance/transactionSummary/filter';
|
|
import type {EventsDisplayFilterName} from 'sentry/views/performance/transactionSummary/transactionEvents/utils';
|
|
|
|
import {statsPeriodToDays} from '../dates';
|
|
import type {WebVital} from '../fields';
|
|
import {MutableSearch} from '../tokenizeSearch';
|
|
|
|
import {getSortField} from './fieldRenderers';
|
|
|
|
// Metadata mapping for discover results.
|
|
export type MetaType = Record<string, any> & {
|
|
isMetricsData?: boolean;
|
|
isMetricsExtractedData?: boolean;
|
|
tips?: {columns: string; query: string};
|
|
units?: Record<string, string>;
|
|
};
|
|
export type EventsMetaType = {fields: Record<string, ColumnType>} & {
|
|
units: Record<string, string>;
|
|
} & {
|
|
isMetricsData?: boolean;
|
|
isMetricsExtractedData?: boolean;
|
|
};
|
|
|
|
// Data in discover results.
|
|
export type EventData = Record<string, any>;
|
|
|
|
export type LocationQuery = {
|
|
cursor?: string | string[] | null;
|
|
end?: string | string[] | null;
|
|
start?: string | string[] | null;
|
|
statsPeriod?: string | string[] | null;
|
|
utc?: string | string[] | null;
|
|
};
|
|
|
|
const DATETIME_QUERY_STRING_KEYS = ['start', 'end', 'utc', 'statsPeriod'] as const;
|
|
|
|
const EXTERNAL_QUERY_STRING_KEYS: Readonly<Array<keyof LocationQuery>> = [
|
|
...DATETIME_QUERY_STRING_KEYS,
|
|
'cursor',
|
|
];
|
|
|
|
const setSortOrder = (sort: Sort, kind: 'desc' | 'asc'): Sort => ({
|
|
kind,
|
|
field: sort.field,
|
|
});
|
|
|
|
const reverseSort = (sort: Sort): Sort => ({
|
|
kind: sort.kind === 'desc' ? 'asc' : 'desc',
|
|
field: sort.field,
|
|
});
|
|
|
|
const isSortEqualToField = (
|
|
sort: Sort,
|
|
field: Field,
|
|
tableMeta: MetaType | undefined
|
|
): boolean => {
|
|
const sortKey = getSortKeyFromField(field, tableMeta);
|
|
return sort.field === sortKey;
|
|
};
|
|
|
|
const fieldToSort = (
|
|
field: Field,
|
|
tableMeta: MetaType | undefined,
|
|
kind?: 'desc' | 'asc',
|
|
useFunctionFormat?: boolean
|
|
): Sort | undefined => {
|
|
const sortKey = getSortKeyFromField(field, tableMeta, useFunctionFormat);
|
|
|
|
if (!sortKey) {
|
|
return void 0;
|
|
}
|
|
|
|
return {
|
|
kind: kind || 'desc',
|
|
field: sortKey,
|
|
};
|
|
};
|
|
|
|
function getSortKeyFromField(
|
|
field: Field,
|
|
tableMeta?: MetaType,
|
|
useFunctionFormat?: boolean
|
|
): string | null {
|
|
const fieldString = useFunctionFormat ? field.field : getAggregateAlias(field.field);
|
|
return getSortField(fieldString, tableMeta);
|
|
}
|
|
|
|
export function isFieldSortable(
|
|
field: Field,
|
|
tableMeta?: MetaType,
|
|
useFunctionFormat?: boolean
|
|
): boolean {
|
|
return !!getSortKeyFromField(field, tableMeta, useFunctionFormat);
|
|
}
|
|
|
|
const decodeFields = (location: Location): Array<Field> => {
|
|
const {query} = location;
|
|
if (!query || !query.field) {
|
|
return [];
|
|
}
|
|
|
|
const fields = decodeList(query.field);
|
|
const widths = decodeList(query.widths);
|
|
|
|
const parsed: Field[] = [];
|
|
fields.forEach((field, i) => {
|
|
const w = Number(widths[i]);
|
|
const width = !isNaN(w) ? w : COL_WIDTH_UNDEFINED;
|
|
|
|
parsed.push({field, width});
|
|
});
|
|
|
|
return parsed;
|
|
};
|
|
|
|
const parseSort = (sort: string): Sort => {
|
|
sort = sort.trim();
|
|
|
|
if (sort.startsWith('-')) {
|
|
return {
|
|
kind: 'desc',
|
|
field: sort.substring(1),
|
|
};
|
|
}
|
|
|
|
return {
|
|
kind: 'asc',
|
|
field: sort,
|
|
};
|
|
};
|
|
|
|
export const fromSorts = (sorts: string | string[] | undefined): Array<Sort> => {
|
|
if (sorts === undefined) {
|
|
return [];
|
|
}
|
|
|
|
sorts = typeof sorts === 'string' ? [sorts] : sorts;
|
|
|
|
// NOTE: sets are iterated in insertion order
|
|
const uniqueSorts = [...new Set(sorts)];
|
|
|
|
return uniqueSorts.reduce((acc: Array<Sort>, sort: string) => {
|
|
acc.push(parseSort(sort));
|
|
return acc;
|
|
}, []);
|
|
};
|
|
|
|
export const decodeSorts = (location: Location): Array<Sort> => {
|
|
const {query} = location;
|
|
|
|
if (!query || !query.sort) {
|
|
return [];
|
|
}
|
|
const sorts = decodeList(query.sort);
|
|
return fromSorts(sorts);
|
|
};
|
|
|
|
export const encodeSort = (sort: Sort): string => {
|
|
switch (sort.kind) {
|
|
case 'desc': {
|
|
return `-${sort.field}`;
|
|
}
|
|
case 'asc': {
|
|
return String(sort.field);
|
|
}
|
|
default: {
|
|
throw new Error('Unexpected sort type');
|
|
}
|
|
}
|
|
};
|
|
|
|
const encodeSorts = (sorts: Readonly<Array<Sort>>): Array<string> =>
|
|
sorts.map(encodeSort);
|
|
|
|
const collectQueryStringByKey = (query: Query, key: string): Array<string> => {
|
|
const needle = query[key];
|
|
const collection = decodeList(needle);
|
|
return collection.reduce((acc: Array<string>, item: string) => {
|
|
item = item.trim();
|
|
|
|
if (item.length > 0) {
|
|
acc.push(item);
|
|
}
|
|
|
|
return acc;
|
|
}, []);
|
|
};
|
|
|
|
const decodeQuery = (location: Location): string => {
|
|
if (!location.query || !location.query.query) {
|
|
return '';
|
|
}
|
|
|
|
const queryParameter = location.query.query;
|
|
|
|
return decodeScalar(queryParameter, '').trim();
|
|
};
|
|
|
|
const decodeTeam = (value: string): 'myteams' | number => {
|
|
if (value === 'myteams') {
|
|
return value;
|
|
}
|
|
return parseInt(value, 10);
|
|
};
|
|
|
|
const decodeTeams = (location: Location): ('myteams' | number)[] => {
|
|
if (!location.query || !location.query.team) {
|
|
return [];
|
|
}
|
|
const value = location.query.team;
|
|
return toArray(value)
|
|
.map(decodeTeam)
|
|
.filter(team => team === 'myteams' || !isNaN(team));
|
|
};
|
|
|
|
const decodeProjects = (location: Location): number[] => {
|
|
if (!location.query || !location.query.project) {
|
|
return [];
|
|
}
|
|
|
|
const value = location.query.project;
|
|
return toArray(value).map(i => parseInt(i, 10));
|
|
};
|
|
|
|
const queryStringFromSavedQuery = (saved: NewQuery | SavedQuery): string => {
|
|
if (saved.query) {
|
|
return saved.query || '';
|
|
}
|
|
return '';
|
|
};
|
|
|
|
function validateTableMeta(tableMeta: MetaType | undefined): MetaType | undefined {
|
|
return tableMeta && Object.keys(tableMeta).length > 0 ? tableMeta : undefined;
|
|
}
|
|
|
|
export type EventViewOptions = {
|
|
createdBy: User | undefined;
|
|
display: string | undefined;
|
|
end: string | undefined;
|
|
environment: Readonly<string[]>;
|
|
fields: Readonly<Field[]>;
|
|
id: string | undefined;
|
|
name: string | undefined;
|
|
project: Readonly<number[]>;
|
|
query: string;
|
|
sorts: Readonly<Sort[]>;
|
|
start: string | undefined;
|
|
statsPeriod: string | undefined;
|
|
team: Readonly<('myteams' | number)[]>;
|
|
topEvents: string | undefined;
|
|
additionalConditions?: MutableSearch;
|
|
dataset?: DiscoverDatasets;
|
|
expired?: boolean;
|
|
interval?: string;
|
|
utc?: string | boolean | undefined;
|
|
yAxis?: string | string[] | undefined;
|
|
};
|
|
|
|
class EventView {
|
|
id: string | undefined;
|
|
name: string | undefined;
|
|
fields: Readonly<Field[]>;
|
|
sorts: Readonly<Sort[]>;
|
|
query: string;
|
|
team: Readonly<('myteams' | number)[]>;
|
|
project: Readonly<number[]>;
|
|
start: string | undefined;
|
|
end: string | undefined;
|
|
statsPeriod: string | undefined;
|
|
utc?: string | boolean | undefined;
|
|
environment: Readonly<string[]>;
|
|
yAxis: string | string[] | undefined;
|
|
display: string | undefined;
|
|
topEvents: string | undefined;
|
|
interval: string | undefined;
|
|
expired?: boolean;
|
|
createdBy: User | undefined;
|
|
additionalConditions: MutableSearch; // This allows views to always add additional conditions to the query to get specific data. It should not show up in the UI unless explicitly called.
|
|
dataset?: DiscoverDatasets;
|
|
|
|
constructor(props: EventViewOptions) {
|
|
const fields: Field[] = Array.isArray(props.fields) ? props.fields : [];
|
|
let sorts: Sort[] = Array.isArray(props.sorts) ? props.sorts : [];
|
|
const team = Array.isArray(props.team) ? props.team : [];
|
|
const project = Array.isArray(props.project) ? props.project : [];
|
|
const environment = Array.isArray(props.environment) ? props.environment : [];
|
|
|
|
// only include sort keys that are included in the fields
|
|
let equations = 0;
|
|
const sortKeys: string[] = [];
|
|
fields.forEach(field => {
|
|
if (field.field && isEquation(field.field)) {
|
|
const sortKey = getSortKeyFromField({field: `equation[${equations}]`}, undefined);
|
|
equations += 1;
|
|
if (sortKey) {
|
|
sortKeys.push(sortKey);
|
|
}
|
|
}
|
|
const sortKey = getSortKeyFromField(field, undefined);
|
|
if (sortKey) {
|
|
sortKeys.push(sortKey);
|
|
}
|
|
});
|
|
|
|
const sort = sorts.find(currentSort => sortKeys.includes(currentSort.field));
|
|
sorts = sort ? [sort] : [];
|
|
|
|
const id = props.id !== null && props.id !== void 0 ? String(props.id) : void 0;
|
|
|
|
this.id = id;
|
|
this.name = props.name;
|
|
this.fields = fields;
|
|
this.sorts = sorts;
|
|
this.query = typeof props.query === 'string' ? props.query : '';
|
|
this.team = team;
|
|
this.project = project;
|
|
this.start = props.start;
|
|
this.end = props.end;
|
|
this.statsPeriod = props.statsPeriod;
|
|
this.utc = props.utc;
|
|
this.environment = environment;
|
|
this.yAxis = props.yAxis;
|
|
this.dataset = props.dataset;
|
|
this.display = props.display;
|
|
this.topEvents = props.topEvents;
|
|
this.interval = props.interval;
|
|
this.createdBy = props.createdBy;
|
|
this.expired = props.expired;
|
|
this.additionalConditions = props.additionalConditions
|
|
? props.additionalConditions.copy()
|
|
: new MutableSearch([]);
|
|
}
|
|
|
|
static fromLocation(location: Location): EventView {
|
|
const {start, end, statsPeriod} = normalizeDateTimeParams(location.query);
|
|
|
|
return new EventView({
|
|
id: decodeScalar(location.query.id),
|
|
name: decodeScalar(location.query.name),
|
|
fields: decodeFields(location),
|
|
sorts: decodeSorts(location),
|
|
query: decodeQuery(location),
|
|
team: decodeTeams(location),
|
|
project: decodeProjects(location),
|
|
start: decodeScalar(start),
|
|
end: decodeScalar(end),
|
|
statsPeriod: decodeScalar(statsPeriod),
|
|
environment: collectQueryStringByKey(location.query, 'environment'),
|
|
yAxis: decodeScalar(location.query.yAxis),
|
|
display: decodeScalar(location.query.display),
|
|
topEvents: decodeScalar(location.query.topEvents),
|
|
interval: decodeScalar(location.query.interval),
|
|
createdBy: undefined,
|
|
additionalConditions: new MutableSearch([]),
|
|
dataset: decodeScalar(location.query.dataset) as DiscoverDatasets,
|
|
});
|
|
}
|
|
|
|
static fromNewQueryWithLocation(newQuery: NewQuery, location: Location): EventView {
|
|
const query = location.query;
|
|
|
|
// apply global selection header values from location whenever possible
|
|
const environment: string[] =
|
|
Array.isArray(newQuery.environment) && newQuery.environment.length > 0
|
|
? newQuery.environment
|
|
: collectQueryStringByKey(query, 'environment');
|
|
|
|
const project: number[] =
|
|
Array.isArray(newQuery.projects) && newQuery.projects.length > 0
|
|
? newQuery.projects
|
|
: decodeProjects(location);
|
|
|
|
const saved: NewQuery = {
|
|
...newQuery,
|
|
|
|
environment,
|
|
projects: project,
|
|
|
|
// datetime selection
|
|
start: newQuery.start || decodeScalar(query.start),
|
|
end: newQuery.end || decodeScalar(query.end),
|
|
range: newQuery.range || decodeScalar(query.statsPeriod),
|
|
};
|
|
|
|
return EventView.fromSavedQuery(saved);
|
|
}
|
|
|
|
static fromNewQueryWithPageFilters(newQuery: NewQuery, pageFilters: PageFilters) {
|
|
return EventView.fromSavedQuery({
|
|
...newQuery,
|
|
environment: newQuery.environment ?? pageFilters.environments,
|
|
projects: newQuery.projects ?? pageFilters.projects,
|
|
start: newQuery.start ?? pageFilters.datetime.start ?? undefined,
|
|
end: newQuery.end ?? pageFilters.datetime.end ?? undefined,
|
|
range: newQuery.range ?? pageFilters.datetime.period ?? undefined,
|
|
utc: newQuery.utc ?? pageFilters.datetime.utc ?? undefined,
|
|
});
|
|
}
|
|
|
|
static getFields(saved: NewQuery | SavedQuery) {
|
|
return saved.fields.map((field, i) => {
|
|
const width = saved.widths?.[i] ? Number(saved.widths[i]) : COL_WIDTH_UNDEFINED;
|
|
|
|
return {field, width};
|
|
});
|
|
}
|
|
|
|
static fromSavedQuery(saved: NewQuery | SavedQuery): EventView {
|
|
const fields = EventView.getFields(saved);
|
|
// normalize datetime selection
|
|
const {start, end, statsPeriod, utc} = normalizeDateTimeParams({
|
|
start: saved.start,
|
|
end: saved.end,
|
|
statsPeriod: saved.range,
|
|
utc: saved.utc,
|
|
});
|
|
|
|
return new EventView({
|
|
id: saved.id,
|
|
name: saved.name,
|
|
fields,
|
|
query: queryStringFromSavedQuery(saved),
|
|
team: saved.teams ?? [],
|
|
project: saved.projects ?? [],
|
|
start: decodeScalar(start),
|
|
end: decodeScalar(end),
|
|
statsPeriod: decodeScalar(statsPeriod),
|
|
utc,
|
|
sorts: fromSorts(saved.orderby),
|
|
environment: collectQueryStringByKey(
|
|
{
|
|
environment: saved.environment as string[],
|
|
},
|
|
'environment'
|
|
),
|
|
yAxis:
|
|
Array.isArray(saved.yAxis) && saved.yAxis.length === 1
|
|
? saved.yAxis[0]
|
|
: saved.yAxis,
|
|
display: saved.display,
|
|
topEvents: saved.topEvents ? saved.topEvents.toString() : undefined,
|
|
interval: saved.interval,
|
|
createdBy: saved.createdBy,
|
|
expired: saved.expired,
|
|
additionalConditions: new MutableSearch([]),
|
|
dataset: saved.dataset,
|
|
});
|
|
}
|
|
|
|
static fromSavedQueryOrLocation(
|
|
saved: SavedQuery | undefined,
|
|
location: Location
|
|
): EventView {
|
|
let fields = decodeFields(location);
|
|
const id = decodeScalar(location.query.id);
|
|
const teams = decodeTeams(location);
|
|
const projects = decodeProjects(location);
|
|
const sorts = decodeSorts(location);
|
|
const environments = collectQueryStringByKey(location.query, 'environment');
|
|
|
|
if (saved) {
|
|
if (fields.length === 0) {
|
|
fields = EventView.getFields(saved);
|
|
}
|
|
|
|
const {start, end, statsPeriod, utc} = normalizeDateTimeParams(
|
|
location.query.start ||
|
|
location.query.end ||
|
|
location.query.statsPeriod ||
|
|
location.query.utc
|
|
? location.query
|
|
: {
|
|
start: saved.start,
|
|
end: saved.end,
|
|
statsPeriod: saved.range,
|
|
utc: saved.utc,
|
|
}
|
|
);
|
|
return new EventView({
|
|
id: id || saved.id,
|
|
name: decodeScalar(location.query.name) || saved.name,
|
|
fields,
|
|
query:
|
|
'query' in location.query
|
|
? decodeQuery(location)
|
|
: queryStringFromSavedQuery(saved),
|
|
sorts: sorts.length === 0 ? fromSorts(saved.orderby) : sorts,
|
|
yAxis:
|
|
decodeScalar(location.query.yAxis) ||
|
|
// Workaround to only use the first yAxis since eventView yAxis doesn't accept string[]
|
|
(Array.isArray(saved.yAxis) ? saved.yAxis[0] : saved.yAxis),
|
|
display: decodeScalar(location.query.display) || saved.display,
|
|
topEvents: (
|
|
decodeScalar(location.query.topEvents) ||
|
|
saved.topEvents ||
|
|
TOP_N
|
|
).toString(),
|
|
interval: decodeScalar(location.query.interval) || saved.interval,
|
|
createdBy: saved.createdBy,
|
|
expired: saved.expired,
|
|
additionalConditions: new MutableSearch([]),
|
|
// Always read team from location since they can be set by other parts
|
|
// of the UI
|
|
team: teams,
|
|
// Always read project and environment from location since they can
|
|
// be set by the GlobalSelectionHeaders.
|
|
project: projects,
|
|
environment: environments,
|
|
start: decodeScalar(start),
|
|
end: decodeScalar(end),
|
|
statsPeriod: decodeScalar(statsPeriod),
|
|
utc,
|
|
dataset:
|
|
(decodeScalar(location.query.dataset) as DiscoverDatasets) ?? saved.dataset,
|
|
});
|
|
}
|
|
return EventView.fromLocation(location);
|
|
}
|
|
|
|
isEqualTo(other: EventView, omitList: string[] = []): boolean {
|
|
const defaults = {
|
|
id: undefined,
|
|
name: undefined,
|
|
query: undefined,
|
|
statsPeriod: undefined,
|
|
fields: undefined,
|
|
sorts: undefined,
|
|
project: undefined,
|
|
environment: undefined,
|
|
interval: undefined,
|
|
yAxis: 'count()',
|
|
display: DisplayModes.DEFAULT,
|
|
topEvents: '5',
|
|
dataset: DiscoverDatasets.DISCOVER,
|
|
};
|
|
const keys = Object.keys(defaults).filter(key => !omitList.includes(key));
|
|
for (const key of keys) {
|
|
const currentValue = this[key] ?? defaults[key];
|
|
const otherValue = other[key] ?? defaults[key];
|
|
|
|
if (!isEqual(currentValue, otherValue)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// compare datetime selections using moment
|
|
|
|
const dateTimeKeys = ['start', 'end'];
|
|
|
|
for (const key of dateTimeKeys) {
|
|
const currentValue = this[key];
|
|
const otherValue = other[key];
|
|
|
|
if (currentValue && otherValue) {
|
|
const currentDateTime = moment.utc(currentValue);
|
|
const otherDateTime = moment.utc(otherValue);
|
|
|
|
if (!currentDateTime.isSame(otherDateTime)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
toNewQuery(): NewQuery {
|
|
const orderby = this.sorts.length > 0 ? encodeSorts(this.sorts)[0] : undefined;
|
|
|
|
const newQuery: NewQuery = {
|
|
version: 2,
|
|
id: this.id,
|
|
name: this.name || '',
|
|
fields: this.getFields(),
|
|
widths: this.getWidths().map(w => String(w)),
|
|
orderby,
|
|
query: this.query || '',
|
|
projects: this.project,
|
|
start: this.start,
|
|
end: this.end,
|
|
range: this.statsPeriod,
|
|
environment: this.environment,
|
|
yAxis: typeof this.yAxis === 'string' ? [this.yAxis] : this.yAxis,
|
|
dataset: this.dataset,
|
|
display: this.display,
|
|
topEvents: this.topEvents,
|
|
interval: this.interval,
|
|
};
|
|
|
|
if (!newQuery.query) {
|
|
// if query is an empty string, then it cannot be saved, so we omit it
|
|
// from the payload
|
|
delete newQuery.query;
|
|
}
|
|
|
|
return newQuery;
|
|
}
|
|
|
|
getPageFilters(): PageFilters {
|
|
return {
|
|
projects: this.project as number[],
|
|
environments: this.environment as string[],
|
|
datetime: {
|
|
start: this.start ?? null,
|
|
end: this.end ?? null,
|
|
period: this.statsPeriod ?? null,
|
|
// TODO(tony) Add support for the Use UTC option from the global
|
|
// headers, currently, that option is not supported and all times are
|
|
// assumed to be UTC
|
|
utc: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
getPageFiltersQuery(): Query {
|
|
const {
|
|
environments: environment,
|
|
projects,
|
|
datetime: {start, end, period, utc},
|
|
} = this.getPageFilters();
|
|
return {
|
|
project: projects.map(proj => proj.toString()),
|
|
environment,
|
|
utc: utc ? 'true' : 'false',
|
|
|
|
// since these values are from `getGlobalSelection`
|
|
// we know they have type `string | null`
|
|
start: (start ?? undefined) as string | undefined,
|
|
end: (end ?? undefined) as string | undefined,
|
|
|
|
// we can't use the ?? operator here as we want to
|
|
// convert the empty string to undefined
|
|
statsPeriod: period ? period : undefined,
|
|
};
|
|
}
|
|
|
|
generateBlankQueryStringObject(): Query {
|
|
const output = {
|
|
id: undefined,
|
|
name: undefined,
|
|
field: undefined,
|
|
widths: undefined,
|
|
sort: undefined,
|
|
tag: undefined,
|
|
query: undefined,
|
|
yAxis: undefined,
|
|
display: undefined,
|
|
topEvents: undefined,
|
|
interval: undefined,
|
|
};
|
|
|
|
for (const field of EXTERNAL_QUERY_STRING_KEYS) {
|
|
output[field] = undefined;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
generateQueryStringObject(): Query {
|
|
const output = {
|
|
id: this.id,
|
|
name: this.name,
|
|
field: this.getFields(),
|
|
widths: this.getWidths(),
|
|
sort: encodeSorts(this.sorts),
|
|
environment: this.environment,
|
|
project: this.project,
|
|
query: this.query,
|
|
yAxis: this.yAxis || this.getYAxis(),
|
|
dataset: this.dataset,
|
|
display: this.display,
|
|
topEvents: this.topEvents,
|
|
interval: this.interval,
|
|
};
|
|
|
|
for (const field of EXTERNAL_QUERY_STRING_KEYS) {
|
|
if (this[field]?.length) {
|
|
output[field] = this[field];
|
|
}
|
|
}
|
|
|
|
return cloneDeep(output as any);
|
|
}
|
|
|
|
isValid(): boolean {
|
|
return this.fields.length > 0;
|
|
}
|
|
|
|
getWidths(): number[] {
|
|
const result = this.fields.map(field =>
|
|
field.width ? field.width : COL_WIDTH_UNDEFINED
|
|
);
|
|
|
|
while (result.length > 0) {
|
|
const width = result[result.length - 1];
|
|
if (width === COL_WIDTH_UNDEFINED) {
|
|
result.pop();
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
getFields(): string[] {
|
|
return this.fields.map(field => field.field);
|
|
}
|
|
|
|
getEquations(): string[] {
|
|
return this.fields
|
|
.filter(field => isEquation(field.field))
|
|
.map(field => getEquation(field.field));
|
|
}
|
|
|
|
getAggregateFields(): Field[] {
|
|
return this.fields.filter(
|
|
field => isAggregateField(field.field) || isAggregateEquation(field.field)
|
|
);
|
|
}
|
|
|
|
hasAggregateField() {
|
|
return this.fields.some(field => isAggregateField(field.field));
|
|
}
|
|
|
|
hasIdField() {
|
|
return this.fields.some(field => field.field === 'id');
|
|
}
|
|
|
|
numOfColumns(): number {
|
|
return this.fields.length;
|
|
}
|
|
|
|
getColumns(): TableColumn<React.ReactText>[] {
|
|
return decodeColumnOrder(this.fields);
|
|
}
|
|
|
|
getDays(): number {
|
|
const statsPeriod = decodeScalar(this.statsPeriod);
|
|
return statsPeriodToDays(statsPeriod, this.start, this.end);
|
|
}
|
|
|
|
clone(): EventView {
|
|
// NOTE: We rely on usage of Readonly from TypeScript to ensure we do not mutate
|
|
// the attributes of EventView directly. This enables us to quickly
|
|
// clone new instances of EventView.
|
|
|
|
return new EventView({
|
|
id: this.id,
|
|
name: this.name,
|
|
fields: this.fields,
|
|
sorts: this.sorts,
|
|
query: this.query,
|
|
team: this.team,
|
|
project: this.project,
|
|
start: this.start,
|
|
end: this.end,
|
|
statsPeriod: this.statsPeriod,
|
|
environment: this.environment,
|
|
yAxis: this.yAxis,
|
|
dataset: this.dataset,
|
|
display: this.display,
|
|
topEvents: this.topEvents,
|
|
interval: this.interval,
|
|
expired: this.expired,
|
|
createdBy: this.createdBy,
|
|
additionalConditions: this.additionalConditions.copy(),
|
|
});
|
|
}
|
|
|
|
withSorts(sorts: Sort[]): EventView {
|
|
const newEventView = this.clone();
|
|
const fields = newEventView.fields.map(field => getAggregateAlias(field.field));
|
|
newEventView.sorts = sorts.filter(sort => fields.includes(sort.field));
|
|
|
|
return newEventView;
|
|
}
|
|
|
|
withColumns(columns: Column[]): EventView {
|
|
const newEventView = this.clone();
|
|
const fields: Field[] = columns
|
|
.filter(
|
|
col =>
|
|
((col.kind === 'field' || col.kind === FieldValueKind.EQUATION) && col.field) ||
|
|
(col.kind === 'function' && col.function[0])
|
|
)
|
|
.map(col => generateFieldAsString(col))
|
|
.map((field, i) => {
|
|
// newly added field
|
|
if (!newEventView.fields[i]) {
|
|
return {field, width: COL_WIDTH_UNDEFINED};
|
|
}
|
|
// Existing columns that were not re ordered should retain
|
|
// their old widths.
|
|
const existing = newEventView.fields[i];
|
|
const width =
|
|
existing.field === field && existing.width !== undefined
|
|
? existing.width
|
|
: COL_WIDTH_UNDEFINED;
|
|
return {field, width};
|
|
});
|
|
newEventView.fields = fields;
|
|
|
|
// Update sorts as sorted fields may have been removed.
|
|
if (newEventView.sorts) {
|
|
// Filter the sort fields down to those that are still selected.
|
|
const sortKeys = fields.map(field => fieldToSort(field, undefined)?.field);
|
|
const newSort = newEventView.sorts.filter(
|
|
sort => sort && sortKeys.includes(sort.field)
|
|
);
|
|
// If the sort field was removed, try and find a new sortable column.
|
|
if (newSort.length === 0) {
|
|
const sortField = fields.find(field => isFieldSortable(field, undefined));
|
|
if (sortField) {
|
|
newSort.push({field: sortField.field, kind: 'desc'});
|
|
}
|
|
}
|
|
newEventView.sorts = newSort;
|
|
}
|
|
|
|
newEventView.yAxis = newEventView.getYAxis();
|
|
|
|
return newEventView;
|
|
}
|
|
|
|
withNewColumn(newColumn: Column): EventView {
|
|
const fieldAsString = generateFieldAsString(newColumn);
|
|
const newField: Field = {
|
|
field: fieldAsString,
|
|
width: COL_WIDTH_UNDEFINED,
|
|
};
|
|
const newEventView = this.clone();
|
|
newEventView.fields = [...newEventView.fields, newField];
|
|
|
|
return newEventView;
|
|
}
|
|
|
|
withResizedColumn(columnIndex: number, newWidth: number) {
|
|
const field = this.fields[columnIndex];
|
|
const newEventView = this.clone();
|
|
if (!field) {
|
|
return newEventView;
|
|
}
|
|
|
|
const updateWidth = field.width !== newWidth;
|
|
if (updateWidth) {
|
|
const fields = [...newEventView.fields];
|
|
fields[columnIndex] = {
|
|
...field,
|
|
width: newWidth,
|
|
};
|
|
newEventView.fields = fields;
|
|
}
|
|
|
|
return newEventView;
|
|
}
|
|
|
|
withUpdatedColumn(
|
|
columnIndex: number,
|
|
updatedColumn: Column,
|
|
tableMeta: MetaType | undefined
|
|
): EventView {
|
|
const columnToBeUpdated = this.fields[columnIndex];
|
|
const fieldAsString = generateFieldAsString(updatedColumn);
|
|
|
|
const updateField = columnToBeUpdated.field !== fieldAsString;
|
|
if (!updateField) {
|
|
return this;
|
|
}
|
|
|
|
// ensure tableMeta is non-empty
|
|
tableMeta = validateTableMeta(tableMeta);
|
|
|
|
const newEventView = this.clone();
|
|
|
|
const updatedField: Field = {
|
|
field: fieldAsString,
|
|
width: COL_WIDTH_UNDEFINED,
|
|
};
|
|
|
|
const fields = [...newEventView.fields];
|
|
fields[columnIndex] = updatedField;
|
|
|
|
newEventView.fields = fields;
|
|
|
|
// if the updated column is one of the sorted columns, we may need to remove
|
|
// it from the list of sorts
|
|
const needleSortIndex = this.sorts.findIndex(sort =>
|
|
isSortEqualToField(sort, columnToBeUpdated, tableMeta)
|
|
);
|
|
|
|
if (needleSortIndex >= 0) {
|
|
const needleSort = this.sorts[needleSortIndex];
|
|
|
|
const numOfColumns = this.fields.reduce((sum, currentField) => {
|
|
if (isSortEqualToField(needleSort, currentField, tableMeta)) {
|
|
return sum + 1;
|
|
}
|
|
|
|
return sum;
|
|
}, 0);
|
|
|
|
// do not bother deleting the sort key if there are more than one columns
|
|
// of it in the table.
|
|
if (numOfColumns <= 1) {
|
|
if (isFieldSortable(updatedField, tableMeta)) {
|
|
// use the current updated field as the sort key
|
|
const sort = fieldToSort(updatedField, tableMeta)!;
|
|
|
|
// preserve the sort kind
|
|
sort.kind = needleSort.kind;
|
|
|
|
const sorts = [...newEventView.sorts];
|
|
sorts[needleSortIndex] = sort;
|
|
newEventView.sorts = sorts;
|
|
} else {
|
|
const sorts = [...newEventView.sorts];
|
|
sorts.splice(needleSortIndex, 1);
|
|
newEventView.sorts = [...new Set(sorts)];
|
|
}
|
|
}
|
|
|
|
if (newEventView.sorts.length <= 0 && newEventView.fields.length > 0) {
|
|
// establish a default sort by finding the first sortable field
|
|
|
|
if (isFieldSortable(updatedField, tableMeta)) {
|
|
// use the current updated field as the sort key
|
|
const sort = fieldToSort(updatedField, tableMeta)!;
|
|
|
|
// preserve the sort kind
|
|
sort.kind = needleSort.kind;
|
|
|
|
newEventView.sorts = [sort];
|
|
} else {
|
|
const sortableFieldIndex = newEventView.fields.findIndex(currentField =>
|
|
isFieldSortable(currentField, tableMeta)
|
|
);
|
|
if (sortableFieldIndex >= 0) {
|
|
const fieldToBeSorted = newEventView.fields[sortableFieldIndex];
|
|
const sort = fieldToSort(fieldToBeSorted, tableMeta)!;
|
|
newEventView.sorts = [sort];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
newEventView.yAxis = newEventView.getYAxis();
|
|
|
|
return newEventView;
|
|
}
|
|
|
|
withDeletedColumn(columnIndex: number, tableMeta: MetaType | undefined): EventView {
|
|
// Disallow removal of the orphan column, and check for out-of-bounds
|
|
if (this.fields.length <= 1 || this.fields.length <= columnIndex || columnIndex < 0) {
|
|
return this;
|
|
}
|
|
|
|
// ensure tableMeta is non-empty
|
|
tableMeta = validateTableMeta(tableMeta);
|
|
|
|
// delete the column
|
|
const newEventView = this.clone();
|
|
const fields = [...newEventView.fields];
|
|
fields.splice(columnIndex, 1);
|
|
newEventView.fields = fields;
|
|
|
|
// Ensure there is at least one auto width column
|
|
// To ensure a well formed table results.
|
|
const hasAutoIndex = fields.find(field => field.width === COL_WIDTH_UNDEFINED);
|
|
if (!hasAutoIndex) {
|
|
newEventView.fields[0].width = COL_WIDTH_UNDEFINED;
|
|
}
|
|
|
|
// if the deleted column is one of the sorted columns, we need to remove
|
|
// it from the list of sorts
|
|
const columnToBeDeleted = this.fields[columnIndex];
|
|
const needleSortIndex = this.sorts.findIndex(sort =>
|
|
isSortEqualToField(sort, columnToBeDeleted, tableMeta)
|
|
);
|
|
|
|
if (needleSortIndex >= 0) {
|
|
const needleSort = this.sorts[needleSortIndex];
|
|
|
|
const numOfColumns = this.fields.reduce((sum, field) => {
|
|
if (isSortEqualToField(needleSort, field, tableMeta)) {
|
|
return sum + 1;
|
|
}
|
|
|
|
return sum;
|
|
}, 0);
|
|
|
|
// do not bother deleting the sort key if there are more than one columns
|
|
// of it in the table.
|
|
if (numOfColumns <= 1) {
|
|
const sorts = [...newEventView.sorts];
|
|
sorts.splice(needleSortIndex, 1);
|
|
newEventView.sorts = [...new Set(sorts)];
|
|
|
|
if (newEventView.sorts.length <= 0 && newEventView.fields.length > 0) {
|
|
// establish a default sort by finding the first sortable field
|
|
const sortableFieldIndex = newEventView.fields.findIndex(field =>
|
|
isFieldSortable(field, tableMeta)
|
|
);
|
|
|
|
if (sortableFieldIndex >= 0) {
|
|
const fieldToBeSorted = newEventView.fields[sortableFieldIndex];
|
|
const sort = fieldToSort(fieldToBeSorted, tableMeta)!;
|
|
newEventView.sorts = [sort];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
newEventView.yAxis = newEventView.getYAxis();
|
|
|
|
return newEventView;
|
|
}
|
|
|
|
withTeams(teams: ('myteams' | number)[]): EventView {
|
|
const newEventView = this.clone();
|
|
newEventView.team = teams;
|
|
return newEventView;
|
|
}
|
|
|
|
getSorts(): TableColumnSort<React.ReactText>[] {
|
|
return this.sorts.map(
|
|
sort =>
|
|
({
|
|
key: sort.field,
|
|
order: sort.kind,
|
|
}) as TableColumnSort<string>
|
|
);
|
|
}
|
|
|
|
// returns query input for the search
|
|
getQuery(inputQuery: string | string[] | null | undefined = undefined): string {
|
|
const queryParts: string[] = [];
|
|
|
|
if (this.query) {
|
|
if (this.additionalConditions) {
|
|
queryParts.push(this.getQueryWithAdditionalConditions());
|
|
} else {
|
|
queryParts.push(this.query);
|
|
}
|
|
}
|
|
|
|
if (inputQuery) {
|
|
// there may be duplicate query in the query string
|
|
// e.g. query=hello&query=world
|
|
if (Array.isArray(inputQuery)) {
|
|
inputQuery.forEach(query => {
|
|
if (typeof query === 'string' && !queryParts.includes(query)) {
|
|
queryParts.push(query);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (typeof inputQuery === 'string' && !queryParts.includes(inputQuery)) {
|
|
queryParts.push(inputQuery);
|
|
}
|
|
}
|
|
|
|
return queryParts.join(' ');
|
|
}
|
|
|
|
getFacetsAPIPayload(
|
|
location: Location
|
|
): Exclude<EventQuery & LocationQuery, 'sort' | 'cursor'> {
|
|
const payload = this.getEventsAPIPayload(location);
|
|
|
|
const remove = [
|
|
'id',
|
|
'name',
|
|
'per_page',
|
|
'sort',
|
|
'cursor',
|
|
'field',
|
|
'equation',
|
|
'interval',
|
|
];
|
|
for (const key of remove) {
|
|
delete payload[key];
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
normalizeDateSelection(location: Location) {
|
|
const query = location?.query || {};
|
|
|
|
// pick only the query strings that we care about
|
|
const picked = pickRelevantLocationQueryStrings(location);
|
|
|
|
const hasDateSelection = this.statsPeriod || (this.start && this.end);
|
|
|
|
// an eventview's date selection has higher precedence than the date selection in the query string
|
|
const dateSelection = hasDateSelection
|
|
? {
|
|
start: this.start,
|
|
end: this.end,
|
|
statsPeriod: this.statsPeriod,
|
|
}
|
|
: {
|
|
start: picked.start,
|
|
end: picked.end,
|
|
period: decodeScalar(query.period),
|
|
statsPeriod: picked.statsPeriod,
|
|
};
|
|
|
|
// normalize datetime selection
|
|
return normalizeDateTimeParams({
|
|
...dateSelection,
|
|
utc: decodeScalar(query.utc),
|
|
});
|
|
}
|
|
|
|
// Takes an EventView instance and converts it into the format required for the events API
|
|
getEventsAPIPayload(
|
|
location: Location,
|
|
forceAppendRawQueryString?: string
|
|
): EventQuery & LocationQuery {
|
|
// pick only the query strings that we care about
|
|
const picked = pickRelevantLocationQueryStrings(location);
|
|
|
|
// normalize datetime selection
|
|
const normalizedTimeWindowParams = this.normalizeDateSelection(location);
|
|
|
|
const sort =
|
|
this.sorts.length <= 0
|
|
? undefined
|
|
: this.sorts.length > 1
|
|
? encodeSorts(this.sorts)
|
|
: encodeSort(this.sorts[0]);
|
|
const fields = this.getFields();
|
|
const team = this.team.map(proj => String(proj));
|
|
const project = this.project.map(proj => String(proj));
|
|
const environment = this.environment as string[];
|
|
|
|
let queryString = this.getQueryWithAdditionalConditions();
|
|
if (forceAppendRawQueryString) {
|
|
queryString += ' ' + forceAppendRawQueryString;
|
|
}
|
|
|
|
// generate event query
|
|
const eventQuery = Object.assign(
|
|
omit(picked, DATETIME_QUERY_STRING_KEYS),
|
|
normalizedTimeWindowParams,
|
|
{
|
|
team,
|
|
project,
|
|
environment,
|
|
field: [...new Set(fields)],
|
|
sort,
|
|
per_page: DEFAULT_PER_PAGE,
|
|
query: queryString,
|
|
dataset: this.dataset,
|
|
}
|
|
) as EventQuery & LocationQuery;
|
|
|
|
if (eventQuery.team && !eventQuery.team.length) {
|
|
delete eventQuery.team;
|
|
}
|
|
|
|
if (!eventQuery.sort) {
|
|
delete eventQuery.sort;
|
|
}
|
|
|
|
return eventQuery;
|
|
}
|
|
|
|
getResultsViewUrlTarget(
|
|
slug: string,
|
|
isHomepage: boolean = false
|
|
): {pathname: string; query: Query} {
|
|
const target = isHomepage ? 'homepage' : 'results';
|
|
return {
|
|
pathname: normalizeUrl(`/organizations/${slug}/discover/${target}/`),
|
|
query: this.generateQueryStringObject(),
|
|
};
|
|
}
|
|
|
|
getResultsViewShortUrlTarget(slug: string): {pathname: string; query: Query} {
|
|
const output = {id: this.id};
|
|
for (const field of [...Object.values(URL_PARAM), 'cursor']) {
|
|
if (this[field]?.length) {
|
|
output[field] = this[field];
|
|
}
|
|
}
|
|
return {
|
|
pathname: normalizeUrl(`/organizations/${slug}/discover/results/`),
|
|
query: cloneDeep(output as any),
|
|
};
|
|
}
|
|
|
|
getPerformanceTransactionEventsViewUrlTarget(
|
|
slug: string,
|
|
options: {
|
|
breakdown?: SpanOperationBreakdownFilter;
|
|
showTransactions?: EventsDisplayFilterName;
|
|
webVital?: WebVital;
|
|
}
|
|
): {pathname: string; query: Query} {
|
|
const {showTransactions, breakdown, webVital} = options;
|
|
const output = {
|
|
sort: encodeSorts(this.sorts),
|
|
project: this.project,
|
|
query: this.query,
|
|
transaction: this.name,
|
|
showTransactions,
|
|
breakdown,
|
|
webVital,
|
|
};
|
|
|
|
for (const field of EXTERNAL_QUERY_STRING_KEYS) {
|
|
if (this[field]?.length) {
|
|
output[field] = this[field];
|
|
}
|
|
}
|
|
|
|
const query = cloneDeep(output as any);
|
|
return {
|
|
pathname: normalizeUrl(`/organizations/${slug}/performance/summary/events/`),
|
|
query,
|
|
};
|
|
}
|
|
|
|
sortForField(field: Field, tableMeta: MetaType | undefined): Sort | undefined {
|
|
if (!tableMeta) {
|
|
return undefined;
|
|
}
|
|
return this.sorts.find(sort => isSortEqualToField(sort, field, tableMeta));
|
|
}
|
|
|
|
sortOnField(
|
|
field: Field,
|
|
tableMeta: MetaType,
|
|
kind?: 'desc' | 'asc',
|
|
useFunctionFormat?: boolean
|
|
): EventView {
|
|
// check if field can be sorted
|
|
if (!isFieldSortable(field, tableMeta)) {
|
|
return this;
|
|
}
|
|
|
|
const needleIndex = this.sorts.findIndex(sort =>
|
|
isSortEqualToField(sort, field, tableMeta)
|
|
);
|
|
|
|
if (needleIndex >= 0) {
|
|
const newEventView = this.clone();
|
|
|
|
const currentSort = this.sorts[needleIndex];
|
|
|
|
const sorts = [...newEventView.sorts];
|
|
sorts[needleIndex] = kind
|
|
? setSortOrder(
|
|
{...currentSort, ...(useFunctionFormat ? {field: field.field} : {})},
|
|
kind
|
|
)
|
|
: reverseSort({
|
|
...currentSort,
|
|
...(useFunctionFormat ? {field: field.field} : {}),
|
|
});
|
|
|
|
newEventView.sorts = sorts;
|
|
|
|
return newEventView;
|
|
}
|
|
|
|
// field is currently not sorted; so, we sort on it
|
|
const newEventView = this.clone();
|
|
|
|
// invariant: this is not falsey, since sortKey exists
|
|
const sort = fieldToSort(field, tableMeta, kind, useFunctionFormat)!;
|
|
|
|
newEventView.sorts = [sort];
|
|
|
|
return newEventView;
|
|
}
|
|
|
|
getYAxisOptions(): SelectValue<string>[] {
|
|
// Make option set and add the default options in.
|
|
return uniqBy(
|
|
this.getAggregateFields()
|
|
// Only include aggregates that make sense to be graphable (eg. not string or date)
|
|
.filter(
|
|
(field: Field) =>
|
|
isLegalYAxisType(aggregateOutputType(field.field)) ||
|
|
isAggregateEquation(field.field)
|
|
)
|
|
.map((field: Field) => ({
|
|
label: isEquation(field.field) ? getEquation(field.field) : field.field,
|
|
value: field.field,
|
|
}))
|
|
.concat(CHART_AXIS_OPTIONS),
|
|
'value'
|
|
);
|
|
}
|
|
|
|
getYAxis(): string {
|
|
const yAxisOptions = this.getYAxisOptions();
|
|
|
|
const yAxis = this.yAxis;
|
|
const defaultOption = yAxisOptions[0].value;
|
|
|
|
if (!yAxis) {
|
|
return defaultOption;
|
|
}
|
|
|
|
// ensure current selected yAxis is one of the items in yAxisOptions
|
|
const result = yAxisOptions.findIndex(
|
|
(option: SelectValue<string>) => option.value === yAxis
|
|
);
|
|
|
|
if (result >= 0) {
|
|
return typeof yAxis === 'string' ? yAxis : yAxis[0];
|
|
}
|
|
|
|
return defaultOption;
|
|
}
|
|
|
|
getDisplayOptions(): SelectValue<string>[] {
|
|
return DISPLAY_MODE_OPTIONS.map(item => {
|
|
if (item.value === DisplayModes.PREVIOUS) {
|
|
if (this.start || this.end) {
|
|
return {...item, disabled: true};
|
|
}
|
|
}
|
|
|
|
if (item.value === DisplayModes.TOP5 || item.value === DisplayModes.DAILYTOP5) {
|
|
if (this.getAggregateFields().length === 0) {
|
|
return {
|
|
...item,
|
|
disabled: true,
|
|
tooltip: t('Add a function that groups events to use this view.'),
|
|
};
|
|
}
|
|
}
|
|
|
|
if (item.value === DisplayModes.DAILY || item.value === DisplayModes.DAILYTOP5) {
|
|
if (this.getDays() < 1) {
|
|
return {
|
|
...item,
|
|
disabled: true,
|
|
tooltip: t('Change the date rage to at least 1 day to use this view.'),
|
|
};
|
|
}
|
|
}
|
|
|
|
return item;
|
|
});
|
|
}
|
|
|
|
getDisplayMode() {
|
|
const mode = this.display ?? DisplayModes.DEFAULT;
|
|
const displayOptions = this.getDisplayOptions();
|
|
|
|
let display = (Object.values(DisplayModes) as string[]).includes(mode)
|
|
? mode
|
|
: DisplayModes.DEFAULT;
|
|
const cond = option => option.value === display;
|
|
|
|
// Just in case we define a fallback chain that results in an infinite loop.
|
|
// The number 5 isn't anything special, its just larger than the longest fallback
|
|
// chain that exists and isn't too big.
|
|
for (let i = 0; i < 5; i++) {
|
|
const selectedOption = displayOptions.find(cond);
|
|
if (selectedOption && !selectedOption.disabled) {
|
|
return display;
|
|
}
|
|
display = DISPLAY_MODE_FALLBACK_OPTIONS[display];
|
|
}
|
|
|
|
// after trying to find an enabled display mode and failing to find one,
|
|
// we just use the default display mode
|
|
return DisplayModes.DEFAULT;
|
|
}
|
|
|
|
getQueryWithAdditionalConditions() {
|
|
const {query} = this;
|
|
if (this.additionalConditions.isEmpty()) {
|
|
return query;
|
|
}
|
|
const conditions = new MutableSearch(query);
|
|
Object.entries(this.additionalConditions.filters).forEach(([tag, tagValues]) => {
|
|
const existingTagValues = conditions.getFilterValues(tag);
|
|
const newTagValues = tagValues.filter(
|
|
tagValue => !existingTagValues.includes(tagValue)
|
|
);
|
|
if (newTagValues.length) {
|
|
conditions.addFilterValues(tag, newTagValues);
|
|
}
|
|
});
|
|
return conditions.formatString();
|
|
}
|
|
|
|
/**
|
|
* Eventview usually just holds onto a project id for selected projects.
|
|
* Sometimes we need to iterate over the related project objects, this will give you the full projects if the Projects list is passed in.
|
|
* Also covers the 'My Projects' case which is sometimes missed, tries using the 'isMember' property of projects to pick the right list.
|
|
*/
|
|
getFullSelectedProjects(fullProjectList: Project[]) {
|
|
const selectedProjectIds = this.project;
|
|
const isMyProjects = selectedProjectIds.length === 0;
|
|
if (isMyProjects) {
|
|
return fullProjectList.filter(p => p.isMember);
|
|
}
|
|
const isAllProjects =
|
|
selectedProjectIds.length === 1 && selectedProjectIds[0] === ALL_ACCESS_PROJECTS;
|
|
if (isAllProjects) {
|
|
return fullProjectList;
|
|
}
|
|
|
|
const projectMap = Object.fromEntries(fullProjectList.map(p => [String(p.id), p]));
|
|
|
|
return selectedProjectIds.map(id => projectMap[String(id)]);
|
|
}
|
|
}
|
|
|
|
export type ImmutableEventView = Readonly<Omit<EventView, 'additionalConditions'>>;
|
|
|
|
const isFieldsSimilar = (
|
|
currentValue: Array<string>,
|
|
otherValue: Array<string>
|
|
): boolean => {
|
|
// For equation's their order matters because we alias them based on index
|
|
const currentEquations = currentValue.filter(isEquation);
|
|
const otherEquations = otherValue.filter(isEquation);
|
|
|
|
// Field orders don't matter, so using a set for comparison
|
|
const currentFields = new Set(currentValue.filter(value => !isEquation(value)));
|
|
const otherFields = new Set(otherValue.filter(value => !isEquation(value)));
|
|
|
|
if (!isEqual(currentEquations, otherEquations)) {
|
|
return false;
|
|
}
|
|
if (!isEqual(currentFields, otherFields)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
export const isAPIPayloadSimilar = (
|
|
current: EventQuery & LocationQuery,
|
|
other: EventQuery & LocationQuery
|
|
): boolean => {
|
|
const currentKeys = new Set(Object.keys(current));
|
|
const otherKeys = new Set(Object.keys(other));
|
|
|
|
if (!isEqual(currentKeys, otherKeys)) {
|
|
return false;
|
|
}
|
|
|
|
for (const key of currentKeys) {
|
|
const currentValue = current[key];
|
|
const otherValue = other[key];
|
|
if (key === 'field') {
|
|
if (!isFieldsSimilar(currentValue, otherValue)) {
|
|
return false;
|
|
}
|
|
} else {
|
|
const currentTarget = Array.isArray(currentValue)
|
|
? new Set(currentValue)
|
|
: currentValue;
|
|
|
|
const otherTarget = Array.isArray(otherValue) ? new Set(otherValue) : otherValue;
|
|
|
|
if (!isEqual(currentTarget, otherTarget)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
export function pickRelevantLocationQueryStrings(location: Location) {
|
|
const query = location.query || {};
|
|
const picked = pick(query || {}, EXTERNAL_QUERY_STRING_KEYS);
|
|
|
|
return picked;
|
|
}
|
|
|
|
export default EventView;
|