@nextlyhq/plugin-form-builder 0.0.2-alpha.72 → 0.0.2-alpha.74

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,4 +1,4 @@
1
- import { S as FormNotification, T as FormSettings, _ as FormField, t as AnyFormField, y as FormFieldTypeId } from "../types-BHReIA0j.js";
1
+ import { O as FormSettings, S as FormFieldTypeId, T as FormNotification, b as FormField, i as AnyFormField, r as NewSubmissionsData } from "../new-submissions-CzlhVGhH.js";
2
2
  import { ComponentPath, DocumentLockGates } from "@nextlyhq/plugin-sdk/admin";
3
3
  import React, { JSX, ReactNode } from "react";
4
4
  import { FieldTypeCatalogEntry, FormFieldCatalogType } from "nextly/field-catalog";
@@ -18,6 +18,11 @@ interface FormBuilderViewProps {
18
18
  fields?: unknown[];
19
19
  settings?: Record<string, unknown>;
20
20
  notifications?: unknown[];
21
+ /**
22
+ * The form's version as read, which a save sends back so one made from an
23
+ * older version is refused rather than replacing what someone saved since.
24
+ */
25
+ _rev?: string;
21
26
  };
22
27
  onSave?: (data: unknown) => void;
23
28
  onSuccess?: (entry?: Record<string, unknown>) => void;
@@ -49,6 +54,8 @@ interface SubmissionRow {
49
54
  submittedAt?: string | null;
50
55
  editedAt?: string | null;
51
56
  editedBy?: string | null;
57
+ /** The submission's version as the list read it; see `submissionPatch`. */
58
+ _rev?: string;
52
59
  }
