@typed/navigation 0.10.0 → 0.10.2

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.
@@ -1,3 +1,5 @@
1
+ import * as HttpClient from "@effect/platform/HttpClient"
2
+
1
3
  import { Schema } from "@effect/schema"
2
4
  import type * as Context from "@typed/context"
3
5
  import * as RefSubject from "@typed/fx/RefSubject"
@@ -12,6 +14,9 @@ import type {
12
14
  BeforeNavigationEvent,
13
15
  BeforeNavigationHandler,
14
16
  CancelNavigation,
17
+ FormDataEvent,
18
+ FormDataHandler,
19
+ FormInputFrom,
15
20
  NavigateOptions,
16
21
  Navigation,
17
22
  NavigationError,
@@ -60,9 +65,18 @@ export type ModelAndIntent = {
60
65
  never,
61
66
  Set<readonly [NavigationHandler<any, any>, Context.Context<any>]>
62
67
  >
68
+
69
+ readonly formDataHandlers: RefSubject.RefSubject<
70
+ never,
71
+ never,
72
+ Set<readonly [FormDataHandler<any, any>, Context.Context<any>]>
73
+ >
74
+
63
75
  readonly commit: Commit
64
76
  }
65
77
 
78
+ const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308])
79
+
66
80
  export function setupFromModelAndIntent(
67
81
  modelAndIntent: ModelAndIntent,
68
82
  origin: string,
@@ -70,8 +84,7 @@ export function setupFromModelAndIntent(
70
84
  getRandomValues: Context.Fn.FnOf<typeof GetRandomValues>,
71
85
  newNavigationState?: () => NavigationState
72
86
  ) {
73
- const { beforeHandlers, canGoBack, canGoForward, commit, handlers, state } = modelAndIntent
74
-
87
+ const { beforeHandlers, canGoBack, canGoForward, commit, formDataHandlers, handlers, state } = modelAndIntent
75
88
  const entries = RefSubject.map(state, (s) => s.entries)
76
89
  const currentEntry = RefSubject.map(state, (s) => s.entries[s.index])
77
90
  const transition = RefSubject.map(state, (s) => s.transition)
@@ -122,6 +135,52 @@ export function setupFromModelAndIntent(
122
135
  }
123
136
  })
124
137
 
138
+ const runFormDataHandlers = (
139
+ event: FormDataEvent
140
+ ): Effect.Effect<
141
+ HttpClient.client.Client.Default,
142
+ NavigationError | HttpClient.error.HttpClientError,
143
+ Either.Either<RedirectError | CancelNavigation, Option.Option<HttpClient.response.ClientResponse>>
144
+ > =>
145
+ Effect.gen(function*(_) {
146
+ const handlers = yield* _(formDataHandlers)
147
+ const matches: Array<
148
+ Effect.Effect<never, RedirectError | CancelNavigation, Option.Option<HttpClient.response.ClientResponse>>
149
+ > = []
150
+
151
+ for (const [handler, ctx] of handlers) {
152
+ const exit = yield* _(handler(event), Effect.provide(ctx), Effect.either)
153
+ if (Either.isRight(exit)) {
154
+ const match = exit.right
155
+ if (Option.isSome(match)) {
156
+ matches.push(Effect.provide(match.value, ctx))
157
+ }
158
+ } else {
159
+ return Either.left(exit.left)
160
+ }
161
+ }
162
+
163
+ if (matches.length > 0) {
164
+ for (const match of matches) {
165
+ const exit = yield* _(match, Effect.either)
166
+ if (Either.isLeft(exit)) {
167
+ return Either.left(exit.left)
168
+ } else if (Option.isSome(exit.right)) {
169
+ return Either.right(exit.right)
170
+ }
171
+ }
172
+ } else {
173
+ // Only if there are 0 matches, we'll make a request to the server ourselves
174
+ const response = yield* _(
175
+ makeFormDataRequest(event, Option.getOrElse(event.action, () => event.from.url.href))
176
+ )
177
+
178
+ return Either.right(Option.some(response))
179
+ }
180
+
181
+ return Either.right(Option.none())
182
+ })
183
+
125
184
  const runNavigationEvent = (
126
185
  beforeEvent: BeforeNavigationEvent,
127
186
  get: Effect.Effect<never, never, NavigationState>,
@@ -190,7 +249,7 @@ export function setupFromModelAndIntent(
190
249
  get: Effect.Effect<never, never, NavigationState>,
191
250
  set: (a: NavigationState) => Effect.Effect<never, never, NavigationState>,
192
251
  depth: number
193
- ) =>
252
+ ): Effect.Effect<never, NavigationError, Destination> =>
194
253
  Effect.gen(function*(_) {
195
254
  if (depth >= 25) {
196
255
  return yield* _(Effect.dieMessage(`Redirect loop detected.`))
@@ -208,7 +267,7 @@ export function setupFromModelAndIntent(
208
267
 
209
268
  return yield* _(runNavigationEvent(event, get, set, depth + 1))
210
269
  }
211
- })
270
+ }).pipe(GetRandomValues.provide(getRandomValues))
212
271
 
213
272
  const navigate = (pathOrUrl: string | URL, options?: NavigateOptions, skipCommit: boolean = false) =>
214
273
  state.runUpdates(({ get, set }) =>
@@ -347,6 +406,73 @@ export function setupFromModelAndIntent(
347
406
  })
