@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 +1 -1
- package/src/forms/OptionSelect.jsx +115 -0
- package/src/index.js +3 -0
- package/src/sdk/token/TokenContext.jsx +8 -2
package/package.json
CHANGED
|
@@ -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,
|
|
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>
|