import React, { useCallback } from 'react'; import 'twin.macro'; import AutoSizer from 'react-virtualized-auto-sizer'; // @ts-ignore import { extent, scaleLinear, bisectLeft } from 'd3'; import { FilterValue, FilterMap, CategoryValue } from '../types'; import { StickyGrid } from './sticky-grid'; import { Header } from './header'; import { Cell } from './cell'; import { Loader } from './loader'; import { useGridStore } from './store-wrapper'; import { cellTypeMap } from '../store'; import { ArrowRightIcon, ArrowLeftIcon, DiffModifiedIcon, DownloadIcon, SyncIcon, } from '@primer/octicons-react'; import fromPairs from 'lodash/fromPairs'; import { TwStyle } from 'twin.macro'; interface ScrollRefType { current: number; } interface GridStateObject { stickyColumnName?: string; columnNames: string[]; filteredData: any[]; diffs: object[]; filters: FilterMap; sort: string[]; schema?: object; } export interface GridProps { data: any[]; diffData?: any[]; metadata?: Record; canDownload?: Boolean; defaultFilters?: FilterMap; defaultSort?: string[]; defaultStickyColumnName?: string; onChange?: (currentState: GridStateObject) => void; downloadFilename?: string; isEditable?: boolean; onEdit?: (newData: any[]) => void; } export function Grid(props: GridProps) { const { downloadFilename, canDownload = true } = props; const [focusedColumnIndex, setFocusedColumnIndex] = React.useState(); const [highlightedDiffIndex, setHighlightedDiffIndex] = React.useState< number >(); const currentScrollYOffset = React.useRef(); // const [showFilters, setShowFilters] = React.useState(true); const showFilters = true; const { data, columnNames, handleDataChange, handleDiffDataChange, uniqueColumnName, diffs, stickyColumnName, sort, filteredData, filters, focusedRowIndex, handleFocusedRowIndexChange, handleMetadataChange, handleFiltersChange, updateFilteredColumns, updateColumnNames, handleSortChange, handleStickyColumnNameChange, columnWidths, updateColumnWidths, schema, cellTypes, handleIsEditableChange, updatedData, focusedCellPosition, } = useGridStore(state => state); React.useEffect(() => { handleDataChange(props.data); if (!ref.current) return; // preserve scroll position if (focusedCellPosition) return // @ts-ignore ref.current?.scrollToItem({ columnIndex: 0, rowIndex: 0, align: 'center', }); }, [props.data]); React.useEffect(() => { if (!focusedCellPosition) return; if (!ref.current) return; // @ts-ignore const numberOfStickiedColumns = ref.current.props.numberOfStickiedColumns // @ts-ignore const left = ref.current.state.scrollLeft let columnIndex = focusedCellPosition[1] const sum = (array: number[]) => array.reduce((a, b) => a + b, 0) const stickyColumnWidth = sum(columnWidths.slice(0, numberOfStickiedColumns)) const unstickyLeft = left + stickyColumnWidth const leftTarget = sum(columnWidths.slice(0, columnIndex)) let numberOfColumnsToOffsetStickyColumn = 0 let xOffset = sum(columnWidths.slice(columnIndex - numberOfColumnsToOffsetStickyColumn, columnIndex)) while (leftTarget < unstickyLeft && xOffset < stickyColumnWidth && columnIndex > 0) { numberOfColumnsToOffsetStickyColumn += 1 xOffset = sum(columnWidths.slice(columnIndex - numberOfColumnsToOffsetStickyColumn, columnIndex)) } columnIndex -= numberOfColumnsToOffsetStickyColumn // @ts-ignore const rowHeight = ref.current.props.rowHeight(1) // @ts-ignore const headerHeight = ref.current.props.rowHeight(0) // @ts-ignore const top = ref.current.state.scrollTop // @ts-ignore const footerHeight = rowHeight // @ts-ignore const maxHeight = ref.current.props.height - footerHeight let rowIndex = focusedCellPosition[0] const topTarget = headerHeight + rowHeight * rowIndex if (topTarget > top + maxHeight) { rowIndex += 1 } // @ts-ignore ref.current.scrollToItem({ rowIndex, columnIndex, align: 'nearest', }); }, [focusedCellPosition]); React.useEffect(() => { if (props.metadata) handleMetadataChange(props.metadata); }, [props.metadata]); React.useEffect(() => { if (props.diffData) handleDiffDataChange(props.diffData); }, [props.diffData, props.data]); React.useEffect(() => { if (props.defaultFilters) handleFiltersChange(props.defaultFilters); }, [encodeFilterString(props.defaultFilters), props.data]); React.useEffect(() => { if (props.defaultSort) handleSortChange(props.defaultSort[0], props.defaultSort[1]); }, [props.defaultSort?.join(',')]); React.useEffect(updateColumnNames, [props.data, stickyColumnName]); React.useEffect(() => { if (props.defaultStickyColumnName) handleStickyColumnNameChange(props.defaultStickyColumnName); }, [props.defaultStickyColumnName]); React.useEffect(() => { handleIsEditableChange(!!props.isEditable); }, [props.isEditable]); React.useEffect(() => { if (updatedData === null) return if (!props.onEdit || !props.isEditable) return; props.onEdit(updatedData); }, [updatedData]); React.useEffect(updateFilteredColumns, [data, filters, sort]); React.useEffect(() => { if (typeof props.onChange !== 'function') return; if (!schema) return; const currentState = { stickyColumnName, columnNames, filteredData, diffs, filters, sort, schema, }; props.onChange(currentState); }, [sort, stickyColumnName, encodeFilterString(filters)]); const scrollToTop = () => { // @ts-ignore ref?.current?.scrollToItem({ rowIndex: 0 }); }; React.useEffect(scrollToTop, [sort.join(",")]); const isFiltered = Object.keys(filters).length > 0; React.useEffect(updateColumnWidths, [columnNames, data]); const filteredDataWithOptionalEmptyRows = React.useMemo(() => { let res = [...filteredData] if (props.isEditable) { const emptyRows = new Array(numberOfExtraRowsWhenEditing).fill(null).map(() => ({})) res = [...res, ...emptyRows] } return res }, [filteredData, props.isEditable]) const columnWidthCallback = React.useCallback(i => columnWidths[i] || 150, [ columnWidths.join(','), ]); const rowHeightCallback = React.useCallback(i => (i ? 40 : 117), []); const columnNamesWithOptionalEmptyColumn = React.useMemo(() => { let res = [...columnNames] if (props.isEditable) { res = [...res, "__new-blank-column__"] } return res }, [columnNames, props.isEditable]) const columnWidthsWithOptionalEmptyColumn = React.useMemo(() => { let res = [...columnWidths] if (props.isEditable) { res = [...res, columnWidthCallback(columnWidths.length)] } return res }, [columnNames, props.isEditable]) const columnScales = React.useMemo(() => { let scales = {}; columnNamesWithOptionalEmptyColumn.forEach((columnName: string) => { // @ts-ignore const cellType = cellTypes[columnName]; // @ts-ignore const cellInfo = cellTypeMap[cellType] || {}; if (!cellInfo.hasScale) return; const scale = scaleLinear() // @ts-ignore .domain(extent(data, (d: object) => d[columnName])) // @ts-ignore .range(['rgba(200,200,200,0)', 'rgba(224,231,255,1)']); // @ts-ignore scales[columnName] = scale; }); return scales; }, [data]); interface AutoSizerType { height: number; width: number; } // @ts-ignore const positiveDiffs = diffs.filter(d => d.__status__ === 'new'); // @ts-ignore const negativeDiffs = diffs.filter(d => d.__status__ === 'old'); // @ts-ignore const modifiedDiffs = diffs.filter(d => d.__status__ === 'modified'); const ref = useRespondToColumnChange([columnWidthsWithOptionalEmptyColumn]); const handleHighlightDiffChange = (delta: number = 0) => { let newHighlight = 0; if ( typeof highlightedDiffIndex !== 'number' && typeof currentScrollYOffset.current === 'number' ) { if (currentScrollYOffset.current === 0) { newHighlight = diffs.length; } else { const currentRowIndex = Math.round((currentScrollYOffset.current - 117) / 40) + 6; const nearestDiffIndex = bisectLeft( // @ts-ignore diffs.map(d => d.__rowIndex__), currentRowIndex ); newHighlight = delta < 0 ? nearestDiffIndex - 1 : nearestDiffIndex; } } else { newHighlight = ((highlightedDiffIndex || 0) + delta) % diffs.length; } if (newHighlight < 0) newHighlight = diffs.length + newHighlight; setHighlightedDiffIndex(newHighlight); const highlightedDiff = diffs[newHighlight] || {}; if (!uniqueColumnName) return; const rowIndex = filteredData.findIndex( // @ts-ignore d => d[uniqueColumnName] === highlightedDiff[uniqueColumnName] ); if (!ref.current) return; // @ts-ignore ref.current.scrollToItem({ // columnIndex: 0, rowIndex: rowIndex, align: 'center', }); handleFocusedRowIndexChange(rowIndex); setFocusedColumnIndex(undefined); }; interface ScrollType { scrollTop: number; scrollUpdateWasRequested: boolean; } const onScroll = (scrollInfo: ScrollType) => { const { scrollTop, scrollUpdateWasRequested } = scrollInfo; if (scrollUpdateWasRequested) return; // @ts-ignore currentScrollYOffset.current = scrollTop; if (typeof highlightedDiffIndex !== 'number') return; setHighlightedDiffIndex(undefined); }; const handleDownloadJson = () => { var dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent( JSON.stringify( filteredData.map(d => fromPairs( columnNames.map(columnName => [ columnName, d['__rawData__'][columnName] || d[columnName], ]) ) ) ) ); const link = document.createElement('a'); link.setAttribute('href', dataStr); const date = new Date().toDateString(); link.setAttribute( 'download', `${downloadFilename || `flat-ui__data-${date}`}.json` ); document.body.appendChild(link); link.click(); document.body.removeChild(link); }; const handleDownloadCsv = () => { let csvContent = [ columnNames.map(columnName => columnName), filteredData .map(d => columnNames .map(columnName => { // @ts-ignore const cellType = cellTypes[columnName]; // @ts-ignore const data = d['__rawData__'][columnName] || d[columnName]; let formattedData = typeof data === 'object' ? JSON.stringify(data) : data; if ( typeof formattedData === 'string' && (formattedData.includes('"') || formattedData.includes(',') || formattedData.includes('\n')) ) { formattedData = `"${formattedData.replace(/"/g, '""')}"`; } return formattedData; }) .join(',') ) .join('\n'), ].join('\n'); var blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); var url = URL.createObjectURL(blob); const link = document.createElement('a'); link.setAttribute('href', url); const date = new Date().toDateString(); link.setAttribute( 'download', `${downloadFilename || `flat-ui__data-${date}`}.csv` ); document.body.appendChild(link); // Required for FF link.click(); document.body.removeChild(link); }; if (!schema) return (
Loading...
); if (!Object.keys(schema).length) return (
No valid data
); return (
{/*
Show Filters
*/}
handleFocusedRowIndexChange(undefined)} > {({ height, width }: AutoSizerType) => ( { // return filteredData[rowIndex].LongName; // }} HeaderComponent={HeaderWrapper} > {CellWrapper} )} {!!Object.keys(filters).length && !filteredData.length && (
No data with those filters
)}
{!!diffs.length && ( <> Changes:
{!!positiveDiffs.length && (
+{positiveDiffs.length} row {positiveDiffs.length === 1 ? '' : 's'}
)} {!!modifiedDiffs.length && (
{modifiedDiffs.length} row {modifiedDiffs.length === 1 ? '' : 's'}
)} {!!negativeDiffs.length && (
-{negativeDiffs.length} row {negativeDiffs.length === 1 ? '' : 's'}
)}
{typeof highlightedDiffIndex === 'number' ? highlightedDiffIndex + 1 : ''}
)}
Showing {filteredData.length.toLocaleString()} {isFiltered && ` of ${data.length.toLocaleString()}`} row {data.length === 1 ? '' : 's'} × {columnNames.length.toLocaleString()} column{columnNames.length === 1 ? '' : 's'}
{canDownload && ( )} {isFiltered && ( )}
); } const numberOfExtraRowsWhenEditing = 1 interface StyleObject { width?: number; top?: number; left?: number; marginTop?: number; marginLeft?: number; position?: number; display?: number; } interface CellPropsData { originalData: any[]; filteredData: any[]; focusedRowIndex?: number; showFilters: boolean; sort: string[]; setFocusedRowIndex: Function; setFocusedColumnIndex: Function; focusedColumnIndex?: number; columnScales: Function[]; } interface CellProps { columnIndex: number; rowIndex: number; data: CellPropsData; style: StyleObject; } const CellWrapper = function (props: CellProps) { const { rowIndex: rawRowIndex, columnIndex, data, style } = props; const { focusedColumnIndex, setFocusedColumnIndex, columnScales } = data; const { columnNames, filteredData, categoryValues, focusedRowIndex, handleFocusedRowIndexChange, cellTypes, isEditable, onCellChange, onRowDelete, focusedCellPosition, handleFocusedCellPositionChange, } = useGridStore(); const name = columnNames[columnIndex]; const rowIndex = rawRowIndex - 1; const onCellChangeLocal = useCallback((value: any) => { onCellChange(rowIndex, name, value); }, [onCellChange, rowIndex, name]); const onRowDeleteLocal = useCallback(() => { onRowDelete(rowIndex); }, [onRowDelete, rowIndex]); const onFocusChangeLocal = useCallback((diff: [number, number] | null) => { if (!diff) { handleFocusedCellPositionChange(null); } else { const [diffRow, diffColumn] = diff const newRowIndex = Math.max(0, Math.min(rowIndex + diffRow, filteredData.length - 1 + (isEditable ? numberOfExtraRowsWhenEditing : 0))) const newColumnIndex = Math.max(0, Math.min(columnIndex + diffColumn, columnNames.length - 1)) const newPosition = [ newRowIndex, newColumnIndex, ] as [number, number]; handleFocusedCellPositionChange(newPosition); } }, [rowIndex, columnIndex, filteredData, isEditable]) const onMouseEnter = useCallback(() => { setFocusedColumnIndex(columnIndex); handleFocusedRowIndexChange(rowIndex); }, [columnIndex, handleFocusedRowIndexChange, rowIndex, setFocusedColumnIndex]) if (rowIndex == -1) { return ; } // @ts-ignore const type = cellTypes[name] const cellData = filteredData[rowIndex] || { [name]: "" } // if (!cellData) return null; const value = cellData[name]; const rawValue = cellData['__rawData__']?.[name]; // @ts-ignore const formattedValue = cellTypeMap[type || ""]?.format?.(value, rawValue) || value; let possibleValues = type === 'category' ? categoryValues[name] : []; const possibleValue = possibleValues?.find(d => d.value === value); const categoryColor = possibleValue?.color; let status = cellData.__status__; if (status === 'modified') { const modifiedColumnNames = cellData.__modifiedColumnNames__ || []; status = modifiedColumnNames.includes(name) ? 'modified' : 'modified-row'; } // @ts-ignore® const scale = columnScales && columnScales[name]; const statusColors = new Map([ ['new', '#ECFDF5'], ['old', '#FDF2F8'], ['modified', '#FEFBEB'], ]); const focusedStatusColors = new Map([ ['new', '#D1FBE5'], ['old', '#FBE7F3'], ['modified', '#FEF2C7'], ]); const statusColor = focusedRowIndex == rowIndex ? focusedStatusColors.get(status) : statusColors.get(status); // prettier-ignore const backgroundColor = focusedColumnIndex == columnIndex && scale ? scale(value) : statusColor ? statusColor : focusedRowIndex == rowIndex ? '#f3f4f6' : '#fff'; return ( columnNames.length - 3} isNearBottomEdge={rowIndex > filteredData.length - 3} isEditable={isEditable} isFocused={!!(focusedCellPosition && focusedCellPosition[0] === rowIndex && focusedCellPosition[1] === columnIndex)} onFocusChange={onFocusChangeLocal} onCellChange={onCellChangeLocal} onRowDelete={onRowDeleteLocal} onMouseEnter={onMouseEnter} /> ); }; interface CellComputedProps { type: string; value: any; rawValue: any; formattedValue: any; style: StyleObject; background?: string; categoryColor?: string | TwStyle; status?: string; isFirstColumn: boolean; isExtraBlankRow: boolean; isNearRightEdge?: boolean; isNearBottomEdge?: boolean; isEditable: boolean; onCellChange: (value: any) => void; onRowDelete: () => void; isFocused: boolean; onFocusChange: (value: [number, number] | null) => void; onMouseEnter?: Function; } const CellWrapperComputed = React.memo( function (props: CellComputedProps) { return ; }, (props, newProps) => { if (props.value != newProps.value) return false; if (props.type != newProps.type) return false; if (props.background != newProps.background) return false; if (props.style != newProps.style) return false; if (props.categoryColor != newProps.categoryColor) return false; if (props.status != newProps.status) return false; if (props.isNearRightEdge != newProps.isNearRightEdge) return false; if (props.isNearBottomEdge != newProps.isNearBottomEdge) return false; if (props.isExtraBlankRow != newProps.isExtraBlankRow) return false; if (props.isEditable != newProps.isEditable) return false; if (props.isFirstColumn != newProps.isFirstColumn) return false; if (props.isFocused != newProps.isFocused) return false; if (props.style.left != newProps.style.left) return false; if (props.style.top != newProps.style.top) return false; if (props.style.position != newProps.style.position) return false; if (props.style.display != newProps.style.display) return false; if (props.style.marginTop != newProps.style.marginTop) return false; if (props.style.marginLeft != newProps.style.marginLeft) return false; return true; } ); const HeaderWrapper = function (props: CellProps) { const { columnIndex, data, style } = props; const { data: originalData, columnNames, columnWidths, stickyColumnName, handleStickyColumnNameChange, filters, handleFilterChange, filteredData, metadata, sort, categoryValues, handleSortChange, focusedRowIndex, cellTypes, isEditable, handleFocusedCellPositionChange, onHeaderCellChange, onHeaderDelete, onHeaderAdd, } = useGridStore(); const columnNameRef = React.useRef(''); const { showFilters } = data; const columnName = columnNames[columnIndex]; columnNameRef.current = columnName; const columnWidth = columnWidths[columnIndex]; // @ts-ignore const cellType = cellTypes[columnName] || "string" // @ts-ignore const cellInfo = cellTypeMap[cellType] || {} const onHeaderCellChangeLocal = useCallback((value: any) => { onHeaderCellChange(columnName, value); }, [onHeaderCellChange, columnName]); const onHeaderDeleteLocal = useCallback(() => { onHeaderDelete(columnName); }, [onHeaderDelete, columnName]); const onHeaderAddLocal = useCallback((newColumnName: string) => { onHeaderAdd(newColumnName); handleFocusedCellPositionChange([ 0, columnNames.length, ]) }, [onHeaderAdd, handleFocusedCellPositionChange, columnNames]); const onSticky = useCallback(() => { handleStickyColumnNameChange(columnName) }, [handleStickyColumnNameChange, columnName]) const onFilterChange = useCallback((value: FilterValue) => { handleFilterChange(columnNameRef.current, value); }, [handleFilterChange, columnNameRef]) const maxColumns = isEditable ? columnNames.length + 1 : columnNames.length; if (columnIndex >= maxColumns) return null; const isNewColumn = isEditable && columnIndex === columnNames.length; const focusedValue = typeof focusedRowIndex == 'number' && filteredData[0] ? (filteredData[focusedRowIndex] || {})[columnName] : undefined; const activeSortDirection = sort[0] == columnName ? sort[1] : undefined; const isSticky = stickyColumnName === columnName; let possibleValues = cellType === 'category' ? categoryValues[columnName] : undefined; return ( ); }; interface HeaderComputedProps { style: StyleObject; cellInfo: object; cellType: string; columnName: string; width?: number; activeSortDirection?: string; metadata?: string; originalData: any[]; filteredData: any[]; possibleValues?: CategoryValue[]; filter?: FilterValue; focusedValue?: number; showFilters: boolean; isFirstColumn: boolean; isSticky: boolean; isNewColumn: boolean; isEditable: boolean; onChange: (value: any) => void; onDelete: () => void; onAdd: (name: string) => void; onFilterChange: Function; onSort: Function; onSticky: Function; } const HeaderWrapperComputed = React.memo( function (props: HeaderComputedProps) { return
; }, (props, newProps) => { if (props.cellType != newProps.cellType) return false; if (props.columnName != newProps.columnName) return false; if (props.activeSortDirection != newProps.activeSortDirection) return false; if (props.filteredData != newProps.filteredData) return false; if (props.filter != newProps.filter) return false; if (props.width != newProps.width) return false; if (props.isSticky != newProps.isSticky) return false; if (props.isNewColumn != newProps.isNewColumn) return false; if (props.isEditable != newProps.isEditable) return false; if (props.focusedValue != newProps.focusedValue) return false; if (props.style.width != newProps.style.width) return false; if (props.style.left != newProps.style.left) return false; if (props.style.top != newProps.style.top) return false; if (props.style.position != newProps.style.position) return false; if (props.style.display != newProps.style.display) return false; if (props.style.marginTop != newProps.style.marginTop) return false; if (props.style.marginLeft != newProps.style.marginLeft) return false; return true; } ); function useRespondToColumnChange(deps: any[]) { const ref = React.useRef(); React.useEffect(() => { if (ref.current) { // @ts-ignore ref.current.resetAfterIndices({ columnIndex: 0, rowIndex: 0, shouldForceUpdate: true, }); } }, deps); return ref; } export function encodeFilterString(filters?: Record) { if (!filters) return ''; return encodeURI( Object.keys(filters) .map(columnName => { const value = filters[columnName]; return [ columnName, typeof value === 'string' ? value : Array.isArray(value) ? value.join(',') : '', ].join('='); }) .join('&') ); }