@growth-operations/ui-shared 1.6.16 → 1.7.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.6.16",
3
+ "version": "1.7.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -27,7 +27,12 @@ const SOURCES = {
27
27
  return results.map((f) => ({ label: f.name, value: f.id }));
28
28
  },
29
29
  lists: async (context, token, opts) => {
30
- const { lists = [] } = await getLists(context, token, opts?.query ?? "");
30
+ const { lists = [] } = await getLists(
31
+ context,
32
+ token,
33
+ opts?.query ?? "",
34
+ opts?.objectTypeId ?? null
35
+ );
31
36
  return lists.map((l) => ({ label: l.name, value: String(l.listId) }));
32
37
  },
33
38
  pipelines: async (context, token, opts) => {
@@ -61,7 +66,7 @@ export function OptionSelect({
61
66
  // url is null/absent the link is skipped, so callers can pass an "Edit" action
62
67
  // gated on a selected value.
63
68
  actions = [],
64
- // Extra source args (pipelineId/objectType for pipelineStages, query for lists)
69
+ // Extra source args (pipelineId/objectType for pipelineStages, query/objectTypeId for lists)
65
70
  ...opts
66
71
  }) {
67
72
  const [options, setOptions] = useState([]);
@@ -75,6 +80,7 @@ export function OptionSelect({
75
80
  pipelineId: opts.pipelineId,
76
81
  objectType: opts.objectType,
77
82
  query: opts.query,
83
+ objectTypeId: opts.objectTypeId,
78
84
  });
79
85
 
80
86
  useStrictModeEffect(
@@ -15,20 +15,34 @@
15
15
  // unconditionally with no signal a sandbox already existed, and a customer
16
16
  // could keep minting more with zero visibility). At the limit, shows the
17
17
  // linked list + a "X of Y used" indicator with no mint action underneath —
18
- // not a disabled/dead button, just nothing to click.
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).
19
22
  //
20
23
  // Extensible per app: the caller passes the mint `path` on its OWN backend
21
24
  // (base-hosted apps -> /v1/hubspot/app_pages/... no; the install mint lives at
22
25
  // /v2/hubspot/install/{app}/sandbox-link on base; self-hosted apps serve their
23
26
  // 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
27
+ // POST (mint) — the backend distinguishes by HTTP method. The uninstall action
28
+ // posts to `${path}/{sandboxPortalId}/uninstall`. callAppApi targets
25
29
  // context.variables.BASE_URL, so each app's BASE_URL routes to the right
26
30
  // service. Apps not allowlisted get a 403 from the endpoint, surfaced as a
27
31
  // calm message.
28
32
  import React, { useEffect, useState } from "react";
29
- import { Flex, Heading, Text, Link, Alert, StatusTag } from "@hubspot/ui-extensions";
33
+ import {
34
+ Flex,
35
+ Tile,
36
+ Heading,
37
+ Text,
38
+ Link,
39
+ Alert,
40
+ Button,
41
+ StatusTag,
42
+ } from "@hubspot/ui-extensions";
30
43
 
31
44
  import { callAppApi, AppApiError } from "../sdk/app/base";
45
+ import { fmtDateTime } from "../lib/format";
32
46
 
33
47
  // context: extension context. path: the status+mint endpoint on the app's
34
48
  // backend (e.g. `/v2/hubspot/install/${appKey}/sandbox-link`). portalId: the
@@ -47,14 +61,18 @@ export function SandboxInstall({
47
61
  const [installUrl, setInstallUrl] = useState(null);
48
62
  const [loading, setLoading] = useState(true);
49
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}`;
50
71
 
51
72
  useEffect(() => {
52
73
  let cancelled = false;
53
74
  (async () => {
54
75
  try {
55
- const pid = portalId ?? context.portal.id;
56
- const sep = path.includes("?") ? "&" : "?";
57
- const qs = `${path}${sep}portalId=${pid}`;
58
76
  const status = await callAppApi(context, qs, "GET");
59
77
  if (cancelled) return;
60
78
  const linked = status?.linked_sandboxes ?? [];
@@ -84,6 +102,22 @@ export function SandboxInstall({
84
102
  // eslint-disable-next-line react-hooks/exhaustive-deps
85
103
  }, [path, portalId]);
86
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
+
87
121
  const atLimit = linkedSandboxes.length >= limit;
88
122
 
89
123
  return (
@@ -111,13 +145,66 @@ export function SandboxInstall({
111
145
  </Alert>
112
146
  )}
113
147
 
148
+ {uninstallError && (
149
+ <Alert title="Couldn't uninstall that sandbox" variant="warning">
150
+ <Text>{uninstallError}</Text>
151
+ </Alert>
152
+ )}
153
+
114
154
  {linkedSandboxes.length > 0 && (
115
- <Flex direction="column" gap="extra-small">
155
+ <Flex direction="column" gap="small">
116
156
  <Text format={{ fontWeight: "bold" }}>Already linked</Text>
117
157
  {linkedSandboxes.map((s) => (
118
- <Text key={s.portal_id}>
119
- {s.name || s.portal_id} — {s.status}
120
- </Text>
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>
121
208
  ))}
122
209
  </Flex>
123
210
  )}
@@ -1,11 +1,14 @@
1
- // HubSpot contact-list fetcher (for list-selection pickers).
1
+ // HubSpot list fetcher (for list-selection pickers).
2
2
  //
3
3
  // Paginates crm/v3/lists/search until all DYNAMIC lists are collected. The POST
4
4
  // body is a plain OBJECT — callHubSpotApi sends it as-is (NEVER JSON.stringify).
5
5
  import { buildHubSpotUrl, callHubSpotApi } from "./base";
6
6
 
7
7
  // context is accepted for signature consistency. query: optional name filter.
8
- export async function getLists(context, token, query = "") {
8
+ // objectTypeId: optional HubSpot object type id (e.g. "0-1" for contacts, or a
9
+ // custom object's numeric type id) to scope results to lists built on that
10
+ // object — omit to search across all objects, as before.
11
+ export async function getLists(context, token, query = "", objectTypeId = null) {
9
12
  const url = buildHubSpotUrl("crm/v3/lists/search");
10
13
  const allLists = [];
11
14
  let offset = 0;
@@ -18,6 +21,7 @@ export async function getLists(context, token, query = "") {
18
21
  count: count,
19
22
  offset: offset,
20
23
  processingTypes: ["DYNAMIC"],
24
+ ...(objectTypeId ? { objectTypeId } : {}),
21
25
  };
22
26
 
23
27
  const response = await callHubSpotApi(url, token, "POST", payload);