@everystack/admin 0.2.3 → 0.2.5
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 +16 -1
- package/src/AdminRoot.tsx +8 -3
- package/src/adapters/EverystackAdapter.ts +110 -31
- package/src/components/AnalyticsHeader.tsx +134 -0
- package/src/components/ImageDisplay.tsx +14 -10
- package/src/components/RowActionButton.tsx +109 -0
- package/src/editors/tiptap.tsx +69 -0
- package/src/hooks/useAdminData.ts +58 -20
- package/src/hooks/useEditors.ts +17 -0
- package/src/hooks/useViewState.ts +51 -0
- package/src/index.ts +35 -0
- package/src/presets/twitter.ts +4 -4
- package/src/screens/AdminLayout.tsx +166 -23
- package/src/screens/AdminRouter.tsx +137 -0
- package/src/screens/AdminShell.tsx +66 -0
- package/src/screens/ResourceCreate.tsx +13 -0
- package/src/screens/ResourceEdit.tsx +16 -1
- package/src/screens/ResourceList.tsx +86 -15
- package/src/screens/ResourceListView.tsx +82 -0
- package/src/screens/ResourceShow.tsx +13 -84
- package/src/screens/index.ts +6 -0
- package/src/types.ts +123 -6
- package/src/utils/admin-routing.ts +66 -0
- package/src/utils/breadcrumbs.ts +81 -0
- package/src/utils/render-resolution.ts +91 -0
- package/src/utils/standard-actions.ts +48 -0
- package/src/utils/view-filters.ts +75 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/admin",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Declarative admin dashboard framework for PostgREST APIs",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"publishConfig": {
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
"./presets/twitter": {
|
|
23
23
|
"types": "./src/presets/twitter.ts",
|
|
24
24
|
"default": "./src/presets/twitter.ts"
|
|
25
|
+
},
|
|
26
|
+
"./editors/tiptap": {
|
|
27
|
+
"types": "./src/editors/tiptap.tsx",
|
|
28
|
+
"default": "./src/editors/tiptap.tsx"
|
|
25
29
|
}
|
|
26
30
|
},
|
|
27
31
|
"scripts": {
|
|
@@ -36,12 +40,23 @@
|
|
|
36
40
|
"peerDependencies": {
|
|
37
41
|
"@everystack/api": ">=0.2.4",
|
|
38
42
|
"@everystack/ui": ">=0.2.2",
|
|
43
|
+
"@tiptap/pm": ">=2.0.0",
|
|
44
|
+
"@tiptap/react": ">=2.0.0",
|
|
45
|
+
"@tiptap/starter-kit": ">=2.0.0",
|
|
39
46
|
"react": "19.2.6",
|
|
40
47
|
"react-native": "0.83.6"
|
|
41
48
|
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"@tiptap/pm": { "optional": true },
|
|
51
|
+
"@tiptap/react": { "optional": true },
|
|
52
|
+
"@tiptap/starter-kit": { "optional": true }
|
|
53
|
+
},
|
|
42
54
|
"devDependencies": {
|
|
43
55
|
"@everystack/api": "workspace:*",
|
|
44
56
|
"@everystack/ui": "workspace:*",
|
|
57
|
+
"@tiptap/pm": "^2.0.0",
|
|
58
|
+
"@tiptap/react": "^2.0.0",
|
|
59
|
+
"@tiptap/starter-kit": "^2.0.0",
|
|
45
60
|
"@types/d3-scale": "4.0.9",
|
|
46
61
|
"@types/d3-time-format": "4.0.3",
|
|
47
62
|
"@types/jest": "29.5.14",
|
package/src/AdminRoot.tsx
CHANGED
|
@@ -6,27 +6,32 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import React from 'react';
|
|
9
|
-
import type { AdminConfig } from './types';
|
|
9
|
+
import type { AdminConfig, EditorRegistry } from './types';
|
|
10
10
|
import type { DataAdapter } from './adapters/types';
|
|
11
11
|
import { AdminConfigProvider } from './providers/AdminConfigProvider';
|
|
12
12
|
import { DataAdapterProvider } from './providers/DataAdapterProvider';
|
|
13
13
|
import { StorageConfigContext } from './hooks/useStorageConfig';
|
|
14
|
+
import { EditorRegistryContext } from './hooks/useEditors';
|
|
14
15
|
|
|
15
16
|
export interface AdminRootProps {
|
|
16
17
|
/** Admin configuration */
|
|
17
18
|
config: AdminConfig;
|
|
18
19
|
/** Data adapter for backend communication */
|
|
19
20
|
adapter: DataAdapter;
|
|
21
|
+
/** Editor adapters available to fields via `field.editor` (opt-in plugins). */
|
|
22
|
+
editors?: EditorRegistry;
|
|
20
23
|
/** Children to render (typically expo-router's Slot or navigator) */
|
|
21
24
|
children?: React.ReactNode;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
export function AdminRoot({ config, adapter, children }: AdminRootProps) {
|
|
27
|
+
export function AdminRoot({ config, adapter, editors, children }: AdminRootProps) {
|
|
25
28
|
return (
|
|
26
29
|
<AdminConfigProvider config={config}>
|
|
27
30
|
<DataAdapterProvider adapter={adapter}>
|
|
28
31
|
<StorageConfigContext.Provider value={config.storage ?? null}>
|
|
29
|
-
{
|
|
32
|
+
<EditorRegistryContext.Provider value={editors ?? {}}>
|
|
33
|
+
{children}
|
|
34
|
+
</EditorRegistryContext.Provider>
|
|
30
35
|
</StorageConfigContext.Provider>
|
|
31
36
|
</DataAdapterProvider>
|
|
32
37
|
</AdminConfigProvider>
|
|
@@ -22,13 +22,35 @@ const POSTGREST_OPERATORS = new Set([
|
|
|
22
22
|
'sl', 'sr', 'nxl', 'nxr', 'adj', 'ov', 'fts',
|
|
23
23
|
]);
|
|
24
24
|
|
|
25
|
-
export interface EverystackAdapterOptions extends ClientOptions {
|
|
25
|
+
export interface EverystackAdapterOptions extends ClientOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Resources whose list rows come from an RPC instead of the PostgREST table.
|
|
28
|
+
* Maps resource name → RPC function name. The RPC speaks the same list
|
|
29
|
+
* protocol — body `{ limit, offset, sort, dir, filter }`, result
|
|
30
|
+
* `{ data, total }` — so the list UI is identical to the REST path. Used to
|
|
31
|
+
* serve DB-free observability tables (logs, crashes) from the S3 archive.
|
|
32
|
+
*/
|
|
33
|
+
listRpcs?: Record<string, string>;
|
|
34
|
+
/**
|
|
35
|
+
* Resources whose single-record reads + writes go through an RPC instead of
|
|
36
|
+
* the PostgREST table. Maps resource name → RPC function name. The RPC is
|
|
37
|
+
* action-dispatched: body `{ action: 'get'|'create'|'update'|'delete', id?,
|
|
38
|
+
* data? }` → the record (get/create/update) or `{}` (delete). Used to serve
|
|
39
|
+
* DB-free mutable config (alert rules) from an S3 object with CAS. Pair with
|
|
40
|
+
* `listRpcs` (same RPC name) for the list view.
|
|
41
|
+
*/
|
|
42
|
+
crudRpcs?: Record<string, string>;
|
|
43
|
+
}
|
|
26
44
|
|
|
27
45
|
export class EverystackAdapter implements DataAdapter {
|
|
28
46
|
private client: Client;
|
|
47
|
+
private listRpcs: Record<string, string>;
|
|
48
|
+
private crudRpcs: Record<string, string>;
|
|
29
49
|
|
|
30
50
|
constructor(options: EverystackAdapterOptions) {
|
|
31
51
|
this.client = createClient(options);
|
|
52
|
+
this.listRpcs = options.listRpcs ?? {};
|
|
53
|
+
this.crudRpcs = options.crudRpcs ?? {};
|
|
32
54
|
}
|
|
33
55
|
|
|
34
56
|
async fetchList<T extends DataRecord = DataRecord>(
|
|
@@ -39,6 +61,24 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
39
61
|
const perPage = params.perPage ?? 25;
|
|
40
62
|
const offset = (page - 1) * perPage;
|
|
41
63
|
|
|
64
|
+
// RPC-backed list (e.g. DB-free observability tables over the S3 archive).
|
|
65
|
+
const listRpc = this.listRpcs[resource];
|
|
66
|
+
if (listRpc) {
|
|
67
|
+
const result = await this.rpc<{ data: T[]; total: number }>(listRpc, {
|
|
68
|
+
limit: perPage,
|
|
69
|
+
offset,
|
|
70
|
+
sort: params.sort,
|
|
71
|
+
dir: params.order ?? 'asc',
|
|
72
|
+
filter: params.filter,
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
data: result?.data ?? [],
|
|
76
|
+
total: result?.total ?? 0,
|
|
77
|
+
page,
|
|
78
|
+
perPage,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
42
82
|
let builder = this.client.from(resource);
|
|
43
83
|
|
|
44
84
|
// Apply filters
|
|
@@ -70,6 +110,14 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
70
110
|
resource: string,
|
|
71
111
|
id: string | number,
|
|
72
112
|
): Promise<T> {
|
|
113
|
+
// RPC-backed single read (e.g. DB-free alert rules from the S3 config object).
|
|
114
|
+
const crudRpc = this.crudRpcs[resource];
|
|
115
|
+
if (crudRpc) {
|
|
116
|
+
const record = await this.rpc<T | null>(crudRpc, { action: 'get', id: String(id) });
|
|
117
|
+
if (!record) throw new Error(`${resource}/${id} not found`);
|
|
118
|
+
return record;
|
|
119
|
+
}
|
|
120
|
+
|
|
73
121
|
const result = await this.client.from(resource).eq('id', String(id)).single();
|
|
74
122
|
|
|
75
123
|
if (result.error) {
|
|
@@ -85,6 +133,11 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
85
133
|
resource: string,
|
|
86
134
|
data: Omit<T, 'id'>,
|
|
87
135
|
): Promise<T> {
|
|
136
|
+
const crudRpc = this.crudRpcs[resource];
|
|
137
|
+
if (crudRpc) {
|
|
138
|
+
return this.rpcResult<T>(await this.rpc<T | { error: string }>(crudRpc, { action: 'create', data }));
|
|
139
|
+
}
|
|
140
|
+
|
|
88
141
|
const result = await this.client.from(resource).insert(data as Record<string, unknown>);
|
|
89
142
|
|
|
90
143
|
if (result.error) {
|
|
@@ -99,6 +152,11 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
99
152
|
id: string | number,
|
|
100
153
|
data: Partial<T>,
|
|
101
154
|
): Promise<T> {
|
|
155
|
+
const crudRpc = this.crudRpcs[resource];
|
|
156
|
+
if (crudRpc) {
|
|
157
|
+
return this.rpcResult<T>(await this.rpc<T | { error: string }>(crudRpc, { action: 'update', id: String(id), data }));
|
|
158
|
+
}
|
|
159
|
+
|
|
102
160
|
const result = await this.client
|
|
103
161
|
.from(resource)
|
|
104
162
|
.eq('id', String(id))
|
|
@@ -113,6 +171,12 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
113
171
|
}
|
|
114
172
|
|
|
115
173
|
async delete(resource: string, id: string | number): Promise<void> {
|
|
174
|
+
const crudRpc = this.crudRpcs[resource];
|
|
175
|
+
if (crudRpc) {
|
|
176
|
+
this.rpcResult(await this.rpc<{ error?: string }>(crudRpc, { action: 'delete', id: String(id) }));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
116
180
|
const result = await this.client.from(resource).eq('id', String(id)).remove();
|
|
117
181
|
|
|
118
182
|
if (result.error) {
|
|
@@ -120,6 +184,14 @@ export class EverystackAdapter implements DataAdapter {
|
|
|
120
184
|
}
|
|
121
185
|
}
|
|
122
186
|
|
|
187
|
+
/** Unwrap an action RPC result, surfacing a `{ error }` payload as a throw. */
|
|
188
|
+
private rpcResult<T>(result: T | { error: string }): T {
|
|
189
|
+
if (result && typeof result === 'object' && 'error' in result && (result as { error?: unknown }).error) {
|
|
190
|
+
throw new Error(String((result as { error: unknown }).error));
|
|
191
|
+
}
|
|
192
|
+
return result as T;
|
|
193
|
+
}
|
|
194
|
+
|
|
123
195
|
async rpc<T = unknown>(fn: string, body?: unknown): Promise<T> {
|
|
124
196
|
const result = await this.client.rpc<T>(fn, body);
|
|
125
197
|
|
|
@@ -180,43 +252,50 @@ function applyFilters(builder: QueryBuilder, filter?: Record<string, unknown>):
|
|
|
180
252
|
|
|
181
253
|
for (const [key, value] of Object.entries(filter)) {
|
|
182
254
|
if (value == null) continue;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
// Already formatted — extract operator and value
|
|
188
|
-
const dotIndex = strValue.indexOf('.');
|
|
189
|
-
const op = strValue.substring(0, dotIndex);
|
|
190
|
-
const val = strValue.substring(dotIndex + 1);
|
|
191
|
-
|
|
192
|
-
switch (op) {
|
|
193
|
-
case 'eq': builder = builder.eq(key, val); break;
|
|
194
|
-
case 'neq': builder = builder.neq(key, val); break;
|
|
195
|
-
case 'gt': builder = builder.gt(key, val); break;
|
|
196
|
-
case 'gte': builder = builder.gte(key, val); break;
|
|
197
|
-
case 'lt': builder = builder.lt(key, val); break;
|
|
198
|
-
case 'lte': builder = builder.lte(key, val); break;
|
|
199
|
-
case 'like': builder = builder.like(key, val); break;
|
|
200
|
-
case 'ilike': builder = builder.ilike(key, val); break;
|
|
201
|
-
case 'is': builder = builder.is(key, val); break;
|
|
202
|
-
case 'in': {
|
|
203
|
-
const values = val.replace(/^\(/, '').replace(/\)$/, '').split(',');
|
|
204
|
-
builder = builder.in(key, values);
|
|
205
|
-
break;
|
|
206
|
-
}
|
|
207
|
-
default:
|
|
208
|
-
// Unknown operator — use eq with full value
|
|
209
|
-
builder = builder.eq(key, strValue);
|
|
210
|
-
}
|
|
255
|
+
// An array applies several conditions to the same column (PostgREST ANDs
|
|
256
|
+
// them) — e.g. a time range as ['gte.<from>', 'lte.<to>'].
|
|
257
|
+
if (Array.isArray(value)) {
|
|
258
|
+
for (const v of value) builder = applyOneFilter(builder, key, v);
|
|
211
259
|
} else {
|
|
212
|
-
|
|
213
|
-
builder = builder.eq(key, strValue);
|
|
260
|
+
builder = applyOneFilter(builder, key, value);
|
|
214
261
|
}
|
|
215
262
|
}
|
|
216
263
|
|
|
217
264
|
return builder;
|
|
218
265
|
}
|
|
219
266
|
|
|
267
|
+
/** Apply one condition: a PostgREST-formatted value (`op.value`) or a plain eq. */
|
|
268
|
+
function applyOneFilter(builder: QueryBuilder, key: string, value: unknown): QueryBuilder {
|
|
269
|
+
if (value == null) return builder;
|
|
270
|
+
const strValue = String(value);
|
|
271
|
+
|
|
272
|
+
if (!isPostgrestFilter(strValue)) {
|
|
273
|
+
return builder.eq(key, strValue); // plain value → eq
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const dotIndex = strValue.indexOf('.');
|
|
277
|
+
const op = strValue.substring(0, dotIndex);
|
|
278
|
+
const val = strValue.substring(dotIndex + 1);
|
|
279
|
+
|
|
280
|
+
switch (op) {
|
|
281
|
+
case 'eq': return builder.eq(key, val);
|
|
282
|
+
case 'neq': return builder.neq(key, val);
|
|
283
|
+
case 'gt': return builder.gt(key, val);
|
|
284
|
+
case 'gte': return builder.gte(key, val);
|
|
285
|
+
case 'lt': return builder.lt(key, val);
|
|
286
|
+
case 'lte': return builder.lte(key, val);
|
|
287
|
+
case 'like': return builder.like(key, val);
|
|
288
|
+
case 'ilike': return builder.ilike(key, val);
|
|
289
|
+
case 'is': return builder.is(key, val);
|
|
290
|
+
case 'in': {
|
|
291
|
+
const values = val.replace(/^\(/, '').replace(/\)$/, '').split(',');
|
|
292
|
+
return builder.in(key, values);
|
|
293
|
+
}
|
|
294
|
+
default:
|
|
295
|
+
return builder.eq(key, strValue); // unknown operator → eq with full value
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
220
299
|
/**
|
|
221
300
|
* Check if a filter value is already in PostgREST format (operator.value).
|
|
222
301
|
*/
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnalyticsHeader — the analytics chrome that sits atop a CRUD ResourceList.
|
|
3
|
+
*
|
|
4
|
+
* Stats + counts-over-time (brushable) + per-dimension breakdowns for any
|
|
5
|
+
* resource that declares a `dashboard` config. Controlled: the parent owns the
|
|
6
|
+
* filter state (time range + dimension selections) so the same brush/segment
|
|
7
|
+
* that updates these charts also filters the table below. Data comes from the
|
|
8
|
+
* resource's `{prefix}DashboardData` RPC.
|
|
9
|
+
*
|
|
10
|
+
* This replaces ResourceDashboard's role: the table is now the CRUD list, not a
|
|
11
|
+
* read-only event table — the header is the analytics layer over it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { useMemo } from 'react';
|
|
15
|
+
import { View, useWindowDimensions } from 'react-native';
|
|
16
|
+
import { useContainerWidth } from '@everystack/ui/dashboard';
|
|
17
|
+
import { useRpc } from '../hooks/useRpc';
|
|
18
|
+
import { StatDials } from './dashboard/StatDials';
|
|
19
|
+
import { TimeHistogram } from './dashboard/TimeHistogram';
|
|
20
|
+
import { DimensionBars } from './dashboard/DimensionBars';
|
|
21
|
+
import { DimensionDonut } from './dashboard/DimensionDonut';
|
|
22
|
+
import type { ResourceDashboardConfig } from '../types';
|
|
23
|
+
import type { DashboardFilters, DashboardAggregates } from '../rpc/resource-dashboard-rpc';
|
|
24
|
+
|
|
25
|
+
export interface AnalyticsHeaderProps {
|
|
26
|
+
/** Resource name — determines the RPC prefix ({prefix}DashboardData). */
|
|
27
|
+
resource: string;
|
|
28
|
+
/** The resource's dashboard/analytics config. */
|
|
29
|
+
dashboard: ResourceDashboardConfig;
|
|
30
|
+
/** API base for the DashboardData RPC. */
|
|
31
|
+
baseUrl: string;
|
|
32
|
+
/** Auth token provider. */
|
|
33
|
+
getToken?: () => string | null | Promise<string | null>;
|
|
34
|
+
/** Controlled filter state (time range + dimension selections). */
|
|
35
|
+
filters: DashboardFilters;
|
|
36
|
+
/** Emitted when the brush or a dimension chip changes the filters. */
|
|
37
|
+
onFiltersChange: (filters: DashboardFilters) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toCamelCase(snake: string): string {
|
|
41
|
+
return snake.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function AnalyticsHeader({
|
|
45
|
+
resource,
|
|
46
|
+
dashboard,
|
|
47
|
+
baseUrl,
|
|
48
|
+
getToken,
|
|
49
|
+
filters,
|
|
50
|
+
onFiltersChange,
|
|
51
|
+
}: AnalyticsHeaderProps): React.ReactElement {
|
|
52
|
+
const { width: windowWidth } = useWindowDimensions();
|
|
53
|
+
const chart = useContainerWidth(Math.min(windowWidth - 48, 800));
|
|
54
|
+
|
|
55
|
+
const dashboardDataFn = `${toCamelCase(resource)}DashboardData`;
|
|
56
|
+
const hours = dashboard.defaultHours ?? 168;
|
|
57
|
+
|
|
58
|
+
const body = useMemo(() => ({ hours, filters }), [hours, filters]);
|
|
59
|
+
const { data } = useRpc<DashboardAggregates>({ baseUrl, fn: dashboardDataFn, body, getToken });
|
|
60
|
+
|
|
61
|
+
const handleTimeBrush = (extent: [string, string] | null) => {
|
|
62
|
+
const next = { ...filters };
|
|
63
|
+
if (extent) next.time = extent;
|
|
64
|
+
else delete next.time;
|
|
65
|
+
onFiltersChange(next);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const handleDimensionFilter = (column: string, value: string) => {
|
|
69
|
+
const next = { ...filters };
|
|
70
|
+
const current = (next[column] as string[] | undefined) ?? [];
|
|
71
|
+
const updated = current.includes(value)
|
|
72
|
+
? current.filter(v => v !== value)
|
|
73
|
+
: [...current, value];
|
|
74
|
+
if (updated.length === 0) delete next[column];
|
|
75
|
+
else next[column] = updated;
|
|
76
|
+
onFiltersChange(next);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const dials = useMemo(() => {
|
|
80
|
+
const stats = data?.stats;
|
|
81
|
+
const total = stats?.total ?? stats?.[dashboard.stats[0]?.id] ?? 1;
|
|
82
|
+
return dashboard.stats.map(stat => {
|
|
83
|
+
const value = stats?.[stat.id] ?? 0;
|
|
84
|
+
let ratio: number | undefined;
|
|
85
|
+
if (stat.ratio === 'of_total' && total > 0 && stat.id !== 'total') ratio = value / total;
|
|
86
|
+
else if (typeof stat.ratio === 'number') ratio = stat.ratio;
|
|
87
|
+
return { label: stat.label, value, color: stat.color, ratio };
|
|
88
|
+
});
|
|
89
|
+
}, [data?.stats, dashboard.stats]);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<View style={{ paddingHorizontal: 16, paddingTop: 8 }}>
|
|
93
|
+
<StatDials dials={dials} />
|
|
94
|
+
|
|
95
|
+
<View onLayout={chart.onLayout}>
|
|
96
|
+
<TimeHistogram
|
|
97
|
+
data={data?.timeHistogram ?? []}
|
|
98
|
+
width={chart.width}
|
|
99
|
+
title={dashboard.title ? `${dashboard.title} Over Time` : undefined}
|
|
100
|
+
onBrush={handleTimeBrush}
|
|
101
|
+
/>
|
|
102
|
+
</View>
|
|
103
|
+
|
|
104
|
+
{dashboard.dimensions.length > 0 && (
|
|
105
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 24, marginTop: 16 }}>
|
|
106
|
+
{dashboard.dimensions.map(dim => {
|
|
107
|
+
const dimData = data?.dimensions?.[dim.column] ?? [];
|
|
108
|
+
const activeFilters = (filters[dim.column] as string[] | undefined) ?? [];
|
|
109
|
+
return dim.chartType === 'donut' ? (
|
|
110
|
+
<DimensionDonut
|
|
111
|
+
key={dim.column}
|
|
112
|
+
title={dim.label}
|
|
113
|
+
data={dimData}
|
|
114
|
+
colors={dim.colors}
|
|
115
|
+
order={dim.order}
|
|
116
|
+
activeFilters={activeFilters}
|
|
117
|
+
onFilter={value => handleDimensionFilter(dim.column, value)}
|
|
118
|
+
/>
|
|
119
|
+
) : (
|
|
120
|
+
<DimensionBars
|
|
121
|
+
key={dim.column}
|
|
122
|
+
title={dim.label}
|
|
123
|
+
data={dimData}
|
|
124
|
+
colors={dim.colors}
|
|
125
|
+
activeFilters={activeFilters}
|
|
126
|
+
onFilter={value => handleDimensionFilter(dim.column, value)}
|
|
127
|
+
/>
|
|
128
|
+
);
|
|
129
|
+
})}
|
|
130
|
+
</View>
|
|
131
|
+
)}
|
|
132
|
+
</View>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -11,14 +11,17 @@ import { View, Text, Image } from 'react-native';
|
|
|
11
11
|
import { Avatar } from '@everystack/ui';
|
|
12
12
|
import { useStorageConfig } from '../hooks/useStorageConfig';
|
|
13
13
|
import { resolveImageUrl } from '../utils/resolve-image-url';
|
|
14
|
+
import { imageRenderSpec, type ImageView } from '../utils/render-resolution';
|
|
14
15
|
import type { FieldDefinition } from '../types';
|
|
15
16
|
|
|
16
17
|
interface ImageDisplayProps {
|
|
17
18
|
field: FieldDefinition;
|
|
18
19
|
value: unknown;
|
|
20
|
+
/** Which view is rendering \u2014 drives default sizing. Defaults to 'show'. */
|
|
21
|
+
view?: ImageView;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
export function ImageDisplay({ field, value }: ImageDisplayProps): React.ReactElement {
|
|
24
|
+
export function ImageDisplay({ field, value, view = 'show' }: ImageDisplayProps): React.ReactElement {
|
|
22
25
|
const storage = useStorageConfig();
|
|
23
26
|
|
|
24
27
|
if (value == null) {
|
|
@@ -26,15 +29,14 @@ export function ImageDisplay({ field, value }: ImageDisplayProps): React.ReactEl
|
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
const stringValue = String(value);
|
|
32
|
+
const spec = imageRenderSpec(field, view);
|
|
29
33
|
const uri = resolveImageUrl(stringValue, {
|
|
30
34
|
cdnUrl: storage?.cdnUrl,
|
|
31
35
|
endpoint: storage?.endpoint,
|
|
32
|
-
transform:
|
|
33
|
-
? { width: 128, height: 128, fit: 'cover' }
|
|
34
|
-
: { width: 400, height: 300, fit: 'cover' },
|
|
36
|
+
transform: spec.transform,
|
|
35
37
|
});
|
|
36
38
|
|
|
37
|
-
if (
|
|
39
|
+
if (spec.isAvatar) {
|
|
38
40
|
return (
|
|
39
41
|
<View className="flex-row items-center gap-2">
|
|
40
42
|
<Avatar uri={uri} size="lg" />
|
|
@@ -46,12 +48,14 @@ export function ImageDisplay({ field, value }: ImageDisplayProps): React.ReactEl
|
|
|
46
48
|
<View>
|
|
47
49
|
<Image
|
|
48
50
|
source={{ uri }}
|
|
49
|
-
className="
|
|
50
|
-
style={{ resizeMode: 'cover' }}
|
|
51
|
+
className="rounded-lg"
|
|
52
|
+
style={{ width: spec.width, height: spec.height, resizeMode: 'cover' }}
|
|
51
53
|
/>
|
|
52
|
-
|
|
53
|
-
{
|
|
54
|
-
|
|
54
|
+
{spec.showCaption && (
|
|
55
|
+
<Text className="text-xs text-gray-500 mt-1" numberOfLines={1}>
|
|
56
|
+
{stringValue}
|
|
57
|
+
</Text>
|
|
58
|
+
)}
|
|
55
59
|
</View>
|
|
56
60
|
);
|
|
57
61
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RowActionButton — a declarative record action, shared by the list and show
|
|
3
|
+
* views. Behavior resolves render → onPress → rpc (see actionBehavior):
|
|
4
|
+
*
|
|
5
|
+
* - render : the app owns the element; we render it as-is.
|
|
6
|
+
* - onPress : custom behavior, default button chrome + two-tap confirm.
|
|
7
|
+
* - rpc : adapter.rpc(action.rpc, { id: recordId }), same chrome.
|
|
8
|
+
*
|
|
9
|
+
* Two-tap confirm: actions with `confirm` swap their label on first press and
|
|
10
|
+
* execute on the second. Cross-platform (no Alert/window.confirm); resets after
|
|
11
|
+
* 3 seconds without the confirming press.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
15
|
+
import { View, Text } from 'react-native';
|
|
16
|
+
import { Button } from '@everystack/ui';
|
|
17
|
+
import { actionBehavior } from '../utils/render-resolution';
|
|
18
|
+
import type { RowActionConfig, ActionContext, DataRecord } from '../types';
|
|
19
|
+
import type { DataAdapter } from '../adapters/types';
|
|
20
|
+
|
|
21
|
+
export interface RowActionButtonProps {
|
|
22
|
+
action: RowActionConfig;
|
|
23
|
+
/** The full record the action operates on. */
|
|
24
|
+
record: DataRecord;
|
|
25
|
+
/** Value sent as `{ id }` to an rpc action (record[primaryKey]). */
|
|
26
|
+
recordId: string | number;
|
|
27
|
+
adapter: DataAdapter;
|
|
28
|
+
/** Re-fetch the current view after the action completes. */
|
|
29
|
+
refetch: () => Promise<void> | void;
|
|
30
|
+
/** Navigate to an admin path, when the route layer provides it. */
|
|
31
|
+
navigate?: (path: string) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function RowActionButton({
|
|
35
|
+
action,
|
|
36
|
+
record,
|
|
37
|
+
recordId,
|
|
38
|
+
adapter,
|
|
39
|
+
refetch,
|
|
40
|
+
navigate,
|
|
41
|
+
}: RowActionButtonProps): React.ReactElement | null {
|
|
42
|
+
const [confirming, setConfirming] = useState(false);
|
|
43
|
+
const [busy, setBusy] = useState(false);
|
|
44
|
+
const [error, setError] = useState<string | null>(null);
|
|
45
|
+
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
return () => {
|
|
49
|
+
if (resetTimer.current) clearTimeout(resetTimer.current);
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const behavior = actionBehavior(action);
|
|
54
|
+
|
|
55
|
+
const execute = useCallback(async () => {
|
|
56
|
+
setBusy(true);
|
|
57
|
+
setError(null);
|
|
58
|
+
try {
|
|
59
|
+
if (action.onPress) {
|
|
60
|
+
await action.onPress({ record, adapter, refetch, navigate });
|
|
61
|
+
} else if (action.rpc) {
|
|
62
|
+
if (!adapter.rpc) throw new Error('Adapter does not support RPC actions');
|
|
63
|
+
await adapter.rpc(action.rpc, { id: recordId });
|
|
64
|
+
}
|
|
65
|
+
await refetch();
|
|
66
|
+
} catch (err) {
|
|
67
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
68
|
+
} finally {
|
|
69
|
+
setBusy(false);
|
|
70
|
+
setConfirming(false);
|
|
71
|
+
}
|
|
72
|
+
}, [action, record, recordId, adapter, refetch, navigate]);
|
|
73
|
+
|
|
74
|
+
const handlePress = useCallback(() => {
|
|
75
|
+
if (busy) return;
|
|
76
|
+
if (action.confirm && !confirming) {
|
|
77
|
+
setConfirming(true);
|
|
78
|
+
if (resetTimer.current) clearTimeout(resetTimer.current);
|
|
79
|
+
resetTimer.current = setTimeout(() => setConfirming(false), 3000);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (resetTimer.current) clearTimeout(resetTimer.current);
|
|
83
|
+
execute();
|
|
84
|
+
}, [busy, action.confirm, confirming, execute]);
|
|
85
|
+
|
|
86
|
+
// Fully custom element — the app owns chrome and behavior.
|
|
87
|
+
if (behavior === 'render') {
|
|
88
|
+
return <>{action.render!({ record, adapter, refetch, navigate })}</>;
|
|
89
|
+
}
|
|
90
|
+
// Misconfigured action (no rpc/onPress/render) renders nothing.
|
|
91
|
+
if (behavior === 'none') return null;
|
|
92
|
+
|
|
93
|
+
const buttonLabel = busy ? '…' : confirming ? (action.confirm ?? action.label) : action.label;
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<View>
|
|
97
|
+
<Button
|
|
98
|
+
label={buttonLabel}
|
|
99
|
+
variant={action.variant ?? 'outline'}
|
|
100
|
+
onPress={handlePress}
|
|
101
|
+
/>
|
|
102
|
+
{error && (
|
|
103
|
+
<Text className="text-xs text-red-600 mt-1" numberOfLines={2}>
|
|
104
|
+
{error}
|
|
105
|
+
</Text>
|
|
106
|
+
)}
|
|
107
|
+
</View>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TipTap editor adapter — a reference EditorRegistry plugin (HTML).
|
|
3
|
+
*
|
|
4
|
+
* Opt-in and web-only. The core admin never imports this; tiptap is an
|
|
5
|
+
* optional peer dependency. To use:
|
|
6
|
+
*
|
|
7
|
+
* pnpm add @tiptap/react @tiptap/starter-kit @tiptap/pm
|
|
8
|
+
* import { TiptapEditor } from '@everystack/admin/editors/tiptap';
|
|
9
|
+
* <AdminShell editors={{ tiptap: TiptapEditor }} ... />
|
|
10
|
+
* // field: { name: 'body', type: 'html', editor: 'tiptap' }
|
|
11
|
+
*
|
|
12
|
+
* Reads and writes an HTML string via the EditorProps contract. The toolbar
|
|
13
|
+
* uses React Native primitives; the editable surface is tiptap's EditorContent
|
|
14
|
+
* (a DOM contenteditable, which is why this is web-only). See
|
|
15
|
+
* docs/plans/admin-editors.md.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import React from 'react';
|
|
19
|
+
import { View, Text, Pressable } from 'react-native';
|
|
20
|
+
import { useEditor, EditorContent } from '@tiptap/react';
|
|
21
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
22
|
+
import type { EditorProps } from '../types';
|
|
23
|
+
|
|
24
|
+
interface ToolbarButtonProps {
|
|
25
|
+
label: string;
|
|
26
|
+
active?: boolean;
|
|
27
|
+
onPress: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ToolbarButton({ label, active, onPress }: ToolbarButtonProps): React.ReactElement {
|
|
31
|
+
return (
|
|
32
|
+
<Pressable
|
|
33
|
+
onPress={onPress}
|
|
34
|
+
className={active ? 'px-2 py-1 rounded bg-blue-100' : 'px-2 py-1 rounded border border-gray-200'}
|
|
35
|
+
>
|
|
36
|
+
<Text className={active ? 'text-sm text-blue-700 font-semibold' : 'text-sm text-gray-700'}>
|
|
37
|
+
{label}
|
|
38
|
+
</Text>
|
|
39
|
+
</Pressable>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function TiptapEditor({ value, onChange, error }: EditorProps): React.ReactElement {
|
|
44
|
+
const editor = useEditor({
|
|
45
|
+
extensions: [StarterKit],
|
|
46
|
+
content: typeof value === 'string' ? value : '',
|
|
47
|
+
// Render on the client only — the admin shell SSRs, and a contenteditable
|
|
48
|
+
// can't hydrate from server output.
|
|
49
|
+
immediatelyRender: false,
|
|
50
|
+
onUpdate: ({ editor }) => onChange(editor.getHTML()),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<View>
|
|
55
|
+
<View className="flex-row flex-wrap gap-1 mb-2">
|
|
56
|
+
<ToolbarButton label="B" active={editor?.isActive('bold')} onPress={() => editor?.chain().focus().toggleBold().run()} />
|
|
57
|
+
<ToolbarButton label="I" active={editor?.isActive('italic')} onPress={() => editor?.chain().focus().toggleItalic().run()} />
|
|
58
|
+
<ToolbarButton label="H2" active={editor?.isActive('heading', { level: 2 })} onPress={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} />
|
|
59
|
+
<ToolbarButton label="List" active={editor?.isActive('bulletList')} onPress={() => editor?.chain().focus().toggleBulletList().run()} />
|
|
60
|
+
<ToolbarButton label="1." active={editor?.isActive('orderedList')} onPress={() => editor?.chain().focus().toggleOrderedList().run()} />
|
|
61
|
+
<ToolbarButton label="Quote" active={editor?.isActive('blockquote')} onPress={() => editor?.chain().focus().toggleBlockquote().run()} />
|
|
62
|
+
</View>
|
|
63
|
+
<View className="border border-gray-200 rounded-lg px-3 py-2">
|
|
64
|
+
<EditorContent editor={editor} />
|
|
65
|
+
</View>
|
|
66
|
+
{error ? <Text className="text-xs text-red-600 mt-1">{error}</Text> : null}
|
|
67
|
+
</View>
|
|
68
|
+
);
|
|
69
|
+
}
|