diff --git a/api/src/Sozsoft.Platform.Domain.Shared/Enums/ListFormTabTypeEnum.cs b/api/src/Sozsoft.Platform.Domain.Shared/Enums/ListFormTabTypeEnum.cs index 6cc5d210..8e611b25 100644 --- a/api/src/Sozsoft.Platform.Domain.Shared/Enums/ListFormTabTypeEnum.cs +++ b/api/src/Sozsoft.Platform.Domain.Shared/Enums/ListFormTabTypeEnum.cs @@ -1,11 +1,14 @@ -namespace Sozsoft.Platform.Enums; +namespace Sozsoft.Platform.Enums; public static class ListFormTabTypeEnum { public const string List = "List"; + public const string Card = "Card"; public const string Tree = "Tree"; + public const string Gantt = "Gantt"; + public const string Scheduler = "Scheduler"; + public const string Todo = "Todo"; public const string Form = "Form"; public const string Chart = "Chart"; public const string Pivot = "Pivot"; } - diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicFormReport.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicFormReport.cs index 9e1b4bf4..e8d2673a 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicFormReport.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicFormReport.cs @@ -30,6 +30,7 @@ public class DynamicFormReport : XtraReport private const string QueryName = "DynamicForm"; private const int HorizontalMargin = 50; private const int LandscapeColumnThreshold = 4; + private const int VerticalMargin = 35; private const float RowHeight = 24; private const float ColumnGap = 10; private static readonly System.Drawing.Color FieldBorderColor = System.Drawing.Color.FromArgb(226, 232, 240); @@ -106,7 +107,7 @@ public class DynamicFormReport : XtraReport await selectQueryManager.PrepareQueriesAsync(listForm, fields, dataSourceType, customizations, queryParams); - var reportFields = GetReportFields(listForm, fields, selectQueryManager, defaultValueHelper); + var reportFields = GetReportFields(fields, selectQueryManager, defaultValueHelper, localizer); var selectQuery = DynamicReportImageHelper.StripReportPaging(DynamicReportImageHelper.ApplyQueryParameters( selectQueryManager.SelectQuery, selectQueryManager.SelectQueryParameters, @@ -133,23 +134,12 @@ public class DynamicFormReport : XtraReport private void ConfigurePage(bool landscape) { - Margins = CreateReportMargins(HorizontalMargin, HorizontalMargin, 35, 35); + Margins = DynamicReportLayoutHelper.CreateMargins(HorizontalMargin, VerticalMargin); PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4; Landscape = landscape; Font = DynamicReportFontHelper.CreateDataFont(); } -#pragma warning disable CA1416 - private static System.Drawing.Printing.Margins CreateReportMargins( - int left, - int right, - int top, - int bottom) - { - return new System.Drawing.Printing.Margins(left, right, top, bottom); - } -#pragma warning restore CA1416 - private void ConfigureDataSource(string selectQuery, string connectionString, DataSourceTypeEnum dataSourceType) { var dataSource = new SqlDataSource(new CustomStringConnectionParameters( @@ -175,7 +165,8 @@ public class DynamicFormReport : XtraReport var topMargin = new TopMarginBand { HeightF = Margins.Top }; var bottomMargin = new BottomMarginBand { HeightF = 32 }; var reportHeader = new ReportHeaderBand { HeightF = 38 }; - var detail = new DetailBand { HeightF = CalculateDetailHeight(formGroups, subReports.Count) }; + // Nihai yükseklik yerleşim tamamlandıktan sonra hesaplanıp atanır. + var detail = new DetailBand { HeightF = RowHeight }; Bands.AddRange([topMargin, reportHeader, detail, bottomMargin]); @@ -195,13 +186,7 @@ public class DynamicFormReport : XtraReport DynamicReportImageHelper.AddFooterTopLine(bottomMargin.Controls, pageWidth); DynamicReportImageHelper.AddCurrentUserNameToFooter(bottomMargin.Controls, currentUserName, pageWidth); - bottomMargin.Controls.Add(new XRPageInfo - { - TextFormatString = "Sayfa {0} / {1}", - BoundsF = new System.Drawing.RectangleF(pageWidth / 2, 8, pageWidth / 2, 18), - Font = DynamicReportFontHelper.CreateValueFont(8F), - TextAlignment = TextAlignment.MiddleRight - }); + DynamicReportLayoutHelper.AddPageInfoToFooter(bottomMargin.Controls, pageWidth); var y = 0F; foreach (var group in formGroups) @@ -451,25 +436,6 @@ public class DynamicFormReport : XtraReport return true; } - private static float CalculateDetailHeight( - IReadOnlyList groups, - int subFormCount) - { - var groupHeight = groups - .Sum(group => (string.IsNullOrWhiteSpace(group.Caption) ? 0 : 26) + - CalculateGroupFieldsHeight(group) + 8); - - var subFormHeight = subFormCount * 32; - return Math.Max(groupHeight + subFormHeight, RowHeight); - } - - private static float CalculateGroupFieldsHeight(FormGroup group) - { - var columnCount = GetSafeColumnCount(group.ColumnCount); - return CreateLayoutRows(group.Fields, columnCount) - .Sum(row => row.Max(x => GetFieldRowHeight(x.Field)) + 3); - } - private static float GetCellWidth(float pageWidth, int columnCount) { return (pageWidth - ColumnGap * (columnCount - 1)) / columnCount; @@ -532,10 +498,10 @@ public class DynamicFormReport : XtraReport } private static List GetReportFields( - ListForm listForm, List fields, ISelectQueryManager selectQueryManager, - DefaultValueHelper defaultValueHelper) + DefaultValueHelper defaultValueHelper, + IStringLocalizer localizer) { var fieldsByName = fields .Where(x => !string.IsNullOrWhiteSpace(x.FieldName) && x.FieldName != "undefined") @@ -565,7 +531,13 @@ public class DynamicFormReport : XtraReport .Select(x => { var selectField = selectFieldsByName[x.Field.FieldName]; - return CreateFormReportField(x.Field, selectField, x.EditorType, x.ColumnSpan, defaultValueHelper); + return CreateFormReportField( + x.Field, + selectField, + x.EditorType, + x.ColumnSpan, + defaultValueHelper, + localizer); }) .Where(x => !string.IsNullOrWhiteSpace(x.BindingName) && x.BindingName != "undefined") .ToList(); @@ -573,7 +545,6 @@ public class DynamicFormReport : XtraReport private static IEnumerable GetFormGroups(ListForm listForm, IReadOnlyList fields) { - var fieldsByName = fields.ToDictionary(x => x.Field.FieldName, StringComparer.OrdinalIgnoreCase); var groups = GetEditingFormGroups(listForm).ToList(); if (groups.Count == 0) { @@ -645,7 +616,7 @@ public class DynamicFormReport : XtraReport yield break; } - foreach (var subForm in subForms.Where(x => string.Equals(x.TabType, ListFormTypeEnum.List, StringComparison.OrdinalIgnoreCase))) + foreach (var subForm in subForms.Where(x => string.Equals(x.TabType, ListFormTabTypeEnum.List, StringComparison.OrdinalIgnoreCase))) { var relation = subForm.Relation? .FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.ParentFieldName) && @@ -679,7 +650,8 @@ public class DynamicFormReport : XtraReport SelectField selectField, string editorType, int columnSpan, - DefaultValueHelper defaultValueHelper) + DefaultValueHelper defaultValueHelper, + IStringLocalizer localizer) { var bindingName = DynamicReportImageHelper.GetReportFieldName(selectField); var lookup = DynamicReportImageHelper.GetLookup(field); @@ -697,7 +669,10 @@ public class DynamicFormReport : XtraReport if (lookup.DataSourceType == UiLookupDataSourceTypeEnum.StaticData) { - var staticLookupValues = DynamicReportImageHelper.GetStaticLookupValues(lookup, defaultValueHelper); + var staticLookupValues = DynamicReportImageHelper.GetStaticLookupValues( + lookup, + defaultValueHelper, + localizer); return new FormReportField( field, staticLookupValues?.Count > 0 ? DynamicReportSqlBuilder.GetLookupBindingName(field) : bindingName, diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicGridReport.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicGridReport.cs index e506deaa..bc8f44a1 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicGridReport.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicGridReport.cs @@ -29,8 +29,8 @@ public class DynamicGridReport : XtraReport private const int A4PortraitWidth = 827; private const int HorizontalMargin = 50; private const int LandscapeColumnThreshold = 5; + private const int VerticalMargin = 35; private const float GroupIndentWidth = 18F; - private static readonly System.Drawing.Color TableBorderColor = System.Drawing.Color.FromArgb(226, 232, 240); private static IServiceScopeFactory _scopeFactory; public bool RequiresLandscapeLayout { get; private set; } @@ -149,12 +149,14 @@ public class DynamicGridReport : XtraReport groupedFields, selectFieldsByName, editorTypesByField, - defaultValueHelper); + defaultValueHelper, + localizer); var reportColumns = CreateReportColumns( reportFields, selectFieldsByName, editorTypesByField, - defaultValueHelper); + defaultValueHelper, + localizer); var allColumns = groupColumns .Concat(reportColumns) @@ -188,40 +190,38 @@ public class DynamicGridReport : XtraReport RequestParameters = false; } - private static List CreateReportColumns( + private static List CreateReportColumns( IEnumerable fields, IReadOnlyDictionary selectFieldsByName, IReadOnlyDictionary editorTypesByField, - DefaultValueHelper defaultValueHelper) + DefaultValueHelper defaultValueHelper, + IStringLocalizer localizer) { return fields .Select(field => { - var selectField = selectFieldsByName[field.FieldName]; editorTypesByField.TryGetValue(field.FieldName, out var editorType); - return CreateReportColumn( + return DynamicReportColumnFactory.Create( field, - selectField, + selectFieldsByName[field.FieldName], defaultValueHelper, editorType, - DynamicReportImageHelper.IsImageEditor(editorType), - editorType == PlatformConsts.EditorTypes.dxHtmlEditor); + localizer); }) - .Where(x => !string.IsNullOrWhiteSpace(x.BindingName) && x.BindingName != "undefined") + .Where(x => x.HasBindingName) .ToList(); } -#pragma warning disable CA1416 private void ConfigurePage( - IReadOnlyCollection visibleFields, + IReadOnlyCollection visibleFields, IStringLocalizer localizer, IReadOnlyDictionary stateWidths) { - Margins = CreateReportMargins(HorizontalMargin, HorizontalMargin, 35, 35); + Margins = DynamicReportLayoutHelper.CreateMargins(HorizontalMargin, VerticalMargin); PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4; var portraitContentWidth = A4PortraitWidth - Margins.Left - Margins.Right; var requestedContentWidth = visibleFields.Sum(x => x.IsImage - ? GetImageColumnWidth(x) + ? x.ImageWidth : DynamicReportGridStateHelper.GetPreferredColumnWidth(x.Field, localizer, stateWidths)); RequiresLandscapeLayout = visibleFields.Count >= LandscapeColumnThreshold || requestedContentWidth > portraitContentWidth; @@ -229,16 +229,6 @@ public class DynamicGridReport : XtraReport Font = DynamicReportFontHelper.CreateDataFont(); } - private static System.Drawing.Printing.Margins CreateReportMargins( - int left, - int right, - int top, - int bottom) - { - return new System.Drawing.Printing.Margins(left, right, top, bottom); - } -#pragma warning restore CA1416 - private void ConfigureDataSource(string selectQuery, string connectionString, DataSourceTypeEnum dataSourceType) { var dataSource = new SqlDataSource(new CustomStringConnectionParameters( @@ -249,8 +239,8 @@ public class DynamicGridReport : XtraReport DataMember = QueryName; } - private static async Task> ApplyImageCountsAsync( - IReadOnlyList columns, + private static async Task> ApplyImageCountsAsync( + IReadOnlyList columns, IDynamicDataRepository dynamicDataRepository, string selectQuery, string connectionString, @@ -286,7 +276,7 @@ public class DynamicGridReport : XtraReport .ToList(); } - private static object GetImageRawValue(IDictionary row, ReportColumn column) + private static object GetImageRawValue(IDictionary row, DynamicReportColumn column) { if (DynamicReportImageHelper.TryGetRowValue(row, column.BindingName, out var value) && DynamicReportImageHelper.HasValue(value)) @@ -299,8 +289,8 @@ public class DynamicGridReport : XtraReport private void BuildLayout( ListForm listForm, - IReadOnlyList fields, - IReadOnlyList groupFields, + IReadOnlyList fields, + IReadOnlyList groupFields, IStringLocalizer localizer, string imageBaseUrl, string currentUserName, @@ -317,14 +307,11 @@ public class DynamicGridReport : XtraReport { HeightF = !isSubReport && !string.IsNullOrWhiteSpace(criteriaText) ? 50 : 32 }; - var pageHeader = new PageHeaderBand { HeightF = 28 }; - var detailHeight = fields.Any(x => x.IsImage) - ? DynamicReportImageHelper.GetGridImageHeight() - : fields.Any(x => x.IsHtml) - ? 48 - : 24; + var pageHeader = new PageHeaderBand { HeightF = DynamicReportLayoutHelper.HeaderRowHeight }; + var detailHeight = DynamicReportLayoutHelper.GetDetailHeight(fields); var detail = new DetailBand { HeightF = detailHeight }; - var reportFooter = new ReportFooterBand { HeightF = HasAnySummary(fields) ? 28 : 0 }; + var hasSummary = DynamicReportLayoutHelper.HasAnySummary(fields); + var reportFooter = new ReportFooterBand { HeightF = hasSummary ? 28 : 0 }; var groupHeaderBands = CreateGroupHeaderBands(groupFields, localizer, pageWidth); Bands.AddRange([topMargin, reportHeader, pageHeader]); @@ -344,34 +331,23 @@ public class DynamicGridReport : XtraReport if (!isSubReport) { DynamicReportImageHelper.AddGeneratedAtToHeader(reportHeader.Controls, pageWidth); - } - if (!isSubReport) - { DynamicReportImageHelper.AddReportCriteriaToHeader(reportHeader.Controls, criteriaText, pageWidth); } DynamicReportImageHelper.AddFooterTopLine(bottomMargin.Controls, pageWidth); DynamicReportImageHelper.AddCurrentUserNameToFooter(bottomMargin.Controls, currentUserName, pageWidth); + DynamicReportLayoutHelper.AddPageInfoToFooter(bottomMargin.Controls, pageWidth); - var pageInfo = new XRPageInfo - { - TextFormatString = "Sayfa {0} / {1}", - BoundsF = new System.Drawing.RectangleF(pageWidth / 2, 8, pageWidth / 2, 18), - Font = DynamicReportFontHelper.CreateValueFont(8F), - TextAlignment = TextAlignment.MiddleRight - }; - bottomMargin.Controls.Add(pageInfo); - - var detailIndent = GetGroupDetailIndent(groupFields.Count, pageWidth); + var detailIndent = GetGroupIndent(groupFields.Count, pageWidth); var tableWidth = pageWidth - detailIndent; var detailWidths = DynamicReportGridStateHelper.CalculateWidths(fields, tableWidth, localizer, stateWidths); - var headerTable = CreateTable(pageWidth, 28); - var headerRow = new XRTableRow { HeightF = 28 }; - var detailTable = CreateTable(tableWidth, detailHeight, detailIndent); + var headerTable = DynamicReportLayoutHelper.CreateTable(pageWidth, DynamicReportLayoutHelper.HeaderRowHeight); + var headerRow = new XRTableRow { HeightF = DynamicReportLayoutHelper.HeaderRowHeight }; + var detailTable = DynamicReportLayoutHelper.CreateTable(tableWidth, detailHeight, detailIndent); detailTable.CanGrow = true; var detailRow = new XRTableRow { HeightF = detailHeight, CanGrow = true }; - var footerTable = CreateTable(tableWidth, 24, detailIndent); - var footerRow = new XRTableRow { HeightF = 24 }; + var footerTable = DynamicReportLayoutHelper.CreateTable(tableWidth, DynamicReportLayoutHelper.FooterRowHeight, detailIndent); + var footerRow = new XRTableRow { HeightF = DynamicReportLayoutHelper.FooterRowHeight }; if (detailIndent > 0) { @@ -381,9 +357,9 @@ public class DynamicGridReport : XtraReport for (var i = 0; i < fields.Count; i++) { var field = fields[i]; - headerRow.Cells.Add(CreateHeaderCell(field, detailWidths[i], localizer)); - detailRow.Cells.Add(CreateDetailCell(field, detailWidths[i], imageBaseUrl, detailHeight)); - footerRow.Cells.Add(CreateFooterCell(field, detailWidths[i])); + headerRow.Cells.Add(DynamicReportLayoutHelper.CreateHeaderCell(field, detailWidths[i], localizer)); + detailRow.Cells.Add(DynamicReportLayoutHelper.CreateDetailCell(field, detailWidths[i], imageBaseUrl, detailHeight)); + footerRow.Cells.Add(DynamicReportLayoutHelper.CreateFooterCell(field, detailWidths[i])); } headerTable.Rows.Add(headerRow); @@ -392,14 +368,14 @@ public class DynamicGridReport : XtraReport pageHeader.Controls.Add(headerTable); detail.Controls.Add(detailTable); - if (HasAnySummary(fields)) + if (hasSummary) { reportFooter.Controls.Add(footerTable); } } private static List CreateGroupHeaderBands( - IReadOnlyList groupFields, + IReadOnlyList groupFields, IStringLocalizer localizer, float pageWidth) { @@ -418,7 +394,7 @@ public class DynamicGridReport : XtraReport }; band.GroupFields.Add(new GroupField(groupField.BindingName, XRColumnSortOrder.Ascending)); - var indent = GetGroupHeaderIndent(i, pageWidth); + var indent = GetGroupIndent(i, pageWidth); var caption = DynamicReportImageHelper.GetLocalizedFieldCaption(groupField.Field, localizer); var availableWidth = pageWidth - indent; var captionWidth = Math.Clamp(caption.Length * 6F + 28F, 90F, Math.Min(220F, availableWidth / 2)); @@ -432,7 +408,7 @@ public class DynamicGridReport : XtraReport TextAlignment = TextAlignment.MiddleLeft, BackColor = System.Drawing.Color.FromArgb(226, 232, 240), Borders = BorderSide.Left | BorderSide.Top | BorderSide.Bottom, - BorderColor = TableBorderColor, + BorderColor = DynamicReportLayoutHelper.TableBorderColor, BorderWidth = 0.25F }; var valueLabel = new XRLabel @@ -443,7 +419,7 @@ public class DynamicGridReport : XtraReport TextAlignment = TextAlignment.MiddleLeft, BackColor = System.Drawing.Color.FromArgb(241, 245, 249), Borders = BorderSide.All, - BorderColor = TableBorderColor, + BorderColor = DynamicReportLayoutHelper.TableBorderColor, BorderWidth = 0.25F, CanGrow = true, Multiline = true @@ -463,25 +439,9 @@ public class DynamicGridReport : XtraReport return bands; } - private static float GetGroupHeaderIndent(int groupIndex, float pageWidth) + private static float GetGroupIndent(int level, float pageWidth) { - return Math.Min(groupIndex * GroupIndentWidth, pageWidth * 0.25F); - } - - private static float GetGroupDetailIndent(int groupCount, float pageWidth) - { - return Math.Min(groupCount * GroupIndentWidth, pageWidth * 0.25F); - } - - private static XRTable CreateTable(float width, float height, float left = 0) - { - return new XRTable - { - BoundsF = new System.Drawing.RectangleF(left, 0, width, height), - Borders = BorderSide.All, - BorderColor = TableBorderColor, - BorderWidth = 0.25F - }; + return Math.Min(level * GroupIndentWidth, pageWidth * 0.25F); } private static XRTableCell CreateIndentHeaderCell(float width) @@ -495,244 +455,6 @@ public class DynamicGridReport : XtraReport }; } - private static XRTableCell CreateHeaderCell( - ReportColumn column, - float width, - IStringLocalizer localizer) - { - var field = column.Field; - return new XRTableCell - { - Text = DynamicReportImageHelper.GetLocalizedFieldCaption(field, localizer), - WidthF = width, - Weight = width, - Font = DynamicReportFontHelper.CreateHeaderFont(), - BackColor = System.Drawing.Color.FromArgb(238, 242, 247), - Padding = new PaddingInfo(4, 4, 2, 2), - TextAlignment = TextAlignment.MiddleCenter, - Multiline = true - }; - } - - private static XRTableCell CreateDetailCell( - ReportColumn column, - float width, - string imageBaseUrl, - float height) - { - var field = column.Field; - if (column.IsImage) - { - return DynamicReportImageHelper.CreateGridImageCell( - column.BindingName, - column.Field.FieldName, - width, - imageBaseUrl, - column.ImageCount); - } - - if (column.IsHtml) - { - return CreateHtmlDetailCell(column, width, height); - } - - var cell = new XRTableCell - { - WidthF = width, - Weight = width, - Font = DynamicReportFontHelper.CreateDataFont(), - Padding = new PaddingInfo(4, 4, 2, 2), - TextAlignment = DynamicReportImageHelper.ToTextAlignment(field.Alignment, field.SourceDbType), - Multiline = true, - CanGrow = true - }; - var valueExpression = DynamicReportImageHelper.CreateFormatValueExpression($"[{column.BindingName}]", field, column.EditorType); - cell.ExpressionBindings.Add(new ExpressionBinding( - "BeforePrint", - "Text", - valueExpression)); - cell.ExpressionBindings.Add(new ExpressionBinding( - "BeforePrint", - "Font.Name", - DynamicReportImageHelper.CreateFontFamilyExpression(valueExpression))); - - return cell; - } - - private static XRTableCell CreateHtmlDetailCell( - ReportColumn column, - float width, - float height) - { - var cell = new XRTableCell - { - WidthF = width, - Weight = width, - Padding = new PaddingInfo(0, 0, 0, 0), - CanGrow = true - }; - - var richText = new XRRichText - { - BoundsF = new System.Drawing.RectangleF(0, 0, width, height), - Font = DynamicReportFontHelper.CreateDataFont(), - Padding = new PaddingInfo(4, 4, 2, 2), - CanGrow = true - }; - richText.ExpressionBindings.Add(new ExpressionBinding("BeforePrint", "Html", $"[{column.BindingName}]")); - cell.Controls.Add(richText); - - return cell; - } - - private static XRTableCell CreateFooterCell(ReportColumn column, float width) - { - var field = column.Field; - var summaryType = GetSummaryType(field.TotalSummaryJson); - var cell = new XRTableCell - { - WidthF = width, - Weight = width, - Font = DynamicReportFontHelper.CreateDataFont(DXFontStyle.Bold), - Padding = new PaddingInfo(4, 4, 2, 2), - TextAlignment = DynamicReportImageHelper.ToTextAlignment(field.Alignment, field.SourceDbType) - }; - - if (summaryType == null) - { - return cell; - } - - cell.ExpressionBindings.Add(new ExpressionBinding( - "BeforePrint", - "Text", - DynamicReportImageHelper.CreateFormatValueExpression( - CreateSummaryExpression(summaryType.Value, column.BindingName), - field, - column.EditorType))); - cell.Summary = new XRSummary - { - Running = SummaryRunning.Report, - Func = summaryType.Value, - IgnoreNullValues = true - }; - - return cell; - } - - private static string CreateSummaryExpression(SummaryFunc summaryType, string bindingName) - { - return summaryType switch - { - SummaryFunc.Sum => $"sumSum([{bindingName}])", - SummaryFunc.Avg => $"sumAvg([{bindingName}])", - SummaryFunc.Min => $"sumMin([{bindingName}])", - SummaryFunc.Max => $"sumMax([{bindingName}])", - SummaryFunc.Count => "sumCount()", - _ => $"sumSum([{bindingName}])" - }; - } - - private static bool HasAnySummary(IEnumerable fields) - { - return fields.Any(x => GetSummaryType(x.Field.TotalSummaryJson) != null); - } - - private static float GetImageColumnWidth(ReportColumn column) - { - return DynamicReportImageHelper.GetGridImageColumnWidth(column.ImageCount); - } - - private static ReportColumn CreateReportColumn( - ListFormField field, - SelectField selectField, - DefaultValueHelper defaultValueHelper, - string editorType, - bool isImage, - bool isHtml) - { - var bindingName = DynamicReportImageHelper.GetReportFieldName(selectField); - var lookup = DynamicReportImageHelper.GetLookup(field); - var isMultiValue = IsMultiValueLookupEditor(editorType); - - if (lookup == null || selectField?.JoinOptions != null) - { - return new ReportColumn(field, bindingName, null, null, isMultiValue, isImage, isHtml, editorType); - } - - if (DynamicReportSqlBuilder.IsReportQueryableLookup(lookup)) - { - return new ReportColumn(field, DynamicReportSqlBuilder.GetLookupBindingName(field), lookup, null, isMultiValue, isImage, isHtml, editorType); - } - - if (lookup.DataSourceType == UiLookupDataSourceTypeEnum.StaticData) - { - var staticLookupValues = DynamicReportImageHelper.GetStaticLookupValues(lookup, defaultValueHelper); - return new ReportColumn( - field, - staticLookupValues?.Count > 0 ? DynamicReportSqlBuilder.GetLookupBindingName(field) : bindingName, - lookup, - staticLookupValues, - isMultiValue, - isImage, - isHtml, - editorType); - } - - return new ReportColumn(field, bindingName, null, null, isMultiValue, isImage, isHtml, editorType); - } - - private static bool IsMultiValueLookupEditor(string editorType) - { - return editorType is PlatformConsts.EditorTypes.dxTagBox or "dxGridBox"; - } - - private sealed record ReportColumn( - ListFormField Field, - string BindingName, - LookupDto Lookup, - Dictionary StaticLookupValues, - bool IsMultiValue, - bool IsImage, - bool IsHtml, - string EditorType, - int ImageCount = 1) : DynamicReportSqlBuilder.IReportLookupColumn, DynamicReportGridStateHelper.IReportColumnWidth - { - public float ImageWidth => GetImageColumnWidth(this); - } - - private static SummaryFunc? GetSummaryType(string totalSummaryJson) - { - if (string.IsNullOrWhiteSpace(totalSummaryJson)) - { - return null; - } - - var value = totalSummaryJson.ToLowerInvariant(); - if (value.Contains("\"summarytype\":\"sum\"")) - { - return SummaryFunc.Sum; - } - if (value.Contains("\"summarytype\":\"avg\"")) - { - return SummaryFunc.Avg; - } - if (value.Contains("\"summarytype\":\"min\"")) - { - return SummaryFunc.Min; - } - if (value.Contains("\"summarytype\":\"max\"")) - { - return SummaryFunc.Max; - } - if (value.Contains("\"summarytype\":\"count\"")) - { - return SummaryFunc.Count; - } - - return null; - } - private static string GetParameter(IDictionary parameters, string name) => DynamicReportImageHelper.GetParameter(parameters, name); } diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReport.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReport.cs index 085c64b2..ab272110 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReport.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReport.cs @@ -111,10 +111,9 @@ public partial class DynamicReport : XtraReport using var scope = _scopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - - var language = dbContext.Set() - .FirstOrDefault(l => l.DisplayName == "Türkçe"); - + + var language = GetCurrentLanguage(dbContext); + if (language != null) { SetParameter("id", language.Id.ToString()); @@ -129,6 +128,24 @@ public partial class DynamicReport : XtraReport } } + /// + /// Rapor isteğiyle gelen kültüre karşılık gelen dil kaydını döner. + /// Eşleşme yoksa etkin dillerden ilki kullanılır. + /// + private static Language GetCurrentLanguage(PlatformDbContext dbContext) + { + var cultureName = System.Globalization.CultureInfo.CurrentUICulture.Name; + var twoLetterName = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName; + var languages = dbContext.Set().ToList(); + + return languages.FirstOrDefault(l => + string.Equals(l.CultureName, cultureName, StringComparison.OrdinalIgnoreCase) || + string.Equals(l.UiCultureName, cultureName, StringComparison.OrdinalIgnoreCase)) ?? + languages.FirstOrDefault(l => + string.Equals(l.TwoLetterISOLanguageName, twoLetterName, StringComparison.OrdinalIgnoreCase)) ?? + languages.FirstOrDefault(l => l.IsEnabled); + } + private void XrRichText1_BeforePrint(object sender, CancelEventArgs e) { var rt = (XRRichText)sender; diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportColumn.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportColumn.cs new file mode 100644 index 00000000..8334fb85 --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportColumn.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using Microsoft.Extensions.Localization; +using Sozsoft.Platform; +using Sozsoft.Platform.Localization; +using Sozsoft.Platform.Entities; +using Sozsoft.Platform.Enums; +using Sozsoft.Platform.ListForms; +using Sozsoft.Platform.Queries; + +namespace Sozsoft.Reports.PredefinedReports; + +/// +/// Grid ve Tree raporlarının ortak kolon tanımı. +/// +internal sealed record DynamicReportColumn( + ListFormField Field, + string BindingName, + LookupDto Lookup, + Dictionary StaticLookupValues, + bool IsMultiValue, + bool IsImage, + bool IsHtml, + string EditorType, + int ImageCount = 1) : DynamicReportSqlBuilder.IReportLookupColumn, DynamicReportGridStateHelper.IReportColumnWidth +{ + public float ImageWidth => DynamicReportImageHelper.GetGridImageColumnWidth(ImageCount); + + public bool HasBindingName => + !string.IsNullOrWhiteSpace(BindingName) && BindingName != "undefined"; +} + +internal static class DynamicReportColumnFactory +{ + public static DynamicReportColumn Create( + ListFormField field, + SelectField selectField, + DefaultValueHelper defaultValueHelper, + string editorType, + IStringLocalizer localizer) + { + var bindingName = DynamicReportImageHelper.GetReportFieldName(selectField); + var lookup = DynamicReportImageHelper.GetLookup(field); + var isMultiValue = IsMultiValueLookupEditor(editorType); + var isImage = DynamicReportImageHelper.IsImageEditor(editorType); + var isHtml = editorType == PlatformConsts.EditorTypes.dxHtmlEditor; + + if (lookup == null || selectField?.JoinOptions != null) + { + return new DynamicReportColumn(field, bindingName, null, null, isMultiValue, isImage, isHtml, editorType); + } + + if (DynamicReportSqlBuilder.IsReportQueryableLookup(lookup)) + { + return new DynamicReportColumn( + field, + DynamicReportSqlBuilder.GetLookupBindingName(field), + lookup, + null, + isMultiValue, + isImage, + isHtml, + editorType); + } + + if (lookup.DataSourceType == UiLookupDataSourceTypeEnum.StaticData) + { + var staticLookupValues = DynamicReportImageHelper.GetStaticLookupValues( + lookup, + defaultValueHelper, + localizer); + return new DynamicReportColumn( + field, + staticLookupValues?.Count > 0 ? DynamicReportSqlBuilder.GetLookupBindingName(field) : bindingName, + lookup, + staticLookupValues, + isMultiValue, + isImage, + isHtml, + editorType); + } + + return new DynamicReportColumn(field, bindingName, null, null, isMultiValue, isImage, isHtml, editorType); + } + + public static bool IsMultiValueLookupEditor(string editorType) + { + return editorType is PlatformConsts.EditorTypes.dxTagBox or "dxGridBox"; + } +} diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportGridStateHelper.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportGridStateHelper.cs index d4f0c0d8..f6f065f0 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportGridStateHelper.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportGridStateHelper.cs @@ -136,12 +136,6 @@ internal static class DynamicReportGridStateHelper IStringLocalizer localizer = null, IReadOnlyDictionary stateWidths = null) { - var caption = localizer == null - ? (string.IsNullOrWhiteSpace(field.CaptionName) ? field.FieldName : field.CaptionName) - : DynamicReportImageHelper.GetLocalizedFieldCaption(field, localizer); - var name = field.FieldName ?? string.Empty; - var calculatedWidth = CalculateTextColumnWidth(field, caption, name); - if (!string.IsNullOrWhiteSpace(field.FieldName) && stateWidths?.TryGetValue(field.FieldName, out var stateWidth) == true && stateWidth > 0) @@ -154,7 +148,11 @@ internal static class DynamicReportGridStateHelper return Math.Max(field.Width.Value, 40F); } - return calculatedWidth; + var caption = localizer == null + ? (string.IsNullOrWhiteSpace(field.CaptionName) ? field.FieldName : field.CaptionName) + : DynamicReportImageHelper.GetLocalizedFieldCaption(field, localizer); + + return CalculateTextColumnWidth(field, caption, field.FieldName ?? string.Empty); } private static string GetStateStorageKey(ListForm listForm) diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportImageHelper.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportImageHelper.cs index 5b9839e6..10d9966d 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportImageHelper.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportImageHelper.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Data; using System.Globalization; @@ -51,6 +52,11 @@ internal static class DynamicReportImageHelper Timeout = TimeSpan.FromSeconds(5) }; + // Aynı görsel bir raporda çok sayıda satırda ve hem ImageUrl hem ImageSource ifadelerinde + // kullanılabildiği için indirilen içerik süreç boyunca önbelleklenir. + private const int MaxCachedRemoteImages = 256; + private static readonly ConcurrentDictionary RemoteImageCache = new(StringComparer.Ordinal); + static DynamicReportImageHelper() { RegisterImageFunction(new ReportImageUrlFunction()); @@ -209,9 +215,15 @@ internal static class DynamicReportImageHelper } } + /// + /// Statik lookup tanımlarını okur. Görüntü metinleri arayüzdeki gibi + /// (translate('::' + name)) yerelleştirme anahtarı olabildiği için + /// verildiğinde çevrilerek döner. + /// public static Dictionary GetStaticLookupValues( LookupDto lookup, - DefaultValueHelper defaultValueHelper) + DefaultValueHelper defaultValueHelper, + IStringLocalizer localizer = null) { if (string.IsNullOrWhiteSpace(lookup.LookupQuery)) { @@ -244,7 +256,8 @@ internal static class DynamicReportImageHelper continue; } - values[value] = GetJsonScalar(displayElement) ?? value; + var display = GetJsonScalar(displayElement) ?? value; + values[value] = localizer == null ? display : Localize(localizer, display, display); } return values.Count == 0 ? null : values; @@ -440,18 +453,6 @@ internal static class DynamicReportImageHelper return editorType is PlatformConsts.EditorTypes.dxImageUpload or PlatformConsts.EditorTypes.dxImageViewer; } - public static bool HasImageEditor(IEnumerable fields, string fieldName) - { - if (string.IsNullOrWhiteSpace(fieldName)) - { - return false; - } - - return (fields ?? []).Any(x => - string.Equals(x.FieldName, fieldName, StringComparison.OrdinalIgnoreCase) && - IsImageEditor(x.EditorType2)); - } - public static float GetGridImageHeight() => DefaultGridImageHeight; public static float GetFormImageHeight() => DefaultFormImageHeight; @@ -496,46 +497,6 @@ internal static class DynamicReportImageHelper return safeImageCount * DefaultGridImageWidth + (safeImageCount - 1) * 4F + 8F; } - private static object GetCurrentValue(XRControl control, string bindingName, string fieldName) - { - if (HasImageValue(control.Tag)) - { - return control.Tag; - } - - var report = control.Report; - if (report == null) - { - return null; - } - - var value = GetCurrentColumnValue(report, bindingName); - return HasImageValue(value) || string.Equals(bindingName, fieldName, StringComparison.OrdinalIgnoreCase) - ? value - : GetCurrentColumnValue(report, fieldName); - } - - private static object GetCurrentColumnValue(XtraReportBase report, string fieldName) - { - if (string.IsNullOrWhiteSpace(fieldName)) - { - return null; - } - - try - { - return report.GetCurrentColumnValue(fieldName); - } - catch (InvalidOperationException) - { - return null; - } - catch (ArgumentException) - { - return null; - } - } - public static bool HasValue(object value) { return value != null && @@ -1070,8 +1031,8 @@ internal static class DynamicReportImageHelper { JsonValueKind.String => value.GetString(), JsonValueKind.Number => value.GetRawText(), - JsonValueKind.True => "Evet", - JsonValueKind.False => "Hayır", + JsonValueKind.True => FormatBoolean(true), + JsonValueKind.False => FormatBoolean(false), JsonValueKind.Null => string.Empty, JsonValueKind.Array => string.Join(", ", value.EnumerateArray().Select(FormatJsonValue)), _ => value.ToString() @@ -1107,20 +1068,6 @@ internal static class DynamicReportImageHelper dbType == DbType.Boolean; } - private static string GetCustomFormat(string format) - { - if (string.IsNullOrWhiteSpace(format)) - { - return null; - } - - var customFormat = TryGetFormatOption(format, "displayFormat") ?? - TryGetFormatOption(format, "format") ?? - format.Trim(); - - return NormalizeDateTimeFormat(customFormat); - } - private static string GetEffectiveFormat(ListFormField field) { return TryGetFormatOption(field?.EditorOptions, "displayFormat") ?? @@ -1355,11 +1302,6 @@ internal static class DynamicReportImageHelper } } - private static bool HasImageValue(object value) - { - return HasValue(value) && NormalizeImageValue(value).Any(IsPotentialImageSource); - } - private static bool IsPotentialImageSource(string value) { if (string.IsNullOrWhiteSpace(value) || @@ -1436,28 +1378,6 @@ internal static class DynamicReportImageHelper } } - private static void ArrangePictureBoxes( - IReadOnlyList pictureBoxes, - float width, - float height, - int visibleCount) - { - var safeCount = Math.Max(visibleCount, 1); - var gap = 4F; - var boxWidth = safeCount == 1 - ? width - : Math.Max((width - gap * (safeCount - 1)) / safeCount, 1); - - for (var i = 0; i < pictureBoxes.Count; i++) - { - pictureBoxes[i].BoundsF = new System.Drawing.RectangleF( - i * (boxWidth + gap), - 0, - boxWidth, - height); - } - } - private static string GetImageSourceAt(object rawValue, string baseUrl, int index) { if (index < 0) @@ -1600,25 +1520,44 @@ internal static class DynamicReportImageHelper return false; } + bytes = GetRemoteImageBytes(uri); + return bytes?.Length > 0; + } + + private static byte[] GetRemoteImageBytes(Uri uri) + { + var cacheKey = uri.AbsoluteUri; + if (RemoteImageCache.TryGetValue(cacheKey, out var cached)) + { + return cached; + } + + var bytes = DownloadImageBytes(uri); + if (RemoteImageCache.Count < MaxCachedRemoteImages) + { + RemoteImageCache.TryAdd(cacheKey, bytes); + } + + return bytes; + } + + private static byte[] DownloadImageBytes(Uri uri) + { try { - bytes = HttpClient.GetByteArrayAsync(uri).GetAwaiter().GetResult(); - return bytes?.Length > 0; + return HttpClient.GetByteArrayAsync(uri).GetAwaiter().GetResult(); } catch (HttpRequestException) { - bytes = null; - return false; + return null; } catch (TaskCanceledException) { - bytes = null; - return false; + return null; } catch (InvalidOperationException) { - bytes = null; - return false; + return null; } } diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportLayoutHelper.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportLayoutHelper.cs new file mode 100644 index 00000000..1fe14601 --- /dev/null +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportLayoutHelper.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using DevExpress.Drawing; +using DevExpress.XtraPrinting; +using DevExpress.XtraReports.UI; +using Microsoft.Extensions.Localization; +using Sozsoft.Platform.Localization; + +namespace Sozsoft.Reports.PredefinedReports; + +/// +/// Grid ve Tree raporlarının ortak tablo/hücre/özet üretimi. +/// +internal static class DynamicReportLayoutHelper +{ + public const float HeaderRowHeight = 28F; + public const float FooterRowHeight = 24F; + public const float DefaultDetailHeight = 24F; + public const float HtmlDetailHeight = 48F; + + public static readonly System.Drawing.Color TableBorderColor = System.Drawing.Color.FromArgb(226, 232, 240); + private static readonly System.Drawing.Color HeaderBackColor = System.Drawing.Color.FromArgb(238, 242, 247); + +#pragma warning disable CA1416 + public static System.Drawing.Printing.Margins CreateMargins(int horizontal, int vertical) + { + return new System.Drawing.Printing.Margins(horizontal, horizontal, vertical, vertical); + } +#pragma warning restore CA1416 + + public static float GetDetailHeight(IEnumerable columns) + { + var materialized = columns as IReadOnlyCollection ?? columns.ToList(); + if (materialized.Any(x => x.IsImage)) + { + return DynamicReportImageHelper.GetGridImageHeight(); + } + + return materialized.Any(x => x.IsHtml) ? HtmlDetailHeight : DefaultDetailHeight; + } + + public static XRTable CreateTable(float width, float height, float left = 0) + { + return new XRTable + { + BoundsF = new System.Drawing.RectangleF(left, 0, width, height), + Borders = BorderSide.All, + BorderColor = TableBorderColor, + BorderWidth = 0.25F + }; + } + + public static XRTableCell CreateHeaderCell( + DynamicReportColumn column, + float width, + IStringLocalizer localizer) + { + return new XRTableCell + { + Text = DynamicReportImageHelper.GetLocalizedFieldCaption(column.Field, localizer), + WidthF = width, + Weight = width, + Font = DynamicReportFontHelper.CreateHeaderFont(), + BackColor = HeaderBackColor, + Padding = new PaddingInfo(4, 4, 2, 2), + TextAlignment = TextAlignment.MiddleCenter, + Multiline = true + }; + } + + /// + /// Detay hücresi üretir. verildiğinde metnin başına + /// ilgili kolonun değeri eklenir (Tree raporundaki girinti için kullanılır). + /// + public static XRTableCell CreateDetailCell( + DynamicReportColumn column, + float width, + string imageBaseUrl, + float height, + string prefixColumnName = null, + string boldColumnName = null) + { + if (column.IsImage) + { + return DynamicReportImageHelper.CreateGridImageCell( + column.BindingName, + column.Field.FieldName, + width, + imageBaseUrl, + column.ImageCount); + } + + if (column.IsHtml) + { + return CreateHtmlDetailCell(column, width, height); + } + + var hasPrefix = !string.IsNullOrWhiteSpace(prefixColumnName); + var cell = new XRTableCell + { + WidthF = width, + Weight = width, + Font = DynamicReportFontHelper.CreateDataFont(), + Padding = new PaddingInfo(4, 4, 2, 2), + TextAlignment = hasPrefix + ? TextAlignment.MiddleLeft + : DynamicReportImageHelper.ToTextAlignment(column.Field.Alignment, column.Field.SourceDbType), + Multiline = true, + CanGrow = true + }; + + var valueExpression = DynamicReportImageHelper.CreateFormatValueExpression( + $"[{column.BindingName}]", + column.Field, + column.EditorType); + var textExpression = hasPrefix + ? $"Concat([{prefixColumnName}], {valueExpression})" + : valueExpression; + + cell.ExpressionBindings.Add(new ExpressionBinding("BeforePrint", "Text", textExpression)); + cell.ExpressionBindings.Add(new ExpressionBinding( + "BeforePrint", + "Font.Name", + DynamicReportImageHelper.CreateFontFamilyExpression(textExpression))); + + if (!string.IsNullOrWhiteSpace(boldColumnName)) + { + cell.ExpressionBindings.Add(new ExpressionBinding("BeforePrint", "Font.Bold", $"[{boldColumnName}]")); + } + + return cell; + } + + public static XRTableCell CreateHtmlDetailCell(DynamicReportColumn column, float width, float height) + { + var cell = new XRTableCell + { + WidthF = width, + Weight = width, + Padding = new PaddingInfo(0, 0, 0, 0), + CanGrow = true + }; + + var richText = new XRRichText + { + BoundsF = new System.Drawing.RectangleF(0, 0, width, height), + Font = DynamicReportFontHelper.CreateDataFont(), + Padding = new PaddingInfo(4, 4, 2, 2), + CanGrow = true + }; + richText.ExpressionBindings.Add(new ExpressionBinding("BeforePrint", "Html", $"[{column.BindingName}]")); + cell.Controls.Add(richText); + + return cell; + } + + public static XRTableCell CreateFooterCell(DynamicReportColumn column, float width) + { + var summaryType = GetSummaryType(column.Field.TotalSummaryJson); + var cell = new XRTableCell + { + WidthF = width, + Weight = width, + Font = DynamicReportFontHelper.CreateDataFont(DXFontStyle.Bold), + Padding = new PaddingInfo(4, 4, 2, 2), + TextAlignment = DynamicReportImageHelper.ToTextAlignment(column.Field.Alignment, column.Field.SourceDbType) + }; + + if (summaryType == null) + { + return cell; + } + + cell.ExpressionBindings.Add(new ExpressionBinding( + "BeforePrint", + "Text", + DynamicReportImageHelper.CreateFormatValueExpression( + CreateSummaryExpression(summaryType.Value, column.BindingName), + column.Field, + column.EditorType))); + cell.Summary = new XRSummary + { + Running = SummaryRunning.Report, + Func = summaryType.Value, + IgnoreNullValues = true + }; + + return cell; + } + + public static bool HasAnySummary(IEnumerable columns) + { + return columns.Any(x => GetSummaryType(x.Field.TotalSummaryJson) != null); + } + + public static void AddPageInfoToFooter(XRControlCollection controls, float pageWidth) + { + controls.Add(new XRPageInfo + { + TextFormatString = "Sayfa {0} / {1}", + BoundsF = new System.Drawing.RectangleF(pageWidth / 2, 8, pageWidth / 2, 18), + Font = DynamicReportFontHelper.CreateValueFont(8F), + TextAlignment = TextAlignment.MiddleRight + }); + } + + public static string CreateSummaryExpression(SummaryFunc summaryType, string bindingName) + { + return summaryType switch + { + SummaryFunc.Avg => $"sumAvg([{bindingName}])", + SummaryFunc.Min => $"sumMin([{bindingName}])", + SummaryFunc.Max => $"sumMax([{bindingName}])", + SummaryFunc.Count => "sumCount()", + _ => $"sumSum([{bindingName}])" + }; + } + + /// + /// ListForm alanındaki toplam özeti tanımını okur. JSON tek bir nesne veya dizi olabilir. + /// + public static SummaryFunc? GetSummaryType(string totalSummaryJson) + { + if (string.IsNullOrWhiteSpace(totalSummaryJson)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(totalSummaryJson); + return GetSummaryType(document.RootElement); + } + catch (JsonException) + { + return null; + } + } + + private static SummaryFunc? GetSummaryType(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Array) + { + return element.EnumerateArray() + .Select(GetSummaryType) + .FirstOrDefault(x => x != null); + } + + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + foreach (var property in element.EnumerateObject()) + { + if (!string.Equals(property.Name, "summaryType", StringComparison.OrdinalIgnoreCase) || + property.Value.ValueKind != JsonValueKind.String) + { + continue; + } + + return property.Value.GetString()?.ToLowerInvariant() switch + { + "sum" => SummaryFunc.Sum, + "avg" => SummaryFunc.Avg, + "min" => SummaryFunc.Min, + "max" => SummaryFunc.Max, + "count" => SummaryFunc.Count, + _ => null + }; + } + + return null; + } +} diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportSqlBuilder.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportSqlBuilder.cs index a4b11e3a..71d37d08 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportSqlBuilder.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicReportSqlBuilder.cs @@ -13,9 +13,6 @@ internal static class DynamicReportSqlBuilder { private const string SourceAlias = "ReportSource"; private const string LookupAlias = "LookupSource"; - private static readonly Regex OrderByRegex = new( - @"(? part.CommonTableExpression) + .Prepend(sourceCommonTableExpression) .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct() .ToList(); @@ -292,8 +295,7 @@ internal static class DynamicReportSqlBuilder return null; } - var query = lookupQuery.Trim().TrimEnd(';'); - query = OrderByRegex.Replace(query, string.Empty); + var query = RemoveOrderBy(NormalizeSql(lookupQuery)); return LookupParameterRegex.Replace(query, "NULL"); } @@ -305,8 +307,83 @@ internal static class DynamicReportSqlBuilder private static string ExtractOrderBy(string sql) { - var match = OrderByRegex.Match(sql); - return match.Success ? " " + match.Value.Trim() : string.Empty; + var index = FindTrailingOrderByIndex(sql); + return index < 0 ? string.Empty : " " + sql[index..].Trim(); + } + + private static string RemoveOrderBy(string sql) + { + var index = FindTrailingOrderByIndex(sql); + return index < 0 ? sql : sql[..index].TrimEnd(); + } + + /// + /// Sorgunun sonundaki ORDER BY ifadesini bulur. Alt sorgular ve OVER (ORDER BY ...) + /// gibi parantez içindeki sıralamalar dikkate alınmaz. + /// + private static int FindTrailingOrderByIndex(string sql) + { + var depth = 0; + var quote = '\0'; + var result = -1; + + for (var index = 0; index < sql.Length; index++) + { + var current = sql[index]; + if (quote != '\0') + { + if (current == quote) + { + if (index + 1 < sql.Length && sql[index + 1] == quote) + { + index++; + } + else + { + quote = '\0'; + } + } + + continue; + } + + if (current is '\'' or '"') + { + quote = current; + continue; + } + + if (current == '(') + { + depth++; + continue; + } + + if (current == ')') + { + depth--; + continue; + } + + if (depth != 0 || !IsKeywordAt(sql, index, "ORDER")) + { + continue; + } + + var next = index + "ORDER".Length; + while (next < sql.Length && char.IsWhiteSpace(sql[next])) + { + next++; + } + + if (IsKeywordAt(sql, next, "BY")) + { + result = index; + index = next + 1; + } + } + + return result; } private static string QuoteSqlIdentifier(string identifier) diff --git a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicTreeReport.cs b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicTreeReport.cs index 588b47ab..cb19d693 100644 --- a/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicTreeReport.cs +++ b/api/src/Sozsoft.Platform.HttpApi.Host/PredefinedReports/DynamicTreeReport.cs @@ -29,11 +29,11 @@ public class DynamicTreeReport : XtraReport private const int A4PortraitWidth = 827; private const int HorizontalMargin = 50; private const int LandscapeColumnThreshold = 5; + private const int VerticalMargin = 35; private const string TreeLevelColumn = "__TreeLevel"; private const string TreeHasChildrenColumn = "__TreeHasChildren"; private const string TreePrefixColumn = "__TreePrefix"; private const string TreeDataSourceName = "DynamicTree"; - private static readonly System.Drawing.Color TableBorderColor = System.Drawing.Color.FromArgb(226, 232, 240); private static IServiceScopeFactory _scopeFactory; public static void SetScopeFactory(IServiceScopeFactory scopeFactory) @@ -139,17 +139,15 @@ public class DynamicTreeReport : XtraReport var reportColumns = visibleFields .Select(field => { - var selectField = selectFieldsByName[field.FieldName]; editorTypesByField.TryGetValue(field.FieldName, out var editorType); - return CreateReportColumn( + return DynamicReportColumnFactory.Create( field, - selectField, + selectFieldsByName[field.FieldName], defaultValueHelper, editorType, - DynamicReportImageHelper.IsImageEditor(editorType), - editorType == PlatformConsts.EditorTypes.dxHtmlEditor); + localizer); }) - .Where(x => !string.IsNullOrWhiteSpace(x.BindingName) && x.BindingName != "undefined") + .Where(x => x.HasBindingName) .ToList(); var selectQuery = DynamicReportImageHelper.StripReportPaging(DynamicReportImageHelper.ApplyQueryParameters( @@ -178,32 +176,21 @@ public class DynamicTreeReport : XtraReport RequestParameters = false; } -#pragma warning disable CA1416 private void ConfigurePage( - IReadOnlyCollection visibleFields, + IReadOnlyCollection visibleFields, IStringLocalizer localizer, IReadOnlyDictionary stateWidths) { - Margins = CreateReportMargins(HorizontalMargin, HorizontalMargin, 35, 35); + Margins = DynamicReportLayoutHelper.CreateMargins(HorizontalMargin, VerticalMargin); PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4; var portraitContentWidth = A4PortraitWidth - Margins.Left - Margins.Right; var requestedContentWidth = visibleFields.Sum(x => x.IsImage - ? GetImageColumnWidth(x) + ? x.ImageWidth : DynamicReportGridStateHelper.GetPreferredColumnWidth(x.Field, localizer, stateWidths)); Landscape = visibleFields.Count >= LandscapeColumnThreshold || requestedContentWidth > portraitContentWidth; Font = DynamicReportFontHelper.CreateDataFont(); } - private static System.Drawing.Printing.Margins CreateReportMargins( - int left, - int right, - int top, - int bottom) - { - return new System.Drawing.Printing.Margins(left, right, top, bottom); - } -#pragma warning restore CA1416 - private static async Task>> ReadRowsAsync( IDynamicDataRepository dynamicDataRepository, string selectQuery, @@ -312,7 +299,7 @@ public class DynamicTreeReport : XtraReport private void ConfigureDataSource( IEnumerable> rows, - IReadOnlyList columns) + IReadOnlyList columns) { var normalizedRows = rows .Select(row => NormalizeJsonRow(row, columns)) @@ -334,7 +321,7 @@ public class DynamicTreeReport : XtraReport private static Dictionary NormalizeJsonRow( IDictionary row, - IReadOnlyList columns) + IReadOnlyList columns) { var normalized = row.ToDictionary( x => x.Key, @@ -382,8 +369,8 @@ public class DynamicTreeReport : XtraReport return value; } - private static List ApplyImageCounts( - IReadOnlyList columns, + private static List ApplyImageCounts( + IReadOnlyList columns, IReadOnlyList> rows, string imageBaseUrl) { @@ -413,7 +400,7 @@ public class DynamicTreeReport : XtraReport .ToList(); } - private static object GetImageRawValue(IDictionary row, ReportColumn column) + private static object GetImageRawValue(IDictionary row, DynamicReportColumn column) { if (DynamicReportImageHelper.TryGetRowValue(row, column.BindingName, out var value) && DynamicReportImageHelper.HasValue(value)) @@ -426,7 +413,7 @@ public class DynamicTreeReport : XtraReport private void BuildLayout( ListForm listForm, - IReadOnlyList fields, + IReadOnlyList fields, IStringLocalizer localizer, string imageBaseUrl, string currentUserName, @@ -439,14 +426,11 @@ public class DynamicTreeReport : XtraReport var topMargin = new TopMarginBand { HeightF = Margins.Top }; var bottomMargin = new BottomMarginBand { HeightF = 32 }; var reportHeader = new ReportHeaderBand { HeightF = string.IsNullOrWhiteSpace(criteriaText) ? 32 : 50 }; - var pageHeader = new PageHeaderBand { HeightF = 28 }; - var detailHeight = fields.Any(x => x.IsImage) - ? DynamicReportImageHelper.GetGridImageHeight() - : fields.Any(x => x.IsHtml) - ? 48 - : 24; + var pageHeader = new PageHeaderBand { HeightF = DynamicReportLayoutHelper.HeaderRowHeight }; + var detailHeight = DynamicReportLayoutHelper.GetDetailHeight(fields); var detail = new DetailBand { HeightF = detailHeight }; - var reportFooter = new ReportFooterBand { HeightF = HasAnySummary(fields) ? 28 : 0 }; + var hasSummary = DynamicReportLayoutHelper.HasAnySummary(fields); + var reportFooter = new ReportFooterBand { HeightF = hasSummary ? 28 : 0 }; Bands.AddRange([topMargin, reportHeader, pageHeader, detail, reportFooter, bottomMargin]); @@ -467,29 +451,30 @@ public class DynamicTreeReport : XtraReport DynamicReportImageHelper.AddFooterTopLine(bottomMargin.Controls, pageWidth); DynamicReportImageHelper.AddCurrentUserNameToFooter(bottomMargin.Controls, currentUserName, pageWidth); - bottomMargin.Controls.Add(new XRPageInfo - { - TextFormatString = "Sayfa {0} / {1}", - BoundsF = new System.Drawing.RectangleF(pageWidth / 2, 8, pageWidth / 2, 18), - Font = DynamicReportFontHelper.CreateValueFont(8F), - TextAlignment = TextAlignment.MiddleRight - }); + DynamicReportLayoutHelper.AddPageInfoToFooter(bottomMargin.Controls, pageWidth); var widths = DynamicReportGridStateHelper.CalculateWidths(fields, pageWidth, localizer, stateWidths); - var headerTable = CreateTable(pageWidth, 28); - var headerRow = new XRTableRow { HeightF = 28 }; - var detailTable = CreateTable(pageWidth, detailHeight); + var headerTable = DynamicReportLayoutHelper.CreateTable(pageWidth, DynamicReportLayoutHelper.HeaderRowHeight); + var headerRow = new XRTableRow { HeightF = DynamicReportLayoutHelper.HeaderRowHeight }; + var detailTable = DynamicReportLayoutHelper.CreateTable(pageWidth, detailHeight); detailTable.CanGrow = true; var detailRow = new XRTableRow { HeightF = detailHeight, CanGrow = true }; - var footerTable = CreateTable(pageWidth, 24); - var footerRow = new XRTableRow { HeightF = 24 }; + var footerTable = DynamicReportLayoutHelper.CreateTable(pageWidth, DynamicReportLayoutHelper.FooterRowHeight); + var footerRow = new XRTableRow { HeightF = DynamicReportLayoutHelper.FooterRowHeight }; for (var i = 0; i < fields.Count; i++) { var field = fields[i]; - headerRow.Cells.Add(CreateHeaderCell(field, widths[i], localizer)); - detailRow.Cells.Add(CreateDetailCell(field, widths[i], imageBaseUrl, detailHeight, i == 0)); - footerRow.Cells.Add(CreateFooterCell(field, widths[i])); + var isTreeColumn = i == 0; + headerRow.Cells.Add(DynamicReportLayoutHelper.CreateHeaderCell(field, widths[i], localizer)); + detailRow.Cells.Add(DynamicReportLayoutHelper.CreateDetailCell( + field, + widths[i], + imageBaseUrl, + detailHeight, + isTreeColumn ? TreePrefixColumn : null, + isTreeColumn ? TreeHasChildrenColumn : null)); + footerRow.Cells.Add(DynamicReportLayoutHelper.CreateFooterCell(field, widths[i])); } headerTable.Rows.Add(headerRow); @@ -498,211 +483,12 @@ public class DynamicTreeReport : XtraReport pageHeader.Controls.Add(headerTable); detail.Controls.Add(detailTable); - if (HasAnySummary(fields)) + if (hasSummary) { reportFooter.Controls.Add(footerTable); } } - private static XRTable CreateTable(float width, float height) - { - return new XRTable - { - BoundsF = new System.Drawing.RectangleF(0, 0, width, height), - Borders = BorderSide.All, - BorderColor = TableBorderColor, - BorderWidth = 0.25F - }; - } - - private static XRTableCell CreateHeaderCell( - ReportColumn column, - float width, - IStringLocalizer localizer) - { - return new XRTableCell - { - Text = DynamicReportImageHelper.GetLocalizedFieldCaption(column.Field, localizer), - WidthF = width, - Weight = width, - Font = DynamicReportFontHelper.CreateHeaderFont(), - BackColor = System.Drawing.Color.FromArgb(238, 242, 247), - Padding = new PaddingInfo(4, 4, 2, 2), - TextAlignment = TextAlignment.MiddleCenter, - Multiline = true - }; - } - - private static XRTableCell CreateDetailCell( - ReportColumn column, - float width, - string imageBaseUrl, - float height, - bool isTreeColumn) - { - if (column.IsImage) - { - return DynamicReportImageHelper.CreateGridImageCell( - column.BindingName, - column.Field.FieldName, - width, - imageBaseUrl, - column.ImageCount); - } - - if (column.IsHtml) - { - return CreateHtmlDetailCell(column, width, height); - } - - var cell = new XRTableCell - { - WidthF = width, - Weight = width, - Font = DynamicReportFontHelper.CreateDataFont(), - Padding = new PaddingInfo(4, 4, 2, 2), - TextAlignment = isTreeColumn - ? TextAlignment.MiddleLeft - : DynamicReportImageHelper.ToTextAlignment(column.Field.Alignment, column.Field.SourceDbType), - Multiline = true, - CanGrow = true - }; - - var valueExpression = DynamicReportImageHelper.CreateFormatValueExpression($"[{column.BindingName}]", column.Field, column.EditorType); - var textExpression = isTreeColumn - ? $"Concat([{TreePrefixColumn}], {valueExpression})" - : valueExpression; - cell.ExpressionBindings.Add(new ExpressionBinding( - "BeforePrint", - "Text", - textExpression)); - cell.ExpressionBindings.Add(new ExpressionBinding( - "BeforePrint", - "Font.Name", - DynamicReportImageHelper.CreateFontFamilyExpression(textExpression))); - - if (isTreeColumn) - { - cell.ExpressionBindings.Add(new ExpressionBinding( - "BeforePrint", - "Font.Bold", - $"[{TreeHasChildrenColumn}]")); - } - - return cell; - } - - private static XRTableCell CreateHtmlDetailCell( - ReportColumn column, - float width, - float height) - { - var cell = new XRTableCell - { - WidthF = width, - Weight = width, - Padding = new PaddingInfo(0, 0, 0, 0), - CanGrow = true - }; - - var richText = new XRRichText - { - BoundsF = new System.Drawing.RectangleF(0, 0, width, height), - Font = DynamicReportFontHelper.CreateDataFont(), - Padding = new PaddingInfo(4, 4, 2, 2), - CanGrow = true - }; - richText.ExpressionBindings.Add(new ExpressionBinding("BeforePrint", "Html", $"[{column.BindingName}]")); - cell.Controls.Add(richText); - - return cell; - } - - private static XRTableCell CreateFooterCell(ReportColumn column, float width) - { - var summaryType = GetSummaryType(column.Field.TotalSummaryJson); - var cell = new XRTableCell - { - WidthF = width, - Weight = width, - Font = DynamicReportFontHelper.CreateDataFont(DXFontStyle.Bold), - Padding = new PaddingInfo(4, 4, 2, 2), - TextAlignment = DynamicReportImageHelper.ToTextAlignment(column.Field.Alignment, column.Field.SourceDbType) - }; - - if (summaryType == null) - { - return cell; - } - - cell.ExpressionBindings.Add(new ExpressionBinding( - "BeforePrint", - "Text", - DynamicReportImageHelper.CreateFormatValueExpression($"sumSum([{column.BindingName}])", column.Field, column.EditorType))); - cell.Summary = new XRSummary - { - Running = SummaryRunning.Report, - Func = summaryType.Value, - IgnoreNullValues = true - }; - - return cell; - } - - private static bool HasAnySummary(IEnumerable fields) - { - return fields.Any(x => GetSummaryType(x.Field.TotalSummaryJson) != null); - } - - private static float GetImageColumnWidth(ReportColumn column) - { - return DynamicReportImageHelper.GetGridImageColumnWidth(column.ImageCount); - } - - private static ReportColumn CreateReportColumn( - ListFormField field, - SelectField selectField, - DefaultValueHelper defaultValueHelper, - string editorType, - bool isImage, - bool isHtml) - { - var bindingName = DynamicReportImageHelper.GetReportFieldName(selectField); - var lookup = DynamicReportImageHelper.GetLookup(field); - var isMultiValue = IsMultiValueLookupEditor(editorType); - - if (lookup == null || selectField?.JoinOptions != null) - { - return new ReportColumn(field, bindingName, null, null, isMultiValue, isImage, isHtml, editorType); - } - - if (DynamicReportSqlBuilder.IsReportQueryableLookup(lookup)) - { - return new ReportColumn(field, DynamicReportSqlBuilder.GetLookupBindingName(field), lookup, null, isMultiValue, isImage, isHtml, editorType); - } - - if (lookup.DataSourceType == UiLookupDataSourceTypeEnum.StaticData) - { - var staticLookupValues = DynamicReportImageHelper.GetStaticLookupValues(lookup, defaultValueHelper); - return new ReportColumn( - field, - staticLookupValues?.Count > 0 ? DynamicReportSqlBuilder.GetLookupBindingName(field) : bindingName, - lookup, - staticLookupValues, - isMultiValue, - isImage, - isHtml, - editorType); - } - - return new ReportColumn(field, bindingName, null, null, isMultiValue, isImage, isHtml, editorType); - } - - private static bool IsMultiValueLookupEditor(string editorType) - { - return editorType is PlatformConsts.EditorTypes.dxTagBox or "dxGridBox"; - } - private static TreeOptionDto GetTreeOptions(ListForm listForm) { if (string.IsNullOrWhiteSpace(listForm.TreeOptionJson)) @@ -723,52 +509,6 @@ public class DynamicTreeReport : XtraReport } } - private sealed record ReportColumn( - ListFormField Field, - string BindingName, - LookupDto Lookup, - Dictionary StaticLookupValues, - bool IsMultiValue, - bool IsImage, - bool IsHtml, - string EditorType, - int ImageCount = 1) : DynamicReportSqlBuilder.IReportLookupColumn, DynamicReportGridStateHelper.IReportColumnWidth - { - public float ImageWidth => GetImageColumnWidth(this); - } - - private static SummaryFunc? GetSummaryType(string totalSummaryJson) - { - if (string.IsNullOrWhiteSpace(totalSummaryJson)) - { - return null; - } - - var value = totalSummaryJson.ToLowerInvariant(); - if (value.Contains("\"summarytype\":\"sum\"")) - { - return SummaryFunc.Sum; - } - if (value.Contains("\"summarytype\":\"avg\"")) - { - return SummaryFunc.Avg; - } - if (value.Contains("\"summarytype\":\"min\"")) - { - return SummaryFunc.Min; - } - if (value.Contains("\"summarytype\":\"max\"")) - { - return SummaryFunc.Max; - } - if (value.Contains("\"summarytype\":\"count\"")) - { - return SummaryFunc.Count; - } - - return null; - } - private static string GetParameter(IDictionary parameters, string name) => DynamicReportImageHelper.GetParameter(parameters, name); } diff --git a/ui/src/proxy/form/models.ts b/ui/src/proxy/form/models.ts index f4e8aaac..7a07b56c 100644 --- a/ui/src/proxy/form/models.ts +++ b/ui/src/proxy/form/models.ts @@ -839,9 +839,11 @@ export enum ColumnRowTypeListOptions { export enum SubFormTabTypeEnum { List = 'List', + Card = 'Card', Tree = 'Tree', Gantt = 'Gantt', Scheduler = 'Scheduler', + Todo = 'Todo', Form = 'Form', Chart = 'Chart', Pivot = 'Pivot', diff --git a/ui/src/views/form/FormDevExpress.tsx b/ui/src/views/form/FormDevExpress.tsx index 98b4bbc6..437b7752 100644 --- a/ui/src/views/form/FormDevExpress.tsx +++ b/ui/src/views/form/FormDevExpress.tsx @@ -8,7 +8,7 @@ import { SimpleItem as SimpleItemDx, } from 'devextreme-react/form' import type { FieldDataChangedEvent, GroupItem } from 'devextreme/ui/form' -import { Dispatch, RefObject, useEffect, useRef, useState } from 'react' +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' @@ -16,135 +16,21 @@ 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' -const flattenFormItems = (items: any[] = []): SimpleItemWithColData[] => - items.flatMap((item) => [ - ...(item?.dataField ? [item] : []), - ...flattenFormItems(item?.items || []), - ...(item?.tabs || []).flatMap((tab: any) => flattenFormItems(tab?.items || [])), - ]) - -const updateReadOnlyInFormItems = (items: any[] = [], field: string, readOnly: boolean) => { - let changed = false - const expected = String(field || '').toLowerCase() - - const nextItems = items.map((item) => { - const key = item?.dataField || item?.name - let nextItem = item - - if (key && String(key).toLowerCase() === expected) { - const editorOptions = nextItem.editorOptions || {} - if (editorOptions.readOnly !== readOnly) { - changed = true - nextItem = { - ...nextItem, - editorOptions: { - ...editorOptions, - readOnly, - }, - } - } - } - - if (nextItem?.items?.length) { - const childResult = updateReadOnlyInFormItems(nextItem.items, field, readOnly) - if (childResult.changed) { - changed = true - nextItem = { ...nextItem, items: childResult.items } - } - } - - if (nextItem?.tabs?.length) { - const tabs = nextItem.tabs.map((tab: any) => { - const tabResult = updateReadOnlyInFormItems(tab.items, field, readOnly) - if (tabResult.changed) { - changed = true - return { ...tab, items: tabResult.items } - } - return tab - }) - nextItem = tabs === nextItem.tabs ? nextItem : { ...nextItem, tabs } - } - - return nextItem - }) - - return { items: nextItems, changed } -} - -const findFormFieldKey = (items: any[] = [], field: string): string => { - const expected = String(field || '').toLowerCase() - - for (const item of items || []) { - const key = item?.dataField || item?.name - if (key && String(key).toLowerCase() === expected) { - return key - } - - const childKey = findFormFieldKey(item?.items || [], field) - if (childKey) { - return childKey - } - - for (const tab of item?.tabs || []) { - const tabKey = findFormFieldKey(tab?.items || [], field) - if (tabKey) { - return tabKey - } - } - } - - return field -} - -const setFormEditorReadOnly = (form: any, field: string, readOnly: boolean) => { - if (!form?.option) return false - - const apply = () => { - const formItems = form.option('items') || [] - const resolvedField = findFormFieldKey(formItems, field) - const editor = form.getEditor?.(resolvedField) ?? form.getEditor?.(field) - const result = updateReadOnlyInFormItems(formItems, resolvedField, readOnly) - - if (result.changed) { - try { - const item = form.itemOption?.(resolvedField) ?? form.itemOption?.(field) - if (item) { - form.itemOption?.(resolvedField, 'editorOptions', { - ...(item.editorOptions || {}), - readOnly, - }) - } else { - form.option('items', result.items) - } - } catch { - form.option('items', result.items) - } - } - - const activeEditor = editor ?? form.getEditor?.(resolvedField) ?? form.getEditor?.(field) - if (activeEditor?.option?.('readOnly') !== readOnly) { - activeEditor?.option?.('readOnly', readOnly) - } - } - - apply() - return true -} - -const getValueByField = (data: Record = {}, field?: string) => { - if (!field) return undefined - if (Object.prototype.hasOwnProperty.call(data, field)) return data[field] - const key = Object.keys(data).find( - (itemKey) => itemKey.toLowerCase() === String(field).toLowerCase(), - ) - return key ? data[key] : undefined -} - -const shouldRunEditorScriptOnContentReady = (script?: string) => - Boolean( - script && (script.includes('setEditorReadOnly') || script.includes('runtimeSetEditorReadOnly')), - ) +/** 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 @@ -166,18 +52,8 @@ const FormDevExpress = (props: { const [runtimeReadOnlyFields, setRuntimeReadOnlyFields] = useState>({}) const runtimeReadOnlyFieldsRef = useRef>({}) - const isTouchLikeDevice = () => - typeof window !== 'undefined' && - (window.matchMedia?.('(pointer: coarse)').matches || - window.matchMedia?.('(hover: none)').matches) - - useEffect(() => { - formDataRef.current = formData - }, [formData]) - - useEffect(() => { - formItemsRef.current = formItems - }, [formItems]) + formDataRef.current = formData + formItemsRef.current = formItems useEffect(() => { runtimeReadOnlyFieldsRef.current = runtimeReadOnlyFields @@ -187,24 +63,21 @@ const FormDevExpress = (props: { didAutoFocusRef.current = false }, [listFormCode, mode]) + const getFieldKey = (formItem: SimpleItemWithColData) => + String(formItem.dataField || formItem.name || '').toLowerCase() + const setRuntimeEditorReadOnly = (field: string, readOnly: boolean) => { - const resolvedField = findFormFieldKey(formItemsRef.current, field) - const key = String(resolvedField || field || '').toLowerCase() + const key = String(field || '').toLowerCase() if (!key || runtimeReadOnlyFieldsRef.current[key] === readOnly) { return } - runtimeReadOnlyFieldsRef.current = { - ...runtimeReadOnlyFieldsRef.current, - [key]: readOnly, - } + runtimeReadOnlyFieldsRef.current = { ...runtimeReadOnlyFieldsRef.current, [key]: readOnly } setRuntimeReadOnlyFields(runtimeReadOnlyFieldsRef.current) } const getRuntimeEditorReadOnly = (formItem: SimpleItemWithColData) => { - const field = formItem.dataField || formItem.name - const resolvedField = findFormFieldKey(formItemsRef.current, field || '') - const key = String(resolvedField || field || '').toLowerCase() + const key = getFieldKey(formItem) return Object.prototype.hasOwnProperty.call(runtimeReadOnlyFields, key) ? runtimeReadOnlyFields[key] : undefined @@ -212,32 +85,31 @@ const FormDevExpress = (props: { const applyEditorReadOnly = (form: any, field: string, readOnly: boolean) => { setRuntimeEditorReadOnly(field, readOnly) - setFormEditorReadOnly(form, field, readOnly) - setTimeout(() => setFormEditorReadOnly(formInstanceRef.current ?? form, field, readOnly), 0) + setFormEditorReadOnly(formInstanceRef.current ?? form, field, readOnly) } - const applyEditorScriptFormData = (form: any, newData: any) => { - const nextFormData = { - ...(formDataRef.current || {}), - ...(newData || {}), - } + /** 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 - formDataRef.current = nextFormData - form?.option?.('formData', nextFormData) + const targetForm = form ?? formInstanceRef.current + targetForm?.option?.('formData', nextFormData) + Object.keys(patch).forEach((field) => { + targetForm?.getEditor?.(field)?.option?.('value', patch[field]) + }) - Object.keys(newData || {}).forEach((field) => { - form?.getEditor?.(field)?.option?.('value', newData[field]) - }) + setFormData(nextFormData) + return nextFormData + }, + [setFormData], + ) - setFormData(nextFormData) - } + const runEditorScript = (formItem: SimpleItemWithColData, eventValue: any) => { + if (!formItem?.editorScript) return - const runEditorScript = (formItem: SimpleItemWithColData, eventValue: any, component?: any) => { - if (!formItem?.editorScript) { - return - } - - const form = formInstanceRef.current ?? component + const form = formInstanceRef.current const dataField = formItem.dataField const nextFormData = { ...(formDataRef.current || {}), @@ -247,46 +119,38 @@ const FormDevExpress = (props: { formDataRef.current = nextFormData try { - const editor = { - dataField, - component: form, - } - const formData = nextFormData - const e = { - component: form, - dataField, - scriptEvent: 'valueChanged', - value: eventValue, - } - const runtimeSetEditorReadOnly = (field: string, readOnly: boolean) => - applyEditorReadOnly(form, field, readOnly) - executeEditorScript(formItem.editorScript, { - formData, - e, - editor, - runtimeSetEditorReadOnly, - setFormData: (newData: any) => applyEditorScriptFormData(form, newData), + 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 prevOnValueChanged = formItem.editorOptions?.onValueChanged + 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, - } + ? { useMaskBehavior: true, openOnFieldClick: true, showClearButton: true } : {}), ...(formItem.colData?.placeHolder ? { placeholder: translate('::' + formItem.colData.placeHolder) } @@ -297,350 +161,303 @@ const FormDevExpress = (props: { ...(formItem.editorScript ? { onValueChanged: (e: any) => { - if (typeof prevOnValueChanged === 'function') { - prevOnValueChanged(e) - } + previousOnValueChanged?.(e) if (formItem.dataField) { - const nextFormData = { - ...(formDataRef.current || {}), - ...(formInstanceRef.current?.option?.('formData') || {}), - [formItem.dataField]: e?.value, - } - formDataRef.current = nextFormData - formInstanceRef.current?.option?.('formData', nextFormData) - setFormData(nextFormData) + commitFormData({ [formItem.dataField]: e?.value }) } - runEditorScript(formItem, e?.value, formInstanceRef.current) + runEditorScript(formItem, e?.value) }, } : {}), - buttons: (formItem.editorOptions?.buttons || []).map((btn: any) => { - if (btn?.options?.onClick && typeof btn.options.onClick === 'string') { - const origClick = eval(`(${btn.options.onClick})`) - btn.options.onClick = (e: any) => { - origClick({ - ...e, - formData: formDataRef.current, - fieldName: formItem.dataField, - mode, - }) + ...(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, + }), + }, + } + }), } - } - return btn - }), + : {}), } } - const getFormItemKey = (formItem: SimpleItemWithColData, index: number) => { - const runtimeReadOnly = getRuntimeEditorReadOnly(formItem) - return `formItem-${formItem.dataField || formItem.name || index}-${String(runtimeReadOnly)}` - } + const getFormItemKey = (formItem: SimpleItemWithColData, index: number) => + `formItem-${formItem.dataField || formItem.name || index}-${String( + getRuntimeEditorReadOnly(formItem), + )}` - // formItems değiştiğinde (özellikle cascading alanlar için) editörlerin dataSource'larını güncelle + // Cascading alanlarda formItems yenilendiğinde editör dataSource'larını güncelle. useEffect(() => { - if (!refForm.current?.instance()) return - - const allItems = formItems.flatMap((group) => flattenFormItems([group])) - - allItems.forEach((item) => { - if (item.colData?.lookupDto?.dataSourceType && item.editorOptions?.dataSource) { - try { - const editor = refForm.current?.instance().getEditor(item.dataField!) - if (editor) { - editor.option('dataSource', item.editorOptions.dataSource) - } - } catch (err) { - // Editor henüz oluşmamış olabilir, sessizce devam et - console.debug('Editor update skipped for', item.dataField, err) - } - } - }) - }, [formItems]) - - // Cascade fieldlerin disabled durumunu güncelle - const updateCascadeDisabledStates = () => { - if (!refForm.current?.instance()) return - - const allItems = formItemsRef.current.flatMap((group) => flattenFormItems([group])) - - allItems.forEach((item) => { - const cascadeParentFields = item.colData?.lookupDto?.cascadeParentFields - if (cascadeParentFields) { - const parentFields = cascadeParentFields.split(',').map((f: string) => f.trim()) - - try { - const editor = refForm.current?.instance().getEditor(item.dataField!) - if (editor && mode !== 'view') { - if (item.editorOptions?.disabled === true) { - editor.option('disabled', true) - return - } - - // Parent fieldlerden en az biri boşsa disabled olmalı - const shouldDisable = parentFields.some((parentField: string) => { - return !formDataRef.current || !formDataRef.current[parentField] - }) - - editor.option('disabled', shouldDisable) - } - } catch (err) { - console.debug('Cascade disabled update skipped for', item.dataField, err) - } - } - }) - } - - // formData değiştiğinde cascade disabled durumlarını güncelle - useEffect(() => { - updateCascadeDisabledStates() - }, [formData, mode]) - - const runReadOnlyScripts = (form: any) => { + const form = refForm.current?.instance() if (!form) return - const currentFormData = { - ...(formDataRef.current || {}), - ...(form?.option?.('formData') || {}), - } - formDataRef.current = currentFormData + flattenFormGroups(formItems).forEach((item) => { + if (!item.colData?.lookupDto?.dataSourceType || !item.editorOptions?.dataSource) return - formItemsRef.current - .flatMap((group) => flattenFormItems([group])) - .filter((formItem) => shouldRunEditorScriptOnContentReady(formItem.editorScript)) - .forEach((formItem) => { - try { - const editor = { - dataField: formItem.dataField, - component: form, - } - const formData = currentFormData - const e = { - component: form, - dataField: formItem.dataField, - scriptEvent: 'contentReady', - value: getValueByField(currentFormData, formItem.dataField), - } - const runtimeSetEditorReadOnly = (field: string, readOnly: boolean) => - applyEditorReadOnly(form, field, readOnly) + 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]) - executeEditorScript(formItem.editorScript!, { - formData, - e, - editor, - runtimeSetEditorReadOnly, - setFormData: undefined, - }) - } catch (err) { - console.error('Script execution failed on contentReady for', formItem.name, err) - } - }) - } + /** 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 anında salt-okunur davranışını belirleyen script'leri çalıştırır. */ + const runReadOnlyScripts = useCallback( + (form: any) => { + if (!form) return + + const currentFormData = { + ...(formDataRef.current || {}), + ...(form?.option?.('formData') || {}), + } + formDataRef.current = currentFormData + + 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), + setFormData: undefined, + }) + } catch (err) { + console.error('Script execution failed on contentReady for', formItem.name, err) + } + }) + }, + // 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 - } + if (!form || mode === 'view' || !formData || !formItems?.length) return - const scriptFields = formItemsRef.current - .flatMap((group) => flattenFormItems([group])) + const scriptFields = flattenFormGroups(formItemsRef.current) .filter((formItem) => shouldRunEditorScriptOnContentReady(formItem.editorScript)) .map((formItem) => formItem.dataField) .join(MULTIVALUE_DELIMITER) - if (!scriptFields) { - return - } + if (!scriptFields) return const scriptKey = `${mode}|${scriptFields}|${JSON.stringify(formData)}` - if (lastContentReadyScriptKeyRef.current === scriptKey) { - return - } + if (lastContentReadyScriptKeyRef.current === scriptKey) return lastContentReadyScriptKeyRef.current = scriptKey setTimeout(() => runReadOnlyScripts(form), 0) - }, [formData, formItems, mode]) + }, [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 - } + onFieldDataChanged={(e: FieldDataChangedEvent) => { + if (!e.dataField) return const newFormData = { ...formData, [e.dataField]: e.value } let hasChanges = !Object.is(formData?.[e.dataField], e.value) - // Cascading child field'leri temizle (parent field değiştiğinde) - const allItems = formItemsRef.current.flatMap((group) => flattenFormItems([group])) - const cascadingChildren = allItems.filter((item) => { - const parentFields = item.colData?.lookupDto?.cascadeParentFields?.split(',') || [] - return parentFields.some((field) => field.trim() === e.dataField) - }) - - // Parent field değiştiğinde child field'leri temizle - cascadingChildren.forEach((child) => { - if (!Object.is(newFormData[child.dataField!], null)) { - newFormData[child.dataField!] = null - hasChanges = true - } - }) + // 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) } - // Cascade disabled durumlarını güncelle (setTimeout ile editor güncellemesinden sonra çalışsın) - setTimeout(() => { - updateCascadeDisabledStates() - }, 0) + // 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 form = e.component - - runReadOnlyScripts(form) - - const groupItems = e.component.option('items') as any[] - const firstItem = groupItems?.[0]?.items?.[0] - - if (!didAutoFocusRef.current && firstItem?.dataField && !isTouchLikeDevice()) { + const firstItem = (e.component.option('items') as any[])?.[0]?.items?.[0] + if ( + !didAutoFocusRef.current && + firstItem?.dataField && + mode !== 'view' && + !isTouchLikeDevice() + ) { didAutoFocusRef.current = true - const editor = e.component.getEditor(firstItem.dataField) - if (mode !== 'view') editor?.focus() + e.component.getEditor(firstItem.dataField)?.focus() } }} > - {formItems.map((formGroupItem, i) => { - return ( - - {(formGroupItem.items as SimpleItemWithColData[]) - ?.filter((formItem) => { - if (mode === 'edit') return formItem.allowEditing !== false - if (mode === 'new') return formItem.allowAdding !== false - return true - }) - .map((formItem, i) => { - return formItem.editorType2 === PlatformEditorTypes.dxTagBox ? ( - ( - { - const newData = { ...formDataRef.current, [formItem.dataField!]: e } - formDataRef.current = newData - setFormData(newData) - runEditorScript(formItem, e, formInstanceRef.current) - }} - editorOptions={getEditorOptions(formItem)} - > - )} - label={{ - text: translate('::' + formItem.colData?.captionName), - className: 'font-semibold', - }} - > - ) : formItem.editorType2 === PlatformEditorTypes.dxGridBox ? ( - ( - { - const newData = { ...formDataRef.current, [formItem.dataField!]: e } - formDataRef.current = newData - setFormData(newData) - runEditorScript(formItem, e, formInstanceRef.current) - }} - editorOptions={getEditorOptions(formItem)} - > - )} - label={{ - text: translate('::' + formItem.colData?.captionName), - className: 'font-semibold', - }} - > - ) : formItem.editorType2 === PlatformEditorTypes.dxImageUpload ? ( - ( - { - const newData = { ...formDataRef.current, [formItem.dataField!]: val } - formDataRef.current = newData - setFormData(newData) - runEditorScript(formItem, val, formInstanceRef.current) - }} - editorOptions={getEditorOptions(formItem)} - /> - )} - label={{ - text: translate('::' + formItem.colData?.captionName), - className: 'font-semibold', - }} - > - ) : formItem.editorType2 === PlatformEditorTypes.dxImageViewer ? ( - ( - - )} - label={{ - text: translate('::' + formItem.colData?.captionName), - className: 'font-semibold', - }} - > - ) : ( - - ) - })} - - ) - })} + {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)} + + ))} ) } diff --git a/ui/src/views/form/FormEdit.tsx b/ui/src/views/form/FormEdit.tsx index ff22a634..2d408363 100644 --- a/ui/src/views/form/FormEdit.tsx +++ b/ui/src/views/form/FormEdit.tsx @@ -1,46 +1,16 @@ -import { Container, Loading } from '@/components/shared' -import { useLocalization } from '@/utils/hooks/useLocalization' -import { Helmet } from 'react-helmet' import { useParams } from 'react-router-dom' import FormButtons from './FormButtons' -import FormDevExpress from './FormDevExpress' -import SubForms from './SubForms' import { FormProps } from './types' import { useGridData } from './useFormData' -import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon' -import { Badge } from '@/components/ui' -import { useState } from 'react' -import { NotePanel } from './notes/NotePanel' -import { usePermission } from '@/utils/hooks/usePermission' -import { APP_NAME } from '@/constants/app.constant' +import { FormPageShell } from './shared/FormPageShell' -const FormEdit = ( - props: FormProps = { - isSubForm: false, - onActionView: () => {}, - onActionNew: () => {}, - }, -) => { +const FormEdit = (props: FormProps = { isSubForm: false }) => { const mode = 'edit' - const { isSubForm, level } = props + const { isSubForm, level, onActionView, onActionNew } = props const params = useParams() const listFormCode = props?.listFormCode ?? params?.listFormCode ?? '' const id = props?.id ?? params?.id ?? '' - const MenuIcon = useCurrentMenuIcon('w-5 h-5') - const { checkPermission } = usePermission() - const [isNotePanelVisible, setIsNotePanelVisible] = useState(false) - const isNotePanel = () => { - return ( - listFormCode && - id && - !isSubForm && - gridDto?.gridOptions?.showNote && - checkPermission(gridDto?.gridOptions.permissionDto.n) - ) - } - - const { translate } = useLocalization() const { fetchData, setFormData, @@ -55,97 +25,49 @@ const FormEdit = ( refForm, permissionResults, } = useGridData({ - mode: 'edit', + mode, listFormCode, id, level, isSubForm, - onSubmitAction: props?.onActionView, + onSubmitAction: onActionView, }) - if (!listFormCode) { - return <> - } - - if (loading) { - return - } - - if (!formData) { - return <>{translate('::App.NoResults')} - } - return ( - <> - - {!isSubForm && ( - - )} - -
-
- {MenuIcon} - {!isSubForm && ( - <> -

- {translate('::' + gridDto?.gridOptions?.title)} -

- ● - - - )} -
- {permissionResults && ( - [id]} - getSelectedRowsData={() => [formData]} - getFilter={() => filter} - onActionView={props?.onActionView} - onActionNew={props?.onActionNew} - /> - )} -
-
- [id]} + getSelectedRowsData={() => [formData]} + getFilter={() => filter} + onActionView={onActionView} + onActionNew={onActionNew} /> -
- -
- - {/* Note Panel - sadece ana formda göster */} - {isNotePanel() && ( - setIsNotePanelVisible(!isNotePanelVisible)} - /> - )} - + ) + } + /> ) } diff --git a/ui/src/views/form/FormNew.tsx b/ui/src/views/form/FormNew.tsx index 7c7c7de1..0169cac1 100644 --- a/ui/src/views/form/FormNew.tsx +++ b/ui/src/views/form/FormNew.tsx @@ -1,35 +1,23 @@ -import { Container, Loading } from '@/components/shared' -import { useLocalization } from '@/utils/hooks/useLocalization' +import { Loading } from '@/components/shared' +import { lazy, useEffect, useMemo, useRef, useState } from 'react' +import { useParams, useSearchParams } from 'react-router-dom' import type { DataType } from 'devextreme/common' import type { SimpleItem } from 'devextreme/ui/form' -import { lazy, useEffect, useRef, useState } from 'react' -import { Helmet } from 'react-helmet' -import { useParams, useSearchParams } from 'react-router-dom' import FormButtons from './FormButtons' -import FormDevExpress from './FormDevExpress' import { FormProps } from './types' import { useGridData } from './useFormData' -import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon' -import { Badge } from '@/components/ui' -import { APP_NAME } from '@/constants/app.constant' -import { FieldCustomValueTypeEnum } from '@/proxy/form/models' -import { getList, getNextSequenceValue } from '@/services/form.service' -import { autoNumber } from '../list/Utils' +import { getList } from '@/services/form.service' +import { applyNewRowDefaults, castValueByDataType } from '../list/shared/editingForm' +import { FormPageShell } from './shared/FormPageShell' const FormView = lazy(() => import('./FormView')) -export const FormNewContent = ( - props: FormProps = { - isSubForm: false, - }, -) => { +export const FormNewContent = (props: FormProps = { isSubForm: false }) => { const mode = 'new' - const { isSubForm, level, sParams } = props + const { isSubForm, level, sParams, onActionView } = props const params = useParams() const listFormCode = props?.listFormCode ?? params?.listFormCode ?? '' const [searchParams] = useSearchParams() - const { translate } = useLocalization() - const MenuIcon = useCurrentMenuIcon('w-5 h-5') const initializedListFormCodeRef = useRef() const { @@ -48,220 +36,107 @@ export const FormNewContent = ( listFormCode, level, isSubForm, - onSubmitAction: (props?.onActionView), + onSubmitAction: onActionView, }) - const [newParams, setNewParams] = useState(new URLSearchParams()) - useEffect(() => { - setNewParams( + // URL parametreleri + üst formdan gelen parametreler. + const newParams = useMemo( + () => new URLSearchParams({ ...Object.fromEntries(searchParams), ...Object.fromEntries(sParams ?? []), }), - ) - }, [searchParams, sParams]) + [searchParams, sParams], + ) useEffect(() => { initializedListFormCodeRef.current = undefined }, [listFormCode]) + /** Form ilk açıldığında varsayılan değerleri kurar. */ useEffect(() => { - if (!formItems?.length || !gridDto?.columnFormats) { - return - } - - if (initializedListFormCodeRef.current === listFormCode) { - return - } + if (!formItems?.length || !gridDto?.columnFormats) return + if (initializedListFormCodeRef.current === listFormCode) return initializedListFormCodeRef.current = listFormCode let isActive = true - const initializeFormData = async () => { - const initialFormData = await createInitialFormData() - if (isActive) { - setFormData(initialFormData) - } - } + // Tanımlı tüm alanlar önce boşaltılır, ardından kolon varsayılanları uygulanır. + const initialData: Record = {} + formItems.forEach((group) => + (group.items as SimpleItem[] | undefined)?.forEach((item) => { + if (item.dataField) initialData[item.dataField] = undefined + }), + ) - initializeFormData() + applyNewRowDefaults(gridDto, initialData).then((data) => { + if (isActive) setFormData(data) + }) return () => { isActive = false } }, [listFormCode, formItems?.length, gridDto?.columnFormats]) - // newParams değiştiğinde sadece newParams'tan gelen alanları güncelle + /** URL parametrelerinden gelen alanları forma yazar. */ useEffect(() => { - if (!formItems || !formData || !gridDto?.columnFormats) { - return - } + if (!formItems?.length || !formData || !gridDto?.columnFormats) return const updatedData = { ...formData } let hasChanges = false for (const colFormat of gridDto.columnFormats) { - if (!colFormat.fieldName || !newParams.has(colFormat.fieldName)) { - continue - } + const fieldName = colFormat.fieldName + if (!fieldName || !newParams.has(fieldName)) continue - const dType = colFormat.dataType as DataType - let newValue: any - - switch (dType) { - case 'date': - case 'datetime': - newValue = new Date(newParams.get(colFormat.fieldName)!) - break - case 'number': - newValue = Number(newParams.get(colFormat.fieldName)) - break - case 'boolean': - if (newParams.get(colFormat.fieldName) === 'true') { - newValue = true - } else if (newParams.get(colFormat.fieldName) === 'false') { - newValue = false - } - break - case 'object': - try { - newValue = JSON.parse(newParams.get(colFormat.fieldName) as string) - } catch { - newValue = updatedData[colFormat.fieldName] - } - break - default: - newValue = newParams.get(colFormat.fieldName) - break - } - - if (updatedData[colFormat.fieldName] !== newValue) { - updatedData[colFormat.fieldName] = newValue + const nextValue = castValueByDataType( + newParams.get(fieldName), + colFormat.dataType as DataType, + ) + if (updatedData[fieldName] !== nextValue) { + updatedData[fieldName] = nextValue hasChanges = true } } - if (hasChanges) { - setFormData(updatedData) - } - }, [newParams, gridDto?.columnFormats]) + if (hasChanges) setFormData(updatedData) + }, [newParams, formData, gridDto?.columnFormats]) - // newParams'tan gelen alanları readonly yap + /** URL'den gelen alanlar kullanıcı tarafından değiştirilemez. */ useEffect(() => { - if (!formItems || !gridDto?.columnFormats || !newParams) { - return - } + if (!formItems?.length || !gridDto?.columnFormats) return - for (const colFormat of gridDto.columnFormats) { - if (!colFormat.fieldName || !newParams.has(colFormat.fieldName)) { - continue - } + const lockedFields = new Set( + gridDto.columnFormats + .map((colFormat) => colFormat.fieldName) + .filter((fieldName): fieldName is string => !!fieldName && newParams.has(fieldName)), + ) + if (!lockedFields.size) return - // Eğer URL'den değer geliyorsa, bu editörü readonly yapıyoruz - formItems.forEach((form) => { - form?.items?.forEach((item: SimpleItem) => { - if (item.dataField === colFormat.fieldName) { - item.editorOptions = { ...item.editorOptions, readOnly: true } - } - }) - }) - } + formItems.forEach((group) => + (group.items as SimpleItem[] | undefined)?.forEach((item) => { + if (item.dataField && lockedFields.has(item.dataField)) { + item.editorOptions = { ...item.editorOptions, readOnly: true } + } + }), + ) }, [formItems, newParams, gridDto?.columnFormats]) - async function createInitialFormData() { - const data: any = {} - if (!formItems?.length) { - return data - } - - // Default tüm alanlar undefined olsun - for (const formGroupItem of formItems) { - if (formGroupItem.items) { - //SimpleItem - for (const formItem of formGroupItem.items) { - const f = formItem as SimpleItem - data[f.dataField!] = undefined - } - } - } - - if (!gridDto?.columnFormats) { - return data - } - - // Grid'den gelen columnFormat'ları kullanarak default değerleri set et - for (const colFormat of gridDto?.columnFormats || []) { - if (!colFormat.fieldName) { - continue - } - - if (colFormat.defaultValue !== null && colFormat.defaultValue !== undefined) { - if ( - typeof colFormat.defaultValue === 'string' && - colFormat.defaultValue === '@AUTONUMBER' - ) { - data[colFormat.fieldName] = autoNumber() - continue - } - - if (colFormat.defaultValueType === FieldCustomValueTypeEnum.Sequence) { - try { - const response = await getNextSequenceValue(String(colFormat.defaultValue)) - data[colFormat.fieldName] = response.data - } catch (error) { - console.error('Sequence default value alınamadı:', { - fieldName: colFormat.fieldName, - defaultValue: colFormat.defaultValue, - error, - }) - } - continue - } - - data[colFormat.fieldName] = colFormat.defaultValue - } - } - - return data - } - - if (!listFormCode) { - return <> - } - - if (loading) { - return - } - - if (!formData) { - return <>{translate('::App.NoResults')} - } - return ( - - {!isSubForm && ( - - )} -
-
- {MenuIcon} - {!isSubForm && ( - <> -

- {translate('::' + gridDto?.gridOptions?.title)} -

- ● - - - )} -
- {permissionResults && ( + []} getSelectedRowsData={() => [formData]} getFilter={() => []} - onActionView={props?.onActionView} + onActionView={onActionView} /> - )} -
-
- -
-
+ ) + } + /> ) } -const FormNew = ( - props: FormProps = { - isSubForm: false, - }, -) => { +/** + * `listFormType === 'Form'` olan liste formlarında "yeni kayıt" ekranı yerine + * doğrudan görüntüleme ekranı açılır (tekil kayıtlı formlar). + */ +const FormNew = (props: FormProps = { isSubForm: false }) => { const params = useParams() const listFormCode = props.listFormCode ?? params.listFormCode ?? '' const [listFormType, setListFormType] = useState() const [loading, setLoading] = useState(true) useEffect(() => { - let isActive = true - - const resolveFormType = async () => { - setLoading(true) - try { - const response = await getList({ listFormCode }) - if (isActive) { - setListFormType(response?.data?.gridOptions?.listFormType) - } - } finally { - if (isActive) { - setLoading(false) - } - } - } - - if (listFormCode && !props.isSubForm) { - resolveFormType() - } else { + if (!listFormCode || props.isSubForm) { setLoading(false) + return } + let isActive = true + setLoading(true) + + getList({ listFormCode }) + .then((response: any) => { + if (isActive) setListFormType(response?.data?.gridOptions?.listFormType) + }) + .catch((error: unknown) => console.error('ListForm tipi alınamadı:', error)) + .finally(() => { + if (isActive) setLoading(false) + }) + return () => { isActive = false } diff --git a/ui/src/views/form/FormView.tsx b/ui/src/views/form/FormView.tsx index fad60bf0..4e1bc9fc 100644 --- a/ui/src/views/form/FormView.tsx +++ b/ui/src/views/form/FormView.tsx @@ -1,45 +1,15 @@ -import { Container, Loading } from '@/components/shared' -import { useLocalization } from '@/utils/hooks/useLocalization' -import { Helmet } from 'react-helmet' import { useParams } from 'react-router-dom' import FormButtons from './FormButtons' -import FormDevExpress from './FormDevExpress' -import SubForms from './SubForms' import { FormProps } from './types' import { useGridData } from './useFormData' -import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon' -import { Badge } from '@/components/ui' -import { useState } from 'react' -import { NotePanel } from './notes/NotePanel' -import { usePermission } from '@/utils/hooks/usePermission' -import { APP_NAME } from '@/constants/app.constant' +import { FormPageShell } from './shared/FormPageShell' -const FormView = ( - props: FormProps = { - isSubForm: false, - onActionEdit: () => {}, - onActionNew: () => {}, - }, -) => { +const FormView = (props: FormProps = { isSubForm: false }) => { const mode = 'view' - const { isSubForm, level } = props + const { isSubForm, level, onActionEdit, onActionNew } = props const params = useParams() const listFormCode = props?.listFormCode ?? params?.listFormCode ?? '' const id = props?.id ?? params?.id ?? '' - const { translate } = useLocalization() - const MenuIcon = useCurrentMenuIcon('w-5 h-5') - const { checkPermission } = usePermission() - - const [isNotePanelVisible, setIsNotePanelVisible] = useState(false) - const isNotePanel = () => { - return ( - listFormCode && - formData && - !isSubForm && - gridDto?.gridOptions?.showNote && - checkPermission(gridDto?.gridOptions.permissionDto.n) - ) - } const { loading, @@ -52,102 +22,49 @@ const FormView = ( refForm, permissionResults, fetchData, - } = useGridData({ - mode, - listFormCode, - id, - isSubForm, - }) + } = useGridData({ mode, listFormCode, id, isSubForm }) - if (!listFormCode) { - return <> - } - - if (loading) { - return - } - - if (!formData) { - return <>{translate('::App.NoResults')} - } - - const recordId = id || formData[gridDto?.gridOptions.keyFieldName ?? 'Id'] + // Form tipi liste formları ID olmadan da açılabildiği için kayıt anahtarı + // veriden okunur. + const recordId = id || formData?.[gridDto?.gridOptions.keyFieldName ?? 'Id'] return ( - <> - - {!isSubForm && ( - - )} -
-
- {MenuIcon} - {!isSubForm && ( - <> -

- {translate('::' + gridDto?.gridOptions?.title)} -

- ● - - - )} -
- {permissionResults && ( - {}} - refreshData={fetchData} - getSelectedRowKeys={() => [recordId]} - getSelectedRowsData={() => [formData]} - getFilter={() => filter} - onActionEdit={props.onActionEdit} - onActionNew={props.onActionNew} - /> - )} -
-
- {}} + noteEntityId={recordId} + refreshData={fetchData} + contentClassName={isSubForm ? 'px-2' : ''} + actions={ + permissionResults && ( + {}} listFormCode={listFormCode} + id={recordId} + gridDto={gridDto!} + commandColumnData={commandColumnData!} + dataSource={dataSource!} + permissions={permissionResults} + handleSubmit={() => {}} + refreshData={fetchData} + getSelectedRowKeys={() => [recordId]} + getSelectedRowsData={() => [formData]} + getFilter={() => filter} + onActionEdit={onActionEdit} + onActionNew={onActionNew} /> -
- -
- - {/* Note Panel - sadece ana formda göster */} - {isNotePanel() && ( - setIsNotePanelVisible(!isNotePanelVisible)} - /> - )} - + ) + } + /> ) } diff --git a/ui/src/views/form/SubForms.tsx b/ui/src/views/form/SubForms.tsx index 19c3ef98..24c2a437 100644 --- a/ui/src/views/form/SubForms.tsx +++ b/ui/src/views/form/SubForms.tsx @@ -2,11 +2,26 @@ import { Tabs } from '@/components/ui' import TabContent from '@/components/ui/Tabs/TabContent' import TabList from '@/components/ui/Tabs/TabList' import TabNav from '@/components/ui/Tabs/TabNav' -import { useEffect, useState } from 'react' -import { FaChartBar, FaList } from 'react-icons/fa' +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { + FaCalendarAlt, + FaChartArea, + FaChartBar, + FaList, + FaProjectDiagram, + FaSitemap, + FaTable, + FaTasks, + FaThLarge, + FaWpforms, +} from 'react-icons/fa' import { useLocation, useNavigate } from 'react-router-dom' import Grid from '../list/Grid' +import CardView from '../list/CardView' import Chart from '../list/Chart' +import Pivot from '../list/Pivot' +import TodoBoard from '../list/TodoBoard' import { GridDto, SubFormDto, SubFormTabTypeEnum } from '@/proxy/form/models' import FormEdit from './FormEdit' import { FormNewContent as FormNew } from './FormNew' @@ -16,6 +31,74 @@ import Tree from '../list/Tree' import GanttView from '../list/GanttView' import SchedulerView from '../list/SchedulerView' +/** + * Aynı props sözleşmesini paylaşan liste tipi alt form görünümleri. + * + * DİKKAT: Bu eşleme modül seviyesinde sabit olarak tutulamaz. Grid/CardView/Tree/ + * GanttView/SchedulerView/TodoBoard/Pivot bu dosyayı geri import ettiği için + * döngüsel bağımlılık vardır ve modül değerlendirme anında bileşenlere erişmek + * "Cannot access 'Grid' before initialization" hatası verir. Bu yüzden çözüm + * render anında yapılır. + */ +const resolveListView = (tabType: SubFormTabTypeEnum) => { + switch (tabType) { + case SubFormTabTypeEnum.List: + return Grid + case SubFormTabTypeEnum.Card: + return CardView + case SubFormTabTypeEnum.Tree: + return Tree + case SubFormTabTypeEnum.Gantt: + return GanttView + case SubFormTabTypeEnum.Scheduler: + return SchedulerView + case SubFormTabTypeEnum.Todo: + return TodoBoard + case SubFormTabTypeEnum.Pivot: + return Pivot + default: + return undefined + } +} + +/** Sekme başlığındaki ikonlar. */ +const TAB_ICONS: Partial> = { + [SubFormTabTypeEnum.List]: , + [SubFormTabTypeEnum.Card]: , + [SubFormTabTypeEnum.Tree]: , + [SubFormTabTypeEnum.Gantt]: , + [SubFormTabTypeEnum.Scheduler]: , + [SubFormTabTypeEnum.Todo]: , + [SubFormTabTypeEnum.Form]: , + [SubFormTabTypeEnum.Chart]: , + [SubFormTabTypeEnum.Pivot]: , +} + +type SubFormTabMode = 'view' | 'edit' | 'new' + +/** + * Alt form ile üst formun ilişkili alanlarından filtre ve kayıt anahtarı üretir. + * (Üst kayıt seçili değilse alt form filtresiz kalır.) + */ +const withParentRelation = (subForm: SubFormDto, formData: any): SubFormDto => { + const relation = subForm.relation?.find((item) => formData?.[item.parentFieldName]) + if (!relation) { + return subForm + } + + const filter: [string, string, string] = [ + relation.childFieldName, + '=', + formData[relation.parentFieldName], + ] + + return { + ...subForm, + searchParams: new URLSearchParams({ filter: JSON.stringify(filter) }), + id: formData[relation.parentFieldName], + } +} + const SubForms = (props: { gridDto: GridDto formData: any @@ -25,206 +108,137 @@ const SubForms = (props: { }) => { const { gridDto, formData, level, refreshData, refreshGridDto } = props const { hash } = useLocation() - const [currentTab, setCurrentTab] = useState() - const [subForms, setSubForms] = useState() const navigate = useNavigate() const { translate } = useLocalization() + const [currentTab, setCurrentTab] = useState() + // Form tipi sekmelerin görüntüle/düzenle/yeni modu; kod bazında tutulur. + const [tabModes, setTabModes] = useState>({}) + + const subForms = useMemo( + () => (gridDto?.gridOptions?.subFormsDto ?? []).map((sub) => withParentRelation(sub, formData)), + [gridDto, formData], + ) + useEffect(() => { if (hash && level === 0) { setCurrentTab(hash.replace('#', '')) - } else if (gridDto?.gridOptions?.subFormsDto?.length) { - setCurrentTab(gridDto?.gridOptions?.subFormsDto[0].code) } else { - setCurrentTab(undefined) + setCurrentTab(subForms[0]?.code) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [hash, gridDto]) - useEffect(() => { - if (!gridDto?.gridOptions?.subFormsDto?.length) { - return + const setTabMode = useCallback( + (code: string, tabMode: SubFormTabMode) => + setTabModes((current) => ({ ...current, [code]: tabMode })), + [], + ) + + if (!formData || !subForms.length) { + return null + } + + const renderTabContent = (subForm: SubFormDto) => { + const ListView = resolveListView(subForm.tabType) + + if (ListView) { + return ( + + ) } - for (const subForm of gridDto.gridOptions.subFormsDto) { - if (formData && subForm.relation?.length) { - // sadece ilk eşleşmeyi al - const rel = subForm.relation.find((a) => formData?.[a.parentFieldName]) - - if (rel) { - const filter: [string, string, string] = [ - rel.childFieldName, - '=', - formData[rel.parentFieldName], - ] - - subForm.searchParams = new URLSearchParams({ - filter: JSON.stringify(filter), - }) - - subForm.id = formData[rel.parentFieldName] - } - } + if (subForm.tabType === SubFormTabTypeEnum.Chart) { + return ( + + ) } - setSubForms([...gridDto.gridOptions.subFormsDto]) - }, [gridDto, formData]) + if (subForm.tabType !== SubFormTabTypeEnum.Form) { + return null + } - if (!formData || !subForms?.length) { - return <> + const tabMode = tabModes[subForm.code] ?? (subForm.tabMode as SubFormTabMode) ?? 'view' + const commonProps = { + listFormCode: subForm.code, + level: level + 1, + isSubForm: true, + } + + if (tabMode === 'edit') { + return ( + setTabMode(subForm.code, 'new')} + onActionView={() => setTabMode(subForm.code, 'view')} + /> + ) + } + + if (tabMode === 'new') { + return ( + setTabMode(subForm.code, 'view')} + /> + ) + } + + return ( + setTabMode(subForm.code, 'new')} + onActionEdit={() => setTabMode(subForm.code, 'edit')} + /> + ) } return ( { - if (subForms.some((a) => a.code === val)) { - setCurrentTab(val) - if (level === 0) { - navigate(`#${val}`) - } + onChange={(value: string) => { + if (!subForms.some((subForm) => subForm.code === value)) return + + setCurrentTab(value) + if (level === 0) { + navigate(`#${value}`) } }} className="mt-2" > - {subForms.map((subForm) => { - return ( - : } - > - {translate('::' + subForm.tabTitle)} - - ) - })} + {subForms.map((subForm) => ( + } + > + {translate('::' + subForm.tabTitle)} + + ))} - {subForms.map((subForm) => { - return ( - - {subForm.tabType == SubFormTabTypeEnum.List && ( - - )} - {subForm.tabType == SubFormTabTypeEnum.Tree && ( - - )} - {subForm.tabType == SubFormTabTypeEnum.Gantt && ( - - )} - {subForm.tabType == SubFormTabTypeEnum.Scheduler && ( - - )} - {subForm.tabType == SubFormTabTypeEnum.Form && - (subForm.tabMode == 'edit' ? ( - { - setSubForms( - subForms.map((a) => { - if (a.code == subForm.code) { - a.tabMode = 'new' - } - return a - }), - ) - }} - onActionView={() => { - setSubForms( - subForms.map((a) => { - if (a.code == subForm.code) { - a.tabMode = 'view' - } - return a - }), - ) - }} - listFormCode={subForm.code} - id={subForm.id} - level={level + 1} - isSubForm={true} - /> - ) : subForm.tabMode == 'new' ? ( - { - setSubForms( - subForms.map((a) => { - if (a.code == subForm.code) { - a.tabMode = 'view' - } - return a - }), - ) - }} - /> - ) : ( - { - setSubForms( - subForms.map((a) => { - if (a.code == subForm.code) { - a.tabMode = 'new' - } - return a - }), - ) - }} - onActionEdit={() => { - setSubForms( - subForms.map((a) => { - if (a.code == subForm.code) { - a.tabMode = 'edit' - } - return a - }), - ) - }} - listFormCode={subForm.code} - id={subForm.id} - level={level + 1} - isSubForm={true} - /> - ))} - {subForm.tabType == SubFormTabTypeEnum.Chart && ( - - )} - - ) - })} + {subForms.map((subForm) => ( + + {renderTabContent(subForm)} + + ))} ) } diff --git a/ui/src/views/form/editors/ImageUploadEditorComponent.tsx b/ui/src/views/form/editors/ImageUploadEditorComponent.tsx index 99ad5aa3..619b954c 100644 --- a/ui/src/views/form/editors/ImageUploadEditorComponent.tsx +++ b/ui/src/views/form/editors/ImageUploadEditorComponent.tsx @@ -1,5 +1,5 @@ import { ImageUploadOptionsDto } from '@/proxy/form/models' -import { ReactElement, useRef, useState } from 'react' +import { ReactElement, useEffect, useMemo, useRef, useState } from 'react' import { FaSpinner, FaUpload } from 'react-icons/fa' import { Button } from '@/components/ui' import { @@ -7,6 +7,7 @@ import { openImageInNewTab, showImageHoverPreview, } from './imageHoverPreview' +import { normalizeImageSize } from '../../list/shared/imageValue' const ImageUploadEditorComponent = ({ value, @@ -28,19 +29,24 @@ const ImageUploadEditorComponent = ({ const isMultiple: boolean = resolvedOptions.multiple === true || (resolvedOptions.multiple as any) === 'true' - const thumbW: number = resolvedOptions.width ?? 40 - const thumbH: number = resolvedOptions.height ?? 40 + const thumbW = normalizeImageSize(resolvedOptions.width, 40) + const thumbH = normalizeImageSize(resolvedOptions.height, 40) - const initialUrls: string[] = value - ? Array.isArray(value) - ? (value as string[]).filter(Boolean) - : [value as string].filter(Boolean) - : [] + const valueUrls = useMemo( + () => (Array.isArray(value) ? value : [value]).filter(Boolean) as string[], + [value], + ) - const [urls, setUrls] = useState(initialUrls) + const [urls, setUrls] = useState(valueUrls) const [uploading, setUploading] = useState(false) const inputRef = useRef(null) + // Değer dışarıdan (editör script'i, veri yenileme…) değiştiğinde önizlemeler + // güncellenmiyordu; state yalnızca ilk render'da kuruluyordu. + useEffect(() => { + setUrls(valueUrls) + }, [valueUrls]) + const removeImage = (index: number) => { const newUrls = urls.filter((_, i) => i !== index) setUrls(newUrls) @@ -76,8 +82,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() - const url: string = data?.url ?? data?.fileUrl ?? data?.path ?? data - uploadedUrls.push(url) + uploadedUrls.push(String(data?.url ?? data?.fileUrl ?? data?.path ?? data ?? '').trim()) } else { // uploadUrl yoksa base64 olarak sakla const base64 = await toBase64(file) diff --git a/ui/src/views/form/editors/ImageViewerEditorComponent.tsx b/ui/src/views/form/editors/ImageViewerEditorComponent.tsx index 5b8cb00d..c695b1ab 100644 --- a/ui/src/views/form/editors/ImageViewerEditorComponent.tsx +++ b/ui/src/views/form/editors/ImageViewerEditorComponent.tsx @@ -6,91 +6,12 @@ import { openImageInNewTab, showImageHoverPreview, } from './imageHoverPreview' - -const parseJsonObject = (value: unknown) => { - if (!value) return undefined - if (typeof value === 'object') return value as Record - if (typeof value !== 'string') return undefined - - try { - const parsed = JSON.parse(value) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : undefined - } catch { - return undefined - } -} - -const normalizeImageSize = (value: unknown, fallback: number) => { - const size = Number(value) - return Number.isFinite(size) && size > 0 ? size : fallback -} - -const getImageSource = (value: unknown) => { - if (!value) return '' - if (typeof value === 'string') return value.trim() - - if (typeof value === 'object') { - const item = value as Record - return String(item.url ?? item.src ?? item.fileUrl ?? item.path ?? item.value ?? '').trim() - } - - return String(value).trim() -} - -const isProbablyBase64Image = (value: string) => - value.length > 80 && /^[A-Za-z0-9+/]+={0,2}$/.test(value) - -const toImageSource = (value: string) => { - const trimmed = value.trim() - if (!trimmed) return '' - if (trimmed.startsWith('data:image/') || !isProbablyBase64Image(trimmed)) return trimmed - return `data:image/jpeg;base64,${trimmed}` -} - -const splitImageString = (value: string) => { - const trimmed = value.trim() - if (!trimmed) return [] - if (trimmed.startsWith('data:image/')) return [trimmed] - - return trimmed - .split(/\r?\n|\|\s*/) - .flatMap((part) => { - const text = part.trim() - if (!text || text.startsWith('data:image/')) return text ? [text] : [] - return text.split(',').map((item) => item.trim()) - }) - .map(toImageSource) - .filter(Boolean) -} - -const normalizeImageValue = (value: unknown): string[] => { - if (Array.isArray(value)) { - return value.flatMap((item) => normalizeImageValue(item)) - } - - 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((item) => normalizeImageValue(item)) - } - } catch { - return [toImageSource(trimmed)] - } - } - - return splitImageString(trimmed) - } - - const source = getImageSource(value) - return source ? [toImageSource(source)] : [] -} +import { + NO_IMAGE, + normalizeImageSize, + normalizeImageValue, + parseJsonObject, +} from '../../list/shared/imageValue' const ImageViewerEditorComponent = ({ value, @@ -137,7 +58,7 @@ const ImageViewerEditorComponent = ({ }} onError={({ currentTarget }) => { currentTarget.onerror = null - currentTarget.src = '/img/others/no-image.png' + currentTarget.src = NO_IMAGE }} /> diff --git a/ui/src/views/form/editors/imageHoverPreview.ts b/ui/src/views/form/editors/imageHoverPreview.ts index dd7b75e6..1da9c8de 100644 --- a/ui/src/views/form/editors/imageHoverPreview.ts +++ b/ui/src/views/form/editors/imageHoverPreview.ts @@ -1,7 +1,12 @@ -import React from 'react' - const NO_IMAGE = '/img/others/no-image.png' +/** Hem React SyntheticEvent hem de native MouseEvent ile çalışabilmesi için minimum sözleşme. */ +type HoverPreviewEvent = { + clientX: number + clientY: number + currentTarget: EventTarget | null +} + let previewElement: HTMLDivElement | null = null let previewTarget: HTMLElement | null = null let previewListenersBound = false @@ -71,9 +76,9 @@ const getPreviewElement = () => { return previewElement } -export const showImageHoverPreview = (src: string, event: React.MouseEvent) => { +export const showImageHoverPreview = (src: string, event: HoverPreviewEvent) => { const element = getPreviewElement() - previewTarget = event.currentTarget + previewTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null const image = element.querySelector('img') as HTMLImageElement image.onerror = () => { image.onerror = null diff --git a/ui/src/views/form/notes/NoteModal.tsx b/ui/src/views/form/notes/NoteModal.tsx index 846da781..2b1f3c64 100644 --- a/ui/src/views/form/notes/NoteModal.tsx +++ b/ui/src/views/form/notes/NoteModal.tsx @@ -6,15 +6,8 @@ import { FaFileAlt, FaFileUpload, FaPlus, FaTrash } from 'react-icons/fa' import { Field, FieldProps, Form, Formik } from 'formik' import * as Yup from 'yup' import { noteService } from '@/services/note.service' -import { - sizeValues, - fontSizeOptions, - fontValues, - fontFamilyOptions, - headerValues, - headerOptions, -} from '@/proxy/reports/data' -import { HtmlEditor, ImageUpload, Item, MediaResizing, Toolbar } from 'devextreme-react/html-editor' +import { HtmlEditor, ImageUpload, MediaResizing } from 'devextreme-react/html-editor' +import { HtmlEditorToolbar } from '@/views/shared/HtmlEditorToolbarItems' import { useLocalization } from '@/utils/hooks/useLocalization' const validationSchema = Yup.object({ @@ -161,36 +154,7 @@ function NoteModalContent({ > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + )} diff --git a/ui/src/views/form/shared/FormPageShell.tsx b/ui/src/views/form/shared/FormPageShell.tsx new file mode 100644 index 00000000..03c0b1e7 --- /dev/null +++ b/ui/src/views/form/shared/FormPageShell.tsx @@ -0,0 +1,149 @@ +/** + * FormView / FormEdit / FormNew ekranlarının ortak sayfa iskeleti. + * + * Üç bileşen de aynı düzeni (Helmet + başlık + FormButtons + FormDevExpress + + * SubForms + NotePanel) satır satır kopyalıyordu. Tek noktadan yönetilir. + */ +import type { ReactNode, RefObject } from 'react' +import { useState } from 'react' +import { Container, Loading } from '@/components/shared' +import { Badge } from '@/components/ui' +import { APP_NAME } from '@/constants/app.constant' +import type { GridDto } from '@/proxy/form/models' +import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon' +import { useLocalization } from '@/utils/hooks/useLocalization' +import { usePermission } from '@/utils/hooks/usePermission' +import type { FormRef } from 'devextreme-react/form' +import type { GroupItem } from 'devextreme/ui/form' +import { Helmet } from 'react-helmet' +import FormDevExpress from '../FormDevExpress' +import SubForms from '../SubForms' +import { NotePanel } from '../notes/NotePanel' +import type { RowMode } from '../types' + +type FormPageShellProps = { + mode: RowMode + listFormCode: string + isSubForm?: boolean + level?: number + loading: boolean + gridDto?: GridDto + formData: any + formItems: GroupItem[] + refForm: RefObject + setFormData: (data: any) => void + /** Başlığın sağındaki aksiyon butonları (FormButtons). */ + actions?: ReactNode + /** Not paneli kimliği; verilmezse panel gösterilmez. */ + noteEntityId?: string + /** Alt form sekmelerini yenileme geri çağrısı. */ + refreshData?: () => Promise + /** FormNew'de alt form gösterilmez. */ + showSubForms?: boolean + /** Form gövdesinin sarmalayıcı sınıfı (FormView farklı dolgu kullanır). */ + contentClassName?: string +} + +export const FormPageShell = ({ + mode, + listFormCode, + isSubForm, + level, + loading, + gridDto, + formData, + formItems, + refForm, + setFormData, + actions, + noteEntityId, + refreshData, + showSubForms = true, + contentClassName = 'px-2', +}: FormPageShellProps) => { + const { translate } = useLocalization() + const { checkPermission } = usePermission() + const MenuIcon = useCurrentMenuIcon('w-5 h-5') + const [isNotePanelVisible, setIsNotePanelVisible] = useState(false) + + const showNotePanel = Boolean( + listFormCode && + noteEntityId && + !isSubForm && + gridDto?.gridOptions?.showNote && + checkPermission(gridDto?.gridOptions.permissionDto.n), + ) + + if (!listFormCode) { + return null + } + + if (loading) { + return + } + + if (!formData) { + return <>{translate('::App.NoResults')} + } + + const title = translate('::' + gridDto?.gridOptions.title) + + return ( + <> + + {!isSubForm && ( + + )} + +
+
+ {MenuIcon} + {!isSubForm && ( + <> +

{title}

● + + + )} +
+ {actions} +
+ +
+ +
+ + {showSubForms && gridDto && ( + + )} +
+ + {/* Not paneli yalnızca ana formda gösterilir. */} + {showNotePanel && ( + setIsNotePanelVisible((visible) => !visible)} + /> + )} + + ) +} diff --git a/ui/src/views/form/shared/formItems.ts b/ui/src/views/form/shared/formItems.ts new file mode 100644 index 00000000..7f3d18c0 --- /dev/null +++ b/ui/src/views/form/shared/formItems.ts @@ -0,0 +1,126 @@ +/** + * Form (FormNew/FormEdit/FormView) ekranlarının item üretimi ve gezinme + * yardımcıları. Daha önce `useFormData.tsx` ve `FormDevExpress.tsx` içinde + * ayrı ayrı tanımlıydı. + */ +import { captionize } from 'devextreme/core/utils/inflector' +import type { GroupItem } from 'devextreme/ui/form' +import type { ColumnFormatDto, GridDto } from '@/proxy/form/models' +import { getEditingFormGroups } from '../../list/Utils' +import { isFormItemVisible, resolveEditorType } from '../../list/shared/editingForm' +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[] => + items.flatMap((item) => [ + ...(item?.dataField ? [item] : []), + ...flattenFormItems(item?.items || []), + ...(item?.tabs || []).flatMap((tab: any) => flattenFormItems(tab?.items || [])), + ]) + +/** Bir grup listesindeki tüm alanları tek seferde düzleştirir. */ +export const flattenFormGroups = (groups: GroupItem[] = []): SimpleItemWithColData[] => + groups.flatMap((group) => flattenFormItems([group])) + +/** 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) ?? [] + +type BuildFormItemsParams = { + gridDto: GridDto + mode: RowMode + columns?: GridColumnData[] + /** Lookup alanları için DevExtreme dataSource üretici. */ + getLookupDataSource: (options: any, colData: ColumnFormatDto, formData: any) => any + formData: any +} + +/** Tek bir ColumnFormatDto'yu DevExtreme form item'ına çevirir. */ +const toFormItem = ( + colFormat: ColumnFormatDto, + { mode, columns, getLookupDataSource, formData }: Omit, +): SimpleItemWithColData => { + let parsedEditorOptions: Record = {} + try { + parsedEditorOptions = colFormat.editorOptions ? JSON.parse(colFormat.editorOptions) : {} + } catch (err) { + console.error('EditorOptions parse error:', colFormat.fieldName, err) + } + + const lookupEditorOptions = colFormat.lookupDto?.dataSourceType + ? { + dataSource: getLookupDataSource(colFormat.editorOptions, colFormat, formData), + valueExpr: colFormat.lookupDto?.valueExpr?.toLowerCase(), + displayExpr: colFormat.lookupDto?.displayExpr?.toLowerCase(), + } + : {} + + const item: SimpleItemWithColData = { + canRead: colFormat.canRead ?? false, + canUpdate: colFormat.canUpdate ?? false, + canCreate: colFormat.canCreate ?? false, + canExport: colFormat.canExport ?? false, + allowEditing: colFormat.allowEditing ?? true, + allowAdding: colFormat.allowAdding ?? true, + dataField: colFormat.fieldName, + name: colFormat.fieldName, + editorType2: colFormat.editorType2, + editorType: resolveEditorType(colFormat.editorType2), + colSpan: colFormat.colSpan, + editorOptions: { ...lookupEditorOptions, ...parsedEditorOptions }, + colData: colFormat, + tagBoxOptions: colFormat.tagBoxOptions, + gridBoxOptions: colFormat.gridBoxOptions, + imageUploadOptions: colFormat.imageUploadOptions, + editorScript: colFormat.editorScript, + } + + // "Options:TemplateName" biçimindeki JSON alt alanları için etiket üret. + if (colFormat.fieldName?.includes(':')) { + item.label = { text: captionize(colFormat.fieldName.split(':')[1]) } + } + + if ((mode === 'edit' && !item.canUpdate) || (mode === 'new' && !item.canCreate)) { + item.editorOptions = { ...item.editorOptions, readOnly: true } + } + + item.isRequired = + columns?.some( + (column) => + column.dataField === colFormat.fieldName && + colFormat.validationRuleDto?.some((rule) => rule.type === 'required'), + ) ?? false + + return item +} + +/** + * EditingFormDto gruplarını DevExtreme `GroupItem` listesine çevirir. + * Alanlar `editOrderNo`'ya göre sıralanır ve moda göre yetki filtresinden geçer. + */ +export const buildFormGroupItems = ({ + gridDto, + mode, + columns, + getLookupDataSource, + formData, +}: BuildFormItemsParams): GroupItem[] => + getEditingFormGroups(gridDto).map( + (group: any) => + ({ + itemType: group.itemType, + colCount: group.colCount, + colSpan: group.colSpan, + caption: group.caption, + items: [...(group.items ?? [])] + .sort((left, right) => (left.editOrderNo ?? 0) - (right.editOrderNo ?? 0)) + .map((colFormat: ColumnFormatDto) => + toFormItem(colFormat, { mode, columns, getLookupDataSource, formData }), + ) + .filter((item) => isFormItemVisible(item, mode)), + }) as GroupItem, + ) diff --git a/ui/src/views/form/useFormData.tsx b/ui/src/views/form/useFormData.tsx index 9afb1424..eab55df1 100644 --- a/ui/src/views/form/useFormData.tsx +++ b/ui/src/views/form/useFormData.tsx @@ -3,28 +3,25 @@ import { getList } from '@/services/form.service' import { useLocalization } from '@/utils/hooks/useLocalization' import { usePermission } from '@/utils/hooks/usePermission' import type { FormRef } from 'devextreme-react/form' -import { captionize } from 'devextreme/core/utils/inflector' import CustomStore from 'devextreme/data/custom_store' import type { GroupItem } from 'devextreme/ui/form' import { useEffect, useRef, useState } from 'react' import { useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { GridColumnData } from '../list/GridColumnData' -import { addCss, addJs, getEditingFormGroups } from '../list/Utils' import { PermissionResults, RowMode, SimpleItemWithColData } from './types' -import { ColumnFormatDto, GridDto, PlatformEditorTypes } from '@/proxy/form/models' +import { GridDto } from '@/proxy/form/models' import { getAccessDeniedPath } from '@/utils/routing' import { ROUTES_ENUM } from '@/routes/route.constant' import { useLookupDataSource } from './useLookupDataSource' import { layoutTypes } from '../admin/listForm/edit/types' import { useListFormCustomDataSource } from '../list/useListFormCustomDataSource' import { useListFormColumns } from '../list/useListFormColumns' - -const flattenFormItems = (items: any[] = []): SimpleItemWithColData[] => - items.flatMap((item) => [ - ...(item?.dataField ? [item] : []), - ...flattenFormItems(item?.items || []), - ...(item?.tabs || []).flatMap((tab: any) => flattenFormItems(tab?.items || [])), - ]) +import { useListFormCustomSources } from '../list/shared/hooks' +import { + buildFormGroupItems, + flattenFormGroups, + getCascadeParentFields, +} from './shared/formItems' const useGridData = (props: { mode: RowMode @@ -63,21 +60,15 @@ const useGridData = (props: { const { createSelectDataSource } = useListFormCustomDataSource({} as any) const { getLookupDataSource } = useLookupDataSource({ listFormCode, isSubForm }) + useListFormCustomSources(gridDto) + const fetchData = async () => { setLoading(true) try { - const response: any = await dataSource?.load({ - filter, - skip: 0, - take: 1, - }) - if (response?.data?.length) { - setFormData(response.data[0]) - setFormDataOld({ ...response.data[0] }) - } else { - setFormData(undefined) - setFormDataOld(undefined) - } + const response: any = await dataSource?.load({ filter, skip: 0, take: 1 }) + const record = response?.data?.[0] + setFormData(record) + setFormDataOld(record ? { ...record } : undefined) } catch (error: any) { toast.push(, { placement: 'bottom-end', @@ -87,77 +78,71 @@ const useGridData = (props: { } } - const handleSubmit = async (e: any) => { - e.preventDefault() - if (!dataSource) { - return + const showSaveSuccess = () => + toast.push( + + {translate('::ListForms.FormBilgileriKaydedildi')} + , + { placement: 'bottom-end' }, + ) + + /** Kaydetme sonrası yönlendirme; alt formda üst bileşene haber verilir. */ + const finishSubmit = (viewPath: string) => { + if (isSubForm) { + props.onSubmitAction?.() + } else { + navigate(viewPath) + } + showSaveSuccess() + } + + /** Güncelleme isteğinin gövdesini hazırlar. */ + const buildUpdateData = (formValues: Record) => { + const sendOnlyChanged = gridDto?.gridOptions.editingOptionDto?.sendOnlyChangedFormValuesUpdate + const data = + sendOnlyChanged && formDataOld + ? Object.fromEntries( + Object.keys(formValues) + .filter((key) => !Object.is(formValues[key], formDataOld[key])) + .map((key) => [key, formValues[key]]), + ) + : { ...formValues } + + if (gridDto?.gridOptions.keyFieldName) { + delete data[gridDto.gridOptions.keyFieldName] } - const validationResult = refForm.current?.instance().validate() - if (!validationResult?.isValid) { + return data + } + + const handleSubmit = async (e: any) => { + e.preventDefault() + if (!dataSource) return + + if (!refForm.current?.instance().validate()?.isValid) { return } setLoading(true) try { const formValues = { ...formData } + if (mode === 'new') { const result = await dataSource.insert(formValues) - if (result.data) { - if (!isSubForm) { - navigate(result.data) - } else if (props.onSubmitAction) { - props.onSubmitAction() - } - toast.push( - - {translate('::ListForms.FormBilgileriKaydedildi')} - , - { - placement: 'bottom-end', - }, - ) - } else { + if (!result?.data) { throw new Error(translate('::ListForms.FormBilgileriKaydedilemedi')) } + finishSubmit(result.data) } else if (mode === 'edit') { - let data: any = {} - if (gridDto?.gridOptions.editingOptionDto?.sendOnlyChangedFormValuesUpdate) { - Object.keys(formValues).forEach((key) => { - if (formValues[key] !== formDataOld[key]) { - data[key] = formValues[key] - } - }) - } else { - data = { ...formValues } - } - - if (gridDto?.gridOptions.keyFieldName) { - delete data[gridDto?.gridOptions.keyFieldName] - } - - var result = await dataSource.update(id, data) - if (result.data > 0) { - if (!isSubForm) { - navigate( - ROUTES_ENUM.protected.admin.formView - .replace(':listFormCode', encodeURIComponent(listFormCode)) - .replace(':id', encodeURIComponent(id!)), - ) - } else if (props.onSubmitAction) { - props.onSubmitAction() - } - toast.push( - - {translate('::ListForms.FormBilgileriKaydedildi')} - , - { - placement: 'bottom-end', - }, - ) - } else { + const result = await dataSource.update(id, buildUpdateData(formValues)) + if (!(result?.data > 0)) { throw new Error(translate('::ListForms.FormBilgileriKaydedilemedi')) } + finishSubmit( + ROUTES_ENUM.protected.admin.formView + .replace(':listFormCode', encodeURIComponent(listFormCode)) + .replace(':id', encodeURIComponent(id!)), + ) } } catch (error: any) { toast.push(, { @@ -170,142 +155,64 @@ const useGridData = (props: { useEffect(() => { setGridReady(false) - const initializeGrid = async () => { - const response = await getList({ listFormCode }) - setGridDto(response.data) - } - initializeGrid() + let isActive = true + getList({ listFormCode }) + .then((response: any) => { + if (isActive) setGridDto(response.data) + }) + .catch((error: unknown) => console.error('GridDto load error:', error)) + + return () => { + isActive = false + } }, [listFormCode]) useEffect(() => { setGridReady(false) - if (!gridDto) { - return - } + if (!gridDto) return + const { editingOptionDto, permissionDto } = gridDto.gridOptions setPermissionResults({ - c: - gridDto?.gridOptions.editingOptionDto.allowAdding === true && - checkPermission(gridDto?.gridOptions.permissionDto.c), - r: checkPermission(gridDto?.gridOptions.permissionDto.r), - u: - gridDto?.gridOptions.editingOptionDto.allowUpdating === true && - checkPermission(gridDto?.gridOptions.permissionDto.u), - d: - gridDto?.gridOptions.editingOptionDto.allowDeleting === true && - checkPermission(gridDto?.gridOptions.permissionDto.d), - e: checkPermission(gridDto?.gridOptions.permissionDto.e), - i: checkPermission(gridDto?.gridOptions.permissionDto.i), + c: editingOptionDto.allowAdding === true && checkPermission(permissionDto.c), + r: checkPermission(permissionDto.r), + u: editingOptionDto.allowUpdating === true && checkPermission(permissionDto.u), + d: editingOptionDto.allowDeleting === true && checkPermission(permissionDto.d), + e: checkPermission(permissionDto.e), + i: checkPermission(permissionDto.i), }) - // Set js and css - const grdOpt = gridDto.gridOptions - grdOpt.customJsSources.forEach(addJs) - grdOpt.customStyleSources.forEach(addCss) + const columns = getBandedColumns() + setCommandColumnData(columns?.find((column) => column.type === 'buttons')) - // Set columns - const cols = getBandedColumns() - setCommandColumnData(cols?.find((a) => a.type == 'buttons')) - - // Set data source - const dataSource: CustomStore = createSelectDataSource( - gridDto.gridOptions, - listFormCode, - searchParams, - layoutTypes.grid, - cols, + setDataSource( + createSelectDataSource( + gridDto.gridOptions, + listFormCode, + searchParams, + layoutTypes.grid, + columns, + ), ) - setDataSource(dataSource) - const items = getEditingFormGroups(gridDto) - .map((e: any) => { - return { - itemType: e.itemType, - colCount: e.colCount, - colSpan: e.colSpan, - caption: e.caption, - items: e.items - ?.sort((a: any, b: any) => { - return (a.editOrderNo ?? 0) >= (b.editOrderNo ?? 0) ? 1 : -1 - }) - .map((i: ColumnFormatDto) => { - let editorOptions: Record = {} - let parsedEditorOptions: Record = {} - const colData = i - - parsedEditorOptions = i.editorOptions ? JSON.parse(i.editorOptions) : {} - const lookupEditorOptions = colData?.lookupDto?.dataSourceType - ? { - dataSource: getLookupDataSource(colData?.editorOptions, colData, formData), - valueExpr: colData?.lookupDto?.valueExpr?.toLowerCase(), - displayExpr: colData?.lookupDto?.displayExpr?.toLowerCase(), - } - : {} - editorOptions = { - ...lookupEditorOptions, - ...parsedEditorOptions, - } - const item: SimpleItemWithColData = { - canRead: i.canRead ?? false, - canUpdate: i.canUpdate ?? false, - canCreate: i.canCreate ?? false, - canExport: i.canExport ?? false, - allowEditing: i.allowEditing ?? true, - allowAdding: i.allowAdding ?? true, - dataField: i.fieldName, - name: i.fieldName, - editorType2: i.editorType2, - editorType: - i.editorType2 == PlatformEditorTypes.dxGridBox - ? 'dxDropDownBox' - : i.editorType2 == PlatformEditorTypes.dxImageUpload || - i.editorType2 == PlatformEditorTypes.dxImageViewer - ? undefined - : i.editorType2, - colSpan: i.colSpan, - editorOptions, - colData, - tagBoxOptions: i.tagBoxOptions, - gridBoxOptions: i.gridBoxOptions, - imageUploadOptions: i.imageUploadOptions, - editorScript: i.editorScript, - } - if (i.fieldName?.indexOf(':') >= 0) { - item.label = { text: captionize(i.fieldName.split(':')[1]) } - } - if ((mode == 'edit' && !item.canUpdate) || (mode == 'new' && !item.canCreate)) { - item.editorOptions = { - ...item.editorOptions, - readOnly: true, - } - } - item.isRequired = - cols?.some( - (c) => - c.dataField === item.colData?.fieldName && - item.colData?.validationRuleDto?.some((a) => a.type === 'required'), - ) ?? false - return item - }) - .filter((a: any) => { - if (mode === 'view') { - return a.canRead - } else if (mode === 'new') { - return a.canCreate && a.allowAdding - } else if (mode === 'edit') { - return a.canUpdate && a.allowEditing - } else { - return false - } - }), - } as GroupItem - }) - setFormItems(items) + setFormItems( + buildFormGroupItems({ + gridDto, + mode, + columns, + getLookupDataSource, + formData, + }), + ) setGridReady(true) + // getBandedColumns/getLookupDataSource her render'da yeniden oluşabilir. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [gridDto]) - // formData değiştiğinde sadece etkilenen cascading lookup datasource'ları güncelle + /** + * formData değiştiğinde yalnızca etkilenen cascading lookup dataSource'larını + * yeniden üretir; tüm form item ağacını gereksiz yere yenilemez. + */ useEffect(() => { if (!gridDto || !formItems.length) { previousFormDataRef.current = formData @@ -319,23 +226,26 @@ const useGridData = (props: { ) : [] - const shouldRefreshLookup = (item: SimpleItemWithColData) => { - const cascadeParentFields = item.colData?.lookupDto?.cascadeParentFields - ?.split(',') - .map((field: string) => field.trim()) - .filter(Boolean) + const shouldRefreshLookup = (item: SimpleItemWithColData) => + !previousFormData || + getCascadeParentFields(item).some((field) => changedFields.includes(field)) - return ( - !previousFormData || - cascadeParentFields?.some((field: string) => changedFields.includes(field)) - ) + const hasAffectedLookup = flattenFormGroups(formItems).some( + (item) => item.colData?.lookupDto?.dataSourceType && shouldRefreshLookup(item), + ) + + if (!hasAffectedLookup) { + previousFormDataRef.current = formData + return } const updateItems = (items: any[] = []): any[] => items.map((item) => { const colData = item.colData + if (colData?.lookupDto?.dataSourceType && shouldRefreshLookup(item)) { const currentDataSource = item.editorOptions?.dataSource + // Kullanıcı tarafından elle verilmiş (statik dizi vb.) dataSource korunur. const keepCustomDataSource = currentDataSource !== undefined && typeof currentDataSource?.load !== 'function' @@ -343,7 +253,6 @@ const useGridData = (props: { ...item, editorOptions: { ...item.editorOptions, - // formData null bile olsa getLookupDataSource çağrılmalı (null parametrelerle API çağrısı yapılacak) dataSource: keepCustomDataSource ? currentDataSource : getLookupDataSource(colData?.editorOptions, colData, formData || null), @@ -356,82 +265,66 @@ const useGridData = (props: { } if (item?.items?.length) { - return { - ...item, - items: updateItems(item.items), - } + return { ...item, items: updateItems(item.items) } } if (item?.tabs?.length) { return { ...item, - tabs: item.tabs.map((tab: any) => ({ - ...tab, - items: updateItems(tab.items), - })), + tabs: item.tabs.map((tab: any) => ({ ...tab, items: updateItems(tab.items) })), } } return item }) - const hasAffectedLookup = - !previousFormData || - formItems - .flatMap((group) => flattenFormItems([group])) - .some((item) => item.colData?.lookupDto?.dataSourceType && shouldRefreshLookup(item)) - - if (!hasAffectedLookup) { - previousFormDataRef.current = formData - return - } - - const updatedItems = formItems.map((groupItem) => ({ - ...groupItem, - items: updateItems(groupItem.items as any[]), - tabs: (groupItem as any).tabs?.map((tab: any) => ({ - ...tab, - items: updateItems(tab.items), - })), - })) - previousFormDataRef.current = formData - setFormItems(updatedItems) + setFormItems((currentItems) => + currentItems.map((groupItem) => ({ + ...groupItem, + items: updateItems(groupItem.items as any[]), + tabs: (groupItem as any).tabs?.map((tab: any) => ({ + ...tab, + items: updateItems(tab.items), + })), + })), + ) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [formData, gridDto]) useEffect(() => { - if (!gridReady) { - return - } + if (!gridReady || mode === 'new') return - if (mode !== 'new') { - if (id) { - setFilter([gridDto?.gridOptions.keyFieldName ?? 'Id', '=', id]) - } else { - // Form tipi listformlar ID olmadan açılabilir. Bu durumda kayıt seçimini - // sunucudaki DefaultFilter'a bırakıyoruz. - fetchData() - } + if (id) { + setFilter([gridDto?.gridOptions.keyFieldName ?? 'Id', '=', id]) + } else { + // Form tipi liste formlar ID olmadan açılabilir; kayıt seçimi sunucudaki + // DefaultFilter'a bırakılır. + fetchData() } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, gridReady]) useEffect(() => { if (filter?.length) { fetchData() } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [filter]) - // Auth check + // Yetki kontrolü useEffect(() => { if (!permissionResults) return - const noCreate = mode === 'new' && !permissionResults.c - const noUpdate = mode === 'edit' && !permissionResults.u - const noRead = mode === 'view' && !permissionResults.r + const denied = + (mode === 'new' && !permissionResults.c) || + (mode === 'edit' && !permissionResults.u) || + (mode === 'view' && !permissionResults.r) - if (noCreate || noUpdate || noRead) { + if (denied) { navigate(getAccessDeniedPath(location.pathname), { replace: true, state: { from: location } }) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [permissionResults]) return { diff --git a/ui/src/views/form/useLookupDataSource.ts b/ui/src/views/form/useLookupDataSource.ts index 340f997d..8e2fe93e 100644 --- a/ui/src/views/form/useLookupDataSource.ts +++ b/ui/src/views/form/useLookupDataSource.ts @@ -3,21 +3,16 @@ import { UiLookupDataSourceTypeEnum } from '@/proxy/form/models' import CustomStore from 'devextreme/data/custom_store' import { useCallback } from 'react' import { useLocalization } from '@/utils/hooks/useLocalization' +import { cachedLookupLoad } from '../shared/lookupCache' -const __lookupCache = new Map>() +export { clearLookupCache } from '../shared/lookupCache' -const cachedLoader = (key: string, loader: () => Promise) => { - if (__lookupCache.has(key)) return __lookupCache.get(key)! - const p = Promise.resolve() - .then(() => loader()) - .then((res) => res ?? []) - .catch((err) => { - __lookupCache.delete(key) - throw err - }) - __lookupCache.set(key, p) - return p -} +/** + * Lookup sorgusu yalnızca ilgili liste formu ekranı açıkken çalışmalı. + * (SubForm'larda bu kontrol uygulanmaz.) + */ +const isLookupAllowed = (listFormCode?: string, isSubForm?: boolean) => + isSubForm || !listFormCode || window.location.pathname.includes(listFormCode) const createLookupStaticDataSource = ( load: () => any, @@ -28,13 +23,12 @@ const createLookupStaticDataSource = ( store: new CustomStore({ key, loadMode: 'raw', - load: async () => { - const cacheKey = `static:${key}` - return cachedLoader(cacheKey, async () => { - const res = await Promise.resolve(load()) - return Array.isArray(res) ? res : [res] - }) - }, + load: () => + cachedLookupLoad(`static:${key}`, async () => { + const result = await Promise.resolve(load()) + if (result === undefined || result === null) return [] + return Array.isArray(result) ? result : [result] + }), }), sort, filter, @@ -45,31 +39,38 @@ const createLookupQueryDataSource = ( listFormFieldName?: string, filters?: any[], isSubForm?: boolean, -) => { - return new CustomStore({ +) => + new CustomStore({ loadMode: 'raw', load: async () => { - if (!isSubForm && listFormCode && !window.location.pathname.includes(listFormCode)) { - return + if (!isLookupAllowed(listFormCode, isSubForm)) { + return [] } - const cacheKey = `query:${listFormCode}:${listFormFieldName}:${JSON.stringify(filters ?? null)}` - return cachedLoader(cacheKey, async () => { - const response = await dynamicFetch('list-form-select/lookup', 'POST', null, { - listFormCode, - listFormFieldName, - filters, + try { + const cacheKey = `query:${listFormCode}:${listFormFieldName}:${JSON.stringify(filters ?? null)}` + return await cachedLookupLoad(cacheKey, async () => { + const response = await dynamicFetch('list-form-select/lookup', 'POST', null, { + listFormCode, + listFormFieldName, + filters, + }) + + const data = response?.data ?? [] + return (Array.isArray(data) ? data : [data]).map((item: any) => ({ + key: item.Key, + name: item.Name, + group: item.Group, + + ...item, + })) }) - const data = response?.data ?? [] - return (Array.isArray(data) ? data : [data]).map((a: any) => ({ - key: a.Key, - name: a.Name, - group: a.Group, - })) - }).catch(() => null as any) + } catch (error) { + console.error('Lookup query data could not be loaded:', error) + return [] + } }, }) -} const createLookupApiDataSource = ( listFormCode?: string, @@ -77,19 +78,15 @@ const createLookupApiDataSource = ( filters?: any[], keyName?: string, isSubForm?: boolean, -) => { - return new CustomStore({ +) => + new CustomStore({ key: keyName, loadMode: 'raw', load: async () => { - if (!isSubForm && listFormCode && !window.location.pathname.includes(listFormCode)) { - return - } - if (!lookupQuery) { - return + if (!isLookupAllowed(listFormCode, isSubForm) || !lookupQuery) { + return [] } - const parts = lookupQuery.split(';') const [ method = 'GET', url = '', @@ -97,30 +94,40 @@ const createLookupApiDataSource = ( keySelector = 'a=>a.id', nameSelector = 'a=>a.name', groupSelector = 'a=>a.group', - ] = parts + ] = lookupQuery.split(';') + // Gövdedeki @param0, @param1… yer tutucularını cascade filtreleriyle doldur. let resolvedBody = bodyTemplate ?? '' - if (filters?.length) { - for (let i = 0; i < filters.length; i++) { - resolvedBody = resolvedBody.replace(new RegExp(`@param${i}`, 'g'), String(filters[i])) - } - } + filters?.forEach((filterValue, index) => { + resolvedBody = resolvedBody.replace(new RegExp(`@param${index}`, 'g'), String(filterValue)) + }) - const cacheKey = `api:${lookupQuery}:${JSON.stringify(filters ?? null)}` - return cachedLoader(cacheKey, async () => { - const response = await dynamicFetch(url, method, null, resolvedBody) - let { data } = response - if (!data) return [] - if (!Array.isArray(data)) data = [data] - return data.map(() => ({ - key: eval(keySelector), - name: eval(nameSelector), - group: eval(groupSelector), - })) - }).catch(() => [] as any) + try { + const cacheKey = `api:${lookupQuery}:${JSON.stringify(filters ?? null)}` + return await cachedLookupLoad(cacheKey, async () => { + const response = await dynamicFetch(url, method, null, resolvedBody) + let { data } = response + if (!data) return [] + if (!Array.isArray(data)) data = [data] + + // NOT: Selector'lar `a=>a.id` biçiminde yapılandırmadan gelir; bu yüzden + // map parametresi `a` olarak adlandırılmalıdır. Önceden parametresiz + // `() => ...` kullanıldığı için eval fonksiyonun kendisini döndürüyor ve + // key/name/group alanları bozuk geliyordu. + return data.map((a: any) => ({ + key: eval(keySelector), + name: eval(nameSelector), + group: eval(groupSelector), + + ...a, + })) + }) + } catch (error) { + console.error('Lookup API data could not be loaded:', error) + return [] + } }, }) -} export const useLookupDataSource = ({ listFormCode, @@ -136,7 +143,7 @@ export const useLookupDataSource = ({ const { lookupDto } = colData const filters: any[] = [] - // Cascading field'ler için - data yoksa veya parent field değeri yoksa null parametreler ile API çağrısı yap + // Cascading alanlar: parent değeri yoksa null gönderilir, istek yine de yapılır. if (lookupDto.cascadeParentFields) { if (lookupDto.dataSourceType == UiLookupDataSourceTypeEnum.StaticData) { filters.push([ @@ -146,34 +153,34 @@ export const useLookupDataSource = ({ ]) } else { for (const cascadeParentField of lookupDto.cascadeParentFields.split(',')) { - filters.push(data?.[cascadeParentField] ?? null) + filters.push(data?.[cascadeParentField.trim()] ?? null) } } } - if (lookupDto.dataSourceType == UiLookupDataSourceTypeEnum.StaticData) { - const staticKey = `static:${cultureName}:${listFormCode}:${colData.fieldName}` - return createLookupStaticDataSource( - () => - JSON.parse(lookupDto?.lookupQuery).map((item: any) => ({ - ...item, - name: typeof item.name === 'string' ? translate(`::${item.name}`) : item.name, - })), - filters.length ? filters : null, - staticKey, - ) - } else if (lookupDto.dataSourceType == UiLookupDataSourceTypeEnum.Query) { - return createLookupQueryDataSource(listFormCode, colData.fieldName, filters, isSubForm) - } else if (lookupDto.dataSourceType == UiLookupDataSourceTypeEnum.WebService) { - return createLookupApiDataSource( - listFormCode, - lookupDto?.lookupQuery, - filters, - colData.lookupDto?.valueExpr?.toLowerCase(), - isSubForm, - ) - } else { - return { store: [] } + switch (lookupDto.dataSourceType) { + case UiLookupDataSourceTypeEnum.StaticData: + return createLookupStaticDataSource( + () => + JSON.parse(lookupDto?.lookupQuery).map((item: any) => ({ + ...item, + name: typeof item.name === 'string' ? translate(`::${item.name}`) : item.name, + })), + filters.length ? filters : null, + `static:${cultureName}:${listFormCode}:${colData.fieldName}`, + ) + case UiLookupDataSourceTypeEnum.Query: + return createLookupQueryDataSource(listFormCode, colData.fieldName, filters, isSubForm) + case UiLookupDataSourceTypeEnum.WebService: + return createLookupApiDataSource( + listFormCode, + lookupDto?.lookupQuery, + filters, + colData.lookupDto?.valueExpr?.toLowerCase(), + isSubForm, + ) + default: + return { store: [] } } }, [listFormCode, isSubForm, translate, cultureName], @@ -181,9 +188,3 @@ export const useLookupDataSource = ({ return { getLookupDataSource } } - -/** Opsiyonel: dışarıdan cache temizlemek istersen export et */ -export const clearLookupCache = (key?: string) => { - if (!key) __lookupCache.clear() - else __lookupCache.delete(key) -} diff --git a/ui/src/views/list/CardView.tsx b/ui/src/views/list/CardView.tsx index 3cfdcf9c..32734423 100644 --- a/ui/src/views/list/CardView.tsx +++ b/ui/src/views/list/CardView.tsx @@ -50,6 +50,16 @@ import { GridExtraFilterState, safeJsonParse, } from './Utils' +import { + buildEditPopupToolbarItems, + getEditPopupHeight, + getEditPopupMaxHeight, + getMobilePopupPosition, + shouldUseMobileEditPopup, +} from './shared/editPopup' +import { getValueByField, resolveEditorType } from './shared/editingForm' +import { flattenColumns } from './shared/columns' +import { getInitialExtraFilters, mergeFilterTriplets } from './shared/hooks' import { useFilters } from './useFilters' import { useListFormColumns } from './useListFormColumns' import { useListFormCustomDataSource } from './useListFormCustomDataSource' @@ -71,28 +81,6 @@ interface CardViewProps { const cardColumnCountOptions = [1, 2, 3, 4, 5] as const const defaultCardColumnCount = 3 -const flattenColumns = (columns: any[] = []): any[] => - columns.flatMap((column) => (column?.columns?.length ? flattenColumns(column.columns) : [column])) - -const isTouchLikeDevice = () => - typeof window !== 'undefined' && - (window.matchMedia?.('(pointer: coarse)').matches || window.matchMedia?.('(hover: none)').matches) - -const isMobileViewport = () => - typeof window !== 'undefined' && window.matchMedia?.('(max-width: 767px)').matches - -const getPopupHeight = (useMobilePopup: boolean, fullScreen: boolean) => - useMobilePopup && fullScreen ? '100%' : 'auto' - -const getPopupMaxHeight = ( - useMobilePopup: boolean, - fullScreen: boolean, - configuredHeight?: number, -) => { - if (useMobilePopup && fullScreen) return '100%' - return configuredHeight && configuredHeight > 0 ? configuredHeight : '90vh' -} - const toCardColumn = ( column: any, lookupDisplayValuesRef: { current: Record> }, @@ -171,13 +159,6 @@ const toCardColumn = ( ) as CardViewTypes.ColumnProperties } -const getValueByField = (data: Record, field?: string | null) => { - if (!field) return undefined - if (Object.prototype.hasOwnProperty.call(data, field)) return data[field] - const key = Object.keys(data).find((item) => item.toLowerCase() === field.toLowerCase()) - return key ? data[key] : undefined -} - const getEditingFieldName = (dataField?: string | null) => String(dataField ?? '') .split(':')[0] @@ -202,14 +183,6 @@ const lookupDisplayValuesEqual = ( const isGridDtoForList = (gridDto: GridDto | undefined, listFormCode: string) => gridDto?.gridOptions.listFormCode?.toLowerCase() === listFormCode.toLowerCase() -const getInitialExtraFilters = (gridDto?: GridDto): GridExtraFilterState[] => - (gridDto?.gridOptions.extraFilterDto ?? []).map((filter) => ({ - fieldName: filter.fieldName, - operator: filter.operator, - controlType: filter.controlType, - value: filter.defaultValue ?? '', - })) - const loadCascadeLookupDisplayValues = async ( columns: any[], rows: Record[], @@ -384,7 +357,7 @@ const CardView = (props: CardViewProps) => { (state) => state.abpConfig.config?.localization.currentCulture.cultureName, ) const localizationTexts = useStoreState((state) => state.abpConfig.texts) - const useMobileEditPopup = isMobileViewport() || isTouchLikeDevice() + const useMobileEditPopup = shouldUseMobileEditPopup() const cardViewRef = useRef() const searchParamsRef = useRef(searchParams) const defaultSearchFilterRef = useRef(searchParams?.get('filter') ?? null) @@ -418,31 +391,20 @@ const CardView = (props: CardViewProps) => { ), ) - const mergedExtraFilter = useMemo(() => { - const base = safeJsonParse(defaultSearchFilterRef.current, null, 'Search filter parse error:') - const triplets = [ - ...extractSearchParamsFields(base), - ...extraFilters - .filter( - (filter) => filter.value !== undefined && filter.value !== null && filter.value !== '', - ) - .map( - (filter) => - [filter.fieldName, filter.operator, filter.value] as [string, string, unknown], + const mergedExtraFilter = useMemo( + () => + mergeFilterTriplets([ + ...extractSearchParamsFields( + safeJsonParse(defaultSearchFilterRef.current, null, 'Search filter parse error:'), ), - ] - const unique = triplets.reduce( - (result, current) => { - const index = result.findIndex((item) => item[0] === current[0] && item[1] === current[1]) - if (index >= 0) result[index] = current - else result.push(current) - return result - }, - [] as [string, string, unknown][], - ) - - return unique.reduce((result, filter) => (result ? [result, 'and', filter] : filter), null) - }, [extraFilters]) + ...extraFilters + .filter((filter) => !!filter.value) + .map( + (filter) => [filter.fieldName, filter.operator, filter.value] as [string, string, any], + ), + ]), + [extraFilters], + ) if (!dataSourceSearchParamsRef.current) { dataSourceSearchParamsRef.current = new URLSearchParams(searchParams?.toString()) @@ -552,7 +514,7 @@ const CardView = (props: CardViewProps) => { listColumnsCacheRef.current = { gridDto, localizationRevision, - columns: flattenColumns(getBandedColumns()), + columns: flattenColumns(getBandedColumns() ?? []), } } const listColumns = listColumnsCacheRef.current.columns @@ -869,13 +831,7 @@ const CardView = (props: CardViewProps) => { ? item.fieldName!.split(':')[1] : translate('::' + (column.captionName || column.fieldName)), }, - editorType: - item.editorType2 === PlatformEditorTypes.dxGridBox - ? 'dxDropDownBox' - : item.editorType2 === PlatformEditorTypes.dxImageUpload || - item.editorType2 === PlatformEditorTypes.dxImageViewer - ? undefined - : item.editorType2, + editorType: resolveEditorType(item.editorType2), editorOptions, template: item.editorType2 === PlatformEditorTypes.dxImageUpload @@ -1219,57 +1175,24 @@ const CardView = (props: CardViewProps) => { ? editingOptions?.popup?.width : 'auto', maxWidth: useMobileEditPopup ? '100%' : '95vw', - height: getPopupHeight(useMobileEditPopup, isPopupFullScreen), - maxHeight: getPopupMaxHeight( + height: getEditPopupHeight(useMobileEditPopup, isPopupFullScreen), + maxHeight: getEditPopupMaxHeight( useMobileEditPopup, isPopupFullScreen, editingOptions?.popup?.height, ), fullScreen: isPopupFullScreen, position: useMobileEditPopup - ? { - my: 'top center', - at: 'top center', - of: typeof window !== 'undefined' ? window : undefined, - } + ? getMobilePopupPosition() : editingOptions?.popup?.position, resizeEnabled: editingOptions?.popup?.resizeEnabled, dragEnabled: editingOptions?.popup?.dragEnabled, restorePosition: editingOptions?.popup?.restorePosition, - toolbarItems: [ - { - widget: 'dxButton', - toolbar: 'bottom', - location: 'after', - options: { - text: translate('::Save'), - type: 'default', - onClick: () => cardViewRef.current?.instance().saveEditData(), - }, - }, - { - widget: 'dxButton', - toolbar: 'bottom', - location: 'after', - options: { - text: translate('::Cancel'), - onClick: () => cardViewRef.current?.instance().cancelEditData(), - }, - }, - { - widget: 'dxButton', - toolbar: 'top', - location: 'after', - options: { - icon: isPopupFullScreen ? 'collapse' : 'fullscreen', - hint: isPopupFullScreen - ? translate('::Normal Boyut') - : translate('::Tam Ekran'), - stylingMode: 'text', - onClick: () => setIsPopupFullScreen((current) => !current), - }, - }, - ], + toolbarItems: buildEditPopupToolbarItems(translate, isPopupFullScreen, { + onSave: () => cardViewRef.current?.instance().saveEditData(), + onCancel: () => cardViewRef.current?.instance().cancelEditData(), + onToggleFullScreen: () => setIsPopupFullScreen((current) => !current), + }), }} /> diff --git a/ui/src/views/list/GanttView.tsx b/ui/src/views/list/GanttView.tsx index edc819e2..6e7c6886 100644 --- a/ui/src/views/list/GanttView.tsx +++ b/ui/src/views/list/GanttView.tsx @@ -17,9 +17,7 @@ import Gantt, { } from 'devextreme-react/gantt' import { useCallback, useEffect, useRef, useState } from 'react' import { Helmet } from 'react-helmet' -import { getList } from '@/services/form.service' import { useListFormCustomDataSource } from './useListFormCustomDataSource' -import { addCss, addJs } from './Utils' import { layoutTypes } from '../admin/listForm/edit/types' import WidgetGroup from '@/components/ui/Widget/WidgetGroup' import { ROUTES_ENUM } from '@/routes/route.constant' @@ -34,6 +32,11 @@ import { useListFormStateStoring } from './useListFormStateStoring' import TreeList from 'devextreme/ui/tree_list' import { postListFormCustomization } from '@/services/list-form-customization.service' import { useFilters } from './useFilters' +import { + useListFormCustomSources, + useListFormGridDto, + useWidgetGroupHeight, +} from './shared/hooks' import GridFilterDialogs from './GridFilterDialogs' type GanttInstance = ReturnType @@ -68,20 +71,21 @@ const GanttView = (props: GanttViewProps) => { const isPwaMode = usePWA() const gridRef = useRef() - const refListFormCode = useRef('') const widgetGroupRef = useRef(null) const { checkPermission } = usePermission() const [ganttDataSource, setGanttDataSource] = useState>() const [columnData, setColumnData] = useState() - const [gridDto, setGridDto] = useState() - const [widgetGroupHeight, setWidgetGroupHeight] = useState(0) + const gridDto = useListFormGridDto(listFormCode, extGridDto) const [scaleType, setScaleType] = useState('weeks') const [taskListWidth, setTaskListWidth] = useState(500) const layout = layoutTypes.gantt || 'gantt' const pendingStateRef = useRef>() const loadedStateKeyRef = useRef('') + const widgetGroupHeight = useWidgetGroupHeight(widgetGroupRef, gridDto?.widgets) + useListFormCustomSources(gridDto) + const { customSaveState, customLoadState, storageKey } = useListFormStateStoring({ listFormCode, storageKey: gridDto?.gridOptions.stateStoringDto?.storageKey, @@ -103,31 +107,12 @@ const GanttView = (props: GanttViewProps) => { ) useEffect(() => { - const initializeGantt = async () => { - const response = await getList({ listFormCode }) - setGridDto(response.data) - } - - if (extGridDto === undefined) { - initializeGantt() - } else { - setGridDto(extGridDto) - } - - setScaleType(extGridDto?.gridOptions.ganttOptionDto?.scaleType || 'weeks') - }, [listFormCode, extGridDto]) + setScaleType(gridDto?.gridOptions.ganttOptionDto?.scaleType || 'weeks') + }, [gridDto]) + // listFormCode değişiminde eski veri kaynağını temizle. useEffect(() => { - if (gridRef?.current) { - const instance = gridRef?.current?.instance() - if (instance) { - instance.option('dataSource', undefined) - } - } - - if (refListFormCode.current !== listFormCode) { - // Reset state if needed - } + gridRef.current?.instance()?.option('dataSource', undefined) }, [listFormCode]) const { createSelectDataSource } = useListFormCustomDataSource({ gridRef }) @@ -137,24 +122,6 @@ const GanttView = (props: GanttViewProps) => { isSubForm, gridRef, }) - useEffect(() => { - if (!gridDto) { - return - } - - // Set js and css - const grdOpt = gridDto.gridOptions - if (grdOpt.customJsSources.length) { - for (const js of grdOpt.customJsSources) { - addJs(js) - } - } - if (grdOpt.customStyleSources.length) { - for (const css of grdOpt.customStyleSources) { - addCss(css) - } - } - }, [gridDto]) useEffect(() => { if (!gridDto) return @@ -173,31 +140,6 @@ const GanttView = (props: GanttViewProps) => { setGanttDataSource(dataSource) }, [createSelectDataSource, getBandedColumns, gridDto, layout, listFormCode, searchParams]) - useEffect(() => { - refListFormCode.current = listFormCode - }, [listFormCode]) - - // WidgetGroup yüksekliğini hesapla - useEffect(() => { - const calculateWidgetHeight = () => { - if (widgetGroupRef.current) { - const height = widgetGroupRef.current.offsetHeight - setWidgetGroupHeight(height) - } - } - - calculateWidgetHeight() - - const resizeObserver = new ResizeObserver(calculateWidgetHeight) - if (widgetGroupRef.current) { - resizeObserver.observe(widgetGroupRef.current) - } - - return () => { - resizeObserver.disconnect() - } - }, [gridDto?.widgets]) - const settingButtonClick = useCallback(() => { window.open( ROUTES_ENUM.protected.saas.listFormManagement.edit.replace(':listFormCode', listFormCode), diff --git a/ui/src/views/list/Grid.tsx b/ui/src/views/list/Grid.tsx index 386cfa39..74f432dd 100644 --- a/ui/src/views/list/Grid.tsx +++ b/ui/src/views/list/Grid.tsx @@ -2,16 +2,8 @@ import Container from '@/components/shared/Container' import '@/utils/registerDevExtremeEditors' import { Dialog, Notification, toast } from '@/components/ui' import { APP_NAME, DX_CLASSNAMES } from '@/constants/app.constant' -import { - ColumnFormatDto, - DbTypeEnum, - FieldCustomValueTypeEnum, - GridDto, - PlatformEditorTypes, - SubFormTabTypeEnum, -} from '@/proxy/form/models' +import { GridDto, SubFormTabTypeEnum } from '@/proxy/form/models' import { useLocalization } from '@/utils/hooks/useLocalization' -import { executeEditorScript } from '@/utils/editorScriptRuntime' import { Template } from 'devextreme-react/core/template' import DataGrid, { ColumnChooser, @@ -38,27 +30,13 @@ import DataGrid, { TotalItem, } from 'devextreme-react/data-grid' import { Item } from 'devextreme-react/toolbar' -import type { DataType } from 'devextreme/common' -import { captionize } from 'devextreme/core/utils/inflector' import CustomStore from 'devextreme/data/custom_store' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet' import SubForms from '../form/SubForms' -import { RowMode, SimpleItemWithColData } from '../form/types' +import { RowMode } from '../form/types' import { GridColumnData } from './GridColumnData' import GridFilterDialogs from './GridFilterDialogs' -import { - addCss, - addJs, - autoNumber, - controlStyleCondition, - extractSearchParamsFields, - getEditingFormFields, - getEditingFormGroups, - GridExtraFilterState, - safeJsonParse, - setFormEditingExtraItemValues, -} from './Utils' import { GridBoxEditorComponent } from './editors/GridBoxEditorComponent' import { ImageUploadEditorComponent } from './editors/ImageUploadEditorComponent' import { ImageViewerEditorComponent } from './editors/ImageViewerEditorComponent' @@ -68,16 +46,44 @@ import { updateWorkflowApprovalToolbarItems, useToolbar } from './useToolbar' import { ImportDashboard } from '@/components/importManager/ImportDashboard' import WidgetGroup from '@/components/ui/Widget/WidgetGroup' import { GridExtraFilterToolbar } from './GridExtraFilterToolbar' -import { getList, getNextSequenceValue } from '@/services/form.service' import { layoutTypes } from '../admin/listForm/edit/types' import { useListFormCustomDataSource } from './useListFormCustomDataSource' import { useListFormColumns } from './useListFormColumns' import { orderListToolbarItems } from './toolbarOrder' import { Loading } from '@/components/shared' import { useStoreState } from '@/store' -import { workflowService } from '@/services/workflow.service' import { NotePanel } from '../form/notes/NotePanel' import { useListFormStateStoring } from './useListFormStateStoring' +import { exportListView } from './shared/exportListView' +import { + buildEditPopupOptions, + shouldUseMobileEditPopup, +} from './shared/editPopup' +import { + applyNewRowDefaults, + buildEditingFormItems, + buildInsertPayload, + buildUpdatePayload, + createFormItemMapper, + expandJsonFormFields, + getActiveEditingForm, + getValueByField, +} from './shared/editingForm' +import { + attachEditorButtonContext, + attachEditorValueChangedScript, + createEditingFormHandlers, +} from './shared/editingFormScripts' +import { attachCascadeBehavior, createCascadeMaps } from './shared/cascade' +import { createConditionalCellStyleHandler } from './shared/cellStyles' +import { + useExtraFilters, + useListFormCustomSources, + useListFormGridDto, + useWidgetGroupHeight, +} from './shared/hooks' +import { startWorkflowForInsertedRow } from './shared/workflowRows' +import { applyPhoneGlobalFormat } from './shared/columnFormatters' interface GridProps { listFormCode: string @@ -88,172 +94,12 @@ interface GridProps { gridDto?: GridDto } -const isTemporaryDxKey = (key: unknown) => typeof key === 'string' && key.startsWith('_DX_KEY_') - -const getPersistedInsertedKey = ( - e: DataGridTypes.RowInsertedEvent, - keyFieldName?: string, -) => { - const dataKey = keyFieldName ? e.data?.[keyFieldName] : undefined - if (dataKey !== undefined && dataKey !== null && !isTemporaryDxKey(dataKey)) { - return dataKey - } - - if (e.key !== undefined && e.key !== null && !isTemporaryDxKey(e.key)) { - return e.key - } - - return undefined -} - -const updateReadOnlyInFormItems = (items: any[] = [], field: string, readOnly: boolean) => { - let changed = false - const expected = String(field || '').toLowerCase() - - const nextItems = items.map((item) => { - const key = item?.dataField || item?.name - let nextItem = item - - if (key && String(key).toLowerCase() === expected) { - const editorOptions = nextItem.editorOptions || {} - if (editorOptions.readOnly !== readOnly) { - changed = true - nextItem = { - ...nextItem, - editorOptions: { - ...editorOptions, - readOnly, - }, - } - } - } - - if (nextItem?.items?.length) { - const childResult = updateReadOnlyInFormItems(nextItem.items, field, readOnly) - if (childResult.changed) { - changed = true - nextItem = { ...nextItem, items: childResult.items } - } - } - - if (nextItem?.tabs?.length) { - const tabs = nextItem.tabs.map((tab: any) => { - const tabResult = updateReadOnlyInFormItems(tab.items, field, readOnly) - if (tabResult.changed) { - changed = true - return { ...tab, items: tabResult.items } - } - return tab - }) - nextItem = tabs === nextItem.tabs ? nextItem : { ...nextItem, tabs } - } - - return nextItem - }) - - return { items: nextItems, changed } -} - -const findFormFieldKey = (items: any[] = [], field: string): string => { - const expected = String(field || '').toLowerCase() - - for (const item of items || []) { - const key = item?.dataField || item?.name - if (key && String(key).toLowerCase() === expected) { - return key - } - - const childKey = findFormFieldKey(item?.items || [], field) - if (childKey) { - return childKey - } - - for (const tab of item?.tabs || []) { - const tabKey = findFormFieldKey(tab?.items || [], field) - if (tabKey) { - return tabKey - } - } - } - - return field -} - -const setFormEditorReadOnly = (form: any, field: string, readOnly: boolean) => { - if (!form?.option) return false - - const apply = () => { - const formItems = form.option('items') || [] - const resolvedField = findFormFieldKey(formItems, field) - const editor = form.getEditor?.(resolvedField) ?? form.getEditor?.(field) - const result = updateReadOnlyInFormItems(formItems, resolvedField, readOnly) - - if (result.changed) { - try { - const item = form.itemOption?.(resolvedField) ?? form.itemOption?.(field) - if (item) { - form.itemOption?.(resolvedField, 'editorOptions', { - ...(item.editorOptions || {}), - readOnly, - }) - } else { - form.option('items', result.items) - } - } catch { - form.option('items', result.items) - } - } - - const activeEditor = editor ?? form.getEditor?.(resolvedField) ?? form.getEditor?.(field) - if (activeEditor?.option?.('readOnly') !== readOnly) { - activeEditor?.option?.('readOnly', readOnly) - } - } - - apply() - setTimeout(apply, 0) - return true -} - -const getActiveEditingForm = (component: any) => { - const editForm = component?.getView?.('editingView')?._editForm - return editForm?.getEditor || editForm?.itemOption ? editForm : undefined -} - -const getValueByField = (data: Record = {}, field?: string) => { - if (!field) return undefined - if (Object.prototype.hasOwnProperty.call(data, field)) return data[field] - const key = Object.keys(data).find( - (itemKey) => itemKey.toLowerCase() === String(field).toLowerCase(), - ) - return key ? data[key] : undefined -} - -const shouldRunEditorScriptOnContentReady = (script?: string) => - Boolean( - script && (script.includes('setEditorReadOnly') || script.includes('runtimeSetEditorReadOnly')), - ) - -const isTouchLikeDevice = () => - typeof window !== 'undefined' && - (window.matchMedia?.('(pointer: coarse)').matches || window.matchMedia?.('(hover: none)').matches) - -const isMobileViewport = () => - typeof window !== 'undefined' && window.matchMedia?.('(max-width: 767px)').matches - -const getEditPopupHeight = (useMobileEditPopup: boolean, isPopupFullScreen: boolean) => - useMobileEditPopup && isPopupFullScreen ? '100%' : 'auto' - -const getEditPopupMaxHeight = ( - useMobileEditPopup: boolean, - isPopupFullScreen: boolean, - configuredHeight?: number, -) => { - if (useMobileEditPopup && isPopupFullScreen) { - return '100%' - } - - return configuredHeight && configuredHeight > 0 ? configuredHeight : '90vh' +const remoteOperations = { + paging: true, + filtering: true, + sorting: true, + grouping: false, + summary: false, } const Grid = (props: GridProps) => { @@ -264,53 +110,30 @@ const Grid = (props: GridProps) => { (state) => state.abpConfig.config?.localization.currentCulture.cultureName, ) const localizationTexts = useStoreState((state) => state.abpConfig.texts) - const useMobileEditPopup = isMobileViewport() || isTouchLikeDevice() + const useMobileEditPopup = shouldUseMobileEditPopup() const gridRef = useRef() - const refListFormCode = useRef('') const widgetGroupRef = useRef(null) const editingFormDataRef = useRef>({}) const editingFormInstanceRef = useRef() - // Edit popup state kaydetmeyi engellemek için flag + // Edit popup açıkken grid state'inin kaydedilmesini engeller. const isEditingRef = useRef(false) const [gridDataSource, setGridDataSource] = useState>() const [columnData, setColumnData] = useState() const [formData, setFormData] = useState() const [mode, setMode] = useState('view') - const [extraFilters, setExtraFilters] = useState([]) - const [gridDto, setGridDto] = useState() + const gridDto = useListFormGridDto(listFormCode, extGridDto) const [isPopupFullScreen, setIsPopupFullScreen] = useState(false) - const [widgetGroupHeight, setWidgetGroupHeight] = useState(0) const [notePanelTarget, setNotePanelTarget] = useState<{ entityName: string entityId: string } | null>(null) - type EditorOptionsWithButtons = { - buttons?: any[] - } & Record + const widgetGroupHeight = useWidgetGroupHeight(widgetGroupRef, gridDto?.widgets) + useListFormCustomSources(gridDto) - const defaultSearchParams = useRef(null) - - useEffect(() => { - const initializeGrid = async () => { - const response = await getList({ listFormCode }) - setGridDto(response.data) - } - - if (extGridDto === undefined) { - initializeGrid() - } else { - setGridDto(extGridDto) - } - }, [listFormCode, extGridDto]) - - useEffect(() => { - if (!defaultSearchParams.current) { - defaultSearchParams.current = searchParams?.get('filter') ?? null - } - }, [searchParams]) + const getGridInstance = useCallback(() => gridRef.current?.instance(), []) const { customSaveState, customLoadState } = useListFormStateStoring({ listFormCode, @@ -328,10 +151,20 @@ const Grid = (props: GridProps) => { const { createSelectDataSource } = useListFormCustomDataSource({ gridRef }) + const refreshGrid = useCallback(() => { + getGridInstance()?.refresh() + }, [getGridInstance]) + + const { extraFilters, setExtraFilters } = useExtraFilters({ + gridDto, + listFormCode, + searchParams, + onFilterChanged: refreshGrid, + }) + const openNotePanel = useCallback( (rowData: Record) => { - const keyFieldName = gridDto?.gridOptions.keyFieldName - const entityId = getValueByField(rowData, keyFieldName) + const entityId = getValueByField(rowData, gridDto?.gridOptions.keyFieldName) if (entityId === undefined || entityId === null || entityId === '') { return } @@ -352,57 +185,40 @@ const Grid = (props: GridProps) => { onShowNote: openNotePanel, }) - const getSelectedRowKeys = useCallback(async () => { - const grd = gridRef.current?.instance() - if (!grd) { - return [] - } - - return await grd.getSelectedRowKeys() - }, []) - - const getSelectedRowsData = useCallback(() => { - const grd = gridRef.current?.instance() - if (!grd) { - return [] - } - - return grd.getSelectedRowsData() - }, []) + /* ======================= + TOOLBAR + ======================= */ const updateWorkflowApprovalButtons = useCallback( (component?: any, selectedRowsData?: Record[]) => { - const grd = component ?? gridRef.current?.instance() - if (!grd) { - return - } + const grid = component ?? getGridInstance() + if (!grid) return updateWorkflowApprovalToolbarItems( - grd, + grid, gridDto?.gridOptions.workflowDto, - selectedRowsData ?? grd.getSelectedRowsData(), + selectedRowsData ?? grid.getSelectedRowsData(), currentUser, ) }, - [currentUser, gridDto], + [currentUser, getGridInstance, gridDto], ) const refreshData = useCallback(() => { - const grd = gridRef.current?.instance() - const refreshResult = grd?.refresh() - Promise.resolve(refreshResult).finally(() => updateWorkflowApprovalButtons(grd)) - }, [updateWorkflowApprovalButtons]) + const grid = getGridInstance() + Promise.resolve(grid?.refresh()).finally(() => updateWorkflowApprovalButtons(grid)) + }, [getGridInstance, updateWorkflowApprovalButtons]) - const getFilter = useCallback(() => { - const grd = gridRef.current?.instance() - if (!grd) { - return - } + const getSelectedRowKeys = useCallback( + async () => (await getGridInstance()?.getSelectedRowKeys()) ?? [], + [getGridInstance], + ) + const getSelectedRowsData = useCallback( + () => getGridInstance()?.getSelectedRowsData() ?? [], + [getGridInstance], + ) + const getFilter = useCallback(() => getGridInstance()?.getCombinedFilter(), [getGridInstance]) - return grd.getCombinedFilter() - }, []) - - const layout = layoutTypes.grid const { toolbarData, toolbarModalData, setToolbarModalData } = useToolbar({ gridDto, listFormCode, @@ -410,89 +226,52 @@ const Grid = (props: GridProps) => { getSelectedRowsData, refreshData, getFilter, - layout, + layout: layoutTypes.grid, }) + const orderedToolbarData = useMemo( () => orderListToolbarItems([...toolbarData, ...filterToolbarData]), [toolbarData, filterToolbarData], ) + /* ======================= + GRID EVENTS + ======================= */ + const onSelectionChanged = useCallback( (data: any) => { - const grdOpt = gridDto?.gridOptions - const grd = gridRef.current?.instance() - if (!grdOpt || !grd) { - return + const grid = getGridInstance() + if (!gridDto?.gridOptions || !grid) return + + // Birden fazla kayıt seçiliyse ve silme yetkisi varsa toplu silme butonunu göster. + if (gridDto.gridOptions.editingOptionDto?.allowDeleting) { + const items = grid.option('toolbar')?.items ?? [] + const deleteSelectedIndex = items.findIndex( + (item: any) => item.name === 'deleteSelectedRecords', + ) + + if (deleteSelectedIndex >= 0) { + grid.option( + `toolbar.items[${deleteSelectedIndex}].options.visible`, + data.selectedRowsData.length > 1, + ) + } } - // kullanicinin yetkisi varsa ve birden fazla kayit secili ise coklu silme gorunsun - if (grdOpt.editingOptionDto?.allowDeleting) { - // && abp.auth.isGranted(grdOpt.permissionDto?.d) - // kullanicinin silme yetkisi var ise - const opt = grd.option('toolbar') - const deleteSelectedRecordsIndex = opt?.items - ?.map((e: any) => e.name) - .indexOf('deleteSelectedRecords') - // deleteSelectedRecords ismindeki custom butonun index degerini bul - - grd.option( - `toolbar.items[${deleteSelectedRecordsIndex}].options.visible`, - data.selectedRowsData.length > 1, - ) // birden fazla kayit secilmis ise gorunsun - } - - // SubForm'ları gösterebilmek için secili satiri formData'ya at - updateWorkflowApprovalButtons(grd, data.selectedRowsData) + updateWorkflowApprovalButtons(grid, data.selectedRowsData) + // SubForm'ları besleyebilmek için seçili ilk satırı sakla. if (data.selectedRowsData.length) { setFormData(data.selectedRowsData[0]) } }, - [gridDto], + [getGridInstance, gridDto, updateWorkflowApprovalButtons], ) - const onCellPrepared = useCallback( - (e: any) => { - const columnFormats = gridDto?.columnFormats - if (!columnFormats) { - return - } - - // satir, hucre yada header vb. kisimlara conditional style uygulamak icin - for (let indxCol = 0; indxCol < columnFormats.length; indxCol++) { - const colFormat = columnFormats[indxCol] - for (let indxStyl = 0; indxStyl < colFormat.columnStylingDto.length; indxStyl++) { - const colStyle = colFormat.columnStylingDto[indxStyl] // uygulanacak style - if (e.rowType == colStyle.rowType) { - // header, filter, data, group, summaries ..her birisine style uygulanabilir - // style bütün satıra uygulansın olarak seçili ise yada sadece ilgili field üzerinde ise - if (colStyle.useRow || e.column.dataField == colFormat.fieldName) { - if ( - !colStyle.conditionValue || - controlStyleCondition(e.data, colFormat.fieldName, colStyle) - ) { - // css sınıf ismi var ise uygula - if (colStyle.cssClassName) { - e.cellElement.addClass(colStyle.cssClassName) - } - // css inline style var ise uygula - if (colStyle.cssStyles) { - e.cellElement.attr( - 'style', - e.cellElement.attr('style') + ';' + colStyle.cssStyles, - ) - } - } - } - } - } - } - }, - [gridDto], - ) + const onCellPrepared = useMemo(() => createConditionalCellStyleHandler(gridDto), [gridDto]) const onInitNewRow = useCallback( - (e: any) => { + (e: DataGridTypes.InitNewRowEvent) => { if (!gridDto?.columnFormats) { editingFormDataRef.current = { ...(e.data || {}) } return @@ -500,115 +279,24 @@ const Grid = (props: GridProps) => { setMode('new') setIsPopupFullScreen( - useMobileEditPopup || (gridDto?.gridOptions.editingOptionDto?.popup?.fullScreen ?? false), + useMobileEditPopup || (gridDto.gridOptions.editingOptionDto?.popup?.fullScreen ?? false), ) - e.promise = (async () => { - const rawFilter = searchParams?.get('filter') - let filters: any[] = [] - - if (rawFilter) { - try { - const parsed = JSON.parse(rawFilter) - filters = extractSearchParamsFields(parsed) - } catch (error) { - console.error('Filter parse edilemedi:', error) - } - } - - for (const colFormat of gridDto.columnFormats) { - const fieldName = colFormat.fieldName - - if (!fieldName) { - continue - } - - // ExtraFilters içerisinde ilgili Field varsa, default değerleri set etme - if (extraFilters.some((f) => f.fieldName === fieldName)) { - continue - } - - // 1) Önce grid default value set et - if (colFormat.defaultValue !== null && colFormat.defaultValue !== undefined) { - if ( - typeof colFormat.defaultValue === 'string' && - colFormat.defaultValue === '@AUTONUMBER' - ) { - e.data[fieldName] = autoNumber() - } else if (colFormat.defaultValueType === FieldCustomValueTypeEnum.Sequence) { - try { - const response = await getNextSequenceValue(String(colFormat.defaultValue)) - e.data[fieldName] = response.data - } catch (error) { - console.error('Sequence default value alınamadı:', { - fieldName, - defaultValue: colFormat.defaultValue, - error, - }) - } - } else { - e.data[fieldName] = colFormat.defaultValue - } - } - - // 2) Sonra URL / prop filter varsa üzerine yaz - const fieldMatch = filters.find(([field]) => field === fieldName) - - if (fieldMatch) { - const val = fieldMatch[2] - const dType = colFormat.dataType as DataType - - switch (dType) { - case 'date': - case 'datetime': - e.data[fieldName] = new Date(val) - break - - case 'number': - e.data[fieldName] = Number(val) - break - - case 'boolean': - e.data[fieldName] = val === true || val === 'true' - break - - case 'object': - try { - e.data[fieldName] = JSON.parse(val) - } catch { - e.data[fieldName] = val - } - break - - default: - e.data[fieldName] = val - break - } - } - } - - editingFormDataRef.current = { ...(e.data || {}) } - })() + e.promise = applyNewRowDefaults( + gridDto, + e.data, + searchParams?.get('filter'), + extraFilters, + ).then((data) => { + editingFormDataRef.current = { ...data } + }) }, - [gridDto, searchParams, extraFilters, getNextSequenceValue, useMobileEditPopup], + [gridDto, searchParams, extraFilters, useMobileEditPopup], ) const onRowInserting = useCallback( (e: DataGridTypes.RowInsertingEvent) => { - if (!gridDto?.columnFormats) { - e.data = setFormEditingExtraItemValues(e.data) - return - } - const allowedFields = gridDto.columnFormats - .filter((f) => f.allowAdding) - .map((f) => f.fieldName) - const filteredData: any = {} - for (const key of allowedFields) { - if (key && Object.prototype.hasOwnProperty.call(e.data, key)) { - filteredData[key] = e.data[key] - } - } - e.data = setFormEditingExtraItemValues(filteredData) + e.data = buildInsertPayload(gridDto, e.data) }, [gridDto], ) @@ -616,21 +304,7 @@ const Grid = (props: GridProps) => { const onRowUpdating = useCallback( (e: DataGridTypes.RowUpdatingEvent) => { if (!gridDto?.columnFormats) return - const allowedFields = gridDto.columnFormats - .filter((f) => f.allowEditing) - .map((f) => f.fieldName) - let newData = { ...e.oldData, ...e.newData } - // Remove keys not allowed - Object.keys(newData).forEach((key) => { - if (!allowedFields.includes(key) || key.includes(':')) { - delete newData[key] - } - }) - newData = setFormEditingExtraItemValues(newData) - if (gridDto?.gridOptions.keyFieldName) { - delete newData[gridDto?.gridOptions.keyFieldName] - } - e.newData = newData + e.newData = buildUpdatePayload(gridDto, e.oldData, e.newData) }, [gridDto], ) @@ -643,423 +317,112 @@ const Grid = (props: GridProps) => { setIsPopupFullScreen( useMobileEditPopup || (gridDto?.gridOptions.editingOptionDto?.popup?.fullScreen ?? false), ) - const columns = e.component.option('columns') as GridColumnData[] - // FormEditingExtraItem field ise datayı doldur - columns?.forEach((col) => { - if (!col.dataField?.includes(':')) { - return - } - const field = col.dataField.split(':') - if (!e.data[field[0]]) { - return - } - - const json = JSON.parse(e.data[field[0]]) - e.data[col.dataField] = json[field[1]] - }) + expandJsonFormFields(e.component.option('columns') as GridColumnData[], e.data) }, - [gridDto, mode, useMobileEditPopup], + [gridDto, useMobileEditPopup], ) + const onEditFinished = useCallback(() => { + isEditingRef.current = false + setMode('view') + setIsPopupFullScreen(false) + }, []) + const onDataErrorOccurred = useCallback((e: DataGridTypes.DataErrorOccurredEvent) => { toast.push( {e.error?.message} , - { - placement: 'bottom-end', - }, + { placement: 'bottom-end' }, ) }, []) - // Cascade parent fields mapping'i memoize et - popup açılışını hızlandırır - // Ayrıca parent -> child ilişkisi için reverse map oluştur (performans için) - const { cascadeFieldsMap, parentToChildrenMap } = useMemo(() => { - if (!gridDto) return { cascadeFieldsMap: new Map(), parentToChildrenMap: new Map() } - - const cascadeMap = new Map< - string, - { - parentFields: string[] - childFields?: string[] - } - >() - - const parentChildMap = new Map>() - - gridDto.columnFormats.forEach((col) => { - if (col.lookupDto?.cascadeParentFields && col.fieldName) { - const parentFields = col.lookupDto.cascadeParentFields - .split(',') - .map((f: string) => f.trim()) - const childFields = col.lookupDto.cascadeEmptyFields - ?.split(',') - .map((f: string) => f.trim()) - - cascadeMap.set(col.fieldName, { - parentFields, - childFields, - }) - - // Her parent field için, hangi childları etkilediğini kaydet - parentFields.forEach((parentField) => { - if (!parentChildMap.has(parentField)) { - parentChildMap.set(parentField, new Set()) - } - if (col.fieldName) { - parentChildMap.get(parentField)!.add(col.fieldName) - } - }) - } - }) - - return { cascadeFieldsMap: cascadeMap, parentToChildrenMap: parentChildMap } - }, [gridDto]) + const cascadeMaps = useMemo(() => createCascadeMaps(gridDto), [gridDto]) const onEditorPreparing = useCallback( (editor: DataGridTypes.EditorPreparingEvent) => { - if (editor.parentType === 'dataRow' && editor.dataField && gridDto) { - const columnFormat = gridDto.columnFormats.find( - (column) => column.fieldName === editor.dataField, - ) - const formItem = columnFormat?.editOrderNo != null ? columnFormat : undefined - const isNewRow = Boolean((editor as any).row?.isNewRow) || mode === 'new' + if (editor.parentType !== 'dataRow' || !editor.dataField || !gridDto) return - if (columnFormat?.editorOptions) { - try { - const configuredEditorOptions = JSON.parse(columnFormat.editorOptions) - editor.editorOptions = { - ...editor.editorOptions, - ...configuredEditorOptions, - } - } catch (err) { - console.error('EditorOptions parse error:', columnFormat.fieldName, err) + 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), } - } - - 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 - } - } - - // Cascade mantığı - const cascadeInfo = cascadeFieldsMap.get(editor.dataField) - if (cascadeInfo) { - const parentFields = cascadeInfo.parentFields - const childFields = cascadeInfo.childFields - const affectedChildren = parentToChildrenMap.get(editor.dataField!) - const prevHandler = editor.editorOptions.onValueChanged - - editor.editorOptions.onValueChanged = (e: any) => { - prevHandler?.(e) - - const grid = editor.component - const rowKey = grid.option('editing.editRowKey') - const rowIndex = grid.getRowIndexByKey(rowKey) - - // Parent field değiştiğinde child fieldleri temizle - if (childFields && rowIndex >= 0) { - childFields.forEach((childField: string) => { - grid.cellValue(rowIndex, childField, null) - }) - } - - // Sadece bu parent'tan etkilenen childların disabled durumunu güncelle - if (affectedChildren?.size) { - const formInstance = grid.option('editing.form') as any - if (formInstance?.getEditor) { - const visibleRows = grid.getVisibleRows() - const rowData = visibleRows.find((r) => r.key === rowKey)?.data - - if (rowData) { - affectedChildren.forEach((childFieldName: string) => { - const childInfo = cascadeFieldsMap.get(childFieldName) - if (childInfo) { - const shouldDisable = childInfo.parentFields.some( - (pf: string) => !rowData[pf], - ) - try { - const childFormItem = gridDto.columnFormats.find( - (column) => column.fieldName === childFieldName, - ) - const childEditorOptions = childFormItem?.editorOptions - ? JSON.parse(childFormItem.editorOptions) - : {} - const childEditor = formInstance.getEditor(childFieldName) - - if (childEditorOptions?.disabled === true) { - childEditor?.option('disabled', true) - } else { - childEditor?.option('disabled', shouldDisable) - } - } catch (err) { - console.log(`Error occurred while updating child editor for field "${childFieldName}":`, err) - } - } - }) - } - } - } - } - - // İlk açılışta disabled durumunu kontrol et - const grid = editor.component - const rowKey = grid.option('editing.editRowKey') - const visibleRows = grid.getVisibleRows() - const rowData = visibleRows.find((r) => r.key === rowKey)?.data - - if (rowData && parentFields.some((pf: string) => !rowData[pf])) { - editor.editorOptions.disabled = true - } - } - - if (formItem?.editorScript) { - const prevHandler = editor.editorOptions.onValueChanged - const editorDataField = editor.dataField - - editor.editorOptions.onValueChanged = (e: any) => { - if (prevHandler) prevHandler(e) - - try { - const grid = editor.component - const rowKey = grid.option('editing.editRowKey') - const rowIndex = grid.getRowIndexByKey(rowKey) - - const formData = { - ...(grid.getVisibleRows().find((r) => r.key === rowKey)?.data || {}), - [editorDataField]: e.value, - } - const runtimeSetEditorReadOnly = (field: string, readOnly: boolean) => - setFormEditorReadOnly( - editingFormInstanceRef.current ?? getActiveEditingForm(grid), - field, - readOnly, - ) - const setFormData = (newData: any) => { - if (rowIndex >= 0) { - Object.keys(newData).forEach((field) => { - grid.cellValue(rowIndex, field, newData[field]) - }) - } - } - - executeEditorScript(formItem.editorScript!, { - formData, - e: { ...e, scriptEvent: 'valueChanged' }, - editor, - runtimeSetEditorReadOnly, - setFormData, - }) - } catch (err) { - console.error('Script exec error', formItem.fieldName, err) - } - } - } - - if (editor.editorOptions?.buttons) { - editor.editorOptions.buttons = editor.editorOptions.buttons.map((btn: any) => { - if (btn?.options?.onClick && typeof btn.options.onClick === 'function') { - const origClick = btn.options.onClick - btn.options.onClick = (e: any) => { - const grid = editor.component - const rowKey = grid.option('editing.editRowKey') - const rowIndex = grid.getRowIndexByKey(rowKey) - const formData = grid.getVisibleRows().find((r) => r.key === rowKey)?.data || {} - - origClick({ - ...e, - formData, - fieldName: editor.dataField, - rowKey, - rowIndex, - }) - } - } - return btn - }) + } 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 }) + } + + // 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) }, - [gridDto, cascadeFieldsMap, parentToChildrenMap, mode], + [gridDto, cascadeMaps, mode], ) + /* ======================= + COLUMNS & DATASOURCE + ======================= */ + + // listFormCode değişiminde eski kolon/veri kaynağını temizle. useEffect(() => { - // React state'i sıfırla - eski değerlerin customLoadState'i erken tetiklemesini önle setGridDataSource(undefined) setColumnData(undefined) - if (gridRef?.current) { - const instance = gridRef?.current?.instance() - if (instance) { - instance.option('columns', undefined) - instance.option('remoteOperations', false) - instance.option('dataSource', undefined) - instance.state(null) - } + const instance = getGridInstance() + if (instance) { + instance.option('columns', undefined) + instance.option('remoteOperations', false) + instance.option('dataSource', undefined) + instance.state(null) } + }, [listFormCode, getGridInstance]) - if (refListFormCode.current !== listFormCode) { - setExtraFilters([]) - } - }, [listFormCode]) - - useEffect(() => { - if (!gridDto) { - return - } - - // Set js and css - const grdOpt = gridDto.gridOptions - if (grdOpt.customJsSources.length) { - for (const js of grdOpt.customJsSources) { - addJs(js) - } - } - if (grdOpt.customStyleSources.length) { - for (const css of grdOpt.customStyleSources) { - addCss(css) - } - } - - if (gridDto?.gridOptions.extraFilterDto) { - setExtraFilters( - gridDto.gridOptions.extraFilterDto.map((f) => ({ - fieldName: f.fieldName, - operator: f.operator, - controlType: f.controlType, - value: f.defaultValue ?? '', - })), - ) - } - - if (gridDto.gridOptions.editingOptionDto?.popup) { - setIsPopupFullScreen( - useMobileEditPopup || (gridDto.gridOptions.editingOptionDto?.popup?.fullScreen ?? false), - ) - } - }, [gridDto, useMobileEditPopup]) - - // extraFilters değişikliklerini useMemo ile optimize et - const filterParams = useMemo(() => { - const activeFilters = extraFilters.filter((f) => f.value) - - let base: any = null - if (defaultSearchParams.current) { - base = safeJsonParse(defaultSearchParams.current, null, 'Search filter parse error:') - } - - // Default filter tripletlerini çıkar - const baseTriplets = extractSearchParamsFields(base) - - // Extra filter tripletleri hazırla - const extraTriplets = activeFilters.map( - (f) => [f.fieldName, f.operator, f.value] as [string, string, any], - ) - - // Tripletleri birleştir, aynı field+op varsa sonuncuyu al - const merged = [...baseTriplets, ...extraTriplets].reduce( - (acc, cur) => { - const [field, op] = cur - const idx = acc.findIndex((a) => a[0] === field && a[1] === op) - if (idx >= 0) { - acc[idx] = cur // varsa üzerine yaz - } else { - acc.push(cur) - } - return acc - }, - [] as [string, string, any][], - ) - - // Tek filtre varsa direkt yaz - let filter: any = null - if (merged.length === 1) { - filter = merged[0] - } else if (merged.length > 1) { - filter = merged.reduce((acc, f, idx) => { - if (idx === 0) return f - return [acc, 'and', f] - }, null as any) - } - - return filter - }, [extraFilters]) - - // Filter değiştiğinde searchParams'ı güncelle (side effect) - // NOT: searchParams'ı dependency'e koymuyoruz çünkü sonsuz döngü yaratır - useEffect(() => { - if (filterParams) { - searchParams?.set('filter', JSON.stringify(filterParams)) - } else { - searchParams?.delete('filter') - } - // Grid'i yenile - gridRef.current?.instance()?.refresh() - }, [filterParams]) - - // Kolonları oluştur - dil değiştiğinde güncelle + // Kolonlar dil değiştiğinde de yeniden üretilir. const memoizedColumns = useMemo(() => { if (!gridDto) return undefined - const cols = getBandedColumns() - - cols?.forEach((col: any) => { - const eo = col.editorOptions - - // Sadece phoneGlobal formatlı kolonlarda çalış - if (eo?.format === 'phoneGlobal') { - // DevExtreme bazen string tipinde formatter'ı çağırmaz - // Bu yüzden her durumda çalışması için customizeText ekleyeceğiz - col.dataType = 'string' - - const formatter = (value: any) => { - if (!value) return '' - - // string'e dönüştür ve sadece rakamları al - let digits = String(value).replace(/\D/g, '') - - // +90, 0090, 0 gibi ülke kodu veya ön ekleri atla - if (digits.startsWith('90') && digits.length > 10) digits = digits.slice(-10) - if (digits.startsWith('0') && digits.length > 10) digits = digits.slice(-10) - if (digits.length > 10) digits = digits.slice(-10) - - // 🔒 Eğer 10 haneli değilse geçersiz → boş göster - if (digits.length !== 10) return '' - - // (XXX) XXX-XXXX formatında göster - const match = digits.match(/^(\d{3})(\d{3})(\d{4})$/) - return match ? `(${match[1]}) ${match[2]}-${match[3]}` : digits - } - - // 1️⃣ Normal format nesnesi - col.format = { formatter } - - // 2️⃣ CustomizeText fallback — bazı durumlarda zorunlu - col.customizeText = (cellInfo: any) => formatter(cellInfo?.value) - } - }) - - return cols + const columns = getBandedColumns() + columns?.forEach(applyPhoneGlobalFormat) + return columns + // getBandedColumns her render'da yeni referans üretebildiği için bağımlılığa alınmaz. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [gridDto, currentCulture, localizationTexts]) - useEffect(() => { - setColumnData(memoizedColumns) - }, [memoizedColumns]) - - // DataSource oluştur - sadece gridDto değiştiğinde const memoizedDataSource = useMemo(() => { if (!gridDto) return undefined @@ -1068,311 +431,132 @@ const Grid = (props: GridProps) => { listFormCode, searchParams, layoutTypes.grid, - undefined, // columnData yerine undefined - datasource oluşturulurken columns henüz set edilmeyebilir + // Kolonlar henüz grid'e set edilmemiş olabilir; store instance üzerinden okur. + undefined, ) - }, [gridDto, listFormCode, createSelectDataSource]) + }, [gridDto, listFormCode, searchParams, createSelectDataSource]) + + useEffect(() => setColumnData(memoizedColumns), [memoizedColumns]) + useEffect(() => setGridDataSource(memoizedDataSource), [memoizedDataSource]) useEffect(() => { - setGridDataSource(memoizedDataSource) - }, [memoizedDataSource]) - - // Grid columns'ı set et - sadece columnData değiştiğinde - useEffect(() => { - if (!columnData || !gridRef?.current) return - - const instance = gridRef?.current?.instance() - if (instance) { + const instance = getGridInstance() + if (columnData && instance) { instance.option('columns', columnData) } - }, [columnData]) + }, [columnData, getGridInstance]) - // listFormCode'u ref'e kaydet + // Otomatik state kaydetme/yükleme kapalı; manuel akış kullanılır. useEffect(() => { - refListFormCode.current = listFormCode - }, [listFormCode]) + if (!gridDto) return + getGridInstance()?.option('stateStoring', { enabled: false }) + }, [gridDto, getGridInstance]) - // State yükle + DataSource'u tek seferde set et → tek bir veri çekimi - // customLoadState önce çalışır, state grid'e uygulandıktan SONRA dataSource set edilir + /** + * State önce yüklenir, ardından dataSource set edilir. Böylece kayıtlı + * filtre/sıralama tek bir veri çekimiyle uygulanır. + */ useEffect(() => { - if (!gridDto || !gridRef?.current || !gridDataSource || !columnData) return + const instance = getGridInstance() + if (!gridDto || !instance || !gridDataSource || !columnData) return - const instance = gridRef?.current?.instance() - if (!instance) return - - const remoteOps = { - paging: true, - filtering: true, - sorting: true, - grouping: false, - summary: false, + const applyDataSource = (state?: any) => { + instance.option('remoteOperations', remoteOperations) + if (state) instance.state(state) + instance.option('dataSource', gridDataSource) } customLoadState() - .then((state) => { - instance.option('remoteOperations', remoteOps) - // State varsa dataSource set edilmeden ÖNCE uygula → tek veri çekimi - if (state) { - instance.state(state) - } - instance.option('dataSource', gridDataSource) - }) + .then(applyDataSource) .catch((err) => { console.error('State load error:', err) - instance.option('remoteOperations', remoteOps) - instance.option('dataSource', gridDataSource) + applyDataSource() }) - }, [gridDto, gridDataSource, columnData, customLoadState]) + }, [gridDto, gridDataSource, columnData, customLoadState, getGridInstance]) - // StateStoring'i devre dışı bırak - manuel kaydetme kullanılacak - useEffect(() => { - if (!gridDto || !gridRef?.current) return + /* ======================= + EDITING FORM + ======================= */ - const instance = gridRef?.current?.instance() - if (instance) { - // Otomatik state kaydetme/yükleme kapalı - instance.option('stateStoring', { - enabled: false, - }) - } - }, [gridDto]) - - useEffect(() => { - refListFormCode.current = listFormCode - }, [listFormCode]) - - // Form items mapper'ı memoize et - popup açılışını hızlandırır - const mapFormItem = useCallback( - (i: ColumnFormatDto) => { - let editorOptions: EditorOptionsWithButtons = {} - let parsedEditorOptions: EditorOptionsWithButtons = {} - const forcedEditorOptions: EditorOptionsWithButtons = {} - try { - parsedEditorOptions = i.editorOptions ? JSON.parse(i.editorOptions) : {} - - const rawFilter = searchParams?.get('filter') - if (rawFilter) { - const parsed = JSON.parse(rawFilter) - const filters = extractSearchParamsFields(parsed) - - const hasFilter = filters.some(([field]) => field === i.fieldName) - - if (hasFilter) { - const existsInExtra = extraFilters.some((f) => f.fieldName === i.fieldName && !!f.value) - - if (!existsInExtra) { - forcedEditorOptions.readOnly = true - } - } - } - } catch (err) { - console.error('EditorOptions parse error:', i.fieldName, err) - } - - const fieldName = i.fieldName! - const listFormField = i - - const defaultEditorOptions: EditorOptionsWithButtons = {} - - if (listFormField?.sourceDbType === DbTypeEnum.Date) { - Object.assign(defaultEditorOptions, { - type: 'date', - dateSerializationFormat: 'yyyy-MM-dd', - displayFormat: 'shortDate', - }) - } else if ( - listFormField?.sourceDbType === DbTypeEnum.DateTime || - listFormField?.sourceDbType === DbTypeEnum.DateTime2 || - listFormField?.sourceDbType === DbTypeEnum.DateTimeOffset - ) { - Object.assign(defaultEditorOptions, { - type: 'datetime', - dateSerializationFormat: 'yyyy-MM-ddTHH:mm:ss', - displayFormat: 'shortDateShortTime', - }) - } - - // Her item'a placeholder olarak captionName ekle - if (listFormField?.placeHolder) { - defaultEditorOptions.placeholder = translate('::' + listFormField.placeHolder) - } - - // Set defaultValue for @AUTONUMBER fields - if ( - typeof listFormField?.defaultValue === 'string' && - listFormField?.defaultValue === '@AUTONUMBER' && - mode === 'new' - ) { - defaultEditorOptions.value = autoNumber() - } - - editorOptions = { - ...defaultEditorOptions, - ...parsedEditorOptions, - ...forcedEditorOptions, - } - - if (editorOptions?.buttons) { - editorOptions.buttons = (editorOptions?.buttons || []).map((btn: any) => { - if (btn?.options?.onClick && typeof btn.options.onClick === 'string') { - btn.options.onClick = eval(`(${btn.options.onClick})`) - } - return btn - }) - } - - const item: SimpleItemWithColData = { - canRead: listFormField?.canRead ?? false, - canUpdate: listFormField?.canUpdate ?? false, - canCreate: listFormField?.canCreate ?? false, - canExport: listFormField?.canExport ?? false, - allowEditing: listFormField?.allowEditing ?? true, - allowAdding: listFormField?.allowAdding ?? true, - dataField: i.fieldName, - name: i.fieldName, - editorType2: i.editorType2, - editorType: - i.editorType2 == PlatformEditorTypes.dxGridBox - ? 'dxDropDownBox' - : i.editorType2 == PlatformEditorTypes.dxImageUpload || - i.editorType2 == PlatformEditorTypes.dxImageViewer - ? undefined - : i.editorType2, - colSpan: i.colSpan, - editorOptions, - editorScript: i.editorScript, - } - - if (i.editorType2 == PlatformEditorTypes.dxImageViewer) { - item.template = 'cellEditImageViewer' - item.editorOptions = { - ...item.editorOptions, - value: getValueByField(editingFormDataRef.current, i.fieldName), - } - } - - if (i.fieldName?.indexOf(':') >= 0) { - item.label = { text: captionize(i.fieldName.split(':')[1]) } - } - - if ((mode == 'edit' && !item.canUpdate) || (mode == 'new' && !item.canCreate)) { - item.editorOptions = { - ...item.editorOptions, - readOnly: true, - } - } - - return item - }, - [gridDto, mode, searchParams, extraFilters], + const mapFormItem = useMemo( + () => + createFormItemMapper({ + mode, + translate, + rawFilter: searchParams?.get('filter'), + extraFilters, + getEditingFormData: () => editingFormDataRef.current, + }), + [mode, translate, searchParams, extraFilters], ) - // WidgetGroup yüksekliğini hesapla - useEffect(() => { - const calculateWidgetHeight = () => { - if (widgetGroupRef.current) { - const height = widgetGroupRef.current.offsetHeight - setWidgetGroupHeight(height) - } - } + const editingFormItems = useMemo( + () => buildEditingFormItems(gridDto, mapFormItem, mode), + [gridDto, mapFormItem, mode], + ) - // İlk render'da hesapla - calculateWidgetHeight() + const editingFormHandlers = useMemo( + () => + createEditingFormHandlers({ + getGridDto: () => gridDto, + getGridInstance, + editingFormDataRef, + editingFormInstanceRef, + }), + [gridDto, getGridInstance], + ) - // Resize durumunda tekrar hesapla - const resizeObserver = new ResizeObserver(calculateWidgetHeight) - if (widgetGroupRef.current) { - resizeObserver.observe(widgetGroupRef.current) - } + const editPopupOptions = useMemo( + () => + buildEditPopupOptions({ + popupOptions: gridDto?.gridOptions.editingOptionDto?.popup, + mode, + isPopupFullScreen, + useMobileEditPopup, + translate, + handlers: { + onSave: () => getGridInstance()?.saveEditData(), + onCancel: () => getGridInstance()?.cancelEditData(), + onToggleFullScreen: () => setIsPopupFullScreen((current) => !current), + }, + }), + [gridDto, mode, isPopupFullScreen, useMobileEditPopup, translate, getGridInstance], + ) - return () => { - resizeObserver.disconnect() - } - }, [gridDto?.widgets]) + const onExporting = useCallback( + (e: DataGridTypes.ExportingEvent) => { + e.cancel = true + const grid = getGridInstance() + if (!grid) return - const onExporting = async (e: DataGridTypes.ExportingEvent) => { - // DevExtreme’in varsayılan export davranışını iptal ediyoruz; kendi akışımızı çalıştıracağız - e.cancel = true + void exportListView({ + component: grid, + format: e.format, + fileName: listFormCode, + onError: () => + toast.push( + + {translate('::App.Common.ExportError') ?? 'Dışa aktarma sırasında hata oluştu.'} + , + { placement: 'bottom-end' }, + ), + }) + }, + [getGridInstance, listFormCode, translate], + ) - const grid = gridRef?.current?.instance() - if (!grid) return + /* ======================= + RENDER + ======================= */ - try { - if (e.format === 'xlsx') { - // Sadece Excel için gerekli kütüphaneleri yükle - const [{ Workbook }, { saveAs }, { exportDataGrid }] = await Promise.all([ - import('devextreme-exceljs-fork'), - import('file-saver'), - import('devextreme/excel_exporter'), - ]) - - const workbook = new Workbook() - const worksheet = workbook.addWorksheet(`${listFormCode}_sheet`) - - await exportDataGrid({ - component: grid as any, - worksheet, - autoFilterEnabled: true, - }) - - const buffer = await workbook.xlsx.writeBuffer() - saveAs( - new Blob([buffer], { type: 'application/octet-stream' }), - `${listFormCode}_export.xlsx`, - ) - } else if (e.format === 'csv') { - // Sadece CSV için gerekli kütüphaneleri yükle (exceljs CSV desteği için) - const [{ Workbook }, { saveAs }] = await Promise.all([ - import('devextreme-exceljs-fork'), - import('file-saver'), - ]) - - const workbook = new Workbook() - const worksheet = workbook.addWorksheet(`${listFormCode}_sheet`) - - // CSV için basit data export - const dataSource = grid.getDataSource() - const items = dataSource?.items() || [] - const columns = grid.getVisibleColumns().filter((c: any) => c.dataField) - - // Header ekle - worksheet.addRow(columns.map((c: any) => c.caption || c.dataField)) - - // Data ekle - items.forEach((item: any) => { - worksheet.addRow(columns.map((c: any) => item[c.dataField!])) - }) - - const buffer = await workbook.csv.writeBuffer() - saveAs(new Blob([buffer], { type: 'text/csv' }), `${listFormCode}_export.csv`) - } else if (e.format === 'pdf') { - // jspdf + devextreme pdf exporter => ihtiyaç anında yükle - const [jspdfMod, { exportDataGrid: exportDataPdf }] = await Promise.all([ - import('jspdf'), - import('devextreme/pdf_exporter'), - ]) - - // jsPDF bazı paketlemelerde default, bazılarında named export olarak gelir - const JsPDFCtor = (jspdfMod as any).default ?? (jspdfMod as any).jsPDF - const doc = new JsPDFCtor({}) - - await exportDataPdf({ - jsPDFDocument: doc, - component: grid as any, - indent: 5, - }) - - doc.save(`${listFormCode}_export.pdf`) - } - } catch (err) { - console.error('Export error:', err) - toast.push( - - {translate('::App.Common.ExportError') ?? 'Dışa aktarma sırasında hata oluştu.'} - , - { placement: 'bottom-end' }, - ) - } - } + const gridOptions = gridDto?.gridOptions + const loadingMessage = !gridDto + ? 'Loading grid configuration...' + : !columnData + ? 'Loading columns...' + : !gridDataSource + ? 'Loading data source...' + : undefined return ( <> @@ -1384,573 +568,221 @@ const Grid = (props: GridProps) => { {!isSubForm && ( + /> )} - {!gridDto && ( -
- Loading grid configuration... -
- )} - {gridDto && !columnData && ( -
- Loading columns... -
- )} - {gridDto && columnData && !gridDataSource && ( -
- Loading data source... -
- )} - {gridDto && - columnData && - gridDataSource && - (() => { - return true - })() && ( - <> -
- 0 - ? gridDto.gridOptions.height - : gridDto.gridOptions.fullHeight - ? `calc(100vh - ${170 + widgetGroupHeight}px)` - : undefined - } - width={gridDto.gridOptions.width || '100%'} - allowColumnResizing={gridDto.gridOptions.columnOptionDto?.allowColumnResizing} - allowColumnReordering={gridDto.gridOptions.columnOptionDto?.allowColumnReordering} - showBorders={gridDto.gridOptions.columnOptionDto?.showBorders} - showRowLines={gridDto.gridOptions.columnOptionDto?.showRowLines} - showColumnLines={gridDto.gridOptions.columnOptionDto?.showColumnLines} - columnResizingMode={gridDto.gridOptions.columnOptionDto?.columnResizingMode} - columnAutoWidth={gridDto.gridOptions.columnOptionDto?.columnAutoWidth} - rtlEnabled={gridDto.gridOptions.columnOptionDto?.rtlEnabled} - rowAlternationEnabled={gridDto.gridOptions.columnOptionDto?.rowAlternationEnabled} - onRowPrepared={(e) => { - //header, filter, data, group, summaries - if (e.rowType === 'data') { - e.rowElement.style.height = gridDto.gridOptions?.rowDto.rowHeight - e.rowElement.style.whiteSpace = gridDto.gridOptions?.rowDto.whiteSpace - e.rowElement.style.overflowWrap = gridDto.gridOptions?.rowDto.overflowWrap - } - }} - hoverStateEnabled={gridDto.gridOptions.columnOptionDto?.hoverStateEnabled} - columnHidingEnabled={gridDto.gridOptions.columnOptionDto?.columnHidingEnabled} - focusedRowEnabled={gridDto.gridOptions.columnOptionDto?.focusedRowEnabled} - showColumnHeaders={gridDto.gridOptions.columnOptionDto?.showColumnHeaders} - filterSyncEnabled={true} - onSelectionChanged={onSelectionChanged} - onContentReady={(e) => updateWorkflowApprovalButtons(e.component)} - onInitNewRow={onInitNewRow} - onCellPrepared={onCellPrepared} - onRowInserting={onRowInserting} - onRowUpdating={onRowUpdating} - onEditingStart={onEditingStart} - onDataErrorOccurred={onDataErrorOccurred} - onExporting={onExporting} - onEditCanceling={() => { - setMode('view') - }} - onEditCanceled={() => { - isEditingRef.current = false - setMode('view') - setIsPopupFullScreen(false) - }} - onSaved={() => { - isEditingRef.current = false - setMode('view') - setIsPopupFullScreen(false) - }} - onRowInserted={(e) => { - const insertedKey = getPersistedInsertedKey(e, gridDto.gridOptions.keyFieldName) - if ( - gridDto.gridOptions.workflowDto?.approvalStatusFieldName && - insertedKey !== undefined - ) { - workflowService - .startWorkflow(listFormCode, [insertedKey]) - .then((result) => { - const messages = result.toastMessages ?? [] - if (messages.length > 0) { - toast.push( - - {messages.map((message, messageIndex) => ( -
0 ? 'mt-2 border-t pt-2' : undefined} - > - {message.split('\n').map((line, lineIndex) => ( -
{line}
- ))} -
- ))} -
, - { placement: 'bottom-end' }, - ) - } - gridRef.current?.instance()?.refresh() - }) - .catch(console.error) - } - props.refreshData?.() - }} - onRowUpdated={() => { - props.refreshData?.() - }} - onRowRemoved={() => { - props.refreshData?.() - }} - onEditorPreparing={onEditorPreparing} - > - + {loadingMessage} +
+ )} + + {gridDto && gridOptions && columnData && gridDataSource && ( +
+ 0 + ? gridOptions.height + : gridOptions.fullHeight + ? `calc(100vh - ${170 + widgetGroupHeight}px)` + : undefined + } + width={gridOptions.width || '100%'} + allowColumnResizing={gridOptions.columnOptionDto?.allowColumnResizing} + allowColumnReordering={gridOptions.columnOptionDto?.allowColumnReordering} + showBorders={gridOptions.columnOptionDto?.showBorders} + showRowLines={gridOptions.columnOptionDto?.showRowLines} + showColumnLines={gridOptions.columnOptionDto?.showColumnLines} + columnResizingMode={gridOptions.columnOptionDto?.columnResizingMode} + columnAutoWidth={gridOptions.columnOptionDto?.columnAutoWidth} + rtlEnabled={gridOptions.columnOptionDto?.rtlEnabled} + rowAlternationEnabled={gridOptions.columnOptionDto?.rowAlternationEnabled} + hoverStateEnabled={gridOptions.columnOptionDto?.hoverStateEnabled} + columnHidingEnabled={gridOptions.columnOptionDto?.columnHidingEnabled} + focusedRowEnabled={gridOptions.columnOptionDto?.focusedRowEnabled} + showColumnHeaders={gridOptions.columnOptionDto?.showColumnHeaders} + filterSyncEnabled + onRowPrepared={(e) => { + if (e.rowType === 'data') { + e.rowElement.style.height = gridOptions.rowDto.rowHeight + e.rowElement.style.whiteSpace = gridOptions.rowDto.whiteSpace + e.rowElement.style.overflowWrap = gridOptions.rowDto.overflowWrap + } + }} + onSelectionChanged={onSelectionChanged} + onContentReady={(e) => updateWorkflowApprovalButtons(e.component)} + onInitNewRow={onInitNewRow} + onCellPrepared={onCellPrepared} + onRowInserting={onRowInserting} + onRowUpdating={onRowUpdating} + onEditingStart={onEditingStart} + onDataErrorOccurred={onDataErrorOccurred} + onExporting={onExporting} + onEditCanceling={() => setMode('view')} + onEditCanceled={onEditFinished} + onSaved={onEditFinished} + onRowInserted={(e) => { + startWorkflowForInsertedRow(e, gridDto, listFormCode, refreshGrid) + props.refreshData?.() + }} + onRowUpdated={() => props.refreshData?.()} + onRowRemoved={() => props.refreshData?.()} + onEditorPreparing={onEditorPreparing} + > + + +