@uniweb/kit 0.10.16 → 0.10.17
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 +2 -2
- package/src/hooks/useFormSubmit.js +14 -0
- package/src/utils/submitForm.js +122 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.17",
|
|
4
4
|
"description": "Standard component library for Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"fuse.js": "^7.0.0",
|
|
44
44
|
"shiki": "^3.0.0",
|
|
45
45
|
"tailwind-merge": "^3.6.0",
|
|
46
|
-
"@uniweb/core": "0.8.2",
|
|
47
46
|
"@uniweb/scene": "0.1.2",
|
|
47
|
+
"@uniweb/core": "0.8.2",
|
|
48
48
|
"@uniweb/semantic-parser": "1.2.1"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
@@ -104,6 +104,20 @@ export function useFormSubmit(defaults = {}) {
|
|
|
104
104
|
response,
|
|
105
105
|
canSubmit: !!target,
|
|
106
106
|
unavailableReason,
|
|
107
|
+
// Whether attachments can be delivered. True once there is a target: the
|
|
108
|
+
// client sends the manifest, then the bytes, then finalizes.
|
|
109
|
+
//
|
|
110
|
+
// It tracks `canSubmit` because it is a statement about THIS client, not
|
|
111
|
+
// about the endpoint — whether a given endpoint accepts uploads is
|
|
112
|
+
// discovered on submit, and a failure there throws with a message saying
|
|
113
|
+
// the submission landed and the attachment did not. What this rules out is
|
|
114
|
+
// the case that has no honest report: offering a file input when nothing
|
|
115
|
+
// could ever send the bytes.
|
|
116
|
+
//
|
|
117
|
+
// Kept as its own field rather than folded into `canSubmit` because a
|
|
118
|
+
// component decides *whether to render a file input* separately from
|
|
119
|
+
// whether to render the form, and that decision belongs at render time.
|
|
120
|
+
canUploadFiles: !!target,
|
|
107
121
|
submit,
|
|
108
122
|
reset,
|
|
109
123
|
}
|
package/src/utils/submitForm.js
CHANGED
|
@@ -35,8 +35,17 @@
|
|
|
35
35
|
* pageId, pageLabel
|
|
36
36
|
* @param {string} [args.verificationToken] — bot-protection token, when the
|
|
37
37
|
* endpoint verifies one
|
|
38
|
+
* @param {Array<File|{file:File,field?:string}>} [args.files]
|
|
39
|
+
* — attachments to upload. The
|
|
40
|
+
* manifest is derived from
|
|
41
|
+
* these; phase two sends the
|
|
42
|
+
* bytes. The `{file, field}`
|
|
43
|
+
* form records which field an
|
|
44
|
+
* attachment came from.
|
|
38
45
|
* @param {Array<{name:string,size:number,mime?:string}>} [args.fileSlots]
|
|
39
|
-
* —
|
|
46
|
+
* — a manifest with no bytes.
|
|
47
|
+
* Accepted, but ONLY sends the
|
|
48
|
+
* declaration; prefer `files`
|
|
40
49
|
* @param {typeof fetch} [args.fetchFn=fetch] — fetch override (testing / SSR)
|
|
41
50
|
*
|
|
42
51
|
* @returns {Promise<{ submissionId: string, uploadUrls?: Array }>}
|
|
@@ -48,6 +57,7 @@ export async function submitForm({
|
|
|
48
57
|
summary,
|
|
49
58
|
context = {},
|
|
50
59
|
verificationToken,
|
|
60
|
+
files,
|
|
51
61
|
fileSlots,
|
|
52
62
|
fetchFn = typeof fetch === 'function' ? fetch : null,
|
|
53
63
|
} = {}) {
|
|
@@ -64,12 +74,33 @@ export async function submitForm({
|
|
|
64
74
|
throw new Error('submitForm: fetch is unavailable in this environment')
|
|
65
75
|
}
|
|
66
76
|
|
|
77
|
+
const entries = normalizeFiles(files)
|
|
78
|
+
|
|
79
|
+
// A manifest without the files it describes cannot be delivered — the bytes
|
|
80
|
+
// are what phase two sends. Passing `fileSlots` alone declares attachments
|
|
81
|
+
// nobody receives, which is a success that is not one, so say so.
|
|
82
|
+
if (entries.length === 0 && Array.isArray(fileSlots) && fileSlots.length > 0) {
|
|
83
|
+
console.warn(
|
|
84
|
+
`[uniweb] submitForm: ${fileSlots.length} file(s) declared via \`fileSlots\` with no ` +
|
|
85
|
+
'`files` — the manifest is sent and the bytes are NOT. Pass `files` so they upload.',
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const slots = entries.length
|
|
90
|
+
? entries.map(({ file, field }) => ({
|
|
91
|
+
name: file.name,
|
|
92
|
+
size: file.size,
|
|
93
|
+
mime: file.type || 'application/octet-stream',
|
|
94
|
+
...(field ? { field } : {}),
|
|
95
|
+
}))
|
|
96
|
+
: fileSlots
|
|
97
|
+
|
|
67
98
|
// ── API name → wire name. See the header before "correcting" these. ──
|
|
68
99
|
const body = {
|
|
69
100
|
formData,
|
|
70
101
|
metadata: { ...context, preview: summary || deriveSummary(formData) },
|
|
71
102
|
...(verificationToken ? { turnstileToken: verificationToken } : {}),
|
|
72
|
-
...(Array.isArray(
|
|
103
|
+
...(Array.isArray(slots) && slots.length ? { fileSlots: slots } : {}),
|
|
73
104
|
}
|
|
74
105
|
|
|
75
106
|
const res = await fetchFn(target, {
|
|
@@ -84,7 +115,95 @@ export async function submitForm({
|
|
|
84
115
|
throw new Error(serverMessage || `Submission failed (HTTP ${res.status})`)
|
|
85
116
|
}
|
|
86
117
|
|
|
87
|
-
|
|
118
|
+
const result = await res.json()
|
|
119
|
+
if (entries.length === 0) return result
|
|
120
|
+
|
|
121
|
+
await uploadFiles(entries, result, target, fetchFn)
|
|
122
|
+
return { ...result, filesUploaded: entries.length }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Accept either bare `File`s or `{ file, field }` pairs, and drop anything that
|
|
127
|
+
* is not a file. The pair form exists so a submission can say WHICH field an
|
|
128
|
+
* attachment came from — a form may have more than one file input.
|
|
129
|
+
*/
|
|
130
|
+
function normalizeFiles(files) {
|
|
131
|
+
if (!Array.isArray(files)) return []
|
|
132
|
+
return files
|
|
133
|
+
.map((f) => (f && typeof f === 'object' && 'file' in f ? f : { file: f }))
|
|
134
|
+
.filter(({ file }) => file && typeof file === 'object' && 'name' in file)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Phase two — send the bytes.
|
|
139
|
+
*
|
|
140
|
+
* Phase one posts a *manifest* and gets back a submission id; the bytes go
|
|
141
|
+
* separately so they never ride inside the JSON. Two shapes are honoured:
|
|
142
|
+
*
|
|
143
|
+
* - **`uploadUrls` in the response** — one per slot, in slot order. Used when
|
|
144
|
+
* present, because an endpoint returning them is telling you where to write.
|
|
145
|
+
* - **Otherwise `{target}/upload`** — raw body, `X-Submission-Id` and `X-Slot`
|
|
146
|
+
* headers, then `{target}/finalize`. This is the shape the endpoint
|
|
147
|
+
* documents, and it is the default rather than a fallback.
|
|
148
|
+
*
|
|
149
|
+
* `X-Slot` is the index into the manifest sent in phase one, which is why the
|
|
150
|
+
* entries and the slots are built from one list in one order.
|
|
151
|
+
*
|
|
152
|
+
* **Failures throw, and the message says what did land.** The submission row
|
|
153
|
+
* already exists at this point, so a silent failure here is the same
|
|
154
|
+
* discarded-attachment bug in a new place — a caller must be able to tell the
|
|
155
|
+
* visitor that their message arrived and their file did not.
|
|
156
|
+
*/
|
|
157
|
+
async function uploadFiles(entries, result, target, fetchFn) {
|
|
158
|
+
const submissionId = result?.submissionId
|
|
159
|
+
if (!submissionId) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
'submitForm: the submission was recorded but returned no submissionId, so its ' +
|
|
162
|
+
`${entries.length} attachment(s) could not be uploaded.`,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const base = String(target).replace(/\/+$/, '')
|
|
167
|
+
const urls = Array.isArray(result?.uploadUrls) ? result.uploadUrls : []
|
|
168
|
+
|
|
169
|
+
for (const [slot, { file }] of entries.entries()) {
|
|
170
|
+
const url = urls[slot] || `${base}/upload`
|
|
171
|
+
let res
|
|
172
|
+
try {
|
|
173
|
+
res = await fetchFn(url, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: {
|
|
176
|
+
'Content-Type': file.type || 'application/octet-stream',
|
|
177
|
+
'X-Submission-Id': submissionId,
|
|
178
|
+
'X-Slot': String(slot),
|
|
179
|
+
},
|
|
180
|
+
body: file,
|
|
181
|
+
})
|
|
182
|
+
} catch (err) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`submitForm: submission ${submissionId} was recorded, but uploading ` +
|
|
185
|
+
`"${file.name}" failed — ${err.message}`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`submitForm: submission ${submissionId} was recorded, but uploading ` +
|
|
191
|
+
`"${file.name}" failed (HTTP ${res.status}).`,
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const done = await fetchFn(`${base}/finalize`, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: { 'Content-Type': 'application/json' },
|
|
199
|
+
body: JSON.stringify({ submissionId }),
|
|
200
|
+
})
|
|
201
|
+
if (!done.ok) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`submitForm: submission ${submissionId} and its ${entries.length} attachment(s) ` +
|
|
204
|
+
`were uploaded, but finalizing failed (HTTP ${done.status}).`,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
88
207
|
}
|
|
89
208
|
|
|
90
209
|
/**
|