Grid, Tree, FormDevexpress komponentlerin yetenekleri eşitlendi

This commit is contained in:
Sedat ÖZTÜRK 2026-10-07 21:08:10 +03:00
parent 7e5322d831
commit 3922c46539
30 changed files with 784 additions and 804 deletions

View file

@ -1490,9 +1490,28 @@ cascade eksik yetenek değildir ve analizde `Ready to Start`'ı engellemez. Tam
| `CascadeFilterOperator` | `StaticData` cascade filtresinin operatörü; varsayılan `=` |
| `CascadeEmptyFields` | **Ebeveyn alanda** doldurulur; bu alan değişince boşaltılacak çocuk alanlar, virgülle. Ara halka (İl) hem çocuk hem ebeveyn olduğu için iki tarafı birden taşır |
Cascade liste popup'ında ve form sayfasında (`FormDevExpress`) aynı davranır. Form sayfasında çocuk
Cascade liste popup'ında (Grid, Tree), satır içi düzenlemede ve form sayfasında (`FormDevExpress`)
aynı kuralla çalışır; kural tek yerdedir (`list/shared/cascade.ts`, `getCascadeClearFields`): bir alan
değişince ebeveyni o alan olan çocuklar **ve** alanın `CascadeEmptyFields` listesi boşaltılır. Kilit
ve temizleme platform editörlerinde (TagBox, GridBox, ImageUpload) de uygulanır. Form sayfasında çocuk
yalnızca ebeveynin değeri **gerçekten değiştiğinde** temizlenir ve listesi yeni ebeveyn değeriyle
yeniden kurulur; formun aynı değeri tekrar bildirmesi (veri forma geri verilince) yok sayılır.
**Liste popup'ı ↔ form sayfası editör eşliği.** İkisi aynı ListForm tanımından beslendiği için
editör yetenekleri ortak koddan gelir; bir tarafa yeni yetenek ortak yardımcıya eklenerek verilir:
| Yetenek | Ortak kaynak |
| --- | --- |
| `EditorOptions` varsayılanları (tarih maskesi/biçimi, HtmlEditor, telefon) | `withEditorTypeDefaults` (`list/shared/editingForm.ts`) |
| Lookup varsayılanları (`valueExpr`, `displayExpr`, `searchEnabled`) | `getLookupEditorDefaults` |
| Buton `onClick` (fonksiyon metni, `@SCAN_BARCODE`) ve bağlamı | `resolveEditorButtons` + `withButtonContext` |
| Cascade temizleme/kilit | `list/shared/cascade.ts` |
| Grid/Tree `onEditorPreparing` gövdesi | `prepareEditingEditor` (`list/shared/editingFormScripts.ts`) |
| TagBox, GridBox, ImageUpload, ImageViewer | Tek uygulama `form/editors/*`; liste hücresi `list/editors/cellEditors.tsx` adaptörüyle bağlanır |
DataGrid/TreeList platform editörlerini `editCellTemplate` ile çizer ve `onEditorPreparing` onlar için
çalışmaz; salt-okunur/devre dışı kararı, cascade ve `EditorScript` bu editörlere
`createCustomEditorCellAdapter` ile verilir.
Adres zincirinin hazır örneği Kiracılar ekranıdır: Ülke › İl › İlçe › Mahalle; mahalle lookup'ı
`PostalCode`'u `group` olarak döndürür ve `copy("Group", "PostalCode")` tarifi posta kodunu doldurur.
@ -2842,7 +2861,7 @@ Preset mevcut JSON ile **birleşir**, diğer ayarları silmez; aynı anahtar var
| Çoklu görsel | `width`, `height`, `multiple`, `accept:image/*` |
| HTML tam araç çubuğu | `htmlEditorOptions.ts` varsayılanı: `toolbar.items` (tam liste), `toolbar.multiline`, `mediaResizing`, `imageUpload` (`tabs: file/url`, base64), `tableResizing`, `tableContextMenu` — boş `editorOptions` da aynı varsayılanı alır |
| Yükseklik 100 / 200 | `{"height":100}` · `{"height":200}` |
| Barkod okutma butonu | `buttons[]` — kamera ikonlu buton; `onClick` = `"@SCAN_BARCODE"` token'ı. Yalnız `dxTextBox`. Token'ı `editingForm.ts` kamera diyaloğuna bağlar, okunan kod alana yazılır. HTTPS şart. |
| Barkod okutma butonu | `buttons[]` — kamera ikonlu buton; `onClick` = `"@SCAN_BARCODE"` token'ı. Yalnız `dxTextBox`. Token'ı `resolveEditorButtons` (`editingForm.ts`) kamera diyaloğuna bağlar, okunan kod alana yazılır; liste popup'ı ve form sayfası aynı. HTTPS şart. Fonksiyon metni yazılan `onClick` olayla birlikte `formData`, `fieldName`, `mode` alır (popup'ta ek olarak `rowKey`, `rowIndex`). |
| Kamerayla foto çek | `{"accept":"image/*","capture":"environment"}` — yalnız `dxImageUpload`. Mobilde dosya seçici yerine **yalnızca** kamera açılır (`user` = ön kamera); masaüstünde yok sayılır. Ayarlanmazsa dokunmatik cihazda dosya seçicinin yanına ayrı bir kamera butonu gelir, yani ikisi birden kullanılabilir — bu kalıp kamerayı **zorunlu** kılmak içindir. |
#### 6.4.1 Telefon biçimleri (`format` token'ı)
@ -2867,7 +2886,7 @@ varsayılanı ezer.
- Ülke alanı olan her ekranda yeni kayıt Türkiye ile açılır: `FormFieldsDefaultValueJson` →
`{ FieldName: "Country", FieldDbType: String, Value: "Türkiye", CustomValueType: Value }`.
- Ülke alanı değişince maske yenilenir: Card/Scheduler/Form sayfasında formun `fieldDataChanged` olayıyla,
Grid/Tree'de `onEditorPreparing`'deki `attachFieldValueBroadcast` olayıyla (DataGrid formu `fieldDataChanged`
Grid/Tree'de `onEditorPreparing`'deki (`prepareEditingEditor`) `attachFieldValueBroadcast` olayıyla (DataGrid formu `fieldDataChanged`
yayınlamaz; `editorOptions.onValueChanged` da kullanılamaz — editör fabrikası onu kendi değer yazan
işleyicisinin üstüne birleştirir). Satır içi düzenlemede (form yok) maske satırın ülkesiyle açılır, değişikliği izlemez.
- `phoneCountry` ekranında sabit uzunluk `pattern`'ı (`^[0-9]{10}$`) yazılmaz; uzunluğu maske denetler,

View file

@ -446,9 +446,13 @@ ve atanan kullanıcı alanlarından kurulur.
`dxDropDownBox`, `dxGridBox`, `dxAutocomplete`, `dxColorBox`, `dxHtmlEditor`, `dxSlider`,
`dxRangeSlider` — ayrıca görsel yükleme/görüntüleme editörleri.
- **Lookup kaynakları:** `StaticData`, `Query`, `WebService`; ebeveyn–çocuk (cascade) desteklidir.
Cascade liste popup'ında ve form sayfasında (`/admin/form/...`, `FormDevExpress`) aynı çalışır.
Cascade liste popup'ında, satır içi düzenlemede ve form sayfasında (`/admin/form/...`, `FormDevExpress`)
aynı kuralla çalışır: alan değişince ebeveyni o alan olan çocuklar ve alanın `CascadeEmptyFields`
listesi boşaltılır.
Lookup'lı seçim kutusunda yazarak arama ikisinde de varsayılan açıktır; alanın `EditorOptions`'ında
`{"searchEnabled": false}` kapatır.
- **Popup ↔ form sayfası eşliği:** `EditorOptions` varsayılanları, butonlar, `EditorScript`, cascade ve
platform editörleri (TagBox, GridBox, görsel) iki yerde ortak koddan gelir; tablo `lowcode.instructions.md` §4.3.4.
- **Doğrulama:** `ValidationRuleJson` ile required/range/pattern/custom kuralları. Kurallar liste popup'ında ve form sayfasında (`/admin/form/...`) aynı uygulanır; maske (`mask`) `EditorOptions` ile verilir.
- **Editör script:** `EditorScript` alanı ile alan değişiminde çalışan JS (örn. tarih farkından
gün hesaplama, miktar × birim fiyat toplamı). Çalıştırma `utils/editorScriptRuntime.ts` üzerinden yapılır.

View file

@ -231,6 +231,11 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
- **`phoneFormat.ts` ↔ `DynamicReportPhoneFormatter.cs`** — telefon token'larının (`phoneGlobal`, `phoneTR`,
`phoneIntl`, `phoneCountry`) görüntü kuralı iki yerde yazılır: liste/kart istemcide, dinamik rapor sunucuda.
Biri değişirse diğeri de değişir; yoksa liste ile rapor aynı numarayı farklı yazar.
- **Liste popup'ı (Grid/Tree) ↔ form sayfası (`FormDevExpress`)** — editör yetenekleri ortak yardımcılardadır
(`list/shared/editingForm.ts`, `editingFormScripts.ts`, `cascade.ts`); TagBox/GridBox/ImageUpload/ImageViewer'ın
tek uygulaması `form/editors/*`, liste hücresi yalnızca `list/editors/cellEditors.tsx` adaptörüdür. Bir tarafa
yetenek eklenirken ortak yardımcıya eklenir; tek tarafa yazılan yetenek eşliği bozar. Tablo:
`lowcode.instructions.md` §4.3.4.
- **`htmlEditorOptions.ts` ↔ `EditorOptions.Html`** — HtmlEditor varsayılanı iki yerde yazılır ve
bloklar birebir aynıdır. İstemci iç içe blokları tek seviye birleştirdiği için seed edilen
`toolbar` varsayılanın yerine geçer; biri değişip diğeri değişmezse seed'li ekranlar eski araç

File diff suppressed because one or more lines are too long

View file

