@pyreon/feature 0.11.5 → 0.11.7
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 +70 -76
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +3 -3
- package/package.json +22 -22
- package/src/define-feature.ts +35 -35
- package/src/index.ts +4 -9
- package/src/schema.ts +56 -56
- package/src/tests/feature.test.tsx +381 -381
- package/src/types.ts +9 -9
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { QueryClient, QueryClientProvider } from
|
|
2
|
-
import { signal } from
|
|
3
|
-
import { mount } from
|
|
4
|
-
import { resetAllStores } from
|
|
5
|
-
import { z } from
|
|
6
|
-
import { defineFeature } from
|
|
7
|
-
import { defaultInitialValues, extractFields, isReference, reference } from
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@pyreon/query'
|
|
2
|
+
import { signal } from '@pyreon/reactivity'
|
|
3
|
+
import { mount } from '@pyreon/runtime-dom'
|
|
4
|
+
import { resetAllStores } from '@pyreon/store'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
import { defineFeature } from '../define-feature'
|
|
7
|
+
import { defaultInitialValues, extractFields, isReference, reference } from '../schema'
|
|
8
8
|
|
|
9
9
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
10
10
|
|
|
@@ -15,7 +15,7 @@ function Capture<T>({ fn }: { fn: () => T }) {
|
|
|
15
15
|
|
|
16
16
|
function mountWith<T>(client: QueryClient, fn: () => T): { result: T; unmount: () => void } {
|
|
17
17
|
let result: T | undefined
|
|
18
|
-
const el = document.createElement(
|
|
18
|
+
const el = document.createElement('div')
|
|
19
19
|
document.body.appendChild(el)
|
|
20
20
|
const unmount = mount(
|
|
21
21
|
<QueryClientProvider client={client}>
|
|
@@ -41,7 +41,7 @@ function mountWith<T>(client: QueryClient, fn: () => T): { result: T; unmount: (
|
|
|
41
41
|
const userSchema = z.object({
|
|
42
42
|
name: z.string().min(2),
|
|
43
43
|
email: z.string().email(),
|
|
44
|
-
role: z.enum([
|
|
44
|
+
role: z.enum(['admin', 'editor', 'viewer']),
|
|
45
45
|
age: z.number().optional(),
|
|
46
46
|
active: z.boolean(),
|
|
47
47
|
})
|
|
@@ -52,21 +52,21 @@ type UserValues = z.infer<typeof userSchema>
|
|
|
52
52
|
|
|
53
53
|
function createMockFetch(responses: Record<string, { status?: number; body?: unknown }>) {
|
|
54
54
|
return async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
55
|
-
const urlStr = typeof url ===
|
|
56
|
-
const method = init?.method ??
|
|
55
|
+
const urlStr = typeof url === 'string' ? url : url.toString()
|
|
56
|
+
const method = init?.method ?? 'GET'
|
|
57
57
|
const key = `${method} ${urlStr}`
|
|
58
58
|
|
|
59
59
|
const match = responses[key] ?? Object.entries(responses).find(([k]) => key.startsWith(k))?.[1]
|
|
60
60
|
|
|
61
61
|
if (!match) {
|
|
62
|
-
return new Response(JSON.stringify({ message:
|
|
62
|
+
return new Response(JSON.stringify({ message: 'Not found' }), {
|
|
63
63
|
status: 404,
|
|
64
64
|
})
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
return new Response(match.body !== undefined ? JSON.stringify(match.body) : null, {
|
|
68
68
|
status: match.status ?? 200,
|
|
69
|
-
headers: {
|
|
69
|
+
headers: { 'Content-Type': 'application/json' },
|
|
70
70
|
})
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -79,94 +79,94 @@ afterEach(() => {
|
|
|
79
79
|
|
|
80
80
|
// ─── Schema introspection ──────────────────────────────────────────────────────
|
|
81
81
|
|
|
82
|
-
describe(
|
|
83
|
-
it(
|
|
82
|
+
describe('extractFields', () => {
|
|
83
|
+
it('extracts field names and types from a Zod schema', () => {
|
|
84
84
|
const fields = extractFields(userSchema)
|
|
85
85
|
|
|
86
86
|
expect(fields).toHaveLength(5)
|
|
87
87
|
expect(fields[0]).toEqual({
|
|
88
|
-
name:
|
|
89
|
-
type:
|
|
88
|
+
name: 'name',
|
|
89
|
+
type: 'string',
|
|
90
90
|
optional: false,
|
|
91
|
-
label:
|
|
91
|
+
label: 'Name',
|
|
92
92
|
})
|
|
93
93
|
expect(fields[1]).toEqual({
|
|
94
|
-
name:
|
|
95
|
-
type:
|
|
94
|
+
name: 'email',
|
|
95
|
+
type: 'string',
|
|
96
96
|
optional: false,
|
|
97
|
-
label:
|
|
97
|
+
label: 'Email',
|
|
98
98
|
})
|
|
99
99
|
expect(fields[2]).toMatchObject({
|
|
100
|
-
name:
|
|
101
|
-
type:
|
|
100
|
+
name: 'role',
|
|
101
|
+
type: 'enum',
|
|
102
102
|
optional: false,
|
|
103
|
-
label:
|
|
103
|
+
label: 'Role',
|
|
104
104
|
})
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
-
it(
|
|
107
|
+
it('detects optional fields', () => {
|
|
108
108
|
const fields = extractFields(userSchema)
|
|
109
|
-
const ageField = fields.find((f) => f.name ===
|
|
109
|
+
const ageField = fields.find((f) => f.name === 'age')
|
|
110
110
|
expect(ageField?.optional).toBe(true)
|
|
111
|
-
expect(ageField?.type).toBe(
|
|
111
|
+
expect(ageField?.type).toBe('number')
|
|
112
112
|
})
|
|
113
113
|
|
|
114
|
-
it(
|
|
114
|
+
it('detects boolean fields', () => {
|
|
115
115
|
const fields = extractFields(userSchema)
|
|
116
|
-
const activeField = fields.find((f) => f.name ===
|
|
117
|
-
expect(activeField?.type).toBe(
|
|
116
|
+
const activeField = fields.find((f) => f.name === 'active')
|
|
117
|
+
expect(activeField?.type).toBe('boolean')
|
|
118
118
|
})
|
|
119
119
|
|
|
120
|
-
it(
|
|
120
|
+
it('converts field names to labels', () => {
|
|
121
121
|
const schema = z.object({
|
|
122
122
|
firstName: z.string(),
|
|
123
123
|
last_name: z.string(),
|
|
124
124
|
email_address: z.string(),
|
|
125
125
|
})
|
|
126
126
|
const fields = extractFields(schema)
|
|
127
|
-
expect(fields[0]!.label).toBe(
|
|
128
|
-
expect(fields[1]!.label).toBe(
|
|
129
|
-
expect(fields[2]!.label).toBe(
|
|
127
|
+
expect(fields[0]!.label).toBe('First Name')
|
|
128
|
+
expect(fields[1]!.label).toBe('Last Name')
|
|
129
|
+
expect(fields[2]!.label).toBe('Email Address')
|
|
130
130
|
})
|
|
131
131
|
|
|
132
|
-
it(
|
|
132
|
+
it('returns empty array for non-object input', () => {
|
|
133
133
|
expect(extractFields(null)).toEqual([])
|
|
134
134
|
expect(extractFields(undefined)).toEqual([])
|
|
135
|
-
expect(extractFields(
|
|
135
|
+
expect(extractFields('string')).toEqual([])
|
|
136
136
|
})
|
|
137
137
|
|
|
138
|
-
it(
|
|
138
|
+
it('handles Zod v3-style schema with _def.typeName', () => {
|
|
139
139
|
// Mock v3 schema shape
|
|
140
140
|
const mockSchema = {
|
|
141
141
|
_def: {
|
|
142
142
|
shape: () => ({
|
|
143
143
|
title: {
|
|
144
|
-
_def: { typeName:
|
|
144
|
+
_def: { typeName: 'ZodString' },
|
|
145
145
|
},
|
|
146
146
|
count: {
|
|
147
|
-
_def: { typeName:
|
|
147
|
+
_def: { typeName: 'ZodNumber' },
|
|
148
148
|
},
|
|
149
149
|
done: {
|
|
150
|
-
_def: { typeName:
|
|
150
|
+
_def: { typeName: 'ZodBoolean' },
|
|
151
151
|
},
|
|
152
152
|
}),
|
|
153
153
|
},
|
|
154
154
|
}
|
|
155
155
|
const fields = extractFields(mockSchema)
|
|
156
156
|
expect(fields).toHaveLength(3)
|
|
157
|
-
expect(fields[0]).toMatchObject({ name:
|
|
158
|
-
expect(fields[1]).toMatchObject({ name:
|
|
159
|
-
expect(fields[2]).toMatchObject({ name:
|
|
157
|
+
expect(fields[0]).toMatchObject({ name: 'title', type: 'string' })
|
|
158
|
+
expect(fields[1]).toMatchObject({ name: 'count', type: 'number' })
|
|
159
|
+
expect(fields[2]).toMatchObject({ name: 'done', type: 'boolean' })
|
|
160
160
|
})
|
|
161
161
|
|
|
162
|
-
it(
|
|
162
|
+
it('handles Zod v3-style optional with _def.typeName ZodOptional', () => {
|
|
163
163
|
const mockSchema = {
|
|
164
164
|
_def: {
|
|
165
165
|
shape: () => ({
|
|
166
166
|
name: {
|
|
167
167
|
_def: {
|
|
168
|
-
typeName:
|
|
169
|
-
innerType: { _def: { typeName:
|
|
168
|
+
typeName: 'ZodOptional',
|
|
169
|
+
innerType: { _def: { typeName: 'ZodString' } },
|
|
170
170
|
},
|
|
171
171
|
},
|
|
172
172
|
}),
|
|
@@ -174,20 +174,20 @@ describe("extractFields", () => {
|
|
|
174
174
|
}
|
|
175
175
|
const fields = extractFields(mockSchema)
|
|
176
176
|
expect(fields[0]).toMatchObject({
|
|
177
|
-
name:
|
|
178
|
-
type:
|
|
177
|
+
name: 'name',
|
|
178
|
+
type: 'string',
|
|
179
179
|
optional: true,
|
|
180
180
|
})
|
|
181
181
|
})
|
|
182
182
|
|
|
183
|
-
it(
|
|
183
|
+
it('handles Zod v3-style enum with _def.values', () => {
|
|
184
184
|
const mockSchema = {
|
|
185
185
|
_def: {
|
|
186
186
|
shape: () => ({
|
|
187
187
|
status: {
|
|
188
188
|
_def: {
|
|
189
|
-
typeName:
|
|
190
|
-
values: [
|
|
189
|
+
typeName: 'ZodEnum',
|
|
190
|
+
values: ['active', 'inactive'],
|
|
191
191
|
},
|
|
192
192
|
},
|
|
193
193
|
}),
|
|
@@ -195,42 +195,42 @@ describe("extractFields", () => {
|
|
|
195
195
|
}
|
|
196
196
|
const fields = extractFields(mockSchema)
|
|
197
197
|
expect(fields[0]).toMatchObject({
|
|
198
|
-
name:
|
|
199
|
-
type:
|
|
200
|
-
enumValues: [
|
|
198
|
+
name: 'status',
|
|
199
|
+
type: 'enum',
|
|
200
|
+
enumValues: ['active', 'inactive'],
|
|
201
201
|
})
|
|
202
202
|
})
|
|
203
203
|
|
|
204
|
-
it(
|
|
204
|
+
it('returns unknown for unrecognized field type', () => {
|
|
205
205
|
const mockSchema = {
|
|
206
206
|
shape: {
|
|
207
|
-
weird: { _def: { typeName:
|
|
207
|
+
weird: { _def: { typeName: 'ZodSomethingNew' } },
|
|
208
208
|
},
|
|
209
209
|
}
|
|
210
210
|
const fields = extractFields(mockSchema)
|
|
211
|
-
expect(fields[0]).toMatchObject({ name:
|
|
211
|
+
expect(fields[0]).toMatchObject({ name: 'weird', type: 'string' })
|
|
212
212
|
})
|
|
213
213
|
|
|
214
|
-
it(
|
|
214
|
+
it('handles schema with static _def.shape object (not function)', () => {
|
|
215
215
|
const mockSchema = {
|
|
216
216
|
_def: {
|
|
217
217
|
shape: {
|
|
218
|
-
name: { _def: { typeName:
|
|
218
|
+
name: { _def: { typeName: 'ZodString' } },
|
|
219
219
|
},
|
|
220
220
|
},
|
|
221
221
|
}
|
|
222
222
|
const fields = extractFields(mockSchema)
|
|
223
223
|
expect(fields).toHaveLength(1)
|
|
224
|
-
expect(fields[0]).toMatchObject({ name:
|
|
224
|
+
expect(fields[0]).toMatchObject({ name: 'name', type: 'string' })
|
|
225
225
|
})
|
|
226
226
|
|
|
227
|
-
it(
|
|
227
|
+
it('handles schema with _zod.def.shape (v4 path)', () => {
|
|
228
228
|
const mockSchema = {
|
|
229
229
|
_zod: {
|
|
230
230
|
def: {
|
|
231
231
|
shape: {
|
|
232
232
|
email: {
|
|
233
|
-
_zod: { def: { type:
|
|
233
|
+
_zod: { def: { type: 'string' } },
|
|
234
234
|
},
|
|
235
235
|
},
|
|
236
236
|
},
|
|
@@ -238,27 +238,27 @@ describe("extractFields", () => {
|
|
|
238
238
|
}
|
|
239
239
|
const fields = extractFields(mockSchema)
|
|
240
240
|
expect(fields).toHaveLength(1)
|
|
241
|
-
expect(fields[0]).toMatchObject({ name:
|
|
241
|
+
expect(fields[0]).toMatchObject({ name: 'email', type: 'string' })
|
|
242
242
|
})
|
|
243
243
|
|
|
244
|
-
it(
|
|
244
|
+
it('handles getTypeName returning undefined', () => {
|
|
245
245
|
const mockSchema = {
|
|
246
246
|
shape: {
|
|
247
247
|
field: {},
|
|
248
248
|
},
|
|
249
249
|
}
|
|
250
250
|
const fields = extractFields(mockSchema)
|
|
251
|
-
expect(fields[0]).toMatchObject({ name:
|
|
251
|
+
expect(fields[0]).toMatchObject({ name: 'field', type: 'unknown' })
|
|
252
252
|
})
|
|
253
253
|
})
|
|
254
254
|
|
|
255
|
-
describe(
|
|
256
|
-
it(
|
|
255
|
+
describe('defaultInitialValues', () => {
|
|
256
|
+
it('generates defaults from field types', () => {
|
|
257
257
|
const fields = extractFields(userSchema)
|
|
258
258
|
const values = defaultInitialValues(fields)
|
|
259
259
|
|
|
260
|
-
expect(values.name).toBe(
|
|
261
|
-
expect(values.email).toBe(
|
|
260
|
+
expect(values.name).toBe('')
|
|
261
|
+
expect(values.email).toBe('')
|
|
262
262
|
expect(values.age).toBe(0)
|
|
263
263
|
expect(values.active).toBe(false)
|
|
264
264
|
})
|
|
@@ -266,52 +266,52 @@ describe("defaultInitialValues", () => {
|
|
|
266
266
|
|
|
267
267
|
// ─── defineFeature ─────────────────────────────────────────────────────────────
|
|
268
268
|
|
|
269
|
-
describe(
|
|
270
|
-
it(
|
|
269
|
+
describe('defineFeature', () => {
|
|
270
|
+
it('returns a feature with name, api, schema, fields, and queryKey', () => {
|
|
271
271
|
const users = defineFeature<UserValues>({
|
|
272
|
-
name:
|
|
272
|
+
name: 'users',
|
|
273
273
|
schema: userSchema,
|
|
274
|
-
api:
|
|
274
|
+
api: '/api/users',
|
|
275
275
|
})
|
|
276
276
|
|
|
277
|
-
expect(users.name).toBe(
|
|
278
|
-
expect(users.api).toBe(
|
|
277
|
+
expect(users.name).toBe('users')
|
|
278
|
+
expect(users.api).toBe('/api/users')
|
|
279
279
|
expect(users.schema).toBe(userSchema)
|
|
280
280
|
expect(users.fields.length).toBeGreaterThan(0)
|
|
281
|
-
expect(users.fields[0]!.name).toBe(
|
|
282
|
-
expect(users.queryKey()).toEqual([
|
|
283
|
-
expect(users.queryKey(
|
|
284
|
-
expect(users.queryKey(42)).toEqual([
|
|
281
|
+
expect(users.fields[0]!.name).toBe('name')
|
|
282
|
+
expect(users.queryKey()).toEqual(['users'])
|
|
283
|
+
expect(users.queryKey('123')).toEqual(['users', '123'])
|
|
284
|
+
expect(users.queryKey(42)).toEqual(['users', 42])
|
|
285
285
|
})
|
|
286
286
|
|
|
287
|
-
it(
|
|
287
|
+
it('has all hooks', () => {
|
|
288
288
|
const users = defineFeature<UserValues>({
|
|
289
|
-
name:
|
|
289
|
+
name: 'users',
|
|
290
290
|
schema: userSchema,
|
|
291
|
-
api:
|
|
291
|
+
api: '/api/users',
|
|
292
292
|
})
|
|
293
293
|
|
|
294
|
-
expect(typeof users.useList).toBe(
|
|
295
|
-
expect(typeof users.useById).toBe(
|
|
296
|
-
expect(typeof users.useSearch).toBe(
|
|
297
|
-
expect(typeof users.useCreate).toBe(
|
|
298
|
-
expect(typeof users.useUpdate).toBe(
|
|
299
|
-
expect(typeof users.useDelete).toBe(
|
|
300
|
-
expect(typeof users.useForm).toBe(
|
|
301
|
-
expect(typeof users.useTable).toBe(
|
|
302
|
-
expect(typeof users.useStore).toBe(
|
|
294
|
+
expect(typeof users.useList).toBe('function')
|
|
295
|
+
expect(typeof users.useById).toBe('function')
|
|
296
|
+
expect(typeof users.useSearch).toBe('function')
|
|
297
|
+
expect(typeof users.useCreate).toBe('function')
|
|
298
|
+
expect(typeof users.useUpdate).toBe('function')
|
|
299
|
+
expect(typeof users.useDelete).toBe('function')
|
|
300
|
+
expect(typeof users.useForm).toBe('function')
|
|
301
|
+
expect(typeof users.useTable).toBe('function')
|
|
302
|
+
expect(typeof users.useStore).toBe('function')
|
|
303
303
|
})
|
|
304
304
|
|
|
305
|
-
it(
|
|
305
|
+
it('auto-generates initial values from schema', () => {
|
|
306
306
|
const users = defineFeature<UserValues>({
|
|
307
|
-
name:
|
|
307
|
+
name: 'users-auto-init',
|
|
308
308
|
schema: userSchema,
|
|
309
|
-
api:
|
|
309
|
+
api: '/api/users',
|
|
310
310
|
})
|
|
311
311
|
|
|
312
312
|
const client = new QueryClient()
|
|
313
313
|
const { result: form, unmount } = mountWith(client, () => users.useForm())
|
|
314
|
-
expect(form.values().name).toBe(
|
|
314
|
+
expect(form.values().name).toBe('')
|
|
315
315
|
expect(form.values().active).toBe(false)
|
|
316
316
|
unmount()
|
|
317
317
|
})
|
|
@@ -319,16 +319,16 @@ describe("defineFeature", () => {
|
|
|
319
319
|
|
|
320
320
|
// ─── useList ────────────────────────────────────────────────────────────────────
|
|
321
321
|
|
|
322
|
-
describe(
|
|
323
|
-
it(
|
|
324
|
-
const mockUsers = [{ name:
|
|
322
|
+
describe('useList', () => {
|
|
323
|
+
it('fetches list from API', async () => {
|
|
324
|
+
const mockUsers = [{ name: 'Alice', email: 'a@t.com', role: 'admin', active: true }]
|
|
325
325
|
|
|
326
326
|
const users = defineFeature<UserValues>({
|
|
327
|
-
name:
|
|
327
|
+
name: 'users-list',
|
|
328
328
|
schema: userSchema,
|
|
329
|
-
api:
|
|
329
|
+
api: '/api/users',
|
|
330
330
|
fetcher: createMockFetch({
|
|
331
|
-
|
|
331
|
+
'GET /api/users': { body: mockUsers },
|
|
332
332
|
}) as typeof fetch,
|
|
333
333
|
})
|
|
334
334
|
|
|
@@ -343,15 +343,15 @@ describe("useList", () => {
|
|
|
343
343
|
unmount()
|
|
344
344
|
})
|
|
345
345
|
|
|
346
|
-
it(
|
|
347
|
-
let capturedUrl =
|
|
346
|
+
it('passes query params to URL', async () => {
|
|
347
|
+
let capturedUrl = ''
|
|
348
348
|
const users = defineFeature<UserValues>({
|
|
349
|
-
name:
|
|
349
|
+
name: 'users-params',
|
|
350
350
|
schema: userSchema,
|
|
351
|
-
api:
|
|
351
|
+
api: '/api/users',
|
|
352
352
|
fetcher: (async (url: string) => {
|
|
353
353
|
capturedUrl = url
|
|
354
|
-
return new Response(
|
|
354
|
+
return new Response('[]', { status: 200 })
|
|
355
355
|
}) as typeof fetch,
|
|
356
356
|
})
|
|
357
357
|
|
|
@@ -360,23 +360,23 @@ describe("useList", () => {
|
|
|
360
360
|
})
|
|
361
361
|
const { unmount } = mountWith(client, () => users.useList({ params: { page: 2 } }))
|
|
362
362
|
await new Promise((r) => setTimeout(r, 50))
|
|
363
|
-
expect(capturedUrl).toContain(
|
|
363
|
+
expect(capturedUrl).toContain('page=2')
|
|
364
364
|
unmount()
|
|
365
365
|
})
|
|
366
366
|
})
|
|
367
367
|
|
|
368
368
|
// ─── useList pagination ─────────────────────────────────────────────────────────
|
|
369
369
|
|
|
370
|
-
describe(
|
|
371
|
-
it(
|
|
372
|
-
let capturedUrl =
|
|
370
|
+
describe('useList pagination', () => {
|
|
371
|
+
it('appends page and pageSize when page is provided as number', async () => {
|
|
372
|
+
let capturedUrl = ''
|
|
373
373
|
const users = defineFeature<UserValues>({
|
|
374
|
-
name:
|
|
374
|
+
name: 'users-page-num',
|
|
375
375
|
schema: userSchema,
|
|
376
|
-
api:
|
|
376
|
+
api: '/api/users',
|
|
377
377
|
fetcher: (async (url: string) => {
|
|
378
378
|
capturedUrl = url
|
|
379
|
-
return new Response(
|
|
379
|
+
return new Response('[]', { status: 200 })
|
|
380
380
|
}) as typeof fetch,
|
|
381
381
|
})
|
|
382
382
|
|
|
@@ -385,20 +385,20 @@ describe("useList pagination", () => {
|
|
|
385
385
|
})
|
|
386
386
|
const { unmount } = mountWith(client, () => users.useList({ page: 1, pageSize: 10 }))
|
|
387
387
|
await new Promise((r) => setTimeout(r, 50))
|
|
388
|
-
expect(capturedUrl).toContain(
|
|
389
|
-
expect(capturedUrl).toContain(
|
|
388
|
+
expect(capturedUrl).toContain('page=1')
|
|
389
|
+
expect(capturedUrl).toContain('pageSize=10')
|
|
390
390
|
unmount()
|
|
391
391
|
})
|
|
392
392
|
|
|
393
|
-
it(
|
|
394
|
-
let capturedUrl =
|
|
393
|
+
it('defaults pageSize to 20 when page is provided', async () => {
|
|
394
|
+
let capturedUrl = ''
|
|
395
395
|
const users = defineFeature<UserValues>({
|
|
396
|
-
name:
|
|
396
|
+
name: 'users-page-default-size',
|
|
397
397
|
schema: userSchema,
|
|
398
|
-
api:
|
|
398
|
+
api: '/api/users',
|
|
399
399
|
fetcher: (async (url: string) => {
|
|
400
400
|
capturedUrl = url
|
|
401
|
-
return new Response(
|
|
401
|
+
return new Response('[]', { status: 200 })
|
|
402
402
|
}) as typeof fetch,
|
|
403
403
|
})
|
|
404
404
|
|
|
@@ -407,20 +407,20 @@ describe("useList pagination", () => {
|
|
|
407
407
|
})
|
|
408
408
|
const { unmount } = mountWith(client, () => users.useList({ page: 3 }))
|
|
409
409
|
await new Promise((r) => setTimeout(r, 50))
|
|
410
|
-
expect(capturedUrl).toContain(
|
|
411
|
-
expect(capturedUrl).toContain(
|
|
410
|
+
expect(capturedUrl).toContain('page=3')
|
|
411
|
+
expect(capturedUrl).toContain('pageSize=20')
|
|
412
412
|
unmount()
|
|
413
413
|
})
|
|
414
414
|
|
|
415
|
-
it(
|
|
415
|
+
it('accepts reactive page signal', async () => {
|
|
416
416
|
const capturedUrls: string[] = []
|
|
417
417
|
const users = defineFeature<UserValues>({
|
|
418
|
-
name:
|
|
418
|
+
name: 'users-page-signal',
|
|
419
419
|
schema: userSchema,
|
|
420
|
-
api:
|
|
420
|
+
api: '/api/users',
|
|
421
421
|
fetcher: (async (url: string) => {
|
|
422
422
|
capturedUrls.push(url)
|
|
423
|
-
return new Response(
|
|
423
|
+
return new Response('[]', { status: 200 })
|
|
424
424
|
}) as typeof fetch,
|
|
425
425
|
})
|
|
426
426
|
|
|
@@ -430,17 +430,17 @@ describe("useList pagination", () => {
|
|
|
430
430
|
})
|
|
431
431
|
const { unmount } = mountWith(client, () => users.useList({ page, pageSize: 5 }))
|
|
432
432
|
await new Promise((r) => setTimeout(r, 50))
|
|
433
|
-
expect(capturedUrls.some((u) => u.includes(
|
|
433
|
+
expect(capturedUrls.some((u) => u.includes('page=1'))).toBe(true)
|
|
434
434
|
unmount()
|
|
435
435
|
})
|
|
436
436
|
|
|
437
|
-
it(
|
|
437
|
+
it('includes page in query key for independent caching', () => {
|
|
438
438
|
const users = defineFeature<UserValues>({
|
|
439
|
-
name:
|
|
439
|
+
name: 'users-page-key',
|
|
440
440
|
schema: userSchema,
|
|
441
|
-
api:
|
|
441
|
+
api: '/api/users',
|
|
442
442
|
fetcher: (async () => {
|
|
443
|
-
return new Response(
|
|
443
|
+
return new Response('[]', { status: 200 })
|
|
444
444
|
}) as typeof fetch,
|
|
445
445
|
})
|
|
446
446
|
|
|
@@ -453,21 +453,21 @@ describe("useList pagination", () => {
|
|
|
453
453
|
|
|
454
454
|
// ─── useById ────────────────────────────────────────────────────────────────────
|
|
455
455
|
|
|
456
|
-
describe(
|
|
457
|
-
it(
|
|
456
|
+
describe('useById', () => {
|
|
457
|
+
it('fetches single item by ID', async () => {
|
|
458
458
|
const mockUser = {
|
|
459
|
-
name:
|
|
460
|
-
email:
|
|
461
|
-
role:
|
|
459
|
+
name: 'Alice',
|
|
460
|
+
email: 'a@t.com',
|
|
461
|
+
role: 'admin',
|
|
462
462
|
active: true,
|
|
463
463
|
}
|
|
464
464
|
|
|
465
465
|
const users = defineFeature<UserValues>({
|
|
466
|
-
name:
|
|
466
|
+
name: 'users-by-id',
|
|
467
467
|
schema: userSchema,
|
|
468
|
-
api:
|
|
468
|
+
api: '/api/users',
|
|
469
469
|
fetcher: createMockFetch({
|
|
470
|
-
|
|
470
|
+
'GET /api/users/1': { body: mockUser },
|
|
471
471
|
}) as typeof fetch,
|
|
472
472
|
})
|
|
473
473
|
|
|
@@ -483,21 +483,21 @@ describe("useById", () => {
|
|
|
483
483
|
|
|
484
484
|
// ─── useCreate ──────────────────────────────────────────────────────────────────
|
|
485
485
|
|
|
486
|
-
describe(
|
|
487
|
-
it(
|
|
486
|
+
describe('useCreate', () => {
|
|
487
|
+
it('posts to API', async () => {
|
|
488
488
|
const users = defineFeature<UserValues>({
|
|
489
|
-
name:
|
|
489
|
+
name: 'users-create',
|
|
490
490
|
schema: userSchema,
|
|
491
|
-
api:
|
|
491
|
+
api: '/api/users',
|
|
492
492
|
fetcher: createMockFetch({
|
|
493
|
-
|
|
493
|
+
'POST /api/users': { body: { name: 'Created' } },
|
|
494
494
|
}) as typeof fetch,
|
|
495
495
|
})
|
|
496
496
|
|
|
497
497
|
const client = new QueryClient()
|
|
498
498
|
const { result: mutation, unmount } = mountWith(client, () => users.useCreate())
|
|
499
499
|
|
|
500
|
-
mutation.mutate({ name:
|
|
500
|
+
mutation.mutate({ name: 'New' })
|
|
501
501
|
await new Promise((r) => setTimeout(r, 50))
|
|
502
502
|
expect(mutation.isSuccess()).toBe(true)
|
|
503
503
|
unmount()
|
|
@@ -506,30 +506,30 @@ describe("useCreate", () => {
|
|
|
506
506
|
|
|
507
507
|
// ─── useUpdate ──────────────────────────────────────────────────────────────────
|
|
508
508
|
|
|
509
|
-
describe(
|
|
510
|
-
it(
|
|
511
|
-
let capturedUrl =
|
|
512
|
-
let capturedMethod =
|
|
509
|
+
describe('useUpdate', () => {
|
|
510
|
+
it('sends PUT with id and data', async () => {
|
|
511
|
+
let capturedUrl = ''
|
|
512
|
+
let capturedMethod = ''
|
|
513
513
|
|
|
514
514
|
const users = defineFeature<UserValues>({
|
|
515
|
-
name:
|
|
515
|
+
name: 'users-update',
|
|
516
516
|
schema: userSchema,
|
|
517
|
-
api:
|
|
517
|
+
api: '/api/users',
|
|
518
518
|
fetcher: (async (url: string, init?: RequestInit) => {
|
|
519
519
|
capturedUrl = url
|
|
520
|
-
capturedMethod = init?.method ??
|
|
521
|
-
return new Response(
|
|
520
|
+
capturedMethod = init?.method ?? 'GET'
|
|
521
|
+
return new Response('{}', { status: 200 })
|
|
522
522
|
}) as typeof fetch,
|
|
523
523
|
})
|
|
524
524
|
|
|
525
525
|
const client = new QueryClient()
|
|
526
526
|
const { result: mutation, unmount } = mountWith(client, () => users.useUpdate())
|
|
527
527
|
|
|
528
|
-
mutation.mutate({ id: 42, data: { name:
|
|
528
|
+
mutation.mutate({ id: 42, data: { name: 'Updated' } })
|
|
529
529
|
await new Promise((r) => setTimeout(r, 50))
|
|
530
530
|
|
|
531
|
-
expect(capturedUrl).toBe(
|
|
532
|
-
expect(capturedMethod).toBe(
|
|
531
|
+
expect(capturedUrl).toBe('/api/users/42')
|
|
532
|
+
expect(capturedMethod).toBe('PUT')
|
|
533
533
|
expect(mutation.isSuccess()).toBe(true)
|
|
534
534
|
unmount()
|
|
535
535
|
})
|
|
@@ -537,36 +537,36 @@ describe("useUpdate", () => {
|
|
|
537
537
|
|
|
538
538
|
// ─── Optimistic updates ─────────────────────────────────────────────────────────
|
|
539
539
|
|
|
540
|
-
describe(
|
|
541
|
-
it(
|
|
540
|
+
describe('optimistic updates', () => {
|
|
541
|
+
it('updates cache optimistically before server responds', async () => {
|
|
542
542
|
let resolveUpdate: ((value: Response) => void) | undefined
|
|
543
543
|
const mockUser = {
|
|
544
544
|
id: 1,
|
|
545
|
-
name:
|
|
546
|
-
email:
|
|
547
|
-
role:
|
|
545
|
+
name: 'Alice',
|
|
546
|
+
email: 'a@t.com',
|
|
547
|
+
role: 'admin',
|
|
548
548
|
active: true,
|
|
549
549
|
}
|
|
550
550
|
|
|
551
551
|
const users = defineFeature<UserValues & { id: number }>({
|
|
552
|
-
name:
|
|
552
|
+
name: 'users-optimistic',
|
|
553
553
|
schema: z.object({
|
|
554
554
|
id: z.number(),
|
|
555
555
|
name: z.string().min(2),
|
|
556
556
|
email: z.string().email(),
|
|
557
|
-
role: z.enum([
|
|
557
|
+
role: z.enum(['admin', 'editor', 'viewer']),
|
|
558
558
|
active: z.boolean(),
|
|
559
559
|
}),
|
|
560
|
-
api:
|
|
560
|
+
api: '/api/users',
|
|
561
561
|
fetcher: (async (_url: string, init?: RequestInit) => {
|
|
562
|
-
if (init?.method ===
|
|
562
|
+
if (init?.method === 'PUT') {
|
|
563
563
|
return new Promise<Response>((resolve) => {
|
|
564
564
|
resolveUpdate = resolve
|
|
565
565
|
})
|
|
566
566
|
}
|
|
567
567
|
return new Response(JSON.stringify(mockUser), {
|
|
568
568
|
status: 200,
|
|
569
|
-
headers: {
|
|
569
|
+
headers: { 'Content-Type': 'application/json' },
|
|
570
570
|
})
|
|
571
571
|
}) as typeof fetch,
|
|
572
572
|
})
|
|
@@ -576,23 +576,23 @@ describe("optimistic updates", () => {
|
|
|
576
576
|
})
|
|
577
577
|
|
|
578
578
|
// Pre-populate cache
|
|
579
|
-
client.setQueryData([
|
|
579
|
+
client.setQueryData(['users-optimistic', 1], mockUser)
|
|
580
580
|
|
|
581
581
|
const { result: mutation, unmount } = mountWith(client, () => users.useUpdate())
|
|
582
582
|
|
|
583
583
|
// Start mutation — should optimistically update cache
|
|
584
|
-
mutation.mutate({ id: 1, data: { name:
|
|
584
|
+
mutation.mutate({ id: 1, data: { name: 'Bob' } })
|
|
585
585
|
await new Promise((r) => setTimeout(r, 20))
|
|
586
586
|
|
|
587
587
|
// Cache should be optimistically updated
|
|
588
|
-
const cached = client.getQueryData([
|
|
589
|
-
expect(cached.name).toBe(
|
|
588
|
+
const cached = client.getQueryData(['users-optimistic', 1]) as Record<string, unknown>
|
|
589
|
+
expect(cached.name).toBe('Bob')
|
|
590
590
|
|
|
591
591
|
// Resolve server response
|
|
592
592
|
resolveUpdate!(
|
|
593
|
-
new Response(JSON.stringify({ ...mockUser, name:
|
|
593
|
+
new Response(JSON.stringify({ ...mockUser, name: 'Bob' }), {
|
|
594
594
|
status: 200,
|
|
595
|
-
headers: {
|
|
595
|
+
headers: { 'Content-Type': 'application/json' },
|
|
596
596
|
}),
|
|
597
597
|
)
|
|
598
598
|
await new Promise((r) => setTimeout(r, 50))
|
|
@@ -600,35 +600,35 @@ describe("optimistic updates", () => {
|
|
|
600
600
|
unmount()
|
|
601
601
|
})
|
|
602
602
|
|
|
603
|
-
it(
|
|
603
|
+
it('rolls back cache on server error', async () => {
|
|
604
604
|
const mockUser = {
|
|
605
605
|
id: 2,
|
|
606
|
-
name:
|
|
607
|
-
email:
|
|
608
|
-
role:
|
|
606
|
+
name: 'Alice',
|
|
607
|
+
email: 'a@t.com',
|
|
608
|
+
role: 'admin',
|
|
609
609
|
active: true,
|
|
610
610
|
}
|
|
611
611
|
|
|
612
612
|
const users = defineFeature<UserValues & { id: number }>({
|
|
613
|
-
name:
|
|
613
|
+
name: 'users-rollback',
|
|
614
614
|
schema: z.object({
|
|
615
615
|
id: z.number(),
|
|
616
616
|
name: z.string().min(2),
|
|
617
617
|
email: z.string().email(),
|
|
618
|
-
role: z.enum([
|
|
618
|
+
role: z.enum(['admin', 'editor', 'viewer']),
|
|
619
619
|
active: z.boolean(),
|
|
620
620
|
}),
|
|
621
|
-
api:
|
|
621
|
+
api: '/api/users',
|
|
622
622
|
fetcher: (async (_url: string, init?: RequestInit) => {
|
|
623
|
-
if (init?.method ===
|
|
624
|
-
return new Response(JSON.stringify({ message:
|
|
623
|
+
if (init?.method === 'PUT') {
|
|
624
|
+
return new Response(JSON.stringify({ message: 'Server error' }), {
|
|
625
625
|
status: 500,
|
|
626
|
-
headers: {
|
|
626
|
+
headers: { 'Content-Type': 'application/json' },
|
|
627
627
|
})
|
|
628
628
|
}
|
|
629
629
|
return new Response(JSON.stringify(mockUser), {
|
|
630
630
|
status: 200,
|
|
631
|
-
headers: {
|
|
631
|
+
headers: { 'Content-Type': 'application/json' },
|
|
632
632
|
})
|
|
633
633
|
}) as typeof fetch,
|
|
634
634
|
})
|
|
@@ -638,16 +638,16 @@ describe("optimistic updates", () => {
|
|
|
638
638
|
})
|
|
639
639
|
|
|
640
640
|
// Pre-populate cache
|
|
641
|
-
client.setQueryData([
|
|
641
|
+
client.setQueryData(['users-rollback', 2], mockUser)
|
|
642
642
|
|
|
643
643
|
const { result: mutation, unmount } = mountWith(client, () => users.useUpdate())
|
|
644
644
|
|
|
645
|
-
mutation.mutate({ id: 2, data: { name:
|
|
645
|
+
mutation.mutate({ id: 2, data: { name: 'Should rollback' } })
|
|
646
646
|
await new Promise((r) => setTimeout(r, 100))
|
|
647
647
|
|
|
648
648
|
// Should roll back to original
|
|
649
|
-
const cached = client.getQueryData([
|
|
650
|
-
expect(cached.name).toBe(
|
|
649
|
+
const cached = client.getQueryData(['users-rollback', 2]) as Record<string, unknown>
|
|
650
|
+
expect(cached.name).toBe('Alice')
|
|
651
651
|
expect(mutation.isError()).toBe(true)
|
|
652
652
|
unmount()
|
|
653
653
|
})
|
|
@@ -655,18 +655,18 @@ describe("optimistic updates", () => {
|
|
|
655
655
|
|
|
656
656
|
// ─── useDelete ──────────────────────────────────────────────────────────────────
|
|
657
657
|
|
|
658
|
-
describe(
|
|
659
|
-
it(
|
|
660
|
-
let capturedUrl =
|
|
661
|
-
let capturedMethod =
|
|
658
|
+
describe('useDelete', () => {
|
|
659
|
+
it('sends DELETE with id', async () => {
|
|
660
|
+
let capturedUrl = ''
|
|
661
|
+
let capturedMethod = ''
|
|
662
662
|
|
|
663
663
|
const users = defineFeature<UserValues>({
|
|
664
|
-
name:
|
|
664
|
+
name: 'users-delete',
|
|
665
665
|
schema: userSchema,
|
|
666
|
-
api:
|
|
666
|
+
api: '/api/users',
|
|
667
667
|
fetcher: (async (url: string, init?: RequestInit) => {
|
|
668
668
|
capturedUrl = url
|
|
669
|
-
capturedMethod = init?.method ??
|
|
669
|
+
capturedMethod = init?.method ?? 'GET'
|
|
670
670
|
return new Response(null, { status: 204 })
|
|
671
671
|
}) as typeof fetch,
|
|
672
672
|
})
|
|
@@ -677,8 +677,8 @@ describe("useDelete", () => {
|
|
|
677
677
|
mutation.mutate(7)
|
|
678
678
|
await new Promise((r) => setTimeout(r, 50))
|
|
679
679
|
|
|
680
|
-
expect(capturedUrl).toBe(
|
|
681
|
-
expect(capturedMethod).toBe(
|
|
680
|
+
expect(capturedUrl).toBe('/api/users/7')
|
|
681
|
+
expect(capturedMethod).toBe('DELETE')
|
|
682
682
|
expect(mutation.isSuccess()).toBe(true)
|
|
683
683
|
unmount()
|
|
684
684
|
})
|
|
@@ -686,71 +686,71 @@ describe("useDelete", () => {
|
|
|
686
686
|
|
|
687
687
|
// ─── useForm ────────────────────────────────────────────────────────────────────
|
|
688
688
|
|
|
689
|
-
describe(
|
|
690
|
-
it(
|
|
689
|
+
describe('useForm', () => {
|
|
690
|
+
it('creates form with schema validation', () => {
|
|
691
691
|
const users = defineFeature<UserValues>({
|
|
692
|
-
name:
|
|
692
|
+
name: 'users-form',
|
|
693
693
|
schema: userSchema,
|
|
694
|
-
api:
|
|
694
|
+
api: '/api/users',
|
|
695
695
|
})
|
|
696
696
|
|
|
697
697
|
const client = new QueryClient()
|
|
698
698
|
const { result: form, unmount } = mountWith(client, () => users.useForm())
|
|
699
699
|
|
|
700
|
-
expect(typeof form.handleSubmit).toBe(
|
|
701
|
-
expect(typeof form.register).toBe(
|
|
700
|
+
expect(typeof form.handleSubmit).toBe('function')
|
|
701
|
+
expect(typeof form.register).toBe('function')
|
|
702
702
|
unmount()
|
|
703
703
|
})
|
|
704
704
|
|
|
705
|
-
it(
|
|
706
|
-
let capturedMethod =
|
|
705
|
+
it('submits as POST in create mode', async () => {
|
|
706
|
+
let capturedMethod = ''
|
|
707
707
|
const users = defineFeature<UserValues>({
|
|
708
|
-
name:
|
|
708
|
+
name: 'users-form-post',
|
|
709
709
|
schema: userSchema,
|
|
710
|
-
api:
|
|
710
|
+
api: '/api/users',
|
|
711
711
|
fetcher: (async (_url: string, init?: RequestInit) => {
|
|
712
|
-
capturedMethod = init?.method ??
|
|
713
|
-
return new Response(
|
|
712
|
+
capturedMethod = init?.method ?? 'GET'
|
|
713
|
+
return new Response('{}', { status: 200 })
|
|
714
714
|
}) as typeof fetch,
|
|
715
715
|
})
|
|
716
716
|
|
|
717
717
|
const client = new QueryClient()
|
|
718
718
|
const { result: form, unmount } = mountWith(client, () => users.useForm())
|
|
719
719
|
|
|
720
|
-
form.setFieldValue(
|
|
721
|
-
form.setFieldValue(
|
|
722
|
-
form.setFieldValue(
|
|
723
|
-
form.setFieldValue(
|
|
720
|
+
form.setFieldValue('name', 'Al')
|
|
721
|
+
form.setFieldValue('email', 'a@t.com')
|
|
722
|
+
form.setFieldValue('role', 'admin')
|
|
723
|
+
form.setFieldValue('active', true)
|
|
724
724
|
await form.handleSubmit()
|
|
725
725
|
await new Promise((r) => setTimeout(r, 50))
|
|
726
726
|
|
|
727
|
-
expect(capturedMethod).toBe(
|
|
727
|
+
expect(capturedMethod).toBe('POST')
|
|
728
728
|
unmount()
|
|
729
729
|
})
|
|
730
730
|
|
|
731
|
-
it(
|
|
732
|
-
let capturedMethod =
|
|
733
|
-
let capturedUrl =
|
|
731
|
+
it('submits as PUT in edit mode', async () => {
|
|
732
|
+
let capturedMethod = ''
|
|
733
|
+
let capturedUrl = ''
|
|
734
734
|
const users = defineFeature<UserValues>({
|
|
735
|
-
name:
|
|
735
|
+
name: 'users-form-put',
|
|
736
736
|
schema: userSchema,
|
|
737
|
-
api:
|
|
737
|
+
api: '/api/users',
|
|
738
738
|
fetcher: (async (url: string, init?: RequestInit) => {
|
|
739
739
|
capturedUrl = url
|
|
740
|
-
capturedMethod = init?.method ??
|
|
741
|
-
return new Response(
|
|
740
|
+
capturedMethod = init?.method ?? 'GET'
|
|
741
|
+
return new Response('{}', { status: 200 })
|
|
742
742
|
}) as typeof fetch,
|
|
743
743
|
})
|
|
744
744
|
|
|
745
745
|
const client = new QueryClient()
|
|
746
746
|
const { result: form, unmount } = mountWith(client, () =>
|
|
747
747
|
users.useForm({
|
|
748
|
-
mode:
|
|
748
|
+
mode: 'edit',
|
|
749
749
|
id: 42,
|
|
750
750
|
initialValues: {
|
|
751
|
-
name:
|
|
752
|
-
email:
|
|
753
|
-
role:
|
|
751
|
+
name: 'Al',
|
|
752
|
+
email: 'a@t.com',
|
|
753
|
+
role: 'admin',
|
|
754
754
|
active: true,
|
|
755
755
|
},
|
|
756
756
|
}),
|
|
@@ -762,19 +762,19 @@ describe("useForm", () => {
|
|
|
762
762
|
await form.handleSubmit()
|
|
763
763
|
await new Promise((r) => setTimeout(r, 50))
|
|
764
764
|
|
|
765
|
-
expect(capturedMethod).toBe(
|
|
766
|
-
expect(capturedUrl).toBe(
|
|
765
|
+
expect(capturedMethod).toBe('PUT')
|
|
766
|
+
expect(capturedUrl).toBe('/api/users/42')
|
|
767
767
|
unmount()
|
|
768
768
|
})
|
|
769
769
|
|
|
770
|
-
it(
|
|
770
|
+
it('calls onSuccess callback', async () => {
|
|
771
771
|
let successResult: unknown = null
|
|
772
772
|
const users = defineFeature<UserValues>({
|
|
773
|
-
name:
|
|
773
|
+
name: 'users-form-cb',
|
|
774
774
|
schema: userSchema,
|
|
775
|
-
api:
|
|
775
|
+
api: '/api/users',
|
|
776
776
|
fetcher: createMockFetch({
|
|
777
|
-
|
|
777
|
+
'POST /api/users': { body: { id: 1 } },
|
|
778
778
|
}) as typeof fetch,
|
|
779
779
|
})
|
|
780
780
|
|
|
@@ -787,10 +787,10 @@ describe("useForm", () => {
|
|
|
787
787
|
}),
|
|
788
788
|
)
|
|
789
789
|
|
|
790
|
-
form.setFieldValue(
|
|
791
|
-
form.setFieldValue(
|
|
792
|
-
form.setFieldValue(
|
|
793
|
-
form.setFieldValue(
|
|
790
|
+
form.setFieldValue('name', 'Al')
|
|
791
|
+
form.setFieldValue('email', 'a@t.com')
|
|
792
|
+
form.setFieldValue('role', 'admin')
|
|
793
|
+
form.setFieldValue('active', true)
|
|
794
794
|
await form.handleSubmit()
|
|
795
795
|
await new Promise((r) => setTimeout(r, 50))
|
|
796
796
|
|
|
@@ -801,28 +801,28 @@ describe("useForm", () => {
|
|
|
801
801
|
|
|
802
802
|
// ─── Auto-fetch edit form ────────────────────────────────────────────────────
|
|
803
803
|
|
|
804
|
-
describe(
|
|
805
|
-
it(
|
|
804
|
+
describe('auto-fetch edit form', () => {
|
|
805
|
+
it('populates form fields from API when mode is edit', async () => {
|
|
806
806
|
const mockUser = {
|
|
807
|
-
name:
|
|
808
|
-
email:
|
|
809
|
-
role:
|
|
807
|
+
name: 'Alice',
|
|
808
|
+
email: 'alice@example.com',
|
|
809
|
+
role: 'admin',
|
|
810
810
|
active: true,
|
|
811
811
|
}
|
|
812
812
|
|
|
813
813
|
const users = defineFeature<UserValues>({
|
|
814
|
-
name:
|
|
814
|
+
name: 'users-form-autofetch',
|
|
815
815
|
schema: userSchema,
|
|
816
|
-
api:
|
|
816
|
+
api: '/api/users',
|
|
817
817
|
fetcher: createMockFetch({
|
|
818
|
-
|
|
819
|
-
|
|
818
|
+
'GET /api/users/42': { body: mockUser },
|
|
819
|
+
'PUT /api/users/42': { body: mockUser },
|
|
820
820
|
}) as typeof fetch,
|
|
821
821
|
})
|
|
822
822
|
|
|
823
823
|
const client = new QueryClient()
|
|
824
824
|
const { result: form, unmount } = mountWith(client, () =>
|
|
825
|
-
users.useForm({ mode:
|
|
825
|
+
users.useForm({ mode: 'edit', id: 42 }),
|
|
826
826
|
)
|
|
827
827
|
|
|
828
828
|
// Should be loading initially
|
|
@@ -832,26 +832,26 @@ describe("auto-fetch edit form", () => {
|
|
|
832
832
|
|
|
833
833
|
// Should have populated values
|
|
834
834
|
expect(form.isSubmitting()).toBe(false)
|
|
835
|
-
expect(form.values().name).toBe(
|
|
836
|
-
expect(form.values().email).toBe(
|
|
837
|
-
expect(form.values().role).toBe(
|
|
835
|
+
expect(form.values().name).toBe('Alice')
|
|
836
|
+
expect(form.values().email).toBe('alice@example.com')
|
|
837
|
+
expect(form.values().role).toBe('admin')
|
|
838
838
|
expect(form.values().active).toBe(true)
|
|
839
839
|
unmount()
|
|
840
840
|
})
|
|
841
841
|
|
|
842
|
-
it(
|
|
842
|
+
it('clears loading state on fetch error', async () => {
|
|
843
843
|
const users = defineFeature<UserValues>({
|
|
844
|
-
name:
|
|
844
|
+
name: 'users-form-autofetch-err',
|
|
845
845
|
schema: userSchema,
|
|
846
|
-
api:
|
|
846
|
+
api: '/api/users',
|
|
847
847
|
fetcher: createMockFetch({
|
|
848
|
-
|
|
848
|
+
'GET /api/users/999': { status: 404, body: { message: 'Not found' } },
|
|
849
849
|
}) as typeof fetch,
|
|
850
850
|
})
|
|
851
851
|
|
|
852
852
|
const client = new QueryClient()
|
|
853
853
|
const { result: form, unmount } = mountWith(client, () =>
|
|
854
|
-
users.useForm({ mode:
|
|
854
|
+
users.useForm({ mode: 'edit', id: 999 }),
|
|
855
855
|
)
|
|
856
856
|
|
|
857
857
|
expect(form.isSubmitting()).toBe(true)
|
|
@@ -863,12 +863,12 @@ describe("auto-fetch edit form", () => {
|
|
|
863
863
|
|
|
864
864
|
// ─── useStore ──────────────────────────────────────────────────────────────────
|
|
865
865
|
|
|
866
|
-
describe(
|
|
867
|
-
it(
|
|
866
|
+
describe('useStore', () => {
|
|
867
|
+
it('returns a store with items, selected, and loading signals', () => {
|
|
868
868
|
const users = defineFeature<UserValues>({
|
|
869
|
-
name:
|
|
869
|
+
name: 'users-store',
|
|
870
870
|
schema: userSchema,
|
|
871
|
-
api:
|
|
871
|
+
api: '/api/users',
|
|
872
872
|
})
|
|
873
873
|
|
|
874
874
|
const client = new QueryClient()
|
|
@@ -880,17 +880,17 @@ describe("useStore", () => {
|
|
|
880
880
|
unmount()
|
|
881
881
|
})
|
|
882
882
|
|
|
883
|
-
it(
|
|
883
|
+
it('select() finds item by id from items list', () => {
|
|
884
884
|
const users = defineFeature<UserValues & { id: number }>({
|
|
885
|
-
name:
|
|
885
|
+
name: 'users-store-select',
|
|
886
886
|
schema: z.object({
|
|
887
887
|
id: z.number(),
|
|
888
888
|
name: z.string().min(2),
|
|
889
889
|
email: z.string().email(),
|
|
890
|
-
role: z.enum([
|
|
890
|
+
role: z.enum(['admin', 'editor', 'viewer']),
|
|
891
891
|
active: z.boolean(),
|
|
892
892
|
}),
|
|
893
|
-
api:
|
|
893
|
+
api: '/api/users',
|
|
894
894
|
})
|
|
895
895
|
|
|
896
896
|
const client = new QueryClient()
|
|
@@ -899,37 +899,37 @@ describe("useStore", () => {
|
|
|
899
899
|
const items = [
|
|
900
900
|
{
|
|
901
901
|
id: 1,
|
|
902
|
-
name:
|
|
903
|
-
email:
|
|
904
|
-
role:
|
|
902
|
+
name: 'Alice',
|
|
903
|
+
email: 'a@t.com',
|
|
904
|
+
role: 'admin' as const,
|
|
905
905
|
active: true,
|
|
906
906
|
},
|
|
907
907
|
{
|
|
908
908
|
id: 2,
|
|
909
|
-
name:
|
|
910
|
-
email:
|
|
911
|
-
role:
|
|
909
|
+
name: 'Bob',
|
|
910
|
+
email: 'b@t.com',
|
|
911
|
+
role: 'editor' as const,
|
|
912
912
|
active: false,
|
|
913
913
|
},
|
|
914
914
|
]
|
|
915
915
|
storeApi.store.items.set(items)
|
|
916
916
|
storeApi.store.select(2)
|
|
917
917
|
|
|
918
|
-
expect(storeApi.store.selected()?.name).toBe(
|
|
918
|
+
expect(storeApi.store.selected()?.name).toBe('Bob')
|
|
919
919
|
unmount()
|
|
920
920
|
})
|
|
921
921
|
|
|
922
|
-
it(
|
|
922
|
+
it('select() sets null when id not found', () => {
|
|
923
923
|
const users = defineFeature<UserValues & { id: number }>({
|
|
924
|
-
name:
|
|
924
|
+
name: 'users-store-select-miss',
|
|
925
925
|
schema: z.object({
|
|
926
926
|
id: z.number(),
|
|
927
927
|
name: z.string().min(2),
|
|
928
928
|
email: z.string().email(),
|
|
929
|
-
role: z.enum([
|
|
929
|
+
role: z.enum(['admin', 'editor', 'viewer']),
|
|
930
930
|
active: z.boolean(),
|
|
931
931
|
}),
|
|
932
|
-
api:
|
|
932
|
+
api: '/api/users',
|
|
933
933
|
})
|
|
934
934
|
|
|
935
935
|
const client = new QueryClient()
|
|
@@ -938,9 +938,9 @@ describe("useStore", () => {
|
|
|
938
938
|
storeApi.store.items.set([
|
|
939
939
|
{
|
|
940
940
|
id: 1,
|
|
941
|
-
name:
|
|
942
|
-
email:
|
|
943
|
-
role:
|
|
941
|
+
name: 'Alice',
|
|
942
|
+
email: 'a@t.com',
|
|
943
|
+
role: 'admin' as const,
|
|
944
944
|
active: true,
|
|
945
945
|
},
|
|
946
946
|
])
|
|
@@ -950,17 +950,17 @@ describe("useStore", () => {
|
|
|
950
950
|
unmount()
|
|
951
951
|
})
|
|
952
952
|
|
|
953
|
-
it(
|
|
953
|
+
it('clear() resets selection to null', () => {
|
|
954
954
|
const users = defineFeature<UserValues & { id: number }>({
|
|
955
|
-
name:
|
|
955
|
+
name: 'users-store-clear',
|
|
956
956
|
schema: z.object({
|
|
957
957
|
id: z.number(),
|
|
958
958
|
name: z.string().min(2),
|
|
959
959
|
email: z.string().email(),
|
|
960
|
-
role: z.enum([
|
|
960
|
+
role: z.enum(['admin', 'editor', 'viewer']),
|
|
961
961
|
active: z.boolean(),
|
|
962
962
|
}),
|
|
963
|
-
api:
|
|
963
|
+
api: '/api/users',
|
|
964
964
|
})
|
|
965
965
|
|
|
966
966
|
const client = new QueryClient()
|
|
@@ -969,9 +969,9 @@ describe("useStore", () => {
|
|
|
969
969
|
storeApi.store.items.set([
|
|
970
970
|
{
|
|
971
971
|
id: 1,
|
|
972
|
-
name:
|
|
973
|
-
email:
|
|
974
|
-
role:
|
|
972
|
+
name: 'Alice',
|
|
973
|
+
email: 'a@t.com',
|
|
974
|
+
role: 'admin' as const,
|
|
975
975
|
active: true,
|
|
976
976
|
},
|
|
977
977
|
])
|
|
@@ -983,11 +983,11 @@ describe("useStore", () => {
|
|
|
983
983
|
unmount()
|
|
984
984
|
})
|
|
985
985
|
|
|
986
|
-
it(
|
|
986
|
+
it('loading signal reflects loading state', () => {
|
|
987
987
|
const users = defineFeature<UserValues>({
|
|
988
|
-
name:
|
|
988
|
+
name: 'users-store-loading',
|
|
989
989
|
schema: userSchema,
|
|
990
|
-
api:
|
|
990
|
+
api: '/api/users',
|
|
991
991
|
})
|
|
992
992
|
|
|
993
993
|
const client = new QueryClient()
|
|
@@ -999,11 +999,11 @@ describe("useStore", () => {
|
|
|
999
999
|
unmount()
|
|
1000
1000
|
})
|
|
1001
1001
|
|
|
1002
|
-
it(
|
|
1002
|
+
it('is singleton — same store returned on multiple calls', () => {
|
|
1003
1003
|
const users = defineFeature<UserValues>({
|
|
1004
|
-
name:
|
|
1004
|
+
name: 'users-store-singleton',
|
|
1005
1005
|
schema: userSchema,
|
|
1006
|
-
api:
|
|
1006
|
+
api: '/api/users',
|
|
1007
1007
|
})
|
|
1008
1008
|
|
|
1009
1009
|
const client = new QueryClient()
|
|
@@ -1020,54 +1020,54 @@ describe("useStore", () => {
|
|
|
1020
1020
|
|
|
1021
1021
|
// ─── useTable ───────────────────────────────────────────────────────────────────
|
|
1022
1022
|
|
|
1023
|
-
describe(
|
|
1024
|
-
it(
|
|
1023
|
+
describe('useTable', () => {
|
|
1024
|
+
it('creates table with schema-inferred columns', () => {
|
|
1025
1025
|
const users = defineFeature<UserValues>({
|
|
1026
|
-
name:
|
|
1026
|
+
name: 'users-table',
|
|
1027
1027
|
schema: userSchema,
|
|
1028
|
-
api:
|
|
1028
|
+
api: '/api/users',
|
|
1029
1029
|
})
|
|
1030
1030
|
|
|
1031
|
-
const data: UserValues[] = [{ name:
|
|
1031
|
+
const data: UserValues[] = [{ name: 'Alice', email: 'a@t.com', role: 'admin', active: true }]
|
|
1032
1032
|
|
|
1033
1033
|
const client = new QueryClient()
|
|
1034
1034
|
const { result, unmount } = mountWith(client, () => users.useTable(data))
|
|
1035
1035
|
|
|
1036
1036
|
expect(result.columns.length).toBeGreaterThan(0)
|
|
1037
|
-
expect(result.columns[0]!.name).toBe(
|
|
1038
|
-
expect(result.columns[0]!.label).toBe(
|
|
1037
|
+
expect(result.columns[0]!.name).toBe('name')
|
|
1038
|
+
expect(result.columns[0]!.label).toBe('Name')
|
|
1039
1039
|
expect(result.table().getRowModel().rows).toHaveLength(1)
|
|
1040
1040
|
unmount()
|
|
1041
1041
|
})
|
|
1042
1042
|
|
|
1043
|
-
it(
|
|
1043
|
+
it('filters columns by name', () => {
|
|
1044
1044
|
const users = defineFeature<UserValues>({
|
|
1045
|
-
name:
|
|
1045
|
+
name: 'users-table-cols',
|
|
1046
1046
|
schema: userSchema,
|
|
1047
|
-
api:
|
|
1047
|
+
api: '/api/users',
|
|
1048
1048
|
})
|
|
1049
1049
|
|
|
1050
1050
|
const client = new QueryClient()
|
|
1051
1051
|
const { result, unmount } = mountWith(client, () =>
|
|
1052
|
-
users.useTable([{ name:
|
|
1053
|
-
columns: [
|
|
1052
|
+
users.useTable([{ name: 'Alice', email: 'a@t.com', role: 'admin', active: true }], {
|
|
1053
|
+
columns: ['name', 'email'],
|
|
1054
1054
|
}),
|
|
1055
1055
|
)
|
|
1056
1056
|
|
|
1057
1057
|
expect(result.columns).toHaveLength(2)
|
|
1058
|
-
expect(result.columns.map((c) => c.name)).toEqual([
|
|
1058
|
+
expect(result.columns.map((c) => c.name)).toEqual(['name', 'email'])
|
|
1059
1059
|
unmount()
|
|
1060
1060
|
})
|
|
1061
1061
|
|
|
1062
|
-
it(
|
|
1062
|
+
it('accepts reactive data function', () => {
|
|
1063
1063
|
const users = defineFeature<UserValues>({
|
|
1064
|
-
name:
|
|
1064
|
+
name: 'users-table-fn',
|
|
1065
1065
|
schema: userSchema,
|
|
1066
|
-
api:
|
|
1066
|
+
api: '/api/users',
|
|
1067
1067
|
})
|
|
1068
1068
|
|
|
1069
1069
|
const data = signal<UserValues[]>([
|
|
1070
|
-
{ name:
|
|
1070
|
+
{ name: 'Alice', email: 'a@t.com', role: 'admin', active: true },
|
|
1071
1071
|
])
|
|
1072
1072
|
|
|
1073
1073
|
const client = new QueryClient()
|
|
@@ -1080,41 +1080,41 @@ describe("useTable", () => {
|
|
|
1080
1080
|
|
|
1081
1081
|
// ─── useSearch ──────────────────────────────────────────────────────────────────
|
|
1082
1082
|
|
|
1083
|
-
describe(
|
|
1084
|
-
it(
|
|
1085
|
-
let capturedUrl =
|
|
1083
|
+
describe('useSearch', () => {
|
|
1084
|
+
it('passes search term as query param', async () => {
|
|
1085
|
+
let capturedUrl = ''
|
|
1086
1086
|
const users = defineFeature<UserValues>({
|
|
1087
|
-
name:
|
|
1087
|
+
name: 'users-search',
|
|
1088
1088
|
schema: userSchema,
|
|
1089
|
-
api:
|
|
1089
|
+
api: '/api/users',
|
|
1090
1090
|
fetcher: (async (url: string) => {
|
|
1091
1091
|
capturedUrl = url
|
|
1092
|
-
return new Response(
|
|
1092
|
+
return new Response('[]', { status: 200 })
|
|
1093
1093
|
}) as typeof fetch,
|
|
1094
1094
|
})
|
|
1095
1095
|
|
|
1096
|
-
const term = signal(
|
|
1096
|
+
const term = signal('alice')
|
|
1097
1097
|
const client = new QueryClient({
|
|
1098
1098
|
defaultOptions: { queries: { retry: false } },
|
|
1099
1099
|
})
|
|
1100
1100
|
const { unmount } = mountWith(client, () => users.useSearch(term))
|
|
1101
1101
|
|
|
1102
1102
|
await new Promise((r) => setTimeout(r, 50))
|
|
1103
|
-
expect(capturedUrl).toContain(
|
|
1103
|
+
expect(capturedUrl).toContain('q=alice')
|
|
1104
1104
|
unmount()
|
|
1105
1105
|
})
|
|
1106
1106
|
|
|
1107
|
-
it(
|
|
1107
|
+
it('disables query when search term is empty', () => {
|
|
1108
1108
|
const users = defineFeature<UserValues>({
|
|
1109
|
-
name:
|
|
1109
|
+
name: 'users-search-empty',
|
|
1110
1110
|
schema: userSchema,
|
|
1111
|
-
api:
|
|
1111
|
+
api: '/api/users',
|
|
1112
1112
|
fetcher: (() => {
|
|
1113
|
-
throw new Error(
|
|
1113
|
+
throw new Error('Should not fetch')
|
|
1114
1114
|
}) as typeof fetch,
|
|
1115
1115
|
})
|
|
1116
1116
|
|
|
1117
|
-
const term = signal(
|
|
1117
|
+
const term = signal('')
|
|
1118
1118
|
const client = new QueryClient()
|
|
1119
1119
|
const { result: query, unmount } = mountWith(client, () => users.useSearch(term))
|
|
1120
1120
|
|
|
@@ -1126,14 +1126,14 @@ describe("useSearch", () => {
|
|
|
1126
1126
|
|
|
1127
1127
|
// ─── Error handling ────────────────────────────────────────────────────────────
|
|
1128
1128
|
|
|
1129
|
-
describe(
|
|
1130
|
-
it(
|
|
1129
|
+
describe('error handling', () => {
|
|
1130
|
+
it('handles API errors in useList', async () => {
|
|
1131
1131
|
const users = defineFeature<UserValues>({
|
|
1132
|
-
name:
|
|
1132
|
+
name: 'users-err',
|
|
1133
1133
|
schema: userSchema,
|
|
1134
|
-
api:
|
|
1134
|
+
api: '/api/users',
|
|
1135
1135
|
fetcher: createMockFetch({
|
|
1136
|
-
|
|
1136
|
+
'GET /api/users': { status: 500, body: { message: 'Server error' } },
|
|
1137
1137
|
}) as typeof fetch,
|
|
1138
1138
|
})
|
|
1139
1139
|
|
|
@@ -1147,15 +1147,15 @@ describe("error handling", () => {
|
|
|
1147
1147
|
unmount()
|
|
1148
1148
|
})
|
|
1149
1149
|
|
|
1150
|
-
it(
|
|
1150
|
+
it('parses structured error body from API', async () => {
|
|
1151
1151
|
const users = defineFeature<UserValues>({
|
|
1152
|
-
name:
|
|
1152
|
+
name: 'users-err-struct',
|
|
1153
1153
|
schema: userSchema,
|
|
1154
|
-
api:
|
|
1154
|
+
api: '/api/users',
|
|
1155
1155
|
fetcher: createMockFetch({
|
|
1156
|
-
|
|
1156
|
+
'POST /api/users': {
|
|
1157
1157
|
status: 422,
|
|
1158
|
-
body: { message:
|
|
1158
|
+
body: { message: 'Validation failed', errors: { email: 'Taken' } },
|
|
1159
1159
|
},
|
|
1160
1160
|
}) as typeof fetch,
|
|
1161
1161
|
})
|
|
@@ -1163,49 +1163,49 @@ describe("error handling", () => {
|
|
|
1163
1163
|
const client = new QueryClient()
|
|
1164
1164
|
const { result: mutation, unmount } = mountWith(client, () => users.useCreate())
|
|
1165
1165
|
|
|
1166
|
-
mutation.mutate({ name:
|
|
1166
|
+
mutation.mutate({ name: 'Test' })
|
|
1167
1167
|
await new Promise((r) => setTimeout(r, 50))
|
|
1168
1168
|
|
|
1169
1169
|
expect(mutation.isError()).toBe(true)
|
|
1170
1170
|
const err = mutation.error() as Error & { errors?: unknown }
|
|
1171
|
-
expect(err.message).toBe(
|
|
1172
|
-
expect(err.errors).toEqual({ email:
|
|
1171
|
+
expect(err.message).toBe('Validation failed')
|
|
1172
|
+
expect(err.errors).toEqual({ email: 'Taken' })
|
|
1173
1173
|
unmount()
|
|
1174
1174
|
})
|
|
1175
1175
|
})
|
|
1176
1176
|
|
|
1177
1177
|
// ─── References ─────────────────────────────────────────────────────────────────
|
|
1178
1178
|
|
|
1179
|
-
describe(
|
|
1180
|
-
it(
|
|
1179
|
+
describe('reference', () => {
|
|
1180
|
+
it('creates a reference schema object', () => {
|
|
1181
1181
|
const users = defineFeature<UserValues>({
|
|
1182
|
-
name:
|
|
1182
|
+
name: 'users-ref',
|
|
1183
1183
|
schema: userSchema,
|
|
1184
|
-
api:
|
|
1184
|
+
api: '/api/users',
|
|
1185
1185
|
})
|
|
1186
1186
|
|
|
1187
1187
|
const ref = reference(users)
|
|
1188
1188
|
expect(isReference(ref)).toBe(true)
|
|
1189
|
-
expect(ref._featureName).toBe(
|
|
1189
|
+
expect(ref._featureName).toBe('users-ref')
|
|
1190
1190
|
})
|
|
1191
1191
|
|
|
1192
|
-
it(
|
|
1192
|
+
it('validates string and number IDs', () => {
|
|
1193
1193
|
const users = defineFeature<UserValues>({
|
|
1194
|
-
name:
|
|
1194
|
+
name: 'users-ref-validate',
|
|
1195
1195
|
schema: userSchema,
|
|
1196
|
-
api:
|
|
1196
|
+
api: '/api/users',
|
|
1197
1197
|
})
|
|
1198
1198
|
|
|
1199
1199
|
const ref = reference(users)
|
|
1200
1200
|
expect(ref.safeParse(42).success).toBe(true)
|
|
1201
|
-
expect(ref.safeParse(
|
|
1201
|
+
expect(ref.safeParse('abc-123').success).toBe(true)
|
|
1202
1202
|
expect(ref.safeParse(null).success).toBe(false)
|
|
1203
1203
|
expect(ref.safeParse(undefined).success).toBe(false)
|
|
1204
1204
|
expect(ref.safeParse({}).success).toBe(false)
|
|
1205
1205
|
})
|
|
1206
1206
|
|
|
1207
|
-
it(
|
|
1208
|
-
const ref = reference({ name:
|
|
1207
|
+
it('safeParseAsync works the same as safeParse', async () => {
|
|
1208
|
+
const ref = reference({ name: 'test' })
|
|
1209
1209
|
const result = await ref.safeParseAsync(42)
|
|
1210
1210
|
expect(result.success).toBe(true)
|
|
1211
1211
|
|
|
@@ -1213,20 +1213,20 @@ describe("reference", () => {
|
|
|
1213
1213
|
expect(fail.success).toBe(false)
|
|
1214
1214
|
})
|
|
1215
1215
|
|
|
1216
|
-
it(
|
|
1216
|
+
it('isReference returns false for non-reference objects', () => {
|
|
1217
1217
|
expect(isReference(null)).toBe(false)
|
|
1218
1218
|
expect(isReference(undefined)).toBe(false)
|
|
1219
1219
|
expect(isReference(42)).toBe(false)
|
|
1220
|
-
expect(isReference(
|
|
1220
|
+
expect(isReference('string')).toBe(false)
|
|
1221
1221
|
expect(isReference({})).toBe(false)
|
|
1222
1222
|
expect(isReference(z.string())).toBe(false)
|
|
1223
1223
|
})
|
|
1224
1224
|
|
|
1225
|
-
it(
|
|
1225
|
+
it('reference fields detected in schema introspection', () => {
|
|
1226
1226
|
const users = defineFeature<UserValues>({
|
|
1227
|
-
name:
|
|
1227
|
+
name: 'users-ref-introspect',
|
|
1228
1228
|
schema: userSchema,
|
|
1229
|
-
api:
|
|
1229
|
+
api: '/api/users',
|
|
1230
1230
|
})
|
|
1231
1231
|
|
|
1232
1232
|
const postSchema = z.object({
|
|
@@ -1236,63 +1236,63 @@ describe("reference", () => {
|
|
|
1236
1236
|
})
|
|
1237
1237
|
|
|
1238
1238
|
const fields = extractFields(postSchema)
|
|
1239
|
-
const authorField = fields.find((f) => f.name ===
|
|
1239
|
+
const authorField = fields.find((f) => f.name === 'authorId')
|
|
1240
1240
|
expect(authorField).toBeDefined()
|
|
1241
|
-
expect(authorField!.type).toBe(
|
|
1242
|
-
expect(authorField!.referenceTo).toBe(
|
|
1241
|
+
expect(authorField!.type).toBe('reference')
|
|
1242
|
+
expect(authorField!.referenceTo).toBe('users-ref-introspect')
|
|
1243
1243
|
})
|
|
1244
1244
|
|
|
1245
|
-
it(
|
|
1245
|
+
it('reference fields in defineFeature schema produce reference FieldInfo', () => {
|
|
1246
1246
|
const users = defineFeature<UserValues>({
|
|
1247
|
-
name:
|
|
1247
|
+
name: 'users-ref-in-feat',
|
|
1248
1248
|
schema: userSchema,
|
|
1249
|
-
api:
|
|
1249
|
+
api: '/api/users',
|
|
1250
1250
|
})
|
|
1251
1251
|
|
|
1252
1252
|
type PostValues = { title: string; body: string; authorId: string }
|
|
1253
1253
|
const posts = defineFeature<PostValues>({
|
|
1254
|
-
name:
|
|
1254
|
+
name: 'posts-ref',
|
|
1255
1255
|
schema: z.object({
|
|
1256
1256
|
title: z.string(),
|
|
1257
1257
|
body: z.string(),
|
|
1258
1258
|
authorId: reference(users) as any,
|
|
1259
1259
|
}),
|
|
1260
|
-
api:
|
|
1260
|
+
api: '/api/posts',
|
|
1261
1261
|
})
|
|
1262
1262
|
|
|
1263
|
-
const authorField = posts.fields.find((f) => f.name ===
|
|
1263
|
+
const authorField = posts.fields.find((f) => f.name === 'authorId')
|
|
1264
1264
|
expect(authorField).toBeDefined()
|
|
1265
|
-
expect(authorField!.type).toBe(
|
|
1266
|
-
expect(authorField!.referenceTo).toBe(
|
|
1265
|
+
expect(authorField!.type).toBe('reference')
|
|
1266
|
+
expect(authorField!.referenceTo).toBe('users-ref-in-feat')
|
|
1267
1267
|
})
|
|
1268
1268
|
})
|
|
1269
1269
|
|
|
1270
1270
|
// ─── Edge case coverage ────────────────────────────────────────────────────────
|
|
1271
1271
|
|
|
1272
|
-
describe(
|
|
1273
|
-
it(
|
|
1272
|
+
describe('edge cases', () => {
|
|
1273
|
+
it('defineFeature works without a Zod schema (no validation)', () => {
|
|
1274
1274
|
const items = defineFeature<{ title: string }>({
|
|
1275
|
-
name:
|
|
1275
|
+
name: 'items-no-zod',
|
|
1276
1276
|
schema: { notAZodSchema: true } as { _output?: { title: string } },
|
|
1277
|
-
api:
|
|
1277
|
+
api: '/api/items',
|
|
1278
1278
|
})
|
|
1279
1279
|
|
|
1280
1280
|
const client = new QueryClient()
|
|
1281
1281
|
const { result: form, unmount } = mountWith(client, () => items.useForm())
|
|
1282
|
-
expect(typeof form.handleSubmit).toBe(
|
|
1282
|
+
expect(typeof form.handleSubmit).toBe('function')
|
|
1283
1283
|
unmount()
|
|
1284
1284
|
})
|
|
1285
1285
|
|
|
1286
|
-
it(
|
|
1286
|
+
it('useForm onError callback fires on submit failure', async () => {
|
|
1287
1287
|
let caughtError: unknown = null
|
|
1288
1288
|
const users = defineFeature<UserValues>({
|
|
1289
|
-
name:
|
|
1289
|
+
name: 'users-form-onerror',
|
|
1290
1290
|
schema: userSchema,
|
|
1291
|
-
api:
|
|
1291
|
+
api: '/api/users',
|
|
1292
1292
|
fetcher: createMockFetch({
|
|
1293
|
-
|
|
1293
|
+
'POST /api/users': {
|
|
1294
1294
|
status: 500,
|
|
1295
|
-
body: { message:
|
|
1295
|
+
body: { message: 'Server broke' },
|
|
1296
1296
|
},
|
|
1297
1297
|
}) as typeof fetch,
|
|
1298
1298
|
})
|
|
@@ -1306,10 +1306,10 @@ describe("edge cases", () => {
|
|
|
1306
1306
|
}),
|
|
1307
1307
|
)
|
|
1308
1308
|
|
|
1309
|
-
form.setFieldValue(
|
|
1310
|
-
form.setFieldValue(
|
|
1311
|
-
form.setFieldValue(
|
|
1312
|
-
form.setFieldValue(
|
|
1309
|
+
form.setFieldValue('name', 'Al')
|
|
1310
|
+
form.setFieldValue('email', 'a@t.com')
|
|
1311
|
+
form.setFieldValue('role', 'admin')
|
|
1312
|
+
form.setFieldValue('active', true)
|
|
1313
1313
|
|
|
1314
1314
|
try {
|
|
1315
1315
|
await form.handleSubmit()
|
|
@@ -1322,32 +1322,32 @@ describe("edge cases", () => {
|
|
|
1322
1322
|
unmount()
|
|
1323
1323
|
})
|
|
1324
1324
|
|
|
1325
|
-
it(
|
|
1325
|
+
it('useTable sorting via direct state object (non-function updater)', () => {
|
|
1326
1326
|
const users = defineFeature<UserValues>({
|
|
1327
|
-
name:
|
|
1327
|
+
name: 'users-table-sort-direct',
|
|
1328
1328
|
schema: userSchema,
|
|
1329
|
-
api:
|
|
1329
|
+
api: '/api/users',
|
|
1330
1330
|
})
|
|
1331
1331
|
|
|
1332
1332
|
const data: UserValues[] = [
|
|
1333
|
-
{ name:
|
|
1334
|
-
{ name:
|
|
1333
|
+
{ name: 'Bob', email: 'b@t.com', role: 'editor', active: true },
|
|
1334
|
+
{ name: 'Alice', email: 'a@t.com', role: 'admin', active: true },
|
|
1335
1335
|
]
|
|
1336
1336
|
|
|
1337
1337
|
const client = new QueryClient()
|
|
1338
1338
|
const { result, unmount } = mountWith(client, () => users.useTable(data))
|
|
1339
1339
|
|
|
1340
1340
|
// Trigger sorting via the table's toggle handler (function updater)
|
|
1341
|
-
result.table().getColumn(
|
|
1341
|
+
result.table().getColumn('name')!.toggleSorting(false)
|
|
1342
1342
|
expect(result.sorting().length).toBe(1)
|
|
1343
1343
|
|
|
1344
1344
|
// Also set sorting directly (non-function updater path)
|
|
1345
|
-
result.sorting.set([{ id:
|
|
1346
|
-
expect(result.sorting()).toEqual([{ id:
|
|
1345
|
+
result.sorting.set([{ id: 'email', desc: true }])
|
|
1346
|
+
expect(result.sorting()).toEqual([{ id: 'email', desc: true }])
|
|
1347
1347
|
|
|
1348
1348
|
// Set globalFilter directly
|
|
1349
|
-
result.globalFilter.set(
|
|
1350
|
-
expect(result.globalFilter()).toBe(
|
|
1349
|
+
result.globalFilter.set('alice')
|
|
1350
|
+
expect(result.globalFilter()).toBe('alice')
|
|
1351
1351
|
|
|
1352
1352
|
unmount()
|
|
1353
1353
|
})
|