@nextlyhq/plugin-form-builder 0.0.2-alpha.27 → 0.0.2-alpha.29

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.
@@ -1,3 +1,3 @@
1
- export { J as SubmissionDetail, t as SubmissionDetailProps, K as SubmissionList, u as SubmissionListProps } from '../index-C0kBvYjm.js';
1
+ export { J as SubmissionDetail, t as SubmissionDetailProps, K as SubmissionList, u as SubmissionListProps } from '../index-BRumOEsE.js';
2
2
  import 'react';
3
3
  import 'nextly';
@@ -33,6 +33,27 @@ interface EmailConfig {
33
33
  cc?: string | string[];
34
34
  bcc?: string | string[];
35
35
  }
36
+ /**
37
+ * @experimental A resolved outgoing form notification (template-based) as built
38
+ * by the submission handler, before it is sent. This is the value transformed by
39
+ * the `form-builder.beforeEmail` D63 filter seam.
40
+ */
41
+ interface FormEmailNotification {
42
+ /** Resolved recipient address. */
43
+ to: string;
44
+ /** Email template slug to render. */
45
+ templateSlug: string;
46
+ /** Template variables (submitted data + form metadata). */
47
+ variables: Record<string, unknown>;
48
+ /** Email provider id; undefined = system default. */
49
+ providerId?: string;
50
+ /** CC addresses, if any. */
51
+ cc?: string[];
52
+ /** BCC addresses, if any. */
53
+ bcc?: string[];
54
+ /** Source notification id; undefined for filter-injected emails. */
55
+ notificationId?: string;
56
+ }
36
57
  /**
37
58
  * A single email notification integration stored on a form.
38
59
  * Matches the shape saved by the form builder admin UI.
@@ -200,26 +221,6 @@ interface FormBuilderPluginOptions {
200
221
  defaultFields: FieldDefinition[];
201
222
  }) => FieldDefinition[]);
202
223
  };
203
- /**
204
- * @deprecated Use `formOverrides` and `formSubmissionOverrides` instead.
205
- * This option is kept for backward compatibility.
206
- */
207
- collections?: {
208
- forms?: Partial<CollectionConfig> & {
209
- slug?: string;
210
- labels?: {
211
- singular?: string;
212
- plural?: string;
213
- };
214
- };
215
- submissions?: Partial<CollectionConfig> & {
216
- slug?: string;
217
- labels?: {
218
- singular?: string;
219
- plural?: string;
220
- };
221
- };
222
- };
223
224
  /**
224
225
  * Configure which field types are available in the form builder.
225
226
  *
@@ -272,25 +273,24 @@ interface FormBuilderPluginOptions {
272
273
  /**
273
274
  * Hook called before sending email notifications.
274
275
  *
275
- * Allows modifying, adding, or filtering emails before they are sent.
276
- * Return the modified emails array.
276
+ * Runs as the `form-builder.beforeEmail` D63 filter: the resolved outgoing
277
+ * notifications (template + recipient + variables) are threaded through this
278
+ * callback before they are sent. Allows modifying, adding, or filtering the
279
+ * outgoing notifications. Return the modified array.
277
280
  *
278
281
  * @example
279
282
  * ```typescript
280
283
  * beforeEmail: async ({ emails, form, submission }) => {
281
- * // Add BCC to all emails
282
- * return emails.map(email => ({
283
- * ...email,
284
- * bcc: ['archive@example.com'],
285
- * }));
284
+ * // Add BCC to all outgoing notifications
285
+ * return emails.map((e) => ({ ...e, bcc: ['archive@example.com'] }));
286
286
  * }
287
287
  * ```
288
288
  */
