diff --git a/src/components/cell.tsx b/src/components/cell.tsx index 57f0d77..6f2884c 100644 --- a/src/components/cell.tsx +++ b/src/components/cell.tsx @@ -17,11 +17,13 @@ interface CellProps { status?: string; isNearRightEdge?: boolean; isNearBottomEdge?: boolean; - isFirstColumn?: boolean; + isFirstColumn: boolean; + isExtraBlankRow: boolean; hasStatusIndicator?: boolean; background?: string; isEditable: boolean; onCellChange?: (value: any) => void; + onRowDelete?: () => void; isFocused: boolean; onFocusChange: (value: [number, number] | null) => void; onMouseEnter?: Function; @@ -35,10 +37,12 @@ export const Cell = React.memo(function (props: CellProps) { categoryColor, status, isFirstColumn, + isExtraBlankRow, isNearRightEdge, isNearBottomEdge, isEditable, onCellChange, + onRowDelete, isFocused, onFocusChange, background, @@ -114,9 +118,12 @@ export const Cell = React.memo(function (props: CellProps) { + isExtraBlankRow={isExtraBlankRow} + onFocusChange={onFocusChange} + onRowDelete={onRowDelete}>
void; + isFirstColumn: boolean; isFocused: boolean; + isExtraBlankRow: boolean; onFocusChange?: (value: [number, number] | null) => void; + onRowDelete?: () => void; children: any; } export const EditableCell = React.memo(function (props: EditableCellProps) { const { value, + isFirstColumn, isEditable, onChange, isFocused, + isExtraBlankRow, onFocusChange, + onRowDelete, children, } = props; @@ -24,19 +31,20 @@ export const EditableCell = React.memo(function (props: EditableCellProps) { const isEditingRef = React.useRef(isEditing); const hasSubmittedFormRef = React.useRef(false); const buttonElement = React.useRef(null); - const [editedValue, setEditedValue] = React.useState(value); + const [editedValue, setEditedValue] = React.useState(value || ""); useEffect(() => { - setEditedValue(value); + setEditedValue(value || ""); }, [value]); useEffect(() => { isEditingRef.current = isEditing; }, [isEditing]); const onSubmit = () => { - onFocusChange?.([1, 0]); - onChange?.(editedValue); hasSubmittedFormRef.current = true; + onChange?.(editedValue); + setIsEditing(false); + onFocusChange?.([1, 0]); } useEffect(() => { @@ -62,6 +70,9 @@ export const EditableCell = React.memo(function (props: EditableCellProps) { e.stopPropagation() e.preventDefault() } else if (e.key === 'Enter' && !isEditingRef.current) { + // don't focus when triggering delete + // @ts-ignore + if (e.target?.classList.contains('delete-button')) return setTimeout(() => { // without the timeout, the form submits immediately setIsEditing(true); @@ -87,7 +98,8 @@ export const EditableCell = React.memo(function (props: EditableCellProps) { onSubmit(); }} css={[ - tw`w-full h-full border-[3px] border-transparent border-indigo-500`, + tw`w-full h-full border-[3px] border-transparent`, + isExtraBlankRow ? `border-gray-300` : `border-indigo-500`, ]}> setEditedValue(e.target.value)} onKeyDown={e => { if (e.key === 'Escape') { @@ -115,19 +127,34 @@ export const EditableCell = React.memo(function (props: EditableCellProps) { /> ) : ( - - +
+ {isFirstColumn && ( + + )} + +
); }, areEqual); diff --git a/src/components/grid.tsx b/src/components/grid.tsx index 471f490..105b804 100644 --- a/src/components/grid.tsx +++ b/src/components/grid.tsx @@ -93,10 +93,10 @@ export function Grid(props: GridProps) { if (!ref.current) return; // preserve scroll position - if (focusedCellPosition) return; + if (focusedCellPosition) return // @ts-ignore - ref.current.scrollToItem({ + ref.current?.scrollToItem({ columnIndex: 0, rowIndex: 0, align: 'center', @@ -203,15 +203,45 @@ export function Grid(props: GridProps) { // @ts-ignore ref?.current?.scrollToItem({ rowIndex: 0 }); }; - React.useEffect(scrollToTop, [sort]); + 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 = {}; - columnNames.forEach((columnName: string) => { + columnNamesWithOptionalEmptyColumn.forEach((columnName: string) => { // @ts-ignore const cellType = cellTypes[columnName]; // @ts-ignore @@ -229,11 +259,6 @@ export function Grid(props: GridProps) { return scales; }, [data]); - const columnWidthCallback = React.useCallback(i => columnWidths[i], [ - columnWidths.join(','), - ]); - const rowHeightCallback = React.useCallback(i => (i ? 40 : 117), []); - interface AutoSizerType { height: number; width: number; @@ -246,7 +271,7 @@ export function Grid(props: GridProps) { // @ts-ignore const modifiedDiffs = diffs.filter(d => d.__status__ === 'modified'); - const ref = useRespondToColumnChange([columnWidths]); + const ref = useRespondToColumnChange([columnWidthsWithOptionalEmptyColumn]); const handleHighlightDiffChange = (delta: number = 0) => { let newHighlight = 0; @@ -419,22 +444,22 @@ export function Grid(props: GridProps) { ref={ref} height={height} width={width} - rowCount={filteredData.length + 1} + rowCount={filteredDataWithOptionalEmptyRows.length + 1} columnWidth={columnWidthCallback} - columnCount={columnNames.length} + columnCount={columnNamesWithOptionalEmptyColumn.length} rowHeight={rowHeightCallback} - columnWidths={columnWidths} + columnWidths={columnWidthsWithOptionalEmptyColumn} numberOfStickiedColumns={width < 700 ? 0 : 1} overscanRowCount={5} onScroll={onScroll} itemData={{ - filteredData, + filteredData: filteredDataWithOptionalEmptyRows, focusedRowIndex, focusedColumnIndex, setFocusedColumnIndex, // @ts-ignore columnScales, - columnNames, + columnNames: columnNamesWithOptionalEmptyColumn, showFilters, }} // // @ts-ignore @@ -545,6 +570,8 @@ export function Grid(props: GridProps) { ); } +const numberOfExtraRowsWhenEditing = 1 + interface StyleObject { width?: number; top?: number; @@ -583,6 +610,7 @@ const CellWrapper = function (props: CellProps) { cellTypes, isEditable, onCellChange, + onRowDelete, focusedCellPosition, handleFocusedCellPositionChange, } = useGridStore(); @@ -597,9 +625,9 @@ const CellWrapper = function (props: CellProps) { // @ts-ignore const type = cellTypes[name]; - const cellData = filteredData[rowIndex] + const cellData = filteredData[rowIndex] || { [name]: "" } - if (!cellData) return null; + // if (!cellData) return null; const value = cellData[name]; const rawValue = cellData['__rawData__']?.[name]; @@ -642,13 +670,16 @@ const CellWrapper = function (props: CellProps) { const onCellChangeLocal = (value: any) => { onCellChange(rowIndex, name, value); } + const onRowDeleteLocal = () => { + onRowDelete(rowIndex); + } const onFocusChangeLocal = (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)) + 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, @@ -668,12 +699,14 @@ const CellWrapper = function (props: CellProps) { style={style} status={status} isFirstColumn={columnIndex === 0} + isExtraBlankRow={rowIndex === filteredData.length} isNearRightEdge={columnIndex > 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={() => { setFocusedColumnIndex(columnIndex); handleFocusedRowIndexChange(rowIndex); @@ -690,11 +723,13 @@ interface CellComputedProps { background?: string; categoryColor?: string | TwStyle; status?: string; - isFirstColumn?: boolean; + 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; @@ -712,7 +747,9 @@ const CellWrapperComputed = React.memo( 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; @@ -742,7 +779,10 @@ const HeaderWrapper = function (props: CellProps) { focusedRowIndex, cellTypes, isEditable, + handleFocusedCellPositionChange, onHeaderCellChange, + onHeaderDelete, + onHeaderAdd, } = useGridStore(); const columnNameRef = React.useRef(''); @@ -753,12 +793,14 @@ const HeaderWrapper = function (props: CellProps) { const columnWidth = columnWidths[columnIndex]; // @ts-ignore - const cellType = cellTypes[columnName]; + const cellType = cellTypes[columnName] || "string" // @ts-ignore - const cellInfo = cellTypeMap[cellType]; - if (!cellInfo) return null; + const cellInfo = cellTypeMap[cellType] || {} + + const maxColumns = isEditable ? columnNames.length + 1 : columnNames.length; + if (columnIndex >= maxColumns) return null; - // if (!filteredData[0]) return null; + const isNewColumn = isEditable && columnIndex === columnNames.length; const focusedValue = typeof focusedRowIndex == 'number' && filteredData[0] @@ -775,6 +817,16 @@ const HeaderWrapper = function (props: CellProps) { const onHeaderCellChangeLocal = (value: any) => { onHeaderCellChange(columnName, value); } + const onHeaderDeleteLocal = () => { + onHeaderDelete(columnName); + } + const onHeaderAddLocal = (newColumnName: string) => { + onHeaderAdd(newColumnName); + handleFocusedCellPositionChange([ + 0, + columnNames.length, + ]) + } return ( handleStickyColumnNameChange(columnName)} onFilterChange={(value: FilterValue) => { @@ -820,8 +875,11 @@ interface HeaderComputedProps { 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; @@ -838,6 +896,7 @@ const HeaderWrapperComputed = React.memo( 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; diff --git a/src/components/header.tsx b/src/components/header.tsx index 9ceda19..484e337 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -4,9 +4,11 @@ import { ArrowDownIcon, InfoIcon, PinIcon, + TrashIcon, } from '@primer/octicons-react'; import { FilterValue, CategoryValue } from '../types'; import { EditableHeader } from './editable-header'; +import { NewColumnHeader } from './new-column-header'; interface HeaderProps { style: object; @@ -24,8 +26,11 @@ interface HeaderProps { 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; @@ -47,8 +52,11 @@ export function Header(props: HeaderProps) { showFilters, isFirstColumn, isSticky, + isNewColumn, isEditable, onChange, + onDelete, + onAdd, onFilterChange, onSort, onSticky, @@ -59,6 +67,13 @@ export function Header(props: HeaderProps) { // @ts-ignore const { filter: FilterComponent } = cellInfo; + if (isNewColumn) return ( + + ) + return (
+ {isEditable && ( + + )} + )} +
+ ) +} \ No newline at end of file diff --git a/src/store.ts b/src/store.ts index b9999ac..6aebff4 100644 --- a/src/store.ts +++ b/src/store.ts @@ -63,7 +63,10 @@ export type GridState = { isEditable: boolean; handleIsEditableChange: (isEditable: boolean) => void; onCellChange: (rowIndex: number, columnName: string, value: any) => void; + onRowDelete: (rowIndex: number) => void; onHeaderCellChange: (oldColumnName: string, newColumnName: any) => void; + onHeaderAdd: (columnName: string) => void; + onHeaderDelete: (columnName: string) => void; focusedCellPosition: [number, number] | null; handleFocusedCellPositionChange: (position: [number, number] | null) => void; }; @@ -352,12 +355,28 @@ export const createGridStore = () => updatedData: null, onCellChange: (rowIndex: number, columnName: string, value: any) => { set((draft) => { - const filteredRow = draft.filteredData[rowIndex]; - const rowIndexInFullDataset = filteredRow[originalRowIndexColumnName]; - if (!draft.rawData[rowIndexInFullDataset]) return; - if (draft.rawData[rowIndexInFullDataset][columnName] === value) - return; - const newData = [...draft.rawData].map((d) => { + const filteredRow = draft.filteredData[rowIndex] || {}; + let rowIndexInFullDataset = filteredRow[originalRowIndexColumnName]; + let newData = [...draft.rawData]; + if ( + !draft.rawData[rowIndexInFullDataset] && + rowIndex === draft.filteredData.length + ) { + rowIndexInFullDataset = newData.length; + newData.push({ + [originalRowIndexColumnName]: rowIndexInFullDataset, + ...draft.columnNames.reduce( + (acc, columnName) => ({ + ...acc, + [columnName]: '', + }), + {} + ), + }); + } + if (!newData[rowIndexInFullDataset]) return; + if (newData[rowIndexInFullDataset][columnName] === value) return; + newData = newData.map((d) => { const originalRowIndex = d[originalRowIndexColumnName]; delete d[originalRowIndexColumnName]; if (originalRowIndex === rowIndexInFullDataset) { @@ -368,12 +387,24 @@ export const createGridStore = () => draft.updatedData = newData; }); }, + onRowDelete: (rowIndex: number) => { + set((draft) => { + const filteredRow = draft.filteredData[rowIndex]; + const rowIndexInFullDataset = filteredRow[originalRowIndexColumnName]; + if (!draft.rawData[rowIndexInFullDataset]) return; + let newData = [...draft.rawData].map((d) => { + delete d[originalRowIndexColumnName]; + return d; + }); + newData.splice(rowIndexInFullDataset, 1); + draft.updatedData = newData; + }); + }, onHeaderCellChange: (oldColumnName: string, newColumnName: string) => { set((draft) => { - const columnKeys = Object.keys(draft.rawData[0]); const newData = [...draft.rawData].map((row) => { // keep same order of keys so it matches when the data updates - return columnKeys.reduce((acc, columnKey) => { + return draft.columnNames.reduce((acc, columnKey) => { if (columnKey === oldColumnName) { // @ts-ignore acc[newColumnName] = row[oldColumnName]; @@ -387,6 +418,32 @@ export const createGridStore = () => draft.updatedData = newData; }); }, + onHeaderAdd: (columnName: string) => { + set((draft) => { + const newData = [...draft.rawData].map((row) => { + return { + ...row, + [columnName]: row[columnName] || '', + }; + }); + draft.updatedData = newData; + }); + }, + onHeaderDelete: (columnName: string) => { + set((draft) => { + const newData = [...draft.rawData].map((row) => { + // keep same order of keys so it matches when the data updates + return draft.columnNames.reduce((acc, columnKey) => { + if (columnKey !== columnName) { + // @ts-ignore + acc[columnKey] = row[columnKey]; + } + return acc; + }, {}); + }); + draft.updatedData = newData; + }); + }, focusedCellPosition: null, handleFocusedCellPositionChange: (position: [number, number] | null) => set((draft) => {