strapi-plugin-hubspot 0.6.1 → 0.8.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/_chunks/{Forms-B2y_0-x4.mjs → Forms-77Mornxt.mjs} +115 -18
  3. package/dist/_chunks/{Forms-CaT0YEJo.js → Forms-B8kkPY0c.js} +133 -36
  4. package/dist/_chunks/HubspotFormInput-BVknnlmR.mjs +61 -0
  5. package/dist/_chunks/HubspotFormInput-kx3LlQYF.js +79 -0
  6. package/dist/_chunks/{HubspotObjectInput-DdLAUh0N.mjs → HubspotObjectInput-B-Y1PZ6c.mjs} +4 -3
  7. package/dist/_chunks/{HubspotObjectInput-Crpcpa8T.js → HubspotObjectInput-BOO1PvOj.js} +8 -7
  8. package/dist/_chunks/{HubspotPropertyInput-k7wMSmeF.mjs → HubspotPropertyInput-Drxn_Ddt.mjs} +4 -3
  9. package/dist/_chunks/{HubspotPropertyInput-SnJMFsII.js → HubspotPropertyInput-awfD1opH.js} +18 -17
  10. package/dist/_chunks/{Settings-BPAAD-QW.js → Settings-BT1NhvxO.js} +16 -15
  11. package/dist/_chunks/{Settings-BMOmj4NJ.mjs → Settings-DrujhMxS.mjs} +3 -2
  12. package/dist/_chunks/{en-C_-N8cjv.js → en-B8adhJhO.js} +17 -3
  13. package/dist/_chunks/{en-CJ9wYHgN.mjs → en-BYKKi1LK.mjs} +17 -3
  14. package/dist/_chunks/{fr-BHObPoUp.js → fr-DnKtGXaB.js} +17 -3
  15. package/dist/_chunks/{fr-DttDn-2Y.mjs → fr-DzgArgi3.mjs} +17 -3
  16. package/dist/_chunks/{objectLabels-C9ocOgVY.mjs → index-CEh8vkxY.mjs} +0 -14
  17. package/dist/_chunks/{objectLabels-VTt_-qTZ.js → index-DkTxsEqL.js} +0 -14
  18. package/dist/_chunks/{index-foVSGFWb.mjs → index-LrCSAHoH.mjs} +21 -5
  19. package/dist/_chunks/{index-D_CPrIHe.js → index-P7p8ASb6.js} +21 -5
  20. package/dist/_chunks/objectLabels-Dn-zriuZ.js +15 -0
  21. package/dist/_chunks/objectLabels-yNrnTOBo.mjs +16 -0
  22. package/dist/_chunks/{useHubspotSchema-B8SctALV.js → useHubspotSchema-BottzdRR.js} +1 -1
  23. package/dist/_chunks/{useHubspotSchema-Du3rLY_O.mjs → useHubspotSchema-DccG2KbZ.mjs} +1 -1
  24. package/dist/admin/index.js +1 -1
  25. package/dist/admin/index.mjs +1 -1
  26. package/dist/admin/src/builder/types.d.ts +1 -0
  27. package/dist/admin/src/components/HubspotFormInput.d.ts +29 -0
  28. package/dist/server/index.js +100 -8
  29. package/dist/server/index.mjs +100 -8
  30. package/dist/server/src/content-types.d.ts +3 -0
  31. package/dist/server/src/forms.d.ts +2 -0
  32. package/dist/server/src/formsAdmin.d.ts +13 -0
  33. package/dist/server/src/importLegacy.d.ts +1 -0
  34. package/dist/server/src/index.d.ts +5 -0
  35. package/dist/shared/types.d.ts +2 -0
  36. package/package.json +1 -1
@@ -1,6 +1,5 @@
1
1
  "use strict";
2
2
  const React = require("react");
