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