@pyreon/feature 0.0.1
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/LICENSE +21 -0
- package/README.md +382 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +464 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +501 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +259 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +60 -0
- package/src/define-feature.ts +420 -0
- package/src/index.ts +19 -0
- package/src/schema.ts +334 -0
- package/src/tests/feature.test.ts +1416 -0
- package/src/types.ts +159 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { signal, batch } from '@pyreon/reactivity'
|
|
2
|
+
import { useForm as _useForm } from '@pyreon/form'
|
|
3
|
+
import type { SchemaValidateFn } from '@pyreon/form'
|
|
4
|
+
import { zodSchema } from '@pyreon/validation'
|
|
5
|
+
import {
|
|
6
|
+
useQuery as _useQuery,
|
|
7
|
+
useMutation as _useMutation,
|
|
8
|
+
useQueryClient,
|
|
9
|
+
} from '@pyreon/query'
|
|
10
|
+
import type { QueryKey } from '@pyreon/query'
|
|
11
|
+
import {
|
|
12
|
+
useTable as _useTable,
|
|
13
|
+
getCoreRowModel,
|
|
14
|
+
getSortedRowModel,
|
|
15
|
+
getFilteredRowModel,
|
|
16
|
+
getPaginationRowModel,
|
|
17
|
+
} from '@pyreon/table'
|
|
18
|
+
import type { ColumnDef, SortingState } from '@pyreon/table'
|
|
19
|
+
import { defineStore } from '@pyreon/store'
|
|
20
|
+
import { extractFields, defaultInitialValues } from './schema'
|
|
21
|
+
import type {
|
|
22
|
+
Feature,
|
|
23
|
+
FeatureConfig,
|
|
24
|
+
FeatureFormOptions,
|
|
25
|
+
FeatureStore,
|
|
26
|
+
FeatureTableOptions,
|
|
27
|
+
ListOptions,
|
|
28
|
+
} from './types'
|
|
29
|
+
|
|
30
|
+
// ─── Fetch wrapper ────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function createFetcher(baseFetcher: typeof fetch = fetch) {
|
|
33
|
+
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
|
34
|
+
const res = await baseFetcher(url, init)
|
|
35
|
+
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
let message = `${init?.method ?? 'GET'} ${url} failed: ${res.status}`
|
|
38
|
+
try {
|
|
39
|
+
const body = await res.json()
|
|
40
|
+
if (body?.message) message = body.message
|
|
41
|
+
if (body?.errors) {
|
|
42
|
+
throw Object.assign(new Error(message), {
|
|
43
|
+
status: res.status,
|
|
44
|
+
errors: body.errors,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
if (e instanceof Error && 'errors' in e) throw e
|
|
49
|
+
}
|
|
50
|
+
throw Object.assign(new Error(message), { status: res.status })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (res.status === 204) return undefined as T
|
|
54
|
+
return res.json()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
list<T>(
|
|
59
|
+
url: string,
|
|
60
|
+
params?: Record<string, string | number | boolean>,
|
|
61
|
+
): Promise<T[]> {
|
|
62
|
+
const query = params
|
|
63
|
+
? `?${new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])).toString()}`
|
|
64
|
+
: ''
|
|
65
|
+
return request<T[]>(`${url}${query}`)
|
|
66
|
+
},
|
|
67
|
+
getById<T>(url: string, id: string | number): Promise<T> {
|
|
68
|
+
return request<T>(`${url}/${id}`)
|
|
69
|
+
},
|
|
70
|
+
create<T>(url: string, data: unknown): Promise<T> {
|
|
71
|
+
return request<T>(url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify(data),
|
|
75
|
+
})
|
|
76
|
+
},
|
|
77
|
+
update<T>(url: string, id: string | number, data: unknown): Promise<T> {
|
|
78
|
+
return request<T>(`${url}/${id}`, {
|
|
79
|
+
method: 'PUT',
|
|
80
|
+
headers: { 'Content-Type': 'application/json' },
|
|
81
|
+
body: JSON.stringify(data),
|
|
82
|
+
})
|
|
83
|
+
},
|
|
84
|
+
delete(url: string, id: string | number): Promise<void> {
|
|
85
|
+
return request<void>(`${url}/${id}`, { method: 'DELETE' })
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── Schema validation ────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function createValidator<TValues extends Record<string, unknown>>(
|
|
93
|
+
schema: unknown,
|
|
94
|
+
customValidate?: SchemaValidateFn<TValues>,
|
|
95
|
+
): SchemaValidateFn<TValues> | undefined {
|
|
96
|
+
if (customValidate) return customValidate
|
|
97
|
+
|
|
98
|
+
if (
|
|
99
|
+
schema &&
|
|
100
|
+
typeof schema === 'object' &&
|
|
101
|
+
'safeParseAsync' in schema &&
|
|
102
|
+
typeof (schema as Record<string, unknown>).safeParseAsync === 'function'
|
|
103
|
+
) {
|
|
104
|
+
return zodSchema(
|
|
105
|
+
schema as Parameters<typeof zodSchema>[0],
|
|
106
|
+
) as SchemaValidateFn<TValues>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Resolve page value ───────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
function resolvePageValue(
|
|
115
|
+
page: number | (() => number) | undefined,
|
|
116
|
+
): number | undefined {
|
|
117
|
+
if (page === undefined) return undefined
|
|
118
|
+
if (typeof page === 'function') return page()
|
|
119
|
+
return page
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── defineFeature ────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Define a schema-driven feature with auto-generated CRUD hooks.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```ts
|
|
129
|
+
* import { defineFeature } from '@pyreon/feature'
|
|
130
|
+
* import { z } from 'zod'
|
|
131
|
+
*
|
|
132
|
+
* const users = defineFeature({
|
|
133
|
+
* name: 'users',
|
|
134
|
+
* schema: z.object({
|
|
135
|
+
* name: z.string().min(2),
|
|
136
|
+
* email: z.string().email(),
|
|
137
|
+
* role: z.enum(['admin', 'editor', 'viewer']),
|
|
138
|
+
* }),
|
|
139
|
+
* api: '/api/users',
|
|
140
|
+
* })
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
export function defineFeature<TValues extends Record<string, unknown>>(
|
|
144
|
+
config: FeatureConfig<TValues>,
|
|
145
|
+
): Feature<TValues> {
|
|
146
|
+
const { name, schema, api, fetcher: customFetcher } = config
|
|
147
|
+
const http = createFetcher(customFetcher)
|
|
148
|
+
|
|
149
|
+
// Introspect schema fields
|
|
150
|
+
const fields = extractFields(schema)
|
|
151
|
+
const autoInitialValues = defaultInitialValues(fields) as TValues
|
|
152
|
+
const initialValues = config.initialValues
|
|
153
|
+
? { ...autoInitialValues, ...config.initialValues }
|
|
154
|
+
: autoInitialValues
|
|
155
|
+
|
|
156
|
+
const validate = createValidator<TValues>(schema, config.validate)
|
|
157
|
+
|
|
158
|
+
const queryKeyBase = [name] as const
|
|
159
|
+
const queryKey = (suffix?: string | number): QueryKey =>
|
|
160
|
+
suffix !== undefined ? [name, suffix] : [name]
|
|
161
|
+
|
|
162
|
+
// ─── Store definition ──────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
const useStoreHook = defineStore<FeatureStore<TValues>>(name, () => {
|
|
165
|
+
const items = signal<TValues[]>([])
|
|
166
|
+
const selected = signal<TValues | null>(null)
|
|
167
|
+
const loading = signal(false)
|
|
168
|
+
|
|
169
|
+
const select = (id: string | number) => {
|
|
170
|
+
const found = items.peek().find((item) => {
|
|
171
|
+
const record = item as Record<string, unknown>
|
|
172
|
+
return record.id === id
|
|
173
|
+
})
|
|
174
|
+
selected.set(found ?? null)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const clear = () => {
|
|
178
|
+
selected.set(null)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { items, selected, loading, select, clear }
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
name,
|
|
186
|
+
api,
|
|
187
|
+
schema,
|
|
188
|
+
fields,
|
|
189
|
+
queryKey,
|
|
190
|
+
|
|
191
|
+
// ─── Store ───────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
useStore: useStoreHook,
|
|
194
|
+
|
|
195
|
+
// ─── Queries ────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
useList(options?: ListOptions) {
|
|
198
|
+
return _useQuery(() => {
|
|
199
|
+
const pageValue = resolvePageValue(options?.page)
|
|
200
|
+
const pageSize = options?.pageSize ?? 20
|
|
201
|
+
|
|
202
|
+
const params: Record<string, string | number | boolean> = {
|
|
203
|
+
...(options?.params ?? {}),
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (pageValue !== undefined) {
|
|
207
|
+
params.page = pageValue
|
|
208
|
+
params.pageSize = pageSize
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const queryKeyParts: unknown[] = [...queryKeyBase, 'list', params]
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
queryKey: queryKeyParts as QueryKey,
|
|
215
|
+
queryFn: () =>
|
|
216
|
+
http.list<TValues>(
|
|
217
|
+
api,
|
|
218
|
+
Object.keys(params).length > 0 ? params : undefined,
|
|
219
|
+
),
|
|
220
|
+
staleTime: options?.staleTime,
|
|
221
|
+
enabled: options?.enabled,
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
useById(id: string | number) {
|
|
227
|
+
return _useQuery(() => ({
|
|
228
|
+
queryKey: [name, id],
|
|
229
|
+
queryFn: () => http.getById<TValues>(api, id),
|
|
230
|
+
enabled: id !== undefined && id !== null,
|
|
231
|
+
}))
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
useSearch(searchTerm, options?: ListOptions) {
|
|
235
|
+
return _useQuery(() => ({
|
|
236
|
+
queryKey: [...queryKeyBase, 'search', searchTerm()],
|
|
237
|
+
queryFn: () =>
|
|
238
|
+
http.list<TValues>(api, { ...options?.params, q: searchTerm() }),
|
|
239
|
+
enabled: searchTerm().length > 0,
|
|
240
|
+
staleTime: options?.staleTime,
|
|
241
|
+
}))
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
// ─── Mutations ──────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
useCreate() {
|
|
247
|
+
const client = useQueryClient()
|
|
248
|
+
return _useMutation({
|
|
249
|
+
mutationFn: (data: Partial<TValues>) => http.create<TValues>(api, data),
|
|
250
|
+
onSuccess: () => {
|
|
251
|
+
client.invalidateQueries({
|
|
252
|
+
queryKey: queryKeyBase as unknown as QueryKey,
|
|
253
|
+
})
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
useUpdate() {
|
|
259
|
+
type TVariables = { id: string | number; data: Partial<TValues> }
|
|
260
|
+
const client = useQueryClient()
|
|
261
|
+
return _useMutation<TValues, unknown, TVariables, { previous?: unknown }>(
|
|
262
|
+
{
|
|
263
|
+
mutationFn: ({ id, data }: TVariables) =>
|
|
264
|
+
http.update<TValues>(api, id, data),
|
|
265
|
+
onMutate: async (variables) => {
|
|
266
|
+
await client.cancelQueries({ queryKey: [name, variables.id] })
|
|
267
|
+
const previous = client.getQueryData([name, variables.id])
|
|
268
|
+
client.setQueryData([name, variables.id], (old: unknown) => {
|
|
269
|
+
if (old && typeof old === 'object') {
|
|
270
|
+
return { ...old, ...variables.data }
|
|
271
|
+
}
|
|
272
|
+
return variables.data
|
|
273
|
+
})
|
|
274
|
+
return { previous }
|
|
275
|
+
},
|
|
276
|
+
onError: (_err, variables, context) => {
|
|
277
|
+
if (context?.previous) {
|
|
278
|
+
client.setQueryData([name, variables.id], context.previous)
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
onSuccess: (_data, variables) => {
|
|
282
|
+
client.invalidateQueries({
|
|
283
|
+
queryKey: queryKeyBase as unknown as QueryKey,
|
|
284
|
+
})
|
|
285
|
+
client.invalidateQueries({ queryKey: [name, variables.id] })
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
) as ReturnType<Feature<TValues>['useUpdate']>
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
useDelete() {
|
|
292
|
+
const client = useQueryClient()
|
|
293
|
+
return _useMutation({
|
|
294
|
+
mutationFn: (id: string | number) => http.delete(api, id),
|
|
295
|
+
onSuccess: () => {
|
|
296
|
+
client.invalidateQueries({
|
|
297
|
+
queryKey: queryKeyBase as unknown as QueryKey,
|
|
298
|
+
})
|
|
299
|
+
},
|
|
300
|
+
})
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
// ─── Form ───────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
useForm(options?: FeatureFormOptions<TValues>) {
|
|
306
|
+
const mode = options?.mode ?? 'create'
|
|
307
|
+
const mergedInitial = {
|
|
308
|
+
...initialValues,
|
|
309
|
+
...(options?.initialValues ?? {}),
|
|
310
|
+
} as TValues
|
|
311
|
+
|
|
312
|
+
const form = _useForm<TValues>({
|
|
313
|
+
initialValues: mergedInitial,
|
|
314
|
+
schema: validate,
|
|
315
|
+
validateOn: options?.validateOn ?? 'blur',
|
|
316
|
+
onSubmit: async (values) => {
|
|
317
|
+
try {
|
|
318
|
+
let result: unknown
|
|
319
|
+
if (mode === 'edit' && options?.id !== undefined) {
|
|
320
|
+
result = await http.update<TValues>(api, options.id, values)
|
|
321
|
+
} else {
|
|
322
|
+
result = await http.create<TValues>(api, values)
|
|
323
|
+
}
|
|
324
|
+
options?.onSuccess?.(result)
|
|
325
|
+
} catch (err) {
|
|
326
|
+
options?.onError?.(err)
|
|
327
|
+
throw err
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
// Auto-fetch in edit mode
|
|
333
|
+
if (mode === 'edit' && options?.id !== undefined) {
|
|
334
|
+
form.isSubmitting.set(true)
|
|
335
|
+
http.getById<TValues>(api, options.id).then(
|
|
336
|
+
(data) => {
|
|
337
|
+
batch(() => {
|
|
338
|
+
for (const key of Object.keys(data)) {
|
|
339
|
+
form.setFieldValue(
|
|
340
|
+
key as keyof TValues & string,
|
|
341
|
+
(data as Record<string, unknown>)[
|
|
342
|
+
key
|
|
343
|
+
] as TValues[keyof TValues],
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
form.isSubmitting.set(false)
|
|
347
|
+
})
|
|
348
|
+
},
|
|
349
|
+
() => {
|
|
350
|
+
form.isSubmitting.set(false)
|
|
351
|
+
},
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return form
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
// ─── Table ──────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
useTable(
|
|
361
|
+
data: TValues[] | (() => TValues[]),
|
|
362
|
+
options?: FeatureTableOptions<TValues>,
|
|
363
|
+
) {
|
|
364
|
+
const visibleFields = options?.columns
|
|
365
|
+
? fields.filter((f) =>
|
|
366
|
+
options.columns!.includes(f.name as keyof TValues & string),
|
|
367
|
+
)
|
|
368
|
+
: fields
|
|
369
|
+
|
|
370
|
+
const columns: ColumnDef<TValues, unknown>[] = visibleFields.map(
|
|
371
|
+
(field) => ({
|
|
372
|
+
accessorKey: field.name,
|
|
373
|
+
header: field.label,
|
|
374
|
+
...(options?.columnOverrides?.[
|
|
375
|
+
field.name as keyof TValues & string
|
|
376
|
+
] ?? {}),
|
|
377
|
+
}),
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
const sorting = signal<SortingState>([])
|
|
381
|
+
const globalFilter = signal('')
|
|
382
|
+
|
|
383
|
+
const table = _useTable(() => ({
|
|
384
|
+
data: typeof data === 'function' ? data() : data,
|
|
385
|
+
columns,
|
|
386
|
+
state: {
|
|
387
|
+
sorting: sorting(),
|
|
388
|
+
globalFilter: globalFilter(),
|
|
389
|
+
},
|
|
390
|
+
onSortingChange: (updater: unknown) => {
|
|
391
|
+
sorting.set(
|
|
392
|
+
typeof updater === 'function'
|
|
393
|
+
? (updater as (prev: SortingState) => SortingState)(sorting())
|
|
394
|
+
: (updater as SortingState),
|
|
395
|
+
)
|
|
396
|
+
},
|
|
397
|
+
onGlobalFilterChange: (updater: unknown) => {
|
|
398
|
+
globalFilter.set(
|
|
399
|
+
typeof updater === 'function'
|
|
400
|
+
? (updater as (prev: string) => string)(globalFilter())
|
|
401
|
+
: (updater as string),
|
|
402
|
+
)
|
|
403
|
+
},
|
|
404
|
+
getCoreRowModel: getCoreRowModel(),
|
|
405
|
+
getSortedRowModel: getSortedRowModel(),
|
|
406
|
+
getFilteredRowModel: getFilteredRowModel(),
|
|
407
|
+
...(options?.pageSize
|
|
408
|
+
? { getPaginationRowModel: getPaginationRowModel() }
|
|
409
|
+
: {}),
|
|
410
|
+
}))
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
table,
|
|
414
|
+
sorting,
|
|
415
|
+
globalFilter,
|
|
416
|
+
columns: visibleFields,
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
}
|
|
420
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { defineFeature } from './define-feature'
|
|
2
|
+
export {
|
|
3
|
+
extractFields,
|
|
4
|
+
defaultInitialValues,
|
|
5
|
+
reference,
|
|
6
|
+
isReference,
|
|
7
|
+
} from './schema'
|
|
8
|
+
|
|
9
|
+
export type {
|
|
10
|
+
Feature,
|
|
11
|
+
FeatureConfig,
|
|
12
|
+
FeatureFormOptions,
|
|
13
|
+
FeatureStore,
|
|
14
|
+
FeatureTableOptions,
|
|
15
|
+
FeatureTableResult,
|
|
16
|
+
ListOptions,
|
|
17
|
+
} from './types'
|
|
18
|
+
|
|
19
|
+
export type { FieldInfo, FieldType, ReferenceSchema } from './schema'
|