@growth-operations/ui-shared 1.6.15 → 1.6.16

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.6.15",
3
+ "version": "1.6.16",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -2,28 +2,38 @@
2
2
  //
3
3
  // The one sanctioned path to a sandbox install (a customer can't self-install on
4
4
  // a sandbox from the marketplace — the backend rejects that). From a prod
5
- // portal, this mints a signed sandbox install link (carrying sandbox=true + the
6
- // prod customer to link) THE MOMENT THE COMPONENT MOUNTS, so the link is a
7
- // single, ordinary click — open it on the sandbox account you want to install
8
- // on, no separate "generate" step first. HubSpot's Link/Button href must be a
9
- // static value at render time (can't be set from an async result in the same
10
- // click), so minting on mount rather than on click is what makes one-click
11
- // possible at all.
5
+ // portal, this first GETs the account's existing linked sandboxes + limit, then
6
+ // — only when still under the limit — mints a signed sandbox install link
7
+ // (carrying sandbox=true + the prod customer to link) THE MOMENT THE COMPONENT
8
+ // MOUNTS, so the link is a single, ordinary click — open it on the sandbox
9
+ // account you want to install on, no separate "generate" step first. HubSpot's
10
+ // Link/Button href must be a static value at render time (can't be set from an
11
+ // async result in the same click), so minting on mount rather than on click is
12
+ // what makes one-click possible at all.
13
+ //
14
+ // Always shows what's already linked (previously this rendered the mint action
15
+ // unconditionally with no signal a sandbox already existed, and a customer
16
+ // could keep minting more with zero visibility). At the limit, shows the
17
+ // linked list + a "X of Y used" indicator with no mint action underneath —
18
+ // not a disabled/dead button, just nothing to click.
12
19
  //
13
20
  // Extensible per app: the caller passes the mint `path` on its OWN backend
14
21
  // (base-hosted apps -> /v1/hubspot/app_pages/... no; the install mint lives at
15
22
  // /v2/hubspot/install/{app}/sandbox-link on base; self-hosted apps serve their
16
- // own), plus the portalId. callAppApi targets context.variables.BASE_URL, so
17
- // each app's BASE_URL routes to the right service. Apps not allowlisted get a
18
- // 403 from the endpoint, surfaced as a calm message.
23
+ // own), plus the portalId. The SAME path is used for both the GET (status) and
24
+ // POST (mint) — the backend distinguishes by HTTP method. callAppApi targets
25
+ // context.variables.BASE_URL, so each app's BASE_URL routes to the right
26
+ // service. Apps not allowlisted get a 403 from the endpoint, surfaced as a
27
+ // calm message.
19
28
  import React, { useEffect, useState } from "react";
20
- import { Flex, Heading, Text, Link, Alert } from "@hubspot/ui-extensions";
29
+ import { Flex, Heading, Text, Link, Alert, StatusTag } from "@hubspot/ui-extensions";
21
30
 
22
31
  import { callAppApi, AppApiError } from "../sdk/app/base";
23
32
 
24
- // context: extension context. path: the mint endpoint on the app's backend
25
- // (e.g. `/v2/hubspot/install/${appKey}/sandbox-link`). portalId: the prod portal
26
- // minting the link. appName: copy. heading/description: optional overrides.
33
+ // context: extension context. path: the status+mint endpoint on the app's
34
+ // backend (e.g. `/v2/hubspot/install/${appKey}/sandbox-link`). portalId: the
35
+ // prod portal minting the link. appName: copy. heading/description: optional
36
+ // overrides.
27
37
  export function SandboxInstall({
28
38
  context,
29
39
  path,
@@ -32,6 +42,8 @@ export function SandboxInstall({
32
42
  heading = "Install on a sandbox",
33
43
  description,
34
44
  }) {
45
+ const [linkedSandboxes, setLinkedSandboxes] = useState([]);
46
+ const [limit, setLimit] = useState(1);
35
47
  const [installUrl, setInstallUrl] = useState(null);
36
48
  const [loading, setLoading] = useState(true);
37
49
  const [error, setError] = useState(null);
@@ -42,8 +54,20 @@ export function SandboxInstall({
42
54
  try {
43
55
  const pid = portalId ?? context.portal.id;
44
56
  const sep = path.includes("?") ? "&" : "?";
45
- const res = await callAppApi(context, `${path}${sep}portalId=${pid}`, "POST");
46
- if (!cancelled) setInstallUrl(res?.install_url ?? null);
57
+ const qs = `${path}${sep}portalId=${pid}`;
58
+ const status = await callAppApi(context, qs, "GET");
59
+ if (cancelled) return;
60
+ const linked = status?.linked_sandboxes ?? [];
61
+ const cap = status?.limit ?? 1;
62
+ setLinkedSandboxes(linked);
63
+ setLimit(cap);
64
+ // Only mint (and offer) a NEW link while still under the limit — at
65
+ // the limit, the linked list above is the whole story; there's
66
+ // nothing else to click.
67
+ if (linked.length < cap) {
68
+ const res = await callAppApi(context, qs, "POST");
69
+ if (!cancelled) setInstallUrl(res?.install_url ?? null);
70
+ }
47
71
  } catch (err) {
48
72
  // The backend 403s apps that don't allow sandbox installs — show its
49
73
  // message rather than a generic failure.
@@ -60,9 +84,21 @@ export function SandboxInstall({
60
84
  // eslint-disable-next-line react-hooks/exhaustive-deps
61
85
  }, [path, portalId]);
62
86
 
87
+ const atLimit = linkedSandboxes.length >= limit;
88
+
63
89
  return (
64
90
  <Flex direction="column" gap="small">
65
- <Heading>{heading}</Heading>
91
+ <Flex direction="row" gap="small" align="center">
92
+ <Heading>{heading}</Heading>
93
+ {!loading && !error && (
94
+ // At the limit is the NORMAL end state for the common case (limit
95
+ // defaults to 1) — reaching it isn't a problem, so this reads
96
+ // success/neutral either way, never a warning color.
97
+ <StatusTag variant="success">
98
+ {linkedSandboxes.length} of {limit} used
99
+ </StatusTag>
100
+ )}
101
+ </Flex>
66
102
  <Text>
67
103
  {description ??
68
104
  `Spin up ${appName} on a HubSpot sandbox account, linked to your billing. ` +
@@ -75,6 +111,17 @@ export function SandboxInstall({
75
111
  </Alert>
76
112
  )}
77
113
 
114
+ {linkedSandboxes.length > 0 && (
115
+ <Flex direction="column" gap="extra-small">
116
+ <Text format={{ fontWeight: "bold" }}>Already linked</Text>
117
+ {linkedSandboxes.map((s) => (
118
+ <Text key={s.portal_id}>
119
+ {s.name || s.portal_id} — {s.status}
120
+ </Text>
121
+ ))}
122
+ </Flex>
123
+ )}
124
+
78
125
  {installUrl && (
79
126
  <Flex direction="column" gap="extra-small">
80
127
  <Link href={{ url: installUrl, external: true }}>
@@ -87,7 +134,13 @@ export function SandboxInstall({
87
134
  </Flex>
88
135
  )}
89
136
 
90
- {loading && !error && <Text variant="microcopy">Preparing your sandbox install link…</Text>}
137
+ {!loading && !error && atLimit && linkedSandboxes.length > 0 && (
138
+ <Text variant="microcopy">
139
+ Need another sandbox linked? Contact us.
140
+ </Text>
141
+ )}
142
+
143
+ {loading && !error && <Text variant="microcopy">Checking your sandbox installs…</Text>}
91
144
  </Flex>
92
145
  );
93
146
  }