sozsoft-platform/ui/src/views/form/FormDevExpress.tsx
2026-10-04 22:17:16 +03:00

484 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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