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 formData: any formItems: GroupItem[] setFormData: Dispatch }) => { const { listFormCode, isSubForm, mode, refForm, formData, formItems, setFormData } = props const { translate } = useLocalization() const formDataRef = useRef(formData) const formItemsRef = useRef(formItems) const formInstanceRef = useRef(undefined) const lastContentReadyScriptKeyRef = useRef(undefined) const didAutoFocusRef = useRef(false) const [runtimeReadOnlyFields, setRuntimeReadOnlyFields] = useState>({}) const runtimeReadOnlyFieldsRef = useRef>({}) 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, 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 ( ) case PlatformEditorTypes.dxGridBox: return ( ) case PlatformEditorTypes.dxImageUpload: return ( ) case PlatformEditorTypes.dxImageViewer: return ( ) 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 ( ) } // 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 ( renderCustomEditor(formItem)} label={label} /> ) } return ( { 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) => ( {(formGroupItem.items as SimpleItemWithColData[]) ?.filter((formItem) => { if (mode === 'edit') return formItem.allowEditing !== false if (mode === 'new') return formItem.allowAdding !== false return true }) .map(renderFormItem)} ))} ) } export default FormDevExpress