Browse Source

feat(perf): Add dropdown for tags page heatmap (#26935)

* feat(perf): Add dropdown for tags page heatmap

This adds a dropdown that loads 3 transactions for a specific heat map bucket when clicked. This will allow users to find examples of transactions with certain conditions right from the visualization itself.
k-fish 3 years ago
parent
commit
1f4bcd17f3

+ 1 - 0
src/sentry/api/endpoints/organization_events.py

@@ -23,6 +23,7 @@ ALLOWED_EVENTS_V2_REFERRERS = {
     "api.performance.status-breakdown",
     "api.performance.status-breakdown",
     "api.performance.vital-detail",
     "api.performance.vital-detail",
     "api.performance.durationpercentilechart",
     "api.performance.durationpercentilechart",
+    "api.performance.tag-page",
     "api.trace-view.span-detail",
     "api.trace-view.span-detail",
     "api.trace-view.errors-view",
     "api.trace-view.errors-view",
     "api.trace-view.hover-card",
     "api.trace-view.hover-card",

+ 21 - 23
static/app/components/charts/heatMapChart.tsx

@@ -3,7 +3,7 @@ import './components/visualMap';
 import * as React from 'react';
 import * as React from 'react';
 import {EChartOption} from 'echarts';
 import {EChartOption} from 'echarts';
 
 
-import {Series} from 'app/types/echarts';
+import {ReactEchartsRef, Series} from 'app/types/echarts';
 
 
 import HeatMapSeries from './series/heatMapSeries';
 import HeatMapSeries from './series/heatMapSeries';
 import BaseChart from './baseChart';
 import BaseChart from './baseChart';
@@ -21,25 +21,23 @@ type Props = Omit<ChartProps, 'series'> & {
   visualMaps: EChartOption.VisualMap[];
   visualMaps: EChartOption.VisualMap[];
 };
 };
 
 
-export default class HeatMapChart extends React.Component<Props> {
-  render() {
-    const {series, seriesOptions, visualMaps, ...props} = this.props;
-
-    return (
-      <BaseChart
-        options={{
-          visualMap: visualMaps,
-        }}
-        {...props}
-        series={series.map(({seriesName, data, dataArray, ...options}) =>
-          HeatMapSeries({
-            ...seriesOptions,
-            ...options,
-            name: seriesName,
-            data: dataArray || data.map(({value, name}) => [name, value]),
-          })
-        )}
-      />
-    );
-  }
-}
+export default React.forwardRef<ReactEchartsRef, Props>((props, ref) => {
+  const {series, seriesOptions, visualMaps, ...otherProps} = props;
+  return (
+    <BaseChart
+      ref={ref}
+      options={{
+        visualMap: visualMaps,
+      }}
+      {...otherProps}
+      series={series.map(({seriesName, data, dataArray, ...options}) =>
+        HeatMapSeries({
+          ...seriesOptions,
+          ...options,
+          name: seriesName,
+          data: dataArray || data.map(({value, name}) => [name, value]),
+        })
+      )}
+    />
+  );
+});

+ 3 - 1
static/app/utils/discover/eventView.tsx

@@ -337,7 +337,9 @@ class EventView {
     this.interval = props.interval;
     this.interval = props.interval;
     this.createdBy = props.createdBy;
     this.createdBy = props.createdBy;
     this.expired = props.expired;
     this.expired = props.expired;
-    this.additionalConditions = props.additionalConditions ?? new QueryResults([]);
+    this.additionalConditions = props.additionalConditions
+      ? props.additionalConditions.copy()
+      : new QueryResults([]);
   }
   }
 
 
   static fromLocation(location: Location): EventView {
   static fromLocation(location: Location): EventView {

+ 37 - 0
static/app/utils/performance/segmentExplorer/tagTransactionsQuery.tsx

@@ -0,0 +1,37 @@
+import * as React from 'react';
+
+import {MetaType} from 'app/utils/discover/eventView';
+import GenericDiscoverQuery, {
+  DiscoverQueryProps,
+} from 'app/utils/discover/genericDiscoverQuery';
+import withApi from 'app/utils/withApi';
+
+export type TableDataRow = {
+  id: string;
+  [key: string]: React.ReactText;
+};
+
+export type TableData = {
+  data: Array<TableDataRow>;
+  meta?: MetaType;
+};
+
+type QueryProps = DiscoverQueryProps & {
+  query: string;
+};
+
+function shouldRefetchData(prevProps: QueryProps, nextProps: QueryProps) {
+  return prevProps.query !== nextProps.query;
+}
+
+function TagTransactionsQuery(props: QueryProps) {
+  return (
+    <GenericDiscoverQuery<TableData, QueryProps>
+      route="eventsv2"
+      shouldRefetchData={shouldRefetchData}
+      {...props}
+    />
+  );
+}
+
+export default withApi(TagTransactionsQuery);

+ 2 - 0
static/app/views/performance/transactionSummary/header.tsx

@@ -7,6 +7,7 @@ import Feature from 'app/components/acl/feature';
 import Button from 'app/components/button';
 import Button from 'app/components/button';
 import ButtonBar from 'app/components/buttonBar';
 import ButtonBar from 'app/components/buttonBar';
 import {CreateAlertFromViewButton} from 'app/components/createAlertButton';
 import {CreateAlertFromViewButton} from 'app/components/createAlertButton';
+import FeatureBadge from 'app/components/featureBadge';
 import * as Layout from 'app/components/layouts/thirds';
 import * as Layout from 'app/components/layouts/thirds';
 import ListLink from 'app/components/links/listLink';
 import ListLink from 'app/components/links/listLink';
 import NavTabs from 'app/components/navTabs';
 import NavTabs from 'app/components/navTabs';
@@ -268,6 +269,7 @@ class TransactionHeader extends React.Component<Props> {
                 onClick={this.trackTagsTabClick}
                 onClick={this.trackTagsTabClick}
               >
               >
                 {t('Tags')}
                 {t('Tags')}
+                <FeatureBadge type="beta" noTooltip />
               </ListLink>
               </ListLink>
             </Feature>
             </Feature>
             <Feature features={['organizations:performance-events-page']}>
             <Feature features={['organizations:performance-events-page']}>

+ 5 - 1
static/app/views/performance/transactionSummary/transactionTags/index.tsx

@@ -141,7 +141,7 @@ function generateTagsEventView(
   }
   }
   const query = decodeScalar(location.query.query, '');
   const query = decodeScalar(location.query.query, '');
   const conditions = tokenizeSearch(query);
   const conditions = tokenizeSearch(query);
-  return EventView.fromNewQueryWithLocation(
+  const eventView = EventView.fromNewQueryWithLocation(
     {
     {
       id: undefined,
       id: undefined,
       version: 2,
       version: 2,
@@ -152,6 +152,10 @@ function generateTagsEventView(
     },
     },
     location
     location
   );
   );
+
+  eventView.additionalConditions.setTagValues('event.type', ['transaction']);
+  eventView.additionalConditions.setTagValues('transaction', [transactionName]);
+  return eventView;
 }
 }
 
 
 export default withGlobalSelection(withProjects(withOrganization(TransactionTags)));
 export default withGlobalSelection(withProjects(withOrganization(TransactionTags)));

+ 8 - 1
static/app/views/performance/transactionSummary/transactionTags/tagValueTable.tsx

@@ -100,6 +100,7 @@ const COLUMN_ORDER: TagColumn[] = [
 type Props = {
 type Props = {
   location: Location;
   location: Location;
   organization: Organization;
   organization: Organization;
+  aggregateColumn: string;
   projects: Project[];
   projects: Project[];
   transactionName: string;
   transactionName: string;
   tagKey: string;
   tagKey: string;
@@ -285,13 +286,19 @@ export class TagValueTable extends Component<Props, State> {
   };
   };
 
 
   render() {
   render() {
-    const {eventView, tagKey, location, isLoading, tableData} = this.props;
+    const {eventView, tagKey, location, isLoading, tableData, aggregateColumn} =
+      this.props;
 
 
     const newColumns = [...COLUMN_ORDER].map(c => {
     const newColumns = [...COLUMN_ORDER].map(c => {
       const newColumn = {...c};
       const newColumn = {...c};
       if (c.key === 'tagValue') {
       if (c.key === 'tagValue') {
         newColumn.name = tagKey;
         newColumn.name = tagKey;
       }
       }
+      if (c.key === 'aggregate') {
+        if (aggregateColumn === 'measurements.lcp') {
+          newColumn.name = 'Avg LCP';
+        }
+      }
       return newColumn;
       return newColumn;
     });
     });
 
 

+ 18 - 4
static/app/views/performance/transactionSummary/transactionTags/tagsDisplay.tsx

@@ -24,8 +24,8 @@ type Props = {
 
 
 const TAG_VALUE_LIMIT = 10;
 const TAG_VALUE_LIMIT = 10;
 
 
-const HISTOGRAM_TAG_KEY_LIMIT = 8;
-const HISTOGRAM_BUCKET_LIMIT = 50;
+const HISTOGRAM_TAG_KEY_LIMIT = 6;
+const HISTOGRAM_BUCKET_LIMIT = 20;
 
 
 const TagsDisplay = (props: Props) => {
 const TagsDisplay = (props: Props) => {
   const {eventView, location, organization, projects, tagKey} = props;
   const {eventView, location, organization, projects, tagKey} = props;
@@ -50,7 +50,14 @@ const TagsDisplay = (props: Props) => {
         sort="-frequency"
         sort="-frequency"
       >
       >
         {({isLoading, tableData}) => {
         {({isLoading, tableData}) => {
-          return <TagsHeatMap {...props} tableData={tableData} isLoading={isLoading} />;
+          return (
+            <TagsHeatMap
+              {...props}
+              aggregateColumn={aggregateColumn}
+              tableData={tableData}
+              isLoading={isLoading}
+            />
+          );
         }}
         }}
       </TagKeyHistogramQuery>
       </TagKeyHistogramQuery>
       <SegmentExplorerQuery
       <SegmentExplorerQuery
@@ -64,7 +71,14 @@ const TagsDisplay = (props: Props) => {
         allTagKeys
         allTagKeys
       >
       >
         {({isLoading, tableData}) => {
         {({isLoading, tableData}) => {
-          return <TagValueTable {...props} tableData={tableData} isLoading={isLoading} />;
+          return (
+            <TagValueTable
+              {...props}
+              aggregateColumn={aggregateColumn}
+              tableData={tableData}
+              isLoading={isLoading}
+            />
+          );
         }}
         }}
       </SegmentExplorerQuery>
       </SegmentExplorerQuery>
     </React.Fragment>
     </React.Fragment>

+ 291 - 5
static/app/views/performance/transactionSummary/transactionTags/tagsHeatMap.tsx

@@ -1,26 +1,51 @@
+import React, {useRef, useState} from 'react';
+import ReactDOM from 'react-dom';
+import {Popper} from 'react-popper';
 import {withTheme} from '@emotion/react';
 import {withTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 import styled from '@emotion/styled';
+import {truncate} from '@sentry/utils';
+import classNames from 'classnames';
 import {EChartOption} from 'echarts';
 import {EChartOption} from 'echarts';
 import {Location} from 'history';
 import {Location} from 'history';
+import memoize from 'lodash/memoize';
 
 
 import HeatMapChart from 'app/components/charts/heatMapChart';
 import HeatMapChart from 'app/components/charts/heatMapChart';
 import {HeaderTitleLegend} from 'app/components/charts/styles';
 import {HeaderTitleLegend} from 'app/components/charts/styles';
 import TransitionChart from 'app/components/charts/transitionChart';
 import TransitionChart from 'app/components/charts/transitionChart';
 import TransparentLoadingMask from 'app/components/charts/transparentLoadingMask';
 import TransparentLoadingMask from 'app/components/charts/transparentLoadingMask';
+import {Content} from 'app/components/dropdownControl';
+import DropdownMenu from 'app/components/dropdownMenu';
+import LoadingIndicator from 'app/components/loadingIndicator';
 import {Panel} from 'app/components/panels';
 import {Panel} from 'app/components/panels';
+import Placeholder from 'app/components/placeholder';
 import QuestionTooltip from 'app/components/questionTooltip';
 import QuestionTooltip from 'app/components/questionTooltip';
+import {
+  DropdownContainer,
+  DropdownItem,
+  SectionSubtext,
+} from 'app/components/quickTrace/styles';
+import Truncate from 'app/components/truncate';
 import {t} from 'app/locale';
 import {t} from 'app/locale';
 import space from 'app/styles/space';
 import space from 'app/styles/space';
 import {Organization, Project} from 'app/types';
 import {Organization, Project} from 'app/types';
-import {Series} from 'app/types/echarts';
-import {axisDuration, axisLabelFormatter} from 'app/utils/discover/charts';
+import {ReactEchartsRef, Series} from 'app/types/echarts';
+import {axisLabelFormatter} from 'app/utils/discover/charts';
 import EventView from 'app/utils/discover/eventView';
 import EventView from 'app/utils/discover/eventView';
 import {TableData as TagTableData} from 'app/utils/performance/segmentExplorer/tagKeyHistogramQuery';
 import {TableData as TagTableData} from 'app/utils/performance/segmentExplorer/tagKeyHistogramQuery';
+import TagTransactionsQuery from 'app/utils/performance/segmentExplorer/tagTransactionsQuery';
+import {decodeScalar} from 'app/utils/queryString';
 import {Theme} from 'app/utils/theme';
 import {Theme} from 'app/utils/theme';
 
 
+import {getPerformanceDuration, PerformanceDuration} from '../../utils';
+import {eventsRouteWithQuery} from '../transactionEvents/utils';
+import {generateTransactionLink} from '../utils';
+
+import {parseHistogramBucketInfo} from './utils';
+
 type Props = {
 type Props = {
   eventView: EventView;
   eventView: EventView;
   location: Location;
   location: Location;
+  aggregateColumn: string;
   organization: Organization;
   organization: Organization;
   projects: Project[];
   projects: Project[];
   transactionName: string;
   transactionName: string;
@@ -31,6 +56,34 @@ const findRowKey = row => {
   return Object.keys(row).find(key => key.includes('histogram'));
   return Object.keys(row).find(key => key.includes('histogram'));
 };
 };
 
 
+class VirtualReference {
+  boundingRect: DOMRect;
+
+  constructor(element: HTMLElement) {
+    this.boundingRect = element.getBoundingClientRect();
+  }
+  getBoundingClientRect() {
+    return this.boundingRect;
+  }
+
+  get clientWidth() {
+    return this.getBoundingClientRect().width;
+  }
+
+  get clientHeight() {
+    return this.getBoundingClientRect().height;
+  }
+}
+const getPortal = memoize((): HTMLElement => {
+  let portal = document.getElementById('heatmap-portal');
+  if (!portal) {
+    portal = document.createElement('div');
+    portal.setAttribute('id', 'heatmap-portal');
+    document.body.appendChild(portal);
+  }
+  return portal;
+});
+
 const TagsHeatMap = (
 const TagsHeatMap = (
   props: Props & {
   props: Props & {
     theme: Theme;
     theme: Theme;
@@ -38,7 +91,23 @@ const TagsHeatMap = (
     isLoading: boolean;
     isLoading: boolean;
   }
   }
 ) => {
 ) => {
-  const {tableData, isLoading} = props;
+  const {
+    tableData,
+    isLoading,
+    organization,
+    eventView,
+    location,
+    tagKey,
+    transactionName,
+    aggregateColumn,
+  } = props;
+
+  const chartRef = useRef<ReactEchartsRef>(null);
+  const [chartElement, setChartElement] = useState<VirtualReference | undefined>();
+  const [transactionEventView, setTransactionEventView] = useState<
+    EventView | undefined
+  >();
+  const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
 
 
   if (!tableData || !tableData.data || !tableData.data.length) {
   if (!tableData || !tableData.data || !tableData.data.length) {
     return null;
     return null;
@@ -57,7 +126,8 @@ const TagsHeatMap = (
   let maxCount = 0;
   let maxCount = 0;
 
 
   const _data = tableData.data.map(row => {
   const _data = tableData.data.map(row => {
-    const x = axisDuration(row[rowKey] as number);
+    const rawDuration = row[rowKey] as number;
+    const x = getPerformanceDuration(rawDuration);
     const y = row.tags_value;
     const y = row.tags_value;
     columnNames.add(y);
     columnNames.add(y);
     xValues.add(x);
     xValues.add(x);
@@ -86,6 +156,9 @@ const TagsHeatMap = (
       splitArea: {
       splitArea: {
         show: true,
         show: true,
       },
       },
+      axisLabel: {
+        formatter: (value: string) => truncate(value, 50),
+      },
     } as any, // TODO(k-fish): Expand typing to allow data option
     } as any, // TODO(k-fish): Expand typing to allow data option
     xAxis: {
     xAxis: {
       boundaryGap: true,
       boundaryGap: true,
@@ -151,6 +224,24 @@ const TagsHeatMap = (
   const reloading = isLoading;
   const reloading = isLoading;
   const loading = isLoading;
   const loading = isLoading;
 
 
+  const onOpenMenu = () => {
+    setIsMenuOpen(true);
+  };
+
+  const onCloseMenu = () => {
+    setIsMenuOpen(false);
+  };
+
+  const shouldIgnoreMenuClose = e => {
+    if (chartRef.current?.getEchartsInstance().getDom().contains(e.target)) {
+      // Ignore the menu being closed if the echart is being clicked.
+      return true;
+    }
+    return false;
+  };
+
+  const histogramBucketInfo = parseHistogramBucketInfo(tableData.data[0]);
+
   return (
   return (
     <StyledPanel>
     <StyledPanel>
       <StyledHeaderTitleLegend>
       <StyledHeaderTitleLegend>
@@ -166,13 +257,208 @@ const TagsHeatMap = (
 
 
       <TransitionChart loading={loading} reloading={reloading}>
       <TransitionChart loading={loading} reloading={reloading}>
         <TransparentLoadingMask visible={reloading} />
         <TransparentLoadingMask visible={reloading} />
+        <DropdownMenu
+          onOpen={onOpenMenu}
+          onClose={onCloseMenu}
+          shouldIgnoreClickOutside={shouldIgnoreMenuClose}
+        >
+          {({isOpen, getMenuProps, actions}) => {
+            const onChartClick = bucket => {
+              const htmlEvent = bucket.event.event;
+              // Make a copy of the dims because echarts can remove elements after this click happens.
+              // TODO(k-fish): Look at improving this to respond properly to resize events.
+              const virtualRef = new VirtualReference(htmlEvent.target);
+              setChartElement(virtualRef);
+
+              const newTransactionEventView = eventView.clone();
+
+              newTransactionEventView.fields = [{field: aggregateColumn}];
+              const [_, tagValue] = bucket.value;
+
+              if (histogramBucketInfo) {
+                const row = tableData.data[bucket.dataIndex];
+                const currentBucketStart = parseInt(
+                  `${row[histogramBucketInfo.histogramField]}`,
+                  10
+                );
+                const currentBucketEnd =
+                  currentBucketStart + histogramBucketInfo.bucketSize;
+
+                newTransactionEventView.additionalConditions.setTagValues(
+                  aggregateColumn,
+                  [`>=${currentBucketStart}`, `<${currentBucketEnd}`]
+                );
+              }
+
+              newTransactionEventView.additionalConditions.setTagValues(tagKey, [
+                tagValue,
+              ]);
 
 
-        <HeatMapChart visualMaps={visualMaps} series={series} {...chartOptions} />
+              setTransactionEventView(newTransactionEventView);
+
+              if (!isMenuOpen) {
+                actions.open();
+              }
+            };
+
+            return (
+              <React.Fragment>
+                {ReactDOM.createPortal(
+                  <div>
+                    {chartElement ? (
+                      <Popper referenceElement={chartElement} placement="bottom">
+                        {({ref, style, placement}) => (
+                          <StyledDropdownContainer
+                            ref={ref}
+                            style={style}
+                            className="anchor-middle"
+                            data-placement={placement}
+                          >
+                            <StyledDropdownContent
+                              {...getMenuProps({
+                                className: classNames('dropdown-menu'),
+                              })}
+                              isOpen={isOpen}
+                              alignMenu="right"
+                              blendCorner={false}
+                            >
+                              {transactionEventView ? (
+                                <TagTransactionsQuery
+                                  query={transactionEventView.getQueryWithAdditionalConditions()}
+                                  location={location}
+                                  eventView={transactionEventView}
+                                  orgSlug={organization.slug}
+                                  limit={4}
+                                  referrer="api.performance.tag-page"
+                                >
+                                  {({
+                                    isLoading: isTransactionsLoading,
+                                    tableData: transactionTableData,
+                                  }) => {
+                                    const moreEventsTarget = isTransactionsLoading
+                                      ? null
+                                      : eventsRouteWithQuery({
+                                          orgSlug: organization.slug,
+                                          transaction: transactionName,
+                                          projectID: decodeScalar(location.query.project),
+                                          query: {
+                                            ...transactionEventView.generateQueryStringObject(),
+                                            query:
+                                              transactionEventView.getQueryWithAdditionalConditions(),
+                                          },
+                                        });
+                                    return (
+                                      <React.Fragment>
+                                        {isTransactionsLoading ? (
+                                          <LoadingContainer>
+                                            <LoadingIndicator size={40} hideMessage />
+                                          </LoadingContainer>
+                                        ) : (
+                                          <div>
+                                            {!transactionTableData.data.length ? (
+                                              <Placeholder />
+                                            ) : null}
+                                            {[...transactionTableData.data]
+                                              .slice(0, 3)
+                                              .map(row => {
+                                                const target = generateTransactionLink(
+                                                  transactionName
+                                                )(organization, row, location.query);
+
+                                                return (
+                                                  <DropdownItem
+                                                    width="small"
+                                                    key={row.id}
+                                                    to={target}
+                                                  >
+                                                    <DropdownItemContainer>
+                                                      <Truncate
+                                                        value={row.id}
+                                                        maxLength={12}
+                                                      />
+                                                      <SectionSubtext>
+                                                        <PerformanceDuration
+                                                          milliseconds={
+                                                            row[aggregateColumn]
+                                                          }
+                                                          abbreviation
+                                                        />
+                                                      </SectionSubtext>
+                                                    </DropdownItemContainer>
+                                                  </DropdownItem>
+                                                );
+                                              })}
+                                            {moreEventsTarget &&
+                                            transactionTableData.data.length > 3 ? (
+                                              <DropdownItem
+                                                width="small"
+                                                to={moreEventsTarget}
+                                              >
+                                                <DropdownItemContainer>
+                                                  {t('View all events')}
+                                                </DropdownItemContainer>
+                                              </DropdownItem>
+                                            ) : null}
+                                          </div>
+                                        )}
+                                      </React.Fragment>
+                                    );
+                                  }}
+                                </TagTransactionsQuery>
+                              ) : null}
+                            </StyledDropdownContent>
+                          </StyledDropdownContainer>
+                        )}
+                      </Popper>
+                    ) : null}
+                  </div>,
+                  getPortal()
+                )}
+
+                <HeatMapChart
+                  ref={chartRef}
+                  visualMaps={visualMaps}
+                  series={series}
+                  onClick={onChartClick}
+                  {...chartOptions}
+                />
+              </React.Fragment>
+            );
+          }}
+        </DropdownMenu>
       </TransitionChart>
       </TransitionChart>
     </StyledPanel>
     </StyledPanel>
   );
   );
 };
 };
 
 
+const LoadingContainer = styled('div')`
+  width: 200px;
+  height: 100px;
+
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const DropdownItemContainer = styled('div')`
+  width: 100%;
+  display: flex;
+  flex-direction: row;
+
+  justify-content: space-between;
+`;
+
+const StyledDropdownContainer = styled(DropdownContainer)`
+  z-index: ${p => p.theme.zIndex.dropdown};
+`;
+
+const StyledDropdownContent = styled(Content)`
+  right: auto;
+  transform: translate(-50%);
+
+  overflow: visible;
+`;
+
 const StyledPanel = styled(Panel)`
 const StyledPanel = styled(Panel)`
   padding: ${space(3)};
   padding: ${space(3)};
   margin-bottom: 0;
   margin-bottom: 0;

+ 15 - 0
static/app/views/performance/transactionSummary/transactionTags/utils.tsx

@@ -31,3 +31,18 @@ export function tagsRouteWithQuery({
     },
     },
   };
   };
 }
 }
+
+// TODO(k-fish): Improve meta of backend response to return these directly
+export function parseHistogramBucketInfo(row: {[key: string]: React.ReactText}) {
+  const field = Object.keys(row).find(f => f.includes('histogram'));
+  if (!field) {
+    return undefined;
+  }
+  const parts = field.split('_');
+  return {
+    histogramField: field,
+    bucketSize: parseInt(parts[parts.length - 3], 10),
+    offset: parseInt(parts[parts.length - 2], 10),
+    multiplier: parseInt(parts[parts.length - 1], 10),
+  };
+}

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