@uniweb/kit 0.10.22 → 0.10.23

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.10.22",
3
+ "version": "0.10.23",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -43,9 +43,9 @@
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",
46
+ "@uniweb/scene": "0.1.3",
47
47
  "@uniweb/semantic-parser": "1.2.1",
48
- "@uniweb/scene": "0.1.3"
48
+ "@uniweb/core": "0.8.2"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": "^19.0.0",
@@ -170,6 +170,18 @@ function setIn(node, [key, ...rest], value) {
170
170
  return { ...base, [key]: setIn(base[key], rest, value) }
171
171
  }
172
172
 
173
+ /**
174
+ * Which control kinds hold uploads.
175
+ *
176
+ * BOTH `file` and `image` — they are two words in the authoring vocabulary for
177
+ * the same control, and the visual editor draws a file picker for either. Only
178
+ * `file` was checked here, so an `image` control's `File` objects went into
179
+ * `formData` and `JSON.stringify` turned each into `{}`: an attachment the
180
+ * visitor chose, reported as sent, arriving empty. Exactly the failure the
181
+ * split below exists to prevent, reachable through the other spelling.
182
+ */
183
+ const isUpload = (control) => control.type === 'file' || control.type === 'image'
184
+
173
185
  /**
174
186
  * Split the held values into what is submitted and what is uploaded.
175
187
  *
@@ -186,7 +198,7 @@ function split(controls, values) {
186
198
  const value = valueAt(values, control.path)
187
199
  if (value === undefined) continue
188
200
 
189
- if (control.type === 'file') {
201
+ if (isUpload(control)) {
190
202
  for (const file of [].concat(value).filter(isFile)) {
191
203
  files.push({ file, field: control.path })
192
204
  }
@@ -32,7 +32,10 @@
32
32
  * when omitted
33
33
  * @param {object} [args.context] — where the submission came from:
34
34
  * formId, sectionType, sectionId,
35
- * pageId, pageLabel
35
+ * pageId, pageLabel. `formId` is
36
+ * sent at the top level of the
37
+ * body; the rest ride in
38
+ * `metadata` (see below)
36
39
  * @param {string} [args.verificationToken] — bot-protection token, when the
37
40
  * endpoint verifies one
38
41
  * @param {Array<File|{file:File,field?:string}>} [args.files]
@@ -95,10 +98,20 @@ export async function submitForm({
95
98
  }))
96
99
  : fileSlots
97
100
 
101
+ // `formId` rides at the TOP LEVEL, not inside `metadata` with the rest of the
102
+ // context. It is the only part of a submission's origin an endpoint stores as
103
+ // its own field rather than in an opaque blob, because it is what submissions
104
+ // are grouped BY — every other origin key is decoration read back for display.
105
+ // Nesting it means the endpoint's own column is never filled, and nothing on
106
+ // either side reports that: the value is present, one level down, and the
107
+ // column is simply null forever.
108
+ const { formId, ...origin } = context || {}
109
+
98
110
  // ── API name → wire name. See the header before "correcting" these. ──
99
111
  const body = {
100
112
  formData,
101
- metadata: { ...context, preview: summary || deriveSummary(formData) },
113
+ ...(formId ? { formId } : {}),
114
+ metadata: { ...origin, preview: summary || deriveSummary(formData) },
102
115
  ...(verificationToken ? { turnstileToken: verificationToken } : {}),
103
116
  ...(Array.isArray(slots) && slots.length ? { fileSlots: slots } : {}),
104
117
  }
@@ -126,6 +139,35 @@ export async function submitForm({
126
139
  return { ...result, filesUploaded: entries.length, ...report }
127
140
  }
128
141
 
142
+ /**
143
+ * One entry of an endpoint's `uploadUrls`, as a URL.
144
+ *
145
+ * Two shapes are in the wild and both mean the same thing: a bare URL string,
146
+ * or a **record** describing the slot — `{slot, name, uploadUrl}` is what the
147
+ * endpoint this client is built against actually returns. Reading only the
148
+ * string form does not degrade, it *breaks*: a record is truthy, so it was used
149
+ * as the URL directly and `fetch` stringified it to `[object Object]`, turning
150
+ * every upload into a request for a path that cannot exist. The submission row
151
+ * was already written by then, so the visitor's message arrived and their files
152
+ * did not.
153
+ *
154
+ * Anything that does not yield a non-empty string returns `''`, so the caller
155
+ * falls back to the documented `{target}/upload` — which is where the bytes were
156
+ * going anyway in every deployment seen so far.
157
+ *
158
+ * @param {*} entry
159
+ * @returns {string}
160
+ */
161
+ function readUploadUrl(entry) {
162
+ if (typeof entry === 'string') return entry.trim()
163
+ if (entry && typeof entry === 'object') {
164
+ for (const key of ['uploadUrl', 'url', 'href']) {
165
+ if (typeof entry[key] === 'string' && entry[key].trim()) return entry[key].trim()
166
+ }
167
+ }
168
+ return ''
169
+ }
170
+
129
171
  /**
130
172
  * Accept either bare `File`s or `{ file, field }` pairs, and drop anything that
131
173
  * is not a file. The pair form exists so a submission can say WHICH field an
@@ -176,7 +218,7 @@ async function uploadFiles(entries, result, target, fetchFn) {
176
218
  const urls = Array.isArray(result?.uploadUrls) ? result.uploadUrls : []
177
219
 
178
220
  for (const [slot, { file }] of entries.entries()) {
179
- const url = urls[slot] || `${base}/upload`
221
+ const url = readUploadUrl(urls[slot]) || `${base}/upload`
180
222
  let res
181
223
  try {
182
224
  res = await fetchFn(url, {
@@ -208,11 +250,18 @@ async function uploadFiles(entries, result, target, fetchFn) {
208
250
  // count is what a quota or an invoice would otherwise derive from. Sending it
209
251
  // costs a few bytes and satisfies the stricter reading of the contract, in
210
252
  // which `files` is required and its absence is a malformed call.
211
- const manifest = entries.map(({ file }, slot) => ({
253
+ // Carries `field` for the same reason the create manifest does — which form
254
+ // control an attachment answers is the difference between a readable
255
+ // submission and two anonymous blobs. The two manifests describe the same
256
+ // files and now describe them with the same keys; a receiver that built its
257
+ // stored record from this one rather than from the create manifest would
258
+ // otherwise lose the association, silently and only for uploads.
259
+ const manifest = entries.map(({ file, field }, slot) => ({
212
260
  slot,
213
261
  name: file.name,
214
262
  size: file.size,
215
263
  mime: file.type || 'application/octet-stream',
264
+ ...(field ? { field } : {}),
216
265
  }))
217
266
 
218
267
  const done = await fetchFn(`${base}/finalize`, {