columnEditCollection.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849
  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. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  543. AGGREGATIONS[col.function[0]] ?? SESSIONS_OPERATIONS[col.function[0]];
  544. if (!operation || !operation.parameters) {
  545. // Operation should be in the look-up table, but not all operations are (eg. private). This should be changed at some point.
  546. return 3;
  547. }
  548. return operation.parameters.length === 2 ? 3 : 2;
  549. })
  550. );
  551. return (
  552. <div className={className}>
  553. {this.renderGhost({gridColumns, singleColumn})}
  554. {!showAliasField && source !== WidgetType.ISSUE && (
  555. <RowContainer showAliasField={showAliasField} singleColumn={singleColumn}>
  556. <Heading gridColumns={gridColumns}>
  557. <StyledSectionHeading>{t('Tag / Field / Function')}</StyledSectionHeading>
  558. <StyledSectionHeading>{t('Field Parameter')}</StyledSectionHeading>
  559. </Heading>
  560. </RowContainer>
  561. )}
  562. {columns.map((col: Column, i: number) => {
  563. // Issue column in Issue widgets are fixed (cannot be changed or deleted)
  564. if (this.isFixedIssueColumn(i)) {
  565. return this.renderItem(col, i, {
  566. singleColumn,
  567. canDelete: false,
  568. canDrag,
  569. gridColumns,
  570. disabled: true,
  571. });
  572. }
  573. if (this.isRemainingReleaseHealthAggregate(i)) {
  574. return this.renderItem(col, i, {
  575. singleColumn,
  576. canDelete: false,
  577. canDrag,
  578. gridColumns,
  579. });
  580. }
  581. if (this.isFixedMetricsColumn(i)) {
  582. return this.renderItem(col, i, {
  583. singleColumn,
  584. canDelete: false,
  585. canDrag: false,
  586. gridColumns,
  587. });
  588. }
  589. return this.renderItem(col, i, {
  590. singleColumn,
  591. canDelete,
  592. canDrag,
  593. gridColumns,
  594. });
  595. })}
  596. <RowContainer showAliasField={showAliasField} singleColumn={singleColumn}>
  597. <Actions gap={1} showAliasField={showAliasField}>
  598. <Button
  599. size="sm"
  600. aria-label={t('Add a Column')}
  601. onClick={this.handleAddColumn}
  602. title={title}
  603. disabled={!canAdd}
  604. icon={<IconAdd isCircled />}
  605. >
  606. {t('Add a Column')}
  607. </Button>
  608. {supportsEquations && (
  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: any = [];
  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. // @ts-expect-error TS(7006): Parameter 'acc' implicitly has an 'any' type.
  637. (acc, tag) => {
  638. acc[`tag:${tag.key}`] = {
  639. label: tag.key,
  640. value: {
  641. kind: FieldValueKind.TAG,
  642. meta: {
  643. dataType: 'string',
  644. name: tag.key,
  645. },
  646. },
  647. };
  648. return acc;
  649. },
  650. {} as Record<string, FieldValueOption>
  651. );
  652. }, [data]);
  653. return <QueryField fieldOptions={fieldOptions} {...props} />;
  654. }
  655. function OnDemandEquationsWarning() {
  656. return (
  657. <OnDemandContainer>
  658. <Tooltip
  659. containerDisplayMode="inline-flex"
  660. title={t(
  661. `This is using indexed data because we don't routinely collect metrics for equations.`
  662. )}
  663. >
  664. <IconWarning color="warningText" />
  665. </Tooltip>
  666. </OnDemandContainer>
  667. );
  668. }
  669. const Actions = styled(ButtonBar)<{showAliasField?: boolean}>`
  670. grid-column: ${p => (p.showAliasField ? '1/-1' : ' 2/3')};
  671. justify-content: flex-start;
  672. `;
  673. const RowContainer = styled('div')<{
  674. singleColumn: boolean;
  675. showAliasField?: boolean;
  676. }>`
  677. display: grid;
  678. grid-template-columns: ${space(3)} 1fr 40px 40px;
  679. justify-content: center;
  680. align-items: center;
  681. width: 100%;
  682. touch-action: none;
  683. padding-bottom: ${space(1)};
  684. ${p =>
  685. p.showAliasField &&
  686. css`
  687. align-items: flex-start;
  688. grid-template-columns: ${p.singleColumn ? `1fr` : `${space(3)} 1fr 40px 40px`};
  689. @media (min-width: ${p.theme.breakpoints.small}) {
  690. grid-template-columns: ${p.singleColumn
  691. ? `1fr calc(200px + ${space(1)})`
  692. : `${space(3)} 1fr calc(200px + ${space(1)}) 40px 40px`};
  693. }
  694. `};
  695. `;
  696. const Ghost = styled('div')`
  697. background: ${p => p.theme.background};
  698. display: block;
  699. position: absolute;
  700. padding: ${GHOST_PADDING}px;
  701. border-radius: ${p => p.theme.borderRadius};
  702. box-shadow: 0 0 15px rgba(0, 0, 0, 0.15);
  703. width: 710px;
  704. opacity: 0.8;
  705. cursor: grabbing;
  706. padding-right: ${space(2)};
  707. & > ${RowContainer} {
  708. padding-bottom: 0;
  709. }
  710. & svg {
  711. cursor: grabbing;
  712. }
  713. `;
  714. const OnDemandContainer = styled('div')`
  715. display: flex;
  716. align-items: center;
  717. justify-content: center;
  718. height: 100%;
  719. `;
  720. const DragPlaceholder = styled('div')`
  721. margin: 0 ${space(3)} ${space(1)} ${space(3)};
  722. border: 2px dashed ${p => p.theme.border};
  723. border-radius: ${p => p.theme.borderRadius};
  724. height: ${p => p.theme.form.md.height}px;
  725. `;
  726. const Heading = styled('div')<{gridColumns: number}>`
  727. grid-column: 2 / 3;
  728. /* Emulate the grid used in the column editor rows */
  729. display: grid;
  730. grid-template-columns: repeat(${p => p.gridColumns}, 1fr);
  731. grid-column-gap: ${space(1)};
  732. `;
  733. const StyledSectionHeading = styled(SectionHeading)`
  734. margin: 0;
  735. `;
  736. const AliasInput = styled(Input)`
  737. min-width: 50px;
  738. `;
  739. const AliasField = styled('div')<{singleColumn: boolean}>`
  740. margin-top: ${space(1)};
  741. @media (min-width: ${p => p.theme.breakpoints.small}) {
  742. margin-top: 0;
  743. margin-left: ${space(1)};
  744. }
  745. @media (max-width: ${p => p.theme.breakpoints.small}) {
  746. grid-row: 2/2;
  747. grid-column: ${p => (p.singleColumn ? '1/-1' : '2/2')};
  748. }
  749. `;
  750. const RemoveButton = styled(Button)`
  751. margin-left: ${space(1)};
  752. height: ${p => p.theme.form.md.height}px;
  753. `;
  754. const DragAndReorderButton = styled(Button)`
  755. height: ${p => p.theme.form.md.height}px;
  756. `;
  757. export default ColumnEditCollection;