Browse Source

ref(discover2): Delegate view updates to the EventView and the other things (#15045)

Alberto Leal 5 years ago
parent
commit
74118b4fdf

+ 10 - 5
src/sentry/static/sentry/app/components/gridEditable/index.tsx

@@ -49,7 +49,10 @@ type GridEditableProps<DataRow, ColumnKey extends keyof DataRow> = {
    * data within it. Note that this is optional.
    */
   grid: {
-    renderHeaderCell?: (column: GridColumnOrder<ColumnKey>) => React.ReactNode;
+    renderHeaderCell?: (
+      column: GridColumnOrder<ColumnKey>,
+      columnIndex: number
+    ) => React.ReactNode;
     renderBodyCell?: (
       column: GridColumnOrder<ColumnKey>,
       dataRow: DataRow
@@ -218,12 +221,12 @@ class GridEditable<
               HTML semantics. */
           isEditable && this.renderGridHeadEditButtons()}
 
-          {columnOrder.map((column, i) => (
+          {columnOrder.map((column, columnIndex) => (
             <GridHeadCell
-              key={`${i}.${column.key}`}
+              key={`${columnIndex}.${column.key}`}
               isPrimary={column.isPrimary}
               isEditing={enableEdit}
-              indexColumnOrder={i}
+              indexColumnOrder={columnIndex}
               column={column}
               actions={{
                 moveColumn: actions.moveColumn,
@@ -231,7 +234,9 @@ class GridEditable<
                 toggleModalEditColumn: this.toggleModalEditColumn,
               }}
             >
-              {grid.renderHeaderCell ? grid.renderHeaderCell(column) : column.name}
+              {grid.renderHeaderCell
+                ? grid.renderHeaderCell(column, columnIndex)
+                : column.name}
             </GridHeadCell>
           ))}
         </GridRow>

+ 4 - 3
src/sentry/static/sentry/app/stores/discoverSavedQueriesStore.tsx

@@ -7,14 +7,15 @@ export type NewQuery = {
   id: string | undefined;
   version: Versions;
   name: string;
-  projects: number[];
-  fields: string[];
-  fieldnames: string[];
+  projects: Readonly<number[]>;
+  fields: Readonly<string[]>;
+  fieldnames: Readonly<string[]>;
   query: string;
   orderby?: string;
   range?: string;
   start?: string;
   end?: string;
+  environment?: Readonly<string[]>;
 };
 
 export type SavedQuery = NewQuery & {

+ 10 - 4
src/sentry/static/sentry/app/views/eventsV2/eventQueryParams.tsx

@@ -4,10 +4,16 @@ export type ColumnValueType =
   | 'number'
   | 'duration'
   | 'timestamp'
+  | 'boolean'
   | 'never'; // Matches to nothing
 
 // Refer to src/sentry/utils/snuba.py
-export const AGGREGATIONS = {
+export const AGGREGATIONS: {
+  [key: string]: {
+    type: '*' | ColumnValueType[];
+    isSortable: boolean;
+  };
+} = {
   count: {
     type: '*',
     isSortable: true,
@@ -35,7 +41,7 @@ export const AGGREGATIONS = {
     isSortable: true,
   },
   sum: {
-    type: ['transaction.duration'],
+    type: ['duration'],
     isSortable: true,
   },
   avg: {
@@ -57,11 +63,11 @@ export type Aggregation = keyof typeof AGGREGATIONS | '';
 /**
  * Refer to src/sentry/utils/snuba.py, search for SENTRY_SNUBA_MAP
  */
-export const FIELDS = {
+export const FIELDS: {[key: string]: ColumnValueType} = {
   id: 'string',
 
   title: 'string',
-  project: 'name',
+  project: 'string',
   environment: 'string',
   release: 'string',
   'issue.id': 'string',

+ 506 - 89
src/sentry/static/sentry/app/views/eventsV2/eventView.tsx

@@ -1,5 +1,5 @@
 import {Location, Query} from 'history';
-import {isString, cloneDeep, pick} from 'lodash';
+import {isString, cloneDeep, pick, isEqual} from 'lodash';
 
 import {DEFAULT_PER_PAGE} from 'app/constants';
 import {EventViewv1} from 'app/types';
@@ -7,13 +7,41 @@ import {SavedQuery as LegacySavedQuery} from 'app/views/discover/types';
 import {SavedQuery, NewQuery} from 'app/stores/discoverSavedQueriesStore';
 
 import {AUTOLINK_FIELDS, SPECIAL_FIELDS, FIELD_FORMATTERS} from './data';
-import {MetaType, EventQuery, getAggregateAlias} from './utils';
+import {MetaType, EventQuery, getAggregateAlias, decodeColumnOrder} from './utils';
+import {TableColumn, TableColumnSort} from './table/types';
+
+type LocationQuery = {
+  project?: string | string[];
+  environment?: string | string[];
+  start?: string | string[];
+  end?: string | string[];
+  utc?: string | string[];
+  statsPeriod?: string | string[];
+  cursor?: string | string[];
+};
+
+const EXTERNAL_QUERY_STRING_KEYS: Readonly<Array<keyof LocationQuery>> = [
+  'project',
+  'environment',
+  'start',
+  'end',
+  'utc',
+  'statsPeriod',
+  'cursor',
+];
 
 export type Sort = {
   kind: 'asc' | 'desc';
   field: string;
 };
 
+const reverseSort = (sort: Sort): Sort => {
+  return {
+    kind: sort.kind === 'desc' ? 'asc' : 'desc',
+    field: sort.field,
+  };
+};
+
 export type Field = {
   field: string;
   title: string;
@@ -21,6 +49,67 @@ export type Field = {
   // width: number;
 };
 
+const isSortEqualToField = (
+  sort: Sort,
+  field: Field,
+  tableDataMeta: MetaType
+): boolean => {
+  const sortKey = getSortKeyFromField(field, tableDataMeta);
+  return sort.field === sortKey;
+};
+
+const fieldToSort = (field: Field, tableDataMeta: MetaType): Sort | undefined => {
+  const sortKey = getSortKeyFromField(field, tableDataMeta);
+
+  if (!sortKey) {
+    return void 0;
+  }
+
+  return {
+    kind: 'desc',
+    field: sortKey,
+  };
+};
+
+function getSortKeyFromFieldWithoutMeta(field: Field): string | null {
+  const column = getAggregateAlias(field.field);
+  if (SPECIAL_FIELDS.hasOwnProperty(column)) {
+    return SPECIAL_FIELDS[column as keyof typeof SPECIAL_FIELDS].sortField;
+  }
+
+  return column;
+}
+
+function getSortKeyFromField(field: Field, tableDataMeta: MetaType): string | null {
+  const column = getAggregateAlias(field.field);
+  if (SPECIAL_FIELDS.hasOwnProperty(column)) {
+    return SPECIAL_FIELDS[column as keyof typeof SPECIAL_FIELDS].sortField;
+  }
+
+  if (FIELD_FORMATTERS.hasOwnProperty(tableDataMeta[column])) {
+    return FIELD_FORMATTERS[tableDataMeta[column] as keyof typeof FIELD_FORMATTERS]
+      .sortField
+      ? column
+      : null;
+  }
+
+  return null;
+}
+
+export function isFieldSortable(field: Field, tableDataMeta: MetaType): boolean {
+  return !!getSortKeyFromField(field, tableDataMeta);
+}
+
+const generateFieldAsString = (props: {aggregation: string; field: string}): string => {
+  const {aggregation, field} = props;
+
+  const hasAggregation = aggregation.length > 0;
+
+  const fieldAsString = hasAggregation ? `${aggregation}(${field})` : field;
+
+  return fieldAsString;
+};
+
 const decodeFields = (location: Location): Array<Field> => {
   const {query} = location;
 
@@ -70,7 +159,10 @@ const fromSorts = (sorts: string | string[] | undefined): Array<Sort> => {
 
   sorts = isString(sorts) ? [sorts] : sorts;
 
-  return sorts.reduce((acc: Array<Sort>, sort: string) => {
+  // 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;
   }, []);
@@ -102,24 +194,23 @@ const encodeSort = (sort: Sort): string => {
   }
 };
 
-const encodeSorts = (sorts: Array<Sort>): Array<string> => {
+const encodeSorts = (sorts: Readonly<Array<Sort>>): Array<string> => {
   return sorts.map(encodeSort);
 };
 
-const decodeTags = (location: Location): Array<string> => {
-  const {query} = location;
-
-  if (!query || !query.tag) {
-    return [];
-  }
-
-  const tags: Array<string> = isString(query.tag) ? [query.tag] : query.tag;
+const collectQueryStringByKey = (query: Query, key: string): Array<string> => {
+  const needle = query[key];
+  const collection: Array<string> = Array.isArray(needle)
+    ? needle
+    : typeof needle === 'string'
+    ? [needle]
+    : [];
 
-  return tags.reduce((acc: Array<string>, tag: string) => {
-    tag = tag.trim();
+  return collection.reduce((acc: Array<string>, item: string) => {
+    item = item.trim();
 
-    if (tag.length > 0) {
-      acc.push(tag);
+    if (item.length > 0) {
+      acc.push(item);
     }
 
     return acc;
@@ -195,37 +286,60 @@ const queryStringFromSavedQuery = (saved: LegacySavedQuery | SavedQuery): string
 class EventView {
   id: string | undefined;
   name: string | undefined;
-  fields: Field[];
-  sorts: Sort[];
-  tags: string[];
+  fields: Readonly<Field[]>;
+  sorts: Readonly<Sort[]>;
+  tags: Readonly<string[]>;
   query: string | undefined;
-  project: number[];
-  range: string | undefined;
+  project: Readonly<number[]>;
   start: string | undefined;
   end: string | undefined;
+  statsPeriod: string | undefined;
+  environment: Readonly<string[]>;
 
   constructor(props: {
     id: string | undefined;
     name: string | undefined;
-    fields: Field[];
-    sorts: Sort[];
-    tags: string[];
+    fields: Readonly<Field[]>;
+    sorts: Readonly<Sort[]>;
+    tags: Readonly<string[]>;
     query?: string | undefined;
-    project: number[];
-    range: string | undefined;
+    project: Readonly<number[]>;
     start: string | undefined;
     end: string | undefined;
+    statsPeriod: string | undefined;
+    environment: Readonly<string[]>;
   }) {
-    this.id = props.id;
+    // only include sort keys that are included in the fields
+
+    const sortKeys = props.fields
+      .map(field => {
+        return getSortKeyFromFieldWithoutMeta(field);
+      })
+      .filter(
+        (sortKey): sortKey is string => {
+          return !!sortKey;
+        }
+      );
+
+    const sort = props.sorts.find(currentSort => {
+      return sortKeys.includes(currentSort.field);
+    });
+
+    const 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 = props.fields;
-    this.sorts = props.sorts;
+    this.sorts = sorts;
     this.tags = props.tags;
     this.query = props.query;
     this.project = props.project;
-    this.range = props.range;
     this.start = props.start;
     this.end = props.end;
+    this.statsPeriod = props.statsPeriod;
+    this.environment = props.environment;
   }
 
   static fromLocation(location: Location): EventView {
@@ -234,12 +348,13 @@ class EventView {
       name: decodeScalar(location.query.name),
       fields: decodeFields(location),
       sorts: decodeSorts(location),
-      tags: decodeTags(location),
+      tags: collectQueryStringByKey(location.query, 'tag'),
       query: decodeQuery(location),
       project: decodeProjects(location),
       start: decodeScalar(location.query.start),
       end: decodeScalar(location.query.end),
-      range: decodeScalar(location.query.range),
+      statsPeriod: decodeScalar(location.query.statsPeriod),
+      environment: collectQueryStringByKey(location.query, 'environment'),
     });
   }
 
@@ -259,9 +374,10 @@ class EventView {
       query: eventViewV1.data.query,
       project: [],
       id: undefined,
-      range: undefined,
       start: undefined,
       end: undefined,
+      statsPeriod: undefined,
+      environment: [],
     });
   }
 
@@ -287,59 +403,77 @@ class EventView {
       project: saved.projects,
       start: saved.start,
       end: saved.end,
-      range: saved.range,
       sorts: fromSorts(saved.orderby),
       tags: [],
+      statsPeriod: saved.range,
+      environment: collectQueryStringByKey(
+        {
+          environment: (saved as SavedQuery).environment as string[],
+        },
+        'environment'
+      ),
     });
   }
 
   toNewQuery(): NewQuery {
-    const orderby = this.sorts ? encodeSorts(this.sorts)[0] : undefined;
-    return {
-      id: this.id,
+    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(),
+      fieldnames: this.getFieldNames(),
+      orderby,
+      // TODO: tags?
       query: this.query || '',
       projects: this.project,
       start: this.start,
       end: this.end,
-      range: this.range,
-      fields: this.fields.map(item => item.field),
-      fieldnames: this.fields.map(item => item.title),
-      orderby,
+      range: this.statsPeriod,
+      environment: this.environment,
     };
+
+    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;
   }
 
   generateQueryStringObject(): Query {
     const output = {
       id: this.id,
-      field: this.fields.map(item => item.field),
-      fieldnames: this.fields.map(item => item.title),
+      name: this.name,
+      field: this.getFields(),
+      fieldnames: this.getFieldNames(),
       sort: encodeSorts(this.sorts),
       tag: this.tags,
       query: this.query,
     };
-    const conditionalFields = ['name', 'project', 'start', 'end', 'range'];
-    for (const field of conditionalFields) {
+
+    for (const field of EXTERNAL_QUERY_STRING_KEYS) {
       if (this[field] && this[field].length) {
         output[field] = this[field];
       }
     }
 
-    return cloneDeep(output);
+    return cloneDeep(output as any);
   }
 
   isValid(): boolean {
     return this.fields.length > 0;
   }
 
-  getFieldTitles(): string[] {
+  getFieldNames(): string[] {
     return this.fields.map(field => {
       return field.title;
     });
   }
 
-  getFieldNames(): string[] {
+  getFields(): string[] {
     return this.fields.map(field => {
       return field.field;
     });
@@ -358,6 +492,234 @@ class EventView {
     return this.fields.length;
   }
 
+  getColumns(): TableColumn<React.ReactText>[] {
+    return decodeColumnOrder({
+      field: this.getFields(),
+      fieldnames: this.getFieldNames(),
+    });
+  }
+
+  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,
+      tags: this.tags,
+      query: this.query,
+      project: this.project,
+      start: this.start,
+      end: this.end,
+      statsPeriod: this.statsPeriod,
+      environment: this.environment,
+    });
+  }
+
+  withNewColumn(newColumn: {
+    aggregation: string;
+    field: string;
+    fieldname: string;
+  }): EventView {
+    const field = newColumn.field.trim();
+
+    const aggregation = newColumn.aggregation.trim();
+
+    const fieldAsString = generateFieldAsString({field, aggregation});
+
+    const name = newColumn.fieldname.trim();
+    const hasName = name.length > 0;
+
+    const newField: Field = {
+      field: fieldAsString,
+      title: hasName ? name : fieldAsString,
+    };
+
+    const newEventView = this.clone();
+
+    newEventView.fields = [...newEventView.fields, newField];
+
+    return newEventView;
+  }
+
+  withUpdatedColumn(
+    columnIndex: number,
+    updatedColumn: {
+      aggregation: string;
+      field: string;
+      fieldname: string;
+    },
+    tableDataMeta: MetaType
+  ): EventView {
+    const {field, aggregation, fieldname} = updatedColumn;
+
+    const columnToBeUpdated = this.fields[columnIndex];
+
+    const fieldAsString = generateFieldAsString({field, aggregation});
+
+    const updateField = columnToBeUpdated.field !== fieldAsString;
+    const updateFieldName = columnToBeUpdated.title !== fieldname;
+
+    if (!updateField && !updateFieldName) {
+      return this;
+    }
+
+    const newEventView = this.clone();
+
+    const updatedField: Field = {
+      field: fieldAsString,
+      title: fieldname,
+    };
+
+    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 => {
+      return isSortEqualToField(sort, columnToBeUpdated, tableDataMeta);
+    });
+
+    if (needleSortIndex >= 0) {
+      const needleSort = this.sorts[needleSortIndex];
+
+      const numOfColumns = this.fields.reduce((sum, currentField) => {
+        if (isSortEqualToField(needleSort, currentField, tableDataMeta)) {
+          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
+
+        if (isFieldSortable(updatedField, tableDataMeta)) {
+          // use the current updated field as the sort key
+          const sort = fieldToSort(updatedField, tableDataMeta)!;
+
+          // preserve the sort kind
+          sort.kind = needleSort.kind;
+
+          newEventView.sorts = [sort];
+        } else {
+          const sortableFieldIndex = newEventView.fields.findIndex(currentField => {
+            return isFieldSortable(currentField, tableDataMeta);
+          });
+          if (sortableFieldIndex >= 0) {
+            const fieldToBeSorted = newEventView.fields[sortableFieldIndex];
+            const sort = fieldToSort(fieldToBeSorted, tableDataMeta)!;
+            newEventView.sorts = [sort];
+          }
+        }
+      }
+    }
+
+    return newEventView;
+  }
+
+  withDeletedColumn(columnIndex: number, tableDataMeta: MetaType): 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;
+    }
+
+    // delete the column
+
+    const newEventView = this.clone();
+
+    const fields = [...newEventView.fields];
+    fields.splice(columnIndex, 1);
+    newEventView.fields = fields;
+
+    // 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 => {
+      return isSortEqualToField(sort, columnToBeDeleted, tableDataMeta);
+    });
+
+    if (needleSortIndex >= 0) {
+      const needleSort = this.sorts[needleSortIndex];
+
+      const numOfColumns = this.fields.reduce((sum, field) => {
+        if (isSortEqualToField(needleSort, field, tableDataMeta)) {
+          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 => {
+            return isFieldSortable(field, tableDataMeta);
+          });
+
+          if (sortableFieldIndex >= 0) {
+            const fieldToBeSorted = newEventView.fields[sortableFieldIndex];
+            const sort = fieldToSort(fieldToBeSorted, tableDataMeta)!;
+            newEventView.sorts = [sort];
+          }
+        }
+      }
+    }
+
+    return newEventView;
+  }
+
+  withMovedColumn({fromIndex, toIndex}: {fromIndex: number; toIndex: number}): EventView {
+    if (fromIndex === toIndex) {
+      return this;
+    }
+
+    const newEventView = this.clone();
+
+    const fields = [...newEventView.fields];
+
+    fields.splice(toIndex, 0, fields.splice(fromIndex, 1)[0]);
+
+    newEventView.fields = fields;
+
+    return newEventView;
+  }
+
+  getSorts(): TableColumnSort<React.ReactText>[] {
+    return this.sorts.map(sort => {
+      return {
+        key: sort.field,
+        order: sort.kind,
+      } as TableColumnSort<string>;
+    });
+  }
+
+  // returns query input for the search
   getQuery(inputQuery: string | string[] | null | undefined): string {
     const queryParts: string[] = [];
 
@@ -384,39 +746,38 @@ class EventView {
     return queryParts.join(' ');
   }
 
+  getTagsAPIPayload(
+    location: Location
+  ): Exclude<EventQuery & LocationQuery, 'sort' | 'cursor'> {
+    const payload = this.getEventsAPIPayload(location);
+
+    if (payload.sort) {
+      delete payload.sort;
+    }
+
+    if (payload.cursor) {
+      delete payload.cursor;
+    }
+
+    return payload;
+  }
+
   // Takes an EventView instance and converts it into the format required for the events API
-  getEventsAPIPayload(location: Location): EventQuery {
-    const query = location.query || {};
-
-    type LocationQuery = {
-      project?: string;
-      environment?: string;
-      start?: string;
-      end?: string;
-      utc?: string;
-      statsPeriod?: string;
-      cursor?: string;
-      sort?: string;
-    };
+  getEventsAPIPayload(location: Location): EventQuery & LocationQuery {
+    const query = (location && location.query) || {};
+
+    // pick only the query strings that we care about
 
-    const picked = pick<LocationQuery>(query || {}, [
-      'project',
-      'environment',
-      'start',
-      'end',
-      'utc',
-      'statsPeriod',
-      'cursor',
-      'sort',
-    ]);
+    const picked = pickRelevantLocationQueryStrings(location);
 
-    const fieldNames = this.getFieldNames();
+    const sort = this.sorts.length > 0 ? encodeSort(this.sorts[0]) : undefined;
+    const fields = this.getFields();
 
-    const defaultSort = fieldNames.length > 0 ? [fieldNames[0]] : undefined;
+    // generate event query
 
-    const eventQuery: EventQuery = Object.assign(picked, {
-      field: [...new Set(fieldNames)],
-      sort: picked.sort ? picked.sort : defaultSort,
+    const eventQuery: EventQuery & LocationQuery = Object.assign(picked, {
+      field: [...new Set(fields)],
+      sort,
       per_page: DEFAULT_PER_PAGE,
       query: this.getQuery(query.query),
     });
@@ -428,28 +789,84 @@ class EventView {
     return eventQuery;
   }
 
-  getDefaultSort(): string | undefined {
-    if (this.sorts.length <= 0) {
-      return undefined;
-    }
+  isFieldSorted(field: Field, tableDataMeta: MetaType): Sort | undefined {
+    const needle = this.sorts.find(sort => {
+      return isSortEqualToField(sort, field, tableDataMeta);
+    });
 
-    return encodeSort(this.sorts[0]);
+    return needle;
   }
 
-  getSortKey(fieldname: string, meta: MetaType): string | null {
-    const column = getAggregateAlias(fieldname);
-    if (SPECIAL_FIELDS.hasOwnProperty(column)) {
-      return SPECIAL_FIELDS[column as keyof typeof SPECIAL_FIELDS].sortField;
+  sortOnField(field: Field, tableDataMeta: MetaType): EventView {
+    // check if field can be sorted
+    if (!isFieldSortable(field, tableDataMeta)) {
+      return this;
     }
 
-    if (FIELD_FORMATTERS.hasOwnProperty(meta[column])) {
-      return FIELD_FORMATTERS[meta[column] as keyof typeof FIELD_FORMATTERS].sortField
-        ? column
-        : null;
+    const needleIndex = this.sorts.findIndex(sort => {
+      return isSortEqualToField(sort, field, tableDataMeta);
+    });
+
+    if (needleIndex >= 0) {
+      const newEventView = this.clone();
+
+      const currentSort = this.sorts[needleIndex];
+
+      const sorts = [...newEventView.sorts];
+      sorts[needleIndex] = reverseSort(currentSort);
+
+      newEventView.sorts = sorts;
+
+      return newEventView;
     }
 
-    return null;
+    // 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, tableDataMeta)!;
+
+    newEventView.sorts = [sort];
+
+    return newEventView;
+  }
+}
+
+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 currentTarget = Array.isArray(currentValue)
+      ? new Set(currentValue)
+      : currentValue;
+
+    const otherValue = other[key];
+    const otherTarget = Array.isArray(otherValue) ? new Set(otherValue) : otherValue;
+
+    if (!isEqual(currentTarget, otherTarget)) {
+      return false;
+    }
+  }
+
+  return true;
+};
+
+export function pickRelevantLocationQueryStrings(location: Location): LocationQuery {
+  const query = location.query || {};
+
+  const picked = pick<LocationQuery>(query || {}, EXTERNAL_QUERY_STRING_KEYS);
+
+  return picked;
 }
 
 export default EventView;

+ 2 - 2
src/sentry/static/sentry/app/views/eventsV2/modalLineGraph.tsx

@@ -185,7 +185,7 @@ const ModalLineGraph = (props: ModalLineGraphProps) => {
         interval={interval}
         showLoading
         query={queryString}
-        field={eventView.getFieldNames()}
+        field={eventView.getFields()}
         referenceEvent={referenceEvent}
         includePrevious={false}
       >
@@ -199,7 +199,7 @@ const ModalLineGraph = (props: ModalLineGraphProps) => {
             }}
             onClick={series =>
               handleClick(series, {
-                field: eventView.getFieldNames(),
+                field: eventView.getFields(),
                 api,
                 organization,
                 currentEvent,

+ 36 - 31
src/sentry/static/sentry/app/views/eventsV2/sortLink.tsx

@@ -7,59 +7,54 @@ import {omit} from 'lodash';
 import InlineSvg from 'app/components/inlineSvg';
 import Link from 'app/components/links/link';
 
+import EventView, {Field, Sort, isFieldSortable} from './eventView';
+import {MetaType} from './utils';
+
 type Alignments = 'left' | 'right' | undefined;
 
 type Props = {
-  title: string;
-  sortKey: string;
-  defaultSort: string;
-  location: Location;
   align: Alignments;
+  field: Field;
+  location: Location;
+  eventView: EventView;
+  tableDataMeta: MetaType;
 };
 
 class SortLink extends React.Component<Props> {
   static propTypes = {
     align: PropTypes.string,
-    title: PropTypes.string.isRequired,
-    sortKey: PropTypes.string.isRequired,
-    defaultSort: PropTypes.string.isRequired,
+    field: PropTypes.object.isRequired,
     location: PropTypes.object.isRequired,
+    eventView: PropTypes.object.isRequired,
+    tableDataMeta: PropTypes.object.isRequired,
   };
 
-  getCurrentSort(): string {
-    const {defaultSort, location} = this.props;
-    return typeof location.query.sort === 'string' ? location.query.sort : defaultSort;
-  }
-
-  getSort() {
-    const {sortKey} = this.props;
-    const currentSort = this.getCurrentSort();
-
-    // Page is currently unsorted or is ascending
-    if (currentSort === `-${sortKey}`) {
-      return sortKey;
-    }
+  isCurrentColumnSorted(): Sort | undefined {
+    const {eventView, field, tableDataMeta} = this.props;
 
-    // Reverse direction
-    return `-${sortKey}`;
+    return eventView.isFieldSorted(field, tableDataMeta);
   }
 
   getTarget() {
-    const {location} = this.props;
+    const {location, field, eventView, tableDataMeta} = this.props;
+
+    const nextEventView = eventView.sortOnField(field, tableDataMeta);
+    const queryStringObject = nextEventView.generateQueryStringObject();
+
     return {
-      pathname: location.pathname,
-      query: {...location.query, sort: this.getSort()},
+      ...location,
+      query: queryStringObject,
     };
   }
 
   renderChevron() {
-    const currentSort = this.getCurrentSort();
-    const {sortKey} = this.props;
-    if (!currentSort || currentSort.indexOf(sortKey) === -1) {
+    const currentSort = this.isCurrentColumnSorted();
+
+    if (!currentSort) {
       return null;
     }
 
-    if (currentSort[0] === '-') {
+    if (currentSort.kind === 'desc') {
       return <InlineSvg src="icon-chevron-down" />;
     }
 
@@ -67,10 +62,15 @@ class SortLink extends React.Component<Props> {
   }
 
   render() {
-    const {align, title} = this.props;
+    const {align, field, tableDataMeta} = this.props;
+
+    if (!isFieldSortable(field, tableDataMeta)) {
+      return <StyledNonLink align={align}>{field.title}</StyledNonLink>;
+    }
+
     return (
       <StyledLink align={align} to={this.getTarget()}>
-        {title} {this.renderChevron()}
+        {field.title} {this.renderChevron()}
       </StyledLink>
     );
   }
@@ -87,4 +87,9 @@ const StyledLink = styled((props: StyledLinkProps) => {
   ${(p: StyledLinkProps) => (p.align ? `text-align: ${p.align};` : '')}
 `;
 
+const StyledNonLink = styled('div')<{align: Alignments}>`
+  white-space: nowrap;
+  ${(p: {align: Alignments}) => (p.align ? `text-align: ${p.align};` : '')}
+`;
+
 export default SortLink;

+ 11 - 13
src/sentry/static/sentry/app/views/eventsV2/table/index.tsx

@@ -10,7 +10,7 @@ import withApi from 'app/utils/withApi';
 import Pagination from 'app/components/pagination';
 
 import {DEFAULT_EVENT_VIEW_V1} from '../data';
-import EventView from '../eventView';
+import EventView, {isAPIPayloadSimilar} from '../eventView';
 import TableView from './tableView';
 import {TableData} from './types';
 
@@ -63,10 +63,7 @@ class Table extends React.PureComponent<TableProps, TableState> {
 
       browserHistory.replace({
         pathname: location.pathname,
-        query: {
-          ...location.query,
-          ...nextEventView.generateQueryStringObject(),
-        },
+        query: nextEventView.generateQueryStringObject(),
       });
       return;
     }
@@ -74,18 +71,19 @@ class Table extends React.PureComponent<TableProps, TableState> {
     this.fetchData();
   }
 
-  componentDidUpdate(prevProps) {
-    if (
-      this.props.location !== prevProps.location ||
-      this.props.location.query !== prevProps.location.query ||
-      this.props.location.query.fieldnames !== prevProps.location.query.fieldnames ||
-      this.props.location.query.field !== prevProps.location.query.field ||
-      this.props.location.query.sort !== prevProps.location.query.sort
-    ) {
+  componentDidUpdate(prevProps: TableProps, prevState: TableState) {
+    if (!this.state.isLoading && this.shouldRefetchData(prevProps, prevState)) {
       this.fetchData();
     }
   }
 
+  shouldRefetchData = (prevProps: TableProps, prevState: TableState): boolean => {
+    const thisAPIPayload = this.state.eventView.getEventsAPIPayload(this.props.location);
+    const otherAPIPayload = prevState.eventView.getEventsAPIPayload(prevProps.location);
+
+    return !isAPIPayloadSimilar(thisAPIPayload, otherAPIPayload);
+  };
+
   fetchData = () => {
     const {organization, location} = this.props;
     const url = `/organizations/${organization.slug}/eventsv2/`;

+ 94 - 111
src/sentry/static/sentry/app/views/eventsV2/table/tableView.tsx

@@ -5,16 +5,12 @@ import {Organization} from 'app/types';
 
 import GridEditable from 'app/components/gridEditable';
 
-import {
-  decodeColumnOrder,
-  decodeColumnSortBy,
-  getFieldRenderer,
-  setColumnStateOnLocation,
-} from '../utils';
-import EventView from '../eventView';
+import {getFieldRenderer, getAggregateAlias, pushEventViewToLocation} from '../utils';
+import EventView, {pickRelevantLocationQueryStrings} from '../eventView';
 import SortLink from '../sortLink';
 import renderTableModalEditColumnFactory from './tableModalEditColumn';
-import {TableColumn, TableState, TableData, TableDataRow} from './types';
+import {TableColumn, TableData, TableDataRow} from './types';
+import {ColumnValueType} from '../eventQueryParams';
 
 export type TableViewProps = {
   location: Location;
@@ -28,147 +24,131 @@ export type TableViewProps = {
 };
 
 /**
- * `TableView` is currently in turmoil as it is containing 2 implementations
- * of the Discover V2 QueryBuilder.
  *
- * The old `TableView` is split away from `table.tsx` file as it was too long
- * and its methods have not been changed. It reads its state from `EventView`,
- * which is shared across several component.
- *
- * The new `TableView` is marked with leading _ in its method names. It
- * is coupled to the `Location` object and derives its state entirely from it.
- * It implements methods to mutate the column state in `Location.query`.
+ * The `TableView` is marked with leading _ in its method names. It consumes
+ * the EventView object given in its props to generate new EventView objects
+ * for actions such as creating new columns, updating columns, sorting columns,
+ * and re-ordering columns.
  */
-class TableView extends React.Component<TableViewProps, TableState> {
-  constructor(props) {
-    super(props);
-
-    this.setState = () => {
-      throw new Error(
-        'TableView: Please do not directly mutate the state of TableView. Please read the comments on TableView.createColumn for more info.'
-      );
-    };
-  }
-
-  state = {
-    columnOrder: [],
-    columnSortBy: [],
-  } as TableState;
-
-  static getDerivedStateFromProps(props: TableViewProps): TableState {
-    // Avoid using props.location to get derived state.
-    const {eventView} = props;
-
-    return {
-      columnOrder: decodeColumnOrder({
-        field: eventView.getFieldNames(),
-        fieldnames: eventView.getFieldTitles(),
-      }),
-      columnSortBy: decodeColumnSortBy({
-        sort: eventView.getDefaultSort(),
-      }),
-    };
-  }
-
+class TableView extends React.Component<TableViewProps> {
+  // TODO: update this docs
   /**
-   * The "truth" on the state of the columns is found in `Location`,
-   * `createColumn`, `updateColumn`, `deleteColumn` and `moveColumn`.
-   * Syncing the state between `Location` and `TableView` may cause weird
-   * side-effects, as such the local state is not allowed to be mutated.
+   * The entire state of the table view (or event view) is co-located within
+   * the EventView object. This object is fed from the props.
+   *
+   * Attempting to modify the state, and therefore, modifying the given EventView
+   * object given from its props, will generate new instances of EventView objects.
    *
-   * State change should be done through  `setColumnStateOnLocation` which will
-   * update the `Location` object and changes are propagated downwards to child
-   * components
+   * In most cases, the new EventView object differs from the previous EventView
+   * object. The new EventView object is pushed to the location object.
    */
   _createColumn = (nextColumn: TableColumn<keyof TableDataRow>) => {
-    const {location} = this.props;
-    const {columnOrder, columnSortBy} = this.state;
-    const nextColumnOrder = [...columnOrder, nextColumn];
-    const nextColumnSortBy = [...columnSortBy];
+    const {location, eventView} = this.props;
+
+    const nextEventView = eventView.withNewColumn({
+      aggregation: String(nextColumn.aggregation),
+      field: String(nextColumn.field),
+      fieldname: nextColumn.name,
+    });
 
-    setColumnStateOnLocation(location, nextColumnOrder, nextColumnSortBy);
+    pushEventViewToLocation({
+      location,
+      nextEventView,
+      extraQuery: pickRelevantLocationQueryStrings(location),
+    });
   };
 
   /**
-   * Please read the comment on `createColumn`
+   * Please read the comment on `_createColumn`
    */
-  _updateColumn = (i: number, nextColumn: TableColumn<keyof TableDataRow>) => {
-    const {location} = this.props;
-    const {columnOrder, columnSortBy} = this.state;
-
-    if (columnOrder[i].key !== nextColumn.key) {
-      throw new Error(
-        'TableView.updateColumn: nextColumn does not have the same key as prevColumn'
-      );
+  _updateColumn = (columnIndex: number, nextColumn: TableColumn<keyof TableDataRow>) => {
+    const {location, eventView, tableData} = this.props;
+
+    if (!tableData) {
+      return;
     }
 
-    const nextColumnOrder = [...columnOrder];
-    const nextColumnSortBy = [...columnSortBy];
-    nextColumnOrder[i] = nextColumn;
+    const nextEventView = eventView.withUpdatedColumn(
+      columnIndex,
+      {
+        aggregation: String(nextColumn.aggregation),
+        field: String(nextColumn.field),
+        fieldname: nextColumn.name,
+      },
+      tableData.meta
+    );
 
-    setColumnStateOnLocation(location, nextColumnOrder, nextColumnSortBy);
+    pushEventViewToLocation({
+      location,
+      nextEventView,
+      extraQuery: pickRelevantLocationQueryStrings(location),
+    });
   };
 
   /**
-   * Please read the comment on `createColumn`
+   * Please read the comment on `_createColumn`
    */
-  _deleteColumn = (i: number) => {
-    const {location} = this.props;
-    const {columnOrder, columnSortBy} = this.state;
-    const nextColumnOrder = [...columnOrder];
-    const nextColumnSortBy = [...columnSortBy];
-
-    // Disallow delete of last column and check for out-of-bounds
-    if (columnOrder.length === 1 || nextColumnOrder.length <= i) {
+  _deleteColumn = (columnIndex: number) => {
+    const {location, eventView, tableData} = this.props;
+
+    if (!tableData) {
       return;
     }
 
-    // Remove column from columnOrder
-    const deletedColumn = nextColumnOrder.splice(i, 1)[0];
-
-    // Remove column from columnSortBy (if it is there)
-    // EventView will throw an error if sorting by a column that isn't displayed
-    const j = nextColumnSortBy.findIndex(c => c.key === deletedColumn.key);
-    if (j >= 0) {
-      nextColumnSortBy.splice(j, 1);
-    }
+    const nextEventView = eventView.withDeletedColumn(columnIndex, tableData.meta);
 
-    setColumnStateOnLocation(location, nextColumnOrder, nextColumnSortBy);
+    pushEventViewToLocation({
+      location,
+      nextEventView,
+      extraQuery: pickRelevantLocationQueryStrings(location),
+    });
   };
 
   /**
-   * Please read the comment on `createColumn`
+   * Please read the comment on `_createColumn`
    */
   _moveColumn = (fromIndex: number, toIndex: number) => {
-    const {location} = this.props;
-    const {columnOrder, columnSortBy} = this.state;
+    const {location, eventView} = this.props;
 
-    const nextColumnOrder = [...columnOrder];
-    const nextColumnSortBy = [...columnSortBy];
-    nextColumnOrder.splice(toIndex, 0, nextColumnOrder.splice(fromIndex, 1)[0]);
+    const nextEventView = eventView.withMovedColumn({fromIndex, toIndex});
 
-    setColumnStateOnLocation(location, nextColumnOrder, nextColumnSortBy);
+    pushEventViewToLocation({
+      location,
+      nextEventView,
+      extraQuery: pickRelevantLocationQueryStrings(location),
+    });
   };
 
-  _renderGridHeaderCell = (column: TableColumn<keyof TableDataRow>): React.ReactNode => {
+  _renderGridHeaderCell = (
+    column: TableColumn<keyof TableDataRow>,
+    columnIndex: number
+  ): React.ReactNode => {
     const {eventView, location, tableData} = this.props;
     if (!tableData) {
       return column.name;
     }
 
-    // TODO(leedongwei): Deprecate eventView and use state.columnSortBy
-    const defaultSort = eventView.getDefaultSort() || eventView.fields[0].field;
-    const align = ['integer', 'number', 'duration'].includes(column.type)
-      ? 'right'
-      : 'left';
+    const field = eventView.fields[columnIndex];
+
+    const alignedTypes: ColumnValueType[] = ['number', 'duration'];
+    let align: 'right' | 'left' = alignedTypes.includes(column.type) ? 'right' : 'left';
+
+    // TODO(alberto): clean this
+    if (column.type === 'never' || column.type === '*') {
+      const maybeType = tableData.meta[getAggregateAlias(field.field)];
+
+      if (maybeType === 'integer' || maybeType === 'number') {
+        align = 'right';
+      }
+    }
 
     return (
       <SortLink
         align={align}
-        defaultSort={defaultSort}
-        sortKey={`${column.key}`}
-        title={column.name}
+        field={field}
         location={location}
+        eventView={eventView}
+        tableDataMeta={tableData.meta}
       />
     );
   };
@@ -183,15 +163,18 @@ class TableView extends React.Component<TableViewProps, TableState> {
     }
     const hasLinkField = eventView.hasAutolinkField();
     const forceLink =
-      !hasLinkField && eventView.getFieldNames().indexOf(column.field) === 0;
+      !hasLinkField && eventView.getFields().indexOf(String(column.field)) === 0;
 
     const fieldRenderer = getFieldRenderer(String(column.key), tableData.meta, forceLink);
     return fieldRenderer(dataRow, {organization, location});
   };
 
   render() {
-    const {organization, isLoading, error, tableData} = this.props;
-    const {columnOrder, columnSortBy} = this.state;
+    const {organization, isLoading, error, tableData, eventView} = this.props;
+
+    const columnOrder = eventView.getColumns();
+    const columnSortBy = eventView.getSorts();
+
     const {
       renderModalBodyWithForm,
       renderModalFooter,

+ 15 - 10
src/sentry/static/sentry/app/views/eventsV2/tags.tsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import styled from 'react-emotion';
-import {isEqual, omit} from 'lodash';
+import {isEqual} from 'lodash';
 import {Location} from 'history';
 import * as Sentry from '@sentry/browser';
 
@@ -19,8 +19,7 @@ import {
   Tag,
   TagTopValue,
 } from './utils';
-import {MODAL_QUERY_KEYS} from './data';
-import EventView from './eventView';
+import EventView, {isAPIPayloadSimilar} from './eventView';
 
 type Props = {
   api: Client;
@@ -51,18 +50,24 @@ class Tags extends React.Component<Props, State> {
     this.fetchData();
   }
 
-  componentDidUpdate(prevProps) {
-    // Do not update if we are just opening/closing the modal
-    const locationHasChanged = !isEqual(
-      omit(prevProps.location.query, MODAL_QUERY_KEYS),
-      omit(this.props.location.query, MODAL_QUERY_KEYS)
+  componentDidUpdate(prevProps: Props) {
+    const tagsChanged = !isEqual(
+      new Set(this.props.eventView.tags),
+      new Set(prevProps.eventView.tags)
     );
 
-    if (locationHasChanged) {
+    if (tagsChanged || this.shouldRefetchData(prevProps)) {
       this.fetchData();
     }
   }
 
+  shouldRefetchData = (prevProps: Props): boolean => {
+    const thisAPIPayload = this.props.eventView.getTagsAPIPayload(this.props.location);
+    const otherAPIPayload = prevProps.eventView.getTagsAPIPayload(prevProps.location);
+
+    return !isAPIPayloadSimilar(thisAPIPayload, otherAPIPayload);
+  };
+
   fetchData = async () => {
     const {api, organization, eventView, location} = this.props;
 
@@ -74,7 +79,7 @@ class Tags extends React.Component<Props, State> {
           api,
           organization.slug,
           tag,
-          eventView.getEventsAPIPayload(location)
+          eventView.getTagsAPIPayload(location)
         );
 
         this.setState(state => ({tags: {...state.tags, [tag]: val}}));

+ 20 - 95
src/sentry/static/sentry/app/views/eventsV2/utils.tsx

@@ -22,7 +22,7 @@ import {
   FIELDS,
   ColumnValueType,
 } from './eventQueryParams';
-import {TableColumn, TableColumnSort, TableState} from './table/types';
+import {TableColumn} from './table/types';
 
 export type EventQuery = {
   field: Array<string>;
@@ -44,7 +44,7 @@ const ROUND_BRACKETS_PATTERN = /[\(\)]/;
  */
 export function hasAggregateField(eventView: EventView): boolean {
   return eventView
-    .getFieldNames()
+    .getFields()
     .some(
       field => AGGREGATE_ALIASES.includes(field as any) || field.match(AGGREGATE_PATTERN)
     );
@@ -234,36 +234,15 @@ const TEMPLATE_TABLE_COLUMN: TableColumn<React.ReactText> = {
   isPrimary: false,
 };
 
-export function decodeColumnOrderAndColumnSortBy(location: Location): TableState {
-  const {query} = location;
-  return {
-    columnOrder: query ? decodeColumnOrder(query) : [],
-    columnSortBy: query ? decodeColumnSortBy(query) : [],
-  };
-}
+export function decodeColumnOrder(props: {
+  fieldnames: string[];
+  field: string[];
+}): TableColumn<React.ReactText>[] {
+  const {fieldnames, field} = props;
 
-export function decodeColumnOrder(
-  query: QueryWithColumnState
-): TableColumn<React.ReactText>[] {
-  const {fieldnames, field} = query;
-  const columnsRaw: {
-    aggregationField: string;
-    name: string;
-  }[] = [];
-
-  if (typeof fieldnames === 'string' && typeof field === 'string') {
-    columnsRaw.push({aggregationField: field, name: fieldnames});
-  } else if (
-    Array.isArray(fieldnames) &&
-    Array.isArray(field) &&
-    fieldnames.length === field.length
-  ) {
-    field.forEach((f, i) => {
-      columnsRaw.push({aggregationField: f, name: fieldnames[i]});
-    });
-  }
+  return field.map((f: string, index: number) => {
+    const col = {aggregationField: f, name: fieldnames[index]};
 
-  return columnsRaw.map(col => {
     const column: TableColumn<React.ReactText> = {...TEMPLATE_TABLE_COLUMN};
 
     // "field" will be split into ["field"]
@@ -282,6 +261,7 @@ export function decodeColumnOrder(
     column.key = col.aggregationField;
     column.name = col.name;
     column.type = (FIELDS[column.field] || 'never') as ColumnValueType;
+
     column.isSortable = AGGREGATIONS[column.aggregation]
       ? AGGREGATIONS[column.aggregation].isSortable
       : false;
@@ -291,77 +271,22 @@ export function decodeColumnOrder(
   });
 }
 
-export function decodeColumnSortBy(
-  query: QueryWithColumnState
-): TableColumnSort<React.ReactText>[] {
-  const {sort} = query;
-
-  // Linter forced the ternary into a single line ¯\_(ツ)_/¯
-  const keys: string[] =
-    typeof sort === 'string' ? [sort] : Array.isArray(sort) ? sort : [];
+export function pushEventViewToLocation(props: {
+  location: Location;
+  nextEventView: EventView;
+  extraQuery?: Query;
+}) {
+  const {location, nextEventView} = props;
 
-  return keys.map(key => {
-    const hasLeadingDash = key[0] === '-';
+  const extraQuery = props.extraQuery || {};
 
-    return {
-      key: hasLeadingDash ? key.substring(1) : key,
-      order: hasLeadingDash ? 'desc' : 'asc',
-    } as TableColumnSort<string>;
-  });
-}
-
-export function encodeColumnOrderAndColumnSortBy(
-  tableState: TableState
-): QueryWithColumnState {
-  return {
-    fieldnames: encodeColumnFieldName(tableState),
-    field: encodeColumnField(tableState),
-    sort: encodeColumnSort(tableState),
-  };
-}
-
-function encodeColumnFieldName(tableState: TableState): string[] {
-  return tableState.columnOrder.map(col => col.name);
-}
-
-function encodeColumnField(tableState: TableState): string[] {
-  return tableState.columnOrder.map(col =>
-    col.aggregation ? `${col.aggregation}(${col.field})` : col.field
-  );
-}
-
-function encodeColumnSort(tableState: TableState): string[] {
-  return tableState.columnSortBy.map(col =>
-    col.order === 'desc' ? `-${col.key}` : `${col.key}`
-  );
-}
-
-/**
- * The state of the columns is derived from `Location.query`. There are other
- * components mutating the state of the column (sidebar, etc) too.
- *
- * To make add/edit/remove tableColumns, we will update `Location.query` and
- * the changes will be propagated downwards to all the other components.
- */
-export function setColumnStateOnLocation(
-  location: Location,
-  nextColumnOrder: TableColumn<React.ReactText>[],
-  nextColumnSortBy: TableColumnSort<React.ReactText>[]
-) {
-  // Remove a column from columnSortBy if it is not in columnOrder
-  // EventView will throw an error if sorting by a column that isn't queried
-  nextColumnSortBy = nextColumnSortBy.filter(
-    sortBy => nextColumnOrder.findIndex(order => order.key === sortBy.key) > -1
-  );
+  const queryStringObject = nextEventView.generateQueryStringObject();
 
   browserHistory.push({
     ...location,
     query: {
-      ...location.query,
-      ...encodeColumnOrderAndColumnSortBy({
-        columnOrder: nextColumnOrder,
-        columnSortBy: nextColumnSortBy,
-      }),
+      ...extraQuery,
+      ...queryStringObject,
     },
   });
 }

Some files were not shown because too many files changed in this diff