@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.
- package/lib/cjs/Forms/forms.js +1 -1
- package/lib/cjs/Forms/forms.js.map +1 -1
- package/lib/cjs/Forms/forms.v2.d.ts +1 -1
- package/lib/cjs/Forms/forms.v2.js +1 -1
- package/lib/cjs/Forms/forms.v2.js.map +1 -1
- package/lib/cjs/Forms/hooks.d.ts.map +1 -1
- package/lib/cjs/Forms/hooks.js +24 -0
- package/lib/cjs/Forms/hooks.js.map +1 -1
- package/lib/cjs/Forms/inputs.d.ts +6 -3
- package/lib/cjs/Forms/inputs.d.ts.map +1 -1
- package/lib/cjs/Forms/inputs.js +171 -44
- package/lib/cjs/Forms/inputs.js.map +1 -1
- package/lib/cjs/Forms/inputs.v2.d.ts +7 -11
- package/lib/cjs/Forms/inputs.v2.d.ts.map +1 -1
- package/lib/cjs/Forms/inputs.v2.js +16 -445
- package/lib/cjs/Forms/inputs.v2.js.map +1 -1
- package/lib/esm/CMS/components.d.ts +0 -1
- package/lib/esm/CMS/components.d.ts.map +1 -1
- package/lib/esm/Forms/form_responses.d.ts +0 -1
- package/lib/esm/Forms/form_responses.d.ts.map +1 -1
- package/lib/esm/Forms/forms.d.ts +3 -3
- package/lib/esm/Forms/forms.js +1 -1
- package/lib/esm/Forms/forms.js.map +1 -1
- package/lib/esm/Forms/forms.v2.d.ts +4 -4
- package/lib/esm/Forms/forms.v2.js +1 -1
- package/lib/esm/Forms/forms.v2.js.map +1 -1
- package/lib/esm/Forms/hooks.d.ts +0 -1
- package/lib/esm/Forms/hooks.d.ts.map +1 -1
- package/lib/esm/Forms/hooks.js +24 -0
- package/lib/esm/Forms/hooks.js.map +1 -1
- package/lib/esm/Forms/inputs.d.ts +7 -4
- package/lib/esm/Forms/inputs.d.ts.map +1 -1
- package/lib/esm/Forms/inputs.js +173 -46
- package/lib/esm/Forms/inputs.js.map +1 -1
- package/lib/esm/Forms/inputs.v2.d.ts +8 -12
- package/lib/esm/Forms/inputs.v2.d.ts.map +1 -1
- package/lib/esm/Forms/inputs.v2.js +17 -446
- package/lib/esm/Forms/inputs.v2.js.map +1 -1
- package/lib/esm/controls.d.ts +2 -2
- package/lib/esm/inputs.d.ts +1 -1
- package/lib/esm/inputs.native.d.ts +0 -1
- package/lib/esm/inputs.native.d.ts.map +1 -1
- package/lib/esm/state.d.ts +315 -315
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -9
- package/src/Forms/forms.tsx +1 -1
- package/src/Forms/forms.v2.tsx +1 -1
- package/src/Forms/hooks.tsx +33 -5
- package/src/Forms/inputs.tsx +224 -35
- 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.
|
|
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.
|
|
51
|
-
"@tellescope/sdk": "1.
|
|
52
|
-
"@tellescope/types-client": "1.
|
|
53
|
-
"@tellescope/types-models": "1.
|
|
54
|
-
"@tellescope/types-utilities": "1.
|
|
55
|
-
"@tellescope/utilities": "1.
|
|
56
|
-
"@tellescope/validation": "1.
|
|
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": "
|
|
86
|
+
"gitHead": "1cbb2f579785066cd64d72b4bfdd2c788e192391",
|
|
87
87
|
"publishConfig": {
|
|
88
88
|
"access": "public"
|
|
89
89
|
}
|
package/src/Forms/forms.tsx
CHANGED
|
@@ -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} />
|
package/src/Forms/forms.v2.tsx
CHANGED
|
@@ -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} />
|
package/src/Forms/hooks.tsx
CHANGED
|
@@ -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(() => (
|
package/src/Forms/inputs.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
{
|
|
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
|
|
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 [
|
|
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
|
-
//
|
|
2224
|
+
// Load initial page on mount (only once, not recursively)
|
|
2225
|
+
const initialLoadRef = useRef(false)
|
|
2132
2226
|
useEffect(() => {
|
|
2133
|
-
if (
|
|
2134
|
-
if (
|
|
2135
|
-
|
|
2136
|
-
|
|
2227
|
+
if (initialLoadRef.current) return
|
|
2228
|
+
if (choicesForDatabase[databaseId]?.done || choicesForDatabase[databaseId]?.records?.length) {
|
|
2229
|
+
setInitialLoadComplete(true)
|
|
2230
|
+
return
|
|
2231
|
+
}
|
|
2137
2232
|
|
|
2138
|
-
|
|
2139
|
-
preventRefetch[databaseId + field.id
|
|
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:
|
|
2151
|
-
|
|
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
|
-
|
|
2157
|
-
}
|
|
2158
|
-
|
|
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
|
-
|
|
2317
|
+
setIsSearching(false)
|
|
2163
2318
|
})
|
|
2164
|
-
}, [session, field, databaseId,
|
|
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:
|
|
2338
|
+
doneLoading: initialLoadComplete,
|
|
2339
|
+
isSearching,
|
|
2179
2340
|
choices: [
|
|
2180
|
-
...
|
|
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
|
-
|
|
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
|
|
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 =>
|
|
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>
|