@tellescope/react-components 1.228.0 → 1.230.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.
Files changed (57) hide show
  1. package/lib/cjs/Forms/forms.v2.d.ts +116 -0
  2. package/lib/cjs/Forms/forms.v2.d.ts.map +1 -0
  3. package/lib/cjs/Forms/forms.v2.js +760 -0
  4. package/lib/cjs/Forms/forms.v2.js.map +1 -0
  5. package/lib/cjs/Forms/hooks.d.ts.map +1 -1
  6. package/lib/cjs/Forms/hooks.js +8 -3
  7. package/lib/cjs/Forms/hooks.js.map +1 -1
  8. package/lib/cjs/Forms/index.d.ts +1 -0
  9. package/lib/cjs/Forms/index.d.ts.map +1 -1
  10. package/lib/cjs/Forms/index.js +6 -0
  11. package/lib/cjs/Forms/index.js.map +1 -1
  12. package/lib/cjs/Forms/inputs.v2.d.ts +81 -0
  13. package/lib/cjs/Forms/inputs.v2.d.ts.map +1 -0
  14. package/lib/cjs/Forms/inputs.v2.js +2289 -0
  15. package/lib/cjs/Forms/inputs.v2.js.map +1 -0
  16. package/lib/cjs/Forms/localization.d.ts.map +1 -1
  17. package/lib/cjs/Forms/localization.js +3 -0
  18. package/lib/cjs/Forms/localization.js.map +1 -1
  19. package/lib/cjs/Forms/types.d.ts +1 -0
  20. package/lib/cjs/Forms/types.d.ts.map +1 -1
  21. package/lib/cjs/state.d.ts +34 -0
  22. package/lib/cjs/state.d.ts.map +1 -1
  23. package/lib/cjs/state.js +16 -2
  24. package/lib/cjs/state.js.map +1 -1
  25. package/lib/esm/Forms/forms.v2.d.ts +116 -0
  26. package/lib/esm/Forms/forms.v2.d.ts.map +1 -0
  27. package/lib/esm/Forms/forms.v2.js +725 -0
  28. package/lib/esm/Forms/forms.v2.js.map +1 -0
  29. package/lib/esm/Forms/hooks.d.ts.map +1 -1
  30. package/lib/esm/Forms/hooks.js +8 -3
  31. package/lib/esm/Forms/hooks.js.map +1 -1
  32. package/lib/esm/Forms/index.d.ts +1 -0
  33. package/lib/esm/Forms/index.d.ts.map +1 -1
  34. package/lib/esm/Forms/index.js +2 -0
  35. package/lib/esm/Forms/index.js.map +1 -1
  36. package/lib/esm/Forms/inputs.v2.d.ts +81 -0
  37. package/lib/esm/Forms/inputs.v2.d.ts.map +1 -0
  38. package/lib/esm/Forms/inputs.v2.js +2218 -0
  39. package/lib/esm/Forms/inputs.v2.js.map +1 -0
  40. package/lib/esm/Forms/localization.d.ts.map +1 -1
  41. package/lib/esm/Forms/localization.js +3 -0
  42. package/lib/esm/Forms/localization.js.map +1 -1
  43. package/lib/esm/Forms/types.d.ts +1 -0
  44. package/lib/esm/Forms/types.d.ts.map +1 -1
  45. package/lib/esm/state.d.ts +34 -0
  46. package/lib/esm/state.d.ts.map +1 -1
  47. package/lib/esm/state.js +13 -0
  48. package/lib/esm/state.js.map +1 -1
  49. package/lib/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +9 -9
  51. package/src/Forms/forms.v2.tsx +1321 -0
  52. package/src/Forms/hooks.tsx +10 -5
  53. package/src/Forms/index.ts +5 -2
  54. package/src/Forms/inputs.v2.tsx +3869 -0
  55. package/src/Forms/localization.ts +1 -0
  56. package/src/Forms/types.ts +1 -0
  57. package/src/state.tsx +25 -5
