rastack 0.0.26 → 0.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/components/auto-form/AutoForm.tsx +254 -0
- package/components/auto-form/fields.ts +108 -0
- package/components/auto-form/use-auto-form.ts +89 -0
- package/components/data-table/DataTable.tsx +267 -0
- package/components/data-table/columns.ts +117 -0
- package/components/data-table/use-data-table.ts +141 -0
- package/components/index.ts +77 -0
- package/components/primitives/controls.tsx +291 -0
- package/components/primitives/field.tsx +117 -0
- package/components/primitives/index.ts +11 -0
- package/components/primitives/misc.tsx +173 -0
- package/components/primitives/table.tsx +147 -0
- package/components/style.ts +195 -0
- package/components/types.ts +98 -0
- package/components/util.ts +14 -0
- package/hooks/form/form.ts +10 -4
- package/hooks/form/interfaces.ts +40 -1
- package/hooks/form/structure.ts +83 -26
- package/package.json +1 -1
- package/test/components.spec.ts +386 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import React, { useEffect } from "react";
|
|
2
|
+
import { tokens } from "../style";
|
|
3
|
+
import type { TableQuery } from "../types";
|
|
4
|
+
import { Table, THead, TBody, Tr, Th, Td } from "../primitives/table";
|
|
5
|
+
import { Input, Button } from "../primitives/controls";
|
|
6
|
+
import { Spinner } from "../primitives/misc";
|
|
7
|
+
import { formatCell } from "./columns";
|
|
8
|
+
import {
|
|
9
|
+
useDataTable,
|
|
10
|
+
type DataTableModel,
|
|
11
|
+
type DataTableOptions,
|
|
12
|
+
} from "./use-data-table";
|
|
13
|
+
|
|
14
|
+
/** Inject the row-interaction CSS once (`:hover` can't be an inline style). */
|
|
15
|
+
let dtCssInjected = false;
|
|
16
|
+
function useDataTableCss(): void {
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (dtCssInjected || typeof document === "undefined") return;
|
|
19
|
+
dtCssInjected = true;
|
|
20
|
+
const el = document.createElement("style");
|
|
21
|
+
el.setAttribute("data-rastack-ui", "");
|
|
22
|
+
el.textContent =
|
|
23
|
+
".rastack-dt tbody tr[data-clickable='true']{cursor:pointer}" +
|
|
24
|
+
".rastack-dt tbody tr[data-clickable='true']:hover{background:var(--color-surface-alt,#f2f5fb)}";
|
|
25
|
+
document.head.appendChild(el);
|
|
26
|
+
}, []);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DataTableProps<
|
|
30
|
+
TRow extends Record<string, unknown>,
|
|
31
|
+
> extends DataTableOptions<TRow> {
|
|
32
|
+
/** The list hook result driving the table (`useListX(...)`, or any `TableQuery`). */
|
|
33
|
+
query: TableQuery<TRow>;
|
|
34
|
+
/** Row click handler; makes rows look/behave interactive. */
|
|
35
|
+
onRowClick?: (row: TRow, index: number) => void;
|
|
36
|
+
/** Show the search box when the hook supports searching. Default `true`. */
|
|
37
|
+
searchable?: boolean;
|
|
38
|
+
searchPlaceholder?: string;
|
|
39
|
+
/** Rendered when there are no rows. */
|
|
40
|
+
emptyMessage?: React.ReactNode;
|
|
41
|
+
/** Stable React key per row. Defaults to `row.id` then the index. */
|
|
42
|
+
rowKey?: (row: TRow, index: number) => React.Key;
|
|
43
|
+
/** Total-control escape hatch: receives the headless model, you render everything. */
|
|
44
|
+
children?: (model: DataTableModel<TRow>) => React.ReactNode;
|
|
45
|
+
style?: React.CSSProperties;
|
|
46
|
+
className?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A data table that renders itself from a list hook.
|
|
51
|
+
*
|
|
52
|
+
* ```tsx
|
|
53
|
+
* <DataTable query={useListAirports({ options: { pageSize: 20 } })} />
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* Columns come from your `columns` prop, else the server `header`, else the
|
|
57
|
+
* first row's keys. Sorting, search and pagination light up automatically when
|
|
58
|
+
* the hook exposes them and stay hidden otherwise, so the same component drives
|
|
59
|
+
* both REST and local-sync entities.
|
|
60
|
+
*/
|
|
61
|
+
export function DataTable<TRow extends Record<string, unknown>>(
|
|
62
|
+
props: DataTableProps<TRow>,
|
|
63
|
+
): React.JSX.Element {
|
|
64
|
+
const {
|
|
65
|
+
query,
|
|
66
|
+
columns: columnsOption,
|
|
67
|
+
sortable,
|
|
68
|
+
onRowClick,
|
|
69
|
+
searchable = true,
|
|
70
|
+
searchPlaceholder = "Search…",
|
|
71
|
+
emptyMessage = "No records.",
|
|
72
|
+
rowKey,
|
|
73
|
+
children,
|
|
74
|
+
style,
|
|
75
|
+
className,
|
|
76
|
+
} = props;
|
|
77
|
+
|
|
78
|
+
useDataTableCss();
|
|
79
|
+
const model = useDataTable<TRow>(query, { columns: columnsOption, sortable });
|
|
80
|
+
|
|
81
|
+
if (children) return <>{children(model)}</>;
|
|
82
|
+
|
|
83
|
+
const { columns, rows, isLoading, isEmpty, error, search, sort, pagination } =
|
|
84
|
+
model;
|
|
85
|
+
const colSpan = Math.max(columns.length, 1);
|
|
86
|
+
|
|
87
|
+
const keyOf = (row: TRow, index: number): React.Key => {
|
|
88
|
+
if (rowKey) return rowKey(row, index);
|
|
89
|
+
const id = (row as Record<string, unknown>).id;
|
|
90
|
+
return id != null ? String(id) : index;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const messageRow = (content: React.ReactNode) => (
|
|
94
|
+
<Tr>
|
|
95
|
+
<Td
|
|
96
|
+
colSpan={colSpan}
|
|
97
|
+
style={{
|
|
98
|
+
textAlign: "center",
|
|
99
|
+
color: tokens.color.textMuted,
|
|
100
|
+
padding: tokens.space.xl,
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
{content}
|
|
104
|
+
</Td>
|
|
105
|
+
</Tr>
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
className={className}
|
|
111
|
+
style={{
|
|
112
|
+
display: "flex",
|
|
113
|
+
flexDirection: "column",
|
|
114
|
+
gap: tokens.space.md,
|
|
115
|
+
...style,
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
{search && searchable ? (
|
|
119
|
+
<div style={{ maxWidth: 320 }}>
|
|
120
|
+
<Input
|
|
121
|
+
size="sm"
|
|
122
|
+
value={search.value}
|
|
123
|
+
placeholder={searchPlaceholder}
|
|
124
|
+
onChange={(e) => search.setValue(e.target.value)}
|
|
125
|
+
aria-label="Search"
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
) : null}
|
|
129
|
+
|
|
130
|
+
<div
|
|
131
|
+
style={{
|
|
132
|
+
overflowX: "auto",
|
|
133
|
+
border: `1px solid ${tokens.color.border}`,
|
|
134
|
+
borderRadius: tokens.radius.lg,
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
<Table className="rastack-dt">
|
|
138
|
+
<THead>
|
|
139
|
+
<Tr>
|
|
140
|
+
{columns.map((col) => {
|
|
141
|
+
const direction =
|
|
142
|
+
col.sortable && sort ? sort.directionOf(col.key) : undefined;
|
|
143
|
+
const canSort = Boolean(col.sortable && sort);
|
|
144
|
+
return (
|
|
145
|
+
<Th
|
|
146
|
+
key={col.key}
|
|
147
|
+
align={col.align}
|
|
148
|
+
sortable={canSort}
|
|
149
|
+
sortDirection={direction}
|
|
150
|
+
style={col.width != null ? { width: col.width } : undefined}
|
|
151
|
+
onClick={canSort ? () => sort!.by(col.key) : undefined}
|
|
152
|
+
>
|
|
153
|
+
{col.header}
|
|
154
|
+
</Th>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
</Tr>
|
|
158
|
+
</THead>
|
|
159
|
+
<TBody>
|
|
160
|
+
{error
|
|
161
|
+
? messageRow(
|
|
162
|
+
<span style={{ color: tokens.color.danger }}>
|
|
163
|
+
{error instanceof Error
|
|
164
|
+
? error.message
|
|
165
|
+
: "Something went wrong."}
|
|
166
|
+
</span>,
|
|
167
|
+
)
|
|
168
|
+
: isLoading && rows.length === 0
|
|
169
|
+
? messageRow(
|
|
170
|
+
<span
|
|
171
|
+
style={{
|
|
172
|
+
display: "inline-flex",
|
|
173
|
+
alignItems: "center",
|
|
174
|
+
gap: tokens.space.sm,
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
<Spinner /> Loading…
|
|
178
|
+
</span>,
|
|
179
|
+
)
|
|
180
|
+
: isEmpty
|
|
181
|
+
? messageRow(emptyMessage)
|
|
182
|
+
: rows.map((row, index) => (
|
|
183
|
+
<Tr
|
|
184
|
+
key={keyOf(row, index)}
|
|
185
|
+
data-clickable={onRowClick ? "true" : undefined}
|
|
186
|
+
onClick={
|
|
187
|
+
onRowClick ? () => onRowClick(row, index) : undefined
|
|
188
|
+
}
|
|
189
|
+
>
|
|
190
|
+
{columns.map((col) => {
|
|
191
|
+
const value = (row as Record<string, unknown>)[
|
|
192
|
+
col.key
|
|
193
|
+
];
|
|
194
|
+
return (
|
|
195
|
+
<Td key={col.key} align={col.align}>
|
|
196
|
+
{col.render
|
|
197
|
+
? col.render(row, value)
|
|
198
|
+
: formatCell(value)}
|
|
199
|
+
</Td>
|
|
200
|
+
);
|
|
201
|
+
})}
|
|
202
|
+
</Tr>
|
|
203
|
+
))}
|
|
204
|
+
</TBody>
|
|
205
|
+
</Table>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{pagination ? (
|
|
209
|
+
<div
|
|
210
|
+
style={{
|
|
211
|
+
display: "flex",
|
|
212
|
+
alignItems: "center",
|
|
213
|
+
justifyContent: "space-between",
|
|
214
|
+
gap: tokens.space.md,
|
|
215
|
+
flexWrap: "wrap",
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
<span
|
|
219
|
+
style={{
|
|
220
|
+
fontSize: "0.8125rem",
|
|
221
|
+
color: tokens.color.textMuted,
|
|
222
|
+
fontFamily: tokens.font.sans,
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
{pagination.itemCount > 0
|
|
226
|
+
? `${pagination.itemCount} record${pagination.itemCount === 1 ? "" : "s"}`
|
|
227
|
+
: ""}
|
|
228
|
+
</span>
|
|
229
|
+
<div
|
|
230
|
+
style={{
|
|
231
|
+
display: "flex",
|
|
232
|
+
alignItems: "center",
|
|
233
|
+
gap: tokens.space.sm,
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
<Button
|
|
237
|
+
size="sm"
|
|
238
|
+
variant="outline"
|
|
239
|
+
disabled={!pagination.canPrev}
|
|
240
|
+
onClick={pagination.prev}
|
|
241
|
+
>
|
|
242
|
+
Previous
|
|
243
|
+
</Button>
|
|
244
|
+
<span
|
|
245
|
+
style={{
|
|
246
|
+
fontSize: "0.8125rem",
|
|
247
|
+
color: tokens.color.textMuted,
|
|
248
|
+
fontFamily: tokens.font.sans,
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
Page {pagination.current}
|
|
252
|
+
{pagination.count ? ` of ${pagination.count}` : ""}
|
|
253
|
+
</span>
|
|
254
|
+
<Button
|
|
255
|
+
size="sm"
|
|
256
|
+
variant="outline"
|
|
257
|
+
disabled={!pagination.canNext}
|
|
258
|
+
onClick={pagination.next}
|
|
259
|
+
>
|
|
260
|
+
Next
|
|
261
|
+
</Button>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
) : null}
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Column resolution + cell formatting for the data table — the pure core.
|
|
3
|
+
*
|
|
4
|
+
* "Render correctly based on the data fetched" comes down to: where do the
|
|
5
|
+
* columns come from, and how is a raw value shown? Both are decided here, with
|
|
6
|
+
* no React or DOM, so they are unit tested directly.
|
|
7
|
+
*
|
|
8
|
+
* Column source, in priority order:
|
|
9
|
+
* 1. an explicit `columns` prop (author knows best);
|
|
10
|
+
* 2. the server envelope's `header` (`{ key, name }[]`) — the DRF-shaped list
|
|
11
|
+
* response carries these, and `IListQuery` surfaces them as `.header`;
|
|
12
|
+
* 3. otherwise, the keys of the first row.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type React from "react";
|
|
16
|
+
import { humanizeKey, type CellAlign } from "../util";
|
|
17
|
+
|
|
18
|
+
export { humanizeKey };
|
|
19
|
+
|
|
20
|
+
export interface DataTableColumn<TRow = Record<string, unknown>> {
|
|
21
|
+
/** The row property this column reads (also its React key). */
|
|
22
|
+
key: string;
|
|
23
|
+
/** The header label. Defaults to a humanised `key`. */
|
|
24
|
+
header: React.ReactNode;
|
|
25
|
+
/** Cell alignment. Defaults to `"left"`. */
|
|
26
|
+
align?: CellAlign;
|
|
27
|
+
/** Whether this column can be sorted. The table also requires the hook to expose sorting. */
|
|
28
|
+
sortable?: boolean;
|
|
29
|
+
/** Custom cell renderer. Receives the row and the resolved value (`row[key]`). */
|
|
30
|
+
render?: (row: TRow, value: unknown) => React.ReactNode;
|
|
31
|
+
/** Optional fixed column width. */
|
|
32
|
+
width?: string | number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A caller-supplied column: everything optional but `key`. */
|
|
36
|
+
export type DataTableColumnInput<TRow = Record<string, unknown>> = Partial<
|
|
37
|
+
DataTableColumn<TRow>
|
|
38
|
+
> & {
|
|
39
|
+
key: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export interface ResolveColumnsInput<TRow> {
|
|
43
|
+
/** Explicit columns — highest priority. */
|
|
44
|
+
columns?: DataTableColumnInput<TRow>[];
|
|
45
|
+
/** Server-provided headers (`IListQuery.header`). */
|
|
46
|
+
header?: { key: string; name: string }[];
|
|
47
|
+
/** The rows, used only to infer columns from the first row's keys. */
|
|
48
|
+
rows?: TRow[];
|
|
49
|
+
/** Default `sortable` for inferred columns (the table sets this from hook capability). */
|
|
50
|
+
sortableByDefault?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Fill a partial column with defaults. */
|
|
54
|
+
function normalizeColumn<TRow>(
|
|
55
|
+
col: DataTableColumnInput<TRow>,
|
|
56
|
+
sortableByDefault: boolean,
|
|
57
|
+
): DataTableColumn<TRow> {
|
|
58
|
+
return {
|
|
59
|
+
key: col.key,
|
|
60
|
+
header: col.header ?? humanizeKey(col.key),
|
|
61
|
+
align: col.align,
|
|
62
|
+
sortable: col.sortable ?? sortableByDefault,
|
|
63
|
+
render: col.render,
|
|
64
|
+
width: col.width,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Decide the columns to render from the (optional) explicit columns, the server
|
|
70
|
+
* header, or the first row. Returns an empty array only when nothing is known
|
|
71
|
+
* yet (no columns, no header, no rows).
|
|
72
|
+
*/
|
|
73
|
+
export function resolveColumns<TRow extends Record<string, unknown>>(
|
|
74
|
+
input: ResolveColumnsInput<TRow>,
|
|
75
|
+
): DataTableColumn<TRow>[] {
|
|
76
|
+
const sortableByDefault = input.sortableByDefault ?? false;
|
|
77
|
+
|
|
78
|
+
if (input.columns && input.columns.length > 0) {
|
|
79
|
+
return input.columns.map((c) => normalizeColumn(c, sortableByDefault));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (input.header && input.header.length > 0) {
|
|
83
|
+
return input.header.map((h) =>
|
|
84
|
+
normalizeColumn({ key: h.key, header: h.name }, sortableByDefault),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const first = input.rows?.find((r) => r != null && typeof r === "object");
|
|
89
|
+
if (first) {
|
|
90
|
+
return Object.keys(first).map((key) =>
|
|
91
|
+
normalizeColumn({ key }, sortableByDefault),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Default rendering for a raw cell value when a column has no custom `render`.
|
|
100
|
+
* Kept deliberately conservative: booleans read as Yes/No, nullish as an em
|
|
101
|
+
* dash, Dates localise, objects/arrays serialise compactly, everything else is
|
|
102
|
+
* stringified.
|
|
103
|
+
*/
|
|
104
|
+
export function formatCell(value: unknown): string {
|
|
105
|
+
if (value == null) return "—";
|
|
106
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
107
|
+
if (value instanceof Date) return value.toLocaleString();
|
|
108
|
+
if (typeof value === "object") {
|
|
109
|
+
try {
|
|
110
|
+
const json = JSON.stringify(value);
|
|
111
|
+
return json.length > 80 ? `${json.slice(0, 77)}…` : json;
|
|
112
|
+
} catch {
|
|
113
|
+
return String(value);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return String(value);
|
|
117
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useDataTable` — the headless table model.
|
|
3
|
+
*
|
|
4
|
+
* Give it any `TableQuery` (a REST `IListQuery` or a sync list result) and it
|
|
5
|
+
* returns a flat, render-ready model: resolved columns, the rows, loading/empty
|
|
6
|
+
* flags, and — only when the underlying hook supports them — normalized
|
|
7
|
+
* `search`, `sort` and `pagination` handles. `<DataTable>` renders this; you can
|
|
8
|
+
* also consume it directly to build a fully custom table (the "headless" path).
|
|
9
|
+
*
|
|
10
|
+
* The model assembly is pure (no hooks inside), so it is unit tested via
|
|
11
|
+
* {@link buildDataTableModel}.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useMemo } from "react";
|
|
15
|
+
import type { TableQuery } from "../types";
|
|
16
|
+
import {
|
|
17
|
+
resolveColumns,
|
|
18
|
+
type DataTableColumn,
|
|
19
|
+
type DataTableColumnInput,
|
|
20
|
+
} from "./columns";
|
|
21
|
+
|
|
22
|
+
export interface DataTableSearch {
|
|
23
|
+
value: string;
|
|
24
|
+
setValue: (value: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DataTableSort {
|
|
28
|
+
/** Toggle the sort on a column (asc → desc → off, per the hook). */
|
|
29
|
+
by: (key: string) => void;
|
|
30
|
+
/** The current direction for a column, or `undefined` if unsorted. */
|
|
31
|
+
directionOf: (key: string) => "asc" | "desc" | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DataTablePagination {
|
|
35
|
+
current: number;
|
|
36
|
+
count: number;
|
|
37
|
+
size: number;
|
|
38
|
+
itemCount: number;
|
|
39
|
+
next: () => void;
|
|
40
|
+
prev: () => void;
|
|
41
|
+
setSize: (size: number) => void;
|
|
42
|
+
canPrev: boolean;
|
|
43
|
+
canNext: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DataTableModel<TRow = Record<string, unknown>> {
|
|
47
|
+
columns: DataTableColumn<TRow>[];
|
|
48
|
+
rows: TRow[];
|
|
49
|
+
isLoading: boolean;
|
|
50
|
+
isEmpty: boolean;
|
|
51
|
+
error: unknown;
|
|
52
|
+
/** Present only when the hook exposes searching. */
|
|
53
|
+
search?: DataTableSearch;
|
|
54
|
+
/** Present only when the hook exposes sorting. */
|
|
55
|
+
sort?: DataTableSort;
|
|
56
|
+
/** Present only when the hook exposes pagination. */
|
|
57
|
+
pagination?: DataTablePagination;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DataTableOptions<TRow = Record<string, unknown>> {
|
|
61
|
+
/** Explicit columns (labels, order, alignment, custom `render`). Overrides inference. */
|
|
62
|
+
columns?: DataTableColumnInput<TRow>[];
|
|
63
|
+
/** Force sortable headers on/off. Defaults to on whenever the hook supports sorting. */
|
|
64
|
+
sortable?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Pure model assembly — see {@link useDataTable}. */
|
|
68
|
+
export function buildDataTableModel<TRow extends Record<string, unknown>>(
|
|
69
|
+
query: TableQuery<TRow>,
|
|
70
|
+
options: DataTableOptions<TRow> = {},
|
|
71
|
+
): DataTableModel<TRow> {
|
|
72
|
+
const rows = query.data ?? [];
|
|
73
|
+
const hasSorting = Boolean(query.sorting);
|
|
74
|
+
const sortableByDefault = hasSorting && (options.sortable ?? true);
|
|
75
|
+
|
|
76
|
+
const columns = resolveColumns<TRow>({
|
|
77
|
+
columns: options.columns,
|
|
78
|
+
header: query.header,
|
|
79
|
+
rows,
|
|
80
|
+
sortableByDefault,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const isLoading = query.isLoading ?? query.isReady === false;
|
|
84
|
+
const isEmpty = !isLoading && rows.length === 0;
|
|
85
|
+
const error =
|
|
86
|
+
query.error ?? (query.isError ? new Error("Request failed") : undefined);
|
|
87
|
+
|
|
88
|
+
const model: DataTableModel<TRow> = {
|
|
89
|
+
columns,
|
|
90
|
+
rows,
|
|
91
|
+
isLoading,
|
|
92
|
+
isEmpty,
|
|
93
|
+
error,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (query.searching) {
|
|
97
|
+
const s = query.searching;
|
|
98
|
+
model.search = {
|
|
99
|
+
value: s.searchText ?? "",
|
|
100
|
+
// The hook treats `undefined` as "no search"; map an empty box to that.
|
|
101
|
+
setValue: (value) => s.setSearchText(value === "" ? undefined : value),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (query.sorting) {
|
|
106
|
+
const so = query.sorting;
|
|
107
|
+
model.sort = {
|
|
108
|
+
by: (key) => so.sortField(key as never),
|
|
109
|
+
directionOf: (key) => so.getSortingDirection(key as never),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (query.pagination) {
|
|
114
|
+
const p = query.pagination;
|
|
115
|
+
model.pagination = {
|
|
116
|
+
current: p.currentPage,
|
|
117
|
+
count: p.pageCount,
|
|
118
|
+
size: p.pageSize,
|
|
119
|
+
itemCount: p.itemCount,
|
|
120
|
+
next: p.nextPage,
|
|
121
|
+
prev: p.previousPage,
|
|
122
|
+
setSize: p.setPageSize,
|
|
123
|
+
canPrev: p.currentPage > 1,
|
|
124
|
+
canNext: p.currentPage < p.pageCount,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return model;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** React hook wrapper around {@link buildDataTableModel} (memoized on the query + options). */
|
|
132
|
+
export function useDataTable<TRow extends Record<string, unknown>>(
|
|
133
|
+
query: TableQuery<TRow>,
|
|
134
|
+
options: DataTableOptions<TRow> = {},
|
|
135
|
+
): DataTableModel<TRow> {
|
|
136
|
+
return useMemo(
|
|
137
|
+
() => buildDataTableModel(query, options),
|
|
138
|
+
// The query object identity changes as react-query state advances.
|
|
139
|
+
[query, options.columns, options.sortable],
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rastack/components` — headless, token-styled React components for the stack.
|
|
3
|
+
*
|
|
4
|
+
* Two layers:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Primitives** (`Button`, `Input`, `Select`, `Table`, `Field`, …) — the
|
|
7
|
+
* shadcn-style building blocks. Every one is painted purely from design
|
|
8
|
+
* tokens (`../style.ts` → `var(--…)`), so dropping them inside a
|
|
9
|
+
* `<ThemeProvider>` (from `rastack/theme`) themes them automatically,
|
|
10
|
+
* light/dark included, with zero hard-coded colours.
|
|
11
|
+
*
|
|
12
|
+
* 2. **Data-connected components** — the differentiator. Hand a component a
|
|
13
|
+
* framework hook and it renders itself from the data and its datatypes:
|
|
14
|
+
* - `<DataTable query={useListAirports(...)} />` builds columns from the
|
|
15
|
+
* server header (or the rows), and lights up search / sortable headers /
|
|
16
|
+
* pagination when the hook supports them.
|
|
17
|
+
* - `<AutoForm form={useUpdateAirportForm(...)} />` renders one control per
|
|
18
|
+
* field, chosen from the datatype the form hook derives from the write
|
|
19
|
+
* schema (checkbox for booleans, date picker for dates, select for
|
|
20
|
+
* enums, …).
|
|
21
|
+
* Each also exposes a headless hook (`useDataTable`, `useAutoForm`) and a
|
|
22
|
+
* render-prop escape hatch for full control.
|
|
23
|
+
*
|
|
24
|
+
* Web/DOM-first (the components render HTML and style via CSS variables). On
|
|
25
|
+
* React Native, drive your own views from the headless hooks + `useTokenValue`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// Styling surface (token → CSS-var helpers, the `tokens` map, variant styles).
|
|
29
|
+
export * from "./style";
|
|
30
|
+
export { humanizeKey } from "./util";
|
|
31
|
+
|
|
32
|
+
// Structural contracts for the connected components.
|
|
33
|
+
export type {
|
|
34
|
+
TableQuery,
|
|
35
|
+
AutoFormLike,
|
|
36
|
+
AutoFormField,
|
|
37
|
+
ReturnedHeader,
|
|
38
|
+
} from "./types";
|
|
39
|
+
|
|
40
|
+
// Primitives.
|
|
41
|
+
export * from "./primitives";
|
|
42
|
+
|
|
43
|
+
// Data table.
|
|
44
|
+
export { DataTable, type DataTableProps } from "./data-table/DataTable";
|
|
45
|
+
export {
|
|
46
|
+
useDataTable,
|
|
47
|
+
buildDataTableModel,
|
|
48
|
+
type DataTableModel,
|
|
49
|
+
type DataTableOptions,
|
|
50
|
+
type DataTableSearch,
|
|
51
|
+
type DataTableSort,
|
|
52
|
+
type DataTablePagination,
|
|
53
|
+
} from "./data-table/use-data-table";
|
|
54
|
+
export {
|
|
55
|
+
resolveColumns,
|
|
56
|
+
formatCell,
|
|
57
|
+
type DataTableColumn,
|
|
58
|
+
type DataTableColumnInput,
|
|
59
|
+
type ResolveColumnsInput,
|
|
60
|
+
} from "./data-table/columns";
|
|
61
|
+
|
|
62
|
+
// Auto form.
|
|
63
|
+
export { AutoForm, type AutoFormProps } from "./auto-form/AutoForm";
|
|
64
|
+
export {
|
|
65
|
+
useAutoForm,
|
|
66
|
+
buildAutoFormModel,
|
|
67
|
+
type AutoFormModel,
|
|
68
|
+
type AutoFormFieldModel,
|
|
69
|
+
} from "./auto-form/use-auto-form";
|
|
70
|
+
export {
|
|
71
|
+
inputKindForFieldType,
|
|
72
|
+
resolveFieldSpecs,
|
|
73
|
+
type AutoFormInputKind,
|
|
74
|
+
type AutoFormOptions,
|
|
75
|
+
type AutoFormFieldConfig,
|
|
76
|
+
type AutoFormFieldSpec,
|
|
77
|
+
} from "./auto-form/fields";
|