@growth-operations/ui-shared 1.10.0 → 1.12.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,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from "react";
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { Select, LoadingSpinner, Alert, Flex, Text, Link } from "@hubspot/ui-extensions";
|
|
3
3
|
import { useToken } from "../sdk/index";
|
|
4
4
|
import { getForms } from "../sdk/hubspot/forms";
|
|
@@ -6,6 +6,11 @@ import { getLists } from "../sdk/hubspot/lists";
|
|
|
6
6
|
import { getPipelines, getPipelineStages } from "../sdk/hubspot/pipeline";
|
|
7
7
|
import { useStrictModeEffect } from "../lib/useStrictModeEffect";
|
|
8
8
|
|
|
9
|
+
// How long to wait after the user stops typing before re-querying the
|
|
10
|
+
// source. HubSpot's Select docs recommend debouncing onInput, which
|
|
11
|
+
// fires on every keystroke.
|
|
12
|
+
const SEARCH_DEBOUNCE_MS = 300;
|
|
13
|
+
|
|
9
14
|
// OptionSelect — one shared HubSpot-options dropdown, replacing the per-app
|
|
10
15
|
// FormSelector / SegmentSelector / PipelineStageSelector that every app
|
|
11
16
|
// re-implemented (fetch options → map to {label,value} → <Select> → save on
|
|
@@ -71,16 +76,34 @@ export function OptionSelect({
|
|
|
71
76
|
}) {
|
|
72
77
|
const [options, setOptions] = useState([]);
|
|
73
78
|
const [loading, setLoading] = useState(true);
|
|
79
|
+
const [initialLoad, setInitialLoad] = useState(true);
|
|
74
80
|
const [error, setError] = useState(null);
|
|
81
|
+
// The live search term, committed only after SEARCH_DEBOUNCE_MS of no
|
|
82
|
+
// typing — this is what actually triggers a re-fetch. `Select`'s
|
|
83
|
+
// `onInput` fires on every keystroke; debouncing here is what HubSpot's
|
|
84
|
+
// docs recommend instead of updating state directly in that callback.
|
|
85
|
+
const [searchTerm, setSearchTerm] = useState(opts.query ?? "");
|
|
86
|
+
const debounceRef = useRef(null);
|
|
75
87
|
const { ensureValidToken } = useToken();
|
|
76
88
|
|
|
77
|
-
|
|
89
|
+
const handleInput = (v) => {
|
|
90
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
91
|
+
debounceRef.current = setTimeout(() => setSearchTerm(v), SEARCH_DEBOUNCE_MS);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
return () => {
|
|
96
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
97
|
+
};
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
// Re-fetch when the source, its key args, or the search term change.
|
|
78
101
|
const depKey = JSON.stringify({
|
|
79
102
|
source,
|
|
80
103
|
pipelineId: opts.pipelineId,
|
|
81
104
|
objectType: opts.objectType,
|
|
82
|
-
query: opts.query,
|
|
83
105
|
objectTypeId: opts.objectTypeId,
|
|
106
|
+
searchTerm,
|
|
84
107
|
});
|
|
85
108
|
|
|
86
109
|
useStrictModeEffect(
|
|
@@ -90,18 +113,25 @@ export function OptionSelect({
|
|
|
90
113
|
if (!token || !mounted.current) return;
|
|
91
114
|
const loader =
|
|
92
115
|
fetchOptions ?? SOURCES[source] ?? (async () => []);
|
|
93
|
-
const opted = await loader(context, token, opts);
|
|
116
|
+
const opted = await loader(context, token, { ...opts, query: searchTerm });
|
|
94
117
|
if (mounted.current) setOptions(opted);
|
|
95
118
|
} catch (e) {
|
|
96
119
|
if (mounted.current) setError(String(e?.message ?? e));
|
|
97
120
|
} finally {
|
|
98
|
-
if (mounted.current)
|
|
121
|
+
if (mounted.current) {
|
|
122
|
+
setLoading(false);
|
|
123
|
+
setInitialLoad(false);
|
|
124
|
+
}
|
|
99
125
|
}
|
|
100
126
|
},
|
|
101
127
|
[context, depKey]
|
|
102
128
|
);
|
|
103
129
|
|
|
104
|
-
|
|
130
|
+
// Only the first fetch replaces the whole component with a spinner.
|
|
131
|
+
// Re-fetches triggered by typing a search term keep `Select` mounted
|
|
132
|
+
// (with its current options) so the dropdown doesn't visibly close
|
|
133
|
+
// and the user doesn't lose focus/typed text mid-search.
|
|
134
|
+
if (loading && initialLoad) {
|
|
105
135
|
return <LoadingSpinner size="small" label={`Loading ${label.toLowerCase()}…`} showLabel />;
|
|
106
136
|
}
|
|
107
137
|
if (error) {
|
|
@@ -123,6 +153,7 @@ export function OptionSelect({
|
|
|
123
153
|
options={options}
|
|
124
154
|
value={value ?? ""}
|
|
125
155
|
onChange={(v) => onChange?.(v)}
|
|
156
|
+
onInput={handleInput}
|
|
126
157
|
/>
|
|
127
158
|
{actionLinks.length > 0 && (
|
|
128
159
|
<Flex direction="row" gap="small">
|
|
@@ -4,9 +4,10 @@ import { useEffect, useRef } from 'react';
|
|
|
4
4
|
* A custom hook that handles React Strict Mode's double-mount pattern for async effects.
|
|
5
5
|
*
|
|
6
6
|
* This hook prevents duplicate API calls in development by:
|
|
7
|
-
* 1. Using a ref to track if the effect has already run
|
|
7
|
+
* 1. Using a ref to track if the effect has already run for the current dependencies
|
|
8
8
|
* 2. Properly resetting state when the component unmounts during async operations
|
|
9
|
-
* 3. Allowing the effect to run again when the component remounts
|
|
9
|
+
* 3. Allowing the effect to run again when the component remounts, OR when
|
|
10
|
+
* the dependency array actually changes (e.g. a search term or selected id)
|
|
10
11
|
*
|
|
11
12
|
* @param {Function} effect - Async function to run. Will receive a cleanup object with:
|
|
12
13
|
* - mounted: ref to check if component is still mounted
|
|
@@ -36,11 +37,20 @@ import { useEffect, useRef } from 'react';
|
|
|
36
37
|
*/
|
|
37
38
|
export function useStrictModeEffect(effect, dependencies = [], options = {}) {
|
|
38
39
|
const fetchedRef = useRef(false);
|
|
40
|
+
const prevDependenciesRef = useRef(dependencies);
|
|
39
41
|
const { setLoading } = options;
|
|
40
42
|
|
|
41
43
|
useEffect(() => {
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
+
// A genuine dependency change (e.g. a search term or selected
|
|
45
|
+
// pipeline) must always re-run the effect — only Strict Mode's
|
|
46
|
+
// same-render double-invocation should be suppressed. Without
|
|
47
|
+
// this, fetchedRef.current stays true forever after the first
|
|
48
|
+
// successful fetch and no dependency change ever re-fetches.
|
|
49
|
+
const dependenciesChanged = dependencies.some(
|
|
50
|
+
(dep, i) => dep !== prevDependenciesRef.current[i]
|
|
51
|
+
);
|
|
52
|
+
prevDependenciesRef.current = dependencies;
|
|
53
|
+
if (fetchedRef.current && !dependenciesChanged) return;
|
|
44
54
|
|
|
45
55
|
const mounted = { current: true };
|
|
46
56
|
|
package/src/sdk/hubspot/lists.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// HubSpot list fetcher (for list-selection pickers).
|
|
2
2
|
//
|
|
3
|
-
// Paginates crm/v3/lists/search until all
|
|
4
|
-
//
|
|
3
|
+
// Paginates crm/v3/lists/search until all lists are collected — MANUAL
|
|
4
|
+
// (static), DYNAMIC (active), and SNAPSHOT, so a picker isn't silently
|
|
5
|
+
// missing hand-curated test/segment lists. The POST body is a plain OBJECT —
|
|
6
|
+
// callHubSpotApi sends it as-is (NEVER JSON.stringify).
|
|
5
7
|
import { buildHubSpotUrl, callHubSpotApi } from "./base";
|
|
6
8
|
|
|
7
9
|
// context is accepted for signature consistency. query: optional name filter.
|
|
@@ -20,7 +22,7 @@ export async function getLists(context, token, query = "", objectTypeId = null)
|
|
|
20
22
|
query: query,
|
|
21
23
|
count: count,
|
|
22
24
|
offset: offset,
|
|
23
|
-
processingTypes: ["DYNAMIC"],
|
|
25
|
+
processingTypes: ["MANUAL", "DYNAMIC", "SNAPSHOT"],
|
|
24
26
|
...(objectTypeId ? { objectTypeId } : {}),
|
|
25
27
|
};
|
|
26
28
|
|