3
- const index = require("./index-D_CPrIHe.js");
4
3
  function _interopNamespace(e) {
5
4
  if (e && e.__esModule) return e;
6
5
  const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
@@ -426,17 +425,4 @@ createFormattedComponent("formatList");
426
425
  createFormattedComponent("formatDisplayName");
427
426
  createFormattedDateTimePartsComponent("formatDate");
428
427
  createFormattedDateTimePartsComponent("formatTime");
429
- const STANDARD = /* @__PURE__ */ new Set([
430
- "contact",
431
- "company",
432
- "deal",
433
- "ticket",
434
- "product",
435
- "line_item",
436
- "quote"
437
- ]);
438
- function objectLabel(intl, object) {
439
- return STANDARD.has(object) ? intl.formatMessage({ id: index.getTranslation(`objects.${object}`), defaultMessage: object }) : object;
440
- }
441
- exports.objectLabel = objectLabel;
442
428
  exports.useIntl = useIntl;
@@ -25,7 +25,7 @@ const index = {
25
25
  icon: PaperPlane,
26
26
  intlLabel: { id: `${PLUGIN_ID}.menu.label`, defaultMessage: "HubSpot Forms" },
27
27
  permissions: [{ action: `plugin::${PLUGIN_ID}.forms`, subject: null }],
28
- Component: async () => (await import("./Forms-B2y_0-x4.mjs")).default
28
+ Component: async () => (await import("./Forms-77Mornxt.mjs")).default
29
29
  });
30
30
  app.customFields.register({
31
31
  name: "property",
@@ -40,7 +40,7 @@ const index = {
40
40
  defaultMessage: "Picked from the portal's real properties"
41
41
  },
42
42
  components: {
43
- Input: async () => import("./HubspotPropertyInput-k7wMSmeF.mjs")
43
+ Input: async () => import("./HubspotPropertyInput-Drxn_Ddt.mjs")
44
44
  },
45
45
  // Exposed in the Content-Type Builder. Without this, `objectField` could
46
46
  // only be set by hand-editing schema.json — which is not how anyone
@@ -103,7 +103,23 @@ const index = {
103
103
  defaultMessage: "Picked from the portal's configured objects"
104
104
  },
105
105
  components: {
106
- Input: async () => import("./HubspotObjectInput-DdLAUh0N.mjs")
106
+ Input: async () => import("./HubspotObjectInput-B-Y1PZ6c.mjs")
107
+ }
108
+ });
109
+ app.customFields.register({
110
+ name: "form-picker",
111
+ pluginId: PLUGIN_ID,
112
+ type: "string",
113
+ intlLabel: {
114
+ id: `${PLUGIN_ID}.form-picker.label`,
115
+ defaultMessage: "HubSpot form"
116
+ },
117
+ intlDescription: {
118
+ id: `${PLUGIN_ID}.form-picker.description`,
119
+ defaultMessage: "A form built in the HubSpot form builder, stored by slug"
120
+ },
121
+ components: {
122
+ Input: async () => import("./HubspotFormInput-BVknnlmR.mjs")
107
123
  }
108
124
  });
109
125
  app.createSettingSection(
@@ -119,7 +135,7 @@ const index = {
119
135
  // Mirrors the RBAC action registered server-side: a role without it
120
136
  // doesn't see the link at all — the routes refuse it anyway.
121
137
  permissions: [{ action: `plugin::${PLUGIN_ID}.settings`, subject: null }],
122
- Component: async () => (await import("./Settings-BMOmj4NJ.mjs")).default
138
+ Component: async () => (await import("./Settings-DrujhMxS.mjs")).default
123
139
  }
124
140
  ]
125
141
  );
@@ -129,7 +145,7 @@ const index = {
129
145
  return Promise.all(
130
146
  locales.map(async (locale) => {
131
147
  try {
132
- const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => import("./en-CJ9wYHgN.mjs"), "./translations/fr.json": () => import("./fr-DttDn-2Y.mjs") }), `./translations/${locale}.json`, 3);
148
+ const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => import("./en-BYKKi1LK.mjs"), "./translations/fr.json": () => import("./fr-DzgArgi3.mjs") }), `./translations/${locale}.json`, 3);
133
149
  return { data: prefixPluginTranslations(data), locale };
134
150
  } catch {
135
151
  return { data: {}, locale };
@@ -26,7 +26,7 @@ const index = {
26
26
  icon: icons.PaperPlane,
27
27
  intlLabel: { id: `${PLUGIN_ID}.menu.label`, defaultMessage: "HubSpot Forms" },
28
28
  permissions: [{ action: `plugin::${PLUGIN_ID}.forms`, subject: null }],
29
- Component: async () => (await Promise.resolve().then(() => require("./Forms-CaT0YEJo.js"))).default
29
+ Component: async () => (await Promise.resolve().then(() => require("./Forms-B8kkPY0c.js"))).default
30
30
  });
