strapi-plugin-hubspot 0.16.0 → 0.16.2

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 (33) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +4 -4
  3. package/dist/_chunks/{Forms-6EoEYzAc.mjs → Forms-BOQWely0.mjs} +240 -114
  4. package/dist/_chunks/{Forms-BW_zGc1P.js → Forms-mMNP7hc9.js} +238 -112
  5. package/dist/_chunks/{HubspotFormInput-V5w4y1fb.mjs → HubspotFormInput-DCkoavrN.mjs} +1 -1
  6. package/dist/_chunks/{HubspotFormInput-oMhm1UJm.js → HubspotFormInput-DI35Aqiu.js} +1 -1
  7. package/dist/_chunks/{HubspotObjectInput-DhsnSguG.mjs → HubspotObjectInput-BbnvhdbD.mjs} +3 -3
  8. package/dist/_chunks/{HubspotObjectInput-Bmo6YQgs.js → HubspotObjectInput-Cq1TuL_l.js} +3 -3
  9. package/dist/_chunks/{HubspotPropertyInput-CrYP4pc0.mjs → HubspotPropertyInput-BHfpBD36.mjs} +3 -3
  10. package/dist/_chunks/{HubspotPropertyInput-luFfnxqy.js → HubspotPropertyInput-BLZzsW08.js} +3 -3
  11. package/dist/_chunks/{Settings-B3A22i7Z.js → Settings-BAsx0LGv.js} +2 -2
  12. package/dist/_chunks/{Settings-CjcWj9I6.mjs → Settings-DqRpHtnT.mjs} +2 -2
  13. package/dist/_chunks/{en-D6GHgMOt.mjs → en-Djd3TID0.mjs} +7 -3
  14. package/dist/_chunks/{en-C7ls2EUl.js → en-sfzopB5K.js} +7 -3
  15. package/dist/_chunks/{fr-CfohMY8n.mjs → fr-C7t1elmR.mjs} +7 -3
  16. package/dist/_chunks/{fr-pOP8ZXqM.js → fr-sF5Josfh.js} +7 -3
  17. package/dist/_chunks/{index-7cL9PRh1.mjs → index-CLAy5dVN.mjs} +6 -6
  18. package/dist/_chunks/{index-CodpLKZx.js → index-R3ReZXF1.js} +6 -6
  19. package/dist/_chunks/{objectLabels-Dnj14fsp.mjs → objectLabels--zOmqsfm.mjs} +1 -1
  20. package/dist/_chunks/{objectLabels-CSWGIgNb.js → objectLabels-CW2iBOkD.js} +1 -1
  21. package/dist/_chunks/{useHubspotSchema-DiRN3HrN.js → useHubspotSchema-BRzzvs9t.js} +1 -1
  22. package/dist/_chunks/{useHubspotSchema-BsmQgJgx.mjs → useHubspotSchema-Bz2o1BU-.mjs} +1 -1
  23. package/dist/admin/index.js +1 -1
  24. package/dist/admin/index.mjs +1 -1
  25. package/dist/admin/src/builder/Sortable.d.ts +25 -12
  26. package/dist/admin/src/builder/__tests__/relocateField.test.d.ts +1 -0
  27. package/dist/admin/src/builder/relocateField.d.ts +14 -0
  28. package/dist/server/index.js +129 -12
  29. package/dist/server/index.mjs +129 -12
  30. package/dist/server/src/formsAdmin.d.ts +8 -3
  31. package/dist/server/src/importHubspot.d.ts +12 -0
  32. package/dist/server/src/index.d.ts +1 -0
  33. package/package.json +1 -1
