From f6a7337b0431cdc2dbf4f0b782ceb193d54b62ca Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 17 Aug 2026 11:22:01 -0700 Subject: [PATCH 1/2] refactor(wells, contacts): move list pages onto shadcn DataTable Replaces the MUI DataGrid on the Wells and Contacts lists with a shared shadcn table built on TanStack Table, following the denser table style introduced by the Projects work in #344. - Adds src/components/DataTable: table renderer, sortable/filterable column headers, column visibility menu, filter chips, pager, and a useRefineDataTable hook bridging Refine's useTable server state (paging, sorting, filtering) to TanStack's manual mode. - Column filters cover text, single-select, and numeric/date comparison operators; PostHog events keep the names the DataGrid pages emitted (_sorted, _filter_applied, _column_visibility_changed). - Wells: same columns, tooltips, project filter chip, server search, batch field sheets and CSV export; now defaults to newest first (created_at desc, the closest field the API exposes to "last updated"). - Contacts: rows now select instead of navigating, so the email, phone and address cards open below the table; the name cell links to the contact page. - Adds ListPageShell for the page chrome the DataTable pages share, and moves the row navigation helpers next to the DataTable (re-exported from ListPage). Density toggle is not carried over; the shadcn table is already compact. Co-Authored-By: Claude Opus 5 --- package-lock.json | 34 + package.json | 1 + src/components/DataTable/DataTable.tsx | 178 +++++ .../DataTable/DataTableColumnHeader.tsx | 314 +++++++++ .../DataTable/DataTablePagination.tsx | 86 +++ src/components/DataTable/DataTableToolbar.tsx | 131 ++++ .../DataTable/DataTableViewOptions.tsx | 72 +++ src/components/DataTable/index.ts | 8 + src/components/DataTable/rowNavigation.ts | 22 + src/components/DataTable/types.ts | 77 +++ .../DataTable/useRefineDataTable.ts | 267 ++++++++ src/components/ListPage.tsx | 52 +- src/components/ListPageShell.tsx | 64 ++ src/components/index.ts | 22 +- src/components/ui/popover.tsx | 87 +++ src/pages/ocotillo/contact/list.tsx | 608 ++++++++++-------- src/pages/ocotillo/thing/list.tsx | 514 ++++----------- src/pages/ocotillo/thing/wellListColumns.tsx | 441 +++++++++++++ src/test/components/DataTable.test.tsx | 129 ++++ .../components/useRefineDataTable.test.tsx | 201 ++++++ 20 files changed, 2604 insertions(+), 704 deletions(-) create mode 100644 src/components/DataTable/DataTable.tsx create mode 100644 src/components/DataTable/DataTableColumnHeader.tsx create mode 100644 src/components/DataTable/DataTablePagination.tsx create mode 100644 src/components/DataTable/DataTableToolbar.tsx create mode 100644 src/components/DataTable/DataTableViewOptions.tsx create mode 100644 src/components/DataTable/index.ts create mode 100644 src/components/DataTable/rowNavigation.ts create mode 100644 src/components/DataTable/types.ts create mode 100644 src/components/DataTable/useRefineDataTable.ts create mode 100644 src/components/ListPageShell.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/pages/ocotillo/thing/wellListColumns.tsx create mode 100644 src/test/components/DataTable.test.tsx create mode 100644 src/test/components/useRefineDataTable.test.tsx diff --git a/package-lock.json b/package-lock.json index de8833d0..9319089e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,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", @@ -8020,6 +8021,39 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index 765fbb14..7e13fdab 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/DataTable/DataTable.tsx b/src/components/DataTable/DataTable.tsx new file mode 100644 index 00000000..922676e4 --- /dev/null +++ b/src/components/DataTable/DataTable.tsx @@ -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 { + table: TanstackTable + 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({ + table, + isLoading = false, + emptyMessage = 'No records match these filters.', + rowHref, + onRowClick, + isRowSelected, + skeletonRowCount = 8, + className, +}: DataTableProps) { + const navigate = useNavigate() + const visibleColumnCount = table.getVisibleLeafColumns().length + + const handleRowClick = ( + event: MouseEvent, + 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 ( +
+ {/* 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.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta + const sorted = header.column.getIsSorted() + + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + + {isLoading ? ( + Array.from({ length: skeletonRowCount }).map((_, rowIndex) => ( + + {table.getVisibleLeafColumns().map((column) => ( + + + + ))} + + )) + ) : rows.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + rows.map((row) => ( + 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 ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ) + })} + + )) + )} + +
+
+ ) +} diff --git a/src/components/DataTable/DataTableColumnHeader.tsx b/src/components/DataTable/DataTableColumnHeader.tsx new file mode 100644 index 00000000..a80918c6 --- /dev/null +++ b/src/components/DataTable/DataTableColumnHeader.tsx @@ -0,0 +1,314 @@ +import type { Column } from '@tanstack/react-table' +import { + ArrowDownIcon, + ArrowUpIcon, + CheckIcon, + ChevronsUpDownIcon, + FilterIcon, +} from 'lucide-react' +import { useEffect, useState } from 'react' +import { + COMPARISON_OPERATOR_LABELS, + type DataTableComparisonOperator, + isComparisonValue, +} from '@/components/DataTable/types' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { cn } from '@/lib/utils' + +/** + * Header cell content for a DataTable column: the label, a tri-state sort + * toggle (ascending, descending, unsorted) and — when the column declares a + * `filter` in its meta — a filter popover. Filtering and sorting state live in + * the table instance, so this component works the same whether the page runs + * client side or hands the state to the server. + */ + +const FILTER_DEBOUNCE_MS = 400 + +function TextFilter({ + column, + label, + placeholder, +}: { + column: Column + label: string + placeholder?: string +}) { + const committed = (column.getFilterValue() as string | undefined) ?? '' + const [draft, setDraft] = useState(committed) + + // Re-sync when the filter is cleared from a chip or by another control. + useEffect(() => { + setDraft(committed) + }, [committed]) + + useEffect(() => { + if (draft === committed) return + + const timer = setTimeout(() => { + const next = draft.trim() + column.setFilterValue(next === '' ? undefined : next) + }, FILTER_DEBOUNCE_MS) + + return () => clearTimeout(timer) + }, [column, committed, draft]) + + return ( +
+ setDraft(event.target.value)} + placeholder={placeholder ?? `Filter by ${label.toLowerCase()}…`} + aria-label={`Filter by ${label}`} + className="h-8 text-sm" + /> + +
+ ) +} + +function SelectFilter({ + column, + label, + options, +}: { + column: Column + label: string + options: { label: string; value: string }[] +}) { + const selected = column.getFilterValue() as string | undefined + + return ( +
+ + {options.map((option) => ( + + ))} +
+ ) +} + +function ComparisonFilter({ + column, + label, + inputType, + defaultOperator = 'eq', +}: { + column: Column + label: string + inputType: 'number' | 'date' + defaultOperator?: DataTableComparisonOperator +}) { + const committed = column.getFilterValue() + const current = isComparisonValue(committed) ? committed : undefined + const [operator, setOperator] = useState( + current?.operator ?? defaultOperator + ) + const [draft, setDraft] = useState(current?.value ?? '') + + const commit = (nextOperator: DataTableComparisonOperator, next: string) => { + const trimmed = next.trim() + column.setFilterValue( + trimmed === '' ? undefined : { operator: nextOperator, value: trimmed } + ) + } + + return ( +
+
+ + + setDraft(event.target.value)} + onBlur={() => commit(operator, draft)} + onKeyDown={(event) => { + if (event.key === 'Enter') commit(operator, draft) + }} + aria-label={`Filter by ${label}`} + className="h-8 flex-1 text-sm" + /> +
+ + +
+ ) +} + +export function DataTableColumnHeader({ + column, + title, +}: { + column: Column + title: string +}) { + const filter = column.columnDef.meta?.filter + const description = column.columnDef.meta?.description + const canFilter = Boolean(filter) && column.getCanFilter() + const canSort = column.getCanSort() + const sorted = column.getIsSorted() + const hasFilter = column.getFilterValue() !== undefined + + const SortIcon = !sorted + ? ChevronsUpDownIcon + : sorted === 'asc' + ? ArrowUpIcon + : ArrowDownIcon + + return ( +
+ {canSort ? ( + + ) : ( + {title} + )} + + {canFilter && filter ? ( + + + + + + {filter.type === 'text' ? ( + + ) : filter.type === 'select' ? ( + + ) : ( + + )} + + + ) : null} +
+ ) +} diff --git a/src/components/DataTable/DataTablePagination.tsx b/src/components/DataTable/DataTablePagination.tsx new file mode 100644 index 00000000..7455bc50 --- /dev/null +++ b/src/components/DataTable/DataTablePagination.tsx @@ -0,0 +1,86 @@ +import type { Table } from '@tanstack/react-table' +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' + +/** + * Pager for a DataTable. Reads the row count from the table instance, so it + * reports server totals on manual-pagination tables and filtered row counts on + * client-side ones. + */ + +const PAGE_SIZE_OPTIONS = [25, 50, 100] + +export function DataTablePagination({ + table, + pageSizeOptions = PAGE_SIZE_OPTIONS, +}: { + table: Table + pageSizeOptions?: number[] +}) { + const { pageIndex, pageSize } = table.getState().pagination + const rowCount = table.getRowCount() + const pageCount = Math.max(1, table.getPageCount()) + + const rangeStart = rowCount === 0 ? 0 : pageIndex * pageSize + 1 + const rangeEnd = Math.min(rowCount, (pageIndex + 1) * pageSize) + + return ( +
+ + {rowCount === 0 + ? 'No results' + : `${rangeStart.toLocaleString()}–${rangeEnd.toLocaleString()} of ${rowCount.toLocaleString()}`} + + +
+ + + + + Page {pageIndex + 1} of {pageCount} + + +
+
+ ) +} diff --git a/src/components/DataTable/DataTableToolbar.tsx b/src/components/DataTable/DataTableToolbar.tsx new file mode 100644 index 00000000..a4bc2949 --- /dev/null +++ b/src/components/DataTable/DataTableToolbar.tsx @@ -0,0 +1,131 @@ +import type { Table } from '@tanstack/react-table' +import { SearchIcon, XIcon } from 'lucide-react' +import type { ReactNode } from 'react' +import { DataTableViewOptions } from '@/components/DataTable/DataTableViewOptions' +import { + COMPARISON_OPERATOR_LABELS, + isComparisonValue, +} from '@/components/DataTable/types' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' + +/** + * Toolbar above a DataTable: free-text search, page-supplied actions, the + * column visibility menu and a row of dismissible chips for the active column + * filters. Mirrors what the MUI DataGrid toolbar offered on the older list + * pages, minus the density selector. + */ + +export interface DataTableToolbarProps { + table: Table + /** Search is only rendered when a change handler is supplied. */ + searchValue?: string + onSearchChange?: (value: string) => void + searchPlaceholder?: string + searchAriaLabel?: string + /** Right-aligned summary, typically the total record count. */ + summary?: ReactNode + /** Extra controls rendered next to the search input. */ + children?: ReactNode + /** Chips rendered before the column filter chips, e.g. a project filter. */ + leadingChips?: ReactNode + hideViewOptions?: boolean +} + +export function DataTableToolbar({ + table, + searchValue, + onSearchChange, + searchPlaceholder, + searchAriaLabel, + summary, + children, + leadingChips, + hideViewOptions = false, +}: DataTableToolbarProps) { + const columnFilters = table.getState().columnFilters + const hasChips = columnFilters.length > 0 || Boolean(leadingChips) + + return ( +
+
+ {onSearchChange ? ( +
+ + onSearchChange(event.target.value)} + placeholder={searchPlaceholder ?? 'Search all records…'} + aria-label={searchAriaLabel ?? 'Search all records'} + className="h-8 w-80 pl-8 text-sm" + /> +
+ ) : null} + + {children} + +
+ {summary ? ( + {summary} + ) : null} + {hideViewOptions ? null : } +
+
+ + {hasChips ? ( +
+ {leadingChips} + + {columnFilters.map((filter) => { + const column = table.getColumn(filter.id) + const label = column?.columnDef.meta?.label ?? filter.id + const display = isComparisonValue(filter.value) + ? `${COMPARISON_OPERATOR_LABELS[filter.value.operator]} ${filter.value.value}` + : `: ${String(filter.value)}` + + return ( + + + + {label} + {display} + + + + + ) + })} + + {columnFilters.length > 1 ? ( + + ) : null} +
+ ) : null} +
+ ) +} diff --git a/src/components/DataTable/DataTableViewOptions.tsx b/src/components/DataTable/DataTableViewOptions.tsx new file mode 100644 index 00000000..964aaac3 --- /dev/null +++ b/src/components/DataTable/DataTableViewOptions.tsx @@ -0,0 +1,72 @@ +import type { Table } from '@tanstack/react-table' +import { Settings2Icon } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' + +/** + * Column visibility menu. Replaces the MUI DataGrid "Columns" toolbar button; + * columns opt out with `enableHiding: false`. + */ +export function DataTableViewOptions({ + table, +}: { + table: Table +}) { + const hideableColumns = table + .getAllLeafColumns() + .filter((column) => column.getCanHide()) + + if (hideableColumns.length === 0) return null + + const hiddenCount = hideableColumns.filter( + (column) => !column.getIsVisible() + ).length + + return ( + + + + + + Toggle columns + + {hideableColumns.map((column) => ( + column.toggleVisibility(Boolean(value))} + onSelect={(event) => event.preventDefault()} + > + {column.columnDef.meta?.label ?? column.id} + + ))} + + table.resetColumnVisibility()} + > + Show all columns + + + + ) +} diff --git a/src/components/DataTable/index.ts b/src/components/DataTable/index.ts new file mode 100644 index 00000000..27f07c59 --- /dev/null +++ b/src/components/DataTable/index.ts @@ -0,0 +1,8 @@ +export * from './DataTable' +export * from './DataTableColumnHeader' +export * from './DataTablePagination' +export * from './DataTableToolbar' +export * from './DataTableViewOptions' +export * from './rowNavigation' +export * from './types' +export * from './useRefineDataTable' diff --git a/src/components/DataTable/rowNavigation.ts b/src/components/DataTable/rowNavigation.ts new file mode 100644 index 00000000..d99bb632 --- /dev/null +++ b/src/components/DataTable/rowNavigation.ts @@ -0,0 +1,22 @@ +import { settings } from '@/settings' + +/** + * Table rows are not anchors, so modifier clicks would otherwise navigate in + * place. Treat the browser conventions for "open elsewhere" as new-window + * intent. Shared by the MUI ListPage and the shadcn DataTable. + */ +export function isNewWindowClick(event: { + ctrlKey?: boolean + metaKey?: boolean + button?: number +}): boolean { + // Shift is left alone: grids use it for row range selection. + return Boolean(event.ctrlKey || event.metaKey || event.button === 1) +} + +export function openInNewWindow(href: string) { + // Router paths are basename-relative; window.open is not. + const target = href.startsWith('/') ? `${settings.urlprefix}${href}` : href + const opened = window.open(target, '_blank', 'noopener,noreferrer') + if (opened) opened.opener = null +} diff --git a/src/components/DataTable/types.ts b/src/components/DataTable/types.ts new file mode 100644 index 00000000..3e9145de --- /dev/null +++ b/src/components/DataTable/types.ts @@ -0,0 +1,77 @@ +import type { CrudOperators } from '@refinedev/core' +import type { RowData } from '@tanstack/react-table' + +/** + * Column metadata shared by every DataTable. Column definitions carry their own + * label, alignment and filter shape so the toolbar, the visibility menu and the + * filter chips can all describe a column without the page repeating itself. + */ + +export type DataTableFilterOption = { label: string; value: string } + +/** Comparisons offered by the numeric and date filters. */ +export type DataTableComparisonOperator = Extract< + CrudOperators, + 'eq' | 'gte' | 'lte' | 'gt' | 'lt' +> + +/** Value stored for a numeric or date column filter. */ +export type DataTableComparisonValue = { + operator: DataTableComparisonOperator + value: string +} + +export const isComparisonValue = ( + value: unknown +): value is DataTableComparisonValue => + typeof value === 'object' && + value !== null && + 'operator' in value && + 'value' in value + +export const COMPARISON_OPERATOR_LABELS: Record< + DataTableComparisonOperator, + string +> = { + eq: '=', + gte: '≥', + lte: '≤', + gt: '>', + lt: '<', +} + +export type DataTableFilterConfig = + /** Free text match; `contains` unless the API only understands equality. */ + | { + type: 'text' + operator?: Extract + placeholder?: string + } + /** Single choice from a known vocabulary. */ + | { + type: 'select' + options: DataTableFilterOption[] + operator?: Extract + } + /** Comparison against a number or a date; the operator ships with the value. */ + | { + type: 'number' | 'date' + defaultOperator?: DataTableComparisonOperator + } + +declare module '@tanstack/react-table' { + // The generics have to mirror the upstream declaration to merge with it. + interface ColumnMeta { + /** Human label used by the visibility menu, filter chips and export. */ + label?: string + /** Long-form help shown as the header tooltip. */ + description?: string + align?: 'left' | 'center' | 'right' + headClassName?: string + cellClassName?: string + filter?: DataTableFilterConfig + } +} + +export const DEFAULT_TEXT_FILTER_OPERATOR = 'contains' as const +export const DEFAULT_SELECT_FILTER_OPERATOR = 'eq' as const diff --git a/src/components/DataTable/useRefineDataTable.ts b/src/components/DataTable/useRefineDataTable.ts new file mode 100644 index 00000000..e3a09a3e --- /dev/null +++ b/src/components/DataTable/useRefineDataTable.ts @@ -0,0 +1,267 @@ +import type { + BaseRecord, + CrudFilter, + CrudOperators, + HttpError, + LogicalFilter, + useTableReturnType, +} from '@refinedev/core' +import { + type ColumnDef, + type ColumnFiltersState, + functionalUpdate, + type OnChangeFn, + type PaginationState, + type SortingState, + type VisibilityState, +} from '@tanstack/react-table' +import { useCallback, useMemo, useState } from 'react' +import { captureEvent } from '@/analytics/posthog' +import { + type DataTableComparisonOperator, + DEFAULT_SELECT_FILTER_OPERATOR, + DEFAULT_TEXT_FILTER_OPERATOR, + isComparisonValue, +} from '@/components/DataTable/types' + +/** + * Bridges Refine's `useTable` server state to the TanStack table options the + * DataTable expects, and reports the same PostHog events the MUI DataGrid list + * pages did (`_sorted`, `_filter_applied`, + * `_column_visibility_changed`). + * + * Refine keeps permanent filters inside its filter state; those are dropped + * from the column filter state so a page-level pin (a project id, say) never + * shows up as a removable column filter chip. + */ + +const isLogicalFilter = (filter: CrudFilter): filter is LogicalFilter => + 'field' in filter + +type ColumnDefs = ColumnDef[] + +type ColumnFilterKind = { + /** Operator used when the filter value is a bare string. */ + operator: CrudOperators + /** Comparison filters carry their own operator alongside the value. */ + isComparison: boolean +} + +function filterLookup(columns: ColumnDefs) { + const kinds = new Map() + + for (const column of columns) { + const id = (column.id ?? + (column as { accessorKey?: string }).accessorKey) as string | undefined + const filter = column.meta?.filter + if (!id || !filter) continue + + if (filter.type === 'text') { + kinds.set(id, { + operator: filter.operator ?? DEFAULT_TEXT_FILTER_OPERATOR, + isComparison: false, + }) + continue + } + + if (filter.type === 'select') { + kinds.set(id, { + operator: filter.operator ?? DEFAULT_SELECT_FILTER_OPERATOR, + isComparison: false, + }) + continue + } + + kinds.set(id, { + operator: filter.defaultOperator ?? 'eq', + isComparison: true, + }) + } + + return kinds +} + +export interface UseRefineDataTableOptions { + /** Return value of Refine's `useTable`. */ + refineTable: useTableReturnType + columns: ColumnDefs + /** Filters the page pins; hidden from the column filter state. */ + permanentFilters?: CrudFilter[] + /** PostHog event prefix, e.g. `wells`. Omit to skip analytics. */ + analyticsPrefix?: string + initialColumnVisibility?: VisibilityState +} + +export function useRefineDataTable({ + refineTable, + columns, + permanentFilters = [], + analyticsPrefix, + initialColumnVisibility, +}: UseRefineDataTableOptions) { + const { + sorters, + setSorters, + filters, + setFilters, + currentPage, + setCurrentPage, + pageSize, + setPageSize, + result, + } = refineTable + + const [columnVisibility, setColumnVisibility] = useState( + initialColumnVisibility ?? {} + ) + + const filterKinds = useMemo(() => filterLookup(columns), [columns]) + + const sorting = useMemo( + () => + sorters.map((sorter) => ({ + id: sorter.field, + desc: sorter.order === 'desc', + })), + [sorters] + ) + + const columnFilters = useMemo( + () => + filters + .filter(isLogicalFilter) + .filter( + (filter) => + !permanentFilters + .filter(isLogicalFilter) + .some( + (permanent) => + permanent.field === filter.field && + permanent.operator === filter.operator + ) + ) + .map((filter) => ({ + id: filter.field, + value: filterKinds.get(filter.field)?.isComparison + ? { + operator: filter.operator as DataTableComparisonOperator, + value: String(filter.value), + } + : filter.value, + })), + [filterKinds, filters, permanentFilters] + ) + + const pagination = useMemo( + () => ({ pageIndex: Math.max(0, currentPage - 1), pageSize }), + [currentPage, pageSize] + ) + + const onSortingChange = useCallback>( + (updater) => { + const next = functionalUpdate(updater, sorting) + + if (analyticsPrefix && next.length > 0) { + captureEvent(`${analyticsPrefix}_sorted`, { + field: next[0].id, + direction: next[0].desc ? 'desc' : 'asc', + }) + } + + setSorters( + next.map((sort) => ({ + field: sort.id, + order: sort.desc ? ('desc' as const) : ('asc' as const), + })) + ) + + // Re-ordering the collection makes the current page meaningless. + setCurrentPage(1) + }, + [analyticsPrefix, setCurrentPage, setSorters, sorting] + ) + + const onColumnFiltersChange = useCallback>( + (updater) => { + const next = functionalUpdate(updater, columnFilters) + + const crudFilters = next.map((filter) => + isComparisonValue(filter.value) + ? { + field: filter.id, + operator: filter.value.operator, + value: filter.value.value, + } + : { + field: filter.id, + operator: + filterKinds.get(filter.id)?.operator ?? + DEFAULT_TEXT_FILTER_OPERATOR, + value: filter.value, + } + ) as CrudFilter[] + + if (analyticsPrefix && crudFilters.length > 0) { + captureEvent(`${analyticsPrefix}_filter_applied`, { + filter_count: crudFilters.length, + filter_fields: crudFilters + .filter(isLogicalFilter) + .map((filter) => filter.field), + filter_operators: crudFilters.map((filter) => filter.operator), + }) + } + + setFilters(crudFilters, 'replace') + + // A narrower result set invalidates the current page. + setCurrentPage(1) + }, + [analyticsPrefix, columnFilters, filterKinds, setCurrentPage, setFilters] + ) + + const onPaginationChange = useCallback>( + (updater) => { + const next = functionalUpdate(updater, pagination) + + if (next.pageSize !== pagination.pageSize) { + setPageSize(next.pageSize) + setCurrentPage(1) + return + } + + setCurrentPage(next.pageIndex + 1) + }, + [pagination, setCurrentPage, setPageSize] + ) + + const onColumnVisibilityChange = useCallback>( + (updater) => { + const next = functionalUpdate(updater, columnVisibility) + const hidden = Object.entries(next) + .filter(([, visible]) => !visible) + .map(([field]) => field) + + if (analyticsPrefix) { + captureEvent(`${analyticsPrefix}_column_visibility_changed`, { + hidden_count: hidden.length, + hidden_columns: hidden, + }) + } + + setColumnVisibility(next) + }, + [analyticsPrefix, columnVisibility] + ) + + return { + state: { sorting, columnFilters, pagination, columnVisibility }, + onSortingChange, + onColumnFiltersChange, + onPaginationChange, + onColumnVisibilityChange, + manualSorting: true as const, + manualFiltering: true as const, + manualPagination: true as const, + rowCount: result.total ?? 0, + } +} diff --git a/src/components/ListPage.tsx b/src/components/ListPage.tsx index be52d1bf..1f19b970 100644 --- a/src/components/ListPage.tsx +++ b/src/components/ListPage.tsx @@ -1,25 +1,22 @@ -import { ExportButton, List } from '@refinedev/mui' -import { AppBreadcrumb } from '@/components/AppBreadcrumb' +import SearchIcon from '@mui/icons-material/Search' +import { Box, Chip, InputBase, Stack, Typography } from '@mui/material' import { DataGrid, + GridColDef, + GridFilterItem, + GridRowParams, GridToolbarColumnsButton, - GridToolbarFilterButton, GridToolbarContainer, GridToolbarDensitySelector, GridToolbarExport, - gridFilterActiveItemsSelector, + GridToolbarFilterButton, gridColumnLookupSelector, + gridFilterActiveItemsSelector, gridFilterModelSelector, - GridFilterItem, + MuiEvent, useGridApiContext, useGridSelector, - GridColDef, - GridRowParams, - MuiEvent, } from '@mui/x-data-grid' -import { settings } from '@/settings' -import React from 'react' -import { useNavigate } from 'react-router' import { CanAccess, useExport, @@ -27,12 +24,19 @@ import { useNavigation, useResourceParams, } from '@refinedev/core' -import { Box, Chip, InputBase, Stack, Typography } from '@mui/material' -import SearchIcon from '@mui/icons-material/Search' +import { ExportButton, List } from '@refinedev/mui' +import React from 'react' +import { useNavigate } from 'react-router' +import { AppBreadcrumb } from '@/components/AppBreadcrumb' +import { + isNewWindowClick, + openInNewWindow, +} from '@/components/DataTable/rowNavigation' import { ocotilloCardHeaderProps, ocotilloPageTitleTypographySx, } from '@/components/OcotilloPageHeader' +import { settings } from '@/settings' /** * Standard layout for Ocotillo list pages: title, optional description, record @@ -158,25 +162,9 @@ function ListPageToolbar({ ) } -/** - * DataGrid rows are not anchors, so modifier clicks would otherwise navigate in - * place. Treat the browser conventions for "open elsewhere" as new-window intent. - */ -export function isNewWindowClick(event: { - ctrlKey?: boolean - metaKey?: boolean - button?: number -}): boolean { - // Shift is left alone: the DataGrid uses it for row range selection. - return Boolean(event.ctrlKey || event.metaKey || event.button === 1) -} - -export function openInNewWindow(href: string) { - // Router paths are basename-relative; window.open is not. - const target = href.startsWith('/') ? `${settings.urlprefix}${href}` : href - const opened = window.open(target, '_blank', 'noopener,noreferrer') - if (opened) opened.opener = null -} +// Row navigation helpers now live with the DataTable so both table +// implementations share them; re-exported here for existing importers. +export { isNewWindowClick, openInNewWindow } type ListPageProps = { title?: string diff --git a/src/components/ListPageShell.tsx b/src/components/ListPageShell.tsx new file mode 100644 index 00000000..5757e1d9 --- /dev/null +++ b/src/components/ListPageShell.tsx @@ -0,0 +1,64 @@ +import { Typography } from '@mui/material' +import { CanAccess } from '@refinedev/core' +import type { ReactNode } from 'react' +import { AppBreadcrumb } from '@/components/AppBreadcrumb' +import { ocotilloPageTitleTypographySx } from '@/components/OcotilloPageHeader' + +/** + * Page frame for the shadcn list pages: breadcrumb, title, optional + * description and header buttons, wrapped in the resource access check. This + * is the MUI `ListPage` header without the DataGrid, so pages that render a + * DataTable keep the same chrome as the ones that still use ListPage. + */ + +export interface ListPageShellProps { + title: string + description?: string + /** Resource passed to `CanAccess`, e.g. `ocotillo.thing-well`. */ + accessResource: string + headerButtons?: ReactNode + children: ReactNode +} + +export const ListPageShell: React.FC = ({ + title, + description, + accessResource, + headerButtons, + children, +}) => ( + + {/* pt-3 aligns the title with the MUI List header the other lists use. */} +
+ + +
+
+ + {title} + + {description ? ( + + {description} + + ) : null} +
+ + {headerButtons ? ( +
+ {headerButtons} +
+ ) : null} +
+ + {children} +
+
+) diff --git a/src/components/index.ts b/src/components/index.ts index 94d02e9f..0b9dd68d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,33 +2,35 @@ export * from './Auth' export * from './BasemapControl' export * from './BasemapSelector' export * from './Button' -export * from './ContactShow' -export * from './WellShow' -export * from './ClearableSelect' export * from './ChipWithExplain' +export * from './ClearableSelect' export * from './ConfirmDialog' +export * from './ContactShow' export * from './Controlled' +export * from './card' +export * from './DataTable' export * from './DebouncedTextInput' +export * from './ErrorAlertFormField' export * from './ExternalLink' export * from './enums' -export * from './ErrorAlertFormField' -export * from './card' export * from './FileSelectionSection' export * from './FilterComponent' +export * from './HydrographPngExporter' export * from './Hydrographs/EditableHydrograph' -export * from './Hydrographs/OcotilloHydrographCorrectionWorkbench' export * from './Hydrographs/HydrographUiModeToggle' export * from './Hydrographs/hydrographUiMode' -export * from './HydrographPngExporter' +export * from './Hydrographs/OcotilloHydrographCorrectionWorkbench' export * from './LegendComponent' export * from './ListPage' -export * from './OcotilloPageHeader' +export * from './ListPageShell' export * from './MapComponent' export * from './MapPopupComponent' +export * from './OcotilloPageHeader' +export * from './ProtectedRoute' +export * from './pdf' export * from './SkeletonFormField' export * from './util' -export * from './pdf' -export * from './ProtectedRoute' export * from './VisuallyHiddenTextField' +export * from './WellShow' export * from './WellStatusChips' export * from './WIPAlert' diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 00000000..540d1269 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,87 @@ +import { Popover as PopoverPrimitive } from 'radix-ui' +import * as React from 'react' + +import { cn } from '@/lib/utils' + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = 'center', + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) { + return ( +
+ ) +} + +function PopoverDescription({ + className, + ...props +}: React.ComponentProps<'p'>) { + return ( +

+ ) +} + +export { + Popover, + PopoverAnchor, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} diff --git a/src/pages/ocotillo/contact/list.tsx b/src/pages/ocotillo/contact/list.tsx index 72f071dc..f0f2977a 100644 --- a/src/pages/ocotillo/contact/list.tsx +++ b/src/pages/ocotillo/contact/list.tsx @@ -1,22 +1,52 @@ +import { type BaseRecord, useLink, useList, useTable } from '@refinedev/core' +import { + type ColumnDef, + getCoreRowModel, + useReactTable, +} from '@tanstack/react-table' +import { MailIcon, MapPinIcon, PhoneIcon } from 'lucide-react' import React, { useEffect, useMemo, useState } from 'react' -import { useDataGrid } from '@refinedev/mui' -import { DataGrid, GridColDef } from '@mui/x-data-grid' import { captureEvent } from '@/analytics/posthog' +import { + DataTable, + DataTableColumnHeader, + DataTablePagination, + DataTableToolbar, + useRefineDataTable, +} from '@/components/DataTable' +import { ListPageShell } from '@/components/ListPageShell' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { useAccessCapabilities } from '@/hooks' import { IAddress, IContact, IEmail, IPhone, } from '@/interfaces/ocotillo/IContact' -import { Card, CardHeader, SxProps } from '@mui/material' -import { Email, Home, Phone } from '@mui/icons-material' -import { useLink } from '@refinedev/core' -import { settings } from '@/settings' -import { formatAppDateTime, formatPhone } from '@/utils' +import { + filterConfidentialRows, + formatAppDateTime, + formatPhone, + sanitizeContacts, +} from '@/utils' import { getContactDisplayName } from '@/utils/contactDisplayName' -import { ListPage } from '@/components/ListPage' -import { useAccessCapabilities, useListPageDataGridAnalytics } from '@/hooks' -import { filterConfidentialRows, sanitizeContacts } from '@/utils' + +/** + * Contacts list. Rows select rather than navigate: selecting a contact opens + * the email, phone and address cards below the table for anyone cleared to see + * confidential details, and the name cell links through to the contact page. + */ + +const CONTACTS_PAGE_SIZE = 50 +const NO_VALUE = '—' + +const pickPrimary = ( + items: T[] | undefined | null, + isPrimary: (item: T) => boolean +): T | undefined => { + if (!items || items.length === 0) return undefined + return items.find(isPrimary) ?? items[0] +} export const ContactList: React.FC = () => { useEffect(() => { @@ -28,123 +58,118 @@ export const ContactList: React.FC = () => { null ) - const { dataGridProps } = useDataGrid({ - pagination: { pageSize: 50 }, - }) + const Link = useLink() - const dataGridPropsWithAnalytics = useListPageDataGridAnalytics( - dataGridProps, - 'contacts' - ) + const refineTable = useTable({ + pagination: { pageSize: CONTACTS_PAGE_SIZE }, + }) const visibleContacts = useMemo( - () => sanitizeContacts(dataGridProps.rows, canViewConfidential), - [canViewConfidential, dataGridProps.rows] + () => sanitizeContacts(refineTable.result.data, canViewConfidential), + [canViewConfidential, refineTable.result.data] ) - const Link = useLink() - - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { - field: 'name', - headerName: 'Name', - type: 'string', - minWidth: 160, - flex: 1, - valueGetter: (_: unknown, row: IContact) => getContactDisplayName(row), + id: 'name', + accessorFn: (contact) => getContactDisplayName(contact), + header: ({ column }) => ( + + ), + cell: ({ row, getValue }) => ( + ) => + event.stopPropagation() + } + > + {(getValue() as string) || NO_VALUE} + + ), + meta: { + label: 'Name', + cellClassName: 'font-medium', + filter: { type: 'text' }, + }, }, { - field: 'organization', - headerName: 'Organization', - type: 'string', - minWidth: 180, - flex: 1, - valueGetter: (_: unknown, row: IContact) => row.organization ?? '', + id: 'organization', + accessorFn: (contact) => contact.organization ?? '', + header: ({ column }) => ( + + ), + meta: { label: 'Organization', filter: { type: 'text' } }, }, { - field: 'role', - headerName: 'Role', - type: 'string', - width: 140, + id: 'role', + accessorFn: (contact) => contact.role ?? '', + header: ({ column }) => ( + + ), + meta: { label: 'Role', filter: { type: 'text' } }, }, { - field: 'contact_type', - headerName: 'Contact Type', - width: 140, + id: 'contact_type', + accessorFn: (contact) => contact.contact_type ?? '', + header: ({ column }) => ( + + ), + meta: { label: 'Contact Type', filter: { type: 'text' } }, }, { - field: 'primary_phone', - headerName: 'Primary Phone', - type: 'string', - width: 160, - sortable: false, - valueGetter: (_: unknown, row: IContact) => { - const primary = pickPrimary( - row.phones, - (p) => p.phone_type === 'Primary' - ) - return primary?.phone_number ?? '' - }, - renderCell: (params) => { - if (!canViewConfidential) return - const primary = pickPrimary( - params.row.phones, - (p) => p.phone_type === 'Primary' - ) - return primary?.phone_number ? ( - {formatPhone(primary.phone_number)} - ) : ( - - ) + id: 'primary_phone', + accessorFn: (contact) => + pickPrimary(contact.phones, (phone) => phone.phone_type === 'Primary') + ?.phone_number ?? '', + header: ({ column }) => ( + + ), + // Sorting and filtering are not offered: the value is derived from the + // phone list, which the API does not sort or filter on. + enableSorting: false, + cell: ({ getValue }) => { + if (!canViewConfidential) return null + const value = getValue() as string + return value ? formatPhone(value) : null }, + meta: { label: 'Primary Phone' }, }, { - field: 'primary_email', - headerName: 'Primary Email', - type: 'string', - minWidth: 200, - flex: 1, - sortable: false, - valueGetter: (_: unknown, row: IContact) => { - const primary = pickPrimary( - row.emails, - (e) => e.email_type === 'Primary' - ) - return primary?.email ?? '' - }, - renderCell: (params) => { - if (!canViewConfidential) return - const primary = pickPrimary( - params.row.emails, - (e) => e.email_type === 'Primary' - ) - return primary?.email ? {primary.email} : - }, + id: 'primary_email', + accessorFn: (contact) => + pickPrimary(contact.emails, (email) => email.email_type === 'Primary') + ?.email ?? '', + header: ({ column }) => ( + + ), + enableSorting: false, + cell: ({ getValue }) => + canViewConfidential ? ((getValue() as string) ?? null) : null, + meta: { label: 'Primary Email' }, }, { - field: 'things', - headerName: 'Associated Sites', - description: - 'Monitoring sites linked to this contact. Sort uses the alphabetically first linked site name.', - type: 'string', - minWidth: 180, - flex: 1, - valueGetter: (_: unknown, row: IContact) => - row.things?.map((thing) => thing.name).join('; ') ?? '', - renderCell: (params) => { - const things = params.row.things ?? [] + id: 'things', + accessorFn: (contact) => + contact.things?.map((thing) => thing.name).join('; ') ?? '', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const things = row.original.things ?? [] + if (things.length === 0) return NO_VALUE + return ( -

- {things.map((thing, idx) => ( +
+ {things.map((thing, index) => ( - {idx > 0 && ', '} + {index > 0 && ', '} { id: thing.id, }, }} - onClick={(e: React.MouseEvent) => e.stopPropagation()} + onClick={(event: React.MouseEvent) => + event.stopPropagation() + } > {thing.name} @@ -162,174 +189,250 @@ export const ContactList: React.FC = () => {
) }, + meta: { + label: 'Associated Sites', + description: + 'Monitoring sites linked to this contact. Sort uses the alphabetically first linked site name.', + filter: { type: 'text' }, + }, }, { - field: 'created_at', - headerName: 'Created At', - width: 180, - valueGetter: (isoDate: string) => formatAppDateTime(isoDate), + id: 'created_at', + accessorFn: (contact) => contact.created_at, + header: ({ column }) => ( + + ), + cell: ({ getValue }) => formatAppDateTime(getValue() as string), + meta: { + label: 'Created At', + filter: { type: 'date', defaultOperator: 'gte' }, + }, }, ], - [canViewConfidential] + [Link, canViewConfidential] ) - const { dataGridProps: emailDataGridProps } = useDataGrid({ - dataProviderName: 'ocotillo', - resource: `contact/${selectedContactId}/email`, - meta: { enabled: !!selectedContactId }, - }) - const { dataGridProps: phoneDataGridProps } = useDataGrid({ - dataProviderName: 'ocotillo', - resource: `contact/${selectedContactId}/phone`, - meta: { enabled: !!selectedContactId }, + const tableOptions = useRefineDataTable({ + refineTable, + columns, + analyticsPrefix: 'contacts', }) - const { dataGridProps: addressDataGridProps } = useDataGrid({ - dataProviderName: 'ocotillo', - resource: `contact/${selectedContactId}/address`, - meta: { enabled: !!selectedContactId }, + const table = useReactTable({ + data: visibleContacts, + columns, + getCoreRowModel: getCoreRowModel(), + getRowId: (contact) => String(contact.id), + ...tableOptions, }) return ( - <> - row.id} - hideHeaderButtons - onRowClick={(params) => - captureEvent('contacts_row_clicked', { contact_id: params.id }) - } - onSelectionChange={(params) => - setSelectedContactId(params.length > 0 ? (params[0] as number) : null) + + - {selectedContactId && ( - <> - {canViewConfidential && ( - - )} - {canViewConfidential && ( - - )} - {canViewConfidential && ( - - )} - - )} - + + contact.id === selectedContactId} + onRowClick={(contact) => { + setSelectedContactId(contact.id) + captureEvent('contacts_row_clicked', { contact_id: contact.id }) + }} + /> + + + + {selectedContactId && canViewConfidential ? ( +
+ + + +
+ ) : null} +
) } -const EmailInfoCard = ({ dataGridProps }: { dataGridProps: any }) => { - const columns = useMemo[]>( +/** Small client-side table for the per-contact detail cards. */ +function DetailTable({ + rows, + columns, + isLoading, + emptyMessage, +}: { + rows: TData[] + columns: ColumnDef[] + isLoading: boolean + emptyMessage: string +}) { + const table = useReactTable({ + data: rows, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + + ) +} + +function InfoCard({ + title, + icon, + children, +}: { + title: string + icon: React.ReactNode + children: React.ReactNode +}) { + return ( + + + + {icon} + {title} + + + {children} + + ) +} + +/** Contact detail lists are short; one page covers them. */ +const DETAIL_PAGE_SIZE = 100 + +function useContactDetail< + TData extends BaseRecord & { release_status?: string | null }, +>(contactId: number, path: string) { + const { result, query } = useList({ + resource: `contact/${contactId}/${path}`, + dataProviderName: 'ocotillo', + pagination: { pageSize: DETAIL_PAGE_SIZE }, + }) + + return { rows: result?.data ?? [], isLoading: query.isLoading } +} + +const EmailInfoCard = ({ contactId }: { contactId: number }) => { + const { rows, isLoading } = useContactDetail(contactId, 'email') + + const columns = useMemo[]>( () => [ { - field: 'email_type', - headerName: 'Type', - type: 'string', - width: 140, + id: 'email_type', + accessorFn: (email) => email.email_type, + header: 'Type', + meta: { label: 'Type', headClassName: 'w-36' }, }, { - field: 'email', - headerName: 'Email', - type: 'string', - minWidth: 200, - flex: 1, + id: 'email', + accessorFn: (email) => email.email, + header: 'Email', + meta: { label: 'Email' }, }, ], [] ) return ( - } - dataGridProps={{ - ...dataGridProps, - rows: filterConfidentialRows(dataGridProps.rows, true), - }} - columns={columns} - /> + }> + + ) } -const PhoneInfoCard = ({ dataGridProps }: { dataGridProps: any }) => { - const columns = useMemo[]>( +const PhoneInfoCard = ({ contactId }: { contactId: number }) => { + const { rows, isLoading } = useContactDetail(contactId, 'phone') + + const columns = useMemo[]>( () => [ { - field: 'phone_type', - headerName: 'Type', - type: 'string', - width: 140, + id: 'phone_type', + accessorFn: (phone) => phone.phone_type, + header: 'Type', + meta: { label: 'Type', headClassName: 'w-36' }, }, { - field: 'phone_number', - headerName: 'Phone', - type: 'string', - width: 180, - renderCell: (params) => ( - {formatPhone(params.row.phone_number)} - ), + id: 'phone_number', + accessorFn: (phone) => phone.phone_number, + header: 'Phone', + cell: ({ getValue }) => formatPhone(getValue() as string), + meta: { label: 'Phone' }, }, ], [] ) return ( - } - dataGridProps={{ - ...dataGridProps, - rows: filterConfidentialRows(dataGridProps.rows, true), - }} - columns={columns} - /> + }> + + ) } -const AddressInfoCard = ({ dataGridProps }: { dataGridProps: any }) => { - const columns = useMemo[]>( +const AddressInfoCard = ({ contactId }: { contactId: number }) => { + const { rows, isLoading } = useContactDetail(contactId, 'address') + + const columns = useMemo[]>( () => [ { - field: 'address_type', - headerName: 'Type', - type: 'string', - width: 120, + id: 'address_type', + accessorFn: (address) => address.address_type, + header: 'Type', + meta: { label: 'Type', headClassName: 'w-32' }, }, { - field: 'address_line_1', - headerName: 'Address', - type: 'string', - minWidth: 200, - flex: 1, + id: 'address_line_1', + accessorFn: (address) => address.address_line_1, + header: 'Address', + meta: { label: 'Address' }, }, { - field: 'address_line_2', - headerName: 'Line 2', - type: 'string', - width: 160, + id: 'address_line_2', + accessorFn: (address) => address.address_line_2 ?? '', + header: 'Line 2', + meta: { label: 'Line 2' }, }, { - field: 'city', - headerName: 'City', - type: 'string', - width: 140, + id: 'city', + accessorFn: (address) => address.city, + header: 'City', + meta: { label: 'City' }, }, { - field: 'state', - headerName: 'State', - type: 'string', - width: 80, + id: 'state', + accessorFn: (address) => address.state, + header: 'State', + meta: { label: 'State', headClassName: 'w-20' }, }, { - field: 'postal_code', - headerName: 'Postal Code', - type: 'string', - width: 110, + id: 'postal_code', + accessorFn: (address) => address.postal_code, + header: 'Postal Code', + meta: { label: 'Postal Code', headClassName: 'w-32' }, }, ], [] @@ -338,61 +441,14 @@ const AddressInfoCard = ({ dataGridProps }: { dataGridProps: any }) => { return ( } - dataGridProps={{ - ...dataGridProps, - rows: filterConfidentialRows(dataGridProps.rows, true), - }} - columns={columns} - /> + icon={} + > + + ) } - -const InfoCard = ({ - title, - icon, - dataGridProps, - columns, -}: { - title: string - icon: React.ReactNode - dataGridProps: any - columns: any[] -}) => ( - - - - -) - -const IconCardHeader = ({ - text, - icon, - sx, -}: { - text: string - icon: React.ReactNode - sx?: SxProps -}) => ( - - {icon} - {text} - - } - /> -) - -const pickPrimary = ( - items: T[] | undefined | null, - isPrimary: (item: T) => boolean -): T | undefined => { - if (!items || items.length === 0) return undefined - return items.find(isPrimary) ?? items[0] -} diff --git a/src/pages/ocotillo/thing/list.tsx b/src/pages/ocotillo/thing/list.tsx index c1241269..60d0ddd7 100644 --- a/src/pages/ocotillo/thing/list.tsx +++ b/src/pages/ocotillo/thing/list.tsx @@ -1,30 +1,35 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { Link as RouterLink, useNavigate, useSearchParams } from 'react-router' +import { GridColDef } from '@mui/x-data-grid' import { + type CrudFilter, useExport, useGo, - useLink, useOne, - type CrudFilter, + useTable, } from '@refinedev/core' import { useDataGrid } from '@refinedev/mui' -import { GridColDef } from '@mui/x-data-grid' +import { getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { Download, FileText, Loader2, X } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router' import { captureEvent, consumeWellsProjectFilterSource, - setWellsProjectFilterSource, } from '@/analytics/posthog' -import { Download, ExternalLink, FileText, Loader2, X } from 'lucide-react' +import { + DataTable, + DataTablePagination, + DataTableToolbar, + useRefineDataTable, +} from '@/components/DataTable' +import { ListPage } from '@/components/ListPage' +import { ListPageShell } from '@/components/ListPageShell' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { ListPage } from '@/components/ListPage' -import { useListPageDataGridAnalytics } from '@/hooks' import { ISpring, IWell } from '@/interfaces/ocotillo' import { IGroup } from '@/interfaces/ocotillo/IGroup' -import { displayWellSiteName, formatAppDate, formatAppDateTime } from '@/utils' -import { getContactDisplayName } from '@/utils/contactDisplayName' +import { useWellListColumns } from '@/pages/ocotillo/thing/wellListColumns' +import { formatAppDateTime } from '@/utils' import { buildWellShowPath } from '@/utils/wellPublicUrls' -import { WellListColumnLabels } from '@/well-list/wellListColumnLabels' export const SpringList: React.FC = () => { const { dataGridProps } = useDataGrid({ @@ -75,7 +80,13 @@ export const SpringList: React.FC = () => { ) } -/** Standard ListPage template for Ocotillo resource lists. Copy for new list pages. */ +const WELLS_PAGE_SIZE = 50 + +/** + * Wells list. Uses the shadcn DataTable over Refine's `useTable`: paging, + * sorting and column filtering all run server side, so the toolbar controls + * describe the whole collection rather than the loaded page. + */ export const WellList: React.FC = () => { const [searchParams] = useSearchParams() const navigate = useNavigate() @@ -129,45 +140,62 @@ export const WellList: React.FC = () => { } }, [search]) - const { dataGridProps, setFilters } = useDataGrid({ + const refineTable = useTable({ resource: 'thing/water-well', dataProviderName: 'ocotillo', filters: { permanent: projectFilters, }, + // Most recently added records first. The API exposes no update timestamp + // on wells, so `created_at` is the closest thing to "last updated". + sorters: { + initial: [{ field: 'created_at', order: 'desc' }], + }, meta: { params: { include_contacts: true, ...(search ? { name_contains: search } : {}), }, }, - pagination: { pageSize: 50 }, + pagination: { pageSize: WELLS_PAGE_SIZE }, }) - const onFilterModelChangeRef = useRef< - typeof dataGridProps.onFilterModelChange | undefined - >(undefined) - const prevProjectIdRef = useRef(null) + const { setFilters, setCurrentPage, tableQuery, result } = refineTable + // A new search describes a different collection, so start at its first page. + // biome-ignore lint/correctness/useExhaustiveDependencies: search is the trigger useEffect(() => { - onFilterModelChangeRef.current = dataGridProps.onFilterModelChange - }, [dataGridProps.onFilterModelChange]) + setCurrentPage(1) + }, [search, setCurrentPage]) + + const prevProjectIdRef = useRef(null) - // Refine hides permanent filters from the DataGrid filterModel. When the URL - // project filter is removed, stale groups filters remain in muiCrudFilters - // and surface as a grid chip unless we reset both Refine and grid state. + // Refine seeds its filter state with the permanent filters. Dropping the URL + // project filter therefore leaves a stale `groups` filter behind unless the + // state is reset alongside it. useEffect(() => { if (prevProjectIdRef.current && !projectId) { setFilters([], 'replace') - onFilterModelChangeRef.current?.({ items: [] }) } prevProjectIdRef.current = projectId }, [projectId, setFilters]) - const dataGridPropsWithAnalytics = useListPageDataGridAnalytics( - dataGridProps, - 'wells' - ) + const columns = useWellListColumns() + + const tableOptions = useRefineDataTable({ + refineTable, + columns, + permanentFilters: projectFilters, + analyticsPrefix: 'wells', + }) + + const table = useReactTable({ + data: result.data, + columns, + getCoreRowModel: getCoreRowModel(), + getRowId: (well) => String(well.id), + ...tableOptions, + }) const { triggerExport, isLoading: exportIsLoading } = useExport({ resource: 'thing', @@ -182,375 +210,89 @@ export const WellList: React.FC = () => { }, }) - const Link = useLink() + const go = useGo() - const columns = useMemo[]>( - () => [ - { - field: 'open_in_new_window', - headerName: '', - description: - 'Open this well detail page in a new browser window so several wells can stay open at once.', - width: 52, - sortable: false, - filterable: false, - hideable: false, - disableColumnMenu: true, - disableExport: true, - align: 'center', - headerAlign: 'center', - // Drop the default cell padding so the link can cover the full cell. - cellClassName: 'relative !p-0', - // The link fills the whole cell so the entire column is the hit target, - // not just the icon glyph. - renderCell: (params) => ( - ) => { - e.stopPropagation() - captureEvent('wells_opened_new_window', { - well_id: params.row.id, - }) - }} - className="absolute inset-0 flex items-center justify-center text-muted-foreground hover:bg-primary/5 hover:text-primary" - > - - - ), - }, - { - field: 'name', - headerName: WellListColumnLabels.name, - description: - 'Official well identifier used in bureau records (for example county prefix and local ID).', - type: 'string', - minWidth: 100, - flex: 1, - }, - { - field: 'site_name', - headerName: WellListColumnLabels.siteName, - description: - 'Name of the monitoring site or facility associated with this well when one is recorded (NMBGMR alternate ID when present).', - type: 'string', - minWidth: 140, - flex: 0.9, - valueGetter: (_: unknown, row: IWell) => displayWellSiteName(row), - }, - { - field: 'monitoring_status', - headerName: WellListColumnLabels.monitoring, - description: - 'Whether the well is actively monitored or how monitoring is categorized in the current record.', - type: 'string', - width: 160, - }, - { - field: 'created_at', - headerName: WellListColumnLabels.createdAt, - description: - 'Calendar date when this well record was first added to Ocotillo.', - width: 130, - valueGetter: (v: string) => formatAppDate(v), - }, - { - field: 'well_status', - headerName: WellListColumnLabels.wellStatus, - description: 'Operational or administrative status of the well.', - type: 'string', - width: 150, - }, - { - field: 'thing_type', - headerName: WellListColumnLabels.type, - description: - 'Infrastructure type from the controlled vocabulary (for example water well or geothermal well).', - type: 'string', - width: 130, - }, - { - field: 'aquifers', - headerName: WellListColumnLabels.aquifers, - description: - 'Aquifer systems linked to this well, summarized from association data. Sort uses the first aquifer name alphabetically among linked systems.', - minWidth: 180, - flex: 1, - valueGetter: (_: unknown, row: IWell) => - row.aquifers - ?.map( - (a: { aquifer_system: string; aquifer_types: string[] }) => - a.aquifer_system - ) - .join(', ') ?? '', - }, - { - field: 'groups', - headerName: WellListColumnLabels.projects, - description: - 'Projects linked to this well. A well may belong to more than one. Filter matches any linked project name. Sort uses the alphabetically first project name.', - type: 'string', - minWidth: 180, - flex: 1, - valueGetter: (_: unknown, row: IWell) => - row.groups?.map((group) => group.name).join(', ') ?? '', - renderCell: (params) => { - const groups = params.row.groups ?? [] - return ( -
- {groups.map((group, idx) => ( - - {idx > 0 && ', '} - ) => { - e.stopPropagation() - setWellsProjectFilterSource('wells_column') - captureEvent('wells_project_link_clicked', { - project_id: group.id, - project_name: group.name, - }) - }} - className="text-primary hover:underline no-underline" - > - {group.name} - - - ))} -
- ) - }, - }, - { - field: 'release_status', - headerName: WellListColumnLabels.releaseStatus, - description: - 'Whether the record is released for public viewing under data release rules.', - type: 'string', - width: 130, - }, - { - field: 'well_depth', - headerName: WellListColumnLabels.wellDepthFt, - description: - 'Completed well depth from ground surface to bottom of the well in feet.', - type: 'number', - width: 130, - align: 'right', - headerAlign: 'right', - }, - { - field: 'hole_depth', - headerName: WellListColumnLabels.holeDepthFt, - description: - 'Total drilled hole depth from ground surface to bottom of the borehole in feet.', - type: 'number', - width: 130, - align: 'right', - headerAlign: 'right', - }, - { - field: 'first_visit_date', - headerName: WellListColumnLabels.firstVisit, - description: - 'Date of the bureau first recorded visit to this well when available.', - width: 130, - valueGetter: (v: string) => formatAppDate(v), - }, - { - field: 'contacts', - headerName: WellListColumnLabels.contacts, - description: - 'People or organizations linked to this well; open a contact from the link. Sort uses the alphabetically first linked contact name.', - minWidth: 180, - flex: 1, - valueGetter: (_: unknown, row: IWell) => - row.contacts?.map((c) => getContactDisplayName(c)).join(', ') ?? '', - renderCell: (params) => { - const contacts = params.row.contacts ?? [] - return ( -
- {contacts.map((contact, idx) => ( - - {idx > 0 && ', '} - {contact?.id != null ? ( - ) => - e.stopPropagation() - } - > - {getContactDisplayName(contact)} - - ) : ( - getContactDisplayName(contact) - )} - - ))} -
- ) - }, - }, - { - field: 'well_completion_date', - headerName: WellListColumnLabels.completed, - description: 'Reported date the well construction was completed.', - width: 130, - valueGetter: (v: string) => formatAppDate(v), - }, - { - field: 'well_driller_name', - headerName: WellListColumnLabels.driller, - description: - 'Drilling company name when it was recorded for this well.', - type: 'string', - minWidth: 150, - flex: 1, - }, - { - field: 'latitude', - headerName: WellListColumnLabels.latitude, - description: - 'Latitude of the current mapped location in decimal degrees (WGS84).', - type: 'number', - width: 110, - sortable: false, - align: 'right', - headerAlign: 'right', - valueGetter: (_: unknown, row: IWell) => - row.current_location?.geometry?.coordinates[1] ?? null, - }, - { - field: 'longitude', - headerName: WellListColumnLabels.longitude, - description: - 'Longitude of the current mapped location in decimal degrees (WGS84).', - type: 'number', - width: 110, - sortable: false, - align: 'right', - headerAlign: 'right', - valueGetter: (_: unknown, row: IWell) => - row.current_location?.geometry?.coordinates[0] ?? null, - }, - { - field: 'alternate_ids', - headerName: WellListColumnLabels.alternateIds, - description: - 'Identifiers from other agencies or programs that cross reference this well.', - minWidth: 160, - flex: 1, - sortable: false, - valueGetter: (_: unknown, row: IWell) => - row.alternate_ids - ?.map((a) => `${a.alternate_organization}: ${a.alternate_id}`) - .join(', ') ?? '', - }, - ], - [] + const headerButtons = ( + <> + + + ) - const go = useGo() - - const customHeaderButtons = () => { - return ( - <> - + const projectChip = projectId ? ( + +
+ Project: {projectName ?? projectId} - - ) - } +
+
+ ) : null return ( - row.id} - headerButtons={customHeaderButtons} - searchMode="server" - searchValue={searchInput} - onSearchChange={setSearchInput} - searchPlaceholder="Search by well name" - searchAriaLabel="Search wells by well name" - onRowClick={(params) => - captureEvent('wells_row_clicked', { well_id: params.id }) - } accessResource="ocotillo.thing-well" + headerButtons={headerButtons} > - {projectId ? ( -
- -
- Project: {projectName ?? projectId} - -
-
-
- ) : null} -
+ + + buildWellShowPath(well.id)} + onRowClick={(well) => + captureEvent('wells_row_clicked', { well_id: well.id }) + } + /> + + + ) } diff --git a/src/pages/ocotillo/thing/wellListColumns.tsx b/src/pages/ocotillo/thing/wellListColumns.tsx new file mode 100644 index 00000000..baa8c49f --- /dev/null +++ b/src/pages/ocotillo/thing/wellListColumns.tsx @@ -0,0 +1,441 @@ +import { useLink } from '@refinedev/core' +import type { ColumnDef } from '@tanstack/react-table' +import { ExternalLink } from 'lucide-react' +import { useMemo } from 'react' +import { Link as RouterLink } from 'react-router' +import { captureEvent, setWellsProjectFilterSource } from '@/analytics/posthog' +import { DataTableColumnHeader } from '@/components/DataTable' +import type { IWell } from '@/interfaces/ocotillo' +import { displayWellSiteName, formatAppDate } from '@/utils' +import { getContactDisplayName } from '@/utils/contactDisplayName' +import { buildWellShowPath } from '@/utils/wellPublicUrls' +import { WellListColumnLabels } from '@/well-list/wellListColumnLabels' + +/** + * Column definitions for the wells list. Column ids match the API field names + * so sorting and filtering can be handed straight to the server; columns the + * API cannot sort on (coordinates, alternate ids) opt out. + */ + +const NO_VALUE = '—' + +export function useWellListColumns(): ColumnDef[] { + // Contact links go through Refine so the contact route stays resource-aware. + const Link = useLink() + + return useMemo( + () => [ + { + id: 'open_in_new_window', + header: () => Open in new window, + enableSorting: false, + enableHiding: false, + meta: { + label: 'Open in new window', + description: + 'Open this well detail page in a new browser window so several wells can stay open at once.', + headClassName: 'w-[52px]', + // Drop the cell padding so the link can cover the full cell. + cellClassName: 'relative !p-0', + }, + // The link fills the whole cell so the entire column is the hit target, + // not just the icon glyph. + cell: ({ row }) => ( + ) => { + event.stopPropagation() + captureEvent('wells_opened_new_window', { + well_id: row.original.id, + }) + }} + className="absolute inset-0 flex items-center justify-center text-muted-foreground hover:bg-primary/5 hover:text-primary" + > + + + ), + }, + { + id: 'name', + accessorFn: (well) => well.name, + header: ({ column }) => ( + + ), + meta: { + label: WellListColumnLabels.name, + description: + 'Official well identifier used in bureau records (for example county prefix and local ID).', + filter: { type: 'text' }, + cellClassName: 'font-medium', + }, + }, + { + id: 'site_name', + accessorFn: (well) => displayWellSiteName(well), + header: ({ column }) => ( + + ), + meta: { + label: WellListColumnLabels.siteName, + description: + 'Name of the monitoring site or facility associated with this well when one is recorded (NMBGMR alternate ID when present).', + filter: { type: 'text' }, + }, + }, + { + id: 'monitoring_status', + accessorFn: (well) => well.monitoring_status ?? '', + header: ({ column }) => ( + + ), + meta: { + label: WellListColumnLabels.monitoring, + description: + 'Whether the well is actively monitored or how monitoring is categorized in the current record.', + filter: { type: 'text' }, + }, + }, + { + id: 'created_at', + accessorFn: (well) => well.created_at, + header: ({ column }) => ( + + ), + cell: ({ getValue }) => formatAppDate(getValue() as string), + meta: { + label: WellListColumnLabels.createdAt, + description: + 'Calendar date when this well record was first added to Ocotillo.', + filter: { type: 'date', defaultOperator: 'gte' }, + }, + }, + { + id: 'well_status', + accessorFn: (well) => well.well_status ?? '', + header: ({ column }) => ( + + ), + meta: { + label: WellListColumnLabels.wellStatus, + description: 'Operational or administrative status of the well.', + filter: { type: 'text' }, + }, + }, + { + id: 'thing_type', + accessorFn: (well) => well.thing_type, + header: ({ column }) => ( + + ), + meta: { + label: WellListColumnLabels.type, + description: + 'Infrastructure type from the controlled vocabulary (for example water well or geothermal well).', + filter: { + type: 'select', + options: [ + { label: 'Water well', value: 'water well' }, + { label: 'Geothermal well', value: 'geothermal well' }, + ], + }, + }, + }, + { + id: 'aquifers', + accessorFn: (well) => + well.aquifers?.map((aquifer) => aquifer.aquifer_system).join(', ') ?? + '', + header: ({ column }) => ( + + ), + meta: { + label: WellListColumnLabels.aquifers, + description: + 'Aquifer systems linked to this well, summarized from association data. Sort uses the first aquifer name alphabetically among linked systems.', + filter: { type: 'text' }, + }, + }, + { + id: 'groups', + accessorFn: (well) => + well.groups?.map((group) => group.name).join(', ') ?? '', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const groups = row.original.groups ?? [] + if (groups.length === 0) return NO_VALUE + + return ( +
+ {groups.map((group, index) => ( + + {index > 0 && ', '} + ) => { + event.stopPropagation() + setWellsProjectFilterSource('wells_column') + captureEvent('wells_project_link_clicked', { + project_id: group.id, + project_name: group.name, + }) + }} + className="text-primary no-underline hover:underline" + > + {group.name} + + + ))} +
+ ) + }, + meta: { + label: WellListColumnLabels.projects, + description: + 'Projects linked to this well. A well may belong to more than one. Filter matches any linked project name. Sort uses the alphabetically first project name.', + filter: { type: 'text' }, + }, + }, + { + id: 'release_status', + accessorFn: (well) => well.release_status, + header: ({ column }) => ( + + ), + meta: { + label: WellListColumnLabels.releaseStatus, + description: + 'Whether the record is released for public viewing under data release rules.', + filter: { type: 'text' }, + }, + }, + { + id: 'well_depth', + accessorFn: (well) => well.well_depth ?? null, + header: ({ column }) => ( + + ), + cell: ({ getValue }) => (getValue() as number | null) ?? NO_VALUE, + meta: { + label: WellListColumnLabels.wellDepthFt, + description: + 'Completed well depth from ground surface to bottom of the well in feet.', + align: 'right', + filter: { type: 'number', defaultOperator: 'gte' }, + }, + }, + { + id: 'hole_depth', + accessorFn: (well) => well.hole_depth ?? null, + header: ({ column }) => ( + + ), + cell: ({ getValue }) => (getValue() as number | null) ?? NO_VALUE, + meta: { + label: WellListColumnLabels.holeDepthFt, + description: + 'Total drilled hole depth from ground surface to bottom of the borehole in feet.', + align: 'right', + filter: { type: 'number', defaultOperator: 'gte' }, + }, + }, + { + id: 'first_visit_date', + accessorFn: (well) => well.first_visit_date ?? '', + header: ({ column }) => ( + + ), + cell: ({ getValue }) => formatAppDate(getValue() as string), + meta: { + label: WellListColumnLabels.firstVisit, + description: + 'Date of the bureau first recorded visit to this well when available.', + filter: { type: 'date', defaultOperator: 'gte' }, + }, + }, + { + id: 'contacts', + accessorFn: (well) => + well.contacts + ?.map((contact) => getContactDisplayName(contact)) + .join(', ') ?? '', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const contacts = row.original.contacts ?? [] + if (contacts.length === 0) return NO_VALUE + + return ( +
+ {contacts.map((contact, index) => ( + + {index > 0 && ', '} + {contact?.id != null ? ( + ) => + event.stopPropagation() + } + > + {getContactDisplayName(contact)} + + ) : ( + getContactDisplayName(contact) + )} + + ))} +
+ ) + }, + meta: { + label: WellListColumnLabels.contacts, + description: + 'People or organizations linked to this well; open a contact from the link. Sort uses the alphabetically first linked contact name.', + filter: { type: 'text' }, + }, + }, + { + id: 'well_completion_date', + accessorFn: (well) => well.well_completion_date ?? '', + header: ({ column }) => ( + + ), + cell: ({ getValue }) => formatAppDate(getValue() as string), + meta: { + label: WellListColumnLabels.completed, + description: 'Reported date the well construction was completed.', + filter: { type: 'date', defaultOperator: 'gte' }, + }, + }, + { + id: 'well_driller_name', + accessorFn: (well) => well.well_driller_name ?? '', + header: ({ column }) => ( + + ), + meta: { + label: WellListColumnLabels.driller, + description: + 'Drilling company name when it was recorded for this well.', + filter: { type: 'text' }, + }, + }, + { + id: 'latitude', + accessorFn: (well) => well.current_location?.geometry?.coordinates[1], + header: ({ column }) => ( + + ), + cell: ({ getValue }) => (getValue() as number | undefined) ?? NO_VALUE, + enableSorting: false, + meta: { + label: WellListColumnLabels.latitude, + description: + 'Latitude of the current mapped location in decimal degrees (WGS84).', + align: 'right', + }, + }, + { + id: 'longitude', + accessorFn: (well) => well.current_location?.geometry?.coordinates[0], + header: ({ column }) => ( + + ), + cell: ({ getValue }) => (getValue() as number | undefined) ?? NO_VALUE, + enableSorting: false, + meta: { + label: WellListColumnLabels.longitude, + description: + 'Longitude of the current mapped location in decimal degrees (WGS84).', + align: 'right', + }, + }, + { + id: 'alternate_ids', + accessorFn: (well) => + well.alternate_ids + ?.map( + (alternate) => + `${alternate.alternate_organization}: ${alternate.alternate_id}` + ) + .join(', ') ?? '', + header: ({ column }) => ( + + ), + enableSorting: false, + meta: { + label: WellListColumnLabels.alternateIds, + description: + 'Identifiers from other agencies or programs that cross reference this well.', + }, + }, + ], + [Link] + ) +} diff --git a/src/test/components/DataTable.test.tsx b/src/test/components/DataTable.test.tsx new file mode 100644 index 00000000..75a2b8c9 --- /dev/null +++ b/src/test/components/DataTable.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom + +import { getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +const navigate = vi.fn() + +vi.mock('react-router', () => ({ + useNavigate: () => navigate, +})) + +import { DataTable } from '@/components/DataTable/DataTable' + +type Row = { id: number; name: string; depth: number | null } + +const rows: Row[] = [ + { id: 1, name: 'Well A', depth: 120 }, + { id: 2, name: 'Well B', depth: null }, +] + +const columns = [ + { + id: 'name', + accessorFn: (row: Row) => row.name, + header: 'Name', + meta: { label: 'Name' }, + }, + { + id: 'depth', + accessorFn: (row: Row) => row.depth, + header: 'Depth', + cell: ({ getValue }: { getValue: () => unknown }) => + (getValue() as number | null) ?? '—', + meta: { label: 'Depth', align: 'right' as const }, + }, +] + +function Harness({ + data = rows, + ...props +}: { data?: Row[] } & Omit< + React.ComponentProps>, + 'table' +>) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => String(row.id), + }) + + return +} + +describe('DataTable', () => { + it('renders a row per record with the column cells', () => { + render() + + const [, firstRow, secondRow] = screen.getAllByRole('row') + + expect(within(firstRow).getByText('Well A')).toBeInTheDocument() + expect(within(firstRow).getByText('120')).toBeInTheDocument() + // Null values fall back to the placeholder rather than rendering blank. + expect(within(secondRow).getByText('—')).toBeInTheDocument() + }) + + it('shows the empty message when there are no rows', () => { + render() + + expect( + screen.getByText('No wells match these filters.') + ).toBeInTheDocument() + }) + + it('renders skeleton rows instead of data while loading', () => { + render() + + // Header plus the three placeholder rows, and no record content. + expect(screen.getAllByRole('row')).toHaveLength(4) + expect(screen.queryByText('Well A')).not.toBeInTheDocument() + }) + + it('navigates to the row href on click', async () => { + const user = userEvent.setup() + const onRowClick = vi.fn() + + render( + `/ocotillo/well/show/${row.id}`} + onRowClick={onRowClick} + /> + ) + + await user.click(screen.getByText('Well A')) + + expect(onRowClick).toHaveBeenCalledWith(rows[0]) + expect(navigate).toHaveBeenCalledWith('/ocotillo/well/show/1') + }) + + it('opens the row in a new window for a modifier click', async () => { + const user = userEvent.setup() + const open = vi.fn().mockReturnValue({ opener: {} }) + vi.stubGlobal('open', open) + navigate.mockClear() + + render( `/ocotillo/well/show/${row.id}`} />) + + await user.keyboard('{Meta>}') + await user.click(screen.getByText('Well B')) + await user.keyboard('{/Meta}') + + expect(open).toHaveBeenCalled() + expect(navigate).not.toHaveBeenCalled() + + vi.unstubAllGlobals() + }) + + it('marks the selected row', () => { + render( row.id === 2} />) + + const [, firstRow, secondRow] = screen.getAllByRole('row') + + expect(firstRow).not.toHaveAttribute('data-state', 'selected') + expect(secondRow).toHaveAttribute('data-state', 'selected') + }) +}) diff --git a/src/test/components/useRefineDataTable.test.tsx b/src/test/components/useRefineDataTable.test.tsx new file mode 100644 index 00000000..386e60da --- /dev/null +++ b/src/test/components/useRefineDataTable.test.tsx @@ -0,0 +1,201 @@ +// @vitest-environment jsdom + +import type { CrudFilter } from '@refinedev/core' +import type { ColumnDef } from '@tanstack/react-table' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const captureEvent = vi.fn() + +vi.mock('@/analytics/posthog', () => ({ + captureEvent: (...args: unknown[]) => captureEvent(...args), +})) + +import { useRefineDataTable } from '@/components/DataTable/useRefineDataTable' + +type Well = { id: number; name: string; well_depth: number | null } + +const columns: ColumnDef[] = [ + { + id: 'name', + accessorFn: (well) => well.name, + meta: { label: 'Name', filter: { type: 'text' } }, + }, + { + id: 'thing_type', + accessorFn: (well) => well.name, + meta: { + label: 'Type', + filter: { type: 'select', options: [{ label: 'Well', value: 'well' }] }, + }, + }, + { + id: 'well_depth', + accessorFn: (well) => well.well_depth, + meta: { + label: 'Depth', + filter: { type: 'number', defaultOperator: 'gte' }, + }, + }, +] + +function makeRefineTable(overrides: Record = {}) { + return { + sorters: [], + setSorters: vi.fn(), + filters: [] as CrudFilter[], + setFilters: vi.fn(), + currentPage: 1, + setCurrentPage: vi.fn(), + pageSize: 50, + setPageSize: vi.fn(), + result: { data: [], total: 137 }, + ...overrides, + // biome-ignore lint/suspicious/noExplicitAny: test double for Refine's useTable + } as any +} + +const renderGlue = (refineTable: ReturnType) => + renderHook(() => + useRefineDataTable({ + refineTable, + columns, + permanentFilters: [{ field: 'groups', operator: 'eq', value: '42' }], + analyticsPrefix: 'wells', + }) + ) + +describe('useRefineDataTable', () => { + beforeEach(() => { + captureEvent.mockClear() + }) + + it('maps Refine sorters and pagination into table state', () => { + const refineTable = makeRefineTable({ + sorters: [{ field: 'created_at', order: 'desc' }], + currentPage: 3, + }) + + const { result } = renderGlue(refineTable) + + expect(result.current.state.sorting).toEqual([ + { id: 'created_at', desc: true }, + ]) + expect(result.current.state.pagination).toEqual({ + pageIndex: 2, + pageSize: 50, + }) + expect(result.current.rowCount).toBe(137) + expect(result.current.manualPagination).toBe(true) + }) + + it('hides permanent filters from the column filter state', () => { + const refineTable = makeRefineTable({ + filters: [ + { field: 'groups', operator: 'eq', value: '42' }, + { field: 'name', operator: 'contains', value: 'SR-' }, + ], + }) + + const { result } = renderGlue(refineTable) + + expect(result.current.state.columnFilters).toEqual([ + { id: 'name', value: 'SR-' }, + ]) + }) + + it('rebuilds comparison filters as operator/value pairs', () => { + const refineTable = makeRefineTable({ + filters: [{ field: 'well_depth', operator: 'gte', value: 100 }], + }) + + const { result } = renderGlue(refineTable) + + expect(result.current.state.columnFilters).toEqual([ + { id: 'well_depth', value: { operator: 'gte', value: '100' } }, + ]) + }) + + it('sends sorting changes to Refine and reports them', () => { + const refineTable = makeRefineTable() + const { result } = renderGlue(refineTable) + + act(() => { + result.current.onSortingChange([{ id: 'name', desc: true }]) + }) + + expect(refineTable.setSorters).toHaveBeenCalledWith([ + { field: 'name', order: 'desc' }, + ]) + expect(captureEvent).toHaveBeenCalledWith('wells_sorted', { + field: 'name', + direction: 'desc', + }) + // Re-ordering invalidates the page the user was on. + expect(refineTable.setCurrentPage).toHaveBeenCalledWith(1) + }) + + it('converts column filters to CrudFilters using the column operator', () => { + const refineTable = makeRefineTable() + const { result } = renderGlue(refineTable) + + act(() => { + result.current.onColumnFiltersChange([ + { id: 'name', value: 'SR-' }, + { id: 'thing_type', value: 'well' }, + { id: 'well_depth', value: { operator: 'lte', value: '250' } }, + ]) + }) + + expect(refineTable.setFilters).toHaveBeenCalledWith( + [ + { field: 'name', operator: 'contains', value: 'SR-' }, + { field: 'thing_type', operator: 'eq', value: 'well' }, + { field: 'well_depth', operator: 'lte', value: '250' }, + ], + 'replace' + ) + // Filtering changes the result set, so paging restarts. + expect(refineTable.setCurrentPage).toHaveBeenCalledWith(1) + }) + + it('resets to the first page when the page size changes', () => { + const refineTable = makeRefineTable({ currentPage: 4 }) + const { result } = renderGlue(refineTable) + + act(() => { + result.current.onPaginationChange({ pageIndex: 3, pageSize: 100 }) + }) + + expect(refineTable.setPageSize).toHaveBeenCalledWith(100) + expect(refineTable.setCurrentPage).toHaveBeenCalledWith(1) + }) + + it('moves pages without touching the page size', () => { + const refineTable = makeRefineTable() + const { result } = renderGlue(refineTable) + + act(() => { + result.current.onPaginationChange({ pageIndex: 2, pageSize: 50 }) + }) + + expect(refineTable.setPageSize).not.toHaveBeenCalled() + expect(refineTable.setCurrentPage).toHaveBeenCalledWith(3) + }) + + it('reports hidden columns when visibility changes', () => { + const { result } = renderGlue(makeRefineTable()) + + act(() => { + result.current.onColumnVisibilityChange({ well_depth: false }) + }) + + expect(captureEvent).toHaveBeenCalledWith( + 'wells_column_visibility_changed', + { hidden_count: 1, hidden_columns: ['well_depth'] } + ) + expect(result.current.state.columnVisibility).toEqual({ + well_depth: false, + }) + }) +}) From 7877e0b338d499426daf3e5e85ca143dbb7910c3 Mon Sep 17 00:00:00 2001 From: jakeross Date: Mon, 17 Aug 2026 13:54:25 -0700 Subject: [PATCH 2/2] test(cypress): match native table semantics on wells and contacts lists The wells and contacts assertions still looked for the DataGrid's explicit role attributes; the shadcn table renders th/tr, same as the projects list already asserted. Co-Authored-By: Claude Opus 5 --- cypress/e2e/ocotillo/list-pages.cy.ts | 30 +++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/cypress/e2e/ocotillo/list-pages.cy.ts b/cypress/e2e/ocotillo/list-pages.cy.ts index b4f2919d..b49c4374 100644 --- a/cypress/e2e/ocotillo/list-pages.cy.ts +++ b/cypress/e2e/ocotillo/list-pages.cy.ts @@ -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') }) @@ -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') }) })