@@ -0,0 +1,1321 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
+ import { Button, CircularProgress, FileBlob, FileUploadHandler, Flex, LinearProgress, LoadingButton, Modal, Paper, Styled, Typography, WithTheme, form_display_text_for_language, useFileUpload, useFormResponses, useSession } from "../index"
3
+ import { useListForFormFields, useOrganizationTheme, useTellescopeForm, WithOrganizationTheme, Response, FileResponse, NextFieldLogicOptions } from "./hooks"
4
+ import { ChangeHandler, FormInputs } from "./types"
5
+ import { AddToDatabaseProps, AddressInput, AllergiesInput, AppointmentBookingInput, ChargeebeeInput, ConditionsInput, DatabaseSelectInput, DateInput, DateStringInput, DropdownInput, EmailInput, EmotiiInput, FileInput, FilesInput, HeightInput, HiddenValueInput, InsuranceInput, LanguageSelect, MedicationsInput, MultipleChoiceInput, NumberInput, PhoneInput, Progress, RankingInput, RatingInput, RedirectInput, RelatedContactsInput, RichTextInput, SignatureInput, StringInput, StringLongInput, StripeInput, TableInput, TimeInput, TimezoneInput, defaultButtonStyles } from "./inputs.v2"
6
+ import { PRIMARY_HEX } from "@tellescope/constants"
7
+ import { FormResponse, FormField, Form, Enduser } from "@tellescope/types-client"
8
+ import { FormResponseAnswerFileValue, OrganizationTheme } from "@tellescope/types-models"
9
+ import { calculate_form_scoring, field_can_autosubmit, form_response_value_to_string, formatted_date, object_is_empty, objects_equivalent, read_local_storage, remove_script_tags, responses_satisfy_conditions, truncate_string } from "@tellescope/utilities"
10
+ import { Divider } from "@mui/material"
11
+
12
+ export const TellescopeFormContainerV2 = ({ businessId, organizationIds, ...props } : {
13
+ businessId?: string,
14
+ organizationIds?: string[],
15
+ dontAddContext?: boolean,
16
+ children: React.ReactNode,
17
+ hideBg?: boolean,
18
+ backgroundColor?: string,
19
+ hideLogo?: boolean,
20
+ logoHeight?: number,
21
+ language?: string,
22
+ onChangeLanguage?: (l: string) => void,
23
+ paperMinHeight?: React.CSSProperties['minHeight'],
24
+ maxWidth?: number,
25
+ } & Styled) => {
26
+ // if context already is provided, no need to duplicate
27
+ if (props.dontAddContext) return (
28
+ <TellescopeFormContainerWithThemeV2 {...props} />
29
+ )
30
+
31
+ return (
32
+ <WithOrganizationTheme businessId={businessId} organizationIds={organizationIds}>
33
+ <TellescopeFormContainerWithThemeV2 {...props} />
34
+ </WithOrganizationTheme>
35
+ )
36
+ }
37
+
38
+ const TellescopeFormContainerWithThemeV2: typeof TellescopeFormContainerV2 = ({ paperMinHeight=575, children, language, onChangeLanguage, style, hideBg, backgroundColor, hideLogo, logoHeight, maxWidth }) => {
39
+ const theme = useOrganizationTheme()
40
+
41
+ // V2: No paper background by default, cleaner layout with light blue background
42
+ // Ignore theme colors or white backgrounds - use our V2 default light blue
43
+ const shouldUseCustomBg = backgroundColor && backgroundColor !== theme.themeColor && backgroundColor !== '#ffffff'
44
+ const finalBgColor = shouldUseCustomBg ? backgroundColor : '#F4F3FA'
45
+
46
+ return (
47
+ <Flex flex={1} column alignItems="center" style={{ backgroundColor: finalBgColor, overflow: 'hidden', paddingTop: 40, paddingBottom: 40, ...style }}>
48
+ <Flex flex={1} column style={{ padding: '0 20px', maxWidth: maxWidth ?? 650, minWidth: 250, width: '100%', height: '100%' }}>
49
+ {language && onChangeLanguage &&
50
+ <Flex style={{ marginTop: 22 }}>
51
+ <LanguageSelect value={language} onChange={onChangeLanguage} />
52
+ </Flex>
53
+ }
54
+ {children}
55
+ </Flex>
56
+ </Flex>
57
+ )
58
+ }
59
+
60
+ export interface TellescopeFormProps extends ReturnType<typeof useTellescopeForm> {
61
+ form?: Form,
62
+ isPreview?: boolean,
63
+ onSuccess?: (r: FormResponse) => void,
64
+ customInputs?: FormInputs
65
+ submitted?: boolean,
66
+ thanksMessage?: string,
67
+ htmlThanksMessage?: string,
68
+ showSaveDraft?: boolean,
69
+ formTitle?: string,
70
+ repeats: Record<string, string | number>
71
+ backgroundColor?: string,
72
+ rootResponseId?: string,
73
+ parentResponseId?: string,
74
+ downloadComponent?: React.ReactNode,
75
+ enduser?: Partial<Enduser>,
76
+ groupId?: string,
77
+ groupInstance?: string,
78
+ logoHeight?: number,
79
+ }
80
+
81
+ const LOGO_HEIGHT = 40
82
+ export const TellescopeFormV2 = (props : TellescopeFormProps & Styled & { hideBg?: boolean, theme?: OrganizationTheme, inputStyle?: React.CSSProperties }) => (
83
+ <WithOrganizationTheme>
84
+ <TellescopeFormWithContextV2 {...props} logoHeight={props?.logoHeight || props?.form?.customization?.logoHeight} />
85
+ </WithOrganizationTheme>
86
+ )
87
+
88
+ export const QuestionForField = ({
89
+ form,
90
+ value,
91
+ field,
92
+ file,
93
+ responses,
94
+ selectedFiles,
95
+ onAddFile,
96
+ onFieldChange,
97
+ customInputs,
98
+ fields,
99
+ validateField,
100
+ repeats,
101
+ onRepeatsChange,
102
+ setCustomerId,
103
+ handleDatabaseSelect,
104
+ enduser,
105
+ goToPreviousField,
106
+ isPreviousDisabled,
107
+ enduserId,
108
+ formResponseId,
109
+ submit,
110
+ groupId,
111
+ groupInstance,
112
+ goToNextField,
113
+ spacing,
114
+ isSinglePage,
115
+ rootResponseId,
116
+ isInQuestionGroup,
117
+ logicOptions,
118
+ uploadingFiles, setUploadingFiles, handleFileUpload,
119
+ groupFields,
120
+ AddToDatabase,
121
+ } : {
122
+ spacing?: number,
123
+ form?: Form,
124
+ repeats: Record<string, string | number>,
125
+ onRepeatsChange: (v: Record<string, string | number>) => void,
126
+ value: Response,
127
+ file: FileResponse,
128
+ field: FormField,
129
+ setCustomerId: React.Dispatch<React.SetStateAction<string | undefined>>,
130
+ isSinglePage?: boolean,
131
+ isInQuestionGroup?: boolean,
132
+ questionGroupSize?: number,
133
+ logicOptions?: NextFieldLogicOptions,
134
+ handleFileUpload: (blob: FileBlob, fieldId: string) => Promise<any>,
135
+ uploadingFiles: { fieldId: string }[]
136
+ setUploadingFiles: React.Dispatch<React.SetStateAction<{ fieldId: string }[]>>,
137
+ groupFields?: FormField[],
138
+ AddToDatabase?: React.JSXElementConstructor<AddToDatabaseProps>,
139
+ } & Pick<TellescopeFormProps, "rootResponseId" | "goToNextField" | "groupId" | "groupInstance" | "submit" | "formResponseId" | 'enduserId' | 'isPreviousDisabled' | 'goToPreviousField' | 'enduser' | 'handleDatabaseSelect' | 'onAddFile' | 'onFieldChange' | 'fields' | 'customInputs' | 'responses' | 'selectedFiles' | 'validateField'>) => {
140
+ const String = customInputs?.['string'] ?? StringInput
141
+ const StringLong = customInputs?.['stringLong'] ?? StringLongInput
142
+ const Email = customInputs?.['email'] ?? EmailInput
143
+ const Number = customInputs?.['number'] ?? NumberInput
144
+ const Phone = customInputs?.['phone'] ?? PhoneInput
145
+ const ResolvedDateInput = customInputs?.['date'] ?? DateInput
146
+ const Signature = customInputs?.['signature'] ?? SignatureInput
147
+ const MultipleChoice = customInputs?.['multiple_choice'] ?? MultipleChoiceInput
148
+ const Stripe = customInputs?.['Stripe'] ?? StripeInput
149
+ const Chargebee = customInputs?.['Chargebee'] ?? ChargeebeeInput
150
+ const File = customInputs?.['file'] ?? FileInput
151
+ const Files = customInputs?.['files'] ?? FilesInput
152
+ const Ranking = customInputs?.['ranking'] ?? RankingInput
153
+ const Rating = customInputs?.['rating'] ?? RatingInput
154
+ const Address = customInputs?.['Address'] ?? AddressInput
155
+ const Time = customInputs?.['Time'] ?? TimeInput
156
+ const TimeZone = customInputs?.['Timezone'] ?? TimezoneInput
157
+ const Dropdown = customInputs?.['Dropdown'] ?? DropdownInput
158
+ const DatabaseSelect = customInputs?.['Database Select'] ?? DatabaseSelectInput
159
+ const Medications = customInputs?.['Medications'] ?? MedicationsInput
160
+ const RelatedContacts = customInputs?.['Related Contacts'] ?? RelatedContactsInput
161
+ const Insurance = customInputs?.['Insurance'] ?? InsuranceInput
162
+ const AppointmentBooking = customInputs?.['Appointment Booking'] ?? AppointmentBookingInput
163
+ const Height = customInputs?.['Height'] ?? HeightInput
164
+ const Redirect = customInputs?.['Redirect'] ?? RedirectInput
165
+ const HiddenValue = customInputs?.['Hidden Value'] ?? HiddenValueInput
166
+ const Emotii = customInputs?.['Emotii'] ?? EmotiiInput
167
+ const Allergies = customInputs?.['Allergies'] ?? AllergiesInput
168
+ const Conditions = customInputs?.['Conditions'] ?? ConditionsInput
169
+ const RichText = customInputs?.['Rich Text'] ?? RichTextInput
170
+
171
+ const validationMessage = validateField(field)
172
+
173
+ const feedback = useMemo(() => (
174
+ (field.feedback || [])
175
+ .filter(({ display, ifEquals }) => (
176
+ ifEquals === value.answer.value
177
+ || (Array.isArray(value.answer.value) && value.answer.value.includes(ifEquals as any))
178
+ ))
179
+ .map(v => v.display)
180
+ ), [field.feedback, value])
181
+
182
+ if (!value) return null
183
+ if (
184
+ isInQuestionGroup
185
+ && field.groupShowCondition && !object_is_empty(field.groupShowCondition)
186
+ && !responses_satisfy_conditions(responses, field.groupShowCondition, logicOptions)
187
+ ) {
188
+ return null
189
+ }
190
+ return (
191
+ // margin leaves room for error message in Question Group
192
+ <Flex column flex={1} style={{ marginBottom: spacing ?? 25 }} id={field.id}>
193
+ {field.type !== 'Redirect' && field.title &&
194
+ <Typography component="h4" style={{
195
+ marginTop: 15, // ensures PDF display doesn't push description into overlap with logo / title at top of form
196
+ marginBottom: 14,
197
+ fontSize: field.titleFontSize || (field.type === 'Question Group' ? 22 : 20),
198
+ fontWeight: field.type === 'Question Group' ? 'bold' : undefined,
199
+ }}>
200
+ {field.title}{!(field.isOptional || field.type === 'description' || field.type === 'Question Group' || field.type === 'Insurance') ? '*' : ''}
201
+ </Typography>
202
+ }
203
+ {!field.title && (field.type === 'Question Group' || field.type === 'signature') && !form?.customization?.hideLogo &&
204
+ // ensures PDF display doesn't push description into overlap with logo / title at top of form
205
+ // also ensures spacing between logo and question group
206
+ <div style={{ marginTop: 15 }}></div>
207
+ }
208
+
209
+ {feedback.length > 0 &&
210
+ <Flex column style={{ marginBottom: 11, marginTop: 3, }}>
211
+ {feedback.map((f, i) => (
212
+ <Typography key={i} color="error" style={{ fontSize: 20 }}>
213
+ {f}
214
+ </Typography>
215
+ ))}
216
+ </Flex>
217
+ }
218
+
219
+ {
220
+ // If field has pre-populated value and is set to be disabled when pre-populated, show as underlined text
221
+ field.disabledWhenPrepopulated && value.answer.value !== undefined && value.answer.value !== null && value.answer.value !== '' ? (
222
+ <div
223
+ style={{
224
+ padding: '8px 0',
225
+ borderBottom: '1px solid rgba(0, 0, 0, 0.42)',
226
+ width: '100%'
227
+ }}
228
+ >
229
+ <Typography
230
+ style={{
231
+ fontSize: '1rem',
232
+ color: 'rgba(0, 0, 0, 0.87)',
233
+ whiteSpace: 'pre-wrap'
234
+ }}
235
+ >
236
+ {form_response_value_to_string(value.answer.value)}
237
+ </Typography>
238
+ </div>
239
+ )
240
+ : field.type === 'file' ? (
241
+ <File field={field} value={file.blobs?.[0] as any} onChange={onAddFile as any} form={form}
242
+ existingFileName={
243
+ value.answer.type === 'file'
244
+ ? value.answer.value?.name
245
+ : ''
246
+ }
247
+ handleFileUpload={handleFileUpload} uploadingFiles={uploadingFiles} setUploadingFiles={setUploadingFiles}
248
+ />
249
+ )
250
+ : field.type === 'files' ? (
251
+ <Files field={field} value={file.blobs as any} onChange={onAddFile as any} form={form}
252
+ // existingFileName={
253
+ // value.answer.type === 'files'
254
+ // ? value.answer.value?.name
255
+ // : ''
256
+ // }
257
+ handleFileUpload={handleFileUpload} uploadingFiles={uploadingFiles} setUploadingFiles={setUploadingFiles}
258
+ />
259
+ )
260
+ : field.type === 'dateString' ? (
261
+ <DateStringInput field={field} disabled={value.disabled} value={value.answer.value as string} onChange={onFieldChange as ChangeHandler<'string'>} form={form} />
262
+ )
263
+ : field.type === 'Hidden Value' ? (
264
+ <HiddenValue groupFields={groupFields} isSinglePage={isSinglePage} goToNextField={goToNextField} goToPreviousField={goToPreviousField} field={field} value={value.answer.value as string} onChange={onFieldChange as ChangeHandler<any>} form={form} />
265
+ )
266
+ : field.type === 'Address' ? (
267
+ <Address field={field} disabled={value.disabled} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<any>} form={form} />
268
+ )
269
+ : field.type === 'Emotii' ? (
270
+ <Emotii enduser={enduser} enduserId={enduserId} field={field} disabled={value.disabled} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<any>} form={form} />
271
+ )
272
+ : field.type === 'Allergies' ? (
273
+ <Allergies enduser={enduser} enduserId={enduserId} field={field} disabled={value.disabled} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<any>} form={form} />
274
+ )
275
+ : field.type === 'Conditions' ? (
276
+ <Conditions enduser={enduser} enduserId={enduserId} field={field} disabled={value.disabled} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<any>} form={form} />
277
+ )
278
+ : field.type === 'Height' ? (
279
+ <Height field={field} disabled={value.disabled} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<any>} form={form} />
280
+ )
281
+ : field.type === 'Redirect' ? (
282
+ <Redirect enduserId={enduserId} rootResponseId={rootResponseId} formResponseId={formResponseId} responses={responses} enduser={enduser} groupId={groupId} groupInsance={groupInstance} submit={submit} field={field} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<any>} form={form} />
283
+ )
284
+ : field.type === 'Related Contacts' ? (
285
+ <RelatedContacts field={field} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<any>} form={form} />
286
+ )
287
+ : field.type === 'string' ? (
288
+ <String field={field} disabled={value.disabled} value={value.answer.value as string} onChange={onFieldChange as ChangeHandler<'string'>} form={form} error={!!validationMessage && (!['A response is required', 'A value must be checked', 'A file is required', 'Enter a valid phone number', 'Insurer is required'].includes(validationMessage) || value.touched)} />
289
+ )
290
+ : field.type === 'Appointment Booking' ? (
291
+ <AppointmentBooking formResponseId={formResponseId} enduserId={enduserId} goToPreviousField={goToPreviousField} isPreviousDisabled={isPreviousDisabled} responses={responses} field={field} value={value.answer.value as string} onChange={onFieldChange as ChangeHandler<'Appointment Booking'>} form={form} />
292
+ )
293
+ : field.type === 'Stripe' ? (
294
+ <Stripe enduserId={enduserId} field={field} value={value.answer.value as string} onChange={onFieldChange as ChangeHandler<any>} setCustomerId={setCustomerId} form={form} />
295
+ )
296
+ : field.type === 'Chargebee' ? (
297
+ <Chargebee field={field} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<'Chargebee'>} setCustomerId={setCustomerId} form={form} />
298
+ )
299
+ : field.type === 'stringLong' ? (
300
+ <StringLong field={field} disabled={value.disabled} value={value.answer.value as string} onChange={onFieldChange as ChangeHandler<'string' | 'stringLong'>} form={form} error={!!validationMessage && (!['A response is required', 'A value must be checked', 'A file is required', 'Enter a valid phone number', 'Insurer is required'].includes(validationMessage) || value.touched)} />
301
+ )
302
+ : field.type === 'Rich Text' ? (
303
+ <RichText field={field} disabled={value.disabled} value={value.answer.value as string} onChange={onFieldChange as ChangeHandler<'Rich Text'>} form={form} />
304
+ )
305
+ : field.type === 'email' ? (
306
+ <Email field={field} disabled={value.disabled} value={value.answer.value as string} onChange={onFieldChange as ChangeHandler<'email'>} form={form} error={!!validationMessage && (!['A response is required', 'A value must be checked', 'A file is required', 'Enter a valid phone number', 'Insurer is required'].includes(validationMessage) || value.touched)} />
307
+ )
308
+ : field.type === 'number' ? (
309
+ <Number field={field} disabled={value.disabled} value={value.answer.value as number} onChange={onFieldChange as ChangeHandler<'number'>} form={form} error={!!validationMessage && (!['A response is required', 'A value must be checked', 'A file is required', 'Enter a valid phone number', 'Insurer is required'].includes(validationMessage) || value.touched)} />
310
+ )
311
+ : field.type === 'phone' ? (
312
+ <Phone field={field} disabled={value.disabled} value={value.answer.value as string} onChange={onFieldChange as ChangeHandler<'phone'>} form={form} error={!!validationMessage && (!['A response is required', 'A value must be checked', 'A file is required', 'Enter a valid phone number', 'Insurer is required'].includes(validationMessage) || value.touched)} />
313
+ )
314
+ : field.type === 'date' ? (
315
+ <ResolvedDateInput field={field} disabled={value.disabled} value={value.answer.value ? new Date(value.answer.value as string | Date) : undefined} onChange={onFieldChange as ChangeHandler<'date'>} form={form} />
316
+ )
317
+ : field.type === 'signature' ? (
318
+ <Signature enduser={enduser} field={field} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<'signature'>} form={form}/>
319
+ )
320
+ : field.type === 'multiple_choice' ? (
321
+ <MultipleChoice field={field} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<'multiple_choice'>} form={form}/>
322
+ )
323
+ : field.type === 'Dropdown' ? (
324
+ <Dropdown field={field} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<'Dropdown'>} form={form}/>
325
+ )
326
+ : field.type === 'Database Select' ? (
327
+ <DatabaseSelect field={field} disabled={value.disabled} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<'Database Select'>}
328
+ onDatabaseSelect={handleDatabaseSelect}
329
+ responses={responses} form={form}
330
+ AddToDatabase={AddToDatabase}
331
+ />
332
+ )
333
+ : field.type === 'Medications' ? (
334
+ <Medications field={field} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<'Medications'>} form={form}/>
335
+ )
336
+ : field.type === 'Insurance' ? (
337
+ <Insurance field={field} value={value.answer.value as any} form={form}
338
+ onDatabaseSelect={handleDatabaseSelect}
339
+ enduser={enduser} responses={responses} // for filtering insurers by state
340
+ onChange={(v, fieldId) => (onFieldChange as ChangeHandler<'Insurance'>)({
341
+ ...v,
342
+ relationship: v?.relationship || 'Self', // make sure relationship is initialized to self if input is provided
343
+ }, fieldId)}
344
+ />
345
+ )
346
+ : field.type === 'rating' ? (
347
+ <Rating field={field} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<'rating'>} form={form}/>
348
+ )
349
+ : field.type === 'ranking' ? (
350
+ <Ranking field={field} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<'ranking'>} form={form}/>
351
+ )
352
+ : field.type === 'Table Input' ? (
353
+ <TableInput field={field} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<any>} form={form}/>
354
+ )
355
+ : field.type === 'Timezone' ? (
356
+ <TimezoneInput field={field} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<'Timezone'>} form={form}/>
357
+ )
358
+ : field.type === 'Time' ? (
359
+ <Time field={field} value={value.answer.value as any} onChange={onFieldChange as ChangeHandler<any>} form={form}/>
360
+ )
361
+ : field.type === 'Question Group' ? (
362
+ <Flex column flex={1}>
363
+ {(field.options?.subFields ?? []).map(({ id }, indexInGroup) => {
364
+ const match = fields.find(f => f.id === id)
365
+ if (!match) return null
366
+
367
+ const value = responses.find(r => r.fieldId === match.id)
368
+ const file = selectedFiles.find(r => r.fieldId === match.id)
369
+ if (!value) return null
370
+ if (!file) return null
371
+
372
+ return (
373
+ <Flex key={id} flex={1}>
374
+ <QuestionForField groupId={groupId} groupInstance={groupInstance} customInputs={customInputs} field={match} fields={fields} handleDatabaseSelect={handleDatabaseSelect}
375
+ enduser={enduser} goToPreviousField={goToPreviousField} isPreviousDisabled={isPreviousDisabled} goToNextField={goToNextField}
376
+ form={form} formResponseId={formResponseId} rootResponseId={rootResponseId} submit={submit}
377
+ repeats={repeats} onRepeatsChange={onRepeatsChange} setCustomerId={setCustomerId}
378
+ value={value} file={file}
379
+ onAddFile={onAddFile} onFieldChange={onFieldChange}
380
+ responses={responses} selectedFiles={selectedFiles}
381
+ validateField={validateField} enduserId={enduserId}
382
+ spacing={field.options?.groupPadding}
383
+ logicOptions={logicOptions}
384
+ isInQuestionGroup
385
+ groupFields={
386
+ fields.filter(f => field.options?.subFields?.find(s => s.id === f.id))
387
+ }
388
+ questionGroupSize={field.options?.subFields?.length}
389
+ uploadingFiles={uploadingFiles} setUploadingFiles={setUploadingFiles}
390
+ handleFileUpload={handleFileUpload}
391
+ AddToDatabase={AddToDatabase}
392
+ />
393
+ </Flex>
394
+ )
395
+ })}
396
+ </Flex>
397
+ )
398
+ : null
399
+ }
400
+
401
+ {field.options?.repeat && (
402
+ <Flex style={{ marginTop: '8px' }}>
403
+ {
404
+ field.type === 'string' ? (
405
+ <String field={field} label="Repeat" value={repeats[field.id] as string ?? ''} onChange={u => onRepeatsChange({ ...repeats, [field.id]: u! })} form={form} />
406
+ )
407
+ : field.type === 'stringLong' ? (
408
+ <StringLong field={field} label="Repeat" value={repeats[field.id] as string ?? ''} onChange={u => onRepeatsChange({ ...repeats, [field.id]: u! })} form={form} />
409
+ )
410
+ : field.type === 'number' ? (
411
+ <Number field={field} label="Repeat" value={repeats[field.id] as number ?? ''} onChange={u => onRepeatsChange({ ...repeats, [field.id]: u! })} form={form} />
412
+ )
413
+ : null
414
+ }
415
+ </Flex>
416
+ )}
417
+
418
+ <Description field={field} style={{ fontSize: 14, color: '#00000099', marginTop: 4 }} />
419
+
420
+ {field.type !== 'Question Group' &&
421
+ <Typography color="error" style={{ marginTop: 3, height: 10, fontSize: 14, marginBottom: -10 }}>
422
+ {(validationMessage === 'A response is required' || validationMessage === 'A value must be checked' || validationMessage === 'A file is required' || 'Enter a valid phone number' || 'Insurer is required')
423
+ ? value.touched
424
+ ? form_display_text_for_language(form, validationMessage)
425
+ : null
426
+ : form_display_text_for_language(form, validationMessage)
427
+ }
428
+ </Typography>
429
+ }
430
+ </Flex>
431
+ )
432
+ }
433
+
434
+ export const TellescopeSingleQuestionFlowV2: typeof TellescopeFormV2 = ({
435
+ form,
436
+ activeField,
437
+ currentFileValue,
438
+ customInputs,
439
+ currentValue,
440
+ submitErrorMessage,
441
+ onAddFile,
442
+ onFieldChange,
443
+ goToNextField,
444
+ goToPreviousField,
445
+ isAutoAdvancing,
446
+ isNextDisabled,
447
+ isPreviousDisabled,
448
+ submit,
449
+ showSubmit,
450
+ submittingStatus,
451
+ validateField,
452
+
453
+ thanksMessage="Your response was successfully recorded",
454
+ htmlThanksMessage,
455
+ submitted,
456
+ onSuccess,
457
+ isPreview,
458
+ theme,
459
+
460
+ fields,
461
+ responses,
462
+ selectedFiles,
463
+ inputStyle,
464
+ repeats,
465
+ setRepeats,
466
+
467
+ currentPageIndex,
468
+ getNumberOfRemainingPages,
469
+ validateCurrentField,
470
+ handleDatabaseSelect,
471
+
472
+ setCustomerId,
473
+ customization,
474
+ enduserId,
475
+ enduser,
476
+ formResponseId,
477
+ groupId,
478
+ groupInstance,
479
+ logicOptions,
480
+ uploadingFiles, setUploadingFiles, handleFileUpload,
481
+ }) => {
482
+ const beforeunloadHandler = React.useCallback((e: BeforeUnloadEvent) => {
483
+ try {
484
+ e.preventDefault()
485
+ e.returnValue = 'You have unsaved changes'
486
+ } catch(err) { }
487
+
488
+ return ''
489
+ }, [])
490
+
491
+ const [uploading, setUploading] = useState(false)
492
+ const [autosubmitting, setAutoSubmitting] = useState(false)
493
+
494
+ useEffect(() => {
495
+ // ensure redirect question doesn't trip this alert
496
+ if (activeField.value.type === 'Redirect') { return }
497
+
498
+ window.addEventListener('beforeunload', beforeunloadHandler)
499
+ return () => { window.removeEventListener('beforeunload', beforeunloadHandler) }
500
+ }, [beforeunloadHandler, activeField])
501
+
502
+ const handleSubmit = useCallback(async (options?: { onFileUploadsDone?: () => void }) => {
503
+ if (isPreview) {
504
+ return onSuccess?.({} as any)
505
+ }
506
+
507
+
508
+ await submit({
509
+ onSuccess,
510
+ ...options,
511
+ onPreRedirect: () => {
512
+ // submission may trigger a redirect, so don't block with warning message
513
+ try {
514
+ window.removeEventListener('beforeunload', beforeunloadHandler)
515
+ } catch(err) {}
516
+ }
517
+ })
518
+ }, [isPreview, onSuccess, submit, beforeunloadHandler])
519
+
520
+ const autoSubmitRef = useRef(false)
521
+ useEffect(() => {
522
+ if (!activeField.value.options?.autoSubmit) {
523
+ return
524
+ }
525
+ if (autoSubmitRef.current) return
526
+
527
+ if (responses.find(r => r.fieldId === activeField.value.id && field_can_autosubmit(activeField.value) && r.answer.value)) {
528
+ autoSubmitRef.current = true
529
+ setAutoSubmitting(true)
530
+ handleSubmit()
531
+ .finally(() => setAutoSubmitting(false))
532
+ }
533
+ }, [handleSubmit, responses, activeField])
534
+
535
+ const validationMessage = validateField(activeField.value)
536
+
537
+ const handleKeyPress = useCallback((e: KeyboardEvent) => {
538
+ if (
539
+ e.key === 'Enter'
540
+ && !(activeField.value.type === 'Dropdown' && activeField.value.options?.other && !activeField.value.options?.radio)
541
+ ) {
542
+ if (activeField.value.type === 'stringLong') return //
543
+ if (activeField.value.type === 'Question Group') return // ensure enter is allowed in stringLong at end of a question group before next
544
+ if (isNextDisabled()) return
545
+ goToNextField(undefined)
546
+ }
547
+ }, [activeField, isNextDisabled, goToNextField, isPreviousDisabled, goToPreviousField])
548
+
549
+ useEffect(() => {
550
+ window.addEventListener('keydown', handleKeyPress)
551
+ return () => { window.removeEventListener('keydown', handleKeyPress)}
552
+ }, [handleKeyPress])
553
+
554
+ const numRemainingPages = getNumberOfRemainingPages()
555
+
556
+ // Calculate current score if real-time scoring is enabled
557
+ const currentScores = useMemo(() => {
558
+ if (!form?.realTimeScoring || !form.scoring?.length) return null
559
+
560
+ return calculate_form_scoring({
561
+ response: { responses },
562
+ form: { scoring: form.scoring }
563
+ })
564
+ }, [form?.realTimeScoring, form?.scoring, responses])
565
+
566
+ if (!(currentValue && currentFileValue)) return <></>
567
+
568
+ // Show loading state while auto-advancing to target question
569
+ if (isAutoAdvancing) {
570
+ return (
571
+ <Flex column alignItems="center" style={{ minHeight: 200, justifyContent: 'center' }}>
572
+ <CircularProgress size={40} />
573
+ <Typography style={{ marginTop: 16, textAlign: 'center' }}>
574
+ Picking up where you left off...
575
+ </Typography>
576
+ </Flex>
577
+ )
578
+ }
579
+
580
+ return (
581
+ submitted
582
+ ? <ThanksMessage htmlThanksMessage={htmlThanksMessage} thanksMessage={thanksMessage}
583
+ showRestartAtEnd={customization?.showRestartAtEnd}
584
+ />
585
+ : (
586
+ <Flex column flex={1} style={{ justifyContent: 'space-between' }}>
587
+ {/* V2: Top section - progress, logo, and content */}
588
+ <Flex column>
589
+ {/* V2: Progress bar at top */}
590
+ {!customization?.hideProgressBar &&
591
+ <Progress
592
+ numerator={currentPageIndex + (validateCurrentField() ? 0 : 1)}
593
+ denominator={currentPageIndex + 1 + numRemainingPages}
594
+ style={{ marginBottom: '20px' }}
595
+ color={customization?.primaryColor ?? theme?.themeColor ?? '#798ED0'}
596
+ />
597
+ }
598
+
599
+ {/* V2: Logo below progress bar, left-aligned */}
600
+ {!customization?.hideLogo && (
601
+ theme?.logoURL
602
+ ? (
603
+ <Flex alignItems="flex-start" style={{ marginBottom: '20px' }}>
604
+ <img src={theme.logoURL} alt={theme.name} style={{ maxHeight: customization?.logoHeight || LOGO_HEIGHT, maxWidth: 225 }} />
605
+ </Flex>
606
+ )
607
+ : (
608
+ <Typography style={{ fontSize: 22, marginBottom: '20px', textAlign: 'left', fontWeight: 600 }}>
609
+ {theme?.name}
610
+ </Typography>
611
+ )
612
+ )}
613
+
614
+ <Flex column>
615
+ <Flex style={inputStyle}>
616
+ <QuestionForField form={form} fields={fields} field={activeField.value} submit={submit}
617
+ enduserId={enduserId} formResponseId={formResponseId}
618
+ enduser={enduser} goToPreviousField={goToPreviousField} isPreviousDisabled={isPreviousDisabled} goToNextField={goToNextField}
619
+ handleDatabaseSelect={handleDatabaseSelect}
620
+ setCustomerId={setCustomerId}
621
+ repeats={repeats} onRepeatsChange={setRepeats}
622
+ value={currentValue} file={currentFileValue}
623
+ customInputs={customInputs}
624
+ onAddFile={onAddFile} onFieldChange={onFieldChange}
625
+ responses={responses} selectedFiles={selectedFiles}
626
+ validateField={validateField}
627
+ groupId={groupId} groupInstance={groupInstance}
628
+ logicOptions={logicOptions}
629
+ uploadingFiles={uploadingFiles} setUploadingFiles={setUploadingFiles}
630
+ handleFileUpload={handleFileUpload}
631
+ />
632
+ </Flex>
633
+ </Flex>
634
+ </Flex>
635
+
636
+ {/* V2: Buttons aligned to bottom */}
637
+ <Flex flex={1} alignItems={'flex-end'} justifyContent="space-between" style={{ gap: 10, marginTop: '20px' }}>
638
+ {!isPreviousDisabled() && (
639
+ <Button variant="outlined" color="secondary" disabled={isPreviousDisabled()} onClick={goToPreviousField}
640
+ style={{
641
+ ...defaultButtonStyles,
642
+ flex: 1,
643
+ }}
644
+ >
645
+ {form_display_text_for_language(form, "Previous")}
646
+ </Button>
647
+ )}
648
+ {uploading &&
649
+ <Modal open setOpen={() => undefined}>
650
+ <Flex style={{ }} justifyContent="center">
651
+ <Typography style={{ fontSize: 20, width: 250, fontWeight: 'bold', textAlign: 'center' }}>
652
+ Uploading files...
653
+ </Typography>
654
+
655
+ <CircularProgress size={75} style={{ marginTop: 10, marginBottom: 10 }} />
656
+
657
+ <Typography style={{ fontSize: 20, width: 250, fontWeight: 'bold', textAlign: 'center' }}>
658
+ Please stay on this page until your submission is complete!
659
+ </Typography>
660
+ </Flex>
661
+ </Modal>
662
+ }
663
+ {showSubmit
664
+ ? (
665
+ <LoadingButton muiColor="secondary"
666
+ onClick={() => {
667
+ setUploading(!!selectedFiles.find(r => !!r.blobs?.length))
668
+ return handleSubmit({ onFileUploadsDone: () => setUploading(false) })
669
+ }}
670
+ disabled={!!validationMessage || currentValue.field?.options?.disableNext === true || autosubmitting}
671
+ submitText={form_display_text_for_language(form, "Submit")}
672
+ submittingText={
673
+ submittingStatus === 'uploading-files'
674
+ ? 'Uploading files...'
675
+ : "Submitting..."
676
+ }
677
+ style={{
678
+ ...defaultButtonStyles,
679
+ flex: 1,
680
+ }}
681
+ />
682
+ )
683
+ : (
684
+ <Button variant="contained" disabled={isNextDisabled()} onClick={() => goToNextField(undefined)}
685
+ color="secondary"
686
+ style={{
687
+ ...defaultButtonStyles,
688
+ flex: 1,
689
+ }}
690
+ >
691
+ {form_display_text_for_language(form, "Continue")}
692
+ </Button>
693
+ )
694
+ }
695
+ </Flex>
696
+
697
+ {/* Real-time scoring display */}
698
+ {currentScores && currentScores.length > 0 && (
699
+ <Flex style={{ marginTop: 10, marginBottom: 5, width: '100%' }}>
700
+ {currentScores.map((score, index) => {
701
+ const primaryColor = customization?.primaryColor ?? theme?.themeColor ?? '#798ED0'
702
+ return (
703
+ <Flex key={index} style={{
704
+ padding: '10px 14px',
705
+ backgroundColor: '#f8f9fa',
706
+ borderRadius: 8,
707
+ border: `1px solid ${primaryColor}20`,
708
+ marginRight: index < currentScores.length - 1 ? 12 : 0,
709
+ minWidth: 120,
710
+ flexDirection: 'column',
711
+ alignItems: 'center'
712
+ }}>
713
+ <Typography style={{
714
+ fontSize: 12,
715
+ fontWeight: 'medium',
716
+ textAlign: 'center',
717
+ lineHeight: 1.2,
718
+ marginBottom: 4
719
+ }}>
720
+ {score.title}
721
+ </Typography>
722
+ <Typography style={{
723
+ fontWeight: 'bold',
724
+ color: primaryColor,
725
+ fontSize: 18
726
+ }}>
727
+ {score.value}
728
+ </Typography>
729
+ </Flex>
730
+ )})}
731
+ </Flex>
732
+ )}
733
+
734
+ <Typography color="error" style={{ alignText: 'center', marginTop: 3 }}>
735
+ {submitErrorMessage}
736
+ </Typography>
737
+ </Flex>
738
+ )
739
+ )
740
+ }
741
+
742
+ export const DEFAULT_THANKS_MESSAGE = "Your response was successfully recorded";
743
+ export const ThanksMessage = ({
744
+ thanksMessage,
745
+ htmlThanksMessage,
746
+ showRestartAtEnd,
747
+ downloadComponent,
748
+ } : {
749
+ thanksMessage?: string,
750
+ htmlThanksMessage?: string,
751
+ showRestartAtEnd?: boolean,
752
+ downloadComponent?: React.ReactNode,
753
+ }) => (
754
+ <Flex column>
755
+ {htmlThanksMessage
756
+ ? (
757
+ <div style={{ textAlign: 'center' }} dangerouslySetInnerHTML={{
758
+ __html: remove_script_tags(htmlThanksMessage)
759
+ }} />
760
+ ) : (
761
+ <Typography style={{ marginTop: 25, alignSelf: 'center' }}>{thanksMessage || DEFAULT_THANKS_MESSAGE}</Typography>
762
+ )
763
+ }
764
+ {read_local_storage('redirecting_public_group') === 'true' &&
765
+ <>
766
+ <Typography style={{ marginTop: 25, alignSelf: 'center' }}>
767
+ Redirecting to next form... <CircularProgress size={20} color="primary" />
768
+ </Typography>
769
+ </>
770
+ }
771
+ {downloadComponent &&
772
+ <Flex justifyContent="center" style={{ marginTop: 15, marginBottom: 15 }}>
773
+ {downloadComponent}
774
+ </Flex>
775
+ }
776
+ {showRestartAtEnd && window.localStorage[`ts_form_url`] &&
777
+ <Button variant="outlined" style={{ ...defaultButtonStyles, maxWidth: 200, marginTop: 25, alignSelf: 'center' }}
778
+ onClick={() => window.location.href = window.localStorage[`ts_form_url`]}
779
+ >
780
+ Submit Again
781
+ </Button>
782
+ }
783
+ </Flex>
784
+ )
785
+
786
+ const TellescopeFormWithContextV2: typeof TellescopeFormV2 = (props) => {
787
+ const theme = useOrganizationTheme()
788
+
789
+ // V2: Override MUI theme with customization colors
790
+ const primaryColor = props.customization?.primaryColor ?? theme?.themeColor ?? '#798ED0'
791
+ const secondaryColor = props.customization?.secondaryColor ?? theme?.themeColorSecondary ?? '#585E72'
792
+
793
+ return (
794
+ <WithTheme theme={{ primary: primaryColor, secondary: secondaryColor }}>
795
+ <TellescopeFormContainerV2 style={props.style} dontAddContext
796
+ hideBg={props.hideBg || props.form?.customization?.hideBg}
797
+ logoHeight={props.logoHeight}
798
+ backgroundColor={props.backgroundColor}
799
+ hideLogo={props?.customization?.hideLogo}
800
+ maxWidth={props.form?.customization?.maxWidth}
801
+ >
802
+ {props.submitted
803
+ ? <ThanksMessage {...props} showRestartAtEnd={props?.customization?.showRestartAtEnd} />
804
+ : (<TellescopeSingleQuestionFlowV2 {...props} theme={theme} />)
805
+ }
806
+ </TellescopeFormContainerV2>
807
+ </WithTheme>
808
+ )
809
+ }
810
+
811
+ export const SaveDraft = ({
812
+ selectedFiles,
813
+ enduserId,
814
+ responses,
815
+ existingResponses,
816
+ fields,
817
+ onSuccess,
818
+ formResponseId,
819
+ includedFieldIds,
820
+ formId,
821
+ style,
822
+ disabled,
823
+ getResponsesWithQuestionGroupAnswers,
824
+ isInternalNote,
825
+ formTitle,
826
+ rootResponseId,
827
+ parentResponseId,
828
+ } : Styled & Pick<TellescopeFormProps, 'existingResponses' | 'fields' | 'onSuccess' | 'selectedFiles' | 'responses' | 'enduserId' | 'getResponsesWithQuestionGroupAnswers'> & {
829
+ disabled?: boolean,
830
+ formResponseId?: string,
831
+ formId: string,
832
+ includedFieldIds: string[]
833
+ isInternalNote?: boolean,
834
+ formTitle?: string,
835
+ rootResponseId?: string,
836
+ parentResponseId?: string,
837
+ }) => {
838
+ const [, { updateElement: updateFormResponse }] = useFormResponses({ dontFetch: true })
839
+ const session = useSession()
840
+ const { handleUpload } = useFileUpload({ })
841
+
842
+ return (
843
+ <LoadingButton style={style} disabled={disabled} variant='outlined'
844
+ onClick={async () => {
845
+ const hasFile = selectedFiles.find(f => !!f.blobs?.length) !== undefined
846
+
847
+ if (hasFile) {
848
+ try { // convert FileBlobs to FileResponses
849
+ for (const blobInfo of selectedFiles) {
850
+ const { blobs, fieldId } = blobInfo
851
+ if (!blobs) continue
852
+
853
+ for (const blob of blobs) {
854
+ const result: FormResponseAnswerFileValue = { name: blob.name, secureName: '' }
855
+ const { secureName } = await handleUpload(
856
+ {
857
+ name: blob.name,
858
+ size: blob.size,
859
+ type: blob.type,
860
+ enduserId,
861
+ },
862
+ blob
863
+ )
864
+
865
+ const responseIndex = responses.findIndex(f => f.fieldId === fieldId)
866
+
867
+ if (responses[responseIndex].answer.type === 'files') {
868
+ if (!responses[responseIndex].answer.value) {
869
+ responses[responseIndex].answer.value = []
870
+ }
871
+ (responses[responseIndex].answer.value as any[]).push({
872
+ ...result, type: blob.type, secureName, name: result.name ?? ''
873
+ })
874
+ } else {
875
+ responses[responseIndex].answer.value = { ...result, type: blob.type, secureName, name: result.name ?? '' }
876
+ }
877
+ }
878
+ }
879
+ } catch(err: any) {
880
+ } finally {
881
+ }
882
+ }
883
+
884
+ try {
885
+ const response = await updateFormResponse(
886
+ (
887
+ formResponseId
888
+ ?? (await session.api.form_responses.prepare_form_response({ rootResponseId, parentResponseId, isInternalNote, formId, enduserId, title: formTitle })).response.id
889
+ ),
890
+ {
891
+ draftSavedAt: new Date(),
892
+ draftSavedBy: session?.userInfo?.id,
893
+ responses: [
894
+ ...(existingResponses ?? []).filter(r => !fields.find(f => f.id === r.fieldId)),
895
+ ...getResponsesWithQuestionGroupAnswers(includedFieldIds.map(id => responses.find(r => r.fieldId === id)!))
896
+ ]
897
+ },
898
+ { replaceObjectFields: true }
899
+ )
900
+
901
+ onSuccess?.(response)
902
+ } catch(err: any) {
903
+ // setSubmitErrorMessage(err?.message ?? 'Failed to upload file')
904
+ } finally {
905
+ // setSubmittingStatus(undefined)
906
+ }
907
+ }}
908
+ submitText="Save Draft"
909
+ submittingText="Saving..."
910
+ />
911
+ )
912
+ }
913
+
914
+ export const UpdateResponse = ({
915
+ selectedFiles,
916
+ enduserId,
917
+ responses,
918
+ onSuccess,
919
+ formResponseId,
920
+ includedFieldIds,
921
+ formId,
922
+ style,
923
+ disabled,
924
+ getResponsesWithQuestionGroupAnswers,
925
+ existingResponses,
926
+ fields,
927
+ } : Styled & Pick<TellescopeFormProps, 'existingResponses' | 'fields' | 'onSuccess' | 'selectedFiles' | 'responses' | 'enduserId' | 'getResponsesWithQuestionGroupAnswers'> & {
928
+ disabled?: boolean,
929
+ formResponseId?: string,
930
+ formId: string,
931
+ includedFieldIds: string[]
932
+ }) => {
933
+ const [, { updateElement: updateFormResponse }] = useFormResponses({ dontFetch: true })
934
+ const session = useSession()
935
+ const { handleUpload } = useFileUpload({ })
936
+
937
+ return (
938
+ <LoadingButton style={style} disabled={disabled} variant='contained'
939
+ onClick={async () => {
940
+ const hasFile = selectedFiles.find(f => !!f.blobs?.length) !== undefined
941
+
942
+ if (hasFile) {
943
+ try { // convert FileBlobs to FileResponses
944
+ for (const blobInfo of selectedFiles) {
945
+ const { blobs, fieldId } = blobInfo
946
+ if (!blobs) continue
947
+
948
+ for (const blob of blobs) {
949
+ const result: FormResponseAnswerFileValue = { name: blob.name, secureName: '' }
950
+ const { secureName } = await handleUpload(
951
+ {
952
+ name: blob.name,
953
+ size: blob.size,
954
+ type: blob.type,
955
+ enduserId,
956
+ },
957
+ blob
958
+ )
959
+
960
+ const responseIndex = responses.findIndex(f => f.fieldId === fieldId)
961
+
962
+ if (responses[responseIndex].answer.type === 'files') {
963
+ if (!responses[responseIndex].answer.value) {
964
+ responses[responseIndex].answer.value = []
965
+ }
966
+ (responses[responseIndex].answer.value as any[]).push({
967
+ ...result, type: blob.type, secureName, name: result.name ?? ''
968
+ })
969
+ } else {
970
+ responses[responseIndex].answer.value = { ...result, type: blob.type, secureName, name: result.name ?? '' }
971
+ }
972
+ }
973
+ }
974
+
975
+ } catch(err: any) {
976
+ } finally {
977
+ }
978
+ }
979
+
980
+ const response = await updateFormResponse(
981
+ (
982
+ formResponseId
983
+ ?? (await session.api.form_responses.prepare_form_response({ formId, enduserId })).response.id
984
+ ),
985
+ {
986
+ responses: [
987
+ ...(existingResponses ?? []).filter(r => !fields.find(f => f.id === r.fieldId)),
988
+ ...getResponsesWithQuestionGroupAnswers(includedFieldIds.map(id => responses.find(r => r.fieldId === id)!))
989
+ ]
990
+ },
991
+ { replaceObjectFields: true }
992
+ )
993
+
994
+ onSuccess?.(response)
995
+ }}
996
+ submitText="Update"
997
+ submittingText="Saving..."
998
+ />
999
+ )
1000
+ }
1001
+
1002
+ export const Description = ({ field, color="primary", style } : { field: FormField, color?: string } & Styled) => {
1003
+ if (!field.htmlDescription && field.description) {
1004
+ return (
1005
+ <Typography color={color as any} style={style}>
1006
+ {field.description}
1007
+ </Typography>
1008
+ )
1009
+ }
1010
+ if (!field.htmlDescription) return null
1011
+
1012
+ return (
1013
+ <span style={style} dangerouslySetInnerHTML={{
1014
+ __html: remove_script_tags(field.htmlDescription)
1015
+ }} />
1016
+ )
1017
+ }
1018
+
1019
+ export const TellescopeSinglePageForm: React.JSXElementConstructor<TellescopeFormProps & Styled & {
1020
+ updating?: boolean,
1021
+ isInternalNote?: boolean,
1022
+ submittedAt?: Date,
1023
+ updatedAt?: Date,
1024
+ otherEnduserIds?: string[],
1025
+ onBulkErrors?: (errors: { enduserId: string, message: string }[]) => void,
1026
+ AddToDatabase?: React.JSXElementConstructor<AddToDatabaseProps>,
1027
+ }> = ({
1028
+ customInputs,
1029
+ submitErrorMessage,
1030
+ onAddFile,
1031
+ onFieldChange,
1032
+ goToNextField,
1033
+ goToPreviousField,
1034
+ isNextDisabled,
1035
+ isPreviousDisabled,
1036
+ submit,
1037
+ showSubmit,
1038
+ showSaveDraft,
1039
+ submittingStatus,
1040
+ updating,
1041
+ validateField,
1042
+ validateResponsesForFields,
1043
+ formTitle,
1044
+
1045
+ thanksMessage=DEFAULT_THANKS_MESSAGE,
1046
+ htmlThanksMessage,
1047
+ submitted,
1048
+ style,
1049
+ onSuccess,
1050
+ isPreview,
1051
+
1052
+ fields,
1053
+ selectedFiles,
1054
+ responses,
1055
+
1056
+ isInternalNote,
1057
+ existingResponses,
1058
+
1059
+ repeats,
1060
+ setRepeats,
1061
+
1062
+ setCustomerId,
1063
+
1064
+ rootResponseId,
1065
+ parentResponseId,
1066
+
1067
+ handleDatabaseSelect,
1068
+
1069
+ submittedAt,
1070
+ updatedAt,
1071
+
1072
+ otherEnduserIds,
1073
+ onBulkErrors,
1074
+ enduser,
1075
+ groupId,
1076
+ groupInstance,
1077
+ uploadingFiles, setUploadingFiles, handleFileUpload,
1078
+ AddToDatabase,
1079
+ ...props
1080
+ }) => {
1081
+ const list = useListForFormFields(fields, responses, { form: props.form, gender: enduser?.gender })
1082
+
1083
+ const includedFieldIds = (
1084
+ Array.from(new Set([
1085
+ ...list.map(f => f.id),
1086
+ ...(existingResponses ?? []).filter(e => !e.isPrepopulatedFromEnduserField).map(e => e.fieldId)
1087
+ ]))
1088
+ )
1089
+
1090
+ const handleSubmit = useCallback(async () => {
1091
+ if (isPreview) {
1092
+ return onSuccess?.({} as any)
1093
+ }
1094
+ await submit({
1095
+ onSuccess,
1096
+ includedFieldIds, /* ensures all answers are included and in the correct order */
1097
+ otherEnduserIds, onBulkErrors,
1098
+ })
1099
+ }, [isPreview, onSuccess, submit, otherEnduserIds, onBulkErrors])
1100
+
1101
+ const errors = useMemo(() => {
1102
+ const es: { id: string, title: string, error: string }[] = []
1103
+
1104
+ try {
1105
+ list.forEach(field => {
1106
+ const error = validateField(field)
1107
+ if (error && typeof error === 'string') es.push({
1108
+ id: field.id,
1109
+ title: field.title,
1110
+ error,
1111
+ })
1112
+ })
1113
+ } catch(err) {
1114
+ console.error(err)
1115
+ }
1116
+
1117
+ return es
1118
+ }, [list, validateField])
1119
+
1120
+ let updatesDisabled = true
1121
+ for (const r of responses ?? []) {
1122
+ const match = existingResponses?.find(_r => _r.fieldId === r.fieldId)
1123
+ if (!match) {
1124
+ updatesDisabled = false
1125
+ break;
1126
+ }
1127
+ if (!objects_equivalent(r.answer, match.answer)) {
1128
+ updatesDisabled = false
1129
+ break;
1130
+ }
1131
+ }
1132
+
1133
+ // Calculate current score if real-time scoring is enabled
1134
+ const currentScores = useMemo(() => {
1135
+ if (!props.form?.realTimeScoring || !props.form.scoring?.length) return null
1136
+
1137
+ return calculate_form_scoring({
1138
+ response: { responses },
1139
+ form: { scoring: props.form.scoring }
1140
+ })
1141
+ }, [props.form?.realTimeScoring, props.form?.scoring, responses])
1142
+
1143
+ return (
1144
+ <Flex flex={1} column>
1145
+ {submitted
1146
+ ? <ThanksMessage htmlThanksMessage={htmlThanksMessage} thanksMessage={thanksMessage}
1147
+ showRestartAtEnd={props?.customization?.showRestartAtEnd}
1148
+ />
1149
+ : (
1150
+ <>
1151
+ {/* Real-time scoring display - pinned to top */}
1152
+ {currentScores && currentScores.length > 0 && (
1153
+ <Flex style={{
1154
+ position: 'sticky',
1155
+ top: 0,
1156
+ zIndex: 1000,
1157
+ backgroundColor: 'white',
1158
+ borderBottom: '1px solid #e0e0e0',
1159
+ padding: '12px 0',
1160
+ marginBottom: '16px',
1161
+ width: '100%',
1162
+ justifyContent: 'center'
1163
+ }}>
1164
+ {currentScores.map((score, index) => (
1165
+ <Flex key={index} style={{
1166
+ padding: '10px 14px',
1167
+ backgroundColor: '#f8f9fa',
1168
+ borderRadius: 8,
1169
+ border: `1px solid ${PRIMARY_HEX}20`,
1170
+ marginRight: index < currentScores.length - 1 ? 12 : 0,
1171
+ minWidth: 120,
1172
+ flexDirection: 'column',
1173
+ alignItems: 'center'
1174
+ }}>
1175
+ <Typography style={{
1176
+ fontSize: 12,
1177
+ fontWeight: 'medium',
1178
+ textAlign: 'center',
1179
+ lineHeight: 1.2,
1180
+ marginBottom: 4
1181
+ }}>
1182
+ {score.title}
1183
+ </Typography>
1184
+ <Typography style={{
1185
+ fontWeight: 'bold',
1186
+ color: PRIMARY_HEX,
1187
+ fontSize: 18
1188
+ }}>
1189
+ {score.value}
1190
+ </Typography>
1191
+ </Flex>
1192
+ ))}
1193
+ </Flex>
1194
+ )}
1195
+
1196
+ <Flex flex={1} justifyContent={"center"} column style={{ marginBottom: 15 }}>
1197
+ {list.map((activeField) => {
1198
+ const value = responses.find(r => r.fieldId === activeField.id)!
1199
+ const file = selectedFiles.find(r => r.fieldId === activeField.id)!
1200
+
1201
+ return (
1202
+ <Flex key={activeField.id} style={{ marginBottom: 5 }}>
1203
+ <Flex column flex={1}>
1204
+ <QuestionForField isSinglePage fields={fields} field={activeField} handleDatabaseSelect={handleDatabaseSelect}
1205
+ enduserId={props.enduserId} formResponseId={props.formResponseId} rootResponseId={rootResponseId} submit={submit}
1206
+ enduser={enduser} goToPreviousField={goToPreviousField} isPreviousDisabled={isPreviousDisabled} goToNextField={goToNextField}
1207
+ repeats={repeats} onRepeatsChange={setRepeats} setCustomerId={setCustomerId}
1208
+ value={value} file={file}
1209
+ customInputs={customInputs}
1210
+ onAddFile={onAddFile} onFieldChange={onFieldChange}
1211
+ responses={responses} selectedFiles={selectedFiles}
1212
+ validateField={validateField}
1213
+ groupId={groupId} groupInstance={groupInstance}
1214
+ uploadingFiles={uploadingFiles} setUploadingFiles={setUploadingFiles}
1215
+ handleFileUpload={handleFileUpload}
1216
+ AddToDatabase={AddToDatabase}
1217
+ />
1218
+ </Flex>
1219
+ </Flex>
1220
+ )
1221
+ })}
1222
+ </Flex>
1223
+
1224
+ <Flex flex={1} wrap="nowrap">
1225
+ {updating
1226
+ ? (
1227
+ <Flex flex={1} column>
1228
+ <UpdateResponse
1229
+ {...props} fields={fields} existingResponses={existingResponses}
1230
+ includedFieldIds={includedFieldIds}
1231
+ // style={{ width: 200, marginRight: 5, height: 42 }}
1232
+ formId={fields[0].formId}
1233
+ responses={responses}
1234
+ selectedFiles={selectedFiles}
1235
+ onSuccess={onSuccess}
1236
+ disabled={updatesDisabled}
1237
+ />
1238
+
1239
+ {submittedAt &&
1240
+ <Typography style={{ marginTop: 5 }}>
1241
+ Originally Submitted: {formatted_date(new Date(submittedAt))}
1242
+ </Typography>
1243
+ }
1244
+ {updatedAt &&
1245
+ <Typography>
1246
+ Last Updated: {formatted_date(new Date(updatedAt))}
1247
+ </Typography>
1248
+ }
1249
+ </Flex>
1250
+ ) : (
1251
+ <>
1252
+ {showSaveDraft &&
1253
+ <SaveDraft existingResponses={existingResponses} fields={fields}
1254
+ {...props} formTitle={formTitle} isInternalNote={isInternalNote}
1255
+ includedFieldIds={includedFieldIds}
1256
+ style={{ width: 200, marginRight: 5, height: 42 }}
1257
+ formId={fields[0].formId}
1258
+ responses={responses}
1259
+ selectedFiles={selectedFiles}
1260
+ onSuccess={onSuccess}
1261
+ rootResponseId={rootResponseId} parentResponseId={parentResponseId}
1262
+ />
1263
+ }
1264
+
1265
+ <LoadingButton onClick={handleSubmit}
1266
+ disabled={!!validateResponsesForFields(list)}
1267
+ style={{ height: 42, width: '100%' }}
1268
+ submitText="Submit Response"
1269
+ submittingText={
1270
+ submittingStatus === 'uploading-files'
1271
+ ? 'Uploading files...'
1272
+ : "Submitting..."
1273
+ }
1274
+ />
1275
+ </>
1276
+ )
1277
+ }
1278
+ </Flex>
1279
+
1280
+ <Typography color="error" style={{ alignText: 'center', marginTop: 3 }}>
1281
+ {submitErrorMessage}
1282
+ </Typography>
1283
+
1284
+ {errors.length > 0 &&
1285
+ <>
1286
+ <Divider flexItem sx={{ my: 1 }} />
1287
+
1288
+ <Flex alignItems="center" wrap="nowrap">
1289
+ <Typography noWrap style={{ width: 200 }}>
1290
+ Question
1291
+ </Typography>
1292
+ <Typography noWrap style={{ }}>
1293
+ Error
1294
+ </Typography>
1295
+ </Flex>
1296
+ </>
1297
+ }
1298
+ {errors.map(e => (
1299
+ <Flex key={e.id} alignItems="center" wrap="nowrap">
1300
+ <Typography noWrap style={{ width: 200, textDecoration: 'underline', cursor: 'pointer' }}
1301
+ onClick={() => {
1302
+ try {
1303
+ document.getElementById(e.id)?.scrollIntoView({ behavior: 'smooth' });
1304
+ } catch(err) {
1305
+ console.error(err)
1306
+ }
1307
+ }}
1308
+ >
1309
+ {truncate_string(e.title, { length: 50 })}
1310
+ </Typography>
1311
+
1312
+ <Typography color="error" style={{ width: 300 }}>
1313
+ {e.error}
1314
+ </Typography>
1315
+ </Flex>
1316
+ ))}
1317
+ </>
1318
+ )}
1319
+ </Flex>
1320
+ )
1321
+ }