@marktiderman/genesis-forms 0.4.2 → 0.6.0
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/README.md +195 -0
- package/dist/destinations.cjs +147 -7
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.d.cts +1 -1
- package/dist/destinations.d.ts +1 -1
- package/dist/destinations.js +147 -7
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-BkNuzlna.d.cts → engine-CpfOz8FN.d.ts} +33 -3
- package/dist/{engine-Dz9J5ej2.d.ts → engine-DC4YTPdr.d.cts} +33 -3
- package/dist/index.cjs +734 -69
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +142 -75
- package/dist/index.d.ts +142 -75
- package/dist/index.js +687 -70
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +721 -26
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +201 -4
- package/dist/server.d.ts +201 -4
- package/dist/server.js +702 -27
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs +494 -26
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +4 -4
- package/dist/testing.d.ts +4 -4
- package/dist/testing.js +494 -26
- package/dist/testing.js.map +1 -1
- package/dist/{types-BBKBQ8sp.d.cts → types-oAckGR_Z.d.cts} +31 -1
- package/dist/{types-BBKBQ8sp.d.ts → types-oAckGR_Z.d.ts} +31 -1
- package/dist/validate-B5WKHbst.d.cts +322 -0
- package/dist/validate-GpTXPwyl.d.ts +322 -0
- package/package.json +1 -1
- package/dist/validate-BXXqi0fq.d.cts +0 -30
- package/dist/validate-BjNwF1UE.d.ts +0 -30
package/README.md
CHANGED
|
@@ -62,6 +62,13 @@ const version = eventFeedback.hash(); // content fingerprint for publish
|
|
|
62
62
|
`validateSubmission` refuses a definition that does not parse. Ids, keys, hidden names and ending
|
|
63
63
|
ids may not be `Object.prototype` names (`constructor`, `__proto__`…).
|
|
64
64
|
- **Unanswered recall renders its fallback** (`{{field:name|there}}`); the text is never dropped.
|
|
65
|
+
- **Saving answers as they go is a setting.** `settings.saveProgress` (off by default) says the
|
|
66
|
+
host keeps the answers so far each time the respondent moves on, so an unfinished response
|
|
67
|
+
survives. `formProgress(engine)` says how far they got: `{ step, total, fieldId }`, where
|
|
68
|
+
`fieldId` is the furthest question reached (kept after Back; in classic layout, the last one
|
|
69
|
+
answered). genesis-forms-web's `onProgress` calls the host with it; the server checks the
|
|
70
|
+
answers so far with `validateSubmission(definition, input, { complete: false })`. After a
|
|
71
|
+
reload, `createForm(definition, { initialValues: saved, resumeAt: fieldId })` picks up there.
|
|
65
72
|
|
|
66
73
|
## Pieces
|
|
67
74
|
|
|
@@ -129,6 +136,28 @@ Each type validates, normalizes (trimmed, blanks dropped, notes in time order),
|
|
|
129
136
|
samples for `coverage()`. Helpers: `listLimits`, `tableColumns`, `tableLimits`, `formatTimestamp`
|
|
130
137
|
and `parseTimestamp`; types `TableColumn`, `MediaNote` and `Highlight`.
|
|
131
138
|
|
|
139
|
+
### Photo fields
|
|
140
|
+
|
|
141
|
+
A `photo` field asks for up to a few photos, such as a picture of broken equipment:
|
|
142
|
+
`f.photo(key, { maxFiles, maxSizeMb, formats })`. The answer holds references the host's storage
|
|
143
|
+
returned, never the bytes: `PhotoRef[]`, each `{ id, name, type, size }`. The renderer uploads
|
|
144
|
+
through the host (`onUpload` on genesis-forms-web's `Form`), so the engine and `validateSubmission`
|
|
145
|
+
stay storage-agnostic; the host's server should check that every reference is a file it stored
|
|
146
|
+
for this respondent and form before saving.
|
|
147
|
+
|
|
148
|
+
| Prop | Default | Rule |
|
|
149
|
+
| --- | --- | --- |
|
|
150
|
+
| `maxFiles` | 3 | 1 to `MAX_PHOTO_FILES` (5). |
|
|
151
|
+
| `maxSizeMb` | 10 | 1 to `MAX_PHOTO_SIZE_MB` (25), per photo. |
|
|
152
|
+
| `formats` | all four | One or more of `jpeg`, `png`, `heic` (also HEIF) and `webp` (`PHOTO_FORMATS` maps each to its MIME types). |
|
|
153
|
+
|
|
154
|
+
A photo is neither `sensitive` nor `identifying` by default; set either on the field when it is.
|
|
155
|
+
Validation checks each reference's shape, type and size and the count; `normalize` keeps only the
|
|
156
|
+
four keys; `summarizeValue` lists the file names. Like the structured types it is opt-in in a
|
|
157
|
+
builder palette: `UPLOAD_FIELD_TYPES` (`["photo"]`). Helpers: `photoLimits(field)` (`maxFiles`,
|
|
158
|
+
`maxSizeMb`, `maxBytes`, `formats`, `mimeTypes`), `photoRefs(value)`, `isPhotoRef`,
|
|
159
|
+
`photoFormatList`, and the messages `photoTypeMessage`, `photoSizeMessage` and `photoCountMessage`.
|
|
160
|
+
|
|
132
161
|
### Calculated fields
|
|
133
162
|
|
|
134
163
|
A `calculated` field shows a value worked out from other answers, such as a total, an average or a
|
|
@@ -210,6 +239,129 @@ as `The formula uses "wage", which is not the answer name of a question in this
|
|
|
210
239
|
own; `formatCalculated` shows a value as the field does. Builders offer the type only when the host
|
|
211
240
|
names it (`COMPUTED_FIELD_TYPES`).
|
|
212
241
|
|
|
242
|
+
### Options from the app
|
|
243
|
+
|
|
244
|
+
A single choice, multiple choice or dropdown question can take its options from the app showing
|
|
245
|
+
the form, such as a coach picker that always lists the current coaches:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
f.dropdown("coach", { label: "Who coached you?", optionsFrom: { source: "host", key: "coaches" } });
|
|
249
|
+
// stored as props: { optionsFrom: { source: "host", key: "coaches" } }
|
|
250
|
+
|
|
251
|
+
const lists = { coaches: [{ value: profileId, label: "Ada Lovelace" }] }; // loaded by the app
|
|
252
|
+
createForm(definition, { hostOptions: lists }); // or <Form hostOptions={lists}>
|
|
253
|
+
validateSubmission(definition, body, { hostOptions: lists }); // loaded again on the server
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`withHostOptions(definition, lists)` fills them in: the question's own `props.options` first (it
|
|
257
|
+
may have some), then the list for its key, each value once. The engine does it for
|
|
258
|
+
`engine.definition`, so renderers show the list and an answer must be one of these options;
|
|
259
|
+
`validateSubmission` does it with the server's lists, so a value the server doesn't offer now (a
|
|
260
|
+
coach who left, a made-up id) is refused. Without a list for the key, the question offers its own
|
|
261
|
+
options only. Answers store the option's value; `summarizeValue` shows its label while the list has
|
|
262
|
+
it. `parseForm` checks the shape of `optionsFrom`, and doesn't warn about rules comparing with
|
|
263
|
+
values only the app knows.
|
|
264
|
+
|
|
265
|
+
### Option details, filtered lists, search and links
|
|
266
|
+
|
|
267
|
+
An option can carry more than its value and label, from the question's own `options` or the
|
|
268
|
+
app's list:
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
{ value: "cff", label: "CrossFit Fairfield",
|
|
272
|
+
description: "12 Main St", // a muted line under the label
|
|
273
|
+
image: "https://cdn.example.com/cff.png", // a small picture; https only
|
|
274
|
+
attributes: { region: "north", coaches: ["ada", "sam"] } } // for filters and recall
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`readOption` keeps each detail only when it is well formed and within `OPTION_LIMITS`
|
|
278
|
+
(description 500 characters, image URL 2048 and `https://` only (`isHttpsUrl`), at most 20
|
|
279
|
+
attributes, each a text or a list of up to 50 texts of 200 characters). `parseForm` reports a
|
|
280
|
+
definition's malformed details; an app's list is read leniently (the bad detail is dropped).
|
|
281
|
+
|
|
282
|
+
**Filtered by another answer.** `optionsFrom.filter` shows only the options whose attribute
|
|
283
|
+
matches an earlier question's answer, such as coaches at the chosen location:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
f.dropdown("location", { label: "Where?", optionsFrom: { source: "host", key: "locations" } });
|
|
287
|
+
f.singleChoice("coach", {
|
|
288
|
+
label: "Who coached you?",
|
|
289
|
+
optionsFrom: { source: "host", key: "coaches", filter: { attribute: "location", field: "location" } },
|
|
290
|
+
});
|
|
291
|
+
// a coach: { value: "ada", label: "Ada", attributes: { location: ["cff", "cornerstone"] } }
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`field` is the earlier question's key (`parseForm` checks it exists and comes first). An option
|
|
295
|
+
matches when its attribute (a text or a list) shares a value with the answer (a text, or a
|
|
296
|
+
multiple choice's list); one without the attribute never matches. Until that question is answered
|
|
297
|
+
(or while it is off the path) every option shows. `visibleOptions(definition, field, values)`
|
|
298
|
+
says what shows, `answersOnPath(state)` gives it the answers it should read, and React renderers
|
|
299
|
+
use `useChoiceOptions` (genesis-forms-react). When the answer changes, the engine clears picks the
|
|
300
|
+
filter no longer shows (`clearFilteredAnswers`, following on through filters of filters); a pick
|
|
301
|
+
outside the filtered list is refused by the engine and by `validateSubmission`
|
|
302
|
+
(`filteredChoiceError`).
|
|
303
|
+
|
|
304
|
+
**Search.** `isSearchable(field, count)` says whether a renderer puts a search box over the
|
|
305
|
+
options: `props.searchable` when it is true or false, else more than `SEARCHABLE_OPTION_COUNT`
|
|
306
|
+
(10) options. `searchOptions(options, query)` is the match every renderer uses: every word of the
|
|
307
|
+
query in the label, value or description, ignoring case and accents.
|
|
308
|
+
|
|
309
|
+
**Preselect from a link.** `props.prefill: { param }` on a choice question takes a link's query
|
|
310
|
+
parameter, such as `?location=cornerstone`:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
f.dropdown("location", { label: "Where?", optionsFrom: { source: "host", key: "locations" },
|
|
314
|
+
prefill: { param: "location", hideWhenPrefilled: true } });
|
|
315
|
+
|
|
316
|
+
createForm(definition, { hostOptions: lists, linkParams: new URLSearchParams(location.search) });
|
|
317
|
+
// or <Form hostOptions={lists} linkParams={new URLSearchParams(location.search)}>
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
`prefillFromLink(definition, params, values)` resolves it: the value is taken only when it is one
|
|
321
|
+
of the options the question shows (a filtered list too), anything else is ignored; a multiple
|
|
322
|
+
choice takes repeated parameters or one comma-separated value. The picks become `initialValues`
|
|
323
|
+
(logged as `source: "prefill"` answers; the host's own `initialValues` win), so the server checks
|
|
324
|
+
them like any answer. With `hideWhenPrefilled` the question is skipped while it holds the link's
|
|
325
|
+
answer: `hiddenByPrefill(definition, state)` lists such questions, the engine's Next and Back step
|
|
326
|
+
over them, and `respondentPath(definition, state)` is the path a renderer shows and counts. The
|
|
327
|
+
answer is stored as usual. A parameter name is letters, digits, `_`, `-` and `.`, at most 64
|
|
328
|
+
characters (`isPrefillParam`).
|
|
329
|
+
|
|
330
|
+
**Snapshots of what was picked.** Store the option's label with its value, so reports group by the
|
|
331
|
+
id and history keeps the name it had then:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
import { answerSnapshots, answersFromSnapshots, withHostOptions } from "@marktiderman/genesis-forms/server";
|
|
335
|
+
|
|
336
|
+
const shown = withHostOptions(definition, lists);
|
|
337
|
+
const snapshots = answerSnapshots(shown, result.submission.answers);
|
|
338
|
+
// { location: { value: "cff", label: "CrossFit Fairfield" }, helpers: [{ value: "ada", label: "Ada" }] }
|
|
339
|
+
|
|
340
|
+
// An edit link: find each pick again in today's lists.
|
|
341
|
+
const { answers, missing } = answersFromSnapshots(withHostOptions(definition, listsNow), stored);
|
|
342
|
+
createForm(definition, { hostOptions: listsNow, initialValues: answers });
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
`answerSnapshots` covers questions with `optionsFrom`; a typed "Other" answer is marked `other`.
|
|
346
|
+
`answersFromSnapshots` keeps a value still offered, else takes an option with the same label
|
|
347
|
+
(ignoring case and accents), keeps a typed "Other" answer when the question still allows one, and
|
|
348
|
+
reports the rest in `missing`. Both are also exported from the main entry.
|
|
349
|
+
|
|
350
|
+
### An "Other" answer
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
f.multiChoice("areas", { label: "Which area?", options: [...], other: true });
|
|
354
|
+
f.singleChoice("where", { label: "Where?", options: [...], other: { label: "Somewhere else" } });
|
|
355
|
+
// stored as props: { other: true } or { other: { label: "Somewhere else" } }
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
A single choice, multiple choice or dropdown question with `props.other` offers one more option,
|
|
359
|
+
"Other" (or its `label`), with a text box. What the respondent types is the answer: the value of a
|
|
360
|
+
single choice or dropdown, one more item on a multiple choice. `validateSubmission` accepts at most
|
|
361
|
+
one such value per question, not blank and at most `OTHER_MAX_LENGTH` (200) characters, and only on
|
|
362
|
+
a question with `other`. `otherOption(field)` reads the setting; `isOtherValue(field, value)` tells a
|
|
363
|
+
typed answer from an option.
|
|
364
|
+
|
|
213
365
|
### Props for the host
|
|
214
366
|
|
|
215
367
|
Props a field type doesn't use are kept and ignored: the engine, `parseForm` and
|
|
@@ -235,6 +387,49 @@ limits above (`maxEvents`, `maxValues`, `maxReplayWork`). Events of an unknown t
|
|
|
235
387
|
client's log still replays. With `settings.clearHiddenValues: false`, a hidden field's value is kept
|
|
236
388
|
only if its format is valid.
|
|
237
389
|
|
|
390
|
+
### Edit links
|
|
391
|
+
|
|
392
|
+
A respondent can change a sent response through a signed link, `?edit=<id>.<nonce>.<expires>.<sig>`:
|
|
393
|
+
an HMAC-SHA256 (Web Crypto) under a server secret of at least 32 bytes, working for 30 days. The app
|
|
394
|
+
keeps a nonce per response and changes it after each edit, so only the newest link works; it checks
|
|
395
|
+
the nonce itself. Without a usable secret there are no edit links: signing returns null and nothing
|
|
396
|
+
verifies.
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
import {
|
|
400
|
+
createEditSecretReader,
|
|
401
|
+
editLinkUrl,
|
|
402
|
+
verifyEditToken,
|
|
403
|
+
withEditLinkHeaders,
|
|
404
|
+
} from "@marktiderman/genesis-forms/server";
|
|
405
|
+
|
|
406
|
+
const editSecret = createEditSecretReader({ variable: "FORM_EDIT_SECRET" }); // warns once when missing
|
|
407
|
+
const editUrl = await editLinkUrl(`/f/${slug}`, { id: response.id, nonce: response.editNonce }, editSecret(env));
|
|
408
|
+
const claims = await verifyEditToken(url.searchParams.get("edit"), editSecret(env)); // then compare claims.nonce
|
|
409
|
+
// The link is a credential: no Referer, no caching, on the page it opens.
|
|
410
|
+
return withEditLinkHeaders(request, response, { isFormPage: (u) => u.pathname.startsWith("/f/") });
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
`signEditToken`, `withEditToken`, `isEditLinkRequest`, `isUsableEditSecret`, `EDIT_LINK_TTL_SECONDS`,
|
|
414
|
+
`MIN_EDIT_SECRET_BYTES` and `EDIT_LINK_PARAM` are the parts. `carryAnswers` (main entry) moves a
|
|
415
|
+
stored response's answers onto a newer version of the form by field key, for the edit page's prefill.
|
|
416
|
+
|
|
417
|
+
### Rate limits
|
|
418
|
+
|
|
419
|
+
The submit endpoint's limit, without the counter: `clientIp(headers, trusted?)` reads the first
|
|
420
|
+
trusted proxy header (`DEFAULT_TRUSTED_IP_HEADERS`: `x-vercel-forwarded-for`, `x-forwarded-for`,
|
|
421
|
+
`x-real-ip`; trust only headers your proxy overwrites), `addressPrefix` keeps an IPv4 address whole
|
|
422
|
+
and an IPv6 one by its /64, and `rateLimitKey({ kind, scope, ip, secret })` is
|
|
423
|
+
`<kind>:<scope>:<hex HMAC-SHA256 of the prefix>`, so the address is never stored. The app's
|
|
424
|
+
`RateLimitStore.hit(key, limit, windowSeconds)` counts (shared by every instance); `allow` fails
|
|
425
|
+
open, and logs, when it answers null or throws. `RATE_LIMIT_DEFAULTS` holds 60 submits, 900 saves as
|
|
426
|
+
they go and 120 photo uploads per 10 minutes.
|
|
427
|
+
|
|
428
|
+
```ts
|
|
429
|
+
const key = await rateLimitKey({ kind: "submit", scope: practiceId, ip: clientIp(request.headers), secret });
|
|
430
|
+
if (!(await allow(store, { key, ...RATE_LIMIT_DEFAULTS.submit }, { log }))) return tooMany();
|
|
431
|
+
```
|
|
432
|
+
|
|
238
433
|
## Destinations
|
|
239
434
|
|
|
240
435
|
`@marktiderman/genesis-forms/destinations` sends a checked submission somewhere. No dependencies:
|
package/dist/destinations.cjs
CHANGED
|
@@ -328,6 +328,79 @@ var structuredFieldTypes = [
|
|
|
328
328
|
}
|
|
329
329
|
];
|
|
330
330
|
|
|
331
|
+
// src/photo.ts
|
|
332
|
+
var PHOTO_TYPE = "photo";
|
|
333
|
+
var PHOTO_FORMATS = {
|
|
334
|
+
jpeg: ["image/jpeg"],
|
|
335
|
+
png: ["image/png"],
|
|
336
|
+
heic: ["image/heic", "image/heif"],
|
|
337
|
+
webp: ["image/webp"]
|
|
338
|
+
};
|
|
339
|
+
var FORMAT_NAMES = { jpeg: "JPEG", png: "PNG", heic: "HEIC", webp: "WebP" };
|
|
340
|
+
var ALL_FORMATS = Object.keys(PHOTO_FORMATS);
|
|
341
|
+
var DEFAULT_PHOTO_FILES = 3;
|
|
342
|
+
var MAX_PHOTO_FILES = 5;
|
|
343
|
+
var DEFAULT_PHOTO_SIZE_MB = 10;
|
|
344
|
+
var MAX_PHOTO_SIZE_MB = 25;
|
|
345
|
+
var MAX_PHOTO_ID_LENGTH = 200;
|
|
346
|
+
var MAX_PHOTO_NAME_LENGTH = 255;
|
|
347
|
+
var wholeIn = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
|
|
348
|
+
function photoLimits(field) {
|
|
349
|
+
const maxFiles = wholeIn(field.props?.maxFiles, 1, MAX_PHOTO_FILES) ? field.props.maxFiles : DEFAULT_PHOTO_FILES;
|
|
350
|
+
const maxSizeMb = wholeIn(field.props?.maxSizeMb, 1, MAX_PHOTO_SIZE_MB) ? field.props.maxSizeMb : DEFAULT_PHOTO_SIZE_MB;
|
|
351
|
+
const raw = field.props?.formats;
|
|
352
|
+
const picked = Array.isArray(raw) ? ALL_FORMATS.filter((f) => raw.includes(f)) : [];
|
|
353
|
+
const formats = picked.length ? picked : [...ALL_FORMATS];
|
|
354
|
+
return {
|
|
355
|
+
maxFiles,
|
|
356
|
+
maxSizeMb,
|
|
357
|
+
maxBytes: maxSizeMb * 1024 * 1024,
|
|
358
|
+
formats,
|
|
359
|
+
mimeTypes: formats.flatMap((f) => PHOTO_FORMATS[f])
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function isPhotoRef(v) {
|
|
363
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return false;
|
|
364
|
+
const r = v;
|
|
365
|
+
return typeof r.id === "string" && r.id.length > 0 && r.id.length <= MAX_PHOTO_ID_LENGTH && typeof r.name === "string" && r.name.length <= MAX_PHOTO_NAME_LENGTH && typeof r.type === "string" && typeof r.size === "number" && Number.isInteger(r.size) && r.size > 0;
|
|
366
|
+
}
|
|
367
|
+
function photoRefs(value) {
|
|
368
|
+
if (!Array.isArray(value)) return [];
|
|
369
|
+
const refs = [];
|
|
370
|
+
for (const r of value) if (isPhotoRef(r)) refs.push({ id: r.id, name: r.name, type: r.type, size: r.size });
|
|
371
|
+
return refs;
|
|
372
|
+
}
|
|
373
|
+
var plural2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
374
|
+
function photoFormatList(formats) {
|
|
375
|
+
const names = formats.map((f) => FORMAT_NAMES[f]);
|
|
376
|
+
return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}` : names[0] ?? "";
|
|
377
|
+
}
|
|
378
|
+
var photoTypeMessage = (field) => `Use a ${photoFormatList(photoLimits(field).formats)} photo.`;
|
|
379
|
+
var photoSizeMessage = (field) => `Each photo must be ${photoLimits(field).maxSizeMb} MB or smaller.`;
|
|
380
|
+
var photoCountMessage = (field) => `Add at most ${plural2(photoLimits(field).maxFiles, "photo")}.`;
|
|
381
|
+
var photoFieldType = {
|
|
382
|
+
type: PHOTO_TYPE,
|
|
383
|
+
label: "Photo",
|
|
384
|
+
category: "media",
|
|
385
|
+
value: { json: true },
|
|
386
|
+
operators: ["answered", "empty"],
|
|
387
|
+
a11y: { semantics: "group" },
|
|
388
|
+
isEmpty: (value) => value === void 0 || value === null || Array.isArray(value) && value.length === 0,
|
|
389
|
+
validate: (value, field) => {
|
|
390
|
+
if (!Array.isArray(value) || !value.every(isPhotoRef)) return "Add a photo.";
|
|
391
|
+
const limits = photoLimits(field);
|
|
392
|
+
if (value.length > limits.maxFiles) return photoCountMessage(field);
|
|
393
|
+
const refs = value;
|
|
394
|
+
if (new Set(refs.map((r) => r.id)).size !== refs.length) return "Each photo can be added once.";
|
|
395
|
+
if (refs.some((r) => !limits.mimeTypes.includes(r.type.toLowerCase()))) return photoTypeMessage(field);
|
|
396
|
+
if (refs.some((r) => r.size > limits.maxBytes)) return photoSizeMessage(field);
|
|
397
|
+
return null;
|
|
398
|
+
},
|
|
399
|
+
normalize: (value) => Array.isArray(value) && value.every(isPhotoRef) ? photoRefs(value) : value,
|
|
400
|
+
summarize: (value) => photoRefs(value).map((r) => r.name || "Photo").join(", "),
|
|
401
|
+
samples: () => [[{ id: "sample-photo", name: "photo.jpg", type: "image/jpeg", size: 1024 }]]
|
|
402
|
+
};
|
|
403
|
+
|
|
331
404
|
// src/fields.ts
|
|
332
405
|
var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
|
|
333
406
|
var globalScope = globalThis;
|
|
@@ -363,12 +436,78 @@ function summarizeValue(value, field) {
|
|
|
363
436
|
if (Array.isArray(value)) return value.join(", ");
|
|
364
437
|
return String(value);
|
|
365
438
|
}
|
|
439
|
+
var OPTION_LIMITS = {
|
|
440
|
+
/** Characters in a description. */
|
|
441
|
+
description: 500,
|
|
442
|
+
/** Characters in an image URL. */
|
|
443
|
+
image: 2048,
|
|
444
|
+
/** Attributes on one option. */
|
|
445
|
+
attributes: 20,
|
|
446
|
+
/** Characters in an attribute's name. */
|
|
447
|
+
attributeName: 64,
|
|
448
|
+
/** Characters in one attribute value. */
|
|
449
|
+
attributeValue: 200,
|
|
450
|
+
/** Values in one attribute's list. */
|
|
451
|
+
attributeItems: 50
|
|
452
|
+
};
|
|
453
|
+
function isHttpsUrl(url) {
|
|
454
|
+
return typeof url === "string" && url.length <= OPTION_LIMITS.image && /^https:\/\/[^\s/?#@\\]+(?:[/?#][^\s]*)?$/i.test(url) && // No control characters anywhere (a line break would end a CSV row or an HTML attribute).
|
|
455
|
+
![...url].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
|
|
456
|
+
}
|
|
457
|
+
function attributeName(name) {
|
|
458
|
+
return name.trim() !== "" && name.length <= OPTION_LIMITS.attributeName && !(name in Object.prototype);
|
|
459
|
+
}
|
|
460
|
+
function readAttributes(raw) {
|
|
461
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
462
|
+
const out = {};
|
|
463
|
+
let count = 0;
|
|
464
|
+
for (const name of Object.keys(raw)) {
|
|
465
|
+
if (count >= OPTION_LIMITS.attributes) break;
|
|
466
|
+
if (!attributeName(name)) continue;
|
|
467
|
+
const v = raw[name];
|
|
468
|
+
const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
|
|
469
|
+
if (fits(v)) out[name] = v;
|
|
470
|
+
else if (Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits)) out[name] = [...v];
|
|
471
|
+
else continue;
|
|
472
|
+
count++;
|
|
473
|
+
}
|
|
474
|
+
return count ? out : void 0;
|
|
475
|
+
}
|
|
476
|
+
function readOption(raw) {
|
|
477
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
478
|
+
const o = raw;
|
|
479
|
+
if (typeof o.value !== "string") return null;
|
|
480
|
+
const out = { value: o.value };
|
|
481
|
+
if (typeof o.label === "string") out.label = o.label;
|
|
482
|
+
if (typeof o.description === "string" && o.description.trim() && o.description.length <= OPTION_LIMITS.description) {
|
|
483
|
+
out.description = o.description;
|
|
484
|
+
}
|
|
485
|
+
if (isHttpsUrl(o.image)) out.image = o.image;
|
|
486
|
+
const attributes = readAttributes(o.attributes);
|
|
487
|
+
if (attributes) out.attributes = attributes;
|
|
488
|
+
return out;
|
|
489
|
+
}
|
|
490
|
+
var OTHER_MAX_LENGTH = 200;
|
|
491
|
+
function otherOption(field) {
|
|
492
|
+
const other = field.props?.other;
|
|
493
|
+
if (other === true) return { label: "Other" };
|
|
494
|
+
if (other && typeof other === "object" && !Array.isArray(other)) {
|
|
495
|
+
const label = typeof other.label === "string" && other.label.trim() ? other.label.trim() : "Other";
|
|
496
|
+
return { label };
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
function isOtherValue(field, value) {
|
|
501
|
+
return otherOption(field) !== null && typeof value === "string" && !choiceOptions(field).some((o) => o.value === value);
|
|
502
|
+
}
|
|
503
|
+
var typedOther = (field, value) => isOtherValue(field, value) && value.trim() !== "" && value.length <= OTHER_MAX_LENGTH;
|
|
366
504
|
function choiceOptions(field) {
|
|
367
505
|
const raw = field.props?.options;
|
|
368
506
|
if (!Array.isArray(raw)) return [];
|
|
369
|
-
return raw.flatMap(
|
|
370
|
-
|
|
371
|
-
|
|
507
|
+
return raw.flatMap((o) => {
|
|
508
|
+
const option = readOption(o);
|
|
509
|
+
return option ? [option] : [];
|
|
510
|
+
});
|
|
372
511
|
}
|
|
373
512
|
function optionLabel(field, value) {
|
|
374
513
|
const option = choiceOptions(field).find((o) => o.value === value);
|
|
@@ -488,7 +627,7 @@ var builtins = [
|
|
|
488
627
|
value: { json: true },
|
|
489
628
|
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
490
629
|
a11y: { semantics: "group" },
|
|
491
|
-
validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
|
|
630
|
+
validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
|
|
492
631
|
summarize: (value, field) => optionLabel(field, String(value))
|
|
493
632
|
},
|
|
494
633
|
{
|
|
@@ -500,7 +639,8 @@ var builtins = [
|
|
|
500
639
|
a11y: { semantics: "group" },
|
|
501
640
|
validate: (value, field) => {
|
|
502
641
|
const allowed = new Set(choiceOptions(field).map((o) => o.value));
|
|
503
|
-
|
|
642
|
+
const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
|
|
643
|
+
return Array.isArray(value) && new Set(value).size === value.length && value.every((v) => typeof v === "string") && (others.length === 0 || others.length === 1 && typedOther(field, others[0])) ? null : "Choose from the options.";
|
|
504
644
|
},
|
|
505
645
|
summarize: (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value)
|
|
506
646
|
},
|
|
@@ -521,7 +661,7 @@ var builtins = [
|
|
|
521
661
|
value: { json: true },
|
|
522
662
|
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
523
663
|
a11y: { semantics: "control" },
|
|
524
|
-
validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
|
|
664
|
+
validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
|
|
525
665
|
summarize: (value, field) => optionLabel(field, String(value))
|
|
526
666
|
},
|
|
527
667
|
{
|
|
@@ -628,7 +768,7 @@ function dateLimits(field) {
|
|
|
628
768
|
if (typeof max === "string" && isIsoDate(max)) out.max = max;
|
|
629
769
|
return out;
|
|
630
770
|
}
|
|
631
|
-
for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
|
|
771
|
+
for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerFieldType(manifest);
|
|
632
772
|
|
|
633
773
|
// src/logic.ts
|
|
634
774
|
function own(record, key) {
|