@tellescope/react-components 1.231.0 → 1.232.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.
Files changed (50) hide show
  1. package/lib/cjs/Forms/forms.js +1 -1
  2. package/lib/cjs/Forms/forms.js.map +1 -1
  3. package/lib/cjs/Forms/forms.v2.d.ts +1 -1
  4. package/lib/cjs/Forms/forms.v2.js +1 -1
  5. package/lib/cjs/Forms/forms.v2.js.map +1 -1
  6. package/lib/cjs/Forms/hooks.d.ts.map +1 -1
  7. package/lib/cjs/Forms/hooks.js +24 -0
  8. package/lib/cjs/Forms/hooks.js.map +1 -1
  9. package/lib/cjs/Forms/inputs.d.ts +6 -3
  10. package/lib/cjs/Forms/inputs.d.ts.map +1 -1
  11. package/lib/cjs/Forms/inputs.js +171 -44
  12. package/lib/cjs/Forms/inputs.js.map +1 -1
  13. package/lib/cjs/Forms/inputs.v2.d.ts +7 -11
  14. package/lib/cjs/Forms/inputs.v2.d.ts.map +1 -1
  15. package/lib/cjs/Forms/inputs.v2.js +16 -445
  16. package/lib/cjs/Forms/inputs.v2.js.map +1 -1
  17. package/lib/esm/CMS/components.d.ts +0 -1
  18. package/lib/esm/CMS/components.d.ts.map +1 -1
  19. package/lib/esm/Forms/form_responses.d.ts +0 -1
  20. package/lib/esm/Forms/form_responses.d.ts.map +1 -1
  21. package/lib/esm/Forms/forms.d.ts +3 -3
  22. package/lib/esm/Forms/forms.js +1 -1
  23. package/lib/esm/Forms/forms.js.map +1 -1
  24. package/lib/esm/Forms/forms.v2.d.ts +4 -4
  25. package/lib/esm/Forms/forms.v2.js +1 -1
  26. package/lib/esm/Forms/forms.v2.js.map +1 -1
  27. package/lib/esm/Forms/hooks.d.ts +0 -1
  28. package/lib/esm/Forms/hooks.d.ts.map +1 -1
  29. package/lib/esm/Forms/hooks.js +24 -0
  30. package/lib/esm/Forms/hooks.js.map +1 -1
  31. package/lib/esm/Forms/inputs.d.ts +7 -4
  32. package/lib/esm/Forms/inputs.d.ts.map +1 -1
  33. package/lib/esm/Forms/inputs.js +173 -46
  34. package/lib/esm/Forms/inputs.js.map +1 -1
  35. package/lib/esm/Forms/inputs.v2.d.ts +8 -12
  36. package/lib/esm/Forms/inputs.v2.d.ts.map +1 -1
  37. package/lib/esm/Forms/inputs.v2.js +17 -446
  38. package/lib/esm/Forms/inputs.v2.js.map +1 -1
  39. package/lib/esm/controls.d.ts +2 -2
  40. package/lib/esm/inputs.d.ts +1 -1
  41. package/lib/esm/inputs.native.d.ts +0 -1
  42. package/lib/esm/inputs.native.d.ts.map +1 -1
  43. package/lib/esm/state.d.ts +315 -315
  44. package/lib/tsconfig.tsbuildinfo +1 -1
  45. package/package.json +9 -9
  46. package/src/Forms/forms.tsx +1 -1
  47. package/src/Forms/forms.v2.tsx +1 -1
  48. package/src/Forms/hooks.tsx +33 -5
  49. package/src/Forms/inputs.tsx +224 -35
  50. package/src/Forms/inputs.v2.tsx +20 -639
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tellescope/react-components",
3
- "version": "1.231.0",
3
+ "version": "1.232.1",
4
4
  "description": "",
5
5
  "main": "./lib/cjs/index.js",
6
6
  "module": "./lib/esm/index.js",
@@ -47,13 +47,13 @@
47
47
  "@reduxjs/toolkit": "^1.6.2",
48
48
  "@stripe/react-stripe-js": "^2.9.0",
49
49
  "@stripe/stripe-js": "^1.52.1",
