asasvirtuais 1.0.2 → 1.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 ADDED
@@ -0,0 +1,600 @@
1
+ # asasvirtuais
2
+
3
+ React form and action management utilities for building complex forms with async validation and multi-step workflows.
4
+
5
+ ## Installation
6
+
7
+ ### From npm
8
+ ```bash
9
+ npm install asasvirtuais
10
+ ```
11
+
12
+ ### From esm.sh
13
+ ```typescript
14
+ import { Form } from 'https://esm.sh/asasvirtuais@latest/form'
15
+ import { useFields } from 'https://esm.sh/asasvirtuais@latest/fields'
16
+ import { useAction } from 'https://esm.sh/asasvirtuais@latest/action'
17
+ ```
18
+
19
+ ## Basic Usage
20
+
21
+ ### Simple Form
22
+
23
+ ```tsx
24
+ import { Form } from 'asasvirtuais/form'
25
+
26
+ type LoginFields = {
27
+ email: string
28
+ password: string
29
+ }
30
+
31
+ type LoginResult = {
32
+ token: string
33
+ user: { id: string; name: string }
34
+ }
35
+
36
+ async function loginAction(fields: LoginFields): Promise<LoginResult> {
37
+ const response = await fetch('/api/login', {
38
+ method: 'POST',
39
+ body: JSON.stringify(fields)
40
+ })
41
+ return response.json()
42
+ }
43
+
44
+ function LoginForm() {
45
+ return (
46
+ <Form<LoginFields, LoginResult>
47
+ defaults={{ email: '', password: '' }}
48
+ action={loginAction}
49
+ onResult={(result) => console.log('Logged in:', result.user.name)}
50
+ >
51
+ {({ fields, setField, submit, loading, error }) => (
52
+ <form onSubmit={submit}>
53
+ <input
54
+ type="email"
55
+ value={fields.email}
56
+ onChange={(e) => setField('email', e.target.value)}
57
+ />
58
+ <input
59
+ type="password"
60
+ value={fields.password}
61
+ onChange={(e) => setField('password', e.target.value)}
62
+ />
63
+ <button type="submit" disabled={loading}>
64
+ {loading ? 'Logging in...' : 'Login'}
65
+ </button>
66
+ {error && <p>Error: {error.message}</p>}
67
+ </form>
68
+ )}
69
+ </Form>
70
+ )
71
+ }
72
+ ```
73
+
74
+ ### Using Fields Only
75
+
76
+ ```tsx
77
+ import { FieldsProvider, useFields } from 'asasvirtuais/fields'
78
+
79
+ type ProfileFields = {
80
+ name: string
81
+ bio: string
82
+ }
83
+
84
+ function ProfileEditor() {
85
+ return (
86
+ <FieldsProvider<ProfileFields> defaults={{ name: '', bio: '' }}>
87
+ {({ fields, setField }) => (
88
+ <div>
89
+ <input
90
+ value={fields.name}
91
+ onChange={(e) => setField('name', e.target.value)}
92
+ />
93
+ <textarea
94
+ value={fields.bio}
95
+ onChange={(e) => setField('bio', e.target.value)}
96
+ />
97
+ </div>
98
+ )}
99
+ </FieldsProvider>
100
+ )
101
+ }
102
+ ```
103
+
104
+ ### Using Actions Only
105
+
106
+ ```tsx
107
+ import { ActionProvider, useAction } from 'asasvirtuais/action'
108
+
109
+ async function deleteAccount(params: { userId: string }) {
110
+ await fetch(`/api/users/${params.userId}`, { method: 'DELETE' })
111
+ }
112
+
113
+ function DeleteButton({ userId }: { userId: string }) {
114
+ return (
115
+ <ActionProvider
116
+ params={{ userId }}
117
+ action={deleteAccount}
118
+ onResult={() => alert('Account deleted')}
119
+ >
120
+ {({ submit, loading }) => (
121
+ <button onClick={submit} disabled={loading}>
122
+ {loading ? 'Deleting...' : 'Delete Account'}
123
+ </button>
124
+ )}
125
+ </ActionProvider>
126
+ )
127
+ }
128
+ ```
129
+
130
+ ## `react-interface` for Data-Driven Applications
131
+
132
+ The `react-interface` package provides a powerful, high-level abstraction for building data-driven React applications. It's designed to work with a standardized `TableInterface` to provide a consistent, type-safe, and efficient way to interact with your data backend. It gives you React hooks and components that are automatically wired up to your API, handling data fetching, caching, and state management for you.
133
+
134
+ ### Todo App Example
135
+
136
+ Let's walk through building a simple Todo app to demonstrate how to use `react-interface`.
137
+
138
+ #### 1. Define Your Schema
139
+
140
+ First, define the schema for your `todos` table using `zod`. This is the single source of truth for your data shapes.
141
+
142
+ ```typescript
143
+ // app/database.ts
144
+ import { z } from 'zod';
145
+
146
+ export const schema = {
147
+ todos: {
148
+ readable: z.object({
149
+ id: z.string(),
150
+ text: z.string(),
151
+ completed: z.boolean(),
152
+ createdAt: z.date(),
153
+ }),
154
+ writable: z.object({
155
+ text: z.string(),
156
+ completed: z.boolean().optional(),
157
+ }),
158
+ },
159
+ };
160
+ ```
161
+
162
+ #### 2. Initialize the React Interface
163
+
164
+ Create an `interface.ts` file in your `app` directory to initialize the `reactInterface` and export the hooks and components.
165
+
166
+ ```typescript
167
+ // app/interface.ts
168
+ import { reactInterface } from '@asasvirtuais/interface';
169
+ import { schema } from './database';
170
+
171
+ // This would typically be a fetch or other data source implementation
172
+ const yourDataInterface = {
173
+ find: async (props) => { /* ... */ },
174
+ create: async (props) => { /* ... */ },
175
+ update: async (props) => { /* ... */ },
176
+ remove: async (props) => { /* ... */ },
177
+ list: async (props) => { /* ... */ },
178
+ };
179
+
180
+ export const {
181
+ DatabaseProvider,
182
+ useTable,
183
+ CreateForm,
184
+ UpdateForm,
185
+ FilterForm, // Or ListForm
186
+ SingleProvider,
187
+ useSingle,
188
+ } = reactInterface<typeof schema>(schema, yourDataInterface);
189
+ ```
190
+
191
+ #### 3. Provide the Data Context
192
+
193
+ Wrap your application (or the relevant part of it) with the `DatabaseProvider` to make the data available to all child components.
194
+
195
+ ```tsx
196
+ // app/layout.tsx
197
+ import { DatabaseProvider } from '@/app/interface';
198
+ import { fetchTodos } from '@/app/api'; // Example API call
199
+
200
+ export default async function RootLayout({ children }: { children: React.ReactNode }) {
201
+ const initialTodos = await fetchTodos(); // Fetch initial data on the server
202
+ return (
203
+ <html lang="en">
204
+ <body>
205
+ <DatabaseProvider todos={initialTodos}>
206
+ {children}
207
+ </DatabaseProvider>
208
+ </body>
209
+ </html>
210
+ );
211
+ }
212
+ ```
213
+
214
+ #### 4. Listing Todos and Using `useTable`
215
+
216
+ The `useTable` hook is the primary way to interact with your table's data. It gives you access to the data index (a key-value store of your items) and the CRUD methods.
217
+
218
+ ```tsx
219
+ // app/todos/page.tsx
220
+ 'use client';
221
+
222
+ import { useTable } from '@/app/interface';
223
+
224
+ function TodoList() {
225
+ const { array: todos, remove, update } = useTable('todos');
226
+
227
+ return (
228
+ <ul>
229
+ {todos.map(todo => (
230
+ <li key={todo.id}>
231
+ <span
232
+ style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
233
+ onClick={() => update.trigger({ id: todo.id, data: { completed: !todo.completed } })}
234
+ >
235
+ {todo.text}
236
+ </span>
237
+ <button onClick={() => remove.trigger({ id: todo.id })}>Delete</button>
238
+ </li>
239
+ ))}
240
+ </ul>
241
+ );
242
+ }
243
+ ```
244
+
245
+ #### 5. Creating Todos with `CreateForm`
246
+
247
+ The `CreateForm` component provides a ready-to-use form for creating new items. It handles form state, submission, and updates the local data index on success.
248
+
249
+ ```tsx
250
+ // app/todos/page.tsx (continued)
251
+ import { CreateForm, useTable } from '@/app/interface';
252
+ import { z } from 'zod';
253
+ import { schema } from '@/app/database';
254
+
255
+ function AddTodoForm() {
256
+ return (
257
+ <CreateForm<typeof schema, 'todos'>
258
+ table="todos"
259
+ defaults={{ text: '' }}
260
+ onSuccess={(newTodo) => {
261
+ console.log('Successfully created:', newTodo.text);
262
+ }}
263
+ >
264
+ {({ fields, setField, submit, loading }) => (
265
+ <form onSubmit={submit}>
266
+ <input
267
+ value={fields.text}
268
+ onChange={(e) => setField('text', e.target.value)}
269
+ placeholder="What needs to be done?"
270
+ />
271
+ <button type="submit" disabled={loading}>
272
+ {loading ? 'Adding...' : 'Add Todo'}
273
+ </button>
274
+ </form>
275
+ )}
276
+ </CreateForm>
277
+ );
278
+ }
279
+ ```
280
+
281
+ #### 6. Updating Todos with `UpdateForm`
282
+
283
+ Similarly, `UpdateForm` is used for editing existing items. It's often used in combination with `SingleProvider` and `useSingle` to work with a specific item.
284
+
285
+ ```tsx
286
+ // app/todos/[id]/edit/page.tsx
287
+ 'use client';
288
+
289
+ import { UpdateForm, useSingle } from '@/app/interface';
290
+ import { z } from 'zod';
291
+ import { schema } from '@/app/database';
292
+
293
+ function EditTodoForm() {
294
+ const { single: todo } = useSingle('todos');
295
+
296
+ if (!todo) return <div>Loading...</div>;
297
+
298
+ return (
299
+ <UpdateForm<typeof schema, 'todos'>
300
+ table="todos"
301
+ id={todo.id}
302
+ defaults={{ text: todo.text }}
303
+ onSuccess={() => {
304
+ // Redirect or show a success message
305
+ }}
306
+ >
307
+ {({ fields, setField, submit, loading }) => (
308
+ <form onSubmit={submit}>
309
+ <input
310
+ value={fields.text}
311
+ onChange={(e) => setField('text', e.target.value)}
312
+ />
313
+ <button type="submit" disabled={loading}>
314
+ {loading ? 'Saving...' : 'Save Changes'}
315
+ </button>
316
+ </form>
317
+ )}
318
+ </UpdateForm>
319
+ );
320
+ }
321
+ ```
322
+
323
+ #### 7. Filtering Todos with `FilterForm` (ListForm)
324
+
325
+ `FilterForm` allows you to build UIs for filtering and searching your data. It works just like other forms, but its action triggers the `list` method of your `TableInterface`.
326
+
327
+ ```tsx
328
+ // app/todos/page.tsx (continued)
329
+ import { FilterForm, useTable } from '@/app/interface';
330
+ import { z } from 'zod';
331
+ import { schema } from '@/app/database';
332
+
333
+ function TodoFilter() {
334
+ const { list } = useTable('todos');
335
+
336
+ return (
337
+ <FilterForm<typeof schema, 'todos'>
338
+ table="todos"
339
+ defaults={{ query: { completed: false } }}
340
+ onSuccess={(results) => {
341
+ console.log(`Found ${results.length} matching todos.`);
342
+ }}
343
+ >
344
+ {({ fields, setField, submit }) => (
345
+ <div>
346
+ <label>
347
+ <input
348
+ type="checkbox"
349
+ checked={fields.query.completed}
350
+ onChange={(e) => setField('query.completed', e.target.checked)}
351
+ />
352
+ Show completed
353
+ </label>
354
+ <button onClick={submit}>Apply Filter</button>
355
+ </div>
356
+ )}
357
+ </FilterForm>
358
+ );
359
+ }
360
+ ```
361
+
362
+ ## Advanced Usage
363
+
364
+ ### Nested Forms: Multi-Step Async Validation
365
+
366
+ Use nested forms to validate intermediate steps and populate parent form fields based on child form results.
367
+
368
+ ```tsx
369
+ import { Form } from 'asasvirtuais/form'
370
+
371
+ type EmailFields = { email: string }
372
+ type EmailResult = { userId: string; exists: boolean }
373
+
374
+ type PasswordFields = { userId: string; password: string }
375
+ type PasswordResult = { token: string }
376
+
377
+ async function checkEmail(fields: EmailFields): Promise<EmailResult> {
378
+ const response = await fetch('/api/check-email', {
379
+ method: 'POST',
380
+ body: JSON.stringify(fields)
381
+ })
382
+ return response.json()
383
+ }
384
+
385
+ async function verifyPassword(fields: PasswordFields): Promise<PasswordResult> {
386
+ const response = await fetch('/api/verify-password', {
387
+ method: 'POST',
388
+ body: JSON.stringify(fields)
389
+ })
390
+ return response.json()
391
+ }
392
+
393
+ function MultiStepLogin() {
394
+ return (
395
+ <Form<EmailFields, EmailResult>
396
+ defaults={{ email: '' }}
397
+ action={checkEmail}
398
+ >
399
+ {(emailForm) => (
400
+ <div>
401
+ <h2>Step 1: Enter Email</h2>
402
+ <input
403
+ type="email"
404
+ value={emailForm.fields.email}
405
+ onChange={(e) => emailForm.setField('email', e.target.value)}
406
+ />
407
+ <button onClick={emailForm.submit} disabled={emailForm.loading}>
408
+ {emailForm.loading ? 'Checking...' : 'Next'}
409
+ </button>
410
+ {emailForm.error && <p>Error: {emailForm.error.message}</p>}
411
+
412
+ {emailForm.result && emailForm.result.exists && (
413
+ <Form<PasswordFields, PasswordResult>
414
+ defaults={{
415
+ userId: emailForm.result.userId,
416
+ password: ''
417
+ }}
418
+ action={verifyPassword}
419
+ onResult={(result) => {
420
+ localStorage.setItem('token', result.token)
421
+ window.location.href = '/dashboard'
422
+ }}
423
+ >
424
+ {(passwordForm) => (
425
+ <div>
426
+ <h2>Step 2: Enter Password</h2>
427
+ <input
428
+ type="password"
429
+ value={passwordForm.fields.password}
430
+ onChange={(e) => passwordForm.setField('password', e.target.value)}
431
+ />
432
+ <button onClick={passwordForm.submit} disabled={passwordForm.loading}>
433
+ {passwordForm.loading ? 'Verifying...' : 'Login'}
434
+ </button>
435
+ {passwordForm.error && <p>Error: {passwordForm.error.message}</p>}
436
+ </div>
437
+ )}
438
+ </Form>
439
+ )}
440
+ </div>
441
+ )}
442
+ </Form>
443
+ )
444
+ }
445
+ ```
446
+
447
+ ### Complex Multi-Step: Address Validation
448
+
449
+ ```tsx
450
+ type AddressLookupFields = { zipCode: string }
451
+ type AddressLookupResult = {
452
+ city: string
453
+ state: string
454
+ country: string
455
+ }
456
+
457
+ type FullAddressFields = {
458
+ zipCode: string
459
+ city: string
460
+ state: string
461
+ country: string
462
+ street: string
463
+ number: string
464
+ }
465
+
466
+ type OrderResult = { orderId: string }
467
+
468
+ async function lookupAddress(fields: AddressLookupFields): Promise<AddressLookupResult> {
469
+ const response = await fetch(`/api/address/lookup?zip=${fields.zipCode}`)
470
+ return response.json()
471
+ }
472
+
473
+ async function createOrder(fields: FullAddressFields): Promise<OrderResult> {
474
+ const response = await fetch('/api/orders', {
475
+ method: 'POST',
476
+ body: JSON.stringify(fields)
477
+ })
478
+ return response.json()
479
+ }
480
+
481
+ function CheckoutForm() {
482
+ return (
483
+ <Form<AddressLookupFields, AddressLookupResult>
484
+ defaults={{ zipCode: '' }}
485
+ action={lookupAddress}
486
+ >
487
+ {(zipForm) => (
488
+ <div>
489
+ <h3>Enter ZIP Code</h3>
490
+ <input
491
+ type="text"
492
+ value={zipForm.fields.zipCode}
493
+ onChange={(e) => zipForm.setField('zipCode', e.target.value)}
494
+ placeholder="ZIP Code"
495
+ />
496
+ <button onClick={zipForm.submit} disabled={zipForm.loading}>
497
+ {zipForm.loading ? 'Looking up...' : 'Lookup Address'}
498
+ </button>
499
+
500
+ {zipForm.result && (
501
+ <Form<FullAddressFields, OrderResult>
502
+ defaults={{
503
+ zipCode: zipForm.fields.zipCode,
504
+ city: zipForm.result.city,
505
+ state: zipForm.result.state,
506
+ country: zipForm.result.country,
507
+ street: '',
508
+ number: ''
509
+ }}
510
+ action={createOrder}
511
+ onResult={(result) => alert(`Order created: ${result.orderId}`)}
512
+ >
513
+ {(addressForm) => (
514
+ <div>
515
+ <h3>Complete Address</h3>
516
+ <p>City: {addressForm.fields.city}</p>
517
+ <p>State: {addressForm.fields.state}</p>
518
+ <input
519
+ value={addressForm.fields.street}
520
+ onChange={(e) => addressForm.setField('street', e.target.value)}
521
+ placeholder="Street"
522
+ />
523
+ <input
524
+ value={addressForm.fields.number}
525
+ onChange={(e) => addressForm.setField('number', e.target.value)}
526
+ placeholder="Number"
527
+ />
528
+ <button onClick={addressForm.submit} disabled={addressForm.loading}>
529
+ {addressForm.loading ? 'Creating Order...' : 'Place Order'}
530
+ </button>
531
+ {addressForm.error && <p>Error: {addressForm.error.message}</p>}
532
+ </div>
533
+ )}
534
+ </Form>
535
+ )}
536
+ </div>
537
+ )}
538
+ </Form>
539
+ )
540
+ }
541
+ ```
542
+
543
+ ## API Reference
544
+
545
+ ### `Form<Fields, Result>`
546
+
547
+ Combined fields and action management.
548
+
549
+ **Props:**
550
+ - `defaults?: Partial<Fields>` - Initial field values
551
+ - `action: (fields: Fields) => Promise<Result>` - Async action to perform
552
+ - `onResult?: (result: Result) => void` - Success callback
553
+ - `onError?: (error: Error) => void` - Error callback
554
+ - `autoTrigger?: boolean` - Auto-trigger action on mount
555
+ - `children: ReactNode | (props) => ReactNode` - Render prop or children
556
+
557
+ **Render Props:**
558
+ - `fields: Fields` - Current field values
559
+ - `setField: (name, value) => void` - Update single field
560
+ - `setFields: (fields) => void` - Update multiple fields
561
+ - `submit: (e?) => Promise<void>` - Trigger action
562
+ - `loading: boolean` - Action loading state
563
+ - `result: Result | null` - Action result
564
+ - `error: Error | null` - Action error
565
+
566
+ ### `FieldsProvider<T>`
567
+
568
+ Field state management only.
569
+
570
+ **Props:**
571
+ - `defaults?: Partial<T>` - Initial field values
572
+ - `children: ReactNode | (props) => ReactNode` - Render prop or children
573
+
574
+ **Hook: `useFields<T>()`**
575
+ - `fields: T` - Current field values
576
+ - `setField: (name, value) => void` - Update single field
577
+ - `setFields: (fields) => void` - Update multiple fields
578
+
579
+ ### `ActionProvider<Params, Result>`
580
+
581
+ Action management only.
582
+
583
+ **Props:**
584
+ - `params: Partial<Params>` - Action parameters
585
+ - `action: (params) => Promise<Result>` - Async action
586
+ - `onResult?: (result: Result) => void` - Success callback
587
+ - `onError?: (error: Error) => void` - Error callback
588
+ - `autoTrigger?: boolean` - Auto-trigger on mount
589
+ - `children: ReactNode | (props) => ReactNode` - Render prop or children
590
+
591
+ **Hook: `useAction<Params, Result>()`**
592
+ - `params: Partial<Params>` - Current parameters
593
+ - `submit: (e?) => Promise<void>` - Trigger action
594
+ - `loading: boolean` - Loading state
595
+ - `result: Result | null` - Action result
596
+ - `error: Error | null` - Action error
597
+
598
+ ## License
599
+
600
+ MIT
package/package.json CHANGED
@@ -1,33 +1,54 @@
1
- {
2
- "name": "asasvirtuais",
3
- "type": "module",
4
- "version": "1.0.2",
5
- "directories": {
6
- "packages": "./packages"
7
- },
8
- "files": [
9
- "packages",
10
- "tsconfig.json"
11
- ],
12
- "exports": {
13
- "./package.json": "./package.json",
14
- "./action": {
15
- "types": "./packages/action.tsx",
16
- "default": "./packages/action.tsx"
17
- },
18
- "./fields": {
19
- "types": "./packages/fields.tsx",
20
- "default": "./packages/fields.tsx"
21
- },
22
- "./form": {
23
- "types": "./packages/form.tsx",
24
- "default": "./packages/form.tsx"
25
- }
26
- },
27
- "peerDependencies": {
28
- "react": ">=18.0.0"
29
- },
30
- "dependencies": {
31
- "react": "^19.2.0"
32
- }
33
- }
1
+ {
2
+ "name": "asasvirtuais",
3
+ "type": "module",
4
+ "version": "1.1.0",
5
+ "description": "React form and action management utilities",
6
+ "directories": {
7
+ "packages": "./packages"
8
+ },
9
+ "files": [
10
+ "packages",
11
+ "tsconfig.json"
12
+ ],
13
+ "exports": {
14
+ "./package.json": "./package.json",
15
+ "./action": {
16
+ "types": "./packages/action.tsx",
17
+ "default": "./packages/action.tsx"
18
+ },
19
+ "./fields": {
20
+ "types": "./packages/fields.tsx",
21
+ "default": "./packages/fields.tsx"
22
+ },
23
+ "./form": {
24
+ "types": "./packages/form.tsx",
25
+ "default": "./packages/form.tsx"
26
+ },
27
+ "./hooks": {
28
+ "types": "./packages/hooks.tsx",
29
+ "default": "./packages/hooks.tsx"
30
+ },
31
+ "./interface": {
32
+ "types": "./packages/interface/interface.ts",
33
+ "default": "./packages/interface/interface.ts"
34
+ },
35
+ "./react-interface": {
36
+ "types": "./packages/react-interface.tsx",
37
+ "default": "./packages/react-interface.tsx"
38
+ },
39
+ "./next-interface": {
40
+ "types": "./packages/next-interface.tsx",
41
+ "default": "./packages/next-interface.tsx"
42
+ }
43
+ },
44
+ "peerDependencies": {
45
+ "react": "^19.2.3"
46
+ },
47
+ "dependencies": {
48
+ "next": "^16.1.1",
49
+ "zod": "^3.25.76"
50
+ },
51
+ "devDependencies": {
52
+ "@types/react": "^19.2.7"
53
+ }
54
+ }
@@ -1,4 +1,3 @@
1
- 'use client'
2
1
  import React, { createContext, useEffect, useMemo } from 'react'
