@uniweb/kit 0.9.11 → 0.9.13
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/package.json +1 -1
- package/src/components/Link/Link.jsx +1 -0
- package/src/hooks/index.js +3 -0
- package/src/hooks/useFormSubmit.js +70 -0
- package/src/index.js +7 -2
- package/src/utils/index.js +6 -0
- package/src/utils/submitForm.js +92 -0
package/package.json
CHANGED
package/src/hooks/index.js
CHANGED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
import { submitForm } from '../utils/submitForm.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* React hook wrapping `submitForm()` with state machine for the
|
|
6
|
+
* `idle → submitting → success | error` lifecycle most form UIs need.
|
|
7
|
+
*
|
|
8
|
+
* Pass `defaults` once (formId, sectionType, preview-builder, …) and call
|
|
9
|
+
* `submit(formData)` from your submit handler. The hook exposes:
|
|
10
|
+
* - status: 'idle' | 'submitting' | 'success' | 'error'
|
|
11
|
+
* - error: Error | null
|
|
12
|
+
* - response: { submissionId, uploadUrls? } | null (on success)
|
|
13
|
+
* - submit: async (formData, perCallOverrides?) => response
|
|
14
|
+
* - reset: () => void
|
|
15
|
+
*
|
|
16
|
+
* `defaults.preview` may be either a static object or a function of formData
|
|
17
|
+
* that returns one. The function form is useful when the preview is computed
|
|
18
|
+
* from the same fields that just got submitted.
|
|
19
|
+
*
|
|
20
|
+
* Examples — see kit's submitForm() JSDoc for the full payload contract.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} [defaults] — merged into every submit() call
|
|
23
|
+
* @returns {{
|
|
24
|
+
* status: 'idle' | 'submitting' | 'success' | 'error',
|
|
25
|
+
* error: Error | null,
|
|
26
|
+
* response: object | null,
|
|
27
|
+
* submit: (formData: object, overrides?: object) => Promise<object>,
|
|
28
|
+
* reset: () => void,
|
|
29
|
+
* }}
|
|
30
|
+
*/
|
|
31
|
+
export function useFormSubmit(defaults = {}) {
|
|
32
|
+
const [status, setStatus] = useState('idle')
|
|
33
|
+
const [error, setError] = useState(null)
|
|
34
|
+
const [response, setResponse] = useState(null)
|
|
35
|
+
|
|
36
|
+
const submit = useCallback(
|
|
37
|
+
async (formData, perCallOverrides = {}) => {
|
|
38
|
+
setStatus('submitting')
|
|
39
|
+
setError(null)
|
|
40
|
+
try {
|
|
41
|
+
const merged = { ...defaults, ...perCallOverrides, formData }
|
|
42
|
+
// Resolve preview-as-function against the formData being submitted.
|
|
43
|
+
if (typeof merged.preview === 'function') {
|
|
44
|
+
merged.preview = merged.preview(formData)
|
|
45
|
+
}
|
|
46
|
+
const result = await submitForm(merged)
|
|
47
|
+
setStatus('success')
|
|
48
|
+
setResponse(result)
|
|
49
|
+
return result
|
|
50
|
+
} catch (err) {
|
|
51
|
+
setStatus('error')
|
|
52
|
+
setError(err)
|
|
53
|
+
throw err
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
// `defaults` is referentially unstable across renders; we deliberately
|
|
57
|
+
// close over the latest one each render rather than memo the hook.
|
|
58
|
+
// The lint rule fires false positives here.
|
|
59
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
60
|
+
[],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const reset = useCallback(() => {
|
|
64
|
+
setStatus('idle')
|
|
65
|
+
setError(null)
|
|
66
|
+
setResponse(null)
|
|
67
|
+
}, [])
|
|
68
|
+
|
|
69
|
+
return { status, error, response, submit, reset }
|
|
70
|
+
}
|
package/src/index.js
CHANGED
|
@@ -79,7 +79,9 @@ export {
|
|
|
79
79
|
useThemeColorVar,
|
|
80
80
|
// Observable state bridges (page.state / website.state)
|
|
81
81
|
usePageState,
|
|
82
|
-
useWebsiteState
|
|
82
|
+
useWebsiteState,
|
|
83
|
+
// Form submission lifecycle for foundation Form components
|
|
84
|
+
useFormSubmit
|
|
83
85
|
} from './hooks/index.js'
|
|
84
86
|
|
|
85
87
|
// ============================================================================
|
|
@@ -102,7 +104,10 @@ export {
|
|
|
102
104
|
ChildBlocks,
|
|
103
105
|
// Locale utilities
|
|
104
106
|
LOCALE_DISPLAY_NAMES,
|
|
105
|
-
getLocaleLabel
|
|
107
|
+
getLocaleLabel,
|
|
108
|
+
// Form submission utilities (low-level companion of useFormSubmit hook)
|
|
109
|
+
submitForm,
|
|
110
|
+
derivePreviewFromFormData
|
|
106
111
|
} from './utils/index.js'
|
|
107
112
|
|
|
108
113
|
// ============================================================================
|
package/src/utils/index.js
CHANGED
|
@@ -271,3 +271,9 @@ export function detectMediaType(url) {
|
|
|
271
271
|
|
|
272
272
|
return 'unknown'
|
|
273
273
|
}
|
|
274
|
+
|
|
275
|
+
// ─────────────────────────────────────────────────────────────────
|
|
276
|
+
// Form Submission
|
|
277
|
+
// ─────────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
export { submitForm, derivePreviewFromFormData } from './submitForm.js'
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Submit a form to the Uniweb submission endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Form components in a foundation collect field values and call this to
|
|
5
|
+
* deliver them to the platform's submission pipeline. The submission lands
|
|
6
|
+
* keyed by the site's identity (resolved by the platform from the
|
|
7
|
+
* visitor's hostname), so this function takes no siteId — the page's
|
|
8
|
+
* own URL determines the destination.
|
|
9
|
+
*
|
|
10
|
+
* The default `submitPath` is `/_submit`, served on the visitor's hostname
|
|
11
|
+
* by the Uniweb runtime infrastructure. Override only if you're testing
|
|
12
|
+
* against a non-standard endpoint.
|
|
13
|
+
*
|
|
14
|
+
* The `preview` object becomes the {title, subtitle, tag} that the
|
|
15
|
+
* editor's inbox row displays for this submission. If a foundation
|
|
16
|
+
* doesn't pass one, a fallback is derived from the first two non-empty
|
|
17
|
+
* string fields of `formData` so the row is always meaningful.
|
|
18
|
+
*
|
|
19
|
+
* Optional `turnstileToken` is forwarded as-is for bot-protection
|
|
20
|
+
* verification when the platform has Turnstile enabled.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} args
|
|
23
|
+
* @param {Record<string, unknown>} args.formData — field values
|
|
24
|
+
* @param {object} [args.preview] — { title, subtitle, tag? }
|
|
25
|
+
* @param {object} [args.metadata] — formId, sectionType, sectionId, pageId, pageLabel, …
|
|
26
|
+
* @param {string} [args.turnstileToken] — Cloudflare Turnstile token
|
|
27
|
+
* @param {Array<{name:string,size:number,mime?:string}>} [args.fileSlots]
|
|
28
|
+
* — declared file uploads (multi-step ingestion)
|
|
29
|
+
* @param {string} [args.submitPath='/_submit'] — endpoint override (testing)
|
|
30
|
+
* @param {typeof fetch} [args.fetchFn=fetch] — fetch override (testing / SSR)
|
|
31
|
+
*
|
|
32
|
+
* @returns {Promise<{ submissionId: string, uploadUrls?: Array }>}
|
|
33
|
+
* @throws {Error} on non-2xx with the server's `error` message when present.
|
|
34
|
+
*/
|
|
35
|
+
export async function submitForm({
|
|
36
|
+
formData,
|
|
37
|
+
preview,
|
|
38
|
+
metadata = {},
|
|
39
|
+
turnstileToken,
|
|
40
|
+
fileSlots,
|
|
41
|
+
submitPath = '/_submit',
|
|
42
|
+
fetchFn = typeof fetch === 'function' ? fetch : null,
|
|
43
|
+
} = {}) {
|
|
44
|
+
if (!formData || typeof formData !== 'object') {
|
|
45
|
+
throw new Error('submitForm: formData object is required')
|
|
46
|
+
}
|
|
47
|
+
if (!fetchFn) {
|
|
48
|
+
throw new Error('submitForm: fetch is unavailable in this environment')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const finalPreview = preview || derivePreviewFromFormData(formData)
|
|
52
|
+
|
|
53
|
+
const body = {
|
|
54
|
+
formData,
|
|
55
|
+
metadata: { ...metadata, preview: finalPreview },
|
|
56
|
+
...(turnstileToken ? { turnstileToken } : {}),
|
|
57
|
+
...(Array.isArray(fileSlots) && fileSlots.length ? { fileSlots } : {}),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const res = await fetchFn(submitPath, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify(body),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
let serverMessage
|
|
68
|
+
try { serverMessage = (await res.json()).error } catch { /* not JSON */ }
|
|
69
|
+
throw new Error(serverMessage || `Submission failed (HTTP ${res.status})`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return res.json()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a default preview from a form's field values: first two non-empty
|
|
77
|
+
* string fields become the title / subtitle. Mirrors the legacy
|
|
78
|
+
* getStandardPreview() convention from the prior Form class.
|
|
79
|
+
*
|
|
80
|
+
* @param {Record<string, unknown>} data
|
|
81
|
+
* @returns {{ title: string, subtitle: string }}
|
|
82
|
+
*/
|
|
83
|
+
export function derivePreviewFromFormData(data) {
|
|
84
|
+
if (!data || typeof data !== 'object') return { title: 'Submission', subtitle: '' }
|
|
85
|
+
const entries = Object.entries(data).filter(
|
|
86
|
+
([, v]) => typeof v === 'string' && v.trim().length > 0,
|
|
87
|
+
)
|
|
88
|
+
return {
|
|
89
|
+
title: entries[0]?.[1] || 'Submission',
|
|
90
|
+
subtitle: entries[1]?.[1] || '',
|
|
91
|
+
}
|
|
92
|
+
}
|