Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions cypress/e2e/ocotillo/list-pages.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@ describe('Ocotillo List Pages', () => {
.contains(/export/i)
.should('be.visible')

cy.contains('[role="columnheader"]', 'Name').should('be.visible')
cy.contains('[role="columnheader"]', 'Site name').should('be.visible')
cy.contains('[role="columnheader"]', 'Monitoring').should('be.visible')
cy.contains('[role="columnheader"]', 'Well Status').should('be.visible')
// Native table semantics: the shadcn table renders th/tr rather than the
// DataGrid's explicit role attributes.
cy.contains('th', 'Name').should('be.visible')
cy.contains('th', 'Site name').should('be.visible')
cy.contains('th', 'Monitoring').should('be.visible')
cy.contains('th', 'Well Status').should('be.visible')

cy.contains('[role="row"]', wellOne.name).should('be.visible')
cy.contains('[role="row"]', wellTwo.name).should('be.visible')
cy.contains('tr', wellOne.name).should('be.visible')
cy.contains('tr', wellTwo.name).should('be.visible')
cy.contains(projectAlpha.name).should('be.visible')
})

Expand Down Expand Up @@ -71,16 +73,14 @@ describe('Ocotillo List Pages', () => {

cy.contains('h3', /contacts & owners/i).should('be.visible')

cy.contains('[role="columnheader"]', 'Name').should('be.visible')
cy.contains('[role="columnheader"]', 'Organization').should('be.visible')
cy.contains('[role="columnheader"]', 'Role').should('be.visible')
cy.contains('[role="columnheader"]', 'Contact Type').should('be.visible')
cy.contains('[role="columnheader"]', 'Associated Sites').should(
'be.visible'
)
cy.contains('th', 'Name').should('be.visible')
cy.contains('th', 'Organization').should('be.visible')
cy.contains('th', 'Role').should('be.visible')
cy.contains('th', 'Contact Type').should('be.visible')
cy.contains('th', 'Associated Sites').should('be.visible')

cy.contains('[role="row"]', 'Alex Contact').should('be.visible')
cy.contains('[role="row"]', 'Jordan Manager').should('be.visible')
cy.contains('tr', 'Alex Contact').should('be.visible')
cy.contains('tr', 'Jordan Manager').should('be.visible')
cy.contains(wellOne.name).should('be.visible')
})
})
34 changes: 34 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-query": "^5.67.3",
"@tanstack/react-table": "^8.21.3",
"@tiptap/extension-color": "^2.9.1",
"@tiptap/pm": "^2.9.1",
"@tiptap/react": "^2.9.1",
Expand Down
178 changes: 178 additions & 0 deletions src/components/DataTable/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { flexRender, type Table as TanstackTable } from '@tanstack/react-table'
import type { MouseEvent } from 'react'
import { useNavigate } from 'react-router'
import {
isNewWindowClick,
openInNewWindow,
} from '@/components/DataTable/rowNavigation'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { cn } from '@/lib/utils'

/**
* Renders a TanStack table instance with the shadcn table primitives. The page
* owns the table instance, which is what lets the same component back both the
* client-side lists and the server-paginated ones.
*/

const ALIGNMENT_CLASS = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
} as const

export interface DataTableProps<TData> {
table: TanstackTable<TData>
isLoading?: boolean
emptyMessage?: string
/** Row destination; a modifier click opens it in a new window instead. */
rowHref?: (row: TData) => string | undefined
/** Runs before navigation. Use for analytics. */
onRowClick?: (row: TData) => void
isRowSelected?: (row: TData) => boolean
skeletonRowCount?: number
className?: string
}

export function DataTable<TData>({
table,
isLoading = false,
emptyMessage = 'No records match these filters.',
rowHref,
onRowClick,
isRowSelected,
skeletonRowCount = 8,
className,
}: DataTableProps<TData>) {
const navigate = useNavigate()
const visibleColumnCount = table.getVisibleLeafColumns().length

const handleRowClick = (
event: MouseEvent<HTMLTableRowElement>,
row: TData
) => {
onRowClick?.(row)

const href = rowHref?.(row)
if (!href) return

if (isNewWindowClick(event)) {
openInNewWindow(href)
return
}

navigate(href)
}

const rows = table.getRowModel().rows

return (
<div className={cn('rounded-md border', className)}>
{/* Compact rows: shorter header, tighter cell padding than the shadcn
default. Row height is floored by the tallest cell content, so action
buttons are icon-xs to keep them under the text line box. */}
<Table className="[&_td]:py-0.5 [&_th]:h-7">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta
const sorted = header.column.getIsSorted()

return (
<TableHead
key={header.id}
className={cn(
meta?.align ? ALIGNMENT_CLASS[meta.align] : undefined,
meta?.headClassName
)}
aria-sort={
sorted
? sorted === 'asc'
? 'ascending'
: 'descending'
: undefined
}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>

<TableBody>
{isLoading ? (
Array.from({ length: skeletonRowCount }).map((_, rowIndex) => (
<TableRow key={`skeleton-${rowIndex}`}>
{table.getVisibleLeafColumns().map((column) => (
<TableCell key={column.id}>
<Skeleton className="h-4 w-full max-w-40" />
</TableCell>
))}
</TableRow>
))
) : rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={visibleColumnCount}
className="h-24 text-center text-muted-foreground"
>
{emptyMessage}
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow
key={row.id}
data-state={
isRowSelected?.(row.original) ? 'selected' : undefined
}
onClick={(event) => handleRowClick(event, row.original)}
onAuxClick={(event) => {
// Middle click: open elsewhere without following the row.
if (event.button === 1) handleRowClick(event, row.original)
}}
className={cn(
rowHref || onRowClick ? 'cursor-pointer' : undefined
)}
>
{row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta

return (
<TableCell
key={cell.id}
className={cn(
meta?.align ? ALIGNMENT_CLASS[meta.align] : undefined,
meta?.cellClassName
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
)
})}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)
}
Loading
Loading