@chidchanun/bcp 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,548 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useMemo,
5
+ useState,
6
+ type FormHTMLAttributes,
7
+ type ReactNode,
8
+ } from "react";
9
+
10
+ import {
11
+ navigate,
12
+ useRouter,
13
+ } from "./router-v2.js";
14
+
15
+ type FormSubmitEvent =
16
+ Parameters<
17
+ NonNullable<
18
+ FormHTMLAttributes<
19
+ HTMLFormElement
20
+ >["onSubmit"]
21
+ >
22
+ >[0];
23
+
24
+ export type FormActionMethod =
25
+ | "post"
26
+ | "put"
27
+ | "patch"
28
+ | "delete";
29
+
30
+ export interface FormStatus {
31
+ pending: boolean;
32
+ action: string | null;
33
+ method: FormActionMethod | null;
34
+ error: Error | null;
35
+ }
36
+
37
+ export interface FormProps
38
+ extends Omit<
39
+ FormHTMLAttributes<HTMLFormElement>,
40
+ "action" | "method"
41
+ > {
42
+ action: string;
43
+ method?: FormActionMethod;
44
+ refresh?: boolean;
45
+ replace?: boolean;
46
+ scroll?: boolean;
47
+ onActionData?: (
48
+ data: unknown
49
+ ) => void;
50
+ onActionError?: (
51
+ error: Error
52
+ ) => void;
53
+ children?: ReactNode;
54
+ }
55
+
56
+ interface FormRuntimeState {
57
+ status: FormStatus;
58
+ actionData: unknown;
59
+ hasActionData: boolean;
60
+ }
61
+
62
+ interface ActionDataPayload {
63
+ kind: "data";
64
+ data: unknown;
65
+ }
66
+
67
+ interface ActionRedirectPayload {
68
+ kind: "redirect";
69
+ redirect: {
70
+ location: string;
71
+ status: number;
72
+ };
73
+ }
74
+
75
+ interface ActionErrorPayload {
76
+ kind: "error";
77
+ error: {
78
+ message: string;
79
+ };
80
+ }
81
+
82
+ type ActionPayload =
83
+ | ActionDataPayload
84
+ | ActionRedirectPayload
85
+ | ActionErrorPayload;
86
+
87
+ const ACTION_NAME_PATTERN =
88
+ /^[A-Za-z_$][A-Za-z0-9_$]*$/;
89
+
90
+ const FormRuntimeContext =
91
+ createContext<FormRuntimeState>({
92
+ status: {
93
+ pending: false,
94
+ action: null,
95
+ method: null,
96
+ error: null,
97
+ },
98
+ actionData:
99
+ undefined,
100
+ hasActionData:
101
+ false,
102
+ });
103
+
104
+ export function Form({
105
+ action,
106
+ method = "post",
107
+ refresh = false,
108
+ replace = false,
109
+ scroll = true,
110
+ onActionData,
111
+ onActionError,
112
+ onSubmit,
113
+ children,
114
+ ...props
115
+ }: FormProps) {
116
+ assertActionName(
117
+ action
118
+ );
119
+
120
+ const router =
121
+ useRouter();
122
+ const normalizedMethod =
123
+ normalizeMethod(
124
+ method
125
+ );
126
+ const [pending, setPending] =
127
+ useState(
128
+ false
129
+ );
130
+ const [error, setError] =
131
+ useState<Error | null>(
132
+ null
133
+ );
134
+ const [actionData, setActionData] =
135
+ useState<unknown>(
136
+ undefined
137
+ );
138
+ const [hasActionData, setHasActionData] =
139
+ useState(
140
+ false
141
+ );
142
+
143
+ const runtimeState =
144
+ useMemo<FormRuntimeState>(
145
+ () => ({
146
+ status: {
147
+ pending,
148
+ action,
149
+ method:
150
+ normalizedMethod,
151
+ error,
152
+ },
153
+ actionData,
154
+ hasActionData,
155
+ }),
156
+ [
157
+ pending,
158
+ action,
159
+ normalizedMethod,
160
+ error,
161
+ actionData,
162
+ hasActionData,
163
+ ]
164
+ );
165
+
166
+ async function handleSubmit(
167
+ event: FormSubmitEvent
168
+ ) {
169
+ onSubmit?.(
170
+ event
171
+ );
172
+
173
+ if (
174
+ event.defaultPrevented ||
175
+ typeof window ===
176
+ "undefined"
177
+ ) {
178
+ return;
179
+ }
180
+
181
+ event.preventDefault();
182
+
183
+ const form =
184
+ event.currentTarget;
185
+ const data =
186
+ new FormData(
187
+ form
188
+ );
189
+ const targetPath =
190
+ `${window.location.pathname}${window.location.search}`;
191
+ const endpoint =
192
+ createActionEndpoint(
193
+ action,
194
+ normalizedMethod,
195
+ targetPath
196
+ );
197
+
198
+ setPending(
199
+ true
200
+ );
201
+ setError(
202
+ null
203
+ );
204
+ setHasActionData(
205
+ false
206
+ );
207
+
208
+ try {
209
+ const response =
210
+ await fetch(
211
+ endpoint,
212
+ {
213
+ method:
214
+ "POST",
215
+ credentials:
216
+ "same-origin",
217
+ redirect:
218
+ "manual",
219
+ headers: {
220
+ Accept:
221
+ "application/json",
222
+ "X-BCP-Action":
223
+ "1",
224
+ "X-BCP-Action-Target":
225
+ targetPath,
226
+ },
227
+ body:
228
+ data,
229
+ }
230
+ );
231
+
232
+ if (
233
+ response.status ===
234
+ 204
235
+ ) {
236
+ setActionData(
237
+ null
238
+ );
239
+ setHasActionData(
240
+ true
241
+ );
242
+ onActionData?.(
243
+ null
244
+ );
245
+
246
+ if (refresh) {
247
+ await router.refresh();
248
+ }
249
+ return;
250
+ }
251
+
252
+ const payload =
253
+ await readActionPayload(
254
+ response
255
+ );
256
+
257
+ if (
258
+ payload.kind ===
259
+ "redirect"
260
+ ) {
261
+ await navigate(
262
+ payload.redirect.location,
263
+ {
264
+ replace,
265
+ scroll,
266
+ }
267
+ );
268
+ return;
269
+ }
270
+
271
+ if (
272
+ payload.kind ===
273
+ "error"
274
+ ) {
275
+ throw new Error(
276
+ payload.error.message
277
+ );
278
+ }
279
+
280
+ setActionData(
281
+ payload.data
282
+ );
283
+ setHasActionData(
284
+ true
285
+ );
286
+ onActionData?.(
287
+ payload.data
288
+ );
289
+
290
+ if (refresh) {
291
+ await router.refresh();
292
+ }
293
+ } catch (caught) {
294
+ const nextError =
295
+ caught instanceof Error
296
+ ? caught
297
+ : new Error(
298
+ String(
299
+ caught
300
+ )
301
+ );
302
+
303
+ setError(
304
+ nextError
305
+ );
306
+ onActionError?.(
307
+ nextError
308
+ );
309
+ } finally {
310
+ setPending(
311
+ false
312
+ );
313
+ }
314
+ }
315
+
316
+ const progressiveAction =
317
+ createActionEndpoint(
318
+ action,
319
+ normalizedMethod,
320
+ null
321
+ );
322
+
323
+ return (
324
+ <FormRuntimeContext.Provider
325
+ value={runtimeState}
326
+ >
327
+ <form
328
+ {...props}
329
+ action={progressiveAction}
330
+ method="post"
331
+ onSubmit={handleSubmit}
332
+ >
333
+ {children}
334
+ </form>
335
+ </FormRuntimeContext.Provider>
336
+ );
337
+ }
338
+
339
+ export function useFormStatus(): FormStatus {
340
+ return useContext(
341
+ FormRuntimeContext
342
+ ).status;
343
+ }
344
+
345
+ export function useActionData<
346
+ T = unknown
347
+ >(): T | undefined {
348
+ const state =
349
+ useContext(
350
+ FormRuntimeContext
351
+ );
352
+
353
+ return state.hasActionData
354
+ ? state.actionData as T
355
+ : undefined;
356
+ }
357
+
358
+ export function useActionError(): Error | null {
359
+ return useContext(
360
+ FormRuntimeContext
361
+ ).status.error;
362
+ }
363
+
364
+ function createActionEndpoint(
365
+ action: string,
366
+ method: FormActionMethod,
367
+ targetPath: string | null
368
+ ): string {
369
+ const params =
370
+ new URLSearchParams({
371
+ name:
372
+ action,
373
+ method:
374
+ method.toUpperCase(),
375
+ });
376
+
377
+ if (targetPath) {
378
+ params.set(
379
+ "url",
380
+ targetPath
381
+ );
382
+ }
383
+
384
+ return `/_bcp/action?${params.toString()}`;
385
+ }
386
+
387
+ async function readActionPayload(
388
+ response: Response
389
+ ): Promise<ActionPayload> {
390
+ const contentType =
391
+ response.headers.get(
392
+ "content-type"
393
+ ) ??
394
+ "";
395
+
396
+ if (
397
+ !/^application\/json\b/i.test(
398
+ contentType
399
+ )
400
+ ) {
401
+ throw new Error(
402
+ `BCP Framework: form action returned unsupported HTTP ${response.status} ${contentType || "response"}. Return serializable data or redirect().`
403
+ );
404
+ }
405
+
406
+ let value:
407
+ unknown;
408
+
409
+ try {
410
+ value =
411
+ await response.json();
412
+ } catch {
413
+ throw new Error(
414
+ "BCP Framework: form action returned invalid JSON."
415
+ );
416
+ }
417
+
418
+ if (
419
+ !isActionPayload(
420
+ value
421
+ )
422
+ ) {
423
+ throw new Error(
424
+ "BCP Framework: form action returned an invalid action payload."
425
+ );
426
+ }
427
+
428
+ return value;
429
+ }
430
+
431
+ function isActionPayload(
432
+ value: unknown
433
+ ): value is ActionPayload {
434
+ if (
435
+ value === null ||
436
+ typeof value !==
437
+ "object"
438
+ ) {
439
+ return false;
440
+ }
441
+
442
+ const record =
443
+ value as Record<
444
+ string,
445
+ unknown
446
+ >;
447
+
448
+ if (
449
+ record.kind ===
450
+ "data"
451
+ ) {
452
+ return Object.prototype
453
+ .hasOwnProperty.call(
454
+ record,
455
+ "data"
456
+ );
457
+ }
458
+
459
+ if (
460
+ record.kind ===
461
+ "redirect"
462
+ ) {
463
+ const redirect =
464
+ record.redirect;
465
+
466
+ return (
467
+ redirect !== null &&
468
+ typeof redirect ===
469
+ "object" &&
470
+ typeof (
471
+ redirect as Record<
472
+ string,
473
+ unknown
474
+ >
475
+ ).location ===
476
+ "string" &&
477
+ typeof (
478
+ redirect as Record<
479
+ string,
480
+ unknown
481
+ >
482
+ ).status ===
483
+ "number"
484
+ );
485
+ }
486
+
487
+ if (
488
+ record.kind ===
489
+ "error"
490
+ ) {
491
+ const error =
492
+ record.error;
493
+
494
+ return (
495
+ error !== null &&
496
+ typeof error ===
497
+ "object" &&
498
+ typeof (
499
+ error as Record<
500
+ string,
501
+ unknown
502
+ >
503
+ ).message ===
504
+ "string"
505
+ );
506
+ }
507
+
508
+ return false;
509
+ }
510
+
511
+ function normalizeMethod(
512
+ method: FormActionMethod
513
+ ): FormActionMethod {
514
+ const value =
515
+ method.toLowerCase() as
516
+ FormActionMethod;
517
+
518
+ if (
519
+ value !== "post" &&
520
+ value !== "put" &&
521
+ value !== "patch" &&
522
+ value !== "delete"
523
+ ) {
524
+ throw new Error(
525
+ `BCP Framework: unsupported <Form> method "${String(method)}".`
526
+ );
527
+ }
528
+
529
+ return value;
530
+ }
531
+
532
+ function assertActionName(
533
+ action: string
534
+ ): void {
535
+ if (
536
+ !ACTION_NAME_PATTERN.test(
537
+ action
538
+ ) ||
539
+ action === "default" ||
540
+ action === "__proto__" ||
541
+ action === "prototype" ||
542
+ action === "constructor"
543
+ ) {
544
+ throw new Error(
545
+ `BCP Framework: invalid <Form> action name "${action}".`
546
+ );
547
+ }
548
+ }
@@ -12,12 +12,23 @@ export {
12
12
  type LinkProps,
13
13
  } from "./link.js";
14
14
 
15
+ export {
16
+ Form,
17
+ useActionData,
18
+ useActionError,
19
+ useFormStatus,
20
+ type FormActionMethod,
21
+ type FormProps,
22
+ type FormStatus,
23
+ } from "./form.js";
24
+
15
25
  export {
16
26
  useNavigation,
17
27
  type NavigationState,
18
28
  } from "./navigation-state.js";
19
29
 
20
30
  export {
31
+ useGuardData,
21
32
  useLoaderData,
22
33
  } from "./loader-data.js";
23
34