50
- "@tellescope/constants": "1.231.0",
51
- "@tellescope/sdk": "1.231.0",
52
- "@tellescope/types-client": "1.231.0",
53
- "@tellescope/types-models": "1.231.0",
54
- "@tellescope/types-utilities": "1.231.0",
55
- "@tellescope/utilities": "1.231.0",
56
- "@tellescope/validation": "1.231.0",
50
+ "@tellescope/constants": "1.232.1",
51
+ "@tellescope/sdk": "1.232.1",
52
+ "@tellescope/types-client": "1.232.1",
53
+ "@tellescope/types-models": "1.232.1",
54
+ "@tellescope/types-utilities": "1.232.1",
55
+ "@tellescope/utilities": "1.232.1",
56
+ "@tellescope/validation": "1.232.1",
57
57
  "@typescript-eslint/eslint-plugin": "^4.33.0",
58
58
  "@typescript-eslint/parser": "^4.33.0",
59
59
  "css-to-react-native": "^3.0.0",
@@ -83,7 +83,7 @@
83
83
  "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
84
84
  "react-native": "^0.65.0 || ^0.66.0 || ^0.67.0 || ^0.68.0 || ^0.71.0"
85
85
  },
86
- "gitHead": "55bd4ea4e0ef23c03fee4eeff595f42bc501a0f9",
86
+ "gitHead": "1cbb2f579785066cd64d72b4bfdd2c788e192391",
87
87
  "publishConfig": {
88
88
  "access": "public"
89
89
  }