289
289
  beforeEmail?: (args: {
290
- emails: EmailConfig[];
290
+ emails: FormEmailNotification[];
291
291
  form: FormDocument;
292
292
  submission: SubmissionDocument;
293
- }) => Promise<EmailConfig[]> | EmailConfig[];
293
+ }) => Promise<FormEmailNotification[]> | FormEmailNotification[];
294
294
  /**
295
295
  * Email notification settings.
296
296
  */
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import * as nextly from 'nextly';
2
- import { PluginDefinition, CollectionConfig, PluginContext } from 'nextly';
3
- import { R as ResolvedFormBuilderConfig, F as FormBuilderPluginOptions, a as FormConfig, b as FormSettings, c as FormNotifications, C as CheckboxFormField, D as DateFormField, E as EmailFormField, d as FileFormField, H as HiddenFormField, N as NumberFormField, P as PhoneFormField, e as RadioFormField, S as SelectFormField, T as TextFormField, f as TextareaFormField, g as TimeFormField, U as UrlFormField, h as ConditionalLogic, i as FormField, j as SubmissionDocument, k as FormDocument, W as WebhookEvent } from './index-C0kBvYjm.js';
4
- export { B as BaseFormField, l as ConditionalLogicCondition, m as EmailConfig, n as FieldBlockConfig, o as FormAccess, p as FormFieldType, q as FormHookContext, r as FormHooks, s as FormSubmission, t as SubmissionDetailProps, u as SubmissionListProps, V as ValidationRules, v as WebhookConfig, w as isCheckboxFormField, x as isEmailFormField, y as isFileFormField, z as isFormField, A as isFormFieldType, G as isSelectFormField, I as isTextFormField } from './index-C0kBvYjm.js';
2
+ import { CollectionConfig, PluginContext } from 'nextly';
3
+ import { PluginDefinition } from '@nextlyhq/plugin-sdk';
4
+ import { R as ResolvedFormBuilderConfig, F as FormBuilderPluginOptions, a as FormConfig, b as FormSettings, c as FormNotifications, C as CheckboxFormField, D as DateFormField, E as EmailFormField, d as FileFormField, H as HiddenFormField, N as NumberFormField, P as PhoneFormField, e as RadioFormField, S as SelectFormField, T as TextFormField, f as TextareaFormField, g as TimeFormField, U as UrlFormField, h as ConditionalLogic, i as FormField, j as SubmissionDocument, k as FormDocument, W as WebhookEvent } from './index-BRumOEsE.js';
5
+ export { B as BaseFormField, l as ConditionalLogicCondition, m as EmailConfig, n as FieldBlockConfig, o as FormAccess, p as FormFieldType, q as FormHookContext, r as FormHooks, s as FormSubmission, t as SubmissionDetailProps, u as SubmissionListProps, V as ValidationRules, v as WebhookConfig, w as isCheckboxFormField, x as isEmailFormField, y as isFileFormField, z as isFormField, A as isFormFieldType, G as isSelectFormField, I as isTextFormField } from './index-BRumOEsE.js';
5
6
  import { z } from 'zod';
6
7
  import 'react';
7
8
 
@@ -463,7 +464,7 @@ declare const text: (config: Omit<TextFormField, "type">) => TextFormField;
463
464
  * name: 'workEmail',
464
465
  * label: 'Work Email',
465
466
  * required: true,
466
- * placeholder: 'you@company.com',
467
+ * placeholder: 'you@nextly.local',
467
468
  * })
