@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/admin",
3
- "version": "0.2.1",
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.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,
@@ -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 - Database table name (snake_case)
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 handlers = createResourceDashboardRpcs(resource.name, resource.dashboard, db);
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
- 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,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 type { FieldDefinition, DataRecord } from '../types';
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
+ }