@@ -164,7 +164,11 @@ const fr = {
164
164
  "editor.hubspot-form": "Formulaire marketing HubSpot",
165
165
  "editor.hubspot-form-placeholder": "Choisir un formulaire du portail connecté",
166
166
  "editor.hubspot-form-none": "Aucun — upsert CRM uniquement",
167
- "editor.hubspot-form-hint": "Les envois passent par l’API Forms de HubSpot pour compter comme des conversions. Changez de portail (test → prod) et sélectionnez le formulaire correspondant — rien n’est figé dans le code.",
167
+ "editor.hubspot-form-hint": "Les envois passent par l’API Forms de HubSpot pour compter comme des conversions. Après une modification dans HubSpot, utilisez Resynchroniser — les champs mappés se mettent à jour sur place, les nouveaux s’ajoutent, vos étapes restent.",
168
+ "editor.resync": "Resynchroniser depuis HubSpot",
169
+ "editor.resynced": "Resynchronisé depuis HubSpot — {updated, plural, one {# champ mis à jour} other {# champs mis à jour}}, {added, plural, one {# ajouté} other {# ajoutés}}.",
170
+ "editor.resynced-gaps": "Resynchronisé depuis HubSpot — {updated, plural, one {# mis à jour} other {# mis à jour}}, {added, plural, one {# ajouté} other {# ajoutés}}, {skipped, plural, one {# écarté} other {# écartés}}.",
171
+ "editor.resync-error": "Resynchronisation impossible — le formulaire est-il toujours lié ?",
168
172
  "editor.form-hint": "Sélectionnez une étape ou un champ pour l'éditer. Le endpoint public sert ce formulaire sur /api/hubspot/forms/{slug}.",
169
173
  "panel.label": "Libellé",
170
174
  "panel.name": "Clé du payload",
@@ -234,7 +238,7 @@ const fr = {
234
238
  "forms.col-submissions": "Soumissions",
235
239
  "forms.submissions": "Soumissions",
236
240
  "forms.import-hubspot-title": "Importer depuis votre portail HubSpot",
237
- "forms.import-hubspot-hint": "Traduit un formulaire construit dans HubSpot en brouillon ici, mapping CRM inclus. Ce que le builder ne sait pas exprimer est écarté et signalé. Ré-importer écrase le brouillon.",
241
+ "forms.import-hubspot-hint": "Traduit un formulaire construit dans HubSpot en brouillon ici, mapping CRM inclus. Ce que le builder ne sait pas exprimer est écarté et signalé. Ré-importer met à jour les champs mappés et ajoute les nouveaux.",
238
242
  "forms.import-hubspot-source": "Formulaire HubSpot à importer",
239
243
  "forms.hubspot-list-error": "Impossible de lister les formulaires du portail — le jeton a besoin du scope de lecture `forms`.",
240
244
  "forms.report-title": "Importé avec des manques",
@@ -295,7 +299,7 @@ const fr = {
295
299
  "company.datum.nafLabel": "Libellé NAF",
296
300
  "company.datum.headcount": "Tranche d'effectif",
297
301
  "editor.drag-step": "Glisser pour réordonner l'étape",
298
- "editor.drag-field": "Glisser pour réordonner le champ"
302
+ "editor.drag-field": "Glisser pour déplacer le champ, y compris vers une autre étape"
299
303
  };
300
304
  export {
301
305
  fr as default
@@ -166,7 +166,11 @@ const fr = {
166
166
  "editor.hubspot-form": "Formulaire marketing HubSpot",
167
167
  "editor.hubspot-form-placeholder": "Choisir un formulaire du portail connecté",
168
168
  "editor.hubspot-form-none": "Aucun — upsert CRM uniquement",
169
- "editor.hubspot-form-hint": "Les envois passent par l’API Forms de HubSpot pour compter comme des conversions. Changez de portail (test → prod) et sélectionnez le formulaire correspondant — rien n’est figé dans le code.",
169
+ "editor.hubspot-form-hint": "Les envois passent par l’API Forms de HubSpot pour compter comme des conversions. Après une modification dans HubSpot, utilisez Resynchroniser — les champs mappés se mettent à jour sur place, les nouveaux s’ajoutent, vos étapes restent.",
170
+ "editor.resync": "Resynchroniser depuis HubSpot",
171
+ "editor.resynced": "Resynchronisé depuis HubSpot — {updated, plural, one {# champ mis à jour} other {# champs mis à jour}}, {added, plural, one {# ajouté} other {# ajoutés}}.",
172
+ "editor.resynced-gaps": "Resynchronisé depuis HubSpot — {updated, plural, one {# mis à jour} other {# mis à jour}}, {added, plural, one {# ajouté} other {# ajoutés}}, {skipped, plural, one {# écarté} other {# écartés}}.",
173
+ "editor.resync-error": "Resynchronisation impossible — le formulaire est-il toujours lié ?",
170
174
  "editor.form-hint": "Sélectionnez une étape ou un champ pour l'éditer. Le endpoint public sert ce formulaire sur /api/hubspot/forms/{slug}.",
171
175
  "panel.label": "Libellé",
172
176
  "panel.name": "Clé du payload",
@@ -236,7 +240,7 @@ const fr = {
236
240
  "forms.col-submissions": "Soumissions",
237
241
  "forms.submissions": "Soumissions",
238
242
  "forms.import-hubspot-title": "Importer depuis votre portail HubSpot",
239
- "forms.import-hubspot-hint": "Traduit un formulaire construit dans HubSpot en brouillon ici, mapping CRM inclus. Ce que le builder ne sait pas exprimer est écarté et signalé. Ré-importer écrase le brouillon.",
243
+ "forms.import-hubspot-hint": "Traduit un formulaire construit dans HubSpot en brouillon ici, mapping CRM inclus. Ce que le builder ne sait pas exprimer est écarté et signalé. Ré-importer met à jour les champs mappés et ajoute les nouveaux.",
240
244
  "forms.import-hubspot-source": "Formulaire HubSpot à importer",
241
245
  "forms.hubspot-list-error": "Impossible de lister les formulaires du portail — le jeton a besoin du scope de lecture `forms`.",
242
246
  "forms.report-title": "Importé avec des manques",
@@ -297,6 +301,6 @@ const fr = {
297
301
  "company.datum.nafLabel": "Libellé NAF",
298
302
  "company.datum.headcount": "Tranche d'effectif",
299
303
  "editor.drag-step": "Glisser pour réordonner l'étape",
300
- "editor.drag-field": "Glisser pour réordonner le champ"
304
+ "editor.drag-field": "Glisser pour déplacer le champ, y compris vers une autre étape"
301
305
  };
302
306
  exports.default = fr;
@@ -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-6EoEYzAc.mjs")).default
28
+ Component: async () => (await import("./Forms-BOQWely0.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-CrYP4pc0.mjs")
43
+ Input: async () => import("./HubspotPropertyInput-BHfpBD36.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,7 @@ const index = {
103
103
  defaultMessage: "Picked from the portal's configured objects"
104
104
  },
105
105
  components: {
106
- Input: async () => import("./HubspotObjectInput-DhsnSguG.mjs")
106
+ Input: async () => import("./HubspotObjectInput-BbnvhdbD.mjs")
107
107
  }
108
108
  });
109
109
  app.customFields.register({
@@ -119,7 +119,7 @@ const index = {
119
119
  defaultMessage: "A form built in the HubSpot form builder, stored by slug"
120
120
  },
121
121
  components: {
122
- Input: async () => import("./HubspotFormInput-V5w4y1fb.mjs")
122
+ Input: async () => import("./HubspotFormInput-DCkoavrN.mjs")
123
123
  }
124
124
  });
125
125
  app.createSettingSection(
@@ -135,7 +135,7 @@ const index = {
135
135
  // Mirrors the RBAC action registered server-side: a role without it
136
136
  // doesn't see the link at all — the routes refuse it anyway.
137
137
  permissions: [{ action: `plugin::${PLUGIN_ID}.settings`, subject: null }],
138
- Component: async () => (await import("./Settings-CjcWj9I6.mjs")).default
138
+ Component: async () => (await import("./Settings-DqRpHtnT.mjs")).default
139
139
  }
140
140
  ]
141
141
  );
@@ -145,7 +145,7 @@ const index = {
145
145
  return Promise.all(
146
146
  locales.map(async (locale) => {
147
147
  try {
148
- const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => import("./en-D6GHgMOt.mjs"), "./translations/fr.json": () => import("./fr-CfohMY8n.mjs") }), `./translations/${locale}.json`, 3);
148
+ const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => import("./en-Djd3TID0.mjs"), "./translations/fr.json": () => import("./fr-C7t1elmR.mjs") }), `./translations/${locale}.json`, 3);
149
149
  return { data: prefixPluginTranslations(data), locale };
150
150
  } catch {
151
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-BW_zGc1P.js"))).default
29
+ Component: async () => (await Promise.resolve().then(() => require("./Forms-mMNP7hc9.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-luFfnxqy.js"))
44
+ Input: async () => Promise.resolve().then(() => require("./HubspotPropertyInput-BLZzsW08.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,7 @@ 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-Bmo6YQgs.js"))
107
+ Input: async () => Promise.resolve().then(() => require("./HubspotObjectInput-Cq1TuL_l.js"))
108
108
  }
109
109
  });