3
2
  import { useCallback, useState } from 'react'
4
3
 
@@ -1,4 +1,3 @@
1
- 'use client'
2
1
  import React, { createContext, useCallback, useState } from 'react'
3
2
 
4
3
  export type FieldsProps<T> = { defaults?: Partial<T> }
@@ -0,0 +1,120 @@
1
+ import React from 'react'
2
+ import { useState, useCallback, useEffect, useMemo } from 'react'
3
+
4
+ type StoreProps<T> = {
5
+ [table: string]: (T & { id: string} )[]
6
+ }
7
+ function useStoreProvider<T>(props: StoreProps<T>) {
8
+ return Object.fromEntries(
9
+ Object.entries(props).map(
10
+ ([table, initial]) => [table, useIndex<T>({ initial: initial as T & { id: string} [] })]
11
+ )
12
+ ) as {
13
+ [table: string]: ReturnType<typeof useIndex<T>>
14
+ }
15
+ }
16
+
17
+ export const [StoreProvider, useStore] = createContextFromHook((useStoreProvider<any>))
18
+
19
+ export function useAction<Props, Result, Defaults = Partial<Props>>(action: (props: Props) => Promise<Result>, {
20
+ onSuccess, autoTrigger, ...props
21
+ }: {
22
+ defaults?: Defaults
23
+ onSuccess?: (result: Result, props?: Props) => void
24
+ autoTrigger?: boolean
25
+ } = {}) {
26
+ const [loading, setLoading] = useState<boolean>(false)
27
+ const [error, setError] = useState()
28
+ const [result, setResult] = useState<Result>()
29
+ const [defaults, setDefaults] = useState<Defaults>(props.defaults ?? {} as Defaults)
30
+
31
+ const trigger = useCallback(async (props: Omit<Props, keyof Defaults>): Promise<Result> => {
32
+ try {
33
+ setLoading(true)
34
+ const result = await action({
35
+ ...props,
36
+ ...defaults,
37
+ } as Props)
38
+
39
+ setResult(result)
40
+ if (onSuccess)
41
+ onSuccess(result)
42
+ return result
43
+
44
+ } catch (error) {
45
+ // @ts-expect-error
46
+ setError(error)
47
+ throw error
48
+ } finally {
49
+ setLoading(false)
50
+ }
51
+ }, [defaults, onSuccess, loading, setLoading])
52
+
53
+ useEffect(() => {
54
+ if (autoTrigger)
55
+ trigger(props as Omit<Props, keyof Defaults>)
56
+ }, [])
57
+
58
+ return {
59
+ trigger,
60
+ loading,
61
+ error,
62
+ result,
63
+ defaults,
64
+ setDefaults,
65
+ }
66
+ }
67
+
68
+ export function useIndex<T>(value: Record<string, any>) {
69
+
70
+ type readable = T
71
+
72
+ const [index, setIndex] = useState<Record<string, T>>(() => value)
73
+
74
+ const array = useMemo(() => Object.values(index) as readable[], [index])
75
+
76
+ const set = useCallback((...params: readable[]) => {
77
+ setIndex(prev => ({
78
+ ...prev,
79
+ ...Object.fromEntries(params.map(data => ([(data as readable & { id: string} ).id, data])))
80
+ }))
81
+ }, [])
82
+
83
+ const remove = useCallback((...params: readable[]) => {
84
+ setIndex(prev => {
85
+ const newState = { ...prev }
86
+ for (const data of params) {
87
+ const id = (data as readable & { id: string} ).id
88
+ if (newState[id])
89
+ delete newState[id]
90
+ }
91
+ return newState
92
+ })
93
+ }, [])
94
+
95
+ return {
96
+ index,
97
+ array,
98
+ set,
99
+ setIndex,
100
+ remove,
101
+ }
102
+ }
103
+
104
+ export function createContextFromHook<Props, Result>(useHook: (props: Props) => Result) {
105
+
106
+ const Context = React.createContext<Result | undefined>(undefined)
107
+
108
+ function Provider({ children, ...props }: React.PropsWithChildren<Props>) {
109
+
110
+ const value = useHook(props as Props) as Result
111
+
112
+ return <Context.Provider value={value}>{children}</Context.Provider>
113
+ }
114
+
115
+ function useContext() {
116
+ return React.useContext(Context) as Result
117
+ }
118
+
119
+ return [Provider, useContext] as const
120
+ }
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod'
2
+
3
+ export interface TableInterface<Readable, Writable = Readable> {
4
+ find (props: FindProps) : Promise<Readable>
5
+ create (props: CreateProps<Writable>) : Promise<Readable>
6
+ update (props: UpdateProps<Writable>) : Promise<Readable>
7
+ remove (props: RemoveProps) : Promise<Readable>
8
+ list (props: ListProps<Readable>) : Promise<Readable[]>
9
+ }
10
+
11
+ export function tableInterface<Schema extends DatabaseInterface, Table extends keyof Schema & string>(schema: Schema, table?: Table | null, tableInterface?: TableInterface<z.infer<Schema[Table]['readable']>, z.infer<Schema[Table]['writable']>>) {
12
+ return tableInterface
13
+ }
14
+
15
+ export interface DatabaseInterface {
16
+ [T: string]: { readable: z.ZodObject<z.ZodRawShape>, writable: z.ZodObject<z.ZodRawShape> }
17
+ }
18
+
19
+ export type BasicOperators<T, K extends keyof T> = {
20
+ '$ne' ?: T[K]
21
+ '$lt' ?: T[K]
22
+ '$lte' ?: T[K]
23
+ '$gt' ?: T[K]
24
+ '$gte' ?: T[K]
25
+ '$in' ?: T[K][]
26
+ '$nin' ?: T[K][]
27
+ }
28
+
29
+ export type Filters<T> = {
30
+ '$limit' ?: number
31
+ '$skip' ?: number
32
+ '$select' ?: Array<keyof T>
33
+ '$sort' ?: { [K in keyof T]?: 1 | -1 }
34
+ }
35
+
36
+ export type Query<T = any> = { [K in keyof T]?: T[K] | BasicOperators<T, K> } & Filters<T> & {
37
+ '$or' ?: Array<Query<T>>
38
+ '$and' ?: Array<Query<T>>
39
+ }
40
+
41
+ export type Operators<T, K extends keyof T> = BasicOperators<T, K> & {
42
+ '$or' ?: Array<Query<T>>
43
+ '$and' ?: Array<Query<T>>
44
+ }
45
+
46
+ export interface FindProps { table?: string, id: string }
47
+ export interface RemoveProps { table?: string, id: string }
48
+ export interface CreateProps<T = any> { table?: string, data: T }
49
+ export interface UpdateProps<T = any> { table?: string, id: string, data: Partial<T> }
50
+ export interface ListProps<T = any> { table?: string, query?: Query<T> }
@@ -0,0 +1,120 @@
1
+ import { NextRequest } from 'next/server'
2
+ import { TableInterface } from './interface'
3
+
4
+ export function routes(implementation: TableInterface<any, any>) {
5
+ return {
6
+ // GET /api/v1/[table]/[id]
7
+ find: (
8
+ request: NextRequest,
9
+ { params }: { params: Promise<{ table: string; id: string} >}
10
+ ) => handleRoute(request, params, ({ table, id }) => implementation.find({ table, id })
11
+ ),
12
+
13
+ // GET /api/v1/[table]
14
+ list: (
15
+ request: NextRequest,
16
+ { params }: { params: Promise<{ table: string} >}
17
+ ) => handleRoute(request, params, ({ table }) => {
18
+ const url = new URL(request.url)
19
+ const query = Object.fromEntries(url.searchParams)
20
+ return implementation.list({ table, query })
21
+ }),
22
+
23
+ // POST /api/v1/[table]
24
+ create: (
25
+ request: NextRequest,
26
+ { params }: { params: Promise<{ table: string} >}
27
+ ) => handleRoute(request, params, async ({ table }) => {
28
+ const data = await request.json()
29
+ return implementation.create({ table, data })
30
+ }),
31
+
32
+ // PATCH /api/v1/[table]/[id]
33
+ update: (
34
+ request: NextRequest,
35
+ { params }: { params: Promise<{ table: string; id: string} >}
36
+ ) => handleRoute(request, params, async ({ table, id }) => {
37
+ const data = await request.json()
38
+ return implementation.update({ table, id, data })
39
+ }),
40
+
41
+ // DELETE /api/v1/[table]/[id]
42
+ remove: (
43
+ request: NextRequest,
44
+ { params }: { params: Promise<{ table: string; id: string} >}
45
+ ) => handleRoute(request, params, ({ table, id }) => implementation.remove({ table, id })
46
+ ),
47
+ }
48
+ }
49
+
50
+ export async function handleRoute<P, R>(
51
+ request: NextRequest,
52
+ params: Promise<P>,
53
+ handler: (resolvedParams: any) => Promise<R>
54
+ ) {
55
+ try {
56
+ const resolvedParams = await params
57
+ const result = await handler(resolvedParams)
58
+ return Response.json(result)
59
+ } catch (error) {
60
+ console.error("Route error:", error)
61
+ return Response.json(
62
+ { error: error instanceof Error ? error.message : "Unknown error" },
63
+ { status: 500 }
64
+ )
65
+ }
66
+ }
67
+ // Utility for creating a single dynamic route handler
68
+ export function createDynamicRoute(implementation: TableInterface<any, any>) {
69
+ const routeHandlers = routes(implementation)
70
+
71
+ return async function dynamicRoute(
72
+ request: NextRequest,
73
+ { params: promise }: { params: Promise<{ params: string[]} >}
74
+ ) {
75
+ const { params } = await promise
76
+ const [table, id] = params
77
+
78
+ const method = request.method
79
+
80
+ try {
81
+ switch (method) {
82
+ case "GET":
83
+ if (id) {
84
+ return routeHandlers.find(request, {
85
+ params: Promise.resolve({ table, id }),
86
+ })
87
+ } else {
88
+ return routeHandlers.list(request, {
89
+ params: Promise.resolve({
90
+ table,
91
+ ...Object.fromEntries(request.nextUrl.searchParams.entries()),
92
+ }),
93
+ })
94
+ }
95
+ case "POST":
96
+ return routeHandlers.create(request, {
97
+ params: Promise.resolve({ table }),
98
+ })
99
+ case "PATCH":
100
+ if (!id) throw new Error("ID required for PATCH")
101
+ return routeHandlers.update(request, {
102
+ params: Promise.resolve({ table, id }),
103
+ })
104
+ case "DELETE":
105
+ if (!id) throw new Error("ID required for DELETE")
106
+ return routeHandlers.remove(request, {
107
+ params: Promise.resolve({ table, id }),
108
+ })
109
+ default:
110
+ return Response.json({ error: "Method not allowed" }, { status: 405 })
111
+ }
112
+ } catch (error) {
113
+ console.error(error)
114
+ return Response.json(
115
+ { error: error instanceof Error ? error.message : "Unknown error" },
116
+ { status: 500 }
117
+ )
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,394 @@
1
+ import { z } from 'zod'
2
+ import {
3
+ useState,
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ createContext,
8
+ useContext,
9
+ } from 'react'
10
+ import { useAction as useAsyncAction, useIndex } from './hooks'
11
+ import { ActionProvider, useAction, useActionProvider } from './action'
12
+ import { TableInterface, ListProps, DatabaseInterface } from './interface'
13
+ import { FieldsProvider, useFields } from './fields'
14
+
15
+ export function reactInterface<
16
+ Database extends DatabaseInterface>(
17
+ database: Database,
18
+ {
19
+ find,
20
+ create,
21
+ update,
22
+ remove,
23
+ list,
24
+ }: TableInterface<
25
+ z.infer<Database[keyof Database]['readable']>,
26
+ z.infer<Database[keyof Database]['writable']>
27
+ >
28
+ ) {
29
+ type TableKey = keyof Database & string
30
+
31
+ type TableProviderProps<Table extends TableKey> = {
32
+ table: Table
33
+ asAbove?: Record<string, z.infer<Database[Table]["readable"]>>
34
+ }
35
+
36
+ function useTableProvider<Table extends TableKey>({
37
+ table,
38
+ asAbove,
39
+ }: TableProviderProps<Table>) {
40
+ type Readable = z.infer<Database[Table]['readable']>
41
+ type Writable = z.infer<Database[Table]['writable']>
42
+
43
+ const index = useIndex<Readable>({ ...(asAbove ?? {}) })
44
+
45
+ const array = useMemo(
46
+ () => Object.values(index.index) as Readable[],
47
+ [index.index]
48
+ )
49
+
50
+ useEffect(function soBelow() {
51
+ index.setIndex((prev) => ({ ...prev, ...asAbove }))
52
+ }, [])
53
+
54
+ const methods = { find, create, update, remove, list } as TableInterface<
55
+ Readable,
56
+ Writable
57
+ >
58
+
59
+ return {
60
+ ...index,
61
+ array,
62
+ find: useAsyncAction(((props) => methods.find({ ...props, table }).then(res => {
63
+ index.set(res)
64
+ return res
65
+ })) as typeof find),
66
+ create: useAsyncAction(((props) => create({ ...props, table }).then(res => {
67
+ index.set(res)
68
+ return res
69
+ })) as typeof create),
70
+ update: useAsyncAction(((props) => update({ ...props, table }).then(res => {
71
+ index.set(res)
72
+ return res
73
+ })) as typeof update),
74
+ remove: useAsyncAction(((props) => remove({ ...props, table }).then(res => {
75
+ index.remove(res)
76
+ return res
77
+ })) as typeof remove),
78
+ list: useAsyncAction(((props) => list({ ...props, table }).then(arr => {
79
+ index.set(...arr)
80
+ return arr
81
+ })) as typeof list),
82
+ }
83
+ }
84
+
85
+ function useDatabaseProvider(tables: { [T in TableKey]: Record<string, z.infer<Database[T]['readable']>> }): {
86
+ [T in TableKey]: ReturnType<typeof useTableProvider<T>>
87
+ } {
88
+ return Object.fromEntries(
89
+ Object.entries(tables).map(([table, value]) => [
90
+ table, useTableProvider({ table: table as TableKey, asAbove: value })
91
+ ])
92
+ ) as {
93
+ [T in TableKey]: ReturnType<typeof useTableProvider<T>>
94
+ }
95
+ }
96
+ // Create a separate context for each table dynamically
97
+ const tableContexts = new Map<TableKey, React.Context<ReturnType<typeof useTableProvider<any>> | undefined>>();
98
+
99
+ function getTableContext<T extends TableKey>(table: T) {
100
+ if (!tableContexts.has(table)) {
101
+ tableContexts.set(table, createContext<ReturnType<typeof useTableProvider<T>> | undefined>(undefined));
102
+ }
103
+ return tableContexts.get(table)!;
104
+ }
105
+
106
+ function TableProvider<Table extends TableKey>({ children, ...props }: TableProviderProps<Table> & {
107
+ children: React.ReactNode | ((props: ReturnType<typeof useTableProvider<Table>>) => React.ReactNode)
108
+ }) {
109
+ const context = useTableProvider(props);
110
+ const TableContext = getTableContext(props.table);
111
+
112
+ return (
113
+ <TableContext.Provider value={context}>
114
+ {typeof children === 'function' ? children(context) : children}
115
+ </TableContext.Provider>
116
+ )
117
+ }
118
+
119
+ function DatabaseProvider({
120
+ children,
121
+ ...tables
122
+ }: React.PropsWithChildren<{
123
+ [T in TableKey]?: Record<string, z.infer<Database[T]['readable']>>
124
+ }>) {
125
+ return Object.entries(tables).reduce(
126
+ (prev, [table, asAbove]) => {
127
+ return (
128
+ <TableProvider table={table as TableKey} asAbove={asAbove}>
129
+ {prev}
130
+ </TableProvider>
131
+ );
132
+ },
133
+ children as React.ReactNode
134
+ )
135
+ }
136
+
137
+ function useTable<T extends TableKey>(name: T) {
138
+ const TableContext = getTableContext(name);
139
+ const context = useContext(TableContext);
140
+
141
+ if (!context) {
142
+ throw new Error(`useTable("${String(name)}") must be used within a TableProvider or DatabaseProvider with table="${String(name)}"`);
143
+ }
144
+
145
+ return context as ReturnType<typeof useTableProvider<T>>;
146
+ }
147
+
148
+ function useSingleProvider<Table extends TableKey>({
149
+ id,
150
+ table,
151
+ }: {
152
+ id: string
153
+ table: Table
154
+ }) {
155
+ const { index, find } = useTable(table)
156
+ const [single, setSingle] = useState<z.infer<Database[Table]['readable']>>(
157
+ () => index[id]
158
+ )
159
+ useEffect(() => {
160
+ if (!single) find.trigger({ id }).then(setSingle)
161
+ }, [])
162
+ useEffect(() => {
163
+ setSingle(index[id])
164
+ }, [index[id]])
165
+ return {
166
+ id,
167
+ single,
168
+ setSingle,
169
+ loading: find.loading,
170
+ }
171
+ }
172
+
173
+ const SingleContext = createContext<
174
+ ReturnType<typeof useSingleProvider<any>> | undefined
175
+ >(undefined)
176
+
177
+ function SingleProvider<Table extends TableKey>({
178
+ children,
179
+ ...props
180
+ }: {
181
+ id: string
182
+ table: Table
183
+ children: React.ReactNode | ((props: ReturnType<typeof useSingleProvider<Table>>) => React.ReactNode)
184
+ }) {
185
+ const value = useSingleProvider(props)
186
+ if (!value.single) return null
187
+ return (
188
+ <SingleContext.Provider value={value}>
189
+ {typeof children === 'function' ? (
190
+ children(value)
191
+ ) : (
192
+ children
193
+ )}
194
+ </SingleContext.Provider>
195
+ )
196
+ }
197
+
198
+ const useSingle = <Table extends TableKey>(table: Table) =>
199
+ useContext(SingleContext) as ReturnType<typeof useSingleProvider<Table>>
200
+
201
+ function CreateForm<
202
+ T extends TableKey
203
+ >({
204
+ table,
205
+ defaults,
206
+ onSuccess,
207
+ children,
208
+ }: {
209
+ table: T
210
+ defaults?: Partial<z.infer<Database[T]['writable']>>
211
+ onSuccess?: (result: z.infer<Database[T]['readable']>) => void
212
+ children: React.ReactNode | (
213
+ ( props: ReturnType<typeof useActionProvider<z.infer<Database[T]['writable']>, z.infer<Database[T]['readable']>>>
214
+ & ReturnType<typeof useFields<z.infer<Database[T]['writable']>>>
215
+ ) => React.ReactNode
216
+ )
217
+ }) {
218
+ type Readable = z.infer<Database[T]['readable']>
219
+ type Writable = z.infer<Database[T]['writable']>
220
+
221
+ const { create } = useTable(table)
222
+
223
+ const callback = useCallback(
224
+ async (fields: Writable) => {
225
+ const result = await create.trigger({ data: fields })
226
+ if (onSuccess) onSuccess(result as Readable)
227
+ return result
228
+ },
229
+ [create, onSuccess]
230
+ )
231
+
232
+ return (
233
+ <FieldsProvider<Writable> defaults={defaults || ({} as Writable)}>
234
+ {fields => (
235
+ <ActionProvider<Writable, Readable> action={callback} params={fields.fields}>
236
+ {typeof children === 'function' ? (
237
+ form => children({ ...form, ...fields })
238
+ ) : (
239
+ children
240
+ )}
241
+ </ActionProvider>
242
+ )}
243
+ </FieldsProvider>
244
+ )
245
+ }
246
+
247
+ function UpdateForm<T extends TableKey>({
248
+ table,
249
+ id,
250
+ defaults,
251
+ onSuccess,
252
+ children,
253
+ }: {
254
+ table: T
255
+ id: string
256
+ defaults?: Partial<z.infer<Database[T]['writable']>>
257
+ onSuccess?: (result: z.infer<Database[T]['readable']>) => void
258
+ children: React.ReactNode | (
259
+ (props: ReturnType<typeof useActionProvider<Partial<z.infer<Database[T]['writable']>>, z.infer<Database[T]['readable']>>>
260
+ & ReturnType<typeof useFields<z.infer<Database[T]['writable']>>>
261
+ ) => React.ReactNode
262
+ )
263
+ }) {
264
+ type Readable = z.infer<Database[T]['readable']>
265
+ type Writable = z.infer<Database[T]['writable']>
266
+
267
+ const { update } = useTable(table)
268
+
269
+ const callback = useCallback(
270
+ async (fields: Partial<Writable>) => {
271
+ const result = await update.trigger({ id, data: fields })
272
+ if (onSuccess) onSuccess(result as Readable)
273
+ return result
274
+ },
275
+ [update, id, onSuccess]
276
+ )
277
+
278
+ return (
279
+ <FieldsProvider<Writable>
280
+ defaults={defaults || ({} as Partial<Writable>)}
281
+ >
282
+ {fields => (
283
+ <ActionProvider<Partial<Writable>, Readable> action={callback} params={fields.fields}>
284
+ {typeof children === 'function' ? (
285
+ form => children({ ...form, ...fields })
286
+ ) : (
287
+ children
288
+ )}
289
+ </ActionProvider>
290
+ )}
291
+ </FieldsProvider>
292
+ )
293
+ }
294
+
295
+ function FilterForm<T extends TableKey>({
296
+ table,
297
+ defaults,
298
+ onSuccess,
299
+ children,
300
+ }: {
301
+ table: T
302
+ defaults?: Partial<ListProps<z.infer<Database[T]['readable']>>>
303
+ onSuccess?: (result: z.infer<Database[T]['readable']>[]) => void
304
+ children: React.ReactNode | (
305
+ ( props: ReturnType<typeof useActionProvider<ListProps<z.infer<Database[T]['readable']>>, z.infer<Database[T]['readable']>[]>>
306
+ & ReturnType<typeof useFields<ListProps<z.infer<Database[T]['readable']>>>>
307
+ ) => React.ReactNode
308
+ )
309
+ }) {
310
+ type Readable = z.infer<Database[T]['readable']>
311
+ type Writable = z.infer<Database[T]['writable']>
312
+
313
+ const { list } = useTable(table)
314
+
315
+ const callback = useCallback(
316
+ async (fields: Omit<ListProps<Readable>, 'table'>) => {
317
+ const result = await list.trigger(fields)
318
+ if (onSuccess) onSuccess(result)
319
+ return result
320
+ },
321
+ [list, onSuccess]
322
+ )
323
+
324
+ return (
325
+ <FieldsProvider<ListProps<Readable>>
326
+ defaults={(defaults || { query: {} }) as ListProps<Readable>}
327
+ >
328
+ {fields => (
329
+ <ActionProvider<ListProps<Readable>, Readable[]> action={callback} params={fields.fields}>
330
+ {typeof children === 'function' ? (
331
+ form => children({ ...form, ...fields })
332
+ ) : (
333
+ children
334
+ )}
335
+ </ActionProvider>
336
+ )}
337
+ </FieldsProvider>
338
+ )
339
+ }
340
+
341
+ const useCreateForm = <T extends TableKey>(table: T) => {
342
+ return {
343
+ ...useFields<z.infer<Database[T]['writable']>>(),
344
+ ...useAction<
345
+ z.infer<Database[T]['writable']>,
346
+ z.infer<Database[T]['readable']>
347
+ >(),
348
+ }
349
+ }
350
+ const useUpdateForm = <T extends TableKey>(table: T) => {
351
+ return {
352
+ ...useFields<Partial<z.infer<Database[T]['writable']>>>(),
353
+ ...useAction<
354
+ Partial<z.infer<Database[T]['writable']>>,
355
+ z.infer<Database[T]['readable']>
356
+ >(),
357
+ }
358
+ }
359
+ const useFiltersForm = <T extends TableKey>(table: T) => {
360
+ return {
361
+ ...useFields<z.infer<Database[T]['readable']>>(),
362
+ ...useAction<z.infer<Database[T]['readable']>,
363
+ z.infer<Database[T]['readable']>[]
364
+ >(),
365
+ }
366
+ }
367
+
368
+ return {
369
+ DatabaseProvider,
370
+ useTable,
371
+ useTableProvider,
372
+ TableProvider,
373
+ SingleProvider,
374
+ useSingle,
375
+ CreateForm,
376
+ UpdateForm,
377
+ FilterForm,
378
+ useCreateForm,
379
+ useUpdateForm,
380
+ useFiltersForm,
381
+ }
382
+ }
383
+
384
+ export class ReactInterface<Database extends DatabaseInterface> {
385
+ constructor(
386
+ database: Database,
387
+ tableInterface: TableInterface<
388
+ z.infer<Database[keyof Database]['readable']>,
389
+ z.infer<Database[keyof Database]['writable']>
390
+ >
391
+ ) {
392
+ return reactInterface(database, tableInterface)
393
+ }
394
+ }