@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/admin",
3
- "version": "0.2.1",
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.1.0",
34
- "@everystack/ui": ">=0.1.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
 
@@ -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 - Database table name (snake_case)
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 handlers = createResourceDashboardRpcs(resource.name, resource.dashboard, db);
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
- const chartWidth = Math.min(windowWidth - 48, 800);
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
- <TimeHistogram
227
- data={data?.timeHistogram ?? []}
228
- width={chartWidth}
229
- title={dashboard.title ? `${dashboard.title} Over Time` : undefined}
230
- onBrush={handleTimeBrush}
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 type { FieldDefinition, DataRecord } from '../types';
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
- const result = await adapter.fetchOne(resource, id);
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
+ }