@growth-operations/ui-shared 1.5.9 → 1.6.1

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.5.9",
3
+ "version": "1.6.1",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -0,0 +1,115 @@
1
+ import React, { useState } from "react";
2
+ import { Select, LoadingSpinner, Alert, Flex, Text } from "@hubspot/ui-extensions";
3
+ import { useToken } from "../sdk/index";
4
+ import { getForms } from "../sdk/hubspot/forms";
5
+ import { getLists } from "../sdk/hubspot/lists";
6
+ import { getPipelines, getPipelineStages } from "../sdk/hubspot/pipeline";
7
+ import { useStrictModeEffect } from "../lib/useStrictModeEffect";
8
+
9
+ // OptionSelect — one shared HubSpot-options dropdown, replacing the per-app
10
+ // FormSelector / SegmentSelector / PipelineStageSelector that every app
11
+ // re-implemented (fetch options → map to {label,value} → <Select> → save on
12
+ // change). The data-fetch half already lives in the SDK (getForms / getLists /
13
+ // getPipelineStages); this owns the loading/error/Select chrome around it.
14
+ //
15
+ // Pick options ONE of two ways:
16
+ // - source="forms" | "lists" | "pipelines" | "pipelineStages" — built-in SDK
17
+ // fetchers. "pipelines"/"pipelineStages" take opts.objectType; the latter
18
+ // also needs opts.pipelineId.
19
+ // - fetchOptions={async (context, token) => [{label, value}, ...]} — any
20
+ // custom source. Takes precedence over `source`.
21
+ //
22
+ // onChange(value) fires with the selected value; the caller owns persistence
23
+ // (e.g. ui-shared updateSettings). `value` is the controlled selection.
24
+ const SOURCES = {
25
+ forms: async (context, token) => {
26
+ const { results = [] } = await getForms(context, token);
27
+ return results.map((f) => ({ label: f.name, value: f.id }));
28
+ },
29
+ lists: async (context, token, opts) => {
30
+ const { lists = [] } = await getLists(context, token, opts?.query ?? "");
31
+ return lists.map((l) => ({ label: l.name, value: String(l.listId) }));
32
+ },
33
+ pipelines: async (context, token, opts) => {
34
+ const { results = [] } = await getPipelines(context, token, opts?.objectType);
35
+ return results.map((p) => ({ label: p.label, value: p.id }));
36
+ },
37
+ pipelineStages: async (context, token, opts) => {
38
+ if (!opts?.pipelineId) return [];
39
+ const { results = [] } = await getPipelineStages(
40
+ context,
41
+ token,
42
+ opts.pipelineId,
43
+ opts.objectType
44
+ );
45
+ return results.map((s) => ({ label: s.label, value: s.id }));
46
+ },
47
+ };
48
+
49
+ export function OptionSelect({
50
+ context,
51
+ value,
52
+ onChange,
53
+ source,
54
+ fetchOptions,
55
+ label = "Select",
56
+ description,
57
+ placeholder = "Choose…",
58
+ // Extra source args (pipelineId/objectType for pipelineStages, query for lists)
59
+ ...opts
60
+ }) {
61
+ const [options, setOptions] = useState([]);
62
+ const [loading, setLoading] = useState(true);
63
+ const [error, setError] = useState(null);
64
+ const { ensureValidToken } = useToken();
65
+
66
+ // Re-fetch when the source or its key args change (e.g. pipelineId).
67
+ const depKey = JSON.stringify({
68
+ source,
69
+ pipelineId: opts.pipelineId,
70
+ objectType: opts.objectType,
71
+ query: opts.query,
72
+ });
73
+
74
+ useStrictModeEffect(
75
+ async ({ mounted }) => {
76
+ try {
77
+ const token = await ensureValidToken();
78
+ if (!token || !mounted.current) return;
79
+ const loader =
80
+ fetchOptions ?? SOURCES[source] ?? (async () => []);
81
+ const opted = await loader(context, token, opts);
82
+ if (mounted.current) setOptions(opted);
83
+ } catch (e) {
84
+ if (mounted.current) setError(String(e?.message ?? e));
85
+ } finally {
86
+ if (mounted.current) setLoading(false);
87
+ }
88
+ },
89
+ [context, depKey]
90
+ );
91
+
92
+ if (loading) {
93
+ return <LoadingSpinner size="small" label={`Loading ${label.toLowerCase()}…`} showLabel />;
94
+ }
95
+ if (error) {
96
+ return (
97
+ <Alert title={`Couldn't load ${label.toLowerCase()}`} variant="danger">
98
+ <Text>{error}</Text>
99
+ </Alert>
100
+ );
101
+ }
102
+
103
+ return (
104
+ <Flex direction="column" gap="extra-small">
105
+ <Select
106
+ label={label}
107
+ description={description}
108
+ placeholder={placeholder}
109
+ options={options}
110
+ value={value ?? ""}
111
+ onChange={(v) => onChange?.(v)}
112
+ />
113
+ </Flex>
114
+ );
115
+ }
package/src/index.js CHANGED
@@ -27,6 +27,9 @@ export { UsageTab } from "./usage/UsageTab";
27
27
  // Sandbox — the shared "Install on a sandbox" action (mints a signed link).
28
28
  export { SandboxInstall } from "./sandbox/SandboxInstall";
29
29
 
30
+ // Forms — shared HubSpot-options dropdown (forms/lists/pipeline-stages picker).
31
+ export { OptionSelect } from "./forms/OptionSelect";
32
+
30
33
  // Lib helpers.
31
34
  export {
32
35
  fmtDate,
@@ -20,13 +20,17 @@ const TokenContext = createContext(null);
20
20
  let cachedTokenData = null;
21
21
  let tokenPromise = null;
22
22
  let appContext = null;
23
+ // The short-lived-token endpoint. Defaults to the /api/v1 mount (toast/sparkfly);
24
+ // apps mounted at /v1 (e.g. ATS) pass tokenPath="/v1/tokens/short-lived" to
25
+ // TokenProvider. Same per-app override pattern as makeSettingsApi / AlertsTab.
26
+ let tokenPath = "/api/v1/tokens/short-lived";
23
27
 
24
28
  // Fetch the full short-lived token object (token string + expires_at) for the
25
29
  // current portal.
26
30
  async function fetchTokenData(context) {
27
31
  const portalId = context?.portal?.id;
28
32
  const query = portalId ? `?portalId=${portalId}` : "";
29
- return callAppApi(context, `/api/v1/tokens/short-lived${query}`, "GET");
33
+ return callAppApi(context, `${tokenPath}${query}`, "GET");
30
34
  }
31
35
 
32
36
  // Return a valid access token string, fetching a fresh one if the cache is empty
@@ -76,10 +80,12 @@ export function useToken() {
76
80
  return context;
77
81
  }
78
82
 
79
- export function TokenProvider({ children, context }) {
83
+ export function TokenProvider({ children, context, tokenPath: path }) {
80
84
  // Stash the extension context at module level so the token functions (which
81
85
  // live outside React) can reach it.
82
86
  appContext = context;
87
+ // Per-app override for the short-lived-token endpoint (apps mounted at /v1).
88
+ if (path) tokenPath = path;
83
89
 
84
90
  return (
85
91
  <TokenContext.Provider value={stableValue}>{children}</TokenContext.Provider>