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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions Documentation/DataTables/bring-your-own-renderer.md
Original file line number Diff line number Diff line change
@@ -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<TData>`** — 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<br/>(proxy from C#)"] --> Behavior
subgraph Behavior["bindQuery — Cratis behavior"]
Paging["useQueryWithPaging"]
Paginator["TablePaginator"]
end
Behavior -->|"one page of rows via data"| Seam{{"TableRenderer&lt;TData&gt;"}}
Seam --> Default["DataTableCore<br/>(default, PrimeReact)"]
Seam --> Custom["your renderer<br/>(cards, grid, list…)"]
```

## What a renderer receives

A `TableRenderer<TData>` is given `TableRendererProps<TData>`. 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. `<Column>` 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 = <TData extends object,>({ data, emptyMessage }: TableRendererProps<TData>) => {
if (data.length === 0) {
return <div className="empty">{emptyMessage}</div>;
}
return (
<div className="card-list">
{data.map((row, index) => (
<article key={index} className="card">
{Object.entries(row).map(([field, value]) => (
<span key={field}><strong>{field}: </strong>{String(value)}</span>
))}
</article>
))}
</div>
);
};
```

The `<TData extends object,>` 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);

<CardListForQuery query={AllProducts} emptyMessage="No products" dataKey="id" />;
```

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);

<CardListForObservableQuery query={AllTasks} emptyMessage="No tasks" dataKey="id" />;
```

## 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 `<Column>` authoring model the default renderer reads.
9 changes: 9 additions & 0 deletions Documentation/DataTables/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions Documentation/DataTables/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
120 changes: 120 additions & 0 deletions Source/DataTables/BringYourOwnRenderer.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 = <TData extends object,>({ data, emptyMessage }: TableRendererProps<TData>): ReactElement => {
if (data.length === 0) {
return <div style={{ padding: '1rem', color: 'var(--text-color-secondary)' }}>{emptyMessage}</div>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', padding: '1rem' }}>
{data.map((row, index) => (
<div
key={index}
style={{
display: 'flex',
gap: '1.5rem',
padding: '0.75rem 1rem',
border: '1px solid var(--cratis-surface-border)',
borderRadius: 'var(--cratis-border-radius)',
background: 'var(--surface-card)',
}}>
{Object.entries(row).map(([field, value]) => (
<span key={field}>
<strong style={{ textTransform: 'capitalize', color: 'var(--text-color-secondary)' }}>{field}: </strong>
{String(value)}
</span>
))}
</div>
))}
</div>
);
};

// `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<Product, object> {
readonly route = '/api/products';
readonly defaultValue: Product = [] as unknown as Product;
readonly parameterDescriptors = [];
get requiredRequestParameters() {
return [];
}
constructor() {
super(Object, true);
}
override perform(): Promise<QueryResult<Product>> {
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<Product>);
}
}

const meta: Meta<typeof CardListForQuery> = {
title: 'DataTables/BringYourOwnRenderer',
component: CardListForQuery,
};

export default meta;
type Story = StoryObj<typeof CardListForQuery>;

/**
* 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: () => (
<div style={{ height: '32rem' }}>
<CardListForQuery<ProductsQuery, Product, object>
query={ProductsQuery}
emptyMessage="No products found"
dataKey="id"
/>
</div>
)
};
52 changes: 9 additions & 43 deletions Source/DataTables/DataTableCore.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,40 @@
// 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';
import type { UseDataTableSelectionEvent, UseDataTableRowMouseEvent, UseDataTableFilterEvent } from '@primereact/types/headless/datatable';
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<TData> {
/** 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<TData extends object> {
/** The rows to render (already paged by the caller). */
data: TData[];
/** `<Column>` 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<TData extends object> extends TableRendererProps<TData> {
/** 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<TData>) => void;
/** Invoked when a row is clicked. */
onRowClick?: (event: DataTableRowClickEvent<TData>) => 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. */
Expand Down
Loading
Loading