31
31
  app.customFields.register({
32
32
  name: "property",
@@ -41,7 +41,7 @@ const index = {
41
41
  defaultMessage: "Picked from the portal's real properties"
42
42
  },
43
43
  components: {
44
- Input: async () => Promise.resolve().then(() => require("./HubspotPropertyInput-SnJMFsII.js"))
44
+ Input: async () => Promise.resolve().then(() => require("./HubspotPropertyInput-awfD1opH.js"))
45
45
  },
46
46
  // Exposed in the Content-Type Builder. Without this, `objectField` could
47
47
  // only be set by hand-editing schema.json — which is not how anyone
@@ -104,7 +104,23 @@ const index = {
104
104
  defaultMessage: "Picked from the portal's configured objects"
105
105
  },
106
106
  components: {
107
- Input: async () => Promise.resolve().then(() => require("./HubspotObjectInput-Crpcpa8T.js"))
107
+ Input: async () => Promise.resolve().then(() => require("./HubspotObjectInput-BOO1PvOj.js"))
108
+ }
109
+ });
110
+ app.customFields.register({
111
+ name: "form-picker",
112
+ pluginId: PLUGIN_ID,
113
+ type: "string",
114
+ intlLabel: {
115
+ id: `${PLUGIN_ID}.form-picker.label`,
116
+ defaultMessage: "HubSpot form"
117
+ },
118
+ intlDescription: {
119
+ id: `${PLUGIN_ID}.form-picker.description`,
120
+ defaultMessage: "A form built in the HubSpot form builder, stored by slug"
121
+ },
122
+ components: {
123
+ Input: async () => Promise.resolve().then(() => require("./HubspotFormInput-kx3LlQYF.js"))
108
124
  }
109
125
  });
110
126
  app.createSettingSection(
@@ -120,7 +136,7 @@ const index = {
120
136
  // Mirrors the RBAC action registered server-side: a role without it
121
137
  // doesn't see the link at all — the routes refuse it anyway.
122
138
  permissions: [{ action: `plugin::${PLUGIN_ID}.settings`, subject: null }],
123
- Component: async () => (await Promise.resolve().then(() => require("./Settings-BPAAD-QW.js"))).default
139
+ Component: async () => (await Promise.resolve().then(() => require("./Settings-BT1NhvxO.js"))).default
124
140
  }
125
141
  ]
126
142
  );
@@ -130,7 +146,7 @@ const index = {
130
146
  return Promise.all(
131
147
  locales.map(async (locale) => {
132
148
  try {
133
- const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => Promise.resolve().then(() => require("./en-C_-N8cjv.js")), "./translations/fr.json": () => Promise.resolve().then(() => require("./fr-BHObPoUp.js")) }), `./translations/${locale}.json`, 3);
149
+ const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => Promise.resolve().then(() => require("./en-B8adhJhO.js")), "./translations/fr.json": () => Promise.resolve().then(() => require("./fr-DnKtGXaB.js")) }), `./translations/${locale}.json`, 3);
134
150
  return { data: prefixPluginTranslations(data), locale };
