Skip to content

Commit edad771

Browse files
committed
Merge branch 'master' into feature/dynamic-form-propeties
2 parents fe1abdc + 9b0afa6 commit edad771

File tree

28 files changed

+1626
-96
lines changed

28 files changed

+1626
-96
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"dev:test-app:react": "pnpx concurrently -c \"#fdba74,#34d399\" \"cd tests/app && PACKAGE=react pnpm serve:watch\" \"cd packages/react/test-app && pnpm run dev\" --names=server,vite",
1010
"dev:test-app:svelte": "pnpx concurrently -c \"#fdba74,#34d399\" \"cd tests/app && PACKAGE=svelte pnpm serve:watch\" \"cd packages/svelte/test-app && pnpm run dev\" --names=server,vite",
1111
"dev:test-app:vue": "pnpx concurrently -c \"#fdba74,#34d399\" \"cd tests/app && PACKAGE=vue3 pnpm serve:watch\" \"cd packages/vue3/test-app && pnpm run dev\" --names=server,vite",
12+
"type-check:test-app": "pnpm -r --filter './packages/*/test-app' type-check",
1213
"type-check:test-app:react": "cd packages/react/test-app && pnpm run type-check",
1314
"type-check:test-app:svelte": "cd packages/svelte/test-app && pnpm run type-check",
1415
"type-check:test-app:vue": "cd packages/vue3/test-app && pnpm run type-check",

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Router } from './router'
22

33
export { objectToFormData } from './formData'
44
export { formDataToObject } from './formObject'
5+
export { resetFormFields } from './resetFormFields'
56
export { default as createHeadManager } from './head'
67
export { hide as hideProgress, reveal as revealProgress, default as setupProgress } from './progress'
78
export { default as shouldIntercept } from './shouldIntercept'

packages/core/src/prefetched.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ class PrefetchedRequests {
5555
onPrefetchResponse(response) {
5656
resolve(response)
5757
},
58+
onPrefetchError(error) {
59+
prefetchedRequests.removeFromInFlight(params)
60+
reject(error)
61+
},
5862
})
5963
}).then((response) => {
6064
this.remove(params)
@@ -69,10 +73,7 @@ class PrefetchedRequests {
6973
})
7074

7175
this.scheduleForRemoval(params, expires)
72-
73-
this.inFlightRequests = this.inFlightRequests.filter((prefetching) => {
74-
return !this.paramsAreEqual(prefetching.params, params)
75-
})
76+
this.removeFromInFlight(params)
7677

7778
response.handlePrefetch()
7879

@@ -105,6 +106,12 @@ class PrefetchedRequests {
105106
this.clearTimer(params)
106107
}
107108

109+
protected removeFromInFlight(params: ActiveVisit): void {
110+
this.inFlightRequests = this.inFlightRequests.filter((prefetching) => {
111+
return !this.paramsAreEqual(prefetching.params, params)
112+
})
113+
}
114+
108115
protected extractStaleValues(cacheFor: PrefetchOptions['cacheFor']): [number, number] {
109116
const [stale, expires] = this.cacheForToStaleAndExpires(cacheFor)
110117

packages/core/src/request.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ export class Request {
7272
}
7373

7474
if (fireExceptionEvent(error)) {
75+
if (originallyPrefetch) {
76+
this.requestParams.onPrefetchError(error)
77+
}
78+
7579
return Promise.reject(error)
7680
}
7781
})

packages/core/src/requestParams.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export class RequestParams {
3232
...params,
3333
...wrappedCallbacks,
3434
onPrefetchResponse: params.onPrefetchResponse || (() => {}),
35+
onPrefetchError: params.onPrefetchError || (() => {}),
3536
}
3637
}
3738
//
@@ -95,6 +96,12 @@ export class RequestParams {
9596
}
9697
}
9798

