asasvirtuais 0.7.17 → 0.8.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/README.md +474 -421
- package/package.json +74 -74
- package/packages/action.tsx +71 -0
- package/packages/character/components/form.tsx +15 -71
- package/packages/character/components/list.tsx +64 -17
- package/packages/dexie.ts +126 -126
- package/packages/fields.tsx +2 -2
- package/packages/form.tsx +32 -61
- package/packages/interface.ts +2 -2
- package/packages/novelai.ts +1 -1
- package/packages/react-interface.tsx +19 -20
package/README.md
CHANGED
|
@@ -1,421 +1,474 @@
|
|
|
1
|
-
# Asas Virtuais Framework
|
|
2
|
-
|
|
3
|
-
Asas Virtuais is a full-stack, schema-driven framework for Next.js designed for rapid, type-safe, and maintainable application development. It provides a powerful abstraction layer for data management, allowing you to define your data shape once with Zod and get automatically generated API endpoints, client-side hooks, and form components.
|
|
4
|
-
|
|
5
|
-
## Core Principles
|
|
6
|
-
|
|
7
|
-
- **Schema First**: Define your data models using Zod schemas. This provides a single source of truth for validation, type safety, and data shape across the entire stack.
|
|
8
|
-
- **Interface-Driven**: Code against a standardized `TableInterface` on both the client and server. This decouples your application logic from the specific database implementation, allowing for easy swapping of backends.
|
|
9
|
-
- **Convention over Configuration**: The framework automatically generates RESTful API endpoints and client-side hooks from your schema, minimizing boilerplate and accelerating development.
|
|
10
|
-
- **Extensible by Design**: Every layer is built to be wrapped or replaced. You can easily add custom logic like authentication to API endpoints, create new database adapters, or build bespoke form field components.
|
|
11
|
-
|
|
12
|
-
## Step-by-Step Guide
|
|
13
|
-
|
|
14
|
-
This guide will walk you through building a feature using the Asas Virtuais framework.
|
|
15
|
-
|
|
16
|
-
### Step 1: Define Your Schema
|
|
17
|
-
|
|
18
|
-
Everything starts with your data schema. Use Zod to define `readable` (data sent to the client) and `writable` (data accepted from the client) shapes for each table.
|
|
19
|
-
|
|
20
|
-
`app/database.ts`:
|
|
21
|
-
```ts
|
|
22
|
-
import { z } from 'zod'
|
|
23
|
-
|
|
24
|
-
export const schema = {
|
|
25
|
-
// A generic 'users' table
|
|
26
|
-
users: {
|
|
27
|
-
readable: z.object({
|
|
28
|
-
id: z.string(),
|
|
29
|
-
name: z.string(),
|
|
30
|
-
email: z.string().email(),
|
|
31
|
-
}),
|
|
32
|
-
writable: z.object({
|
|
33
|
-
name: z.string().nonempty('Name is required'),
|
|
34
|
-
email: z.string().email(),
|
|
35
|
-
})
|
|
36
|
-
},
|
|
37
|
-
// A generic 'posts' table
|
|
38
|
-
posts: {
|
|
39
|
-
readable: z.object({
|
|
40
|
-
id: z.string(),
|
|
41
|
-
title: z.string(),
|
|
42
|
-
content: z.string().optional(),
|
|
43
|
-
authorId: z.string(),
|
|
44
|
-
}),
|
|
45
|
-
writable: z.object({
|
|
46
|
-
title: z.string().nonempty('Title is required'),
|
|
47
|
-
content: z.string().optional(),
|
|
48
|
-
authorId: z.string(),
|
|
49
|
-
})
|
|
50
|
-
},
|
|
51
|
-
}
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
### Step 2: Create a Server-Side Adapter
|
|
55
|
-
|
|
56
|
-
Implement the `TableInterface` for your chosen database. The framework is backend-agnostic. This project uses a Firestore adapter.
|
|
57
|
-
|
|
58
|
-
`packages/firestore.ts`:
|
|
59
|
-
```ts
|
|
60
|
-
import { TableInterface } from './interface'
|
|
61
|
-
import { firestore } from './firebase'
|
|
62
|
-
|
|
63
|
-
// A generic Firestore adapter implementing the standard interface
|
|
64
|
-
export function firestoreInterface<...>(defaultTable?: T): TableInterface<...> {
|
|
65
|
-
return {
|
|
66
|
-
async find({ table = defaultTable, id }) {
|
|
67
|
-
const docSnap = await firestore.collection(table).doc(id).get()
|
|
68
|
-
if (docSnap.exists) return { id: docSnap.id, ...docSnap.data() }
|
|
69
|
-
throw new Error(`Record not found`)
|
|
70
|
-
},
|
|
71
|
-
// ... list, create, update, remove implementations
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Then, instantiate the adapter for each of your tables.
|
|
77
|
-
|
|
78
|
-
`app/server.ts`:
|
|
79
|
-
```ts
|
|
80
|
-
import { firestoreInterface } from '@/packages/firestore'
|
|
81
|
-
import { schema } from './database'
|
|
82
|
-
|
|
83
|
-
// Create a server-side data access object
|
|
84
|
-
export const server = {
|
|
85
|
-
users: firestoreInterface<typeof schema, 'users'>('users'),
|
|
86
|
-
posts: firestoreInterface<typeof schema, 'posts'>('posts'),
|
|
87
|
-
}
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
### Step 3: Expose API Endpoints
|
|
91
|
-
|
|
92
|
-
With the server adapter ready, you can expose it as a RESTful API using `createDynamicRoute`. This automatically creates `GET`, `POST`, `PATCH`, and `DELETE` handlers. You can easily wrap the base implementation to add cross-cutting concerns like authentication.
|
|
93
|
-
|
|
94
|
-
`app/api/v1/[...params]/route.ts`:
|
|
95
|
-
```ts
|
|
96
|
-
import { authenticate } from '@/packages/auth0' // Your authentication logic
|
|
97
|
-
import { createDynamicRoute } from '@/packages/next-interface'
|
|
98
|
-
import { firestoreInterface } from '@/packages/firestore'
|
|
99
|
-
|
|
100
|
-
const dbInterface = firestoreInterface()
|
|
101
|
-
|
|
102
|
-
// Wrap the base interface to enforce business logic and authentication
|
|
103
|
-
const apiInterface = {
|
|
104
|
-
async create(props) {
|
|
105
|
-
const user = await authenticate()
|
|
106
|
-
// Example: Inject the authorId automatically
|
|
107
|
-
if (props.table === 'posts') {
|
|
108
|
-
props.data.authorId = user.id
|
|
109
|
-
}
|
|
110
|
-
return dbInterface.create(props)
|
|
111
|
-
},
|
|
112
|
-
async find(props) {
|
|
113
|
-
const user = await authenticate()
|
|
114
|
-
const result = await dbInterface.find(props)
|
|
115
|
-
// Example: Check for ownership on 'posts'
|
|
116
|
-
if (result.authorId && result.authorId !== user.id) {
|
|
117
|
-
throw new Error('Unauthorized')
|
|
118
|
-
}
|
|
119
|
-
return result
|
|
120
|
-
},
|
|
121
|
-
// ... implementations for update, remove, and list with auth checks
|
|
122
|
-
} as typeof dbInterface
|
|
123
|
-
|
|
124
|
-
// Export the dynamic route handlers
|
|
125
|
-
export const GET = createDynamicRoute(apiInterface)
|
|
126
|
-
export const POST = createDynamicRoute(apiInterface)
|
|
127
|
-
export const DELETE = createDynamicRoute(apiInterface)
|
|
128
|
-
export const PATCH = createDynamicRoute(apiInterface)
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
### Step 4: Initialize the Client Interface
|
|
132
|
-
|
|
133
|
-
On the client, initialize the React interface. This will provide all the hooks and components you need to interact with your API.
|
|
134
|
-
|
|
135
|
-
`app/interface.tsx`:
|
|
136
|
-
```tsx
|
|
137
|
-
'use client'
|
|
138
|
-
import { fetchInterface } from '@/packages/fetch-interface'
|
|
139
|
-
import { schema } from './database'
|
|
140
|
-
import { reactInterface } from '@/packages/react-interface'
|
|
141
|
-
|
|
142
|
-
// Create the client-side fetcher
|
|
143
|
-
const fetcher = fetchInterface({ schema, baseUrl: '/api/v1' })
|
|
144
|
-
|
|
145
|
-
// Export all the React hooks and components for the app
|
|
146
|
-
export const {
|
|
147
|
-
DatabaseProvider,
|
|
148
|
-
useTable,
|
|
149
|
-
CreateForm,
|
|
150
|
-
UpdateForm,
|
|
151
|
-
SingleProvider,
|
|
152
|
-
useSingle,
|
|
153
|
-
// ... and more
|
|
154
|
-
} = reactInterface<typeof schema>(schema, fetcher)
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
### Step 5: Provide Data with `DatabaseProvider`
|
|
158
|
-
|
|
159
|
-
In your layouts, use `DatabaseProvider` to fetch initial data and make it available to all components underneath it via React Context.
|
|
160
|
-
|
|
161
|
-
`app/posts/[id]/layout.tsx`:
|
|
162
|
-
```tsx
|
|
163
|
-
import { DatabaseProvider, SingleProvider } from '@/app/interface'
|
|
164
|
-
import { server } from '@/app/server'
|
|
165
|
-
import { layout } from '@/packages/next'
|
|
166
|
-
|
|
167
|
-
export default layout<{ id: string }>( async ({ id, children }) => {
|
|
168
|
-
// Fetch initial data on the server
|
|
169
|
-
const post = await server.posts.find({ id })
|
|
170
|
-
|
|
171
|
-
// Provide the fetched data to the client-side context
|
|
172
|
-
return (
|
|
173
|
-
<DatabaseProvider posts={{ [id]: post }} users={{}}>
|
|
174
|
-
<SingleProvider id={id} table='posts'>
|
|
175
|
-
{children}
|
|
176
|
-
</SingleProvider>
|
|
177
|
-
</DatabaseProvider>
|
|
178
|
-
)
|
|
179
|
-
} )
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
### Step 6: Consume Data with Hooks
|
|
183
|
-
|
|
184
|
-
Use the provided hooks to access and manipulate data in your components.
|
|
185
|
-
|
|
186
|
-
#### `useTable`
|
|
187
|
-
To work with a collection of records (list, create, update, delete).
|
|
188
|
-
|
|
189
|
-
`app/posts/page.tsx`:
|
|
190
|
-
```tsx
|
|
191
|
-
'use client'
|
|
192
|
-
import { useTable } from '@/app/interface'
|
|
193
|
-
import Link from 'next/link'
|
|
194
|
-
import { IconButton } from '@chakra-ui/react'
|
|
195
|
-
import { LuTrash } from 'react-icons/lu'
|
|
196
|
-
|
|
197
|
-
export default function PostsPage() {
|
|
198
|
-
const { array: posts, remove, list } = useTable('posts')
|
|
199
|
-
|
|
200
|
-
useEffect(() => {
|
|
201
|
-
list.trigger({}) // Fetch all posts
|
|
202
|
-
}, [])
|
|
203
|
-
|
|
204
|
-
return (
|
|
205
|
-
<VStack>
|
|
206
|
-
{posts.map((post) => (
|
|
207
|
-
<HStack key={post.id}>
|
|
208
|
-
<Link href={`/posts/${post.id}`}>
|
|
209
|
-
<Text>{post.title}</Text>
|
|
210
|
-
</Link>
|
|
211
|
-
<IconButton
|
|
212
|
-
onClick={() => remove.trigger({id: post.id})}
|
|
213
|
-
disabled={remove.loading}
|
|
214
|
-
icon={<LuTrash />}
|
|
215
|
-
/>
|
|
216
|
-
</HStack>
|
|
217
|
-
))}
|
|
218
|
-
</VStack>
|
|
219
|
-
)
|
|
220
|
-
}
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
#### `useSingle`
|
|
224
|
-
To work with a single record, often used within a `SingleProvider`.
|
|
225
|
-
|
|
226
|
-
`app/posts/[id]/page.tsx`:
|
|
227
|
-
```tsx
|
|
228
|
-
'use client'
|
|
229
|
-
import { useSingle } from '@/app/interface'
|
|
230
|
-
|
|
231
|
-
export default function PostPage() {
|
|
232
|
-
// Get the current post from the context provided by SingleProvider
|
|
233
|
-
const { single: post } = useSingle('posts')
|
|
234
|
-
|
|
235
|
-
return (
|
|
236
|
-
<article>
|
|
237
|
-
<h1>{post.title}</h1>
|
|
238
|
-
<p>{post.content}</p>
|
|
239
|
-
</article>
|
|
240
|
-
)
|
|
241
|
-
}
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
### Step 7: High-Level Abstracted Forms
|
|
245
|
-
|
|
246
|
-
The framework provides `CreateForm` and `UpdateForm` components that handle form state, submission, and API calls for you.
|
|
247
|
-
|
|
248
|
-
`app/posts/new/page.tsx`:
|
|
249
|
-
```tsx
|
|
250
|
-
'use client'
|
|
251
|
-
import { CreateForm } from '@/app/interface'
|
|
252
|
-
import { Input, Button, Textarea } from '@chakra-ui/react'
|
|
253
|
-
|
|
254
|
-
export function NewPostPage() {
|
|
255
|
-
const { user } = useUser() // from @auth0/nextjs-auth0
|
|
256
|
-
|
|
257
|
-
return (
|
|
258
|
-
<CreateForm
|
|
259
|
-
table='posts'
|
|
260
|
-
// Set default values for the form
|
|
261
|
-
defaults={{ authorId: user.id, title: 'New Post' }}
|
|
262
|
-
onSuccess={(post) => router.push(`/posts/${post.id}`)}
|
|
263
|
-
>
|
|
264
|
-
{/* The child is a render prop with form state and field state */}
|
|
265
|
-
{({ loading, fields, setField, submit }) => (
|
|
266
|
-
<form onSubmit={submit}>
|
|
267
|
-
<Input
|
|
268
|
-
value={fields.title}
|
|
269
|
-
onChange={(e) => setField('title', e.target.value)}
|
|
270
|
-
/>
|
|
271
|
-
<Textarea
|
|
272
|
-
value={fields.content}
|
|
273
|
-
onChange={(e) => setField('content', e.target.value)}
|
|
274
|
-
/>
|
|
275
|
-
<Button type='submit' disabled={loading}>
|
|
276
|
-
{loading ? 'Saving...' : 'Create Post'}
|
|
277
|
-
</Button>
|
|
278
|
-
</form>
|
|
279
|
-
)}
|
|
280
|
-
</CreateForm>
|
|
281
|
-
)
|
|
282
|
-
}
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
#### `FilterForm`
|
|
286
|
-
Use `FilterForm` to easily create search and filter functionality. It wraps the `list` method of a table and displays the results.
|
|
287
|
-
|
|
288
|
-
`app/posts/SearchPosts.tsx`:
|
|
289
|
-
```tsx
|
|
290
|
-
'use client'
|
|
291
|
-
import { FilterForm } from '@/app/interface'
|
|
292
|
-
import { Input, Button, VStack, Text, Box } from '@chakra-ui/react'
|
|
293
|
-
|
|
294
|
-
function SearchPosts() {
|
|
295
|
-
return (
|
|
296
|
-
<Box>
|
|
297
|
-
<FilterForm
|
|
298
|
-
table="posts"
|
|
299
|
-
// Set default filter values. The form will initially load with these.
|
|
300
|
-
defaults={{ title: '' }}
|
|
301
|
-
>
|
|
302
|
-
{({ loading, fields, setField, submit, result }) => (
|
|
303
|
-
<VStack as="form" onSubmit={submit} align="stretch" spacing={4}>
|
|
304
|
-
<Input
|
|
305
|
-
placeholder="Search by title..."
|
|
306
|
-
value={fields.title}
|
|
307
|
-
onChange={(e) => setField('title', e.target.value)}
|
|
308
|
-
/>
|
|
309
|
-
<Button type="submit" disabled={loading} alignSelf="flex-start">
|
|
310
|
-
{loading ? 'Searching...' : 'Search'}
|
|
311
|
-
</Button>
|
|
312
|
-
|
|
313
|
-
{/* Display the results from the list call */}
|
|
314
|
-
<VStack align="stretch" mt={4} spacing={2}>
|
|
315
|
-
{result && result.length > 0 ? (
|
|
316
|
-
result.map(post => (
|
|
317
|
-
<Text key={post.id}>- {post.title}</Text>
|
|
318
|
-
))
|
|
319
|
-
) : (
|
|
320
|
-
<Text color="gray.500">No results found.</Text>
|
|
321
|
-
)}
|
|
322
|
-
</VStack>
|
|
323
|
-
</VStack>
|
|
324
|
-
)}
|
|
325
|
-
</FilterForm>
|
|
326
|
-
</Box>
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
## Form and Field Primitives
|
|
332
|
-
|
|
333
|
-
The `CreateForm` and `UpdateForm` components are convenient abstractions. For more control, you can use the underlying `
|
|
334
|
-
|
|
335
|
-
### `packages/
|
|
336
|
-
|
|
337
|
-
`
|
|
338
|
-
|
|
339
|
-
- **Props**:
|
|
340
|
-
- `
|
|
341
|
-
- `
|
|
342
|
-
- `
|
|
343
|
-
-
|
|
344
|
-
|
|
345
|
-
- `
|
|
346
|
-
- `
|
|
347
|
-
- `
|
|
348
|
-
- `
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
-
|
|
357
|
-
|
|
358
|
-
- `
|
|
359
|
-
- `
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
</
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
1
|
+
# Asas Virtuais Framework
|
|
2
|
+
|
|
3
|
+
Asas Virtuais is a full-stack, schema-driven framework for Next.js designed for rapid, type-safe, and maintainable application development. It provides a powerful abstraction layer for data management, allowing you to define your data shape once with Zod and get automatically generated API endpoints, client-side hooks, and form components.
|
|
4
|
+
|
|
5
|
+
## Core Principles
|
|
6
|
+
|
|
7
|
+
- **Schema First**: Define your data models using Zod schemas. This provides a single source of truth for validation, type safety, and data shape across the entire stack.
|
|
8
|
+
- **Interface-Driven**: Code against a standardized `TableInterface` on both the client and server. This decouples your application logic from the specific database implementation, allowing for easy swapping of backends.
|
|
9
|
+
- **Convention over Configuration**: The framework automatically generates RESTful API endpoints and client-side hooks from your schema, minimizing boilerplate and accelerating development.
|
|
10
|
+
- **Extensible by Design**: Every layer is built to be wrapped or replaced. You can easily add custom logic like authentication to API endpoints, create new database adapters, or build bespoke form field components.
|
|
11
|
+
|
|
12
|
+
## Step-by-Step Guide
|
|
13
|
+
|
|
14
|
+
This guide will walk you through building a feature using the Asas Virtuais framework.
|
|
15
|
+
|
|
16
|
+
### Step 1: Define Your Schema
|
|
17
|
+
|
|
18
|
+
Everything starts with your data schema. Use Zod to define `readable` (data sent to the client) and `writable` (data accepted from the client) shapes for each table.
|
|
19
|
+
|
|
20
|
+
`app/database.ts`:
|
|
21
|
+
```ts
|
|
22
|
+
import { z } from 'zod'
|
|
23
|
+
|
|
24
|
+
export const schema = {
|
|
25
|
+
// A generic 'users' table
|
|
26
|
+
users: {
|
|
27
|
+
readable: z.object({
|
|
28
|
+
id: z.string(),
|
|
29
|
+
name: z.string(),
|
|
30
|
+
email: z.string().email(),
|
|
31
|
+
}),
|
|
32
|
+
writable: z.object({
|
|
33
|
+
name: z.string().nonempty('Name is required'),
|
|
34
|
+
email: z.string().email(),
|
|
35
|
+
})
|
|
36
|
+
},
|
|
37
|
+
// A generic 'posts' table
|
|
38
|
+
posts: {
|
|
39
|
+
readable: z.object({
|
|
40
|
+
id: z.string(),
|
|
41
|
+
title: z.string(),
|
|
42
|
+
content: z.string().optional(),
|
|
43
|
+
authorId: z.string(),
|
|
44
|
+
}),
|
|
45
|
+
writable: z.object({
|
|
46
|
+
title: z.string().nonempty('Title is required'),
|
|
47
|
+
content: z.string().optional(),
|
|
48
|
+
authorId: z.string(),
|
|
49
|
+
})
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Step 2: Create a Server-Side Adapter
|
|
55
|
+
|
|
56
|
+
Implement the `TableInterface` for your chosen database. The framework is backend-agnostic. This project uses a Firestore adapter.
|
|
57
|
+
|
|
58
|
+
`packages/firestore.ts`:
|
|
59
|
+
```ts
|
|
60
|
+
import { TableInterface } from './interface'
|
|
61
|
+
import { firestore } from './firebase'
|
|
62
|
+
|
|
63
|
+
// A generic Firestore adapter implementing the standard interface
|
|
64
|
+
export function firestoreInterface<...>(defaultTable?: T): TableInterface<...> {
|
|
65
|
+
return {
|
|
66
|
+
async find({ table = defaultTable, id }) {
|
|
67
|
+
const docSnap = await firestore.collection(table).doc(id).get()
|
|
68
|
+
if (docSnap.exists) return { id: docSnap.id, ...docSnap.data() }
|
|
69
|
+
throw new Error(`Record not found`)
|
|
70
|
+
},
|
|
71
|
+
// ... list, create, update, remove implementations
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Then, instantiate the adapter for each of your tables.
|
|
77
|
+
|
|
78
|
+
`app/server.ts`:
|
|
79
|
+
```ts
|
|
80
|
+
import { firestoreInterface } from '@/packages/firestore'
|
|
81
|
+
import { schema } from './database'
|
|
82
|
+
|
|
83
|
+
// Create a server-side data access object
|
|
84
|
+
export const server = {
|
|
85
|
+
users: firestoreInterface<typeof schema, 'users'>('users'),
|
|
86
|
+
posts: firestoreInterface<typeof schema, 'posts'>('posts'),
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Step 3: Expose API Endpoints
|
|
91
|
+
|
|
92
|
+
With the server adapter ready, you can expose it as a RESTful API using `createDynamicRoute`. This automatically creates `GET`, `POST`, `PATCH`, and `DELETE` handlers. You can easily wrap the base implementation to add cross-cutting concerns like authentication.
|
|
93
|
+
|
|
94
|
+
`app/api/v1/[...params]/route.ts`:
|
|
95
|
+
```ts
|
|
96
|
+
import { authenticate } from '@/packages/auth0' // Your authentication logic
|
|
97
|
+
import { createDynamicRoute } from '@/packages/next-interface'
|
|
98
|
+
import { firestoreInterface } from '@/packages/firestore'
|
|
99
|
+
|
|
100
|
+
const dbInterface = firestoreInterface()
|
|
101
|
+
|
|
102
|
+
// Wrap the base interface to enforce business logic and authentication
|
|
103
|
+
const apiInterface = {
|
|
104
|
+
async create(props) {
|
|
105
|
+
const user = await authenticate()
|
|
106
|
+
// Example: Inject the authorId automatically
|
|
107
|
+
if (props.table === 'posts') {
|
|
108
|
+
props.data.authorId = user.id
|
|
109
|
+
}
|
|
110
|
+
return dbInterface.create(props)
|
|
111
|
+
},
|
|
112
|
+
async find(props) {
|
|
113
|
+
const user = await authenticate()
|
|
114
|
+
const result = await dbInterface.find(props)
|
|
115
|
+
// Example: Check for ownership on 'posts'
|
|
116
|
+
if (result.authorId && result.authorId !== user.id) {
|
|
117
|
+
throw new Error('Unauthorized')
|
|
118
|
+
}
|
|
119
|
+
return result
|
|
120
|
+
},
|
|
121
|
+
// ... implementations for update, remove, and list with auth checks
|
|
122
|
+
} as typeof dbInterface
|
|
123
|
+
|
|
124
|
+
// Export the dynamic route handlers
|
|
125
|
+
export const GET = createDynamicRoute(apiInterface)
|
|
126
|
+
export const POST = createDynamicRoute(apiInterface)
|
|
127
|
+
export const DELETE = createDynamicRoute(apiInterface)
|
|
128
|
+
export const PATCH = createDynamicRoute(apiInterface)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Step 4: Initialize the Client Interface
|
|
132
|
+
|
|
133
|
+
On the client, initialize the React interface. This will provide all the hooks and components you need to interact with your API.
|
|
134
|
+
|
|
135
|
+
`app/interface.tsx`:
|
|
136
|
+
```tsx
|
|
137
|
+
'use client'
|
|
138
|
+
import { fetchInterface } from '@/packages/fetch-interface'
|
|
139
|
+
import { schema } from './database'
|
|
140
|
+
import { reactInterface } from '@/packages/react-interface'
|
|
141
|
+
|
|
142
|
+
// Create the client-side fetcher
|
|
143
|
+
const fetcher = fetchInterface({ schema, baseUrl: '/api/v1' })
|
|
144
|
+
|
|
145
|
+
// Export all the React hooks and components for the app
|
|
146
|
+
export const {
|
|
147
|
+
DatabaseProvider,
|
|
148
|
+
useTable,
|
|
149
|
+
CreateForm,
|
|
150
|
+
UpdateForm,
|
|
151
|
+
SingleProvider,
|
|
152
|
+
useSingle,
|
|
153
|
+
// ... and more
|
|
154
|
+
} = reactInterface<typeof schema>(schema, fetcher)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Step 5: Provide Data with `DatabaseProvider`
|
|
158
|
+
|
|
159
|
+
In your layouts, use `DatabaseProvider` to fetch initial data and make it available to all components underneath it via React Context.
|
|
160
|
+
|
|
161
|
+
`app/posts/[id]/layout.tsx`:
|
|
162
|
+
```tsx
|
|
163
|
+
import { DatabaseProvider, SingleProvider } from '@/app/interface'
|
|
164
|
+
import { server } from '@/app/server'
|
|
165
|
+
import { layout } from '@/packages/next'
|
|
166
|
+
|
|
167
|
+
export default layout<{ id: string }>( async ({ id, children }) => {
|
|
168
|
+
// Fetch initial data on the server
|
|
169
|
+
const post = await server.posts.find({ id })
|
|
170
|
+
|
|
171
|
+
// Provide the fetched data to the client-side context
|
|
172
|
+
return (
|
|
173
|
+
<DatabaseProvider posts={{ [id]: post }} users={{}}>
|
|
174
|
+
<SingleProvider id={id} table='posts'>
|
|
175
|
+
{children}
|
|
176
|
+
</SingleProvider>
|
|
177
|
+
</DatabaseProvider>
|
|
178
|
+
)
|
|
179
|
+
} )
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Step 6: Consume Data with Hooks
|
|
183
|
+
|
|
184
|
+
Use the provided hooks to access and manipulate data in your components.
|
|
185
|
+
|
|
186
|
+
#### `useTable`
|
|
187
|
+
To work with a collection of records (list, create, update, delete).
|
|
188
|
+
|
|
189
|
+
`app/posts/page.tsx`:
|
|
190
|
+
```tsx
|
|
191
|
+
'use client'
|
|
192
|
+
import { useTable } from '@/app/interface'
|
|
193
|
+
import Link from 'next/link'
|
|
194
|
+
import { IconButton } from '@chakra-ui/react'
|
|
195
|
+
import { LuTrash } from 'react-icons/lu'
|
|
196
|
+
|
|
197
|
+
export default function PostsPage() {
|
|
198
|
+
const { array: posts, remove, list } = useTable('posts')
|
|
199
|
+
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
list.trigger({}) // Fetch all posts
|
|
202
|
+
}, [])
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<VStack>
|
|
206
|
+
{posts.map((post) => (
|
|
207
|
+
<HStack key={post.id}>
|
|
208
|
+
<Link href={`/posts/${post.id}`}>
|
|
209
|
+
<Text>{post.title}</Text>
|
|
210
|
+
</Link>
|
|
211
|
+
<IconButton
|
|
212
|
+
onClick={() => remove.trigger({id: post.id})}
|
|
213
|
+
disabled={remove.loading}
|
|
214
|
+
icon={<LuTrash />}
|
|
215
|
+
/>
|
|
216
|
+
</HStack>
|
|
217
|
+
))}
|
|
218
|
+
</VStack>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
#### `useSingle`
|
|
224
|
+
To work with a single record, often used within a `SingleProvider`.
|
|
225
|
+
|
|
226
|
+
`app/posts/[id]/page.tsx`:
|
|
227
|
+
```tsx
|
|
228
|
+
'use client'
|
|
229
|
+
import { useSingle } from '@/app/interface'
|
|
230
|
+
|
|
231
|
+
export default function PostPage() {
|
|
232
|
+
// Get the current post from the context provided by SingleProvider
|
|
233
|
+
const { single: post } = useSingle('posts')
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<article>
|
|
237
|
+
<h1>{post.title}</h1>
|
|
238
|
+
<p>{post.content}</p>
|
|
239
|
+
</article>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Step 7: High-Level Abstracted Forms
|
|
245
|
+
|
|
246
|
+
The framework provides `CreateForm` and `UpdateForm` components that handle form state, submission, and API calls for you.
|
|
247
|
+
|
|
248
|
+
`app/posts/new/page.tsx`:
|
|
249
|
+
```tsx
|
|
250
|
+
'use client'
|
|
251
|
+
import { CreateForm } from '@/app/interface'
|
|
252
|
+
import { Input, Button, Textarea } from '@chakra-ui/react'
|
|
253
|
+
|
|
254
|
+
export function NewPostPage() {
|
|
255
|
+
const { user } = useUser() // from @auth0/nextjs-auth0
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<CreateForm
|
|
259
|
+
table='posts'
|
|
260
|
+
// Set default values for the form
|
|
261
|
+
defaults={{ authorId: user.id, title: 'New Post' }}
|
|
262
|
+
onSuccess={(post) => router.push(`/posts/${post.id}`)}
|
|
263
|
+
>
|
|
264
|
+
{/* The child is a render prop with form state and field state */}
|
|
265
|
+
{({ loading, fields, setField, submit }) => (
|
|
266
|
+
<form onSubmit={submit}>
|
|
267
|
+
<Input
|
|
268
|
+
value={fields.title}
|
|
269
|
+
onChange={(e) => setField('title', e.target.value)}
|
|
270
|
+
/>
|
|
271
|
+
<Textarea
|
|
272
|
+
value={fields.content}
|
|
273
|
+
onChange={(e) => setField('content', e.target.value)}
|
|
274
|
+
/>
|
|
275
|
+
<Button type='submit' disabled={loading}>
|
|
276
|
+
{loading ? 'Saving...' : 'Create Post'}
|
|
277
|
+
</Button>
|
|
278
|
+
</form>
|
|
279
|
+
)}
|
|
280
|
+
</CreateForm>
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
#### `FilterForm`
|
|
286
|
+
Use `FilterForm` to easily create search and filter functionality. It wraps the `list` method of a table and displays the results.
|
|
287
|
+
|
|
288
|
+
`app/posts/SearchPosts.tsx`:
|
|
289
|
+
```tsx
|
|
290
|
+
'use client'
|
|
291
|
+
import { FilterForm } from '@/app/interface'
|
|
292
|
+
import { Input, Button, VStack, Text, Box } from '@chakra-ui/react'
|
|
293
|
+
|
|
294
|
+
function SearchPosts() {
|
|
295
|
+
return (
|
|
296
|
+
<Box>
|
|
297
|
+
<FilterForm
|
|
298
|
+
table="posts"
|
|
299
|
+
// Set default filter values. The form will initially load with these.
|
|
300
|
+
defaults={{ title: '' }}
|
|
301
|
+
>
|
|
302
|
+
{({ loading, fields, setField, submit, result }) => (
|
|
303
|
+
<VStack as="form" onSubmit={submit} align="stretch" spacing={4}>
|
|
304
|
+
<Input
|
|
305
|
+
placeholder="Search by title..."
|
|
306
|
+
value={fields.title}
|
|
307
|
+
onChange={(e) => setField('title', e.target.value)}
|
|
308
|
+
/>
|
|
309
|
+
<Button type="submit" disabled={loading} alignSelf="flex-start">
|
|
310
|
+
{loading ? 'Searching...' : 'Search'}
|
|
311
|
+
</Button>
|
|
312
|
+
|
|
313
|
+
{/* Display the results from the list call */}
|
|
314
|
+
<VStack align="stretch" mt={4} spacing={2}>
|
|
315
|
+
{result && result.length > 0 ? (
|
|
316
|
+
result.map(post => (
|
|
317
|
+
<Text key={post.id}>- {post.title}</Text>
|
|
318
|
+
))
|
|
319
|
+
) : (
|
|
320
|
+
<Text color="gray.500">No results found.</Text>
|
|
321
|
+
)}
|
|
322
|
+
</VStack>
|
|
323
|
+
</VStack>
|
|
324
|
+
)}
|
|
325
|
+
</FilterForm>
|
|
326
|
+
</Box>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
## Form and Field Primitives
|
|
332
|
+
|
|
333
|
+
The `CreateForm` and `UpdateForm` components are convenient abstractions. For more control, you can use the underlying `Form`, `ActionProvider`, and `FieldsProvider` primitives directly.
|
|
334
|
+
|
|
335
|
+
### `packages/action.tsx`
|
|
336
|
+
|
|
337
|
+
`ActionProvider` is a render-prop component that wraps your form submission logic. It manages the `loading`, `error`, and `result` state of an async action.
|
|
338
|
+
|
|
339
|
+
- **Props**:
|
|
340
|
+
- `action`: An async function that takes the form data and performs the submission.
|
|
341
|
+
- `params`: The data to pass to the action function.
|
|
342
|
+
- `onError`: An optional function to call when the action fails.
|
|
343
|
+
- `preload`: Optional boolean to trigger the action on mount.
|
|
344
|
+
- **Render Prop State**:
|
|
345
|
+
- `loading`: `boolean` - True while the action is executing.
|
|
346
|
+
- `error`: `Error | null` - The error object if the submission fails.
|
|
347
|
+
- `result`: `Result | null` - The data returned from a successful submission.
|
|
348
|
+
- `submit`: `function` - The function to trigger the action.
|
|
349
|
+
- `data`: `Fields` - The current data object for the action.
|
|
350
|
+
|
|
351
|
+
### `packages/fields.tsx`
|
|
352
|
+
|
|
353
|
+
`FieldsProvider` manages the state of your form's input fields. It holds the `fields` object and provides `setField` and `setFields` methods to update it.
|
|
354
|
+
|
|
355
|
+
- **Props**:
|
|
356
|
+
- `defaults`: The initial values for the form fields.
|
|
357
|
+
- **Render Prop State**:
|
|
358
|
+
- `fields`: `T` - The object containing the current state of all form fields.
|
|
359
|
+
- `setField`: `(name, value)` - A function to update a single field's value.
|
|
360
|
+
- `setFields`: `(newFields)` - A function to replace the entire fields object.
|
|
361
|
+
|
|
362
|
+
### `packages/form.tsx`
|
|
363
|
+
|
|
364
|
+
`Form` is a convenience component that combines `FieldsProvider` and `ActionProvider` into a single component for simpler usage.
|
|
365
|
+
|
|
366
|
+
- **Props**:
|
|
367
|
+
- `action`: An async function that takes the form data and performs the submission.
|
|
368
|
+
- `defaults`: The initial values for the form fields.
|
|
369
|
+
- `onError`: An optional function to call when the action fails.
|
|
370
|
+
- `preload`: Optional boolean to trigger the action on mount.
|
|
371
|
+
- **Render Prop State**:
|
|
372
|
+
- Combines all state from both `FieldsProvider` and `ActionProvider`
|
|
373
|
+
- `fields`, `setField`, `setFields` (from FieldsProvider)
|
|
374
|
+
- `loading`, `error`, `result`, `submit`, `data` (from ActionProvider)
|
|
375
|
+
|
|
376
|
+
### Using Form Primitives
|
|
377
|
+
|
|
378
|
+
Here are different ways to build a form using the low-level primitives:
|
|
379
|
+
|
|
380
|
+
**Option 1: Using `Form` (Recommended - Combines Fields + Action)**
|
|
381
|
+
|
|
382
|
+
```tsx
|
|
383
|
+
import { Form } from 'asasvirtuais/form'
|
|
384
|
+
import { Input, Button, Spinner, Text, Textarea, Stack } from '@chakra-ui/react'
|
|
385
|
+
|
|
386
|
+
function NewPostForm({ onCreate }) {
|
|
387
|
+
const createPostAction = async (fields) => {
|
|
388
|
+
const newPost = await api.posts.create({ data: fields });
|
|
389
|
+
return newPost;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<Form action={createPostAction} defaults={{ title: '', content: '', authorId: '1' }}>
|
|
394
|
+
{({ fields, setField, loading, error, submit }) => (
|
|
395
|
+
<form onSubmit={submit}>
|
|
396
|
+
<Stack>
|
|
397
|
+
<label>Title</label>
|
|
398
|
+
<Input
|
|
399
|
+
value={fields.title}
|
|
400
|
+
onChange={(e) => setField('title', e.target.value)}
|
|
401
|
+
/>
|
|
402
|
+
|
|
403
|
+
<label>Content</label>
|
|
404
|
+
<Textarea
|
|
405
|
+
value={fields.content}
|
|
406
|
+
onChange={(e) => setField('content', e.target.value)}
|
|
407
|
+
/>
|
|
408
|
+
|
|
409
|
+
{error && <Text color="red.500">{error.message}</Text>}
|
|
410
|
+
|
|
411
|
+
<Button type="submit" disabled={loading}>
|
|
412
|
+
{loading ? <Spinner /> : 'Create Post'}
|
|
413
|
+
</Button>
|
|
414
|
+
</Stack>
|
|
415
|
+
</form>
|
|
416
|
+
)}
|
|
417
|
+
</Form>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
**Option 2: Using `ActionProvider` and `FieldsProvider` Separately (Maximum Control)**
|
|
423
|
+
|
|
424
|
+
```tsx
|
|
425
|
+
import { ActionProvider } from 'asasvirtuais/action'
|
|
426
|
+
import { FieldsProvider } from 'asasvirtuais/fields'
|
|
427
|
+
import { Input, Button, Spinner, Text, Textarea, Stack } from '@chakra-ui/react'
|
|
428
|
+
|
|
429
|
+
function NewPostForm({ onCreate }) {
|
|
430
|
+
const createPostAction = async (fields) => {
|
|
431
|
+
const newPost = await api.posts.create({ data: fields });
|
|
432
|
+
return newPost;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
return (
|
|
436
|
+
// 1. FieldsProvider manages the state of the form's inputs
|
|
437
|
+
<FieldsProvider defaults={{ title: '', content: '', authorId: '1' }}>
|
|
438
|
+
{({ fields, setField }) => (
|
|
439
|
+
// 2. ActionProvider handles the submission process
|
|
440
|
+
<ActionProvider action={createPostAction} params={fields}>
|
|
441
|
+
{({ loading, error, submit }) => (
|
|
442
|
+
<form onSubmit={submit}>
|
|
443
|
+
<Stack>
|
|
444
|
+
<label>Title</label>
|
|
445
|
+
<Input
|
|
446
|
+
value={fields.title}
|
|
447
|
+
onChange={(e) => setField('title', e.target.value)}
|
|
448
|
+
/>
|
|
449
|
+
|
|
450
|
+
<label>Content</label>
|
|
451
|
+
<Textarea
|
|
452
|
+
value={fields.content}
|
|
453
|
+
onChange={(e) => setField('content', e.target.value)}
|
|
454
|
+
/>
|
|
455
|
+
|
|
456
|
+
{error && <Text color="red.500">{error.message}</Text>}
|
|
457
|
+
|
|
458
|
+
<Button type="submit" disabled={loading}>
|
|
459
|
+
{loading ? <Spinner /> : 'Create Post'}
|
|
460
|
+
</Button>
|
|
461
|
+
</Stack>
|
|
462
|
+
</form>
|
|
463
|
+
)}
|
|
464
|
+
</ActionProvider>
|
|
465
|
+
)}
|
|
466
|
+
</FieldsProvider>
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
## Roadmap
|
|
472
|
+
|
|
473
|
+
- [ ] Relationships, linked records, TableLookup field
|
|
474
|
+
- [ ] Realtime database with feathersjs
|