asasvirtuais 2.5.1 → 4.0.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 +359 -758
- package/package.json +52 -82
- package/packages/action.tsx +13 -28
- package/packages/fields.tsx +9 -9
- package/packages/form.tsx +165 -3
- package/packages/interface.ts +1 -1
- package/packages/post/fields.tsx +73 -0
- package/packages/post/provider.tsx +17 -0
- package/packages/post/schema.ts +35 -0
- package/packages/providers.tsx +174 -0
- package/tsconfig.json +40 -40
- package/packages/auth0.ts +0 -29
- package/packages/fetch-interface.ts +0 -70
- package/packages/fetch-provider.tsx +0 -14
- package/packages/indexed-interface.tsx +0 -143
- package/packages/interface-provider.tsx +0 -9
- package/packages/mem-interface.tsx +0 -92
- package/packages/mem-provider.tsx +0 -13
- package/packages/next-interface.ts +0 -120
- package/packages/react-interface.tsx +0 -334
package/README.md
CHANGED
|
@@ -1,894 +1,495 @@
|
|
|
1
1
|
# asasvirtuais
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A React framework for building full-stack apps where code is organized by feature, not by layer.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
This isn't about fancy animations or advanced performance optimization. This is about making codebases simple enough that you (or an AI) can focus on business logic instead of wrestling with architectural patterns.
|
|
5
|
+
---
|
|
8
6
|
|
|
9
|
-
##
|
|
7
|
+
## Primitives
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
Three building blocks, each usable on its own.
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
- Design patterns that make simple things complicated
|
|
15
|
-
- Dependencies injected through layers of abstraction
|
|
16
|
-
- Code that's impossible to reason about without opening 10 files
|
|
11
|
+
### `FieldsProvider` — field state
|
|
17
12
|
|
|
18
|
-
|
|
13
|
+
```tsx
|
|
14
|
+
import { FieldsProvider } from 'asasvirtuais/fields'
|
|
19
15
|
|
|
20
|
-
|
|
16
|
+
<FieldsProvider defaults={{ title: '', done: false }}>
|
|
17
|
+
{({ fields, setField }) => (
|
|
18
|
+
<div>
|
|
19
|
+
<input value={fields.title} onChange={e => setField('title', e.target.value)} />
|
|
20
|
+
<input type="checkbox" checked={fields.done} onChange={e => setField('done', e.target.checked)} />
|
|
21
|
+
</div>
|
|
22
|
+
)}
|
|
23
|
+
</FieldsProvider>
|
|
24
|
+
```
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
### `ActionProvider` — async action state
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
```tsx
|
|
29
|
+
import { ActionProvider } from 'asasvirtuais/action'
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
<ActionProvider params={{ id: todo.id }} action={archiveTodo} onResult={() => router.push('/')}>
|
|
32
|
+
{({ submit, loading, error }) => (
|
|
33
|
+
<button onClick={submit} disabled={loading}>
|
|
34
|
+
{loading ? 'Archiving...' : 'Archive'}
|
|
35
|
+
</button>
|
|
36
|
+
)}
|
|
37
|
+
</ActionProvider>
|
|
38
|
+
```
|
|
27
39
|
|
|
28
|
-
|
|
29
|
-
2. **CRUD operations as a solved problem** - Filter, create, update with zero boilerplate
|
|
30
|
-
3. **Code in one place** - Business logic lives in readable, single files, not scattered across a dependency tree
|
|
31
|
-
4. **AI-friendly patterns** - Simple enough that AI can generate complex forms correctly on the first try
|
|
40
|
+
### `Form` — fields + action together
|
|
32
41
|
|
|
33
|
-
|
|
42
|
+
```tsx
|
|
43
|
+
import { Form } from 'asasvirtuais/form'
|
|
34
44
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
<Form defaults={{ email: '', password: '' }} action={login} onResult={handleResult}>
|
|
46
|
+
{({ fields, setField, submit, loading, error }) => (
|
|
47
|
+
<form onSubmit={submit}>
|
|
48
|
+
<input value={fields.email} onChange={e => setField('email', e.target.value)} />
|
|
49
|
+
<input type="password" value={fields.password} onChange={e => setField('password', e.target.value)} />
|
|
50
|
+
<button type="submit" disabled={loading}>Login</button>
|
|
51
|
+
{error && <p>{error.message}</p>}
|
|
52
|
+
</form>
|
|
53
|
+
)}
|
|
54
|
+
</Form>
|
|
38
55
|
```
|
|
39
56
|
|
|
40
|
-
|
|
41
|
-
```typescript
|
|
42
|
-
import { Form } from 'https://esm.sh/asasvirtuais@latest/form'
|
|
43
|
-
import { useFields } from 'https://esm.sh/asasvirtuais@latest/fields'
|
|
44
|
-
import { useAction } from 'https://esm.sh/asasvirtuais@latest/action'
|
|
45
|
-
```
|
|
57
|
+
---
|
|
46
58
|
|
|
47
|
-
##
|
|
59
|
+
## Full-stack CRUD
|
|
48
60
|
|
|
49
|
-
|
|
61
|
+
The framework provides a schema-first CRUD layer where create, update, and remove operations automatically keep the UI in sync through a reactive index — no manual state updates, no refetching.
|
|
50
62
|
|
|
51
|
-
|
|
52
|
-
import { Form } from 'asasvirtuais/form'
|
|
63
|
+
### Project structure
|
|
53
64
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
```
|
|
66
|
+
app/
|
|
67
|
+
├── schema.ts # All table schemas in one place
|
|
68
|
+
├── actions.ts # Server actions — the backend
|
|
69
|
+
├── providers.tsx # App-level providers
|
|
70
|
+
├── layout.tsx
|
|
71
|
+
├── todos/
|
|
72
|
+
│ ├── schema.ts # Schema + types
|
|
73
|
+
│ ├── fields.tsx # Input components
|
|
74
|
+
│ ├── forms.tsx # Create / Update / Delete / Filter forms
|
|
75
|
+
│ ├── components.tsx # Display components
|
|
76
|
+
│ └── provider.tsx # TableProvider + hook
|
|
77
|
+
```
|
|
58
78
|
|
|
59
|
-
|
|
60
|
-
token: string
|
|
61
|
-
user: { id: string; name: string }
|
|
62
|
-
}
|
|
79
|
+
---
|
|
63
80
|
|
|
64
|
-
|
|
65
|
-
const response = await fetch('/api/login', {
|
|
66
|
-
method: 'POST',
|
|
67
|
-
body: JSON.stringify(fields)
|
|
68
|
-
})
|
|
69
|
-
return response.json()
|
|
70
|
-
}
|
|
81
|
+
### 1. Schema
|
|
71
82
|
|
|
72
|
-
|
|
73
|
-
return (
|
|
74
|
-
<Form<LoginFields, LoginResult>
|
|
75
|
-
defaults={{ email: '', password: '' }}
|
|
76
|
-
action={loginAction}
|
|
77
|
-
onResult={(result) => console.log('Logged in:', result.user.name)}
|
|
78
|
-
>
|
|
79
|
-
{({ fields, setField, submit, loading, error }) => (
|
|
80
|
-
<form onSubmit={submit}>
|
|
81
|
-
<input
|
|
82
|
-
type="email"
|
|
83
|
-
value={fields.email}
|
|
84
|
-
onChange={(e) => setField('email', e.target.value)}
|
|
85
|
-
/>
|
|
86
|
-
<input
|
|
87
|
-
type="password"
|
|
88
|
-
value={fields.password}
|
|
89
|
-
onChange={(e) => setField('password', e.target.value)}
|
|
90
|
-
/>
|
|
91
|
-
<button type="submit" disabled={loading}>
|
|
92
|
-
{loading ? 'Logging in...' : 'Login'}
|
|
93
|
-
</button>
|
|
94
|
-
{error && <p>Error: {error.message}</p>}
|
|
95
|
-
</form>
|
|
96
|
-
)}
|
|
97
|
-
</Form>
|
|
98
|
-
)
|
|
99
|
-
}
|
|
100
|
-
```
|
|
83
|
+
Each model defines `readable` (what comes out of the database) and `writable` (what users can create or modify):
|
|
101
84
|
|
|
102
|
-
|
|
85
|
+
```ts
|
|
86
|
+
// app/todos/schema.ts
|
|
87
|
+
import z from 'zod'
|
|
103
88
|
|
|
104
|
-
|
|
89
|
+
export const readable = z.object({
|
|
90
|
+
id: z.string(),
|
|
91
|
+
title: z.string(),
|
|
92
|
+
done: z.boolean(),
|
|
93
|
+
author: z.string(),
|
|
94
|
+
createdAt: z.string(),
|
|
95
|
+
})
|
|
105
96
|
|
|
106
|
-
|
|
97
|
+
export const writable = readable.pick({
|
|
98
|
+
title: true,
|
|
99
|
+
done: true,
|
|
100
|
+
})
|
|
107
101
|
|
|
108
|
-
|
|
109
|
-
// Multi-step form with async validation between steps
|
|
110
|
-
<Form<EmailFields, EmailResult>
|
|
111
|
-
defaults={{ email: '' }}
|
|
112
|
-
action={checkEmail}
|
|
113
|
-
>
|
|
114
|
-
{(emailForm) => (
|
|
115
|
-
<div>
|
|
116
|
-
<input
|
|
117
|
-
value={emailForm.fields.email}
|
|
118
|
-
onChange={(e) => emailForm.setField('email', e.target.value)}
|
|
119
|
-
/>
|
|
120
|
-
<button onClick={emailForm.submit}>Next</button>
|
|
121
|
-
|
|
122
|
-
{emailForm.result?.exists && (
|
|
123
|
-
<Form<PasswordFields, PasswordResult>
|
|
124
|
-
defaults={{ userId: emailForm.result.userId, password: '' }}
|
|
125
|
-
action={verifyPassword}
|
|
126
|
-
>
|
|
127
|
-
{(passwordForm) => (
|
|
128
|
-
<input
|
|
129
|
-
type="password"
|
|
130
|
-
value={passwordForm.fields.password}
|
|
131
|
-
onChange={(e) => passwordForm.setField('password', e.target.value)}
|
|
132
|
-
/>
|
|
133
|
-
)}
|
|
134
|
-
</Form>
|
|
135
|
-
)}
|
|
136
|
-
</div>
|
|
137
|
-
)}
|
|
138
|
-
</Form>
|
|
139
|
-
```
|
|
102
|
+
export const schema = { readable, writable }
|
|
140
103
|
|
|
141
|
-
|
|
104
|
+
export type Readable = z.infer<typeof readable>
|
|
105
|
+
export type Writable = z.infer<typeof writable>
|
|
106
|
+
```
|
|
142
107
|
|
|
143
|
-
|
|
108
|
+
All models are assembled into a single database schema file:
|
|
144
109
|
|
|
145
|
-
```
|
|
146
|
-
|
|
110
|
+
```ts
|
|
111
|
+
// app/schema.ts
|
|
112
|
+
import { schema as todosSchema } from './todos/schema'
|
|
113
|
+
import { schema as tagsSchema } from './tags/schema'
|
|
147
114
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
{({ fields, setField }) => (
|
|
152
|
-
<div>
|
|
153
|
-
<input
|
|
154
|
-
value={fields.name}
|
|
155
|
-
onChange={(e) => setField('name', e.target.value)}
|
|
156
|
-
/>
|
|
157
|
-
<textarea
|
|
158
|
-
value={fields.bio}
|
|
159
|
-
onChange={(e) => setField('bio', e.target.value)}
|
|
160
|
-
/>
|
|
161
|
-
</div>
|
|
162
|
-
)}
|
|
163
|
-
</FieldsProvider>
|
|
164
|
-
)
|
|
115
|
+
export const schema = {
|
|
116
|
+
todos: todosSchema,
|
|
117
|
+
tags: tagsSchema,
|
|
165
118
|
}
|
|
166
119
|
```
|
|
167
120
|
|
|
168
|
-
|
|
121
|
+
---
|
|
169
122
|
|
|
170
|
-
|
|
123
|
+
### 2. Server actions — the backend
|
|
171
124
|
|
|
172
|
-
|
|
173
|
-
import { ActionProvider } from 'asasvirtuais/action'
|
|
125
|
+
The backend is plain Next.js server actions. You pass them directly to the provider — no REST routes, no fetch client needed:
|
|
174
126
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
127
|
+
```ts
|
|
128
|
+
// app/actions.ts
|
|
129
|
+
'use server'
|
|
130
|
+
|
|
131
|
+
import { firestoreInterface } from 'asasvirtuais-firebase/interface'
|
|
132
|
+
import { auth0 } from '@/lib/auth0'
|
|
133
|
+
|
|
134
|
+
const db = firestoreInterface()
|
|
135
|
+
|
|
136
|
+
function clean(obj: any): any {
|
|
137
|
+
if (Array.isArray(obj)) return obj.map(clean)
|
|
138
|
+
if (obj !== null && typeof obj === 'object' && obj.constructor === Object) {
|
|
139
|
+
return Object.fromEntries(
|
|
140
|
+
Object.entries(obj)
|
|
141
|
+
.filter(([_, v]) => v !== undefined)
|
|
142
|
+
.map(([k, v]) => [k, clean(v)])
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
return obj
|
|
189
146
|
}
|
|
190
|
-
```
|
|
191
147
|
|
|
192
|
-
|
|
148
|
+
export const find = async (props: any) => db.find(props)
|
|
193
149
|
|
|
194
|
-
|
|
150
|
+
export const list = async (props: any) => db.list(props)
|
|
195
151
|
|
|
196
|
-
|
|
152
|
+
export const create = async (props: any) => {
|
|
153
|
+
const session = await auth0.getSession()
|
|
154
|
+
if (!session?.user) throw new Error('Unauthorized')
|
|
197
155
|
|
|
198
|
-
|
|
156
|
+
const data = clean({ ...props.data })
|
|
199
157
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
158
|
+
if (props.table === 'todos') {
|
|
159
|
+
data.author = session.user.id
|
|
160
|
+
data.done = false
|
|
161
|
+
data.createdAt = new Date().toISOString()
|
|
162
|
+
}
|
|
203
163
|
|
|
204
|
-
|
|
205
|
-
readable: z.object({
|
|
206
|
-
id: z.string(),
|
|
207
|
-
text: z.string(),
|
|
208
|
-
completed: z.boolean(),
|
|
209
|
-
createdAt: z.date(),
|
|
210
|
-
}),
|
|
211
|
-
writable: z.object({
|
|
212
|
-
text: z.string(),
|
|
213
|
-
completed: z.boolean().optional(),
|
|
214
|
-
}),
|
|
164
|
+
return db.create({ ...props, data })
|
|
215
165
|
}
|
|
216
166
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
167
|
+
export const update = async (props: any) => {
|
|
168
|
+
const session = await auth0.getSession()
|
|
169
|
+
if (!session?.user) throw new Error('Unauthorized')
|
|
170
|
+
|
|
171
|
+
const data = clean({ ...props.data })
|
|
172
|
+
|
|
173
|
+
if (props.table === 'todos') {
|
|
174
|
+
const existing = await db.find({ table: 'todos', id: props.id })
|
|
175
|
+
if (existing.author !== session.user.id) throw new Error('Forbidden')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return db.update({ ...props, data })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const remove = async (props: any) => {
|
|
182
|
+
const session = await auth0.getSession()
|
|
183
|
+
if (!session?.user) throw new Error('Unauthorized')
|
|
184
|
+
|
|
185
|
+
if (props.table === 'todos') {
|
|
186
|
+
const existing = await db.find({ table: 'todos', id: props.id })
|
|
187
|
+
if (existing.author !== session.user.id) throw new Error('Forbidden')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return db.remove(props)
|
|
228
191
|
}
|
|
229
192
|
```
|
|
230
193
|
|
|
231
|
-
|
|
194
|
+
This is where business logic lives: auth, default values, permission checks. All in one place, all readable top to bottom.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
### 3. Providers
|
|
232
199
|
|
|
233
200
|
```tsx
|
|
234
|
-
// app/
|
|
235
|
-
import { InterfaceProvider } from 'asasvirtuais/
|
|
236
|
-
import { DatabaseProvider
|
|
237
|
-
import {
|
|
201
|
+
// app/providers.tsx
|
|
202
|
+
import { InterfaceProvider } from 'asasvirtuais/interface-provider'
|
|
203
|
+
import { DatabaseProvider } from 'asasvirtuais/react-interface'
|
|
204
|
+
import { TodosProvider } from '@/app/todos/provider'
|
|
205
|
+
import * as db from '@/app/actions'
|
|
238
206
|
|
|
239
|
-
export default function
|
|
207
|
+
export default function AppProviders({ children }: { children: React.ReactNode }) {
|
|
240
208
|
return (
|
|
241
|
-
<
|
|
242
|
-
|
|
209
|
+
<InterfaceProvider
|
|
210
|
+
find={db.find}
|
|
211
|
+
list={db.list}
|
|
212
|
+
create={db.create}
|
|
213
|
+
update={db.update}
|
|
214
|
+
remove={db.remove}
|
|
215
|
+
>
|
|
216
|
+
<DatabaseProvider>
|
|
243
217
|
<TodosProvider>
|
|
244
218
|
{children}
|
|
245
219
|
</TodosProvider>
|
|
246
|
-
</
|
|
247
|
-
</
|
|
220
|
+
</DatabaseProvider>
|
|
221
|
+
</InterfaceProvider>
|
|
248
222
|
)
|
|
249
223
|
}
|
|
250
224
|
```
|
|
251
225
|
|
|
252
|
-
#### 3. Create Your Table Provider
|
|
253
|
-
|
|
254
226
|
```tsx
|
|
255
|
-
// app/
|
|
256
|
-
|
|
257
|
-
import { TableProvider } from 'asasvirtuais/react-interface'
|
|
258
|
-
import { useInterface } from 'asasvirtuais/fetch-interface'
|
|
259
|
-
import { todoSchema } from './database'
|
|
227
|
+
// app/layout.tsx
|
|
228
|
+
import AppProviders from './providers'
|
|
260
229
|
|
|
261
|
-
export function
|
|
230
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
262
231
|
return (
|
|
263
|
-
<
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
</
|
|
232
|
+
<html lang="en">
|
|
233
|
+
<body>
|
|
234
|
+
<AppProviders>
|
|
235
|
+
{children}
|
|
236
|
+
</AppProviders>
|
|
237
|
+
</body>
|
|
238
|
+
</html>
|
|
270
239
|
)
|
|
271
240
|
}
|
|
272
241
|
```
|
|
273
242
|
|
|
274
|
-
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
### 4. Model provider
|
|
275
246
|
|
|
276
247
|
```tsx
|
|
277
|
-
// app/todos/
|
|
248
|
+
// app/todos/provider.tsx
|
|
278
249
|
'use client'
|
|
279
|
-
import {
|
|
280
|
-
import {
|
|
250
|
+
import { TableProvider, useTable } from 'asasvirtuais/react-interface'
|
|
251
|
+
import { useInterface } from 'asasvirtuais/interface-provider'
|
|
252
|
+
import { schema } from '.'
|
|
281
253
|
|
|
282
|
-
function
|
|
283
|
-
|
|
284
|
-
|
|
254
|
+
export function useTodos() {
|
|
255
|
+
return useTable('todos', schema)
|
|
256
|
+
}
|
|
285
257
|
|
|
258
|
+
export function TodosProvider({ children }: { children: React.ReactNode }) {
|
|
286
259
|
return (
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
schema={todoSchema}
|
|
291
|
-
defaults={{ text: '' }}
|
|
292
|
-
>
|
|
293
|
-
{({ fields, setField, submit, loading }) => (
|
|
294
|
-
<form onSubmit={submit}>
|
|
295
|
-
<input
|
|
296
|
-
value={fields.text}
|
|
297
|
-
onChange={(e) => setField('text', e.target.value)}
|
|
298
|
-
placeholder="What needs to be done?"
|
|
299
|
-
/>
|
|
300
|
-
<button type="submit" disabled={loading}>
|
|
301
|
-
{loading ? 'Adding...' : 'Add Todo'}
|
|
302
|
-
</button>
|
|
303
|
-
</form>
|
|
304
|
-
)}
|
|
305
|
-
</CreateForm>
|
|
306
|
-
|
|
307
|
-
<ul>
|
|
308
|
-
{todos.map(todo => (
|
|
309
|
-
<li key={todo.id}>
|
|
310
|
-
<span
|
|
311
|
-
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
|
|
312
|
-
onClick={() => update.trigger({
|
|
313
|
-
id: todo.id,
|
|
314
|
-
data: { completed: !todo.completed }
|
|
315
|
-
})}
|
|
316
|
-
>
|
|
317
|
-
{todo.text}
|
|
318
|
-
</span>
|
|
319
|
-
<button onClick={() => remove.trigger({ id: todo.id })}>
|
|
320
|
-
Delete
|
|
321
|
-
</button>
|
|
322
|
-
</li>
|
|
323
|
-
))}
|
|
324
|
-
</ul>
|
|
325
|
-
</>
|
|
260
|
+
<TableProvider table="todos" schema={schema} interface={useInterface()}>
|
|
261
|
+
{children}
|
|
262
|
+
</TableProvider>
|
|
326
263
|
)
|
|
327
264
|
}
|
|
328
265
|
```
|
|
329
266
|
|
|
330
|
-
|
|
267
|
+
---
|
|
331
268
|
|
|
332
|
-
|
|
269
|
+
### 5. UI
|
|
333
270
|
|
|
334
271
|
```tsx
|
|
335
|
-
// app/
|
|
336
|
-
|
|
337
|
-
import {
|
|
338
|
-
import {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
fetchUsers()
|
|
344
|
-
])
|
|
345
|
-
|
|
346
|
-
return (
|
|
347
|
-
<DatabaseProvider>
|
|
348
|
-
<TableProvider table="todos" schema={todoSchema} interface={todosInterface} asAbove={initialTodos}>
|
|
349
|
-
<TableProvider table="users" schema={userSchema} interface={usersInterface} asAbove={initialUsers}>
|
|
350
|
-
{children}
|
|
351
|
-
</TableProvider>
|
|
352
|
-
</TableProvider>
|
|
353
|
-
</DatabaseProvider>
|
|
354
|
-
)
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Now any component can access tables:
|
|
358
|
-
function MyComponent() {
|
|
359
|
-
const todos = useTable('todos', todoSchema)
|
|
360
|
-
const users = useTable('users', userSchema)
|
|
361
|
-
// ...
|
|
362
|
-
}
|
|
363
|
-
```
|
|
272
|
+
// app/todos/page.tsx
|
|
273
|
+
'use client'
|
|
274
|
+
import { useEffect } from 'react'
|
|
275
|
+
import { useTodos } from './provider'
|
|
276
|
+
import { SingleProvider } from 'asasvirtuais/react-interface'
|
|
277
|
+
import { schema } from '.'
|
|
278
|
+
import { TodoItem } from './components'
|
|
279
|
+
import { CreateTodo } from './forms'
|
|
364
280
|
|
|
365
|
-
|
|
281
|
+
export default function TodosPage() {
|
|
282
|
+
const { array, list } = useTodos()
|
|
366
283
|
|
|
367
|
-
|
|
284
|
+
useEffect(() => { list.trigger({}) }, [])
|
|
368
285
|
|
|
369
|
-
|
|
370
|
-
// Complete checkout flow with async address lookup
|
|
371
|
-
<Form<AddressLookupFields, AddressLookupResult>
|
|
372
|
-
defaults={{ zipCode: '' }}
|
|
373
|
-
action={lookupAddress}
|
|
374
|
-
>
|
|
375
|
-
{(zipForm) => (
|
|
286
|
+
return (
|
|
376
287
|
<div>
|
|
377
|
-
<
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
{zipForm.result && (
|
|
385
|
-
<Form<FullAddressFields, OrderResult>
|
|
386
|
-
defaults={{
|
|
387
|
-
zipCode: zipForm.fields.zipCode,
|
|
388
|
-
city: zipForm.result.city,
|
|
389
|
-
state: zipForm.result.state,
|
|
390
|
-
country: zipForm.result.country,
|
|
391
|
-
street: '',
|
|
392
|
-
number: ''
|
|
393
|
-
}}
|
|
394
|
-
action={createOrder}
|
|
395
|
-
onResult={(result) => alert(`Order created: ${result.orderId}`)}
|
|
396
|
-
>
|
|
397
|
-
{(addressForm) => (
|
|
398
|
-
<div>
|
|
399
|
-
<h3>Complete Address</h3>
|
|
400
|
-
<p>City: {addressForm.fields.city}</p>
|
|
401
|
-
<p>State: {addressForm.fields.state}</p>
|
|
402
|
-
<input
|
|
403
|
-
value={addressForm.fields.street}
|
|
404
|
-
onChange={(e) => addressForm.setField('street', e.target.value)}
|
|
405
|
-
placeholder="Street"
|
|
406
|
-
/>
|
|
407
|
-
<input
|
|
408
|
-
value={addressForm.fields.number}
|
|
409
|
-
onChange={(e) => addressForm.setField('number', e.target.value)}
|
|
410
|
-
placeholder="Number"
|
|
411
|
-
/>
|
|
412
|
-
<button onClick={addressForm.submit}>Place Order</button>
|
|
413
|
-
</div>
|
|
414
|
-
)}
|
|
415
|
-
</Form>
|
|
416
|
-
)}
|
|
288
|
+
<CreateTodo />
|
|
289
|
+
{array.map(todo => (
|
|
290
|
+
<SingleProvider key={todo.id} id={todo.id} table="todos" schema={schema}>
|
|
291
|
+
<TodoItem />
|
|
292
|
+
</SingleProvider>
|
|
293
|
+
))}
|
|
417
294
|
</div>
|
|
418
|
-
)
|
|
419
|
-
|
|
295
|
+
)
|
|
296
|
+
}
|
|
420
297
|
```
|
|
421
298
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
One of asasvirtuais's core strengths is making effects simple. No middleware arrays, no lifecycle hooks—just write code where it belongs.
|
|
299
|
+
When `create` resolves, the item appears in `array` immediately. Same for `update` and `remove`.
|
|
425
300
|
|
|
426
|
-
|
|
301
|
+
---
|
|
427
302
|
|
|
428
|
-
|
|
303
|
+
## Listing vs. filtering
|
|
429
304
|
|
|
430
|
-
|
|
305
|
+
### `useTable().list` — reactive, global
|
|
431
306
|
|
|
432
|
-
|
|
307
|
+
Use this when you want all records in the reactive index. Results live in `array` and stay in sync with every create, update, and remove automatically:
|
|
433
308
|
|
|
434
309
|
```tsx
|
|
435
|
-
|
|
436
|
-
import { messageSchema } from '@/app/database'
|
|
310
|
+
const { array, list } = useTodos()
|
|
437
311
|
|
|
438
|
-
|
|
439
|
-
table="messages"
|
|
440
|
-
schema={messageSchema}
|
|
441
|
-
defaults={{ content: '' }}
|
|
442
|
-
>
|
|
443
|
-
{({ fields, setField, submit, loading }) => (
|
|
444
|
-
<form onSubmit={submit}>
|
|
445
|
-
<textarea
|
|
446
|
-
value={fields.content}
|
|
447
|
-
onChange={(e) => setField('content', e.target.value)}
|
|
448
|
-
/>
|
|
449
|
-
<button
|
|
450
|
-
onClick={() => {
|
|
451
|
-
// Pre-flight effect - runs before submit
|
|
452
|
-
trackEvent('message_submit_clicked')
|
|
453
|
-
validateContent(fields.content)
|
|
454
|
-
submit()
|
|
455
|
-
}}
|
|
456
|
-
disabled={loading}
|
|
457
|
-
>
|
|
458
|
-
Send
|
|
459
|
-
</button>
|
|
460
|
-
</form>
|
|
461
|
-
)}
|
|
462
|
-
</CreateForm>
|
|
463
|
-
```
|
|
312
|
+
useEffect(() => { list.trigger({}) }, [])
|
|
464
313
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
table="messages"
|
|
472
|
-
schema={messageSchema}
|
|
473
|
-
defaults={{ content: '' }}
|
|
474
|
-
onSuccess={(message) => {
|
|
475
|
-
// Post-flight effects - run after success
|
|
476
|
-
notifyUser('Message sent!')
|
|
477
|
-
scrollToBottom()
|
|
478
|
-
trackAnalytics('message_created', { id: message.id })
|
|
479
|
-
}}
|
|
480
|
-
>
|
|
481
|
-
{({ fields, setField, submit }) => (
|
|
482
|
-
<form onSubmit={submit}>
|
|
483
|
-
{/* form fields */}
|
|
484
|
-
</form>
|
|
485
|
-
)}
|
|
486
|
-
</CreateForm>
|
|
314
|
+
// array updates automatically when any todo is created, updated, or removed
|
|
315
|
+
return array.map(todo => (
|
|
316
|
+
<SingleProvider key={todo.id} id={todo.id} table="todos" schema={schema}>
|
|
317
|
+
<TodoItem />
|
|
318
|
+
</SingleProvider>
|
|
319
|
+
))
|
|
487
320
|
```
|
|
488
321
|
|
|
489
|
-
|
|
322
|
+
### `FilterForm` — local, paginated, or conditional
|
|
490
323
|
|
|
491
|
-
|
|
324
|
+
Use `FilterForm` when you need pagination, live search, or results that belong to the component rather than the global index. Results live in `form.result` and only update when `submit` is called:
|
|
492
325
|
|
|
493
326
|
```tsx
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
{(form) => (
|
|
327
|
+
import { FilterForm } from 'asasvirtuais/form'
|
|
328
|
+
import { schema } from '.'
|
|
329
|
+
|
|
330
|
+
<FilterForm table="todos" schema={schema} defaults={{ query: { done: false } }} autoTrigger>
|
|
331
|
+
{({ result, loading, fields, setField, submit }) => (
|
|
500
332
|
<div>
|
|
501
|
-
<
|
|
502
|
-
|
|
503
|
-
|
|
333
|
+
<input
|
|
334
|
+
placeholder="Search..."
|
|
335
|
+
value={fields.query?.title ?? ''}
|
|
336
|
+
onChange={e => {
|
|
337
|
+
setField('query', { title: e.target.value })
|
|
338
|
+
submit()
|
|
339
|
+
}}
|
|
504
340
|
/>
|
|
505
|
-
|
|
506
|
-
{
|
|
507
|
-
<button onClick={form.submit}>Send to Server</button>
|
|
508
|
-
|
|
509
|
-
{/* This button uses field values without calling the action */}
|
|
510
|
-
<button onClick={() => {
|
|
511
|
-
// Just use the field values directly for local operations
|
|
512
|
-
saveToLocalStorage(form.fields)
|
|
513
|
-
showPreview(form.fields)
|
|
514
|
-
}}>
|
|
515
|
-
Save Draft Locally
|
|
516
|
-
</button>
|
|
341
|
+
{loading && <p>Loading...</p>}
|
|
342
|
+
{result?.map(todo => <p key={todo.id}>{todo.title}</p>)}
|
|
517
343
|
</div>
|
|
518
344
|
)}
|
|
519
|
-
</
|
|
520
|
-
```
|
|
521
|
-
|
|
522
|
-
### Backend Effects (API Routes)
|
|
523
|
-
|
|
524
|
-
On the backend, effects are just functions wrapping other functions. No framework magic.
|
|
525
|
-
|
|
526
|
-
#### Using tableInterface for Business Logic
|
|
527
|
-
|
|
528
|
-
```typescript
|
|
529
|
-
// app/api/v1/[...params]/route.ts
|
|
530
|
-
import { tableInterface } from '@asasvirtuais/interface'
|
|
531
|
-
import { firestoreInterface } from '@/lib/firestore'
|
|
532
|
-
import { messageSchema } from '@/app/database'
|
|
533
|
-
|
|
534
|
-
// Wrap your base interface with business logic
|
|
535
|
-
const messagesInterface = tableInterface(messageSchema, 'messages', {
|
|
536
|
-
async create(props) {
|
|
537
|
-
// Pre-flight validation
|
|
538
|
-
await checkUserQuota(props.data.userId)
|
|
539
|
-
await moderateContent(props.data.content)
|
|
540
|
-
|
|
541
|
-
// The actual database operation
|
|
542
|
-
const message = await firestoreInterface.create(props)
|
|
543
|
-
|
|
544
|
-
// Post-flight side effects
|
|
545
|
-
await updateConversationTimestamp(message.conversationId)
|
|
546
|
-
await notifyParticipants(message.conversationId, message.id)
|
|
547
|
-
await trackMessageCreated(message)
|
|
548
|
-
|
|
549
|
-
return message
|
|
550
|
-
},
|
|
551
|
-
|
|
552
|
-
async update(props) {
|
|
553
|
-
const existing = await firestoreInterface.find(props)
|
|
554
|
-
|
|
555
|
-
// Business rule enforcement
|
|
556
|
-
if (existing.role === 'assistant') {
|
|
557
|
-
throw new Error("Cannot edit assistant messages")
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
if (existing.userId !== getCurrentUserId()) {
|
|
561
|
-
throw new Error("Cannot edit other users' messages")
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
return firestoreInterface.update(props)
|
|
565
|
-
},
|
|
566
|
-
|
|
567
|
-
async remove(props) {
|
|
568
|
-
const message = await firestoreInterface.find(props)
|
|
569
|
-
|
|
570
|
-
// Cascade deletion
|
|
571
|
-
await deleteMessageAttachments(message.id)
|
|
572
|
-
await updateConversationCount(message.conversationId, -1)
|
|
573
|
-
|
|
574
|
-
return firestoreInterface.remove(props)
|
|
575
|
-
},
|
|
576
|
-
|
|
577
|
-
// Pass through operations that don't need custom logic
|
|
578
|
-
find: firestoreInterface.find,
|
|
579
|
-
list: firestoreInterface.list,
|
|
580
|
-
})
|
|
581
|
-
```
|
|
582
|
-
|
|
583
|
-
### Key Principles
|
|
584
|
-
|
|
585
|
-
1. **Effects are just code** - No special lifecycle methods or middleware patterns
|
|
586
|
-
2. **Control flow is visible** - Reading the code tells you exactly what runs and when
|
|
587
|
-
3. **Composition over configuration** - Wrap functions to add behavior, don't configure hooks
|
|
588
|
-
4. **Backend and frontend mirror each other** - The same compositional patterns work everywhere
|
|
589
|
-
|
|
590
|
-
## API Reference
|
|
591
|
-
|
|
592
|
-
### `Form<Fields, Result>`
|
|
593
|
-
|
|
594
|
-
Combined fields and action management.
|
|
595
|
-
|
|
596
|
-
**Props:**
|
|
597
|
-
- `defaults?: Partial<Fields>` - Initial field values
|
|
598
|
-
- `action: (fields: Fields) => Promise<Result>` - Async action to perform
|
|
599
|
-
- `onResult?: (result: Result) => void` - Success callback
|
|
600
|
-
- `onError?: (error: Error) => void` - Error callback
|
|
601
|
-
- `autoTrigger?: boolean` - Auto-trigger action on mount
|
|
602
|
-
- `children: ReactNode | (props) => ReactNode` - Render prop or children
|
|
603
|
-
|
|
604
|
-
**Render Props:**
|
|
605
|
-
- `fields: Fields` - Current field values
|
|
606
|
-
- `setField: (name, value) => void` - Update single field
|
|
607
|
-
- `setFields: (fields) => void` - Update multiple fields
|
|
608
|
-
- `submit: (e?) => Promise<void>` - Trigger action
|
|
609
|
-
- `loading: boolean` - Action loading state
|
|
610
|
-
- `result: Result | null` - Action result
|
|
611
|
-
- `error: Error | null` - Action error
|
|
612
|
-
|
|
613
|
-
### `FieldsProvider<T>`
|
|
614
|
-
|
|
615
|
-
Field state management only.
|
|
616
|
-
|
|
617
|
-
**Props:**
|
|
618
|
-
- `defaults?: Partial<T>` - Initial field values
|
|
619
|
-
- `children: ReactNode | (props) => ReactNode` - Render prop or children
|
|
620
|
-
|
|
621
|
-
**Hook: `useFields<T>()`**
|
|
622
|
-
- `fields: T` - Current field values
|
|
623
|
-
- `setField: (name, value) => void` - Update single field
|
|
624
|
-
- `setFields: (fields) => void` - Update multiple fields
|
|
625
|
-
|
|
626
|
-
### `ActionProvider<Params, Result>`
|
|
627
|
-
|
|
628
|
-
Action management only.
|
|
629
|
-
|
|
630
|
-
**Props:**
|
|
631
|
-
- `params: Partial<Params>` - Action parameters
|
|
632
|
-
- `action: (params) => Promise<Result>` - Async action
|
|
633
|
-
- `onResult?: (result: Result) => void` - Success callback
|
|
634
|
-
- `onError?: (error: Error) => void` - Error callback
|
|
635
|
-
- `autoTrigger?: boolean` - Auto-trigger on mount
|
|
636
|
-
- `children: ReactNode | (props) => ReactNode` - Render prop or children
|
|
637
|
-
|
|
638
|
-
**Hook: `useAction<Params, Result>()`**
|
|
639
|
-
- `params: Partial<Params>` - Current parameters
|
|
640
|
-
- `submit: (e?) => Promise<void>` - Trigger action
|
|
641
|
-
- `loading: boolean` - Loading state
|
|
642
|
-
- `result: Result | null` - Action result
|
|
643
|
-
- `error: Error | null` - Action error
|
|
644
|
-
|
|
645
|
-
## Philosophy
|
|
646
|
-
|
|
647
|
-
### Code Maintainability Over Everything
|
|
648
|
-
|
|
649
|
-
The industry has normalized spreading code across dozens of files with dependency injection, decorators, and "clean architecture" patterns that make simple things complicated. asasvirtuais takes the opposite approach:
|
|
650
|
-
|
|
651
|
-
**Keep business logic in single, readable files.**
|
|
652
|
-
|
|
653
|
-
When you can see all the logic in one place, you can reason about it. When logic is scattered, every change becomes archaeology.
|
|
654
|
-
|
|
655
|
-
### Made for Humans and AI
|
|
656
|
-
|
|
657
|
-
The patterns in asasvirtuais are simple enough that:
|
|
658
|
-
- Junior developers can understand them in minutes
|
|
659
|
-
- Senior developers appreciate the lack of ceremony
|
|
660
|
-
- AI assistants can generate correct implementations on the first try
|
|
661
|
-
|
|
662
|
-
This isn't about dumbing down—it's about removing accidental complexity.
|
|
663
|
-
|
|
664
|
-
### Against "Babel Towering"
|
|
665
|
-
|
|
666
|
-
The AI trend seems focused on generating massive codebases quickly, stacking abstraction on abstraction. That's how you build towers that fall.
|
|
667
|
-
|
|
668
|
-
asasvirtuais is designed for the opposite: codebases that stay maintainable even as they grow.
|
|
669
|
-
|
|
670
|
-
## Real-World Use
|
|
671
|
-
|
|
672
|
-
I've used asasvirtuais with Airtable for data modeling on production projects. The combination of a simple frontend framework and a flexible backend lets you focus on solving actual problems instead of fighting your tools.
|
|
673
|
-
|
|
674
|
-
## AI Integration
|
|
675
|
-
|
|
676
|
-
Give AI the asasvirtuais documentation and watch it generate multi-step forms with async validation in a single file—something that would normally require multiple files, complex state management, and careful coordination.
|
|
677
|
-
|
|
678
|
-
Try it with [Google AI Studio](https://ai.studio/apps/drive/1-MwQzpbgMZhRqSbpqQYX1IRpvj61F_l8).
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
# Model Package Instructions
|
|
682
|
-
|
|
683
|
-
A model package is a self-contained module that defines a data model and provides React components for interacting with that data. Based on the chat example, here's how to structure a model package:
|
|
684
|
-
|
|
685
|
-
## File Structure
|
|
686
|
-
|
|
687
|
-
```
|
|
688
|
-
app/[model-name]/
|
|
689
|
-
├── index.ts # Schema definitions and types
|
|
690
|
-
├── fields.tsx # Individual form field components
|
|
691
|
-
├── forms.tsx # Complete form components
|
|
692
|
-
└── table.tsx # Provider and hooks for data access
|
|
345
|
+
</FilterForm>
|
|
693
346
|
```
|
|
694
347
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
Define your data model using Zod schemas:
|
|
698
|
-
|
|
699
|
-
```typescript
|
|
700
|
-
import z from 'zod'
|
|
701
|
-
|
|
702
|
-
// Define the complete object structure (what comes from the database)
|
|
703
|
-
export const readable = z.object({
|
|
704
|
-
id: z.string(),
|
|
705
|
-
// ... other fields that can be read
|
|
706
|
-
})
|
|
707
|
-
|
|
708
|
-
// Define which fields can be written/modified
|
|
709
|
-
export const writable = readable.pick({
|
|
710
|
-
// ... fields that can be created/updated
|
|
711
|
-
})
|
|
712
|
-
|
|
713
|
-
// Export the schema object
|
|
714
|
-
export const schema = { readable, writable }
|
|
715
|
-
|
|
716
|
-
// Export TypeScript types
|
|
717
|
-
export type Readable = z.infer<typeof readable>
|
|
718
|
-
export type Writable = z.infer<typeof writable>
|
|
719
|
-
```
|
|
348
|
+
---
|
|
720
349
|
|
|
721
|
-
|
|
722
|
-
- `readable`: Full object schema including `id` and all readable fields
|
|
723
|
-
- `writable`: Subset of fields that users can create/modify (typically excludes `id`)
|
|
724
|
-
- Use `.pick()` to select fields from readable for writable
|
|
725
|
-
- Export both the schema object and TypeScript types
|
|
350
|
+
## Async selector fields
|
|
726
351
|
|
|
727
|
-
|
|
352
|
+
When a form needs the user to pick a record from another table, `FilterForm` composes naturally inside a field component. The field reads and writes to the parent form's context via `useFields()` — no props needed to bridge them.
|
|
728
353
|
|
|
729
|
-
|
|
354
|
+
Say a todo can be tagged, and the user needs to search and select a tag while creating the todo:
|
|
730
355
|
|
|
731
|
-
```
|
|
732
|
-
|
|
356
|
+
```tsx
|
|
357
|
+
// app/todos/fields.tsx
|
|
733
358
|
import { useFields } from 'asasvirtuais/fields'
|
|
359
|
+
import { FilterForm } from 'asasvirtuais/form'
|
|
360
|
+
import { schema as tagsSchema } from '@/app/tags'
|
|
734
361
|
|
|
735
|
-
export function
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
362
|
+
export function TagSelectorField() {
|
|
363
|
+
// reads/writes to whatever Form or FieldsProvider this is rendered inside
|
|
364
|
+
const { fields, setField } = useFields<{ tagId: string }>()
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<FilterForm table="tags" schema={tagsSchema} defaults={{ query: {} }}>
|
|
368
|
+
{({ fields: search, setField: setSearch, submit, result }) => (
|
|
369
|
+
<div>
|
|
370
|
+
<input
|
|
371
|
+
placeholder="Search tags..."
|
|
372
|
+
onChange={e => {
|
|
373
|
+
setSearch('query', { name: e.target.value })
|
|
374
|
+
submit()
|
|
375
|
+
}}
|
|
376
|
+
/>
|
|
377
|
+
<ul>
|
|
378
|
+
{result?.map(tag => (
|
|
379
|
+
<li
|
|
380
|
+
key={tag.id}
|
|
381
|
+
onClick={() => setField('tagId', tag.id)}
|
|
382
|
+
style={{ fontWeight: fields.tagId === tag.id ? 'bold' : 'normal' }}
|
|
383
|
+
>
|
|
384
|
+
{tag.name}
|
|
385
|
+
</li>
|
|
386
|
+
))}
|
|
387
|
+
</ul>
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
</FilterForm>
|
|
391
|
+
)
|
|
746
392
|
}
|
|
747
393
|
```
|
|
748
394
|
|
|
749
|
-
|
|
750
|
-
- Use `useFields` hook with type annotation matching your field
|
|
751
|
-
- Pass through additional props using spread operator
|
|
752
|
-
- Set appropriate `name` attribute
|
|
753
|
-
- Handle `onChange` with `setField`
|
|
754
|
-
|
|
755
|
-
## 3. Form Components (`forms.tsx`)
|
|
395
|
+
Use it inside any form — it just works:
|
|
756
396
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
import { CreateForm, FilterForm, useTableInterface } from 'asasvirtuais/react-interface'
|
|
397
|
+
```tsx
|
|
398
|
+
// app/todos/forms.tsx
|
|
399
|
+
import { CreateForm } from 'asasvirtuais/form'
|
|
761
400
|
import { schema } from '.'
|
|
762
|
-
import {
|
|
763
|
-
import { Stack, Button } from '@chakra-ui/react'
|
|
764
|
-
|
|
765
|
-
// Create form
|
|
766
|
-
export function Create[Model]() {
|
|
767
|
-
return (
|
|
768
|
-
<CreateForm table='tableName' schema={schema}>
|
|
769
|
-
{form => (
|
|
770
|
-
<Stack as='form' onSubmit={form.submit}>
|
|
771
|
-
<[Field]Field />
|
|
772
|
-
<Button type='submit'>Create [Model]</Button>
|
|
773
|
-
</Stack>
|
|
774
|
-
)}
|
|
775
|
-
</CreateForm>
|
|
776
|
-
)
|
|
777
|
-
}
|
|
401
|
+
import { TitleField, TagSelectorField } from './fields'
|
|
778
402
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
</div>
|
|
794
|
-
))}
|
|
795
|
-
</Stack>
|
|
796
|
-
)}
|
|
797
|
-
</FilterForm>
|
|
798
|
-
)
|
|
403
|
+
export function CreateTodo({ onSuccess }: { onSuccess?: () => void }) {
|
|
404
|
+
return (
|
|
405
|
+
<CreateForm table="todos" schema={schema} defaults={{ title: '', tagId: '' }} onSuccess={onSuccess}>
|
|
406
|
+
{({ submit, loading }) => (
|
|
407
|
+
<div>
|
|
408
|
+
<TitleField />
|
|
409
|
+
<TagSelectorField />
|
|
410
|
+
<button onClick={submit} disabled={loading}>
|
|
411
|
+
{loading ? 'Creating...' : 'Create Todo'}
|
|
412
|
+
</button>
|
|
413
|
+
</div>
|
|
414
|
+
)}
|
|
415
|
+
</CreateForm>
|
|
416
|
+
)
|
|
799
417
|
}
|
|
800
418
|
```
|
|
801
419
|
|
|
802
|
-
|
|
803
|
-
- `CreateForm` handles creation logic, provides `form.submit`
|
|
804
|
-
- `FilterForm` provides `form.result` with filtered data
|
|
805
|
-
- Use `useTableInterface` for operations like `remove`
|
|
806
|
-
- Always provide `table` name and `schema` to forms
|
|
807
|
-
|
|
808
|
-
## 4. Provider and Hooks (`table.tsx`)
|
|
420
|
+
The `FilterForm` queries the `tags` table asynchronously. The `CreateForm` owns the selected `tagId`. Neither knows about the other.
|
|
809
421
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
```typescript
|
|
813
|
-
'use client'
|
|
814
|
-
import { TableProvider, useTableInterface } from 'asasvirtuais/react-interface'
|
|
815
|
-
import { useInterface } from 'asasvirtuais/fetch-interface'
|
|
816
|
-
import { schema } from '.'
|
|
422
|
+
---
|
|
817
423
|
|
|
818
|
-
|
|
819
|
-
return useTableInterface('tableName', schema)
|
|
820
|
-
}
|
|
424
|
+
## The single record pattern
|
|
821
425
|
|
|
822
|
-
|
|
823
|
-
return (
|
|
824
|
-
<TableProvider
|
|
825
|
-
table='tableName'
|
|
826
|
-
schema={schema}
|
|
827
|
-
interface={useInterface()}
|
|
828
|
-
>
|
|
829
|
-
{children}
|
|
830
|
-
</TableProvider>
|
|
831
|
-
)
|
|
832
|
-
}
|
|
833
|
-
```
|
|
834
|
-
|
|
835
|
-
Then in your layout, wrap with `InterfaceProvider` to set up the context:
|
|
426
|
+
`SingleProvider` makes a record available to all its descendants without prop drilling. When multiple components share one record, wrap them all in one provider:
|
|
836
427
|
|
|
837
428
|
```tsx
|
|
838
|
-
import {
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
<
|
|
844
|
-
<
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
**Key Points:**
|
|
853
|
-
- Mark table.tsx as `'use client'` for Next.js
|
|
854
|
-
- `InterfaceProvider` creates the fetch interface and provides it via context
|
|
855
|
-
- `useInterface()` retrieves the interface from context inside `TableProvider`
|
|
856
|
-
- Create a custom hook for easy access to table interface
|
|
857
|
-
|
|
858
|
-
## Naming Conventions
|
|
859
|
-
|
|
860
|
-
- **Model name**: Singular (e.g., `chat`, `user`, `product`)
|
|
861
|
-
- **Table name**: Plural (e.g., `chats`, `users`, `products`)
|
|
862
|
-
- **Field components**: `[Field]Field` (e.g., `TitleField`, `EmailField`)
|
|
863
|
-
- **Form components**: `Create[Model]`, `Filter[Models]` (e.g., `CreateChat`, `FilterChats`)
|
|
864
|
-
- **Provider**: `[Models]Provider` (e.g., `ChatsProvider`)
|
|
865
|
-
- **Hook**: `use[Models]` (e.g., `useChats`)
|
|
866
|
-
|
|
867
|
-
## Usage Example
|
|
868
|
-
|
|
869
|
-
```typescript
|
|
870
|
-
import { ChatsProvider } from './chat/table'
|
|
871
|
-
import { CreateChat, FilterChats } from './chat/forms'
|
|
872
|
-
|
|
873
|
-
function App() {
|
|
874
|
-
return (
|
|
875
|
-
<ChatsProvider>
|
|
876
|
-
<CreateChat />
|
|
877
|
-
<FilterChats />
|
|
878
|
-
</ChatsProvider>
|
|
879
|
-
)
|
|
429
|
+
import { SingleProvider, useSingle } from 'asasvirtuais/react-interface'
|
|
430
|
+
|
|
431
|
+
// Detail page
|
|
432
|
+
<SingleProvider id={params.id} table="todos" schema={schema}>
|
|
433
|
+
<TodoDetail />
|
|
434
|
+
<UpdateTodoForm />
|
|
435
|
+
<DeleteTodoButton />
|
|
436
|
+
</SingleProvider>
|
|
437
|
+
|
|
438
|
+
// Inside any of those:
|
|
439
|
+
function TodoDetail() {
|
|
440
|
+
const { single } = useSingle(schema, 'todos')
|
|
441
|
+
return <h1>{single.title}</h1>
|
|
880
442
|
}
|
|
881
443
|
```
|
|
882
444
|
|
|
445
|
+
If the record isn't in the reactive index yet, `SingleProvider` fetches it automatically.
|
|
883
446
|
|
|
884
|
-
|
|
447
|
+
---
|
|
448
|
+
|
|
449
|
+
## Effects
|
|
885
450
|
|
|
886
|
-
|
|
451
|
+
There is no middleware or lifecycle configuration. Effects are code written around the action:
|
|
887
452
|
|
|
888
|
-
|
|
453
|
+
```tsx
|
|
454
|
+
// Before submit
|
|
455
|
+
<button onClick={() => {
|
|
456
|
+
validateForm(form.fields)
|
|
457
|
+
form.submit()
|
|
458
|
+
}}>
|
|
459
|
+
Save
|
|
460
|
+
</button>
|
|
461
|
+
|
|
462
|
+
// After success
|
|
463
|
+
<CreateForm
|
|
464
|
+
table="todos"
|
|
465
|
+
schema={schema}
|
|
466
|
+
onSuccess={todo => {
|
|
467
|
+
router.push(`/todos/${todo.id}`)
|
|
468
|
+
showNotification('Todo created!')
|
|
469
|
+
}}
|
|
470
|
+
>
|
|
471
|
+
{/* ... */}
|
|
472
|
+
</CreateForm>
|
|
889
473
|
|
|
890
|
-
|
|
474
|
+
// Using field values without submitting
|
|
475
|
+
<button onClick={() => saveDraftLocally(form.fields)}>
|
|
476
|
+
Save Draft
|
|
477
|
+
</button>
|
|
478
|
+
```
|
|
891
479
|
|
|
892
480
|
---
|
|
893
481
|
|
|
894
|
-
|
|
482
|
+
## Naming pattern examples
|
|
483
|
+
|
|
484
|
+
| Concept | Pattern | Example |
|
|
485
|
+
|---|---|---|
|
|
486
|
+
| Table name | lowercase plural | `'todos'` |
|
|
487
|
+
| Schema types | `Readable`, `Writable` | `type Readable = z.infer<...>` |
|
|
488
|
+
| Field components | `{Field}Field` | `TitleField`, `DoneField` |
|
|
489
|
+
| Provider | `{Model}sProvider` | `TodosProvider` |
|
|
490
|
+
| Hook | `use{Model}s()` | `useTodos()` |
|
|
491
|
+
| Create form | `Create{Model}` | `CreateTodo` |
|
|
492
|
+
| Update form | `Update{Model}` | `UpdateTodo` |
|
|
493
|
+
| Delete action | `Delete{Model}` | `DeleteTodo` |
|
|
494
|
+
| Item component | `{Model}Item` | `TodoItem` |
|
|
495
|
+
| Detail component | `Single{Model}` | `SingleTodo` |
|