110
110
  app.customFields.register({
@@ -120,7 +120,7 @@ const index = {
120
120
  defaultMessage: "A form built in the HubSpot form builder, stored by slug"
121
121
  },
122
122
  components: {
123
- Input: async () => Promise.resolve().then(() => require("./HubspotFormInput-oMhm1UJm.js"))
123
+ Input: async () => Promise.resolve().then(() => require("./HubspotFormInput-DI35Aqiu.js"))
124
124
  }
125
125
  });
126
126
  app.createSettingSection(
@@ -136,7 +136,7 @@ const index = {
136
136
  // Mirrors the RBAC action registered server-side: a role without it
137
137
  // doesn't see the link at all — the routes refuse it anyway.
138
138
  permissions: [{ action: `plugin::${PLUGIN_ID}.settings`, subject: null }],
139
- Component: async () => (await Promise.resolve().then(() => require("./Settings-B3A22i7Z.js"))).default
139
+ Component: async () => (await Promise.resolve().then(() => require("./Settings-BAsx0LGv.js"))).default
140
140
  }
141
141
  ]
142
142
  );
@@ -146,7 +146,7 @@ const index = {
146
146
  return Promise.all(
147
147
  locales.map(async (locale) => {
148
148
  try {
149
- const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => Promise.resolve().then(() => require("./en-C7ls2EUl.js")), "./translations/fr.json": () => Promise.resolve().then(() => require("./fr-pOP8ZXqM.js")) }), `./translations/${locale}.json`, 3);
149
+ const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => Promise.resolve().then(() => require("./en-sfzopB5K.js")), "./translations/fr.json": () => Promise.resolve().then(() => require("./fr-sF5Josfh.js")) }), `./translations/${locale}.json`, 3);
150
150
  return { data: prefixPluginTranslations(data), locale };
