@everystack/admin 0.2.1 → 0.2.3
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/package.json +7 -3
- package/src/adapters/EverystackAdapter.ts +10 -0
- package/src/adapters/types.ts +6 -0
- package/src/index.ts +5 -0
- package/src/rpc/resource-dashboard-rpc.ts +115 -2
- package/src/screens/ResourceDashboard.tsx +14 -8
- package/src/screens/ResourceList.tsx +6 -0
- package/src/screens/ResourceShow.tsx +117 -5
- package/src/types.ts +40 -0
- package/src/utils/format-cell-value.ts +30 -0
- package/src/utils/row-actions.ts +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/admin",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Declarative admin dashboard framework for PostgREST APIs",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"publishConfig": {
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"types": "./src/index.ts",
|
|
16
16
|
"default": "./src/index.ts"
|
|
17
17
|
},
|
|
18
|
+
"./rpc": {
|
|
19
|
+
"types": "./src/rpc/resource-dashboard-rpc.ts",
|
|
20
|
+
"default": "./src/rpc/resource-dashboard-rpc.ts"
|
|
21
|
+
},
|
|
18
22
|
"./presets/twitter": {
|
|
19
23
|
"types": "./src/presets/twitter.ts",
|
|
20
24
|
"default": "./src/presets/twitter.ts"
|
|
@@ -30,8 +34,8 @@
|
|
|
30
34
|
"d3-time-format": "4.1.0"
|
|
31
35
|
},
|
|
32
36
|
"peerDependencies": {
|
|
33
|
-
"@everystack/api": ">=0.
|
|
34
|
-
"@everystack/ui": ">=0.
|
|
37
|
+
"@everystack/api": ">=0.2.4",
|
|
38
|
+
"@everystack/ui": ">=0.2.2",
|
|
35
39
|
"react": "19.2.6",
|
|
36
40
|
"react-native": "0.83.6"
|
|
37
41
|
},
|
|
@@ -120,6 +120,16 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
async rpc<T = unknown>(fn: string, body?: unknown): Promise<T> {
|
|
124
|
+
const result = await this.client.rpc<T>(fn, body);
|
|
125
|
+
|
|
126
|
+
if (result.error) {
|
|
127
|
+
throw new Error(result.error.message);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return result.data as T;
|
|
131
|
+
}
|
|
132
|
+
|
|
123
133
|
async aggregate(resource: string, params: AggregateParams): Promise<AggregateResult> {
|
|
124
134
|
const selectParts: string[] = [];
|
|
125
135
|
|
package/src/adapters/types.ts
CHANGED
|
@@ -55,6 +55,12 @@ export interface DataAdapter {
|
|
|
55
55
|
*/
|
|
56
56
|
aggregate?(resource: string, params: AggregateParams): Promise<AggregateResult>;
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Call a named RPC on the backend (row actions, dashboards).
|
|
60
|
+
* Throws on error, like the other adapter methods.
|
|
61
|
+
*/
|
|
62
|
+
rpc?<T = unknown>(fn: string, body?: unknown): Promise<T>;
|
|
63
|
+
|
|
58
64
|
/**
|
|
59
65
|
* Subscribe to real-time updates (optional)
|
|
60
66
|
*/
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,8 @@ export type {
|
|
|
30
30
|
StatDialConfig,
|
|
31
31
|
StatDialValue,
|
|
32
32
|
DimensionConfig,
|
|
33
|
+
// Row actions
|
|
34
|
+
RowActionConfig,
|
|
33
35
|
} from './types';
|
|
34
36
|
|
|
35
37
|
// Adapter types
|
|
@@ -126,6 +128,9 @@ export { ImageDisplay } from './components/ImageDisplay';
|
|
|
126
128
|
export { resolveImageUrl, type ResolveImageUrlOptions } from './utils/resolve-image-url';
|
|
127
129
|
export { uploadFile, type UploadFileOptions, type UploadFileResult } from './utils/upload-file';
|
|
128
130
|
|
|
131
|
+
// Row actions
|
|
132
|
+
export { visibleRowActions } from './utils/row-actions';
|
|
133
|
+
|
|
129
134
|
// Utilities
|
|
130
135
|
export {
|
|
131
136
|
toCamelCase,
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { sql, type SQL } from 'drizzle-orm';
|
|
18
|
+
import { toCamelCaseKeys } from '../utils/naming';
|
|
18
19
|
import type { ResourceDashboardConfig, DimensionConfig, StatDialConfig } from '../types';
|
|
19
20
|
|
|
20
21
|
interface DbExecutor {
|
|
@@ -54,6 +55,53 @@ export interface EventListResult {
|
|
|
54
55
|
total: number;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
// ────────────────────────────────────────────────────────────
|
|
59
|
+
// Identifier validation
|
|
60
|
+
//
|
|
61
|
+
// Every identifier interpolated with sql.raw comes from server-side config,
|
|
62
|
+
// but config flows through several packages before it gets here — validate
|
|
63
|
+
// at factory time so a typo or an injection-shaped value fails loudly at
|
|
64
|
+
// wiring, not silently at query time. Values are always parameterized.
|
|
65
|
+
// ────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const TABLE_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/;
|
|
68
|
+
const COLUMN_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
69
|
+
|
|
70
|
+
function assertTable(name: string): void {
|
|
71
|
+
if (!TABLE_PATTERN.test(name)) {
|
|
72
|
+
throw new Error(`Invalid dashboard table name: ${JSON.stringify(name)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function assertColumn(name: string): void {
|
|
77
|
+
if (!COLUMN_PATTERN.test(name)) {
|
|
78
|
+
throw new Error(`Invalid dashboard column name: ${JSON.stringify(name)}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateConfig(tableName: string, config: ResourceDashboardConfig): void {
|
|
83
|
+
assertTable(tableName);
|
|
84
|
+
assertColumn(config.timestampColumn);
|
|
85
|
+
for (const predicate of config.where ?? []) assertColumn(predicate.column);
|
|
86
|
+
for (const dim of config.dimensions) assertColumn(dim.column);
|
|
87
|
+
for (const col of config.listColumns) assertColumn(col);
|
|
88
|
+
for (const col of config.searchColumns ?? []) assertColumn(col);
|
|
89
|
+
const pkCols = config.primaryKey
|
|
90
|
+
? (Array.isArray(config.primaryKey) ? config.primaryKey : [config.primaryKey])
|
|
91
|
+
: [];
|
|
92
|
+
for (const col of pkCols) assertColumn(col);
|
|
93
|
+
for (const stat of config.stats) {
|
|
94
|
+
assertColumn(stat.id);
|
|
95
|
+
if (stat.value.type === 'count_where' || stat.value.type === 'distinct') {
|
|
96
|
+
assertColumn(stat.value.column);
|
|
97
|
+
}
|
|
98
|
+
if (stat.value.type === 'table_count') {
|
|
99
|
+
assertTable(stat.value.table);
|
|
100
|
+
if (stat.value.timestampColumn) assertColumn(stat.value.timestampColumn);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
57
105
|
// ────────────────────────────────────────────────────────────
|
|
58
106
|
// WHERE clause builder
|
|
59
107
|
// ────────────────────────────────────────────────────────────
|
|
@@ -66,6 +114,11 @@ function buildWhereClause(
|
|
|
66
114
|
const conditions: SQL[] = [];
|
|
67
115
|
const tsCol = config.timestampColumn;
|
|
68
116
|
|
|
117
|
+
// Static predicates (server-side constants, e.g. STI discriminators)
|
|
118
|
+
for (const predicate of config.where ?? []) {
|
|
119
|
+
conditions.push(sql`${sql.raw(predicate.column)} = ${predicate.equals}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
69
122
|
// Time window
|
|
70
123
|
if (filters.time && filters.time[0] && filters.time[1]) {
|
|
71
124
|
conditions.push(sql`${sql.raw(tsCol)} >= ${filters.time[0]}::timestamptz`);
|
|
@@ -361,6 +414,61 @@ function createEventListHandler(
|
|
|
361
414
|
};
|
|
362
415
|
}
|
|
363
416
|
|
|
417
|
+
// ────────────────────────────────────────────────────────────
|
|
418
|
+
// Event Detail RPC
|
|
419
|
+
//
|
|
420
|
+
// The show view's data source for detailVia: 'rpc' resources — one row
|
|
421
|
+
// by primary key, the static where still applied (an STI discriminator
|
|
422
|
+
// must gate detail exactly like it gates the list), keys camelCased to
|
|
423
|
+
// match field definitions. Keeps telemetry off the REST surface entirely.
|
|
424
|
+
// ────────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
const MAX_ID_LENGTH = 256;
|
|
427
|
+
|
|
428
|
+
function createEventDetailHandler(
|
|
429
|
+
tableName: string,
|
|
430
|
+
config: ResourceDashboardConfig,
|
|
431
|
+
db: DbExecutor,
|
|
432
|
+
): (body: unknown) => Promise<unknown> {
|
|
433
|
+
const pkColumn = Array.isArray(config.primaryKey)
|
|
434
|
+
? config.primaryKey[0]
|
|
435
|
+
: config.primaryKey ?? 'id';
|
|
436
|
+
|
|
437
|
+
return async (body: unknown) => {
|
|
438
|
+
const id = (body as { id?: unknown } | null)?.id;
|
|
439
|
+
if (
|
|
440
|
+
(typeof id !== 'string' && typeof id !== 'number') ||
|
|
441
|
+
String(id).length === 0 ||
|
|
442
|
+
String(id).length > MAX_ID_LENGTH
|
|
443
|
+
) {
|
|
444
|
+
throw new Error('Invalid id');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const conditions: SQL[] = [sql`${sql.raw(pkColumn)} = ${String(id)}`];
|
|
448
|
+
for (const predicate of config.where ?? []) {
|
|
449
|
+
conditions.push(sql`${sql.raw(predicate.column)} = ${predicate.equals}`);
|
|
450
|
+
}
|
|
451
|
+
let where = conditions[0];
|
|
452
|
+
for (let i = 1; i < conditions.length; i++) {
|
|
453
|
+
where = sql`${where} AND ${conditions[i]}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const result = await db.execute(sql`
|
|
457
|
+
SELECT row_to_json(r) AS row
|
|
458
|
+
FROM (SELECT * FROM ${sql.raw(tableName)} WHERE ${where} LIMIT 1) r
|
|
459
|
+
`);
|
|
460
|
+
|
|
461
|
+
const rows = extractRows(result);
|
|
462
|
+
const first = rows[0] as { row: Record<string, unknown> | string } | undefined;
|
|
463
|
+
if (!first) {
|
|
464
|
+
throw new Error(`Record not found: ${id}`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const raw = typeof first.row === 'string' ? JSON.parse(first.row) : first.row;
|
|
468
|
+
return toCamelCaseKeys(raw);
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
364
472
|
// ────────────────────────────────────────────────────────────
|
|
365
473
|
// Public API
|
|
366
474
|
// ────────────────────────────────────────────────────────────
|
|
@@ -368,12 +476,13 @@ function createEventListHandler(
|
|
|
368
476
|
export interface ResourceDashboardRpcs {
|
|
369
477
|
dashboardData: (body: unknown) => Promise<unknown>;
|
|
370
478
|
eventList: (body: unknown) => Promise<unknown>;
|
|
479
|
+
eventDetail: (body: unknown) => Promise<unknown>;
|
|
371
480
|
}
|
|
372
481
|
|
|
373
482
|
/**
|
|
374
483
|
* Create dashboard + event list RPC handlers for a resource.
|
|
375
484
|
*
|
|
376
|
-
* @param tableName -
|
|
485
|
+
* @param tableName - Physical table name, may be schema-qualified ('ops.runs')
|
|
377
486
|
* @param config - ResourceDashboardConfig from ResourceConfig.dashboard
|
|
378
487
|
* @param db - Drizzle db executor (must support .execute())
|
|
379
488
|
*/
|
|
@@ -382,9 +491,11 @@ export function createResourceDashboardRpcs(
|
|
|
382
491
|
config: ResourceDashboardConfig,
|
|
383
492
|
db: DbExecutor,
|
|
384
493
|
): ResourceDashboardRpcs {
|
|
494
|
+
validateConfig(tableName, config);
|
|
385
495
|
return {
|
|
386
496
|
dashboardData: createDashboardDataHandler(tableName, config, db),
|
|
387
497
|
eventList: createEventListHandler(tableName, config, db),
|
|
498
|
+
eventDetail: createEventDetailHandler(tableName, config, db),
|
|
388
499
|
};
|
|
389
500
|
}
|
|
390
501
|
|
|
@@ -405,11 +516,13 @@ export function createAllDashboardRpcs(
|
|
|
405
516
|
|
|
406
517
|
for (const resource of resources) {
|
|
407
518
|
if (!resource.dashboard) continue;
|
|
408
|
-
const
|
|
519
|
+
const tableName = resource.dashboard.tableName ?? resource.name;
|
|
520
|
+
const handlers = createResourceDashboardRpcs(tableName, resource.dashboard, db);
|
|
409
521
|
// Convert resource name to camelCase RPC name prefix
|
|
410
522
|
const prefix = resource.name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
411
523
|
rpcs[`${prefix}DashboardData`] = handlers.dashboardData;
|
|
412
524
|
rpcs[`${prefix}EventList`] = handlers.eventList;
|
|
525
|
+
rpcs[`${prefix}EventDetail`] = handlers.eventDetail;
|
|
413
526
|
}
|
|
414
527
|
|
|
415
528
|
return rpcs;
|
|
@@ -18,6 +18,7 @@ import React, { useState, useMemo, useCallback } from 'react';
|
|
|
18
18
|
import { View, Text, ScrollView, Pressable, useWindowDimensions } from 'react-native';
|
|
19
19
|
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
20
20
|
import { useRpc } from '../hooks/useRpc';
|
|
21
|
+
import { useContainerWidth } from '@everystack/ui/dashboard';
|
|
21
22
|
import { StatDials } from '../components/dashboard/StatDials';
|
|
22
23
|
import { TimeHistogram } from '../components/dashboard/TimeHistogram';
|
|
23
24
|
import { DimensionBars } from '../components/dashboard/DimensionBars';
|
|
@@ -98,7 +99,10 @@ function ResourceDashboardInner({
|
|
|
98
99
|
action?: { label: string; onPress: () => void };
|
|
99
100
|
}): React.ReactElement {
|
|
100
101
|
const { width: windowWidth } = useWindowDimensions();
|
|
101
|
-
|
|
102
|
+
// Real width comes from the chart wrapper's onLayout; the window-derived
|
|
103
|
+
// value only serves until the first layout fires.
|
|
104
|
+
const chart = useContainerWidth(Math.min(windowWidth - 48, 800));
|
|
105
|
+
const chartWidth = chart.width;
|
|
102
106
|
|
|
103
107
|
const prefix = toCamelCase(resource);
|
|
104
108
|
const dashboardDataFn = `${prefix}DashboardData`;
|
|
@@ -222,13 +226,15 @@ function ResourceDashboardInner({
|
|
|
222
226
|
{/* Stat Dials */}
|
|
223
227
|
<StatDials dials={dials} />
|
|
224
228
|
|
|
225
|
-
{/* Time Histogram */}
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
229
|
+
{/* Time Histogram — wrapper measures content width */}
|
|
230
|
+
<View onLayout={chart.onLayout}>
|
|
231
|
+
<TimeHistogram
|
|
232
|
+
data={data?.timeHistogram ?? []}
|
|
233
|
+
width={chartWidth}
|
|
234
|
+
title={dashboard.title ? `${dashboard.title} Over Time` : undefined}
|
|
235
|
+
onBrush={handleTimeBrush}
|
|
236
|
+
/>
|
|
237
|
+
</View>
|
|
232
238
|
|
|
233
239
|
{/* Dimension Charts */}
|
|
234
240
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 24, marginTop: 24 }}>
|
|
@@ -10,6 +10,7 @@ import { View, Text } from 'react-native';
|
|
|
10
10
|
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
11
11
|
import { useAdminData } from '../hooks/useAdminData';
|
|
12
12
|
import { DataTable, FilterBar, PaginationBar, LoadingState, ErrorState, EmptyState, Button, type DataTableColumn } from '@everystack/ui';
|
|
13
|
+
import { formatCellValue } from '../utils/format-cell-value';
|
|
13
14
|
import type { ResourceConfig, FieldDefinition } from '../types';
|
|
14
15
|
|
|
15
16
|
export interface ResourceListProps {
|
|
@@ -149,5 +150,10 @@ function fieldsToColumns(
|
|
|
149
150
|
key: field.name,
|
|
150
151
|
label: field.label ?? field.name,
|
|
151
152
|
sortable: field.sortable,
|
|
153
|
+
render: (value: unknown) => (
|
|
154
|
+
<Text className="text-sm text-gray-900" numberOfLines={1}>
|
|
155
|
+
{formatCellValue(field, value)}
|
|
156
|
+
</Text>
|
|
157
|
+
),
|
|
152
158
|
}));
|
|
153
159
|
}
|
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
* Fetches via adapter.fetchOne() and displays field values.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React, { useState, useEffect, useCallback } from 'react';
|
|
8
|
+
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
9
9
|
import { View, Text, ScrollView } from 'react-native';
|
|
10
10
|
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
11
11
|
import { useDataAdapter } from '../hooks/useAdminData';
|
|
12
12
|
import { LoadingState, ErrorState, Button, Card, Divider } from '@everystack/ui';
|
|
13
13
|
import { ImageDisplay } from '../components/ImageDisplay';
|
|
14
|
-
import
|
|
14
|
+
import { visibleRowActions } from '../utils/row-actions';
|
|
15
|
+
import { toCamelCase } from '../utils/naming';
|
|
16
|
+
import type { FieldDefinition, DataRecord, ResourceConfig, RowActionConfig } from '../types';
|
|
17
|
+
import type { DataAdapter } from '../adapters/types';
|
|
15
18
|
|
|
16
19
|
export interface ResourceShowProps {
|
|
17
20
|
/** Resource name */
|
|
@@ -45,14 +48,13 @@ export function ResourceShow({
|
|
|
45
48
|
setLoading(true);
|
|
46
49
|
setError(null);
|
|
47
50
|
try {
|
|
48
|
-
|
|
49
|
-
setRecord(result);
|
|
51
|
+
setRecord(await fetchDetail(adapter, resource, id, resourceConfig));
|
|
50
52
|
} catch (err) {
|
|
51
53
|
setError(err instanceof Error ? err : new Error(String(err)));
|
|
52
54
|
} finally {
|
|
53
55
|
setLoading(false);
|
|
54
56
|
}
|
|
55
|
-
}, [adapter, resource, id]);
|
|
57
|
+
}, [adapter, resource, id, resourceConfig]);
|
|
56
58
|
|
|
57
59
|
useEffect(() => {
|
|
58
60
|
fetchRecord();
|
|
@@ -79,6 +81,7 @@ export function ResourceShow({
|
|
|
79
81
|
const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
|
|
80
82
|
const canEdit = resourceConfig.actions?.edit !== false;
|
|
81
83
|
const canDelete = resourceConfig.actions?.delete !== false;
|
|
84
|
+
const rowActions = visibleRowActions(resourceConfig.rowActions, record);
|
|
82
85
|
|
|
83
86
|
return (
|
|
84
87
|
<ScrollView className="flex-1">
|
|
@@ -93,6 +96,15 @@ export function ResourceShow({
|
|
|
93
96
|
</Text>
|
|
94
97
|
</View>
|
|
95
98
|
<View className="flex-row gap-2">
|
|
99
|
+
{rowActions.map(action => (
|
|
100
|
+
<RowActionButton
|
|
101
|
+
key={action.id}
|
|
102
|
+
action={action}
|
|
103
|
+
recordId={id}
|
|
104
|
+
adapter={adapter}
|
|
105
|
+
onDone={fetchRecord}
|
|
106
|
+
/>
|
|
107
|
+
))}
|
|
96
108
|
{canEdit && onEditPress && (
|
|
97
109
|
<Button label="Edit" variant="outline" onPress={onEditPress} />
|
|
98
110
|
)}
|
|
@@ -120,6 +132,106 @@ export function ResourceShow({
|
|
|
120
132
|
);
|
|
121
133
|
}
|
|
122
134
|
|
|
135
|
+
// =============================================================================
|
|
136
|
+
// Detail fetching
|
|
137
|
+
// =============================================================================
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* detailVia: 'rpc' fetches through the role-gated {prefix}EventDetail RPC —
|
|
141
|
+
* the resource never enters the REST surface (telemetry tables stay out of
|
|
142
|
+
* exposedTables). Everything else uses adapter.fetchOne as before.
|
|
143
|
+
*/
|
|
144
|
+
async function fetchDetail(
|
|
145
|
+
adapter: DataAdapter,
|
|
146
|
+
resource: string,
|
|
147
|
+
id: string | number,
|
|
148
|
+
resourceConfig: ResourceConfig | undefined,
|
|
149
|
+
): Promise<DataRecord> {
|
|
150
|
+
if (resourceConfig?.dashboard?.detailVia === 'rpc') {
|
|
151
|
+
if (!adapter.rpc) {
|
|
152
|
+
throw new Error(`Resource "${resource}" uses detailVia: 'rpc' but the adapter has no rpc()`);
|
|
153
|
+
}
|
|
154
|
+
return (await adapter.rpc(`${toCamelCase(resource)}EventDetail`, { id })) as DataRecord;
|
|
155
|
+
}
|
|
156
|
+
return adapter.fetchOne(resource, id);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// =============================================================================
|
|
160
|
+
// RowActionButton
|
|
161
|
+
// =============================================================================
|
|
162
|
+
|
|
163
|
+
interface RowActionButtonProps {
|
|
164
|
+
action: RowActionConfig;
|
|
165
|
+
recordId: string | number;
|
|
166
|
+
adapter: DataAdapter;
|
|
167
|
+
onDone: () => Promise<void> | void;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Two-tap confirm: actions with `confirm` swap their label on first press
|
|
172
|
+
* and execute on the second. Cross-platform (no Alert/window.confirm),
|
|
173
|
+
* resets after 3 seconds without the confirming press.
|
|
174
|
+
*/
|
|
175
|
+
function RowActionButton({ action, recordId, adapter, onDone }: RowActionButtonProps): React.ReactElement {
|
|
176
|
+
const [confirming, setConfirming] = useState(false);
|
|
177
|
+
const [busy, setBusy] = useState(false);
|
|
178
|
+
const [error, setError] = useState<string | null>(null);
|
|
179
|
+
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
return () => {
|
|
183
|
+
if (resetTimer.current) clearTimeout(resetTimer.current);
|
|
184
|
+
};
|
|
185
|
+
}, []);
|
|
186
|
+
|
|
187
|
+
const execute = useCallback(async () => {
|
|
188
|
+
if (!adapter.rpc) {
|
|
189
|
+
setError('Adapter does not support RPC actions');
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
setBusy(true);
|
|
193
|
+
setError(null);
|
|
194
|
+
try {
|
|
195
|
+
await adapter.rpc(action.rpc, { id: recordId });
|
|
196
|
+
await onDone();
|
|
197
|
+
} catch (err) {
|
|
198
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
199
|
+
} finally {
|
|
200
|
+
setBusy(false);
|
|
201
|
+
setConfirming(false);
|
|
202
|
+
}
|
|
203
|
+
}, [adapter, action.rpc, recordId, onDone]);
|
|
204
|
+
|
|
205
|
+
const handlePress = useCallback(() => {
|
|
206
|
+
if (busy) return;
|
|
207
|
+
if (action.confirm && !confirming) {
|
|
208
|
+
setConfirming(true);
|
|
209
|
+
if (resetTimer.current) clearTimeout(resetTimer.current);
|
|
210
|
+
resetTimer.current = setTimeout(() => setConfirming(false), 3000);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (resetTimer.current) clearTimeout(resetTimer.current);
|
|
214
|
+
execute();
|
|
215
|
+
}, [busy, action.confirm, confirming, execute]);
|
|
216
|
+
|
|
217
|
+
const buttonLabel = busy ? '…' : confirming ? (action.confirm ?? action.label) : action.label;
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<View>
|
|
221
|
+
<Button
|
|
222
|
+
label={buttonLabel}
|
|
223
|
+
variant={action.variant ?? 'outline'}
|
|
224
|
+
onPress={handlePress}
|
|
225
|
+
/>
|
|
226
|
+
{error && (
|
|
227
|
+
<Text className="text-xs text-red-600 mt-1" numberOfLines={2}>
|
|
228
|
+
{error}
|
|
229
|
+
</Text>
|
|
230
|
+
)}
|
|
231
|
+
</View>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
123
235
|
// =============================================================================
|
|
124
236
|
// FieldRow
|
|
125
237
|
// =============================================================================
|
package/src/types.ts
CHANGED
|
@@ -60,6 +60,25 @@ export interface FieldDefinition {
|
|
|
60
60
|
// Resource Configuration
|
|
61
61
|
// =============================================================================
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* A declarative, RPC-backed action on a single record (retry/cancel a job).
|
|
65
|
+
* Rendered on the show view; invoked via adapter.rpc(rpc, { id }).
|
|
66
|
+
*/
|
|
67
|
+
export interface RowActionConfig {
|
|
68
|
+
/** Unique action identifier */
|
|
69
|
+
id: string;
|
|
70
|
+
/** Button label */
|
|
71
|
+
label: string;
|
|
72
|
+
/** RPC function name, called with { id: record[primaryKey] } */
|
|
73
|
+
rpc: string;
|
|
74
|
+
/** Require a confirming second press; shown as the interim label */
|
|
75
|
+
confirm?: string;
|
|
76
|
+
/** Button styling */
|
|
77
|
+
variant?: 'primary' | 'outline' | 'destructive';
|
|
78
|
+
/** Show only when String(record[field]) is in the list */
|
|
79
|
+
when?: { field: string; in: string[] };
|
|
80
|
+
}
|
|
81
|
+
|
|
63
82
|
export interface ResourceConfig {
|
|
64
83
|
/** Unique resource identifier (e.g., 'users', 'orders') */
|
|
65
84
|
name: string;
|
|
@@ -96,6 +115,8 @@ export interface ResourceConfig {
|
|
|
96
115
|
primaryKey?: string;
|
|
97
116
|
/** Optional observability-style dashboard configuration */
|
|
98
117
|
dashboard?: ResourceDashboardConfig;
|
|
118
|
+
/** RPC-backed record actions shown on the show view */
|
|
119
|
+
rowActions?: RowActionConfig[];
|
|
99
120
|
}
|
|
100
121
|
|
|
101
122
|
// =============================================================================
|
|
@@ -143,6 +164,19 @@ export interface DimensionConfig {
|
|
|
143
164
|
export interface ResourceDashboardConfig {
|
|
144
165
|
/** Title displayed at the top */
|
|
145
166
|
title?: string;
|
|
167
|
+
/**
|
|
168
|
+
* Physical table for dashboard SQL, may be schema-qualified (e.g. 'ops.runs',
|
|
169
|
+
* 'logs.crash_reports'). Defaults to the resource name. Required whenever the
|
|
170
|
+
* exposed resource name differs from the table the raw aggregation queries
|
|
171
|
+
* must hit — there is no search_path to paper over the difference.
|
|
172
|
+
*/
|
|
173
|
+
tableName?: string;
|
|
174
|
+
/**
|
|
175
|
+
* Static predicates ANDed into every dashboard query (server-side constants,
|
|
176
|
+
* never user input). The STI case: jobs over ops.runs use
|
|
177
|
+
* [{ column: 'type', equals: 'job' }].
|
|
178
|
+
*/
|
|
179
|
+
where?: Array<{ column: string; equals: string }>;
|
|
146
180
|
/** The datetime column for the time histogram */
|
|
147
181
|
timestampColumn: string;
|
|
148
182
|
/** Time window in hours (default: 168 = 7 days) */
|
|
@@ -161,6 +195,12 @@ export interface ResourceDashboardConfig {
|
|
|
161
195
|
searchColumns?: string[];
|
|
162
196
|
/** Navigate to detail on row press (default: true) */
|
|
163
197
|
linkToDetail?: boolean;
|
|
198
|
+
/**
|
|
199
|
+
* How the show view fetches the record. 'rpc' uses the role-gated
|
|
200
|
+
* {prefix}EventDetail RPC — the whole resource stays off the REST
|
|
201
|
+
* surface (telemetry tables never enter exposedTables). Default 'rest'.
|
|
202
|
+
*/
|
|
203
|
+
detailVia?: 'rpc' | 'rest';
|
|
164
204
|
}
|
|
165
205
|
|
|
166
206
|
// =============================================================================
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field-type-aware cell formatting for list views.
|
|
3
|
+
*
|
|
4
|
+
* The PostgREST protocol delivers ISO strings and raw JSON; the list
|
|
5
|
+
* renders them by the field's declared type. Unparseable values pass
|
|
6
|
+
* through untouched — never hide data behind "Invalid Date".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { FieldDefinition } from '../types';
|
|
10
|
+
|
|
11
|
+
export function formatCellValue(field: FieldDefinition, value: unknown): string {
|
|
12
|
+
if (value === null || value === undefined) return '—';
|
|
13
|
+
|
|
14
|
+
switch (field.type) {
|
|
15
|
+
case 'datetime': {
|
|
16
|
+
const date = new Date(String(value));
|
|
17
|
+
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
|
18
|
+
}
|
|
19
|
+
case 'date': {
|
|
20
|
+
const date = new Date(String(value));
|
|
21
|
+
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleDateString();
|
|
22
|
+
}
|
|
23
|
+
case 'boolean':
|
|
24
|
+
return value ? 'Yes' : 'No';
|
|
25
|
+
case 'json':
|
|
26
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
27
|
+
default:
|
|
28
|
+
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row action visibility — pure logic behind the show view's action buttons.
|
|
3
|
+
*
|
|
4
|
+
* The `when` clause compares String(record[field]) against the configured
|
|
5
|
+
* list, so numeric and boolean fields match their string forms.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { RowActionConfig } from '../types';
|
|
9
|
+
|
|
10
|
+
export function visibleRowActions(
|
|
11
|
+
actions: RowActionConfig[] | undefined,
|
|
12
|
+
record: Record<string, unknown>,
|
|
13
|
+
): RowActionConfig[] {
|
|
14
|
+
if (!actions) return [];
|
|
15
|
+
return actions.filter((action) => {
|
|
16
|
+
if (!action.when) return true;
|
|
17
|
+
const value = record[action.when.field];
|
|
18
|
+
if (value === null || value === undefined) return false;
|
|
19
|
+
return action.when.in.includes(String(value));
|
|
20
|
+
});
|
|
21
|
+
}
|