@everystack/admin 0.1.0
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/README.md +282 -0
- package/package.json +53 -0
- package/src/AdminRoot.tsx +34 -0
- package/src/adapters/EverystackAdapter.ts +218 -0
- package/src/adapters/types.ts +78 -0
- package/src/components/AvatarFieldInput.tsx +100 -0
- package/src/components/ImageDisplay.tsx +57 -0
- package/src/components/ImageFieldInput.tsx +140 -0
- package/src/components/dashboard/Brush.tsx +139 -0
- package/src/components/dashboard/DimensionBars.tsx +67 -0
- package/src/components/dashboard/DimensionDonut.tsx +98 -0
- package/src/components/dashboard/EventTable.tsx +248 -0
- package/src/components/dashboard/StatDials.tsx +67 -0
- package/src/components/dashboard/TimeHistogram.tsx +158 -0
- package/src/context.ts +75 -0
- package/src/hooks/useAdminConfig.ts +22 -0
- package/src/hooks/useAdminData.ts +132 -0
- package/src/hooks/useAggregates.ts +290 -0
- package/src/hooks/useRpc.ts +124 -0
- package/src/hooks/useStorageConfig.ts +16 -0
- package/src/index.ts +138 -0
- package/src/presets/twitter.ts +267 -0
- package/src/providers/AdminConfigProvider.tsx +18 -0
- package/src/providers/DataAdapterProvider.tsx +18 -0
- package/src/rpc/resource-dashboard-rpc.ts +416 -0
- package/src/screens/AdminDashboard.tsx +217 -0
- package/src/screens/AdminLayout.tsx +97 -0
- package/src/screens/ResourceCreate.tsx +243 -0
- package/src/screens/ResourceDashboard.tsx +278 -0
- package/src/screens/ResourceEdit.tsx +283 -0
- package/src/screens/ResourceList.tsx +153 -0
- package/src/screens/ResourceShow.tsx +171 -0
- package/src/screens/index.ts +14 -0
- package/src/types.ts +358 -0
- package/src/utils/naming.ts +105 -0
- package/src/utils/pick-image-web.ts +81 -0
- package/src/utils/resolve-image-url.ts +43 -0
- package/src/utils/upload-file.ts +93 -0
package/README.md
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# @everystack/admin
|
|
2
|
+
|
|
3
|
+
Declarative admin dashboard framework for PostgREST APIs. Configuration-driven CRUD screens, dashboard widgets, image field renderers, and data hooks — all wired to `@everystack/api`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @everystack/admin @everystack/api @everystack/ui react react-native
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
Define your admin configuration and render with `AdminRoot`:
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { AdminRoot, EverystackAdapter } from '@everystack/admin';
|
|
17
|
+
import { createClient } from '@everystack/api/client';
|
|
18
|
+
import type { AdminConfig } from '@everystack/admin';
|
|
19
|
+
|
|
20
|
+
const client = createClient({ baseUrl: '/api', getToken: () => token });
|
|
21
|
+
const adapter = new EverystackAdapter({ client });
|
|
22
|
+
|
|
23
|
+
const config: AdminConfig = {
|
|
24
|
+
resources: {
|
|
25
|
+
users: {
|
|
26
|
+
label: 'Users',
|
|
27
|
+
fields: {
|
|
28
|
+
id: { type: 'text', label: 'ID' },
|
|
29
|
+
name: { type: 'text', label: 'Name' },
|
|
30
|
+
email: { type: 'text', label: 'Email' },
|
|
31
|
+
avatar: { type: 'image', label: 'Avatar' },
|
|
32
|
+
createdAt: { type: 'datetime', label: 'Created' },
|
|
33
|
+
},
|
|
34
|
+
listFields: ['name', 'email', 'createdAt'],
|
|
35
|
+
searchFields: ['name', 'email'],
|
|
36
|
+
},
|
|
37
|
+
posts: {
|
|
38
|
+
label: 'Posts',
|
|
39
|
+
fields: {
|
|
40
|
+
id: { type: 'text', label: 'ID' },
|
|
41
|
+
title: { type: 'text', label: 'Title' },
|
|
42
|
+
content: { type: 'textarea', label: 'Content' },
|
|
43
|
+
status: { type: 'select', label: 'Status', options: ['draft', 'published'] },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
menu: [
|
|
48
|
+
{ label: 'Users', resource: 'users', icon: 'users' },
|
|
49
|
+
{ label: 'Posts', resource: 'posts', icon: 'file-text' },
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function AdminApp() {
|
|
54
|
+
return <AdminRoot config={config} adapter={adapter} />;
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Entry Points
|
|
59
|
+
|
|
60
|
+
| Import | Description |
|
|
61
|
+
|--------|-------------|
|
|
62
|
+
| `@everystack/admin` | Config types, hooks, screens, adapter, components |
|
|
63
|
+
| `@everystack/admin/presets/twitter` | Pre-built Twitter clone admin preset |
|
|
64
|
+
|
|
65
|
+
## Admin Configuration
|
|
66
|
+
|
|
67
|
+
### Field Types
|
|
68
|
+
|
|
69
|
+
| Type | Description |
|
|
70
|
+
|------|-------------|
|
|
71
|
+
| `text` | Single-line text input |
|
|
72
|
+
| `textarea` | Multi-line text |
|
|
73
|
+
| `number` | Numeric input |
|
|
74
|
+
| `boolean` | Toggle switch |
|
|
75
|
+
| `select` | Dropdown with `options` array |
|
|
76
|
+
| `datetime` | Date/time display |
|
|
77
|
+
| `image` | Image upload with `ImageFieldInput` |
|
|
78
|
+
| `avatar` | Avatar upload with `AvatarFieldInput` |
|
|
79
|
+
| `json` | JSON display |
|
|
80
|
+
| `relation` | Foreign key reference |
|
|
81
|
+
|
|
82
|
+
### Resource Config
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
interface ResourceConfig {
|
|
86
|
+
label: string;
|
|
87
|
+
fields: Record<string, FieldDefinition>;
|
|
88
|
+
listFields?: string[]; // Columns shown in list view
|
|
89
|
+
searchFields?: string[]; // Fields searchable via text input
|
|
90
|
+
defaultSort?: string; // Default ordering (e.g., 'createdAt.desc')
|
|
91
|
+
dashboard?: ResourceDashboardConfig; // Per-resource analytics
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Data Adapter
|
|
96
|
+
|
|
97
|
+
The `EverystackAdapter` bridges admin hooks to the `@everystack/api` client:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import { EverystackAdapter } from '@everystack/admin';
|
|
101
|
+
import { createClient } from '@everystack/api/client';
|
|
102
|
+
|
|
103
|
+
const client = createClient({ baseUrl: '/api', getToken: () => token });
|
|
104
|
+
|
|
105
|
+
const adapter = new EverystackAdapter({
|
|
106
|
+
client,
|
|
107
|
+
// Optional: custom fetch for RPC calls
|
|
108
|
+
rpcFetch: async (name, body) => { /* ... */ },
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The adapter implements the `DataAdapter` interface:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
interface DataAdapter {
|
|
116
|
+
list(resource: string, params: ListParams): Promise<ListResult>;
|
|
117
|
+
getOne(resource: string, id: string): Promise<DataRecord>;
|
|
118
|
+
create(resource: string, data: DataRecord): Promise<DataRecord>;
|
|
119
|
+
update(resource: string, id: string, data: DataRecord): Promise<DataRecord>;
|
|
120
|
+
delete(resource: string, id: string): Promise<void>;
|
|
121
|
+
aggregate(resource: string, params: AggregateParams): Promise<AggregateResult>;
|
|
122
|
+
rpc(name: string, body: unknown): Promise<unknown>;
|
|
123
|
+
subscribe?(resource: string, callback: (event: SubscriptionEvent) => void): () => void;
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Hooks
|
|
128
|
+
|
|
129
|
+
### useAdminData
|
|
130
|
+
|
|
131
|
+
CRUD data hook for any resource:
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
const { data, total, isLoading, error, refetch } = useAdminData('posts', {
|
|
135
|
+
page: 1,
|
|
136
|
+
perPage: 20,
|
|
137
|
+
sort: 'createdAt.desc',
|
|
138
|
+
filters: { status: 'eq.published' },
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### useAggregates
|
|
143
|
+
|
|
144
|
+
Server-side aggregation hooks:
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
const { data: count } = useCount('posts');
|
|
148
|
+
const { data: grouped } = useGroupedCount('posts', 'status');
|
|
149
|
+
const { data: stats } = useStats('posts', 'views', ['sum', 'avg', 'max']);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### useRpc
|
|
153
|
+
|
|
154
|
+
Call server-side RPC functions:
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
const { data, isLoading, call } = useRpc('dashboard-stats', {
|
|
158
|
+
autoFetch: true,
|
|
159
|
+
body: { period: '7d' },
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### useStorageConfig
|
|
164
|
+
|
|
165
|
+
Access storage configuration for image uploads:
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
const { uploadUrl, cdnUrl } = useStorageConfig();
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Screens
|
|
172
|
+
|
|
173
|
+
Built-in screens that render from config:
|
|
174
|
+
|
|
175
|
+
| Component | Description |
|
|
176
|
+
|-----------|-------------|
|
|
177
|
+
| `AdminLayout` | Navigation sidebar + content area |
|
|
178
|
+
| `AdminDashboard` | Overview with stat cards |
|
|
179
|
+
| `ResourceList` | Paginated table with filters |
|
|
180
|
+
| `ResourceShow` | Read-only record detail |
|
|
181
|
+
| `ResourceCreate` | Create form |
|
|
182
|
+
| `ResourceEdit` | Edit form |
|
|
183
|
+
| `ResourceDashboard` | Per-resource analytics |
|
|
184
|
+
|
|
185
|
+
### Manual Screen Usage
|
|
186
|
+
|
|
187
|
+
```tsx
|
|
188
|
+
import { AdminLayout, ResourceList, ResourceEdit } from '@everystack/admin';
|
|
189
|
+
|
|
190
|
+
// In your router:
|
|
191
|
+
<AdminLayout config={config}>
|
|
192
|
+
<ResourceList resource="posts" config={config.resources.posts} />
|
|
193
|
+
</AdminLayout>
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Image Fields
|
|
197
|
+
|
|
198
|
+
Image upload field renderers for use in create/edit forms:
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
import { ImageFieldInput, AvatarFieldInput, ImageDisplay } from '@everystack/admin';
|
|
202
|
+
|
|
203
|
+
// In a form
|
|
204
|
+
<ImageFieldInput
|
|
205
|
+
value={formData.coverImage}
|
|
206
|
+
onChange={(key) => setFormData({ ...formData, coverImage: key })}
|
|
207
|
+
storageConfig={{ uploadUrl: '/api/storage', cdnUrl: 'https://cdn.example.com' }}
|
|
208
|
+
/>
|
|
209
|
+
|
|
210
|
+
<AvatarFieldInput
|
|
211
|
+
value={formData.avatar}
|
|
212
|
+
onChange={(key) => setFormData({ ...formData, avatar: key })}
|
|
213
|
+
storageConfig={storageConfig}
|
|
214
|
+
/>
|
|
215
|
+
|
|
216
|
+
// Read-only display
|
|
217
|
+
<ImageDisplay value={record.coverImage} cdnUrl="https://cdn.example.com" />
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Dashboard Widgets
|
|
221
|
+
|
|
222
|
+
SVG-based chart components for analytics:
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
import { StatDials, DimensionBars, TimeHistogram, EventTable } from '@everystack/admin';
|
|
226
|
+
|
|
227
|
+
<StatDials data={[{ label: 'Users', value: 1234, trend: 12 }]} />
|
|
228
|
+
<DimensionBars data={[{ label: 'iOS', value: 60 }, { label: 'Android', value: 40 }]} />
|
|
229
|
+
<TimeHistogram data={timeSeriesData} />
|
|
230
|
+
<EventTable columns={columns} data={events} />
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Presets
|
|
234
|
+
|
|
235
|
+
Pre-built admin configurations for common schemas:
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
import { twitterPreset } from '@everystack/admin/presets/twitter';
|
|
239
|
+
|
|
240
|
+
const config = twitterPreset({
|
|
241
|
+
// Override any resource config
|
|
242
|
+
resources: {
|
|
243
|
+
posts: { label: 'Tweets' },
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Utilities
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
import { toCamelCase, toSnakeCase, resolveImageUrl, uploadFile } from '@everystack/admin';
|
|
252
|
+
|
|
253
|
+
// Naming conversion
|
|
254
|
+
toCamelCase('created_at'); // 'createdAt'
|
|
255
|
+
toSnakeCase('createdAt'); // 'created_at'
|
|
256
|
+
|
|
257
|
+
// Image URL resolution
|
|
258
|
+
const url = resolveImageUrl('uploads/abc.jpg', { cdnUrl: 'https://cdn.example.com' });
|
|
259
|
+
|
|
260
|
+
// File upload
|
|
261
|
+
const result = await uploadFile(file, {
|
|
262
|
+
uploadUrl: '/api/storage/presign',
|
|
263
|
+
getToken: () => token,
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Peer Dependencies
|
|
268
|
+
|
|
269
|
+
| Package | Version |
|
|
270
|
+
|---------|---------|
|
|
271
|
+
| `@everystack/api` | `>=0.1.0` |
|
|
272
|
+
| `@everystack/ui` | `>=0.1.0` |
|
|
273
|
+
| `react` | `>=18.0.0` |
|
|
274
|
+
| `react-native` | `>=0.72.0` |
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
Part of [everystack](https://github.com/scalable-technology/everystack) — a self-hosted application stack for Expo apps on AWS.
|
|
279
|
+
|
|
280
|
+
## License
|
|
281
|
+
|
|
282
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@everystack/admin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Declarative admin dashboard framework for PostgREST APIs",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"default": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"./presets/twitter": {
|
|
20
|
+
"types": "./src/presets/twitter.ts",
|
|
21
|
+
"default": "./src/presets/twitter.ts"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"d3-scale": "^4.0.2",
|
|
26
|
+
"d3-time-format": "^4.1.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@everystack/api": ">=0.1.0",
|
|
30
|
+
"@everystack/ui": ">=0.1.0",
|
|
31
|
+
"react": ">=18.0.0",
|
|
32
|
+
"react-native": ">=0.72.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/d3-scale": "^4.0.9",
|
|
36
|
+
"@types/d3-time-format": "^4.0.3",
|
|
37
|
+
"@types/jest": "^29.5.14",
|
|
38
|
+
"@types/node": "^22.0.0",
|
|
39
|
+
"@types/react": "~19.2.14",
|
|
40
|
+
"jest": "^29.7.0",
|
|
41
|
+
"react": "^19.0.0",
|
|
42
|
+
"react-native": ">=0.72.0",
|
|
43
|
+
"ts-jest": "^29.3.0",
|
|
44
|
+
"typescript": "^5.7.0",
|
|
45
|
+
"@everystack/api": "0.1.0",
|
|
46
|
+
"@everystack/ui": "0.1.0"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
|
50
|
+
"build": "tsc --build",
|
|
51
|
+
"lint": "tsc --noEmit"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdminRoot
|
|
3
|
+
*
|
|
4
|
+
* Root component for the admin dashboard.
|
|
5
|
+
* Combines AdminConfigProvider and DataAdapterProvider.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from 'react';
|
|
9
|
+
import type { AdminConfig } from './types';
|
|
10
|
+
import type { DataAdapter } from './adapters/types';
|
|
11
|
+
import { AdminConfigProvider } from './providers/AdminConfigProvider';
|
|
12
|
+
import { DataAdapterProvider } from './providers/DataAdapterProvider';
|
|
13
|
+
import { StorageConfigContext } from './hooks/useStorageConfig';
|
|
14
|
+
|
|
15
|
+
export interface AdminRootProps {
|
|
16
|
+
/** Admin configuration */
|
|
17
|
+
config: AdminConfig;
|
|
18
|
+
/** Data adapter for backend communication */
|
|
19
|
+
adapter: DataAdapter;
|
|
20
|
+
/** Children to render (typically expo-router's Slot or navigator) */
|
|
21
|
+
children?: React.ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function AdminRoot({ config, adapter, children }: AdminRootProps) {
|
|
25
|
+
return (
|
|
26
|
+
<AdminConfigProvider config={config}>
|
|
27
|
+
<DataAdapterProvider adapter={adapter}>
|
|
28
|
+
<StorageConfigContext.Provider value={config.storage ?? null}>
|
|
29
|
+
{children}
|
|
30
|
+
</StorageConfigContext.Provider>
|
|
31
|
+
</DataAdapterProvider>
|
|
32
|
+
</AdminConfigProvider>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EverystackAdapter — bridges the admin DataAdapter interface to everystack/client.
|
|
3
|
+
*
|
|
4
|
+
* Translates the admin's ListParams (page/perPage/sort/order/filter)
|
|
5
|
+
* into PostgREST-compatible queries via the everystack client.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createClient, type Client, type ClientOptions, type QueryBuilder } from '@everystack/api/client';
|
|
9
|
+
import type { DataAdapter } from './types';
|
|
10
|
+
import type {
|
|
11
|
+
ListParams,
|
|
12
|
+
ListResult,
|
|
13
|
+
DataRecord,
|
|
14
|
+
AggregateParams,
|
|
15
|
+
AggregateResult,
|
|
16
|
+
} from '../types';
|
|
17
|
+
|
|
18
|
+
/** Known PostgREST filter operators */
|
|
19
|
+
const POSTGREST_OPERATORS = new Set([
|
|
20
|
+
'eq', 'neq', 'gt', 'gte', 'lt', 'lte',
|
|
21
|
+
'like', 'ilike', 'is', 'in', 'not', 'cs', 'cd',
|
|
22
|
+
'sl', 'sr', 'nxl', 'nxr', 'adj', 'ov', 'fts',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export interface EverystackAdapterOptions extends ClientOptions {}
|
|
26
|
+
|
|
27
|
+
export class EverystackAdapter implements DataAdapter {
|
|
28
|
+
private client: Client;
|
|
29
|
+
|
|
30
|
+
constructor(options: EverystackAdapterOptions) {
|
|
31
|
+
this.client = createClient(options);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async fetchList<T extends DataRecord = DataRecord>(
|
|
35
|
+
resource: string,
|
|
36
|
+
params: ListParams,
|
|
37
|
+
): Promise<ListResult<T>> {
|
|
38
|
+
const page = params.page ?? 1;
|
|
39
|
+
const perPage = params.perPage ?? 25;
|
|
40
|
+
const offset = (page - 1) * perPage;
|
|
41
|
+
|
|
42
|
+
let builder = this.client.from(resource);
|
|
43
|
+
|
|
44
|
+
// Apply filters
|
|
45
|
+
builder = applyFilters(builder, params.filter);
|
|
46
|
+
|
|
47
|
+
// Apply sort
|
|
48
|
+
if (params.sort) {
|
|
49
|
+
builder = builder.order(params.sort, params.order ?? 'asc');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Apply pagination
|
|
53
|
+
builder = builder.limit(perPage).offset(offset);
|
|
54
|
+
|
|
55
|
+
const result = await builder.executeWithCount();
|
|
56
|
+
|
|
57
|
+
if (result.error) {
|
|
58
|
+
throw new Error(result.error.message);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
data: (result.data ?? []) as T[],
|
|
63
|
+
total: result.total,
|
|
64
|
+
page,
|
|
65
|
+
perPage,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async fetchOne<T extends DataRecord = DataRecord>(
|
|
70
|
+
resource: string,
|
|
71
|
+
id: string | number,
|
|
72
|
+
): Promise<T> {
|
|
73
|
+
const result = await this.client.from(resource).eq('id', String(id)).single();
|
|
74
|
+
|
|
75
|
+
if (result.error) {
|
|
76
|
+
throw new Error(result.error.status === 406
|
|
77
|
+
? `${resource}/${id} not found`
|
|
78
|
+
: result.error.message);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return result.data as T;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async create<T extends DataRecord = DataRecord>(
|
|
85
|
+
resource: string,
|
|
86
|
+
data: Omit<T, 'id'>,
|
|
87
|
+
): Promise<T> {
|
|
88
|
+
const result = await this.client.from(resource).insert(data as Record<string, unknown>);
|
|
89
|
+
|
|
90
|
+
if (result.error) {
|
|
91
|
+
throw new Error(result.error.message);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return result.data as T;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async update<T extends DataRecord = DataRecord>(
|
|
98
|
+
resource: string,
|
|
99
|
+
id: string | number,
|
|
100
|
+
data: Partial<T>,
|
|
101
|
+
): Promise<T> {
|
|
102
|
+
const result = await this.client
|
|
103
|
+
.from(resource)
|
|
104
|
+
.eq('id', String(id))
|
|
105
|
+
.update(data as Record<string, unknown>);
|
|
106
|
+
|
|
107
|
+
if (result.error) {
|
|
108
|
+
throw new Error(result.error.message);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const rows = result.data as T[];
|
|
112
|
+
return rows[0];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async delete(resource: string, id: string | number): Promise<void> {
|
|
116
|
+
const result = await this.client.from(resource).eq('id', String(id)).remove();
|
|
117
|
+
|
|
118
|
+
if (result.error) {
|
|
119
|
+
throw new Error(result.error.message);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async aggregate(resource: string, params: AggregateParams): Promise<AggregateResult> {
|
|
124
|
+
const selectParts: string[] = [];
|
|
125
|
+
|
|
126
|
+
// Add groupBy columns first
|
|
127
|
+
if (params.groupBy) {
|
|
128
|
+
selectParts.push(...params.groupBy);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Build aggregate select expressions
|
|
132
|
+
for (const agg of params.aggregates) {
|
|
133
|
+
if (agg.column) {
|
|
134
|
+
// column.fn() — e.g., amount.sum()
|
|
135
|
+
selectParts.push(`${agg.column}.${agg.function}()`);
|
|
136
|
+
} else {
|
|
137
|
+
// count() with no column
|
|
138
|
+
selectParts.push(`${agg.function}()`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let builder = this.client.from(resource).select(selectParts.join(','));
|
|
143
|
+
|
|
144
|
+
// Apply filters
|
|
145
|
+
builder = applyFilters(builder, params.filter);
|
|
146
|
+
|
|
147
|
+
const result = await builder.execute();
|
|
148
|
+
|
|
149
|
+
if (result.error) {
|
|
150
|
+
throw new Error(result.error.message);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
data: (result.data ?? []) as Record<string, unknown>[],
|
|
155
|
+
meta: {
|
|
156
|
+
aggregate: true,
|
|
157
|
+
groupBy: params.groupBy,
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Apply admin filter params to a query builder.
|
|
165
|
+
* Detects PostgREST-formatted values (e.g., "eq.active", "gt.100")
|
|
166
|
+
* vs plain values (e.g., "active") and wraps plain values with eq.
|
|
167
|
+
*/
|
|
168
|
+
function applyFilters(builder: QueryBuilder, filter?: Record<string, unknown>): QueryBuilder {
|
|
169
|
+
if (!filter) return builder;
|
|
170
|
+
|
|
171
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
172
|
+
if (value == null) continue;
|
|
173
|
+
|
|
174
|
+
const strValue = String(value);
|
|
175
|
+
|
|
176
|
+
if (isPostgrestFilter(strValue)) {
|
|
177
|
+
// Already formatted — extract operator and value
|
|
178
|
+
const dotIndex = strValue.indexOf('.');
|
|
179
|
+
const op = strValue.substring(0, dotIndex);
|
|
180
|
+
const val = strValue.substring(dotIndex + 1);
|
|
181
|
+
|
|
182
|
+
switch (op) {
|
|
183
|
+
case 'eq': builder = builder.eq(key, val); break;
|
|
184
|
+
case 'neq': builder = builder.neq(key, val); break;
|
|
185
|
+
case 'gt': builder = builder.gt(key, val); break;
|
|
186
|
+
case 'gte': builder = builder.gte(key, val); break;
|
|
187
|
+
case 'lt': builder = builder.lt(key, val); break;
|
|
188
|
+
case 'lte': builder = builder.lte(key, val); break;
|
|
189
|
+
case 'like': builder = builder.like(key, val); break;
|
|
190
|
+
case 'ilike': builder = builder.ilike(key, val); break;
|
|
191
|
+
case 'is': builder = builder.is(key, val); break;
|
|
192
|
+
case 'in': {
|
|
193
|
+
const values = val.replace(/^\(/, '').replace(/\)$/, '').split(',');
|
|
194
|
+
builder = builder.in(key, values);
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
default:
|
|
198
|
+
// Unknown operator — use eq with full value
|
|
199
|
+
builder = builder.eq(key, strValue);
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
// Plain value — wrap with eq
|
|
203
|
+
builder = builder.eq(key, strValue);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return builder;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Check if a filter value is already in PostgREST format (operator.value).
|
|
212
|
+
*/
|
|
213
|
+
function isPostgrestFilter(value: string): boolean {
|
|
214
|
+
const dotIndex = value.indexOf('.');
|
|
215
|
+
if (dotIndex <= 0) return false;
|
|
216
|
+
const prefix = value.substring(0, dotIndex);
|
|
217
|
+
return POSTGREST_OPERATORS.has(prefix);
|
|
218
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Adapter Interface
|
|
3
|
+
*
|
|
4
|
+
* The DataAdapter provides a backend-agnostic abstraction for data operations.
|
|
5
|
+
* Implement this interface to connect the admin to any data source.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ListParams,
|
|
10
|
+
ListResult,
|
|
11
|
+
DataRecord,
|
|
12
|
+
AggregateParams,
|
|
13
|
+
AggregateResult,
|
|
14
|
+
} from '../types';
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Adapter Interface
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
export interface DataAdapter {
|
|
21
|
+
/**
|
|
22
|
+
* Fetch a list of records with pagination, sorting, and filtering
|
|
23
|
+
*/
|
|
24
|
+
fetchList<T extends DataRecord = DataRecord>(
|
|
25
|
+
resource: string,
|
|
26
|
+
params: ListParams
|
|
27
|
+
): Promise<ListResult<T>>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Fetch a single record by ID
|
|
31
|
+
*/
|
|
32
|
+
fetchOne<T extends DataRecord = DataRecord>(resource: string, id: string | number): Promise<T>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create a new record
|
|
36
|
+
*/
|
|
37
|
+
create<T extends DataRecord = DataRecord>(resource: string, data: Omit<T, 'id'>): Promise<T>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Update an existing record
|
|
41
|
+
*/
|
|
42
|
+
update<T extends DataRecord = DataRecord>(
|
|
43
|
+
resource: string,
|
|
44
|
+
id: string | number,
|
|
45
|
+
data: Partial<T>
|
|
46
|
+
): Promise<T>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Delete a record
|
|
50
|
+
*/
|
|
51
|
+
delete(resource: string, id: string | number): Promise<void>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fetch aggregate data (count, sum, avg, min, max) with optional grouping
|
|
55
|
+
*/
|
|
56
|
+
aggregate?(resource: string, params: AggregateParams): Promise<AggregateResult>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Subscribe to real-time updates (optional)
|
|
60
|
+
*/
|
|
61
|
+
subscribe?<T extends DataRecord = DataRecord>(
|
|
62
|
+
resource: string,
|
|
63
|
+
callback: (event: SubscriptionEvent<T>) => void
|
|
64
|
+
): () => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// Subscription Types
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
export type SubscriptionEventType = 'insert' | 'update' | 'delete';
|
|
72
|
+
|
|
73
|
+
export interface SubscriptionEvent<T extends DataRecord = DataRecord> {
|
|
74
|
+
type: SubscriptionEventType;
|
|
75
|
+
resource: string;
|
|
76
|
+
record: T;
|
|
77
|
+
previousRecord?: T;
|
|
78
|
+
}
|