@everystack/admin 0.2.1 → 0.2.2
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 +56 -2
- package/src/screens/ResourceDashboard.tsx +14 -8
- package/src/screens/ResourceList.tsx +6 -0
- package/src/screens/ResourceShow.tsx +90 -2
- package/src/types.ts +34 -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.2",
|
|
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,
|
|
@@ -54,6 +54,53 @@ export interface EventListResult {
|
|
|
54
54
|
total: number;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// ────────────────────────────────────────────────────────────
|
|
58
|
+
// Identifier validation
|
|
59
|
+
//
|
|
60
|
+
// Every identifier interpolated with sql.raw comes from server-side config,
|
|
61
|
+
// but config flows through several packages before it gets here — validate
|
|
62
|
+
// at factory time so a typo or an injection-shaped value fails loudly at
|
|
63
|
+
// wiring, not silently at query time. Values are always parameterized.
|
|
64
|
+
// ────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
const TABLE_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/;
|
|
67
|
+
const COLUMN_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
68
|
+
|
|
69
|
+
function assertTable(name: string): void {
|
|
70
|
+
if (!TABLE_PATTERN.test(name)) {
|
|
71
|
+
throw new Error(`Invalid dashboard table name: ${JSON.stringify(name)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function assertColumn(name: string): void {
|
|
76
|
+
if (!COLUMN_PATTERN.test(name)) {
|
|
77
|
+
throw new Error(`Invalid dashboard column name: ${JSON.stringify(name)}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function validateConfig(tableName: string, config: ResourceDashboardConfig): void {
|
|
82
|
+
assertTable(tableName);
|
|
83
|
+
assertColumn(config.timestampColumn);
|
|
84
|
+
for (const predicate of config.where ?? []) assertColumn(predicate.column);
|
|
85
|
+
for (const dim of config.dimensions) assertColumn(dim.column);
|
|
86
|
+
for (const col of config.listColumns) assertColumn(col);
|
|
87
|
+
for (const col of config.searchColumns ?? []) assertColumn(col);
|
|
88
|
+
const pkCols = config.primaryKey
|
|
89
|
+
? (Array.isArray(config.primaryKey) ? config.primaryKey : [config.primaryKey])
|
|
90
|
+
: [];
|
|
91
|
+
for (const col of pkCols) assertColumn(col);
|
|
92
|
+
for (const stat of config.stats) {
|
|
93
|
+
assertColumn(stat.id);
|
|
94
|
+
if (stat.value.type === 'count_where' || stat.value.type === 'distinct') {
|
|
95
|
+
assertColumn(stat.value.column);
|
|
96
|
+
}
|
|
97
|
+
if (stat.value.type === 'table_count') {
|
|
98
|
+
assertTable(stat.value.table);
|
|
99
|
+
if (stat.value.timestampColumn) assertColumn(stat.value.timestampColumn);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
57
104
|
// ────────────────────────────────────────────────────────────
|
|
58
105
|
// WHERE clause builder
|
|
59
106
|
// ────────────────────────────────────────────────────────────
|
|
@@ -66,6 +113,11 @@ function buildWhereClause(
|
|
|
66
113
|
const conditions: SQL[] = [];
|
|
67
114
|
const tsCol = config.timestampColumn;
|
|
68
115
|
|
|
116
|
+
// Static predicates (server-side constants, e.g. STI discriminators)
|
|
117
|
+
for (const predicate of config.where ?? []) {
|
|
118
|
+
conditions.push(sql`${sql.raw(predicate.column)} = ${predicate.equals}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
69
121
|
// Time window
|
|
70
122
|
if (filters.time && filters.time[0] && filters.time[1]) {
|
|
71
123
|
conditions.push(sql`${sql.raw(tsCol)} >= ${filters.time[0]}::timestamptz`);
|
|
@@ -373,7 +425,7 @@ export interface ResourceDashboardRpcs {
|
|
|
373
425
|
/**
|
|
374
426
|
* Create dashboard + event list RPC handlers for a resource.
|
|
375
427
|
*
|
|
376
|
-
* @param tableName -
|
|
428
|
+
* @param tableName - Physical table name, may be schema-qualified ('ops.runs')
|
|
377
429
|
* @param config - ResourceDashboardConfig from ResourceConfig.dashboard
|
|
378
430
|
* @param db - Drizzle db executor (must support .execute())
|
|
379
431
|
*/
|
|
@@ -382,6 +434,7 @@ export function createResourceDashboardRpcs(
|
|
|
382
434
|
config: ResourceDashboardConfig,
|
|
383
435
|
db: DbExecutor,
|
|
384
436
|
): ResourceDashboardRpcs {
|
|
437
|
+
validateConfig(tableName, config);
|
|
385
438
|
return {
|
|
386
439
|
dashboardData: createDashboardDataHandler(tableName, config, db),
|
|
387
440
|
eventList: createEventListHandler(tableName, config, db),
|
|
@@ -405,7 +458,8 @@ export function createAllDashboardRpcs(
|
|
|
405
458
|
|
|
406
459
|
for (const resource of resources) {
|
|
407
460
|
if (!resource.dashboard) continue;
|
|
408
|
-
const
|
|
461
|
+
const tableName = resource.dashboard.tableName ?? resource.name;
|
|
462
|
+
const handlers = createResourceDashboardRpcs(tableName, resource.dashboard, db);
|
|
409
463
|
// Convert resource name to camelCase RPC name prefix
|
|
410
464
|
const prefix = resource.name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
411
465
|
rpcs[`${prefix}DashboardData`] = handlers.dashboardData;
|
|
@@ -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,15 @@
|
|
|
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 type { FieldDefinition, DataRecord, RowActionConfig } from '../types';
|
|
16
|
+
import type { DataAdapter } from '../adapters/types';
|
|
15
17
|
|
|
16
18
|
export interface ResourceShowProps {
|
|
17
19
|
/** Resource name */
|
|
@@ -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,82 @@ export function ResourceShow({
|
|
|
120
132
|
);
|
|
121
133
|
}
|
|
122
134
|
|
|
135
|
+
// =============================================================================
|
|
136
|
+
// RowActionButton
|
|
137
|
+
// =============================================================================
|
|
138
|
+
|
|
139
|
+
interface RowActionButtonProps {
|
|
140
|
+
action: RowActionConfig;
|
|
141
|
+
recordId: string | number;
|
|
142
|
+
adapter: DataAdapter;
|
|
143
|
+
onDone: () => Promise<void> | void;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Two-tap confirm: actions with `confirm` swap their label on first press
|
|
148
|
+
* and execute on the second. Cross-platform (no Alert/window.confirm),
|
|
149
|
+
* resets after 3 seconds without the confirming press.
|
|
150
|
+
*/
|
|
151
|
+
function RowActionButton({ action, recordId, adapter, onDone }: RowActionButtonProps): React.ReactElement {
|
|
152
|
+
const [confirming, setConfirming] = useState(false);
|
|
153
|
+
const [busy, setBusy] = useState(false);
|
|
154
|
+
const [error, setError] = useState<string | null>(null);
|
|
155
|
+
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
return () => {
|
|
159
|
+
if (resetTimer.current) clearTimeout(resetTimer.current);
|
|
160
|
+
};
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
const execute = useCallback(async () => {
|
|
164
|
+
if (!adapter.rpc) {
|
|
165
|
+
setError('Adapter does not support RPC actions');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
setBusy(true);
|
|
169
|
+
setError(null);
|
|
170
|
+
try {
|
|
171
|
+
await adapter.rpc(action.rpc, { id: recordId });
|
|
172
|
+
await onDone();
|
|
173
|
+
} catch (err) {
|
|
174
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
175
|
+
} finally {
|
|
176
|
+
setBusy(false);
|
|
177
|
+
setConfirming(false);
|
|
178
|
+
}
|
|
179
|
+
}, [adapter, action.rpc, recordId, onDone]);
|
|
180
|
+
|
|
181
|
+
const handlePress = useCallback(() => {
|
|
182
|
+
if (busy) return;
|
|
183
|
+
if (action.confirm && !confirming) {
|
|
184
|
+
setConfirming(true);
|
|
185
|
+
if (resetTimer.current) clearTimeout(resetTimer.current);
|
|
186
|
+
resetTimer.current = setTimeout(() => setConfirming(false), 3000);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (resetTimer.current) clearTimeout(resetTimer.current);
|
|
190
|
+
execute();
|
|
191
|
+
}, [busy, action.confirm, confirming, execute]);
|
|
192
|
+
|
|
193
|
+
const buttonLabel = busy ? '…' : confirming ? (action.confirm ?? action.label) : action.label;
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<View>
|
|
197
|
+
<Button
|
|
198
|
+
label={buttonLabel}
|
|
199
|
+
variant={action.variant ?? 'outline'}
|
|
200
|
+
onPress={handlePress}
|
|
201
|
+
/>
|
|
202
|
+
{error && (
|
|
203
|
+
<Text className="text-xs text-red-600 mt-1" numberOfLines={2}>
|
|
204
|
+
{error}
|
|
205
|
+
</Text>
|
|
206
|
+
)}
|
|
207
|
+
</View>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
123
211
|
// =============================================================================
|
|
124
212
|
// FieldRow
|
|
125
213
|
// =============================================================================
|
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) */
|
|
@@ -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
|
+
}
|