columnEditCollection.tsx 25 KB

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