form-craft-package 1.9.10 → 1.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "form-craft-package",
3
- "version": "1.9.10",
3
+ "version": "1.10.0",
4
4
  "main": "index.ts",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1",
@@ -7,6 +7,10 @@ export const useFormPreservedItemValues = (formRef?: FormInstance): IFormValues
7
7
  const signatureFields = Form.useWatch(FormPreservedItemKeys.SignatureFields, { form: formRef, preserve: true })
8
8
  const isPublic = Form.useWatch(FormPreservedItemKeys.IsPublic, { form: formRef, preserve: true })
9
9
  const formNotifications = Form.useWatch(FormPreservedItemKeys.FormNotifications, { form: formRef, preserve: true })
10
+ const formTemplateReports = Form.useWatch(FormPreservedItemKeys.FormTemplateReports, {
11
+ form: formRef,
12
+ preserve: true,
13
+ })
10
14
  const submissionPdfConfig = Form.useWatch(FormPreservedItemKeys.SubmissionPdfConfig, {
11
15
  form: formRef,
12
16
  preserve: true,
@@ -27,6 +31,7 @@ export const useFormPreservedItemValues = (formRef?: FormInstance): IFormValues
27
31
  [FormPreservedItemKeys.SignatureFields]: signatureFields,
28
32
  [FormPreservedItemKeys.IsPublic]: isPublic,
29
33
  [FormPreservedItemKeys.FormNotifications]: formNotifications,
34
+ [FormPreservedItemKeys.FormTemplateReports]: formTemplateReports,
30
35
  [FormPreservedItemKeys.DuplicateDataFound]:
31
36
  duplicateDataMatches && Object.values(duplicateDataMatches).some((result) => result),
32
37
  [FormPreservedItemKeys.DuplicateCheckPending]: isDuplicateCheckPending,
@@ -9,6 +9,7 @@ import { IDataListHeaderLayoutContext } from './table'
9
9
  import {
10
10
  BSON_DATA_IDENTIFIER_PREFIXES,
11
11
  ELEMENTS_DEFAULT_CLASS,
12
+ MongoDbExtendedJsonObjectKeys,
12
13
  VALUE_REPLACEMENT_PLACEHOLDER,
13
14
  VALUE_REPLACEMENT_PLACEHOLDER2,
14
15
  } from '../../../constants'
@@ -179,9 +180,9 @@ const handleFilterValues = async (config: IFilterConfig, value: any) => {
179
180
  : dayjs.utc().endOf('day').toISOString()
180
181
  } else value = dayjs.utc(value as Date).toISOString()
181
182
  } else if ((config as IFilterCustom).bsonDataType === BSON_DATA_IDENTIFIER_PREFIXES.Number) {
182
- value = { $numberDecimal: value }
183
+ value = { [MongoDbExtendedJsonObjectKeys.Number]: value }
183
184
  } else if ((config as IFilterCustom).bsonDataType === BSON_DATA_IDENTIFIER_PREFIXES.ObjectId) {
184
- value = { $oid: value }
185
+ value = { [MongoDbExtendedJsonObjectKeys.ObjectId]: value }
185
186
  }
186
187
 
187
188
  if (config.type === FilterConfigTypeEnum.Custom && (config as IFilterCustom).config) {
@@ -160,6 +160,7 @@ function FormDataDetailsComponentChild({
160
160
  )
161
161
 
162
162
  useEffect(() => {
163
+ // set form notifications
163
164
  if (!cachedConfig?.notificationsConfig || !Array.isArray(cachedConfig.notificationsConfig.notifications)) return
164
165
 
165
166
  formDataRef.setFieldValue(FormPreservedItemKeys.FormNotifications, {
@@ -168,6 +169,16 @@ function FormDataDetailsComponentChild({
168
169
  }, [cachedConfig])
169
170
 
170
171
  useEffect(() => {
172
+ // set form template reports
173
+ if (!cachedConfig?.templateReportConfig || !Array.isArray(cachedConfig.templateReportConfig.templates)) return
174
+
175
+ formDataRef.setFieldValue(FormPreservedItemKeys.FormTemplateReports, {
176
+ [cachedConfig.id]: cachedConfig.templateReportConfig,
177
+ })
178
+ }, [cachedConfig])
179
+
180
+ useEffect(() => {
181
+ // set form text translations
171
182
  if (!cachedConfig?.id || !cachedConfig?.translations) return
172
183
 
173
184
  translationStore.setTranslations(cachedConfig.id, cachedConfig.translations)
@@ -180,8 +191,8 @@ function FormDataDetailsComponentChild({
180
191
  originalTzFieldsRef.current = getPickerFieldsWithOriginalTz(elements)
181
192
 
182
193
  if (isPublic) {
183
- if (cachedConfig.generateConfig?.submissionPdf?.enabled)
184
- formDataRef.setFieldValue(FormPreservedItemKeys.SubmissionPdfConfig, cachedConfig.generateConfig.submissionPdf)
194
+ if (cachedConfig.submissionPdfConfig?.enabled)
195
+ formDataRef.setFieldValue(FormPreservedItemKeys.SubmissionPdfConfig, cachedConfig.submissionPdfConfig)
185
196
  formDataRef.setFieldValue(FormPreservedItemKeys.IsDetailsDataSet, true)
186
197
  } else fetchFormData(formId, cachedConfig.detailsConfig.dataFetchConfig)
187
198
  }, [cachedConfig, formId, isPublic, formDataRef, fetchFormData])
@@ -114,9 +114,15 @@ export const DynamicFormButtonRender = memo((props: IDynamicButton) => {
114
114
  },
115
115
  })
116
116
 
117
- const handleSecondaryAction = useCallback(
118
- (formDataId?: string) => {
119
- if (!btnProps.secondaryAction || Object.values(btnProps.secondaryAction).length === 0) {
117
+ const handleFollowUpAction = useCallback(
118
+ ({
119
+ actionLevel = 'secondaryAction',
120
+ formDataId,
121
+ }: {
122
+ actionLevel?: 'secondaryAction' | 'tertiaryAction'
123
+ formDataId?: string
124
+ }) => {
125
+ if (!btnProps[actionLevel] || Object.values(btnProps[actionLevel]).length === 0) {
120
126
  if (formInfo?.name && formDataId) {
121
127
  const detailsUrl = `${constructDynamicFormHref(formInfo.name)}/${formDataId}`
122
128
 
@@ -129,36 +135,41 @@ export const DynamicFormButtonRender = memo((props: IDynamicButton) => {
129
135
  return
130
136
  }
131
137
 
132
- const { category } = btnProps.secondaryAction
133
- if (category === ButtonActionCategoryEnum.Navigate) onButtonNavigate(btnProps.secondaryAction, btnProps.formId)
138
+ const hasTertiaryAction = actionLevel === 'secondaryAction' && !!btnProps.tertiaryAction
139
+ if (hasTertiaryAction) handleFollowUpAction({ actionLevel: 'tertiaryAction', formDataId })
140
+
141
+ const { category } = btnProps[actionLevel]
142
+ if (category === ButtonActionCategoryEnum.Navigate) onButtonNavigate(btnProps[actionLevel], btnProps.formId)
134
143
  else if (category === ButtonActionCategoryEnum.CustomFunction) {
135
144
  setLoading(false)
136
- onFunctionCall({ functionName: btnProps.secondaryAction.functionName, newFormDataId: formDataId })
137
- } else if (category === ButtonActionCategoryEnum.SendNotification) onSendNotification(btnProps.secondaryAction)
145
+ onFunctionCall({ functionName: btnProps[actionLevel].functionName, newFormDataId: formDataId })
146
+ } else if (category === ButtonActionCategoryEnum.SendNotification) onSendNotification(btnProps[actionLevel])
138
147
  else {
139
148
  // fallback
149
+ if (hasTertiaryAction) return
150
+
140
151
  setLoading(false)
141
152
  setDataLoadingType(undefined)
142
153
  }
143
154
  },
144
- [btnProps, onButtonNavigate, formInfo, formDataId, onSendNotification],
155
+ [btnProps, onButtonNavigate, formInfo, onSendNotification],
145
156
  )
146
157
 
147
158
  const onDuplicateData = useDuplicateDataAction({
148
159
  ...formContext,
149
- onSuccess: () => handleSecondaryAction(),
160
+ onSuccess: () => handleFollowUpAction({}),
150
161
  onError: () => displayResultMessage(false),
151
162
  onFinal: () => setLoading(false),
152
163
  })
153
164
  const onDeleteData = useDeleteDataAction({
154
165
  ...formContext,
155
- onSuccess: () => handleSecondaryAction(),
166
+ onSuccess: () => handleFollowUpAction({}),
156
167
  onError: () => displayResultMessage(false),
157
168
  onFinal: () => setLoading(false),
158
169
  })
159
170
  const onPublishData = usePublishDataAction({
160
171
  ...formContext,
161
- onSuccess: () => handleSecondaryAction(),
172
+ onSuccess: () => handleFollowUpAction({}),
162
173
  onError: () => displayResultMessage(false),
163
174
  onFinal: () => setLoading(false),
164
175
  })
@@ -169,7 +180,7 @@ export const DynamicFormButtonRender = memo((props: IDynamicButton) => {
169
180
  setDataLoadingType(undefined)
170
181
  setLoading(false)
171
182
  },
172
- onSuccess: (formDataId?: string) => handleSecondaryAction(formDataId),
183
+ onSuccess: (formDataId?: string) => handleFollowUpAction({ formDataId }),
173
184
  onError: () => {
174
185
  setDataLoadingType(undefined)
175
186
  displayResultMessage(false)
@@ -181,7 +192,7 @@ export const DynamicFormButtonRender = memo((props: IDynamicButton) => {
181
192
  setDataLoadingType,
182
193
  onSuccess: () => {
183
194
  displayResultMessage()
184
- handleSecondaryAction()
195
+ handleFollowUpAction({})
185
196
  },
186
197
  onError: () => {
187
198
  setDataLoadingType(undefined)
@@ -1,14 +1,15 @@
1
- import { useCallback, useRef, useState } from 'react'
1
+ import { useCallback, useState } from 'react'
2
2
  import { Modal, Select, Spin } from 'antd'
3
3
  import { FaCaretDown } from 'react-icons/fa6'
4
- import { IDynamicForm, IFormSchema, IFormTemplateReport } from '../../../../../types'
4
+ import { IDynamicForm, IFormTemplateReport } from '../../../../../types'
5
5
  import { Button_FillerPortal } from '../../../../common/button'
6
6
  import { useNotification } from '../../../../common/custom-hooks'
7
- import { fetchFormDataAsLookup, getIdEqualsQuery, renderData } from '../../../../../functions/forms'
7
+ import { replaceAndGetFileBlob } from '../../../../../functions/forms'
8
8
  import client from '../../../../../api/client'
9
- import { FieldElementOptionSourceEnum, LOCAL_STORAGE_KEYS_ENUM } from '../../../../../enums'
9
+ import { LOCAL_STORAGE_KEYS_ENUM } from '../../../../../enums'
10
10
  import { IFormContext } from '../../1-row'
11
11
  import { IOnSuccessFunctions } from '.'
12
+ import { useFormPreservedItemValues } from '../../../../common/custom-hooks/use-preserved-form-items.hook'
12
13
 
13
14
  /* --------------------------------------------------------------------------
14
15
  Generates a report by logging current form values.
@@ -17,6 +18,7 @@ export const useGenerateReportAction = ({
17
18
  onSuccess,
18
19
  onError,
19
20
  onFinal,
21
+ formRef,
20
22
  ...formContext
21
23
  }: IOnSuccessFunctions & IFormContext) => {
22
24
  const { companyKey, formName, formDataId } = formContext
@@ -26,49 +28,72 @@ export const useGenerateReportAction = ({
26
28
  const [templateReports, setTemplateReports] = useState<IFormTemplateReport[]>([])
27
29
  const [loading, setLoading] = useState(false)
28
30
  const [formData, setFormData] = useState<{ [key: string]: any }>({})
29
- const selectedDataFormIdRef = useRef<number | undefined>()
31
+ const { formTemplateReports } = useFormPreservedItemValues(formRef)
30
32
 
31
33
  const onGenerateReport = useCallback(async () => {
32
34
  setLoading(true)
33
35
  setIsModalOpen(true)
34
36
  try {
35
37
  const storedData = localStorage.getItem(LOCAL_STORAGE_KEYS_ENUM.DynamicForms)
36
- if (storedData) {
37
- const parsedData: IDynamicForm[] = JSON.parse(storedData)
38
- const form = parsedData.find((entry) => entry.name.toLowerCase() === formName?.toLowerCase())
38
+ if (!storedData) {
39
+ setLoading(false)
40
+ return
41
+ }
42
+ const parsedData: IDynamicForm[] = JSON.parse(storedData)
43
+ const form = parsedData.find((entry) => entry.name.toLowerCase() === formName?.toLowerCase())
39
44
 
40
- if (form) {
41
- selectedDataFormIdRef.current = form.id
42
- client
43
- .get(`/api/form/${form.id}`)
44
- .then((res) => {
45
- if (res.status === 200) {
46
- const parsedFormData: IFormSchema | null = JSON.parse(res.data.data)
47
- if (parsedFormData) {
48
- const reports = parsedFormData.generateConfig.templateReports ?? []
49
- if (reports.length)
50
- client
51
- .post(`/api/report/data/${form.id}`, {
52
- joins: parsedFormData.generateConfig.formJoins,
53
- match: JSON.stringify(getIdEqualsQuery('_id', formDataId)),
54
- })
55
- .then((res) => {
56
- if (res.status === 200) {
57
- setFormData(res.data.data[0])
58
- setTemplateReports(reports)
59
- }
60
- })
61
- }
62
- }
63
- })
64
- .finally(() => setLoading(false))
65
- } else setLoading(false)
66
- } else setLoading(false)
45
+ if (!form) {
46
+ setLoading(false)
47
+ return
48
+ }
49
+ const { templates = [], joins = [] } = formTemplateReports?.[form.id] || { templates: [], joins: [] }
50
+
51
+ if (templates.length === 0) {
52
+ setLoading(false)
53
+ return
54
+ }
55
+
56
+ client
57
+ .post(`/api/report/${form.id}/${formDataId}`, { joins })
58
+ .then((res) => {
59
+ if (res.status === 200) {
60
+ setFormData(res.data)
61
+ setTemplateReports(templates)
62
+ }
63
+ })
64
+ .finally(() => setLoading(false))
67
65
  } catch (error) {
68
66
  console.error('Error reading or parsing localStorage data', error)
69
67
  setLoading(false)
70
68
  }
71
- }, [templateReports, formName, formDataId])
69
+ }, [formTemplateReports, formName, formDataId])
70
+
71
+ const handleDataReplacement = useCallback(async () => {
72
+ setLoading(true)
73
+ const selectedTemplateInfo: IFormTemplateReport = templateReports.find(
74
+ (t: IFormTemplateReport) => t.fileBlobName === selectedTemplate,
75
+ )!
76
+
77
+ const replacedFileBlob = await replaceAndGetFileBlob(selectedTemplateInfo, formData, companyKey)
78
+ if (replacedFileBlob) {
79
+ const url = window.URL.createObjectURL(new Blob([replacedFileBlob]))
80
+ const link = document.createElement('a')
81
+ link.href = url
82
+ link.setAttribute(
83
+ 'download',
84
+ selectedTemplateInfo.templateName.includes('.pdf')
85
+ ? selectedTemplateInfo.templateName
86
+ : `${selectedTemplateInfo.templateName}.pdf`,
87
+ ) //or any other extension
88
+ document.body.appendChild(link)
89
+ link.click()
90
+ onSuccess()
91
+ } else onError()
92
+
93
+ onFinal()
94
+ setLoading(false)
95
+ setIsModalOpen(false)
96
+ }, [templateReports, selectedTemplate])
72
97
 
73
98
  const ChooseTemplateReportModal = isModalOpen ? (
74
99
  <Modal
@@ -93,74 +118,7 @@ export const useGenerateReportAction = ({
93
118
  return
94
119
  }
95
120
 
96
- setLoading(true)
97
- const selectedTemplateInfo: IFormTemplateReport = templateReports.find(
98
- (t: IFormTemplateReport) => t.fileBlobName === selectedTemplate,
99
- )!
100
-
101
- const replacements = await Promise.all(
102
- selectedTemplateInfo.replacements.map(async (rep) => {
103
- const value = rep.field.split('.').reduce((curr, n) => curr[n] ?? {}, formData)
104
-
105
- if (!!rep.optionSource) {
106
- if (rep.optionSource.type === FieldElementOptionSourceEnum.Static) {
107
- return {
108
- placeholder: rep.placeholder,
109
- value: renderData(
110
- rep.optionSource.options.find((op) => op.id === (value as unknown as string))?.value,
111
- rep.renderConfig,
112
- ),
113
- }
114
- } else if (rep.optionSource.type === FieldElementOptionSourceEnum.DynamicForm) {
115
- const formDataResData = await fetchFormDataAsLookup(rep.optionSource.baseFormId)
116
-
117
- const field = rep.optionSource.optionRender?.fields?.[0].field
118
- return {
119
- placeholder: rep.placeholder,
120
- value: renderData(
121
- formDataResData.find((data) => data.id === (value as unknown as string))?.[
122
- field ? `Data.${field}` : field
123
- ],
124
- rep.renderConfig,
125
- ),
126
- }
127
- }
128
- }
129
- return {
130
- placeholder: rep.placeholder,
131
- value: renderData(value, rep.renderConfig),
132
- type: rep.type,
133
- }
134
- }),
135
- )
136
-
137
- client
138
- .post(
139
- `/api/template/${companyKey}/${selectedTemplateInfo.fileBlobName}`,
140
- replacements.map((r) => ({
141
- key: r.placeholder,
142
- value: r.value?.toString() ?? '-',
143
- type: r.type,
144
- })),
145
- { responseType: 'blob' },
146
- )
147
- .then((res) => {
148
- if (res.status === 200) {
149
- const url = window.URL.createObjectURL(new Blob([res.data]))
150
- const link = document.createElement('a')
151
- link.href = url
152
- link.setAttribute('download', `${selectedTemplateInfo.templateName}.pdf`) //or any other extension
153
- document.body.appendChild(link)
154
- link.click()
155
- onSuccess()
156
- }
157
- })
158
- .catch(() => onError())
159
- .finally(() => {
160
- onFinal()
161
- setLoading(false)
162
- setIsModalOpen(false)
163
- })
121
+ handleDataReplacement()
164
122
  }}
165
123
  >
166
124
  Continue
@@ -1,12 +1,25 @@
1
- import { useCallback, useMemo } from 'react'
2
- import { NotificationTypeEnum } from '../../../../../enums'
1
+ import { useCallback, useMemo, useRef } from 'react'
3
2
  import client from '../../../../../api/client'
4
3
  import { IFormContext } from '../../1-row'
5
4
  import { IOnSuccessFunctions } from '.'
6
5
  import { useNotification } from '../../../../common/custom-hooks'
7
- import { IButtonProps_SendNotification, IEmail_Attachment, IFormJoin, IFormNotification } from '../../../../../types'
8
6
  import { useFormPreservedItemValues } from '../../../../common/custom-hooks/use-preserved-form-items.hook'
9
- import { REGEX_PATTERNS } from '../../../../../constants'
7
+ import { MongoDbExtendedJsonObjectKeys, REGEX_PATTERNS } from '../../../../../constants'
8
+ import { renderData, replaceAndGetFileBlob } from '../../../../../functions'
9
+ import {
10
+ CountryEnum,
11
+ DataRenderTypeEnum,
12
+ LOCAL_STORAGE_KEYS_ENUM,
13
+ NotificationTypeEnum,
14
+ TranslationTextTypeEnum,
15
+ } from '../../../../../enums'
16
+ import {
17
+ IButtonProps_SendNotification,
18
+ IEmail_Attachment,
19
+ IFormJoin,
20
+ IFormNotification,
21
+ IFormTemplateReport,
22
+ } from '../../../../../types'
10
23
 
11
24
  /* --------------------------------------------------------------------------
12
25
  Send notification: Email | Text | Push
@@ -16,20 +29,78 @@ export const useSendNotificationAction = ({
16
29
  formRef,
17
30
  formDataId,
18
31
  formId,
32
+ companyKey,
19
33
  onSuccess,
20
34
  onError,
21
35
  onFinal,
22
36
  }: IOnSuccessFunctions & IFormContext) => {
23
- const { warning } = useNotification()
24
- const { formNotifications: allNotificaitons } = useFormPreservedItemValues(formRef)
37
+ const { success, warning } = useNotification()
38
+ const {
39
+ baseServerUrl,
40
+ formNotifications: allNotifications,
41
+ formTemplateReports,
42
+ } = useFormPreservedItemValues(formRef)
43
+ const blobDataToDeleteRef = useRef<string[]>([])
44
+
45
+ const fileBaseUrl = useMemo(() => {
46
+ const domain = localStorage.getItem(LOCAL_STORAGE_KEYS_ENUM.Domain)
47
+ return `${baseServerUrl}/api/attachment/${domain}`
48
+ }, [baseServerUrl])
25
49
 
26
50
  const formNotifications: { joins: IFormJoin[]; notifications: IFormNotification[] } = useMemo(() => {
27
51
  const defaultNotifs = { joins: [], notifications: [] }
28
52
 
29
- if (!formId || !allNotificaitons || !allNotificaitons[formId]) return defaultNotifs
53
+ if (!formId || !allNotifications || !allNotifications[formId]) return defaultNotifs
54
+
55
+ return allNotifications[formId]
56
+ }, [allNotifications, formId])
57
+
58
+ const handleTemplateReport = useCallback(
59
+ async (attachments: IEmail_Attachment[]): Promise<IEmail_Attachment[]> => {
60
+ try {
61
+ if (!formId) return []
62
+
63
+ const templateReports = formTemplateReports[formId]
64
+
65
+ const joinedFormData = await client
66
+ .post(`/api/report/${formId}/${formDataId}`, { joins: templateReports.joins })
67
+ .then((res) => (res.status === 200 ? res.data : {}))
68
+ .catch(() => ({}))
30
69
 
31
- return allNotificaitons[formId]
32
- }, [allNotificaitons, formId])
70
+ const dataReplacedFormAttachments: IEmail_Attachment[] = []
71
+
72
+ for (const attachment of attachments) {
73
+ const selectedTemplateInfo = templateReports.templates.find(
74
+ (tR: IFormTemplateReport) => tR.fileBlobName === attachment.blobName,
75
+ )
76
+ if (!selectedTemplateInfo) continue
77
+
78
+ const replacedFileBlob = await replaceAndGetFileBlob(selectedTemplateInfo, joinedFormData, companyKey)
79
+
80
+ if (replacedFileBlob) {
81
+ const formData = new FormData()
82
+ formData.append('file', replacedFileBlob)
83
+ const blobName = await client
84
+ .post('/api/attachment', formData)
85
+ .then((res) => (res.status === 200 ? res.data : ''))
86
+ .catch(() => '')
87
+
88
+ blobDataToDeleteRef.current = [...blobDataToDeleteRef.current, blobName]
89
+ dataReplacedFormAttachments.push({
90
+ ...attachment,
91
+ blobName,
92
+ fileName: attachment.fileName.replace('.docx', '.pdf'),
93
+ })
94
+ }
95
+ }
96
+ return dataReplacedFormAttachments
97
+ } catch (err) {
98
+ console.error('handleTemplateReport failed: ', err)
99
+ return []
100
+ }
101
+ },
102
+ [formTemplateReports, formId, formDataId, companyKey],
103
+ )
33
104
 
34
105
  const onSendNotification = useCallback(
35
106
  async (notifActionProps: IButtonProps_SendNotification) => {
@@ -44,6 +115,10 @@ export const useSendNotificationAction = ({
44
115
  return
45
116
  }
46
117
 
118
+ let formTemplateReportAttachments: IEmail_Attachment[] = []
119
+ if (Array.isArray(formNotif?.attachments) && formNotif.attachments.length > 0)
120
+ formTemplateReportAttachments = await handleTemplateReport(formNotif?.attachments)
121
+
47
122
  try {
48
123
  const notifConfigRes = await client.get(`/api/notificationconfig/${notificationId}/data`)
49
124
  const formDataRes = await client.post(`/api/report/${formId}/${formDataId}`, { joins })
@@ -55,19 +130,63 @@ export const useSendNotificationAction = ({
55
130
  ) {
56
131
  let notifConfig = notifConfigRes.data.data || ''
57
132
 
58
- for (const [placeholderKey, placeholderValue] of Object.entries(formNotif.replacements)) {
59
- const repValue = placeholderValue
60
- .split('.')
61
- .reduce((accData, path) => accData[path] || accData, formDataRes.data)
133
+ for (const [placeholderKey, placeholderReplacement] of Object.entries(formNotif.replacements)) {
134
+ const { field: fieldPath, renderConfig } = placeholderReplacement
135
+
136
+ if (renderConfig?.type === DataRenderTypeEnum.Image) {
137
+ const imgTag = buildImageTag(`${fileBaseUrl}/${encodeURIComponent(fieldPath)}`, {
138
+ width: renderConfig.style?.width,
139
+ height: renderConfig.style?.height,
140
+ alt: 'Image is attached as attachment!',
141
+ })
142
+ notifConfig = notifConfig.replace(placeholderKey, imgTag)
143
+ } else if (fieldPath) {
144
+ let repValue = fieldPath.split('.').reduce((accData, path) => accData[path] || accData, formDataRes.data)
145
+
146
+ if (typeof repValue === 'object' && MongoDbExtendedJsonObjectKeys.Date in repValue)
147
+ repValue = repValue[MongoDbExtendedJsonObjectKeys.Date]
148
+ else if (typeof repValue === 'object' && MongoDbExtendedJsonObjectKeys.Number in repValue)
149
+ repValue = repValue[MongoDbExtendedJsonObjectKeys.Number]
150
+
151
+ if (renderConfig?.type === DataRenderTypeEnum.FieldOption) {
152
+ try {
153
+ const fieldPathParts = fieldPath.split('.')
154
+ const fieldFormId = !isNaN(Number(fieldPathParts[0])) ? Number(fieldPathParts[0]) : formId
155
+ const fieldKey = fieldPathParts[fieldPathParts.length - 1]
156
+
157
+ const tKey = `${fieldKey}__${TranslationTextTypeEnum.OptionValue}__${repValue}`
158
+ const selectedLanguage =
159
+ localStorage.getItem(LOCAL_STORAGE_KEYS_ENUM.SelectedLanguage) || CountryEnum.US
160
+ const projectPath = `Data.translations.${tKey}`
161
+
162
+ await client
163
+ .post(`/api/form/${fieldFormId}`, { project: JSON.stringify({ [projectPath]: 1 }) })
164
+ .then((res) => {
165
+ if (res.status === 200) {
166
+ const translationsPerCountry =
167
+ projectPath.split('.').reduce((acc, p) => acc[p] || acc, res.data) || {}
168
+ if (typeof translationsPerCountry[selectedLanguage] === 'string')
169
+ repValue = translationsPerCountry[selectedLanguage]
170
+ }
171
+ })
172
+ } catch {}
173
+ }
62
174
 
63
- if (['string', 'boolean', 'number'].includes(typeof repValue))
64
- notifConfig = notifConfig.replaceAll(placeholderKey, repValue)
175
+ if (['string', 'boolean', 'number'].includes(typeof repValue))
176
+ notifConfig = notifConfig.replaceAll(
177
+ placeholderKey,
178
+ renderConfig ? renderData(repValue, renderConfig) : repValue,
179
+ )
180
+ }
65
181
  }
66
182
 
67
183
  const parsedNotifConfig = JSON.parse(notifConfig) || {}
68
- const notifTypes = Object.keys(parsedNotifConfig) || []
184
+ const notifTypes =
185
+ Object.keys(parsedNotifConfig).filter((key) =>
186
+ Object.values(NotificationTypeEnum).includes(key as NotificationTypeEnum),
187
+ ) || []
69
188
 
70
- await Promise.all(
189
+ const notifRes = await Promise.all(
71
190
  notifTypes.map((type) => {
72
191
  let endpoint = '/api/notification/email'
73
192
  if (type === NotificationTypeEnum.Push) endpoint = '/api/notification/push'
@@ -75,20 +194,27 @@ export const useSendNotificationAction = ({
75
194
 
76
195
  const typeReqData = parsedNotifConfig[type] || {}
77
196
 
197
+ const selectedLanguage = localStorage.getItem(LOCAL_STORAGE_KEYS_ENUM.SelectedLanguage) || CountryEnum.US
198
+
78
199
  if (type === NotificationTypeEnum.Email) {
79
200
  if (!typeReqData.subject) typeReqData.subject = ''
201
+ else typeReqData.subject = typeReqData.subject[selectedLanguage]
202
+
80
203
  if (!typeReqData.to) typeReqData.to = ''
204
+
81
205
  if (!typeReqData.body) typeReqData.body = ''
82
206
  else {
83
207
  // used safeNormalizeForEmail, otherwise, it's received with big line heights
84
- typeReqData.body = safeNormalizeForEmail(typeReqData.body)
208
+ typeReqData.body = safeNormalizeForEmail(typeReqData.body[selectedLanguage])
85
209
  }
86
210
 
87
211
  if (Array.isArray(typeReqData.attachments))
88
- typeReqData.attachments = typeReqData.attachments.map((att: IEmail_Attachment) => {
89
- const { type, ...restAtt } = att
90
- return restAtt
91
- })
212
+ typeReqData.attachments = [...typeReqData.attachments, ...formTemplateReportAttachments].map(
213
+ (att: IEmail_Attachment) => {
214
+ const { type, ...restAtt } = att
215
+ return restAtt
216
+ },
217
+ )
92
218
 
93
219
  const emailFields = ['to', 'from', 'cc', 'bcc']
94
220
  emailFields.forEach((f) => {
@@ -99,11 +225,28 @@ export const useSendNotificationAction = ({
99
225
  }
100
226
 
101
227
  const reqData = { notificationId: formNotif.id, ...typeReqData }
102
- client.post(endpoint, reqData)
228
+
229
+ return client.post(endpoint, reqData).then((res) => {
230
+ if (res.status === 200) return type
231
+ })
103
232
  }),
104
233
  )
105
234
 
106
- onSuccess()
235
+ if (notifRes.filter(Boolean).length > 0) {
236
+ if (blobDataToDeleteRef.current.length)
237
+ setTimeout(async () => {
238
+ await Promise.all(
239
+ blobDataToDeleteRef.current.map((blobName) => client.delete(`/api/attachment/${blobName}`)),
240
+ )
241
+ blobDataToDeleteRef.current = []
242
+ }, 500)
243
+ success({
244
+ message: `Successfully sent out the ${notifRes.join(', ').toLowerCase()} notification${
245
+ notifRes.length === 1 ? '' : 's'
246
+ }!`,
247
+ })
248
+ onSuccess()
249
+ }
107
250
  }
108
251
 
109
252
  onFinal()
@@ -112,7 +255,7 @@ export const useSendNotificationAction = ({
112
255
  onError()
113
256
  }
114
257
  },
115
- [onSuccess, onError, onFinal, formNotifications, formId, formDataId],
258
+ [onSuccess, onError, onFinal, formNotifications, formId, formDataId, handleTemplateReport, fileBaseUrl],
116
259
  )
117
260
 
118
261
  return onSendNotification
@@ -140,6 +283,8 @@ function safeNormalizeForEmail(
140
283
  listIndentPx?: number // default 20
141
284
  } = {},
142
285
  ) {
286
+ if (!html) return ''
287
+
143
288
  const baseFontPx = opts.baseFontPx ?? 14
144
289
  const lineHeightPx = opts.lineHeightPx ?? 20
145
290
  const listIndentPx = opts.listIndentPx ?? 20
@@ -188,3 +333,74 @@ function safeNormalizeForEmail(
188
333
 
189
334
  return out
190
335
  }
336
+ type Dim = number | string | undefined
337
+
338
+ function normCssDim(v: Dim, fallback: string): string {
339
+ if (v == null) return fallback
340
+ if (typeof v === 'number') return `${v}px`
341
+ return v
342
+ }
343
+
344
+ function pxNumber(v: Dim): number | undefined {
345
+ if (v == null) return undefined
346
+ if (typeof v === 'number') return v
347
+ if (v.endsWith('px')) {
348
+ const n = parseInt(v, 10)
349
+ return Number.isFinite(n) ? n : undefined
350
+ }
351
+ return undefined // don't emit % as attributes
352
+ }
353
+
354
+ function escAttrSingle(s: string): string {
355
+ return s.replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
356
+ }
357
+
358
+ /**
359
+ * Email-safe <img> generator (single-quoted attributes).
360
+ * - If height (px) is provided and width is not, we emit:
361
+ * height='N' + style 'height:Npx;width:auto;max-width:100%' => closest to "contain by height"
362
+ * - If width is % (e.g., '100%'), it's emitted only in CSS (not as an attribute).
363
+ * - If width/height are numeric (px), attributes + matching CSS are emitted for Outlook reliability.
364
+ */
365
+ export function buildImageTag(
366
+ src: string,
367
+ opts?: {
368
+ alt?: string
369
+ width?: Dim // 300 | '300px' | '100%'
370
+ height?: Dim // 30 | '30px' | '50%'
371
+ extraStyle?: string
372
+ className?: string
373
+ },
374
+ ): string {
375
+ const { alt = '', width, height, extraStyle, className } = opts || {}
376
+
377
+ const cssW = normCssDim(width, 'auto')
378
+ const cssH = normCssDim(height, 'auto')
379
+
380
+ const wAttrNum = pxNumber(width)
381
+ const hAttrNum = pxNumber(height)
382
+
383
+ let attrs = ` src='${escAttrSingle(src)}' alt='${alt}'`
384
+ if (wAttrNum !== undefined) attrs += ` width='${wAttrNum}'`
385
+ if (hAttrNum !== undefined) attrs += ` height='${hAttrNum}'`
386
+ if (className) attrs += ` class='${escAttrSingle(className)}'`
387
+
388
+ const styles = [
389
+ 'display:block',
390
+ 'border:0',
391
+ 'outline:0',
392
+ 'text-decoration:none',
393
+ '-ms-interpolation-mode:bicubic',
394
+ `width:${cssW}`,
395
+ `height:${cssH}`,
396
+ ]
397
+
398
+ // If we're constraining by height only, keep width fluid but prevent overflow
399
+ if (height && !width) styles.push('max-width:100%')
400
+
401
+ if (extraStyle?.trim()) styles.push(extraStyle.trim())
402
+
403
+ attrs += ` style='${styles.join(';')}'`
404
+
405
+ return `<img${attrs} />`
406
+ }
package/src/constants.ts CHANGED
@@ -174,6 +174,11 @@ export const BSON_DATA_IDENTIFIER_PREFIXES = {
174
174
  Date: 'Date__',
175
175
  Number: 'Number__',
176
176
  }
177
+ export enum MongoDbExtendedJsonObjectKeys {
178
+ Date = '$date',
179
+ ObjectId = '$oid',
180
+ Number = '$numberDecimal',
181
+ }
177
182
  export const DEFAULT_FORM_SCHEMA_DATA: IFormSchema = {
178
183
  detailsConfig: {
179
184
  elements: {},
@@ -186,7 +191,8 @@ export const DEFAULT_FORM_SCHEMA_DATA: IFormSchema = {
186
191
  columns: [],
187
192
  header: { elements: {}, layouts: { [DeviceBreakpointEnum.Default]: [] } },
188
193
  },
189
- generateConfig: { submissionPdf: { enabled: false } },
194
+ submissionPdfConfig: { enabled: false },
195
+ templateReportConfig: { templates: [], joins: [] },
190
196
  relationships: [],
191
197
  translations: {},
192
198
  notificationsConfig: { notifications: [] },
@@ -48,6 +48,7 @@ export enum DataRenderTypeEnum {
48
48
  Image = 'Image',
49
49
  // PhoneNumber = 'PhoneNumber',
50
50
  Date = 'Date',
51
+ FieldOption = 'FieldOption',
51
52
  Buttons = 'Buttons',
52
53
  Conditional = 'Conditional',
53
54
  // Concatenation = 'Concatenation',
@@ -142,6 +143,7 @@ export enum FormPreservedItemKeys {
142
143
  FormNotifications = 'formNotifications',
143
144
  DuplicateDataFound = 'isDuplicateDataFound',
144
145
  DuplicateCheckPending = 'isDuplicateCheckPending',
146
+ FormTemplateReports = 'formTemplateReports',
145
147
  }
146
148
  export enum DataCategoryEnum {
147
149
  Number = 'Number',
@@ -140,4 +140,4 @@ export enum TranslationTextSubTypeEnum {
140
140
  ErrorMsg = 'ErrMsg',
141
141
  ButtonsGroup = 'BtnsGroup',
142
142
  NotFound = 'NotFound',
143
- }
143
+ }
@@ -1,4 +1,4 @@
1
- import { BSON_DATA_IDENTIFIER_PREFIXES } from '../../constants'
1
+ import { BSON_DATA_IDENTIFIER_PREFIXES, MongoDbExtendedJsonObjectKeys } from '../../constants'
2
2
  import { isValidMongoDbId } from '..'
3
3
  import dayjs from 'dayjs'
4
4
 
@@ -7,21 +7,21 @@ export function toMongoDbExtendedJSON(input: Record<string, any>): Record<string
7
7
 
8
8
  for (const [key, val] of Object.entries(input)) {
9
9
  if (key.startsWith(BSON_DATA_IDENTIFIER_PREFIXES.ObjectId)) {
10
- if (typeof val === 'string' && isValidMongoDbId(val)) out[key] = { $oid: val }
10
+ if (typeof val === 'string' && isValidMongoDbId(val)) out[key] = { [MongoDbExtendedJsonObjectKeys.ObjectId]: val }
11
11
  else {
12
12
  console.warn(`Invalid ObjectId for "${key}":`, val)
13
13
  out[key] = val
14
14
  }
15
15
  } else if (key.startsWith(BSON_DATA_IDENTIFIER_PREFIXES.Date)) {
16
16
  if (val && dayjs(val).isValid()) {
17
- out[key] = { $date: dayjs(val).utc().toISOString() }
17
+ out[key] = { [MongoDbExtendedJsonObjectKeys.Date]: dayjs(val).utc().toISOString() }
18
18
  } else {
19
19
  console.warn(`Invalid Date for "${key}":`, val)
20
20
  out[key] = val
21
21
  }
22
22
  } else if (key.startsWith(BSON_DATA_IDENTIFIER_PREFIXES.Number)) {
23
23
  const num = typeof val === 'number' ? val : Number(val)
24
- if (!isNaN(num)) out[key] = { $numberDecimal: num }
24
+ if (!isNaN(num)) out[key] = { [MongoDbExtendedJsonObjectKeys.Number]: num }
25
25
  else {
26
26
  console.warn(`Invalid Number for "${key}":`, val)
27
27
  out[key] = val
@@ -39,18 +39,28 @@ export function fromMongoDbExtendedJSON(
39
39
  const out: Record<string, any> = {}
40
40
 
41
41
  for (const [key, val] of Object.entries(input)) {
42
- if (key.startsWith(BSON_DATA_IDENTIFIER_PREFIXES.ObjectId) && val && typeof val === 'object' && '$oid' in val) {
43
- out[key] = (val as any).$oid
44
- } else if (key.startsWith(BSON_DATA_IDENTIFIER_PREFIXES.Date) && val && typeof val === 'object' && '$date' in val) {
45
- const date = (val as any).$date
42
+ if (
43
+ key.startsWith(BSON_DATA_IDENTIFIER_PREFIXES.ObjectId) &&
44
+ val &&
45
+ typeof val === 'object' &&
46
+ MongoDbExtendedJsonObjectKeys.ObjectId in val
47
+ ) {
48
+ out[key] = (val as any)[MongoDbExtendedJsonObjectKeys.ObjectId]
49
+ } else if (
50
+ key.startsWith(BSON_DATA_IDENTIFIER_PREFIXES.Date) &&
51
+ val &&
52
+ typeof val === 'object' &&
53
+ MongoDbExtendedJsonObjectKeys.Date in val
54
+ ) {
55
+ const date = (val as any)[MongoDbExtendedJsonObjectKeys.Date]
46
56
  out[key] = date ? (originalTzFields.includes(key) ? dayjs(date).utc() : dayjs(date)) : date
47
57
  } else if (
48
58
  key.startsWith(BSON_DATA_IDENTIFIER_PREFIXES.Number) &&
49
59
  val &&
50
60
  typeof val === 'object' &&
51
- '$numberDecimal' in val
61
+ MongoDbExtendedJsonObjectKeys.Number in val
52
62
  ) {
53
- const n = (val as any).$numberDecimal
63
+ const n = (val as any)[MongoDbExtendedJsonObjectKeys.Number]
54
64
  out[key] = typeof n === 'number' ? n : Number(n)
55
65
  } else out[key] = val
56
66
  }
@@ -15,7 +15,9 @@ import {
15
15
  IGridContainerConfig,
16
16
  IDynamicForm,
17
17
  IFormDataApiReqConfig,
18
+ IFormTemplateReport,
18
19
  } from '../../types'
20
+ import { renderData } from '..'
19
21
 
20
22
  export const extractFiltersFromLayout = (elements: { [key: string]: IDndLayoutElement }) => {
21
23
  const filters: IFilterNested = {}
@@ -197,4 +199,66 @@ export const isNewFormDataPage = (formDataId?: string) => !formDataId || formDat
197
199
 
198
200
  /** --------------------------------------------------------------------------------------------------------- */
199
201
 
202
+ export const replaceAndGetFileBlob = async (
203
+ tInfo: IFormTemplateReport,
204
+ formData: any,
205
+ companyKey?: string,
206
+ ): Promise<string> => {
207
+ if (!companyKey) return ''
208
+
209
+ try {
210
+ const replacements = await Promise.all(
211
+ tInfo.replacements.map(async (rep) => {
212
+ const value = rep.field.split('.').reduce((curr, n) => curr?.[n] ?? {}, formData)
213
+
214
+ if (rep.optionSource) {
215
+ if (rep.optionSource.type === FieldElementOptionSourceEnum.Static) {
216
+ return {
217
+ placeholder: rep.placeholder,
218
+ value: renderData(
219
+ rep.optionSource.options.find((op) => op.id === (value as unknown as string))?.value,
220
+ rep.renderConfig,
221
+ ),
222
+ }
223
+ } else if (rep.optionSource.type === FieldElementOptionSourceEnum.DynamicForm) {
224
+ const formDataResData = await fetchFormDataAsLookup(rep.optionSource.baseFormId)
225
+ const field = rep.optionSource.optionRender?.fields?.[0]?.field
226
+
227
+ return {
228
+ placeholder: rep.placeholder,
229
+ value: renderData(
230
+ formDataResData.find((data) => data.id === (value as unknown as string))?.[
231
+ field ? `Data.${field}` : field
232
+ ],
233
+ rep.renderConfig,
234
+ ),
235
+ }
236
+ }
237
+ }
238
+
239
+ return {
240
+ placeholder: rep.placeholder,
241
+ value: renderData(value, rep.renderConfig),
242
+ type: rep.type,
243
+ }
244
+ }),
245
+ )
246
+
247
+ return await client
248
+ .post(
249
+ `/api/template/${companyKey}/${tInfo.fileBlobName}`,
250
+ replacements.map((r) => ({ key: r.placeholder, value: r.value?.toString() ?? '-', type: r.type })),
251
+ { responseType: 'blob' },
252
+ )
253
+ .then((res) => (res.status === 200 ? res.data : ''))
254
+ } catch (err) {
255
+ console.error('replaceAndGetFileBlob failed:', err)
256
+ return ''
257
+ }
258
+ }
259
+
260
+ /** --------------------------------------------------------------------------------------------------------- */
261
+
262
+ /** --------------------------------------------------------------------------------------------------------- */
263
+
200
264
  /** --------------------------------------------------------------------------------------------------------- */
@@ -1,7 +1,7 @@
1
1
  import { TemplateDataReplacementFieldTypesEnum } from '../../../enums'
2
2
  import { IDataRenderConfig, IFieldElementOptionSource } from '../layout-elements'
3
3
 
4
- export interface IFormSubmissionPdf {
4
+ export interface IFormSubmissionPdfConfig { // only for public forms
5
5
  enabled: false
6
6
  title?: { values: { value: string; isDynamic: boolean; order: number }[]; delimiter: string }
7
7
  showDownloadModal?: boolean
@@ -4,9 +4,9 @@ export * from './generate'
4
4
  export * from './relationship'
5
5
 
6
6
  import { IDataListSorter, IFormDataListColumn, IFormDataListPagination } from './data-list'
7
- import { IFormSubmissionPdf, IFormTemplateReport } from './generate'
7
+ import { IFormSubmissionPdfConfig, IFormTemplateReport } from './generate'
8
8
  import { IFormRelationshipConfig } from './relationship'
9
- import { IDndLayoutStructure_Responsive, IEmail_Attachment } from '..'
9
+ import { IDataRenderConfig, IDndLayoutStructure_Responsive, IEmail_Attachment } from '..'
10
10
  import {
11
11
  ButtonElementSizeEnum,
12
12
  ButtonElementTypeEnum,
@@ -32,7 +32,11 @@ export interface IFormSchema {
32
32
  duplicateCheckConfig?: IDuplicateCheckConfig
33
33
  }
34
34
  dataListConfig: IFormDataListConfig
35
- generateConfig: IFormGenerateConfig
35
+ submissionPdfConfig?: IFormSubmissionPdfConfig
36
+ templateReportConfig?: {
37
+ templates?: IFormTemplateReport[]
38
+ joins?: IFormJoin[]
39
+ }
36
40
  relationships?: IFormRelationshipConfig[]
37
41
  translations?: IFormTranslations
38
42
  notificationsConfig?: {
@@ -58,7 +62,7 @@ export type IFormTranslations = { [key: string]: { [key in CountryEnum]?: string
58
62
  export interface IFormNotification {
59
63
  id: number
60
64
  name?: string
61
- replacements?: { [key: string]: string }
65
+ replacements?: { [key: string]: { field: string; renderConfig: IDataRenderConfig } }
62
66
  attachments?: IEmail_Attachment[]
63
67
  }
64
68
 
@@ -74,12 +78,6 @@ export interface IFormDataListConfig {
74
78
  noDataText?: string
75
79
  }
76
80
 
77
- export interface IFormGenerateConfig {
78
- submissionPdf?: IFormSubmissionPdf
79
- templateReports?: IFormTemplateReport[]
80
- formJoins?: IFormJoin[]
81
- }
82
-
83
81
  export interface IFormDndLayoutRowHeader {
84
82
  isCollapsible?: boolean
85
83
  defaultCollapsed?: boolean
@@ -12,8 +12,9 @@ export type IButtonElementProps = IButtonPropsBase & {
12
12
  | IButtonProps_CustomFunctionCall
13
13
  | IButtonProps_SendNotification
14
14
  | IButtonProps_Other
15
- // secondaryAction only applies if category is NOT [CustomFunction, SendNotification, Navigate]
15
+ // secondaryAction & tertiaryAction only applies if category is NOT [CustomFunction, SendNotification, Navigate]
16
16
  secondaryAction?: IButtonProps_Navigate | IButtonProps_CustomFunctionCall | IButtonProps_SendNotification
17
+ tertiaryAction?: IButtonProps_Navigate | IButtonProps_CustomFunctionCall | IButtonProps_SendNotification
17
18
  }
18
19
 
19
20
  export interface IButtonPropsBase {
@@ -8,6 +8,7 @@ export type IDataRenderConfig =
8
8
  | IDataRender_Image
9
9
  // | IDataRender_PhoneNumber
10
10
  | IDataRender_Date
11
+ | IDataRender_FieldOption
11
12
  | IDataRender_Buttons
12
13
  | IDataRender_Conditional
13
14
  | IDataRender_Tags
@@ -48,6 +49,10 @@ export interface IDataRender_Date {
48
49
  displayInOriginalTz?: boolean
49
50
  }
50
51
 
52
+ export interface IDataRender_FieldOption {
53
+ type: DataRenderTypeEnum.FieldOption
54
+ }
55
+
51
56
  export interface IDataRender_Conditional {
52
57
  type: DataRenderTypeEnum.Conditional
53
58
  conditions: { [key: string]: any } // similar to filter conditions
@@ -7,7 +7,7 @@ export type INotificationConfig = {
7
7
  [NotificationTypeEnum.Push]: INotificationConfig_Push
8
8
  }
9
9
 
10
- interface INotificationConfig_Email {
10
+ export interface INotificationConfig_Email {
11
11
  type: NotificationTypeEnum.Email
12
12
  subject: Record<CountryEnum, string>
13
13
  body: Record<CountryEnum, string>