348
407
  )
349
408
 
409
+ const submit = (
410
+ data: FormData,
411
+ input?: Omit<FormInputFrom, "data">
412
+ ): Effect.Effect<
413
+ HttpClient.client.Client.Default,
414
+ NavigationError | HttpClient.error.HttpClientError,
415
+ Option.Option<HttpClient.response.ClientResponse>
416
+ > =>
417
+ state.runUpdates(({ get, set }) =>
418
+ Effect.gen(function*(_) {
419
+ const { entries, index } = yield* _(get)
420
+ const from = entries[index]
421
+ const event: FormDataEvent = {
422
+ from,
423
+ data,
424
+ name: Option.fromNullable(input?.name),
425
+ action: Option.fromNullable(input?.action),
426
+ method: Option.fromNullable(input?.method),
427
+ encoding: Option.fromNullable(input?.encoding)
428
+ }
429
+
430
+ const either = yield* _(runFormDataHandlers(event))
431
+
432
+ if (Either.isLeft(either)) {
433
+ yield* _(handleError(either.left, get, set, 0))
434
+ return Option.none<HttpClient.response.ClientResponse>()
435
+ } else {
436
+ if (Option.isNone(either.right)) {
437
+ return either.right
438
+ }
439
+
440
+ const response = either.right.value
441
+
442
+ // If it is a redirect
443
+ if (REDIRECT_STATUS_CODES.has(response.status)) {
444
+ const location = HttpClient.headers.get(response.headers, "location")
445
+
446
+ // And we have a location header
447
+ if (Option.isSome(location)) {
448
+ // Then we navigate to that location
449
+ yield* _(navigate(location.value, { history: "replace" }))
450
+ }
451
+ }
452
+
453
+ return Option.some(response)
454
+ }
455
+ })
456
+ )
457
+
458
+ const onFormData = <R = never, R2 = never>(
459
+ handler: FormDataHandler<R, R2>
460
+ ): Effect.Effect<R | R2 | Scope.Scope, never, void> =>
461
+ Effect.contextWithEffect((ctx) => {
462
+ const entry = [handler, ctx] as const
463
+
464
+ return Effect.zipRight(
465
+ RefSubject.update(formDataHandlers, (handlers) => new Set([...handlers, entry])),
466
+ Effect.addFinalizer(() =>
467
+ RefSubject.update(formDataHandlers, (handlers) => {
468
+ const updated = new Set(handlers)
469
+ updated.delete(entry)
470
+ return updated
471
+ })
472
+ )
473
+ )
474
+ })
475
+
350
476
  const navigation = {
351
477
  back,
352
478
  base,
@@ -362,7 +488,9 @@ export function setupFromModelAndIntent(
362
488
  reload,
363
489
  transition,
364
490
  traverseTo,
365
- updateCurrentEntry
491
+ updateCurrentEntry,
492
+ submit,
493
+ onFormData
366
494
  } satisfies Navigation
367
495
 
368
496
  return navigation
@@ -494,22 +622,53 @@ export function isDestination(proposed: ProposedDestination): proposed is Destin
494
622
  return "id" in proposed && "key" in proposed
495
623
  }
496
624
 
625
+ const strictEqual = <A>(a: A, b: A) => a === b
626
+
497
627
  export function makeHandlersState() {
498
628
  return Effect.gen(function*(_) {
499
629
  const beforeHandlers = yield* _(
500
630
  RefSubject.fromEffect(
501
- Effect.sync(() => new Set<readonly [BeforeNavigationHandler<any, any>, Context.Context<any>]>())
631
+ Effect.sync(() => new Set<readonly [BeforeNavigationHandler<any, any>, Context.Context<any>]>()),
632
+ { eq: strictEqual }
502
633
  )
503
634
  )
504
635
  const handlers = yield* _(
505
636
  RefSubject.fromEffect(
506
- Effect.sync(() => new Set<readonly [NavigationHandler<any, any>, Context.Context<any>]>())
637
+ Effect.sync(() => new Set<readonly [NavigationHandler<any, any>, Context.Context<any>]>()),
638
+ { eq: strictEqual }
639
+ )
640
+ )
641
+ const formDataHandlers = yield* _(
642
+ RefSubject.fromEffect(
643
+ Effect.sync(() => new Set<readonly [FormDataHandler<any, any>, Context.Context<any>]>()),
644
+ { eq: strictEqual }
507
645
  )
508
646
  )
509
647
 
510
648
  return {
511
649
  beforeHandlers,
512
- handlers
650
+ handlers,
651
+ formDataHandlers
513
652
  }
514
653
  })
515
654
  }
655
+
656
+ function makeFormDataRequest(event: FormDataEvent, url: string) {
657
+ const headers = new Headers()
658
+
659
+ if (Option.isSome(event.encoding)) {
660
+ headers.set("Content-Type", event.encoding.value)
661
+ }
662
+ const method = Option.getOrElse(event.method, () => "POST")
663
+
664
+ return Effect.flatMap(
665
+ HttpClient.client.Client,
666
+ (client) =>
667
+ client(
668
+ HttpClient.request.make(method as "POST")(url, {
669
+ headers: HttpClient.headers.fromInput(headers),
670
+ body: HttpClient.body.formData(event.data)
671
+ })
672
+ )
673
+ )
674
+ }