@tellescope/react-components 1.232.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 (40) hide show
  1. package/lib/cjs/Forms/forms.v2.d.ts +1 -1
  2. package/lib/cjs/Forms/hooks.d.ts.map +1 -1
  3. package/lib/cjs/Forms/hooks.js +24 -0
  4. package/lib/cjs/Forms/hooks.js.map +1 -1
  5. package/lib/cjs/Forms/inputs.d.ts +4 -1
  6. package/lib/cjs/Forms/inputs.d.ts.map +1 -1
  7. package/lib/cjs/Forms/inputs.js +100 -26
  8. package/lib/cjs/Forms/inputs.js.map +1 -1
  9. package/lib/cjs/Forms/inputs.v2.d.ts +5 -7
  10. package/lib/cjs/Forms/inputs.v2.d.ts.map +1 -1
  11. package/lib/cjs/Forms/inputs.v2.js +7 -234
  12. package/lib/cjs/Forms/inputs.v2.js.map +1 -1
  13. package/lib/esm/CMS/components.d.ts +0 -1
  14. package/lib/esm/CMS/components.d.ts.map +1 -1
  15. package/lib/esm/Forms/form_responses.d.ts +0 -1
  16. package/lib/esm/Forms/form_responses.d.ts.map +1 -1
  17. package/lib/esm/Forms/forms.d.ts +3 -3
  18. package/lib/esm/Forms/forms.v2.d.ts +4 -4
  19. package/lib/esm/Forms/hooks.d.ts +0 -1
  20. package/lib/esm/Forms/hooks.d.ts.map +1 -1
  21. package/lib/esm/Forms/hooks.js +24 -0
  22. package/lib/esm/Forms/hooks.js.map +1 -1
  23. package/lib/esm/Forms/inputs.d.ts +5 -2
  24. package/lib/esm/Forms/inputs.d.ts.map +1 -1
  25. package/lib/esm/Forms/inputs.js +101 -27
  26. package/lib/esm/Forms/inputs.js.map +1 -1
  27. package/lib/esm/Forms/inputs.v2.d.ts +6 -8
  28. package/lib/esm/Forms/inputs.v2.d.ts.map +1 -1
  29. package/lib/esm/Forms/inputs.v2.js +7 -234
  30. package/lib/esm/Forms/inputs.v2.js.map +1 -1
  31. package/lib/esm/controls.d.ts +2 -2
  32. package/lib/esm/inputs.d.ts +1 -1
  33. package/lib/esm/inputs.native.d.ts +0 -1
  34. package/lib/esm/inputs.native.d.ts.map +1 -1
  35. package/lib/esm/state.d.ts +315 -315
  36. package/lib/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +9 -9
  38. package/src/Forms/hooks.tsx +33 -5
  39. package/src/Forms/inputs.tsx +151 -29
  40. package/src/Forms/inputs.v2.tsx +9 -299
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tellescope/react-components",
3
- "version": "1.232.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.232.0",
51
- "@tellescope/sdk": "1.232.0",
52
- "@tellescope/types-client": "1.232.0",
53
- "@tellescope/types-models": "1.232.0",
54
- "@tellescope/types-utilities": "1.232.0",
55
- "@tellescope/utilities": "1.232.0",
56
- "@tellescope/validation": "1.232.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": "b79c7d50da5ff767345e58331f483cc541abef20",
86
+ "gitHead": "1cbb2f579785066cd64d72b4bfdd2c788e192391",
87
87
  "publishConfig": {
88
88
  "access": "public"
89
89
  }
@@ -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,6 +1,6 @@
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"
@@ -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>
@@ -2191,48 +2201,126 @@ const choicesForDatabase: {
2191
2201
  const preventRefetch: Record<string, boolean> = {}
2192
2202
 
2193
2203
  const LOAD_CHOICES_LIMIT = 500
2194
- 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
+ }) => {
2195
2218
  const session = useResolvedSession()
2196
- 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)
2197
2223
 
2198
- // todo: make searchable, don't load all
2224
+ // Load initial page on mount (only once, not recursively)
2225
+ const initialLoadRef = useRef(false)
2199
2226
  useEffect(() => {
2200
- if (choicesForDatabase[databaseId]?.done) return
2201
- if (renderCount > 100) return // limit to 50000 entries / prevent infinite looping
2202
- const choices = choicesForDatabase[databaseId]?.records ?? []
2203
- 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
+ }
2204
2232
 
2205
- if (preventRefetch[databaseId + field.id + lastId]) return
2206
- preventRefetch[databaseId + field.id + lastId] = true
2233
+ initialLoadRef.current = true
2234
+ preventRefetch[databaseId + field.id] = true
2207
2235
 
2208
2236
  session.api.form_fields.load_choices_from_database({
2209
2237
  fieldId: field.id,
2210
- lastId,
2211
2238
  limit: LOAD_CHOICES_LIMIT,
2212
2239
  databaseId, // overrides fieldId, supports using Database question in Table Input
2213
2240
  })
2214
2241
  .then(({ choices: newChoices }) => {
2215
2242
  choicesForDatabase[databaseId] = {
2216
2243
  lastId: newChoices?.[newChoices.length - 1]?.id,
2217
- records: [...choices, ...newChoices]
2218
- .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) => (
2219
2302
  label_for_database_record(field, c1)
2220
2303
  .localeCompare(label_for_database_record(field, c2))
2221
- )
2222
- ),
2223
- done: newChoices.length < LOAD_CHOICES_LIMIT,
2224
- }
2225
- 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)
2226
2314
  })
