@growth-operations/ui-shared 1.33.0 → 1.35.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-operations/ui-shared",
3
- "version": "1.33.0",
3
+ "version": "1.35.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -1,4 +1,4 @@
1
- import React, { useState } from "react";
1
+ import React, { useEffect, useReducer, useState } from "react";
2
2
  import {
3
3
  Alert,
4
4
  Box,
@@ -6,10 +6,12 @@ import {
6
6
  Link,
7
7
  LoadingSpinner,
8
8
  Text,
9
+ logger,
9
10
  } from "@hubspot/ui-extensions";
10
11
 
11
12
  import { useStrictModeEffect } from "../lib/useStrictModeEffect";
12
13
  import { callAppApi } from "../sdk/app/base";
14
+ import { contextReadiness } from "../context/readiness";
13
15
 
14
16
  // Full-surface "no active subscription" gate for CRM cards (and any other
15
17
  // extension surface that should hard-block a canceled install).
@@ -38,15 +40,31 @@ import { callAppApi } from "../sdk/app/base";
38
40
  // the card's existing error handling, since the gated endpoints 402 with the
39
41
  // same { detail, code: "subscription_canceled", billing_url } body.
40
42
  //
43
+ // Children also wait for the extension IDENTIFIER context to hydrate
44
+ // (2026.03 runtime race — see context/readiness.js): at first render
45
+ // context.extension.appId is the placeholder string "Identifier context is
46
+ // not found", so CRM hooks subscribing immediately send garbage property
47
+ // names and can wedge in isLoading forever when the corrected re-subscription's
48
+ // delivery is dropped (the "spinner until the user switches browser tabs"
49
+ // freeze). The entitlement check above doesn't need the identifier context
50
+ // (portal.id only), so it starts immediately and runs in parallel; only the
51
+ // children render is held, polled at 4Hz (the hydration re-render trigger is
52
+ // platform-internal), with a bounded 8s fallback that renders children anyway
53
+ // rather than spinning forever if hydration never comes.
54
+ //
41
55
  // Props:
42
56
  // context — the hubspot.extend context (portal.id, app.id, variables).
43
57
  // checkPath — backend entitlement-check path; default "/v1/entitlement".
44
58
  // appName — display name for the copy, e.g. "Applicant Tracking System".
59
+ // requiredVariables — context.variables keys the child tree's hooks depend
60
+ // on (e.g. ATS's ["APPLICATION_OBJECT_TYPE_ID"]); the context
61
+ // gate waits for these too.
45
62
  export function SubscriptionGate({
46
63
  context,
47
64
  children,
48
65
  checkPath = "/v1/entitlement",
49
66
  appName = "this app",
67
+ requiredVariables = [],
50
68
  }) {
51
69
  // null = still loading; { entitled, status, billing_url } once resolved.
52
70
  const [check, setCheck] = useState(null);
@@ -67,6 +85,32 @@ export function SubscriptionGate({
67
85
  [context, checkPath]
68
86
  );
69
87
 
88
+ const { ready: contextReady, missing: contextMissing } = contextReadiness(
89
+ context,
90
+ requiredVariables
91
+ );
92
+ const contextMissingKey = contextMissing.join(", ");
93
+ const [contextTimedOut, setContextTimedOut] = useState(false);
94
+ const [, forcePoll] = useReducer((x) => x + 1, 0);
95
+
96
+ useEffect(() => {
97
+ if (contextReady || contextTimedOut) return;
98
+ const poll = setInterval(forcePoll, 250);
99
+ const timeout = setTimeout(() => {
100
+ logger.error(
101
+ `[SubscriptionGate] Extension context never hydrated (missing: ${contextMissingKey}) — rendering children anyway`
102
+ );
103
+ setContextTimedOut(true);
104
+ }, 8000);
105
+ return () => {
106
+ clearInterval(poll);
107
+ clearTimeout(timeout);
108
+ };
109
+ }, [contextReady, contextTimedOut, contextMissingKey]);
110
+
111
+ if (!contextReady && !contextTimedOut) {
112
+ return <LoadingSpinner label="Loading…" showLabel />;
113
+ }
70
114
  if (failedOpen) return children;
71
115
  if (!check) {
72
116
  return <LoadingSpinner label="Checking subscription…" showLabel />;
@@ -0,0 +1,33 @@
1
+ // Extension identifier-context readiness (pure helpers).
2
+ //
3
+ // Platform race (HubSpot 2026.03 runtime, verified live 2026-10-22 on the ATS
4
+ // demo portal): context.extension.appId is NOT usable at first render — it
5
+ // evaluates to the placeholder string "Identifier context is not found", so
6
+ // every `a${context.extension.appId}_*` property name composed during that
7
+ // window is garbage until the identifier context hydrates a beat later. CRM
8
+ // hooks (useCrmProperties / useAssociations) that subscribe in that window
9
+ // send a first resolveData wave with garbage names, then re-subscribe with
10
+ // correct ones; when the runtime drops that second delivery into the iframe,
11
+ // the hook wedges in isLoading forever — the card "loads instantly" only
12
+ // after the user switches browser tabs (HubSpot re-resolves the record on
13
+ // window focus). SubscriptionGate uses these to hold children until hooks
14
+ // can subscribe with correct names on the FIRST try.
15
+
16
+ export const isUsableAppId = (appId) =>
17
+ appId != null && /^\d+$/.test(String(appId));
18
+
19
+ // What (if anything) is still missing before hooks may subscribe.
20
+ // requiredVariables names context.variables keys the tree's hooks depend on
21
+ // (e.g. ATS's APPLICATION_OBJECT_TYPE_ID).
22
+ export const contextReadiness = (context, requiredVariables = []) => {
23
+ const missing = [];
24
+ if (!isUsableAppId(context?.extension?.appId)) {
25
+ missing.push("extension.appId");
26
+ }
27
+ for (const name of requiredVariables) {
28
+ if (!context?.variables?.[name]) {
29
+ missing.push(`variables.${name}`);
30
+ }
31
+ }
32
+ return { ready: missing.length === 0, missing };
33
+ };
package/src/index.js CHANGED
@@ -43,6 +43,17 @@ export {
43
43
  // Sandbox — the shared "Install on a sandbox" action (mints a signed link).
44
44
  export { SandboxInstall } from "./sandbox/SandboxInstall";
45
45
 
46
+ // Platform — extension identifier-context readiness (2026.03 runtime:
47
+ // context.extension.appId is a placeholder string at first render; hooks that
48
+ // subscribe in that window can wedge isLoading forever). SubscriptionGate
49
+ // holds children on this internally; the pure helpers are exported for
50
+ // surfaces that gate without SubscriptionGate. See src/context/readiness.js.
51
+ export { contextReadiness, isUsableAppId } from "./context/readiness";
52
+
53
+ // Support — the shared in-app support request form (posts to the app's
54
+ // support route; see common.support).
55
+ export { SupportTab } from "./support/SupportTab";
56
+
46
57
  // Forms — shared HubSpot-options dropdown (forms/lists/pipeline-stages picker).
47
58
  export { OptionSelect } from "./forms/OptionSelect";
48
59
 
@@ -0,0 +1,198 @@
1
+ // SupportTab — the shared in-app support request form.
2
+ //
3
+ // Renders a native Form mirroring the "Growth Ops Apps - Support Form" on the
4
+ // website (First name*, Last name*, Email*, "What can we help you with
5
+ // today?"*, optional screenshot) and POSTs it to the app's own support route
6
+ // (common.support), which forwards it into the real form via the Forms API.
7
+ // Name/email prefill from the signed-in HubSpot user and stay editable.
8
+ //
9
+ // Props:
10
+ // context — the HubSpot extension context (user prefill, portal.id,
11
+ // variables.BASE_URL).
12
+ // supportPath — the app's support route path, e.g.
13
+ // "/v1/hubspot/app_pages/<app_key>/support" (base-hosted) or
14
+ // "/api/v1/support" (self-hosted).
15
+ // appName — display name, used in headings/success copy.
16
+ // supportUrl — external support link shown as a fallback on error.
17
+ import React, { useState } from "react";
18
+ import {
19
+ Alert,
20
+ Button,
21
+ Divider,
22
+ Flex,
23
+ Form,
24
+ Heading,
25
+ Input,
26
+ Link,
27
+ Text,
28
+ TextArea,
29
+ } from "@hubspot/ui-extensions";
30
+ import { FileInput } from "@hubspot/ui-extensions/experimental";
31
+
32
+ import { callAppApi } from "../sdk/app/base";
33
+ import {
34
+ buildSupportBody,
35
+ readFileAsBase64,
36
+ unwrapFileEvent,
37
+ validateSupportFields,
38
+ } from "./supportLogic";
39
+
40
+ export function SupportTab({ context, supportPath, appName = "the app", supportUrl }) {
41
+ const [fields, setFields] = useState({
42
+ firstname: context.user?.firstName ?? "",
43
+ lastname: context.user?.lastName ?? "",
44
+ email: context.user?.email ?? "",
45
+ content: "",
46
+ });
47
+ const [file, setFile] = useState(null); // { name, contentBase64 }
48
+ const [submitting, setSubmitting] = useState(false);
49
+ const [submitted, setSubmitted] = useState(false);
50
+ const [error, setError] = useState(null);
51
+
52
+ const set = (key) => (value) =>
53
+ setFields((prev) => ({ ...prev, [key]: value }));
54
+
55
+ const handleFileChange = async (event) => {
56
+ const picked = unwrapFileEvent(event);
57
+ if (!picked) return;
58
+ try {
59
+ const contentBase64 = await readFileAsBase64(picked);
60
+ setFile({ name: picked.name, contentBase64 });
61
+ } catch {
62
+ setError("Couldn't read that file — try a different one.");
63
+ }
64
+ };
65
+
66
+ const handleSubmit = async () => {
67
+ const validationError = validateSupportFields(fields);
68
+ if (validationError) {
69
+ setError(validationError);
70
+ return;
71
+ }
72
+ setSubmitting(true);
73
+ setError(null);
74
+ try {
75
+ await callAppApi(
76
+ context,
77
+ `${supportPath}?portalId=${context.portal.id}`,
78
+ "POST",
79
+ buildSupportBody(fields, file)
80
+ );
81
+ setSubmitted(true);
82
+ } catch (err) {
83
+ setError(
84
+ err?.message ||
85
+ "Something went wrong sending your request. Please try again."
86
+ );
87
+ } finally {
88
+ setSubmitting(false);
89
+ }
90
+ };
91
+
92
+ if (submitted) {
93
+ return (
94
+ <Flex direction="column" gap="medium">
95
+ <Alert title="Request received" variant="success">
96
+ <Text>
97
+ Thanks — we've received your request and will be in touch soon
98
+ (typical response time is under 1–2 hours). You can reply to the
99
+ confirmation email with anything else.
100
+ </Text>
101
+ </Alert>
102
+ <Button
103
+ onClick={() => {
104
+ setSubmitted(false);
105
+ setFields((prev) => ({ ...prev, content: "" }));
106
+ setFile(null);
107
+ }}
108
+ >
109
+ Submit another request
110
+ </Button>
111
+ </Flex>
112
+ );
113
+ }
114
+
115
+ return (
116
+ <Flex direction="column" gap="medium">
117
+ <Flex direction="column" gap="small">
118
+ <Heading>Contact {appName} support</Heading>
119
+ <Text>
120
+ Send us a message and we'll get back to you. For urgent issues you
121
+ can also{" "}
122
+ {supportUrl ? (
123
+ <Link href={{ url: supportUrl, external: true }}>
124
+ use our contact page
125
+ </Link>
126
+ ) : (
127
+ "use our contact page"
128
+ )}
129
+ .
130
+ </Text>
131
+ </Flex>
132
+ <Divider />
133
+ {error && (
134
+ <Alert title="Couldn't send your request" variant="error">
135
+ <Text>{error}</Text>
136
+ {supportUrl ? (
137
+ <Text>
138
+ You can also reach us via{" "}
139
+ <Link href={{ url: supportUrl, external: true }}>
140
+ our contact page
141
+ </Link>
142
+ .
143
+ </Text>
144
+ ) : null}
145
+ </Alert>
146
+ )}
147
+ <Form onSubmit={handleSubmit}>
148
+ <Flex direction="column" gap="medium">
149
+ <Input
150
+ label="First name"
151
+ name="firstname"
152
+ required={true}
153
+ value={fields.firstname}
154
+ onChange={set("firstname")}
155
+ />
156
+ <Input
157
+ label="Last name"
158
+ name="lastname"
159
+ required={true}
160
+ value={fields.lastname}
161
+ onChange={set("lastname")}
162
+ />
163
+ <Input
164
+ label="Email"
165
+ name="email"
166
+ required={true}
167
+ value={fields.email}
168
+ onChange={set("email")}
169
+ />
170
+ <TextArea
171
+ label="What can we help you with today?"
172
+ name="content"
173
+ required={true}
174
+ value={fields.content}
175
+ onChange={set("content")}
176
+ />
177
+ <Flex direction="column" gap="extra-small">
178
+ <Text format={{ fontWeight: "bold" }}>Screenshot (optional)</Text>
179
+ <FileInput name="file" value={null} onChange={handleFileChange} />
180
+ {file && (
181
+ <Flex direction="row" gap="small" align="center">
182
+ <Text>Attached: {file.name}</Text>
183
+ <Button variant="secondary" size="xs" onClick={() => setFile(null)}>
184
+ Remove
185
+ </Button>
186
+ </Flex>
187
+ )}
188
+ </Flex>
189
+ <Button variant="primary" type="submit" disabled={submitting}>
190
+ {submitting ? "Sending…" : "Send"}
191
+ </Button>
192
+ </Flex>
193
+ </Form>
194
+ </Flex>
195
+ );
196
+ }
197
+
198
+ export default SupportTab;
@@ -0,0 +1,50 @@
1
+ // supportLogic — pure helpers for SupportTab, kept separate so the validation
2
+ // and payload shaping are unit-testable without rendering (see
3
+ // tests/supportTab.test.js).
4
+
5
+ // Mirrors common.support's contract: contact fields + the ticket `content`,
6
+ // optional file as base64 (hubspot.fetch carries JSON only, no multipart).
7
+ export function validateSupportFields({ firstname, lastname, email, content }) {
8
+ if (!firstname?.trim()) return "First name is required.";
9
+ if (!lastname?.trim()) return "Last name is required.";
10
+ const e = email?.trim() ?? "";
11
+ if (!e || !e.includes("@") || e.startsWith("@") || e.endsWith("@")) {
12
+ return "A valid email is required.";
13
+ }
14
+ if (!content?.trim()) return "Tell us what we can help with.";
15
+ return null;
16
+ }
17
+
18
+ // Body for POST {supportPath}?portalId=N — plain object (callAppApi
19
+ // serializes; NEVER pre-stringify for hubspot.fetch).
20
+ export function buildSupportBody({ firstname, lastname, email, content }, file) {
21
+ const body = {
22
+ firstname: firstname.trim(),
23
+ lastname: lastname.trim(),
24
+ email: email.trim(),
25
+ content: content.trim(),
26
+ };
27
+ if (file) {
28
+ body.file = { filename: file.name, contentBase64: file.contentBase64 };
29
+ }
30
+ return body;
31
+ }
32
+
33
+ // FileInput (@hubspot/ui-extensions/experimental) has an ambiguous event
34
+ // shape per HubSpot's own types (`any`) — defensively unwrap to a real File.
35
+ // Same empirically-verified pattern as ATS AddCandidate's resume picker.
36
+ export function unwrapFileEvent(event) {
37
+ const file = event?.target?.files?.[0] || event;
38
+ return file instanceof File ? file : null;
39
+ }
40
+
41
+ // FileReader → raw base64 (strips the "data:<mime>;base64," prefix).
42
+ export function readFileAsBase64(file) {
43
+ return new Promise((resolve, reject) => {
44
+ const reader = new FileReader();
45
+ reader.onload = () => resolve(String(reader.result).split(",")[1] || "");
46
+ reader.onerror = () =>
47
+ reject(reader.error || new Error("Failed to read file"));
48
+ reader.readAsDataURL(file);
49
+ });
50
+ }