Browse Source

feat(arithmetic): Allow adding equations to discover (#26413)

- This adds the ability to include arithmetic on fields (not functions
  yet) to discover.
  - Is currently behind a feature flag, orderby&cell-actions don't work
    yet
- In the url params equations are still fields just with a `equation|`
  prefix so that we can tell them apart
  - This is so that we can maintain equation order with other fields,
    and so that we can apply widths to them just like fields
  - `equation|` was chosen because it describes the field + cause `|` is
    an invalid field character so there's no overlap
William Mak 3 years ago
parent
commit
24e3d8b427

+ 1 - 1
static/app/components/createAlertButton.tsx

@@ -208,7 +208,7 @@ type CreateAlertFromViewButtonProps = React.ComponentProps<typeof Button> & {
 
 function incompatibleYAxis(eventView: EventView): boolean {
   const column = explodeFieldString(eventView.getYAxis());
-  if (column.kind === 'field') {
+  if (column.kind === 'field' || column.kind === 'equation') {
     return true;
   }
 

+ 1 - 0
static/app/components/dashboards/widgetQueriesForm.tsx

@@ -151,6 +151,7 @@ class WidgetQueriesForm extends React.Component<Props> {
           fieldOptions={fieldOptions}
           errors={this.getFirstQueryError('fields')}
           fields={queries[0].fields}
+          organization={organization}
           onChange={fields => {
             queries.forEach((widgetQuery, queryIndex) => {
               const newQuery = cloneDeep(widgetQuery);

+ 4 - 0
static/app/components/dashboards/widgetQueryFields.tsx

@@ -5,6 +5,7 @@ import Button from 'app/components/button';
 import {IconAdd, IconDelete} from 'app/icons';
 import {t} from 'app/locale';
 import space from 'app/styles/space';
+import {Organization} from 'app/types';
 import {
   aggregateFunctionOutputType,
   explodeField,
@@ -36,6 +37,7 @@ type Props = {
   /**
    * Any errors that need to be rendered.
    */
+  organization: Organization;
   errors?: Record<string, any>;
   style?: React.CSSProperties;
 };
@@ -45,6 +47,7 @@ function WidgetQueryFields({
   errors,
   fields,
   fieldOptions,
+  organization,
   onChange,
   style,
 }: Props) {
@@ -91,6 +94,7 @@ function WidgetQueryFields({
           columns={fields.map(field => explodeField({field}))}
           onChange={handleColumnChange}
           fieldOptions={fieldOptions}
+          organization={organization}
         />
       </Field>
     );

+ 27 - 4
static/app/utils/discover/eventView.tsx

@@ -15,7 +15,11 @@ import {URL_PARAM} from 'app/constants/globalSelectionHeader';
 import {t} from 'app/locale';
 import {GlobalSelection, NewQuery, SavedQuery, SelectValue, User} from 'app/types';
 import {decodeList, decodeScalar} from 'app/utils/queryString';
-import {TableColumn, TableColumnSort} from 'app/views/eventsV2/table/types';
+import {
+  FieldValueKind,
+  TableColumn,
+  TableColumnSort,
+} from 'app/views/eventsV2/table/types';
 import {decodeColumnOrder} from 'app/views/eventsV2/utils';
 
 import {statsPeriodToDays} from '../dates';
@@ -29,7 +33,9 @@ import {
   Field,
   generateFieldAsString,
   getAggregateAlias,
+  getEquation,
   isAggregateField,
+  isEquation,
   isLegalYAxisType,
   Sort,
 } from './fields';
@@ -628,6 +634,12 @@ class EventView {
     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));
   }
@@ -692,7 +704,7 @@ class EventView {
     const fields: Field[] = columns
       .filter(
         col =>
-          (col.kind === 'field' && col.field) ||
+          ((col.kind === 'field' || col.kind === FieldValueKind.EQUATION) && col.field) ||
           (col.kind === 'function' && col.function[0])
       )
       .map(col => generateFieldAsString(col))
@@ -973,7 +985,16 @@ class EventView {
   ): Exclude<EventQuery & LocationQuery, 'sort' | 'cursor'> {
     const payload = this.getEventsAPIPayload(location);
 
-    const remove = ['id', 'name', 'per_page', 'sort', 'cursor', 'field', 'interval'];
+    const remove = [
+      'id',
+      'name',
+      'per_page',
+      'sort',
+      'cursor',
+      'field',
+      'equation',
+      'interval',
+    ];
     for (const key of remove) {
       delete payload[key];
     }
@@ -1024,7 +1045,8 @@ class EventView {
         : this.sorts.length > 1
         ? encodeSorts(this.sorts)
         : encodeSort(this.sorts[0]);
-    const fields = this.getFields();
+    const fields = this.getFields().filter(field => !isEquation(field));
+    const equations = this.getEquations();
     const team = this.team.map(proj => String(proj));
     const project = this.project.map(proj => String(proj));
     const environment = this.environment as string[];
@@ -1038,6 +1060,7 @@ class EventView {
         project,
         environment,
         field: [...new Set(fields)],
+        equation: [...new Set(equations)],
         sort,
         per_page: DEFAULT_PER_PAGE,
         query: this.getQueryWithAdditionalConditions(),

+ 20 - 0
static/app/utils/discover/fields.tsx

@@ -53,6 +53,10 @@ export type QueryFieldValue =
       kind: 'field';
       field: string;
     }
+  | {
+      kind: 'equation';
+      field: string;
+    }
   | {
       kind: 'function';
       function: [AggregationKey, string, AggregationRefinement];
@@ -609,6 +613,17 @@ export function getAggregateArg(field: string): string | null {
   return null;
 }
 
+// `|` is an invalid field character, so it is used to determine whether a field is an equation or not
+const EQUATION_PREFIX = 'equation|';
+
+export function isEquation(field: string): boolean {
+  return field.startsWith(EQUATION_PREFIX);
+}
+
+export function getEquation(field: string): string {
+  return field.slice(EQUATION_PREFIX.length);
+}
+
 export function generateAggregateFields(
   organization: LightWeightOrganization,
   eventFields: readonly Field[] | Field[],
@@ -653,6 +668,9 @@ export function explodeFieldString(field: string): Column {
       ],
     };
   }
+  if (isEquation(field)) {
+    return {kind: 'equation', field: getEquation(field)};
+  }
 
   return {kind: 'field', field};
 }
@@ -660,6 +678,8 @@ export function explodeFieldString(field: string): Column {
 export function generateFieldAsString(value: QueryFieldValue): string {
   if (value.kind === 'field') {
     return value.field;
+  } else if (value.kind === 'equation') {
+    return `${EQUATION_PREFIX}${value.field}`;
   }
 
   const aggregation = value.function[0];

+ 1 - 0
static/app/views/dashboardsV2/widget/eventWidget/index.tsx

@@ -290,6 +290,7 @@ class EventWidget extends AsyncView<Props, State> {
                     displayType={displayType}
                     fieldOptions={amendedFieldOptions}
                     fields={queries[0].fields}
+                    organization={organization}
                     onChange={fields => {
                       queries.forEach((query, queryIndex) => {
                         const clonedQuery = cloneDeep(query);

+ 26 - 1
static/app/views/eventsV2/table/columnEditCollection.tsx

@@ -2,11 +2,13 @@ import * as React from 'react';
 import ReactDOM from 'react-dom';
 import styled from '@emotion/styled';
 
+import Feature from 'app/components/acl/feature';
 import Button from 'app/components/button';
 import {SectionHeading} from 'app/components/charts/styles';
 import {IconAdd, IconDelete, IconGrabbable} from 'app/icons';
 import {t} from 'app/locale';
 import space from 'app/styles/space';
+import {LightWeightOrganization} from 'app/types';
 import {Column} from 'app/utils/discover/fields';
 import theme from 'app/utils/theme';
 import {getPointerPosition} from 'app/utils/touch';
@@ -15,6 +17,7 @@ import {setBodyUserSelect, UserSelectValues} from 'app/utils/userselect';
 import {generateFieldOptions} from '../utils';
 
 import {QueryField} from './queryField';
+import {FieldValueKind} from './types';
 
 type Props = {
   // Input columns
@@ -22,6 +25,7 @@ type Props = {
   fieldOptions: ReturnType<typeof generateFieldOptions>;
   // Fired when columns are added/removed/modified
   onChange: (columns: Column[]) => void;
+  organization: LightWeightOrganization;
   className?: string;
 };
 
@@ -101,6 +105,11 @@ class ColumnEditCollection extends React.Component<Props, State> {
     this.props.onChange([...this.props.columns, newColumn]);
   };
 
+  handleAddEquation = () => {
+    const newColumn: Column = {kind: FieldValueKind.EQUATION, field: ''};
+    this.props.onChange([...this.props.columns, newColumn]);
+  };
+
   handleUpdateColumn = (index: number, column: Column) => {
     const newColumns = [...this.props.columns];
     newColumns.splice(index, 1, column);
@@ -339,7 +348,7 @@ class ColumnEditCollection extends React.Component<Props, State> {
   }
 
   render() {
-    const {className, columns} = this.props;
+    const {className, columns, organization} = this.props;
     const canDelete = columns.length > 1;
     const canAdd = columns.length < MAX_COL_COUNT;
     const title = canAdd
@@ -378,6 +387,18 @@ class ColumnEditCollection extends React.Component<Props, State> {
             >
               {t('Add a Column')}
             </Button>
+            <Feature organization={organization} features={['discover-arithmetic']}>
+              <Button
+                size="small"
+                label={t('Add an Equation')}
+                onClick={this.handleAddEquation}
+                title={title}
+                disabled={!canAdd}
+                icon={<IconAdd isCircled size="xs" />}
+              >
+                {t('Add an Equation')}
+              </Button>
+            </Feature>
           </Actions>
         </RowContainer>
       </div>
@@ -425,6 +446,10 @@ const DragPlaceholder = styled('div')`
 
 const Actions = styled('div')`
   grid-column: 2 / 3;
+
+  & button {
+    margin-right: ${space(1)};
+  }
 `;
 
 const Heading = styled('div')<{gridColumns: number}>`

+ 1 - 0
static/app/views/eventsV2/table/columnEditModal.tsx

@@ -94,6 +94,7 @@ class ColumnEditModal extends Component<Props, State> {
             columns={this.state.columns}
             fieldOptions={fieldOptions}
             onChange={this.handleChange}
+            organization={organization}
           />
         </Body>
         <Footer>

+ 24 - 0
static/app/views/eventsV2/table/queryField.tsx

@@ -160,6 +160,14 @@ class QueryField extends React.Component<Props> {
     this.triggerChange(fieldValue);
   };
 
+  handleEquationChange = (value: string) => {
+    const newColumn = cloneDeep(this.props.fieldValue);
+    if (newColumn.kind === FieldValueKind.EQUATION) {
+      newColumn.field = value;
+    }
+    this.triggerChange(newColumn);
+  };
+
   handleFieldParameterChange = ({value}) => {
     const newColumn = cloneDeep(this.props.fieldValue);
     if (newColumn.kind === 'function') {
@@ -441,6 +449,7 @@ class QueryField extends React.Component<Props> {
       className,
       takeFocus,
       filterPrimaryOptions,
+      fieldValue,
       inFieldLabels,
       disabled,
       hidePrimarySelector,
@@ -488,6 +497,21 @@ class QueryField extends React.Component<Props> {
 
     const parameters = this.renderParameterInputs(parameterDescriptions);
 
+    if (fieldValue.kind === FieldValueKind.EQUATION) {
+      return (
+        <Container className={className} gridColumns={1}>
+          <BufferedInput
+            name="refinement"
+            key="parameter:text"
+            type="text"
+            required
+            value={fieldValue.field}
+            onUpdate={this.handleEquationChange}
+          />
+        </Container>
+      );
+    }
+
     return (
       <Container
         className={className}

+ 1 - 0
static/app/views/eventsV2/table/types.tsx

@@ -34,6 +34,7 @@ export enum FieldValueKind {
   BREAKDOWN = 'breakdown',
   FIELD = 'field',
   FUNCTION = 'function',
+  EQUATION = 'equation',
 }
 
 export type FieldValueColumns =

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