@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.9.11",
3
+ "version": "0.9.13",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -217,6 +217,7 @@ export function Link({
217
217
  href={basePath + linkHref}
218
218
  title={linkTitle}
219
219
  className={className}
220
+ data-reload="true"
220
221
  {...props}
221
222
  >
222
223
  {children}
@@ -23,3 +23,6 @@ export {
23
23
  useThemeColor,
24
24
  useThemeColorVar
25
25
  } from './useThemeData.js'
26
+
27
+ // Form submission lifecycle for foundation Form components
28
+ export { useFormSubmit } from './useFormSubmit.js'
@@ -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
  // ============================================================================
@@ -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
+ }