sozsoft-platform/ui/src/views/form/FormDevExpress.tsx

479 lines
17 KiB
TypeScript
Raw Normal View History

2026-07-08 06:14:40 +00:00
import { DX_CLASSNAMES, MULTIVALUE_DELIMITER } from '@/constants/app.constant'
2026-07-11 20:35:32 +00:00
import '@/utils/registerDevExtremeEditors'
import { executeEditorScript } from '@/utils/editorScriptRuntime'
2026-02-24 20:44:16 +00:00
import {
Form as FormDx,
2026-07-13 08:39:13 +00:00
type FormRef,
2026-02-24 20:44:16 +00:00
GroupItem as GroupItemDx,
SimpleItem as SimpleItemDx,
} from 'devextreme-react/form'
2026-07-13 08:39:13 +00:00
import type { FieldDataChangedEvent, GroupItem } from 'devextreme/ui/form'
import { Dispatch, ReactNode, RefObject, useCallback, useEffect, useRef, useState } from 'react'
2026-02-24 20:44:16 +00:00
import { GridBoxEditorComponent } from './editors/GridBoxEditorComponent'
import { ImageUploadEditorComponent } from './editors/ImageUploadEditorComponent'
import { ImageViewerEditorComponent } from './editors/ImageViewerEditorComponent'
2026-02-24 20:44:16 +00:00
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,
} 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
2026-06-01 13:47:38 +00:00
2026-02-24 20:44:16 +00:00
const FormDevExpress = (props: {
listFormCode: string
isSubForm?: boolean
mode: RowMode
refForm: RefObject<FormRef>
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)
2026-06-01 13:47:38 +00:00
const formInstanceRef = useRef<any>()
const lastContentReadyScriptKeyRef = useRef<string>()
const didAutoFocusRef = useRef(false)
const [runtimeReadOnlyFields, setRuntimeReadOnlyFields] = useState<Record<string, boolean>>({})
const runtimeReadOnlyFieldsRef = useRef<Record<string, boolean>>({})
2026-02-24 20:44:16 +00:00
formDataRef.current = formData
formItemsRef.current = formItems
2026-02-24 20:44:16 +00:00
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.
2026-02-24 20:44:16 +00:00
useEffect(() => {
const form = refForm.current?.instance()
if (!form) return
2026-02-24 20:44:16 +00:00
flattenFormGroups(formItems).forEach((item) => {
if (!item.colData?.lookupDto?.dataSourceType || !item.editorOptions?.dataSource) return
2026-02-24 20:44:16 +00:00
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)
2026-02-24 20:44:16 +00:00
}
})
}, [formItems, refForm])
2026-02-24 20:44:16 +00:00
/** 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
2026-02-24 20:44:16 +00:00
flattenFormGroups(formItemsRef.current).forEach((item) => {
const parentFields = getCascadeParentFields(item)
if (!parentFields.length) return
2026-02-24 20:44:16 +00:00
try {
const editor = form.getEditor(item.dataField!)
if (!editor) return
2026-02-24 20:44:16 +00:00
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)
2026-02-24 20:44:16 +00:00
}
})
}, [mode, refForm])
2026-02-24 20:44:16 +00:00
useEffect(() => {
updateCascadeDisabledStates()
}, [formData, updateCascadeDisabledStates])
2026-02-24 20:44:16 +00:00
// 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)
2026-07-08 06:14:40 +00:00
.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}
/>
)
}
2026-02-24 20:44:16 +00:00
return (
<FormDx
ref={refForm}
className={`${DX_CLASSNAMES} ${!isSubForm ? 'px-2' : ''} pb-2`}
formData={formData}
onFieldDataChanged={(e: FieldDataChangedEvent) => {
if (!e.dataField) return
const newFormData = { ...formData, [e.dataField]: e.value }
let hasChanges = !Object.is(formData?.[e.dataField], e.value)
2026-02-24 20:44:16 +00:00
// 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
}
})
2026-02-24 20:44:16 +00:00
if (hasChanges) {
formDataRef.current = newFormData
setFormData(newFormData)
}
2026-02-24 20:44:16 +00:00
// Editör güncellemesi tamamlandıktan sonra devre dışı durumlarını yenile.
setTimeout(updateCascadeDisabledStates, 0)
2026-02-24 20:44:16 +00:00
}}
onContentReady={(e) => {
2026-06-01 13:47:38 +00:00
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()
2026-02-24 20:44:16 +00:00
}
}}
>
{formItems.map((formGroupItem, groupIndex) => (
<GroupItemDx
key={'formGroupItem-' + groupIndex}
colCount={formGroupItem.colCount}
colSpan={formGroupItem.colSpan}
caption={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>
))}
2026-02-24 20:44:16 +00:00
</FormDx>
)
}
export default FormDevExpress