Skip to content

Commit d2db8e3

Browse files
authored
Implement Data grid for showing dataset rows (and make it editable in (#1007)
upcoming PRs)
1 parent 8ba6fd4 commit d2db8e3

File tree

38 files changed

+1048
-215
lines changed

38 files changed

+1048
-215
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use server'
2+
3+
import { z } from 'zod'
4+
import { updateDatasetRow } from '@latitude-data/core/services/datasetRows/update'
5+
import {
6+
DatasetRowsRepository,
7+
DatasetsV2Repository,
8+
} from '@latitude-data/core/repositories'
9+
import { authProcedure } from '$/actions/procedures'
10+
import { DatasetRowDataContent } from '@latitude-data/core/schema'
11+
12+
const rowDataSchema = z.record(
13+
z.custom<DatasetRowDataContent>((val) => {
14+
return (
15+
['string', 'number', 'boolean'].includes(typeof val) ||
16+
val === null ||
17+
val === undefined
18+
)
19+
}),
20+
)
21+
22+
export const updateDatasetRowAction = authProcedure
23+
.createServerAction()
24+
.input(
25+
z.object({
26+
datasetId: z.number(),
27+
rows: z.array(
28+
z.object({
29+
rowId: z.number(),
30+
rowData: rowDataSchema,
31+
}),
32+
),
33+
}),
34+
{ type: 'json' },
35+
)
36+
.handler(async ({ ctx, input }) => {
37+
const datasetRepo = new DatasetsV2Repository(ctx.workspace.id)
38+
const dataset = await datasetRepo
39+
.find(input.datasetId)
40+
.then((r) => r.unwrap())
41+
const scope = new DatasetRowsRepository(ctx.workspace.id)
42+
const rows = await scope.findManyByDataset({
43+
dataset,
44+
rowIds: input.rows.map((r) => r.rowId),
45+
})
46+
const rowsByRowId = new Map(input.rows.map((r) => [r.rowId, r]))
47+
const rowsMap = rows.map((r) => ({
48+
rowId: rowsByRowId.get(r.id)!.rowId,
49+
rowData: rowsByRowId.get(r.id)!.rowData,
50+
}))
51+
52+
return updateDatasetRow({
53+
dataset,
54+
data: { rows: rowsMap },
55+
}).then((r) => r.unwrap())
56+
})
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {
2+
useCallback,
3+
useState,
4+
ChangeEvent,
5+
FocusEvent,
6+
KeyboardEvent,
7+
} from 'react'
8+
import { createPortal } from 'react-dom'
9+
import { TextArea } from '@latitude-data/web-ui'
10+
import type { RenderEditCellProps } from '@latitude-data/web-ui/data-grid'
11+
import { ClientDatasetRow } from '$/stores/datasetRows/rowSerializationHelpers'
12+
13+
const COMMIT_CHANGES_KEYS = ['Escape', 'Tab']
14+
export function EditCell({
15+
row,
16+
column,
17+
onRowChange,
18+
}: RenderEditCellProps<ClientDatasetRow, unknown>) {
19+
const rawValue = row.processedRowData[column.key]
20+
const initialValue = rawValue === undefined ? '' : String(rawValue)
21+
const [value, setValue] = useState(initialValue)
22+
const [position, setPosition] = useState<{
23+
top: number
24+
left: number
25+
width: number
26+
container: HTMLElement | null
27+
} | null>(null)
28+
29+
const commitChanges = useCallback(() => {
30+
const commitChanges = true
31+
onRowChange(row, commitChanges)
32+
}, [row, onRowChange])
33+
34+
const handleRef = useCallback((el: HTMLDivElement | null) => {
35+
if (!el) return
36+
37+
const cellRect = el.getBoundingClientRect()
38+
const gridWrapperEl = el.closest(
39+
'[role="grid-wrapper"]',
40+
) as HTMLElement | null
41+
42+
if (!gridWrapperEl) return
43+
44+
const preferredWidth = Math.max(cellRect.width, 230)
45+
const gridScrollLeft = gridWrapperEl.scrollLeft
46+
const gridScrollTop = gridWrapperEl.scrollTop
47+
const gridRect = gridWrapperEl.getBoundingClientRect()
48+
49+
const relativeLeft = cellRect.left - gridRect.left + gridScrollLeft
50+
const relativeTop = cellRect.top - gridRect.top + gridScrollTop
51+
52+
setPosition({
53+
top: relativeTop,
54+
left: relativeLeft,
55+
width: preferredWidth,
56+
container: gridWrapperEl,
57+
})
58+
}, [])
59+
60+
const onFocus = useCallback((e: FocusEvent<HTMLTextAreaElement>) => {
61+
const length = e.target.value.length
62+
e.target.setSelectionRange(length, length)
63+
}, [])
64+
65+
const onChange = useCallback(
66+
(e: ChangeEvent<HTMLTextAreaElement>) => {
67+
const prevData = row.processedRowData
68+
const changedValue = e.target.value
69+
const newRow = {
70+
...row,
71+
rowData: { ...prevData, [column.key]: changedValue },
72+
}
73+
setValue(changedValue)
74+
onRowChange(newRow, false)
75+
},
76+
[row, column.key, onRowChange],
77+
)
78+
79+
const onKeyDown = useCallback(
80+
(e: KeyboardEvent<HTMLTextAreaElement>) => {
81+
const key = e.key
82+
83+
const isCmd = e.metaKey || e.ctrlKey
84+
if (isCmd && key === 'Enter') {
85+
e.stopPropagation()
86+
e.preventDefault()
87+
commitChanges()
88+
} else if (key === 'Enter') {
89+
e.stopPropagation()
90+
} else if (COMMIT_CHANGES_KEYS.includes(key)) {
91+
e.stopPropagation()
92+
e.preventDefault()
93+
commitChanges()
94+
}
95+
},
96+
[commitChanges],
97+
)
98+
99+
const grid = position ? position.container : null
100+
const canCreatePortal = position && grid
101+
return (
102+
<div ref={handleRef} style={{ position: 'relative', height: '100%' }}>
103+
{canCreatePortal
104+
? createPortal(
105+
<div
106+
className='absolute'
107+
style={{
108+
top: position.top,
109+
left: position.left,
110+
width: position.width,
111+
zIndex: 9999,
112+
}}
113+
>
114+
<TextArea
115+
value={value}
116+
className='min-w-72 resize'
117+
onChange={onChange}
118+
onKeyDown={onKeyDown}
119+
style={{
120+
minWidth: position.width,
121+
}}
122+
autoFocus
123+
onFocus={onFocus}
124+
minRows={1}
125+
maxRows={5}
126+
/>
127+
</div>,
128+
grid,
129+
)
130+
: null}
131+
</div>
132+
)
133+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import BaseDataGrid, { SelectColumn } from '@latitude-data/web-ui/data-grid'
2+
import type {
3+
RenderCellProps,
4+
RenderEditCellProps,
5+
Props as DataGridProps,
6+
RowsChangeData,
7+
CellClickArgs,
8+
CellMouseEvent,
9+
} from '@latitude-data/web-ui/data-grid'
10+
import { Text } from '@latitude-data/web-ui'
11+
import { DatasetRoleStyle } from '$/hooks/useDatasetRoles'
12+
import { DatasetV2 } from '@latitude-data/core/browser'
13+
import { ClientPagination } from '@latitude-data/core/lib/pagination/buildPagination'
14+
import { useCallback, useMemo, useState } from 'react'
15+
import { LinkableTablePaginationFooter } from '$/components/TablePaginationFooter'
16+
import { EditCell } from '$/app/(private)/datasets/[datasetId]/DatasetDetailTable/DataGrid/EditCell'
17+
import { ClientDatasetRow } from '$/stores/datasetRows/rowSerializationHelpers'
18+
import useDatasetRows from '$/stores/datasetRows'
19+
20+
function rowKeyGetter(row: ClientDatasetRow) {
21+
return row.id
22+
}
23+
24+
export type DatasetRowsTableProps = {
25+
dataset: DatasetV2
26+
rows: ClientDatasetRow[]
27+
pagination: ClientPagination
28+
datasetCellRoleStyles: DatasetRoleStyle
29+
}
30+
31+
function renderCell(props: RenderCellProps<ClientDatasetRow, unknown>) {
32+
return (
33+
<div className='max-w-72'>
34+
<Text.H5 ellipsis noWrap>
35+
{props.row.processedRowData[props.column.key]}
36+
</Text.H5>
37+
</div>
38+
)
39+
}
40+
41+
function renderEditCell(props: RenderEditCellProps<ClientDatasetRow, unknown>) {
42+
return <EditCell {...props} />
43+
}
44+
45+
type Props = DatasetRowsTableProps & {
46+
updateRows: ReturnType<typeof useDatasetRows>['updateRows']
47+
}
48+
49+
export default function DataGrid({
50+
dataset,
51+
rows,
52+
updateRows,
53+
pagination,
54+
}: Props) {
55+
const [selectedRows, setSelectedRows] = useState(() => new Set<number>())
56+
const columns = useMemo<DataGridProps<ClientDatasetRow>['columns']>(() => {
57+
const dataColumns: DataGridProps<ClientDatasetRow>['columns'] =
58+
dataset.columns.map((col) => ({
59+
key: col.identifier,
60+
name: col.name,
61+
resizable: true,
62+
selectable: true,
63+
minWidth: 80,
64+
renderEditCell,
65+
renderCell,
66+
}))
67+
68+
return [SelectColumn, ...dataColumns]
69+
}, [dataset.columns])
70+
const onCellClick = useCallback(
71+
(args: CellClickArgs<ClientDatasetRow>, event: CellMouseEvent) => {
72+
event.preventGridDefault()
73+
args.selectCell(true)
74+
},
75+
[],
76+
)
77+
78+
const onRowsChange = useCallback(
79+
(
80+
rows: ClientDatasetRow[],
81+
{ indexes }: RowsChangeData<ClientDatasetRow>,
82+
) => {
83+
const changedRows = indexes
84+
.map((index) => rows[index])
85+
.filter((r) => r !== undefined)
86+
87+
updateRows({ rows: changedRows })
88+
},
89+
[updateRows],
90+
)
91+
return (
92+
<BaseDataGrid<ClientDatasetRow, unknown, number>
93+
rowKeyGetter={rowKeyGetter}
94+
rows={rows}
95+
columns={columns}
96+
onRowsChange={onRowsChange}
97+
onCellClick={onCellClick}
98+
selectedRows={selectedRows}
99+
onSelectedRowsChange={setSelectedRows}
100+
footer={<LinkableTablePaginationFooter pagination={pagination} />}
101+
/>
102+
)
103+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { DatasetHeadText } from '$/app/(private)/datasets/_components/DatasetHeadText'
2+
import {
3+
dateFormatter,
4+
Table,
5+
TableBody,
6+
TableCell,
7+
TableHead,
8+
TableHeader,
9+
TableRow,
10+
Text,
11+
} from '@latitude-data/web-ui'
12+
import { LinkableTablePaginationFooter } from '$/components/TablePaginationFooter'
13+
import { DatasetRowsTableProps } from '../DataGrid'
14+
15+
export function SimpleTable({
16+
dataset,
17+
rows,
18+
pagination,
19+
datasetCellRoleStyles,
20+
}: DatasetRowsTableProps) {
21+
const { backgroundCssClasses } = datasetCellRoleStyles
22+
return (
23+
<Table
24+
externalFooter={<LinkableTablePaginationFooter pagination={pagination} />}
25+
>
26+
<TableHeader>
27+
<TableRow verticalPadding>
28+
{dataset.columns.map((column) => (
29+
<TableHead
30+
verticalBorder
31+
key={column.identifier}
32+
className={backgroundCssClasses[column.role]}
33+
>
34+
<DatasetHeadText text={column.name} role={column.role} />
35+
</TableHead>
36+
))}
37+
<TableHead className={backgroundCssClasses['metadata']}>
38+
Created at
39+
</TableHead>
40+
</TableRow>
41+
</TableHeader>
42+
<TableBody>
43+
{rows.map((row) => (
44+
<TableRow key={row.id} verticalPadding hoverable={false}>
45+
{row.cells.map((cell, index) => {
46+
const role = dataset.columns[index]!.role
47+
return (
48+
<TableCell
49+
verticalBorder
50+
key={index}
51+
className={backgroundCssClasses[role]}
52+
>
53+
<Text.H5 wordBreak='breakAll' ellipsis lineClamp={1}>
54+
{cell}
55+
</Text.H5>
56+
</TableCell>
57+
)
58+
})}
59+
<TableCell className={backgroundCssClasses['metadata']}>
60+
<Text.H5 color='foregroundMuted'>
61+
{dateFormatter.formatDate(row.createdAt)}
62+
</Text.H5>
63+
</TableCell>
64+
</TableRow>
65+
))}
66+
</TableBody>
67+
</Table>
68+
)
69+
}

0 commit comments

Comments
 (0)