@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,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-operations/ui-shared",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -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
- // Re-fetch when the source or its key args change (e.g. pipelineId).
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) setLoading(false);
121
+ if (mounted.current) {
122
+ setLoading(false);
123
+ setInitialLoad(false);
124
+ }
99
125
  }
100
126
  },
101
127
  [context, depKey]
102
128
  );
103
129
 
104
- if (loading) {
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
- // Prevent duplicate fetches in React Strict Mode
43
- if (fetchedRef.current) return;
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
 
@@ -1,7 +1,9 @@
1
1
  // HubSpot list fetcher (for list-selection pickers).
2
2
  //
3
- // Paginates crm/v3/lists/search until all DYNAMIC lists are collected. The POST
4
- // body is a plain OBJECT — callHubSpotApi sends it as-is (NEVER JSON.stringify).
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