table-editor-table.tsx304 lines · main
| 1 | import { createContext, PropsWithChildren, useContext, useEffect, useRef } from 'react' |
| 2 | import { CalculatedColumn } from 'react-data-grid' |
| 3 | import { proxy, ref, subscribe, useSnapshot } from 'valtio' |
| 4 | import { proxySet } from 'valtio/utils' |
| 5 | |
| 6 | import { useTableEditorStateSnapshot } from './table-editor' |
| 7 | import { TableIndexAdvisorProvider } from '@/components/grid/context/TableIndexAdvisorContext' |
| 8 | import { |
| 9 | loadTableEditorStateFromLocalStorage, |
| 10 | parseSupaTable, |
| 11 | saveTableEditorStateToLocalStorageDebounced, |
| 12 | } from '@/components/grid/BrivenGrid.utils' |
| 13 | import { Filter, SupaRow } from '@/components/grid/types' |
| 14 | import { getInitialGridColumns } from '@/components/grid/utils/column' |
| 15 | import { getGridColumns } from '@/components/grid/utils/gridColumns' |
| 16 | import { Entity } from '@/data/table-editor/table-editor-types' |
| 17 | |
| 18 | const FALLBACK_TABLE_STATE = proxy({}) as TableEditorTableState |
| 19 | |
| 20 | export const createTableEditorTableState = ({ |
| 21 | projectRef, |
| 22 | table: originalTable, |
| 23 | editable = true, |
| 24 | preflightCheck = true, |
| 25 | onAddColumn, |
| 26 | onExpandJSONEditor, |
| 27 | onExpandTextEditor, |
| 28 | }: { |
| 29 | projectRef: string |
| 30 | table: Entity |
| 31 | /** If set to true, render an additional "+" column to support adding a new column in the grid editor */ |
| 32 | editable?: boolean |
| 33 | preflightCheck?: boolean |
| 34 | onAddColumn: () => void |
| 35 | onExpandJSONEditor: (column: string, row: SupaRow) => void |
| 36 | onExpandTextEditor: (column: string, row: SupaRow) => void |
| 37 | }) => { |
| 38 | const table = parseSupaTable(originalTable) |
| 39 | |
| 40 | const savedState = loadTableEditorStateFromLocalStorage(projectRef, table.id) |
| 41 | const gridColumns = getInitialGridColumns( |
| 42 | getGridColumns(table, { |
| 43 | tableId: table.id, |
| 44 | editable, |
| 45 | onAddColumn: editable ? onAddColumn : undefined, |
| 46 | onExpandJSONEditor, |
| 47 | onExpandTextEditor, |
| 48 | }), |
| 49 | savedState |
| 50 | ) |
| 51 | |
| 52 | const state = proxy({ |
| 53 | /* Table */ |
| 54 | table, |
| 55 | originalTable, |
| 56 | |
| 57 | /** |
| 58 | * Used for tracking changes to the table |
| 59 | * Do not use outside of table-editor-table.tsx |
| 60 | */ |
| 61 | _originalTableRef: ref(originalTable), |
| 62 | |
| 63 | updateTable: (table: Entity) => { |
| 64 | const supaTable = parseSupaTable(table) |
| 65 | |
| 66 | const gridColumns = getInitialGridColumns( |
| 67 | getGridColumns(supaTable, { |
| 68 | tableId: table.id, |
| 69 | editable: state.editable, |
| 70 | onAddColumn: state.editable ? onAddColumn : undefined, |
| 71 | onExpandJSONEditor, |
| 72 | onExpandTextEditor, |
| 73 | }), |
| 74 | { gridColumns: state.gridColumns } |
| 75 | ) |
| 76 | |
| 77 | state.table = supaTable |
| 78 | state.gridColumns = gridColumns |
| 79 | state.originalTable = table |
| 80 | state._originalTableRef = ref(table) |
| 81 | }, |
| 82 | |
| 83 | /* Rows */ |
| 84 | selectedRows: proxySet<number>(), |
| 85 | allRowsSelected: false, |
| 86 | setSelectedRows: (rows: Set<number>, selectAll?: boolean) => { |
| 87 | state.allRowsSelected = selectAll ?? false |
| 88 | state.selectedRows = proxySet(rows) |
| 89 | }, |
| 90 | resetSelectedRows: () => { |
| 91 | state.allRowsSelected = false |
| 92 | state.selectedRows = proxySet(new Set()) |
| 93 | }, |
| 94 | |
| 95 | /* Columns */ |
| 96 | gridColumns, |
| 97 | moveColumn: (fromKey: string, toKey: string) => { |
| 98 | const fromIdx = state.gridColumns.findIndex((x) => x.key === fromKey) |
| 99 | const toIdx = state.gridColumns.findIndex((x) => x.key === toKey) |
| 100 | if (fromIdx === -1 || toIdx === -1) return |
| 101 | const moveItem = state.gridColumns[fromIdx] |
| 102 | const overItem = state.gridColumns[toIdx] |
| 103 | if (moveItem.frozen || overItem.frozen) return |
| 104 | |
| 105 | state.gridColumns.splice(fromIdx, 1) |
| 106 | state.gridColumns.splice(toIdx, 0, moveItem) |
| 107 | |
| 108 | // Update idx values to match new positions |
| 109 | state.gridColumns.forEach((col, i) => { |
| 110 | ;(col as CalculatedColumn<any, any> & { idx: number }).idx = i |
| 111 | }) |
| 112 | }, |
| 113 | updateColumnSize: (index: number, width: number) => { |
| 114 | if (state.gridColumns[index]) { |
| 115 | ;(state.gridColumns[index] as CalculatedColumn<any, any> & { width?: number }).width = width |
| 116 | } |
| 117 | }, |
| 118 | freezeColumn: (columnKey: string) => { |
| 119 | const index = state.gridColumns.findIndex((x) => x.key === columnKey) |
| 120 | if (index === -1) return |
| 121 | ;(state.gridColumns[index] as CalculatedColumn<any, any> & { frozen?: boolean }).frozen = true |
| 122 | |
| 123 | // Move the column to just after the last currently-frozen column |
| 124 | const lastFrozenIdx = state.gridColumns.reduce( |
| 125 | (last, col, i) => (col.frozen && i !== index ? i : last), |
| 126 | -1 |
| 127 | ) |
| 128 | const col = state.gridColumns[index] |
| 129 | state.gridColumns.splice(index, 1) |
| 130 | state.gridColumns.splice(lastFrozenIdx + 1, 0, col) |
| 131 | state.gridColumns.forEach((col, i) => { |
| 132 | ;(col as CalculatedColumn<any, any> & { idx: number }).idx = i |
| 133 | }) |
| 134 | }, |
| 135 | unfreezeColumn: (columnKey: string) => { |
| 136 | const index = state.gridColumns.findIndex((x) => x.key === columnKey) |
| 137 | if (index === -1) return |
| 138 | ;(state.gridColumns[index] as CalculatedColumn<any, any> & { frozen?: boolean }).frozen = |
| 139 | false |
| 140 | |
| 141 | // Move the column to just after the remaining frozen columns |
| 142 | const col = state.gridColumns[index] |
| 143 | state.gridColumns.splice(index, 1) |
| 144 | const lastFrozenIdx = state.gridColumns.reduce((last, col, i) => (col.frozen ? i : last), -1) |
| 145 | state.gridColumns.splice(lastFrozenIdx + 1, 0, col) |
| 146 | state.gridColumns.forEach((col, i) => { |
| 147 | ;(col as CalculatedColumn<any, any> & { idx: number }).idx = i |
| 148 | }) |
| 149 | }, |
| 150 | updateColumnIdx: (columnKey: string, columnIdx: number) => { |
| 151 | const index = state.gridColumns.findIndex((x) => x.key === columnKey) |
| 152 | if (state.gridColumns[index]) { |
| 153 | ;(state.gridColumns[index] as CalculatedColumn<any, any> & { idx?: number }).idx = columnIdx |
| 154 | } |
| 155 | state.gridColumns.sort((a, b) => a.idx - b.idx) |
| 156 | }, |
| 157 | |
| 158 | /* Cells */ |
| 159 | selectedCellPosition: null as { idx: number; rowIdx: number } | null, |
| 160 | setSelectedCellPosition: (position: { idx: number; rowIdx: number } | null) => { |
| 161 | state.selectedCellPosition = position |
| 162 | }, |
| 163 | |
| 164 | /* Misc */ |
| 165 | enforceExactCount: false, |
| 166 | setEnforceExactCount: (value: boolean) => { |
| 167 | state.enforceExactCount = value |
| 168 | }, |
| 169 | |
| 170 | page: 1, |
| 171 | setPage: (page: number) => { |
| 172 | state.page = page |
| 173 | |
| 174 | // reset selected row state |
| 175 | state.setSelectedRows(new Set()) |
| 176 | }, |
| 177 | |
| 178 | editable, |
| 179 | setEditable: (editable: boolean) => { |
| 180 | state.editable = editable |
| 181 | |
| 182 | // When changing the editable flag, all grid columns need to be recreated for the editable flag to be propagated. |
| 183 | state.gridColumns = getInitialGridColumns( |
| 184 | getGridColumns(state.table, { |
| 185 | tableId: table.id, |
| 186 | editable, |
| 187 | onAddColumn: editable ? onAddColumn : undefined, |
| 188 | onExpandJSONEditor, |
| 189 | onExpandTextEditor, |
| 190 | }), |
| 191 | { gridColumns: state.gridColumns } |
| 192 | ) |
| 193 | }, |
| 194 | |
| 195 | /* Filters (NOTE: this is only for the new AI filter bar) */ |
| 196 | filters: [] as Filter[], |
| 197 | setFilters: (filters: Filter[]) => { |
| 198 | state.filters = filters |
| 199 | }, |
| 200 | clearFilters: () => { |
| 201 | state.filters = [] |
| 202 | }, |
| 203 | |
| 204 | preflightCheck, |
| 205 | setPreflightCheck: (value: boolean) => (state.preflightCheck = value), |
| 206 | }) |
| 207 | |
| 208 | return state |
| 209 | } |
| 210 | |
| 211 | export type TableEditorTableState = ReturnType<typeof createTableEditorTableState> |
| 212 | |
| 213 | export const TableEditorTableStateContext = createContext<TableEditorTableState>(undefined as any) |
| 214 | |
| 215 | type TableEditorTableStateContextProviderProps = Omit< |
| 216 | Parameters<typeof createTableEditorTableState>[0], |
| 217 | 'onAddColumn' | 'onExpandJSONEditor' | 'onExpandTextEditor' |
| 218 | > |
| 219 | |
| 220 | export const TableEditorTableStateContextProvider = ({ |
| 221 | children, |
| 222 | projectRef, |
| 223 | table, |
| 224 | ...props |
| 225 | }: PropsWithChildren<TableEditorTableStateContextProviderProps>) => { |
| 226 | const tableEditorSnap = useTableEditorStateSnapshot() |
| 227 | const state = useRef( |
| 228 | createTableEditorTableState({ |
| 229 | ...props, |
| 230 | projectRef, |
| 231 | table, |
| 232 | onAddColumn: tableEditorSnap.onAddColumn, |
| 233 | onExpandJSONEditor: (column: string, row: SupaRow) => { |
| 234 | tableEditorSnap.onExpandJSONEditor({ |
| 235 | column, |
| 236 | row, |
| 237 | value: JSON.stringify(row[column]) || '', |
| 238 | }) |
| 239 | }, |
| 240 | onExpandTextEditor: (column: string, row: SupaRow) => { |
| 241 | tableEditorSnap.onExpandTextEditor(column, row) |
| 242 | }, |
| 243 | }) |
| 244 | ).current |
| 245 | |
| 246 | useEffect(() => { |
| 247 | if (typeof window !== 'undefined') { |
| 248 | return subscribe(state, () => { |
| 249 | saveTableEditorStateToLocalStorageDebounced({ |
| 250 | gridColumns: state.gridColumns, |
| 251 | projectRef, |
| 252 | tableId: state.table.id, |
| 253 | }) |
| 254 | }) |
| 255 | } |
| 256 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 257 | }, []) |
| 258 | |
| 259 | useEffect(() => { |
| 260 | // We can use a === check here because react-query is good |
| 261 | // about returning objects with the same ref / different ref |
| 262 | if (state._originalTableRef !== table) { |
| 263 | state.updateTable(table) |
| 264 | } |
| 265 | }, [table]) |
| 266 | |
| 267 | useEffect(() => { |
| 268 | if (state.editable !== props.editable) { |
| 269 | state.setEditable(props.editable ?? true) |
| 270 | } |
| 271 | }, [props.editable, state]) |
| 272 | |
| 273 | return ( |
| 274 | <TableEditorTableStateContext.Provider value={state}> |
| 275 | {state.table.schema ? ( |
| 276 | <TableIndexAdvisorProvider schema={state.table.schema ?? 'public'} table={state.table.name}> |
| 277 | {children} |
| 278 | </TableIndexAdvisorProvider> |
| 279 | ) : ( |
| 280 | children |
| 281 | )} |
| 282 | </TableEditorTableStateContext.Provider> |
| 283 | ) |
| 284 | } |
| 285 | |
| 286 | export const useTableEditorTableStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) => { |
| 287 | const state = useContext(TableEditorTableStateContext) |
| 288 | // as TableEditorTableState so this doesn't get marked as readonly, |
| 289 | // making adopting this state easier since we're migrating from react-tracked |
| 290 | return useSnapshot(state, options) as TableEditorTableState |
| 291 | } |
| 292 | |
| 293 | /** |
| 294 | * Same as useTableEditorTableStateSnapshot but returns undefined when called |
| 295 | * outside a TableEditorTableStateContextProvider instead of crashing. |
| 296 | * Use this for components that may render outside the provider (e.g. sidebar). |
| 297 | */ |
| 298 | export const useOptionalTableEditorTableStateSnapshot = (): TableEditorTableState | undefined => { |
| 299 | const state = useContext(TableEditorTableStateContext) |
| 300 | const snap = useSnapshot(state ?? FALLBACK_TABLE_STATE) |
| 301 | return state != undefined ? (snap as TableEditorTableState) : undefined |
| 302 | } |
| 303 | |
| 304 | export type TableEditorTableStateSnapshot = ReturnType<typeof useTableEditorTableStateSnapshot> |