53
60
  interface FormOption {
54
61
  id: string;
@@ -69,6 +76,10 @@ interface SubmissionSheetProps {
69
76
  onSave: (changes: Record<string, unknown>) => Promise<void>;
70
77
  }
71
78
  export declare function SubmissionSheet({ submission, form, canUpdate, onClose, onPrev, onNext, onSave }: SubmissionSheetProps): JSX.Element;
79
+ /** The card's rows, as many as its size holds. */
80
+ export declare function NewSubmissionsCard({ data }: {
81
+ data: NewSubmissionsData;
82
+ }): JSX.Element;
72
83
  interface FieldCardsProps {
73
84
  /**
74
85
  * Form-surface types the host has NOT excluded (the plugin's field
@@ -1,5 +1,5 @@
1
- import { A as isKnownFormField, C as enforcedValidationRules, N as documentReachability, P as parseRedirectReference, l as evaluateConditions, m as DEFAULT_FORM_SETTINGS, n as parseFieldRef, p as normalizeFormSettings, s as formatExportValue, t as buildFieldReferenceMap } from "../field-references-BshlgMcn.js";
2
- import { ConditionRow, DataTableView, FieldOptionsEditor, FieldTypePicker, ValidationRulesEditor, drawsAnyValidationRule, registerComponents, registerKnownPlugin, usePluginFieldTypeEntries, withOptionIds } from "@nextlyhq/plugin-sdk/admin";
1
+ import { A as isKnownFormField, C as enforcedValidationRules, L as documentReachability, R as parseRedirectReference, l as evaluateConditions, m as DEFAULT_FORM_SETTINGS, n as parseFieldRef, p as normalizeFormSettings, s as formatExportValue, t as buildFieldReferenceMap } from "../field-references-CtzGg51W.js";
2
+ import { ConditionRow, DataTableView, FieldOptionsEditor, FieldTypePicker, FrameState, ValidationRulesEditor, Widget, drawsAnyValidationRule, formatWidgetTime, registerComponents, registerKnownPlugin, usePluginFieldTypeEntries, useRefreshDashboard, useWidgetClock, withOptionIds } from "@nextlyhq/plugin-sdk/admin";
3
3
  import { Badge, Button, Card, Checkbox, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, FieldShell, FormActions, FormLabelWithTooltip, FormSection, Grid, Input, Label, RadioGroup, RadioGroupItem, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Switch, Tabs, TabsContent, TabsList, TabsTrigger, Textarea, Tooltip, TooltipContent, TooltipTrigger, toast } from "@nextlyhq/ui";
4
4
  import * as Lucide from "lucide-react";
5
5
  import { ChevronLeft, ChevronRight, Columns3, Copy, Download, ExternalLink, Eye, Filter, Mail, Monitor, MoreHorizontal, Paperclip, Pencil, Plus, RotateCcw, Smartphone, Trash2, TriangleAlert, X } from "lucide-react";
@@ -388,6 +388,19 @@ function SubmissionSheet({ submission, form, canUpdate, onClose, onPrev, onNext,
388
388
  *
389
389
  * @module admin/components/submissions/SubmissionsView
390
390
  */
391
+ /**
392
+ * What a change to a submission sends: the change, with the version its row
393
+ * had when the list read it, so a change made from an older copy is refused
394
+ * rather than replacing what someone saved since.
395
+ */
396
+ function submissionPatch(row, changes) {
397
+ return typeof row?._rev === "string" ? {
398
+ ...changes,
399
+ _rev: row._rev
400
+ } : changes;
401
+ }
402
+ /** What a refused change says when someone saved the submission since. */
403
+ const SUBMISSION_CHANGED_SINCE = "Someone changed this submission since the list loaded. The list now shows their change; make yours again.";
391
404
  const STATUS_TABS = [
392
405
  {
393
406
  value: "all",
@@ -469,7 +482,7 @@ function SubmissionsView({ collectionSlug }) {
469
482
  const allForms = [];
470
483
  const formsPageSize = 200;
471
484
  for (let formsPage = 1;; formsPage += 1) {
472
- const formsRes = await fetch(`/admin/api/collections/${resolved.forms}/entries?pageSize=${formsPageSize}&page=${formsPage}`, { credentials: "include" });
485
+ const formsRes = await fetch(`/admin/api/collections/${resolved.forms}/entries?limit=${formsPageSize}&page=${formsPage}`, { credentials: "include" });
473
486
  if (!formsRes.ok) break;
474
487
  const items = (await formsRes.json()).items ?? [];
475
488
  allForms.push(...items.map((item) => ({
@@ -513,7 +526,7 @@ function SubmissionsView({ collectionSlug }) {
513
526
  else where.status = { equals: statusTab };
514
527
  const params = new URLSearchParams({
515
528
  page: String(page),
516
- pageSize: String(PAGE_SIZE$1),
529
+ limit: String(PAGE_SIZE$1),
517
530
  sortBy: "submittedAt",
518
531
  sortOrder: "desc",
519
532
  where: JSON.stringify(where)
@@ -592,14 +605,22 @@ function SubmissionsView({ collectionSlug }) {
592
605
  formNameById,
593
606
  hiddenFieldColumns
594
607
  ]);
608
+ const rowsNow = useRef(rows);
609
+ rowsNow.current = rows;
595
610
  const patchSubmission = useCallback(async (id, body) => {
596
611
  const res = await fetch(`/admin/api/collections/${submissionsSlug}/entries/${id}`, {
597
612
  method: "PATCH",
598
613
  headers: { "Content-Type": "application/json" },
599
614
  credentials: "include",
600
- body: JSON.stringify(body)
615
+ body: JSON.stringify(submissionPatch(rowsNow.current.find((row) => row.id === id), body))
601
616
  });
602
- if (!res.ok) throw new Error(`Update failed (${res.status})`);
617
+ if (!res.ok) {
618
+ if ((await res.json().catch(() => null))?.error?.code === "STALE_VERSION") {
619
+ await fetchRows();
620
+ throw new Error(SUBMISSION_CHANGED_SINCE);
621
+ }
622
+ throw new Error(`Update failed (${res.status})`);
623
+ }
603
624
  await fetchRows();
604
625
  }, [submissionsSlug, fetchRows]);
605
626
  const deleteSubmission = useCallback(async (id) => {
@@ -619,7 +640,7 @@ function SubmissionsView({ collectionSlug }) {
619
640
  onSelect: (target) => void patchSubmission(target.id, {
620
641
  status: "new",
621
642
  spamReason: null
622
- })
643
+ }).catch((reason) => setError(reason instanceof Error ? reason.message : "Update failed."))
623
644
  });
624
645
  if (canDelete) actions.push({
625
646
  id: "delete",
@@ -2427,7 +2448,7 @@ async function fetchCanEditTemplates() {
2427
2448
  if (!res.ok) return false;
2428
2449
  const json = await res.json();
2429
2450
  const held = json.permissions ?? [];
2430
- return Boolean(json.isSuperAdmin) || held.includes("update-email-templates") || held.includes("manage-email-templates");
2451
+ return Boolean(json.isSuperAdmin) || held.includes("update-email-templates");
2431
2452
  } catch {
2432
2453
  return false;
2433
2454
  }
@@ -4407,10 +4428,32 @@ function FormPreview({ fields, formData }) {
4407
4428
  * @module admin/FormBuilderView
4408
4429
  * @since 0.1.0
4409
4430
  */
4431
+ /**
4432
+ * The key a form's version travels under, as the admin reads and sends it.
4433
+ * Spelled here because the plugin reaches the admin only through the SDK,
4434
+ * which does not publish it.
4435
+ */
4436
+ const DOCUMENT_VERSION_KEY = "_rev";
4437
+ /**
4438
+ * What a save sends: an existing form's save names the version the editor's
4439
+ * copy started from, where it has one, so a save from an older copy is refused.
4440
+ */
4441
+ function saveBody(saveData, isCreating, startedFrom) {
4442
+ return isCreating || startedFrom === void 0 ? saveData : {
4443
+ ...saveData,
4444
+ [DOCUMENT_VERSION_KEY]: startedFrom
4445
+ };
4446
+ }
4447
+ /** The version a save answered, or the copy's own where it names none. */
4448
+ function versionAfterSave(saved, previous) {
4449
+ const answered = saved[DOCUMENT_VERSION_KEY];
4450
+ return typeof answered === "string" ? answered : previous;
4451
+ }
4410
4452
  function slugify(text) {
4411
4453
  return text.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
4412
4454
  }
4413
- function FormBuilderViewInner({ isCreating = false, collectionSlug = "forms", entryId, onSave, onSuccess, onCancel, documentLock }) {
4455
+ function FormBuilderViewInner({ isCreating = false, collectionSlug = "forms", entryId, onSave, onSuccess, onCancel, documentLock, version }) {
4456
+ const startedFrom = useRef(version);
4414
4457
  const { fields, activeTab, isDirty, formData, settings, notifications, setActiveTab, addField, selectField, seedNotifications, updateFormData, markAsSaved } = useFormBuilder();
4415
4458
  const queryClient = useQueryClient();
4416
4459
  const [isSaving, setIsSaving] = useState(false);
@@ -4497,11 +4540,12 @@ function FormBuilderViewInner({ isCreating = false, collectionSlug = "forms", en
4497
4540
  method: isCreating ? "POST" : "PATCH",
4498
4541
  headers: { "Content-Type": "application/json" },
4499
4542
  credentials: "include",
4500
- body: JSON.stringify(saveData)
4543
+ body: JSON.stringify(saveBody(saveData, isCreating, startedFrom.current))
4501
4544
  });
4502
4545
  if (!response.ok) throw new Error(saveErrorMessage(await response.json().catch(() => ({})), response.statusText));
4503
4546
  const result = await response.json();
4504
4547
  const savedEntry = result.item ?? result;
4548
+ startedFrom.current = versionAfterSave(savedEntry, startedFrom.current);
4505
4549
  queryClient.invalidateQueries({ queryKey: [
4506
4550
  "entries",
4507
4551
  "list",
@@ -4744,6 +4788,10 @@ function FormBuilderViewInner({ isCreating = false, collectionSlug = "forms", en
4744
4788
  */
4745
4789
  function saveErrorMessage(body, statusText) {
4746
4790
  const error = body?.error;
4791
+ if (error?.code === "STALE_VERSION") {
4792
+ const name = error.data?.changedBy?.name;
4793
+ return `${typeof name === "string" && name.trim() !== "" ? name.trim() : "Someone"} changed this form since you opened it. Reload to see their change; your edits are still here, not saved.`;
4794
+ }
4747
4795
  return (error?.data?.errors ?? []).map((issue) => issue.message).filter((message) => Boolean(message)).join(" ") || error?.message || `Failed to save form: ${statusText}`;
4748
4796
  }
4749
4797
  function FormBuilderView({ id, entryId, collection, collectionSlug, isCreating, initialData, onSave, onSuccess, onCancel, documentLock }) {
@@ -4771,11 +4819,144 @@ function FormBuilderView({ id, entryId, collection, collectionSlug, isCreating,
4771
4819
  onSave,
4772
4820
  onSuccess,
4773
4821
  onCancel,
4774
- documentLock
4822
+ documentLock,
4823
+ version: initialData?._rev
4775
4824
  })
4776
4825
  }, providerKey);
4777
4826
  }
4778
4827
 
4828
+ // src/dashboard/mark-read.ts
4829
+ /**
4830
+ * Marking a submission read when it is opened from the New form submissions
4831
+ * card: the status the submissions list sets, through the same collection
4832
+ * update the list's own status changes are sent through, as the reader.
4833
+ *
4834
+ * Sent as an update of the submissions that match the id AND are still new,
4835
+ * so a submission someone archived, flagged or read since the card loaded is
4836
+ * matched by nothing and keeps its status. The update is judged as every
4837
+ * update of the collection is (`update-<slug>` and its rule): a reader who
4838
+ * may not update submissions is refused, and the submission stays new.
4839
+ *
4840
+ * @module dashboard/mark-read
4841
+ */
4842
+ /** The status an opened submission moves to, as the submissions list names it. */
4843
+ const READ_STATUS = "read";
4844
+ /** The admin's API root, which the submissions list sends its updates to. */
4845
+ const ADMIN_API = "/admin/api";
4846
+ /** The request that marks submission `id` of `collection` read while it is still new. */
4847
+ function markReadRequest(collection, id) {
4848
+ return {
4849
+ url: `${ADMIN_API}/collections/${encodeURIComponent(collection)}/entries`,
4850
+ init: {
4851
+ method: "PATCH",
4852
+ headers: { "Content-Type": "application/json" },
4853
+ credentials: "include",
4854
+ keepalive: true,
4855
+ body: JSON.stringify({
4856
+ where: {
4857
+ id: { equals: id },
4858
+ status: { equals: "new" }
4859
+ },
4860
+ data: {
4861
+ status: READ_STATUS,
4862
+ _overwrite: true
4863
+ }
4864
+ })
4865
+ }
4866
+ };
4867
+ }
4868
+ /**
4869
+ * Marks submission `id` read, answering whether the update was taken. A
4870
+ * refusal or a failed request answers `false` and leaves it new, which the
4871
+ * card and the list then still say; it never rejects.
4872
+ */
4873
+ async function markRead(collection, id) {
4874
+ const { url, init } = markReadRequest(collection, id);
4875
+ try {
4876
+ return (await fetch(url, init)).ok;
4877
+ } catch {
4878
+ return false;
4879
+ }
4880
+ }
4881
+
4882
+ // src/dashboard/NewSubmissionsCard.tsx
4883
+ /**
4884
+ * The New form submissions card's body: the newest new submissions as core's
4885
+ * list rows, each opening its submission and marking it read, with the
4886
+ * card's footer leading to every submission, and how many are new beside its
4887
+ * title. While nothing is new it says so, with a link to every submission,
4888
+ * and its title carries no count.
4889
+ *
4890
+ * Drawn only with data: the frame draws loading and every failure itself.
4891
+ * The empty state is drawn here rather than by the frame, because its link
4892
+ * is the submissions collection the site installed, which only the card's
4893
+ * data knows; the frame's would name the slug the plugin declared. Built from
4894
+ * the widget kit alone, so it reads like core's list cards beside it.
4895
+ *
4896
+ * @module dashboard/NewSubmissionsCard
4897
+ */
4898
+ /** The mouse button that opens a link in a new tab. */
4899
+ const MIDDLE_BUTTON = 1;
4900
+ /** What a row says under its title: its form, and the first line of its first text answer. */
4901
+ function metaOf(row) {
4902
+ return [row.form, row.excerpt].filter((part) => part !== void 0);
4903
+ }
4904
+ /** The row whose link a click landed on, if it landed on one. */
4905
+ function openedRow(event, rows) {
4906
+ if (!(event.target instanceof Element)) return void 0;
4907
+ const href = event.target.closest("a[href]")?.getAttribute("href");
4908
+ return href === null || href === void 0 ? void 0 : rows.find((row) => row.href === href);
4909
+ }
4910
+ /**
4911
+ * Nothing new, with a way to every submission. Offered to whoever sees the
4912
+ * card: the card is shown only to a reader who may read the submissions
4913
+ * collection the site installed, which is where the link leads.
4914
+ */
4915
+ function NothingNew({ allHref }) {
4916
+ return /* @__PURE__ */ jsx(FrameState, { state: {
4917
+ kind: "empty",
4918
+ empty: {
4919
+ title: "No new submissions",
4920
+ action: {
4921
+ label: "All submissions",
4922
+ href: allHref
4923
+ }
4924
+ }
4925
+ } });
4926
+ }
4927
+ /** The card's rows, as many as its size holds. */
4928
+ function NewSubmissionsCard({ data }) {
4929
+ const now = useWidgetClock();
4930
+ const refresh = useRefreshDashboard();
4931
+ Widget.useTitleCount(data.total > 0 ? data.total : void 0, data.atLeast === true);
4932
+ if (data.total === 0) return /* @__PURE__ */ jsx(NothingNew, { allHref: data.allHref });
4933
+ const opened = (event) => {
4934
+ const row = openedRow(event, data.items);
4935
+ if (row === void 0) return;
4936
+ markRead(data.collection, row.id).then((taken) => {
4937
+ if (taken) refresh();
4938
+ });
4939
+ };
4940
+ return /* @__PURE__ */ jsx("div", {
4941
+ className: "contents",
4942
+ onClickCapture: opened,
4943
+ onAuxClickCapture: (event) => {
4944
+ if (event.button === MIDDLE_BUTTON) opened(event);
4945
+ },
4946
+ children: /* @__PURE__ */ jsx(Widget.List, {
4947
+ rows: data.items,
4948
+ title: (row) => row.title,
4949
+ href: (row) => row.href,
4950
+ meta: metaOf,
4951
+ trailing: (row) => row.at === void 0 ? void 0 : /* @__PURE__ */ jsx("time", {
4952
+ dateTime: row.at,
4953
+ children: formatWidgetTime(row.at, now)
4954
+ }),
4955
+ viewAllHref: data.allHref
4956
+ })
4957
+ });
4958
+ }
4959
+
4779
4960
  // src/admin/index.ts
4780
4961
  /**
4781
4962
  * Form Builder Admin Components
@@ -4823,5 +5004,5 @@ registerKnownPlugin("@nextlyhq/plugin-form-builder", async () => {
4823
5004
  });
4824
5005
  });
4825
5006
 
4826
- export { AddFieldDialog, DEFAULT_SETTINGS, FORM_BUILDER_VIEW_PATH, FieldCards, FieldEditor, FormBuilderProvider, FormBuilderView, FormNotificationsTab, FormPreview, FormSettingsTab, SUBMISSIONS_VIEW_PATH, SubmissionSheet, SubmissionsView, createFieldFromType, createNotification, generateFieldLabel, generateFieldName, useFormBuilder, useOptionalFormBuilder };
5007
+ export { AddFieldDialog, DEFAULT_SETTINGS, FORM_BUILDER_VIEW_PATH, FieldCards, FieldEditor, FormBuilderProvider, FormBuilderView, FormNotificationsTab, FormPreview, FormSettingsTab, NewSubmissionsCard, SUBMISSIONS_VIEW_PATH, SubmissionSheet, SubmissionsView, createFieldFromType, createNotification, generateFieldLabel, generateFieldName, useFormBuilder, useOptionalFormBuilder };
4827
5008
  //# sourceMappingURL=index.js.map