@growth-operations/ui-shared 1.6.15 → 1.6.17
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 +1 -1
- package/src/sandbox/SandboxInstall.jsx +160 -20
package/package.json
CHANGED
|
@@ -2,28 +2,52 @@
|
|
|
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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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. Each linked sandbox is a
|
|
19
|
+
// card showing who connected it and when, a link to its own app-pages console,
|
|
20
|
+
// and an uninstall action (armed/confirm — same two-step pattern as the
|
|
21
|
+
// migration console's rollback).
|
|
12
22
|
//
|
|
13
23
|
// Extensible per app: the caller passes the mint `path` on its OWN backend
|
|
14
24
|
// (base-hosted apps -> /v1/hubspot/app_pages/... no; the install mint lives at
|
|
15
25
|
// /v2/hubspot/install/{app}/sandbox-link on base; self-hosted apps serve their
|
|
16
|
-
// own), plus the portalId.
|
|
17
|
-
//
|
|
18
|
-
//
|
|
26
|
+
// own), plus the portalId. The SAME path is used for both the GET (status) and
|
|
27
|
+
// POST (mint) — the backend distinguishes by HTTP method. The uninstall action
|
|
28
|
+
// posts to `${path}/{sandboxPortalId}/uninstall`. callAppApi targets
|
|
29
|
+
// context.variables.BASE_URL, so each app's BASE_URL routes to the right
|
|
30
|
+
// service. Apps not allowlisted get a 403 from the endpoint, surfaced as a
|
|
31
|
+
// calm message.
|
|
19
32
|
import React, { useEffect, useState } from "react";
|
|
20
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
Flex,
|
|
35
|
+
Tile,
|
|
36
|
+
Heading,
|
|
37
|
+
Text,
|
|
38
|
+
Link,
|
|
39
|
+
Alert,
|
|
40
|
+
Button,
|
|
41
|
+
StatusTag,
|
|
42
|
+
} from "@hubspot/ui-extensions";
|
|
21
43
|
|
|
22
44
|
import { callAppApi, AppApiError } from "../sdk/app/base";
|
|
45
|
+
import { fmtDateTime } from "../lib/format";
|
|
23
46
|
|
|
24
|
-
// context: extension context. path: the mint endpoint on the app's
|
|
25
|
-
// (e.g. `/v2/hubspot/install/${appKey}/sandbox-link`). portalId: the
|
|
26
|
-
// minting the link. appName: copy. heading/description: optional
|
|
47
|
+
// context: extension context. path: the status+mint endpoint on the app's
|
|
48
|
+
// backend (e.g. `/v2/hubspot/install/${appKey}/sandbox-link`). portalId: the
|
|
49
|
+
// prod portal minting the link. appName: copy. heading/description: optional
|
|
50
|
+
// overrides.
|
|
27
51
|
export function SandboxInstall({
|
|
28
52
|
context,
|
|
29
53
|
path,
|
|
@@ -32,18 +56,36 @@ export function SandboxInstall({
|
|
|
32
56
|
heading = "Install on a sandbox",
|
|
33
57
|
description,
|
|
34
58
|
}) {
|
|
59
|
+
const [linkedSandboxes, setLinkedSandboxes] = useState([]);
|
|
60
|
+
const [limit, setLimit] = useState(1);
|
|
35
61
|
const [installUrl, setInstallUrl] = useState(null);
|
|
36
62
|
const [loading, setLoading] = useState(true);
|
|
37
63
|
const [error, setError] = useState(null);
|
|
64
|
+
const [armedUninstall, setArmedUninstall] = useState(null); // portal_id or null
|
|
65
|
+
const [uninstalling, setUninstalling] = useState(null); // portal_id or null
|
|
66
|
+
const [uninstallError, setUninstallError] = useState(null);
|
|
67
|
+
|
|
68
|
+
const pid = portalId ?? context.portal.id;
|
|
69
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
70
|
+
const qs = `${path}${sep}portalId=${pid}`;
|
|
38
71
|
|
|
39
72
|
useEffect(() => {
|
|
40
73
|
let cancelled = false;
|
|
41
74
|
(async () => {
|
|
42
75
|
try {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
76
|
+
const status = await callAppApi(context, qs, "GET");
|
|
77
|
+
if (cancelled) return;
|
|
78
|
+
const linked = status?.linked_sandboxes ?? [];
|
|
79
|
+
const cap = status?.limit ?? 1;
|
|
80
|
+
setLinkedSandboxes(linked);
|
|
81
|
+
setLimit(cap);
|
|
82
|
+
// Only mint (and offer) a NEW link while still under the limit — at
|
|
83
|
+
// the limit, the linked list above is the whole story; there's
|
|
84
|
+
// nothing else to click.
|
|
85
|
+
if (linked.length < cap) {
|
|
86
|
+
const res = await callAppApi(context, qs, "POST");
|
|
87
|
+
if (!cancelled) setInstallUrl(res?.install_url ?? null);
|
|
88
|
+
}
|
|
47
89
|
} catch (err) {
|
|
48
90
|
// The backend 403s apps that don't allow sandbox installs — show its
|
|
49
91
|
// message rather than a generic failure.
|
|
@@ -60,9 +102,37 @@ export function SandboxInstall({
|
|
|
60
102
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
61
103
|
}, [path, portalId]);
|
|
62
104
|
|
|
105
|
+
async function doUninstall(sandboxPortalId) {
|
|
106
|
+
setUninstalling(sandboxPortalId);
|
|
107
|
+
setUninstallError(null);
|
|
108
|
+
try {
|
|
109
|
+
await callAppApi(context, `${qs}/${sandboxPortalId}/uninstall`, "POST");
|
|
110
|
+
setLinkedSandboxes((prev) =>
|
|
111
|
+
prev.filter((s) => s.portal_id !== sandboxPortalId)
|
|
112
|
+
);
|
|
113
|
+
setArmedUninstall(null);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
setUninstallError(err instanceof AppApiError ? err.message : String(err));
|
|
116
|
+
} finally {
|
|
117
|
+
setUninstalling(null);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const atLimit = linkedSandboxes.length >= limit;
|
|
122
|
+
|
|
63
123
|
return (
|
|
64
124
|
<Flex direction="column" gap="small">
|
|
65
|
-
<
|
|
125
|
+
<Flex direction="row" gap="small" align="center">
|
|
126
|
+
<Heading>{heading}</Heading>
|
|
127
|
+
{!loading && !error && (
|
|
128
|
+
// At the limit is the NORMAL end state for the common case (limit
|
|
129
|
+
// defaults to 1) — reaching it isn't a problem, so this reads
|
|
130
|
+
// success/neutral either way, never a warning color.
|
|
131
|
+
<StatusTag variant="success">
|
|
132
|
+
{linkedSandboxes.length} of {limit} used
|
|
133
|
+
</StatusTag>
|
|
134
|
+
)}
|
|
135
|
+
</Flex>
|
|
66
136
|
<Text>
|
|
67
137
|
{description ??
|
|
68
138
|
`Spin up ${appName} on a HubSpot sandbox account, linked to your billing. ` +
|
|
@@ -75,6 +145,70 @@ export function SandboxInstall({
|
|
|
75
145
|
</Alert>
|
|
76
146
|
)}
|
|
77
147
|
|
|
148
|
+
{uninstallError && (
|
|
149
|
+
<Alert title="Couldn't uninstall that sandbox" variant="warning">
|
|
150
|
+
<Text>{uninstallError}</Text>
|
|
151
|
+
</Alert>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{linkedSandboxes.length > 0 && (
|
|
155
|
+
<Flex direction="column" gap="small">
|
|
156
|
+
<Text format={{ fontWeight: "bold" }}>Already linked</Text>
|
|
157
|
+
{linkedSandboxes.map((s) => (
|
|
158
|
+
<Tile key={s.portal_id}>
|
|
159
|
+
<Flex direction="column" gap="extra-small">
|
|
160
|
+
<Flex direction="row" gap="small" align="center">
|
|
161
|
+
<Text format={{ fontWeight: "demibold" }}>
|
|
162
|
+
{s.name || s.portal_id}
|
|
163
|
+
</Text>
|
|
164
|
+
<StatusTag variant="default">{s.status}</StatusTag>
|
|
165
|
+
</Flex>
|
|
166
|
+
<Text variant="microcopy">
|
|
167
|
+
Connected {s.connected_at ? fmtDateTime(s.connected_at) : "—"}
|
|
168
|
+
{s.connected_by ? ` by ${s.connected_by}` : ""}
|
|
169
|
+
</Text>
|
|
170
|
+
<Flex direction="row" gap="small" align="center">
|
|
171
|
+
{s.console_url && (
|
|
172
|
+
<Link href={{ url: s.console_url, external: true }}>
|
|
173
|
+
Open in HubSpot →
|
|
174
|
+
</Link>
|
|
175
|
+
)}
|
|
176
|
+
{armedUninstall === s.portal_id ? (
|
|
177
|
+
<Flex direction="row" gap="small" align="center">
|
|
178
|
+
<Text variant="microcopy">Uninstall this sandbox?</Text>
|
|
179
|
+
<Button
|
|
180
|
+
size="xs"
|
|
181
|
+
variant="destructive"
|
|
182
|
+
onClick={() => doUninstall(s.portal_id)}
|
|
183
|
+
disabled={uninstalling === s.portal_id}
|
|
184
|
+
>
|
|
185
|
+
Confirm uninstall
|
|
186
|
+
</Button>
|
|
187
|
+
<Button
|
|
188
|
+
size="xs"
|
|
189
|
+
variant="secondary"
|
|
190
|
+
onClick={() => setArmedUninstall(null)}
|
|
191
|
+
disabled={uninstalling === s.portal_id}
|
|
192
|
+
>
|
|
193
|
+
Cancel
|
|
194
|
+
</Button>
|
|
195
|
+
</Flex>
|
|
196
|
+
) : (
|
|
197
|
+
<Button
|
|
198
|
+
size="xs"
|
|
199
|
+
variant="destructive"
|
|
200
|
+
onClick={() => setArmedUninstall(s.portal_id)}
|
|
201
|
+
>
|
|
202
|
+
Uninstall…
|
|
203
|
+
</Button>
|
|
204
|
+
)}
|
|
205
|
+
</Flex>
|
|
206
|
+
</Flex>
|
|
207
|
+
</Tile>
|
|
208
|
+
))}
|
|
209
|
+
</Flex>
|
|
210
|
+
)}
|
|
211
|
+
|
|
78
212
|
{installUrl && (
|
|
79
213
|
<Flex direction="column" gap="extra-small">
|
|
80
214
|
<Link href={{ url: installUrl, external: true }}>
|
|
@@ -87,7 +221,13 @@ export function SandboxInstall({
|
|
|
87
221
|
</Flex>
|
|
88
222
|
)}
|
|
89
223
|
|
|
90
|
-
{loading && !error &&
|
|
224
|
+
{!loading && !error && atLimit && linkedSandboxes.length > 0 && (
|
|
225
|
+
<Text variant="microcopy">
|
|
226
|
+
Need another sandbox linked? Contact us.
|
|
227
|
+
</Text>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
{loading && !error && <Text variant="microcopy">Checking your sandbox installs…</Text>}
|
|
91
231
|
</Flex>
|
|
92
232
|
);
|
|
93
233
|
}
|