/* eslint-disable jsx-a11y/no-noninteractive-tabindex */ /* eslint-disable jsx-a11y/no-static-element-interactions */ import { Button, Colors, Type } from 'browndash-components'; import { IReactionDisposer, action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { ClientUtils, setupMoveUpEvents } from '../../../../../ClientUtils'; import { emptyFunction } from '../../../../../Utils'; import { Doc, Field, NumListCast } from '../../../../../fields/Doc'; import { DocData } from '../../../../../fields/DocSymbols'; import { List } from '../../../../../fields/List'; import { listSpec } from '../../../../../fields/Schema'; import { Cast, DocCast } from '../../../../../fields/Types'; import { DragManager } from '../../../../util/DragManager'; import { undoable } from '../../../../util/UndoManager'; import { ObservableReactComponent } from '../../../ObservableReactComponent'; import { DocumentView } from '../../DocumentView'; import { DataVizView } from '../DataVizBox'; import './Chart.scss'; const { DATA_VIZ_TABLE_ROW_HEIGHT } = require('../../../global/globalCssVariables.module.scss'); // prettier-ignore interface TableBoxProps { Document: Doc; layoutDoc: Doc; records: { [key: string]: unknown }[]; selectAxes: (axes: string[]) => void; selectTitleCol: (titleCol: string) => void; axes: string[]; titleCol: string; width: number; height: number; margin: { top: number; right: number; bottom: number; left: number; }; docView?: () => DocumentView | undefined; specHighlightedRow: number | undefined; } @observer export class TableBox extends ObservableReactComponent { _inputChangedDisposer?: IReactionDisposer; _containerRef: HTMLDivElement | null = null; @observable settingTitle: boolean = false; // true when setting a title column @observable hasRowsToFilter: boolean = false; // true when any rows are selected @observable filtering: boolean = false; // true when the filtering menu is open @observable filteringColumn = ''; // column to filter @observable filteringType: string = 'Value'; // "Value" or "Range" filteringVal = ['', '']; // value or range to filter the column with @observable _scrollTop = -1; @observable _tableHeight = 0; @observable _tableContainerHeight = 0; constructor(props: TableBoxProps) { super(props); makeObservable(this); } componentDidMount() { // if the tableData changes (ie., when records are selected by the parent (input) visulization), // then we need to remove any selected rows that are no longer part of the visualized dataset. this._inputChangedDisposer = reaction(() => this._tableData.slice(), this.filterSelectedRowsDown, { fireImmediately: true }); const selected = NumListCast(this._props.layoutDoc.dataViz_selectedRows); if (selected.length > 0) runInAction(() => { this.hasRowsToFilter = true; }); this.handleScroll(); } componentWillUnmount() { this._inputChangedDisposer?.(); } @computed get _tableDataIds() { return !this.parentViz ? this._props.records.map((rec, i) => i) : NumListCast(this.parentViz.dataViz_selectedRows); } // returns all the data records that will be rendered by only returning those records that have been selected by the parent visualization (or all records if there is no parent) @computed get _tableData() { return !this.parentViz ? this._props.records : this._tableDataIds.map(rowId => this._props.records[rowId]); } @computed get parentViz() { return DocCast(this._props.Document.dataViz_parentViz); } @computed get columns() { return this._tableData.length ? Array.from(Object.keys(this._tableData[0])).filter(header => header !== '' && header !== undefined) : []; } // updates the 'dataViz_selectedRows' and 'dataViz_highlightedRows' fields to no longer include rows that aren't in the table filterSelectedRowsDown = () => { const selected = NumListCast(this._props.layoutDoc.dataViz_selectedRows); this._props.layoutDoc.dataViz_selectedRows = new List(selected.filter(rowId => this._tableDataIds.includes(rowId))); // filters through selected to remove guids that were removed in the incoming data const highlighted = NumListCast(this._props.layoutDoc.dataViz_highlitedRows); this._props.layoutDoc.dataViz_highlitedRows = new List(highlighted.filter(rowId => this._tableDataIds.includes(rowId))); // filters through highlighted to remove guids that were removed in the incoming data }; @computed get viewScale() { return this._props.docView?.()?.screenToViewTransform().Scale || 1; } @computed get rowHeight() { return (this.viewScale * this._tableHeight) / this._tableDataIds.length; } @computed get startID() { return this.rowHeight ? Math.max(Math.floor(this._scrollTop / this.rowHeight) - 1, 0) : 0; } @computed get endID() { return Math.ceil(this.startID + (this._tableContainerHeight * this.viewScale) / (this.rowHeight || 1)); } @action handleScroll = () => { if (!this._props.docView?.()?.ContentDiv?.hidden) { this._scrollTop = this._containerRef?.scrollTop ?? 0; } }; @action tableRowClick = (e: React.MouseEvent, rowId: number) => { const highlited = Cast(this._props.layoutDoc.dataViz_highlitedRows, listSpec('number'), null); const selected = Cast(this._props.layoutDoc.dataViz_selectedRows, listSpec('number'), null); if (e.metaKey) { // highlighting a row if (highlited?.includes(rowId)) highlited.splice(highlited.indexOf(rowId), 1); else highlited?.push(rowId); if (!selected?.includes(rowId)) selected?.push(rowId); } else if (selected?.includes(rowId)) { // selecting a row if (highlited?.includes(rowId)) highlited.splice(highlited.indexOf(rowId), 1); selected.splice(selected.indexOf(rowId), 1); } else selected?.push(rowId); e.stopPropagation(); this.hasRowsToFilter = selected.length > 0; }; columnPointerDown = (e: React.PointerEvent, col: string) => { const downX = e.clientX; const downY = e.clientY; setupMoveUpEvents( {}, e, moveEv => { // dragging off a column to create a brushed DataVizBox const sourceAnchorCreator = () => this._props.docView?.()?.Document || this._props.Document; const targetCreator = (annotationOn: Doc | undefined) => { const doc = this._props.docView?.()?.Document; if (doc) { const embedding = Doc.MakeEmbedding(doc); embedding._dataViz = DataVizView.TABLE; embedding._dataViz_axes = new List([col]); embedding._dataViz_parentViz = this._props.Document; embedding.annotationOn = annotationOn; embedding.histogramBarColors = Field.Copy(this._props.layoutDoc.histogramBarColors); embedding.defaultHistogramColor = this._props.layoutDoc.defaultHistogramColor; embedding.pieSliceColors = Field.Copy(this._props.layoutDoc.pieSliceColors); return embedding; } return this._props.Document; }; if (this._props.docView?.() && !ClientUtils.isClick(moveEv.clientX, moveEv.clientY, downX, downY, Date.now())) { DragManager.StartAnchorAnnoDrag(moveEv.target instanceof HTMLElement ? [moveEv.target] : [], new DragManager.AnchorAnnoDragData(this._props.docView()!, sourceAnchorCreator, targetCreator), downX, downY, { dragComplete: completeEv => { if (!completeEv.aborted && completeEv.annoDragData && completeEv.annoDragData.linkSourceDoc && completeEv.annoDragData.dropDocument && completeEv.linkDocument) { completeEv.linkDocument[DocData].link_matchEmbeddings = true; completeEv.linkDocument[DocData].stroke_startMarker = true; this._props.docView?.()?._props.addDocument?.(completeEv.linkDocument); } }, }); return true; } return false; }, emptyFunction, action(moveEv => { if (moveEv.shiftKey || this.settingTitle) { if (this.settingTitle) this.settingTitle = false; if (this._props.titleCol === col) this._props.titleCol = ''; else this._props.titleCol = col; this._props.selectTitleCol(this._props.titleCol); } else { const newAxes = this._props.axes; if (newAxes.includes(col)) newAxes.splice(newAxes.indexOf(col), 1); else newAxes.push(col); this._props.selectAxes(newAxes); } }) ); }; /** * These functions handle the filtering popup for when the "filter" button is pressed to select rows */ filter = undoable((e: React.MouseEvent) => { let start: string | number; let end: string | number; if (this.filteringType === 'Range') { start = Number.isNaN(Number(this.filteringVal[0])) ? this.filteringVal[0] : Number(this.filteringVal[0]); end = Number.isNaN(Number(this.filteringVal[1])) ? this.filteringVal[1] : Number(this.filteringVal[1]); } this._tableDataIds.forEach(rowID => { if (this.filteringType === 'Value') { if (this._props.records[rowID][this.filteringColumn] === this.filteringVal[0]) { if (!NumListCast(this._props.layoutDoc.dataViz_selectedRows).includes(rowID)) { this.tableRowClick(e, rowID); } } } else { let compare = this._props.records[rowID][this.filteringColumn] as string | number; if (Number(compare) == compare) compare = Number(compare); if (start <= compare && compare <= end) { if (!NumListCast(this._props.layoutDoc.dataViz_selectedRows).includes(rowID)) { this.tableRowClick(e, rowID); } } } }); this.filtering = false; this.filteringColumn = ''; this.filteringVal = ['', '']; }, 'filter table'); @action setFilterColumn = (e: React.ChangeEvent) => { this.filteringColumn = e.currentTarget.value; }; @action setFilterType = (e: React.ChangeEvent) => { this.filteringType = e.currentTarget.value; }; changeFilterValue = action((e: React.ChangeEvent) => { this.filteringVal[0] = e.target.value; }); changeFilterRange0 = action((e: React.ChangeEvent) => { this.filteringVal[0] = e.target.value; }); changeFilterRange1 = action((e: React.ChangeEvent) => { this.filteringVal[1] = e.target.value; }); @computed get renderFiltering() { if (this.filteringColumn === '') [this.filteringColumn] = this.columns; return (
Column:
: {this.filteringType === 'Value' ? ( { e.stopPropagation(); }} type="text" placeholder="" id="search-input" /> ) : (
{ e.stopPropagation(); }} type="text" placeholder="" id="search-input" style={{ width: this._props.width * 0.15 }} /> to { e.stopPropagation(); }} type="text" placeholder="" id="search-input" style={{ width: this._props.width * 0.15 }} />
)}
); } render() { if (this._tableData.length > 0) { return (
{ if (this._props.layoutDoc && e.key === 'a' && (e.ctrlKey || e.metaKey)) { e.stopPropagation(); this._props.layoutDoc.dataViz_selectedRows = new List(this._tableDataIds); } }}>
{this.filtering ? this.renderFiltering : null}
{ this._containerRef = r; if (!this._props.docView?.()?.ContentDiv?.hidden && r) { this._tableContainerHeight = r.getBoundingClientRect().height ?? 0; r.addEventListener( 'wheel', // if scrollTop is 0, then don't let wheel trigger scroll on any container (which it would since onScroll won't be triggered on this) (e: WheelEvent) => { if (!r.scrollTop && e.deltaY <= 0) e.preventDefault(); e.stopPropagation(); }, { passive: false } ); } })}> { if (!this._props.docView?.()?.ContentDiv?.hidden && r) { this._tableHeight = r?.getBoundingClientRect().height ?? 0; } })}>
{this.columns.map(col => ( ))} {this._tableDataIds .filter((rowId, i) => this.startID - 2 <= i && i <= this.endID + 2) ?.map(rowId => ( this.tableRowClick(e, rowId)} style={{ background: rowId === this._props.specHighlightedRow ? 'lightblue' : NumListCast(this._props.layoutDoc.dataViz_highlitedRows).includes(rowId) ? 'lightYellow' : NumListCast(this._props.layoutDoc.dataViz_selectedRows).includes(rowId) ? 'lightgrey' : '', border: rowId === this._props.specHighlightedRow ? `solid 3px ${Colors.MEDIUM_BLUE}` : '' }}> {this.columns.map(col => { let colSelected = false; if (this._props.axes.length > 2) colSelected = this._props.axes[0] === col || this._props.axes[1] === col || this._props.axes[2] === col; else if (this._props.axes.length > 1) colSelected = this._props.axes[0] === col || this._props.axes[1] === col; else if (this._props.axes.length > 0) colSelected = this._props.axes[0] === col; if (this._props.titleCol === col) colSelected = true; return ( ); })} ))}
3 && this._props.axes.lastElement() === col ? 'darkred' : this._props.axes.length > 3 && this._props.axes[1] === col ? 'darkblue' : this._props.axes.lastElement() === col || (this._props.axes.length > 3 && this._props.axes[2] === col) ? 'darkcyan' : undefined, background: this.settingTitle ? 'lightgrey' : this._props.axes.slice().reverse().lastElement() === col ? '#E3fbdb' : this._props.axes.length > 2 && this._props.axes.lastElement() === col ? '#Fbdbdb' : this._props.axes.lastElement() === col || (this._props.axes.length > 2 && this._props.axes[1] === col) ? '#c6ebf7' : this._props.axes.lastElement() === col || (this._props.axes.length > 3 && this._props.axes[2] === col) ? '#c2f0f4' : undefined, fontWeight: 'bolder', border: '3px solid black', }} onPointerDown={e => this.columnPointerDown(e, col)}> {col}
{this._props.records[rowId][col] as string | number}
); } return ( // when it is a brushed table and the incoming table doesn't have any rows selected
Selected rows of data from the incoming DataVizBox to display.
); } }