diff --git a/Documentation/DataTables/bring-your-own-renderer.md b/Documentation/DataTables/bring-your-own-renderer.md new file mode 100644 index 0000000..4b58ecb --- /dev/null +++ b/Documentation/DataTables/bring-your-own-renderer.md @@ -0,0 +1,110 @@ +# Bring your own table renderer + +Cratis's value in a data table is the *behavior* — subscribing to an Arc query, paging it, tracking selection — not the pixels. `DataTableForQuery` renders that behavior with PrimeReact, but the two are not welded together. A small, UI-library-agnostic seam sits between them, so you can keep every bit of Cratis's query and paging behavior and swap in **your own** table rendering: a different component library, a virtualized grid, or a hand-rolled list. + +This page shows how to plug a renderer of your own into that seam. + +## The seam + +Two pieces make up the rendering seam: + +- **`TableRenderer`** — the contract a renderer implements. It is a component that receives one page of rows plus selection and paging-agnostic props, and renders them however it likes. It carries no PrimeReact (or any other UI-library) types. +- **`bindQuery(renderer)`** / **`bindObservableQuery(renderer)`** — higher-order helpers that pair Cratis's query + paging behavior with any `TableRenderer`, returning a paged table component with the same props and behavior the built-in tables expose. + +`DataTableForQuery` is simply `bindQuery(DataTableCore)` — the default `DataTableCore` renderer bound to the snapshot query behavior. Nothing about the built-in table is privileged; your renderer plugs into exactly the same machinery. + +```mermaid +flowchart LR + Query["Arc query
(proxy from C#)"] --> Behavior + subgraph Behavior["bindQuery — Cratis behavior"] + Paging["useQueryWithPaging"] + Paginator["TablePaginator"] + end + Behavior -->|"one page of rows via data"| Seam{{"TableRenderer<TData>"}} + Seam --> Default["DataTableCore
(default, PrimeReact)"] + Seam --> Custom["your renderer
(cards, grid, list…)"] +``` + +## What a renderer receives + +A `TableRenderer` is given `TableRendererProps`. The binding fills these in for you: + +| Prop | Meaning | +|---|---| +| `data` | The current page of rows, already paged by the binding. | +| `emptyMessage` | What to show when there are no rows. | +| `children` | Whatever you passed as children — e.g. `` elements, if your renderer reads them. | +| `dataKey` | The row property that uniquely identifies a row. | +| `selection` / `onSelectionChange` | The selected row and the change callback. | +| `selectionMode` | `'single'` when the binding drives single-row selection. | +| `onRowClick` | Row-click callback. | +| `globalFilterFields` / `defaultFilters` | Filtering hints. | +| `scrollable` / `scrollHeight` | Scroll-region hints (set by `bindObservableQuery`). | +| `className` / `style` | Root class and style. | + +Your renderer reads what it needs and ignores the rest. Notably, it never sees the query — that stays on the behavior side of the seam. + +## Write a renderer + +A renderer is a plain component, generic over the row type just like `DataTableCore`. Here is a trivial one that renders a card per row instead of a table: + +```tsx +import type { TableRenderer, TableRendererProps } from '@cratis/components/DataTables'; + +const CardListRenderer = ({ data, emptyMessage }: TableRendererProps) => { + if (data.length === 0) { + return
{emptyMessage}
; + } + return ( +
+ {data.map((row, index) => ( +
+ {Object.entries(row).map(([field, value]) => ( + {field}: {String(value)} + ))} +
+ ))} +
+ ); +}; +``` + +The `` type parameter is what lets one renderer serve any row type — the same shape `DataTableCore` uses. That is all the seam requires. + +## Bind it to a query + +Pair the renderer with Cratis's query + paging behavior by calling `bindQuery`. The result is a component with the same props as `DataTableForQuery`: + +```tsx +import { bindQuery } from '@cratis/components/DataTables'; +import { AllProducts } from './AllProducts'; // proxy from C# + +const CardListForQuery = bindQuery(CardListRenderer); + +; +``` + +You wrote no query, paging, or subscription code. `CardListForQuery` subscribes to `AllProducts`, feeds one page of rows to your renderer through `data`, and shows a paginator when the result spans more than one page — exactly what `DataTableForQuery` does, only your rendering shows up instead of a table. + +## Real-time queries + +For a real-time [observable query](./data-table-for-observable-query.md), bind the same renderer with `bindObservableQuery` instead. It subscribes via `useObservableQueryWithPaging`, re-renders as the read model changes server-side, and asks your renderer to scroll (`scrollable` / `scrollHeight`) inside a region that resizes to fill its container: + +```tsx +import { bindObservableQuery } from '@cratis/components/DataTables'; +import { AllTasks } from './AllTasks'; // observable proxy from C# + +const CardListForObservableQuery = bindObservableQuery(CardListRenderer); + +; +``` + +## When this is the wrong fit + +Reach for the seam only when you genuinely need different rendering. If PrimeReact styling is all you want to change, stay on `DataTableForQuery` and use its `pt` / `ptOptions` / `unstyled` pass-through and [column configuration](./column-configuration.md) — you get theming and accessibility handled for you. A custom renderer owns its own markup, styling, sorting affordances, and accessibility; the seam gives you Cratis's data behavior, not a free table. + +## See also + +- [DataTableForQuery](./data-table-for-query.md) — the default snapshot table (`bindQuery(DataTableCore)`). +- [DataTableForObservableQuery](./data-table-for-observable-query.md) — the default real-time table. +- [Column Configuration](./column-configuration.md) — the `` authoring model the default renderer reads. diff --git a/Documentation/DataTables/index.md b/Documentation/DataTables/index.md index 4750c05..0cacc56 100644 --- a/Documentation/DataTables/index.md +++ b/Documentation/DataTables/index.md @@ -31,8 +31,17 @@ Both table components share: - Empty state messages - PrimeReact Column support +## Bring your own renderer + +Both tables are built on a small, UI-library-agnostic rendering seam: Cratis +owns the query and paging behavior, and hands rendering off to any component +that satisfies the `TableRenderer` contract. `DataTableForQuery` is just +`bindQuery(DataTableCore)`. To render a query's paged rows with your own table +implementation, see [Bring your own table renderer](bring-your-own-renderer.md). + ## See Also - [DataTableForQuery](data-table-for-query.md) - Standard query tables - [DataTableForObservableQuery](data-table-for-observable-query.md) - Real-time tables - [Column Configuration](column-configuration.md) - Customizing columns +- [Bring your own table renderer](bring-your-own-renderer.md) - Plug in a custom renderer diff --git a/Documentation/DataTables/toc.yml b/Documentation/DataTables/toc.yml index 311a4b0..08137e2 100644 --- a/Documentation/DataTables/toc.yml +++ b/Documentation/DataTables/toc.yml @@ -6,3 +6,5 @@ href: data-table-for-observable-query.md - name: Column Configuration href: column-configuration.md +- name: Bring your own table renderer + href: bring-your-own-renderer.md diff --git a/Source/DataTables/BringYourOwnRenderer.stories.tsx b/Source/DataTables/BringYourOwnRenderer.stories.tsx new file mode 100644 index 0000000..6eead07 --- /dev/null +++ b/Source/DataTables/BringYourOwnRenderer.stories.tsx @@ -0,0 +1,120 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import React, { type ReactElement } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { QueryFor, QueryResult } from '@cratis/arc/queries'; +import { bindQuery } from './bindQuery'; +import type { TableRendererProps } from './TableRenderer'; + +// This story is the visual counterpart to `for_bindQuery` — it proves the +// rendering seam by pairing Cratis's query + paging behavior with a renderer +// that is deliberately *not* a table: a card list. The consumer writes no +// query/paging code, yet gets Cratis paging (note the paginator) for free. + +interface Product { + id: number; + name: string; + category: string; + price: number; +} + +const allProducts: Product[] = Array.from({ length: 25 }, (_, index) => ({ + id: index + 1, + name: `Product ${index + 1}`, + category: ['Electronics', 'Office', 'Accessories'][index % 3], + price: Math.round((10 + index * 3.5) * 100) / 100, +})); + +/** + * A trivial, non-`DataTableCore` renderer: cards, not a table, and free of any + * PrimeReact. Generic over the row type (exactly like `DataTableCore`), so it + * satisfies the {@link TableRenderer} contract and plugs into `bindQuery`. + */ +const CardListRenderer = ({ data, emptyMessage }: TableRendererProps): ReactElement => { + if (data.length === 0) { + return
{emptyMessage}
; + } + return ( +
+ {data.map((row, index) => ( +
+ {Object.entries(row).map(([field, value]) => ( + + {field}: + {String(value)} + + ))} +
+ ))} +
+ ); +}; + +// `bindQuery(CardListRenderer)` returns a component with exactly the same props +// and behavior as `DataTableForQuery` — only the rendering differs. +const CardListForQuery = bindQuery(CardListRenderer); + +// Mock query — overrides perform() to page a static dataset instead of hitting a backend. +class ProductsQuery extends QueryFor { + readonly route = '/api/products'; + readonly defaultValue: Product = [] as unknown as Product; + readonly parameterDescriptors = []; + get requiredRequestParameters() { + return []; + } + constructor() { + super(Object, true); + } + override perform(): Promise> { + const currentPaging = (this as unknown as { paging?: { page: number; pageSize: number } }).paging; + const page = currentPaging?.page ?? 0; + const size = currentPaging?.pageSize ?? 20; + const start = page * size; + return Promise.resolve({ + data: allProducts.slice(start, start + size), + paging: { totalItems: allProducts.length, totalPages: Math.ceil(allProducts.length / size), page, size }, + isSuccess: true, + isAuthorized: true, + isValid: true, + hasExceptions: false, + validationResults: [], + exceptionMessages: [], + exceptionStackTrace: '', + } as unknown as QueryResult); + } +} + +const meta: Meta = { + title: 'DataTables/BringYourOwnRenderer', + component: CardListForQuery, +}; + +export default meta; +type Story = StoryObj; + +/** + * A card renderer bound to a paged Cratis query. The paginator at the bottom + * (25 rows across two pages) is driven by Cratis — the renderer only ever + * receives one page of rows through `data`. + */ +export const Default: Story = { + render: () => ( +
+ + query={ProductsQuery} + emptyMessage="No products found" + dataKey="id" + /> +
+ ) +}; diff --git a/Source/DataTables/DataTableCore.tsx b/Source/DataTables/DataTableCore.tsx index e6dc786..d85c83c 100644 --- a/Source/DataTables/DataTableCore.tsx +++ b/Source/DataTables/DataTableCore.tsx @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import React, { useMemo, useState, type CSSProperties, type ReactNode } from 'react'; +import React, { useMemo, useState, type ReactNode } from 'react'; import { DataTable as PrimeDataTable } from 'primereact/datatable'; import { InputText } from 'primereact/inputtext'; import type { DataTableRootProps } from '@primereact/types/primitive/datatable'; @@ -9,66 +9,32 @@ import type { UseDataTableSelectionEvent, UseDataTableRowMouseEvent, UseDataTabl import type { ColumnProps } from './Column'; import { ColumnFilterMenu } from './ColumnFilterMenu'; import { selectionKeysForRow, rowFromSelectionKeys } from './selectionKeys'; -import type { DataTableSelectionChangeEvent } from './DataTableSelectionChangeEvent'; import type { DataTableFilterMeta } from './DataTableFilterMeta'; +import type { TableRendererProps } from './TableRenderer'; import './DataTableCore.css'; /* eslint-disable @typescript-eslint/no-explicit-any */ -/** - * Row-click event surfaced by {@link DataTableCore}. - * - * @typeParam TData - The row type. - */ -export interface DataTableRowClickEvent { - /** The clicked row. */ - data: TData; - /** The row's index in the current page. */ - index: number; -} +// Re-exported for source compatibility — the canonical definition now lives in +// its own file so the rendering-seam contract (TableRenderer) can reference it. +export type { DataTableRowClickEvent } from './DataTableRowClickEvent'; /** - * Props for {@link DataTableCore}. + * Props for {@link DataTableCore} — the {@link TableRendererProps} rendering + * seam plus the PrimeReact pass-through and rendering extras specific to this + * default, PrimeReact-based implementation. * * @typeParam TData - The row type. */ -export interface DataTableCoreProps { - /** The rows to render (already paged by the caller). */ - data: TData[]; - /** `` elements describing the columns. */ - children?: ReactNode; - /** The row property uniquely identifying each row — required for selection. */ - dataKey?: string; - /** Content shown when there are no rows. */ - emptyMessage: ReactNode; - /** Enables single-row selection by clicking a row. */ - selectionMode?: 'single'; +export interface DataTableCoreProps extends TableRendererProps { /** Accessible name for each row's selection control. Override to localize. Defaults to `'Select row'`. */ selectionAriaLabel?: string; - /** The currently-selected row. */ - selection?: TData | null; - /** Invoked when the selected row changes. */ - onSelectionChange?: (event: DataTableSelectionChangeEvent) => void; - /** Invoked when a row is clicked. */ - onRowClick?: (event: DataTableRowClickEvent) => void; /** Computes an extra class name for each row. */ rowClassName?: (rowData: TData) => string; - /** The fields the global search term is matched against. When set, a search box is shown above the table. */ - globalFilterFields?: string[]; /** Placeholder for the global search box. */ globalSearchPlaceholder?: string; - /** Initial per-column filter state. */ - defaultFilters?: DataTableFilterMeta; /** Invoked whenever the per-column filter state changes. */ onFilter?: (filters: DataTableFilterMeta) => void; - /** Renders the table body in a scroll region of {@link scrollHeight}. */ - scrollable?: boolean; - /** The height of the scroll region when {@link scrollable} is set. */ - scrollHeight?: string; - /** Extra class name for the table root. */ - className?: string; - /** Inline style for the table root. */ - style?: CSSProperties; /** PrimeReact pass-through configuration for the underlying DataTable. */ pt?: DataTableRootProps['pt']; /** PrimeReact pass-through options for the underlying DataTable. */ diff --git a/Source/DataTables/DataTableForObservableQuery.tsx b/Source/DataTables/DataTableForObservableQuery.tsx index 40fac51..7104363 100644 --- a/Source/DataTables/DataTableForObservableQuery.tsx +++ b/Source/DataTables/DataTableForObservableQuery.tsx @@ -2,98 +2,34 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { DataTableRootProps } from '@primereact/types/primitive/datatable'; -import { Constructor } from '@cratis/fundamentals'; -import { IObservableQueryFor, Paging } from '@cratis/arc/queries'; -import { useObservableQueryWithPaging } from '@cratis/arc.react/queries'; -import { ReactNode, useState, useRef, useEffect } from 'react'; +import { IObservableQueryFor } from '@cratis/arc/queries'; import { DataTableCore } from './DataTableCore'; -import { TablePaginator, type TablePaginatorProps } from './TablePaginator'; -import type { DataTableFilterMeta } from './DataTableFilterMeta'; -import type { DataTableSelectionChangeEvent } from './DataTableSelectionChangeEvent'; +import { bindObservableQuery, type BoundObservableQueryTableProps } from './bindObservableQuery'; /** - * Props for {@link DataTableForObservableQuery}. - * - * @typeParam TQuery - The query class implementing `IObservableQueryFor`. - * @typeParam TDataType - The row type returned by the query. - * @typeParam TArguments - The query's argument object type. + * The PrimeReact styling pass-through the default `DataTableCore` renderer + * accepts. Layered onto the observable query binding here (not in the + * UI-library-agnostic {@link bindObservableQuery} seam) and forwarded verbatim + * to `DataTableCore`. */ -export interface DataTableForObservableQueryProps, TDataType extends object, TArguments extends object> { - /** - * Children to render — `` elements describing the visible columns. - */ - children?: ReactNode; - - /** - * The type of query to use - */ - query: Constructor; - - /** - * Optional arguments to pass to the query - */ - queryArguments?: TArguments; - - /** - * The message to show when there is no data - */ - emptyMessage: string; - - /** - * The key to use for the data - */ - dataKey?: string | undefined; - - /** - * The current selection. - */ - selection?: TDataType | undefined | null; - - /** - * Callback for when the selection changes - */ - onSelectionChange?(event: DataTableSelectionChangeEvent): void; - - /** - * Fields to use for global filtering - */ - globalFilterFields?: string[] | undefined; - - /** - * Default filters to use - */ - defaultFilters?: DataTableFilterMeta; - - /** - * @deprecated No longer toggles behavior. Filtering (`` and the - * global search) is always applied client-side to the loaded page; this flag - * is retained only for source compatibility and will be removed in a future - * release. - */ - clientFiltering?: boolean; - - /** - * Extra CSS class name forwarded to the underlying DataTable root. - */ - className?: string; - +interface DataTablePassThroughProps { /** PrimeReact pass-through configuration applied to the underlying DataTable. */ pt?: DataTableRootProps['pt']; - /** PrimeReact pass-through options applied to the underlying DataTable. */ ptOptions?: DataTableRootProps['ptOptions']; - /** When true, disables every base PrimeReact style on the underlying DataTable. */ unstyled?: boolean; - - /** Extra CSS class name forwarded to the paginator. */ - paginatorClassName?: string; - - /** Accessible names for the paginator controls. Override any to localize. */ - paginatorAriaLabels?: TablePaginatorProps['ariaLabels']; } -const paging = new Paging(0, 20); +/** + * Props for {@link DataTableForObservableQuery}. + * + * @typeParam TQuery - The query class implementing `IObservableQueryFor`. + * @typeParam TDataType - The row type returned by the query. + * @typeParam TArguments - The query's argument object type. + */ +export type DataTableForObservableQueryProps, TDataType extends object, TArguments extends object> = + BoundObservableQueryTableProps & DataTablePassThroughProps; /** * A paged data table bound to a real-time Cratis Arc observable query @@ -103,6 +39,13 @@ const paging = new Paging(0, 20); * {@link DataTableCore} inside an internally-scrolling region that resizes to * fill its container. * + * This is `bindObservableQuery(DataTableCore)` — the + * {@link bindObservableQuery} observable query/paging behavior paired with the + * default `DataTableCore` renderer. To render a query's paged rows with a + * *different* table implementation, call `bindObservableQuery` with your own + * {@link TableRenderer}. See + * [Bring your own table renderer](../../Documentation/DataTables/bring-your-own-renderer.md). + * * ## Children * * Children are Cratis `` elements describing the visible columns. @@ -118,7 +61,7 @@ const paging = new Paging(0, 20); * ``` * * Use {@link DataTableForQuery} for one-shot snapshot queries. Use - * {@link DataPage} for a higher-level layout that combines this table with + * `DataPage` for a higher-level layout that combines this table with * an action menubar, selection, and a details pane. * * @typeParam TQuery - The query class (proxy generated from C# `IObservableQueryFor`). @@ -126,98 +69,4 @@ const paging = new Paging(0, 20); * @typeParam TArguments - The query's argument object type. * @param props - {@link DataTableForObservableQueryProps}. */ -export const DataTableForObservableQuery = , TDataType extends object, TArguments extends object>(props: DataTableForObservableQueryProps) => { - const [result, , setPage] = useObservableQueryWithPaging(props.query, paging, props.queryArguments); - const containerRef = useRef(null); - const [tableHeight, setTableHeight] = useState(600); - const timeoutRef = useRef | undefined>(undefined); - const totalItems = result.paging.totalItems; - const pageCount = result.paging.totalPages; - const showPaginator = totalItems > 0 && pageCount > 1; - - useEffect(() => { - if (!containerRef.current) return; - - const resizeObserver = new ResizeObserver((entries) => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - - timeoutRef.current = setTimeout(() => { - for (const entry of entries) { - const containerHeight = entry.contentRect.height; - if (containerHeight > 0) { - const paginatorHeight = showPaginator ? 56 : 0; - const calculatedHeight = containerHeight - paginatorHeight - 2; - const newHeight = Math.max(calculatedHeight, 200); - - setTableHeight(prevHeight => { - if (Math.abs(newHeight - prevHeight) > 5) { - return newHeight; - } - return prevHeight; - }); - } - } - }, 10); - }); - - resizeObserver.observe(containerRef.current); - - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - resizeObserver.disconnect(); - }; - }, [showPaginator]); - - return ( -
-
- - data={result.data as unknown as TDataType[]} - dataKey={props.dataKey} - emptyMessage={props.emptyMessage} - selectionMode='single' - selection={props.selection} - onSelectionChange={props.onSelectionChange} - globalFilterFields={props.globalFilterFields} - defaultFilters={props.defaultFilters} - scrollable - scrollHeight='100%' - className={props.className} - style={{ minWidth: '100%' }} - pt={props.pt} - ptOptions={props.ptOptions} - unstyled={props.unstyled}> - {props.children} - -
- - {showPaginator && ( -
- -
- )} -
- ); -}; +export const DataTableForObservableQuery = bindObservableQuery(DataTableCore); diff --git a/Source/DataTables/DataTableForQuery.tsx b/Source/DataTables/DataTableForQuery.tsx index c8e2891..f1e4d97 100644 --- a/Source/DataTables/DataTableForQuery.tsx +++ b/Source/DataTables/DataTableForQuery.tsx @@ -2,105 +2,47 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { DataTableRootProps } from '@primereact/types/primitive/datatable'; -import { Constructor } from '@cratis/fundamentals'; -import { IQueryFor, Paging } from '@cratis/arc/queries'; -import { useQueryWithPaging } from '@cratis/arc.react/queries'; -import { ReactNode } from 'react'; +import { IQueryFor } from '@cratis/arc/queries'; import { DataTableCore } from './DataTableCore'; -import { TablePaginator, type TablePaginatorProps } from './TablePaginator'; -import type { DataTableFilterMeta } from './DataTableFilterMeta'; -import type { DataTableSelectionChangeEvent } from './DataTableSelectionChangeEvent'; +import { bindQuery, type BoundQueryTableProps } from './bindQuery'; /** - * Props for {@link DataTableForQuery}. - * - * @typeParam TQuery - The query class implementing `IQueryFor`. - * @typeParam TDataType - The row type returned by the query. - * @typeParam TArguments - The query's argument object type, or `object` if it takes none. + * The PrimeReact styling pass-through the default `DataTableCore` renderer + * accepts. Layered onto the query binding here (not in the UI-library-agnostic + * {@link bindQuery} seam) and forwarded verbatim to `DataTableCore`. */ -export interface DataTableForQueryProps, TDataType extends object, TArguments extends object> { - /** - * Children to render — `` elements describing the visible columns. - */ - children?: ReactNode; - - /** - * The type of query to use - */ - query: Constructor; - - /** - * Optional Arguments to pass to the query - */ - queryArguments?: TArguments; - - /** - * The message to show when there is no data - */ - emptyMessage: string; - - /** - * The key to use for the data - */ - dataKey?: string | undefined; - - /** - * The current selection. - */ - selection?: TDataType | undefined | null; - - /** - * Callback for when the selection changes - */ - onSelectionChange?(event: DataTableSelectionChangeEvent): void; - - /** - * Fields to use for global filtering - */ - globalFilterFields?: string[] | undefined; - - /** - * Default filters to use - */ - defaultFilters?: DataTableFilterMeta; - - /** - * @deprecated No longer toggles behavior. Filtering (`` and the - * global search) is always applied client-side to the loaded page; this flag - * is retained only for source compatibility and will be removed in a future - * release. - */ - clientFiltering?: boolean; - - /** - * Extra CSS class name forwarded to the underlying DataTable root. - */ - className?: string; - +interface DataTablePassThroughProps { /** PrimeReact pass-through configuration applied to the underlying DataTable. */ pt?: DataTableRootProps['pt']; - /** PrimeReact pass-through options applied to the underlying DataTable. */ ptOptions?: DataTableRootProps['ptOptions']; - /** When true, disables every base PrimeReact style on the underlying DataTable. */ unstyled?: boolean; - - /** Extra CSS class name forwarded to the paginator. */ - paginatorClassName?: string; - - /** Accessible names for the paginator controls. Override any to localize. */ - paginatorAriaLabels?: TablePaginatorProps['ariaLabels']; } -const paging = new Paging(0, 20); +/** + * Props for {@link DataTableForQuery}. + * + * @typeParam TQuery - The query class implementing `IQueryFor`. + * @typeParam TDataType - The row type returned by the query. + * @typeParam TArguments - The query's argument object type, or `object` if it takes none. + */ +export type DataTableForQueryProps, TDataType extends object, TArguments extends object> = + BoundQueryTableProps & DataTablePassThroughProps; /** * A paged data table bound to a snapshot Cratis Arc query * (`IQueryFor`). Subscribes via * `useQueryWithPaging` from `@cratis/arc.react/queries`, renders the result * page through the headless {@link DataTableCore}, and shows a - * {@link TablePaginator} when the result set exceeds one page. + * `TablePaginator` when the result set exceeds one page. + * + * This is `bindQuery(DataTableCore)` — the {@link bindQuery} query/paging + * behavior paired with the default `DataTableCore` renderer. To render a + * query's paged rows with a *different* table implementation, call `bindQuery` + * with your own {@link TableRenderer}; you get the same props and behavior with + * your own rendering. See + * [Bring your own table renderer](../../Documentation/DataTables/bring-your-own-renderer.md). * * ## What `TQuery` is * @@ -126,7 +68,7 @@ const paging = new Paging(0, 20); * * Use {@link DataTableForObservableQuery} for queries that should update in * real time as the underlying read model changes server-side. Use - * {@link DataPage} for a higher-level layout that combines this table with + * `DataPage` for a higher-level layout that combines this table with * an action menubar, selection, and a details pane. * * ## Styling @@ -139,53 +81,4 @@ const paging = new Paging(0, 20); * @typeParam TArguments - The query's argument object type. * @param props - {@link DataTableForQueryProps}. */ -export const DataTableForQuery = , TDataType extends object, TArguments extends object>(props: DataTableForQueryProps) => { - const [result, , , setPage] = useQueryWithPaging(props.query, paging, props.queryArguments); - const totalItems = result.paging.totalItems; - const pageCount = result.paging.totalPages; - - return ( -
-
- - data={result.data as unknown as TDataType[]} - dataKey={props.dataKey} - emptyMessage={props.emptyMessage} - selectionMode='single' - selection={props.selection} - onSelectionChange={props.onSelectionChange} - globalFilterFields={props.globalFilterFields} - defaultFilters={props.defaultFilters} - className={props.className} - style={{ minWidth: '100%' }} - pt={props.pt} - ptOptions={props.ptOptions} - unstyled={props.unstyled}> - {props.children} - -
- - {totalItems > 0 && pageCount > 1 && ( -
- -
- )} -
- ); -}; +export const DataTableForQuery = bindQuery(DataTableCore); diff --git a/Source/DataTables/DataTableRowClickEvent.ts b/Source/DataTables/DataTableRowClickEvent.ts new file mode 100644 index 0000000..f55aaee --- /dev/null +++ b/Source/DataTables/DataTableRowClickEvent.ts @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** + * Row-click event surfaced by a {@link TableRenderer} (the default + * implementation being `DataTableCore`). + * + * @typeParam TData - The row type. + */ +export interface DataTableRowClickEvent { + /** The clicked row. */ + data: TData; + /** The row's index in the current page. */ + index: number; +} diff --git a/Source/DataTables/TableRenderer.ts b/Source/DataTables/TableRenderer.ts new file mode 100644 index 0000000..7b6a364 --- /dev/null +++ b/Source/DataTables/TableRenderer.ts @@ -0,0 +1,76 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { CSSProperties, ReactElement, ReactNode } from 'react'; +import type { DataTableSelectionChangeEvent } from './DataTableSelectionChangeEvent'; +import type { DataTableRowClickEvent } from './DataTableRowClickEvent'; +import type { DataTableFilterMeta } from './DataTableFilterMeta'; + +/** + * The props a table renderer receives from a Cratis query binding + * ({@link bindQuery} / {@link bindObservableQuery}). This is the **rendering + * seam**: the small, UI-library-agnostic contract that stands between Cratis's + * query/paging *behavior* and however a table is *rendered*. + * + * Cratis owns the behavior — subscribing to an Arc query, paging it, and + * feeding one page of rows in through {@link data} — and hands the rendering + * off to whatever component satisfies this contract. `DataTableCore` is the + * default implementation, but a consumer can supply their own renderer (a + * different component library, a virtualized grid, a plain list) and still get + * Cratis's query and paging behavior for free. + * + * Deliberately free of any PrimeReact (or other UI-library) types — the seam + * describes *what* a renderer is given, never *how* it renders. + * + * @typeParam TData - The row type. + */ +export interface TableRendererProps { + /** The rows to render — already paged by the binding. */ + data: TData[]; + /** Column definitions or other render configuration for the renderer (e.g. `` elements). */ + children?: ReactNode; + /** Content shown when there are no rows. */ + emptyMessage: ReactNode; + /** The row property uniquely identifying each row — required for selection. */ + dataKey?: string; + /** Enables single-row selection. */ + selectionMode?: 'single'; + /** The currently-selected row, or `null`/`undefined` when nothing is selected. */ + selection?: TData | null; + /** Invoked when the selected row changes. */ + onSelectionChange?: (event: DataTableSelectionChangeEvent) => void; + /** Invoked when a row is clicked. */ + onRowClick?: (event: DataTableRowClickEvent) => void; + /** The fields a global search term is matched against. */ + globalFilterFields?: string[]; + /** Initial per-column filter state. */ + defaultFilters?: DataTableFilterMeta; + /** Hint that the renderer should render its rows in a scroll region. */ + scrollable?: boolean; + /** The height of the scroll region when {@link scrollable} is set. */ + scrollHeight?: string; + /** Extra class name for the renderer's root. */ + className?: string; + /** Inline style for the renderer's root. */ + style?: CSSProperties; +} + +/** + * A component that renders one page of rows for a Cratis query binding. This is + * the public type an alternative table renderer implements to plug into + * {@link bindQuery} / {@link bindObservableQuery}. + * + * `DataTableCore` is the default implementation; its props are a superset of + * {@link TableRendererProps} (it adds PrimeReact pass-through and a few + * rendering extras), so it satisfies this contract structurally. + * + * ```tsx + * const MyRenderer: TableRenderer = ({ data, emptyMessage }) => + * data.length === 0 ? <>{emptyMessage} :
    {data.map(row =>
  • {row.name}
  • )}
; + * + * export const MyTable = bindQuery(MyRenderer); + * ``` + * + * @typeParam TData - The row type. + */ +export type TableRenderer = (props: TableRendererProps) => ReactElement | null; diff --git a/Source/DataTables/bindObservableQuery.tsx b/Source/DataTables/bindObservableQuery.tsx new file mode 100644 index 0000000..b19369f --- /dev/null +++ b/Source/DataTables/bindObservableQuery.tsx @@ -0,0 +1,174 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { createElement, useEffect, useRef, useState, type CSSProperties, type ComponentType, type ReactElement, type ReactNode } from 'react'; +import { Constructor } from '@cratis/fundamentals'; +import { IObservableQueryFor, Paging } from '@cratis/arc/queries'; +import { useObservableQueryWithPaging } from '@cratis/arc.react/queries'; +import type { TableRendererProps } from './TableRenderer'; +import { TablePaginator, type TablePaginatorProps } from './TablePaginator'; +import type { DataTableFilterMeta } from './DataTableFilterMeta'; +import type { DataTableSelectionChangeEvent } from './DataTableSelectionChangeEvent'; + +// NOTE: like `bindQuery`, `bindObservableQuery` pairs Cratis's observable query +// + paging *behavior* with an arbitrary table *renderer*. Its natural eventual +// home is `@cratis/arc.react` (the behavior layer); it lives here for now. + +/** + * Public props of a table bound to a real-time Cratis Arc observable query via + * {@link bindObservableQuery}. Mirrors {@link BoundQueryTableProps} for the + * observable case. + * + * @typeParam TQuery - The query class implementing `IObservableQueryFor`. + * @typeParam TDataType - The row type returned by the query. + * @typeParam TArguments - The query's argument object type. + */ +export interface BoundObservableQueryTableProps, TDataType extends object, TArguments extends object> { + /** Children forwarded to the renderer — e.g. `` elements describing the visible columns. */ + children?: ReactNode; + /** The type of query to use. */ + query: Constructor; + /** Optional arguments to pass to the query. */ + queryArguments?: TArguments; + /** The message to show when there is no data. */ + emptyMessage: string; + /** The key to use for the data. */ + dataKey?: string | undefined; + /** The current selection. */ + selection?: TDataType | undefined | null; + /** Callback for when the selection changes. */ + onSelectionChange?(event: DataTableSelectionChangeEvent): void; + /** Fields to use for global filtering. */ + globalFilterFields?: string[] | undefined; + /** Default filters to use. */ + defaultFilters?: DataTableFilterMeta; + /** + * @deprecated No longer toggles behavior. Filtering (`` and the + * global search) is always applied client-side to the loaded page; this flag + * is retained only for source compatibility and will be removed in a future + * release. + */ + clientFiltering?: boolean; + /** Extra CSS class name forwarded to the renderer's root. */ + className?: string; + /** Extra CSS class name forwarded to the paginator. */ + paginatorClassName?: string; + /** Accessible names for the paginator controls. Override any to localize. */ + paginatorAriaLabels?: TablePaginatorProps['ariaLabels']; +} + +const paging = new Paging(0, 20); + +/** + * Pairs Cratis's real-time observable query + paging behavior with any + * {@link TableRenderer}, returning a paged table component with the same public + * shape `DataTableForObservableQuery` exposes. The observable twin of + * {@link bindQuery}. + * + * Subscribes via `useObservableQueryWithPaging`, so the table re-renders as the + * underlying read model changes server-side. The renderer is asked to scroll + * (`scrollable` / `scrollHeight='100%'`) inside an internally-sizing region that + * resizes to fill its container. + * + * @typeParam TExtraProps - Extra props layered onto the bound component and + * forwarded verbatim to the renderer. Defaults to none. + * @param Renderer - The table renderer to bind. `DataTableCore` is the default. + */ +export function bindObservableQuery( + Renderer: (props: TableRendererProps) => ReactElement | null +) { + const BoundObservableQueryTable = , TDataType extends object, TArguments extends object>( + props: BoundObservableQueryTableProps & TExtraProps + ): ReactElement => { + const [result, , setPage] = useObservableQueryWithPaging(props.query, paging, props.queryArguments); + const containerRef = useRef(null); + const [tableHeight, setTableHeight] = useState(600); + const timeoutRef = useRef | undefined>(undefined); + const totalItems = result.paging.totalItems; + const pageCount = result.paging.totalPages; + const showPaginator = totalItems > 0 && pageCount > 1; + + useEffect(() => { + if (!containerRef.current) return; + + const resizeObserver = new ResizeObserver((entries) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + for (const entry of entries) { + const containerHeight = entry.contentRect.height; + if (containerHeight > 0) { + const paginatorHeight = showPaginator ? 56 : 0; + const calculatedHeight = containerHeight - paginatorHeight - 2; + const newHeight = Math.max(calculatedHeight, 200); + + setTableHeight(prevHeight => { + if (Math.abs(newHeight - prevHeight) > 5) { + return newHeight; + } + return prevHeight; + }); + } + } + }, 10); + }); + + resizeObserver.observe(containerRef.current); + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + resizeObserver.disconnect(); + }; + }, [showPaginator]); + + // Strip the binding-owned props so the renderer receives only the + // UI-library-agnostic TableRendererProps — it never sees the query. + const { query, queryArguments, paginatorClassName, paginatorAriaLabels, clientFiltering, ...forwarded } = props; + + const rendererProps = { + ...forwarded, + data: result.data as unknown as TDataType[], + selectionMode: 'single' as const, + scrollable: true, + scrollHeight: '100%', + style: { minWidth: '100%' } as CSSProperties, + }; + + return ( +
+
+ {createElement(Renderer as ComponentType>, rendererProps)} +
+ + {showPaginator && ( +
+ +
+ )} +
+ ); + }; + + return BoundObservableQueryTable; +} diff --git a/Source/DataTables/bindQuery.tsx b/Source/DataTables/bindQuery.tsx new file mode 100644 index 0000000..fe9c35b --- /dev/null +++ b/Source/DataTables/bindQuery.tsx @@ -0,0 +1,133 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { createElement, type CSSProperties, type ComponentType, type ReactElement, type ReactNode } from 'react'; +import { Constructor } from '@cratis/fundamentals'; +import { IQueryFor, Paging } from '@cratis/arc/queries'; +import { useQueryWithPaging } from '@cratis/arc.react/queries'; +import type { TableRendererProps } from './TableRenderer'; +import { TablePaginator, type TablePaginatorProps } from './TablePaginator'; +import type { DataTableFilterMeta } from './DataTableFilterMeta'; +import type { DataTableSelectionChangeEvent } from './DataTableSelectionChangeEvent'; + +// NOTE: `bindQuery` pairs Cratis's query + paging *behavior* with an arbitrary +// table *renderer*. It lives here beside the tables for now, but its natural +// eventual home is `@cratis/arc.react` (the behavior layer) — the paging hooks +// it builds on already live there, and it carries no PrimeReact dependency. + +/** + * Public props of a table bound to a snapshot Cratis Arc query via + * {@link bindQuery}. This is the query/paging surface both Cratis data tables + * expose today, independent of any particular renderer. + * + * @typeParam TQuery - The query class implementing `IQueryFor`. + * @typeParam TDataType - The row type returned by the query. + * @typeParam TArguments - The query's argument object type, or `object` if it takes none. + */ +export interface BoundQueryTableProps, TDataType extends object, TArguments extends object> { + /** Children forwarded to the renderer — e.g. `` elements describing the visible columns. */ + children?: ReactNode; + /** The type of query to use. */ + query: Constructor; + /** Optional arguments to pass to the query. */ + queryArguments?: TArguments; + /** The message to show when there is no data. */ + emptyMessage: string; + /** The key to use for the data. */ + dataKey?: string | undefined; + /** The current selection. */ + selection?: TDataType | undefined | null; + /** Callback for when the selection changes. */ + onSelectionChange?(event: DataTableSelectionChangeEvent): void; + /** Fields to use for global filtering. */ + globalFilterFields?: string[] | undefined; + /** Default filters to use. */ + defaultFilters?: DataTableFilterMeta; + /** + * @deprecated No longer toggles behavior. Filtering (`` and the + * global search) is always applied client-side to the loaded page; this flag + * is retained only for source compatibility and will be removed in a future + * release. + */ + clientFiltering?: boolean; + /** Extra CSS class name forwarded to the renderer's root. */ + className?: string; + /** Extra CSS class name forwarded to the paginator. */ + paginatorClassName?: string; + /** Accessible names for the paginator controls. Override any to localize. */ + paginatorAriaLabels?: TablePaginatorProps['ariaLabels']; +} + +const paging = new Paging(0, 20); + +/** + * Pairs Cratis's snapshot query + paging behavior with any {@link TableRenderer}, + * returning a paged table component with the same public shape + * `DataTableForQuery` exposes. The higher-order function is the *table* analogue + * of `asCommandFormField` from `@cratis/arc.react/commands`: a headless adapter + * that wraps a pure renderer. + * + * `bindQuery` subscribes via `useQueryWithPaging`, feeds one page of rows to the + * renderer through `data`, and renders a {@link TablePaginator} when the result + * set exceeds one page — so the renderer stays purely presentational and any + * table library (or a hand-rolled one) gets Cratis query behavior for free. + * + * @typeParam TExtraProps - Extra props layered onto the bound component and + * forwarded verbatim to the renderer (e.g. a specific renderer's styling + * pass-through). Defaults to none. + * @param Renderer - The table renderer to bind. `DataTableCore` is the default. + */ +export function bindQuery( + Renderer: (props: TableRendererProps) => ReactElement | null +) { + const BoundQueryTable = , TDataType extends object, TArguments extends object>( + props: BoundQueryTableProps & TExtraProps + ): ReactElement => { + const [result, , , setPage] = useQueryWithPaging(props.query, paging, props.queryArguments); + const totalItems = result.paging.totalItems; + const pageCount = result.paging.totalPages; + + // Strip the binding-owned props so the renderer receives only the + // UI-library-agnostic TableRendererProps — it never sees the query. + const { query, queryArguments, paginatorClassName, paginatorAriaLabels, clientFiltering, ...forwarded } = props; + + const rendererProps = { + ...forwarded, + data: result.data as unknown as TDataType[], + selectionMode: 'single' as const, + style: { minWidth: '100%' } as CSSProperties, + }; + + return ( +
+
+ {createElement(Renderer as ComponentType>, rendererProps)} +
+ + {totalItems > 0 && pageCount > 1 && ( +
+ +
+ )} +
+ ); + }; + + return BoundQueryTable; +} diff --git a/Source/DataTables/for_bindQuery/given/a_list_renderer.tsx b/Source/DataTables/for_bindQuery/given/a_list_renderer.tsx new file mode 100644 index 0000000..d7b97b4 --- /dev/null +++ b/Source/DataTables/for_bindQuery/given/a_list_renderer.tsx @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import React, { type ComponentType, type ReactElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import type { TableRendererProps } from '../../TableRenderer'; + +/** A trivial row type for the bring-your-own-renderer proof. */ +export interface Row { + id: number; + name: string; +} + +/** + * A trivial, non-`DataTableCore` table renderer: a plain unordered list with no + * PrimeReact anywhere. Generic over the row type exactly like `DataTableCore`, + * so it plugs into `bindQuery`. Its `byo-list` / `byo-empty` class names are + * markers the specs assert on to prove the consumer's *own* rendering is what + * shows up, driven by Cratis's paged query data. + */ +export const ListRenderer = ({ data, emptyMessage }: TableRendererProps): ReactElement => { + if (data.length === 0) { + return

{emptyMessage}

; + } + return ( +
    + {data.map((row, index) => ( +
  • {JSON.stringify(row)}
  • + ))} +
+ ); +}; + +/** + * A stand-in query constructor. The Arc query hook is mocked in each spec, so + * this is never executed — it only satisfies the bound component's `query` prop. + */ +export class FakeRowsQuery { } + +/** Renders a query-bound table to static markup, supplying the stand-in query. */ +export const renderBoundTable = (Bound: unknown, props: { emptyMessage: string; dataKey?: string }): string => + renderToStaticMarkup( + React.createElement( + Bound as ComponentType<{ query: unknown; emptyMessage: string; dataKey?: string }>, + { query: FakeRowsQuery, ...props } + ) + ); diff --git a/Source/DataTables/for_bindQuery/when_binding_a_custom_renderer.ts b/Source/DataTables/for_bindQuery/when_binding_a_custom_renderer.ts new file mode 100644 index 0000000..c5323b1 --- /dev/null +++ b/Source/DataTables/for_bindQuery/when_binding_a_custom_renderer.ts @@ -0,0 +1,86 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import React from 'react'; +import { vi } from 'vitest'; +import { bindQuery } from '../bindQuery'; +import { ListRenderer, renderBoundTable, type Row } from './given/a_list_renderer'; + +// The proof: a trivial, non-DataTableCore renderer (a plain `
    `) bound via +// bindQuery still gets Cratis's query + paging behavior. These scenarios live in +// one file on purpose — the project runs vitest with `isolate: false`, so a +// per-file module mock of the query hook would otherwise bleed across files. + +const { queryResult } = vi.hoisted(() => ({ + queryResult: { current: undefined as unknown }, +})); + +// Mock the Arc paging hook so the binding renders a deterministic page without a +// backend — the proof is about the seam (rows + paging reach the renderer), not +// the hook itself, which Arc tests in its own repo. +vi.mock('@cratis/arc.react/queries', () => ({ + useQueryWithPaging: () => [queryResult.current, vi.fn(), vi.fn(), vi.fn(), vi.fn()], +})); + +// The paginator uses PrimeReact's Button; render it as a plain button for this +// SSR proof so the navigation landmark still emits without a PrimeReact provider. +vi.mock('primereact/button', () => ({ + Button: (props: { children?: React.ReactNode }) => React.createElement('button', null, props.children), +})); + +const twoRows = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] as Row[]; + +describe('when binding a custom renderer and the result spans multiple pages', () => { + let html: string; + + beforeEach(() => { + queryResult.current = { data: twoRows, paging: { page: 0, size: 20, totalItems: 48, totalPages: 3 } }; + html = renderBoundTable(bindQuery(ListRenderer), { emptyMessage: 'No rows', dataKey: 'id' }); + }); + + it('should render the paged rows through the custom renderer', () => { + html.should.include('byo-list'); + html.should.include('Alice'); + html.should.include('Bob'); + }); + + it('should attach the Cratis paginator when the result spans more than one page', () => { + html.should.include('aria-label="Pagination"'); + }); +}); + +describe('when binding a custom renderer and the result fits a single page', () => { + let html: string; + + beforeEach(() => { + queryResult.current = { data: twoRows, paging: { page: 0, size: 20, totalItems: 2, totalPages: 1 } }; + html = renderBoundTable(bindQuery(ListRenderer), { emptyMessage: 'No rows', dataKey: 'id' }); + }); + + it('should render the rows through the custom renderer', () => { + html.should.include('byo-list'); + html.should.include('Alice'); + }); + + it('should not render a paginator for a single page', () => { + html.should.not.include('aria-label="Pagination"'); + }); +}); + +describe('when binding a custom renderer and there are no rows', () => { + let html: string; + + beforeEach(() => { + queryResult.current = { data: [] as Row[], paging: { page: 0, size: 20, totalItems: 0, totalPages: 0 } }; + html = renderBoundTable(bindQuery(ListRenderer), { emptyMessage: 'No rows', dataKey: 'id' }); + }); + + it('should hand the empty message to the custom renderer', () => { + html.should.include('byo-empty'); + html.should.include('No rows'); + }); + + it('should not render a paginator when there are no rows', () => { + html.should.not.include('aria-label="Pagination"'); + }); +}); diff --git a/Source/DataTables/index.ts b/Source/DataTables/index.ts index 12be183..80c819e 100644 --- a/Source/DataTables/index.ts +++ b/Source/DataTables/index.ts @@ -3,6 +3,11 @@ export * from './DataTableForQuery'; export * from './DataTableForObservableQuery'; +export * from './DataTableCore'; +export * from './TableRenderer'; +export * from './DataTableRowClickEvent'; +export * from './bindQuery'; +export * from './bindObservableQuery'; export * from './Column'; export * from './ColumnFilterMenu'; export * from './DataTableSelectionChangeEvent';