columnEditCollection.tsx 25 KB

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