99+
public onPrefetchError(error: Error) {
100+
if (this.params.onPrefetchError) {
101+
this.params.onPrefetchError(error)
102+
}
103+
}
104+
98105
public all() {
99106
return this.params
100107
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
type FormElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
2+
3+
function isFormElement(element: Element): element is FormElement {
4+
return (
5+
element instanceof HTMLInputElement ||
6+
element instanceof HTMLSelectElement ||
7+
element instanceof HTMLTextAreaElement
8+
)
9+
}
10+
11+
function resetInputElement(input: HTMLInputElement, defaultValues: FormDataEntryValue[]): boolean {
12+
const oldValue = input.value
13+
const oldChecked = input.checked
14+
15+
switch (input.type.toLowerCase()) {
16+
case 'checkbox':
17+
// For checkboxes, check if the input's value is in the array of default values
18+
input.checked = defaultValues.includes(input.value)
19+
break
20+
case 'radio':
21+
// For radios, only use the first default value to avoid multiple radios being checked
22+
input.checked = defaultValues[0] === input.value
23+
break
24+
case 'file':
25+
input.value = ''
26+
break
27+
case 'button':
28+
case 'submit':
29+
case 'reset':
30+
case 'image':
31+
// These input types don't carry form state
32+
break
33+
default:
34+
// text, email, number, date, etc. - use first default value
35+
input.value = defaultValues[0] !== null && defaultValues[0] !== undefined ? String(defaultValues[0]) : ''
36+
}
37+
38+
// Return true if the value actually changed
39+
return input.value !== oldValue || input.checked !== oldChecked
40+
}
41+
42+
function resetSelectElement(select: HTMLSelectElement, defaultValues: FormDataEntryValue[]): boolean {
43+
const oldValue = select.value
44+
const oldSelectedOptions = Array.from(select.selectedOptions).map((opt) => opt.value)
45+
46+
if (select.multiple) {
47+
// For multi-select, select all options that match any of the default values
48+
const defaultStrings = defaultValues.map((value) => String(value))
49+
50+
Array.from(select.options).forEach((option) => {
51+
option.selected = defaultStrings.includes(option.value)
52+
})
53+
} else {
54+
// For single select, use the first default value (or empty string)
55+
select.value = defaultValues[0] !== undefined ? String(defaultValues[0]) : ''
56+
}
57+
58+
// Check if selection actually changed
59+
const newSelectedOptions = Array.from(select.selectedOptions).map((opt) => opt.value)
60+
const hasChanged = select.multiple
61+
? JSON.stringify(oldSelectedOptions.sort()) !== JSON.stringify(newSelectedOptions.sort())
62+
: select.value !== oldValue
63+
64+
return hasChanged
65+
}
66+
67+
function resetFormElement(element: FormElement, defaultValues: FormDataEntryValue[]): boolean {
68+
if (element.disabled) {
69+
// For disabled elements, use their DOM defaultValue since they're not in FormData
70+
if (element instanceof HTMLInputElement) {
71+
const oldValue = element.value
72+
const oldChecked = element.checked
73+
74+
switch (element.type.toLowerCase()) {
75+
case 'checkbox':
76+
case 'radio':
77+
element.checked = element.defaultChecked
78+
return element.checked !== oldChecked
79+
case 'file':
80+
element.value = ''
81+
return oldValue !== ''
82+
case 'button':
83+
case 'submit':
84+
case 'reset':
85+
case 'image':
86+
// These input types don't carry form state
87+
return false
88+
default:
89+
element.value = element.defaultValue
90+
return element.value !== oldValue
91+
}
92+
} else if (element instanceof HTMLSelectElement) {
93+
// Reset select to default selected options
94+
const oldSelectedOptions = Array.from(element.selectedOptions).map((opt) => opt.value)
95+
96+
Array.from(element.options).forEach((option) => {
97+
option.selected = option.defaultSelected
98+
})
99+
100+
const newSelectedOptions = Array.from(element.selectedOptions).map((opt) => opt.value)
101+
return JSON.stringify(oldSelectedOptions.sort()) !== JSON.stringify(newSelectedOptions.sort())
102+
} else if (element instanceof HTMLTextAreaElement) {
103+
const oldValue = element.value
104+
element.value = element.defaultValue
105+
return element.value !== oldValue
106+
}
107+
108+
return false
109+
}
110+
111+
if (element instanceof HTMLInputElement) {
112+
// Pass all default values to handle checkboxes and radios correctly
113+
return resetInputElement(element, defaultValues)
114+
} else if (element instanceof HTMLSelectElement) {
115+
return resetSelectElement(element, defaultValues)
116+
} else if (element instanceof HTMLTextAreaElement) {
117+
const oldValue = element.value
118+
element.value = defaultValues[0] !== undefined ? String(defaultValues[0]) : ''
119+
return element.value !== oldValue
120+
}
121+
122+
return false
123+
}
124+
125+
function resetFieldElements(
126+
elements: Element | RadioNodeList | HTMLCollection,
127+
defaultValues: FormDataEntryValue[],
128+
): boolean {
129+
let hasChanged = false
130+
131+
if (elements instanceof RadioNodeList || elements instanceof HTMLCollection) {
132+
// Handle multiple elements with the same name (e.g., radio buttons, checkboxes, array fields)
133+
Array.from(elements).forEach((node, index) => {
134+
if (node instanceof Element && isFormElement(node)) {
135+
if (node instanceof HTMLInputElement && ['checkbox', 'radio'].includes(node.type.toLowerCase())) {
136+
// For checkboxes and radios, pass all default values for value-based matching
137+
if (resetFormElement(node, defaultValues)) {
138+
hasChanged = true
139+
}
140+
} else {
141+
// For other array elements (like text inputs), use index-based matching
142+
const indexedDefaultValues =
143+
defaultValues[index] !== undefined ? [defaultValues[index]] : [defaultValues[0] ?? null].filter(Boolean)
144+
145+
if (resetFormElement(node, indexedDefaultValues)) {
146+
hasChanged = true
147+
}
148+
}
149+
}
150+
})
151+
} else if (isFormElement(elements)) {
152+
// Handle single element - pass all default values (important for multi-selects)
153+
hasChanged = resetFormElement(elements, defaultValues)
154+
}
155+
156+
return hasChanged
157+
}
158+
159+
export function resetFormFields(formElement: HTMLFormElement, defaults: FormData, fieldNames?: string[]): void {
160+
// If no specific fields provided, reset the entire form
161+
if (!fieldNames || fieldNames.length === 0) {
162+
formElement.reset()
163+
return
164+
}
165+
166+
let hasChanged = false
167+
168+
fieldNames.forEach((fieldName) => {
169+
const elements = formElement.elements.namedItem(fieldName)
170+
171+
if (elements) {
172+
if (resetFieldElements(elements, defaults.getAll(fieldName))) {
173+
hasChanged = true
174+
}
175+
}
176+
})
177+
178+
// Dispatch reset event if any field changed (matching native form.reset() behavior)
179+
if (hasChanged) {
180+
formElement.dispatchEvent(new Event('reset', { bubbles: true }))
181+
}
182+
}

packages/core/src/types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ export type ActiveVisit<T extends RequestPayload = RequestPayload> = PendingVisi
336336

337337
export type InternalActiveVisit = ActiveVisit & {
338338
onPrefetchResponse?: (response: Response) => void
339+
onPrefetchError?: (error: Error) => void
339340
}
340341

341342
export type VisitId = unknown
@@ -430,17 +431,24 @@ export type FormComponentProps = Partial<
430431
action?: string | { url: string; method: Method }
431432
transform?: (data: Record<string, FormDataConvertible>) => Record<string, FormDataConvertible>
432433
options?: FormComponentOptions
434+
onSubmitComplete?: (props: FormComponentonSubmitCompleteArguments) => void
435+
disableWhileProcessing?: boolean
433436
}
434437

435438
export type FormComponentMethods = {
436439
clearErrors: (...fields: string[]) => void
437440
resetAndClearErrors: (...fields: string[]) => void
438441
setError(field: string, value: string): void
439442
setError(errors: Record<string, string>): void
440-
reset: () => void
443+
reset: (...fields: string[]) => void
441444
submit: () => void
442445
}
443446

447+
export type FormComponentonSubmitCompleteArguments = Pick<
448+
FormComponentMethods,
449+
'clearErrors' | 'resetAndClearErrors' | 'reset'
450+
>
451+
444452
export type FormComponentState = {
445453
errors: Record<string, string>
446454
hasErrors: boolean

0 commit comments

Comments
 (0)