484 lines
17 KiB
TypeScript
484 lines
17 KiB
TypeScript
import { DX_CLASSNAMES, MULTIVALUE_DELIMITER } from '@/constants/app.constant'
|
||
import '@/utils/registerDevExtremeEditors'
|
||
import { executeEditorScript } from '@/utils/editorScriptRuntime'
|
||
import {
|
||
Form as FormDx,
|
||
type FormRef,
|
||
GroupItem as GroupItemDx,
|
||
SimpleItem as SimpleItemDx,
|
||
} from 'devextreme-react/form'
|
||
import type { FieldDataChangedEvent, GroupItem } from 'devextreme/ui/form'
|
||
import { Dispatch, ReactNode, RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||
import { GridBoxEditorComponent } from './editors/GridBoxEditorComponent'
|
||
import { ImageUploadEditorComponent } from './editors/ImageUploadEditorComponent'
|
||
import { ImageViewerEditorComponent } from './editors/ImageViewerEditorComponent'
|
||
import { TagBoxEditorComponent } from './editors/TagBoxEditorComponent'
|
||
import { RowMode, SimpleItemWithColData } from './types'
|
||
import { PlatformEditorTypes } from '@/proxy/form/models'
|
||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||
import {
|
||
getValueByField,
|
||
setFormEditorReadOnly,
|
||
shouldRunEditorScriptOnContentReady,
|
||
translateCaption,
|
||
} from '../list/shared/editingForm'
|
||
import { isTouchLikeDevice } from '../list/shared/editPopup'
|
||
import { flattenFormGroups, getCascadeParentFields } from './shared/formItems'
|
||
|
||
/** Kendi render'ını yapan platform editörleri. */
|
||
const CUSTOM_EDITOR_TYPES = [
|
||
PlatformEditorTypes.dxTagBox,
|
||
PlatformEditorTypes.dxGridBox,
|
||
PlatformEditorTypes.dxImageUpload,
|
||
PlatformEditorTypes.dxImageViewer,
|
||
] as const
|
||
|
||
const FormDevExpress = (props: {
|
||
listFormCode: string
|
||
isSubForm?: boolean
|
||
mode: RowMode
|
||
refForm: RefObject<FormRef | null>
|
||
formData: any
|
||
formItems: GroupItem[]
|
||
setFormData: Dispatch<any>
|
||
}) => {
|
||
const { listFormCode, isSubForm, mode, refForm, formData, formItems, setFormData } = props
|
||
const { translate } = useLocalization()
|
||
|
||
const formDataRef = useRef(formData)
|
||
const formItemsRef = useRef(formItems)
|
||
const formInstanceRef = useRef<any | undefined>(undefined)
|
||
const lastContentReadyScriptKeyRef = useRef<string | undefined>(undefined)
|
||
const didAutoFocusRef = useRef(false)
|
||
const [runtimeReadOnlyFields, setRuntimeReadOnlyFields] = useState<Record<string, boolean>>({})
|
||
const runtimeReadOnlyFieldsRef = useRef<Record<string, boolean>>({})
|
||
|
||
formDataRef.current = formData
|
||
formItemsRef.current = formItems
|
||
|
||
useEffect(() => {
|
||
runtimeReadOnlyFieldsRef.current = runtimeReadOnlyFields
|
||
}, [runtimeReadOnlyFields])
|
||
|
||
useEffect(() => {
|
||
didAutoFocusRef.current = false
|
||
}, [listFormCode, mode])
|
||
|
||
const getFieldKey = (formItem: SimpleItemWithColData) =>
|
||
String(formItem.dataField || formItem.name || '').toLowerCase()
|
||
|
||
const setRuntimeEditorReadOnly = (field: string, readOnly: boolean) => {
|
||
const key = String(field || '').toLowerCase()
|
||
if (!key || runtimeReadOnlyFieldsRef.current[key] === readOnly) {
|
||
return
|
||
}
|
||
|
||
runtimeReadOnlyFieldsRef.current = { ...runtimeReadOnlyFieldsRef.current, [key]: readOnly }
|
||
setRuntimeReadOnlyFields(runtimeReadOnlyFieldsRef.current)
|
||
}
|
||
|
||
const getRuntimeEditorReadOnly = (formItem: SimpleItemWithColData) => {
|
||
const key = getFieldKey(formItem)
|
||
return Object.prototype.hasOwnProperty.call(runtimeReadOnlyFields, key)
|
||
? runtimeReadOnlyFields[key]
|
||
: undefined
|
||
}
|
||
|
||
const applyEditorReadOnly = (form: any, field: string, readOnly: boolean) => {
|
||
setRuntimeEditorReadOnly(field, readOnly)
|
||
setFormEditorReadOnly(formInstanceRef.current ?? form, field, readOnly)
|
||
}
|
||
|
||
/** Editör script'i / özel editör değişikliklerini forma ve state'e yazar. */
|
||
const commitFormData = useCallback(
|
||
(patch: Record<string, any>, form?: any) => {
|
||
const nextFormData = { ...(formDataRef.current || {}), ...patch }
|
||
formDataRef.current = nextFormData
|
||
|
||
const targetForm = form ?? formInstanceRef.current
|
||
targetForm?.option?.('formData', nextFormData)
|
||
Object.keys(patch).forEach((field) => {
|
||
targetForm?.getEditor?.(field)?.option?.('value', patch[field])
|
||
})
|
||
|
||
setFormData(nextFormData)
|
||
return nextFormData
|
||
},
|
||
[setFormData],
|
||
)
|
||
|
||
const runEditorScript = (formItem: SimpleItemWithColData, eventValue: any) => {
|
||
if (!formItem?.editorScript) return
|
||
|
||
const form = formInstanceRef.current
|
||
const dataField = formItem.dataField
|
||
const nextFormData = {
|
||
...(formDataRef.current || {}),
|
||
...(form?.option?.('formData') || {}),
|
||
...(dataField ? { [dataField]: eventValue } : {}),
|
||
}
|
||
formDataRef.current = nextFormData
|
||
|
||
try {
|
||
executeEditorScript(formItem.editorScript, {
|
||
formData: nextFormData,
|
||
e: { component: form, dataField, scriptEvent: 'valueChanged', value: eventValue },
|
||
editor: { dataField, component: form },
|
||
runtimeSetEditorReadOnly: (field: string, readOnly: boolean) =>
|
||
applyEditorReadOnly(form, field, readOnly),
|
||
setFormData: (newData: any) => commitFormData(newData || {}, form),
|
||
})
|
||
} catch (err) {
|
||
console.error('Script execution failed for', formItem.name, err)
|
||
}
|
||
}
|
||
|
||
/** Özel editörlerin ortak değer değişimi akışı. */
|
||
const handleCustomEditorChange = (formItem: SimpleItemWithColData, value: any) => {
|
||
if (!formItem.dataField) return
|
||
formDataRef.current = { ...formDataRef.current, [formItem.dataField]: value }
|
||
setFormData(formDataRef.current)
|
||
runEditorScript(formItem, value)
|
||
}
|
||
|
||
const getEditorOptions = (formItem: SimpleItemWithColData, index?: number) => {
|
||
const runtimeReadOnly = getRuntimeEditorReadOnly(formItem)
|
||
const previousOnValueChanged = formItem.editorOptions?.onValueChanged
|
||
const configuredButtons = formItem.editorOptions?.buttons
|
||
|
||
return {
|
||
...(index !== undefined && mode !== 'view' && !isTouchLikeDevice()
|
||
? { autoFocus: index === 1 }
|
||
: {}),
|
||
...(formItem.editorType === 'dxDateBox'
|
||
? { useMaskBehavior: true, openOnFieldClick: true, showClearButton: true }
|
||
: {}),
|
||
...(formItem.colData?.placeHolder
|
||
? { placeholder: translate('::' + formItem.colData.placeHolder) }
|
||
: {}),
|
||
...formItem.editorOptions,
|
||
...(runtimeReadOnly !== undefined ? { readOnly: runtimeReadOnly } : {}),
|
||
...(mode === 'view' ? { readOnly: true } : {}),
|
||
...(formItem.editorScript
|
||
? {
|
||
onValueChanged: (e: any) => {
|
||
previousOnValueChanged?.(e)
|
||
if (formItem.dataField) {
|
||
commitFormData({ [formItem.dataField]: e?.value })
|
||
}
|
||
runEditorScript(formItem, e?.value)
|
||
},
|
||
}
|
||
: {}),
|
||
...(configuredButtons?.length
|
||
? {
|
||
buttons: configuredButtons.map((button: any) => {
|
||
// Yapılandırmadan string olarak gelen onClick bir kez fonksiyona çevrilir.
|
||
if (typeof button?.options?.onClick !== 'string') return button
|
||
|
||
const originalClick = eval(`(${button.options.onClick})`)
|
||
return {
|
||
...button,
|
||
options: {
|
||
...button.options,
|
||
onClick: (e: any) =>
|
||
originalClick({
|
||
...e,
|
||
formData: formDataRef.current,
|
||
fieldName: formItem.dataField,
|
||
mode,
|
||
}),
|
||
},
|
||
}
|
||
}),
|
||
}
|
||
: {}),
|
||
}
|
||
}
|
||
|
||
const getFormItemKey = (formItem: SimpleItemWithColData, index: number) =>
|
||
`formItem-${formItem.dataField || formItem.name || index}-${String(
|
||
getRuntimeEditorReadOnly(formItem),
|
||
)}`
|
||
|
||
// Cascading alanlarda formItems yenilendiğinde editör dataSource'larını güncelle.
|
||
useEffect(() => {
|
||
const form = refForm.current?.instance()
|
||
if (!form) return
|
||
|
||
flattenFormGroups(formItems).forEach((item) => {
|
||
if (!item.colData?.lookupDto?.dataSourceType || !item.editorOptions?.dataSource) return
|
||
|
||
try {
|
||
form.getEditor(item.dataField!)?.option('dataSource', item.editorOptions.dataSource)
|
||
} catch (err) {
|
||
// Editör henüz oluşmamış olabilir.
|
||
console.debug('Editor update skipped for', item.dataField, err)
|
||
}
|
||
})
|
||
}, [formItems, refForm])
|
||
|
||
/** Parent alanı boş olan cascade alanlarını devre dışı bırakır. */
|
||
const updateCascadeDisabledStates = useCallback(() => {
|
||
const form = refForm.current?.instance()
|
||
if (!form || mode === 'view') return
|
||
|
||
flattenFormGroups(formItemsRef.current).forEach((item) => {
|
||
const parentFields = getCascadeParentFields(item)
|
||
if (!parentFields.length) return
|
||
|
||
try {
|
||
const editor = form.getEditor(item.dataField!)
|
||
if (!editor) return
|
||
|
||
editor.option(
|
||
'disabled',
|
||
item.editorOptions?.disabled === true ||
|
||
parentFields.some((parentField) => !formDataRef.current?.[parentField]),
|
||
)
|
||
} catch (err) {
|
||
console.debug('Cascade disabled update skipped for', item.dataField, err)
|
||
}
|
||
})
|
||
}, [mode, refForm])
|
||
|
||
useEffect(() => {
|
||
updateCascadeDisabledStates()
|
||
}, [formData, updateCascadeDisabledStates])
|
||
|
||
// contentReady sırasında yapılan yazmalar yeni bir contentReady doğurabilir.
|
||
const contentReadyRunningRef = useRef(false)
|
||
|
||
/**
|
||
* Form açılışında çalışması gereken script'leri yürütür: salt-okunur
|
||
* kararları ve `@runOnOpen` işaretli varsayılan değer kuralları.
|
||
*/
|
||
const runReadOnlyScripts = useCallback(
|
||
(form: any) => {
|
||
if (!form || contentReadyRunningRef.current) return
|
||
|
||
const currentFormData = {
|
||
...(formDataRef.current || {}),
|
||
...(form?.option?.('formData') || {}),
|
||
}
|
||
formDataRef.current = currentFormData
|
||
|
||
contentReadyRunningRef.current = true
|
||
try {
|
||
flattenFormGroups(formItemsRef.current)
|
||
.filter((formItem) => shouldRunEditorScriptOnContentReady(formItem.editorScript))
|
||
.forEach((formItem) => {
|
||
try {
|
||
executeEditorScript(formItem.editorScript!, {
|
||
formData: currentFormData,
|
||
e: {
|
||
component: form,
|
||
dataField: formItem.dataField,
|
||
scriptEvent: 'contentReady',
|
||
value: getValueByField(currentFormData, formItem.dataField),
|
||
},
|
||
editor: { dataField: formItem.dataField, component: form },
|
||
runtimeSetEditorReadOnly: (field: string, readOnly: boolean) =>
|
||
applyEditorReadOnly(form, field, readOnly),
|
||
// Açılışta yazma artık destekleniyor; varsayılan değer üreten
|
||
// script'ler bu sayede çalışabiliyor.
|
||
setFormData: (newData: any) => commitFormData(newData || {}, form),
|
||
})
|
||
} catch (err) {
|
||
console.error('Script execution failed on contentReady for', formItem.name, err)
|
||
}
|
||
})
|
||
} finally {
|
||
contentReadyRunningRef.current = false
|
||
}
|
||
},
|
||
// applyEditorReadOnly ref tabanlıdır; her render'da yeniden oluşması sorun değil.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
[],
|
||
)
|
||
|
||
useEffect(() => {
|
||
const form = formInstanceRef.current
|
||
if (!form || mode === 'view' || !formData || !formItems?.length) return
|
||
|
||
const scriptFields = flattenFormGroups(formItemsRef.current)
|
||
.filter((formItem) => shouldRunEditorScriptOnContentReady(formItem.editorScript))
|
||
.map((formItem) => formItem.dataField)
|
||
.join(MULTIVALUE_DELIMITER)
|
||
|
||
if (!scriptFields) return
|
||
|
||
const scriptKey = `${mode}|${scriptFields}|${JSON.stringify(formData)}`
|
||
if (lastContentReadyScriptKeyRef.current === scriptKey) return
|
||
|
||
lastContentReadyScriptKeyRef.current = scriptKey
|
||
setTimeout(() => runReadOnlyScripts(form), 0)
|
||
}, [formData, formItems, mode, runReadOnlyScripts])
|
||
|
||
/** Platform editörünün render fonksiyonunu döner. */
|
||
const renderCustomEditor = (formItem: SimpleItemWithColData): ReactNode => {
|
||
const value = formData[formItem.dataField!]
|
||
const editorOptions = getEditorOptions(formItem)
|
||
const onChange = (next: any) => handleCustomEditorChange(formItem, next)
|
||
|
||
switch (formItem.editorType2) {
|
||
case PlatformEditorTypes.dxTagBox:
|
||
return (
|
||
<TagBoxEditorComponent
|
||
value={value || []}
|
||
setDefaultValue={false}
|
||
values={formData}
|
||
options={formItem.tagBoxOptions}
|
||
col={formItem.colData}
|
||
onValueChanged={onChange}
|
||
editorOptions={editorOptions}
|
||
/>
|
||
)
|
||
case PlatformEditorTypes.dxGridBox:
|
||
return (
|
||
<GridBoxEditorComponent
|
||
value={value || []}
|
||
values={formData}
|
||
options={formItem.gridBoxOptions}
|
||
col={formItem.colData}
|
||
onValueChanged={onChange}
|
||
editorOptions={editorOptions}
|
||
/>
|
||
)
|
||
case PlatformEditorTypes.dxImageUpload:
|
||
return (
|
||
<ImageUploadEditorComponent
|
||
value={value}
|
||
options={formItem.imageUploadOptions}
|
||
onValueChanged={onChange}
|
||
editorOptions={editorOptions}
|
||
/>
|
||
)
|
||
case PlatformEditorTypes.dxImageViewer:
|
||
return (
|
||
<ImageViewerEditorComponent
|
||
value={value}
|
||
options={formItem.imageUploadOptions}
|
||
editorOptions={editorOptions}
|
||
/>
|
||
)
|
||
default:
|
||
return null
|
||
}
|
||
}
|
||
|
||
const renderFormItem = (formItem: SimpleItemWithColData, index: number) => {
|
||
const key = getFormItemKey(formItem, index)
|
||
const label = {
|
||
text: translate('::' + formItem.colData?.captionName),
|
||
className: 'font-semibold',
|
||
}
|
||
|
||
if (!CUSTOM_EDITOR_TYPES.includes(formItem.editorType2 as any)) {
|
||
return (
|
||
<SimpleItemDx
|
||
cssClass="font-semibold"
|
||
key={key}
|
||
{...formItem}
|
||
editorOptions={getEditorOptions(formItem, index)}
|
||
label={{ text: label.text }}
|
||
/>
|
||
)
|
||
}
|
||
|
||
// Görsel editörleri DevExtreme'in bilmediği alan tiplerini yaymamak için
|
||
// yalnızca gerekli alanlarla oluşturulur.
|
||
const isImageEditor =
|
||
formItem.editorType2 === PlatformEditorTypes.dxImageUpload ||
|
||
formItem.editorType2 === PlatformEditorTypes.dxImageViewer
|
||
const itemProps = isImageEditor
|
||
? {
|
||
dataField: formItem.dataField,
|
||
name: formItem.name,
|
||
colSpan: formItem.colSpan,
|
||
isRequired: formItem.isRequired,
|
||
}
|
||
: formItem
|
||
|
||
return (
|
||
<SimpleItemDx
|
||
cssClass="font-semibold"
|
||
key={key}
|
||
{...itemProps}
|
||
render={() => renderCustomEditor(formItem)}
|
||
label={label}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<FormDx
|
||
ref={refForm}
|
||
className={`${DX_CLASSNAMES} ${!isSubForm ? 'px-2' : ''} pb-2`}
|
||
formData={formData}
|
||
onFieldDataChanged={(e: FieldDataChangedEvent) => {
|
||
if (!e.dataField) return
|
||
|
||
// DevExtreme değeri formData nesnesine yerinde yazar; o durumda hasChanges false kalır ve
|
||
// state yenilenmez. Script'ler formDataRef'ten çalıştığı için ref her değişimde güncellenir,
|
||
// yoksa sonraki commit script'siz alanların değerini (ör. lookup seçimi) eski kopyayla ezerdi.
|
||
formDataRef.current = { ...formDataRef.current, [e.dataField]: e.value }
|
||
|
||
const newFormData = { ...formData, [e.dataField]: e.value }
|
||
let hasChanges = !Object.is(formData?.[e.dataField], e.value)
|
||
|
||
// Parent alan değiştiğinde bağımlı (cascade) alanları temizle.
|
||
flattenFormGroups(formItemsRef.current)
|
||
.filter((item) => getCascadeParentFields(item).includes(e.dataField!))
|
||
.forEach((child) => {
|
||
if (!Object.is(newFormData[child.dataField!], null)) {
|
||
newFormData[child.dataField!] = null
|
||
hasChanges = true
|
||
}
|
||
})
|
||
|
||
if (hasChanges) {
|
||
formDataRef.current = newFormData
|
||
setFormData(newFormData)
|
||
}
|
||
|
||
// Editör güncellemesi tamamlandıktan sonra devre dışı durumlarını yenile.
|
||
setTimeout(updateCascadeDisabledStates, 0)
|
||
}}
|
||
onContentReady={(e) => {
|
||
formInstanceRef.current = e.component
|
||
runReadOnlyScripts(e.component)
|
||
|
||
const firstItem = (e.component.option('items') as any[])?.[0]?.items?.[0]
|
||
if (
|
||
!didAutoFocusRef.current &&
|
||
firstItem?.dataField &&
|
||
mode !== 'view' &&
|
||
!isTouchLikeDevice()
|
||
) {
|
||
didAutoFocusRef.current = true
|
||
e.component.getEditor(firstItem.dataField)?.focus()
|
||
}
|
||
}}
|
||
>
|
||
{formItems.map((formGroupItem, groupIndex) => (
|
||
<GroupItemDx
|
||
key={'formGroupItem-' + groupIndex}
|
||
colCount={formGroupItem.colCount}
|
||
colSpan={formGroupItem.colSpan}
|
||
caption={translateCaption(translate, formGroupItem.caption)}
|
||
>
|
||
{(formGroupItem.items as SimpleItemWithColData[])
|
||
?.filter((formItem) => {
|
||
if (mode === 'edit') return formItem.allowEditing !== false
|
||
if (mode === 'new') return formItem.allowAdding !== false
|
||
return true
|
||
})
|
||
.map(renderFormItem)}
|
||
</GroupItemDx>
|
||
))}
|
||
</FormDx>
|
||
)
|
||
}
|
||
|
||
export default FormDevExpress
|