columnEditCollection.tsx 24 KB

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