strapi-plugin-hubspot 0.2.1 → 0.4.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 (44) hide show
  1. package/README.md +145 -5
  2. package/dist/_chunks/HubspotObjectInput-DAq_W1rA.js +91 -0
  3. package/dist/_chunks/HubspotObjectInput-G-A_pL2j.mjs +73 -0
  4. package/dist/_chunks/HubspotPropertyInput-B-Jg0x68.js +186 -0
  5. package/dist/_chunks/HubspotPropertyInput-Cv77QiS6.mjs +168 -0
  6. package/dist/_chunks/Settings-58edRL5D.mjs +1293 -0
  7. package/dist/_chunks/Settings-CryiUgSe.js +1311 -0
  8. package/dist/_chunks/en-CnQbjHs-.js +81 -0
  9. package/dist/_chunks/en-DHQZuRsj.mjs +81 -0
  10. package/dist/_chunks/fr-Bv8zsNX5.js +81 -0
  11. package/dist/_chunks/fr-Dxgil7RP.mjs +81 -0
  12. package/dist/_chunks/index-BudHTwSw.mjs +137 -0
  13. package/dist/_chunks/index-Tlw1p93d.js +136 -0
  14. package/dist/_chunks/objectLabels-CJTKZ4vT.mjs +425 -0
  15. package/dist/_chunks/objectLabels-GRmYGCIn.js +442 -0
  16. package/dist/admin/index.js +1 -1
  17. package/dist/admin/index.mjs +1 -1
  18. package/dist/admin/src/components/AuditSection.d.ts +5 -0
  19. package/dist/admin/src/components/FailuresSection.d.ts +11 -0
  20. package/dist/admin/src/components/HubspotObjectInput.d.ts +28 -0
  21. package/dist/admin/src/components/HubspotPropertyInput.d.ts +1 -0
  22. package/dist/admin/src/getTranslation.d.ts +3 -0
  23. package/dist/admin/src/index.d.ts +6 -0
  24. package/dist/admin/src/objectLabels.d.ts +2 -1
  25. package/dist/server/index.js +445 -69
  26. package/dist/server/index.mjs +445 -69
  27. package/dist/server/src/__tests__/audit.test.d.ts +1 -0
  28. package/dist/server/src/__tests__/checkMapping.test.d.ts +1 -0
  29. package/dist/server/src/__tests__/loadSchema.test.d.ts +1 -0
  30. package/dist/server/src/__tests__/submit.test.d.ts +1 -0
  31. package/dist/server/src/__tests__/validation.test.d.ts +1 -0
  32. package/dist/server/src/audit.d.ts +60 -0
  33. package/dist/server/src/content-types.d.ts +56 -0
  34. package/dist/server/src/index.d.ts +104 -22
  35. package/dist/server/src/properties.d.ts +41 -5
  36. package/dist/server/src/submit.d.ts +35 -0
  37. package/dist/server/src/validation.d.ts +37 -0
  38. package/package.json +5 -2
  39. package/dist/_chunks/HubspotPropertyInput-BVFRtt49.js +0 -124
  40. package/dist/_chunks/HubspotPropertyInput-C7Wcw8ee.mjs +0 -106
  41. package/dist/_chunks/Settings-B5w7LAsH.mjs +0 -140
  42. package/dist/_chunks/Settings-TGB0MOHr.js +0 -158
  43. package/dist/_chunks/index-BpZtcsPM.mjs +0 -42
  44. package/dist/_chunks/index-DgsAHPui.js +0 -41
package/README.md CHANGED
@@ -44,8 +44,10 @@ Société · Nombre d'employés (numberofemployees)
44
44
  ```
45
45
 
46
46
  **The list narrows to the object you picked.** Point the field at the sibling
47
- that holds the object and choosing *Contact* leaves only contact properties
48
- the prefix disappears, since it is no longer telling you anything:
47
+ that holds the object from the Content-Type Builder, under *HubSpot → Object
48
+ field* and choosing *Contact* leaves only contact properties. The prefix
49
+ disappears, since it is no longer telling you anything. In `schema.json` that
50
+ setting reads:
49
51
 
50
52
  ```json
51
53
  {
@@ -57,11 +59,46 @@ the prefix disappears, since it is no longer telling you anything:
57
59
  }
