@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.
@@ -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'