@everystack/admin 0.2.2 → 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/rpc/resource-dashboard-rpc.ts +59 -0
- 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 +31 -78
- package/src/screens/index.ts +6 -0
- package/src/types.ts +129 -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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdminRouter
|
|
3
|
+
*
|
|
4
|
+
* Router-agnostic CRUD dispatcher. The app's catch-all route hands it the
|
|
5
|
+
* current `pathname` and a `{ push }` port (e.g. expo-router's useRouter()),
|
|
6
|
+
* and AdminRouter renders the right screen and wires its navigation — so apps
|
|
7
|
+
* no longer hand-write a route file per screen. The package never imports a
|
|
8
|
+
* router. See docs/plans/admin-routing.md.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React, { useMemo } from 'react';
|
|
12
|
+
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
13
|
+
import { useDataAdapter } from '../hooks/useAdminData';
|
|
14
|
+
import { ViewStateContext, normalizeParams, buildQueryString } from '../hooks/useViewState';
|
|
15
|
+
import { AdminDashboard } from './AdminDashboard';
|
|
16
|
+
import { ResourceListView } from './ResourceListView';
|
|
17
|
+
import { ResourceShow } from './ResourceShow';
|
|
18
|
+
import { ResourceCreate } from './ResourceCreate';
|
|
19
|
+
import { ResourceEdit } from './ResourceEdit';
|
|
20
|
+
import { resolveAdminRoute, adminHref, pathToSegments, type AdminHrefParts } from '../utils/admin-routing';
|
|
21
|
+
|
|
22
|
+
/** Minimal navigation port. expo-router's useRouter() satisfies it as-is. */
|
|
23
|
+
export interface AdminNavigator {
|
|
24
|
+
push: (href: string) => void;
|
|
25
|
+
back?: () => void;
|
|
26
|
+
replace?: (href: string) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AdminRouterProps {
|
|
30
|
+
/** Navigation port — the app's router (expo-router's useRouter() fits). */
|
|
31
|
+
router: AdminNavigator;
|
|
32
|
+
/** Current path, e.g. expo-router's usePathname(). */
|
|
33
|
+
pathname: string;
|
|
34
|
+
/** Current query params, e.g. expo-router's useLocalSearchParams(). */
|
|
35
|
+
searchParams?: Record<string, string | string[] | undefined>;
|
|
36
|
+
/** Mount point of the admin. Default '/admin'. */
|
|
37
|
+
basePath?: string;
|
|
38
|
+
/** API base for resources with a `dashboard` config (observability screens). */
|
|
39
|
+
baseUrl?: string;
|
|
40
|
+
/** Token getter for those dashboard RPCs. */
|
|
41
|
+
getToken?: () => string | null | Promise<string | null>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function AdminRouter({
|
|
45
|
+
router,
|
|
46
|
+
pathname,
|
|
47
|
+
searchParams,
|
|
48
|
+
basePath = '/admin',
|
|
49
|
+
baseUrl,
|
|
50
|
+
getToken,
|
|
51
|
+
}: AdminRouterProps): React.ReactElement {
|
|
52
|
+
const config = useAdminConfig();
|
|
53
|
+
const adapter = useDataAdapter();
|
|
54
|
+
|
|
55
|
+
const match = resolveAdminRoute(pathToSegments(pathname, basePath));
|
|
56
|
+
const go = (parts: AdminHrefParts) => router.push(adminHref(basePath, parts));
|
|
57
|
+
|
|
58
|
+
// View state ⇄ URL query: screens read the current params and push merged
|
|
59
|
+
// updates, preserving the path. Powers shareable filtered/scrubbed views.
|
|
60
|
+
const viewState = useMemo(() => {
|
|
61
|
+
const params = normalizeParams(searchParams);
|
|
62
|
+
return {
|
|
63
|
+
params,
|
|
64
|
+
setParams: (next: Record<string, string | undefined>) => {
|
|
65
|
+
const merged: Record<string, string | undefined> = { ...params, ...next };
|
|
66
|
+
(router.replace ?? router.push)(`${pathname}${buildQueryString(merged)}`);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
}, [JSON.stringify(searchParams), pathname, router]);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<ViewStateContext.Provider value={viewState}>
|
|
74
|
+
{renderScreen()}
|
|
75
|
+
</ViewStateContext.Provider>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
function renderScreen(): React.ReactElement {
|
|
79
|
+
|
|
80
|
+
switch (match.screen) {
|
|
81
|
+
case 'dashboard':
|
|
82
|
+
return <AdminDashboard />;
|
|
83
|
+
|
|
84
|
+
case 'create':
|
|
85
|
+
return (
|
|
86
|
+
<ResourceCreate
|
|
87
|
+
resource={match.resource!}
|
|
88
|
+
onSuccess={record => go({ resource: match.resource, id: record.id as string | number })}
|
|
89
|
+
onCancel={() => go({ resource: match.resource })}
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
case 'edit':
|
|
94
|
+
return (
|
|
95
|
+
<ResourceEdit
|
|
96
|
+
resource={match.resource!}
|
|
97
|
+
id={match.id!}
|
|
98
|
+
onSuccess={() => go({ resource: match.resource, id: match.id })}
|
|
99
|
+
onCancel={() => go({ resource: match.resource, id: match.id })}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
case 'show':
|
|
104
|
+
return (
|
|
105
|
+
<ResourceShow
|
|
106
|
+
resource={match.resource!}
|
|
107
|
+
id={match.id!}
|
|
108
|
+
onEditPress={() => go({ resource: match.resource, id: match.id, action: 'edit' })}
|
|
109
|
+
onBackPress={() => go({ resource: match.resource })}
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
case 'list':
|
|
114
|
+
default: {
|
|
115
|
+
const resourceConfig = config.resources.find(r => r.name === match.resource);
|
|
116
|
+
const primaryKey = resourceConfig?.primaryKey ?? 'id';
|
|
117
|
+
const idOf = (record: Record<string, unknown>) =>
|
|
118
|
+
(record[primaryKey] as string | number) ?? (record.id as string | number);
|
|
119
|
+
const toShow = (record: Record<string, unknown>) =>
|
|
120
|
+
go({ resource: match.resource, id: idOf(record) });
|
|
121
|
+
|
|
122
|
+
// One unified view: always a CRUD list, with an optional analytics header
|
|
123
|
+
// when the resource declares a `dashboard` config and a baseUrl is supplied.
|
|
124
|
+
return (
|
|
125
|
+
<ResourceListView
|
|
126
|
+
resource={match.resource!}
|
|
127
|
+
baseUrl={baseUrl}
|
|
128
|
+
getToken={getToken}
|
|
129
|
+
onRowPress={toShow}
|
|
130
|
+
onEditPress={record => go({ resource: match.resource, id: idOf(record), action: 'edit' })}
|
|
131
|
+
onCreatePress={() => go({ resource: match.resource, action: 'create' })}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdminShell
|
|
3
|
+
*
|
|
4
|
+
* The admin chrome, router-agnostic. Wraps the providers (AdminRoot) and the
|
|
5
|
+
* sidebar layout (AdminLayout), wiring the menu's navigation to the app's
|
|
6
|
+
* router port and highlighting the active item from the current path. The app
|
|
7
|
+
* renders this once in its admin _layout with `<Slot/>` as children.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import type { AdminConfig, MenuItem, EditorRegistry } from '../types';
|
|
12
|
+
import type { DataAdapter } from '../adapters/types';
|
|
13
|
+
import { AdminRoot } from '../AdminRoot';
|
|
14
|
+
import { AdminLayout } from './AdminLayout';
|
|
15
|
+
import { adminHref, pathToSegments, resolveAdminRoute } from '../utils/admin-routing';
|
|
16
|
+
import { buildBreadcrumbs } from '../utils/breadcrumbs';
|
|
17
|
+
import type { AdminNavigator } from './AdminRouter';
|
|
18
|
+
|
|
19
|
+
export interface AdminShellProps {
|
|
20
|
+
config: AdminConfig;
|
|
21
|
+
adapter: DataAdapter;
|
|
22
|
+
/** Navigation port — the app's router (expo-router's useRouter() fits). */
|
|
23
|
+
router: AdminNavigator;
|
|
24
|
+
/** Current path, e.g. expo-router's usePathname(). */
|
|
25
|
+
pathname: string;
|
|
26
|
+
/** Editor adapters available to fields via `field.editor` (opt-in plugins). */
|
|
27
|
+
editors?: EditorRegistry;
|
|
28
|
+
/** Mount point of the admin. Default '/admin'. */
|
|
29
|
+
basePath?: string;
|
|
30
|
+
children?: React.ReactNode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function AdminShell({
|
|
34
|
+
config,
|
|
35
|
+
adapter,
|
|
36
|
+
router,
|
|
37
|
+
pathname,
|
|
38
|
+
editors,
|
|
39
|
+
basePath = '/admin',
|
|
40
|
+
children,
|
|
41
|
+
}: AdminShellProps): React.ReactElement {
|
|
42
|
+
const segments = pathToSegments(pathname, basePath);
|
|
43
|
+
const activeItem = segments[0] ?? config.dashboards?.[0]?.id ?? config.menu?.[0]?.id;
|
|
44
|
+
const match = resolveAdminRoute(segments);
|
|
45
|
+
const breadcrumbs = buildBreadcrumbs(config, match, activeItem, basePath);
|
|
46
|
+
|
|
47
|
+
const onNavigate = (item: MenuItem) => {
|
|
48
|
+
if (item.path === '#') return; // divider / non-navigable
|
|
49
|
+
if (item.dashboard) router.push(basePath);
|
|
50
|
+
else if (item.resource) router.push(adminHref(basePath, { resource: item.resource }));
|
|
51
|
+
else if (item.path) router.push(item.path);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<AdminRoot config={config} adapter={adapter} editors={editors}>
|
|
56
|
+
<AdminLayout
|
|
57
|
+
activeItem={activeItem}
|
|
58
|
+
onNavigate={onNavigate}
|
|
59
|
+
breadcrumbs={breadcrumbs}
|
|
60
|
+
onNavigatePath={path => router.push(path)}
|
|
61
|
+
>
|
|
62
|
+
{children}
|
|
63
|
+
</AdminLayout>
|
|
64
|
+
</AdminRoot>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -9,6 +9,8 @@ import React, { useState, useCallback, useMemo } 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
|
+
import { useEditors } from '../hooks/useEditors';
|
|
13
|
+
import { editorFor } from '../utils/render-resolution';
|
|
12
14
|
import { FormField, TextInput, SelectInput, SwitchInput, Button, ErrorState } from '@everystack/ui';
|
|
13
15
|
import { ImageFieldInput } from '../components/ImageFieldInput';
|
|
14
16
|
import { AvatarFieldInput } from '../components/AvatarFieldInput';
|
|
@@ -134,6 +136,17 @@ interface FieldInputProps {
|
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
function FieldInput({ field, value, error, onChange }: FieldInputProps): React.ReactElement {
|
|
139
|
+
const editors = useEditors();
|
|
140
|
+
|
|
141
|
+
// Resolution: inline input → registered editor → type-based default.
|
|
142
|
+
if (field.input) {
|
|
143
|
+
return <>{field.input({ value, onChange, field, error })}</>;
|
|
144
|
+
}
|
|
145
|
+
const Editor = editorFor(field, editors);
|
|
146
|
+
if (Editor) {
|
|
147
|
+
return <>{Editor({ value, onChange, field, format: field.format ?? 'html', error })}</>;
|
|
148
|
+
}
|
|
149
|
+
|
|
137
150
|
const label = field.label ?? field.name;
|
|
138
151
|
const required = field.validation?.required;
|
|
139
152
|
|
|
@@ -9,6 +9,8 @@ import React, { useState, useEffect, useCallback, useMemo } 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
|
+
import { useEditors } from '../hooks/useEditors';
|
|
13
|
+
import { editorFor } from '../utils/render-resolution';
|
|
12
14
|
import { FormField, TextInput, SelectInput, SwitchInput, Button, LoadingState, ErrorState } from '@everystack/ui';
|
|
13
15
|
import { ImageFieldInput } from '../components/ImageFieldInput';
|
|
14
16
|
import { AvatarFieldInput } from '../components/AvatarFieldInput';
|
|
@@ -158,6 +160,7 @@ export function ResourceEdit({ resource, id, onSuccess, onCancel }: ResourceEdit
|
|
|
158
160
|
value={formData[field.name]}
|
|
159
161
|
error={errors[field.name]}
|
|
160
162
|
onChange={value => setFieldValue(field.name, value)}
|
|
163
|
+
record={record}
|
|
161
164
|
/>
|
|
162
165
|
))}
|
|
163
166
|
</View>
|
|
@@ -174,9 +177,21 @@ interface EditFieldInputProps {
|
|
|
174
177
|
value: unknown;
|
|
175
178
|
error?: string;
|
|
176
179
|
onChange: (value: unknown) => void;
|
|
180
|
+
record?: DataRecord;
|
|
177
181
|
}
|
|
178
182
|
|
|
179
|
-
function EditFieldInput({ field, value, error, onChange }: EditFieldInputProps): React.ReactElement {
|
|
183
|
+
function EditFieldInput({ field, value, error, onChange, record }: EditFieldInputProps): React.ReactElement {
|
|
184
|
+
const editors = useEditors();
|
|
185
|
+
|
|
186
|
+
// Resolution: inline input → registered editor → type-based default.
|
|
187
|
+
if (field.input) {
|
|
188
|
+
return <>{field.input({ value, onChange, field, record, error })}</>;
|
|
189
|
+
}
|
|
190
|
+
const Editor = editorFor(field, editors);
|
|
191
|
+
if (Editor) {
|
|
192
|
+
return <>{Editor({ value, onChange, field, format: field.format ?? 'html', error })}</>;
|
|
193
|
+
}
|
|
194
|
+
|
|
180
195
|
const label = field.label ?? field.name;
|
|
181
196
|
const required = field.validation?.required;
|
|
182
197
|
|
|
@@ -8,22 +8,36 @@
|
|
|
8
8
|
import React, { useCallback, useMemo } from 'react';
|
|
9
9
|
import { View, Text } from 'react-native';
|
|
10
10
|
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
11
|
-
import { useAdminData } from '../hooks/useAdminData';
|
|
11
|
+
import { useAdminData, useDataAdapter } from '../hooks/useAdminData';
|
|
12
12
|
import { DataTable, FilterBar, PaginationBar, LoadingState, ErrorState, EmptyState, Button, type DataTableColumn } from '@everystack/ui';
|
|
13
13
|
import { formatCellValue } from '../utils/format-cell-value';
|
|
14
|
-
import
|
|
14
|
+
import { cellRenderKind } from '../utils/render-resolution';
|
|
15
|
+
import { visibleRowActions } from '../utils/row-actions';
|
|
16
|
+
import { standardRowActions } from '../utils/standard-actions';
|
|
17
|
+
import { ImageDisplay } from '../components/ImageDisplay';
|
|
18
|
+
import { RowActionButton } from '../components/RowActionButton';
|
|
19
|
+
import type { DataAdapter } from '../adapters/types';
|
|
20
|
+
import type { ResourceConfig, FieldDefinition, DataRecord, RowActionConfig } from '../types';
|
|
15
21
|
|
|
16
22
|
export interface ResourceListProps {
|
|
17
23
|
/** Resource name (matches ResourceConfig.name) */
|
|
18
24
|
resource: string;
|
|
19
|
-
/** Called when a row is pressed */
|
|
25
|
+
/** Called when a row is pressed — also the built-in "Show" action target */
|
|
20
26
|
onRowPress?: (record: Record<string, unknown>) => void;
|
|
27
|
+
/** Called when a row's built-in "Edit" action is pressed */
|
|
28
|
+
onEditPress?: (record: Record<string, unknown>) => void;
|
|
21
29
|
/** Called when the create button is pressed */
|
|
22
30
|
onCreatePress?: () => void;
|
|
31
|
+
/**
|
|
32
|
+
* Reactive filters merged beneath the search filter (e.g. an analytics-header
|
|
33
|
+
* time range or segment). Memoize at the call site.
|
|
34
|
+
*/
|
|
35
|
+
baseFilter?: Record<string, unknown>;
|
|
23
36
|
}
|
|
24
37
|
|
|
25
|
-
export function ResourceList({ resource, onRowPress, onCreatePress }: ResourceListProps): React.ReactElement {
|
|
38
|
+
export function ResourceList({ resource, onRowPress, onEditPress, onCreatePress, baseFilter }: ResourceListProps): React.ReactElement {
|
|
26
39
|
const config = useAdminConfig();
|
|
40
|
+
const adapter = useDataAdapter();
|
|
27
41
|
const resourceConfig = config.resources.find(r => r.name === resource);
|
|
28
42
|
|
|
29
43
|
const defaultSort = resourceConfig?.defaultSort;
|
|
@@ -41,13 +55,23 @@ export function ResourceList({ resource, onRowPress, onCreatePress }: ResourceLi
|
|
|
41
55
|
} = useAdminData(resource, {
|
|
42
56
|
sort: defaultSort?.field,
|
|
43
57
|
order: defaultSort?.order,
|
|
58
|
+
baseFilter,
|
|
44
59
|
});
|
|
45
60
|
|
|
46
|
-
// Build columns from field definitions
|
|
61
|
+
// Build columns from field definitions, plus an actions column combining the
|
|
62
|
+
// built-in Show/Edit/Delete (gated by actions.*) with any custom rowActions.
|
|
47
63
|
const columns = useMemo(() => {
|
|
48
64
|
if (!resourceConfig) return [];
|
|
49
|
-
|
|
50
|
-
|
|
65
|
+
const cols = fieldsToColumns(resourceConfig);
|
|
66
|
+
const actions = [
|
|
67
|
+
...standardRowActions(resourceConfig, { onShow: onRowPress, onEdit: onEditPress }),
|
|
68
|
+
...(resourceConfig.rowActions ?? []),
|
|
69
|
+
];
|
|
70
|
+
if (actions.length) {
|
|
71
|
+
cols.push(actionsColumn(resourceConfig, actions, adapter, refetch));
|
|
72
|
+
}
|
|
73
|
+
return cols;
|
|
74
|
+
}, [resourceConfig, adapter, refetch, onRowPress, onEditPress]);
|
|
51
75
|
|
|
52
76
|
// Build filter chips from filterable fields
|
|
53
77
|
const filterChips = useMemo(() => {
|
|
@@ -135,11 +159,7 @@ export function ResourceList({ resource, onRowPress, onCreatePress }: ResourceLi
|
|
|
135
159
|
// Helpers
|
|
136
160
|
// =============================================================================
|
|
137
161
|
|
|
138
|
-
function fieldsToColumns(
|
|
139
|
-
resourceConfig: ResourceConfig,
|
|
140
|
-
_sortField?: string,
|
|
141
|
-
_sortOrder?: 'asc' | 'desc'
|
|
142
|
-
): DataTableColumn[] {
|
|
162
|
+
function fieldsToColumns(resourceConfig: ResourceConfig): DataTableColumn[] {
|
|
143
163
|
const fieldNames = resourceConfig.listFields ?? resourceConfig.fields.map(f => f.name);
|
|
144
164
|
const fieldMap = new Map(resourceConfig.fields.map(f => [f.name, f]));
|
|
145
165
|
|
|
@@ -150,10 +170,61 @@ function fieldsToColumns(
|
|
|
150
170
|
key: field.name,
|
|
151
171
|
label: field.label ?? field.name,
|
|
152
172
|
sortable: field.sortable,
|
|
153
|
-
render: (
|
|
173
|
+
render: cellRenderer(field),
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Cell rendering precedence: custom render → image thumbnail → type text.
|
|
179
|
+
* (See render-resolution.cellRenderKind.)
|
|
180
|
+
*/
|
|
181
|
+
function cellRenderer(field: FieldDefinition): DataTableColumn['render'] {
|
|
182
|
+
switch (cellRenderKind(field)) {
|
|
183
|
+
case 'custom':
|
|
184
|
+
return (value, record) => field.render!(value, record as DataRecord, { view: 'list' });
|
|
185
|
+
case 'image':
|
|
186
|
+
return (value) => <ImageDisplay field={field} value={value} view="list" />;
|
|
187
|
+
default:
|
|
188
|
+
return (value) => (
|
|
154
189
|
<Text className="text-sm text-gray-900" numberOfLines={1}>
|
|
155
190
|
{formatCellValue(field, value)}
|
|
156
191
|
</Text>
|
|
157
|
-
)
|
|
158
|
-
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A trailing column of per-row actions, each gated by its `when` clause.
|
|
198
|
+
* The cell claims the press so tapping an action doesn't also fire the row's
|
|
199
|
+
* onRowPress (navigate-to-detail).
|
|
200
|
+
*/
|
|
201
|
+
function actionsColumn(
|
|
202
|
+
resourceConfig: ResourceConfig,
|
|
203
|
+
actions: RowActionConfig[],
|
|
204
|
+
adapter: DataAdapter,
|
|
205
|
+
refetch: () => Promise<void> | void,
|
|
206
|
+
): DataTableColumn {
|
|
207
|
+
const primaryKey = resourceConfig.primaryKey ?? 'id';
|
|
208
|
+
return {
|
|
209
|
+
key: '__actions',
|
|
210
|
+
label: 'Actions',
|
|
211
|
+
render: (_value, record) => {
|
|
212
|
+
const visible = visibleRowActions(actions, record);
|
|
213
|
+
if (!visible.length) return null;
|
|
214
|
+
return (
|
|
215
|
+
<View className="flex-row gap-2" onStartShouldSetResponder={() => true}>
|
|
216
|
+
{visible.map(action => (
|
|
217
|
+
<RowActionButton
|
|
218
|
+
key={action.id}
|
|
219
|
+
action={action}
|
|
220
|
+
record={record as DataRecord}
|
|
221
|
+
recordId={(record[primaryKey] as string | number) ?? (record.id as string | number)}
|
|
222
|
+
adapter={adapter}
|
|
223
|
+
refetch={refetch}
|
|
224
|
+
/>
|
|
225
|
+
))}
|
|
226
|
+
</View>
|
|
227
|
+
);
|
|
228
|
+
},
|
|
229
|
+
};
|
|
159
230
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ResourceListView — the unified resource screen.
|
|
3
|
+
*
|
|
4
|
+
* Always a CRUD ResourceList (all records, paginated, Add/Edit/Delete). When the
|
|
5
|
+
* resource declares a `dashboard` config and the app supplied a baseUrl, an
|
|
6
|
+
* AnalyticsHeader (stats + counts-over-time + segments) renders on top, and its
|
|
7
|
+
* brush/segment selections filter the table beneath — one view, not two.
|
|
8
|
+
*
|
|
9
|
+
* Filter state lives in the URL query, so a filtered/scrubbed view is a direct
|
|
10
|
+
* link and drill-downs work (`/admin/log_events?level=error`, `?from=&to=`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import React, { useMemo } from 'react';
|
|
14
|
+
import { View } from 'react-native';
|
|
15
|
+
import { useAdminConfig } from '../hooks/useAdminConfig';
|
|
16
|
+
import { useViewState } from '../hooks/useViewState';
|
|
17
|
+
import { ResourceList } from './ResourceList';
|
|
18
|
+
import { AnalyticsHeader } from '../components/AnalyticsHeader';
|
|
19
|
+
import { paramsToFilters, filtersToParams, filtersToBaseFilter, paramsToColumnFilters } from '../utils/view-filters';
|
|
20
|
+
|
|
21
|
+
export interface ResourceListViewProps {
|
|
22
|
+
resource: string;
|
|
23
|
+
baseUrl?: string;
|
|
24
|
+
getToken?: () => string | null | Promise<string | null>;
|
|
25
|
+
onRowPress?: (record: Record<string, unknown>) => void;
|
|
26
|
+
onEditPress?: (record: Record<string, unknown>) => void;
|
|
27
|
+
onCreatePress?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ResourceListView({
|
|
31
|
+
resource,
|
|
32
|
+
baseUrl,
|
|
33
|
+
getToken,
|
|
34
|
+
onRowPress,
|
|
35
|
+
onEditPress,
|
|
36
|
+
onCreatePress,
|
|
37
|
+
}: ResourceListViewProps): React.ReactElement {
|
|
38
|
+
const config = useAdminConfig();
|
|
39
|
+
const resourceConfig = config.resources.find(r => r.name === resource);
|
|
40
|
+
const dashboard = resourceConfig?.dashboard;
|
|
41
|
+
const { params, setParams } = useViewState();
|
|
42
|
+
|
|
43
|
+
const dimensions = dashboard?.dimensions ?? [];
|
|
44
|
+
const filters = useMemo(() => paramsToFilters(params, dimensions), [params, dimensions]);
|
|
45
|
+
|
|
46
|
+
// Table filters = analytics filters (time + dimensions) plus pass-through
|
|
47
|
+
// column filters from the URL (drill-downs by any resource field).
|
|
48
|
+
const baseFilter = useMemo(() => {
|
|
49
|
+
const fromAnalytics = dashboard ? filtersToBaseFilter(filters, dashboard) : undefined;
|
|
50
|
+
const fieldNames = new Set((resourceConfig?.fields ?? []).map(f => f.name));
|
|
51
|
+
const exclude = new Set(dimensions.map(d => d.column));
|
|
52
|
+
const passthrough = paramsToColumnFilters(params, fieldNames, exclude);
|
|
53
|
+
const merged = { ...fromAnalytics, ...passthrough };
|
|
54
|
+
return Object.keys(merged).length ? merged : undefined;
|
|
55
|
+
}, [filters, dashboard, params, resourceConfig, dimensions]);
|
|
56
|
+
|
|
57
|
+
const showHeader = Boolean(dashboard && baseUrl);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<View className="flex-1">
|
|
61
|
+
{showHeader && (
|
|
62
|
+
<AnalyticsHeader
|
|
63
|
+
resource={resource}
|
|
64
|
+
dashboard={dashboard!}
|
|
65
|
+
baseUrl={baseUrl!}
|
|
66
|
+
getToken={getToken}
|
|
67
|
+
filters={filters}
|
|
68
|
+
onFiltersChange={next => setParams(filtersToParams(next, dimensions))}
|
|
69
|
+
/>
|
|
70
|
+
)}
|
|
71
|
+
<View className="flex-1">
|
|
72
|
+
<ResourceList
|
|
73
|
+
resource={resource}
|
|
74
|
+
baseFilter={baseFilter}
|
|
75
|
+
onRowPress={onRowPress}
|
|
76
|
+
onEditPress={onEditPress}
|
|
77
|
+
onCreatePress={onCreatePress}
|
|
78
|
+
/>
|
|
79
|
+
</View>
|
|
80
|
+
</View>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
* Fetches via adapter.fetchOne() and displays field values.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React, { useState, useEffect, useCallback
|
|
8
|
+
import React, { useState, useEffect, useCallback } 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 { RowActionButton } from '../components/RowActionButton';
|
|
14
15
|
import { visibleRowActions } from '../utils/row-actions';
|
|
15
|
-
import
|
|
16
|
+
import { toCamelCase } from '../utils/naming';
|
|
17
|
+
import type { FieldDefinition, DataRecord, ResourceConfig } from '../types';
|
|
16
18
|
import type { DataAdapter } from '../adapters/types';
|
|
17
19
|
|
|
18
20
|
export interface ResourceShowProps {
|
|
@@ -47,14 +49,13 @@ export function ResourceShow({
|
|
|
47
49
|
setLoading(true);
|
|
48
50
|
setError(null);
|
|
49
51
|
try {
|
|
50
|
-
|
|
51
|
-
setRecord(result);
|
|
52
|
+
setRecord(await fetchDetail(adapter, resource, id, resourceConfig));
|
|
52
53
|
} catch (err) {
|
|
53
54
|
setError(err instanceof Error ? err : new Error(String(err)));
|
|
54
55
|
} finally {
|
|
55
56
|
setLoading(false);
|
|
56
57
|
}
|
|
57
|
-
}, [adapter, resource, id]);
|
|
58
|
+
}, [adapter, resource, id, resourceConfig]);
|
|
58
59
|
|
|
59
60
|
useEffect(() => {
|
|
60
61
|
fetchRecord();
|
|
@@ -89,7 +90,7 @@ export function ResourceShow({
|
|
|
89
90
|
<View className="flex-row items-center justify-between px-4 py-4">
|
|
90
91
|
<View className="flex-row items-center">
|
|
91
92
|
{onBackPress && (
|
|
92
|
-
<Button label="
|
|
93
|
+
<Button label="← Back" variant="ghost" onPress={onBackPress} style={{ marginRight: 8 }} />
|
|
93
94
|
)}
|
|
94
95
|
<Text className="text-xl font-bold text-gray-900">
|
|
95
96
|
{label} #{String(id)}
|
|
@@ -100,9 +101,10 @@ export function ResourceShow({
|
|
|
100
101
|
<RowActionButton
|
|
101
102
|
key={action.id}
|
|
102
103
|
action={action}
|
|
104
|
+
record={record}
|
|
103
105
|
recordId={id}
|
|
104
106
|
adapter={adapter}
|
|
105
|
-
|
|
107
|
+
refetch={fetchRecord}
|
|
106
108
|
/>
|
|
107
109
|
))}
|
|
108
110
|
{canEdit && onEditPress && (
|
|
@@ -123,7 +125,7 @@ export function ResourceShow({
|
|
|
123
125
|
return (
|
|
124
126
|
<React.Fragment key={fieldName}>
|
|
125
127
|
{idx > 0 && <Divider />}
|
|
126
|
-
<FieldRow field={fieldDef} value={value} />
|
|
128
|
+
<FieldRow field={fieldDef} value={value} record={record} />
|
|
127
129
|
</React.Fragment>
|
|
128
130
|
);
|
|
129
131
|
})}
|
|
@@ -133,79 +135,27 @@ export function ResourceShow({
|
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
// =============================================================================
|
|
136
|
-
//
|
|
138
|
+
// Detail fetching
|
|
137
139
|
// =============================================================================
|
|
138
140
|
|
|
139
|
-
interface RowActionButtonProps {
|
|
140
|
-
action: RowActionConfig;
|
|
141
|
-
recordId: string | number;
|
|
142
|
-
adapter: DataAdapter;
|
|
143
|
-
onDone: () => Promise<void> | void;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
141
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
142
|
+
* detailVia: 'rpc' fetches through the role-gated {prefix}EventDetail RPC —
|
|
143
|
+
* the resource never enters the REST surface (telemetry tables stay out of
|
|
144
|
+
* exposedTables). Everything else uses adapter.fetchOne as before.
|
|
150
145
|
*/
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return () => {
|
|
159
|
-
if (resetTimer.current) clearTimeout(resetTimer.current);
|
|
160
|
-
};
|
|
161
|
-
}, []);
|
|
162
|
-
|
|
163
|
-
const execute = useCallback(async () => {
|
|
146
|
+
async function fetchDetail(
|
|
147
|
+
adapter: DataAdapter,
|
|
148
|
+
resource: string,
|
|
149
|
+
id: string | number,
|
|
150
|
+
resourceConfig: ResourceConfig | undefined,
|
|
151
|
+
): Promise<DataRecord> {
|
|
152
|
+
if (resourceConfig?.dashboard?.detailVia === 'rpc') {
|
|
164
153
|
if (!adapter.rpc) {
|
|
165
|
-
|
|
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);
|
|
154
|
+
throw new Error(`Resource "${resource}" uses detailVia: 'rpc' but the adapter has no rpc()`);
|
|
178
155
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
);
|
|
156
|
+
return (await adapter.rpc(`${toCamelCase(resource)}EventDetail`, { id })) as DataRecord;
|
|
157
|
+
}
|
|
158
|
+
return adapter.fetchOne(resource, id);
|
|
209
159
|
}
|
|
210
160
|
|
|
211
161
|
// =============================================================================
|
|
@@ -215,16 +165,19 @@ function RowActionButton({ action, recordId, adapter, onDone }: RowActionButtonP
|
|
|
215
165
|
interface FieldRowProps {
|
|
216
166
|
field: FieldDefinition;
|
|
217
167
|
value: unknown;
|
|
168
|
+
record: DataRecord;
|
|
218
169
|
}
|
|
219
170
|
|
|
220
|
-
function FieldRow({ field, value }: FieldRowProps): React.ReactElement {
|
|
171
|
+
function FieldRow({ field, value, record }: FieldRowProps): React.ReactElement {
|
|
221
172
|
return (
|
|
222
173
|
<View className="py-3">
|
|
223
174
|
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">
|
|
224
175
|
{field.label ?? field.name}
|
|
225
176
|
</Text>
|
|
226
|
-
{field.
|
|
227
|
-
|
|
177
|
+
{field.render ? (
|
|
178
|
+
<>{field.render(value, record, { view: 'show' })}</>
|
|
179
|
+
) : field.type === 'image' ? (
|
|
180
|
+
<ImageDisplay field={field} value={value} view="show" />
|
|
228
181
|
) : (
|
|
229
182
|
<Text className="text-sm text-gray-900">{formatFieldValue(value, field)}</Text>
|
|
230
183
|
)}
|