2227
2315
  .catch(err => {
2228
2316
  console.error(err)
2229
- preventRefetch[databaseId + field.id + lastId] = false
2317
+ setIsSearching(false)
2230
2318
  })
2231
- }, [session, field, databaseId, renderCount])
2319
+ }, [session, field, databaseId, debouncedSearch])
2232
2320
 
2233
2321
  const addChoice = useCallback((record: DatabaseRecord) => {
2234
2322
  if (!choicesForDatabase[databaseId]) {
2235
- choicesForDatabase[databaseId] = {
2323
+ choicesForDatabase[databaseId] = {
2236
2324
  done: false,
2237
2325
  records: [],
2238
2326
  }
@@ -2240,18 +2328,24 @@ const useDatabaseChoices = ({ databaseId='', field, otherAnswers } : { databaseI
2240
2328
  choicesForDatabase[databaseId].records!.push(record)
2241
2329
  }, [choicesForDatabase, databaseId])
2242
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
+
2243
2336
  return {
2244
2337
  addChoice,
2245
- doneLoading: choicesForDatabase[databaseId]?.done ?? false,
2338
+ doneLoading: initialLoadComplete,
2339
+ isSearching,
2246
2340
  choices: [
2247
- ...choicesForDatabase[databaseId]?.records ?? [],
2341
+ ...activeChoices,
2248
2342
  ...(otherAnswers || []).map(v => ({
2249
2343
  id: v.text,
2250
2344
  databaseId,
2251
2345
  values: [{ label: field.options?.databaseLabel || '', type: 'Text', value: v.text }],
2252
2346
  }) as Pick<DatabaseRecord, 'id' | 'values' | 'databaseId'>)
2253
2347
  ],
2254
- renderCount,
2348
+ minSearchChars: MIN_SEARCH_CHARS,
2255
2349
  }
2256
2350
  }
2257
2351
 
@@ -2297,15 +2391,18 @@ export interface AddToDatabaseProps {
2297
2391
  onAdd: (record: DatabaseRecord) => void
2298
2392
  }
2299
2393
 
2300
- 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'> & {
2301
2395
  responses: FormResponseValue[],
2302
2396
  AddToDatabase?: React.JSXElementConstructor<AddToDatabaseProps>,
2397
+ inputProps?: { sx: SxProps },
2303
2398
  }) => {
2304
2399
  const [typing, setTyping] = useState('')
2305
- const { addChoice, choices, doneLoading } = useDatabaseChoices({
2400
+ const [open, setOpen] = useState(false)
2401
+ const { addChoice, choices, doneLoading, isSearching, minSearchChars } = useDatabaseChoices({
2306
2402
  databaseId: field.options?.databaseId,
2307
2403
  field,
2308
2404
  otherAnswers: get_other_answers(_value, field?.options?.other ? typing : undefined),
2405
+ searchQuery: typing,
2309
2406
  })
2310
2407
 
2311
2408
  const value = React.useMemo(() => {
@@ -2313,8 +2410,8 @@ export const DatabaseSelectInput = ({ AddToDatabase, field, value: _value, onCha
2313
2410
  // if the value is a string (some single answer that was save), make sure we coerce to array
2314
2411
  const __value = typeof _value === 'string' ? [_value] : _value
2315
2412
  return (
2316
- (__value?.map(v =>
2317
- choices.find(c =>
2413
+ (__value?.map(v =>
2414
+ choices.find(c =>
2318
2415
  c.id === v.recordId || (typeof v === 'string' && label_for_database_record(field, c) === v)
2319
2416
  )
2320
2417
  )?.filter(v => v!) ?? []) as DatabaseRecord[]
@@ -2423,12 +2520,21 @@ export const DatabaseSelectInput = ({ AddToDatabase, field, value: _value, onCha
2423
2520
  return filtered
2424
2521
  }, [field, stateFilteredChoices])
2425
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
+
2426
2528
  if (!doneLoading) return <LinearProgress />
2427
2529
  return (
2428
2530
  <>
2429
2531
  <Autocomplete id={field.id} freeSolo={false} size={size}
2430
2532
  componentsProps={{ popper: { sx: { wordBreak: "break-word" } } } }
2431
2533
  options={filteredChoices} multiple={true}
2534
+ loading={isSearching}
2535
+ open={open}
2536
+ onOpen={() => setOpen(true)}
2537
+ onClose={() => setOpen(false)}
2432
2538
  getOptionLabel={o => (
2433
2539
  Array.isArray(o) // edge case
2434
2540
  ? ''
@@ -2463,7 +2569,23 @@ export const DatabaseSelectInput = ({ AddToDatabase, field, value: _value, onCha
2463
2569
  }}
2464
2570
  inputValue={typing}
2465
2571
  onInputChange={(e, v) => e && setTyping(v)}
2466
- 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
+ )}
2467
2589
  // use custom Chip to ensure very long entries break properly (whitespace: normal)
2468
2590
  renderTags={(value, getTagProps) =>
2469
2591
  value.map((value, index) => (