58
60
  ```
59
61
 
62
+ Once filtered, the list is ordered and prefixed by the property's **HubSpot
63
+ group** — the portal's own way of organising hundreds of properties:
64
+
65
+ ```
66
+ Informations de contact · Prénom (firstname)
67
+ Informations de contact · Rôle (hs_role)
68
+ Historique · Source (hs_analytics_source)
69
+ ```
70
+
60
71
  Read-only properties — the ones HubSpot computes and always refuses to
61
72
  accept — are filtered out, so the list only ever offers things that will
62
73
  actually work. When the portal id is readable, each selected property gets a
63
74
  **Voir dans HubSpot** link straight to its settings page.
64
75
 
76
+ ### An object picker to feed it
77
+
78
+ The sibling object field doesn't have to be a hand-typed enum: the
79
+ `hubspot.object` custom field is a select over the portal's configured
80
+ objects. An object the token can't read stays selectable, flagged with its
81
+ missing scope.
82
+
83
+ ```json
84
+ {
85
+ "hsObject": {
86
+ "type": "customField",
87
+ "customField": "plugin::hubspot.object"
88
+ }
89
+ }
90
+ ```
91
+
92
+ ### One-click option import
93
+
94
+ For an **enumeration** property, the picker shows an *Import the N options
95
+ from HubSpot* button that fills the sibling repeatable (default `options`,
96
+ entries `{ value, label }` — the same shape the validation reads) with the
97
+ enumeration's real choices. The `bad-option` check detects a select that
98
+ drifted from the CRM; this is what fixes it. Point `optionsField` (in the
99
+ field's HubSpot options, next to `objectField`) at the repeatable if it isn't
100
+ called `options`.
101
+
65
102
  ### Validation on save
66
103
 
67
104
  Point the plugin at the content types that carry mappings and it walks each
@@ -69,7 +106,82 @@ entry before it is written, at any depth — steps, repeatable components, dynam
69
106
  zones. An invalid mapping is refused with a message that says which property and
70
107
  why:
71
108
 
72
- > Mapping HubSpot invalide« name » existe sur l'objet Société, pas sur Contact
109
+ > Invalid HubSpot mapping"name" exists on company, not on contact
110
+
111
+ It catches four things:
112
+
113
+ | Code | Case |
114
+ |---|---|
115
+ | `unknown` | The property doesn't exist in the portal |
116
+ | `wrong-object` | It exists, but on another object |
117
+ | `whitespace` | It exists, but the stored value has surrounding spaces |
118
+ | `bad-option` | The field offers a choice the enumeration doesn't accept |
119
+
120
+ That last one closes a real hole: HubSpot refuses a value outside an
121
+ enumeration exactly as hard as it refuses an unknown property, so a select whose
122
+ choices drifted from the CRM fails at send time with nothing to show for it.
123
+ Point `optionsField` at the repeatable holding the choices (default `options`,
124
+ each `{ value?, label? }`) and they are checked too.
125
+
126
+ Each problem is also carried as a structured code in the error's `details`, so a
127
+ host app can localize the message instead of parsing the sentence.
128
+
129
+ **`unknown` is the one code a legitimate workflow can produce** — a property
130
+ staged in HubSpot but not created yet. Set `strict: false` on a validate target
131
+ and those pass with a warning in the logs instead of blocking the save. The
132
+ other three codes always block, whatever `strict` says: no workflow produces a
133
+ wrong object, a trailing space, or a drifted option on purpose.
134
+
135
+ ### An audit of existing content
136
+
137
+ Save-time validation only protects entries as they are written. A property
138
+ deleted in HubSpot afterwards leaves invalid mappings dormant in content nobody
139
+ re-saves — until a submission silently fails. **Settings → HubSpot → Mapping
140
+ audit** walks every entry of the validated content types (drafts, every locale,
141
+ at any depth) against a freshly fetched schema, and lists each entry the portal
142
+ would reject today, linking straight to it in the Content Manager.
143
+
144
+ The audit reports `unknown` properties even on `strict: false` targets: strict
145
+ only decides whether a save is blocked, not whether the mapping would reach the
146
+ CRM.
147
+
148
+ ### A sending service
149
+
150
+ The host app doesn't have to talk to HubSpot itself — the plugin exposes the
151
+ upsert, with everything above applied on the way out:
152
+
153
+ ```ts
154
+ const result = await strapi.plugin("hubspot").service("submit").upsert({
155
+ object: "contact",
156
+ idProperty: "email", // find-or-create key
157
+ properties: {
158
+ email: "jane@acme.com",
159
+ firstname: "Jane",
160
+ hs_role: "dev;designer", // multi-select values use HubSpot's `;` separator
161
+ },
162
+ });
163
+ // { ok: true, id: "…" }
164
+ // { ok: false, problems: [{ code: "unknown", … }] } — refused before sending
165
+ // { ok: false, queued: true, error: "…" } — parked for replay
166
+ ```
167
+
168
+ The payload is validated against the portal schema **before** it is sent —
169
+ the same checks as on save, values coerced to strings, empty ones dropped.
170
+ Then:
171
+
172
+ - a **permanent refusal** (4xx) comes back as `{ ok: false, error }` with
173
+ HubSpot's message — retrying can't fix a wrong payload, so nothing is queued;
174
+ - a **transient failure** (429, 5xx, network) is retried twice with backoff,
175
+ then parked in **Content Manager → HubSpot failed submissions** and reported
176
+ as `{ ok: false, queued: true }`.
177
+
178
+ `service("submit").retryFailures()` replays the queue, oldest first — from a
179
+ cron in the host app, or the **Replay now** button in Settings → HubSpot. A
180
+ replay that succeeds removes the row; one that keeps failing stays, with its
181
+ error and attempt count, until it works or an admin deletes it.
182
+
183
+ Sending needs write scopes on the token: `crm.objects.contacts.write` (and
184
+ its equivalent per object you send to).
73
185
 
74
186
  ### A settings screen
75
187
 
@@ -78,6 +190,11 @@ never returned to the browser: the UI only receives whether a key exists, where
78
190
  it comes from, and its last four characters. A "Test connection" button
79
191
  round-trips to HubSpot and reports how many properties it can read.
80
192
 
193
+ Access is gated by a dedicated RBAC permission — **Settings →
194
+ Roles → Plugins → Hubspot**. A role without it neither sees the settings link
195
+ nor can call the settings routes; reading properties stays open to every
196
+ authenticated admin, since the picker needs it in the Content Manager.
197
+
81
198
  > **On storage:** the key is kept in Strapi's core store, which is **not
82
199
  > encrypted at rest** — it is readable by anyone with database access. It never
83
200
  > reaches the browser, but treat it like any other secret in your database. Use
@@ -111,6 +228,9 @@ export default ({ env }) => ({
111
228
  uid: "api::form.form",
112
229
  objectField: "hsObject", // holds "contact" | "company"
113
230
  propertyField: "hsProperty", // holds the property name
231
+ optionsField: "options", // optional — the field's choices
232
+ strict: true, // optional — false lets `unknown`
233
+ // properties through with a warning
114
234
  },
115
235
  ],
116
236
  },
@@ -180,7 +300,7 @@ This one never blocks work:
180
300
  |---|---|
181
301
  | No API key configured | The field falls back to a plain text input, with a note explaining why |
182
302
  | HubSpot unreachable | Saving proceeds; validation is skipped and a warning is logged |
183
- | Property staged in HubSpot but not created yet | The picker accepts a typed value (`creatable`) |
303
+ | Property staged in HubSpot but not created yet | The picker accepts a typed value (`creatable`); set `strict: false` on the validate target so the save passes too |
184
304
  | An object's scope is missing | That object is skipped; the others still work |
185
305
  | Portal id unreadable | Deep links are omitted; everything else is unaffected |
186
306
  | Plugin uninstalled | Values remain as strings — nothing to undo |
@@ -191,15 +311,35 @@ key drops the cache immediately.
191
311
 
192
312
  ## Admin API
193
313
 
194
- All routes require an authenticated admin.
314
+ All routes require an authenticated admin; the settings routes additionally
315
+ require the `plugin::hubspot.settings` RBAC permission.
195
316
 
196
317
  | Method | Path | Purpose |
197
318
  |---|---|---|
198
319
  | `GET` | `/hubspot/properties` | Writable properties, readable objects, unreachable ones and the portal id. `?refresh=1` bypasses the cache |
320
+ | `GET` | `/hubspot/audit` | Scans every entry of the validated content types and returns the invalid mappings, per entry |
321
+ | `GET` | `/hubspot/failures` | Number of parked submissions |
322
+ | `POST` | `/hubspot/failures/retry` | Replays the parked submissions and reports the outcome |
199
323
  | `GET` | `/hubspot/settings` | Whether a key exists, its source and hint — never the key |
200
324
  | `PUT` | `/hubspot/settings` | Save a key (`{ apiKey }`) |
201
325
  | `DELETE` | `/hubspot/settings` | Remove the stored key |
202
326
 
327
+ ## i18n
328
+
329
+ The admin UI ships in English and French, keyed on the Strapi admin locale.
330
+ Server-side error messages stay in English; the structured codes in the
331
+ error's `details` are the localization hook for host apps.
332
+
333
+ ## Tests
334
+
335
+ ```bash
336
+ npm test
337
+ ```
338
+
339
+ Vitest over the server logic: mapping checks, deep collection through dynamic
340
+ zones and repeatables, schema loading (cache, concurrent de-duplication,
341
+ missing scopes) and the strict/non-strict save middleware.
342
+
203
343
  ## Compatibility
204
344
 
205
345
  Strapi **v5**. Node **>= 18**.
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const jsxRuntime = require("react/jsx-runtime");
4
+ const React = require("react");
5
+ const objectLabels = require("./objectLabels-GRmYGCIn.js");
6
+ const designSystem = require("@strapi/design-system");
7
+ const admin = require("@strapi/strapi/admin");
8
+ const index = require("./index-Tlw1p93d.js");
9
+ function _interopNamespace(e) {
10
+ if (e && e.__esModule) return e;
11
+ const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
12
+ if (e) {
13
+ for (const k in e) {
14
+ if (k !== "default") {
15
+ const d = Object.getOwnPropertyDescriptor(e, k);
16
+ Object.defineProperty(n, k, d.get ? d : {
17
+ enumerable: true,
18
+ get: () => e[k]
19
+ });
20
+ }
21
+ }
22
+ }
23
+ n.default = e;
24
+ return Object.freeze(n);
25
+ }
26
+ const React__namespace = /* @__PURE__ */ _interopNamespace(React);
27
+ const HubspotObjectInput = React__namespace.forwardRef(
28
+ ({ name, value, onChange, disabled, error, required, label, hint, labelAction }, ref) => {
29
+ const intl = objectLabels.useIntl();
30
+ const { formatMessage } = intl;
31
+ const { get } = admin.useFetchClient();
32
+ const [schema, setSchema] = React__namespace.useState(null);
33
+ React__namespace.useEffect(() => {
34
+ let cancelled = false;
35
+ get(`/${index.PLUGIN_ID}/properties`).then(({ data }) => {
36
+ if (!cancelled) setSchema(data);
37
+ }).catch(() => {
38
+ if (!cancelled) setSchema({ configured: false, objects: [], unavailable: [] });
39
+ });
40
+ return () => {
41
+ cancelled = true;
42
+ };
43
+ }, [get]);
44
+ const emit = (next) => onChange({ target: { name, value: String(next ?? ""), type: "string" } });
45
+ const known = [
46
+ ...schema?.objects ?? [],
47
+ ...(schema?.unavailable ?? []).map((u) => u.object)
48
+ ];
49
+ const options = value && !known.includes(value) ? [value, ...known] : known;
50
+ return /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Field.Root, { name, error, hint, required, children: [
51
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Label, { action: labelAction, children: label }),
52
+ !schema ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { paddingTop: 2, paddingBottom: 2, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Loader, { small: true, children: formatMessage({
53
+ id: index.getTranslation("object.loading"),
54
+ defaultMessage: "Loading HubSpot objects…"
55
+ }) }) }) : schema.configured ? /* @__PURE__ */ jsxRuntime.jsx(
56
+ designSystem.SingleSelect,
57
+ {
58
+ ref,
59
+ value: value || "",
60
+ onChange: emit,
61
+ onClear: () => emit(""),
62
+ disabled,
63
+ placeholder: formatMessage({
64
+ id: index.getTranslation("object.placeholder"),
65
+ defaultMessage: "Choose an object…"
66
+ }),
67
+ children: options.map((object) => /* @__PURE__ */ jsxRuntime.jsx(designSystem.SingleSelectOption, { value: object, children: schema.unavailable.some((u) => u.object === object) ? formatMessage(
68
+ {
69
+ id: index.getTranslation("object.no-scope"),
70
+ defaultMessage: "{object} — missing scope on the token"
71
+ },
72
+ { object: objectLabels.objectLabel(intl, object) }
73
+ ) : objectLabels.objectLabel(intl, object) }, object))
74
+ }
75
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
76
+ designSystem.Field.Input,
77
+ {
78
+ ref,
79
+ value: value || "",
80
+ onChange: (e) => emit(e.target.value),
81
+ disabled,
82
+ placeholder: "contact"
83
+ }
84
+ ),
85
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Hint, {}),
86
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Error, {})
87
+ ] });
88
+ }
89
+ );
90
+ HubspotObjectInput.displayName = "HubspotObjectInput";
91
+ exports.default = HubspotObjectInput;
@@ -0,0 +1,73 @@
1
+ import { jsxs, jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { u as useIntl, o as objectLabel } from "./objectLabels-CJTKZ4vT.mjs";
4
+ import { Field, Flex, Loader, SingleSelect, SingleSelectOption } from "@strapi/design-system";
5
+ import { useFetchClient } from "@strapi/strapi/admin";
6
+ import { P as PLUGIN_ID, g as getTranslation } from "./index-BudHTwSw.mjs";
7
+ const HubspotObjectInput = React.forwardRef(
8
+ ({ name, value, onChange, disabled, error, required, label, hint, labelAction }, ref) => {
9
+ const intl = useIntl();
10
+ const { formatMessage } = intl;
11
+ const { get } = useFetchClient();
12
+ const [schema, setSchema] = React.useState(null);
13
+ React.useEffect(() => {
14
+ let cancelled = false;
15
+ get(`/${PLUGIN_ID}/properties`).then(({ data }) => {
16
+ if (!cancelled) setSchema(data);
17
+ }).catch(() => {
18
+ if (!cancelled) setSchema({ configured: false, objects: [], unavailable: [] });
19
+ });
20
+ return () => {
21
+ cancelled = true;
22
+ };
23
+ }, [get]);
24
+ const emit = (next) => onChange({ target: { name, value: String(next ?? ""), type: "string" } });
25
+ const known = [
26
+ ...schema?.objects ?? [],
27
+ ...(schema?.unavailable ?? []).map((u) => u.object)
28
+ ];
29
+ const options = value && !known.includes(value) ? [value, ...known] : known;
30
+ return /* @__PURE__ */ jsxs(Field.Root, { name, error, hint, required, children: [
31
+ /* @__PURE__ */ jsx(Field.Label, { action: labelAction, children: label }),
32
+ !schema ? /* @__PURE__ */ jsx(Flex, { paddingTop: 2, paddingBottom: 2, children: /* @__PURE__ */ jsx(Loader, { small: true, children: formatMessage({
33
+ id: getTranslation("object.loading"),
34
+ defaultMessage: "Loading HubSpot objects…"
35
+ }) }) }) : schema.configured ? /* @__PURE__ */ jsx(
36
+ SingleSelect,
37
+ {
38
+ ref,
39
+ value: value || "",
40
+ onChange: emit,
41
+ onClear: () => emit(""),
42
+ disabled,
43
+ placeholder: formatMessage({
44
+ id: getTranslation("object.placeholder"),
45
+ defaultMessage: "Choose an object…"
46
+ }),
47
+ children: options.map((object) => /* @__PURE__ */ jsx(SingleSelectOption, { value: object, children: schema.unavailable.some((u) => u.object === object) ? formatMessage(
48
+ {
49
+ id: getTranslation("object.no-scope"),
50
+ defaultMessage: "{object} — missing scope on the token"
51
+ },
52
+ { object: objectLabel(intl, object) }
53
+ ) : objectLabel(intl, object) }, object))
54
+ }
55
+ ) : /* @__PURE__ */ jsx(
56
+ Field.Input,
57
+ {
58
+ ref,
59
+ value: value || "",
60
+ onChange: (e) => emit(e.target.value),
61
+ disabled,
62
+ placeholder: "contact"
63
+ }
64
+ ),
65
+ /* @__PURE__ */ jsx(Field.Hint, {}),
66
+ /* @__PURE__ */ jsx(Field.Error, {})
67
+ ] });
68
+ }
69
+ );
70
+ HubspotObjectInput.displayName = "HubspotObjectInput";
71
+ export {
72
+ HubspotObjectInput as default
73
+ };
@@ -0,0 +1,186 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const jsxRuntime = require("react/jsx-runtime");
4
+ const React = require("react");
5
+ const objectLabels = require("./objectLabels-GRmYGCIn.js");
6
+ const designSystem = require("@strapi/design-system");
7
+ const admin = require("@strapi/strapi/admin");
8
+ const index = require("./index-Tlw1p93d.js");
9
+ function _interopNamespace(e) {
10
+ if (e && e.__esModule) return e;
11
+ const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
12
+ if (e) {
13
+ for (const k in e) {
14
+ if (k !== "default") {
15
+ const d = Object.getOwnPropertyDescriptor(e, k);
16
+ Object.defineProperty(n, k, d.get ? d : {
17
+ enumerable: true,
18
+ get: () => e[k]
19
+ });
20
+ }
21
+ }
22
+ }
23
+ n.default = e;
24
+ return Object.freeze(n);
25
+ }
26
+ const React__namespace = /* @__PURE__ */ _interopNamespace(React);
27
+ const HubspotPropertyInput = React__namespace.forwardRef(
28
+ ({ name, value, onChange, attribute, disabled, error, required, label, hint, labelAction }, ref) => {
29
+ const intl = objectLabels.useIntl();
30
+ const { formatMessage } = intl;
31
+ const { get } = admin.useFetchClient();
32
+ const [schema, setSchema] = React__namespace.useState(null);
33
+ const [loadError, setLoadError] = React__namespace.useState("");
34
+ const objectFieldName = attribute?.options?.objectField || "hsObject";
35
+ const siblingPath = React__namespace.useMemo(
36
+ () => name.replace(/[^.]+$/, objectFieldName),
37
+ [name, objectFieldName]
38
+ );
39
+ const siblingField = admin.useField(siblingPath);
40
+ const selectedObject = siblingField?.value;
41
+ const optionsFieldName = attribute?.options?.optionsField || "options";
42
+ const optionsPath = React__namespace.useMemo(
43
+ () => name.replace(/[^.]+$/, optionsFieldName),
44
+ [name, optionsFieldName]
45
+ );
46
+ const optionsField = admin.useField(optionsPath);
47
+ const [imported, setImported] = React__namespace.useState(false);
48
+ React__namespace.useEffect(() => {
49
+ let cancelled = false;
50
+ get(`/${index.PLUGIN_ID}/properties`).then(({ data }) => {
51
+ if (!cancelled) setSchema(data);
52
+ }).catch((err) => {
53
+ if (cancelled) return;
54
+ setSchema({ configured: false, properties: [], objects: [], unavailable: [] });
55
+ setLoadError(
56
+ err?.response?.data?.error?.message ?? formatMessage({
57
+ id: index.getTranslation("picker.load-error"),
58
+ defaultMessage: "HubSpot properties unavailable"
59
+ })
60
+ );
61
+ });
62
+ return () => {
63
+ cancelled = true;
64
+ };
65
+ }, [get]);
66
+ const emit = (next) => onChange({ target: { name, value: next ?? "", type: "string" } });
67
+ const options = React__namespace.useMemo(() => {
68
+ const all = schema?.properties ?? [];
69
+ const scoped = selectedObject ? all.filter((p) => p.object === selectedObject) : all;
70
+ const sorted = selectedObject ? [...scoped].sort(
71
+ (a, b) => (a.group ?? "").localeCompare(b.group ?? "") || a.label.localeCompare(b.label)
72
+ ) : scoped;
73
+ const shown = sorted.map((p) => ({
74
+ value: p.name,
75
+ label: selectedObject ? `${p.group ? `${p.group} · ` : ""}${p.label} (${p.name})` : `${objectLabels.objectLabel(intl, p.object)} · ${p.label} (${p.name})`
76
+ }));
77
+ if (value && !scoped.some((p) => p.name === value)) {
78
+ const elsewhere = all.find((p) => p.name === value);
79
+ shown.unshift({
80
+ value,
81
+ label: elsewhere ? formatMessage(
82
+ { id: index.getTranslation("picker.belongs-to"), defaultMessage: "{property} — belongs to {object}" },
83
+ { property: value, object: objectLabels.objectLabel(intl, elsewhere.object) }
84
+ ) : formatMessage(
85
+ { id: index.getTranslation("picker.unknown"), defaultMessage: "{property} — unknown to the portal" },
86
+ { property: value }
87
+ )
88
+ });
89
+ }
90
+ return shown;
91
+ }, [schema, selectedObject, value, intl, formatMessage]);
92
+ const describe = () => {
93
+ if (loadError) return loadError;
94
+ if (schema && !schema.configured) {
95
+ return formatMessage({
96
+ id: index.getTranslation("picker.not-configured"),
97
+ defaultMessage: "No HubSpot API key — free text. Set it in Settings → HubSpot."
98
+ });
99
+ }
100
+ if (selectedObject && schema?.unavailable.some((u) => u.object === selectedObject)) {
101
+ return formatMessage(
102
+ {
103
+ id: index.getTranslation("picker.object-unavailable"),
104
+ defaultMessage: "Properties of “{object}” unreadable — missing scope on the token."
105
+ },
106
+ { object: objectLabels.objectLabel(intl, selectedObject) }
107
+ );
108
+ }
109
+ return hint;
110
+ };
111
+ const crmLink = schema?.portalId && value && schema.properties.some((p) => p.name === value) ? `https://${schema.uiDomain || "app.hubspot.com"}/property-settings/${schema.portalId}/properties?search=${encodeURIComponent(value)}` : null;
112
+ const selectedProperty = value && schema ? schema.properties.find(
113
+ (p) => p.name === value && (!selectedObject || p.object === selectedObject)
114
+ ) : void 0;
115
+ const importable = selectedProperty?.type === "enumeration" && (selectedProperty.options?.length ?? 0) > 0;
116
+ React__namespace.useEffect(() => setImported(false), [value]);
117
+ const importOptions = () => {
118
+ if (!selectedProperty || !optionsField) return;
119
+ const rows = selectedProperty.options.map((o, i) => ({
120
+ __temp_key__: `a${i}`,
121
+ value: o.value,
122
+ label: o.label ?? o.value
123
+ }));
124
+ optionsField.onChange({
125
+ target: { name: optionsPath, value: rows, type: "json" }
126
+ });
127
+ setImported(true);
128
+ };
129
+ return /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Field.Root, { name, error, hint: describe(), required, children: [
130
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Label, { action: labelAction, children: label }),
131
+ !schema ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { paddingTop: 2, paddingBottom: 2, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Loader, { small: true, children: formatMessage({
132
+ id: index.getTranslation("picker.loading"),
133
+ defaultMessage: "Loading HubSpot properties…"
134
+ }) }) }) : schema.configured ? /* @__PURE__ */ jsxRuntime.jsx(
135
+ designSystem.Combobox,
136
+ {
137
+ ref,
138
+ value: value || "",
139
+ onChange: emit,
140
+ onClear: () => emit(""),
141
+ disabled,
142
+ placeholder: formatMessage({
143
+ id: index.getTranslation("picker.placeholder"),
144
+ defaultMessage: "Search a property…"
145
+ }),
146
+ creatable: true,
147
+ onCreateOption: emit,
148
+ children: options.map((o) => /* @__PURE__ */ jsxRuntime.jsx(designSystem.ComboboxOption, { value: o.value, children: o.label }, o.value))
149
+ }
150
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
151
+ designSystem.Field.Input,
152
+ {
153
+ ref,
154
+ value: value || "",
155
+ onChange: (e) => emit(e.target.value),
156
+ disabled,
157
+ placeholder: formatMessage({
158
+ id: index.getTranslation("picker.free-placeholder"),
159
+ defaultMessage: "e.g. hs_role, numberofemployees"
160
+ })
161
+ }
162
+ ),
163
+ crmLink || importable ? /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { paddingTop: 1, gap: 3, alignItems: "center", wrap: "wrap", children: [
164
+ importable ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.Button, { variant: "tertiary", size: "S", onClick: importOptions, disabled, children: formatMessage(
165
+ {
166
+ id: index.getTranslation("picker.import"),
167
+ defaultMessage: "Import the {count} options from HubSpot"
168
+ },
169
+ { count: selectedProperty?.options.length ?? 0 }
170
+ ) }) : null,
171
+ imported ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "success600", children: formatMessage({
172
+ id: index.getTranslation("picker.imported"),
173
+ defaultMessage: "Options imported — save to keep them."
174
+ }) }) : null,
175
+ crmLink ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.Link, { href: crmLink, isExternal: true, children: formatMessage({
176
+ id: index.getTranslation("picker.view"),
177
+ defaultMessage: "View in HubSpot"
178
+ }) }) : null
179
+ ] }) : null,
180
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Hint, {}),
181
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Error, {})
182
+ ] });
183
+ }
184
+ );
185
+ HubspotPropertyInput.displayName = "HubspotPropertyInput";
186
+ exports.default = HubspotPropertyInput;