form-craft-package 1.11.1 → 1.11.4-dev.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.11.1",
3
+ "version": "1.11.4-dev.0",
4
4
  "main": "index.ts",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1",
package/src/api/client.ts CHANGED
@@ -198,18 +198,23 @@ export const auth = async (
198
198
  let userRoleIds: string[] = []
199
199
  const userForm = forms.find((form) => form.isUser)
200
200
  if (userForm?.id && loginAuthRes.data.id) {
201
+ let userRolesRes: AxiosResponse<any> | undefined
201
202
  try {
202
- const userRolesRes = await apiClient.post(`/api/report/data/${userForm.id}`, {
203
+ userRolesRes = await apiClient.post(`/api/report/data/${userForm.id}`, {
203
204
  joins: [],
204
205
  match: JSON.stringify({ DeletedDate: null, 'Data.loginUser_id': loginAuthRes.data.id }),
205
206
  project: JSON.stringify({ Data_roles: '$Data.loginUser_roles' }),
206
207
  skip: 0,
207
208
  limit: 1,
208
209
  })
209
- const fetchedRoles = userRolesRes?.data?.data?.[0]?.Data_roles
210
- if (Array.isArray(fetchedRoles))
211
- userRoleIds = fetchedRoles.filter((role): role is string => typeof role === 'string' && role.length > 0)
212
210
  } catch {}
211
+ if (userRolesRes?.data?.totalRecords === 0 && !JSON.parse(loginAuthRes.data.roles).includes('Admin')) {
212
+ cookieHandler.empty()
213
+ throw { status: 400, response: { status: 400, data: 'User is not found!' } }
214
+ }
215
+ const fetchedRoles = userRolesRes?.data?.data?.[0]?.Data_roles
216
+ if (Array.isArray(fetchedRoles))
217
+ userRoleIds = fetchedRoles.filter((role): role is string => typeof role === 'string' && role.length > 0)
213
218
  }
214
219
 
215
220
  if (userRoleIds.length)
@@ -1,20 +1,12 @@
1
- import { useState, useTransition } from 'react'
1
+ import { useState } from 'react'
2
2
 
3
3
  export const useLazyModalOpener = () => {
4
4
  const [isModalOpen, setIsModalOpen] = useState(false)
5
- const [isPendingTransition, startTransition] = useTransition()
5
+ const isPendingTransition = false
6
6
 
7
- const openModal = () => {
8
- startTransition(() => {
9
- setIsModalOpen(true)
10
- })
11
- }
7
+ const openModal = () => setIsModalOpen(true)
12
8
 
13
- const closeModal = () => {
14
- startTransition(() => {
15
- setIsModalOpen(false)
16
- })
17
- }
9
+ const closeModal = () => setIsModalOpen(false)
18
10
 
19
11
  return { isModalOpen, isPendingTransition, openModal, closeModal }
20
12
  }
@@ -0,0 +1,148 @@
1
+ import client from '../../../../../api/client'
2
+ import { resolveConditionalText } from '../../../../../functions'
3
+ import { TranslationTextSubTypeEnum, TranslationTextTypeEnum } from '../../../../../enums'
4
+ import { Button_FillerPortal } from '../../../../common/button'
5
+ import { useTranslation } from '../../../../common/custom-hooks'
6
+ import useGetCurrentBreakpoint from '../../../../common/custom-hooks/use-window-width.hook'
7
+ import { IElementBaseProps } from '..'
8
+ import { IFormContext } from '../../1-row'
9
+ import { Checkbox, Form, Image, Modal } from 'antd'
10
+ import { useCallback, useMemo, useState } from 'react'
11
+
12
+ export default function GalleryDeleteModal({
13
+ isOpen,
14
+ formContext,
15
+ formItem,
16
+ elementKey,
17
+ textConditions,
18
+ blobNames,
19
+ imageUrls,
20
+ onClose,
21
+ onSuccess,
22
+ }: {
23
+ isOpen: boolean
24
+ formContext: IFormContext
25
+ blobNames: string[]
26
+ imageUrls: string[]
27
+ onClose: () => void
28
+ onSuccess: (blobNames: string[]) => void
29
+ } & IElementBaseProps) {
30
+ const { formRef, formDataId, formId } = formContext
31
+ const { t } = useTranslation(formId)
32
+ const currentBreakpoint = useGetCurrentBreakpoint()
33
+ const [selectedBlobNames, setSelectedBlobNames] = useState<string[]>([])
34
+ const [isDeleting, setIsDeleting] = useState(false)
35
+ const allValues = Form.useWatch([], { form: formRef, preserve: true })
36
+
37
+ const label = useMemo(
38
+ () =>
39
+ resolveConditionalText({
40
+ elementKey,
41
+ textConditions,
42
+ t,
43
+ fieldValues: (allValues ?? {}) as Record<string, any>,
44
+ formDataId,
45
+ currentBreakpoint,
46
+ type: TranslationTextTypeEnum.Label,
47
+ }),
48
+ [allValues, currentBreakpoint, elementKey, formDataId, t, textConditions],
49
+ )
50
+
51
+ const [title, discardText, deleteText] = useMemo(
52
+ () =>
53
+ t([
54
+ { key: elementKey, type: TranslationTextTypeEnum.Label, subType: TranslationTextSubTypeEnum.DeleteModalTitle },
55
+ { key: elementKey, type: TranslationTextTypeEnum.Label, subType: TranslationTextSubTypeEnum.DeleteModalDiscard },
56
+ { key: elementKey, type: TranslationTextTypeEnum.Label, subType: TranslationTextSubTypeEnum.DeleteModalDelete },
57
+ ]),
58
+ [elementKey, t],
59
+ )
60
+
61
+ const toggleBlobName = useCallback(
62
+ (blobName: string) =>
63
+ setSelectedBlobNames((prev) =>
64
+ prev.includes(blobName) ? prev.filter((selectedBlobName) => selectedBlobName !== blobName) : [...prev, blobName],
65
+ ),
66
+ [],
67
+ )
68
+
69
+ const closeModal = useCallback(() => {
70
+ setSelectedBlobNames([])
71
+ onClose()
72
+ }, [onClose])
73
+
74
+ const deletePictures = useCallback(async () => {
75
+ if (!formId || !formDataId || !selectedBlobNames.length) return
76
+
77
+ setIsDeleting(true)
78
+ try {
79
+ await Promise.all(selectedBlobNames.map((blobName) => client.delete(`/api/attachment/${blobName}`)))
80
+ const formDataRes = await client.get(`/api/formdata/${formId}/${formDataId}`)
81
+ const parsedData = JSON.parse(formDataRes.data.data)
82
+ const fieldPath = Array.isArray(formItem.path) ? formItem.path : [formItem.path]
83
+ const existingBlobNames = fieldPath.reduce((current: any, key) => current?.[key], parsedData)
84
+ const remainingBlobNames = (Array.isArray(existingBlobNames) ? existingBlobNames : []).filter(
85
+ (blobName) => !selectedBlobNames.includes(blobName),
86
+ )
87
+ let current = parsedData
88
+ fieldPath.slice(0, -1).forEach((key) => {
89
+ current = current[key]
90
+ })
91
+ current[fieldPath[fieldPath.length - 1]] = remainingBlobNames
92
+ const res = await client.put(`/api/formdata/${formId}/${formDataId}`, {
93
+ name: formDataRes.data.name,
94
+ version: formDataRes.data.version,
95
+ private: formDataRes.data.private,
96
+ data: JSON.stringify(parsedData),
97
+ })
98
+ if (res.status === 200) {
99
+ setSelectedBlobNames([])
100
+ onSuccess(remainingBlobNames)
101
+ }
102
+ } finally {
103
+ setIsDeleting(false)
104
+ }
105
+ }, [formDataId, formId, formItem.path, onSuccess, selectedBlobNames])
106
+
107
+ return (
108
+ <Modal
109
+ open={isOpen}
110
+ title={title || label || 'Delete pictures'}
111
+ onCancel={closeModal}
112
+ footer={
113
+ <div className="flex justify-between">
114
+ <Button_FillerPortal outline onClick={closeModal}>
115
+ {discardText || 'Discard'}
116
+ </Button_FillerPortal>
117
+ <Button_FillerPortal primary loading={isDeleting} onClick={deletePictures}>
118
+ {deleteText || 'Delete Selected'}
119
+ </Button_FillerPortal>
120
+ </div>
121
+ }
122
+ width={800}
123
+ >
124
+ <div className="grid grid-cols-4 gap-2">
125
+ {imageUrls.map((src, idx) => (
126
+ <button
127
+ key={src}
128
+ type="button"
129
+ className={`overflow-hidden rounded-md border bg-gray-200 p-2 aspect-square relative ${selectedBlobNames.includes(blobNames[idx]) ? 'border-primary' : 'border-gray-300'}`}
130
+ onClick={() => toggleBlobName(blobNames[idx])}
131
+ >
132
+ <span className="absolute top-2 right-2 z-[1]">
133
+ <Checkbox checked={selectedBlobNames.includes(blobNames[idx])} />
134
+ </span>
135
+ <Image
136
+ src={src}
137
+ alt={`gallery_delete_${idx}`}
138
+ preview={false}
139
+ className="w-full h-full"
140
+ style={{ width: '100%', height: '100%', objectFit: 'contain' }}
141
+ wrapperStyle={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
142
+ />
143
+ </button>
144
+ ))}
145
+ </div>
146
+ </Modal>
147
+ )
148
+ }
@@ -0,0 +1,264 @@
1
+ import { Empty, Form, Image } from 'antd'
2
+ import { memo, useMemo, useState } from 'react'
3
+ import { FaPlus, FaTrashAlt } from 'react-icons/fa'
4
+ import { FaUpload } from 'react-icons/fa6'
5
+ import { isNewFormDataPage, mapToFormItemRules, resolveConditionalText } from '../../../../../functions'
6
+ import { DeviceBreakpointEnum, FormElementConditionalKeyEnum, TranslationTextSubTypeEnum, TranslationTextTypeEnum } from '../../../../../enums'
7
+ import { IGalleryElementProps, IValidationRule } from '../../../../../types'
8
+ import { Button_FillerPortal } from '../../../../common/button'
9
+ import { useTranslation } from '../../../../common/custom-hooks'
10
+ import { isValidationsMet } from '../../../../common/custom-hooks/use-node-condition.hook/visibility-utils'
11
+ import { useFormPreservedItemValues } from '../../../../common/custom-hooks/use-preserved-form-items.hook'
12
+ import useGetCurrentBreakpoint from '../../../../common/custom-hooks/use-window-width.hook'
13
+ import { IElementBaseProps } from '..'
14
+ import { IFormContext } from '../../1-row'
15
+ import GalleryDeleteModal from './delete.modal'
16
+ import GalleryUploadModal from './upload.modal'
17
+ import GalleryPreviewModal from './preview.modal'
18
+
19
+ function LayoutRenderer_Gallery({
20
+ formContext,
21
+ formItem,
22
+ elementProps,
23
+ isDisabled,
24
+ elementKey,
25
+ textConditions,
26
+ validations,
27
+ }: {
28
+ formContext: IFormContext
29
+ elementProps: IGalleryElementProps
30
+ validations?: IValidationRule[]
31
+ } & IElementBaseProps) {
32
+ const { formRef, formDataId, companyKey, formId } = formContext
33
+ const { t } = useTranslation(formId)
34
+ const currentBreakpoint = useGetCurrentBreakpoint()
35
+ const { baseServerUrl } = useFormPreservedItemValues(formRef)
36
+ const [isUploadModalOpen, setIsUploadModalOpen] = useState(false)
37
+ const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false)
38
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
39
+ const allValues = Form.useWatch([], { form: formRef, preserve: true })
40
+ const savedBlobNames = ((Form.useWatch(formItem.path, { form: formRef, preserve: true }) as string[]) ??
41
+ []) as string[]
42
+ const label = useMemo(
43
+ () =>
44
+ resolveConditionalText({
45
+ elementKey,
46
+ textConditions,
47
+ t,
48
+ fieldValues: (allValues ?? {}) as Record<string, any>,
49
+ formDataId,
50
+ currentBreakpoint,
51
+ type: TranslationTextTypeEnum.Label,
52
+ }),
53
+ [allValues, currentBreakpoint, elementKey, formDataId, t, textConditions],
54
+ )
55
+
56
+ const placeholder = useMemo(
57
+ () =>
58
+ resolveConditionalText({
59
+ elementKey,
60
+ textConditions,
61
+ t,
62
+ fieldValues: (allValues ?? {}) as Record<string, any>,
63
+ formDataId,
64
+ currentBreakpoint,
65
+ type: TranslationTextTypeEnum.Placeholder,
66
+ }),
67
+ [allValues, currentBreakpoint, elementKey, formDataId, t, textConditions],
68
+ )
69
+ const [emptyDescription = 'No pictures uploaded'] = useMemo(
70
+ () =>
71
+ t([
72
+ { key: elementKey, type: TranslationTextTypeEnum.Description, subType: TranslationTextSubTypeEnum.EmptyState },
73
+ ]),
74
+ [elementKey, t],
75
+ )
76
+
77
+ const validationRules = useMemo(() => {
78
+ const baseRules = mapToFormItemRules(elementKey, validations ?? [], formId, {
79
+ fieldValues: (allValues ?? {}) as Record<string, any>,
80
+ formDataId,
81
+ currentBreakpoint,
82
+ })
83
+ const requiredRule = baseRules.find((rule: any) => 'required' in rule && rule.required)
84
+ if (!requiredRule) return baseRules
85
+
86
+ return [
87
+ ...baseRules.filter((rule: any) => !('required' in rule && rule.required)),
88
+ {
89
+ validator(_: unknown, value: unknown) {
90
+ return Array.isArray(value) && value.length > 0
91
+ ? Promise.resolve()
92
+ : Promise.reject(new Error(requiredRule.message as string))
93
+ },
94
+ },
95
+ ]
96
+ }, [allValues, currentBreakpoint, elementKey, formDataId, formId, validations])
97
+
98
+ const { imageCountPerRow, imageUrls, visibleImageUrls, overflowCount } = useMemo(() => {
99
+ const gridConfig = elementProps.grid?.[currentBreakpoint] ?? elementProps.grid?.[DeviceBreakpointEnum.Default]
100
+ const imageCountPerRow = gridConfig?.count ?? 8
101
+ const visibleLimit = gridConfig?.row ? imageCountPerRow * gridConfig.row : savedBlobNames.length
102
+ const hasOverflow = gridConfig?.row != null && savedBlobNames.length > visibleLimit
103
+ const visibleBlobNames = hasOverflow ? savedBlobNames.slice(0, Math.max(visibleLimit - 1, 0)) : savedBlobNames
104
+ return {
105
+ imageCountPerRow,
106
+ imageUrls: savedBlobNames.map((blobName) => `${baseServerUrl}/api/attachment/${companyKey}/${blobName}`),
107
+ visibleImageUrls: visibleBlobNames.map((blobName) => `${baseServerUrl}/api/attachment/${companyKey}/${blobName}`),
108
+ overflowCount: hasOverflow ? savedBlobNames.length - visibleBlobNames.length : 0,
109
+ }
110
+ }, [baseServerUrl, companyKey, currentBreakpoint, elementProps.grid, savedBlobNames])
111
+ const isUploadButtonHidden = !isValidationsMet(
112
+ FormElementConditionalKeyEnum.ShowIf,
113
+ (allValues ?? {}) as { [key: string]: any },
114
+ elementProps.uploadButtonConditions,
115
+ { currentBreakpoint, formDataId },
116
+ )
117
+ const isUploadButtonDisabled = !isValidationsMet(
118
+ FormElementConditionalKeyEnum.EnableIf,
119
+ (allValues ?? {}) as { [key: string]: any },
120
+ elementProps.uploadButtonConditions,
121
+ { currentBreakpoint, formDataId },
122
+ )
123
+ const isDeleteButtonHidden = !isValidationsMet(
124
+ FormElementConditionalKeyEnum.ShowIf,
125
+ (allValues ?? {}) as { [key: string]: any },
126
+ elementProps.deleteButtonConditions,
127
+ { currentBreakpoint, formDataId },
128
+ )
129
+ const isDeleteButtonDisabled = !isValidationsMet(
130
+ FormElementConditionalKeyEnum.EnableIf,
131
+ (allValues ?? {}) as { [key: string]: any },
132
+ elementProps.deleteButtonConditions,
133
+ { currentBreakpoint, formDataId },
134
+ )
135
+
136
+ return (
137
+ <>
138
+ <Form.Item
139
+ name={formItem.name}
140
+ label={
141
+ <div className="w-full flex items-center justify-between gap-2">
142
+ {!elementProps.hasNoLabel && label ? (
143
+ <span>
144
+ {label} ({imageUrls.length})
145
+ </span>
146
+ ) : (
147
+ <span />
148
+ )}
149
+ <div className="flex items-center">
150
+ {!isDeleteButtonHidden && (
151
+ <Button_FillerPortal
152
+ link
153
+ short
154
+ disabled={isDisabled || isNewFormDataPage(formDataId) || isDeleteButtonDisabled}
155
+ onClick={() => setIsDeleteModalOpen(true)}
156
+ className="text-danger underline hover:text-danger"
157
+ >
158
+ <FaTrashAlt />
159
+ Delete
160
+ </Button_FillerPortal>
161
+ )}
162
+ {!isUploadButtonHidden && (
163
+ <Button_FillerPortal
164
+ link
165
+ short
166
+ disabled={isDisabled || isNewFormDataPage(formDataId) || isUploadButtonDisabled}
167
+ onClick={() => setIsUploadModalOpen(true)}
168
+ >
169
+ <FaUpload />
170
+ {placeholder || 'Upload pictures'}
171
+ </Button_FillerPortal>
172
+ )}
173
+ </div>
174
+ </div>
175
+ }
176
+ labelAlign="left"
177
+ rules={validationRules}
178
+ >
179
+ {imageUrls.length ? (
180
+ <Image.PreviewGroup items={imageUrls}>
181
+ <div
182
+ className="grid gap-2"
183
+ style={{ gridTemplateColumns: `repeat(${imageCountPerRow}, minmax(0px, 1fr))` }}
184
+ >
185
+ {visibleImageUrls.map((src, idx) => (
186
+ <div
187
+ key={src}
188
+ className="overflow-hidden rounded-lg border border-gray-300 aspect-square p-2 bg-gray-200"
189
+ >
190
+ <Image
191
+ src={src}
192
+ alt={`${elementKey}_${idx}`}
193
+ className="rounded-md object-contain flex items-center justify-center"
194
+ style={{ width: '100%', height: '100%', objectFit: 'contain' }}
195
+ wrapperStyle={{
196
+ width: '100%',
197
+ height: '100%',
198
+ display: 'flex',
199
+ alignItems: 'center',
200
+ justifyContent: 'center',
201
+ }}
202
+ />
203
+ </div>
204
+ ))}
205
+ {overflowCount > 0 && (
206
+ <button
207
+ type="button"
208
+ className="overflow-hidden rounded-md border border-gray-300 aspect-square text-primary text-[24px] font-bold bg-gray-200 flex items-center justify-center gap-2 hover:bg-gray-300"
209
+ onClick={() => setIsPreviewModalOpen(true)}
210
+ >
211
+ <FaPlus size={18} />
212
+ {overflowCount}
213
+ </button>
214
+ )}
215
+ </div>
216
+ </Image.PreviewGroup>
217
+ ) : (
218
+ <Empty description={emptyDescription || undefined} />
219
+ )}
220
+ </Form.Item>
221
+ <GalleryUploadModal
222
+ isOpen={isUploadModalOpen}
223
+ formContext={formContext}
224
+ formItem={formItem}
225
+ elementKey={elementKey}
226
+ textConditions={textConditions}
227
+ isDisabled={isDisabled}
228
+ onDiscardSuccess={() => setIsUploadModalOpen(false)}
229
+ onSaveSuccess={(uploadedBlobNames) => {
230
+ formRef?.setFieldValue(formItem.path, [
231
+ ...((Array.isArray(formRef?.getFieldValue(formItem.path))
232
+ ? formRef?.getFieldValue(formItem.path)
233
+ : []) as string[]),
234
+ ...uploadedBlobNames,
235
+ ])
236
+ setIsUploadModalOpen(false)
237
+ }}
238
+ />
239
+ <GalleryPreviewModal
240
+ title={label}
241
+ isOpen={isPreviewModalOpen}
242
+ imageUrls={imageUrls}
243
+ onClose={() => setIsPreviewModalOpen(false)}
244
+ />
245
+ <GalleryDeleteModal
246
+ isOpen={isDeleteModalOpen}
247
+ formContext={formContext}
248
+ formItem={formItem}
249
+ elementKey={elementKey}
250
+ textConditions={textConditions}
251
+ isDisabled={isDisabled}
252
+ blobNames={savedBlobNames}
253
+ imageUrls={imageUrls}
254
+ onClose={() => setIsDeleteModalOpen(false)}
255
+ onSuccess={(remainingBlobNames) => {
256
+ formRef?.setFieldValue(formItem.path, remainingBlobNames)
257
+ setIsDeleteModalOpen(false)
258
+ }}
259
+ />
260
+ </>
261
+ )
262
+ }
263
+
264
+ export default memo(LayoutRenderer_Gallery)
@@ -0,0 +1,33 @@
1
+ import { Image, Modal } from 'antd'
2
+
3
+ export default function GalleryPreviewModal({
4
+ title,
5
+ isOpen,
6
+ imageUrls,
7
+ onClose,
8
+ }: {
9
+ title?: string
10
+ isOpen: boolean
11
+ imageUrls: string[]
12
+ onClose: () => void
13
+ }) {
14
+ return (
15
+ <Modal open={isOpen} onCancel={onClose} footer={null} title={title} width={800}>
16
+ <Image.PreviewGroup items={imageUrls}>
17
+ <div className="grid grid-cols-4 gap-2">
18
+ {imageUrls.map((src, idx) => (
19
+ <div key={src} className="overflow-hidden rounded-md border border-gray-300 bg-gray-200 p-2 aspect-square">
20
+ <Image
21
+ src={src}
22
+ alt={`gallery_${idx}`}
23
+ className="w-full h-full"
24
+ style={{ width: '100%', height: '100%', objectFit: 'contain' }}
25
+ wrapperStyle={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
26
+ />
27
+ </div>
28
+ ))}
29
+ </div>
30
+ </Image.PreviewGroup>
31
+ </Modal>
32
+ )
33
+ }
@@ -0,0 +1,185 @@
1
+ import client from '../../../../../api/client'
2
+ import { isNewFormDataPage, resolveConditionalText } from '../../../../../functions'
3
+ import { saveFile } from '../../../../../functions/forms/form'
4
+ import { TranslationTextSubTypeEnum, TranslationTextTypeEnum } from '../../../../../enums'
5
+ import { RcFile, UploadFile, UploadProps } from 'antd/es/upload'
6
+ import { Form, Modal, Spin, Upload } from 'antd'
7
+ import { useCallback, useMemo, useState } from 'react'
8
+ import { FaUpload } from 'react-icons/fa6'
9
+ import { Button_FillerPortal } from '../../../../common/button'
10
+ import { useNotification, useTranslation } from '../../../../common/custom-hooks'
11
+ import useGetCurrentBreakpoint from '../../../../common/custom-hooks/use-window-width.hook'
12
+ import { IElementBaseProps } from '..'
13
+ import { IFormContext } from '../../1-row'
14
+
15
+ export default function GalleryUploadModal({
16
+ isOpen,
17
+ formContext,
18
+ formItem,
19
+ elementKey,
20
+ textConditions,
21
+ onDiscardSuccess,
22
+ onSaveSuccess,
23
+ }: {
24
+ isOpen: boolean
25
+ formContext: IFormContext
26
+ onDiscardSuccess: () => void
27
+ onSaveSuccess: (blobNames: string[]) => void
28
+ } & IElementBaseProps) {
29
+ const { formRef, formDataId, companyKey, formId } = formContext
30
+ const { t } = useTranslation(formId)
31
+ const currentBreakpoint = useGetCurrentBreakpoint()
32
+ const { warningModal } = useNotification()
33
+
34
+ const [uploadedFiles, setUploadedFiles] = useState<UploadFile[]>([])
35
+ const [isUploadLoading, setIsUploadLoading] = useState(false)
36
+ const [isSaving, setIsSaving] = useState(false)
37
+ const [isDiscarding, setIsDiscarding] = useState(false)
38
+
39
+ const allValues = Form.useWatch([], { form: formRef, preserve: true })
40
+
41
+ const label = useMemo(
42
+ () =>
43
+ resolveConditionalText({
44
+ elementKey,
45
+ textConditions,
46
+ t,
47
+ fieldValues: (allValues ?? {}) as Record<string, any>,
48
+ formDataId,
49
+ currentBreakpoint,
50
+ type: TranslationTextTypeEnum.Label,
51
+ }),
52
+ [allValues, currentBreakpoint, elementKey, formDataId, t, textConditions],
53
+ )
54
+
55
+ const placeholder = useMemo(
56
+ () =>
57
+ resolveConditionalText({
58
+ elementKey,
59
+ textConditions,
60
+ t,
61
+ fieldValues: (allValues ?? {}) as Record<string, any>,
62
+ formDataId,
63
+ currentBreakpoint,
64
+ type: TranslationTextTypeEnum.Placeholder,
65
+ }),
66
+ [allValues, currentBreakpoint, elementKey, formDataId, t, textConditions],
67
+ )
68
+
69
+ const [title = label, text = placeholder, discardText, saveText] = useMemo(
70
+ () =>
71
+ t([
72
+ { key: elementKey, type: TranslationTextTypeEnum.Label, subType: TranslationTextSubTypeEnum.UploadModalTitle },
73
+ { key: elementKey, type: TranslationTextTypeEnum.Placeholder, subType: TranslationTextSubTypeEnum.Secondary },
74
+ { key: elementKey, type: TranslationTextTypeEnum.Label, subType: TranslationTextSubTypeEnum.Discard },
75
+ { key: elementKey, type: TranslationTextTypeEnum.Label, subType: TranslationTextSubTypeEnum.UploadSave },
76
+ ]),
77
+ [elementKey, label, placeholder, t],
78
+ )
79
+
80
+ const uploadedBlobNames = useMemo(
81
+ () =>
82
+ uploadedFiles
83
+ .map((file) =>
84
+ typeof file.response === 'object' && file.response ? (file.response as { blobName?: string }).blobName : '',
85
+ )
86
+ .filter((blobName): blobName is string => !!blobName),
87
+ [uploadedFiles],
88
+ )
89
+
90
+ const uploadProps: UploadProps = {
91
+ multiple: true,
92
+ accept: 'image/*',
93
+ onChange: ({ fileList }) => setUploadedFiles(fileList),
94
+ showUploadList: { showRemoveIcon: false },
95
+ beforeUpload: (file) => {
96
+ if (!file.type.startsWith('image/')) {
97
+ warningModal({ title: '', content: 'Invalid file type!', okText: 'Okay' })
98
+ return false
99
+ }
100
+ return true
101
+ },
102
+ customRequest: async ({ file, onSuccess, onError }) => {
103
+ setIsUploadLoading(true)
104
+ const blobName = await saveFile(file as RcFile, (file as RcFile).name, companyKey)
105
+ setIsUploadLoading(false)
106
+ if (blobName) onSuccess?.({ blobName }, file as RcFile)
107
+ else onError?.(new Error('Upload failed'))
108
+ },
109
+ }
110
+
111
+ const discardUploadedFiles = useCallback(async () => {
112
+ setIsDiscarding(true)
113
+ try {
114
+ await Promise.all(uploadedBlobNames.map((blobName) => client.delete(`/api/attachment/${blobName}`)))
115
+ onDiscardSuccess()
116
+ } finally {
117
+ setIsDiscarding(false)
118
+ }
119
+ }, [onDiscardSuccess, uploadedBlobNames])
120
+
121
+ const saveUploadedFiles = useCallback(async () => {
122
+ if (!formId || !formDataId || isNewFormDataPage(formDataId)) return
123
+
124
+ setIsSaving(true)
125
+ try {
126
+ const formDataRes = await client.get(`/api/formdata/${formId}/${formDataId}`)
127
+ const parsedData = JSON.parse(formDataRes.data.data)
128
+ const fieldPath = Array.isArray(formItem.path) ? formItem.path : [formItem.path]
129
+ const existingBlobNames = fieldPath.reduce((current: any, key) => current?.[key], parsedData)
130
+ let current = parsedData
131
+ fieldPath.slice(0, -1).forEach((key) => {
132
+ current = current[key]
133
+ })
134
+ current[fieldPath[fieldPath.length - 1]] = [
135
+ ...(Array.isArray(existingBlobNames) ? existingBlobNames : []),
136
+ ...uploadedBlobNames,
137
+ ]
138
+ const res = await client.put(`/api/formdata/${formId}/${formDataId}`, {
139
+ name: formDataRes.data.name,
140
+ version: formDataRes.data.version,
141
+ private: formDataRes.data.private,
142
+ data: JSON.stringify(parsedData),
143
+ })
144
+ if (res.status === 200) onSaveSuccess(uploadedBlobNames)
145
+ } finally {
146
+ setIsSaving(false)
147
+ }
148
+ }, [formDataId, formId, formItem.path, onSaveSuccess, uploadedBlobNames])
149
+
150
+ return (
151
+ <Modal
152
+ open={isOpen}
153
+ closable={false}
154
+ maskClosable={false}
155
+ onCancel={() => {}}
156
+ title={title || 'Upload pictures'}
157
+ footer={
158
+ <div className="flex justify-between">
159
+ <Button_FillerPortal outline disabled={isUploadLoading} loading={isDiscarding} onClick={discardUploadedFiles}>
160
+ {discardText || 'Discard & Close'}
161
+ </Button_FillerPortal>
162
+ <Button_FillerPortal
163
+ primary
164
+ disabled={isUploadLoading || isDiscarding}
165
+ loading={isSaving}
166
+ onClick={saveUploadedFiles}
167
+ >
168
+ {saveText || 'Save Pictures'}
169
+ </Button_FillerPortal>
170
+ </div>
171
+ }
172
+ >
173
+ <Spin spinning={isUploadLoading}>
174
+ <Upload.Dragger {...uploadProps} fileList={uploadedFiles}>
175
+ <div className="flex flex-col items-center">
176
+ <FaUpload size={24} className="text-primary" />
177
+ <span className="font-semibold mt-2 text-secondary">
178
+ {text || 'Click or drag image files to this area'}
179
+ </span>
180
+ </div>
181
+ </Upload.Dragger>
182
+ </Spin>
183
+ </Modal>
184
+ )
185
+ }
@@ -1,20 +1,20 @@
1
1
  import { Form, Input, Spin, Upload } from 'antd'
2
2
  import { memo, useCallback, useMemo, useState } from 'react'
3
- import { Button_FillerPortal } from '../../../common/button'
4
- import client from '../../../../api/client'
5
- import { FaEye, FaFileCircleCheck, FaUpload } from 'react-icons/fa6'
6
- import { IFileUploadElementProps, IFileUploadElementRules } from '../../../../types'
7
- import { useNotification } from '../../../common/custom-hooks'
8
3
  import { FaTrashAlt } from 'react-icons/fa'
9
- import { saveFile } from '../../../../functions/forms/form'
10
- import { IElementBaseProps } from '.'
11
- import { IFormContext } from '../1-row'
4
+ import { FaEye, FaFileCircleCheck, FaUpload } from 'react-icons/fa6'
5
+ import client from '../../../../api/client'
12
6
  import { ELEMENTS_DEFAULT_CLASS } from '../../../../constants'
7
+ import { resolveConditionalText } from '../../../../functions'
8
+ import { saveFile } from '../../../../functions/forms/form'
13
9
  import { TranslationTextSubTypeEnum, TranslationTextTypeEnum } from '../../../../enums'
10
+ import { IFileUploadElementProps, IFileUploadElementRules } from '../../../../types'
11
+ import { Button_FillerPortal } from '../../../common/button'
12
+ import { useNotification } from '../../../common/custom-hooks'
14
13
  import { useTranslation } from '../../../common/custom-hooks/use-translation.hook/hook'
15
14
  import { translationStore } from '../../../common/custom-hooks/use-translation.hook/store'
16
15
  import useGetCurrentBreakpoint from '../../../common/custom-hooks/use-window-width.hook'
17
- import { resolveConditionalText } from '../../../../functions'
16
+ import { IElementBaseProps } from '.'
17
+ import { IFormContext } from '../1-row'
18
18
 
19
19
  const { VITE_API_BASE_URL } = import.meta.env
20
20
 
@@ -260,4 +260,3 @@ const getUploadRuleErrorMsg = (
260
260
 
261
261
  return null
262
262
  }
263
-
@@ -33,6 +33,7 @@ const layoutConfigCache = new Map<string, LayoutCacheEntry>()
33
33
  function LayoutRenderer_LoadFormData({ elementKey, formContext, elementProps }: ILayoutRenderer_LoadFormData) {
34
34
  const { formId, formRef, formDataId } = formContext
35
35
  const { joins, baseFormId, targetFormId, idMatch, m2mRelFormId, displayConfig, filter: initialFilter } = elementProps
36
+ const savedDisplayConfig = displayConfig?.config
36
37
  const defaultHeaderFilter = useMemo(() => {
37
38
  if (!initialFilter || initialFilter.type !== FilterConfigTypeEnum.Custom) return ''
38
39
  return JSON.stringify(initialFilter.config).replaceAll('@', '$')
@@ -67,7 +68,7 @@ function LayoutRenderer_LoadFormData({ elementKey, formContext, elementProps }:
67
68
  )
68
69
  const [loading, setLoading] = useState(() => !cachedDataEntry)
69
70
  const [checkingForUpdates, setCheckingForUpdates] = useState(false)
70
- const { cachedConfig, isConfigLoading } = useCacheFormLayoutConfig(targetFormId)
71
+ const { cachedConfig, isConfigLoading } = useCacheFormLayoutConfig(savedDisplayConfig ? undefined : targetFormId)
71
72
  const [hasInitialData, setHasInitialData] = useState(() => Boolean(cachedDataEntry))
72
73
  const lastChildInfo = useRef<{ formName: string; parentFormJoins: IFormJoin[] }>({
73
74
  formName: '',
@@ -211,6 +212,10 @@ function LayoutRenderer_LoadFormData({ elementKey, formContext, elementProps }:
211
212
 
212
213
  useEffect(() => {
213
214
  if (!layoutCacheKey || !cachedLayoutEntry) return
215
+ if (savedDisplayConfig) {
216
+ setListLayoutConfig((curr) => curr ?? cachedLayoutEntry.layout)
217
+ return
218
+ }
214
219
  if (cachedLayoutPrimedRef.current) {
215
220
  triggerInitialFetch()
216
221
  return
@@ -222,7 +227,65 @@ function LayoutRenderer_LoadFormData({ elementKey, formContext, elementProps }:
222
227
  m2mForeignKeysRef.current = cachedLayoutEntry.m2m
223
228
  setListLayoutConfig((curr) => curr ?? cachedLayoutEntry.layout)
224
229
  triggerInitialFetch()
225
- }, [cachedLayoutEntry, layoutCacheKey, triggerInitialFetch])
230
+ }, [cachedLayoutEntry, layoutCacheKey, savedDisplayConfig, triggerInitialFetch])
231
+
232
+ useEffect(() => {
233
+ if (!targetFormId || !displayConfig?.translations) return
234
+ translationStore.setTranslations(targetFormId, displayConfig.translations)
235
+ }, [displayConfig?.translations, targetFormId])
236
+
237
+ useEffect(() => {
238
+ if (isNewFormDataPage(formDataId) || !savedDisplayConfig || !targetFormId) return
239
+
240
+ lastChildInfo.current = { formName: displayConfig?.formName ?? '', parentFormJoins: joins }
241
+ formJoinsRef.current = mergeJoins([
242
+ ...savedDisplayConfig.columns.map((c) => c.joins || []),
243
+ ...Object.values(savedDisplayConfig.header.elements).reduce((c: IFormJoin[], el) => {
244
+ if (el.props && 'filter' in el.props && Array.isArray(el.props.filter?.joins)) {
245
+ return [...c, el.props.filter.joins]
246
+ }
247
+ return c
248
+ }, []),
249
+ ])
250
+ dataProjectRef.current = savedDisplayConfig.columns.reduce((curr, c) => {
251
+ if (!c.key) return curr
252
+
253
+ return { ...curr, [getProjectionKey(c.key)]: `$${c.key}` }
254
+ }, {})
255
+ m2mForeignKeysRef.current =
256
+ displayConfig?.m2mCurrentFormFK && displayConfig?.m2mOtherFormFK
257
+ ? { currentForm: displayConfig.m2mCurrentFormFK, otherForm: displayConfig.m2mOtherFormFK }
258
+ : undefined
259
+
260
+ const nextLayoutConfig = {
261
+ configForFormId: targetFormId,
262
+ dataListConfig: {
263
+ ...savedDisplayConfig,
264
+ columns: savedDisplayConfig.columns.map((c) => (c.key ? { ...c, key: getProjectionKey(c.key) } : c)),
265
+ },
266
+ }
267
+ setListLayoutConfig(nextLayoutConfig)
268
+ if (layoutCacheKey)
269
+ layoutConfigCache.set(layoutCacheKey, {
270
+ layout: nextLayoutConfig,
271
+ joins: formJoinsRef.current,
272
+ project: { ...dataProjectRef.current },
273
+ lastChildInfo: lastChildInfo.current,
274
+ m2m: m2mForeignKeysRef.current,
275
+ })
276
+ cachedLayoutPrimedRef.current = true
277
+ triggerInitialFetch()
278
+ }, [
279
+ displayConfig?.formName,
280
+ displayConfig?.m2mCurrentFormFK,
281
+ displayConfig?.m2mOtherFormFK,
282
+ formDataId,
283
+ joins,
284
+ layoutCacheKey,
285
+ savedDisplayConfig,
286
+ targetFormId,
287
+ triggerInitialFetch,
288
+ ])
226
289
 
227
290
  useEffect(() => {
228
291
  if (cachedConfig?.id && cachedConfig.translations)
@@ -230,7 +293,7 @@ function LayoutRenderer_LoadFormData({ elementKey, formContext, elementProps }:
230
293
  }, [cachedConfig])
231
294
 
232
295
  useEffect(() => {
233
- if (isNewFormDataPage(formDataId) || !cachedConfig) return
296
+ if (savedDisplayConfig || isNewFormDataPage(formDataId) || !cachedConfig) return
234
297
 
235
298
  const { relationships = [] } = cachedConfig
236
299
 
@@ -293,10 +356,10 @@ function LayoutRenderer_LoadFormData({ elementKey, formContext, elementProps }:
293
356
  cachedConfig,
294
357
  displayConfig?.id,
295
358
  formDataId,
296
- triggerInitialFetch,
297
- baseFormId,
298
- targetFormId,
299
359
  layoutCacheKey,
360
+ savedDisplayConfig,
361
+ targetFormId,
362
+ triggerInitialFetch,
300
363
  ])
301
364
 
302
365
  useEffect(
@@ -60,6 +60,7 @@ const LayoutRenderer_AutoComplete = lazy(() => import('./14-auto-complete'))
60
60
  const LayoutRenderer_FormSubmissionPdf = lazy(() => import('./15-form-submission-pdf'))
61
61
  const LayoutRenderer_UserRoles = lazy(() => import('./16-user-role'))
62
62
  const LayoutRenderer_CustomComponent = lazy(() => import('./17-custom-component'))
63
+ const LayoutRenderer_Gallery = lazy(() => import('./18-gallery'))
63
64
 
64
65
  type ILayoutRendererElement = {
65
66
  basePath: (string | number)[]
@@ -306,6 +307,18 @@ function LayoutRendererElement({
306
307
  )
307
308
  },
308
309
 
310
+ [ElementTypeEnum.Gallery]: () => {
311
+ if (!key) return <span className="text-warning">Gallery field could not render due to missing KEY!</span>
312
+ return (
313
+ <LayoutRenderer_Gallery
314
+ elementProps={props}
315
+ formContext={formContext}
316
+ validations={validationsAfterIsHidden}
317
+ {...elementBaseProps}
318
+ />
319
+ )
320
+ },
321
+
309
322
  [ElementTypeEnum.ReadFieldData]: () => {
310
323
  let style = (elementData as IReadFieldDataElement).style?.[currentBreakpoint]
311
324
  if (!style) style = (elementData as IReadFieldDataElement).style?.[DeviceBreakpointEnum.Default]
@@ -17,6 +17,7 @@ export enum ElementTypeEnum {
17
17
  RangePicker = 'RangePicker',
18
18
  TimePicker = 'TimePicker',
19
19
  FileUpload = 'FileUpload',
20
+ Gallery = 'Gallery',
20
21
  Switch = 'Switch',
21
22
  Container = 'Container',
22
23
  Placeholder = 'Placeholder',
@@ -134,7 +135,13 @@ export enum TranslationTextSubTypeEnum {
134
135
  RepeatButtonAdd = 'RepeatAdd',
135
136
  RepeatButtonRemove = 'RepeatRemove',
136
137
  Secondary = '2',
138
+ EmptyState = 'EmptyState',
139
+ DeleteModalTitle = 'DeleteModalTitle',
140
+ DeleteModalDiscard = 'DeleteModalDiscard',
141
+ DeleteModalDelete = 'DeleteModalDelete',
137
142
  UploadFileType = 'FileType',
143
+ UploadModalTitle = 'UploadModalTitle',
144
+ UploadSave = 'UploadSave',
138
145
  UploadSizeLimit = 'SizeLimit',
139
146
  ConfirmationTitle = 'ConfTitle',
140
147
  ConfirmationMsg = 'ConfMsg',
@@ -168,6 +175,6 @@ export enum SystemRolePermissionEnum {
168
175
  // PublishData = 6,
169
176
  // UnpublishData = 7,
170
177
  // ReadReport = 8,
171
- // CreateAttachment = 10,
172
- // DeleteAttachment = 11,
178
+ CreateAttachment = 10,
179
+ DeleteAttachment = 11,
173
180
  }
@@ -22,12 +22,15 @@ import {
22
22
  IDataFetchConfig,
23
23
  IEvaluationConfig,
24
24
  IFilterConfig,
25
+ IFormDataListConfig,
26
+ IFormTranslations,
25
27
  IReadFieldDataElementProps,
26
28
  IReportTableElementProps,
27
29
  } from '..'
28
30
  import {
29
31
  AlignTypeEnum,
30
32
  CountryEnum,
33
+ DeviceBreakpointEnum,
31
34
  ElementTypeEnum,
32
35
  FileTypeEnum,
33
36
  DndLayoutAddableNodeEnum,
@@ -179,6 +182,23 @@ export interface IFileUploadElementRules {
179
182
 
180
183
  /** -------------------------------------------------------------------------------------------- */
181
184
 
185
+ export interface IGalleryElement extends BaseFormLayoutElement {
186
+ elementType: ElementTypeEnum.Gallery
187
+ props: IGalleryElementProps
188
+ }
189
+ export interface IGalleryElementProps {
190
+ hasNoLabel?: boolean
191
+ grid?: Partial<Record<DeviceBreakpointEnum, IGalleryElementGridConfig | null>>
192
+ uploadButtonConditions?: IFormLayoutElementConditions
193
+ deleteButtonConditions?: IFormLayoutElementConditions
194
+ }
195
+ export interface IGalleryElementGridConfig {
196
+ count?: number | null
197
+ row?: number | null
198
+ }
199
+
200
+ /** -------------------------------------------------------------------------------------------- */
201
+
182
202
  export interface IBooleanElement extends BaseFormLayoutElement {
183
203
  elementType: ElementTypeEnum.Switch | ElementTypeEnum.Checkbox
184
204
  props: {
@@ -284,9 +304,14 @@ export interface IFormDataLoadElementProps extends IDataFetchConfig {
284
304
  filter?: IFilterConfig
285
305
  pageBreak?: ReportPageBreakEnum // used for report
286
306
  }
287
- interface IFormDataLoadDisplayConfig {
307
+ export interface IFormDataLoadDisplayConfig {
288
308
  id: string
289
309
  name: string
310
+ config?: IFormDataListConfig
311
+ formName?: string
312
+ translations?: IFormTranslations
313
+ m2mCurrentFormFK?: string
314
+ m2mOtherFormFK?: string
290
315
  }
291
316
 
292
317
  /** -------------------------------------------------------------------------------------------- */
@@ -12,6 +12,7 @@ import {
12
12
  IFormDataLoadElement,
13
13
  IFormDndLayoutRowHeader,
14
14
  IFormSubmissionPdfElement,
15
+ IGalleryElement,
15
16
  IGridContainerConfig,
16
17
  IImageElement,
17
18
  IInputElement,
@@ -77,6 +78,7 @@ export type IDndLayoutElement =
77
78
  | IReadFieldDataElement
78
79
  | IBooleanElement
79
80
  | IFileUploadElement
81
+ | IGalleryElement
80
82
  | IButtonElement
81
83
  | ISignatureElement
82
84
  | ITextElement