@ -19,7 +19,7 @@ import FormFieldOrderPanel from './FormFieldOrderPanel'
import type { FormFieldOrder } from './shared/formItems'
/** Form sayfasının kullanıcı bazlı alan sırası (`useGridData`). */
export type FormFieldOrderControl = {
type FormFieldOrderControl = {
value?: FormFieldOrder
preview: (order: FormFieldOrder | undefined) => void
save: (order: FormFieldOrder | undefined) => Promise<void>

View file

@ -23,7 +23,9 @@ import {
setFormEditorReadOnly,
shouldRunEditorScriptOnContentReady,
translateCaption,
withButtonContext,
} from '../list/shared/editingForm'
import { getCascadeClearFields, isCascadeParentEmpty } from '../list/shared/cascade'
import { isTouchLikeDevice } from '../list/shared/editPopup'
import { flattenFormGroups, getCascadeParentFields } from './shared/formItems'
@ -164,11 +166,27 @@ const FormDevExpress = (props: {
}
}
/**
* Alan değeri değişince cascade kuralının boşalttığı alanları uygular: ebeveyni bu alan olan
* çocuklar ve alanın `CascadeEmptyFields` listesi (liste popup'ıyla aynı kural).
*/
const withCascadeCleared = (data: Record<string, any>, dataField: string) => {
const columns = flattenFormGroups(formItemsRef.current).map((item) => item.colData)
getCascadeClearFields(columns, dataField).forEach((field) => {
data[field] = null
})
return data
}
/** Özel editörlerin ortak değer değişimi akışı. */
const handleCustomEditorChange = (formItem: SimpleItemWithColData, value: any) => {
if (!formItem.dataField) return
if (isSameValue(formDataRef.current?.[formItem.dataField], value)) return
formDataRef.current = { ...formDataRef.current, [formItem.dataField]: value }
formDataRef.current = withCascadeCleared(
{ ...formDataRef.current, [formItem.dataField]: value },
formItem.dataField,
)
formDataSnapshotRef.current = { ...formDataRef.current }
setFormData(formDataRef.current)
runEditorScript(formItem, value)
}
@ -182,9 +200,6 @@ const FormDevExpress = (props: {
...(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) }
: {}),
@ -202,27 +217,14 @@ const FormDevExpress = (props: {
},
}
: {}),
// onClick metni item üretilirken çözülür (`resolveEditorButtons`); burada kayıt bağlamı eklenir.
...(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,
}),
},
}
}),
buttons: withButtonContext(configuredButtons, () => ({
formData: formDataRef.current,
fieldName: formItem.dataField,
mode,
})),
}
: {}),
}
@ -266,7 +268,7 @@ const FormDevExpress = (props: {
editor.option(
'disabled',
item.editorOptions?.disabled === true ||
parentFields.some((parentField) => !formDataRef.current?.[parentField]),
isCascadeParentEmpty(parentFields, formDataRef.current),
)
} catch (err) {
console.debug('Cascade disabled update skipped for', item.dataField, err)
@ -350,7 +352,15 @@ const FormDevExpress = (props: {
/** Platform editörünün render fonksiyonunu döner. */
const renderCustomEditor = (formItem: SimpleItemWithColData): ReactNode => {
const value = formData[formItem.dataField!]
const editorOptions = getEditorOptions(formItem)
const parentFields = getCascadeParentFields(formItem)
const editorOptions = {
...getEditorOptions(formItem),
// DevExtreme editörlerinde updateCascadeDisabledStates yapar; platform editörleri
// form.getEditor ile bulunamadığı için kilit burada verilir.
...(mode !== 'view' && parentFields.length && isCascadeParentEmpty(parentFields, formData)
? { disabled: true }
: {}),
}
const onChange = (next: any) => handleCustomEditorChange(formItem, next)
switch (formItem.editorType2) {
@ -359,7 +369,6 @@ const FormDevExpress = (props: {
<TagBoxEditorComponent
value={value || EMPTY_VALUES}
setDefaultValue={false}
values={formData}
options={formItem.tagBoxOptions}
col={formItem.colData}
onValueChanged={onChange}
@ -370,7 +379,6 @@ const FormDevExpress = (props: {
return (
<GridBoxEditorComponent
value={value || EMPTY_VALUES}
values={formData}
options={formItem.gridBoxOptions}
col={formItem.colData}
onValueChanged={onChange}
@ -477,14 +485,8 @@ const FormDevExpress = (props: {
// Aynı değer tekrar geldiğinde (formData forma geri verilince) state yenilenmez; aksi hâlde
// her yenileme yeni bir olay doğurur ve döngüye girer.
if (!isSameValue(formDataSnapshotRef.current?.[e.dataField], e.value)) {
const newFormData = { ...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) => {
newFormData[child.dataField!] = null
})
const newFormData = withCascadeCleared({ ...formData, [e.dataField]: e.value }, e.dataField)
formDataSnapshotRef.current = { ...newFormData }
formDataRef.current = newFormData

View file

@ -2,6 +2,7 @@ import { ColumnFormatDto, GridBoxOptionsDto } from '@/proxy/form/models'
import { ReactElement } from 'react'
import { SharedGridBoxEditor } from '@/views/shared/SharedGridBoxEditor'
/** Form sayfası ve liste düzenleme hücresinin ortak GridBox editörü. */
const GridBoxEditorComponent = ({
value,
options,
@ -11,7 +12,6 @@ const GridBoxEditorComponent = ({
}: {
value: any
options?: GridBoxOptionsDto
values: any // all row data
onValueChanged: (e: any) => void
col?: ColumnFormatDto
editorOptions: any
@ -22,9 +22,13 @@ const GridBoxEditorComponent = ({
<SharedGridBoxEditor
value={value}
dataSource={editorOptions?.dataSource}
lookup={lookupDto}
lookup={{
valueExpr: lookupDto?.valueExpr?.toLowerCase(),
displayExpr: lookupDto?.displayExpr?.toLowerCase(),
}}
options={options}
editorOptions={editorOptions}
dropDownOptions={{ width: options?.width, hideOnOutsideClick: true }}
onValueChanged={onValueChanged}
/>
)

View file

@ -13,6 +13,51 @@ import { normalizeImageSize } from '../../list/shared/imageValue'
import Input from '@/components/ui/Input'
import { supportsPhotoCapture } from '@/utils/device'
/**
* Yüklenen değerin ham biçimini korur; ayraçlara göre bölmez.
* (Görüntüleme tarafındaki `normalizeImageValue` ile bilinçli olarak farklıdır:
* burada kullanıcı tarafından yazılmış virgüllü URL'ler bozulmamalıdır.)
*/
const normalizeUploadValue = (value: unknown): string[] => {
if (!value) return []
if (Array.isArray(value)) return value.flatMap(normalizeUploadValue)
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return []
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) return parsed.flatMap(normalizeUploadValue)
} catch {
return [trimmed]
}
}
return [trimmed]
}
return [String(value)].filter(Boolean)
}
const resolveUploadedUrl = (data: unknown) => {
if (typeof data === 'string') return data
if (!data || typeof data !== 'object') return ''
const record = data as Record<string, unknown>
return String(record.url ?? record.fileUrl ?? record.path ?? '').trim()
}
const toBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
/** Form sayfası ve liste düzenleme hücresinin ortak görsel yükleme editörü. */
const ImageUploadEditorComponent = ({
value,
options,
@ -25,7 +70,7 @@ const ImageUploadEditorComponent = ({
editorOptions?: any
}): ReactElement => {
const { translate } = useLocalization()
const readOnly: boolean = editorOptions?.readOnly ?? false
const readOnly: boolean = editorOptions?.readOnly === true || editorOptions?.disabled === true
// options önce prop olarak gelen ImageUploadOptionsDto'dan, sonra editorOptions JSON'undan okunur
const resolvedOptions: ImageUploadOptionsDto = options ?? editorOptions ?? {}
@ -40,10 +85,7 @@ const ImageUploadEditorComponent = ({
// aksi halde dokunmatik cihazda dosya seciciye ek olarak kamera sunulur.
const showCameraButton = !resolvedOptions.capture && supportsPhotoCapture()
const valueUrls = useMemo<string[]>(
() => (Array.isArray(value) ? value : [value]).filter(Boolean) as string[],
[value],
)
const valueUrls = useMemo<string[]>(() => normalizeUploadValue(value), [value])
const [urls, setUrls] = useState<string[]>(valueUrls)
const [uploading, setUploading] = useState(false)
@ -63,14 +105,6 @@ const ImageUploadEditorComponent = ({
onValueChanged(newValue)
}
const toBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
const handleFiles = async (files: FileList | null) => {
if (!files?.length) return
@ -87,7 +121,7 @@ const ImageUploadEditorComponent = ({
const res = await fetch(resolvedOptions.uploadUrl, { method: 'POST', body: formData })
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
const data = await res.json()
uploadedUrls.push(String(data?.url ?? data?.fileUrl ?? data?.path ?? data ?? '').trim())
uploadedUrls.push(resolveUploadedUrl(data))
} else {
// uploadUrl yoksa base64 olarak sakla
const base64 = await toBase64(file)

View file

@ -14,7 +14,6 @@ const TagBoxEditorComponent = ({
value: any
setDefaultValue: boolean
options?: TagBoxOptionsDto
values: any // all row data
onValueChanged: (e: any) => void
col?: ColumnFormatDto
editorOptions: any
@ -27,11 +26,6 @@ const TagBoxEditorComponent = ({
[lookupDto?.displayExpr],
)
// const dataSource =
// typeof lookup?.dataSource === 'function'
// ? lookup.dataSource(values)
// : (lookup?.dataSource ?? [])
return (
<TagBox
{...editorOptions}

View file

@ -9,15 +9,18 @@ import type { GroupItem } from 'devextreme/ui/form'
import type { ColumnFormatDto, GridDto } from '@/proxy/form/models'
import { getEditingFormGroups } from '../../list/Utils'
import {
getLookupEditorDefaults,
isFormItemVisible,
resolveEditorButtons,
resolveEditorType,
withEditorTypeDefaults,
} from '../../list/shared/editingForm'
import { getCascadeParentFieldsOf } from '../../list/shared/cascade'
import type { GridColumnData } from '../../list/GridColumnData'
import type { RowMode, SimpleItemWithColData } from '../types'
/** Grup / tab ağacındaki tüm veri alanlarını düz listeye indirger. */
export const flattenFormItems = (items: any[] = []): SimpleItemWithColData[] =>
const flattenFormItems = (items: any[] = []): SimpleItemWithColData[] =>
items.flatMap((item) => [
...(item?.dataField ? [item] : []),
...flattenFormItems(item?.items || []),
@ -30,10 +33,7 @@ export const flattenFormGroups = (groups: GroupItem[] = []): SimpleItemWithColDa
/** Bir alanın cascade parent alan adlarını döner. */
export const getCascadeParentFields = (item: SimpleItemWithColData): string[] =>
item.colData?.lookupDto?.cascadeParentFields
?.split(',')
.map((field: string) => field.trim())
.filter(Boolean) ?? []
getCascadeParentFieldsOf(item.colData)
type BuildFormItemsParams = {
gridDto: GridDto
@ -77,16 +77,18 @@ const toFormItem = (
console.error('EditorOptions parse error:', colFormat.fieldName, err)
}
// Liste popup'ıyla aynı lookup varsayılanları; veri kaynağını form sayfası kendisi kurar.
const lookupEditorOptions = colFormat.lookupDto?.dataSourceType
? {
...getLookupEditorDefaults(colFormat),
dataSource: getLookupDataSource(colFormat, formData),
valueExpr: colFormat.lookupDto?.valueExpr?.toLowerCase(),
displayExpr: colFormat.lookupDto?.displayExpr?.toLowerCase(),
// Grid popup'ındaki lookup kolonu aramayı varsayılan açar; form sayfası aynı davranır.
searchEnabled: true,
}
: {}
if (parsedEditorOptions.buttons) {
parsedEditorOptions.buttons = resolveEditorButtons(parsedEditorOptions.buttons)
}
const item: SimpleItemWithColData = {
canRead: colFormat.canRead ?? false,
canUpdate: colFormat.canUpdate ?? false,
@ -99,10 +101,11 @@ const toFormItem = (
editorType2: colFormat.editorType2,
editorType: resolveEditorType(colFormat.editorType2),
colSpan: colFormat.colSpan,
editorOptions: withEditorTypeDefaults(colFormat.editorType2, {
...lookupEditorOptions,
...parsedEditorOptions,
}),
editorOptions: withEditorTypeDefaults(
colFormat.editorType2,
{ ...lookupEditorOptions, ...parsedEditorOptions },
colFormat.sourceDbType,
),
// DevExtreme item seçeneklerini yerinde (deep extend) günceller; ekran tanımı (gridDto) paylaşılırsa
// sıra değişince bir alanın tanımı diğerininkiyle ezilir. Item'a tanımın kopyası verilir.
colData: structuredClone(colFormat),

View file

@ -28,7 +28,7 @@ const isEmptyValue = (value: unknown) => value === undefined || value === null |
* StaticData için öğelerdeki `CascadeRelationField` alanı (varsayılan `group`) ilk ebeveynin
* değeriyle karşılaştırılır; ebeveyn boşsa süzülmez.
*/
export const buildCascadeFilters = (lookupDto: any, rowData: any): any[] | null => {
const buildCascadeFilters = (lookupDto: any, rowData: any): any[] | null => {
const parentFields = splitFields(lookupDto?.cascadeParentFields)
if (!parentFields.length) return null
@ -46,7 +46,7 @@ export const buildCascadeFilters = (lookupDto: any, rowData: any): any[] | null
return parentFields.map((field) => (isEmptyValue(rowData?.[field]) ? null : rowData[field]))
}
export const createLookupStaticDataSource = (
const createLookupStaticDataSource = (
load: () => any,
filter: any[] | null,
cacheKey: string,
@ -65,7 +65,7 @@ export const createLookupStaticDataSource = (
filter,
})
export const createLookupQueryDataSource = (
const createLookupQueryDataSource = (
listFormCode?: string,
listFormFieldName?: string,
filters?: any[] | null,
@ -106,7 +106,7 @@ export const createLookupQueryDataSource = (
/** Gövde JSON şablonudur; değer tırnak içine yerleştiği için JSON kaçışlı yazılır. */
const toJsonText = (value: unknown) => (value === null ? 'null' : JSON.stringify(String(value)).slice(1, -1))
export const createLookupApiDataSource = (
const createLookupApiDataSource = (
listFormCode?: string,
lookupQuery?: string,
filters?: any[] | null,

View file

@ -41,8 +41,7 @@ import { NotePanel } from '../form/notes/NotePanel'
import SubForms from '../form/SubForms'
import GridFilterDialogs from './GridFilterDialogs'
import { GridExtraFilterToolbar } from './GridExtraFilterToolbar'
import { ImageUploadEditorComponent } from './editors/ImageUploadEditorComponent'
import { ImageViewerEditorComponent } from './editors/ImageViewerEditorComponent'
import { ImageUploadEditorComponent, ImageViewerEditorComponent } from './editors/cellEditors'
import {
autoNumber,
extractSearchParamsFields,

View file

@ -37,10 +37,7 @@ import SubForms from '../form/SubForms'
import { RowMode } from '../form/types'
import { GridColumnData } from './GridColumnData'
import GridFilterDialogs from './GridFilterDialogs'
import { GridBoxEditorComponent } from './editors/GridBoxEditorComponent'
import { ImageUploadEditorComponent } from './editors/ImageUploadEditorComponent'
import { ImageViewerEditorComponent } from './editors/ImageViewerEditorComponent'
import { TagBoxEditorComponent } from './editors/TagBoxEditorComponent'
import { GridBoxEditorComponent, ImageUploadEditorComponent, ImageViewerEditorComponent, TagBoxEditorComponent } from './editors/cellEditors'
import { useFilters } from './useFilters'
import { updateWorkflowApprovalToolbarItems, useToolbar } from './useToolbar'
import { ImportDashboard } from '@/components/importManager/ImportDashboard'
@ -63,16 +60,14 @@ import {
buildUpdatePayload,
createFormItemMapper,
expandJsonFormFields,
getActiveEditingForm,
getValueByField,
} from './shared/editingForm'
import {
attachEditorButtonContext,
attachEditorValueChangedScript,
createCustomEditorCellAdapter,
createEditingFormHandlers,
prepareEditingEditor,
} from './shared/editingFormScripts'
import { attachFieldValueBroadcast } from '@/utils/phoneFormat'
import { attachCascadeBehavior, createCascadeMaps } from './shared/cascade'
import { createCascadeMaps } from './shared/cascade'
import { createConditionalCellStyleHandler } from './shared/cellStyles'
import {
useExtraFilters,
@ -343,61 +338,27 @@ const Grid = (props: GridProps) => {
const onEditorPreparing = useCallback(
(editor: DataGridTypes.EditorPreparingEvent<any, any>) => {
if (editor.parentType !== 'dataRow' || !editor.dataField || !gridDto) return
const columnFormat = gridDto.columnFormats.find(
(column) => column.fieldName === editor.dataField,
)
const isNewRow = Boolean((editor as any).row?.isNewRow) || mode === 'new'
if (columnFormat?.editorOptions) {
try {
editor.editorOptions = {
...editor.editorOptions,
...JSON.parse(columnFormat.editorOptions),
}
} catch (err) {
console.error('EditorOptions parse error:', columnFormat.fieldName, err)
}
}
const hasReadOnlyEditorOption = editor.editorOptions?.readOnly === true
const hasDisabledEditorOption = editor.editorOptions?.disabled === true
if (
(isNewRow && columnFormat?.allowAdding === false) ||
(!isNewRow && columnFormat?.allowEditing === false)
) {
editor.editorOptions.readOnly = true
} else if (isNewRow && columnFormat?.allowAdding === true) {
if (!hasReadOnlyEditorOption) editor.editorOptions.readOnly = false
if (!hasDisabledEditorOption) editor.editorOptions.disabled = false
}
const cascadeInfo = cascadeMaps.cascadeFieldsMap.get(editor.dataField)
if (cascadeInfo) {
attachCascadeBehavior({ editor, gridDto, cascadeInfo, maps: cascadeMaps })
}
// phoneCountry maskesi ülke alanını bu olaydan izler; DataGrid formu fieldDataChanged yayınlamaz.
attachFieldValueBroadcast(editor)
// Yalnızca düzenleme formunda yer alan alanların script'i çalıştırılır.
if (columnFormat?.editOrderNo != null && columnFormat.editorScript) {
attachEditorValueChangedScript({
editor,
editorScript: columnFormat.editorScript,
fieldName: columnFormat.fieldName,
editingFormInstanceRef,
getActiveEditingForm,
})
}
attachEditorButtonContext(editor)
if (!gridDto) return
prepareEditingEditor({ editor, gridDto, mode, cascadeMaps, editingFormInstanceRef })
},
[gridDto, cascadeMaps, mode],
)
// Şablon fonksiyonları DevExtreme'de bir kez bağlanır; mod ref üzerinden okunur.
const modeRef = useRef(mode)
modeRef.current = mode
const adaptCustomEditorCell = useMemo(
() =>
createCustomEditorCellAdapter({
getGridDto: () => gridDto,
getMode: () => modeRef.current,
cascadeMaps,
editingFormInstanceRef,
}),
[gridDto, cascadeMaps],
)
/* =======================
COLUMNS & DATASOURCE
======================= */
@ -665,9 +626,18 @@ const Grid = (props: GridProps) => {
items: editingFormItems,
}}
/>
<Template name="cellEditTagBox" render={TagBoxEditorComponent} />
<Template name="cellEditGridBox" render={GridBoxEditorComponent} />
<Template name="cellEditImageUpload" render={ImageUploadEditorComponent} />
<Template
name="cellEditTagBox"
render={(cell: any) => TagBoxEditorComponent(adaptCustomEditorCell(cell))}
/>
<Template
name="cellEditGridBox"
render={(cell: any) => GridBoxEditorComponent(adaptCustomEditorCell(cell))}
/>
<Template
name="cellEditImageUpload"
render={(cell: any) => ImageUploadEditorComponent(adaptCustomEditorCell(cell))}
/>
<Template
name="cellEditImageViewer"
render={(data: any) => (

View file

@ -31,10 +31,7 @@ import PageTitle from '@/components/shared/PageTitle'
import { RowMode } from '../form/types'
import { GridColumnData } from './GridColumnData'
import GridFilterDialogs from './GridFilterDialogs'
import { GridBoxEditorComponent } from './editors/GridBoxEditorComponent'
import { ImageUploadEditorComponent } from './editors/ImageUploadEditorComponent'
import { ImageViewerEditorComponent } from './editors/ImageViewerEditorComponent'
import { TagBoxEditorComponent } from './editors/TagBoxEditorComponent'
import { GridBoxEditorComponent, ImageUploadEditorComponent, ImageViewerEditorComponent, TagBoxEditorComponent } from './editors/cellEditors'
import { useFilters } from './useFilters'
import { updateWorkflowApprovalToolbarItems, useToolbar } from './useToolbar'
import WidgetGroup from '@/components/ui/Widget/WidgetGroup'
@ -59,16 +56,14 @@ import {
buildUpdatePayload,
createFormItemMapper,
expandJsonFormFields,
getActiveEditingForm,
getValueByField,
} from './shared/editingForm'
import {
attachEditorButtonContext,
attachEditorValueChangedScript,
createCustomEditorCellAdapter,
createEditingFormHandlers,
prepareEditingEditor,
} from './shared/editingFormScripts'
import { attachFieldValueBroadcast } from '@/utils/phoneFormat'
import { attachCascadeBehavior, createCascadeMaps } from './shared/cascade'
import { createCascadeMaps } from './shared/cascade'
import { createConditionalCellStyleHandler } from './shared/cellStyles'
import {
useExtraFilters,
@ -354,60 +349,27 @@ const Tree = (props: TreeProps) => {
const onEditorPreparing = useCallback(
(editor: TreeListTypes.EditorPreparingEvent) => {
if (editor.parentType !== 'dataRow' || !editor.dataField || !gridDto) return
const columnFormat = gridDto.columnFormats.find(
(column) => column.fieldName === editor.dataField,
)
const isNewRow = Boolean((editor as any).row?.isNewRow) || mode === 'new'
if (columnFormat?.editorOptions) {
try {
editor.editorOptions = {
...editor.editorOptions,
...JSON.parse(columnFormat.editorOptions),
}
} catch (err) {
console.error('EditorOptions parse error:', columnFormat.fieldName, err)
}
}
const hasReadOnlyEditorOption = editor.editorOptions?.readOnly === true
const hasDisabledEditorOption = editor.editorOptions?.disabled === true
if (
(isNewRow && columnFormat?.allowAdding === false) ||
(!isNewRow && columnFormat?.allowEditing === false)
) {
editor.editorOptions.readOnly = true
} else if (isNewRow && columnFormat?.allowAdding === true) {
if (!hasReadOnlyEditorOption) editor.editorOptions.readOnly = false
if (!hasDisabledEditorOption) editor.editorOptions.disabled = false
}
const cascadeInfo = cascadeMaps.cascadeFieldsMap.get(editor.dataField)
if (cascadeInfo) {
attachCascadeBehavior({ editor, gridDto, cascadeInfo, maps: cascadeMaps })
}
// phoneCountry maskesi ülke alanını bu olaydan izler; DataGrid formu fieldDataChanged yayınlamaz.
attachFieldValueBroadcast(editor)
if (columnFormat?.editOrderNo != null && columnFormat.editorScript) {
attachEditorValueChangedScript({
editor,
editorScript: columnFormat.editorScript,
fieldName: columnFormat.fieldName,
editingFormInstanceRef,
getActiveEditingForm,
})
}
attachEditorButtonContext(editor)
if (!gridDto) return
prepareEditingEditor({ editor, gridDto, mode, cascadeMaps, editingFormInstanceRef })
},
[gridDto, cascadeMaps, mode],
)
// Şablon fonksiyonları DevExtreme'de bir kez bağlanır; mod ref üzerinden okunur.
const modeRef = useRef(mode)
modeRef.current = mode
const adaptCustomEditorCell = useMemo(
() =>
createCustomEditorCellAdapter({
getGridDto: () => gridDto,
getMode: () => modeRef.current,
cascadeMaps,
editingFormInstanceRef,
}),
[gridDto, cascadeMaps],
)
/* =======================
COLUMNS & DATASOURCE
======================= */
@ -653,9 +615,18 @@ const Tree = (props: TreeProps) => {
items: editingFormItems,
}}
/>
<Template name="cellEditTagBox" render={TagBoxEditorComponent} />
<Template name="cellEditGridBox" render={GridBoxEditorComponent} />
<Template name="cellEditImageUpload" render={ImageUploadEditorComponent} />
<Template
name="cellEditTagBox"
render={(cell: any) => TagBoxEditorComponent(adaptCustomEditorCell(cell))}
/>
<Template
name="cellEditGridBox"
render={(cell: any) => GridBoxEditorComponent(adaptCustomEditorCell(cell))}
/>
<Template
name="cellEditImageUpload"
render={(cell: any) => ImageUploadEditorComponent(adaptCustomEditorCell(cell))}
/>
<Template
name="cellEditImageViewer"
render={(data: any) => (

View file

@ -1,30 +0,0 @@
import { ReactElement } from 'react'
import { SharedGridBoxEditor } from '@/views/shared/SharedGridBoxEditor'
const GridBoxEditorComponent = (cellElement: any): ReactElement => {
const col = cellElement.column
if (!col) {
return <></>
}
const options = col.extras.gridBoxOptions
const lookup = col?.lookup
const dataSource: any =
typeof lookup?.dataSource === 'function'
? lookup.dataSource(cellElement.row)
: (lookup?.dataSource ?? [])
return (
<SharedGridBoxEditor
value={cellElement.value}
dataSource={dataSource}
lookup={lookup}
options={options}
editorOptions={col.extras.editorOptions}
dropDownOptions={{ width: options?.width, hideOnOutsideClick: true }}
onCommit={(nextValue) => cellElement.setValue(nextValue)}
setInitialValueOnContentReady
/>
)
}
export { GridBoxEditorComponent }

View file

@ -1,273 +0,0 @@
import type { ReactElement } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { FaCamera, FaSpinner, FaUpload } from 'react-icons/fa'
import { Button } from '@/components/ui'
import { useLocalization } from '@/utils/hooks/useLocalization'
import {
hideImageHoverPreview,
openImageInNewTab,
showImageHoverPreview,
thumbnailSource,
} from '../../form/editors/imageHoverPreview'
import { normalizeImageSize, parseJsonObject } from '../shared/imageValue'
import Input from '@/components/ui/Input'
import { supportsPhotoCapture } from '@/utils/device'
type ImageUploadOptions = {
accept?: string
/** Mobilde dosya seçici yerine kamerayı açar. */
capture?: 'environment' | 'user'
fileFieldName?: string
height?: number | string
multiple?: boolean | string
uploadUrl?: string
width?: number | string
}
/**
* Yüklenen değerin ham biçimini korur; ayraçlara göre bölmez.
* (Görüntüleme tarafındaki `normalizeImageValue` ile bilinçli olarak farklıdır:
* burada kullanıcı tarafından yazılmış virgüllü URL'ler bozulmamalıdır.)
*/
const normalizeUploadValue = (value: unknown): string[] => {
if (!value) return []
if (Array.isArray(value)) return value.flatMap(normalizeUploadValue)
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return []
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed)
if (Array.isArray(parsed)) return parsed.flatMap(normalizeUploadValue)
} catch {
return [trimmed]
}
}
return [trimmed]
}
return [String(value)].filter(Boolean)
}
const resolveOptions = (column: any): ImageUploadOptions => {
const configuredOptions = column?.extras?.imageUploadOptions
if (configuredOptions && Object.keys(configuredOptions).length > 0) {
return configuredOptions
}
const parsedOptions = parseJsonObject(column?.extras?.editorOptions ?? column?.editorOptions)
return parsedOptions ?? {}
}
const resolveUploadedUrl = (data: unknown) => {
if (typeof data === 'string') return data
if (!data || typeof data !== 'object') return ''
const record = data as Record<string, unknown>
return String(record.url ?? record.fileUrl ?? record.path ?? '').trim()
}
const toBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
const { translate } = useLocalization()
const col = cellElement.column
const [uploading, setUploading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const cameraInputRef = useRef<HTMLInputElement>(null)
const [urls, setUrls] = useState<string[]>(() => normalizeUploadValue(cellElement.value))
useEffect(() => {
setUrls(normalizeUploadValue(cellElement.value))
}, [cellElement.value])
const options = useMemo(() => resolveOptions(col), [col])
if (!col) {
return <></>
}
// JSON'dan string olarak gelebilir: "true" / "false"
const isMultiple: boolean = options.multiple === true || options.multiple === 'true'
const thumbW = normalizeImageSize(options.width, 40)
const thumbH = normalizeImageSize(options.height, 40)
// Alanda `capture` acikca ayarliysa kullanicinin istedigi tek davranis odur;
// aksi halde dokunmatik cihazda dosya seciciye ek olarak kamera sunulur.
const showCameraButton = !options.capture && supportsPhotoCapture()
const removeImage = (index: number) => {
const newUrls = urls.filter((_, i) => i !== index)
setUrls(newUrls)
if (isMultiple) {
cellElement.setValue(newUrls.length > 0 ? newUrls : null)
} else {
cellElement.setValue(newUrls[0] ?? null)
}
}
const handleFiles = async (files: FileList | null) => {
if (!files?.length) return
setUploading(true)
try {
// isMultiple=true ise mevcut url'leri koru, üstüne ekle; false ise sıfırla
const uploadedUrls: string[] = isMultiple ? [...urls] : []
for (const file of Array.from(files)) {
if (options.uploadUrl) {
// HTTP upload
const formData = new FormData()
const fileFieldName: string = options.fileFieldName ?? 'file'
formData.append(fileFieldName, file)
const res = await fetch(options.uploadUrl, { method: 'POST', body: formData })
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
const data = await res.json()
const url = resolveUploadedUrl(data)
uploadedUrls.push(url)
} else {
// uploadUrl yapılandırılmamışsa base64 olarak sakla
const base64 = await toBase64(file)
uploadedUrls.push(base64)
}
}
const newUrls = uploadedUrls.filter(Boolean)
setUrls(newUrls)
const newValue = isMultiple ? newUrls : (newUrls[newUrls.length - 1] ?? null)
cellElement.setValue(newValue)
} catch (e) {
console.error('ImageUploadEditorComponent upload error:', e)
} finally {
setUploading(false)
if (inputRef.current) inputRef.current.value = ''
// Ayni fotografi ikinci kez cekmek degisiklik olayini tetiklemezdi.
if (cameraInputRef.current) cameraInputRef.current.value = ''
}
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', padding: 4 }}>
{urls.map((url, i) => (
<div key={i} style={{ position: 'relative', display: 'inline-block' }}>
<img
src={thumbnailSource(url)}
alt=""
style={{
width: thumbW,
height: thumbH,
objectFit: 'cover',
borderRadius: 4,
border: '1px solid #ddd',
cursor: 'zoom-in',
}}
onMouseEnter={(event) => showImageHoverPreview(url, event)}
onMouseMove={(event) => showImageHoverPreview(url, event)}
onMouseLeave={hideImageHoverPreview}
onClick={() => openImageInNewTab(url)}
/>
<Button
type="button"
title={translate('::App.Platform.Delete')}
variant="plain"
shape="circle"
className="!absolute !h-[18px] !w-[18px] !bg-red-600 !p-0 !text-[11px] !text-white hover:!bg-red-700 active:!bg-red-800"
style={{
position: 'absolute',
top: -6,
right: -6,
background: '#dc3545',
color: '#fff',
border: 'none',
borderRadius: '50%',
width: 18,
height: 18,
cursor: 'pointer',
fontSize: 11,
lineHeight: '18px',
padding: 0,
textAlign: 'center',
}}
onClick={() => removeImage(i)}
>
×
</Button>
</div>
))}
<Input
unstyle
ref={inputRef}
type="file"
accept={options.accept ?? 'image/*'}
capture={options.capture}
multiple={isMultiple}
style={{ display: 'none' }}
onChange={(e) => handleFiles(e.target.files)}
/>
{/* Kamera ayrı bir input'tur: `capture` dosya seçiciyi kapattığı için
tek input ile hem galeri hem kamera sunulamaz. */}
{showCameraButton && (
<Input
ref={cameraInputRef}
unstyle
type="file"
accept={options.accept ?? 'image/*'}
capture="environment"
style={{ display: 'none' }}
onChange={(e) => handleFiles(e.target.files)}
/>
)}
<Button
type="button"
title={translate('::App.ImageUpload.Upload')}
disabled={uploading}
variant="solid"
shape="none"
className="!h-auto !rounded !px-2.5 !py-1 !text-[13px]"
style={{
padding: '4px 10px',
background: uploading ? '#aaa' : '#0078d4',
color: '#fff',
border: 'none',
borderRadius: 4,
cursor: uploading ? 'not-allowed' : 'pointer',
fontSize: 13,
display: 'flex',
alignItems: 'center',
gap: 4,
}}
onClick={() => inputRef.current?.click()}
>
{uploading ? (
<FaSpinner style={{ fontSize: 14 }} className="spin" />
) : (
<FaUpload style={{ fontSize: 14 }} />
)}
</Button>
{showCameraButton && (
<Button
type="button"
title={translate('::App.ImageUpload.TakePhoto')}
disabled={uploading}
variant="solid"
shape="none"
className="!h-auto !rounded !bg-[#0078d4] !px-2.5 !py-1 !text-[13px] !text-white"
onClick={() => cameraInputRef.current?.click()}
>
<FaCamera style={{ fontSize: 14 }} />
</Button>
)}
</div>
)
}
export { ImageUploadEditorComponent }

View file

@ -1,114 +0,0 @@
import { ReactElement } from 'react'
import { Button } from '@/components/ui'
import {
hideImageHoverPreview,
openImageInNewTab,
showImageHoverPreview,
} from '../../form/editors/imageHoverPreview'
import {
NO_IMAGE,
normalizeImageSize,
normalizeImageValue,
parseJsonObject,
} from '../shared/imageValue'
const resolveTemplateValue = (templateData: any) => {
const dataField =
templateData?.dataField ||
templateData?.item?.dataField ||
templateData?.editorOptions?.name ||
templateData?.name
const fieldCandidates = String(dataField || '')
.split(':')
.reduce<string[]>(
(fields, field) => {
const value = field.trim()
return value ? [...fields, value] : fields
},
dataField ? [String(dataField)] : [],
)
const formDataSources = [
templateData?.component?.option?.('formData'),
templateData?.formData,
templateData?.data,
templateData?.fallbackFormData,
].filter((source) => source && typeof source === 'object')
for (const formData of formDataSources) {
for (const field of fieldCandidates) {
if (Object.prototype.hasOwnProperty.call(formData, field)) {
return formData[field]
}
}
const actualKey = Object.keys(formData).find((key) =>
fieldCandidates.some((field) => key.toLowerCase() === field.toLowerCase()),
)
if (actualKey) {
return formData[actualKey]
}
}
return templateData?.value ?? templateData?.editorOptions?.value
}
const resolveTemplateOptions = (templateData: any) => {
const itemOptions =
templateData?.editorOptions?.imageUploadOptions ??
templateData?.imageUploadOptions ??
parseJsonObject(templateData?.editorOptions?.editorOptions) ??
parseJsonObject(templateData?.editorOptions)
return itemOptions && typeof itemOptions === 'object' ? itemOptions : {}
}
const ImageViewerEditorComponent = (templateData: any): ReactElement => {
const value = resolveTemplateValue(templateData)
const options = resolveTemplateOptions(templateData)
const urls = normalizeImageValue(value)
const thumbW = normalizeImageSize(options?.width, 80)
const thumbH = normalizeImageSize(options?.height, 80)
if (!urls.length) {
return <div className="text-gray-400 px-1 py-2">-</div>
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', padding: 4 }}>
{urls.map((url, index) => (
<Button
key={`${url}-${index}`}
type="button"
onClick={() => openImageInNewTab(url)}
variant="plain"
shape="none"
className="!border-0 !bg-transparent !p-0 hover:!bg-transparent active:!bg-transparent dark:hover:!bg-transparent dark:active:!bg-transparent"
style={{ cursor: 'zoom-in' }}
>
<img
src={url}
alt=""
onMouseEnter={(event) => showImageHoverPreview(url, event)}
onMouseMove={(event) => showImageHoverPreview(url, event)}
onMouseLeave={hideImageHoverPreview}
style={{
width: thumbW,
height: thumbH,
objectFit: 'cover',
borderRadius: 4,
display: 'block',
}}
onError={({ currentTarget }) => {
currentTarget.onerror = null
currentTarget.src = NO_IMAGE
}}
/>
</Button>
))}
</div>
)
}
export { ImageViewerEditorComponent }

View file

@ -1,42 +0,0 @@
import TagBox from 'devextreme-react/tag-box'
import { ReactElement } from 'react'
import { createColoredTagRender } from '../../shared/lookupColor'
const TagBoxEditorComponent = (cellElement: any): ReactElement => {
const col = cellElement.column
if (!col) {
return <></>
}
const options = col.extras.tagBoxOptions
const val = Array.isArray(cellElement.value) ? cellElement.value : [cellElement.value]
const lookup = col?.lookup
const dataSource =
typeof lookup?.dataSource === 'function'
? lookup.dataSource(cellElement.row)
: (lookup?.dataSource ?? [])
return (
<TagBox
{...col.extras.editorOptions}
defaultValue={val}
dataSource={dataSource}
valueExpr={lookup?.valueExpr}
displayExpr={lookup?.displayExpr}
tagRender={createColoredTagRender(lookup?.displayExpr)}
showClearButton={options?.showClearButton}
showSelectionControls={options?.showSelectionControls ?? true}
maxDisplayedTags={options?.maxDisplayedTags}
showMultiTagOnly={options?.showMultiTagOnly}
applyValueMode={options?.applyValueMode ?? 'useButtons'}
searchEnabled={options?.searchEnabled}
acceptCustomValue={options?.acceptCustomValue}
onValueChanged={(e) => {
cellElement.setValue(typeof e.value === 'string' ? e.value?.trim() : e.value)
}}
onSelectionChanged={cellElement.component.updateDimensions}
></TagBox>
)
}
export { TagBoxEditorComponent }

View file

@ -0,0 +1,172 @@
import type { ReactElement } from 'react'
import type { ImageUploadOptionsDto } from '@/proxy/form/models'
import { GridBoxEditorComponent as FormGridBoxEditor } from '../../form/editors/GridBoxEditorComponent'
import { ImageUploadEditorComponent as FormImageUploadEditor } from '../../form/editors/ImageUploadEditorComponent'
import { ImageViewerEditorComponent as FormImageViewerEditor } from '../../form/editors/ImageViewerEditorComponent'
import { TagBoxEditorComponent as FormTagBoxEditor } from '../../form/editors/TagBoxEditorComponent'
import { parseJsonObject } from '../shared/imageValue'
/**
* Liste düzenleme hücresini (DataGrid/TreeList `editCellTemplate`, CardView form şablonu) form
* sayfasının editör bileşenlerine bağlayan adaptörler. Editörlerin tek uygulaması
* `form/editors/*`tir; burada yalnızca hücre verisi bileşen prop'larına çevrilir.
*/
/**
* Ekranın editorOptions'ı + hücre bağlamının salt-okunur/devre dışı kararı. Bağlamın geri
* kalanı (`value`, `onValueChanged`…) editörün kendi değer akışını ezeceği için alınmaz.
*/
const getCellEditorOptions = (cellElement: any) => {
const { readOnly, disabled } = cellElement.editorOptions ?? {}
return {
...cellElement.column?.extras?.editorOptions,
...(readOnly !== undefined ? { readOnly } : {}),
...(disabled !== undefined ? { disabled } : {}),
}
}
/** Kolonun lookup kaynağı; cascade ebeveynleri düzenlenen satırdan okunur. */
const getCellLookupDataSource = (cellElement: any) => {
const lookup = cellElement.column?.lookup
return typeof lookup?.dataSource === 'function'
? lookup.dataSource(cellElement.row)
: (lookup?.dataSource ?? [])
}
/** Liste düzenleme hücresi; editör form sayfasıyla aynı bileşendir. */
const TagBoxEditorComponent = (cellElement: any): ReactElement => {
const col = cellElement.column
if (!col) {
return <></>
}
return (
<FormTagBoxEditor
setDefaultValue
value={cellElement.value}
options={col.extras.tagBoxOptions}
col={col.colData}
editorOptions={{
...getCellEditorOptions(cellElement),
dataSource: getCellLookupDataSource(cellElement),
onSelectionChanged: cellElement.component.updateDimensions,
}}
onValueChanged={(value) => cellElement.setValue(value)}
/>
)
}
/** Liste düzenleme hücresi; editör form sayfasıyla aynı bileşendir. */
const GridBoxEditorComponent = (cellElement: any): ReactElement => {
const col = cellElement.column
if (!col) {
return <></>
}
return (
<FormGridBoxEditor
value={cellElement.value}
options={col.extras.gridBoxOptions}
col={col.colData}
editorOptions={{
...getCellEditorOptions(cellElement),
dataSource: getCellLookupDataSource(cellElement),
}}
onValueChanged={(value) => cellElement.setValue(value)}
/>
)
}
const resolveOptions = (column: any): ImageUploadOptionsDto => {
const configuredOptions = column?.extras?.imageUploadOptions
if (configuredOptions && Object.keys(configuredOptions).length > 0) {
return configuredOptions
}
return parseJsonObject(column?.extras?.editorOptions ?? column?.editorOptions) ?? {}
}
/** Liste düzenleme hücresi (Grid, Tree, CardView); editör form sayfasıyla aynı bileşendir. */
const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
const col = cellElement.column
if (!col) {
return <></>
}
return (
<FormImageUploadEditor
value={cellElement.value}
options={resolveOptions(col)}
editorOptions={getCellEditorOptions(cellElement)}
onValueChanged={(value) => cellElement.setValue(value)}
/>
)
}
const resolveTemplateValue = (templateData: any) => {
const dataField =
templateData?.dataField ||
templateData?.item?.dataField ||
templateData?.editorOptions?.name ||
templateData?.name
const fieldCandidates = String(dataField || '')
.split(':')
.reduce<string[]>(
(fields, field) => {
const value = field.trim()
return value ? [...fields, value] : fields
},
dataField ? [String(dataField)] : [],
)
const formDataSources = [
templateData?.component?.option?.('formData'),
templateData?.formData,
templateData?.data,
templateData?.fallbackFormData,
].filter((source) => source && typeof source === 'object')
for (const formData of formDataSources) {
for (const field of fieldCandidates) {
if (Object.prototype.hasOwnProperty.call(formData, field)) {
return formData[field]
}
}
const actualKey = Object.keys(formData).find((key) =>
fieldCandidates.some((field) => key.toLowerCase() === field.toLowerCase()),
)
if (actualKey) {
return formData[actualKey]
}
}
return templateData?.value ?? templateData?.editorOptions?.value
}
const resolveTemplateOptions = (templateData: any) => {
const itemOptions =
templateData?.editorOptions?.imageUploadOptions ??
templateData?.imageUploadOptions ??
parseJsonObject(templateData?.editorOptions?.editorOptions) ??
parseJsonObject(templateData?.editorOptions)
return itemOptions && typeof itemOptions === 'object' ? itemOptions : {}
}
/** Liste düzenleme şablonu (Grid, Tree, CardView); görünüm form sayfasıyla aynı bileşendir. */
const ImageViewerEditorComponent = (templateData: any): ReactElement => (
<FormImageViewerEditor
value={resolveTemplateValue(templateData)}
options={resolveTemplateOptions(templateData)}
/>
)
export {
GridBoxEditorComponent,
ImageUploadEditorComponent,
ImageViewerEditorComponent,
TagBoxEditorComponent,
}

View file

@ -1,19 +1,21 @@
/**
* Lookup cascade (bağımlı açılır liste) davranışı.
* Grid ve Tree aynı kuralları paylaşır.
* Grid, Tree ve form sayfası aynı kuralları paylaşır.
*/
import type { GridDto } from '@/proxy/form/models'
import type { ColumnFormatDto, GridDto } from '@/proxy/form/models'
import { getActiveEditingForm } from './editingForm'
export type CascadeInfo = {
type CascadeInfo = {
parentFields: string[]
childFields?: string[]
}
export type CascadeMaps = {
/** fieldName -> cascade bilgisi */
/** child fieldName -> cascade bilgisi */
cascadeFieldsMap: Map<string, CascadeInfo>
/** parentField -> etkilediği child alan adları */
parentToChildrenMap: Map<string, Set<string>>
/** fieldName -> değişince boşaltılacak alanlar */
clearFieldsMap: Map<string, string[]>
}
const splitFields = (value?: string | null) =>
@ -22,103 +24,182 @@ const splitFields = (value?: string | null) =>
.map((field) => field.trim())
.filter(Boolean)
export const getCascadeParentFieldsOf = (colFormat?: Pick<ColumnFormatDto, 'lookupDto'>) =>
splitFields(colFormat?.lookupDto?.cascadeParentFields)
/**
* Bir alan değiştiğinde boşaltılacak alanlar: ebeveyni bu alan olan çocuklar
* (`CascadeParentFields`) ile alanın kendi `CascadeEmptyFields` listesi.
*/
export const getCascadeClearFields = (
columns: (Pick<ColumnFormatDto, 'fieldName' | 'lookupDto'> | undefined)[],
fieldName: string,
) => {
const fields = new Set<string>()
columns.forEach((column) => {
if (!column?.fieldName) return
if (column.fieldName === fieldName) {
splitFields(column.lookupDto?.cascadeEmptyFields).forEach((field) => fields.add(field))
} else if (getCascadeParentFieldsOf(column).includes(fieldName)) {
fields.add(column.fieldName)
}
})
fields.delete(fieldName)
return [...fields]
}
/** Ebeveynlerinden biri boşsa çocuk kilitlenir (bool ebeveynde `false` da boştur). */
export const isCascadeParentEmpty = (parentFields: string[], data?: Record<string, any>) =>
parentFields.some((parentField) => !data?.[parentField])
/** Ekranın cascade ilişkilerini tek geçişte çıkarır; `clearFieldsMap` `getCascadeClearFields` ile aynıdır. */
export const createCascadeMaps = (gridDto?: GridDto): CascadeMaps => {
const cascadeFieldsMap = new Map<string, CascadeInfo>()
const parentToChildrenMap = new Map<string, Set<string>>()
const clearFieldSets = new Map<string, Set<string>>()
const addClearField = (fieldName: string, clearField: string) => {
if (fieldName === clearField) return
const fields = clearFieldSets.get(fieldName) ?? new Set<string>()
fields.add(clearField)
clearFieldSets.set(fieldName, fields)
}
gridDto?.columnFormats.forEach((col) => {
if (!col.fieldName || !col.lookupDto?.cascadeParentFields) return
const fieldName = col.fieldName
if (!fieldName) return
const parentFields = splitFields(col.lookupDto.cascadeParentFields)
const childFields = col.lookupDto.cascadeEmptyFields
? splitFields(col.lookupDto.cascadeEmptyFields)
: undefined
splitFields(col.lookupDto?.cascadeEmptyFields).forEach((field) =>
addClearField(fieldName, field),
)
cascadeFieldsMap.set(col.fieldName, { parentFields, childFields })
const parentFields = getCascadeParentFieldsOf(col)
if (!parentFields.length) return
cascadeFieldsMap.set(fieldName, { parentFields })
parentFields.forEach((parentField) => {
const children = parentToChildrenMap.get(parentField) ?? new Set<string>()
children.add(col.fieldName!)
children.add(fieldName)
parentToChildrenMap.set(parentField, children)
addClearField(parentField, fieldName)
})
})
return { cascadeFieldsMap, parentToChildrenMap }
const clearFieldsMap = new Map<string, string[]>(
[...clearFieldSets].map(([fieldName, fields]) => [fieldName, [...fields]]),
)
return { cascadeFieldsMap, parentToChildrenMap, clearFieldsMap }
}
/** Alan cascade zincirinde (ebeveyn ya da çocuk olarak) yer alıyor mu? */
export const hasCascadeBehavior = (maps: CascadeMaps, fieldName: string) =>
maps.cascadeFieldsMap.has(fieldName) ||
maps.parentToChildrenMap.has(fieldName) ||
maps.clearFieldsMap.has(fieldName)
const getEditingRow = (grid: any) => {
const rowKey = grid.option('editing.editRowKey')
return {
rowIndex: grid.getRowIndexByKey(rowKey) as number,
rowData: grid.getVisibleRows().find((row: any) => row.key === rowKey)?.data as
| Record<string, any>
| undefined,
}
}
/** Parent değiştikten sonra etkilenen çocuk editörlerin kilidini yeniler. */
const updateChildDisabledStates = (
grid: any,
gridDto: GridDto,
maps: CascadeMaps,
children: Iterable<string>,
rowData: Record<string, any>,
) => {
const form = getActiveEditingForm(grid)
if (!form?.getEditor) return
for (const childFieldName of children) {
const childInfo = maps.cascadeFieldsMap.get(childFieldName)
if (!childInfo) continue
try {
const childColumn = gridDto.columnFormats.find(
(column) => column.fieldName === childFieldName,
)
const childEditorOptions = childColumn?.editorOptions
? JSON.parse(childColumn.editorOptions)
: {}
form
.getEditor(childFieldName)
?.option(
'disabled',
childEditorOptions?.disabled === true ||
isCascadeParentEmpty(childInfo.parentFields, rowData),
)
} catch (err) {
console.error(`Cascade child editor güncellenemedi: "${childFieldName}"`, err)
}
}
}
/**
* Bir alanın değeri değiştiğinde cascade kurallarını düzenlenen satıra uygular:
* bağımlı alanları boşaltır ve çocuk editörlerin kilidini yeniler. Grid editörleri
* (`onEditorPreparing`) ve platform editörleri (TagBox, GridBox…) aynı yoldan geçer.
*/
export const applyCascadeChange = (
grid: any,
gridDto: GridDto,
maps: CascadeMaps,
fieldName: string,
value: any,
) => {
if (!grid?.getVisibleRows) return
const { rowIndex, rowData } = getEditingRow(grid)
const clearFields = maps.clearFieldsMap.get(fieldName) ?? []
if (rowIndex >= 0) {
clearFields.forEach((childField) => grid.cellValue(rowIndex, childField, null))
}
const affectedChildren = maps.parentToChildrenMap.get(fieldName)
if (!affectedChildren?.size || !rowData) return
const nextRowData = {
...rowData,
[fieldName]: value,
...Object.fromEntries(clearFields.map((field) => [field, null])),
}
updateChildDisabledStates(grid, gridDto, maps, affectedChildren, nextRowData)
}
type AttachCascadeParams = {
editor: any
gridDto: GridDto
cascadeInfo: CascadeInfo
maps: CascadeMaps
}
/**
* `onEditorPreparing` içinde cascade davranışını editöre bağlar:
* - parent değiştiğinde child alanları temizler,
* - etkilenen child editörlerin `disabled` durumunu günceller,
* - değer değişince bağımlı alanları temizler ve çocukların kilidini yeniler,
* - form ilk açıldığında parent'ı boş olan alanı devre dışı bırakır.
*/
export const attachCascadeBehavior = ({
editor,
gridDto,
cascadeInfo,
maps,
}: AttachCascadeParams) => {
const { cascadeFieldsMap, parentToChildrenMap } = maps
const affectedChildren = parentToChildrenMap.get(editor.dataField)
export const attachCascadeBehavior = ({ editor, gridDto, maps }: AttachCascadeParams) => {
const fieldName = editor.dataField as string
const previousHandler = editor.editorOptions.onValueChanged
editor.editorOptions.onValueChanged = (e: any) => {
previousHandler?.(e)
const grid = editor.component
const rowKey = grid.option('editing.editRowKey')
const rowIndex = grid.getRowIndexByKey(rowKey)
// Parent değiştiğinde bağımlı alanları temizle.
if (cascadeInfo.childFields && rowIndex >= 0) {
cascadeInfo.childFields.forEach((childField) => grid.cellValue(rowIndex, childField, null))
}
if (!affectedChildren?.size) return
const formInstance = grid.option('editing.form') as any
if (!formInstance?.getEditor) return
const rowData = grid.getVisibleRows().find((row: any) => row.key === rowKey)?.data
if (!rowData) return
affectedChildren.forEach((childFieldName) => {
const childInfo = cascadeFieldsMap.get(childFieldName)
if (!childInfo) return
try {
const childColumn = gridDto.columnFormats.find(
(column) => column.fieldName === childFieldName,
)
const childEditorOptions = childColumn?.editorOptions
? JSON.parse(childColumn.editorOptions)
: {}
const childEditor = formInstance.getEditor(childFieldName)
childEditor?.option(
'disabled',
childEditorOptions?.disabled === true ||
childInfo.parentFields.some((parentField) => !rowData[parentField]),
)
} catch (err) {
console.error(`Cascade child editor güncellenemedi: "${childFieldName}"`, err)
}
})
applyCascadeChange(editor.component, gridDto, maps, fieldName, e.value)
}
// İlk açılışta parent'ı dolu değilse editörü kilitle.
const grid = editor.component
const rowKey = grid.option('editing.editRowKey')
const rowData = grid.getVisibleRows().find((row: any) => row.key === rowKey)?.data
if (rowData && cascadeInfo.parentFields.some((parentField) => !rowData[parentField])) {
const cascadeInfo = maps.cascadeFieldsMap.get(fieldName)
const { rowData } = getEditingRow(editor.component)
if (cascadeInfo && rowData && isCascadeParentEmpty(cascadeInfo.parentFields, rowData)) {
editor.editorOptions.disabled = true
}
}

View file

@ -9,7 +9,7 @@ export const isTouchLikeDevice = () =>
typeof window !== 'undefined' &&
(window.matchMedia?.('(pointer: coarse)').matches || window.matchMedia?.('(hover: none)').matches)
export const isMobileViewport = () =>
const isMobileViewport = () =>
typeof window !== 'undefined' && window.matchMedia?.('(max-width: 767px)').matches
/** Popup'ın mobil davranış kurallarıyla açılıp açılmayacağını belirler. */

View file

@ -27,7 +27,7 @@ import {
setFormEditingExtraItemValues,
} from '../Utils'
export type EditorOptionsWithButtons = { buttons?: any[] } & Record<string, any>
type EditorOptionsWithButtons = { buttons?: any[] } & Record<string, any>
/**
* `editorOptions.buttons` içinde bu değeri taşıyan `onClick`, kamerayla barkod
@ -44,6 +44,41 @@ const scanBarcodeIntoEditor = async (e: any) => {
}
}
/**
* Yapılandırmadan gelen buton `onClick` değerlerini fonksiyona çevirir: barkod token'ı ya da
* fonksiyon metni. Liste popup'ı ve form sayfası aynı çözümü kullanır; nesneler yerinde
* değiştirilmez, aynı tanım iki kez çözülse de metin kaybolmaz.
*/
export const resolveEditorButtons = (buttons?: any[]) =>
buttons?.map((button: any) => {
const onClick = button?.options?.onClick
if (typeof onClick !== 'string') return button
return {
...button,
options: {
...button.options,
// Yapılandırmadan gelen fonksiyon tanımı (platform tasarımı gereği).
onClick: onClick === SCAN_BARCODE_TOKEN ? scanBarcodeIntoEditor : eval(`(${onClick})`),
},
}
})
/** Buton tıklamasına kayıt bağlamını (formData, fieldName, mode…) ekler. */
export const withButtonContext = (
buttons: any[] | undefined,
getContext: () => Record<string, any>,
) =>
buttons?.map((button: any) => {
const onClick = button?.options?.onClick
if (typeof onClick !== 'function') return button
return {
...button,
options: { ...button.options, onClick: (e: any) => onClick({ ...e, ...getContext() }) },
}
})
/* ------------------------------------------------------------------ *
* Satır anahtarı yardımcıları
* ------------------------------------------------------------------ */
@ -400,6 +435,9 @@ const dateTimeEditorDefaults = {
displayFormat: 'shortDateShortTime',
}
/** Tarih editörünün klavye/temizleme davranışı; liste popup'ı ve form sayfası aynı açılır. */
const dateBoxDefaults = { useMaskBehavior: true, openOnFieldClick: true, showClearButton: true }
/** Kolonun DB tipine göre tarih editörü varsayılanlarını döner. */
const getDateEditorDefaults = (sourceDbType?: DbTypeEnum) => {
if (sourceDbType === DbTypeEnum.Date) return dateEditorDefaults
@ -429,10 +467,26 @@ export const resolveEditorType = (editorType2?: EditorType2) => {
export const withEditorTypeDefaults = <T extends Record<string, any>>(
editorType2: EditorType2 | undefined,
editorOptions: T,
): T =>
editorType2 === 'dxHtmlEditor'
? mergeHtmlEditorOptions(editorOptions)
: withPhoneEditorOptions(editorOptions)
sourceDbType?: DbTypeEnum,
): T => {
if (editorType2 === 'dxHtmlEditor') return mergeHtmlEditorOptions(editorOptions)
const dateDefaults = {
...(editorType2 === 'dxDateBox' ? dateBoxDefaults : {}),
...(getDateEditorDefaults(sourceDbType) ?? {}),
}
return withPhoneEditorOptions({ ...dateDefaults, ...editorOptions })
}
/** Lookup alanının editör varsayılanları; veri kaynağını liste kolonu ya da form sayfası verir. */
export const getLookupEditorDefaults = (colFormat: ColumnFormatDto) =>
colFormat.lookupDto?.dataSourceType
? {
valueExpr: colFormat.lookupDto.valueExpr?.toLowerCase(),
displayExpr: colFormat.lookupDto.displayExpr?.toLowerCase(),
searchEnabled: true,
}
: {}
type FormItemMapperParams = {
mode: RowMode
@ -477,9 +531,7 @@ export const createFormItemMapper = ({
console.error('EditorOptions parse error:', fieldName, err)
}
const defaultEditorOptions: EditorOptionsWithButtons = {
...(getDateEditorDefaults(colFormat.sourceDbType) ?? {}),
}
const defaultEditorOptions: EditorOptionsWithButtons = getLookupEditorDefaults(colFormat)
if (colFormat.placeHolder) {
defaultEditorOptions.placeholder = translate('::' + colFormat.placeHolder)
@ -495,22 +547,14 @@ export const createFormItemMapper = ({
? { readOnly: true }
: {}
const editorOptions: EditorOptionsWithButtons = withEditorTypeDefaults(colFormat.editorType2, {
...defaultEditorOptions,
...parsedEditorOptions,
...forcedEditorOptions,
})
const editorOptions: EditorOptionsWithButtons = withEditorTypeDefaults(
colFormat.editorType2,
{ ...defaultEditorOptions, ...parsedEditorOptions, ...forcedEditorOptions },
colFormat.sourceDbType,
)
if (editorOptions.buttons) {
editorOptions.buttons = editorOptions.buttons.map((btn: any) => {
if (btn?.options?.onClick === SCAN_BARCODE_TOKEN) {
btn.options.onClick = scanBarcodeIntoEditor
} else if (typeof btn?.options?.onClick === 'string') {
// Yapılandırmadan gelen fonksiyon tanımı (platform tasarımı gereği).
btn.options.onClick = eval(`(${btn.options.onClick})`)
}
return btn
})
editorOptions.buttons = resolveEditorButtons(editorOptions.buttons)
}
const item: SimpleItemWithColData = {

View file

@ -6,12 +6,24 @@
*/
import type { MutableRefObject } from 'react'
import { executeEditorScript } from '@/utils/editorScriptRuntime'
import { attachFieldValueBroadcast } from '@/utils/phoneFormat'
import type { GridDto } from '@/proxy/form/models'
import type { RowMode } from '../../form/types'
import { getEditingFormFields } from '../Utils'
import {
applyCascadeChange,
attachCascadeBehavior,
type CascadeMaps,
hasCascadeBehavior,
isCascadeParentEmpty,
} from './cascade'
import {
getActiveEditingForm,
getValueByField,
resolveEditorButtons,
setFormEditorReadOnly,
shouldRunEditorScriptOnContentReady,
withButtonContext,
} from './editingForm'
type EditingFormHandlerParams = {
@ -199,18 +211,16 @@ type EditorValueChangedScriptParams = {
editorScript: string
fieldName?: string
editingFormInstanceRef: MutableRefObject<any>
getActiveEditingForm: (component: any) => any
}
/**
* `onEditorPreparing` içinde editör script'ini `onValueChanged` zincirine ekler.
*/
export const attachEditorValueChangedScript = ({
const attachEditorValueChangedScript = ({
editor,
editorScript,
fieldName,
editingFormInstanceRef,
getActiveEditingForm,
}: EditorValueChangedScriptParams) => {
const previousHandler = editor.editorOptions.onValueChanged
const editorDataField = editor.dataField
@ -245,22 +255,161 @@ export const attachEditorValueChangedScript = ({
}
/**
* Editör butonlarının `onClick` çağrılarına satır bağlamını (formData, rowKey…) ekler.
* Editör butonlarının `onClick` çağrılarına kayıt bağlamını (formData, fieldName, mode, rowKey…)
* ekler. Form sayfası aynı bağlamı `withButtonContext` ile verir.
*/
export const attachEditorButtonContext = (editor: any) => {
const attachEditorButtonContext = (editor: any, mode: RowMode) => {
if (!editor.editorOptions?.buttons) return
editor.editorOptions.buttons = editor.editorOptions.buttons.map((btn: any) => {
if (typeof btn?.options?.onClick !== 'function') return btn
const originalClick = btn.options.onClick
btn.options.onClick = (e: any) => {
const grid = editor.component
const { rowKey, rowIndex } = getEditingRowIndex(grid)
const formData = grid.getVisibleRows().find((row: any) => row.key === rowKey)?.data || {}
originalClick({ ...e, formData, fieldName: editor.dataField, rowKey, rowIndex })
}
return btn
editor.editorOptions.buttons = withButtonContext(editor.editorOptions.buttons, () => {
const grid = editor.component
const { rowKey, rowIndex } = getEditingRowIndex(grid)
const formData = grid.getVisibleRows().find((row: any) => row.key === rowKey)?.data || {}
return { formData, fieldName: editor.dataField, mode, rowKey, rowIndex }
})
}
type PrepareEditingEditorParams = {
editor: any
gridDto: GridDto
mode: RowMode
cascadeMaps: CascadeMaps
editingFormInstanceRef: MutableRefObject<any>
}
/**
* Grid ve Tree `onEditorPreparing` ortak gövdesi: ekranın editorOptions'ı, ekleme/düzenleme
* kilidi, cascade, telefon maskesi, editör script'i ve buton bağlamı.
*/
export const prepareEditingEditor = ({
editor,
gridDto,
mode,
cascadeMaps,
editingFormInstanceRef,
}: PrepareEditingEditorParams) => {
if (editor.parentType !== 'dataRow' || !editor.dataField) return
const columnFormat = gridDto.columnFormats.find((column) => column.fieldName === editor.dataField)
const isNewRow = Boolean(editor.row?.isNewRow) || mode === 'new'
if (columnFormat?.editorOptions) {
try {
const parsedEditorOptions = JSON.parse(columnFormat.editorOptions)
editor.editorOptions = {
...editor.editorOptions,
...parsedEditorOptions,
// Ham tanımdaki buton onClick'i metindir; form sayfasıyla aynı çözümden geçer.
...(parsedEditorOptions.buttons
? { buttons: resolveEditorButtons(parsedEditorOptions.buttons) }
: {}),
}
} catch (err) {
console.error('EditorOptions parse error:', columnFormat.fieldName, err)
}
}
const hasReadOnlyEditorOption = editor.editorOptions?.readOnly === true
const hasDisabledEditorOption = editor.editorOptions?.disabled === true
if (
(isNewRow && columnFormat?.allowAdding === false) ||
(!isNewRow && columnFormat?.allowEditing === false)
) {
editor.editorOptions.readOnly = true
} else if (isNewRow && columnFormat?.allowAdding === true) {
if (!hasReadOnlyEditorOption) editor.editorOptions.readOnly = false
if (!hasDisabledEditorOption) editor.editorOptions.disabled = false
}
if (hasCascadeBehavior(cascadeMaps, editor.dataField)) {
attachCascadeBehavior({ editor, gridDto, maps: cascadeMaps })
}
// phoneCountry maskesi ülke alanını bu olaydan izler; DataGrid formu fieldDataChanged yayınlamaz.
attachFieldValueBroadcast(editor)
// Yalnızca düzenleme formunda yer alan alanların script'i çalıştırılır.
if (columnFormat?.editOrderNo != null && columnFormat.editorScript) {
attachEditorValueChangedScript({
editor,
editorScript: columnFormat.editorScript,
fieldName: columnFormat.fieldName,
editingFormInstanceRef,
})
}
attachEditorButtonContext(editor, mode)
}
type CustomEditorCellAdapterParams = {
getGridDto: () => GridDto | undefined
getMode: () => RowMode
cascadeMaps: CascadeMaps
editingFormInstanceRef: MutableRefObject<any>
}
/**
* Platform editörleri (TagBox, GridBox, ImageUpload) DataGrid'de `editCellTemplate` ile çizilir;
* `onEditorPreparing` onlar için çalışmaz. Bu adaptör hücre bağlamına form sayfasındaki
* davranışı ekler: salt-okunur/devre dışı durumu, cascade ve editör script'i.
*/
export const createCustomEditorCellAdapter =
({ getGridDto, getMode, cascadeMaps, editingFormInstanceRef }: CustomEditorCellAdapterParams) =>
(cell: any) => {
const gridDto = getGridDto()
const fieldName: string | undefined = cell?.column?.dataField
if (!gridDto || !fieldName || !cell.setValue) return cell
const grid = cell.component
const columnFormat = gridDto.columnFormats.find((column) => column.fieldName === fieldName)
const isNewRow = Boolean(cell.row?.isNewRow) || getMode() === 'new'
const rowData: Record<string, any> = cell.row?.data ?? {}
const columnEditorOptions = cell.column?.extras?.editorOptions ?? {}
// Form item'ının editorOptions'ı yetki/URL kilidini ve script'in readOnly kararını taşır.
const form = editingFormInstanceRef.current ?? getActiveEditingForm(grid)
let itemEditorOptions: Record<string, any> | undefined
try {
itemEditorOptions = form?.itemOption?.(fieldName)?.editorOptions
} catch {
itemEditorOptions = undefined
}
const readOnly =
itemEditorOptions && 'readOnly' in itemEditorOptions
? itemEditorOptions.readOnly === true
: columnEditorOptions.readOnly === true ||
(isNewRow
? columnFormat?.canCreate === false || columnFormat?.allowAdding === false
: columnFormat?.canUpdate === false || columnFormat?.allowEditing === false)
const cascadeInfo = cascadeMaps.cascadeFieldsMap.get(fieldName)
const disabled =
columnEditorOptions.disabled === true ||
(!!cascadeInfo && isCascadeParentEmpty(cascadeInfo.parentFields, rowData))
const setValue = (value: any) => {
cell.setValue(value)
applyCascadeChange(grid, gridDto, cascadeMaps, fieldName, value)
if (columnFormat?.editOrderNo == null || !columnFormat.editorScript) return
const { rowIndex } = getEditingRowIndex(grid)
const activeForm = editingFormInstanceRef.current ?? getActiveEditingForm(grid)
try {
executeEditorScript(columnFormat.editorScript, {
formData: { ...rowData, [fieldName]: value },
e: { component: activeForm, dataField: fieldName, value, scriptEvent: 'valueChanged' },
editor: { dataField: fieldName, component: grid },
runtimeSetEditorReadOnly: (field: string, flag: boolean) =>
setFormEditorReadOnly(activeForm, field, flag),
setFormData: createGridCellWriter(grid, rowIndex),
})
} catch (err) {
console.error('Script exec error', fieldName, err)
}
}
return { ...cell, editorOptions: { ...cell.editorOptions, readOnly, disabled }, setValue }
}

View file

@ -33,7 +33,7 @@ export const normalizeImageSize = (value: unknown, fallback: number) => {
}
/** Obje formatındaki görsel değerinden (url/src/fileUrl/path/value) kaynağı çıkarır. */
export const getImageSource = (value: unknown) => {
const getImageSource = (value: unknown) => {
if (!value) return ''
if (typeof value === 'string') return value.trim()
@ -49,7 +49,7 @@ const isProbablyBase64Image = (value: string) =>
value.length > 80 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
/** Ham base64 içeriğini data URL'e çevirir; zaten URL ise dokunmaz. */
export const toImageSource = (value: string) => {
const toImageSource = (value: string) => {
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed.startsWith('data:image/') || !isProbablyBase64Image(trimmed)) return trimmed

View file

@ -7,7 +7,7 @@ import type { GridDto } from '@/proxy/form/models'
import { workflowService } from '@/services/workflow.service'
import { getPersistedInsertedKey } from './editingForm'
export const showWorkflowMessages = (messages: string[] = []) => {
const showWorkflowMessages = (messages: string[] = []) => {
if (!messages.length) return
toast.push(

View file

@ -18,6 +18,7 @@ import {
} from '@/proxy/form/models'
import { addCss, getEditingFormFields, safeJsonParse } from './Utils'
import { clearLookupCache, clearLookupCacheByPrefix } from '../shared/lookupCache'
import { createCascadeMaps } from './shared/cascade'
import { useLookupDataSource } from '../form/useLookupDataSource'
import {
NO_IMAGE,
@ -593,6 +594,7 @@ const useListFormColumns = ({
return columns
}
const { clearFieldsMap: cascadeClearFieldsMap } = createCascadeMaps(gridDto)
const workflowApprovalStatusField =
gridDto.gridOptions?.workflowDto?.approvalStatusFieldName?.toLowerCase()
columnFormats.forEach((colData) => {
@ -748,15 +750,15 @@ const useListFormColumns = ({
dataSource: (o) => lookupDataSource(o, colData),
}
//cascadeEmptyFields verisi dolu ise bu kolon/field bir parent field dir
if (colData.lookupDto.cascadeEmptyFields) {
// parent field guncellendigi zaman bu fonksiyon cagrilir
// Değeri değişince boşaltılacak alanlar (çocuklar + CascadeEmptyFields); popup ve
// satır içi düzenleme form sayfasıyla aynı kuralı izler.
const cascadeClearFields = cascadeClearFieldsMap.get(colData.fieldName)
if (cascadeClearFields?.length) {
column.setCellValue = function (rowData: any, value: any) {
if (!colData.fieldName) return
//console.log({ rowData, value, colData })
rowData[colData.fieldName] = Array.isArray(value) ? value[0] : value
// cascadeEmptyFields alani aralarinda virgul olacak sekilde bosaltilmak istenen alanlari saklar
colData?.lookupDto?.cascadeEmptyFields?.split(',').forEach((emptyField: any) => {
rowData[colData.fieldName] =
Array.isArray(value) && !column.extras?.multiValue ? value[0] : value
cascadeClearFields.forEach((emptyField) => {
rowData[emptyField] = null
})
}

View file

@ -154,7 +154,7 @@ export function IconPickerField({ value, onChange, invalid }: IconPickerFieldPro
// ─── MenuAddDialog ────────────────────────────────────────────────────────────
export interface MenuAddDialogProps {
interface MenuAddDialogProps {
isOpen: boolean
onClose: () => void
initialParentCode: string

View file

@ -17,8 +17,6 @@ type SharedGridBoxEditorProps = {
editorOptions?: any
dropDownOptions?: any
onValueChanged?: (value: any) => void
onCommit?: (value: any) => void
setInitialValueOnContentReady?: boolean
}
const normalizeValue = (value: any) => (Array.isArray(value) ? value : [value])
@ -46,18 +44,11 @@ const SharedGridBoxEditor = ({
editorOptions,
dropDownOptions,
onValueChanged,
onCommit,
setInitialValueOnContentReady,
}: SharedGridBoxEditorProps): ReactElement => {
const gridRef = useRef<DataGridRef>(null)
const currentValueRef = useRef<any>(normalizeValue(value))
currentValueRef.current = normalizeValue(value)
const commitValue = (nextValue: any) => {
currentValueRef.current = nextValue
onCommit?.(nextValue)
}
return (
<DropDownBox
{...editorOptions}
@ -69,9 +60,7 @@ const SharedGridBoxEditor = ({
showClearButton={options?.showClearButton}
acceptCustomValue={options?.acceptCustomValue}
onContentReady={(e) => {
if (setInitialValueOnContentReady) {
e.component.option('value', currentValueRef.current)
}
e.component.option('value', currentValueRef.current)
editorOptions?.onContentReady?.(e)
}}
@ -81,7 +70,6 @@ const SharedGridBoxEditor = ({
onValueChanged?.(nextValue)
if (e.value === null) {
commitValue(null)
e.component.close()
gridRef.current?.instance().deselectAll()
}
@ -106,7 +94,6 @@ const SharedGridBoxEditor = ({
if (options?.selectionMode === 'single') {
const nextValue = event.data.key
dropDown.component.option('value', nextValue)
commitValue(nextValue)
dropDown.component.close()
}
}}
@ -138,7 +125,6 @@ const SharedGridBoxEditor = ({
.map((item: any) => item.key)
dropDown.component.option('value', selectedValue)
commitValue(selectedValue)
dropDown.component.close()
}}
/>

View file

@ -23,7 +23,7 @@ const headerValues = [false, 1, 2, 3, 4, 5, 6]
type ToolbarItem = NonNullable<NonNullable<HtmlEditorProperties['toolbar']>['items']>[number]
export const htmlToolbarItemNames = [
const htmlToolbarItemNames = [
'undo',
'redo',
'separator',

View file

@ -18,7 +18,7 @@ export const findLookupColor = (lookup: any, value: any): string | undefined =>
}
/** Açık zeminde beyaz yazı okunmadığı için yazı rengi zeminin parlaklığına göre seçilir. */
export const readableTextColor = (color: string): string => {
const readableTextColor = (color: string): string => {
const hex = color.replace('#', '')
const full =
hex.length === 3