135
151
  } catch {
136
152
  return { data: {}, locale };
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ const index = require("./index-P7p8ASb6.js");
3
+ const STANDARD = /* @__PURE__ */ new Set([
4
+ "contact",
5
+ "company",
6
+ "deal",
7
+ "ticket",
8
+ "product",
9
+ "line_item",
10
+ "quote"
11
+ ]);
12
+ function objectLabel(intl, object) {
13
+ return STANDARD.has(object) ? intl.formatMessage({ id: index.getTranslation(`objects.${object}`), defaultMessage: object }) : object;
14
+ }
15
+ exports.objectLabel = objectLabel;
@@ -0,0 +1,16 @@
1
+ import { g as getTranslation } from "./index-LrCSAHoH.mjs";
2
+ const STANDARD = /* @__PURE__ */ new Set([
3
+ "contact",
4
+ "company",
5
+ "deal",
6
+ "ticket",
7
+ "product",
8
+ "line_item",
9
+ "quote"
10
+ ]);
11
+ function objectLabel(intl, object) {
12
+ return STANDARD.has(object) ? intl.formatMessage({ id: getTranslation(`objects.${object}`), defaultMessage: object }) : object;
13
+ }
14
+ export {
15
+ objectLabel as o
16
+ };
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  const React = require("react");
3
3
  const admin = require("@strapi/strapi/admin");
4
- const index = require("./index-D_CPrIHe.js");
4
+ const index = require("./index-P7p8ASb6.js");
5
5
  function _interopNamespace(e) {
6
6
  if (e && e.__esModule) return e;
7
7
  const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { useFetchClient } from "@strapi/strapi/admin";
3
- import { P as PLUGIN_ID } from "./index-foVSGFWb.mjs";
3
+ import { P as PLUGIN_ID } from "./index-LrCSAHoH.mjs";
4
4
  let cached = null;
5
5
  let inFlight = null;
6
6
  const listeners = /* @__PURE__ */ new Set();
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-D_CPrIHe.js");
2
+ const index = require("../_chunks/index-P7p8ASb6.js");
3
3
  require("@strapi/icons");
4
4
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-foVSGFWb.mjs";
1
+ import { i } from "../_chunks/index-LrCSAHoH.mjs";
2
2
  import "@strapi/icons";
3
3
  export {
4
4
  i as default
@@ -55,6 +55,7 @@ export interface FormEntryDto {
55
55
  nextLabel?: string | null;
56
56
  submitLabel?: string | null;
57
57
  successMessage?: string | null;
58
+ class?: string | null;
58
59
  locale?: string | null;
59
60
  updatedAt?: string;
60
61
  definition: FormDefinition;
@@ -0,0 +1,29 @@
1
+ import * as React from "react";
2
+ interface InputProps {
3
+ name: string;
4
+ value?: string;
5
+ onChange: (event: {
6
+ target: {
7
+ name: string;
8
+ value: string;
9
+ type: string;
10
+ };
11
+ }) => void;
12
+ disabled?: boolean;
13
+ error?: string;
14
+ required?: boolean;
15
+ labelAction?: React.ReactNode;
16
+ hint?: React.ReactNode;
17
+ label?: string;
18
+ }
19
+ /**
20
+ * Picker over the forms built in the HubSpot form builder, for host content
21
+ * that references one (a hero block, a landing…). Stores the form's slug as a
22
+ * plain string: the reference survives the plugin being uninstalled, and a
23
+ * fetch failure degrades to a free-text input rather than blocking the editor.
24
+ *
25
+ * An unpublished form stays selectable — an editor can wire the block before
26
+ * publishing — but is flagged, since the public API only serves published ones.
27
+ */
28
+ declare const HubspotFormInput: React.ForwardRefExoticComponent<InputProps & React.RefAttributes<HTMLInputElement>>;
29
+ export default HubspotFormInput;
@@ -397,6 +397,7 @@ const contentTypes = {
397
397
  nextLabel: localizedString(),
398
398
  submitLabel: localizedString(),
399
399
  successMessage: localizedString("text"),
400
+ class: { type: "string" },
400
401
  definition: {
401
402
  type: "json",
402
403
  required: true,
@@ -573,6 +574,7 @@ function publicForm(entry) {
573
574
  nextLabel: entry.nextLabel ?? null,
574
575
  submitLabel: entry.submitLabel ?? null,
575
576
  successMessage: entry.successMessage ?? null,
577
+ class: entry.class ?? null,
576
578
  locale: entry.locale ?? null,
577
579
  steps: (entry.definition?.steps ?? []).map((step) => ({
578
580
  ...step,
@@ -932,6 +934,7 @@ function convertLegacyForm(entry, map = {}) {
932
934
  nextLabel: entry.nextLabel ?? null,
933
935
  submitLabel: entry.submitLabel ?? null,
934
936
  successMessage: entry.successMessage ?? null,
937
+ class: entry.class ?? null,
935
938
  definition: { version: 1, steps }
936
939
  };
937
940
  }
@@ -943,7 +946,8 @@ const WRITABLE = [
943
946
  "subtitle",
944
947
  "nextLabel",
945
948
  "submitLabel",
946
- "successMessage"
949
+ "successMessage",
950
+ "class"
947
951
  ];
948
952
  const slugify = (value) => value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "form";
949
953
  function createFormsAdminController(strapi) {
@@ -962,6 +966,15 @@ function createFormsAdminController(strapi) {
962
966
  return null;
963
967
  }
964
968
  }
969
+ async function uniqueSlug(name) {
970
+ const base = slugify(name);
971
+ let slug = base;
972
+ for (let i = 2; ; i += 1) {
973
+ const clash = await documents().findFirst({ filters: { slug } });
974
+ if (!clash) return slug;
975
+ slug = `${base}-${i}`;
976
+ }
977
+ }
965
978
  function pickWritable(body) {
966
979
  const data = {};
967
980
  for (const key of WRITABLE) {
@@ -1027,13 +1040,7 @@ function createFormsAdminController(strapi) {
1027
1040
  const body = ctx.request.body ?? {};
1028
1041
  const name = typeof body.name === "string" ? body.name.trim() : "";
1029
1042
  if (!name) ctx.throw(400, "A form needs a name");
1030
- const base = slugify(name);
1031
- let slug = base;
1032
- for (let i = 2; ; i += 1) {
1033
- const clash = await documents().findFirst({ filters: { slug } });
1034
- if (!clash) break;
1035
- slug = `${base}-${i}`;
1036
- }
1043
+ const slug = await uniqueSlug(name);
1037
1044
  let definition;
1038
1045
  try {
1039
1046
  definition = requireValidDefinition(ctx, body.definition);
@@ -1094,6 +1101,31 @@ function createFormsAdminController(strapi) {
1094
1101
  });
1095
1102
  ctx.body = { ok: true };
1096
1103
  },
1104
+ /**
1105
+ * Feeds the `hubspot.form-picker` custom field: every form of the builder
1106
+ * as { name, slug, published }. Open to any authenticated admin — like the
1107
+ * properties route — because editors pick forms from the Content Manager,
1108
+ * not from the builder. `name`/`slug` are shared across locales.
1109
+ */
1110
+ async options(ctx) {
1111
+ const drafts = await documents().findMany({
1112
+ fields: ["name", "slug"],
1113
+ sort: "name:asc",
1114
+ status: "draft"
1115
+ });
1116
+ const published = await documents().findMany({
1117
+ fields: ["slug"],
1118
+ status: "published"
1119
+ });
1120
+ const publishedSlugs = new Set(published.map((e) => e.slug));
1121
+ ctx.body = {
1122
+ forms: drafts.filter((e) => e.slug).map((e) => ({
1123
+ name: e.name ?? e.slug,
1124
+ slug: e.slug,
1125
+ published: publishedSlugs.has(e.slug)
1126
+ }))
1127
+ };
1128
+ },
1097
1129
  /** Entries of the configured legacy content type, offered for import. */
1098
1130
  async listSources(ctx) {
1099
1131
  const config2 = strapi.plugin("hubspot").config("forms", {}).import;
@@ -1177,6 +1209,57 @@ function createFormsAdminController(strapi) {
1177
1209
  if (!targetId) ctx.throw(404, "Source entry not found");
1178
1210
  ctx.body = { documentId: targetId, locales: imported };
1179
1211
  },
1212
+ /**
1213
+ * Copies a form (every locale of its draft) into a new draft document —
1214
+ * the quickest way to A/B a variant or to start from an existing form.
1215
+ * Internal step/field ids are kept: they only need uniqueness per form.
1216
+ */
1217
+ async duplicate(ctx) {
1218
+ const sourceId = ctx.params.documentId;
1219
+ let locales = [void 0];
1220
+ try {
1221
+ const found = await strapi.plugin("i18n").service("locales").find();
1222
+ if (Array.isArray(found) && found.length) {
1223
+ locales = [...found].sort((a, b) => Number(b.isDefault ?? false) - Number(a.isDefault ?? false)).map((l) => l.code);
1224
+ }
1225
+ } catch {
1226
+ }
1227
+ let targetId = null;
1228
+ let slug = null;
1229
+ for (const locale of locales) {
1230
+ const source = await documents().findOne({
1231
+ documentId: sourceId,
1232
+ locale,
1233
+ status: "draft"
1234
+ });
1235
+ if (!source) continue;
1236
+ const name = `${source.name} (2)`;
1237
+ slug ??= await uniqueSlug(name);
1238
+ const data = {
1239
+ name,
1240
+ slug,
1241
+ title: source.title,
1242
+ subtitle: source.subtitle,
1243
+ nextLabel: source.nextLabel,
1244
+ submitLabel: source.submitLabel,
1245
+ successMessage: source.successMessage,
1246
+ class: source.class,
1247
+ definition: source.definition
1248
+ };
1249
+ if (targetId) {
1250
+ const { slug: _slug, name: _name, ...localized } = data;
1251
+ await documents().update({ documentId: targetId, locale, data: localized });
1252
+ } else {
1253
+ const created = await documents().create({
1254
+ locale,
1255
+ data
1256
+ });
1257
+ targetId = created.documentId;
1258
+ }
1259
+ }
1260
+ if (!targetId) ctx.throw(404, "Form not found");
1261
+ ctx.body = { documentId: targetId };
1262
+ },
1180
1263
  async remove(ctx) {
1181
1264
  await documents().delete({
1182
1265
  documentId: ctx.params.documentId
@@ -1477,6 +1560,10 @@ const routes = {
1477
1560
  adminRoute("POST", "/builder/forms/:documentId/publish", "formsAdmin.publish", [FORMS_ACTION]),
1478
1561
  adminRoute("POST", "/builder/forms/:documentId/unpublish", "formsAdmin.unpublish", [FORMS_ACTION]),
1479
1562
  adminRoute("DELETE", "/builder/forms/:documentId", "formsAdmin.remove", [FORMS_ACTION]),
1563
+ adminRoute("POST", "/builder/forms/:documentId/duplicate", "formsAdmin.duplicate", [FORMS_ACTION]),
1564
+ // No FORMS_ACTION: editors pick forms from the Content Manager, like
1565
+ // they pick properties — the builder itself stays gated.
1566
+ adminRoute("GET", "/forms-options", "formsAdmin.options"),
1480
1567
  adminRoute("GET", "/builder/import/sources", "formsAdmin.listSources", [FORMS_ACTION]),
1481
1568
  adminRoute("POST", "/builder/import", "formsAdmin.runImport", [FORMS_ACTION])
1482
1569
  ]
@@ -1512,6 +1599,11 @@ const index = {
1512
1599
  plugin: "hubspot",
1513
1600
  type: "string"
1514
1601
  });
1602
+ strapi.customFields.register({
1603
+ name: "form-picker",
1604
+ plugin: "hubspot",
1605
+ type: "string"
1606
+ });
1515
1607
  },
1516
1608
  async bootstrap({ strapi }) {
1517
1609
  await strapi.service("admin::permission").actionProvider.registerMany([
@@ -396,6 +396,7 @@ const contentTypes = {
396
396
  nextLabel: localizedString(),
397
397
  submitLabel: localizedString(),
398
398
  successMessage: localizedString("text"),
399
+ class: { type: "string" },
399
400
  definition: {
400
401
  type: "json",
401
402
  required: true,
@@ -572,6 +573,7 @@ function publicForm(entry) {
572
573
  nextLabel: entry.nextLabel ?? null,
573
574
  submitLabel: entry.submitLabel ?? null,
574
575
  successMessage: entry.successMessage ?? null,
576
+ class: entry.class ?? null,
575
577
  locale: entry.locale ?? null,
576
578
  steps: (entry.definition?.steps ?? []).map((step) => ({
577
579
  ...step,
@@ -931,6 +933,7 @@ function convertLegacyForm(entry, map = {}) {
931
933
  nextLabel: entry.nextLabel ?? null,
932
934
  submitLabel: entry.submitLabel ?? null,
933
935
  successMessage: entry.successMessage ?? null,
936
+ class: entry.class ?? null,
934
937
  definition: { version: 1, steps }
935
938
  };
936
939
  }
@@ -942,7 +945,8 @@ const WRITABLE = [
942
945
  "subtitle",
943
946
  "nextLabel",
944
947
  "submitLabel",
945
- "successMessage"
948
+ "successMessage",
949
+ "class"
946
950
  ];
947
951
  const slugify = (value) => value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "form";
948
952
  function createFormsAdminController(strapi) {
@@ -961,6 +965,15 @@ function createFormsAdminController(strapi) {
961
965
  return null;
962
966
  }
963
967
  }
968
+ async function uniqueSlug(name) {
969
+ const base = slugify(name);
970
+ let slug = base;
971
+ for (let i = 2; ; i += 1) {
972
+ const clash = await documents().findFirst({ filters: { slug } });
973
+ if (!clash) return slug;
974
+ slug = `${base}-${i}`;
975
+ }
976
+ }
964
977
  function pickWritable(body) {
965
978
  const data = {};
966
979
  for (const key of WRITABLE) {
@@ -1026,13 +1039,7 @@ function createFormsAdminController(strapi) {
1026
1039
  const body = ctx.request.body ?? {};
1027
1040
  const name = typeof body.name === "string" ? body.name.trim() : "";
1028
1041
  if (!name) ctx.throw(400, "A form needs a name");
1029
- const base = slugify(name);
1030
- let slug = base;
1031
- for (let i = 2; ; i += 1) {
1032
- const clash = await documents().findFirst({ filters: { slug } });
1033
- if (!clash) break;
1034
- slug = `${base}-${i}`;
1035
- }
1042
+ const slug = await uniqueSlug(name);
1036
1043
  let definition;
1037
1044
  try {
1038
1045
  definition = requireValidDefinition(ctx, body.definition);
@@ -1093,6 +1100,31 @@ function createFormsAdminController(strapi) {
1093
1100
  });
1094
1101
  ctx.body = { ok: true };
1095
1102
  },
1103
+ /**
1104
+ * Feeds the `hubspot.form-picker` custom field: every form of the builder
1105
+ * as { name, slug, published }. Open to any authenticated admin — like the
1106
+ * properties route — because editors pick forms from the Content Manager,
1107
+ * not from the builder. `name`/`slug` are shared across locales.
1108
+ */
1109
+ async options(ctx) {
1110
+ const drafts = await documents().findMany({
1111
+ fields: ["name", "slug"],
1112
+ sort: "name:asc",
1113
+ status: "draft"
1114
+ });
1115
+ const published = await documents().findMany({
1116
+ fields: ["slug"],
1117
+ status: "published"
1118
+ });
1119
+ const publishedSlugs = new Set(published.map((e) => e.slug));
1120
+ ctx.body = {
1121
+ forms: drafts.filter((e) => e.slug).map((e) => ({
1122
+ name: e.name ?? e.slug,
1123
+ slug: e.slug,
1124
+ published: publishedSlugs.has(e.slug)
1125
+ }))
1126
+ };
1127
+ },
1096
1128
  /** Entries of the configured legacy content type, offered for import. */
1097
1129
  async listSources(ctx) {
1098
1130
  const config2 = strapi.plugin("hubspot").config("forms", {}).import;
@@ -1176,6 +1208,57 @@ function createFormsAdminController(strapi) {
1176
1208
  if (!targetId) ctx.throw(404, "Source entry not found");
1177
1209
  ctx.body = { documentId: targetId, locales: imported };
1178
1210
  },
1211
+ /**
1212
+ * Copies a form (every locale of its draft) into a new draft document —
1213
+ * the quickest way to A/B a variant or to start from an existing form.
1214
+ * Internal step/field ids are kept: they only need uniqueness per form.
1215
+ */
1216
+ async duplicate(ctx) {
1217
+ const sourceId = ctx.params.documentId;
1218
+ let locales = [void 0];
1219
+ try {
1220
+ const found = await strapi.plugin("i18n").service("locales").find();
1221
+ if (Array.isArray(found) && found.length) {
1222
+ locales = [...found].sort((a, b) => Number(b.isDefault ?? false) - Number(a.isDefault ?? false)).map((l) => l.code);
1223
+ }
1224
+ } catch {
1225
+ }
1226
+ let targetId = null;
1227
+ let slug = null;
1228
+ for (const locale of locales) {
1229
+ const source = await documents().findOne({
1230
+ documentId: sourceId,
1231
+ locale,
1232
+ status: "draft"
1233
+ });
1234
+ if (!source) continue;
1235
+ const name = `${source.name} (2)`;
1236
+ slug ??= await uniqueSlug(name);
1237
+ const data = {
1238
+ name,
1239
+ slug,
1240
+ title: source.title,
1241
+ subtitle: source.subtitle,
1242
+ nextLabel: source.nextLabel,
1243
+ submitLabel: source.submitLabel,
1244
+ successMessage: source.successMessage,
1245
+ class: source.class,
1246
+ definition: source.definition
1247
+ };
1248
+ if (targetId) {
1249
+ const { slug: _slug, name: _name, ...localized } = data;
1250
+ await documents().update({ documentId: targetId, locale, data: localized });
1251
+ } else {
1252
+ const created = await documents().create({
1253
+ locale,
1254
+ data
1255
+ });
1256
+ targetId = created.documentId;
1257
+ }
1258
+ }
1259
+ if (!targetId) ctx.throw(404, "Form not found");
1260
+ ctx.body = { documentId: targetId };
1261
+ },
1179
1262
  async remove(ctx) {
1180
1263
  await documents().delete({
1181
1264
  documentId: ctx.params.documentId
@@ -1476,6 +1559,10 @@ const routes = {
1476
1559
  adminRoute("POST", "/builder/forms/:documentId/publish", "formsAdmin.publish", [FORMS_ACTION]),
1477
1560
  adminRoute("POST", "/builder/forms/:documentId/unpublish", "formsAdmin.unpublish", [FORMS_ACTION]),
1478
1561
  adminRoute("DELETE", "/builder/forms/:documentId", "formsAdmin.remove", [FORMS_ACTION]),
1562
+ adminRoute("POST", "/builder/forms/:documentId/duplicate", "formsAdmin.duplicate", [FORMS_ACTION]),
1563
+ // No FORMS_ACTION: editors pick forms from the Content Manager, like
1564
+ // they pick properties — the builder itself stays gated.
1565
+ adminRoute("GET", "/forms-options", "formsAdmin.options"),
1479
1566
  adminRoute("GET", "/builder/import/sources", "formsAdmin.listSources", [FORMS_ACTION]),
1480
1567
  adminRoute("POST", "/builder/import", "formsAdmin.runImport", [FORMS_ACTION])
1481
1568
  ]
@@ -1511,6 +1598,11 @@ const index = {
1511
1598
  plugin: "hubspot",
1512
1599
  type: "string"
1513
1600
  });
1601
+ strapi.customFields.register({
1602
+ name: "form-picker",
1603
+ plugin: "hubspot",
1604
+ type: "string"
1605
+ });
1514
1606
  },
1515
1607
  async bootstrap({ strapi }) {
1516
1608
  await strapi.service("admin::permission").actionProvider.registerMany([
@@ -85,6 +85,9 @@ declare const _default: {
85
85
  };
86
86
  };
87
87
  };
88
+ class: {
89
+ type: string;
90
+ };
88
91
  definition: {
89
92
  type: string;
90
93
  required: boolean;
@@ -19,6 +19,7 @@ export interface FormEntry {
19
19
  nextLabel?: string | null;
20
20
  submitLabel?: string | null;
21
21
  successMessage?: string | null;
22
+ class?: string | null;
22
23
  locale?: string | null;
23
24
  definition: FormDefinition;
24
25
  [key: string]: unknown;
@@ -30,6 +31,7 @@ export interface PublicForm {
30
31
  nextLabel?: string | null;
31
32
  submitLabel?: string | null;
32
33
  successMessage?: string | null;
34
+ class?: string | null;
33
35
  locale?: string | null;
34
36
  steps: FormDefinition["steps"];
35
37
  }
@@ -27,6 +27,13 @@ export declare function createFormsAdminController(strapi: Core.Strapi): {
27
27
  update(ctx: Ctx): Promise<void>;
28
28
  publish(ctx: Ctx): Promise<void>;
29
29
  unpublish(ctx: Ctx): Promise<void>;
30
+ /**
31
+ * Feeds the `hubspot.form-picker` custom field: every form of the builder
32
+ * as { name, slug, published }. Open to any authenticated admin — like the
33
+ * properties route — because editors pick forms from the Content Manager,
34
+ * not from the builder. `name`/`slug` are shared across locales.
35
+ */
36
+ options(ctx: Ctx): Promise<void>;
30
37
  /** Entries of the configured legacy content type, offered for import. */
31
38
  listSources(ctx: Ctx): Promise<void>;
32
39
  /**
@@ -35,5 +42,11 @@ export declare function createFormsAdminController(strapi: Core.Strapi): {
35
42
  * published version, and never modifies the source.
36
43
  */
37
44
  runImport(ctx: Ctx): Promise<void>;
45
+ /**
46
+ * Copies a form (every locale of its draft) into a new draft document —
47
+ * the quickest way to A/B a variant or to start from an existing form.
48
+ * Internal step/field ids are kept: they only need uniqueness per form.
49
+ */
50
+ duplicate(ctx: Ctx): Promise<void>;
38
51
  remove(ctx: Ctx): Promise<void>;
39
52
  };
@@ -20,6 +20,7 @@ export interface ConvertedForm {
20
20
  nextLabel?: string | null;
21
21
  submitLabel?: string | null;
22
22
  successMessage?: string | null;
23
+ class?: string | null;
23
24
  definition: FormDefinition;
24
25
  }
25
26
  export declare function convertLegacyForm(entry: Record<string, unknown>, map?: ImportMap): ConvertedForm;