columnEditCollection.tsx 26 KB

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