@nextlyhq/plugin-form-builder 0.0.2-alpha.7 → 0.0.2-alpha.71
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 +29 -17
- package/dist/admin/index.d.ts +251 -454
- package/dist/admin/index.js +4782 -3985
- package/dist/admin/index.js.map +1 -1
- package/dist/field-references-BshlgMcn.js +1048 -0
- package/dist/field-references-BshlgMcn.js.map +1 -0
- package/dist/index.d.ts +341 -615
- package/dist/index.js +3893 -2406
- package/dist/index.js.map +1 -1
- package/dist/types-BHReIA0j.d.ts +986 -0
- package/package.json +40 -39
- package/dist/CheckboxFieldEditor-K4T6QX52.js +0 -121
- package/dist/CheckboxFieldEditor-K4T6QX52.js.map +0 -1
- package/dist/DateFieldEditor-JFJFAVOH.js +0 -187
- package/dist/DateFieldEditor-JFJFAVOH.js.map +0 -1
- package/dist/EmailFieldEditor-MMOXXL7P.js +0 -138
- package/dist/EmailFieldEditor-MMOXXL7P.js.map +0 -1
- package/dist/NumberFieldEditor-UISAYR6Z.js +0 -175
- package/dist/NumberFieldEditor-UISAYR6Z.js.map +0 -1
- package/dist/RadioFieldEditor-6UQFH4WT.js +0 -165
- package/dist/RadioFieldEditor-6UQFH4WT.js.map +0 -1
- package/dist/SelectFieldEditor-PSO4KHV4.js +0 -213
- package/dist/SelectFieldEditor-PSO4KHV4.js.map +0 -1
- package/dist/TextFieldEditor-5EU64EA5.js +0 -187
- package/dist/TextFieldEditor-5EU64EA5.js.map +0 -1
- package/dist/TextareaFieldEditor-773G3LNM.js +0 -190
- package/dist/TextareaFieldEditor-773G3LNM.js.map +0 -1
- package/dist/admin/index.css +0 -934
- package/dist/admin/index.css.map +0 -1
- package/dist/chunk-ABNZFTQK.js +0 -586
- package/dist/chunk-ABNZFTQK.js.map +0 -1
- package/dist/chunk-XTCEPJ7B.js +0 -256
- package/dist/chunk-XTCEPJ7B.js.map +0 -1
- package/dist/components/index.d.ts +0 -3
- package/dist/components/index.js +0 -9
- package/dist/components/index.js.map +0 -1
- package/dist/index-C0kBvYjm.d.ts +0 -1012
- package/dist/styles/form-builder.css +0 -1150
- package/dist/styles/submissions-filter.css +0 -75
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
// src/utils/redirect-reference.ts
|
|
2
|
+
/**
|
|
3
|
+
* The reference a `redirectPage` value holds, in any shape a read produces.
|
|
4
|
+
*
|
|
5
|
+
* Three arrive in practice and they are not interchangeable: a polymorphic
|
|
6
|
+
* field stores `{ relationTo, value }`; a read at depth replaces `value` with
|
|
7
|
+
* the populated row; and a single-target field stores the bare id. The last
|
|
8
|
+
* names no collection, so it resolves only when exactly one is configured —
|
|
9
|
+
* guessing which of several a bare id belongs to would send visitors to a
|
|
10
|
+
* URL built from the wrong collection's pattern.
|
|
11
|
+
*/
|
|
12
|
+
function parseRedirectReference(value, collections) {
|
|
13
|
+
if (value == null) return null;
|
|
14
|
+
if (typeof value === "string") {
|
|
15
|
+
const id = value.trim();
|
|
16
|
+
return id && collections.length === 1 ? {
|
|
17
|
+
collection: collections[0],
|
|
18
|
+
id
|
|
19
|
+
} : null;
|
|
20
|
+
}
|
|
21
|
+
if (typeof value !== "object" || Array.isArray(value)) return null;
|
|
22
|
+
const record = value;
|
|
23
|
+
const relationTo = record.relationTo;
|
|
24
|
+
const inner = record.value;
|
|
25
|
+
if (typeof relationTo === "string" && relationTo.trim()) {
|
|
26
|
+
const id = referenceId(inner);
|
|
27
|
+
return id ? {
|
|
28
|
+
collection: relationTo.trim(),
|
|
29
|
+
id
|
|
30
|
+
} : null;
|
|
31
|
+
}
|
|
32
|
+
const id = referenceId(record);
|
|
33
|
+
if (!id) return null;
|
|
34
|
+
return collections.length === 1 ? {
|
|
35
|
+
collection: collections[0],
|
|
36
|
+
id
|
|
37
|
+
} : null;
|
|
38
|
+
}
|
|
39
|
+
function referenceId(value) {
|
|
40
|
+
if (typeof value === "string") return value.trim() || null;
|
|
41
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
42
|
+
const { id } = value;
|
|
43
|
+
if (typeof id === "string" && id) return id;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The field the publish lifecycle puts on every row it manages.
|
|
49
|
+
*
|
|
50
|
+
* A collection without the lifecycle may legally define an ordinary field
|
|
51
|
+
* named `status` — "active", "archived", anything — so the NAME `status` does
|
|
52
|
+
* not identify a lifecycle and reading it as one marks live documents as
|
|
53
|
+
* drafts. This field does identify it: it is written by the framework, not by
|
|
54
|
+
* a schema author.
|
|
55
|
+
*
|
|
56
|
+
* Measured on 2026-08-25, creating one document in each kind of collection:
|
|
57
|
+
* a `status: true` collection returns
|
|
58
|
+
* `[createdAt, firstPublishedAt, id, slug, status, title, updatedAt]`, and a
|
|
59
|
+
* plain collection carrying its own `status` text field returns the same list
|
|
60
|
+
* WITHOUT `firstPublishedAt`. It is present on a never-published draft too, so
|
|
61
|
+
* presence marks the lifecycle rather than the act of publishing.
|
|
62
|
+
*/
|
|
63
|
+
const PUBLISH_LIFECYCLE_FIELD = "firstPublishedAt";
|
|
64
|
+
/**
|
|
65
|
+
* Whether this document comes from a collection with the publish lifecycle.
|
|
66
|
+
*
|
|
67
|
+
* Callers that project their reads must request
|
|
68
|
+
* {@link PUBLISH_LIFECYCLE_FIELD}; a projection that omits it makes every
|
|
69
|
+
* document look unmanaged, which reads as "always reachable".
|
|
70
|
+
*/
|
|
71
|
+
function hasPublishLifecycle(document) {
|
|
72
|
+
return PUBLISH_LIFECYCLE_FIELD in document;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Whether a visitor can reach this document.
|
|
76
|
+
*
|
|
77
|
+
* `collectionIsLocalized` is the caller's knowledge of the target collection's
|
|
78
|
+
* `localized` setting, or `undefined` where the caller has no way to know.
|
|
79
|
+
*
|
|
80
|
+
* A localized collection publishes PER LOCALE, on a companion row that no read
|
|
81
|
+
* available here returns. Measured on 2026-08-25 against a collection with
|
|
82
|
+
* `localized: true` whose Spanish translation was published while the default
|
|
83
|
+
* locale stayed a draft: `findByID` answers `status: "draft"` at the default
|
|
84
|
+
* locale AND at `es`, and a `find` filtered to `status: "published"` returns
|
|
85
|
+
* the document at no locale at all — no locale, `en` or `es`. So the main
|
|
86
|
+
* row's `status` does not answer for a localized document, and reading it as
|
|
87
|
+
* an answer marks a page visitors can reach as a draft, refuses a correct save
|
|
88
|
+
* and drops a working redirect.
|
|
89
|
+
*
|
|
90
|
+
* `firstPublishedAt` is the one fact that survives that: it records whether the
|
|
91
|
+
* document has ever been public in ANY language, so its absence is decisive
|
|
92
|
+
* whether or not the collection is localized.
|
|
93
|
+
*/
|
|
94
|
+
function documentReachability(document, collectionIsLocalized) {
|
|
95
|
+
if (!hasPublishLifecycle(document)) return "reachable";
|
|
96
|
+
if (document.status === "published") return "reachable";
|
|
97
|
+
if (collectionIsLocalized === false) return "unreachable";
|
|
98
|
+
return "unknown";
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Which settings key names the document this form redirects to, if any.
|
|
102
|
+
*
|
|
103
|
+
* TWO options carry one. "Redirect to a page" stores `redirectPage`, and the
|
|
104
|
+
* URL option falls back to `redirectRelation` when no URL is typed — the shape
|
|
105
|
+
* code-first and legacy forms use, and one the submission path has always
|
|
106
|
+
* resolved. One definition of the question, so the rule that guards a target
|
|
107
|
+
* cannot recognise fewer shapes than the resolver that acts on it: a form the
|
|
108
|
+
* rule skips is a form the resolver still redirects.
|
|
109
|
+
*
|
|
110
|
+
* The two are not equally strict about being EMPTY, deliberately. Naming
|
|
111
|
+
* nothing under "Redirect to a page" contradicts the option itself, so it is
|
|
112
|
+
* reported. Under the URL option an absent relation only means no destination
|
|
113
|
+
* is configured yet — `validateFormConfig` already governs that, and refusing
|
|
114
|
+
* it here would reject saves this rule never governed.
|
|
115
|
+
*/
|
|
116
|
+
function pickedDocumentField(settings) {
|
|
117
|
+
if (settings.confirmationType === "relationship") return "redirectPage";
|
|
118
|
+
if (settings.confirmationType === "redirect" && !settings.redirectUrl && settings.redirectRelation != null) return "redirectRelation";
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/types.ts
|
|
123
|
+
/** Every built-in form field type, as a value list (the single source shared
|
|
124
|
+
* by the field-type guard and the config validator). */
|
|
125
|
+
const BUILT_IN_FORM_FIELD_TYPES = [
|
|
126
|
+
"text",
|
|
127
|
+
"email",
|
|
128
|
+
"number",
|
|
129
|
+
"phone",
|
|
130
|
+
"url",
|
|
131
|
+
"textarea",
|
|
132
|
+
"select",
|
|
133
|
+
"checkbox",
|
|
134
|
+
"radio",
|
|
135
|
+
"file",
|
|
136
|
+
"date",
|
|
137
|
+
"time",
|
|
138
|
+
"hidden"
|
|
139
|
+
];
|
|
140
|
+
const BUILT_IN_FORM_FIELD_TYPES_SET = new Set(BUILT_IN_FORM_FIELD_TYPES);
|
|
141
|
+
/**
|
|
142
|
+
* Narrow an `AnyFormField` to the closed built-in `FormField` union. Returns
|
|
143
|
+
* `false` for a plugin-contributed field, so callers handle it generically
|
|
144
|
+
* before switching on the built-in discriminant.
|
|
145
|
+
*/
|
|
146
|
+
function isKnownFormField(field) {
|
|
147
|
+
return BUILT_IN_FORM_FIELD_TYPES_SET.has(field.type);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Check if a value is a FormField.
|
|
151
|
+
*/
|
|
152
|
+
function isFormField(value) {
|
|
153
|
+
return typeof value === "object" && value !== null && "type" in value && "name" in value && "label" in value;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Check if a FormField is a specific type.
|
|
157
|
+
*/
|
|
158
|
+
function isFormFieldType(field, type) {
|
|
159
|
+
return field.type === type;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Type guard for text field.
|
|
163
|
+
*/
|
|
164
|
+
function isTextFormField(field) {
|
|
165
|
+
return field.type === "text";
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Type guard for email field.
|
|
169
|
+
*/
|
|
170
|
+
function isEmailFormField(field) {
|
|
171
|
+
return field.type === "email";
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Type guard for select field.
|
|
175
|
+
*/
|
|
176
|
+
function isSelectFormField(field) {
|
|
177
|
+
return field.type === "select";
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Type guard for checkbox field.
|
|
181
|
+
*/
|
|
182
|
+
function isCheckboxFormField(field) {
|
|
183
|
+
return field.type === "checkbox";
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Type guard for file field.
|
|
187
|
+
*/
|
|
188
|
+
function isFileFormField(field) {
|
|
189
|
+
return field.type === "file";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/utils/enforced-validation.ts
|
|
193
|
+
/**
|
|
194
|
+
* `message` appears on every row because it is honoured everywhere: each
|
|
195
|
+
* generator passes `validation.errorMessage` to `applyRequired`, so even a type
|
|
196
|
+
* with no bounds of its own uses it for the required failure.
|
|
197
|
+
*
|
|
198
|
+
* Typed as a total `Record` rather than a partial one, so adding a field type
|
|
199
|
+
* without deciding what it enforces is a compile error instead of a type that
|
|
200
|
+
* silently offers nothing.
|
|
201
|
+
*/
|
|
202
|
+
const ENFORCED_VALIDATION_RULES = {
|
|
203
|
+
text: [
|
|
204
|
+
"minLength",
|
|
205
|
+
"maxLength",
|
|
206
|
+
"pattern",
|
|
207
|
+
"message"
|
|
208
|
+
],
|
|
209
|
+
email: ["pattern", "message"],
|
|
210
|
+
number: [
|
|
211
|
+
"min",
|
|
212
|
+
"max",
|
|
213
|
+
"message"
|
|
214
|
+
],
|
|
215
|
+
phone: ["pattern", "message"],
|
|
216
|
+
url: ["pattern", "message"],
|
|
217
|
+
textarea: [
|
|
218
|
+
"minLength",
|
|
219
|
+
"maxLength",
|
|
220
|
+
"message"
|
|
221
|
+
],
|
|
222
|
+
select: ["message"],
|
|
223
|
+
checkbox: ["message"],
|
|
224
|
+
radio: ["message"],
|
|
225
|
+
file: ["message"],
|
|
226
|
+
date: ["message"],
|
|
227
|
+
time: ["message"],
|
|
228
|
+
hidden: ["message"]
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* The rules a field of this type both accepts and has enforced.
|
|
232
|
+
*
|
|
233
|
+
* A plugin-contributed type has no row, and correctly gets nothing: this
|
|
234
|
+
* runtime's generator has no clause for it, so every rule would be inert.
|
|
235
|
+
*/
|
|
236
|
+
function enforcedValidationRules(type) {
|
|
237
|
+
return ENFORCED_VALIDATION_RULES[type] ?? [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/config/removed-options.ts
|
|
241
|
+
/** Forms already told, so a config passing through both entry points hears it once. */
|
|
242
|
+
const warned = /* @__PURE__ */ new Set();
|
|
243
|
+
/**
|
|
244
|
+
* Say so when a form config still sets `notifications.webhooks`.
|
|
245
|
+
*
|
|
246
|
+
* The plugin sends no webhooks of its own: a submission reaches other systems
|
|
247
|
+
* through Nextly's `form.submission.created` webhook, so the option sends
|
|
248
|
+
* nothing and the config is told what to use instead.
|
|
249
|
+
*/
|
|
250
|
+
function warnIfWebhooksConfigured(config) {
|
|
251
|
+
const notifications = config.notifications;
|
|
252
|
+
if (typeof notifications !== "object" || notifications === null) return;
|
|
253
|
+
if (!("webhooks" in notifications) || warned.has(config.slug)) return;
|
|
254
|
+
warned.add(config.slug);
|
|
255
|
+
console.warn(`[form-builder] Form "${config.slug}" sets notifications.webhooks, which the Form Builder no longer reads, so those webhooks are not sent. Subscribe an endpoint to Nextly's form.submission.created webhook instead: https://nextlyhq.com/docs/plugins/form-builder#webhooks`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/config/defaults.ts
|
|
259
|
+
/**
|
|
260
|
+
* Default form settings.
|
|
261
|
+
*/
|
|
262
|
+
const DEFAULT_FORM_SETTINGS = {
|
|
263
|
+
submitButtonText: "Submit",
|
|
264
|
+
confirmationType: "message",
|
|
265
|
+
successMessage: "Thank you for your submission!",
|
|
266
|
+
allowMultipleSubmissions: true
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* Default notification settings.
|
|
270
|
+
*/
|
|
271
|
+
const DEFAULT_NOTIFICATION_SETTINGS = {
|
|
272
|
+
enabled: true,
|
|
273
|
+
replyTo: true
|
|
274
|
+
};
|
|
275
|
+
/**
|
|
276
|
+
* Converts a slug to title case.
|
|
277
|
+
*
|
|
278
|
+
* @param str - The slug string to convert
|
|
279
|
+
* @returns Title case string
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* ```typescript
|
|
283
|
+
* toTitleCase('contact-form'); // 'Contact Form'
|
|
284
|
+
* toTitleCase('newsletter_signup'); // 'Newsletter Signup'
|
|
285
|
+
* ```
|
|
286
|
+
*/
|
|
287
|
+
function toTitleCase(str) {
|
|
288
|
+
return str.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Generates a plural form from a singular label.
|
|
292
|
+
* Handles common English pluralization rules.
|
|
293
|
+
*
|
|
294
|
+
* @param singular - The singular form
|
|
295
|
+
* @returns The plural form
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* ```typescript
|
|
299
|
+
* pluralize('Form'); // 'Forms'
|
|
300
|
+
* pluralize('Category'); // 'Categories'
|
|
301
|
+
* pluralize('Quiz'); // 'Quizzes'
|
|
302
|
+
* ```
|
|
303
|
+
*/
|
|
304
|
+
function pluralize(singular) {
|
|
305
|
+
if (singular.endsWith("y") && !/[aeiou]y$/i.test(singular)) return singular.slice(0, -1) + "ies";
|
|
306
|
+
if (singular.endsWith("s") || singular.endsWith("x") || singular.endsWith("z") || singular.endsWith("ch") || singular.endsWith("sh")) return singular + "es";
|
|
307
|
+
return singular + "s";
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Apply default values to form configuration.
|
|
311
|
+
*
|
|
312
|
+
* This function takes a partial form configuration and returns a complete
|
|
313
|
+
* configuration with all default values applied. User-specified values
|
|
314
|
+
* are preserved and take precedence over defaults.
|
|
315
|
+
*
|
|
316
|
+
* @param config - Form configuration (may be partial)
|
|
317
|
+
* @returns Configuration with defaults applied
|
|
318
|
+
*
|
|
319
|
+
* @example
|
|
320
|
+
* ```typescript
|
|
321
|
+
* import { applyFormDefaults } from '@nextly/plugin-form-builder';
|
|
322
|
+
*
|
|
323
|
+
* const config = {
|
|
324
|
+
* slug: 'contact-form',
|
|
325
|
+
* fields: [
|
|
326
|
+
* { type: 'text', name: 'name', label: 'Name' },
|
|
327
|
+
* { type: 'email', name: 'email', label: 'Email' },
|
|
328
|
+
* ],
|
|
329
|
+
* };
|
|
330
|
+
*
|
|
331
|
+
* const result = applyFormDefaults(config);
|
|
332
|
+
*
|
|
333
|
+
* // Result:
|
|
334
|
+
* // {
|
|
335
|
+
* // slug: 'contact-form',
|
|
336
|
+
* // labels: { singular: 'Contact Form', plural: 'Contact Forms' },
|
|
337
|
+
* // fields: [...],
|
|
338
|
+
* // settings: {
|
|
339
|
+
* // submitButtonText: 'Submit',
|
|
340
|
+
* // confirmationType: 'message',
|
|
341
|
+
* // successMessage: 'Thank you for your submission!',
|
|
342
|
+
* // allowMultipleSubmissions: true,
|
|
343
|
+
* // },
|
|
344
|
+
* // access: {
|
|
345
|
+
* // submit: () => true,
|
|
346
|
+
* // read: ({ req }) => !!req.user,
|
|
347
|
+
* // },
|
|
348
|
+
* // }
|
|
349
|
+
* ```
|
|
350
|
+
*/
|
|
351
|
+
function applyFormDefaults(config) {
|
|
352
|
+
warnIfWebhooksConfigured(config);
|
|
353
|
+
const singularLabel = config.labels?.singular || toTitleCase(config.slug);
|
|
354
|
+
return {
|
|
355
|
+
...config,
|
|
356
|
+
labels: {
|
|
357
|
+
singular: singularLabel,
|
|
358
|
+
plural: config.labels?.plural || pluralize(singularLabel)
|
|
359
|
+
},
|
|
360
|
+
settings: {
|
|
361
|
+
submitButtonText: config.settings?.submitButtonText || DEFAULT_FORM_SETTINGS.submitButtonText,
|
|
362
|
+
confirmationType: config.settings?.confirmationType || DEFAULT_FORM_SETTINGS.confirmationType,
|
|
363
|
+
successMessage: config.settings?.successMessage || DEFAULT_FORM_SETTINGS.successMessage,
|
|
364
|
+
allowMultipleSubmissions: config.settings?.allowMultipleSubmissions ?? DEFAULT_FORM_SETTINGS.allowMultipleSubmissions,
|
|
365
|
+
redirectUrl: config.settings?.redirectUrl,
|
|
366
|
+
redirectRelation: config.settings?.redirectRelation,
|
|
367
|
+
honeypotEnabled: config.settings?.honeypotEnabled,
|
|
368
|
+
captchaEnabled: config.settings?.captchaEnabled,
|
|
369
|
+
captchaSiteKey: config.settings?.captchaSiteKey
|
|
370
|
+
},
|
|
371
|
+
notifications: config.notifications ? {
|
|
372
|
+
enabled: config.notifications.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.enabled,
|
|
373
|
+
recipients: config.notifications.recipients || [],
|
|
374
|
+
subject: config.notifications.subject || `New submission for ${singularLabel}`,
|
|
375
|
+
replyTo: config.notifications.replyTo ?? DEFAULT_NOTIFICATION_SETTINGS.replyTo,
|
|
376
|
+
template: config.notifications.template
|
|
377
|
+
} : void 0,
|
|
378
|
+
access: {
|
|
379
|
+
submit: config.access?.submit || (() => true),
|
|
380
|
+
read: config.access?.read || (({ req }) => !!req.user)
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Apply default values to individual form field.
|
|
386
|
+
*
|
|
387
|
+
* This is a utility function for applying defaults to a single field,
|
|
388
|
+
* useful when processing fields individually (e.g., in the Schema Builder).
|
|
389
|
+
*
|
|
390
|
+
* @param field - The field to apply defaults to
|
|
391
|
+
* @returns Field with defaults applied
|
|
392
|
+
*
|
|
393
|
+
* @example
|
|
394
|
+
* ```typescript
|
|
395
|
+
* import { applyFieldDefaults } from '@nextly/plugin-form-builder';
|
|
396
|
+
*
|
|
397
|
+
* const field = { type: 'text', name: 'firstName' };
|
|
398
|
+
* const fieldWithDefaults = applyFieldDefaults(field);
|
|
399
|
+
* // { type: 'text', name: 'firstName', label: 'First Name', required: false }
|
|
400
|
+
* ```
|
|
401
|
+
*/
|
|
402
|
+
function applyFieldDefaults(field) {
|
|
403
|
+
const label = field.label || (field.name ? toTitleCase(field.name.replace(/([A-Z])/g, " $1").trim()) : "");
|
|
404
|
+
return {
|
|
405
|
+
...field,
|
|
406
|
+
label,
|
|
407
|
+
required: field.required ?? false
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Creates a complete form config with minimal input.
|
|
412
|
+
*
|
|
413
|
+
* This is a convenience function for quickly creating form configs
|
|
414
|
+
* with sensible defaults. Only the slug and fields are required.
|
|
415
|
+
*
|
|
416
|
+
* @param slug - The form slug
|
|
417
|
+
* @param fields - Array of form fields
|
|
418
|
+
* @param options - Optional additional configuration
|
|
419
|
+
* @returns Complete form configuration
|
|
420
|
+
*
|
|
421
|
+
* @example
|
|
422
|
+
* ```typescript
|
|
423
|
+
* import { createFormConfig } from '@nextly/plugin-form-builder';
|
|
424
|
+
*
|
|
425
|
+
* const config = createFormConfig('contact', [
|
|
426
|
+
* { type: 'text', name: 'name', label: 'Name', required: true },
|
|
427
|
+
* { type: 'email', name: 'email', label: 'Email', required: true },
|
|
428
|
+
* { type: 'textarea', name: 'message', label: 'Message' },
|
|
429
|
+
* ]);
|
|
430
|
+
* ```
|
|
431
|
+
*/
|
|
432
|
+
function createFormConfig(slug, fields, options) {
|
|
433
|
+
return applyFormDefaults({
|
|
434
|
+
slug,
|
|
435
|
+
fields,
|
|
436
|
+
...options
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// src/utils/form-settings.ts
|
|
441
|
+
/**
|
|
442
|
+
* The ONE reader for stored form settings. Settings were historically
|
|
443
|
+
* written in two shapes (the builder's old context shape and the collection
|
|
444
|
+
* group's declared shape), so every consumer — the builder UI and the
|
|
445
|
+
* submit handler alike — normalizes through here instead of trusting the
|
|
446
|
+
* stored keys.
|
|
447
|
+
*/
|
|
448
|
+
function asOptionalBoolean(value) {
|
|
449
|
+
return typeof value === "boolean" ? value : void 0;
|
|
450
|
+
}
|
|
451
|
+
function asOptionalString(value) {
|
|
452
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Normalize a stored settings value into the canonical {@link FormSettings}.
|
|
456
|
+
*
|
|
457
|
+
* Legacy keys are migrated on read (no data is lost, nothing is rewritten
|
|
458
|
+
* until the form is next saved):
|
|
459
|
+
* - `confirmationMessage` → `successMessage` + `confirmationType: "message"`
|
|
460
|
+
* - nested `captcha: { enabled, siteKey }` → flat `captchaEnabled`/`captchaSiteKey`
|
|
461
|
+
*
|
|
462
|
+
* Keys with no consumer (`showResetButton`, `resetButtonText`,
|
|
463
|
+
* `storeSubmissions`, `submissionLimit`) are dropped: a setting that does
|
|
464
|
+
* nothing must not survive into the UI.
|
|
465
|
+
*/
|
|
466
|
+
function normalizeFormSettings(raw) {
|
|
467
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ...DEFAULT_FORM_SETTINGS };
|
|
468
|
+
const source = raw;
|
|
469
|
+
const legacyMessage = asOptionalString(source.confirmationMessage);
|
|
470
|
+
const legacyCaptcha = source.captcha && typeof source.captcha === "object" ? source.captcha : void 0;
|
|
471
|
+
const confirmationType = source.confirmationType === "redirect" || source.confirmationType === "relationship" ? source.confirmationType : "message";
|
|
472
|
+
return {
|
|
473
|
+
submitButtonText: asOptionalString(source.submitButtonText) ?? DEFAULT_FORM_SETTINGS.submitButtonText,
|
|
474
|
+
confirmationType,
|
|
475
|
+
successMessage: asOptionalString(source.successMessage) ?? legacyMessage ?? DEFAULT_FORM_SETTINGS.successMessage,
|
|
476
|
+
redirectUrl: asOptionalString(source.redirectUrl),
|
|
477
|
+
redirectPage: source.redirectPage ?? void 0,
|
|
478
|
+
redirectRelation: source.redirectRelation && typeof source.redirectRelation === "object" ? source.redirectRelation : void 0,
|
|
479
|
+
allowMultipleSubmissions: asOptionalBoolean(source.allowMultipleSubmissions) ?? DEFAULT_FORM_SETTINGS.allowMultipleSubmissions,
|
|
480
|
+
honeypotEnabled: asOptionalBoolean(source.honeypotEnabled),
|
|
481
|
+
captchaEnabled: asOptionalBoolean(source.captchaEnabled) ?? asOptionalBoolean(legacyCaptcha?.enabled),
|
|
482
|
+
captchaSiteKey: asOptionalString(source.captchaSiteKey) ?? asOptionalString(legacyCaptcha?.siteKey)
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// src/utils/evaluate-conditions.ts
|
|
487
|
+
/**
|
|
488
|
+
* Checks if a value is considered "empty".
|
|
489
|
+
* Empty values: undefined, null, empty string, empty array.
|
|
490
|
+
*/
|
|
491
|
+
function isEmptyValue(value) {
|
|
492
|
+
if (value === void 0 || value === null) return true;
|
|
493
|
+
if (typeof value === "string" && value.trim() === "") return true;
|
|
494
|
+
if (Array.isArray(value) && value.length === 0) return true;
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Converts a value to a number for numeric comparisons.
|
|
499
|
+
* Returns NaN if conversion is not possible.
|
|
500
|
+
*/
|
|
501
|
+
function toNumber(value) {
|
|
502
|
+
if (typeof value === "number") return value;
|
|
503
|
+
if (typeof value === "string") {
|
|
504
|
+
const trimmed = value.trim();
|
|
505
|
+
if (trimmed === "") return NaN;
|
|
506
|
+
return Number(trimmed);
|
|
507
|
+
}
|
|
508
|
+
return NaN;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Converts a value to a string for string comparisons.
|
|
512
|
+
*/
|
|
513
|
+
function toString(value) {
|
|
514
|
+
if (value === void 0 || value === null) return "";
|
|
515
|
+
if (typeof value === "string") return value;
|
|
516
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
517
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
518
|
+
return String(value);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Compares two values using the specified operator.
|
|
522
|
+
*
|
|
523
|
+
* @param operator - The comparison operator to use
|
|
524
|
+
* @param fieldValue - The actual value from the form data
|
|
525
|
+
* @param conditionValue - The expected value from the condition
|
|
526
|
+
* @returns Whether the comparison is true
|
|
527
|
+
*/
|
|
528
|
+
function compareValues(operator, fieldValue, conditionValue) {
|
|
529
|
+
switch (operator) {
|
|
530
|
+
case "equals":
|
|
531
|
+
if (Array.isArray(fieldValue)) {
|
|
532
|
+
if (Array.isArray(conditionValue)) {
|
|
533
|
+
if (fieldValue.length !== conditionValue.length) return false;
|
|
534
|
+
return fieldValue.every((v, i) => v === conditionValue[i]);
|
|
535
|
+
}
|
|
536
|
+
return fieldValue.includes(conditionValue);
|
|
537
|
+
}
|
|
538
|
+
return toString(fieldValue) === toString(conditionValue);
|
|
539
|
+
case "notEquals":
|
|
540
|
+
if (Array.isArray(fieldValue)) {
|
|
541
|
+
if (Array.isArray(conditionValue)) {
|
|
542
|
+
if (fieldValue.length !== conditionValue.length) return true;
|
|
543
|
+
return !fieldValue.every((v, i) => v === conditionValue[i]);
|
|
544
|
+
}
|
|
545
|
+
return !fieldValue.includes(conditionValue);
|
|
546
|
+
}
|
|
547
|
+
return toString(fieldValue) !== toString(conditionValue);
|
|
548
|
+
case "contains": {
|
|
549
|
+
const fieldStr = toString(fieldValue).toLowerCase();
|
|
550
|
+
const conditionStr = toString(conditionValue).toLowerCase();
|
|
551
|
+
if (Array.isArray(fieldValue)) return fieldValue.some((v) => toString(v).toLowerCase().includes(conditionStr));
|
|
552
|
+
return fieldStr.includes(conditionStr);
|
|
553
|
+
}
|
|
554
|
+
case "isEmpty": return isEmptyValue(fieldValue);
|
|
555
|
+
case "isNotEmpty": return !isEmptyValue(fieldValue);
|
|
556
|
+
case "greaterThan": {
|
|
557
|
+
const fieldNum = toNumber(fieldValue);
|
|
558
|
+
const conditionNum = toNumber(conditionValue);
|
|
559
|
+
if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) return fieldNum > conditionNum;
|
|
560
|
+
return toString(fieldValue) > toString(conditionValue);
|
|
561
|
+
}
|
|
562
|
+
case "lessThan": {
|
|
563
|
+
const fieldNum = toNumber(fieldValue);
|
|
564
|
+
const conditionNum = toNumber(conditionValue);
|
|
565
|
+
if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) return fieldNum < conditionNum;
|
|
566
|
+
return toString(fieldValue) < toString(conditionValue);
|
|
567
|
+
}
|
|
568
|
+
default:
|
|
569
|
+
console.warn(`[evaluateConditions] Unknown comparison operator: ${String(operator)}`);
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Evaluates a single condition against the form data.
|
|
575
|
+
*
|
|
576
|
+
* Exported for consumers that gate on one condition rather than a full
|
|
577
|
+
* conditional-logic block — notification send-conditions use exactly this.
|
|
578
|
+
*
|
|
579
|
+
* @param condition - The condition to evaluate
|
|
580
|
+
* @param formData - The current form data
|
|
581
|
+
* @returns Whether the condition is met
|
|
582
|
+
*/
|
|
583
|
+
function evaluateSingleCondition(condition, formData) {
|
|
584
|
+
const { field, comparison, value: conditionValue } = condition;
|
|
585
|
+
return compareValues(comparison, getNestedValue(formData, field), conditionValue);
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Gets a nested value from an object using dot notation.
|
|
589
|
+
*
|
|
590
|
+
* @param obj - The object to get the value from
|
|
591
|
+
* @param path - The path to the value (e.g., "address.city")
|
|
592
|
+
* @returns The value at the path, or undefined if not found
|
|
593
|
+
*/
|
|
594
|
+
function getNestedValue(obj, path) {
|
|
595
|
+
if (!path) return;
|
|
596
|
+
if (!path.includes(".")) return obj[path];
|
|
597
|
+
const parts = path.split(".");
|
|
598
|
+
let current = obj;
|
|
599
|
+
for (const part of parts) {
|
|
600
|
+
if (current === void 0 || current === null) return;
|
|
601
|
+
if (typeof current !== "object") return;
|
|
602
|
+
current = current[part];
|
|
603
|
+
}
|
|
604
|
+
return current;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Evaluates conditional logic to determine field visibility.
|
|
608
|
+
*
|
|
609
|
+
* This is the main entry point for conditional logic evaluation.
|
|
610
|
+
* It evaluates all conditions according to the logical operator (AND/OR)
|
|
611
|
+
* and returns whether the field should be visible based on the action
|
|
612
|
+
* (show/hide).
|
|
613
|
+
*
|
|
614
|
+
* @param logic - The conditional logic configuration
|
|
615
|
+
* @param formData - The current form data (field values)
|
|
616
|
+
* @returns Whether the field should be visible
|
|
617
|
+
*
|
|
618
|
+
* @example
|
|
619
|
+
* ```typescript
|
|
620
|
+
* // Show field when country is US AND age is over 18
|
|
621
|
+
* const logic: ConditionalLogic = {
|
|
622
|
+
* enabled: true,
|
|
623
|
+
* action: 'show',
|
|
624
|
+
* operator: 'AND',
|
|
625
|
+
* conditions: [
|
|
626
|
+
* { field: 'country', comparison: 'equals', value: 'US' },
|
|
627
|
+
* { field: 'age', comparison: 'greaterThan', value: 18 },
|
|
628
|
+
* ],
|
|
629
|
+
* };
|
|
630
|
+
*
|
|
631
|
+
* evaluateConditions(logic, { country: 'US', age: 25 }); // true
|
|
632
|
+
* evaluateConditions(logic, { country: 'US', age: 15 }); // false
|
|
633
|
+
* evaluateConditions(logic, { country: 'CA', age: 25 }); // false
|
|
634
|
+
*
|
|
635
|
+
* // Hide field when status is "completed"
|
|
636
|
+
* const hideLogic: ConditionalLogic = {
|
|
637
|
+
* enabled: true,
|
|
638
|
+
* action: 'hide',
|
|
639
|
+
* operator: 'AND',
|
|
640
|
+
* conditions: [
|
|
641
|
+
* { field: 'status', comparison: 'equals', value: 'completed' },
|
|
642
|
+
* ],
|
|
643
|
+
* };
|
|
644
|
+
*
|
|
645
|
+
* evaluateConditions(hideLogic, { status: 'completed' }); // false (hidden)
|
|
646
|
+
* evaluateConditions(hideLogic, { status: 'pending' }); // true (visible)
|
|
647
|
+
* ```
|
|
648
|
+
*
|
|
649
|
+
* @example
|
|
650
|
+
* ```typescript
|
|
651
|
+
* // Show field when either condition is met (OR)
|
|
652
|
+
* const orLogic: ConditionalLogic = {
|
|
653
|
+
* enabled: true,
|
|
654
|
+
* action: 'show',
|
|
655
|
+
* operator: 'OR',
|
|
656
|
+
* conditions: [
|
|
657
|
+
* { field: 'role', comparison: 'equals', value: 'admin' },
|
|
658
|
+
* { field: 'role', comparison: 'equals', value: 'editor' },
|
|
659
|
+
* ],
|
|
660
|
+
* };
|
|
661
|
+
*
|
|
662
|
+
* evaluateConditions(orLogic, { role: 'admin' }); // true
|
|
663
|
+
* evaluateConditions(orLogic, { role: 'editor' }); // true
|
|
664
|
+
* evaluateConditions(orLogic, { role: 'viewer' }); // false
|
|
665
|
+
* ```
|
|
666
|
+
*/
|
|
667
|
+
function evaluateConditions(logic, formData) {
|
|
668
|
+
if (!logic || !logic.enabled) return true;
|
|
669
|
+
const { action, operator, conditions } = logic;
|
|
670
|
+
if (!conditions || conditions.length === 0) return true;
|
|
671
|
+
const results = conditions.map((condition) => evaluateSingleCondition(condition, formData));
|
|
672
|
+
let conditionsMet;
|
|
673
|
+
if (operator === "OR") conditionsMet = results.some((result) => result);
|
|
674
|
+
else conditionsMet = results.every((result) => result);
|
|
675
|
+
if (action === "hide") return !conditionsMet;
|
|
676
|
+
return conditionsMet;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Checks if a comparison operator is valid.
|
|
680
|
+
*
|
|
681
|
+
* @param operator - The operator to check
|
|
682
|
+
* @returns Whether the operator is valid
|
|
683
|
+
*/
|
|
684
|
+
function isValidComparisonOperator(operator) {
|
|
685
|
+
return [
|
|
686
|
+
"equals",
|
|
687
|
+
"notEquals",
|
|
688
|
+
"contains",
|
|
689
|
+
"isEmpty",
|
|
690
|
+
"isNotEmpty",
|
|
691
|
+
"greaterThan",
|
|
692
|
+
"lessThan"
|
|
693
|
+
].includes(operator);
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Gets the list of supported comparison operators.
|
|
697
|
+
*
|
|
698
|
+
* @returns Array of supported operator names
|
|
699
|
+
*/
|
|
700
|
+
function getSupportedComparisonOperators() {
|
|
701
|
+
return [
|
|
702
|
+
"equals",
|
|
703
|
+
"notEquals",
|
|
704
|
+
"contains",
|
|
705
|
+
"isEmpty",
|
|
706
|
+
"isNotEmpty",
|
|
707
|
+
"greaterThan",
|
|
708
|
+
"lessThan"
|
|
709
|
+
];
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// src/utils/export-formats.ts
|
|
713
|
+
/** UTF-8 BOM character sequence */
|
|
714
|
+
const UTF8_BOM = "";
|
|
715
|
+
/**
|
|
716
|
+
* Stringify a value safely, falling back to JSON for plain objects so we
|
|
717
|
+
* never emit the default `[object Object]` representation.
|
|
718
|
+
*/
|
|
719
|
+
function safeString(value) {
|
|
720
|
+
if (typeof value === "object" && value !== null) return JSON.stringify(value);
|
|
721
|
+
return String(value);
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Format a field value for export based on field type.
|
|
725
|
+
*
|
|
726
|
+
* @param value - The raw value from submission data
|
|
727
|
+
* @param field - The field definition (optional, for type-specific formatting)
|
|
728
|
+
* @returns Formatted string representation of the value
|
|
729
|
+
*
|
|
730
|
+
* @example
|
|
731
|
+
* ```typescript
|
|
732
|
+
* formatExportValue(true, { type: 'checkbox' }); // "Yes"
|
|
733
|
+
* formatExportValue(['a', 'b'], { type: 'select' }); // "a, b"
|
|
734
|
+
* formatExportValue(new Date(), { type: 'date' }); // "2024-01-15"
|
|
735
|
+
* ```
|
|
736
|
+
*/
|
|
737
|
+
function formatExportValue(value, field) {
|
|
738
|
+
if (value === null || value === void 0) return "";
|
|
739
|
+
if (field) switch (field.type) {
|
|
740
|
+
case "checkbox": return value ? "Yes" : "No";
|
|
741
|
+
case "select":
|
|
742
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
743
|
+
return safeString(value);
|
|
744
|
+
case "file":
|
|
745
|
+
if (Array.isArray(value)) return value.map((v) => safeString(v)).join(", ");
|
|
746
|
+
return safeString(value);
|
|
747
|
+
case "date":
|
|
748
|
+
if (value instanceof Date) return value.toISOString().split("T")[0];
|
|
749
|
+
if (typeof value === "string") {
|
|
750
|
+
const parsed = new Date(value);
|
|
751
|
+
if (!isNaN(parsed.getTime())) return parsed.toISOString().split("T")[0];
|
|
752
|
+
}
|
|
753
|
+
return safeString(value);
|
|
754
|
+
case "time": return safeString(value);
|
|
755
|
+
case "hidden": return safeString(value);
|
|
756
|
+
}
|
|
757
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
758
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
759
|
+
if (value instanceof Date) return value.toISOString();
|
|
760
|
+
return safeString(value);
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Format a date for export.
|
|
764
|
+
*
|
|
765
|
+
* @param date - Date value (Date object or string)
|
|
766
|
+
* @param format - Output format ("iso" or "locale")
|
|
767
|
+
* @returns Formatted date string
|
|
768
|
+
*/
|
|
769
|
+
function formatDate(date, format = "iso") {
|
|
770
|
+
if (!date) return "";
|
|
771
|
+
const d = typeof date === "string" ? new Date(date) : date;
|
|
772
|
+
if (isNaN(d.getTime())) return "";
|
|
773
|
+
return format === "iso" ? d.toISOString() : d.toLocaleString();
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Escape a value for CSV format (RFC 4180 compliant).
|
|
777
|
+
*
|
|
778
|
+
* Rules:
|
|
779
|
+
* - Fields containing comma, double-quote, or newline must be quoted
|
|
780
|
+
* - Double-quotes within fields must be escaped with another double-quote
|
|
781
|
+
*
|
|
782
|
+
* @param value - The value to escape
|
|
783
|
+
* @param delimiter - The delimiter being used
|
|
784
|
+
* @returns Properly escaped CSV value
|
|
785
|
+
*/
|
|
786
|
+
function escapeCSVValue(value, delimiter = ",") {
|
|
787
|
+
const escapedFormula = /^[=+\-@\t\r]/.test(value) && !/^-?\d+(\.\d+)?$/.test(value) ? `'${value}` : value;
|
|
788
|
+
if (!(escapedFormula.includes(delimiter) || escapedFormula.includes("\"") || escapedFormula.includes("\n") || escapedFormula.includes("\r"))) return escapedFormula;
|
|
789
|
+
return `"${escapedFormula.replace(/"/g, "\"\"")}"`;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Export submissions to CSV format.
|
|
793
|
+
*
|
|
794
|
+
* Generates an RFC 4180 compliant CSV string with:
|
|
795
|
+
* - Form field values as columns (in form definition order)
|
|
796
|
+
* - Optional metadata columns at the end (_id, _status, _submittedAt, etc.)
|
|
797
|
+
* - Proper escaping of special characters
|
|
798
|
+
* - Optional UTF-8 BOM for Excel compatibility
|
|
799
|
+
*
|
|
800
|
+
* @param submissions - Array of submission documents to export
|
|
801
|
+
* @param form - Form document containing field definitions
|
|
802
|
+
* @param options - Export options
|
|
803
|
+
* @returns CSV formatted string
|
|
804
|
+
*
|
|
805
|
+
* @example
|
|
806
|
+
* ```typescript
|
|
807
|
+
* const csv = exportToCSV(submissions, form, {
|
|
808
|
+
* includeMetadata: true,
|
|
809
|
+
* includeBOM: true,
|
|
810
|
+
* });
|
|
811
|
+
*
|
|
812
|
+
* // Download in browser
|
|
813
|
+
* downloadFile(csv, 'submissions.csv', 'text/csv;charset=utf-8');
|
|
814
|
+
* ```
|
|
815
|
+
*/
|
|
816
|
+
function exportToCSV(submissions, form, options = {}) {
|
|
817
|
+
const { includeMetadata = true, delimiter = ",", includeBOM = true, dateFormat = "iso" } = options;
|
|
818
|
+
const exportableFields = form.fields;
|
|
819
|
+
const headers = [];
|
|
820
|
+
for (const field of exportableFields) headers.push(field.label);
|
|
821
|
+
if (includeMetadata) headers.push("ID", "Status", "Submitted At", "IP Address", "User Agent");
|
|
822
|
+
const rows = [];
|
|
823
|
+
for (const submission of submissions) {
|
|
824
|
+
const row = [];
|
|
825
|
+
const data = submission.data;
|
|
826
|
+
for (const field of exportableFields) {
|
|
827
|
+
const value = data[field.name];
|
|
828
|
+
const formatted = formatExportValue(value, field);
|
|
829
|
+
row.push(escapeCSVValue(formatted, delimiter));
|
|
830
|
+
}
|
|
831
|
+
if (includeMetadata) {
|
|
832
|
+
row.push(escapeCSVValue(submission.id, delimiter));
|
|
833
|
+
row.push(escapeCSVValue(submission.status, delimiter));
|
|
834
|
+
row.push(escapeCSVValue(formatDate(submission.submittedAt, dateFormat), delimiter));
|
|
835
|
+
row.push(escapeCSVValue(submission.ipAddress || "", delimiter));
|
|
836
|
+
row.push(escapeCSVValue(submission.userAgent || "", delimiter));
|
|
837
|
+
}
|
|
838
|
+
rows.push(row);
|
|
839
|
+
}
|
|
840
|
+
const csvContent = [headers.map((h) => escapeCSVValue(h, delimiter)).join(delimiter), ...rows.map((row) => row.join(delimiter))].join("\r\n");
|
|
841
|
+
return includeBOM ? UTF8_BOM + csvContent : csvContent;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Export submissions to JSON format.
|
|
845
|
+
*
|
|
846
|
+
* Generates a structured JSON export with:
|
|
847
|
+
* - Export metadata (timestamp)
|
|
848
|
+
* - Optional form definition
|
|
849
|
+
* - Array of submissions with field values
|
|
850
|
+
* - Optional submission metadata (_id, _status, etc.)
|
|
851
|
+
*
|
|
852
|
+
* @param submissions - Array of submission documents to export
|
|
853
|
+
* @param form - Form document containing field definitions
|
|
854
|
+
* @param options - Export options
|
|
855
|
+
* @returns JSON formatted string
|
|
856
|
+
*
|
|
857
|
+
* @example
|
|
858
|
+
* ```typescript
|
|
859
|
+
* const json = exportToJSON(submissions, form, {
|
|
860
|
+
* includeMetadata: true,
|
|
861
|
+
* includeFormDefinition: true,
|
|
862
|
+
* indent: 2,
|
|
863
|
+
* });
|
|
864
|
+
*
|
|
865
|
+
* // Download in browser
|
|
866
|
+
* downloadFile(json, 'submissions.json', 'application/json');
|
|
867
|
+
* ```
|
|
868
|
+
*/
|
|
869
|
+
function exportToJSON(submissions, form, options = {}) {
|
|
870
|
+
const { includeMetadata = true, includeFormDefinition = true, indent = 2 } = options;
|
|
871
|
+
const exportableFields = form.fields;
|
|
872
|
+
const formDefinition = includeFormDefinition ? {
|
|
873
|
+
id: form.id,
|
|
874
|
+
name: form.name,
|
|
875
|
+
slug: form.slug,
|
|
876
|
+
fields: exportableFields.map((f) => ({
|
|
877
|
+
name: f.name,
|
|
878
|
+
label: f.label,
|
|
879
|
+
type: f.type
|
|
880
|
+
}))
|
|
881
|
+
} : void 0;
|
|
882
|
+
const exportedSubmissions = submissions.map((submission) => {
|
|
883
|
+
const data = submission.data;
|
|
884
|
+
const fieldData = {};
|
|
885
|
+
for (const field of exportableFields) {
|
|
886
|
+
const value = data[field.name];
|
|
887
|
+
fieldData[field.name] = value;
|
|
888
|
+
}
|
|
889
|
+
if (includeMetadata) return {
|
|
890
|
+
...fieldData,
|
|
891
|
+
_metadata: {
|
|
892
|
+
id: submission.id,
|
|
893
|
+
status: submission.status,
|
|
894
|
+
submittedAt: submission.submittedAt,
|
|
895
|
+
ipAddress: submission.ipAddress || null,
|
|
896
|
+
userAgent: submission.userAgent || null,
|
|
897
|
+
createdAt: submission.createdAt,
|
|
898
|
+
updatedAt: submission.updatedAt
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
return fieldData;
|
|
902
|
+
});
|
|
903
|
+
const exportData = {
|
|
904
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
905
|
+
totalSubmissions: submissions.length,
|
|
906
|
+
submissions: exportedSubmissions
|
|
907
|
+
};
|
|
908
|
+
if (formDefinition) exportData.form = formDefinition;
|
|
909
|
+
return JSON.stringify(exportData, null, indent);
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Trigger a file download in the browser.
|
|
913
|
+
*
|
|
914
|
+
* Creates a Blob from the content and triggers a download using a temporary
|
|
915
|
+
* anchor element. This function is browser-only and will throw in Node.js.
|
|
916
|
+
*
|
|
917
|
+
* @param content - File content as string
|
|
918
|
+
* @param filename - Name for the downloaded file
|
|
919
|
+
* @param mimeType - MIME type of the file
|
|
920
|
+
*
|
|
921
|
+
* @example
|
|
922
|
+
* ```typescript
|
|
923
|
+
* const csv = exportToCSV(submissions, form);
|
|
924
|
+
* downloadFile(csv, 'contact-form-submissions.csv', 'text/csv;charset=utf-8');
|
|
925
|
+
*
|
|
926
|
+
* const json = exportToJSON(submissions, form);
|
|
927
|
+
* downloadFile(json, 'submissions.json', 'application/json');
|
|
928
|
+
* ```
|
|
929
|
+
*/
|
|
930
|
+
function downloadFile(content, filename, mimeType) {
|
|
931
|
+
if (typeof window === "undefined" || typeof document === "undefined") throw new Error("downloadFile is only available in browser environments");
|
|
932
|
+
const blob = new Blob([content], { type: mimeType });
|
|
933
|
+
const url = URL.createObjectURL(blob);
|
|
934
|
+
const link = document.createElement("a");
|
|
935
|
+
link.href = url;
|
|
936
|
+
link.download = filename;
|
|
937
|
+
document.body.appendChild(link);
|
|
938
|
+
link.click();
|
|
939
|
+
document.body.removeChild(link);
|
|
940
|
+
URL.revokeObjectURL(url);
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Generate a filename for export.
|
|
944
|
+
*
|
|
945
|
+
* Creates a filename with form slug and current timestamp.
|
|
946
|
+
*
|
|
947
|
+
* @param formSlug - The form's slug
|
|
948
|
+
* @param format - Export format ('csv' or 'json')
|
|
949
|
+
* @returns Generated filename
|
|
950
|
+
*
|
|
951
|
+
* @example
|
|
952
|
+
* ```typescript
|
|
953
|
+
* generateExportFilename('contact-form', 'csv');
|
|
954
|
+
* // Returns: "contact-form-submissions-2024-01-15.csv"
|
|
955
|
+
* ```
|
|
956
|
+
*/
|
|
957
|
+
function generateExportFilename(formSlug, format) {
|
|
958
|
+
return `${formSlug.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "form"}-submissions-${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}.${format}`;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Export submissions and trigger browser download.
|
|
962
|
+
*
|
|
963
|
+
* Convenience function that combines export and download in one call.
|
|
964
|
+
* Browser-only.
|
|
965
|
+
*
|
|
966
|
+
* @param submissions - Array of submission documents
|
|
967
|
+
* @param form - Form document
|
|
968
|
+
* @param format - Export format ('csv' or 'json')
|
|
969
|
+
* @param options - Format-specific options
|
|
970
|
+
*
|
|
971
|
+
* @example
|
|
972
|
+
* ```typescript
|
|
973
|
+
* // In a React component
|
|
974
|
+
* const handleExport = (format: 'csv' | 'json') => {
|
|
975
|
+
* exportAndDownload(submissions, form, format);
|
|
976
|
+
* };
|
|
977
|
+
* ```
|
|
978
|
+
*/
|
|
979
|
+
function exportAndDownload(submissions, form, format, options) {
|
|
980
|
+
const filename = generateExportFilename(form.slug, format);
|
|
981
|
+
if (format === "csv") downloadFile(exportToCSV(submissions, form, options), filename, "text/csv;charset=utf-8");
|
|
982
|
+
else downloadFile(exportToJSON(submissions, form, options), filename, "application/json");
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// src/utils/field-references.ts
|
|
986
|
+
/**
|
|
987
|
+
* The one spelling of a field reference this plugin recognises: `{{fieldName}}`
|
|
988
|
+
* with no spaces, and `\w` names.
|
|
989
|
+
*
|
|
990
|
+
* Exact, because the SEND path is exact — a value the admin accepts as a
|
|
991
|
+
* reference and the send path does not is delivered with its braces intact,
|
|
992
|
+
* straight to the mail provider. Both sides read this constant so the question
|
|
993
|
+
* has one answer.
|
|
994
|
+
*/
|
|
995
|
+
const FIELD_REF_PATTERN = /^\{\{(\w+)\}\}$/;
|
|
996
|
+
/** The field named by a reference, or `null` when the value is not one. */
|
|
997
|
+
function parseFieldRef(value) {
|
|
998
|
+
if (!value) return null;
|
|
999
|
+
const match = value.match(FIELD_REF_PATTERN);
|
|
1000
|
+
return match ? match[1] : null;
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Match `{{ fieldName }}` interpolations (whitespace-tolerant), the syntax the
|
|
1004
|
+
* notification send path resolves recipients and reply-to values with.
|
|
1005
|
+
*/
|
|
1006
|
+
function referencesInTemplate(template, fieldName) {
|
|
1007
|
+
return new RegExp(`\\{\\{\\s*${fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}\\}`).test(template);
|
|
1008
|
+
}
|
|
1009
|
+
/** All references to `fieldName` across the form's fields and notifications. */
|
|
1010
|
+
function findFieldReferences(fieldName, fields, notifications) {
|
|
1011
|
+
const references = [];
|
|
1012
|
+
for (const field of fields) {
|
|
1013
|
+
if (field.name === fieldName) continue;
|
|
1014
|
+
if (!field.conditionalLogic?.enabled) continue;
|
|
1015
|
+
if ((field.conditionalLogic.conditions ?? []).some((condition) => condition.field === fieldName)) references.push({
|
|
1016
|
+
kind: "condition",
|
|
1017
|
+
label: field.label || field.name
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
for (const notification of notifications) {
|
|
1021
|
+
if (!notification.enabled) continue;
|
|
1022
|
+
const referencedByTemplate = [
|
|
1023
|
+
notification.to,
|
|
1024
|
+
...notification.cc ?? [],
|
|
1025
|
+
...notification.bcc ?? [],
|
|
1026
|
+
notification.replyTo
|
|
1027
|
+
].filter((value) => typeof value === "string").some((template) => referencesInTemplate(template, fieldName));
|
|
1028
|
+
const referencedByCondition = notification.condition?.field === fieldName;
|
|
1029
|
+
if (referencedByTemplate || referencedByCondition) references.push({
|
|
1030
|
+
kind: "notification",
|
|
1031
|
+
label: notification.name || "Notification"
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
return references;
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Reference lists for every field in one pass, keyed by field name, so a
|
|
1038
|
+
* per-card lookup is O(1) instead of re-walking fields and notifications for
|
|
1039
|
+
* each card on every render.
|
|
1040
|
+
*/
|
|
1041
|
+
function buildFieldReferenceMap(fields, notifications) {
|
|
1042
|
+
const map = /* @__PURE__ */ new Map();
|
|
1043
|
+
for (const field of fields) map.set(field.name, findFieldReferences(field.name, fields, notifications));
|
|
1044
|
+
return map;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
export { isKnownFormField as A, enforcedValidationRules as C, isFileFormField as D, isEmailFormField as E, pickedDocumentField as F, isTextFormField as M, documentReachability as N, isFormField as O, parseRedirectReference as P, ENFORCED_VALIDATION_RULES as S, isCheckboxFormField as T, applyFormDefaults as _, exportToCSV as a, toTitleCase as b, generateExportFilename as c, getSupportedComparisonOperators as d, isValidComparisonOperator as f, applyFieldDefaults as g, DEFAULT_NOTIFICATION_SETTINGS as h, exportAndDownload as i, isSelectFormField as j, isFormFieldType as k, evaluateConditions as l, DEFAULT_FORM_SETTINGS as m, parseFieldRef as n, exportToJSON as o, normalizeFormSettings as p, downloadFile as r, formatExportValue as s, buildFieldReferenceMap as t, evaluateSingleCondition as u, createFormConfig as v, BUILT_IN_FORM_FIELD_TYPES as w, warnIfWebhooksConfigured as x, pluralize as y };
|
|
1048
|
+
//# sourceMappingURL=field-references-BshlgMcn.js.map
|