468
469
  * ```
469
470
  */
@@ -1528,7 +1529,7 @@ declare function triggerWebhooks(options: TriggerWebhooksOptions): Promise<Trigg
1528
1529
  * // In submission handler - fire and forget
1529
1530
  * fireWebhooks(
1530
1531
  * { event: 'submission.created', form, submission },
1531
- * pluginContext.infra.logger
1532
+ * pluginContext.logger
1532
1533
  * );
1533
1534
  *
1534
1535
  * // Continue with response immediately
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/plugin.ts
2
- import { getCollectionsHandler } from "nextly/runtime";
2
+ import { definePlugin } from "@nextlyhq/plugin-sdk";
3
3
 
4
4
  // src/collections/forms.ts
5
5
  import {
@@ -425,8 +425,8 @@ function submissionsCollection(pluginConfig) {
425
425
 
426
426
  // src/plugin.ts
427
427
  function resolveConfig(options) {
428
- const formOverrides = options.formOverrides || options.collections?.forms || {};
429
- const submissionOverrides = options.formSubmissionOverrides || options.collections?.submissions || {};
428
+ const formOverrides = options.formOverrides || {};
429
+ const submissionOverrides = options.formSubmissionOverrides || {};
430
430
  return {
431
431
  formOverrides: {
432
432
  ...formOverrides,
@@ -495,41 +495,108 @@ function formBuilder(options = {}) {
495
495
  const resolvedConfig = resolveConfig(options);
496
496
  const formsCol = formsCollection(resolvedConfig);
497
497
  const submissionsCol = submissionsCollection(resolvedConfig);
498
- const plugin = {
498
+ const plugin = definePlugin({
499
499
  name: "@nextlyhq/plugin-form-builder",
500
- version: "0.0.8",
501
- collections: [formsCol, submissionsCol],
500
+ // Keep in sync with package.json `version` (guarded by package-metadata.test).
501
+ version: "0.0.2-alpha.24",
502
+ nextly: ">=0.0.2-alpha.21",
503
+ // Declarative schema: the merged pipeline folds these into the
504
+ // app schema — no manual `setup()` append needed. Just register the plugin.
505
+ contributes: {
506
+ collections: [formsCol, submissionsCol],
507
+ // Custom permission — gates submission export beyond CRUD. The
508
+ // canonical example third-party plugin authors copy.
509
+ permissions: [
510
+ {
511
+ action: "export",
512
+ resource: "submissions",
513
+ label: "Export Submissions",
514
+ description: "Export form submissions as CSV/JSON",
515
+ group: "Form Builder"
516
+ }
517
+ ],
518
+ // HTTP route — exported at
519
+ // /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by
520
+ // default: gated by the custom `export-submissions` permission. Reads
521
+ // via the secure-by-default service path as the authed user and
522
+ // resolves its OWN slug through `ctx.self`. The canonical
523
+ // contributes.routes example for third-party authors.
524
+ routes: [
525
+ {
526
+ method: "GET",
527
+ path: "/submissions/export",
528
+ requiredPermission: "export-submissions",
529
+ handler: async (_req, ctx) => {
530
+ const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
531
+ const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
532
+ const result = await ctx.services.collections.listEntries(
533
+ slug,
534
+ {},
535
+ { as: "user", user: ctx.user ?? void 0 }
536
+ );
537
+ return Response.json({ items: result.data });
538
+ }
539
+ }
540
+ ],
541
+ // Admin UI — the canonical contributes.admin example. Paths
542
+ // are the components form-builder's `/admin` module self-registers (kept
543
+ // as literals so this node entry stays React-free). menu links to
544
+ // the forms collection; settings renders the builder UI at
545
+ // /admin/plugins/<slug>; a custom page is gated by export-submissions;
546
+ // a submissions beforeList view injects the filter above the list.
547
+ admin: {
548
+ menu: [
549
+ {
550
+ label: "Forms",
551
+ to: `/admin/collections/${resolvedConfig.formOverrides.slug}`,
552
+ icon: "file-text",
553
+ order: 50,
554
+ requiredPermission: `read-${resolvedConfig.formOverrides.slug}`
555
+ }
556
+ ],
557
+ settings: {
558
+ component: "@nextlyhq/plugin-form-builder/admin#FormBuilderView"
559
+ },
560
+ pages: [
561
+ {
562
+ path: "submissions",
563
+ component: "@nextlyhq/plugin-form-builder/admin#SubmissionsFilter",
564
+ requiredPermission: "export-submissions"
565
+ }
566
+ ],
567
+ views: {
568
+ [resolvedConfig.formSubmissionOverrides.slug]: {
569
+ beforeList: "@nextlyhq/plugin-form-builder/admin#SubmissionsFilter"
570
+ }
571
+ }
572
+ }
573
+ },
502
574
  admin: {
503
575
  order: 50,
504
576
  description: "Create and manage forms with submission tracking"
505
577
  },
506
- // -- Config transformer --------------------------------------------------
507
- // Automatically adds plugin collections so users don't have to spread them.
508
- config(config) {
509
- const existing = config.collections || [];
510
- const formsSlug = resolvedConfig.formOverrides.slug;
511
- const submissionsSlug = resolvedConfig.formSubmissionOverrides.slug;
512
- const toAdd = [];
513
- if (!existing.some((c) => c.slug === formsSlug))
514
- toAdd.push(formsCol);
515
- if (!existing.some((c) => c.slug === submissionsSlug))
516
- toAdd.push(submissionsCol);
517
- return { ...config, collections: [...existing, ...toAdd] };
518
- },
519
578
  // -- Init ----------------------------------------------------------------
520
579
  // Registers an afterCreate hook on submissions to send email notifications.
521
580
  init(nextly) {
522
- const submissionSlug = resolvedConfig.formSubmissionOverrides.slug;
523
- const guardKey = `__formBuilder_afterCreate_${submissionSlug}`;
524
- const g = globalThis;
525
- if (g[guardKey]) return;
526
- g[guardKey] = true;
527
- nextly.infra.logger.info("Form Builder plugin initialized", {
581
+ const declaredSubmissionsSlug = resolvedConfig.formSubmissionOverrides.slug;
582
+ const submissionSlug = nextly.self.collections[declaredSubmissionsSlug] ?? declaredSubmissionsSlug;
583
+ nextly.logger.info("Form Builder plugin initialized", {
528
584
  formsCollection: resolvedConfig.formOverrides.slug,
529
585
  submissionsCollection: submissionSlug,
530
586
  enabledFields: Object.entries(resolvedConfig.fields).filter(([, on]) => on).map(([name]) => name)
531
587
  });
532
588
  nextly.__formBuilderConfig = resolvedConfig;
589
+ if (resolvedConfig.beforeEmail) {
590
+ nextly.filters.add(
591
+ "form-builder.beforeEmail",
592
+ (emails, ctx) => resolvedConfig.beforeEmail({
593
+ emails,
594
+ // Boundary: loose runtime documents → the user's typed contract.
595
+ form: ctx.form,
596
+ submission: ctx.submission
597
+ })
598
+ );
599
+ }
533
600
  nextly.hooks.on(
534
601
  "afterCreate",
535
602
  submissionSlug,
@@ -538,7 +605,7 @@ function formBuilder(options = {}) {
538
605
  }
539
606
  );
540
607
  }
541
- };
608
+ });
542
609
  return {
543
610
  plugin,
544
611
  collections: [formsCol, submissionsCol],
@@ -596,42 +663,63 @@ async function handleSubmissionCreated(context, config, nextly) {
596
663
  const formFields = Array.isArray(form.fields) ? form.fields : [];
597
664
  const fileAttachments = collectAttachmentInputs(formFields, submittedData);
598
665
  const seen = /* @__PURE__ */ new Set();
666
+ const emails = [];
599
667
  for (const notification of notifications) {
600
668
  if (!notification.enabled || !notification.templateSlug) continue;
601
669
  const key = notification.id || `${notification.to}::${notification.templateSlug}`;
602
670
  if (seen.has(key)) continue;
603
671
  seen.add(key);
672
+ const to = notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to;
673
+ if (!to) {
674
+ nextly.logger.warn?.(
675
+ "Form Builder: empty recipient, skipping notification",
676
+ { notificationId: notification.id, formSlug: form.slug }
677
+ );
678
+ continue;
679
+ }
680
+ const cc = Array.isArray(notification.cc) && notification.cc.length ? notification.cc : void 0;
681
+ const bcc = Array.isArray(notification.bcc) && notification.bcc.length ? notification.bcc : void 0;
682
+ emails.push({
683
+ to,
684
+ templateSlug: notification.templateSlug,
685
+ variables: {
686
+ ...submittedData,
687
+ formName: form.name,
688
+ submissionId: submission.id
689
+ },
690
+ providerId: notification.providerId,
691
+ cc,
692
+ bcc,
693
+ notificationId: notification.id
694
+ });
695
+ }
696
+ if (emails.length === 0) return;
697
+ const finalEmails = await nextly.filters.apply("form-builder.beforeEmail", emails, { form, submission });
698
+ for (const email2 of finalEmails) {
604
699
  try {
605
- const to = notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to;
606
- if (!to) {
607
- nextly.infra.logger.warn?.(
608
- "Form Builder: empty recipient, skipping notification",
609
- { notificationId: notification.id, formSlug: form.slug }
610
- );
611
- continue;
612
- }
613
- const cc = Array.isArray(notification.cc) && notification.cc.length ? notification.cc : void 0;
614
- const bcc = Array.isArray(notification.bcc) && notification.bcc.length ? notification.bcc : void 0;
615
700
  await emailService.sendWithTemplate(
616
- notification.templateSlug,
617
- to,
618
- { ...submittedData, formName: form.name, submissionId: submission.id },
701
+ email2.templateSlug,
702
+ email2.to,
703
+ email2.variables,
619
704
  {
620
- providerId: notification.providerId,
621
- cc,
622
- bcc,
705
+ providerId: email2.providerId,
706
+ cc: email2.cc,
707
+ bcc: email2.bcc,
623
708
  attachments: fileAttachments.length > 0 ? fileAttachments : void 0
624
709
  }
625
710
  );
626
- nextly.infra.logger.info?.("Form Builder: notification sent", {
711
+ nextly.logger.info?.("Form Builder: notification sent", {
627
712
  formSlug: form.slug,
628
- to,
629
- templateSlug: notification.templateSlug,
713
+ to: email2.to,
714
+ templateSlug: email2.templateSlug,
715
+ notificationId: email2.notificationId,
630
716
  attachmentCount: fileAttachments.length
631
717
  });
632
718
  } catch (err) {
633
- nextly.infra.logger.error?.("Form Builder: notification failed", {
634
- notificationId: notification.id,
719
+ nextly.logger.error?.("Form Builder: notification failed", {
720
+ to: email2.to,
721
+ templateSlug: email2.templateSlug,
722
+ notificationId: email2.notificationId,
635
723
  error: err instanceof Error ? err.message : String(err)
636
724
  });
637
725
  }
@@ -639,27 +727,14 @@ async function handleSubmissionCreated(context, config, nextly) {
639
727
  }
640
728
  async function fetchParentForm(config, formId, nextly) {
641
729
  try {
642
- const handler = getCollectionsHandler();
643
- if (!handler) {
644
- nextly.infra.logger.warn?.(
645
- "Form Builder: CollectionsHandler unavailable, skipping notifications"
646
- );
647
- return null;
648
- }
649
- const result = await handler.getEntry({
650
- collectionName: config.formOverrides.slug,
651
- entryId: formId,
652
- overrideAccess: true
653
- });
654
- const fromData = result?.data;
655
- if (fromData) return fromData;
656
- const fromDoc = result?.doc;
657
- if (fromDoc && typeof fromDoc === "object") {
658
- return fromDoc;
659
- }
660
- return null;
730
+ const form = await nextly.services.collections.findEntryById(
731
+ config.formOverrides.slug,
732
+ formId,
733
+ { as: "system" }
734
+ );
735
+ return form;
661
736
  } catch (err) {
662
- nextly.infra.logger.error?.("Form Builder: failed to fetch form", {
737
+ nextly.logger.error?.("Form Builder: failed to fetch form", {
663
738
  formId,
664
739
  error: err instanceof Error ? err.message : String(err)
665
740
  });
@@ -2007,7 +2082,7 @@ async function submitForm(options, context) {
2007
2082
  const { formSlug, data, metadata } = options;
2008
2083
  const { pluginContext, pluginConfig } = context;
2009
2084
  const { collections } = pluginContext.services;
2010
- const { logger } = pluginContext.infra;
2085
+ const { logger } = pluginContext;
2011
2086
  try {
2012
2087
  const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
2013
2088
  if (!form) {
@@ -2076,8 +2151,9 @@ async function submitForm(options, context) {
2076
2151
  const submission = await collections.createEntry(
2077
2152
  pluginConfig.formSubmissionOverrides.slug,
2078
2153
  submissionData,
2079
- {}
2080
- // Empty context - submission creation is public
2154
+ // Public form submission — create as system. No ambient user; an
2155
+ // empty context already resolves to system, but be explicit.
2156
+ { as: "system" }
2081
2157
  );
2082
2158
  logger.info?.("Form submission created successfully", {
2083
2159
  formSlug,
@@ -2105,19 +2181,10 @@ async function fetchFormBySlug(slug, pluginConfig, pluginContext) {
2105
2181
  const { collections } = pluginContext.services;
2106
2182
  const result = await collections.listEntries(
2107
2183
  pluginConfig.formOverrides.slug,
2108
- {
2109
- pagination: { limit: 100 }
2110
- // Fetch enough to find the form
2111
- },
2112
- {}
2113
- // Empty context for public access
2114
- );
2115
- if (!result.data || result.data.length === 0) {
2116
- return null;
2117
- }
2118
- const form = result.data.find(
2119
- (entry) => entry.slug === slug
2184
+ { where: { slug: { equals: slug } }, pagination: { limit: 1 } },
2185
+ { as: "system" }
2120
2186
  );
2187
+ const form = result.data?.[0];
2121
2188
  return form ? form : null;
2122
2189
  } catch {
2123
2190
  return null;
@@ -2191,33 +2258,28 @@ async function getFormSubmissionStats(formSlug, context) {
2191
2258
  return null;
2192
2259
  }
2193
2260
  try {
2194
- const result = await collections.listEntries(
2195
- pluginConfig.formSubmissionOverrides.slug,
2196
- {
2197
- pagination: { limit: 1e3 }
2198
- // Reasonable limit for stats
2199
- },
2200
- {}
2201
- );
2202
- if (!result.data) {
2203
- return { total: 0, new: 0, read: 0, archived: 0 };
2204
- }
2205
- const submissions = result.data.filter(
2206
- (entry) => entry.form === form.id
2207
- );
2208
- const stats = {
2209
- total: submissions.length,
2210
- new: 0,
2211
- read: 0,
2212
- archived: 0
2213
- };
2214
- for (const sub of submissions) {
2215
- const status = sub.status;
2216
- if (status === "new") stats.new++;
2217
- else if (status === "read") stats.read++;
2218
- else if (status === "archived") stats.archived++;
2219
- }
2220
- return stats;
2261
+ const submissionsSlug = pluginConfig.formSubmissionOverrides.slug;
2262
+ const sys = { as: "system" };
2263
+ const base = { form: { equals: form.id } };
2264
+ const [total, newCount, read, archived] = await Promise.all([
2265
+ collections.count(submissionsSlug, { where: base }, sys),
2266
+ collections.count(
2267
+ submissionsSlug,
2268
+ { where: { ...base, status: { equals: "new" } } },
2269
+ sys
2270
+ ),
2271
+ collections.count(
2272
+ submissionsSlug,
2273
+ { where: { ...base, status: { equals: "read" } } },
2274
+ sys
2275
+ ),
2276
+ collections.count(
2277
+ submissionsSlug,
2278
+ { where: { ...base, status: { equals: "archived" } } },
2279
+ sys
2280
+ )
2281
+ ]);
2282
+ return { total, new: newCount, read, archived };
2221
2283
  } catch {
2222
2284
  return null;
2223
2285
  }