columnEditCollection.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847
  1. import {Component, createRef, Fragment, useMemo} from 'react';
  2. import {createPortal} from 'react-dom';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import {parseArithmetic} from 'sentry/components/arithmeticInput/parser';
  6. import {Button} from 'sentry/components/button';
  7. import ButtonBar from 'sentry/components/buttonBar';
  8. import {SectionHeading} from 'sentry/components/charts/styles';
  9. import Input from 'sentry/components/input';
  10. import {getOffsetOfElement} from 'sentry/components/performance/waterfall/utils';
  11. import {Tooltip} from 'sentry/components/tooltip';
  12. import {IconAdd, IconDelete, IconGrabbable, IconWarning} from 'sentry/icons';
  13. import {t} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import type {MRI} from 'sentry/types/metrics';
  16. import type {Organization} from 'sentry/types/organization';
  17. import {trackAnalytics} from 'sentry/utils/analytics';
  18. import type {Column} from 'sentry/utils/discover/fields';
  19. import {
  20. AGGREGATIONS,
  21. generateFieldAsString,
  22. hasDuplicate,
  23. isLegalEquationColumn,
  24. } from 'sentry/utils/discover/fields';
  25. import {useMetricsTags} from 'sentry/utils/metrics/useMetricsTags';
  26. import theme from 'sentry/utils/theme';
  27. import {getPointerPosition} from 'sentry/utils/touch';
  28. import usePageFilters from 'sentry/utils/usePageFilters';
  29. import type {UserSelectValues} from 'sentry/utils/userselect';
  30. import {setBodyUserSelect} from 'sentry/utils/userselect';
  31. import {WidgetType} from 'sentry/views/dashboards/types';
  32. import {FieldKey} from 'sentry/views/dashboards/widgetBuilder/issueWidget/fields';
  33. import {SESSIONS_OPERATIONS} from 'sentry/views/dashboards/widgetBuilder/releaseWidget/fields';
  34. import type {generateFieldOptions} from '../utils';
  35. import type {FieldValueOption} from './queryField';
  36. import {QueryField} from './queryField';
  37. import {FieldValueKind} from './types';
  38. type Sources = WidgetType;
  39. type Props = {
  40. // Input columns
  41. columns: Column[];
  42. fieldOptions: ReturnType<typeof generateFieldOptions>;
  43. // Fired when columns are added/removed/modified
  44. onChange: (columns: Column[]) => void;
  45. organization: Organization;
  46. className?: string;
  47. filterAggregateParameters?: (option: FieldValueOption) => boolean;
  48. filterPrimaryOptions?: (option: FieldValueOption) => boolean;
  49. isOnDemandWidget?: boolean;
  50. noFieldsMessage?: string;
  51. showAliasField?: boolean;
  52. source?: Sources;
  53. supportsEquations?: boolean;
  54. };
  55. type State = {
  56. draggingGrabbedOffset: undefined | {x: number; y: number};
  57. draggingIndex: undefined | number;
  58. draggingTargetIndex: undefined | number;
  59. error: Map<number, string | undefined>;
  60. isDragging: boolean;
  61. left: undefined | number;
  62. top: undefined | number;
  63. };
  64. const DRAG_CLASS = 'draggable-item';
  65. const GHOST_PADDING = 4;
  66. const MAX_COL_COUNT = 20;
  67. enum PlaceholderPosition {
  68. TOP = 0,
  69. BOTTOM = 1,
  70. }
  71. class ColumnEditCollection extends Component<Props, State> {
  72. state: State = {
  73. isDragging: false,
  74. draggingIndex: void 0,
  75. draggingTargetIndex: void 0,
  76. draggingGrabbedOffset: void 0,
  77. error: new Map(),
  78. left: void 0,
  79. top: void 0,
  80. };
  81. componentDidMount() {
  82. if (!this.portal) {
  83. const portal = document.createElement('div');
  84. portal.style.position = 'absolute';
  85. portal.style.top = '0';
  86. portal.style.left = '0';
  87. portal.style.zIndex = String(theme.zIndex.modal);
  88. this.portal = portal;
  89. document.body.appendChild(this.portal);
  90. }
  91. this.checkColumnErrors(this.props.columns);
  92. }
  93. componentWillUnmount() {
  94. if (this.portal) {
  95. document.body.removeChild(this.portal);
  96. }
  97. this.cleanUpListeners();
  98. }
  99. checkColumnErrors(columns: Column[]) {
  100. const error = new Map();
  101. for (let i = 0; i < columns.length; i += 1) {
  102. const column = columns[i];
  103. if (column.kind === 'equation') {
  104. const result = parseArithmetic(column.field);
  105. if (result.error) {
  106. error.set(i, result.error);
  107. }
  108. }
  109. }
  110. this.setState({error});
  111. }
  112. previousUserSelect: UserSelectValues | null = null;
  113. portal: HTMLElement | null = null;
  114. dragGhostRef = createRef<HTMLDivElement>();
  115. keyForColumn(column: Column, isGhost: boolean): string {
  116. if (column.kind === 'function') {
  117. return [...column.function, isGhost].join(':');
  118. }
  119. return [...column.field, isGhost].join(':');
  120. }
  121. cleanUpListeners() {
  122. if (this.state.isDragging) {
  123. window.removeEventListener('mousemove', this.onDragMove);
  124. window.removeEventListener('touchmove', this.onDragMove);
  125. window.removeEventListener('mouseup', this.onDragEnd);
  126. window.removeEventListener('touchend', this.onDragEnd);
  127. }
  128. }
  129. // Signal to the parent that a new column has been added.
  130. handleAddColumn = () => {
  131. const newColumn: Column = {kind: 'field', field: ''};
  132. this.props.onChange([...this.props.columns, newColumn]);
  133. };
  134. handleAddEquation = () => {
  135. const {organization} = this.props;
  136. const newColumn: Column = {kind: FieldValueKind.EQUATION, field: ''};
  137. trackAnalytics('discover_v2.add_equation', {organization});
  138. this.props.onChange([...this.props.columns, newColumn]);
  139. };
  140. handleUpdateColumn = (index: number, updatedColumn: Column) => {
  141. const newColumns = [...this.props.columns];
  142. if (updatedColumn.kind === 'equation') {
  143. this.setState(prevState => {
  144. const error = new Map(prevState.error);
  145. error.set(index, parseArithmetic(updatedColumn.field).error);
  146. return {
  147. ...prevState,
  148. error,
  149. };
  150. });
  151. } else {
  152. // Update any equations that contain the existing column
  153. this.updateEquationFields(newColumns, index, updatedColumn);
  154. }
  155. newColumns.splice(index, 1, updatedColumn);
  156. this.props.onChange(newColumns);
  157. };
  158. updateEquationFields = (newColumns: Column[], index: number, updatedColumn: Column) => {
  159. const oldColumn = newColumns[index];
  160. const existingColumn = generateFieldAsString(newColumns[index]);
  161. const updatedColumnString = generateFieldAsString(updatedColumn);
  162. if (!isLegalEquationColumn(updatedColumn) || hasDuplicate(newColumns, oldColumn)) {
  163. return;
  164. }
  165. // Find the equations in the list of columns
  166. for (let i = 0; i < newColumns.length; i++) {
  167. const newColumn = newColumns[i];
  168. if (newColumn.kind === 'equation') {
  169. const result = parseArithmetic(newColumn.field);
  170. let newEquation = '';
  171. // Track where to continue from, not reconstructing from result so we don't have to worry
  172. // about spacing
  173. let lastIndex = 0;
  174. // the parser separates fields & functions, so we only need to check one
  175. const fields =
  176. oldColumn.kind === 'function' ? result.tc.functions : result.tc.fields;
  177. // for each field, add the text before it, then the new function and update index
  178. // to be where we want to start again
  179. for (const field of fields) {
  180. if (field.term === existingColumn && lastIndex !== field.location.end.offset) {
  181. newEquation +=
  182. newColumn.field.substring(lastIndex, field.location.start.offset) +
  183. updatedColumnString;
  184. lastIndex = field.location.end.offset;
  185. }
  186. }
  187. // Add whatever remains to be added from the equation, if existing field wasn't found
  188. // add the entire equation
  189. newEquation += newColumn.field.substring(lastIndex);
  190. newColumns[i] = {
  191. kind: 'equation',
  192. field: newEquation,
  193. alias: newColumns[i].alias,
  194. };
  195. }
  196. }
  197. };
  198. removeColumn(index: number) {
  199. const newColumns = [...this.props.columns];
  200. newColumns.splice(index, 1);
  201. this.checkColumnErrors(newColumns);
  202. this.props.onChange(newColumns);
  203. }
  204. startDrag(
  205. event: React.MouseEvent<HTMLElement> | React.TouchEvent<HTMLElement>,
  206. index: number
  207. ) {
  208. const isDragging = this.state.isDragging;
  209. if (isDragging || !['mousedown', 'touchstart'].includes(event.type)) {
  210. return;
  211. }
  212. event.preventDefault();
  213. event.stopPropagation();
  214. const top = getPointerPosition(event, 'pageY');
  215. const left = getPointerPosition(event, 'pageX');
  216. // Compute where the user clicked on the drag handle. Avoids the element
  217. // jumping from the cursor on mousedown.
  218. const draggingElement = Array.from(document.querySelectorAll(`.${DRAG_CLASS}`)).find(
  219. n => n.contains(event.currentTarget)
  220. )!;
  221. const {x, y} = getOffsetOfElement(draggingElement);
  222. const draggingGrabbedOffset = {
  223. x: left - x + GHOST_PADDING,
  224. y: top - y + GHOST_PADDING,
  225. };
  226. // prevent the user from selecting things when dragging a column.
  227. this.previousUserSelect = setBodyUserSelect({
  228. userSelect: 'none',
  229. MozUserSelect: 'none',
  230. msUserSelect: 'none',
  231. webkitUserSelect: 'none',
  232. });
  233. // attach event listeners so that the mouse cursor can drag anywhere
  234. window.addEventListener('mousemove', this.onDragMove);
  235. window.addEventListener('touchmove', this.onDragMove);
  236. window.addEventListener('mouseup', this.onDragEnd);
  237. window.addEventListener('touchend', this.onDragEnd);
  238. this.setState({
  239. isDragging: true,
  240. draggingIndex: index,
  241. draggingTargetIndex: index,
  242. draggingGrabbedOffset,
  243. top,
  244. left,
  245. });
  246. }
  247. onDragMove = (event: MouseEvent | TouchEvent) => {
  248. const {isDragging, draggingTargetIndex, draggingGrabbedOffset} = this.state;
  249. if (!isDragging || !['mousemove', 'touchmove'].includes(event.type)) {
  250. return;
  251. }
  252. event.preventDefault();
  253. event.stopPropagation();
  254. const pointerX = getPointerPosition(event, 'pageX');
  255. const pointerY = getPointerPosition(event, 'pageY');
  256. const dragOffsetX = draggingGrabbedOffset?.x ?? 0;
  257. const dragOffsetY = draggingGrabbedOffset?.y ?? 0;
  258. if (this.dragGhostRef.current) {
  259. // move the ghost box
  260. const ghostDOM = this.dragGhostRef.current;
  261. // Adjust so cursor is over the grab handle.
  262. ghostDOM.style.left = `${pointerX - dragOffsetX}px`;
  263. ghostDOM.style.top = `${pointerY - dragOffsetY}px`;
  264. }
  265. const dragItems = document.querySelectorAll(`.${DRAG_CLASS}`);
  266. // Find the item that the ghost is currently over.
  267. const targetIndex = Array.from(dragItems).findIndex(dragItem => {
  268. const rects = dragItem.getBoundingClientRect();
  269. const top = pointerY;
  270. const thresholdStart = window.scrollY + rects.top;
  271. const thresholdEnd = window.scrollY + rects.top + rects.height;
  272. return top >= thresholdStart && top <= thresholdEnd;
  273. });
  274. // Issue column in Issue widgets are fixed (cannot be moved or deleted)
  275. if (
  276. targetIndex >= 0 &&
  277. targetIndex !== draggingTargetIndex &&
  278. !this.isFixedMetricsColumn(targetIndex)
  279. ) {
  280. this.setState({draggingTargetIndex: targetIndex});
  281. }
  282. };
  283. isFixedIssueColumn = (columnIndex: number) => {
  284. const {source, columns} = this.props;
  285. const column = columns[columnIndex];
  286. const issueFieldColumnCount = columns.filter(
  287. col => col.kind === 'field' && col.field === FieldKey.ISSUE
  288. ).length;
  289. return (
  290. issueFieldColumnCount <= 1 &&
  291. source === WidgetType.ISSUE &&
  292. column.kind === 'field' &&
  293. column.field === FieldKey.ISSUE
  294. );
  295. };
  296. isFixedMetricsColumn = (columnIndex: number) => {
  297. const {source} = this.props;
  298. return source === WidgetType.METRICS && columnIndex === 0;
  299. };
  300. isRemainingReleaseHealthAggregate = (columnIndex: number) => {
  301. const {source, columns} = this.props;
  302. const column = columns[columnIndex];
  303. const aggregateCount = columns.filter(
  304. col => col.kind === FieldValueKind.FUNCTION
  305. ).length;
  306. return (
  307. aggregateCount <= 1 &&
  308. source === WidgetType.RELEASE &&
  309. column.kind === FieldValueKind.FUNCTION
  310. );
  311. };
  312. onDragEnd = (event: MouseEvent | TouchEvent) => {
  313. if (!this.state.isDragging || !['mouseup', 'touchend'].includes(event.type)) {
  314. return;
  315. }
  316. const sourceIndex = this.state.draggingIndex;
  317. const targetIndex = this.state.draggingTargetIndex;
  318. if (typeof sourceIndex !== 'number' || typeof targetIndex !== 'number') {
  319. return;
  320. }
  321. // remove listeners that were attached in startColumnDrag
  322. this.cleanUpListeners();
  323. // restore body user-select values
  324. if (this.previousUserSelect) {
  325. setBodyUserSelect(this.previousUserSelect);
  326. this.previousUserSelect = null;
  327. }
  328. // Reorder columns and trigger change.
  329. const newColumns = [...this.props.columns];
  330. const removed = newColumns.splice(sourceIndex, 1);
  331. newColumns.splice(targetIndex, 0, removed[0]);
  332. this.checkColumnErrors(newColumns);
  333. this.props.onChange(newColumns);
  334. this.setState({
  335. isDragging: false,
  336. left: undefined,
  337. top: undefined,
  338. draggingIndex: undefined,
  339. draggingTargetIndex: undefined,
  340. draggingGrabbedOffset: undefined,
  341. });
  342. };
  343. renderGhost({gridColumns, singleColumn}: {gridColumns: number; singleColumn: boolean}) {
  344. const {isDragging, draggingIndex, draggingGrabbedOffset} = this.state;
  345. const index = draggingIndex;
  346. if (typeof index !== 'number' || !isDragging || !this.portal) {
  347. return null;
  348. }
  349. const dragOffsetX = draggingGrabbedOffset?.x ?? 0;
  350. const dragOffsetY = draggingGrabbedOffset?.y ?? 0;
  351. const top = Number(this.state.top) - dragOffsetY;
  352. const left = Number(this.state.left) - dragOffsetX;
  353. const col = this.props.columns[index];
  354. const style = {
  355. top: `${top}px`,
  356. left: `${left}px`,
  357. };
  358. const ghost = (
  359. <Ghost ref={this.dragGhostRef} style={style}>
  360. {this.renderItem(col, index, {
  361. singleColumn,
  362. isGhost: true,
  363. gridColumns,
  364. })}
  365. </Ghost>
  366. );
  367. return createPortal(ghost, this.portal);
  368. }
  369. renderItem(
  370. col: Column,
  371. i: number,
  372. {
  373. singleColumn = false,
  374. canDelete = true,
  375. canDrag = true,
  376. isGhost = false,
  377. gridColumns = 2,
  378. disabled = false,
  379. }: {
  380. gridColumns: number;
  381. singleColumn: boolean;
  382. canDelete?: boolean;
  383. canDrag?: boolean;
  384. disabled?: boolean;
  385. isGhost?: boolean;
  386. }
  387. ) {
  388. const {
  389. columns,
  390. fieldOptions,
  391. filterAggregateParameters,
  392. filterPrimaryOptions,
  393. noFieldsMessage,
  394. showAliasField,
  395. source,
  396. isOnDemandWidget,
  397. } = this.props;
  398. const {isDragging, draggingTargetIndex, draggingIndex} = this.state;
  399. let placeholder: React.ReactNode = null;
  400. // Add a placeholder above the target row.
  401. if (isDragging && isGhost === false && draggingTargetIndex === i) {
  402. placeholder = (
  403. <DragPlaceholder
  404. key={`placeholder:${this.keyForColumn(col, isGhost)}`}
  405. className={DRAG_CLASS}
  406. />
  407. );
  408. }
  409. // If the current row is the row in the drag ghost return the placeholder
  410. // or a hole if the placeholder is elsewhere.
  411. if (isDragging && isGhost === false && draggingIndex === i) {
  412. return placeholder;
  413. }
  414. const position =
  415. Number(draggingTargetIndex) <= Number(draggingIndex)
  416. ? PlaceholderPosition.TOP
  417. : PlaceholderPosition.BOTTOM;
  418. return (
  419. <Fragment key={`${i}:${this.keyForColumn(col, isGhost)}`}>
  420. {position === PlaceholderPosition.TOP && placeholder}
  421. <RowContainer
  422. showAliasField={showAliasField}
  423. singleColumn={singleColumn}
  424. className={isGhost ? '' : DRAG_CLASS}
  425. >
  426. {canDrag ? (
  427. <DragAndReorderButton
  428. aria-label={t('Drag to reorder')}
  429. onMouseDown={event => this.startDrag(event, i)}
  430. onTouchStart={event => this.startDrag(event, i)}
  431. icon={<IconGrabbable size="xs" />}
  432. size="zero"
  433. borderless
  434. />
  435. ) : singleColumn && showAliasField ? null : (
  436. <span />
  437. )}
  438. {source === WidgetType.METRICS && !this.isFixedMetricsColumn(i) ? (
  439. <MetricTagQueryField
  440. mri={
  441. columns[0].kind === FieldValueKind.FUNCTION
  442. ? columns[0].function[1]
  443. : // We should never get here because the first column should always be function for metrics
  444. undefined
  445. }
  446. gridColumns={gridColumns}
  447. fieldValue={col}
  448. onChange={value => this.handleUpdateColumn(i, value)}
  449. error={this.state.error.get(i)}
  450. takeFocus={i === this.props.columns.length - 1}
  451. otherColumns={columns}
  452. shouldRenderTag
  453. disabled={disabled}
  454. noFieldsMessage={noFieldsMessage}
  455. skipParameterPlaceholder={showAliasField}
  456. />
  457. ) : (
  458. <QueryField
  459. fieldOptions={fieldOptions}
  460. gridColumns={gridColumns}
  461. fieldValue={col}
  462. onChange={value => this.handleUpdateColumn(i, value)}
  463. error={this.state.error.get(i)}
  464. takeFocus={i === this.props.columns.length - 1}
  465. otherColumns={columns}
  466. shouldRenderTag
  467. disabled={disabled}
  468. filterPrimaryOptions={filterPrimaryOptions}
  469. filterAggregateParameters={filterAggregateParameters}
  470. noFieldsMessage={noFieldsMessage}
  471. skipParameterPlaceholder={showAliasField}
  472. />
  473. )}
  474. {showAliasField && (
  475. <AliasField singleColumn={singleColumn}>
  476. <AliasInput
  477. name="alias"
  478. placeholder={t('Alias')}
  479. value={col.alias ?? ''}
  480. onChange={value => {
  481. this.handleUpdateColumn(i, {
  482. ...col,
  483. alias: value.target.value,
  484. });
  485. }}
  486. />
  487. </AliasField>
  488. )}
  489. {canDelete || col.kind === 'equation' ? (
  490. showAliasField ? (
  491. <RemoveButton
  492. data-test-id={`remove-column-${i}`}
  493. aria-label={t('Remove column')}
  494. title={t('Remove column')}
  495. onClick={() => this.removeColumn(i)}
  496. icon={<IconDelete />}
  497. borderless
  498. />
  499. ) : (
  500. <RemoveButton
  501. data-test-id={`remove-column-${i}`}
  502. aria-label={t('Remove column')}
  503. onClick={() => this.removeColumn(i)}
  504. icon={<IconDelete />}
  505. borderless
  506. />
  507. )
  508. ) : singleColumn && showAliasField ? null : (
  509. <span />
  510. )}
  511. {isOnDemandWidget && col.kind === 'equation' ? (
  512. <OnDemandEquationsWarning />
  513. ) : null}
  514. </RowContainer>
  515. {position === PlaceholderPosition.BOTTOM && placeholder}
  516. </Fragment>
  517. );
  518. }
  519. render() {
  520. const {className, columns, showAliasField, source, supportsEquations} = this.props;
  521. const canDelete = columns.filter(field => field.kind !== 'equation').length > 1;
  522. const canDrag = columns.length > 1;
  523. const canAdd = columns.length < MAX_COL_COUNT;
  524. const title = canAdd
  525. ? undefined
  526. : t(
  527. `Sorry, you've reached the maximum number of columns (%d). Delete columns to add more.`,
  528. MAX_COL_COUNT
  529. );
  530. const singleColumn = columns.length === 1;
  531. // Get the longest number of columns so we can layout the rows.
  532. // We always want at least 2 columns.
  533. const gridColumns =
  534. source === WidgetType.ISSUE
  535. ? 1
  536. : Math.max(
  537. ...columns.map(col => {
  538. if (col.kind !== 'function') {
  539. return 2;
  540. }
  541. const operation =
  542. AGGREGATIONS[col.function[0]] ?? SESSIONS_OPERATIONS[col.function[0]];
  543. if (!operation || !operation.parameters) {
  544. // Operation should be in the look-up table, but not all operations are (eg. private). This should be changed at some point.
  545. return 3;
  546. }
  547. return operation.parameters.length === 2 ? 3 : 2;
  548. })
  549. );
  550. return (
  551. <div className={className}>
  552. {this.renderGhost({gridColumns, singleColumn})}
  553. {!showAliasField && source !== WidgetType.ISSUE && (
  554. <RowContainer showAliasField={showAliasField} singleColumn={singleColumn}>
  555. <Heading gridColumns={gridColumns}>
  556. <StyledSectionHeading>{t('Tag / Field / Function')}</StyledSectionHeading>
  557. <StyledSectionHeading>{t('Field Parameter')}</StyledSectionHeading>
  558. </Heading>
  559. </RowContainer>
  560. )}
  561. {columns.map((col: Column, i: number) => {
  562. // Issue column in Issue widgets are fixed (cannot be changed or deleted)
  563. if (this.isFixedIssueColumn(i)) {
  564. return this.renderItem(col, i, {
  565. singleColumn,
  566. canDelete: false,
  567. canDrag,
  568. gridColumns,
  569. disabled: true,
  570. });
  571. }
  572. if (this.isRemainingReleaseHealthAggregate(i)) {
  573. return this.renderItem(col, i, {
  574. singleColumn,
  575. canDelete: false,
  576. canDrag,
  577. gridColumns,
  578. });
  579. }
  580. if (this.isFixedMetricsColumn(i)) {
  581. return this.renderItem(col, i, {
  582. singleColumn,
  583. canDelete: false,
  584. canDrag: false,
  585. gridColumns,
  586. });
  587. }
  588. return this.renderItem(col, i, {
  589. singleColumn,
  590. canDelete,
  591. canDrag,
  592. gridColumns,
  593. });
  594. })}
  595. <RowContainer showAliasField={showAliasField} singleColumn={singleColumn}>
  596. <Actions gap={1} showAliasField={showAliasField}>
  597. <Button
  598. size="sm"
  599. aria-label={t('Add a Column')}
  600. onClick={this.handleAddColumn}
  601. title={title}
  602. disabled={!canAdd}
  603. icon={<IconAdd isCircled />}
  604. >
  605. {t('Add a Column')}
  606. </Button>
  607. {supportsEquations && (
  608. <Button
  609. size="sm"
  610. aria-label={t('Add an Equation')}
  611. onClick={this.handleAddEquation}
  612. title={title}
  613. disabled={!canAdd}
  614. icon={<IconAdd isCircled />}
  615. >
  616. {t('Add an Equation')}
  617. </Button>
  618. )}
  619. </Actions>
  620. </RowContainer>
  621. </div>
  622. );
  623. }
  624. }
  625. interface MetricTagQueryFieldProps
  626. extends Omit<React.ComponentProps<typeof QueryField>, 'fieldOptions'> {
  627. mri?: string;
  628. }
  629. const EMPTY_ARRAY = [];
  630. function MetricTagQueryField({mri, ...props}: MetricTagQueryFieldProps) {
  631. const {projects} = usePageFilters().selection;
  632. const {data = EMPTY_ARRAY} = useMetricsTags(mri as MRI | undefined, {projects});
  633. const fieldOptions = useMemo(() => {
  634. return data.reduce(
  635. (acc, tag) => {
  636. acc[`tag:${tag.key}`] = {
  637. label: tag.key,
  638. value: {
  639. kind: FieldValueKind.TAG,
  640. meta: {
  641. dataType: 'string',
  642. name: tag.key,
  643. },
  644. },
  645. };
  646. return acc;
  647. },
  648. {} as Record<string, FieldValueOption>
  649. );
  650. }, [data]);
  651. return <QueryField fieldOptions={fieldOptions} {...props} />;
  652. }
  653. function OnDemandEquationsWarning() {
  654. return (
  655. <OnDemandContainer>
  656. <Tooltip
  657. containerDisplayMode="inline-flex"
  658. title={t(
  659. `This is using indexed data because we don't routinely collect metrics for equations.`
  660. )}
  661. >
  662. <IconWarning color="warningText" />
  663. </Tooltip>
  664. </OnDemandContainer>
  665. );
  666. }
  667. const Actions = styled(ButtonBar)<{showAliasField?: boolean}>`
  668. grid-column: ${p => (p.showAliasField ? '1/-1' : ' 2/3')};
  669. justify-content: flex-start;
  670. `;
  671. const RowContainer = styled('div')<{
  672. singleColumn: boolean;
  673. showAliasField?: boolean;
  674. }>`
  675. display: grid;
  676. grid-template-columns: ${space(3)} 1fr 40px 40px;
  677. justify-content: center;
  678. align-items: center;
  679. width: 100%;
  680. touch-action: none;
  681. padding-bottom: ${space(1)};
  682. ${p =>
  683. p.showAliasField &&
  684. css`
  685. align-items: flex-start;
  686. grid-template-columns: ${p.singleColumn ? `1fr` : `${space(3)} 1fr 40px 40px`};
  687. @media (min-width: ${p.theme.breakpoints.small}) {
  688. grid-template-columns: ${p.singleColumn
  689. ? `1fr calc(200px + ${space(1)})`
  690. : `${space(3)} 1fr calc(200px + ${space(1)}) 40px 40px`};
  691. }
  692. `};
  693. `;
  694. const Ghost = styled('div')`
  695. background: ${p => p.theme.background};
  696. display: block;
  697. position: absolute;
  698. padding: ${GHOST_PADDING}px;
  699. border-radius: ${p => p.theme.borderRadius};
  700. box-shadow: 0 0 15px rgba(0, 0, 0, 0.15);
  701. width: 710px;
  702. opacity: 0.8;
  703. cursor: grabbing;
  704. padding-right: ${space(2)};
  705. & > ${RowContainer} {
  706. padding-bottom: 0;
  707. }
  708. & svg {
  709. cursor: grabbing;
  710. }
  711. `;
  712. const OnDemandContainer = styled('div')`
  713. display: flex;
  714. align-items: center;
  715. justify-content: center;
  716. height: 100%;
  717. `;
  718. const DragPlaceholder = styled('div')`
  719. margin: 0 ${space(3)} ${space(1)} ${space(3)};
  720. border: 2px dashed ${p => p.theme.border};
  721. border-radius: ${p => p.theme.borderRadius};
  722. height: ${p => p.theme.form.md.height}px;
  723. `;
  724. const Heading = styled('div')<{gridColumns: number}>`
  725. grid-column: 2 / 3;
  726. /* Emulate the grid used in the column editor rows */
  727. display: grid;
  728. grid-template-columns: repeat(${p => p.gridColumns}, 1fr);
  729. grid-column-gap: ${space(1)};
  730. `;
  731. const StyledSectionHeading = styled(SectionHeading)`
  732. margin: 0;
  733. `;
  734. const AliasInput = styled(Input)`
  735. min-width: 50px;
  736. `;
  737. const AliasField = styled('div')<{singleColumn: boolean}>`
  738. margin-top: ${space(1)};
  739. @media (min-width: ${p => p.theme.breakpoints.small}) {
  740. margin-top: 0;
  741. margin-left: ${space(1)};
  742. }
  743. @media (max-width: ${p => p.theme.breakpoints.small}) {
  744. grid-row: 2/2;
  745. grid-column: ${p => (p.singleColumn ? '1/-1' : '2/2')};
  746. }
  747. `;
  748. const RemoveButton = styled(Button)`
  749. margin-left: ${space(1)};
  750. height: ${p => p.theme.form.md.height}px;
  751. `;
  752. const DragAndReorderButton = styled(Button)`
  753. height: ${p => p.theme.form.md.height}px;
  754. `;
  755. export default ColumnEditCollection;