151
151
  } catch {
152
152
  return { data: {}, locale };
@@ -1,4 +1,4 @@
1
- import { g as getTranslation } from "./index-7cL9PRh1.mjs";
1
+ import { g as getTranslation } from "./index-CLAy5dVN.mjs";
2
2
  const STANDARD = /* @__PURE__ */ new Set([
3
3
  "contact",
4
4
  "company",
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- const index = require("./index-CodpLKZx.js");
2
+ const index = require("./index-R3ReZXF1.js");
3
3
  const STANDARD = /* @__PURE__ */ new Set([
4
4
  "contact",
5
5
  "company",
@@ -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-CodpLKZx.js");
4
+ const index = require("./index-R3ReZXF1.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-7cL9PRh1.mjs";
3
+ import { P as PLUGIN_ID } from "./index-CLAy5dVN.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-CodpLKZx.js");
2
+ const index = require("../_chunks/index-R3ReZXF1.js");
3
3
  require("@strapi/icons");
4
4
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-7cL9PRh1.mjs";
1
+ import { i } from "../_chunks/index-CLAy5dVN.mjs";
2
2
  import "@strapi/icons";
3
3
  export {
4
4
  i as default
@@ -1,25 +1,38 @@
1
1
  import * as React from "react";
2
- import { arrayMove } from "@dnd-kit/sortable";
3
- export { arrayMove };
2
+ import { type DragEndEvent } from "@dnd-kit/core";
3
+ import { arrayMove, relocateField } from "./relocateField";
4
+ export { arrayMove, relocateField };
5
+ export type { DragEndEvent };
4
6
  /** The drag-handle props to spread on the grip element. */
5
7
  export interface HandleProps {
6
8
  attributes: React.HTMLAttributes<HTMLElement>;
7
9
  listeners: Record<string, unknown> | undefined;
8
10
  }
9
- /**
10
- * One vertical sortable list. Each list gets its OWN DndContext: steps sort
11
- * among steps, a step's fields sort among themselves — no cross-container
12
- * ambiguity, and the arrow buttons keep covering cross-step moves (they also
13
- * stay the accessible path).
14
- */
15
- export declare function SortableList({ ids, onReorder, children, }: {
11
+ export type SortableData = {
12
+ type: "step";
13
+ } | {
14
+ type: "field";
15
+ stepId: string;
16
+ } | {
17
+ type: "container";
18
+ stepId: string;
19
+ };
20
+ export declare function SortableBoard({ children, onDragEnd, }: {
21
+ children: React.ReactNode;
22
+ onDragEnd: (event: DragEndEvent) => void;
23
+ }): React.JSX.Element;
24
+ export declare function SortableGroup({ ids, children, }: {
16
25
  ids: string[];
17
- /** Called with the reordered ids after a completed drag. */
18
- onReorder: (ids: string[]) => void;
26
+ children: React.ReactNode;
27
+ }): React.JSX.Element;
28
+ /** Drop target for a step's field list — including when the list is empty. */
29
+ export declare function SortableContainer({ stepId, children, }: {
30
+ stepId: string;
19
31
  children: React.ReactNode;
20
32
  }): React.JSX.Element;
21
33
  /** A sortable row: wraps the card, hands the grip props to its children. */
22
- export declare function SortableItem({ id, children, }: {
34
+ export declare function SortableItem({ id, data, children, }: {
23
35
  id: string;
36
+ data?: SortableData;
24
37
  children: (handle: HandleProps) => React.ReactNode;
25
38
  }): React.JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { arrayMove } from "@dnd-kit/sortable";
2
+ export { arrayMove };
3
+ /**
4
+ * Move `fieldId` onto `overId` across steps.
5
+ *
6
+ * `overId` is another field (insert at that index), a step id (append),
7
+ * or `container:${stepId}` (the empty-area droppable of that step).
8
+ */
9
+ export declare function relocateField<S extends {
10
+ id: string;
11
+ fields: F[];
12
+ }, F extends {
13
+ id: string;
14
+ }>(steps: S[], fieldId: string, overId: string): S[];
@@ -1778,6 +1778,65 @@ function convertHubspotForm(raw) {
1778
1778
  skipped
1779
1779
  };
1780
1780
  }
1781
+ function mappingKey(field) {
1782
+ const mapping = field.hubspot ?? {};
1783
+ const property = mapping.property?.trim();
1784
+ if (!property) return null;
1785
+ return `${mapping.object?.trim() || "contact"}:${property}`;
1786
+ }
1787
+ function uniqueName(base, used) {
1788
+ if (!used.has(base)) return base;
1789
+ let n = 2;
1790
+ while (used.has(`${base}_${n}`)) n += 1;
1791
+ return `${base}_${n}`;
1792
+ }
1793
+ function mergeHubspotImport(current, incoming) {
1794
+ const steps = (current.steps ?? []).map((step) => ({
1795
+ ...step,
1796
+ fields: [...step.fields ?? []]
1797
+ }));
1798
+ if (!steps.length) {
1799
+ steps.push({ id: makeId$1("stp"), fields: [] });
1800
+ }
1801
+ const byKey = /* @__PURE__ */ new Map();
1802
+ const usedNames = /* @__PURE__ */ new Set();
1803
+ for (const step of steps) {
1804
+ for (const field of step.fields) {
1805
+ usedNames.add(field.name);
1806
+ const key = mappingKey(field);
1807
+ if (key) byKey.set(key, field);
1808
+ }
1809
+ }
1810
+ const added = [];
1811
+ const updated = [];
1812
+ const incomingFields = (incoming.steps ?? []).flatMap((step) => step.fields ?? []);
1813
+ for (const next of incomingFields) {
1814
+ const key = mappingKey(next);
1815
+ const existing = key ? byKey.get(key) : void 0;
1816
+ if (existing) {
1817
+ existing.label = next.label;
1818
+ existing.type = next.type;
1819
+ existing.required = next.required;
1820
+ existing.placeholder = next.placeholder;
1821
+ existing.helpText = next.helpText;
1822
+ if (next.options) existing.options = next.options;
1823
+ else delete existing.options;
1824
+ if (!existing.visibleIf && next.visibleIf) existing.visibleIf = next.visibleIf;
1825
+ updated.push(key);
1826
+ continue;
1827
+ }
1828
+ const field = {
1829
+ ...next,
1830
+ id: makeId$1("fld"),
1831
+ name: uniqueName(next.name, usedNames)
1832
+ };
1833
+ usedNames.add(field.name);
1834
+ if (key) byKey.set(key, field);
1835
+ steps[steps.length - 1].fields.push(field);
1836
+ added.push(key || field.name);
1837
+ }
1838
+ return { definition: { version: 1, steps }, added, updated };
1839
+ }
1781
1840
  async function hsGet(apiKey, path) {
1782
1841
  const res = await fetch(`${HS_BASE$2}${path}`, {
1783
1842
  headers: { Authorization: `Bearer ${apiKey}` }
@@ -2895,9 +2954,9 @@ function createFormsAdminController(strapi) {
2895
2954
  },
2896
2955
  /**
2897
2956
  * Converts one portal form into a plugin form draft, in the current
2898
- * locale. Slug derived from the HubSpot name: re-importing the same form
2899
- * overwrites the draft — never the published version, never the portal.
2900
- * What the builder can't express is skipped and returned in `skipped`.
2957
+ * locale. Matching prefers the linked HubSpot GUID, then the slug.
2958
+ * Re-importing merges: mapped fields update in place, new ones append,
2959
+ * Strapi steps stay. Never the published version, never the portal.
2901
2960
  */
2902
2961
  async runHubspotImport(ctx) {
2903
2962
  const formId = ctx.request.body?.formId;
@@ -2916,24 +2975,81 @@ function createFormsAdminController(strapi) {
2916
2975
  const { skipped, definition, ...meta } = converted;
2917
2976
  const slug = slugify(name);
2918
2977
  const existing = await documents().findFirst({
2978
+ filters: { hubspotFormId: formId }
2979
+ }) ?? await documents().findFirst({
2919
2980
  filters: { slug }
2920
2981
  });
2921
- let documentId;
2922
2982
  if (existing) {
2923
- await documents().update({
2983
+ const current = await documents().findOne({
2924
2984
  documentId: existing.documentId,
2925
2985
  locale: ctx.query.locale,
2926
- data: { ...meta, name, definition }
2986
+ status: "draft"
2927
2987
  });
2928
- documentId = existing.documentId;
2929
- } else {
2930
- const created = await documents().create({
2988
+ const merged = mergeHubspotImport(current?.definition ?? EMPTY_DEFINITION, definition);
2989
+ await documents().update({
2990
+ documentId: existing.documentId,
2931
2991
  locale: ctx.query.locale,
2932
- data: { ...meta, name, slug, definition }
2992
+ data: {
2993
+ hubspotFormId: meta.hubspotFormId ?? formId,
2994
+ definition: merged.definition
2995
+ }
2933
2996
  });
2934
- documentId = created.documentId;
2997
+ ctx.body = {
2998
+ documentId: existing.documentId,
2999
+ skipped,
3000
+ added: merged.added,
3001
+ updated: merged.updated
3002
+ };
3003
+ return;
3004
+ }
3005
+ const created = await documents().create({
3006
+ locale: ctx.query.locale,
3007
+ data: { ...meta, name, slug, definition }
3008
+ });
3009
+ ctx.body = { documentId: created.documentId, skipped, added: [], updated: [] };
3010
+ },
3011
+ /**
3012
+ * Pull the linked HubSpot marketing form back into this draft: mapped
3013
+ * fields update in place, new ones append, Strapi steps and extras stay.
3014
+ */
3015
+ async resync(ctx) {
3016
+ const entry = await documents().findOne({
3017
+ documentId: ctx.params.documentId,
3018
+ locale: ctx.query.locale,
3019
+ status: "draft"
3020
+ });
3021
+ if (!entry) ctx.throw(404, "Form not found");
3022
+ const guid = typeof entry.hubspotFormId === "string" ? entry.hubspotFormId.trim() : "";
3023
+ if (!isFormGuid(guid)) {
3024
+ ctx.throw(400, "This form is not linked to a HubSpot marketing form.");
3025
+ }
3026
+ const { apiKey } = await resolveApiKey(strapi);
3027
+ if (!apiKey) ctx.throw(400, "No HubSpot API key configured");
3028
+ let converted;
3029
+ try {
3030
+ converted = convertHubspotForm(await fetchHubspotForm(apiKey, guid));
3031
+ } catch (err) {
3032
+ strapi.log.warn(`[hubspot] form resync failed — ${err.message}`);
3033
+ ctx.throw(502, err.message || "Cannot reach HubSpot");
3034
+ return;
2935
3035
  }
2936
- ctx.body = { documentId, skipped };
3036
+ const merged = mergeHubspotImport(entry.definition ?? EMPTY_DEFINITION, converted.definition);
3037
+ await documents().update({
3038
+ documentId: ctx.params.documentId,
3039
+ locale: ctx.query.locale,
3040
+ data: { definition: merged.definition }
3041
+ });
3042
+ const form = await documents().findOne({
3043
+ documentId: ctx.params.documentId,
3044
+ locale: ctx.query.locale,
3045
+ status: "draft"
3046
+ });
3047
+ ctx.body = {
3048
+ form,
3049
+ added: merged.added,
3050
+ updated: merged.updated,
3051
+ skipped: converted.skipped
3052
+ };
2937
3053
  },
2938
3054
  /**
2939
3055
  * Copies a form (every locale of its draft) into a new draft document —
@@ -3472,6 +3588,7 @@ const routes = {
3472
3588
  adminRoute("POST", "/builder/forms/:documentId/unpublish", "formsAdmin.unpublish", [FORMS_ACTION]),
3473
3589
  adminRoute("DELETE", "/builder/forms/:documentId", "formsAdmin.remove", [FORMS_ACTION]),
3474
3590
  adminRoute("POST", "/builder/forms/:documentId/duplicate", "formsAdmin.duplicate", [FORMS_ACTION]),
3591
+ adminRoute("POST", "/builder/forms/:documentId/resync", "formsAdmin.resync", [FORMS_ACTION]),
3475
3592
  // No FORMS_ACTION: editors pick forms from the Content Manager, like
3476
3593
  // they pick properties — the builder itself stays gated.
3477
3594
  adminRoute("GET", "/forms-options", "formsAdmin.options"),
@@ -1777,6 +1777,65 @@ function convertHubspotForm(raw) {
1777
1777
  skipped
1778
1778
  };
1779
1779
  }
1780
+ function mappingKey(field) {
1781
+ const mapping = field.hubspot ?? {};
1782
+ const property = mapping.property?.trim();
1783
+ if (!property) return null;
1784
+ return `${mapping.object?.trim() || "contact"}:${property}`;
1785
+ }
1786
+ function uniqueName(base, used) {
1787
+ if (!used.has(base)) return base;
1788
+ let n = 2;
1789
+ while (used.has(`${base}_${n}`)) n += 1;
1790
+ return `${base}_${n}`;
1791
+ }
1792
+ function mergeHubspotImport(current, incoming) {
1793
+ const steps = (current.steps ?? []).map((step) => ({
1794
+ ...step,
1795
+ fields: [...step.fields ?? []]
1796
+ }));
1797
+ if (!steps.length) {
1798
+ steps.push({ id: makeId$1("stp"), fields: [] });
1799
+ }
1800
+ const byKey = /* @__PURE__ */ new Map();
1801
+ const usedNames = /* @__PURE__ */ new Set();
1802
+ for (const step of steps) {
1803
+ for (const field of step.fields) {
1804
+ usedNames.add(field.name);
1805
+ const key = mappingKey(field);
1806
+ if (key) byKey.set(key, field);
1807
+ }
1808
+ }
1809
+ const added = [];
1810
+ const updated = [];
1811
+ const incomingFields = (incoming.steps ?? []).flatMap((step) => step.fields ?? []);
1812
+ for (const next of incomingFields) {
1813
+ const key = mappingKey(next);
1814
+ const existing = key ? byKey.get(key) : void 0;
1815
+ if (existing) {
1816
+ existing.label = next.label;
1817
+ existing.type = next.type;
1818
+ existing.required = next.required;
1819
+ existing.placeholder = next.placeholder;
1820
+ existing.helpText = next.helpText;
1821
+ if (next.options) existing.options = next.options;
1822
+ else delete existing.options;
1823
+ if (!existing.visibleIf && next.visibleIf) existing.visibleIf = next.visibleIf;
1824
+ updated.push(key);
1825
+ continue;
1826
+ }
1827
+ const field = {
1828
+ ...next,
1829
+ id: makeId$1("fld"),
1830
+ name: uniqueName(next.name, usedNames)
1831
+ };
1832
+ usedNames.add(field.name);
1833
+ if (key) byKey.set(key, field);
1834
+ steps[steps.length - 1].fields.push(field);
1835
+ added.push(key || field.name);
1836
+ }
1837
+ return { definition: { version: 1, steps }, added, updated };
1838
+ }
1780
1839
  async function hsGet(apiKey, path) {
1781
1840
  const res = await fetch(`${HS_BASE$2}${path}`, {
1782
1841
  headers: { Authorization: `Bearer ${apiKey}` }
@@ -2894,9 +2953,9 @@ function createFormsAdminController(strapi) {
2894
2953
  },
2895
2954
  /**
2896
2955
  * Converts one portal form into a plugin form draft, in the current
2897
- * locale. Slug derived from the HubSpot name: re-importing the same form
2898
- * overwrites the draft — never the published version, never the portal.
2899
- * What the builder can't express is skipped and returned in `skipped`.
2956
+ * locale. Matching prefers the linked HubSpot GUID, then the slug.
2957
+ * Re-importing merges: mapped fields update in place, new ones append,
2958
+ * Strapi steps stay. Never the published version, never the portal.
2900
2959
  */
2901
2960
  async runHubspotImport(ctx) {
2902
2961
  const formId = ctx.request.body?.formId;
@@ -2915,24 +2974,81 @@ function createFormsAdminController(strapi) {
2915
2974
  const { skipped, definition, ...meta } = converted;
2916
2975
  const slug = slugify(name);
2917
2976
  const existing = await documents().findFirst({
2977
+ filters: { hubspotFormId: formId }
2978
+ }) ?? await documents().findFirst({
2918
2979
  filters: { slug }
2919
2980
  });
2920
- let documentId;
2921
2981
  if (existing) {
2922
- await documents().update({
2982
+ const current = await documents().findOne({
2923
2983
  documentId: existing.documentId,
2924
2984
  locale: ctx.query.locale,
2925
- data: { ...meta, name, definition }
2985
+ status: "draft"
2926
2986
  });
2927
- documentId = existing.documentId;
2928
- } else {
2929
- const created = await documents().create({
2987
+ const merged = mergeHubspotImport(current?.definition ?? EMPTY_DEFINITION, definition);
2988
+ await documents().update({
2989
+ documentId: existing.documentId,
2930
2990
  locale: ctx.query.locale,
2931
- data: { ...meta, name, slug, definition }
2991
+ data: {
2992
+ hubspotFormId: meta.hubspotFormId ?? formId,
2993
+ definition: merged.definition
2994
+ }
2932
2995
  });
2933
- documentId = created.documentId;
2996
+ ctx.body = {
2997
+ documentId: existing.documentId,
2998
+ skipped,
2999
+ added: merged.added,
3000
+ updated: merged.updated
3001
+ };
3002
+ return;
3003
+ }
3004
+ const created = await documents().create({
3005
+ locale: ctx.query.locale,
3006
+ data: { ...meta, name, slug, definition }
3007
+ });
3008
+ ctx.body = { documentId: created.documentId, skipped, added: [], updated: [] };
3009
+ },
3010
+ /**
3011
+ * Pull the linked HubSpot marketing form back into this draft: mapped
3012
+ * fields update in place, new ones append, Strapi steps and extras stay.
3013
+ */
3014
+ async resync(ctx) {
3015
+ const entry = await documents().findOne({
3016
+ documentId: ctx.params.documentId,
3017
+ locale: ctx.query.locale,
3018
+ status: "draft"
3019
+ });
3020
+ if (!entry) ctx.throw(404, "Form not found");
3021
+ const guid = typeof entry.hubspotFormId === "string" ? entry.hubspotFormId.trim() : "";
3022
+ if (!isFormGuid(guid)) {
3023
+ ctx.throw(400, "This form is not linked to a HubSpot marketing form.");
3024
+ }
3025
+ const { apiKey } = await resolveApiKey(strapi);
3026
+ if (!apiKey) ctx.throw(400, "No HubSpot API key configured");
3027
+ let converted;
3028
+ try {
3029
+ converted = convertHubspotForm(await fetchHubspotForm(apiKey, guid));
3030
+ } catch (err) {
3031
+ strapi.log.warn(`[hubspot] form resync failed — ${err.message}`);
3032
+ ctx.throw(502, err.message || "Cannot reach HubSpot");
3033
+ return;
2934
3034
  }
2935
- ctx.body = { documentId, skipped };
3035
+ const merged = mergeHubspotImport(entry.definition ?? EMPTY_DEFINITION, converted.definition);
3036
+ await documents().update({
3037
+ documentId: ctx.params.documentId,
3038
+ locale: ctx.query.locale,
3039
+ data: { definition: merged.definition }
3040
+ });
3041
+ const form = await documents().findOne({
3042
+ documentId: ctx.params.documentId,
3043
+ locale: ctx.query.locale,
3044
+ status: "draft"
3045
+ });
3046
+ ctx.body = {
3047
+ form,
3048
+ added: merged.added,
3049
+ updated: merged.updated,
3050
+ skipped: converted.skipped
3051
+ };
2936
3052
  },
2937
3053
  /**
2938
3054
  * Copies a form (every locale of its draft) into a new draft document —
@@ -3471,6 +3587,7 @@ const routes = {
3471
3587
  adminRoute("POST", "/builder/forms/:documentId/unpublish", "formsAdmin.unpublish", [FORMS_ACTION]),
3472
3588
  adminRoute("DELETE", "/builder/forms/:documentId", "formsAdmin.remove", [FORMS_ACTION]),
3473
3589
  adminRoute("POST", "/builder/forms/:documentId/duplicate", "formsAdmin.duplicate", [FORMS_ACTION]),
3590
+ adminRoute("POST", "/builder/forms/:documentId/resync", "formsAdmin.resync", [FORMS_ACTION]),
3474
3591
  // No FORMS_ACTION: editors pick forms from the Content Manager, like
3475
3592
  // they pick properties — the builder itself stays gated.
3476
3593
  adminRoute("GET", "/forms-options", "formsAdmin.options"),