@@ -316,7 +316,7 @@ export const QuestionForField = ({
316
316
  <AppointmentBooking formResponseId={formResponseId} enduserId={enduserId} goToPreviousField={goToPreviousField} isPreviousDisabled={isPreviousDisabled} responses={responses} field={field} value={value.answer.value as string} onChange={onFieldChange as ChangeHandler<'Appointment Booking'>} form={form} />
317
317
  )
318
318
  : field.type === 'Stripe' ? (
319
- <Stripe enduserId={enduserId} field={field} value={value.answer.value as string} onChange={onFieldChange as ChangeHandler<any>} setCustomerId={setCustomerId} form={form} />
319
+ <Stripe enduserId={enduserId} field={field} value={value.answer.value as string} onChange={onFieldChange as ChangeHandler<any>} setCustomerId={setCustomerId} form={form} responses={responses} enduser={enduser} />
320
320
  )
321
321
  : field.type === 'Chargebee' ? (
322
322
  <Chargebee field={field} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<'Chargebee'>} setCustomerId={setCustomerId} form={form} />
@@ -310,7 +310,7 @@ export const QuestionForField = ({
310
310
  <AppointmentBooking formResponseId={formResponseId} enduserId={enduserId} goToPreviousField={goToPreviousField} isPreviousDisabled={isPreviousDisabled} responses={responses} field={field} value={value.answer.value as string} onChange={onFieldChange as ChangeHandler<'Appointment Booking'>} form={form} />
311
311
  )
312
312
  : field.type === 'Stripe' ? (
313
- <Stripe enduserId={enduserId} field={field} value={value.answer.value as string} onChange={onFieldChange as ChangeHandler<any>} setCustomerId={setCustomerId} form={form} />
313
+ <Stripe enduserId={enduserId} field={field} value={value.answer.value as string} onChange={onFieldChange as ChangeHandler<any>} setCustomerId={setCustomerId} form={form} responses={responses} enduser={enduser} />
314
314
  )
315
315
  : field.type === 'Chargebee' ? (
316
316
  <Chargebee field={field} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<'Chargebee'>} setCustomerId={setCustomerId} form={form} />
@@ -549,6 +549,7 @@ export const useTellescopeForm = ({ dontAutoadvance, isPublicForm, form, urlLogi
549
549
 
550
550
  const gaEventRef = useRef({} as Record<string, boolean>)
551
551
  const gtmEventRef = useRef({} as Record<string, boolean>)
552
+ const fieldViewCacheRef = useRef({} as Record<string, number>) // fieldId -> timestamp
552
553
 
553
554
  let goBackURL = ''
554
555
  try {
@@ -606,16 +607,43 @@ export const useTellescopeForm = ({ dontAutoadvance, isPublicForm, form, urlLogi
606
607
  if (gtmEventRef.current[activeField.value.id]) return
607
608
  gtmEventRef.current[activeField.value.id] = true
608
609
 
609
- emit_gtm_event({
610
- event: 'form_progress',
611
- formId: activeField.value.formId,
612
- fieldId: activeField.value.id,
610
+ emit_gtm_event({
611
+ event: 'form_progress',
612
+ formId: activeField.value.formId,
613
+ fieldId: activeField.value.id,
613
614
  title: activeField.value.title,
614
615
  previousTitle: prevFieldStackRef.current[prevFieldStackRef.current.length - 1]?.value?.title || '',
615
- status: ''
616
+ status: ''
616
617
  })
617
618
  }, [activeField])
618
619
 
620
+ // Track field views for analytics
621
+ useEffect(() => {
622
+ if (!accessCode && !formResponseId) return // Need either accessCode or formResponseId
623
+
624
+ const fieldId = activeField.value.id
625
+ const now = Date.now()
626
+ const lastLogged = fieldViewCacheRef.current[fieldId]
627
+
628
+ // Only log if field hasn't been logged before, or more than 60 seconds have passed
629
+ const shouldLog = !lastLogged || (now - lastLogged > 60000) // 60 seconds
630
+
631
+ if (shouldLog) {
632
+ fieldViewCacheRef.current[fieldId] = now
633
+
634
+ // Call API to log the view (fire and forget, don't block UI)
635
+ session.api.form_responses.save_field_response({
636
+ accessCode,
637
+ formResponseId,
638
+ viewOnly: true,
639
+ fieldId,
640
+ }).catch(err => {
641
+ // Silent fail - view tracking is non-critical
642
+ console.debug('Failed to log field view:', err)
643
+ })
644
+ }
645
+ }, [activeField, accessCode, formResponseId, session])
646
+
619
647
  // placeholders for initial fields, reset when fields prop changes, since questions are now different (e.g. different form selected)
620
648
  const fieldInitRef = useRef('')
621
649
  const initializeFields = useCallback(() => (
@@ -1,10 +1,10 @@
1
1
  import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"
2
2
  import axios from "axios"
3
- import { Autocomplete, Box, Button, Checkbox, Chip, Collapse, Divider, FormControl, FormControlLabel, FormLabel, Grid, IconButton as MuiIconButton, InputLabel, MenuItem, Radio, RadioGroup, Select, SxProps, TextField, TextFieldProps, Typography } from "@mui/material"
3
+ import { Autocomplete, Box, Button, Checkbox, Chip, CircularProgress, Collapse, Divider, FormControl, FormControlLabel, FormLabel, Grid, IconButton as MuiIconButton, InputLabel, MenuItem, Radio, RadioGroup, Select, SxProps, TextField, TextFieldProps, Typography } from "@mui/material"
4
4
  import { FormInputProps } from "./types"
5
5
  import { useDropzone } from "react-dropzone"
6
6
  import { CANVAS_TITLE, EMOTII_TITLE, INSURANCE_RELATIONSHIPS, INSURANCE_RELATIONSHIPS_CANVAS, PRIMARY_HEX, RELATIONSHIP_TYPES, TELLESCOPE_GENDERS } from "@tellescope/constants"
7
- import { MM_DD_YYYY_to_YYYY_MM_DD, capture_is_supported, downloadFile, emit_gtm_event, first_letter_capitalized, form_response_value_to_string, format_stripe_subscription_interval, getLocalTimezone, getPublicFileURL, mm_dd_yyyy, replace_enduser_template_values, truncate_string, update_local_storage, user_display_name } from "@tellescope/utilities"
7
+ import { MM_DD_YYYY_to_YYYY_MM_DD, capture_is_supported, downloadFile, emit_gtm_event, first_letter_capitalized, form_response_value_to_string, format_stripe_subscription_interval, getLocalTimezone, getPublicFileURL, mm_dd_yyyy, object_is_empty, replace_enduser_template_values, responses_satisfy_conditions, truncate_string, update_local_storage, user_display_name } from "@tellescope/utilities"
8
8
  import { Address, DatabaseSelectResponse, Enduser, EnduserRelationship, FormResponseValue, InsuranceRelationship, MedicationResponse, MultipleChoiceOptions, FormFieldOptionDetails, TellescopeGender, TIMEZONES_USA } from "@tellescope/types-models"
9
9
  import { VALID_STATES, emailValidator, phoneValidator } from "@tellescope/validation"
10
10
  import Slider from '@mui/material/Slider';
@@ -26,6 +26,16 @@ import { loadStripe } from '@stripe/stripe-js';
26
26
  import { CheckCircleOutline, Delete, Edit, ExpandMore } from "@mui/icons-material"
27
27
  import { WYSIWYG } from "./wysiwyg"
28
28
 
29
+ // Debounce hook for search functionality
30
+ const useDebounce = <T,>(value: T, delay: number): T => {
31
+ const [debouncedValue, setDebouncedValue] = useState<T>(value)
32
+ useEffect(() => {
33
+ const handler = setTimeout(() => setDebouncedValue(value), delay)
34
+ return () => clearTimeout(handler)
35
+ }, [value, delay])
36
+ return debouncedValue
37
+ }
38
+
29
39
  export const LanguageSelect = ({ value, ...props }: { value: string, onChange: (s: string) => void}) => (
30
40
  <Grid container alignItems="center" justifyContent={"center"} wrap="nowrap" spacing={1}>
31
41
  <Grid item>
@@ -1703,7 +1713,18 @@ export const MultipleChoiceInput = ({ field, form, value: _value, onChange }: Fo
1703
1713
  )
1704
1714
  }
1705
1715
 
1706
- export const StripeInput = ({ field, value, onChange, setCustomerId, enduserId }: FormInputProps<'Stripe'> & {
1716
+ // Helper to emit GTM purchase event for Stripe payments (single source of truth)
1717
+ const emitStripePurchaseEvent = (field: FormField, cost: number) => {
1718
+ emit_gtm_event({
1719
+ event: 'form_purchase',
1720
+ productIds: field.options?.productIds || [],
1721
+ fieldId: field.id,
1722
+ value: cost / 100, // Convert cents to dollars
1723
+ currency: 'USD',
1724
+ })
1725
+ }
1726
+
1727
+ export const StripeInput = ({ field, value, onChange, setCustomerId, enduserId, form, responses, enduser }: FormInputProps<'Stripe'> & {
1707
1728
  setCustomerId: React.Dispatch<React.SetStateAction<string | undefined>>,
1708
1729
  }) => {
1709
1730
  const session = useResolvedSession()
@@ -1718,6 +1739,38 @@ export const StripeInput = ({ field, value, onChange, setCustomerId, enduserId }
1718
1739
  const [availableProducts, setAvailableProducts] = useState<any[]>([])
1719
1740
  const [loadingProducts, setLoadingProducts] = useState(false)
1720
1741
 
1742
+ // Compute visible products based on conditional logic
1743
+ const visibleProducts = useMemo(() => {
1744
+ if (!showProductSelection || availableProducts.length === 0) {
1745
+ return availableProducts
1746
+ }
1747
+
1748
+ return availableProducts.filter(product => {
1749
+ // Find condition for this product
1750
+ const productCondition = field.options?.productConditions?.find(c => c.productId === product._id)
1751
+
1752
+ // If no condition defined, show by default
1753
+ if (!productCondition?.showCondition || object_is_empty(productCondition.showCondition)) {
1754
+ return true
1755
+ }
1756
+
1757
+ // Evaluate condition against current form responses
1758
+ return responses_satisfy_conditions(responses || [], productCondition.showCondition, {
1759
+ dateOfBirth: enduser?.dateOfBirth,
1760
+ gender: enduser?.gender,
1761
+ state: enduser?.state,
1762
+ form,
1763
+ activeResponses: responses,
1764
+ })
1765
+ })
1766
+ }, [availableProducts, field.options?.productConditions, responses, showProductSelection, enduser, form])
1767
+
1768
+ // Automatically deselect products that become hidden
1769
+ useEffect(() => {
1770
+ const visibleProductIds = visibleProducts.map(p => p._id)
1771
+ setSelectedProducts(prev => prev.filter(id => visibleProductIds.includes(id)))
1772
+ }, [visibleProducts])
1773
+
1721
1774
  const fetchRef = useRef(false)
1722
1775
  useEffect(() => {
1723
1776
  if (fetchRef.current) return
@@ -1784,6 +1837,16 @@ export const StripeInput = ({ field, value, onChange, setCustomerId, enduserId }
1784
1837
  : 0 // Will be calculated by existing Stripe flow when not in selection mode
1785
1838
  )
1786
1839
 
1840
+ // Emit GTM purchase event once when success screen is displayed
1841
+ const purchaseEmittedRef = useRef(false)
1842
+ useEffect(() => {
1843
+ // Only emit for actual purchases (chargeImmediately), not for saving card details
1844
+ if (value && field.options?.chargeImmediately && !purchaseEmittedRef.current) {
1845
+ emitStripePurchaseEvent(field, cost)
1846
+ purchaseEmittedRef.current = true
1847
+ }
1848
+ }, [value, field, cost])
1849
+
1787
1850
  // Handle product selection step
1788
1851
  if (showProductSelection) {
1789
1852
  if (error) {
@@ -1815,6 +1878,20 @@ export const StripeInput = ({ field, value, onChange, setCustomerId, enduserId }
1815
1878
  </Grid>
1816
1879
  )
1817
1880
  }
1881
+
1882
+ // Check if all products are filtered out by conditional logic
1883
+ if (visibleProducts.length === 0) {
1884
+ return (
1885
+ <Grid container direction="column" spacing={2} alignItems="center">
1886
+ <Grid item>
1887
+ <Typography color="textSecondary">
1888
+ No products are available based on your previous answers.
1889
+ </Typography>
1890
+ </Grid>
1891
+ </Grid>
1892
+ )
1893
+ }
1894
+
1818
1895
  const isSingleSelection = field.options?.radio === true
1819
1896
 
1820
1897
  const handleProductSelection = (productId: string) => {
@@ -1862,7 +1939,7 @@ export const StripeInput = ({ field, value, onChange, setCustomerId, enduserId }
1862
1939
  <Typography variant="h6">Select Product{isSingleSelection ? '' : 's'}</Typography>
1863
1940
  </Grid>
1864
1941
 
1865
- {availableProducts.map((product) => {
1942
+ {visibleProducts.map((product) => {
1866
1943
  // Use real-time Stripe pricing if available, fallback to Tellescope pricing
1867
1944
  const price = product.currentPrice || product.cost
1868
1945
  const priceAmount = price?.amount || 0
@@ -2124,48 +2201,126 @@ const choicesForDatabase: {
2124
2201
  const preventRefetch: Record<string, boolean> = {}
2125
2202
 
2126
2203
  const LOAD_CHOICES_LIMIT = 500
2127
- const useDatabaseChoices = ({ databaseId='', field, otherAnswers } : { databaseId?: string, field: FormField, otherAnswers?: DatabaseSelectResponse[] }) => {
2204
+ const MIN_SEARCH_CHARS = 3
2205
+ const SEARCH_DEBOUNCE_MS = 300
2206
+
2207
+ const useDatabaseChoices = ({
2208
+ databaseId='',
2209
+ field,
2210
+ otherAnswers,
2211
+ searchQuery = ''
2212
+ } : {
2213
+ databaseId?: string,
2214
+ field: FormField,
2215
+ otherAnswers?: DatabaseSelectResponse[],
2216
+ searchQuery?: string
2217
+ }) => {
2128
2218
  const session = useResolvedSession()
2129
- const [renderCount, setRenderCount] = useState(0)
2219
+ const [isSearching, setIsSearching] = useState(false)
2220
+ const [searchResults, setSearchResults] = useState<DatabaseRecord[]>([])
2221
+ const [initialLoadComplete, setInitialLoadComplete] = useState(false)
2222
+ const debouncedSearch = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS)
2130
2223
 
2131
- // todo: make searchable, don't load all
2224
+ // Load initial page on mount (only once, not recursively)
2225
+ const initialLoadRef = useRef(false)
2132
2226
  useEffect(() => {
2133
- if (choicesForDatabase[databaseId]?.done) return
2134
- if (renderCount > 100) return // limit to 50000 entries / prevent infinite looping
2135
- const choices = choicesForDatabase[databaseId]?.records ?? []
2136
- const lastId = choicesForDatabase[databaseId]?.lastId
2227
+ if (initialLoadRef.current) return
2228
+ if (choicesForDatabase[databaseId]?.done || choicesForDatabase[databaseId]?.records?.length) {
2229
+ setInitialLoadComplete(true)
2230
+ return
2231
+ }
2137
2232
 
2138
- if (preventRefetch[databaseId + field.id + lastId]) return
2139
- preventRefetch[databaseId + field.id + lastId] = true
2233
+ initialLoadRef.current = true
2234
+ preventRefetch[databaseId + field.id] = true
2140
2235
 
2141
2236
  session.api.form_fields.load_choices_from_database({
2142
2237
  fieldId: field.id,
2143
- lastId,
2144
2238
  limit: LOAD_CHOICES_LIMIT,
2145
2239
  databaseId, // overrides fieldId, supports using Database question in Table Input
2146
2240
  })
2147
2241
  .then(({ choices: newChoices }) => {
2148
2242
  choicesForDatabase[databaseId] = {
2149
2243
  lastId: newChoices?.[newChoices.length - 1]?.id,
2150
- records: [...choices, ...newChoices]
2151
- .sort((c1, c2) => (
2244
+ records: newChoices.sort((c1, c2) => (
2245
+ label_for_database_record(field, c1)
2246
+ .localeCompare(label_for_database_record(field, c2))
2247
+ )),
2248
+ done: true, // Don't load more pages automatically
2249
+ }
2250
+ setInitialLoadComplete(true)
2251
+ })
2252
+ .catch(err => {
2253
+ console.error(err)
2254
+ preventRefetch[databaseId + field.id] = false
2255
+ setInitialLoadComplete(true) // Mark as complete even on error to avoid infinite loading
2256
+ })
2257
+ }, [session, field, databaseId])
2258
+
2259
+ // Handle debounced search
2260
+ const searchRef = useRef(debouncedSearch)
2261
+ useEffect(() => {
2262
+ const trimmed = debouncedSearch.trim()
2263
+
2264
+ // If search is cleared, return to initial results
2265
+ if (!trimmed) {
2266
+ setSearchResults([])
2267
+ setIsSearching(false)
2268
+ searchRef.current = debouncedSearch
2269
+ return
2270
+ }
2271
+
2272
+ // Only search if meets minimum character requirement
2273
+ if (trimmed.length < MIN_SEARCH_CHARS) {
2274
+ setSearchResults([])
2275
+ setIsSearching(false)
2276
+ return
2277
+ }
2278
+
2279
+ // Avoid duplicate searches
2280
+ if (searchRef.current === debouncedSearch) return
2281
+ searchRef.current = debouncedSearch
2282
+
2283
+ setIsSearching(true)
2284
+ session.api.form_fields.load_choices_from_database({
2285
+ fieldId: field.id,
2286
+ limit: LOAD_CHOICES_LIMIT,
2287
+ databaseId,
2288
+ search: trimmed,
2289
+ })
2290
+ .then(({ choices: newChoices }) => {
2291
+ // Add search results to the same cache as initial load
2292
+ // This ensures selected search results persist even after search is cleared
2293
+ const existingRecords = choicesForDatabase[databaseId]?.records ?? []
2294
+ const existingIds = new Set(existingRecords.map(r => r.id))
2295
+
2296
+ const uniqueNewChoices = newChoices.filter(c => !existingIds.has(c.id))
2297
+
2298
+ if (uniqueNewChoices.length > 0) {
2299
+ choicesForDatabase[databaseId] = {
2300
+ ...choicesForDatabase[databaseId],
2301
+ records: [...existingRecords, ...uniqueNewChoices].sort((c1, c2) => (
2152
2302
  label_for_database_record(field, c1)
2153
2303
  .localeCompare(label_for_database_record(field, c2))
2154
- )
2155
- ),
2156
- done: newChoices.length < LOAD_CHOICES_LIMIT,
2157
- }
2158
- setRenderCount(r => r + 1)
2304
+ )),
2305
+ done: true, // Mark as done since we're not paginating search results
2306
+ }
2307
+ }
2308
+
2309
+ setSearchResults(newChoices.sort((c1, c2) => (
2310
+ label_for_database_record(field, c1)
2311
+ .localeCompare(label_for_database_record(field, c2))
2312
+ )))
2313
+ setIsSearching(false)
2159
2314
  })
2160
2315
  .catch(err => {
2161
2316
  console.error(err)
2162
- preventRefetch[databaseId + field.id + lastId] = false
2317
+ setIsSearching(false)
2163
2318
  })
2164
- }, [session, field, databaseId, renderCount])
2319
+ }, [session, field, databaseId, debouncedSearch])
2165
2320
 
2166
2321
  const addChoice = useCallback((record: DatabaseRecord) => {
2167
2322
  if (!choicesForDatabase[databaseId]) {
2168
- choicesForDatabase[databaseId] = {
2323
+ choicesForDatabase[databaseId] = {
2169
2324
  done: false,
2170
2325
  records: [],
2171
2326
  }
@@ -2173,18 +2328,24 @@ const useDatabaseChoices = ({ databaseId='', field, otherAnswers } : { databaseI
2173
2328
  choicesForDatabase[databaseId].records!.push(record)
2174
2329
  }, [choicesForDatabase, databaseId])
2175
2330
 
2331
+ // Use search results if searching, otherwise use cached initial results
2332
+ const activeChoices = debouncedSearch.trim().length >= MIN_SEARCH_CHARS
2333
+ ? searchResults
2334
+ : (choicesForDatabase[databaseId]?.records ?? [])
2335
+
2176
2336
  return {
2177
2337
  addChoice,
2178
- doneLoading: choicesForDatabase[databaseId]?.done ?? false,
2338
+ doneLoading: initialLoadComplete,
2339
+ isSearching,
2179
2340
  choices: [
2180
- ...choicesForDatabase[databaseId]?.records ?? [],
2341
+ ...activeChoices,
2181
2342
  ...(otherAnswers || []).map(v => ({
2182
2343
  id: v.text,
2183
2344
  databaseId,
2184
2345
  values: [{ label: field.options?.databaseLabel || '', type: 'Text', value: v.text }],
2185
2346
  }) as Pick<DatabaseRecord, 'id' | 'values' | 'databaseId'>)
2186
2347
  ],
2187
- renderCount,
2348
+ minSearchChars: MIN_SEARCH_CHARS,
2188
2349
  }
2189
2350
  }
2190
2351
 
@@ -2230,15 +2391,18 @@ export interface AddToDatabaseProps {
2230
2391
  onAdd: (record: DatabaseRecord) => void
2231
2392
  }
2232
2393
 
2233
- export const DatabaseSelectInput = ({ AddToDatabase, field, value: _value, onChange, onDatabaseSelect, responses, size, disabled, enduser }: FormInputProps<'Database Select'> & {
2394
+ export const DatabaseSelectInput = ({ AddToDatabase, field, value: _value, onChange, onDatabaseSelect, responses, size, disabled, enduser, inputProps }: FormInputProps<'Database Select'> & {
2234
2395
  responses: FormResponseValue[],
2235
2396
  AddToDatabase?: React.JSXElementConstructor<AddToDatabaseProps>,
2397
+ inputProps?: { sx: SxProps },
2236
2398
  }) => {
2237
2399
  const [typing, setTyping] = useState('')
2238
- const { addChoice, choices, doneLoading } = useDatabaseChoices({
2400
+ const [open, setOpen] = useState(false)
2401
+ const { addChoice, choices, doneLoading, isSearching, minSearchChars } = useDatabaseChoices({
2239
2402
  databaseId: field.options?.databaseId,
2240
2403
  field,
2241
2404
  otherAnswers: get_other_answers(_value, field?.options?.other ? typing : undefined),
2405
+ searchQuery: typing,
2242
2406
  })
2243
2407
 
2244
2408
  const value = React.useMemo(() => {
@@ -2246,8 +2410,8 @@ export const DatabaseSelectInput = ({ AddToDatabase, field, value: _value, onCha
2246
2410
  // if the value is a string (some single answer that was save), make sure we coerce to array
2247
2411
  const __value = typeof _value === 'string' ? [_value] : _value
2248
2412
  return (
2249
- (__value?.map(v =>
2250
- choices.find(c =>
2413
+ (__value?.map(v =>
2414
+ choices.find(c =>
2251
2415
  c.id === v.recordId || (typeof v === 'string' && label_for_database_record(field, c) === v)
2252
2416
  )
2253
2417
  )?.filter(v => v!) ?? []) as DatabaseRecord[]
@@ -2356,12 +2520,21 @@ export const DatabaseSelectInput = ({ AddToDatabase, field, value: _value, onCha
2356
2520
  return filtered
2357
2521
  }, [field, stateFilteredChoices])
2358
2522
 
2523
+ // Show placeholder when typing but below minimum search characters
2524
+ const charsNeeded = typing.trim().length > 0 && typing.trim().length < minSearchChars
2525
+ ? minSearchChars - typing.trim().length
2526
+ : 0
2527
+
2359
2528
  if (!doneLoading) return <LinearProgress />
2360
2529
  return (
2361
2530
  <>
2362
2531
  <Autocomplete id={field.id} freeSolo={false} size={size}
2363
2532
  componentsProps={{ popper: { sx: { wordBreak: "break-word" } } } }
2364
2533
  options={filteredChoices} multiple={true}
2534
+ loading={isSearching}
2535
+ open={open}
2536
+ onOpen={() => setOpen(true)}
2537
+ onClose={() => setOpen(false)}
2365
2538
  getOptionLabel={o => (
2366
2539
  Array.isArray(o) // edge case
2367
2540
  ? ''
@@ -2396,7 +2569,23 @@ export const DatabaseSelectInput = ({ AddToDatabase, field, value: _value, onCha
2396
2569
  }}
2397
2570
  inputValue={typing}
2398
2571
  onInputChange={(e, v) => e && setTyping(v)}
2399
- renderInput={params => <TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }} />}
2572
+ renderInput={params => (
2573
+ <TextField
2574
+ {...params}
2575
+ InputProps={{
2576
+ ...params.InputProps,
2577
+ sx: (inputProps || defaultInputProps).sx,
2578
+ endAdornment: (
2579
+ <>
2580
+ {isSearching ? <CircularProgress color="inherit" size={20} /> : null}
2581
+ {params.InputProps.endAdornment}
2582
+ </>
2583
+ ),
2584
+ }}
2585
+ placeholder={charsNeeded > 0 ? `Type ${charsNeeded} more character${charsNeeded > 1 ? 's' : ''} to search...` : undefined}
2586
+ helperText={charsNeeded > 0 ? `Type ${charsNeeded} more character${charsNeeded > 1 ? 's' : ''} to search` : undefined}
2587
+ />
2588
+ )}
2400
2589
  // use custom Chip to ensure very long entries break properly (whitespace: normal)
2401
2590
  renderTags={(value, getTagProps) =>
2402
2591
  value.map((value, index) => (
@@ -3126,7 +3315,7 @@ export const contact_is_valid = (e: Partial<Enduser>) => {
3126
3315
  }
3127
3316
  }
3128
3317
 
3129
- export const RelatedContactsInput = ({ field, value: _value, onChange, ...props }: FormInputProps<'Related Contacts'>) => {
3318
+ export const RelatedContactsInput = ({ field, value: _value, onChange, error: parentError, ...props }: FormInputProps<'Related Contacts'>) => {
3130
3319
  // safeguard against any rogue values like empty string
3131
3320
  const value = Array.isArray(_value) ? _value : []
3132
3321
 
@@ -3201,7 +3390,7 @@ export const RelatedContactsInput = ({ field, value: _value, onChange, ...props
3201
3390
  <Grid item xs={4}>
3202
3391
  <TextField label="Phone Number" size="small" fullWidth
3203
3392
  InputProps={defaultInputProps}
3204
- value={phone} onChange={e => onChange(value.map((v, i) => i === editing ? { ...v, phone: e.target.value } : v), field.id)}
3393
+ value={phone} onChange={e => onChange(value.map((v, i) => i === editing ? { ...v, phone: e.target.value.trim() } : v), field.id)}
3205
3394
  />
3206
3395
  </Grid>
3207
3396
  }
@@ -3255,7 +3444,7 @@ export const RelatedContactsInput = ({ field, value: _value, onChange, ...props
3255
3444
  }
3256
3445
 
3257
3446
  <Grid item sx={{ my: 0.75 }}>
3258
- <Button variant="outlined" onClick={() => setEditing(-1)} size="small">
3447
+ <Button variant="outlined" onClick={() => setEditing(-1)} size="small" disabled={!!errorMessage || !!parentError}>
3259
3448
  Save Contact
3260
3449
  </Button>
3261
3450
  </Grid>