@tellescope/react-components 1.227.0 → 1.229.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/lib/cjs/Forms/forms.v2.d.ts +116 -0
- package/lib/cjs/Forms/forms.v2.d.ts.map +1 -0
- package/lib/cjs/Forms/forms.v2.js +760 -0
- package/lib/cjs/Forms/forms.v2.js.map +1 -0
- package/lib/cjs/Forms/hooks.d.ts.map +1 -1
- package/lib/cjs/Forms/hooks.js +8 -3
- package/lib/cjs/Forms/hooks.js.map +1 -1
- package/lib/cjs/Forms/index.d.ts +1 -0
- package/lib/cjs/Forms/index.d.ts.map +1 -1
- package/lib/cjs/Forms/index.js +6 -0
- package/lib/cjs/Forms/index.js.map +1 -1
- package/lib/cjs/Forms/inputs.v2.d.ts +81 -0
- package/lib/cjs/Forms/inputs.v2.d.ts.map +1 -0
- package/lib/cjs/Forms/inputs.v2.js +2289 -0
- package/lib/cjs/Forms/inputs.v2.js.map +1 -0
- package/lib/cjs/Forms/localization.d.ts.map +1 -1
- package/lib/cjs/Forms/localization.js +3 -0
- package/lib/cjs/Forms/localization.js.map +1 -1
- package/lib/cjs/Forms/types.d.ts +1 -0
- package/lib/cjs/Forms/types.d.ts.map +1 -1
- package/lib/cjs/state.d.ts +34 -0
- package/lib/cjs/state.d.ts.map +1 -1
- package/lib/cjs/state.js +16 -2
- package/lib/cjs/state.js.map +1 -1
- package/lib/esm/Forms/forms.v2.d.ts +116 -0
- package/lib/esm/Forms/forms.v2.d.ts.map +1 -0
- package/lib/esm/Forms/forms.v2.js +725 -0
- package/lib/esm/Forms/forms.v2.js.map +1 -0
- package/lib/esm/Forms/hooks.d.ts.map +1 -1
- package/lib/esm/Forms/hooks.js +8 -3
- package/lib/esm/Forms/hooks.js.map +1 -1
- package/lib/esm/Forms/index.d.ts +1 -0
- package/lib/esm/Forms/index.d.ts.map +1 -1
- package/lib/esm/Forms/index.js +2 -0
- package/lib/esm/Forms/index.js.map +1 -1
- package/lib/esm/Forms/inputs.v2.d.ts +81 -0
- package/lib/esm/Forms/inputs.v2.d.ts.map +1 -0
- package/lib/esm/Forms/inputs.v2.js +2218 -0
- package/lib/esm/Forms/inputs.v2.js.map +1 -0
- package/lib/esm/Forms/localization.d.ts.map +1 -1
- package/lib/esm/Forms/localization.js +3 -0
- package/lib/esm/Forms/localization.js.map +1 -1
- package/lib/esm/Forms/types.d.ts +1 -0
- package/lib/esm/Forms/types.d.ts.map +1 -1
- package/lib/esm/state.d.ts +34 -0
- package/lib/esm/state.d.ts.map +1 -1
- package/lib/esm/state.js +13 -0
- package/lib/esm/state.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -9
- package/src/Forms/forms.v2.tsx +1321 -0
- package/src/Forms/hooks.tsx +10 -5
- package/src/Forms/index.ts +5 -2
- package/src/Forms/inputs.v2.tsx +3869 -0
- package/src/Forms/localization.ts +1 -0
- package/src/Forms/types.ts +1 -0
- package/src/state.tsx +25 -5
|
@@ -0,0 +1,3869 @@
|
|
|
1
|
+
import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
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"
|
|
4
|
+
import { FormInputProps } from "./types"
|
|
5
|
+
import { useDropzone } from "react-dropzone"
|
|
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, getLocalTimezone, getPublicFileURL, mm_dd_yyyy, replace_enduser_template_values, truncate_string, update_local_storage, user_display_name } from "@tellescope/utilities"
|
|
8
|
+
import { Address, DatabaseSelectResponse, Enduser, EnduserRelationship, FormResponseValue, InsuranceRelationship, MedicationResponse, MultipleChoiceOptions, FormFieldOptionDetails, TellescopeGender, TIMEZONES_USA } from "@tellescope/types-models"
|
|
9
|
+
import { VALID_STATES, emailValidator, phoneValidator } from "@tellescope/validation"
|
|
10
|
+
import Slider from '@mui/material/Slider';
|
|
11
|
+
import LinearProgress from '@mui/material/LinearProgress';
|
|
12
|
+
|
|
13
|
+
import DatePicker from "react-datepicker";
|
|
14
|
+
import { datepickerCSS } from "./css/react-datepicker" // avoids build issue with RN
|
|
15
|
+
import { CancelIcon, FileBlob, IconButton, LabeledIconButton, LoadingButton, Styled, form_display_text_for_language, isDateString, useProducts, useResolvedSession } from ".."
|
|
16
|
+
import { CalendarEvent, DatabaseRecord, FormField } from "@tellescope/types-client"
|
|
17
|
+
import { css } from '@emotion/css'
|
|
18
|
+
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd";
|
|
19
|
+
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
|
20
|
+
import heic2any from "heic2any"
|
|
21
|
+
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate';
|
|
22
|
+
import LanguageIcon from '@mui/icons-material/Language';
|
|
23
|
+
|
|
24
|
+
import { Elements, PaymentElement, useStripe, useElements, EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js';
|
|
25
|
+
import { loadStripe } from '@stripe/stripe-js';
|
|
26
|
+
import { CheckCircleOutline, Delete, Edit, ExpandMore, UploadFile } from "@mui/icons-material"
|
|
27
|
+
import { WYSIWYG } from "./wysiwyg"
|
|
28
|
+
|
|
29
|
+
export const LanguageSelect = ({ value, ...props }: { value: string, onChange: (s: string) => void}) => (
|
|
30
|
+
<Grid container alignItems="center" justifyContent={"center"} wrap="nowrap" spacing={1}>
|
|
31
|
+
<Grid item>
|
|
32
|
+
<LanguageIcon color="primary" />
|
|
33
|
+
</Grid>
|
|
34
|
+
|
|
35
|
+
<Grid item style={{ width: 150 }}>
|
|
36
|
+
<StringSelector {...props} options={["English", "Español"]} size="small"
|
|
37
|
+
value={value === 'Spanish' ? 'Español' : value}
|
|
38
|
+
label={
|
|
39
|
+
(value === 'Español' || value === 'Spanish') ? 'Idioma'
|
|
40
|
+
: "Language"
|
|
41
|
+
}
|
|
42
|
+
/>
|
|
43
|
+
</Grid>
|
|
44
|
+
</Grid>
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
export const defaultInputProps: { sx: SxProps } = { sx: {
|
|
48
|
+
borderRadius: 1.5,
|
|
49
|
+
backgroundColor: '#FFFFFF',
|
|
50
|
+
'& .MuiOutlinedInput-root': {
|
|
51
|
+
backgroundColor: '#FFFFFF',
|
|
52
|
+
'&.Mui-error': {
|
|
53
|
+
boxShadow: '0 0 8px 2px rgba(211, 47, 47, 0.3)',
|
|
54
|
+
},
|
|
55
|
+
'&.Mui-error .MuiOutlinedInput-notchedOutline': {
|
|
56
|
+
borderColor: '#d32f2f',
|
|
57
|
+
borderWidth: '2px',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
'& .MuiInputBase-input': {
|
|
61
|
+
padding: '10px 14px',
|
|
62
|
+
},
|
|
63
|
+
}}
|
|
64
|
+
export const defaultButtonStyles: React.CSSProperties = {
|
|
65
|
+
borderRadius: '10px',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const PdfViewer = ({ url, height=420 } : { url: string, height?: number }) => {
|
|
69
|
+
// const [numPages, setNumPages] = useState<number>();
|
|
70
|
+
// const [page, setPage] = useState(1);
|
|
71
|
+
|
|
72
|
+
// const parentRef = useRef<HTMLDivElement | null>(null);
|
|
73
|
+
// const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
74
|
+
|
|
75
|
+
// function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
|
|
76
|
+
// setNumPages(numPages);
|
|
77
|
+
// }
|
|
78
|
+
|
|
79
|
+
// const pdfHeight: number | undefined = pdfPage?._pageInfo?.view?.[3]
|
|
80
|
+
// const pdfWidth: number | undefined = pdfPage?._pageInfo?.view?.[2]
|
|
81
|
+
|
|
82
|
+
// const parentWidth = parentRef.current?.clientWidth
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Grid container direction="column">
|
|
86
|
+
{/* {!pdfDocument && <span>Loading pdf...</span>} */}
|
|
87
|
+
|
|
88
|
+
{/* <Grid item ref={parentRef} style={{ width: '100%' }}> */}
|
|
89
|
+
{/* {pdfDocument && pdfHeight && pdfWidth && parentWidth &&
|
|
90
|
+
<canvas ref={canvasRef} style={{
|
|
91
|
+
maxWidth: '100%',
|
|
92
|
+
maxHeight: parentWidth / pdfWidth * pdfHeight
|
|
93
|
+
}} />
|
|
94
|
+
} */}
|
|
95
|
+
{/* <Document file="somefile.pdf" onLoadSuccess={onDocumentLoadSuccess}>
|
|
96
|
+
<Page pageNumber={page} />
|
|
97
|
+
</Document>
|
|
98
|
+
</Grid> */}
|
|
99
|
+
|
|
100
|
+
{/* {pdfDocument && pdfHeight && pdfWidth && parentWidth && */}
|
|
101
|
+
{/* <Grid container alignItems="center" justifyContent="space-between">
|
|
102
|
+
<Button variant="outlined" disabled={page === 1} onClick={() => setPage(page - 1)}>
|
|
103
|
+
Previous Page
|
|
104
|
+
</Button>
|
|
105
|
+
<Button variant="outlined"
|
|
106
|
+
disabled={page === numPages}
|
|
107
|
+
onClick={() => setPage(page + 1)}
|
|
108
|
+
>
|
|
109
|
+
Next Page
|
|
110
|
+
</Button>
|
|
111
|
+
</Grid> */}
|
|
112
|
+
{/* } */}
|
|
113
|
+
<iframe
|
|
114
|
+
src={
|
|
115
|
+
// url
|
|
116
|
+
// encodeURI(`http://localhost:5173?url=${url}`)
|
|
117
|
+
// encodeURI(`http://tellescope-pdf-renderer.s3-website.us-east-2.amazonaws.com?url=${url}`)
|
|
118
|
+
encodeURI(`https://pdf.tellescope.com?url=${url}`)
|
|
119
|
+
}
|
|
120
|
+
title="PDF Viewer"
|
|
121
|
+
style={{
|
|
122
|
+
border: 'none',
|
|
123
|
+
height,
|
|
124
|
+
width: '100%',
|
|
125
|
+
marginBottom: '5px'
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
|
|
129
|
+
<a href={url} target="__blank" rel="noopener noreferrer"
|
|
130
|
+
style={{ marginTop: 5 }}
|
|
131
|
+
>
|
|
132
|
+
View in new tab or download here
|
|
133
|
+
</a>
|
|
134
|
+
</Grid>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const RatingInput = ({ field, value, onChange }: FormInputProps<'rating'>) => {
|
|
139
|
+
const from = field?.options?.from ?? 1 // allow 0
|
|
140
|
+
const to = field?.options?.to ?? 10 // allow 0
|
|
141
|
+
|
|
142
|
+
const step = field.options?.rangeStepSize || 1
|
|
143
|
+
const allMarks = []
|
|
144
|
+
for (let i=from; i<=to; i+=(step)) {
|
|
145
|
+
allMarks.push({ value: i, label: i })
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let marks = [...allMarks]
|
|
149
|
+
while (marks.length > 25) {
|
|
150
|
+
marks = marks.filter((_, i) => i%2 === 0)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<Slider min={from} max={to} step={step} marks={marks}
|
|
155
|
+
valueLabelDisplay={marks.length < allMarks.length ? 'auto' : "off"}
|
|
156
|
+
value={value}
|
|
157
|
+
onChange={(e, v) => onChange(v as number, field.id)}
|
|
158
|
+
sx={{
|
|
159
|
+
'& .MuiSlider-thumb': value === undefined ? { display: 'none' } : {}, // Hide thumb until value is set
|
|
160
|
+
}}
|
|
161
|
+
key={field.id} // forces the actual value to be shown when two Rating questions are shown in a row and the value changes
|
|
162
|
+
/>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// a little function to help us with reordering the result
|
|
167
|
+
const reorder = (list: any[], startIndex: number, endIndex: number) => {
|
|
168
|
+
const result = Array.from(list);
|
|
169
|
+
const [removed] = result.splice(startIndex, 1);
|
|
170
|
+
result.splice(endIndex, 0, removed);
|
|
171
|
+
|
|
172
|
+
return result;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const grid = 8;
|
|
176
|
+
|
|
177
|
+
const getItemStyle = (isDragging: boolean, draggableStyle?: React.CSSProperties): React.CSSProperties => ({
|
|
178
|
+
// some basic styles to make the items look a bit nicer
|
|
179
|
+
userSelect: "none",
|
|
180
|
+
padding: `${grid * 2}px`,
|
|
181
|
+
margin: `0 0 ${grid}px 0`,
|
|
182
|
+
|
|
183
|
+
// change background colour if dragging
|
|
184
|
+
backgroundColor: isDragging ? "#ffffff88" : undefined,
|
|
185
|
+
border: '1px solid',
|
|
186
|
+
borderColor: "primary.main",
|
|
187
|
+
borderRadius: 5,
|
|
188
|
+
|
|
189
|
+
// styles we need to apply on draggables
|
|
190
|
+
...draggableStyle
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const getListStyle = (isDraggingOver: boolean) => ({
|
|
194
|
+
// background: isDraggingOver ? "#ffffff44" : undefined,
|
|
195
|
+
// padding: `${grid}px`,
|
|
196
|
+
// width: '250px'
|
|
197
|
+
});
|
|
198
|
+
export const RankingInput = ({ field, value, onChange }: FormInputProps<'ranking'>) => {
|
|
199
|
+
return (
|
|
200
|
+
<Grid container direction='column'>
|
|
201
|
+
{/* <Typography>Most</Typography> */}
|
|
202
|
+
|
|
203
|
+
<DragDropContext onDragEnd={result => {
|
|
204
|
+
if (!value) return
|
|
205
|
+
if (!result.destination) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
onChange(reorder(
|
|
210
|
+
value,
|
|
211
|
+
result.source.index,
|
|
212
|
+
result.destination.index
|
|
213
|
+
), field.id)
|
|
214
|
+
}}>
|
|
215
|
+
<Droppable droppableId="droppable">
|
|
216
|
+
{(provided, snapshot) => (
|
|
217
|
+
<Box
|
|
218
|
+
{...provided.droppableProps}
|
|
219
|
+
ref={provided.innerRef}
|
|
220
|
+
sx={getListStyle(snapshot.isDraggingOver)}
|
|
221
|
+
>
|
|
222
|
+
{(value ?? []).map((item, index) => (
|
|
223
|
+
<Draggable key={item} draggableId={item} index={index}>
|
|
224
|
+
{(provided, snapshot) => (
|
|
225
|
+
<Grid container alignItems="center" justifyContent="space-between"
|
|
226
|
+
ref={provided.innerRef}
|
|
227
|
+
{...provided.draggableProps}
|
|
228
|
+
{...provided.dragHandleProps}
|
|
229
|
+
sx={getItemStyle(
|
|
230
|
+
snapshot.isDragging,
|
|
231
|
+
provided.draggableProps.style
|
|
232
|
+
)}
|
|
233
|
+
>
|
|
234
|
+
{item}
|
|
235
|
+
<DragIndicatorIcon color="primary" />
|
|
236
|
+
</Grid>
|
|
237
|
+
)}
|
|
238
|
+
</Draggable>
|
|
239
|
+
))}
|
|
240
|
+
{provided.placeholder}
|
|
241
|
+
</Box>
|
|
242
|
+
)}
|
|
243
|
+
</Droppable>
|
|
244
|
+
</DragDropContext>
|
|
245
|
+
|
|
246
|
+
<Typography color="primary" style={{ marginTop: 3 }}>
|
|
247
|
+
Drag and drop to re-order the above options
|
|
248
|
+
</Typography>
|
|
249
|
+
|
|
250
|
+
{/* <Typography>Least</Typography> */}
|
|
251
|
+
</Grid>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const CustomDateInput = forwardRef((props: TextFieldProps, ref) => (
|
|
256
|
+
<TextField InputProps={defaultInputProps}
|
|
257
|
+
fullWidth inputRef={ref} {...props}
|
|
258
|
+
/>
|
|
259
|
+
))
|
|
260
|
+
export const DateInput = ({
|
|
261
|
+
field, value, onChange, placement='top', ...props
|
|
262
|
+
} : {
|
|
263
|
+
field: FormField,
|
|
264
|
+
placement?: 'top' | 'right' | 'bottom' | 'left'
|
|
265
|
+
} & FormInputProps<'date'> & Styled) => {
|
|
266
|
+
const inputRef = useRef(null);
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<DatePicker // wrap in item to prevent movement on focused
|
|
270
|
+
selected={value}
|
|
271
|
+
onChange={(d: Date) => onChange?.(d, field.id)}
|
|
272
|
+
showTimeSelect
|
|
273
|
+
required={!field.isOptional}
|
|
274
|
+
dateFormat="Pp"
|
|
275
|
+
autoComplete="off"
|
|
276
|
+
timeIntervals={15} // 30 is default
|
|
277
|
+
popperPlacement={placement}
|
|
278
|
+
customInput={<CustomDateInput inputRef={inputRef} {...props} />}
|
|
279
|
+
// className={css`width: 100%;`}
|
|
280
|
+
className={css`${datepickerCSS}`}
|
|
281
|
+
/>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export const TableInput = ({ field, value=[], onChange, ...props }: FormInputProps<'Input Table'>) => {
|
|
286
|
+
const choices = field.options?.tableChoices
|
|
287
|
+
|
|
288
|
+
const handleNewRow = useCallback(() => {
|
|
289
|
+
if (!choices?.length) return
|
|
290
|
+
|
|
291
|
+
onChange(
|
|
292
|
+
[...value, choices.map(c => ({
|
|
293
|
+
label: c.label,
|
|
294
|
+
entry: '',
|
|
295
|
+
}))],
|
|
296
|
+
field.id,
|
|
297
|
+
true
|
|
298
|
+
)
|
|
299
|
+
}, [value, field.id])
|
|
300
|
+
|
|
301
|
+
const handleChange = useCallback((r: number, c: number, u: { entry: string, label: string }) => {
|
|
302
|
+
onChange(
|
|
303
|
+
value.map((v, _i) =>
|
|
304
|
+
_i !== r
|
|
305
|
+
? v
|
|
306
|
+
: v.map((e, _c) => _c === c ? u : e)
|
|
307
|
+
),
|
|
308
|
+
field.id,
|
|
309
|
+
true,
|
|
310
|
+
)
|
|
311
|
+
}, [value, onChange, field.id])
|
|
312
|
+
|
|
313
|
+
const handleRemove = useCallback((i: number) => {
|
|
314
|
+
onChange(
|
|
315
|
+
value.filter((_, _i) => i !== _i),
|
|
316
|
+
field.id,
|
|
317
|
+
true,
|
|
318
|
+
)
|
|
319
|
+
}, [value,onChange, field.id])
|
|
320
|
+
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
if (field.isOptional) return
|
|
323
|
+
if (value.length) return
|
|
324
|
+
|
|
325
|
+
handleNewRow()
|
|
326
|
+
}, [field.isOptional, value, handleNewRow])
|
|
327
|
+
|
|
328
|
+
if (!choices?.length) {
|
|
329
|
+
return <Typography color="error">No input choices available</Typography>
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const length = choices.length || 1
|
|
333
|
+
const iconWidth = '35px'
|
|
334
|
+
const width = `calc(${(100 / length).toFixed(2)}% - calc(${iconWidth} / ${length}))`
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<Grid container direction="column">
|
|
338
|
+
{value.map((row, i) => (
|
|
339
|
+
<>
|
|
340
|
+
<Grid container alignItems="center" key={i} spacing={1}>
|
|
341
|
+
{choices.map((v, columnIndex) => (
|
|
342
|
+
<Grid item key={v.label} sx={{ width }}>
|
|
343
|
+
{v.type === 'Text'
|
|
344
|
+
? (
|
|
345
|
+
<TextField label={v.label} size="small" fullWidth title={v.label}
|
|
346
|
+
InputProps={defaultInputProps}
|
|
347
|
+
value={row.find((c, _i) => columnIndex === _i)?.entry}
|
|
348
|
+
onChange={e => handleChange(i, columnIndex, { label: v.label, entry: e.target.value })}
|
|
349
|
+
/>
|
|
350
|
+
)
|
|
351
|
+
: v.type === 'Date' ? (
|
|
352
|
+
<DateStringInput label={v.label} size="small" fullWidth title={v.label}
|
|
353
|
+
field={field}
|
|
354
|
+
value={row.find((c, _i) => columnIndex === _i)?.entry}
|
|
355
|
+
onChange={(entry='') => handleChange(i, columnIndex, { label: v.label, entry })}
|
|
356
|
+
/>
|
|
357
|
+
)
|
|
358
|
+
: v.type === 'Select' ? (
|
|
359
|
+
<FormControl size="small" fullWidth>
|
|
360
|
+
<InputLabel id="demo-select-small">{v.label}</InputLabel>
|
|
361
|
+
<Select label={v.label} size="small" title={v.label}
|
|
362
|
+
sx={defaultInputProps.sx}
|
|
363
|
+
value={row.find((c, _i) => columnIndex === _i)?.entry}
|
|
364
|
+
onChange={e => handleChange(i, columnIndex, { label: v.label, entry: e.target.value })}
|
|
365
|
+
>
|
|
366
|
+
<MenuItem value="">
|
|
367
|
+
<em>None</em>
|
|
368
|
+
</MenuItem>
|
|
369
|
+
{v.info.choices.map(c => (
|
|
370
|
+
<MenuItem key={c} value={c}>{c}</MenuItem>
|
|
371
|
+
))}
|
|
372
|
+
</Select>
|
|
373
|
+
</FormControl>
|
|
374
|
+
)
|
|
375
|
+
: (v.type === 'Database' && v.info.databaseId && v.info.databaseLabel) ? (
|
|
376
|
+
<DatabaseSelectInput responses={[]} size="small"
|
|
377
|
+
field={{
|
|
378
|
+
...field,
|
|
379
|
+
options: { databaseId: v.info.databaseId, databaseLabel: v.info.databaseLabel },
|
|
380
|
+
title: v.label,
|
|
381
|
+
}}
|
|
382
|
+
value={row.find((_, _i) => columnIndex === _i)?.entry ? [{
|
|
383
|
+
text: JSON.parse(row.find((_, _i) => columnIndex === _i)?.entry || '{}').text || '',
|
|
384
|
+
databaseId: JSON.parse(row.find((_, _i) => columnIndex === _i)?.entry || '{}').databaseId || '',
|
|
385
|
+
recordId: JSON.parse(row.find((_, _i) => columnIndex === _i)?.entry || '{}').recordId || '',
|
|
386
|
+
}] : []}
|
|
387
|
+
onChange={
|
|
388
|
+
(records) => handleChange(i, columnIndex, { label: v.label, entry: JSON.stringify(records?.[0] ?? '') })
|
|
389
|
+
}
|
|
390
|
+
/>
|
|
391
|
+
)
|
|
392
|
+
: null
|
|
393
|
+
}
|
|
394
|
+
</Grid>
|
|
395
|
+
))}
|
|
396
|
+
|
|
397
|
+
<Grid item sx={{ ml: 'auto', width: iconWidth }}>
|
|
398
|
+
<LabeledIconButton Icon={CancelIcon} label="Remove" onClick={() => handleRemove(i)}
|
|
399
|
+
disabled={!field.isOptional && value.length === 1}
|
|
400
|
+
/>
|
|
401
|
+
</Grid>
|
|
402
|
+
</Grid>
|
|
403
|
+
|
|
404
|
+
<Divider flexItem sx={{ my: 1 }} />
|
|
405
|
+
</>
|
|
406
|
+
))}
|
|
407
|
+
|
|
408
|
+
<Button variant="outlined" size="small" onClick={handleNewRow} sx={{ width: 200 }}>
|
|
409
|
+
Add new entry
|
|
410
|
+
</Button>
|
|
411
|
+
</Grid>
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export const AutoFocusTextField = (props: TextFieldProps) => (
|
|
416
|
+
<TextField InputProps={defaultInputProps} {...props} />
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
const CustomDateStringInput = forwardRef((props: TextFieldProps, ref) => (
|
|
420
|
+
<TextField InputProps={defaultInputProps}
|
|
421
|
+
fullWidth inputRef={ref} {...props}
|
|
422
|
+
/>
|
|
423
|
+
))
|
|
424
|
+
export const DateStringInput = ({ field, value, onChange, ...props }: FormInputProps<'string'>) => {
|
|
425
|
+
const inputRef = useRef(null);
|
|
426
|
+
|
|
427
|
+
// if (value && isDateString(value)) {
|
|
428
|
+
// console.log(value, new Date(
|
|
429
|
+
// new Date(MM_DD_YYYY_to_YYYY_MM_DD(value)).getTime()
|
|
430
|
+
// + (new Date().getTimezoneOffset() * 60 * 1000)
|
|
431
|
+
// ))
|
|
432
|
+
// }
|
|
433
|
+
return (
|
|
434
|
+
field.options?.useDatePicker
|
|
435
|
+
? (
|
|
436
|
+
<DatePicker // wrap in item to prevent movement on focused
|
|
437
|
+
selected={
|
|
438
|
+
(value && isDateString(value))
|
|
439
|
+
? new Date(
|
|
440
|
+
new Date(MM_DD_YYYY_to_YYYY_MM_DD(value)).getTime()
|
|
441
|
+
+ ((new Date().getTimezoneOffset() + 60) * 60 * 1000) // additional hour (60 minutes) needs to be added for date to line up properly
|
|
442
|
+
)
|
|
443
|
+
: undefined
|
|
444
|
+
}
|
|
445
|
+
onChange={(d: Date) => onChange?.(mm_dd_yyyy(d), field.id)}
|
|
446
|
+
showTimeSelect={false}
|
|
447
|
+
required={!field.isOptional}
|
|
448
|
+
autoComplete="off"
|
|
449
|
+
dateFormat={"MM-dd-yyyy"}
|
|
450
|
+
customInput={<CustomDateStringInput inputRef={inputRef} {...props}
|
|
451
|
+
label={(!field.title && field.placeholder) ? field.placeholder : props.label}
|
|
452
|
+
/>}
|
|
453
|
+
// className={css`width: 100%;`}
|
|
454
|
+
className={css`${datepickerCSS}`}
|
|
455
|
+
/>
|
|
456
|
+
)
|
|
457
|
+
: (
|
|
458
|
+
<AutoFocusTextField {...props} required={!field.isOptional} fullWidth placeholder="MM-DD-YYYY" value={value}
|
|
459
|
+
label={(!field.title && field.placeholder) ? field.placeholder : props.label}
|
|
460
|
+
onChange={e => {
|
|
461
|
+
const v = e.target.value || ''
|
|
462
|
+
onChange(
|
|
463
|
+
(
|
|
464
|
+
v.length === 2 && /\d{2}/.test(v) && value?.length !== 3 // allow deletion
|
|
465
|
+
? v + '-'
|
|
466
|
+
: v.length === 5 && /\d{2}-\d{2}/.test(v) && value?.length !== 6 // allow deletion
|
|
467
|
+
? v + '-'
|
|
468
|
+
: v
|
|
469
|
+
)
|
|
470
|
+
.replaceAll('/', '-'),
|
|
471
|
+
field.id
|
|
472
|
+
)
|
|
473
|
+
}}
|
|
474
|
+
/>
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
export const StringInput = ({ field, value, form, onChange, ...props }: FormInputProps<'string'>) => (
|
|
479
|
+
<AutoFocusTextField {...props} required={!field.isOptional} fullWidth value={value} onChange={e => onChange(e.target.value, field.id)}
|
|
480
|
+
placeholder={(field.placeholder || form_display_text_for_language(form, "Answer here...", ''))}
|
|
481
|
+
label={(!field.title && field.placeholder) ? field.placeholder : props.label}
|
|
482
|
+
/>
|
|
483
|
+
)
|
|
484
|
+
export const StringLongInput = ({ field, value, onChange, form, ...props }: FormInputProps<'string'>) => (
|
|
485
|
+
<AutoFocusTextField {...props} multiline minRows={3} maxRows={8} required={!field.isOptional} fullWidth value={value} onChange={e => onChange(e.target.value, field.id)}
|
|
486
|
+
placeholder={field.placeholder || form_display_text_for_language(form, "Answer here...", '')}
|
|
487
|
+
label={(!field.title && field.placeholder) ? field.placeholder : props.label}
|
|
488
|
+
/>
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
export const PhoneInput = ({ field, value, onChange, form, ...props }: FormInputProps<'phone'>) => (
|
|
492
|
+
<AutoFocusTextField {...props} required={!field.isOptional} fullWidth value={value} onChange={e => onChange(e.target.value, field.id)}
|
|
493
|
+
placeholder={field.placeholder || form_display_text_for_language(form, "Enter phone...", '')}
|
|
494
|
+
label={(!field.title && field.placeholder) ? field.placeholder : props.label}
|
|
495
|
+
/>
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
export const EmailInput = ({ field, value, onChange, form, ...props }: FormInputProps<'email'>) => (
|
|
499
|
+
<AutoFocusTextField {...props} required={!field.isOptional} fullWidth type="email" value={value} onChange={e => onChange(e.target.value, field.id)}
|
|
500
|
+
placeholder={field.placeholder || form_display_text_for_language(form, "Enter email...", '')}
|
|
501
|
+
label={(!field.title && field.placeholder) ? field.placeholder : props.label}
|
|
502
|
+
/>
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
export const NumberInput = ({ field, value, onChange, form, ...props }: FormInputProps<'number'>) => {
|
|
506
|
+
// Prevent the default scroll behavior when focused on this input
|
|
507
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
508
|
+
useEffect(() => {
|
|
509
|
+
const handleWheel = (e: any) => {
|
|
510
|
+
e?.preventDefault?.();
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// Get the actual input element inside the TextField
|
|
514
|
+
const inputElement = inputRef.current?.querySelector('input');
|
|
515
|
+
|
|
516
|
+
if (inputElement) {
|
|
517
|
+
inputElement.addEventListener('wheel', handleWheel, { passive: false });
|
|
518
|
+
|
|
519
|
+
// Clean up event listener when component unmounts
|
|
520
|
+
return () => {
|
|
521
|
+
inputElement.removeEventListener('wheel', handleWheel);
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
}, []);
|
|
525
|
+
|
|
526
|
+
return (
|
|
527
|
+
<TextField ref={inputRef} autoFocus InputProps={defaultInputProps} {...props} required={!field.isOptional} fullWidth type="number" value={value}
|
|
528
|
+
onChange={e => onChange(parseInt(e.target.value), field.id)}
|
|
529
|
+
label={(!field.title && field.placeholder) ? field.placeholder : props.label}
|
|
530
|
+
placeholder={field.placeholder || form_display_text_for_language(form, "Enter a number...", '')}
|
|
531
|
+
onScroll={e => e.preventDefault()} // prevent scroll on number input
|
|
532
|
+
sx={{
|
|
533
|
+
'& input[type=number]': {
|
|
534
|
+
'-moz-appearance': 'textfield'
|
|
535
|
+
},
|
|
536
|
+
'& input[type=number]::-webkit-outer-spin-button': {
|
|
537
|
+
'-webkit-appearance': 'none',
|
|
538
|
+
margin: 0
|
|
539
|
+
},
|
|
540
|
+
'& input[type=number]::-webkit-inner-spin-button': {
|
|
541
|
+
'-webkit-appearance': 'none',
|
|
542
|
+
margin: 0
|
|
543
|
+
}
|
|
544
|
+
}}
|
|
545
|
+
/>
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export const InsuranceInput = ({ field, onDatabaseSelect, value, onChange, form, responses, enduser, ...props }: FormInputProps<'Insurance'>) => {
|
|
550
|
+
const session = useResolvedSession()
|
|
551
|
+
|
|
552
|
+
const [payers, setPayers] = useState<{ id: string, name: string, databaseRecord?: DatabaseRecord, type?: string, state?: string }[]>([])
|
|
553
|
+
const [query, setQuery] = useState('')
|
|
554
|
+
|
|
555
|
+
const addressQuestion = useMemo(() => responses?.find(r => {
|
|
556
|
+
if (r.answer.type !== 'Address') return false
|
|
557
|
+
if (r.field.intakeField !== 'Address') return false
|
|
558
|
+
|
|
559
|
+
// make sure state is actually defined (in case of multiple address questions, where 1+ are blank)
|
|
560
|
+
if (!r.answer.value?.state) return false
|
|
561
|
+
|
|
562
|
+
return true
|
|
563
|
+
}), [responses])
|
|
564
|
+
|
|
565
|
+
const state = useMemo(() => (
|
|
566
|
+
(addressQuestion?.answer?.type === 'Address' ? addressQuestion?.answer?.value?.state : undefined) || enduser?.state
|
|
567
|
+
), [enduser?.state, addressQuestion])
|
|
568
|
+
|
|
569
|
+
const loadRef = useRef(false) // so session changes don't cause
|
|
570
|
+
useEffect(() => {
|
|
571
|
+
if (field?.options?.dataSource === CANVAS_TITLE) return // instead, look-up while typing against Canvas Search API
|
|
572
|
+
if (loadRef.current) return
|
|
573
|
+
loadRef.current = true
|
|
574
|
+
|
|
575
|
+
// just load all at once, should be reasonably performant compared to paging
|
|
576
|
+
session.api.form_fields.load_choices_from_database({ fieldId: field.id, limit: 10000 })
|
|
577
|
+
.then(({ choices }) => setPayers(
|
|
578
|
+
choices
|
|
579
|
+
.map(c => ({
|
|
580
|
+
id: c.values.find(v => v.label?.trim()?.toLowerCase() === 'id')?.value?.toString() || '',
|
|
581
|
+
name: c.values.find(v => v.label?.trim()?.toLowerCase() === 'name')?.value?.toString() || '',
|
|
582
|
+
state: c.values.find(v => v.label?.trim()?.toLowerCase() === 'state')?.value?.toString() || '',
|
|
583
|
+
type: c.values.find(v => v.label?.trim()?.toLowerCase() === 'type')?.value?.toString() || '',
|
|
584
|
+
databaseRecord: c,
|
|
585
|
+
}))
|
|
586
|
+
.filter(c => !c.state || !state || (c.state === state))
|
|
587
|
+
))
|
|
588
|
+
.catch(console.error)
|
|
589
|
+
}, [session, state, field?.options?.dataSource])
|
|
590
|
+
|
|
591
|
+
const searchRef = useRef(query)
|
|
592
|
+
useEffect(() => {
|
|
593
|
+
if (field?.options?.dataSource !== CANVAS_TITLE) { return }
|
|
594
|
+
if (!query) return
|
|
595
|
+
if (searchRef.current === query) return
|
|
596
|
+
searchRef.current = query
|
|
597
|
+
|
|
598
|
+
session.api.integrations.proxy_read({
|
|
599
|
+
integration: CANVAS_TITLE,
|
|
600
|
+
query,
|
|
601
|
+
type: 'organizations',
|
|
602
|
+
})
|
|
603
|
+
.then(({ data }) => {
|
|
604
|
+
try {
|
|
605
|
+
setPayers(data.map((d: any) => ({
|
|
606
|
+
id: d.resource.id,
|
|
607
|
+
name: d.resource.name,
|
|
608
|
+
})))
|
|
609
|
+
} catch(err) { console.error }
|
|
610
|
+
})
|
|
611
|
+
.catch(console.error)
|
|
612
|
+
}, [session, field?.options?.dataSource, query])
|
|
613
|
+
|
|
614
|
+
return (
|
|
615
|
+
<Grid container spacing={2} sx={{ mt: '0' }}>
|
|
616
|
+
<Grid item xs={12} sm={6}>
|
|
617
|
+
<Autocomplete freeSolo={!field.options?.requirePredefinedInsurer} options={payers.map(p => p.name)}
|
|
618
|
+
value={value?.payerName || ''}
|
|
619
|
+
onChange={(e, v) => onChange({
|
|
620
|
+
...value,
|
|
621
|
+
payerName: v || '',
|
|
622
|
+
payerId: payers.find(p => p.name === v)?.id || '',
|
|
623
|
+
payerType: payers.find(p => p.name === v)?.type || '',
|
|
624
|
+
}, field.id)}
|
|
625
|
+
onInputChange={
|
|
626
|
+
field.options?.requirePredefinedInsurer
|
|
627
|
+
? (e, v) => { if (v) { setQuery(v) } }
|
|
628
|
+
: (e, v) => {
|
|
629
|
+
if (v) { setQuery(v) }
|
|
630
|
+
|
|
631
|
+
const databaseRecord = payers.find(p => p.name === v)?.databaseRecord
|
|
632
|
+
if (databaseRecord) {
|
|
633
|
+
onDatabaseSelect?.([databaseRecord])
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
onChange({
|
|
637
|
+
...value,
|
|
638
|
+
payerName: v || '',
|
|
639
|
+
payerId: payers.find(p => p.name === v)?.id || '',
|
|
640
|
+
payerType: payers.find(p => p.name === v)?.type || '',
|
|
641
|
+
}, field.id)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
renderInput={(params) => (
|
|
645
|
+
<TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
|
|
646
|
+
required={!field.isOptional} size="small" label={"Insurer"}
|
|
647
|
+
placeholder={field.options?.dataSource === CANVAS_TITLE ? "Search insurer..." : "Insurer"}
|
|
648
|
+
/>
|
|
649
|
+
)}
|
|
650
|
+
/>
|
|
651
|
+
</Grid>
|
|
652
|
+
<Grid item xs={12} sm={6}>
|
|
653
|
+
<TextField InputProps={defaultInputProps} required={!field.isOptional} fullWidth value={value?.memberId ?? ''}
|
|
654
|
+
onChange={e => onChange({ ...value, memberId: e.target.value }, field.id)}
|
|
655
|
+
label={form_display_text_for_language(form, "Member ID", '')}
|
|
656
|
+
size="small"
|
|
657
|
+
/>
|
|
658
|
+
</Grid>
|
|
659
|
+
|
|
660
|
+
<Grid item xs={12} sm={6}>
|
|
661
|
+
<TextField InputProps={defaultInputProps} required={false} fullWidth value={value?.planName ?? ''}
|
|
662
|
+
onChange={e => onChange({ ...value, planName: e.target.value }, field.id)}
|
|
663
|
+
label={form_display_text_for_language(form, "Plan Name", '')}
|
|
664
|
+
size="small"
|
|
665
|
+
/>
|
|
666
|
+
</Grid>
|
|
667
|
+
|
|
668
|
+
<Grid item xs={12} sm={6}>
|
|
669
|
+
<DateStringInput size="small" label="Plan Start Date"
|
|
670
|
+
field={{
|
|
671
|
+
...field,
|
|
672
|
+
isOptional: true, //field.isOptional || field.options?.billingProvider === 'Candid'
|
|
673
|
+
}}
|
|
674
|
+
value={value?.startDate || ''}
|
|
675
|
+
onChange={startDate =>
|
|
676
|
+
onChange({
|
|
677
|
+
...value,
|
|
678
|
+
startDate,
|
|
679
|
+
}, field.id)
|
|
680
|
+
}
|
|
681
|
+
/>
|
|
682
|
+
</Grid>
|
|
683
|
+
|
|
684
|
+
{field.options?.includeGroupNumber &&
|
|
685
|
+
<Grid item xs={12}>
|
|
686
|
+
<TextField InputProps={defaultInputProps} fullWidth value={value?.groupNumber ?? ''}
|
|
687
|
+
onChange={e => onChange({ ...value, groupNumber: e.target.value }, field.id)}
|
|
688
|
+
label={form_display_text_for_language(form, "Group Number", '')}
|
|
689
|
+
size="small"
|
|
690
|
+
/>
|
|
691
|
+
</Grid>
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
<Grid item xs={12}>
|
|
695
|
+
<StringSelector size="small" label="Relationship to Policy Owner"
|
|
696
|
+
options={
|
|
697
|
+
(
|
|
698
|
+
(field.options?.billingProvider === CANVAS_TITLE || field.options?.dataSource === CANVAS_TITLE )
|
|
699
|
+
? INSURANCE_RELATIONSHIPS_CANVAS
|
|
700
|
+
: INSURANCE_RELATIONSHIPS
|
|
701
|
+
)
|
|
702
|
+
.sort((x, y) => x.localeCompare(y))
|
|
703
|
+
}
|
|
704
|
+
value={value?.relationship || 'Self'}
|
|
705
|
+
onChange={relationship =>
|
|
706
|
+
onChange({ ...value, relationship: relationship as InsuranceRelationship || 'Self' }, field.id)
|
|
707
|
+
}
|
|
708
|
+
/>
|
|
709
|
+
</Grid>
|
|
710
|
+
|
|
711
|
+
{(value?.relationship || 'Self') !== 'Self' &&
|
|
712
|
+
<>
|
|
713
|
+
<Grid item xs={12}>
|
|
714
|
+
<Typography sx={{ fontWeight: 'bold' }}>Policy Owner Details</Typography>
|
|
715
|
+
</Grid>
|
|
716
|
+
|
|
717
|
+
<Grid item xs={6}>
|
|
718
|
+
<TextField label="First Name" size="small" InputProps={defaultInputProps} fullWidth
|
|
719
|
+
value={value?.relationshipDetails?.fname || ''}
|
|
720
|
+
required={!field.isOptional}
|
|
721
|
+
onChange={e =>
|
|
722
|
+
onChange({
|
|
723
|
+
...value,
|
|
724
|
+
relationshipDetails: { ...value?.relationshipDetails, fname: e.target.value }
|
|
725
|
+
}, field.id)
|
|
726
|
+
}
|
|
727
|
+
/>
|
|
728
|
+
</Grid>
|
|
729
|
+
<Grid item xs={6}>
|
|
730
|
+
<TextField label="Last Name" size="small" InputProps={defaultInputProps} fullWidth
|
|
731
|
+
value={value?.relationshipDetails?.lname || ''}
|
|
732
|
+
required={!field.isOptional}
|
|
733
|
+
onChange={e =>
|
|
734
|
+
onChange({
|
|
735
|
+
...value,
|
|
736
|
+
relationshipDetails: { ...value?.relationshipDetails, lname: e.target.value }
|
|
737
|
+
}, field.id)
|
|
738
|
+
}
|
|
739
|
+
/>
|
|
740
|
+
</Grid>
|
|
741
|
+
<Grid item xs={6}>
|
|
742
|
+
<StringSelector options={TELLESCOPE_GENDERS} size="small" label="Gender"
|
|
743
|
+
value={value?.relationshipDetails?.gender || ''}
|
|
744
|
+
required={!field.isOptional}
|
|
745
|
+
onChange={v =>
|
|
746
|
+
onChange({
|
|
747
|
+
...value,
|
|
748
|
+
relationshipDetails: { ...value?.relationshipDetails, gender: v as TellescopeGender }
|
|
749
|
+
}, field.id)
|
|
750
|
+
}
|
|
751
|
+
/>
|
|
752
|
+
</Grid>
|
|
753
|
+
<Grid item xs={6}>
|
|
754
|
+
<DateStringInput size="small" label="Date of Birth"
|
|
755
|
+
field={{
|
|
756
|
+
...field,
|
|
757
|
+
isOptional: field.isOptional || field.options?.billingProvider === 'Candid'
|
|
758
|
+
}}
|
|
759
|
+
value={value?.relationshipDetails?.dateOfBirth || ''}
|
|
760
|
+
onChange={dateOfBirth =>
|
|
761
|
+
onChange({
|
|
762
|
+
...value,
|
|
763
|
+
relationshipDetails: { ...value?.relationshipDetails, dateOfBirth }
|
|
764
|
+
}, field.id)
|
|
765
|
+
}
|
|
766
|
+
/>
|
|
767
|
+
</Grid>
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
{/* <Grid item xs={6}>
|
|
771
|
+
<TextField label="Email" type="email" size="small" InputProps={defaultInputProps} fullWidth
|
|
772
|
+
value={value?.relationshipDetails?.email || ''}
|
|
773
|
+
onChange={e =>
|
|
774
|
+
onChange({
|
|
775
|
+
...value,
|
|
776
|
+
relationshipDetails: { ...value?.relationshipDetails, email: e.target.value }
|
|
777
|
+
}, field.id)
|
|
778
|
+
}
|
|
779
|
+
/>
|
|
780
|
+
</Grid>
|
|
781
|
+
<Grid item xs={6}>
|
|
782
|
+
<TextField label="Cell Phone" size="small" InputProps={defaultInputProps} fullWidth
|
|
783
|
+
value={value?.relationshipDetails?.phone || ''}
|
|
784
|
+
onChange={e =>
|
|
785
|
+
onChange({
|
|
786
|
+
...value,
|
|
787
|
+
relationshipDetails: { ...value?.relationshipDetails, phone: e.target.value }
|
|
788
|
+
}, field.id)
|
|
789
|
+
}
|
|
790
|
+
/>
|
|
791
|
+
</Grid>
|
|
792
|
+
|
|
793
|
+
<Grid item xs={12}>
|
|
794
|
+
<AddressInput field={field} value={{
|
|
795
|
+
addressLineOne: value?.relationshipDetails?.address?.lineOne || '',
|
|
796
|
+
addressLineTwo: value?.relationshipDetails?.address?.lineTwo || '',
|
|
797
|
+
city: value?.relationshipDetails?.address?.city || '',
|
|
798
|
+
state: value?.relationshipDetails?.address?.state || '',
|
|
799
|
+
zipCode: value?.relationshipDetails?.address?.zipCode || '',
|
|
800
|
+
}}
|
|
801
|
+
onChange={v => {
|
|
802
|
+
const { addressLineOne='', addressLineTwo='', ...address } = v || {}
|
|
803
|
+
onChange({
|
|
804
|
+
...value,
|
|
805
|
+
relationshipDetails: {
|
|
806
|
+
...value?.relationshipDetails,
|
|
807
|
+
address: {
|
|
808
|
+
lineOne: addressLineOne,
|
|
809
|
+
lineTwo: addressLineTwo,
|
|
810
|
+
...address,
|
|
811
|
+
},
|
|
812
|
+
}
|
|
813
|
+
}, field.id)
|
|
814
|
+
}}
|
|
815
|
+
/>
|
|
816
|
+
</Grid> */}
|
|
817
|
+
|
|
818
|
+
{/* <Grid item xs={6}>
|
|
819
|
+
<TextField label="Address"
|
|
820
|
+
value={value?.relationshipDetails?.address?.lineOne || ''}
|
|
821
|
+
onChange={e =>
|
|
822
|
+
onChange({
|
|
823
|
+
...value,
|
|
824
|
+
relationshipDetails: {
|
|
825
|
+
...value?.relationshipDetails,
|
|
826
|
+
address: {
|
|
827
|
+
...value?.relationshipDetails?.address,
|
|
828
|
+
lineOne: e.target.value
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}, field.id)
|
|
832
|
+
}
|
|
833
|
+
/>
|
|
834
|
+
</Grid>
|
|
835
|
+
<Grid item xs={6}>
|
|
836
|
+
<TextField label="Line Two"
|
|
837
|
+
value={value?.relationshipDetails?.address?.lineTwo || ''}
|
|
838
|
+
onChange={e =>
|
|
839
|
+
onChange({
|
|
840
|
+
...value,
|
|
841
|
+
relationshipDetails: {
|
|
842
|
+
...value?.relationshipDetails,
|
|
843
|
+
address: {
|
|
844
|
+
...value?.relationshipDetails?.address,
|
|
845
|
+
lineTwo: e.target.value
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}, field.id)
|
|
849
|
+
}
|
|
850
|
+
/>
|
|
851
|
+
</Grid>
|
|
852
|
+
<Grid item xs={6}>
|
|
853
|
+
<TextField label="City"
|
|
854
|
+
value={value?.relationshipDetails?.address?.city || ''}
|
|
855
|
+
onChange={e =>
|
|
856
|
+
onChange({
|
|
857
|
+
...value,
|
|
858
|
+
relationshipDetails: {
|
|
859
|
+
...value?.relationshipDetails,
|
|
860
|
+
address: {
|
|
861
|
+
...value?.relationshipDetails?.address,
|
|
862
|
+
city: e.target.value
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}, field.id)
|
|
866
|
+
}
|
|
867
|
+
/>
|
|
868
|
+
</Grid>
|
|
869
|
+
<Grid item xs={2}>
|
|
870
|
+
<TextField label="State"
|
|
871
|
+
value={value?.relationshipDetails?.address?.state || ''}
|
|
872
|
+
onChange={e =>
|
|
873
|
+
onChange({
|
|
874
|
+
...value,
|
|
875
|
+
relationshipDetails: {
|
|
876
|
+
...value?.relationshipDetails,
|
|
877
|
+
address: {
|
|
878
|
+
...value?.relationshipDetails?.address,
|
|
879
|
+
state: e.target.value
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}, field.id)
|
|
883
|
+
}
|
|
884
|
+
/>
|
|
885
|
+
</Grid>
|
|
886
|
+
|
|
887
|
+
<Grid item xs={4}>
|
|
888
|
+
<Autocomplete value={value?.state}
|
|
889
|
+
options={VALID_STATES}
|
|
890
|
+
sx={{ width: 100 }}
|
|
891
|
+
disablePortal
|
|
892
|
+
onChange={(e, v) => v &&
|
|
893
|
+
onChange({
|
|
894
|
+
...value as any,
|
|
895
|
+
state: v ?? '',
|
|
896
|
+
},
|
|
897
|
+
field.id
|
|
898
|
+
)}
|
|
899
|
+
renderInput={(params) => (
|
|
900
|
+
<TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
|
|
901
|
+
size={'small'} label={"State"} required={!field.isOptional}
|
|
902
|
+
/>
|
|
903
|
+
)}
|
|
904
|
+
{...props}
|
|
905
|
+
/>
|
|
906
|
+
</Grid> */}
|
|
907
|
+
</>
|
|
908
|
+
}
|
|
909
|
+
</Grid>
|
|
910
|
+
)
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
const StringSelector = ({ options, value, onChange, required, getDisplayValue, ...props } : {
|
|
915
|
+
options: string[]
|
|
916
|
+
value: string,
|
|
917
|
+
onChange: (v: string) => void,
|
|
918
|
+
label?: string,
|
|
919
|
+
size?: "small",
|
|
920
|
+
required?: boolean,
|
|
921
|
+
getDisplayValue?: (v: string) => string,
|
|
922
|
+
disabled?: boolean,
|
|
923
|
+
}) => (
|
|
924
|
+
<FormControl fullWidth size={props.size} required={required}>
|
|
925
|
+
<InputLabel>{props.label}</InputLabel>
|
|
926
|
+
<Select {...props} value={value} onChange={e => onChange(e.target.value)} fullWidth
|
|
927
|
+
sx={defaultInputProps.sx}
|
|
928
|
+
>
|
|
929
|
+
{options.map((o, i) => (
|
|
930
|
+
<MenuItem value={o} key={o || i}>{getDisplayValue?.(o) ?? o}</MenuItem>
|
|
931
|
+
))}
|
|
932
|
+
</Select>
|
|
933
|
+
</FormControl>
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
const HourSelector = (props : { value: string, onChange: (v: string) => void }) => (
|
|
937
|
+
<StringSelector {...props}
|
|
938
|
+
options={Array(12).fill('').map((_, i) => (i + 1) <= 9 ? `0${i + 1}` : (i + 1).toString())}
|
|
939
|
+
/>
|
|
940
|
+
)
|
|
941
|
+
const MinuteSelector = (props : { value: string, onChange: (v: string) => void }) => (
|
|
942
|
+
<StringSelector {...props}
|
|
943
|
+
options={Array(60).fill('').map((_, i) => i <= 9 ? `0${i}` : i.toString())}
|
|
944
|
+
/>
|
|
945
|
+
)
|
|
946
|
+
const AmPmSelector = (props : { value: string, onChange: (v: string) => void }) => (
|
|
947
|
+
<StringSelector {...props} options={['AM', 'PM']} />
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
export const TimeInput = ({ field, value, onChange, ...props }: FormInputProps<'string'>) => {
|
|
951
|
+
const [hour, rest=''] = (value || '').split(':')
|
|
952
|
+
const [minute, amPm, zone=getLocalTimezone()] = rest.split(' ')
|
|
953
|
+
|
|
954
|
+
return (
|
|
955
|
+
<Grid container alignItems='center' spacing={1}>
|
|
956
|
+
<Grid item sx={{ width: 100 }}>
|
|
957
|
+
<HourSelector value={hour}
|
|
958
|
+
onChange={hour => onChange(`${hour}:${minute} ${amPm} ${zone}`, field.id)}
|
|
959
|
+
/>
|
|
960
|
+
</Grid>
|
|
961
|
+
|
|
962
|
+
<Grid item sx={{ width: 100 }}>
|
|
963
|
+
<MinuteSelector value={minute}
|
|
964
|
+
onChange={minute => onChange(`${hour}:${minute} ${amPm} ${zone}`, field.id)}
|
|
965
|
+
/>
|
|
966
|
+
</Grid>
|
|
967
|
+
|
|
968
|
+
<Grid item sx={{ width: 100 }}>
|
|
969
|
+
<AmPmSelector value={amPm}
|
|
970
|
+
onChange={amPm => onChange(`${hour}:${minute} ${amPm} ${zone}`, field.id)}
|
|
971
|
+
/>
|
|
972
|
+
</Grid>
|
|
973
|
+
</Grid>
|
|
974
|
+
)
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
export const TimezoneInput = ({ value='', field, onChange, ...props }: FormInputProps<'Timezone'>) => (
|
|
978
|
+
<StringSelector {...props} value={value} options={TIMEZONES_USA} onChange={v => onChange(v, field.id)} />
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
export const AddressInput = ({ field, form, value, onChange, ...props }: FormInputProps<'Address'>) => (
|
|
982
|
+
// state only
|
|
983
|
+
field.options?.addressFields?.includes('state')
|
|
984
|
+
? (
|
|
985
|
+
<Autocomplete value={value?.state || ''}
|
|
986
|
+
options={field.options?.validStates?.length ? field.options.validStates : VALID_STATES}
|
|
987
|
+
disablePortal
|
|
988
|
+
onChange={(e, v) => v &&
|
|
989
|
+
onChange({
|
|
990
|
+
...value as any,
|
|
991
|
+
state: v ?? '',
|
|
992
|
+
},
|
|
993
|
+
field.id
|
|
994
|
+
)}
|
|
995
|
+
renderInput={(params) => (
|
|
996
|
+
<TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
|
|
997
|
+
required={!field.isOptional}
|
|
998
|
+
// don't use 'small' so as to be consistent with other text fields, in case this is used in a group
|
|
999
|
+
// size={'small'}
|
|
1000
|
+
label={form_display_text_for_language(form, "State")}
|
|
1001
|
+
/>
|
|
1002
|
+
)}
|
|
1003
|
+
{...props}
|
|
1004
|
+
/>
|
|
1005
|
+
)
|
|
1006
|
+
: (
|
|
1007
|
+
<Grid container direction="column" spacing={2} sx={{ mt: 0 }}>
|
|
1008
|
+
<Grid item>
|
|
1009
|
+
<AutoFocusTextField {...props} size="small" required={!field.isOptional} fullWidth
|
|
1010
|
+
value={value?.addressLineOne ?? ''}
|
|
1011
|
+
label={form_display_text_for_language(form, "Address Line 1")}
|
|
1012
|
+
placeholder={form_display_text_for_language(form, "Address Line 1")}
|
|
1013
|
+
onChange={e =>
|
|
1014
|
+
onChange({
|
|
1015
|
+
...value as any,
|
|
1016
|
+
addressLineOne: e.target.value ?? '',
|
|
1017
|
+
},
|
|
1018
|
+
field.id
|
|
1019
|
+
)}
|
|
1020
|
+
/>
|
|
1021
|
+
</Grid>
|
|
1022
|
+
|
|
1023
|
+
<Grid item>
|
|
1024
|
+
<TextField {...props} size="small" required={false} fullWidth
|
|
1025
|
+
InputProps={defaultInputProps}
|
|
1026
|
+
value={value?.addressLineTwo ?? ''}
|
|
1027
|
+
label={form_display_text_for_language(form, "Address Line 2")}
|
|
1028
|
+
placeholder={form_display_text_for_language(form, "Address Line 2")}
|
|
1029
|
+
onChange={e =>
|
|
1030
|
+
onChange({
|
|
1031
|
+
...value as any,
|
|
1032
|
+
addressLineTwo: e.target.value ?? '',
|
|
1033
|
+
},
|
|
1034
|
+
field.id
|
|
1035
|
+
)}
|
|
1036
|
+
/>
|
|
1037
|
+
</Grid>
|
|
1038
|
+
|
|
1039
|
+
<Grid item>
|
|
1040
|
+
<Grid container alignItems="center" justifyContent={"space-between"} spacing={1}>
|
|
1041
|
+
<Grid item xs={12} sm={field.fullZIP ? 5 : 6}>
|
|
1042
|
+
<TextField {...props} size="small" required={!field.isOptional}
|
|
1043
|
+
InputProps={defaultInputProps}
|
|
1044
|
+
fullWidth
|
|
1045
|
+
value={value?.city ?? ''}
|
|
1046
|
+
label={form_display_text_for_language(form, "City")}
|
|
1047
|
+
placeholder={form_display_text_for_language(form, "City")}
|
|
1048
|
+
onChange={e =>
|
|
1049
|
+
onChange({
|
|
1050
|
+
...value as any,
|
|
1051
|
+
city: e.target.value ?? '',
|
|
1052
|
+
},
|
|
1053
|
+
field.id
|
|
1054
|
+
)}
|
|
1055
|
+
/>
|
|
1056
|
+
</Grid>
|
|
1057
|
+
|
|
1058
|
+
<Grid item xs={field.fullZIP ? 4 : 6} sm={field.fullZIP ? 2 : 3}>
|
|
1059
|
+
<Autocomplete value={value?.state || ''} fullWidth
|
|
1060
|
+
options={field.options?.validStates?.length ? field.options.validStates : VALID_STATES}
|
|
1061
|
+
disablePortal
|
|
1062
|
+
onChange={(e, v) => v &&
|
|
1063
|
+
onChange({
|
|
1064
|
+
...value as any,
|
|
1065
|
+
state: v ?? '',
|
|
1066
|
+
},
|
|
1067
|
+
field.id
|
|
1068
|
+
)}
|
|
1069
|
+
renderInput={(params) => (
|
|
1070
|
+
<TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
|
|
1071
|
+
size={'small'} required={!field.isOptional}
|
|
1072
|
+
label={form_display_text_for_language(form, "State")}
|
|
1073
|
+
/>
|
|
1074
|
+
)}
|
|
1075
|
+
{...props}
|
|
1076
|
+
/>
|
|
1077
|
+
</Grid>
|
|
1078
|
+
|
|
1079
|
+
<Grid item xs={field.fullZIP ? 5 : 6} sm={field.fullZIP ? 2 : 3}>
|
|
1080
|
+
<TextField {...props} size="small" required={!field.isOptional}
|
|
1081
|
+
InputProps={defaultInputProps} fullWidth
|
|
1082
|
+
value={value?.zipCode ?? ''}
|
|
1083
|
+
label={form_display_text_for_language(form, "ZIP Code")}
|
|
1084
|
+
placeholder={form_display_text_for_language(form, "ZIP Code")}
|
|
1085
|
+
onChange={e =>
|
|
1086
|
+
onChange({
|
|
1087
|
+
...value as any,
|
|
1088
|
+
zipCode: e.target.value ?? '',
|
|
1089
|
+
},
|
|
1090
|
+
field.id
|
|
1091
|
+
)}
|
|
1092
|
+
/>
|
|
1093
|
+
</Grid>
|
|
1094
|
+
|
|
1095
|
+
{field.fullZIP &&
|
|
1096
|
+
<Grid item xs={3}>
|
|
1097
|
+
<TextField {...props} size="small" label="ZIP+4" required={!field.isOptional && field.fullZIP}
|
|
1098
|
+
InputProps={defaultInputProps}
|
|
1099
|
+
value={value?.zipPlusFour ?? ''}
|
|
1100
|
+
placeholder="ZIP + 4"
|
|
1101
|
+
onChange={e =>
|
|
1102
|
+
onChange({
|
|
1103
|
+
...value as any,
|
|
1104
|
+
zipPlusFour: e.target.value ?? '',
|
|
1105
|
+
},
|
|
1106
|
+
field.id
|
|
1107
|
+
)}
|
|
1108
|
+
/>
|
|
1109
|
+
</Grid>
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
</Grid>
|
|
1113
|
+
</Grid>
|
|
1114
|
+
</Grid>
|
|
1115
|
+
)
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
export const ESignatureTerms = () => {
|
|
1119
|
+
let companyName = 'Tellescope'
|
|
1120
|
+
try {
|
|
1121
|
+
const indexOfName = window.location.href.indexOf('name=')
|
|
1122
|
+
if (indexOfName !== -1) {
|
|
1123
|
+
companyName = (
|
|
1124
|
+
decodeURIComponent(window.location.href.substring(indexOfName + 5))
|
|
1125
|
+
|| companyName
|
|
1126
|
+
)
|
|
1127
|
+
}
|
|
1128
|
+
} catch(err) {
|
|
1129
|
+
console.error(err)
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
return (
|
|
1133
|
+
<div style={{paddingLeft: 10}}>
|
|
1134
|
+
<h1>{companyName} Electronic Signature Terms</h1>
|
|
1135
|
+
|
|
1136
|
+
<p>
|
|
1137
|
+
By selecting the "I consent to use electronic signatures" checkbox,
|
|
1138
|
+
you are signing this Agreement electronically.
|
|
1139
|
+
You agree your electronic signature is the legal
|
|
1140
|
+
equivalent of your manual/handwritten signature on this Agreement.
|
|
1141
|
+
By selecting "I consent to use electronic signatures" using any device, means or action, you consent to the
|
|
1142
|
+
legally binding terms and conditions of this Agreement.
|
|
1143
|
+
You further agree that your signature on this document (hereafter referred to as your "E-Signature")
|
|
1144
|
+
is as valid as if you signed the document in writing.
|
|
1145
|
+
You also agree that no certification authority or other third party
|
|
1146
|
+
verification is necessary to validate your E-Signature,
|
|
1147
|
+
and that the lack of such certification or third party verification will
|
|
1148
|
+
not in any way affect the enforceability
|
|
1149
|
+
of your E-Signature or any resulting agreement between you and
|
|
1150
|
+
{companyName} or between you and a customer of {companyName}.
|
|
1151
|
+
</p>
|
|
1152
|
+
</div>
|
|
1153
|
+
)
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
export const SignatureInput = ({ value, field, autoFocus=true, enduser, onChange }: FormInputProps<'signature'>) => {
|
|
1157
|
+
const prefill = (
|
|
1158
|
+
field.options?.prefillSignature && enduser?.fname && enduser.lname
|
|
1159
|
+
? `${enduser.fname} ${enduser.lname}`
|
|
1160
|
+
: undefined
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
const handleConsentChange = () => {
|
|
1164
|
+
const newConsent = !value?.signed
|
|
1165
|
+
|
|
1166
|
+
onChange({
|
|
1167
|
+
pdfAttachment: field.options?.pdfAttachment,
|
|
1168
|
+
fullName: value?.fullName ?? prefill ?? '',
|
|
1169
|
+
signed: newConsent,
|
|
1170
|
+
url: field.options?.signatureUrl,
|
|
1171
|
+
}, field.id)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const handleNameChange = (newName: string) => {
|
|
1175
|
+
onChange({
|
|
1176
|
+
pdfAttachment: field.options?.pdfAttachment,
|
|
1177
|
+
signed: value?.signed ?? false,
|
|
1178
|
+
fullName: newName,
|
|
1179
|
+
url: field.options?.signatureUrl,
|
|
1180
|
+
}, field.id)
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
return (
|
|
1184
|
+
<Grid container alignItems="center">
|
|
1185
|
+
{field.options?.pdfAttachment &&
|
|
1186
|
+
<PdfViewer url={getPublicFileURL({ businessId: field.businessId, name: field.options.pdfAttachment })} />
|
|
1187
|
+
}
|
|
1188
|
+
{!field.options?.pdfAttachment && field.options?.signatureUrl &&
|
|
1189
|
+
<Grid container direction="column" sx={{ mb: 2 }}>
|
|
1190
|
+
<iframe src={field.options.signatureUrl}
|
|
1191
|
+
style={{
|
|
1192
|
+
border: 'none',
|
|
1193
|
+
height: 400,
|
|
1194
|
+
width: '100%',
|
|
1195
|
+
marginBottom: '5px'
|
|
1196
|
+
}}
|
|
1197
|
+
/>
|
|
1198
|
+
<a href={field.options.signatureUrl} target="_blank" rel="noopener noreferrer">
|
|
1199
|
+
View document in new tab
|
|
1200
|
+
</a>
|
|
1201
|
+
</Grid>
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
<Grid item xs={12}>
|
|
1205
|
+
<Checkbox
|
|
1206
|
+
style={{ margin: 0, marginTop: 5, padding: 0, paddingRight: 3 }}
|
|
1207
|
+
color="primary"
|
|
1208
|
+
checked={!!value?.signed} // make sure to coerce to boolean to enforce controlled
|
|
1209
|
+
onClick={() => handleConsentChange()}
|
|
1210
|
+
inputProps={{ 'aria-label': 'consent to e-signature checkbox' }}
|
|
1211
|
+
/>
|
|
1212
|
+
<Typography component="span" style={{ position: 'relative', top: 5, left: 2 }}>
|
|
1213
|
+
I consent to
|
|
1214
|
+
use <a href={`/e-signature-terms?name=${field.options?.esignatureTermsCompanyName || ''}`} target="_blank" rel="noopener noreferrer"> electronic signatures </a>
|
|
1215
|
+
</Typography>
|
|
1216
|
+
</Grid>
|
|
1217
|
+
|
|
1218
|
+
<Grid item xs={12} style={{ marginTop: 12 }}>
|
|
1219
|
+
<TextField disabled={!value?.signed} autoFocus={autoFocus}
|
|
1220
|
+
style={{ width: '100%'}}
|
|
1221
|
+
size="small"
|
|
1222
|
+
aria-label="Full Name"
|
|
1223
|
+
value={value?.fullName}
|
|
1224
|
+
placeholder={prefill || "Full Name"} variant="outlined"
|
|
1225
|
+
onChange={e => handleNameChange(e.target.value)}
|
|
1226
|
+
InputProps={defaultInputProps}
|
|
1227
|
+
/>
|
|
1228
|
+
<Typography color="primary" style={{ fontSize: 15, marginTop: 2 }}>
|
|
1229
|
+
Enter your legal full name to complete the signature
|
|
1230
|
+
</Typography>
|
|
1231
|
+
</Grid>
|
|
1232
|
+
</Grid>
|
|
1233
|
+
)
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const formatBytes = (bytes: number) => {
|
|
1237
|
+
if (bytes === 0) return '0 Bytes';
|
|
1238
|
+
const k = 1024;
|
|
1239
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
|
1240
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
1241
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
export async function convertHEIC (file: FileBlob | string){
|
|
1245
|
+
// get image as blob url
|
|
1246
|
+
let blobURL = (
|
|
1247
|
+
typeof file === 'string' ? file : URL.createObjectURL(file)
|
|
1248
|
+
);
|
|
1249
|
+
|
|
1250
|
+
// convert "fetch" the new blob url
|
|
1251
|
+
let blobRes = await fetch(blobURL)
|
|
1252
|
+
|
|
1253
|
+
// convert response to blob
|
|
1254
|
+
let blob = await blobRes.blob()
|
|
1255
|
+
|
|
1256
|
+
// convert to PNG - response is blob
|
|
1257
|
+
let conversionResult = await heic2any({ blob })
|
|
1258
|
+
|
|
1259
|
+
// convert to blob url
|
|
1260
|
+
var url = URL.createObjectURL(Array.isArray(conversionResult) ? conversionResult[0] : conversionResult);
|
|
1261
|
+
|
|
1262
|
+
return url
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
const value_is_image = (f?: { type?: string })=> f?.type?.includes('image')
|
|
1266
|
+
export const FileInput = ({ value, onChange, field, existingFileName, uploadingFiles, handleFileUpload, setUploadingFiles }: FormInputProps<'file'> & { existingFileName?: string }) => {
|
|
1267
|
+
const [error, setError] = useState('')
|
|
1268
|
+
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
1269
|
+
onDrop: useCallback(
|
|
1270
|
+
acceptedFiles => {
|
|
1271
|
+
const file = acceptedFiles.pop()
|
|
1272
|
+
if (!file) return
|
|
1273
|
+
|
|
1274
|
+
if (field.options?.maxFileSize && file.size > field.options.maxFileSize) {
|
|
1275
|
+
return setError(`File size must be less than ${formatBytes(field.options.maxFileSize)}`)
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
if (field.options?.validFileTypes?.length) {
|
|
1279
|
+
const match = field.options.validFileTypes.find(t => file.type.includes(t.toLowerCase()))
|
|
1280
|
+
if (!match) {
|
|
1281
|
+
return setError(`File must have type: ${field.options.validFileTypes.join(', ')}`)
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
setError('')
|
|
1286
|
+
onChange(file, field.id)
|
|
1287
|
+
|
|
1288
|
+
if (field.options?.autoUploadFiles && handleFileUpload) {
|
|
1289
|
+
setUploadingFiles?.(fs => [...fs, { fieldId: field.id }])
|
|
1290
|
+
|
|
1291
|
+
handleFileUpload(file, field.id)
|
|
1292
|
+
.finally(
|
|
1293
|
+
() => setUploadingFiles?.(fs => fs.filter(f => f.fieldId !== field.id))
|
|
1294
|
+
)
|
|
1295
|
+
}
|
|
1296
|
+
}, [onChange, field.options?.validFileTypes, handleFileUpload, setUploadingFiles]
|
|
1297
|
+
),
|
|
1298
|
+
})
|
|
1299
|
+
|
|
1300
|
+
const [preview, setPreview] = useState('')
|
|
1301
|
+
useEffect(() => {
|
|
1302
|
+
if (!value_is_image(value)) return
|
|
1303
|
+
if ((value.type.includes('heif') || value.type.includes('heic'))) {
|
|
1304
|
+
convertHEIC(value).then(setPreview).catch(console.error)
|
|
1305
|
+
return
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
try {
|
|
1309
|
+
setPreview(URL.createObjectURL(value))
|
|
1310
|
+
} catch(err) {
|
|
1311
|
+
console.error(err)
|
|
1312
|
+
}
|
|
1313
|
+
}, [value])
|
|
1314
|
+
|
|
1315
|
+
if (uploadingFiles?.find(f => f.fieldId === field.id)) {
|
|
1316
|
+
return <LinearProgress />
|
|
1317
|
+
}
|
|
1318
|
+
return (
|
|
1319
|
+
<Grid container direction="column">
|
|
1320
|
+
<Grid container {...getRootProps()} sx={{
|
|
1321
|
+
width: "100%",
|
|
1322
|
+
border: "1px dashed #00000033",
|
|
1323
|
+
borderRadius: 1,
|
|
1324
|
+
padding: (preview && !isDragActive) ? 0 : 6,
|
|
1325
|
+
'&:hover': {
|
|
1326
|
+
border: `1px solid ${PRIMARY_HEX}`,
|
|
1327
|
+
cursor: 'pointer',
|
|
1328
|
+
}
|
|
1329
|
+
}}
|
|
1330
|
+
alignItems="center" justifyContent="center">
|
|
1331
|
+
<input {...getInputProps({ multiple: false })} />
|
|
1332
|
+
{
|
|
1333
|
+
<p>
|
|
1334
|
+
{value
|
|
1335
|
+
? (
|
|
1336
|
+
preview
|
|
1337
|
+
? <img src={preview} style={{ paddingLeft: '10%', width : '80%', maxHeight: 200 }} />
|
|
1338
|
+
: `${truncate_string(value.name, { length: 30, showEllipsis: true })} selected!`
|
|
1339
|
+
)
|
|
1340
|
+
: capture_is_supported()
|
|
1341
|
+
? (
|
|
1342
|
+
<Grid container direction="column" alignItems="center">
|
|
1343
|
+
<Grid item>
|
|
1344
|
+
<AddPhotoAlternateIcon color="primary" />
|
|
1345
|
+
</Grid>
|
|
1346
|
+
<Grid item>
|
|
1347
|
+
<Typography sx={{ fontSize: 14, textAlign: 'center' }}>
|
|
1348
|
+
Select file or take picture
|
|
1349
|
+
</Typography>
|
|
1350
|
+
</Grid>
|
|
1351
|
+
</Grid>
|
|
1352
|
+
)
|
|
1353
|
+
: <Grid container direction="column" alignItems="center" rowGap={2}>
|
|
1354
|
+
<UploadFile color="primary" sx={{ fontSize: 25 }} />
|
|
1355
|
+
<Typography>
|
|
1356
|
+
{isDragActive ? "Drop to select file" : "Click or drag and drop"}
|
|
1357
|
+
</Typography>
|
|
1358
|
+
</Grid>
|
|
1359
|
+
}
|
|
1360
|
+
</p>
|
|
1361
|
+
}
|
|
1362
|
+
</Grid>
|
|
1363
|
+
|
|
1364
|
+
<Grid item alignSelf="center" sx={{ mt: 0.5 }}>
|
|
1365
|
+
{(!value?.name && existingFileName) &&
|
|
1366
|
+
<Typography>{existingFileName} selected!</Typography>
|
|
1367
|
+
}
|
|
1368
|
+
</Grid>
|
|
1369
|
+
{error &&
|
|
1370
|
+
<Grid item alignSelf="center" sx={{ mt: 0.5 }}>
|
|
1371
|
+
<Typography color="error">{error}</Typography>
|
|
1372
|
+
</Grid>
|
|
1373
|
+
}
|
|
1374
|
+
</Grid>
|
|
1375
|
+
)
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
export const safe_create_url = (file: any) => {
|
|
1379
|
+
try {
|
|
1380
|
+
return URL.createObjectURL(file)
|
|
1381
|
+
} catch(err) {
|
|
1382
|
+
console.error('safe_create_url error:', err)
|
|
1383
|
+
return null
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
export const FilesInput = ({ value, onChange, field, existingFileName, uploadingFiles, handleFileUpload, setUploadingFiles }: FormInputProps<'files'> & { existingFileName?: string }) => {
|
|
1388
|
+
const [error, setError] = useState('')
|
|
1389
|
+
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
1390
|
+
onDrop: useCallback(
|
|
1391
|
+
async acceptedFiles => {
|
|
1392
|
+
setUploadingFiles?.(fs => [...fs, { fieldId: field.id }])
|
|
1393
|
+
for (const file of acceptedFiles) {
|
|
1394
|
+
if (field.options?.validFileTypes?.length) {
|
|
1395
|
+
const match = field.options.validFileTypes.find(t => file.type.includes(t.toLowerCase()))
|
|
1396
|
+
if (!match) {
|
|
1397
|
+
return setError(`File must have type: ${field.options.validFileTypes.join(', ')}`)
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
if (field.options?.autoUploadFiles && handleFileUpload) {
|
|
1402
|
+
await handleFileUpload(file, field.id).catch(console.error)
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
setUploadingFiles?.(fs => fs.filter(f => f.fieldId !== field.id))
|
|
1406
|
+
|
|
1407
|
+
setError('')
|
|
1408
|
+
onChange([...(value ?? []), ...acceptedFiles], field.id)
|
|
1409
|
+
}, [onChange, value, field.options?.validFileTypes, handleFileUpload, setUploadingFiles]
|
|
1410
|
+
),
|
|
1411
|
+
})
|
|
1412
|
+
|
|
1413
|
+
const previews = useMemo(() => (
|
|
1414
|
+
(value ?? []).map(v => {
|
|
1415
|
+
return value_is_image(v) ? safe_create_url(v) : null
|
|
1416
|
+
})
|
|
1417
|
+
), [value])
|
|
1418
|
+
|
|
1419
|
+
if (uploadingFiles?.find(f => f.fieldId === field.id)) {
|
|
1420
|
+
return <LinearProgress />
|
|
1421
|
+
}
|
|
1422
|
+
return (
|
|
1423
|
+
<Grid container direction="column">
|
|
1424
|
+
<Grid container {...getRootProps()} sx={{
|
|
1425
|
+
width: "100%",
|
|
1426
|
+
border: "1px dashed #00000033",
|
|
1427
|
+
borderRadius: 1,
|
|
1428
|
+
padding: 2,
|
|
1429
|
+
'&:hover': {
|
|
1430
|
+
border: `1px solid ${PRIMARY_HEX}`,
|
|
1431
|
+
cursor: 'pointer',
|
|
1432
|
+
}
|
|
1433
|
+
}}
|
|
1434
|
+
alignItems="center" justifyContent="center">
|
|
1435
|
+
<input {...getInputProps({ multiple: false })} />
|
|
1436
|
+
{
|
|
1437
|
+
<p>
|
|
1438
|
+
{capture_is_supported()
|
|
1439
|
+
? (
|
|
1440
|
+
<Grid container direction="column" alignItems="center">
|
|
1441
|
+
<Grid item>
|
|
1442
|
+
<AddPhotoAlternateIcon color="primary" />
|
|
1443
|
+
</Grid>
|
|
1444
|
+
<Grid item>
|
|
1445
|
+
<Typography sx={{ fontSize: 14, textAlign: 'center' }}>
|
|
1446
|
+
Select files or take pictures
|
|
1447
|
+
</Typography>
|
|
1448
|
+
</Grid>
|
|
1449
|
+
</Grid>
|
|
1450
|
+
)
|
|
1451
|
+
: <Grid container direction="column" alignItems="center" rowGap={2}>
|
|
1452
|
+
<UploadFile color="primary" sx={{ fontSize: 25 }} />
|
|
1453
|
+
<Typography>
|
|
1454
|
+
{isDragActive ? "Drop to select files" : "Click or drag and drop"}
|
|
1455
|
+
</Typography>
|
|
1456
|
+
</Grid>
|
|
1457
|
+
}
|
|
1458
|
+
</p>
|
|
1459
|
+
}
|
|
1460
|
+
</Grid>
|
|
1461
|
+
|
|
1462
|
+
{/* <Grid container sx={{ mt: 1 }} spacing={1}>
|
|
1463
|
+
{previews?.map((preview, i) => (
|
|
1464
|
+
<Grid item key={i} style={{ maxWidth: '25%', maxHeight: 75, height: '100%' }}>
|
|
1465
|
+
<img src={preview} style={{ maxWidth: '25%', maxHeight: 75, height: '100%' }}/>
|
|
1466
|
+
</Grid>
|
|
1467
|
+
))}
|
|
1468
|
+
</Grid> */}
|
|
1469
|
+
|
|
1470
|
+
<Grid container direction="column" sx={{ overflowY: 'auto', maxHeight: '250px', mt: 1 }} wrap="nowrap">
|
|
1471
|
+
{value?.map((file, i) => (
|
|
1472
|
+
<Grid item key={i} sx={{ mt: 0.5 }}>
|
|
1473
|
+
<Grid container alignItems="center" justifyContent={"space-between"} wrap="nowrap">
|
|
1474
|
+
<Grid item>
|
|
1475
|
+
<Grid container alignItems="center">
|
|
1476
|
+
<Typography sx={{ mr: 1 }}>
|
|
1477
|
+
{file.name}
|
|
1478
|
+
</Typography>
|
|
1479
|
+
|
|
1480
|
+
{file.type?.includes('image') && previews[i] &&
|
|
1481
|
+
<Grid item>
|
|
1482
|
+
<img
|
|
1483
|
+
src={previews[i]!}
|
|
1484
|
+
style={{ maxWidth: '45%', maxHeight: 80, height: '100%' }}
|
|
1485
|
+
/>
|
|
1486
|
+
</Grid>
|
|
1487
|
+
}
|
|
1488
|
+
</Grid>
|
|
1489
|
+
</Grid>
|
|
1490
|
+
|
|
1491
|
+
<Grid item>
|
|
1492
|
+
<LabeledIconButton label="Remove"
|
|
1493
|
+
Icon={Delete}
|
|
1494
|
+
onClick={() => onChange(value.filter((f, _i) => i !== _i), field.id)}
|
|
1495
|
+
/>
|
|
1496
|
+
</Grid>
|
|
1497
|
+
</Grid>
|
|
1498
|
+
</Grid>
|
|
1499
|
+
))}
|
|
1500
|
+
</Grid>
|
|
1501
|
+
|
|
1502
|
+
{error &&
|
|
1503
|
+
<Grid item alignSelf="center" sx={{ mt: 0.5 }}>
|
|
1504
|
+
<Typography color="error">{error}</Typography>
|
|
1505
|
+
</Grid>
|
|
1506
|
+
}
|
|
1507
|
+
</Grid>
|
|
1508
|
+
)
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
export const MultipleChoiceInput = ({ field, form, value: _value, onChange }: FormInputProps<'multiple_choice'>) => {
|
|
1512
|
+
const value = typeof _value === 'string' ? [_value] : _value // if loading existingResponses, allows them to be a string
|
|
1513
|
+
const { choices, radio, other, optionDetails } = field.options as MultipleChoiceOptions
|
|
1514
|
+
const [expandedDescriptions, setExpandedDescriptions] = useState<Record<number, boolean>>({})
|
|
1515
|
+
|
|
1516
|
+
// current other string
|
|
1517
|
+
const enteringOtherStringRef = React.useRef('') // if typing otherString as prefix of a checkbox value, don't auto-select
|
|
1518
|
+
const otherString = value?.find(v => v === enteringOtherStringRef.current || !(choices ?? [])?.find(c => c === v)) ?? ''
|
|
1519
|
+
|
|
1520
|
+
// Get primary color from form customization or use default
|
|
1521
|
+
const primaryColor = form?.customization?.primaryColor ?? '#798ED0'
|
|
1522
|
+
|
|
1523
|
+
const getDescriptionForChoice = useCallback((choice: string) => {
|
|
1524
|
+
return optionDetails?.find(detail => detail.option === choice)?.description
|
|
1525
|
+
}, [optionDetails])
|
|
1526
|
+
|
|
1527
|
+
const toggleDescription = useCallback((index: number) => {
|
|
1528
|
+
setExpandedDescriptions(prev => ({
|
|
1529
|
+
...prev,
|
|
1530
|
+
[index]: !prev[index]
|
|
1531
|
+
}))
|
|
1532
|
+
}, [])
|
|
1533
|
+
|
|
1534
|
+
return (
|
|
1535
|
+
<Grid container alignItems="center" rowGap={1.5}>
|
|
1536
|
+
{radio
|
|
1537
|
+
? (
|
|
1538
|
+
<FormControl fullWidth>
|
|
1539
|
+
<RadioGroup
|
|
1540
|
+
aria-labelledby={`radio-group-${field.id}-label`}
|
|
1541
|
+
defaultValue="female"
|
|
1542
|
+
name={`radio-group-${field.id}`}
|
|
1543
|
+
>
|
|
1544
|
+
{(choices ?? []).map((c, i) => {
|
|
1545
|
+
const description = getDescriptionForChoice(c)
|
|
1546
|
+
const hasDescription = !!description
|
|
1547
|
+
const isExpanded = expandedDescriptions[i]
|
|
1548
|
+
const isSelected = !!value?.includes(c) && c !== otherString
|
|
1549
|
+
|
|
1550
|
+
return (
|
|
1551
|
+
<Box key={i} sx={{ width: '100%' }}>
|
|
1552
|
+
<Box
|
|
1553
|
+
sx={{
|
|
1554
|
+
display: 'flex',
|
|
1555
|
+
alignItems: 'center',
|
|
1556
|
+
width: '100%',
|
|
1557
|
+
border: isSelected ? '2px solid' : '1px solid',
|
|
1558
|
+
borderColor: 'primary.main',
|
|
1559
|
+
borderRadius: 1,
|
|
1560
|
+
padding: '16px 16px',
|
|
1561
|
+
marginBottom: '12px',
|
|
1562
|
+
cursor: 'pointer',
|
|
1563
|
+
backgroundColor: 'transparent',
|
|
1564
|
+
'&:hover': {
|
|
1565
|
+
backgroundColor: (theme: any) => `${theme.palette.primary.main}14`,
|
|
1566
|
+
},
|
|
1567
|
+
}}
|
|
1568
|
+
onClick={() => onChange(value?.includes(c) ? [] : [c], field.id)}
|
|
1569
|
+
>
|
|
1570
|
+
<Typography component="span" sx={{ flex: 1, color: 'primary.main', fontSize: 13, fontWeight: 600 }}>{c}</Typography>
|
|
1571
|
+
{hasDescription && (
|
|
1572
|
+
<MuiIconButton
|
|
1573
|
+
className="expand-button"
|
|
1574
|
+
size="small"
|
|
1575
|
+
onClick={(e: React.MouseEvent) => {
|
|
1576
|
+
e.stopPropagation()
|
|
1577
|
+
toggleDescription(i)
|
|
1578
|
+
}}
|
|
1579
|
+
sx={{
|
|
1580
|
+
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
1581
|
+
transition: 'transform 0.2s',
|
|
1582
|
+
ml: 1
|
|
1583
|
+
}}
|
|
1584
|
+
>
|
|
1585
|
+
<ExpandMore fontSize="small" />
|
|
1586
|
+
</MuiIconButton>
|
|
1587
|
+
)}
|
|
1588
|
+
</Box>
|
|
1589
|
+
{hasDescription && (
|
|
1590
|
+
<Collapse in={isExpanded}>
|
|
1591
|
+
<Box sx={{ pl: 2, pr: 2, pb: 1, pt: 1 }}>
|
|
1592
|
+
<Typography variant="body2" color="text.secondary">
|
|
1593
|
+
{description}
|
|
1594
|
+
</Typography>
|
|
1595
|
+
</Box>
|
|
1596
|
+
</Collapse>
|
|
1597
|
+
)}
|
|
1598
|
+
</Box>
|
|
1599
|
+
)
|
|
1600
|
+
})}
|
|
1601
|
+
</RadioGroup>
|
|
1602
|
+
</FormControl>
|
|
1603
|
+
) : (
|
|
1604
|
+
(choices ?? []).map((c, i) => {
|
|
1605
|
+
const description = getDescriptionForChoice(c)
|
|
1606
|
+
const hasDescription = !!description
|
|
1607
|
+
const isExpanded = expandedDescriptions[i]
|
|
1608
|
+
|
|
1609
|
+
return (
|
|
1610
|
+
<Grid xs={12} key={i}>
|
|
1611
|
+
<Box sx={{ width: '100%' }}>
|
|
1612
|
+
<Box
|
|
1613
|
+
sx={{
|
|
1614
|
+
display: 'flex',
|
|
1615
|
+
alignItems: 'center',
|
|
1616
|
+
cursor: 'pointer',
|
|
1617
|
+
width: '100%'
|
|
1618
|
+
}}
|
|
1619
|
+
onClick={(e) => {
|
|
1620
|
+
// Don't trigger selection if clicking on the expand button
|
|
1621
|
+
if ((e.target as HTMLElement).closest('.expand-button')) {
|
|
1622
|
+
return
|
|
1623
|
+
}
|
|
1624
|
+
onChange(
|
|
1625
|
+
(
|
|
1626
|
+
value?.includes(c)
|
|
1627
|
+
? (
|
|
1628
|
+
(radio || field.options?.radioChoices?.includes(c))
|
|
1629
|
+
? []
|
|
1630
|
+
: value.filter(v => v !== c)
|
|
1631
|
+
)
|
|
1632
|
+
: (
|
|
1633
|
+
(radio || field.options?.radioChoices?.includes(c))
|
|
1634
|
+
? [c]
|
|
1635
|
+
: [...(value ?? []).filter(x => !field.options?.radioChoices?.includes(x)), c]
|
|
1636
|
+
)
|
|
1637
|
+
),
|
|
1638
|
+
field.id,
|
|
1639
|
+
)
|
|
1640
|
+
}}
|
|
1641
|
+
>
|
|
1642
|
+
<Checkbox
|
|
1643
|
+
color="primary"
|
|
1644
|
+
checked={!!value?.includes(c) && c !== otherString}
|
|
1645
|
+
inputProps={{ 'aria-label': 'primary checkbox' }}
|
|
1646
|
+
/>
|
|
1647
|
+
<Typography component="span" sx={{ flex: 1 }}>{c}</Typography>
|
|
1648
|
+
{hasDescription && (
|
|
1649
|
+
<MuiIconButton
|
|
1650
|
+
className="expand-button"
|
|
1651
|
+
size="small"
|
|
1652
|
+
onClick={(e: React.MouseEvent) => {
|
|
1653
|
+
e.stopPropagation()
|
|
1654
|
+
toggleDescription(i)
|
|
1655
|
+
}}
|
|
1656
|
+
sx={{
|
|
1657
|
+
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
1658
|
+
transition: 'transform 0.2s',
|
|
1659
|
+
ml: 1
|
|
1660
|
+
}}
|
|
1661
|
+
>
|
|
1662
|
+
<ExpandMore fontSize="small" />
|
|
1663
|
+
</MuiIconButton>
|
|
1664
|
+
)}
|
|
1665
|
+
</Box>
|
|
1666
|
+
{hasDescription && (
|
|
1667
|
+
<Collapse in={isExpanded}>
|
|
1668
|
+
<Box sx={{ pl: '42px', pr: 2, pb: 1 }}>
|
|
1669
|
+
<Typography variant="body2" color="text.secondary">
|
|
1670
|
+
{description}
|
|
1671
|
+
</Typography>
|
|
1672
|
+
</Box>
|
|
1673
|
+
</Collapse>
|
|
1674
|
+
)}
|
|
1675
|
+
</Box>
|
|
1676
|
+
</Grid>
|
|
1677
|
+
)
|
|
1678
|
+
})
|
|
1679
|
+
)
|
|
1680
|
+
}
|
|
1681
|
+
{other &&
|
|
1682
|
+
<Grid item xs={12}>
|
|
1683
|
+
<TextField // className={classes.textField}
|
|
1684
|
+
InputProps={{ sx: { borderRadius: 2.5 }}} // match Checkbox, not default styles
|
|
1685
|
+
sx={{ width: radio ? `calc(100% - 15px)` : '100%' }}
|
|
1686
|
+
size="small"
|
|
1687
|
+
aria-label={form_display_text_for_language(form, "Other")}
|
|
1688
|
+
value={otherString}
|
|
1689
|
+
placeholder={form_display_text_for_language(form, "Other")}
|
|
1690
|
+
variant="outlined"
|
|
1691
|
+
// onClick={() => !otherChecked && handleOtherChecked()} // allow click to enable when disabled
|
|
1692
|
+
onChange={e => {
|
|
1693
|
+
enteringOtherStringRef.current = e.target.value
|
|
1694
|
+
onChange(
|
|
1695
|
+
(
|
|
1696
|
+
radio
|
|
1697
|
+
? (
|
|
1698
|
+
e.target.value.trim()
|
|
1699
|
+
? [e.target.value]
|
|
1700
|
+
: []
|
|
1701
|
+
)
|
|
1702
|
+
: (
|
|
1703
|
+
e.target.value.trim()
|
|
1704
|
+
// remove existing other string (if exists) and append new one
|
|
1705
|
+
? [...(value ?? []).filter(v => v !== otherString), e.target.value]
|
|
1706
|
+
: value?.filter(v => v !== otherString)
|
|
1707
|
+
)
|
|
1708
|
+
),
|
|
1709
|
+
field.id,
|
|
1710
|
+
)
|
|
1711
|
+
}}
|
|
1712
|
+
/>
|
|
1713
|
+
</Grid>
|
|
1714
|
+
}
|
|
1715
|
+
</Grid>
|
|
1716
|
+
)
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
export const StripeInput = ({ field, value, onChange, setCustomerId, enduserId }: FormInputProps<'Stripe'> & {
|
|
1720
|
+
setCustomerId: React.Dispatch<React.SetStateAction<string | undefined>>,
|
|
1721
|
+
}) => {
|
|
1722
|
+
const session = useResolvedSession()
|
|
1723
|
+
const [clientSecret, setClientSecret] = useState('')
|
|
1724
|
+
const [businessName, setBusinessName] = useState('')
|
|
1725
|
+
const [isCheckout, setIsCheckout] = useState(false)
|
|
1726
|
+
const [stripePromise, setStripePromise] = useState<ReturnType<typeof loadStripe>>()
|
|
1727
|
+
const [answertext, setAnswertext] = useState('')
|
|
1728
|
+
const [error, setError] = useState('')
|
|
1729
|
+
const [selectedProducts, setSelectedProducts] = useState<string[]>([])
|
|
1730
|
+
const [showProductSelection, setShowProductSelection] = useState(false)
|
|
1731
|
+
const [availableProducts, setAvailableProducts] = useState<any[]>([])
|
|
1732
|
+
const [loadingProducts, setLoadingProducts] = useState(false)
|
|
1733
|
+
|
|
1734
|
+
const fetchRef = useRef(false)
|
|
1735
|
+
useEffect(() => {
|
|
1736
|
+
if (fetchRef.current) return
|
|
1737
|
+
if (value && (session.userInfo as any)?.stripeCustomerId) {
|
|
1738
|
+
return setCustomerId(c => c ? c : (session.userInfo as any)?.stripeCustomerId) // already paid or saved card
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// Check if product selection mode is enabled
|
|
1742
|
+
if (field.options?.stripeProductSelectionMode && (field.options?.productIds || []).length > 1) {
|
|
1743
|
+
setShowProductSelection(true)
|
|
1744
|
+
setLoadingProducts(true)
|
|
1745
|
+
|
|
1746
|
+
// Fetch product data with real-time Stripe pricing via proxy_read
|
|
1747
|
+
const productIds = (field.options.productIds || []).join(',')
|
|
1748
|
+
session.api.integrations.proxy_read({
|
|
1749
|
+
integration: 'Stripe',
|
|
1750
|
+
type: 'product-prices',
|
|
1751
|
+
id: productIds,
|
|
1752
|
+
query: field.options.stripeKey
|
|
1753
|
+
})
|
|
1754
|
+
.then(({ data }) => {
|
|
1755
|
+
setAvailableProducts(data.products || [])
|
|
1756
|
+
setLoadingProducts(false)
|
|
1757
|
+
})
|
|
1758
|
+
.catch((e: any) => {
|
|
1759
|
+
console.error('Error loading product data:', e)
|
|
1760
|
+
const errorMessage = e?.message?.includes?.('Stripe pricing error:')
|
|
1761
|
+
? e.message.replace('Stripe pricing error: ', '')
|
|
1762
|
+
: 'Failed to load product information from Stripe'
|
|
1763
|
+
setError(`Product configuration error: ${errorMessage}`)
|
|
1764
|
+
setLoadingProducts(false)
|
|
1765
|
+
})
|
|
1766
|
+
return
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
fetchRef.current = true
|
|
1770
|
+
|
|
1771
|
+
session.api.form_responses.stripe_details({ fieldId: field.id, enduserId })
|
|
1772
|
+
.then(({ clientSecret, publishableKey, stripeAccount, businessName, customerId, isCheckout, answerText }) => {
|
|
1773
|
+
setAnswertext(answerText || '')
|
|
1774
|
+
setIsCheckout(!!isCheckout)
|
|
1775
|
+
setClientSecret(clientSecret)
|
|
1776
|
+
setStripePromise(loadStripe(publishableKey, { stripeAccount }))
|
|
1777
|
+
setBusinessName(businessName)
|
|
1778
|
+
setCustomerId(customerId)
|
|
1779
|
+
})
|
|
1780
|
+
.catch((e: any) => {
|
|
1781
|
+
console.error(e)
|
|
1782
|
+
if (typeof e?.message === 'string') {
|
|
1783
|
+
setError(e.message)
|
|
1784
|
+
}
|
|
1785
|
+
})
|
|
1786
|
+
}, [session, value, field.id, enduserId])
|
|
1787
|
+
|
|
1788
|
+
const cost = (
|
|
1789
|
+
showProductSelection
|
|
1790
|
+
? selectedProducts.reduce((total, productId) => {
|
|
1791
|
+
const product = availableProducts.find(p => p._id === productId)
|
|
1792
|
+
if (product?.currentPrice) {
|
|
1793
|
+
return total + (product.currentPrice.amount || 0)
|
|
1794
|
+
}
|
|
1795
|
+
return total + (product?.cost?.amount || 0)
|
|
1796
|
+
}, 0)
|
|
1797
|
+
: 0 // Will be calculated by existing Stripe flow when not in selection mode
|
|
1798
|
+
)
|
|
1799
|
+
|
|
1800
|
+
// Handle product selection step
|
|
1801
|
+
if (showProductSelection) {
|
|
1802
|
+
if (error) {
|
|
1803
|
+
return (
|
|
1804
|
+
<Grid container direction="column" spacing={2} alignItems="center">
|
|
1805
|
+
<Grid item>
|
|
1806
|
+
<Typography color="error" variant="h6">
|
|
1807
|
+
Product Configuration Error
|
|
1808
|
+
</Typography>
|
|
1809
|
+
</Grid>
|
|
1810
|
+
<Grid item>
|
|
1811
|
+
<Typography color="error" sx={{ textAlign: 'center' }}>
|
|
1812
|
+
{error}
|
|
1813
|
+
</Typography>
|
|
1814
|
+
</Grid>
|
|
1815
|
+
</Grid>
|
|
1816
|
+
)
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
if (loadingProducts) {
|
|
1820
|
+
return (
|
|
1821
|
+
<Grid container direction="column" spacing={2} alignItems="center">
|
|
1822
|
+
<Grid item>
|
|
1823
|
+
<LinearProgress />
|
|
1824
|
+
</Grid>
|
|
1825
|
+
<Grid item>
|
|
1826
|
+
<Typography>Loading product information...</Typography>
|
|
1827
|
+
</Grid>
|
|
1828
|
+
</Grid>
|
|
1829
|
+
)
|
|
1830
|
+
}
|
|
1831
|
+
const isSingleSelection = field.options?.radio === true
|
|
1832
|
+
|
|
1833
|
+
const handleProductSelection = (productId: string) => {
|
|
1834
|
+
if (isSingleSelection) {
|
|
1835
|
+
setSelectedProducts([productId])
|
|
1836
|
+
} else {
|
|
1837
|
+
setSelectedProducts(prev =>
|
|
1838
|
+
prev.includes(productId)
|
|
1839
|
+
? prev.filter(id => id !== productId)
|
|
1840
|
+
: [...prev, productId]
|
|
1841
|
+
)
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
const handleContinueToPayment = () => {
|
|
1846
|
+
if (selectedProducts.length === 0) return
|
|
1847
|
+
setShowProductSelection(false)
|
|
1848
|
+
fetchRef.current = true
|
|
1849
|
+
|
|
1850
|
+
// Now fetch Stripe details with selected products
|
|
1851
|
+
session.api.form_responses.stripe_details({
|
|
1852
|
+
fieldId: field.id,
|
|
1853
|
+
enduserId,
|
|
1854
|
+
...(selectedProducts.length > 0 && { selectedProductIds: selectedProducts }) // Pass selected products to Stripe checkout
|
|
1855
|
+
} as any)
|
|
1856
|
+
.then(({ clientSecret, publishableKey, stripeAccount, businessName, customerId, isCheckout, answerText }) => {
|
|
1857
|
+
setAnswertext(answerText || '')
|
|
1858
|
+
setIsCheckout(!!isCheckout)
|
|
1859
|
+
setClientSecret(clientSecret)
|
|
1860
|
+
setStripePromise(loadStripe(publishableKey, { stripeAccount }))
|
|
1861
|
+
setBusinessName(businessName)
|
|
1862
|
+
setCustomerId(customerId)
|
|
1863
|
+
})
|
|
1864
|
+
.catch((e: any) => {
|
|
1865
|
+
console.error(e)
|
|
1866
|
+
if (typeof e?.message === 'string') {
|
|
1867
|
+
setError(e.message)
|
|
1868
|
+
}
|
|
1869
|
+
})
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
return (
|
|
1873
|
+
<Grid container direction="column" spacing={2}>
|
|
1874
|
+
<Grid item>
|
|
1875
|
+
<Typography variant="h6">Select Product{isSingleSelection ? '' : 's'}</Typography>
|
|
1876
|
+
</Grid>
|
|
1877
|
+
|
|
1878
|
+
{availableProducts.map((product) => {
|
|
1879
|
+
// Use real-time Stripe pricing if available, fallback to Tellescope pricing
|
|
1880
|
+
const price = product.currentPrice || product.cost
|
|
1881
|
+
const priceAmount = price?.amount || 0
|
|
1882
|
+
const priceCurrency = price?.currency || 'USD'
|
|
1883
|
+
|
|
1884
|
+
return (
|
|
1885
|
+
<Grid item key={product._id}>
|
|
1886
|
+
<FormControlLabel
|
|
1887
|
+
control={
|
|
1888
|
+
isSingleSelection ? (
|
|
1889
|
+
<Radio
|
|
1890
|
+
checked={selectedProducts.includes(product._id)}
|
|
1891
|
+
onChange={() => handleProductSelection(product._id)}
|
|
1892
|
+
/>
|
|
1893
|
+
) : (
|
|
1894
|
+
<Checkbox
|
|
1895
|
+
checked={selectedProducts.includes(product._id)}
|
|
1896
|
+
onChange={() => handleProductSelection(product._id)}
|
|
1897
|
+
/>
|
|
1898
|
+
)
|
|
1899
|
+
}
|
|
1900
|
+
label={
|
|
1901
|
+
<Box>
|
|
1902
|
+
<Typography variant="body1" fontWeight="bold">
|
|
1903
|
+
{product.title}
|
|
1904
|
+
</Typography>
|
|
1905
|
+
{product.description && (
|
|
1906
|
+
<Typography variant="body2" color="textSecondary">
|
|
1907
|
+
{product.description}
|
|
1908
|
+
</Typography>
|
|
1909
|
+
)}
|
|
1910
|
+
<Typography variant="body2" color="primary">
|
|
1911
|
+
${(priceAmount / 100).toFixed(2)} {priceCurrency.toUpperCase()}
|
|
1912
|
+
{product.currentPrice?.isSubscription && (
|
|
1913
|
+
<Typography component="span" variant="caption" sx={{ ml: 0.5 }}>
|
|
1914
|
+
/month
|
|
1915
|
+
</Typography>
|
|
1916
|
+
)}
|
|
1917
|
+
</Typography>
|
|
1918
|
+
</Box>
|
|
1919
|
+
}
|
|
1920
|
+
/>
|
|
1921
|
+
</Grid>
|
|
1922
|
+
)
|
|
1923
|
+
})}
|
|
1924
|
+
|
|
1925
|
+
<Grid item>
|
|
1926
|
+
<Button
|
|
1927
|
+
variant="contained"
|
|
1928
|
+
onClick={handleContinueToPayment}
|
|
1929
|
+
disabled={selectedProducts.length === 0}
|
|
1930
|
+
sx={{ mt: 2 }}
|
|
1931
|
+
>
|
|
1932
|
+
Continue to Payment
|
|
1933
|
+
</Button>
|
|
1934
|
+
</Grid>
|
|
1935
|
+
</Grid>
|
|
1936
|
+
)
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
if (error) {
|
|
1940
|
+
return (
|
|
1941
|
+
<Typography color="error">
|
|
1942
|
+
{error}
|
|
1943
|
+
</Typography>
|
|
1944
|
+
)
|
|
1945
|
+
}
|
|
1946
|
+
if (value) {
|
|
1947
|
+
return (
|
|
1948
|
+
<Grid container alignItems="center" wrap="nowrap">
|
|
1949
|
+
<CheckCircleOutline color="success" />
|
|
1950
|
+
|
|
1951
|
+
<Typography sx={{ ml: 1, fontSize: 20 }}>
|
|
1952
|
+
{field.options?.chargeImmediately ? 'Your purchase was successful' : "Your payment details have been saved!"}
|
|
1953
|
+
</Typography>
|
|
1954
|
+
</Grid>
|
|
1955
|
+
)
|
|
1956
|
+
}
|
|
1957
|
+
if (!(clientSecret && stripePromise)) return <LinearProgress />
|
|
1958
|
+
if (isCheckout && stripePromise) return (
|
|
1959
|
+
<EmbeddedCheckoutProvider stripe={stripePromise}
|
|
1960
|
+
options={{
|
|
1961
|
+
clientSecret,
|
|
1962
|
+
onComplete: () => onChange(answertext || 'Completed checkout', field.id),
|
|
1963
|
+
}}
|
|
1964
|
+
>
|
|
1965
|
+
<EmbeddedCheckout />
|
|
1966
|
+
</EmbeddedCheckoutProvider>
|
|
1967
|
+
)
|
|
1968
|
+
return (
|
|
1969
|
+
<Elements stripe={stripePromise} options={{
|
|
1970
|
+
clientSecret,
|
|
1971
|
+
}}>
|
|
1972
|
+
<StripeForm businessName={businessName} onSuccess={() => onChange(answertext || 'Saved card details', field.id)}
|
|
1973
|
+
cost={cost}
|
|
1974
|
+
field={field}
|
|
1975
|
+
/>
|
|
1976
|
+
</Elements>
|
|
1977
|
+
)
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
const StripeForm = ({ businessName, onSuccess, field, cost } : { businessName: string, onSuccess: () => void, field: FormField, cost: number }) => {
|
|
1981
|
+
const stripe = useStripe();
|
|
1982
|
+
const elements = useElements()
|
|
1983
|
+
|
|
1984
|
+
const [ready, setReady] = useState(false)
|
|
1985
|
+
const [errorMessage, setErrorMessage] = useState('');
|
|
1986
|
+
|
|
1987
|
+
const handleSubmit = async (event: any) => {
|
|
1988
|
+
// We don't want to let default form submission happen here,
|
|
1989
|
+
// which would refresh the page.
|
|
1990
|
+
event?.preventDefault();
|
|
1991
|
+
|
|
1992
|
+
if (!stripe || !elements) {
|
|
1993
|
+
// Stripe.js hasn't yet loaded.
|
|
1994
|
+
// Make sure to disable form submission until Stripe.js has loaded.
|
|
1995
|
+
return null;
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
const {error} = await (field.options?.chargeImmediately ? stripe.confirmPayment : stripe.confirmSetup)({
|
|
1999
|
+
//`Elements` instance that was used to create the Payment Element
|
|
2000
|
+
elements,
|
|
2001
|
+
confirmParams: {
|
|
2002
|
+
return_url: window.location.href,
|
|
2003
|
+
},
|
|
2004
|
+
redirect: 'if_required', // ensures the redirect url won't be used, unless the Bank redirect payment type is enabled (it's not, just card)
|
|
2005
|
+
});
|
|
2006
|
+
|
|
2007
|
+
if (error) {
|
|
2008
|
+
// This point will only be reached if there is an immediate error when
|
|
2009
|
+
// confirming the payment. Show error to your customer (for example, payment
|
|
2010
|
+
// details incomplete)
|
|
2011
|
+
setErrorMessage(error?.message ?? '');
|
|
2012
|
+
} else {
|
|
2013
|
+
onSuccess()
|
|
2014
|
+
// Your customer will be redirected to your `return_url`. For some payment
|
|
2015
|
+
// methods like iDEAL, your customer will be redirected to an intermediate
|
|
2016
|
+
// site first to authorize the payment, then redirected to the `return_url`.
|
|
2017
|
+
}
|
|
2018
|
+
};
|
|
2019
|
+
|
|
2020
|
+
return (
|
|
2021
|
+
<form onSubmit={handleSubmit}>
|
|
2022
|
+
<PaymentElement onReady={() => setReady(true)}
|
|
2023
|
+
options={{
|
|
2024
|
+
business: { name: businessName },
|
|
2025
|
+
}}
|
|
2026
|
+
/>
|
|
2027
|
+
<Button variant="contained" color="primary" type="submit" sx={{ mt: 1 }}
|
|
2028
|
+
disabled={!(stripe && ready)}
|
|
2029
|
+
>
|
|
2030
|
+
{field.options?.chargeImmediately ? 'Make Payment' : 'Save Payment Details'}
|
|
2031
|
+
</Button>
|
|
2032
|
+
|
|
2033
|
+
{cost > 0 &&
|
|
2034
|
+
<Typography sx={{ mt: 0.5 }}>
|
|
2035
|
+
{
|
|
2036
|
+
field.options?.customPriceMessage
|
|
2037
|
+
? field.options.customPriceMessage.replaceAll('{{PRICE}}', `$${(cost / 100).toFixed(2)}`)
|
|
2038
|
+
: `You will be charged $${(cost / 100).toFixed(2)} ${field.options?.chargeImmediately ? '' : 'on form submission'}`
|
|
2039
|
+
}
|
|
2040
|
+
</Typography>
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
{/* Show error message to your customers */}
|
|
2044
|
+
{errorMessage &&
|
|
2045
|
+
<Typography color="error" sx={{ mt: 0.5 }}>
|
|
2046
|
+
{errorMessage}
|
|
2047
|
+
</Typography>
|
|
2048
|
+
}
|
|
2049
|
+
</form>
|
|
2050
|
+
)
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
export const Progress = ({ numerator, denominator, style, color } : { numerator: number, denominator: number, color?: string } & Styled) => (
|
|
2054
|
+
<Box sx={{ display: 'flex', alignItems: 'center', ...style }}>
|
|
2055
|
+
<Box sx={{ width: '100%' }}>
|
|
2056
|
+
<LinearProgress variant="determinate"
|
|
2057
|
+
value={(numerator / (denominator || 1)) * 100}
|
|
2058
|
+
sx={color ? {
|
|
2059
|
+
height: '16px',
|
|
2060
|
+
borderRadius: '8px',
|
|
2061
|
+
backgroundColor: `${color}20`,
|
|
2062
|
+
'& .MuiLinearProgress-bar': {
|
|
2063
|
+
backgroundColor: color,
|
|
2064
|
+
borderRadius: '8px',
|
|
2065
|
+
}
|
|
2066
|
+
} : {
|
|
2067
|
+
height: '16px',
|
|
2068
|
+
borderRadius: '8px',
|
|
2069
|
+
'& .MuiLinearProgress-bar': {
|
|
2070
|
+
borderRadius: '8px',
|
|
2071
|
+
}
|
|
2072
|
+
}}
|
|
2073
|
+
/>
|
|
2074
|
+
</Box>
|
|
2075
|
+
</Box>
|
|
2076
|
+
)
|
|
2077
|
+
|
|
2078
|
+
export const DropdownInput = ({ field, value, onChange }: FormInputProps<'Dropdown'>) => {
|
|
2079
|
+
const [typing, setTyping] = useState('')
|
|
2080
|
+
|
|
2081
|
+
// this should run only once, even if the field updates but the id is unchanged, otherwise will overwrite input
|
|
2082
|
+
const typingRef = useRef('')
|
|
2083
|
+
useEffect(() => {
|
|
2084
|
+
if (typingRef.current === field.id) return
|
|
2085
|
+
typingRef.current = field.id
|
|
2086
|
+
|
|
2087
|
+
setTyping('')
|
|
2088
|
+
}, [field])
|
|
2089
|
+
|
|
2090
|
+
return (
|
|
2091
|
+
<Autocomplete id={field.id} style={{ marginTop: 5 }}
|
|
2092
|
+
multiple={!field.options?.radio}
|
|
2093
|
+
freeSolo={!!field.options?.other}
|
|
2094
|
+
value={
|
|
2095
|
+
field.options?.radio
|
|
2096
|
+
? (value?.[0] ?? '')
|
|
2097
|
+
: (value ?? [])
|
|
2098
|
+
}
|
|
2099
|
+
onChange={(_, v) => (
|
|
2100
|
+
onChange(
|
|
2101
|
+
(typeof v === 'string' || v === null) ? [v ?? ''] : v,
|
|
2102
|
+
field.id
|
|
2103
|
+
)
|
|
2104
|
+
)}
|
|
2105
|
+
options={field.options?.choices ?? []}
|
|
2106
|
+
inputValue={
|
|
2107
|
+
field.options?.radio && Array.isArray(value) && value[0]
|
|
2108
|
+
? value[0]
|
|
2109
|
+
: typing
|
|
2110
|
+
}
|
|
2111
|
+
onInputChange={(e, value) => setTyping(value)}
|
|
2112
|
+
renderInput={params =>
|
|
2113
|
+
<TextField {...params}
|
|
2114
|
+
InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
|
|
2115
|
+
onChange={e => (
|
|
2116
|
+
(field.options?.radio && field.options.other)
|
|
2117
|
+
? onChange(e.target.value ? [e.target.value] : [], field.id)
|
|
2118
|
+
: undefined
|
|
2119
|
+
)}
|
|
2120
|
+
placeholder={
|
|
2121
|
+
field.placeholder
|
|
2122
|
+
? field.placeholder + ((!field.title && !field.isOptional) ? '*' : '')
|
|
2123
|
+
: undefined
|
|
2124
|
+
}
|
|
2125
|
+
label={
|
|
2126
|
+
(!field.options?.radio && field.options?.other)
|
|
2127
|
+
? "Press enter to save a custom value"
|
|
2128
|
+
: ''
|
|
2129
|
+
}
|
|
2130
|
+
/>
|
|
2131
|
+
}
|
|
2132
|
+
/>
|
|
2133
|
+
)
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
const choicesForDatabase: {
|
|
2137
|
+
[index: string]: {
|
|
2138
|
+
done: boolean,
|
|
2139
|
+
records: DatabaseRecord[],
|
|
2140
|
+
lastId?: string,
|
|
2141
|
+
} | {
|
|
2142
|
+
done: undefined,
|
|
2143
|
+
records: undefined,
|
|
2144
|
+
lastId?: string,
|
|
2145
|
+
}
|
|
2146
|
+
} = {}
|
|
2147
|
+
const preventRefetch: Record<string, boolean> = {}
|
|
2148
|
+
|
|
2149
|
+
const LOAD_CHOICES_LIMIT = 500
|
|
2150
|
+
const useDatabaseChoices = ({ databaseId='', field, otherAnswers } : { databaseId?: string, field: FormField, otherAnswers?: DatabaseSelectResponse[] }) => {
|
|
2151
|
+
const session = useResolvedSession()
|
|
2152
|
+
const [renderCount, setRenderCount] = useState(0)
|
|
2153
|
+
|
|
2154
|
+
// todo: make searchable, don't load all
|
|
2155
|
+
useEffect(() => {
|
|
2156
|
+
if (choicesForDatabase[databaseId]?.done) return
|
|
2157
|
+
if (renderCount > 100) return // limit to 50000 entries / prevent infinite looping
|
|
2158
|
+
const choices = choicesForDatabase[databaseId]?.records ?? []
|
|
2159
|
+
const lastId = choicesForDatabase[databaseId]?.lastId
|
|
2160
|
+
|
|
2161
|
+
if (preventRefetch[databaseId + field.id + lastId]) return
|
|
2162
|
+
preventRefetch[databaseId + field.id + lastId] = true
|
|
2163
|
+
|
|
2164
|
+
session.api.form_fields.load_choices_from_database({
|
|
2165
|
+
fieldId: field.id,
|
|
2166
|
+
lastId,
|
|
2167
|
+
limit: LOAD_CHOICES_LIMIT,
|
|
2168
|
+
databaseId, // overrides fieldId, supports using Database question in Table Input
|
|
2169
|
+
})
|
|
2170
|
+
.then(({ choices: newChoices }) => {
|
|
2171
|
+
choicesForDatabase[databaseId] = {
|
|
2172
|
+
lastId: newChoices?.[newChoices.length - 1]?.id,
|
|
2173
|
+
records: [...choices, ...newChoices]
|
|
2174
|
+
.sort((c1, c2) => (
|
|
2175
|
+
label_for_database_record(field, c1)
|
|
2176
|
+
.localeCompare(label_for_database_record(field, c2))
|
|
2177
|
+
)
|
|
2178
|
+
),
|
|
2179
|
+
done: newChoices.length < LOAD_CHOICES_LIMIT,
|
|
2180
|
+
}
|
|
2181
|
+
setRenderCount(r => r + 1)
|
|
2182
|
+
})
|
|
2183
|
+
.catch(err => {
|
|
2184
|
+
console.error(err)
|
|
2185
|
+
preventRefetch[databaseId + field.id + lastId] = false
|
|
2186
|
+
})
|
|
2187
|
+
}, [session, field, databaseId, renderCount])
|
|
2188
|
+
|
|
2189
|
+
const addChoice = useCallback((record: DatabaseRecord) => {
|
|
2190
|
+
if (!choicesForDatabase[databaseId]) {
|
|
2191
|
+
choicesForDatabase[databaseId] = {
|
|
2192
|
+
done: false,
|
|
2193
|
+
records: [],
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
choicesForDatabase[databaseId].records!.push(record)
|
|
2197
|
+
}, [choicesForDatabase, databaseId])
|
|
2198
|
+
|
|
2199
|
+
return {
|
|
2200
|
+
addChoice,
|
|
2201
|
+
doneLoading: choicesForDatabase[databaseId]?.done ?? false,
|
|
2202
|
+
choices: [
|
|
2203
|
+
...choicesForDatabase[databaseId]?.records ?? [],
|
|
2204
|
+
...(otherAnswers || []).map(v => ({
|
|
2205
|
+
id: v.text,
|
|
2206
|
+
databaseId,
|
|
2207
|
+
values: [{ label: field.options?.databaseLabel || '', type: 'Text', value: v.text }],
|
|
2208
|
+
}) as Pick<DatabaseRecord, 'id' | 'values' | 'databaseId'>)
|
|
2209
|
+
],
|
|
2210
|
+
renderCount,
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
|
|
2215
|
+
const label_for_database_record = (field: FormField, record?: Pick<DatabaseRecord, 'values'>) => {
|
|
2216
|
+
if (!record) return ''
|
|
2217
|
+
|
|
2218
|
+
const addedLabels = (
|
|
2219
|
+
(field.options?.databaseLabels || [])
|
|
2220
|
+
.map(l => record.values.find(v => v.label === l)?.value?.toString())
|
|
2221
|
+
.filter(v => v?.trim())
|
|
2222
|
+
) as string[]
|
|
2223
|
+
|
|
2224
|
+
return (
|
|
2225
|
+
(record.values.find(v => v.label === field.options?.databaseLabel)?.value?.toString() ?? '')
|
|
2226
|
+
+ (
|
|
2227
|
+
addedLabels.length
|
|
2228
|
+
? ` (${addedLabels.join(', ')})`
|
|
2229
|
+
: ''
|
|
2230
|
+
)
|
|
2231
|
+
)
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
const get_other_answers = (_value?: DatabaseSelectResponse[], typing?: string) => {
|
|
2235
|
+
try {
|
|
2236
|
+
const existing = (
|
|
2237
|
+
(_value || [])
|
|
2238
|
+
.filter(v => typeof v === 'string' || v.recordId === v.text)
|
|
2239
|
+
.map(v => typeof v === 'string' ? { databaseId: '', recordId: v, text: v } : v)
|
|
2240
|
+
)
|
|
2241
|
+
if (typing) {
|
|
2242
|
+
existing.push({ text: typing, databaseId: '', recordId: typing })
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
return existing
|
|
2246
|
+
} catch(err) { console.error(err) }
|
|
2247
|
+
|
|
2248
|
+
return []
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
export interface AddToDatabaseProps {
|
|
2252
|
+
databaseId: string,
|
|
2253
|
+
onAdd: (record: DatabaseRecord) => void
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
export const DatabaseSelectInput = ({ AddToDatabase, field, value: _value, onChange, onDatabaseSelect, responses, size, disabled, enduser }: FormInputProps<'Database Select'> & {
|
|
2257
|
+
responses: FormResponseValue[],
|
|
2258
|
+
AddToDatabase?: React.JSXElementConstructor<AddToDatabaseProps>,
|
|
2259
|
+
}) => {
|
|
2260
|
+
const [typing, setTyping] = useState('')
|
|
2261
|
+
const { addChoice, choices, doneLoading } = useDatabaseChoices({
|
|
2262
|
+
databaseId: field.options?.databaseId,
|
|
2263
|
+
field,
|
|
2264
|
+
otherAnswers: get_other_answers(_value, field?.options?.other ? typing : undefined),
|
|
2265
|
+
})
|
|
2266
|
+
|
|
2267
|
+
const value = React.useMemo(() => {
|
|
2268
|
+
try {
|
|
2269
|
+
// if the value is a string (some single answer that was save), make sure we coerce to array
|
|
2270
|
+
const __value = typeof _value === 'string' ? [_value] : _value
|
|
2271
|
+
return (
|
|
2272
|
+
(__value?.map(v =>
|
|
2273
|
+
choices.find(c =>
|
|
2274
|
+
c.id === v.recordId || (typeof v === 'string' && label_for_database_record(field, c) === v)
|
|
2275
|
+
)
|
|
2276
|
+
)?.filter(v => v!) ?? []) as DatabaseRecord[]
|
|
2277
|
+
)
|
|
2278
|
+
} catch(err) {
|
|
2279
|
+
console.error('Error resolving database answers for _value', err)
|
|
2280
|
+
return []
|
|
2281
|
+
}
|
|
2282
|
+
}, [_value, choices, field])
|
|
2283
|
+
|
|
2284
|
+
const filterResponse = useMemo(() => (
|
|
2285
|
+
field.options?.databaseFilter?.fieldId
|
|
2286
|
+
? responses.find(r => r.fieldId === field.options?.databaseFilter?.fieldId)?.answer?.value
|
|
2287
|
+
: undefined
|
|
2288
|
+
), [responses, field.options?.databaseFilter])
|
|
2289
|
+
|
|
2290
|
+
// State filtering logic similar to Insurance component
|
|
2291
|
+
const addressQuestion = useMemo(() => responses?.find(r => {
|
|
2292
|
+
if (r.answer.type !== 'Address') return false
|
|
2293
|
+
if (r.field.intakeField !== 'Address') return false
|
|
2294
|
+
|
|
2295
|
+
// make sure state is actually defined (in case of multiple address questions, where 1+ are blank)
|
|
2296
|
+
if (!r.answer.value?.state) return false
|
|
2297
|
+
|
|
2298
|
+
return true
|
|
2299
|
+
}), [responses])
|
|
2300
|
+
|
|
2301
|
+
const state = useMemo(() => (
|
|
2302
|
+
field.options?.filterByEnduserState
|
|
2303
|
+
? ((addressQuestion?.answer?.type === 'Address' ? addressQuestion?.answer?.value?.state : undefined) || enduser?.state)
|
|
2304
|
+
: undefined
|
|
2305
|
+
), [enduser?.state, addressQuestion, field.options?.filterByEnduserState])
|
|
2306
|
+
|
|
2307
|
+
const filteredChoicesWithPotentialDuplicates = useMemo(() => {
|
|
2308
|
+
if (!choices) return []
|
|
2309
|
+
if (!filterResponse) return choices
|
|
2310
|
+
if (!field?.options?.databaseFilter?.databaseLabel)
|
|
2311
|
+
if (!value || value.length === 0) return choices
|
|
2312
|
+
|
|
2313
|
+
return (
|
|
2314
|
+
choices
|
|
2315
|
+
.filter(c => {
|
|
2316
|
+
const v = c.values.find(_v => _v.label === field.options?.databaseFilter?.databaseLabel)?.value
|
|
2317
|
+
if (!v) return true
|
|
2318
|
+
|
|
2319
|
+
// use .text on r values to handle Database Select types as filter source (in addition to basic text and list of text)
|
|
2320
|
+
|
|
2321
|
+
if (typeof v === 'object') {
|
|
2322
|
+
return !!(
|
|
2323
|
+
Object.values(v).find(oVal => (
|
|
2324
|
+
typeof oVal === 'string' || typeof oVal === 'number'
|
|
2325
|
+
? (
|
|
2326
|
+
Array.isArray(filterResponse)
|
|
2327
|
+
? (filterResponse as any[]).find(r => r === oVal.toString() || (typeof r === 'object' && r.text === oVal))
|
|
2328
|
+
: (typeof filterResponse === 'string' || typeof filterResponse === 'number')
|
|
2329
|
+
? filterResponse.toString() === oVal.toString()
|
|
2330
|
+
: false
|
|
2331
|
+
)
|
|
2332
|
+
: false
|
|
2333
|
+
))
|
|
2334
|
+
)
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
if (typeof v === 'string' || typeof v === 'number') {
|
|
2338
|
+
return !!(
|
|
2339
|
+
Array.isArray(filterResponse)
|
|
2340
|
+
? (filterResponse as any[]).find(r => r === v.toString() || (typeof r === 'object' && r.text === v))
|
|
2341
|
+
: (typeof filterResponse === 'string' || typeof filterResponse === 'number')
|
|
2342
|
+
? filterResponse.toString() === v.toString()
|
|
2343
|
+
: (typeof filterResponse === 'object' && (filterResponse as Address).city === v.toString()) ? true
|
|
2344
|
+
: (typeof filterResponse === 'object' && (filterResponse as Address).state === v.toString()) ? true
|
|
2345
|
+
: (typeof filterResponse === 'object' && (filterResponse as Address).zipCode === v.toString()) ? true
|
|
2346
|
+
: false
|
|
2347
|
+
)
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
return false
|
|
2351
|
+
})
|
|
2352
|
+
)
|
|
2353
|
+
}, [choices, filterResponse, field.options?.databaseFilter, value])
|
|
2354
|
+
|
|
2355
|
+
// Apply state filtering as a secondary filter (doesn't modify existing logic)
|
|
2356
|
+
const stateFilteredChoices = useMemo(() => {
|
|
2357
|
+
if (!field.options?.filterByEnduserState || !state) {
|
|
2358
|
+
return filteredChoicesWithPotentialDuplicates
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
return filteredChoicesWithPotentialDuplicates.filter(c => {
|
|
2362
|
+
const recordState = c.values.find(v => v.label?.trim()?.toLowerCase() === 'state')?.value?.toString() || ''
|
|
2363
|
+
return !recordState || recordState === state
|
|
2364
|
+
})
|
|
2365
|
+
}, [filteredChoicesWithPotentialDuplicates, field.options?.filterByEnduserState, state])
|
|
2366
|
+
|
|
2367
|
+
const filteredChoices = useMemo(() => {
|
|
2368
|
+
const filtered = []
|
|
2369
|
+
|
|
2370
|
+
const uniques = new Set<string>([])
|
|
2371
|
+
for (const c of stateFilteredChoices) {
|
|
2372
|
+
const text = label_for_database_record(field, c)
|
|
2373
|
+
if (uniques.has(text)) continue // duplicate found
|
|
2374
|
+
|
|
2375
|
+
uniques.add(text)
|
|
2376
|
+
filtered.push(c)
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
return filtered
|
|
2380
|
+
}, [field, stateFilteredChoices])
|
|
2381
|
+
|
|
2382
|
+
if (!doneLoading) return <LinearProgress />
|
|
2383
|
+
return (
|
|
2384
|
+
<>
|
|
2385
|
+
<Autocomplete id={field.id} freeSolo={false} size={size}
|
|
2386
|
+
componentsProps={{ popper: { sx: { wordBreak: "break-word" } } } }
|
|
2387
|
+
options={filteredChoices} multiple={true}
|
|
2388
|
+
getOptionLabel={o => (
|
|
2389
|
+
Array.isArray(o) // edge case
|
|
2390
|
+
? ''
|
|
2391
|
+
: label_for_database_record(field, o)
|
|
2392
|
+
)}
|
|
2393
|
+
value={value}
|
|
2394
|
+
disabled={disabled}
|
|
2395
|
+
onChange={(_, v) => {
|
|
2396
|
+
if (v.length && onDatabaseSelect) {
|
|
2397
|
+
onDatabaseSelect(
|
|
2398
|
+
field.options?.radio
|
|
2399
|
+
? [v[v.length - 1]] // if radio, only last selected
|
|
2400
|
+
: v
|
|
2401
|
+
)
|
|
2402
|
+
}
|
|
2403
|
+
return onChange(
|
|
2404
|
+
(
|
|
2405
|
+
!field.options?.radio
|
|
2406
|
+
? v.map(_v => ({
|
|
2407
|
+
databaseId: field.options?.databaseId!,
|
|
2408
|
+
recordId: _v.id,
|
|
2409
|
+
text: label_for_database_record(field, _v),
|
|
2410
|
+
}))
|
|
2411
|
+
: [{
|
|
2412
|
+
databaseId: field.options?.databaseId!,
|
|
2413
|
+
recordId: v[v.length -1]?.id ?? '',
|
|
2414
|
+
text: label_for_database_record(field, v[v.length - 1]),
|
|
2415
|
+
}]
|
|
2416
|
+
),
|
|
2417
|
+
field.id,
|
|
2418
|
+
)
|
|
2419
|
+
}}
|
|
2420
|
+
inputValue={typing}
|
|
2421
|
+
onInputChange={(e, v) => e && setTyping(v)}
|
|
2422
|
+
renderInput={params => <TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }} />}
|
|
2423
|
+
// use custom Chip to ensure very long entries break properly (whitespace: normal)
|
|
2424
|
+
renderTags={(value, getTagProps) =>
|
|
2425
|
+
value.map((value, index) => (
|
|
2426
|
+
<Chip
|
|
2427
|
+
label={<Typography style={{whiteSpace: 'normal'}}>{Array.isArray(value) ? '' : label_for_database_record(field, value)}</Typography>}
|
|
2428
|
+
{...getTagProps({ index })}
|
|
2429
|
+
sx={{height:"100%", py: 0.5 }}
|
|
2430
|
+
/>
|
|
2431
|
+
))
|
|
2432
|
+
}
|
|
2433
|
+
/>
|
|
2434
|
+
|
|
2435
|
+
{AddToDatabase && field?.options?.allowAddToDatabase && (
|
|
2436
|
+
<AddToDatabase databaseId={field.options?.databaseId!} onAdd={addChoice} />
|
|
2437
|
+
)}
|
|
2438
|
+
</>
|
|
2439
|
+
)
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
type DisplayTermsResult = { displayTermsList: { term: string[] } }
|
|
2443
|
+
type Drug = {
|
|
2444
|
+
rxcui: string,
|
|
2445
|
+
name: string,
|
|
2446
|
+
synonym?: string,
|
|
2447
|
+
}
|
|
2448
|
+
let displayTermsCache = undefined as DisplayTermsResult | undefined
|
|
2449
|
+
const DRUGS_FOR_DISPLAY_TERM = {} as Record<string, Drug[]>
|
|
2450
|
+
const RX_NORM_CODE_FOR_DRUG = {} as Record<string, string>
|
|
2451
|
+
const NDC_CODES_FOR_RX_NORM_CODE = {} as Record<string, string[]>
|
|
2452
|
+
|
|
2453
|
+
const useMedications = ({ dontFetch } : { dontFetch?: boolean }) => {
|
|
2454
|
+
const [displayTerms, setDisplayTerms] = useState(displayTermsCache)
|
|
2455
|
+
const fetchRef = useRef(displayTerms !== undefined)
|
|
2456
|
+
|
|
2457
|
+
useEffect(() => {
|
|
2458
|
+
if (dontFetch) return
|
|
2459
|
+
if (fetchRef.current) return
|
|
2460
|
+
fetchRef.current = true
|
|
2461
|
+
|
|
2462
|
+
// thankfully, this endpoint has cache control, so repeated requests should fetch from disk anyway
|
|
2463
|
+
axios.get('https://rxnav.nlm.nih.gov/REST/displaynames.json')
|
|
2464
|
+
.then(result =>
|
|
2465
|
+
setDisplayTerms({
|
|
2466
|
+
displayTermsList: {
|
|
2467
|
+
term: (
|
|
2468
|
+
result.data?.displayTermsList?.term?.filter(
|
|
2469
|
+
(t: string) => {
|
|
2470
|
+
try {
|
|
2471
|
+
// parse out some of the not immediately useful / non-human-readable options
|
|
2472
|
+
if (t.startsWith('(')) return false
|
|
2473
|
+
if (t.startsWith('.')) return false
|
|
2474
|
+
if (!isNaN(parseInt(t.charAt(0)))) return false // starts with a number
|
|
2475
|
+
|
|
2476
|
+
return true
|
|
2477
|
+
} catch(err) { return false }
|
|
2478
|
+
}
|
|
2479
|
+
)
|
|
2480
|
+
)
|
|
2481
|
+
}
|
|
2482
|
+
})
|
|
2483
|
+
)
|
|
2484
|
+
.catch(console.error)
|
|
2485
|
+
}, [dontFetch])
|
|
2486
|
+
|
|
2487
|
+
const getDrugsForDisplayTerm = useCallback(async (s: string) => {
|
|
2488
|
+
const drugs = DRUGS_FOR_DISPLAY_TERM[s] || (
|
|
2489
|
+
(
|
|
2490
|
+
await axios.get(`https://rxnav.nlm.nih.gov/REST/drugs.json?name=${s}`)
|
|
2491
|
+
)
|
|
2492
|
+
.data?.drugGroup?.conceptGroup?.find((v: any) => v.conceptProperties)?.conceptProperties as Drug[]
|
|
2493
|
+
)
|
|
2494
|
+
if (!DRUGS_FOR_DISPLAY_TERM[s]) {
|
|
2495
|
+
DRUGS_FOR_DISPLAY_TERM[s] = drugs // cache for future lookups
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
return drugs
|
|
2499
|
+
}, [])
|
|
2500
|
+
|
|
2501
|
+
const getCodesForDrug = useCallback(async (s: string) => {
|
|
2502
|
+
// console.log(
|
|
2503
|
+
// (await axios.get(`https://rxnav.nlm.nih.gov/REST/rxcui.json?name=${s}&allsrc=1`)).data
|
|
2504
|
+
// )
|
|
2505
|
+
const rxNormCode = RX_NORM_CODE_FOR_DRUG[s] || (
|
|
2506
|
+
(
|
|
2507
|
+
await axios.get(`https://rxnav.nlm.nih.gov/REST/rxcui.json?name=${s}&allsrc=1`)
|
|
2508
|
+
)
|
|
2509
|
+
.data?.idGroup?.rxnormId?.[0] as string
|
|
2510
|
+
)
|
|
2511
|
+
RX_NORM_CODE_FOR_DRUG[s] = rxNormCode // cache for future lookups
|
|
2512
|
+
|
|
2513
|
+
// console.log(
|
|
2514
|
+
// `https://rxnav.nlm.nih.gov/REST/rxcui/${rxNormCode}/ndcs.json`,
|
|
2515
|
+
// (await axios.get(`https://rxnav.nlm.nih.gov/REST/rxcui/${rxNormCode}/ndcs.json`)).data
|
|
2516
|
+
// )
|
|
2517
|
+
const NDCs = NDC_CODES_FOR_RX_NORM_CODE[rxNormCode] || (
|
|
2518
|
+
(
|
|
2519
|
+
await axios.get(`https://rxnav.nlm.nih.gov/REST/rxcui/${rxNormCode}/ndcs.json`)
|
|
2520
|
+
)
|
|
2521
|
+
.data?.ndcGroup?.ndcList?.ndc as string[] ?? []
|
|
2522
|
+
)
|
|
2523
|
+
NDC_CODES_FOR_RX_NORM_CODE[rxNormCode] = NDCs // cache for future lookups
|
|
2524
|
+
|
|
2525
|
+
return {
|
|
2526
|
+
rxNormCode,
|
|
2527
|
+
NDCs,
|
|
2528
|
+
}
|
|
2529
|
+
}, [])
|
|
2530
|
+
|
|
2531
|
+
if (displayTerms === undefined) {
|
|
2532
|
+
return {
|
|
2533
|
+
displayTerms: undefined,
|
|
2534
|
+
doneLoading: false,
|
|
2535
|
+
getDrugsForDisplayTerm,
|
|
2536
|
+
getCodesForDrug,
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
return {
|
|
2540
|
+
displayTerms,
|
|
2541
|
+
doneLoading: true,
|
|
2542
|
+
getDrugsForDisplayTerm,
|
|
2543
|
+
getCodesForDrug,
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
const filterOptions = (options: string[], { inputValue } : { inputValue: string }) => (
|
|
2548
|
+
(
|
|
2549
|
+
inputValue
|
|
2550
|
+
? (
|
|
2551
|
+
options
|
|
2552
|
+
.filter(o => o.toLowerCase().includes(inputValue.toLowerCase()))
|
|
2553
|
+
// show shorter matches first (tends to promote exact match and simpler medications)
|
|
2554
|
+
.sort((v1, v2) => v1.length - v2.length)
|
|
2555
|
+
// .reverse()
|
|
2556
|
+
) : (
|
|
2557
|
+
options
|
|
2558
|
+
)
|
|
2559
|
+
)
|
|
2560
|
+
.slice(0, 100) // dramatic performance improvement (when not virtualized) to show a subset like this
|
|
2561
|
+
)
|
|
2562
|
+
|
|
2563
|
+
const FDB_URL = "http://www.fdbhealth.com/"
|
|
2564
|
+
type CanvasMedicationResult = {
|
|
2565
|
+
entry?: { resource: { code: { coding: { system: string, code: string, display: string } []}} }[]
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
export const CanvasMedicationsInput = ({ field, value=[], onChange }: FormInputProps<'Medications'>) => {
|
|
2569
|
+
const session = useResolvedSession()
|
|
2570
|
+
const [query, setQuery] = useState('')
|
|
2571
|
+
const [results, setResults] = useState<MedicationResponse[]>([])
|
|
2572
|
+
|
|
2573
|
+
// if two Medications questions shown in a row, reset state
|
|
2574
|
+
useEffect(() => {
|
|
2575
|
+
setQuery('')
|
|
2576
|
+
setResults([])
|
|
2577
|
+
}, [field.id])
|
|
2578
|
+
|
|
2579
|
+
const fetchRef = useRef(query)
|
|
2580
|
+
useEffect(() => {
|
|
2581
|
+
if (fetchRef.current === query) return
|
|
2582
|
+
fetchRef.current = query
|
|
2583
|
+
|
|
2584
|
+
if (!query) return
|
|
2585
|
+
|
|
2586
|
+
const t = setTimeout(() => {
|
|
2587
|
+
session.api.integrations
|
|
2588
|
+
.proxy_read({
|
|
2589
|
+
integration: CANVAS_TITLE,
|
|
2590
|
+
type: 'medications',
|
|
2591
|
+
query,
|
|
2592
|
+
})
|
|
2593
|
+
.then((r : { data: CanvasMedicationResult }) => {
|
|
2594
|
+
setResults(
|
|
2595
|
+
(r.data?.entry || [])
|
|
2596
|
+
.map(v => {
|
|
2597
|
+
const fdbCode = v.resource.code.coding.find(c => c.system === FDB_URL)
|
|
2598
|
+
|
|
2599
|
+
return {
|
|
2600
|
+
displayTerm: fdbCode?.display || '',
|
|
2601
|
+
drugName: fdbCode?.display || '',
|
|
2602
|
+
fdbCode: fdbCode?.code || '',
|
|
2603
|
+
}
|
|
2604
|
+
})
|
|
2605
|
+
)
|
|
2606
|
+
})
|
|
2607
|
+
}, 200)
|
|
2608
|
+
|
|
2609
|
+
return () => { clearTimeout(t) }
|
|
2610
|
+
}, [session, query, field?.options?.dataSource])
|
|
2611
|
+
|
|
2612
|
+
return (
|
|
2613
|
+
<Grid container direction="column" spacing={1}>
|
|
2614
|
+
<Grid item>
|
|
2615
|
+
<Autocomplete multiple value={value} options={results} style={{ marginTop: 5 }}
|
|
2616
|
+
noOptionsText={query.length ? 'No results found' : 'Type to start search'}
|
|
2617
|
+
onChange={(e, v) => {
|
|
2618
|
+
if (!v) { return }
|
|
2619
|
+
onChange(v, field.id)
|
|
2620
|
+
setResults([])
|
|
2621
|
+
}}
|
|
2622
|
+
getOptionLabel={v => first_letter_capitalized(v.displayTerm)} filterOptions={o => o}
|
|
2623
|
+
inputValue={query} onInputChange={(e, v) => e && setQuery(v) }
|
|
2624
|
+
renderInput={(params) => (
|
|
2625
|
+
<TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
|
|
2626
|
+
required={!field.isOptional} size="small" label="" placeholder="Search medications..."
|
|
2627
|
+
/>
|
|
2628
|
+
)}
|
|
2629
|
+
renderTags={(value, getTagProps) =>
|
|
2630
|
+
value.map((value, index) => (
|
|
2631
|
+
<Chip
|
|
2632
|
+
label={<Typography style={{whiteSpace: 'normal'}}>{value.displayTerm}</Typography>}
|
|
2633
|
+
{...getTagProps({ index })}
|
|
2634
|
+
sx={{height:"100%", py: 0.5 }}
|
|
2635
|
+
/>
|
|
2636
|
+
))
|
|
2637
|
+
}
|
|
2638
|
+
/>
|
|
2639
|
+
</Grid>
|
|
2640
|
+
|
|
2641
|
+
{(value || []).map((medication, i) => (
|
|
2642
|
+
<Grid item key={i}>
|
|
2643
|
+
<Grid container direction="column" spacing={0.75}>
|
|
2644
|
+
<Grid item>
|
|
2645
|
+
<Typography noWrap sx={{ fontSize: 14 }}>
|
|
2646
|
+
{medication.drugName}
|
|
2647
|
+
</Typography>
|
|
2648
|
+
</Grid>
|
|
2649
|
+
|
|
2650
|
+
<Grid item>
|
|
2651
|
+
<TextField InputProps={{ sx: defaultInputProps.sx }} fullWidth size="small"
|
|
2652
|
+
label="Medication instructions: how much you take, how often, and when"
|
|
2653
|
+
value={medication.dosage?.description || ''}
|
|
2654
|
+
onChange={e => (
|
|
2655
|
+
onChange((value || []).map((v, _i) =>
|
|
2656
|
+
i === _i
|
|
2657
|
+
? { ...v, dosage: { ...v.dosage!, description: e.target.value } }
|
|
2658
|
+
: v
|
|
2659
|
+
),
|
|
2660
|
+
field.id
|
|
2661
|
+
)
|
|
2662
|
+
)} />
|
|
2663
|
+
</Grid>
|
|
2664
|
+
|
|
2665
|
+
<Grid item>
|
|
2666
|
+
<Divider flexItem sx={{ my: 0.5 }} />
|
|
2667
|
+
</Grid>
|
|
2668
|
+
</Grid>
|
|
2669
|
+
</Grid>
|
|
2670
|
+
))}
|
|
2671
|
+
</Grid>
|
|
2672
|
+
)
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
export const MedicationsInput = ({ field, value, onChange, ...props }: FormInputProps<'Medications'>) => {
|
|
2676
|
+
const { displayTerms, doneLoading, getCodesForDrug, getDrugsForDisplayTerm } = useMedications({
|
|
2677
|
+
dontFetch: field.options?.dataSource === CANVAS_TITLE
|
|
2678
|
+
})
|
|
2679
|
+
const [drugs, setDrugs] = useState<Record<string, Drug[]>>({})
|
|
2680
|
+
|
|
2681
|
+
// uncomment to load data after initial typing
|
|
2682
|
+
// const [query, setQuery] = useState('')
|
|
2683
|
+
|
|
2684
|
+
// useEffect(() => {
|
|
2685
|
+
// if (!value?.length) return
|
|
2686
|
+
|
|
2687
|
+
// Promise.all((value ?? []).map(v => (
|
|
2688
|
+
// v.displayTerm ? getDrugsForDisplayTerm(v.displayTerm) : null
|
|
2689
|
+
// )))
|
|
2690
|
+
// .then(values => {
|
|
2691
|
+
// const toSet: typeof drugs = {}
|
|
2692
|
+
// values.forEach((v, i) => {
|
|
2693
|
+
// toSet[value[i].displayTerm] = v ?? []
|
|
2694
|
+
// if (!v?.length) {
|
|
2695
|
+
// // drug is unknown, and previously looked-up NDCs and rxNormCode should be reset
|
|
2696
|
+
// value[i].drugName = "Unknown"
|
|
2697
|
+
// value[i].NDCs = []
|
|
2698
|
+
// value[i].rxNormCode = ''
|
|
2699
|
+
// }
|
|
2700
|
+
// })
|
|
2701
|
+
|
|
2702
|
+
// setDrugs(toSet)
|
|
2703
|
+
// })
|
|
2704
|
+
// .catch(console.error)
|
|
2705
|
+
// }, [value, getDrugsForDisplayTerm])
|
|
2706
|
+
|
|
2707
|
+
if (field.options?.dataSource === CANVAS_TITLE) {
|
|
2708
|
+
return <CanvasMedicationsInput field={field} value={value} onChange={onChange} {...props} />
|
|
2709
|
+
}
|
|
2710
|
+
return (
|
|
2711
|
+
<Grid container direction="column" sx={{ mt: 2 }}>
|
|
2712
|
+
{(value ?? []).map((v, i) => (
|
|
2713
|
+
<>
|
|
2714
|
+
<Grid item key={i}>
|
|
2715
|
+
<Grid container alignItems="center" wrap="nowrap">
|
|
2716
|
+
<Grid item sx={{ width: '100%'}}>
|
|
2717
|
+
<Grid container direction="column">
|
|
2718
|
+
<Grid item>
|
|
2719
|
+
<Autocomplete freeSolo={false} multiple={false} loading={!doneLoading}
|
|
2720
|
+
options={
|
|
2721
|
+
// uncomment to load data after initial typing
|
|
2722
|
+
// query.length === 0 ? [] :
|
|
2723
|
+
(displayTerms?.displayTermsList?.term ?? [])
|
|
2724
|
+
}
|
|
2725
|
+
// uncomment to load data after initial typing
|
|
2726
|
+
// noOptionsText={query.length === 0 ? "Start typing..." : undefined}
|
|
2727
|
+
// uncomment to load data after initial typing
|
|
2728
|
+
// inputValue={query} onInputChange={(e, v) => setQuery(v)}
|
|
2729
|
+
getOptionLabel={first_letter_capitalized}
|
|
2730
|
+
filterOptions={filterOptions}
|
|
2731
|
+
value={v.displayTerm}
|
|
2732
|
+
onChange={async (_, displayTerm) => {
|
|
2733
|
+
const drugs = displayTerm ? await getDrugsForDisplayTerm(displayTerm) : null
|
|
2734
|
+
|
|
2735
|
+
if (displayTerm) {
|
|
2736
|
+
setDrugs((ds) => ({
|
|
2737
|
+
...ds,
|
|
2738
|
+
[displayTerm]: drugs ?? [],
|
|
2739
|
+
}))
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
onChange(
|
|
2743
|
+
(value ?? []).map((_v, _i) => (
|
|
2744
|
+
i === _i
|
|
2745
|
+
? {
|
|
2746
|
+
..._v,
|
|
2747
|
+
displayTerm: displayTerm || '',
|
|
2748
|
+
drugName: drugs?.length ? '' : "Unknown",
|
|
2749
|
+
drugSynonym: '',
|
|
2750
|
+
reasonForTaking: '',
|
|
2751
|
+
dosage: {
|
|
2752
|
+
unit: '',
|
|
2753
|
+
value: '',
|
|
2754
|
+
quantity: '',
|
|
2755
|
+
frequency: '',
|
|
2756
|
+
},
|
|
2757
|
+
// reset these on new search term to avoid stale data
|
|
2758
|
+
NDCs: [],
|
|
2759
|
+
rxNormCode: '',
|
|
2760
|
+
}
|
|
2761
|
+
: _v
|
|
2762
|
+
)),
|
|
2763
|
+
field.id,
|
|
2764
|
+
)
|
|
2765
|
+
}}
|
|
2766
|
+
renderInput={params =>
|
|
2767
|
+
<TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }} required={!field.isOptional} label="Search" size="small" fullWidth />
|
|
2768
|
+
}
|
|
2769
|
+
/>
|
|
2770
|
+
</Grid>
|
|
2771
|
+
|
|
2772
|
+
{v.displayTerm && v.drugName !== "Unknown" && !v.otherDrug &&
|
|
2773
|
+
<Grid item sx={{ mt: 1.5 }}>
|
|
2774
|
+
<Autocomplete freeSolo={false}
|
|
2775
|
+
options={
|
|
2776
|
+
drugs[v.displayTerm]
|
|
2777
|
+
? drugs[v.displayTerm].length
|
|
2778
|
+
? drugs[v.displayTerm]
|
|
2779
|
+
: [{ name: 'Unknown', rxcui: '' }]
|
|
2780
|
+
: [] // still loading
|
|
2781
|
+
}
|
|
2782
|
+
multiple={false}
|
|
2783
|
+
getOptionLabel={d => d?.synonym ? d.synonym : (d?.name || '')}
|
|
2784
|
+
value={
|
|
2785
|
+
[...drugs[v.displayTerm] ?? [], { name: "Unknown", rxcui: '' }]
|
|
2786
|
+
.find(d => d.name === v.drugName) ?? null
|
|
2787
|
+
}
|
|
2788
|
+
onChange={async (_, drug) => {
|
|
2789
|
+
if (!drug) return
|
|
2790
|
+
|
|
2791
|
+
const info = (
|
|
2792
|
+
drug.name === 'Unknown'
|
|
2793
|
+
? await getCodesForDrug(v.displayTerm) // might get us a value, better than searching Unknown or keeping a prior value
|
|
2794
|
+
: await getCodesForDrug(drug.name)
|
|
2795
|
+
)
|
|
2796
|
+
onChange(
|
|
2797
|
+
(value ?? []).map((_v, _i) => (
|
|
2798
|
+
i === _i
|
|
2799
|
+
? {
|
|
2800
|
+
..._v,
|
|
2801
|
+
drugName: drug.name,
|
|
2802
|
+
drugSynonym: drug.synonym || '',
|
|
2803
|
+
...info,
|
|
2804
|
+
}
|
|
2805
|
+
: _v
|
|
2806
|
+
)),
|
|
2807
|
+
field.id,
|
|
2808
|
+
)
|
|
2809
|
+
}}
|
|
2810
|
+
renderInput={params =>
|
|
2811
|
+
<TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }} required={!field.isOptional} label="Drug Select" size="small" fullWidth />
|
|
2812
|
+
}
|
|
2813
|
+
/>
|
|
2814
|
+
</Grid>
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
{v.displayTerm && (v.drugName === "Unknown" || !v.drugName) &&
|
|
2818
|
+
<Grid item sx={{ mt: 1 }}>
|
|
2819
|
+
<TextField label='Other Drug' fullWidth size="small" required
|
|
2820
|
+
InputProps={defaultInputProps}
|
|
2821
|
+
value={value?.find((v, _i) => _i === i)?.otherDrug ?? ''}
|
|
2822
|
+
onChange={e => (
|
|
2823
|
+
onChange(
|
|
2824
|
+
(value ?? []).map((_v, _i) => (
|
|
2825
|
+
i === _i
|
|
2826
|
+
? {
|
|
2827
|
+
..._v,
|
|
2828
|
+
otherDrug: e.target.value
|
|
2829
|
+
}
|
|
2830
|
+
: _v
|
|
2831
|
+
)),
|
|
2832
|
+
field.id,
|
|
2833
|
+
)
|
|
2834
|
+
)}
|
|
2835
|
+
/>
|
|
2836
|
+
</Grid>
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
|
|
2840
|
+
{v.displayTerm &&
|
|
2841
|
+
<Grid container spacing={1} sx={{ mt: 0 }}>
|
|
2842
|
+
<Grid item xs={12} md={6}>
|
|
2843
|
+
<Typography sx={{ fontSize: 13.5 }}>
|
|
2844
|
+
Units (e.g. capsule, table, puff) per dose?
|
|
2845
|
+
</Typography>
|
|
2846
|
+
<TextField type="number" size="small" fullWidth
|
|
2847
|
+
InputProps={defaultInputProps}
|
|
2848
|
+
value={v.dosage?.quantity}
|
|
2849
|
+
onChange={e =>
|
|
2850
|
+
onChange(
|
|
2851
|
+
(value ?? []).map((_v, _i) => (
|
|
2852
|
+
i === _i
|
|
2853
|
+
? {
|
|
2854
|
+
..._v,
|
|
2855
|
+
dosage: {
|
|
2856
|
+
..._v.dosage!,
|
|
2857
|
+
quantity: e.target.value,
|
|
2858
|
+
},
|
|
2859
|
+
}
|
|
2860
|
+
: _v
|
|
2861
|
+
)),
|
|
2862
|
+
field.id,
|
|
2863
|
+
)
|
|
2864
|
+
}
|
|
2865
|
+
// hide arrows for number input, which continue to increase after initial press
|
|
2866
|
+
sx={{
|
|
2867
|
+
'& input[type=number]': {
|
|
2868
|
+
'-moz-appearance': 'textfield'
|
|
2869
|
+
},
|
|
2870
|
+
'& input[type=number]::-webkit-outer-spin-button': {
|
|
2871
|
+
'-webkit-appearance': 'none',
|
|
2872
|
+
margin: 0
|
|
2873
|
+
},
|
|
2874
|
+
'& input[type=number]::-webkit-inner-spin-button': {
|
|
2875
|
+
'-webkit-appearance': 'none',
|
|
2876
|
+
margin: 0
|
|
2877
|
+
}
|
|
2878
|
+
}}
|
|
2879
|
+
/>
|
|
2880
|
+
</Grid>
|
|
2881
|
+
|
|
2882
|
+
<Grid item xs={12} md={6}>
|
|
2883
|
+
<Typography sx={{ fontSize: 13.5 }}>
|
|
2884
|
+
How many times per <strong>day</strong>?
|
|
2885
|
+
</Typography>
|
|
2886
|
+
<StringSelector size="small"
|
|
2887
|
+
options={["1", "2", "3", "4", "5", "6", "As Needed"]}
|
|
2888
|
+
value={v.dosage?.frequency ?? ''}
|
|
2889
|
+
onChange={async (frequency) => {
|
|
2890
|
+
onChange(
|
|
2891
|
+
(value ?? []).map((_v, _i) => (
|
|
2892
|
+
i === _i
|
|
2893
|
+
? {
|
|
2894
|
+
..._v,
|
|
2895
|
+
dosage: {
|
|
2896
|
+
..._v.dosage!,
|
|
2897
|
+
frequency: frequency || ''
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
: _v
|
|
2901
|
+
)),
|
|
2902
|
+
field.id,
|
|
2903
|
+
)
|
|
2904
|
+
}}
|
|
2905
|
+
/>
|
|
2906
|
+
</Grid>
|
|
2907
|
+
</Grid>
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
{v.displayTerm &&
|
|
2911
|
+
<Grid item sx={{ mt: 1.25 }}>
|
|
2912
|
+
<TextField label="Reason for taking medication" size="small" fullWidth
|
|
2913
|
+
InputProps={defaultInputProps}
|
|
2914
|
+
value={v.reasonForTaking ?? ''}
|
|
2915
|
+
onChange={e =>
|
|
2916
|
+
onChange(
|
|
2917
|
+
(value ?? []).map((_v, _i) => (
|
|
2918
|
+
i === _i
|
|
2919
|
+
? {
|
|
2920
|
+
..._v,
|
|
2921
|
+
reasonForTaking: e.target.value,
|
|
2922
|
+
}
|
|
2923
|
+
: _v
|
|
2924
|
+
)),
|
|
2925
|
+
field.id,
|
|
2926
|
+
)
|
|
2927
|
+
}
|
|
2928
|
+
/>
|
|
2929
|
+
</Grid>
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
<Grid item>
|
|
2933
|
+
<Typography color="primary" sx={{ textDecoration: 'underline', cursor: 'pointer' }}
|
|
2934
|
+
onClick={() => onChange((value ?? []).filter((_, _i) => i !== _i), field.id)}
|
|
2935
|
+
>
|
|
2936
|
+
Remove medication
|
|
2937
|
+
</Typography>
|
|
2938
|
+
</Grid>
|
|
2939
|
+
|
|
2940
|
+
{window.location.origin.includes(':300') && i === 0 &&
|
|
2941
|
+
<Grid item sx={{ mt: 3 }}>
|
|
2942
|
+
<strong>DEBUG:</strong> <br />
|
|
2943
|
+
<pre style={{ wordWrap: 'break-word' }}>
|
|
2944
|
+
{JSON.stringify(value ?? {}, null, 2)}
|
|
2945
|
+
</pre>
|
|
2946
|
+
</Grid>
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
<Grid item>
|
|
2950
|
+
<Grid container>
|
|
2951
|
+
|
|
2952
|
+
</Grid>
|
|
2953
|
+
</Grid>
|
|
2954
|
+
</Grid>
|
|
2955
|
+
</Grid>
|
|
2956
|
+
</Grid>
|
|
2957
|
+
</Grid>
|
|
2958
|
+
|
|
2959
|
+
<Grid item><Divider flexItem sx={{ my: 1 }} /></Grid>
|
|
2960
|
+
</>
|
|
2961
|
+
))}
|
|
2962
|
+
|
|
2963
|
+
<Grid item>
|
|
2964
|
+
<Button color="primary" variant="outlined"
|
|
2965
|
+
onClick={() => onChange([...(value ?? []), { displayTerm: '', drugName: '' }], field.id)}
|
|
2966
|
+
>
|
|
2967
|
+
Add Medication
|
|
2968
|
+
</Button>
|
|
2969
|
+
</Grid>
|
|
2970
|
+
</Grid>
|
|
2971
|
+
)
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
export const contact_is_valid = (e: Partial<Enduser>) => {
|
|
2975
|
+
if (e.email) {
|
|
2976
|
+
try {
|
|
2977
|
+
emailValidator.validate()(e.email)
|
|
2978
|
+
} catch(err) {
|
|
2979
|
+
return "Email is invalid"
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
if (e.phone) {
|
|
2983
|
+
try {
|
|
2984
|
+
phoneValidator.validate()(e.phone)
|
|
2985
|
+
} catch(err) {
|
|
2986
|
+
return "Phone is invalid"
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
if (e.dateOfBirth && !isDateString(e.dateOfBirth)) {
|
|
2990
|
+
return "Date of birth should be MM-DD-YYYY"
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
export const RelatedContactsInput = ({ field, value: _value, onChange, ...props }: FormInputProps<'Related Contacts'>) => {
|
|
2995
|
+
// safeguard against any rogue values like empty string
|
|
2996
|
+
const value = Array.isArray(_value) ? _value : []
|
|
2997
|
+
|
|
2998
|
+
const [editing, setEditing] = useState(value.length === 1 ? 0 : -1)
|
|
2999
|
+
|
|
3000
|
+
const handleAddContact = useCallback(() => {
|
|
3001
|
+
onChange([
|
|
3002
|
+
...value,
|
|
3003
|
+
{ relationships: field?.options?.relatedContactTypes?.length === 1 ? [{ type: field.options.relatedContactTypes[0] as EnduserRelationship['type'], id: ''! } ] : [] }],
|
|
3004
|
+
field.id,
|
|
3005
|
+
true
|
|
3006
|
+
)
|
|
3007
|
+
setEditing(value.length)
|
|
3008
|
+
}, [onChange, value, field?.id, field?.options?.relatedContactTypes])
|
|
3009
|
+
|
|
3010
|
+
if (value[editing]) {
|
|
3011
|
+
const { fname, lname, email, phone, fields={}, dateOfBirth='', relationships } = value[editing]
|
|
3012
|
+
const errorMessage = contact_is_valid(value[editing])
|
|
3013
|
+
|
|
3014
|
+
return (
|
|
3015
|
+
<Grid container direction="column" spacing={1}>
|
|
3016
|
+
<Grid item>
|
|
3017
|
+
<Grid container alignItems="center" wrap="nowrap" spacing={1}>
|
|
3018
|
+
{!field.options?.hiddenDefaultFields?.includes('First Name') &&
|
|
3019
|
+
<Grid item xs={4}>
|
|
3020
|
+
<TextField label="First Name" size="small" fullWidth
|
|
3021
|
+
InputProps={defaultInputProps}
|
|
3022
|
+
value={fname} onChange={e => onChange(value.map((v, i) => i === editing ? { ...v, fname: e.target.value } : v), field.id)}
|
|
3023
|
+
/>
|
|
3024
|
+
</Grid>
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
{!field.options?.hiddenDefaultFields?.includes('Last Name') &&
|
|
3028
|
+
<Grid item xs={4}>
|
|
3029
|
+
<TextField label="Last Name" size="small" fullWidth
|
|
3030
|
+
InputProps={defaultInputProps}
|
|
3031
|
+
value={lname} onChange={e => onChange(value.map((v, i) => i === editing ? { ...v, lname: e.target.value } : v), field.id)}
|
|
3032
|
+
/>
|
|
3033
|
+
</Grid>
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
<Grid item xs={4}>
|
|
3037
|
+
<StringSelector options={field.options?.relatedContactTypes?.length ? field.options.relatedContactTypes : RELATIONSHIP_TYPES} label="Relationship" size="small"
|
|
3038
|
+
disabled={field?.options?.relatedContactTypes?.length === 1}
|
|
3039
|
+
value={relationships?.[0]?.type ?? ''}
|
|
3040
|
+
onChange={type => onChange(value.map((v, i) => i === editing ? { ...v, relationships: [{ type: type as EnduserRelationship['type'], id: '' /* to be filled on server-side */ }] } : v), field.id)}
|
|
3041
|
+
/>
|
|
3042
|
+
</Grid>
|
|
3043
|
+
</Grid>
|
|
3044
|
+
</Grid>
|
|
3045
|
+
|
|
3046
|
+
<Grid item>
|
|
3047
|
+
<Grid container alignItems="center" wrap="nowrap" spacing={1}>
|
|
3048
|
+
{!field.options?.hiddenDefaultFields?.includes('Date of Birth') &&
|
|
3049
|
+
<Grid item xs={4}>
|
|
3050
|
+
<DateStringInput value={dateOfBirth} field={{ ...field, isOptional: true }} size="small" label="Date of Birth (MM-DD-YYYY)"
|
|
3051
|
+
onChange={dateOfBirth => onChange(value.map((v, i) => i === editing ? { ...v, dateOfBirth } : v), field.id)}
|
|
3052
|
+
/>
|
|
3053
|
+
</Grid>
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
{!field.options?.hiddenDefaultFields?.includes('Email') &&
|
|
3057
|
+
<Grid item xs={4}>
|
|
3058
|
+
<TextField label="Email" size="small" fullWidth type="email"
|
|
3059
|
+
InputProps={defaultInputProps}
|
|
3060
|
+
value={email} onChange={e => onChange(value.map((v, i) => i === editing ? { ...v, email: e.target.value } : v), field.id)}
|
|
3061
|
+
/>
|
|
3062
|
+
</Grid>
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
{!field.options?.hiddenDefaultFields?.includes('Phone Number') &&
|
|
3066
|
+
<Grid item xs={4}>
|
|
3067
|
+
<TextField label="Phone Number" size="small" fullWidth
|
|
3068
|
+
InputProps={defaultInputProps}
|
|
3069
|
+
value={phone} onChange={e => onChange(value.map((v, i) => i === editing ? { ...v, phone: e.target.value } : v), field.id)}
|
|
3070
|
+
/>
|
|
3071
|
+
</Grid>
|
|
3072
|
+
}
|
|
3073
|
+
</Grid>
|
|
3074
|
+
</Grid>
|
|
3075
|
+
|
|
3076
|
+
{/* todo: refactor instead of copying from table input code? */}
|
|
3077
|
+
{(field.options?.tableChoices || []).length > 0 &&
|
|
3078
|
+
<Grid item>
|
|
3079
|
+
<Grid container spacing={1}>
|
|
3080
|
+
{(field.options?.tableChoices || []).map(({ info, label, type}, i) => (
|
|
3081
|
+
<Grid item xs={6} key={i}>
|
|
3082
|
+
{type === 'Text'
|
|
3083
|
+
? (
|
|
3084
|
+
<TextField label={label} size="small" fullWidth
|
|
3085
|
+
InputProps={defaultInputProps}
|
|
3086
|
+
value={fields[label] as string || ''}
|
|
3087
|
+
onChange={e => onChange(value.map((v, i) => i === editing ? { ...v, fields: { ...fields, [label]: e.target.value} } : v), field.id)}
|
|
3088
|
+
/>
|
|
3089
|
+
)
|
|
3090
|
+
: type === 'Date' ? (
|
|
3091
|
+
<DateStringInput label={label} size="small" fullWidth
|
|
3092
|
+
field={{ ...field, isOptional: true }}
|
|
3093
|
+
value={fields[label] as string || ''}
|
|
3094
|
+
onChange={(e='') => onChange(value.map((v, i) => i === editing ? { ...v, fields: { ...fields, [label]: e } } : v), field.id)}
|
|
3095
|
+
/>
|
|
3096
|
+
)
|
|
3097
|
+
: type === 'Select' ? (
|
|
3098
|
+
<FormControl size="small" fullWidth>
|
|
3099
|
+
<InputLabel id="demo-select-small">{label}</InputLabel>
|
|
3100
|
+
<Select label={label} size="small"
|
|
3101
|
+
sx={defaultInputProps.sx}
|
|
3102
|
+
value={fields[label] as string || ''}
|
|
3103
|
+
onChange={e => onChange(value.map((v, i) => i === editing ? { ...v, fields: { ...fields, [label]: e.target.value } } : v), field.id)}
|
|
3104
|
+
>
|
|
3105
|
+
<MenuItem value="">
|
|
3106
|
+
<em>None</em>
|
|
3107
|
+
</MenuItem>
|
|
3108
|
+
{info.choices.map(c => (
|
|
3109
|
+
<MenuItem key={c} value={c}>{c}</MenuItem>
|
|
3110
|
+
))}
|
|
3111
|
+
</Select>
|
|
3112
|
+
</FormControl>
|
|
3113
|
+
)
|
|
3114
|
+
: null
|
|
3115
|
+
}
|
|
3116
|
+
</Grid>
|
|
3117
|
+
))}
|
|
3118
|
+
</Grid>
|
|
3119
|
+
</Grid>
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
<Grid item sx={{ my: 0.75 }}>
|
|
3123
|
+
<Button variant="outlined" onClick={() => setEditing(-1)} size="small">
|
|
3124
|
+
Save Contact
|
|
3125
|
+
</Button>
|
|
3126
|
+
</Grid>
|
|
3127
|
+
|
|
3128
|
+
{errorMessage &&
|
|
3129
|
+
<Grid item>
|
|
3130
|
+
<Typography color="error">
|
|
3131
|
+
{errorMessage}
|
|
3132
|
+
</Typography>
|
|
3133
|
+
</Grid>
|
|
3134
|
+
}
|
|
3135
|
+
</Grid>
|
|
3136
|
+
)
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
return (
|
|
3140
|
+
<Grid container direction="column" spacing={1}>
|
|
3141
|
+
<Grid item>
|
|
3142
|
+
{value.map((contact, i) => (
|
|
3143
|
+
<Grid item key={i}>
|
|
3144
|
+
<Grid container alignItems="center" justifyContent={"space-between"} wrap="nowrap" spacing={1}>
|
|
3145
|
+
<Grid item>
|
|
3146
|
+
<Grid container alignItems="center">
|
|
3147
|
+
<IconButton onClick={() => setEditing(i)} color="primary" size="small">
|
|
3148
|
+
<Edit />
|
|
3149
|
+
</IconButton>
|
|
3150
|
+
<Typography noWrap>
|
|
3151
|
+
{user_display_name(contact) || `Unnamed Contact ${i + 1}`}
|
|
3152
|
+
</Typography>
|
|
3153
|
+
</Grid>
|
|
3154
|
+
</Grid>
|
|
3155
|
+
|
|
3156
|
+
<Grid item>
|
|
3157
|
+
<LabeledIconButton Icon={Delete} label="Remove" onClick={() => onChange(value.filter((v, _i) => i !== _i), field.id)} />
|
|
3158
|
+
</Grid>
|
|
3159
|
+
</Grid>
|
|
3160
|
+
</Grid>
|
|
3161
|
+
))}
|
|
3162
|
+
</Grid>
|
|
3163
|
+
|
|
3164
|
+
<Grid item>
|
|
3165
|
+
<Button variant="contained" onClick={handleAddContact}>
|
|
3166
|
+
Add Contact
|
|
3167
|
+
</Button>
|
|
3168
|
+
</Grid>
|
|
3169
|
+
</Grid>
|
|
3170
|
+
)
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
export const AppointmentBookingInput = ({ formResponseId, field, value, onChange, form, responses, goToPreviousField, isPreviousDisabled, enduserId, ...props }: FormInputProps<'Appointment Booking'>) => {
|
|
3174
|
+
const session = useResolvedSession()
|
|
3175
|
+
|
|
3176
|
+
const [loaded, setLoaded] = useState<Awaited<ReturnType<typeof session['api']['form_fields']['booking_info']>>>()
|
|
3177
|
+
const [error, setError] = useState('')
|
|
3178
|
+
const [acknowledgedWarning, setAcknowledgedWarning] = useState(false)
|
|
3179
|
+
const [height, setHeight] = useState(450)
|
|
3180
|
+
const [confirming, setConfirming] = useState(false)
|
|
3181
|
+
|
|
3182
|
+
const bookingPageId = field?.options?.bookingPageId
|
|
3183
|
+
|
|
3184
|
+
|
|
3185
|
+
const downloadICS = useCallback(async (event : Pick<CalendarEvent, 'id'>) => {
|
|
3186
|
+
try {
|
|
3187
|
+
downloadFile(
|
|
3188
|
+
await session.api.calendar_events.download_ics_file({ calendarEventId: event.id, excludeAttendee: true }) as any,
|
|
3189
|
+
{ name: "event.ics", dataIsURL: true, type: 'text/calendar'}
|
|
3190
|
+
)
|
|
3191
|
+
} catch(err) {
|
|
3192
|
+
console.error(err)
|
|
3193
|
+
}
|
|
3194
|
+
}, [session])
|
|
3195
|
+
|
|
3196
|
+
const addressQuestion = useMemo(() => responses?.find(r => {
|
|
3197
|
+
if (r.answer.type !== 'Address') return false
|
|
3198
|
+
if (r.field.intakeField !== 'Address') return false
|
|
3199
|
+
|
|
3200
|
+
// make sure state is actually defined (in case of multiple address questions, where 1+ are blank)
|
|
3201
|
+
if (!r.answer.value?.state) return false
|
|
3202
|
+
|
|
3203
|
+
return true
|
|
3204
|
+
}), [responses])
|
|
3205
|
+
const state = useMemo(() => (
|
|
3206
|
+
addressQuestion?.answer?.type === 'Address' ? addressQuestion?.answer?.value?.state : undefined
|
|
3207
|
+
), [addressQuestion])
|
|
3208
|
+
|
|
3209
|
+
const loadBookingInfo = useCallback(() => {
|
|
3210
|
+
if (!bookingPageId) return
|
|
3211
|
+
|
|
3212
|
+
setError('')
|
|
3213
|
+
session.api.form_fields.booking_info({
|
|
3214
|
+
enduserId,
|
|
3215
|
+
bookingPageId,
|
|
3216
|
+
enduserFields: { state }
|
|
3217
|
+
})
|
|
3218
|
+
.then(setLoaded)
|
|
3219
|
+
.catch(e => setError(e?.message || e?.toString() || 'Error loading appointment details'))
|
|
3220
|
+
}, [enduserId, bookingPageId, session, state])
|
|
3221
|
+
|
|
3222
|
+
const fetchRef = useRef(false)
|
|
3223
|
+
useEffect(() => {
|
|
3224
|
+
if (value) return
|
|
3225
|
+
if (!bookingPageId) return
|
|
3226
|
+
if (fetchRef.current) return
|
|
3227
|
+
fetchRef.current = true
|
|
3228
|
+
|
|
3229
|
+
loadBookingInfo()
|
|
3230
|
+
}, [bookingPageId, loadBookingInfo, value])
|
|
3231
|
+
|
|
3232
|
+
useEffect(() => {
|
|
3233
|
+
const handleMessage = (m: MessageEvent) => {
|
|
3234
|
+
// entropy to separate from other booking pages rendered on the same screen
|
|
3235
|
+
if (
|
|
3236
|
+
m?.data?.type === 'Booking Success'
|
|
3237
|
+
&& typeof m?.data?.bookedEventId === 'string'
|
|
3238
|
+
&& (!m?.data?.entropy || m?.data?.entropy === loaded?.entropy)
|
|
3239
|
+
) {
|
|
3240
|
+
onChange(m.data.bookedEventId, field.id)
|
|
3241
|
+
emit_gtm_event({ event: 'form_progress', fieldId: field.id, formId: field.formId, title: field.title, status: "Appointment Booked" })
|
|
3242
|
+
}
|
|
3243
|
+
if (m?.data?.type === 'CalendarPicker') {
|
|
3244
|
+
setHeight(750)
|
|
3245
|
+
}
|
|
3246
|
+
if (m?.data?.type === 'UsersPicker') {
|
|
3247
|
+
setHeight(450)
|
|
3248
|
+
}
|
|
3249
|
+
if (m?.data?.type === 'Confirmation') {
|
|
3250
|
+
setConfirming(true)
|
|
3251
|
+
}
|
|
3252
|
+
if (m?.data?.type === 'Join Link' && m?.data?.link) {
|
|
3253
|
+
update_local_storage('tellescope_last_booking_page_join_link', m.data.link)
|
|
3254
|
+
}
|
|
3255
|
+
else {
|
|
3256
|
+
setConfirming(false)
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
window.addEventListener('message', handleMessage)
|
|
3261
|
+
return () => { window.removeEventListener('message', handleMessage) }
|
|
3262
|
+
}, [field?.id, field?.formId, field?.title, onChange, acknowledgedWarning, value, loaded?.entropy])
|
|
3263
|
+
|
|
3264
|
+
if (value) {
|
|
3265
|
+
return (
|
|
3266
|
+
<Grid container direction="column" spacing={1}>
|
|
3267
|
+
<Grid item>
|
|
3268
|
+
<Grid container alignItems="center" wrap="nowrap">
|
|
3269
|
+
<CheckCircleOutline color="success" />
|
|
3270
|
+
|
|
3271
|
+
<Typography sx={{ ml: 1, fontSize: 20 }}>
|
|
3272
|
+
Your appointment has been booked
|
|
3273
|
+
</Typography>
|
|
3274
|
+
</Grid>
|
|
3275
|
+
</Grid>
|
|
3276
|
+
|
|
3277
|
+
<Grid item sx={{ maxWidth: 250 }}>
|
|
3278
|
+
<LoadingButton variant="contained" style={{ maxWidth: 250 }}
|
|
3279
|
+
submitText="Add to Calendar" submittingText="Downloading..."
|
|
3280
|
+
onClick={() => downloadICS({ id: value })}
|
|
3281
|
+
/>
|
|
3282
|
+
</Grid>
|
|
3283
|
+
</Grid>
|
|
3284
|
+
)
|
|
3285
|
+
}
|
|
3286
|
+
if (!bookingPageId) {
|
|
3287
|
+
return <Typography>No booking page specified</Typography>
|
|
3288
|
+
}
|
|
3289
|
+
if (error) {
|
|
3290
|
+
return (
|
|
3291
|
+
<Grid container direction="column" spacing={1}>
|
|
3292
|
+
<Grid item>
|
|
3293
|
+
<Typography color="error">Error: {error}</Typography>
|
|
3294
|
+
</Grid>
|
|
3295
|
+
|
|
3296
|
+
<Grid item>
|
|
3297
|
+
<LoadingButton disabled={!bookingPageId} style={{ maxWidth: 300 }}
|
|
3298
|
+
variant="contained" onClick={loadBookingInfo}
|
|
3299
|
+
submitText="Try Again" submittingText="Loading..."
|
|
3300
|
+
/>
|
|
3301
|
+
</Grid>
|
|
3302
|
+
</Grid>
|
|
3303
|
+
)
|
|
3304
|
+
}
|
|
3305
|
+
if (!loaded?.bookingURL) {
|
|
3306
|
+
return <LinearProgress />
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
let bookingURL = loaded.bookingURL
|
|
3310
|
+
if (field.options?.userTags?.length) {
|
|
3311
|
+
bookingURL += `&userTags=${
|
|
3312
|
+
field.options.userTags
|
|
3313
|
+
.flatMap(t => {
|
|
3314
|
+
// set dynamic tags if found
|
|
3315
|
+
if (t === '{{logic}}') {
|
|
3316
|
+
return new URL(window.location.href).searchParams.get('logic') || '{{logic}}'
|
|
3317
|
+
}
|
|
3318
|
+
if (t.startsWith("{{field.") && t.endsWith(".value}}")) {
|
|
3319
|
+
const fieldId = t.replace('{{field.', '').replace(".value}}", '')
|
|
3320
|
+
|
|
3321
|
+
const answer = responses?.find(r => r.fieldId === fieldId)?.answer
|
|
3322
|
+
if (!answer?.value) return t
|
|
3323
|
+
|
|
3324
|
+
if (answer.type === 'Insurance') {
|
|
3325
|
+
return answer.value.payerName || ''
|
|
3326
|
+
}
|
|
3327
|
+
if (Array.isArray(answer.value) && typeof answer.value?.[0] === 'string') {
|
|
3328
|
+
return answer.value as string[]
|
|
3329
|
+
}
|
|
3330
|
+
return form_response_value_to_string(answer.value)
|
|
3331
|
+
}
|
|
3332
|
+
return t
|
|
3333
|
+
})
|
|
3334
|
+
.join(',')
|
|
3335
|
+
}`
|
|
3336
|
+
}
|
|
3337
|
+
if (field.options?.userFilterTags?.length) {
|
|
3338
|
+
bookingURL += `&userFilterTags=${
|
|
3339
|
+
field.options.userFilterTags
|
|
3340
|
+
.flatMap(t => {
|
|
3341
|
+
// set dynamic tags if found
|
|
3342
|
+
if (t === '{{logic}}') {
|
|
3343
|
+
return new URL(window.location.href).searchParams.get('logic') || '{{logic}}'
|
|
3344
|
+
}
|
|
3345
|
+
if (t.startsWith("{{field.") && t.endsWith(".value}}")) {
|
|
3346
|
+
const fieldId = t.replace('{{field.', '').replace(".value}}", '')
|
|
3347
|
+
|
|
3348
|
+
const answer = responses?.find(r => r.fieldId === fieldId)?.answer
|
|
3349
|
+
if (!answer?.value) return t
|
|
3350
|
+
|
|
3351
|
+
if (answer.type === 'Insurance') {
|
|
3352
|
+
return answer.value.payerName || ''
|
|
3353
|
+
}
|
|
3354
|
+
if (Array.isArray(answer.value) && typeof answer.value?.[0] === 'string') {
|
|
3355
|
+
return answer.value as string[]
|
|
3356
|
+
}
|
|
3357
|
+
return form_response_value_to_string(answer.value)
|
|
3358
|
+
}
|
|
3359
|
+
return t
|
|
3360
|
+
})
|
|
3361
|
+
.join(',')
|
|
3362
|
+
}`
|
|
3363
|
+
}
|
|
3364
|
+
// need to use form?.id for internally-submitted forms because formResponseId isn't generated until initial submission or saved draft
|
|
3365
|
+
if (field.options?.holdAppointmentMinutes && (formResponseId || field?.id)) {
|
|
3366
|
+
bookingURL += `&formResponseId=${formResponseId || field?.id}`
|
|
3367
|
+
bookingURL += `&holdAppointmentMinutes=${field.options.holdAppointmentMinutes}`
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
return (
|
|
3371
|
+
<Grid container direction="column" spacing={1} sx={{ mt: 1 }}>
|
|
3372
|
+
{/* When skipping user selection, include a back button at the top for clearer navigation on mobile */}
|
|
3373
|
+
{!!field.options?.userFilterTags?.length && !field.options.userTags?.length && !isPreviousDisabled?.() && !confirming &&
|
|
3374
|
+
<Grid item alignSelf="flex-start" >
|
|
3375
|
+
<Button variant="outlined" onClick={goToPreviousField} sx={{ height: 25, p: 0.5, px: 1 }}>
|
|
3376
|
+
Back
|
|
3377
|
+
</Button>
|
|
3378
|
+
</Grid>
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
{loaded.warningMessage &&
|
|
3382
|
+
<Grid item>
|
|
3383
|
+
<Typography color="error" sx={{ fontSize: 20, fontWeight: 'bold' }}>
|
|
3384
|
+
{loaded.warningMessage}
|
|
3385
|
+
</Typography>
|
|
3386
|
+
</Grid>
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
<Grid item>
|
|
3390
|
+
{(!loaded.warningMessage || acknowledgedWarning)
|
|
3391
|
+
? (
|
|
3392
|
+
<iframe title="Appointment Booking Embed"
|
|
3393
|
+
src={bookingURL}
|
|
3394
|
+
style={{ border: 'none', width: '100%', height }}
|
|
3395
|
+
/>
|
|
3396
|
+
)
|
|
3397
|
+
: (
|
|
3398
|
+
<Button variant="outlined" onClick={() => setAcknowledgedWarning(true)}>
|
|
3399
|
+
Show Booking Page Preview
|
|
3400
|
+
</Button>
|
|
3401
|
+
)
|
|
3402
|
+
}
|
|
3403
|
+
</Grid>
|
|
3404
|
+
</Grid>
|
|
3405
|
+
)
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
export const HeightInput = ({ field, value={} as any, onChange, ...props }: FormInputProps<'Height'>) => (
|
|
3409
|
+
<Grid container alignItems='center' wrap="nowrap" spacing={1} style={{ marginTop: 5 }}>
|
|
3410
|
+
<Grid item sx={{ width: '100%' }}>
|
|
3411
|
+
<TextField fullWidth size="small" label="Feet" type="number"
|
|
3412
|
+
value={value?.feet || ''}
|
|
3413
|
+
onChange={e => onChange({ ...value, feet: parseInt(e.target.value) }, field.id)}
|
|
3414
|
+
/>
|
|
3415
|
+
</Grid>
|
|
3416
|
+
<Grid item sx={{ width: '100%' }}>
|
|
3417
|
+
<TextField fullWidth size="small" label="Inches" type="number"
|
|
3418
|
+
value={value?.inches ?? ''}
|
|
3419
|
+
onChange={e => onChange({ ...value, inches: parseInt(e.target.value) }, field.id)}
|
|
3420
|
+
/>
|
|
3421
|
+
</Grid>
|
|
3422
|
+
</Grid>
|
|
3423
|
+
)
|
|
3424
|
+
|
|
3425
|
+
export const include_current_url_parameters_if_templated = (url: string ) => {
|
|
3426
|
+
try {
|
|
3427
|
+
// get parameters from the current URL, and replace all values where {{URL_PARAM.paramName}} is used
|
|
3428
|
+
const params = new URL(window.location.href).searchParams
|
|
3429
|
+
return url.replace(/{{URL_PARAM\.(.*?)}}/g, (_, paramName) => {
|
|
3430
|
+
const value = params.get(paramName)
|
|
3431
|
+
console.log(paramName, value)
|
|
3432
|
+
if (value === null) return ''
|
|
3433
|
+
return value
|
|
3434
|
+
})
|
|
3435
|
+
|
|
3436
|
+
} catch(err) {
|
|
3437
|
+
console.error(err)
|
|
3438
|
+
}
|
|
3439
|
+
return url
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
export const RedirectInput = ({ enduserId, groupId, groupInsance, rootResponseId, formResponseId, field, submit, value={} as any, onChange, responses, enduser, ...props }: FormInputProps<'Redirect'>) => {
|
|
3443
|
+
const session = useResolvedSession()
|
|
3444
|
+
|
|
3445
|
+
let eId = ''
|
|
3446
|
+
try {
|
|
3447
|
+
eId = new URL(window.location.href).searchParams.get('eId') || enduserId || enduser?.id || ''
|
|
3448
|
+
} catch(err) {}
|
|
3449
|
+
|
|
3450
|
+
const email = (
|
|
3451
|
+
responses?.find(r => r.intakeField === 'email')?.answer?.value
|
|
3452
|
+
|| enduser?.email
|
|
3453
|
+
|| session.userInfo.email
|
|
3454
|
+
)
|
|
3455
|
+
const phone = (
|
|
3456
|
+
responses?.find(r => r.intakeField === 'phone')?.answer?.value
|
|
3457
|
+
|| enduser?.phone
|
|
3458
|
+
|| session.userInfo.phone
|
|
3459
|
+
)
|
|
3460
|
+
const fname = (
|
|
3461
|
+
responses?.find(r => r.intakeField === 'fname')?.answer?.value
|
|
3462
|
+
|| enduser?.fname
|
|
3463
|
+
|| session.userInfo?.fname
|
|
3464
|
+
)
|
|
3465
|
+
const lname = (
|
|
3466
|
+
responses?.find(r => r.intakeField === 'lname')?.answer?.value
|
|
3467
|
+
|| enduser?.lname
|
|
3468
|
+
|| session.userInfo?.lname
|
|
3469
|
+
)
|
|
3470
|
+
const state = (
|
|
3471
|
+
responses?.find(r => r.intakeField === 'state')?.answer?.value
|
|
3472
|
+
|| (responses?.find(r => r.intakeField === 'Address')?.answer?.value as any)?.state
|
|
3473
|
+
|| enduser?.state
|
|
3474
|
+
|| (session.userInfo as Enduser)?.state
|
|
3475
|
+
)
|
|
3476
|
+
|
|
3477
|
+
useEffect(() => {
|
|
3478
|
+
if (session.type === 'user') { return }
|
|
3479
|
+
|
|
3480
|
+
if (field.options?.redirectExternalUrl) {
|
|
3481
|
+
submit?.()
|
|
3482
|
+
.finally(() => {
|
|
3483
|
+
if (!field.options?.redirectExternalUrl) { return }
|
|
3484
|
+
|
|
3485
|
+
window.location.href = (
|
|
3486
|
+
include_current_url_parameters_if_templated(
|
|
3487
|
+
replace_enduser_template_values(
|
|
3488
|
+
field.options.redirectExternalUrl,
|
|
3489
|
+
{
|
|
3490
|
+
...session.userInfo as any,
|
|
3491
|
+
id: eId, email, fname, lname, state, phone,
|
|
3492
|
+
}
|
|
3493
|
+
)
|
|
3494
|
+
)
|
|
3495
|
+
)
|
|
3496
|
+
})
|
|
3497
|
+
.catch(console.error)
|
|
3498
|
+
|
|
3499
|
+
return
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
if (!field.options?.redirectFormId) { return }
|
|
3503
|
+
|
|
3504
|
+
session.api.form_responses.prepare_form_response({
|
|
3505
|
+
enduserId: session.userInfo.id || eId,
|
|
3506
|
+
formId: field.options.redirectFormId,
|
|
3507
|
+
rootResponseId: rootResponseId || formResponseId,
|
|
3508
|
+
parentResponseId: formResponseId,
|
|
3509
|
+
})
|
|
3510
|
+
.then(({ fullURL }) => (
|
|
3511
|
+
// we should still redirect even if submission fails
|
|
3512
|
+
submit?.()
|
|
3513
|
+
.catch(console.error)
|
|
3514
|
+
.finally(() => {
|
|
3515
|
+
// if accessing form group in portal
|
|
3516
|
+
if (window.location.href.includes('/documents') && groupId && groupInsance) {
|
|
3517
|
+
const toRedirect = `${window.location.origin}/documents?groupId=${groupId}&groupInstance=${groupInsance}`
|
|
3518
|
+
if (fullURL.endsWith('&')) {
|
|
3519
|
+
window.location.replace(fullURL + `back=${toRedirect}&`)
|
|
3520
|
+
} else {
|
|
3521
|
+
window.location.replace(fullURL + `&back=${toRedirect}`)
|
|
3522
|
+
}
|
|
3523
|
+
} else {
|
|
3524
|
+
window.location.replace(fullURL)
|
|
3525
|
+
}
|
|
3526
|
+
})
|
|
3527
|
+
))
|
|
3528
|
+
.catch(console.error)
|
|
3529
|
+
}, [session, email, fname, lname, state, phone])
|
|
3530
|
+
|
|
3531
|
+
if (session.type === 'user') {
|
|
3532
|
+
return (
|
|
3533
|
+
<Typography>
|
|
3534
|
+
Redirect is for patient-facing forms only
|
|
3535
|
+
</Typography>
|
|
3536
|
+
)
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
return null
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
export const HiddenValueInput = ({ goToNextField, goToPreviousField, field, value, onChange, isSinglePage, groupFields }: FormInputProps<'email'>) => {
|
|
3543
|
+
let lastRef = useRef(0)
|
|
3544
|
+
let lastIdRef = useRef('')
|
|
3545
|
+
|
|
3546
|
+
// in a Question Group, only the first Hidden Value should navigate
|
|
3547
|
+
// AND, it should only navigate if the group only contains hidden values
|
|
3548
|
+
const firstHiddenValue = groupFields?.find(v => v.type === 'Hidden Value')
|
|
3549
|
+
const dontNavigate = (
|
|
3550
|
+
(firstHiddenValue && firstHiddenValue?.id !== field.id) // is in a group, but not the first hidden value
|
|
3551
|
+
|| !!(groupFields?.find(v => v.type !== 'Hidden Value')) // group contains at least 1 non-hidden value
|
|
3552
|
+
)
|
|
3553
|
+
|
|
3554
|
+
const publicIdentifier = useMemo(() => {
|
|
3555
|
+
try {
|
|
3556
|
+
return new URL(window.location.href).searchParams.get('publicIdentifier') || ''
|
|
3557
|
+
} catch(err) {
|
|
3558
|
+
return ''
|
|
3559
|
+
}
|
|
3560
|
+
}, [])
|
|
3561
|
+
|
|
3562
|
+
const valueToSet = useMemo(() => (
|
|
3563
|
+
(field.title === "{{PUBLIC_IDENTIFIER}}" && publicIdentifier) ? publicIdentifier
|
|
3564
|
+
: field.title
|
|
3565
|
+
), [field.title, publicIdentifier])
|
|
3566
|
+
|
|
3567
|
+
useEffect(() => {
|
|
3568
|
+
if (lastRef.current > Date.now() - 1000 && lastIdRef.current === field.id) return
|
|
3569
|
+
lastRef.current = Date.now()
|
|
3570
|
+
lastIdRef.current = field.id
|
|
3571
|
+
|
|
3572
|
+
if (value) {
|
|
3573
|
+
if (isSinglePage) return
|
|
3574
|
+
onChange('', field.id)
|
|
3575
|
+
|
|
3576
|
+
if (dontNavigate) return
|
|
3577
|
+
goToPreviousField?.()
|
|
3578
|
+
} else {
|
|
3579
|
+
onChange(valueToSet, field.id)
|
|
3580
|
+
|
|
3581
|
+
if (dontNavigate) return
|
|
3582
|
+
|
|
3583
|
+
// pass value that is set after above onChange
|
|
3584
|
+
goToNextField?.({ type: 'Hidden Value', value: valueToSet })
|
|
3585
|
+
}
|
|
3586
|
+
}, [value, onChange, field.id, valueToSet, goToNextField, goToPreviousField, isSinglePage, dontNavigate])
|
|
3587
|
+
|
|
3588
|
+
return <></>
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
export const EmotiiInput = ({ goToNextField, goToPreviousField, field, value, onChange, form, formResponseId, ...props }: FormInputProps<'email'>) => {
|
|
3592
|
+
const session = useResolvedSession()
|
|
3593
|
+
const requestIdRef = useRef(value || `${field.id}${formResponseId || Date.now()}`)
|
|
3594
|
+
const [data, setData] = useState<{ surveyRequestId: string, surveyUrl: string }>()
|
|
3595
|
+
const [loadCount, setLoadCount] = useState(0)
|
|
3596
|
+
|
|
3597
|
+
const fetchRef = useRef(false)
|
|
3598
|
+
useEffect(() => {
|
|
3599
|
+
if (value) return
|
|
3600
|
+
if (fetchRef.current) return
|
|
3601
|
+
fetchRef.current = true
|
|
3602
|
+
|
|
3603
|
+
session.api.integrations
|
|
3604
|
+
.proxy_read({
|
|
3605
|
+
integration: EMOTII_TITLE,
|
|
3606
|
+
type: 'get_survey',
|
|
3607
|
+
id: props?.enduserId, // defaults to session id when not defined
|
|
3608
|
+
query: requestIdRef.current,
|
|
3609
|
+
})
|
|
3610
|
+
.then(r => setData(r.data))
|
|
3611
|
+
}, [session, value, props?.enduserId])
|
|
3612
|
+
|
|
3613
|
+
const loadAnswerRef = useRef(false)
|
|
3614
|
+
useEffect(() => {
|
|
3615
|
+
if (loadCount !== 2) return
|
|
3616
|
+
if (loadAnswerRef.current) return
|
|
3617
|
+
loadAnswerRef.current = true
|
|
3618
|
+
|
|
3619
|
+
onChange(requestIdRef.current, field.id)
|
|
3620
|
+
}, [loadCount])
|
|
3621
|
+
|
|
3622
|
+
if (value || loadCount === 2) return (
|
|
3623
|
+
<Grid container alignItems="center" wrap="nowrap">
|
|
3624
|
+
<CheckCircleOutline color="success" />
|
|
3625
|
+
|
|
3626
|
+
<Typography sx={{ ml: 1, fontSize: 20 }}>
|
|
3627
|
+
Please click Next or Submit to continue.
|
|
3628
|
+
</Typography>
|
|
3629
|
+
</Grid>
|
|
3630
|
+
)
|
|
3631
|
+
if (!data) { return <LinearProgress /> }
|
|
3632
|
+
return (
|
|
3633
|
+
<iframe src={data.surveyUrl} style={{ border: 'none', height: 650, width: '100%' }}
|
|
3634
|
+
onLoad={() => setLoadCount(l => l + 1)}
|
|
3635
|
+
/>
|
|
3636
|
+
)
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
type AllergyResult = {
|
|
3640
|
+
entry?: { resource: { code: { coding: { system: "http://www.fdbhealth.com/", code: string, display: string } []}} }[]
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
export const AllergiesInput = ({ goToNextField, goToPreviousField, field, value, onChange, form, formResponseId, ...props }: FormInputProps<'Allergies'>) => {
|
|
3644
|
+
const session = useResolvedSession()
|
|
3645
|
+
const [query, setQuery] = useState('')
|
|
3646
|
+
const [results, setResults] = useState<{ code: string, display: string }[]>([])
|
|
3647
|
+
|
|
3648
|
+
// if two allergy questions shown in a row, reset state
|
|
3649
|
+
useEffect(() => {
|
|
3650
|
+
setQuery('')
|
|
3651
|
+
setResults([])
|
|
3652
|
+
}, [field.id])
|
|
3653
|
+
|
|
3654
|
+
const fetchRef = useRef(query)
|
|
3655
|
+
useEffect(() => {
|
|
3656
|
+
if (fetchRef.current === query) return
|
|
3657
|
+
fetchRef.current = query
|
|
3658
|
+
|
|
3659
|
+
if (!query) return
|
|
3660
|
+
|
|
3661
|
+
const t = setTimeout(() => {
|
|
3662
|
+
if (field.options?.dataSource === CANVAS_TITLE) {
|
|
3663
|
+
session.api.integrations
|
|
3664
|
+
.proxy_read({
|
|
3665
|
+
integration: CANVAS_TITLE,
|
|
3666
|
+
type: 'allergies',
|
|
3667
|
+
query,
|
|
3668
|
+
})
|
|
3669
|
+
.then((r : { data: AllergyResult }) => {
|
|
3670
|
+
const deduped: typeof results = []
|
|
3671
|
+
const totalResults = (
|
|
3672
|
+
(r.data.entry || [])
|
|
3673
|
+
.flatMap(v => v?.resource?.code?.coding || [])
|
|
3674
|
+
.filter(v => v.system.includes('fdbhealth'))
|
|
3675
|
+
.map(v => ({ code: v.code, display: v.display, system: v.system }))
|
|
3676
|
+
)
|
|
3677
|
+
for (const v of totalResults) {
|
|
3678
|
+
if (deduped.find(d => d.display === v.display)) { continue }
|
|
3679
|
+
|
|
3680
|
+
deduped.push(v)
|
|
3681
|
+
}
|
|
3682
|
+
setResults(deduped)
|
|
3683
|
+
})
|
|
3684
|
+
} else {
|
|
3685
|
+
session.api.allergy_codes.getSome({ search: { query }})
|
|
3686
|
+
.then(results => {
|
|
3687
|
+
const deduped: typeof results = []
|
|
3688
|
+
for (const v of results) {
|
|
3689
|
+
if (deduped.find(d => d.display === v.display)) { continue }
|
|
3690
|
+
|
|
3691
|
+
deduped.push(v)
|
|
3692
|
+
}
|
|
3693
|
+
setResults(deduped)
|
|
3694
|
+
})
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
|
|
3698
|
+
}, 200)
|
|
3699
|
+
|
|
3700
|
+
return () => { clearTimeout(t) }
|
|
3701
|
+
}, [session, query, field?.options?.dataSource])
|
|
3702
|
+
|
|
3703
|
+
return (
|
|
3704
|
+
<Grid container direction="column" spacing={1}>
|
|
3705
|
+
<Grid item>
|
|
3706
|
+
<Autocomplete multiple value={value || []} options={results} style={{ marginTop: 5 }}
|
|
3707
|
+
noOptionsText={query.length ? 'No results found' : 'Type to start search'}
|
|
3708
|
+
onChange={(e, v) => {
|
|
3709
|
+
if (!v) { return }
|
|
3710
|
+
onChange(v, field.id)
|
|
3711
|
+
setResults([])
|
|
3712
|
+
}}
|
|
3713
|
+
getOptionLabel={v => first_letter_capitalized(v.display)} filterOptions={o => o}
|
|
3714
|
+
inputValue={query} onInputChange={(e, v) => e && setQuery(v) }
|
|
3715
|
+
renderInput={(params) => (
|
|
3716
|
+
<TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
|
|
3717
|
+
required={!field.isOptional} size="small" label="" placeholder="Search allergies..."
|
|
3718
|
+
/>
|
|
3719
|
+
)}
|
|
3720
|
+
renderTags={(value, getTagProps) =>
|
|
3721
|
+
value.map((value, index) => (
|
|
3722
|
+
<Chip
|
|
3723
|
+
label={<Typography style={{whiteSpace: 'normal'}}>{value.display}</Typography>}
|
|
3724
|
+
{...getTagProps({ index })}
|
|
3725
|
+
sx={{height:"100%", py: 0.5 }}
|
|
3726
|
+
/>
|
|
3727
|
+
))
|
|
3728
|
+
}
|
|
3729
|
+
/>
|
|
3730
|
+
</Grid>
|
|
3731
|
+
|
|
3732
|
+
{(value || []).map((allergy, i) => (
|
|
3733
|
+
<Grid item key={i}>
|
|
3734
|
+
<Grid container alignItems="center" wrap="nowrap" columnGap={0.5} justifyContent={"space-between"}>
|
|
3735
|
+
<Grid item>
|
|
3736
|
+
<Typography noWrap sx={{ width: 85, fontSize: 14 }}>
|
|
3737
|
+
{allergy.display}
|
|
3738
|
+
</Typography>
|
|
3739
|
+
</Grid>
|
|
3740
|
+
|
|
3741
|
+
<Grid item sx={{ width: 140 }}>
|
|
3742
|
+
<StringSelector options={['mild', 'moderate', 'severe']} size="small" label="Severity"
|
|
3743
|
+
value={allergy.severity || ''}
|
|
3744
|
+
onChange={severity => onChange((value || []).map((v, _i) => i === _i ? { ...v, severity } : v), field.id)}
|
|
3745
|
+
getDisplayValue={first_letter_capitalized}
|
|
3746
|
+
/>
|
|
3747
|
+
</Grid>
|
|
3748
|
+
|
|
3749
|
+
<Grid item sx={{ width: "50%" }}>
|
|
3750
|
+
<TextField InputProps={{ sx: defaultInputProps.sx }} fullWidth size="small" label="Note"
|
|
3751
|
+
value={allergy.note || ''}
|
|
3752
|
+
onChange={e => onChange((value || []).map((v, _i) => i === _i ? { ...v, note: e.target.value } : v), field.id)}
|
|
3753
|
+
/>
|
|
3754
|
+
</Grid>
|
|
3755
|
+
</Grid>
|
|
3756
|
+
</Grid>
|
|
3757
|
+
))}
|
|
3758
|
+
</Grid>
|
|
3759
|
+
)
|
|
3760
|
+
}
|
|
3761
|
+
const display_with_code = (v: { code: string, display: string }) => `${v.code}: ${first_letter_capitalized(v.display)}`
|
|
3762
|
+
|
|
3763
|
+
export const ConditionsInput = ({ goToNextField, goToPreviousField, field, value, onChange, form, formResponseId, ...props }: FormInputProps<'Conditions'>) => {
|
|
3764
|
+
const session = useResolvedSession()
|
|
3765
|
+
const [query, setQuery] = useState('')
|
|
3766
|
+
const [results, setResults] = useState<{ code: string, display: string }[]>([])
|
|
3767
|
+
|
|
3768
|
+
const fetchRef = useRef(query)
|
|
3769
|
+
useEffect(() => {
|
|
3770
|
+
if (fetchRef.current === query) return
|
|
3771
|
+
fetchRef.current = query
|
|
3772
|
+
|
|
3773
|
+
if (!query) return
|
|
3774
|
+
|
|
3775
|
+
const t = setTimeout(() => {
|
|
3776
|
+
session.api.diagnosis_codes.getSome({ search: { query } })
|
|
3777
|
+
.then(codes => {
|
|
3778
|
+
const deduped: typeof results = []
|
|
3779
|
+
for (const v of codes) {
|
|
3780
|
+
if (deduped.find(d => d.display === v.display)) { continue }
|
|
3781
|
+
|
|
3782
|
+
deduped.push(v)
|
|
3783
|
+
}
|
|
3784
|
+
setResults(deduped)
|
|
3785
|
+
})
|
|
3786
|
+
}, 200)
|
|
3787
|
+
|
|
3788
|
+
return () => { clearTimeout(t) }
|
|
3789
|
+
}, [session, query])
|
|
3790
|
+
|
|
3791
|
+
return (
|
|
3792
|
+
<Autocomplete multiple value={value || []} options={results} style={{ marginTop: 5 }}
|
|
3793
|
+
noOptionsText={query.length ? 'No results found' : 'Type to start search'}
|
|
3794
|
+
onChange={(e, v) => {
|
|
3795
|
+
if (!v) { return }
|
|
3796
|
+
onChange(v, field.id)
|
|
3797
|
+
setResults([])
|
|
3798
|
+
}}
|
|
3799
|
+
getOptionLabel={display_with_code} filterOptions={o => o}
|
|
3800
|
+
inputValue={query} onInputChange={(e, v) => e && setQuery(v) }
|
|
3801
|
+
renderInput={(params) => (
|
|
3802
|
+
<TextField {...params} InputProps={{ ...params.InputProps, sx: defaultInputProps.sx }}
|
|
3803
|
+
required={!field.isOptional} size="small" label="" placeholder="Search conditions..."
|
|
3804
|
+
/>
|
|
3805
|
+
)}
|
|
3806
|
+
renderTags={(value, getTagProps) =>
|
|
3807
|
+
value.map((value, index) => (
|
|
3808
|
+
<Chip
|
|
3809
|
+
label={<Typography style={{whiteSpace: 'normal'}}>{display_with_code(value)}</Typography>}
|
|
3810
|
+
{...getTagProps({ index })}
|
|
3811
|
+
sx={{height:"100%", py: 0.5 }}
|
|
3812
|
+
/>
|
|
3813
|
+
))
|
|
3814
|
+
}
|
|
3815
|
+
/>
|
|
3816
|
+
)
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3819
|
+
export const RichTextInput = ({ field, value, onChange }: FormInputProps<'Rich Text'>) => (
|
|
3820
|
+
<WYSIWYG stopEnterPropagation initialHTML={value} onChange={v => onChange(v, field.id)} style={{ width: '100%' }} editorStyle={{ width: '100%' }} />
|
|
3821
|
+
)
|
|
3822
|
+
|
|
3823
|
+
export const ChargeebeeInput = ({ field, value, onChange, setCustomerId }: FormInputProps<'Chargebee'> & {
|
|
3824
|
+
setCustomerId: React.Dispatch<React.SetStateAction<string | undefined>>,
|
|
3825
|
+
}) => {
|
|
3826
|
+
const session = useResolvedSession()
|
|
3827
|
+
const [url, setUrl] = useState('')
|
|
3828
|
+
const [error, setError] = useState('')
|
|
3829
|
+
|
|
3830
|
+
const [loadCount, setLoadCount] = useState(0)
|
|
3831
|
+
|
|
3832
|
+
const fetchRef = useRef(false)
|
|
3833
|
+
useEffect(() => {
|
|
3834
|
+
if (fetchRef.current) return
|
|
3835
|
+
fetchRef.current = true
|
|
3836
|
+
|
|
3837
|
+
session.api.form_responses.chargebee_details({ fieldId: field.id })
|
|
3838
|
+
.then(({ url }) => setUrl(url))
|
|
3839
|
+
.catch(setError)
|
|
3840
|
+
}, [session])
|
|
3841
|
+
|
|
3842
|
+
const loadAnswerRef = useRef(false)
|
|
3843
|
+
useEffect(() => {
|
|
3844
|
+
if (loadCount !== 2) return
|
|
3845
|
+
if (loadAnswerRef.current) return
|
|
3846
|
+
loadAnswerRef.current = true
|
|
3847
|
+
|
|
3848
|
+
onChange({ url }, field.id)
|
|
3849
|
+
}, [loadCount, url])
|
|
3850
|
+
|
|
3851
|
+
if (value || loadCount === 2) {
|
|
3852
|
+
return (
|
|
3853
|
+
<Grid container alignItems="center" wrap="nowrap">
|
|
3854
|
+
<CheckCircleOutline color="success" />
|
|
3855
|
+
|
|
3856
|
+
<Typography sx={{ ml: 1, fontSize: 20 }}>
|
|
3857
|
+
Your purchase was successful
|
|
3858
|
+
</Typography>
|
|
3859
|
+
</Grid>
|
|
3860
|
+
)
|
|
3861
|
+
}
|
|
3862
|
+
if (error && typeof error === 'string') return <Typography color="error">{error}</Typography>
|
|
3863
|
+
if (!url) return <LinearProgress />
|
|
3864
|
+
return (
|
|
3865
|
+
<iframe src={url} title="Checkout" style={{ border: 'none', width: '100%', height: 700 }}
|
|
3866
|
+
onLoad={() => setLoadCount(l => l + 1)}
|
|
3867
|
+
/>
|
|
3868
|
+
)
|
|
3869
|
+
}
|