SqlDataSource -> Form olarak değiştirildi.

This commit is contained in:
Sedat ÖZTÜRK 2026-08-19 15:13:32 +03:00
parent 003e74101f
commit 7659dbdaa1
14 changed files with 1566 additions and 114 deletions

View file

@ -1,5 +1,6 @@
using Sozsoft.Platform.Domain.DeveloperKit; using Sozsoft.Platform.Domain.DeveloperKit;
using Sozsoft.Platform.Entities; using Sozsoft.Platform.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
@ -26,11 +27,36 @@ public class CrudEndpointAppService : ApplicationService
_dynamicManager = dynamicManager; _dynamicManager = dynamicManager;
} }
/// <summary>
/// Every query string entry that is not a paging parameter is a column filter:
/// <c>?RoleId=…</c> compares for equality, <c>?Name.contains=…</c> applies the
/// named operator. The column names are validated against the table schema in
/// the manager, so an unknown column fails loudly instead of returning the
/// unfiltered collection.
/// </summary>
[HttpGet("{entityName}")] [HttpGet("{entityName}")]
public async Task<List<object>?> GetEntityListAsync(string entityName) public async Task<List<object>?> GetEntityListAsync(string entityName)
{ {
await EnsureEndpointAsync(entityName, "GET", "GetList"); await EnsureEndpointAsync(entityName, "GET", "GetList");
return await _dynamicManager.GetEntityListAsync(entityName); return await _dynamicManager.GetEntityListAsync(entityName, ReadQueryFilters());
}
/// <summary>
/// Route values are not filters, so only the query string is read. A repeated
/// parameter keeps its comma joined form, which is what the <c>in</c> operator
/// expects.
/// </summary>
private Dictionary<string, string?> ReadQueryFilters()
{
var filters = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
var query = LazyServiceProvider.LazyGetService<IHttpContextAccessor>()?.HttpContext?.Request?.Query;
if (query == null)
return filters;
foreach (var entry in query)
filters[entry.Key] = string.Join(",", entry.Value.ToArray());
return filters;
} }
[HttpGet("{entityName}/{id}")] [HttpGet("{entityName}/{id}")]

View file

@ -19530,6 +19530,108 @@
"en": "Download as JPG", "en": "Download as JPG",
"tr": "JPG olarak indir" "tr": "JPG olarak indir"
}, },
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.FilterMaster",
"en": "Master component",
"tr": "Ana komponent"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.FilterMasterIncomplete",
"en": "Pick both the master component and its column; the filter does not run until then.",
"tr": "Ana komponenti ve sütununu birlikte seçin; ikisi tamamlanmadan filtre çalışmaz."
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.Filters",
"en": "Filters",
"tr": "Filtreler"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.AddFilter",
"en": "Add filter",
"tr": "Filtre ekle"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.FiltersHint",
"en": "Filters are sent as query parameters to the endpoint and apply to every component bound to it. The value can be static, come from the page URL, or come from the record of another Form.",
"tr": "Filtreler endpointe query parametresi olarak gönderilir ve bu endpointe bağlı tüm komponentler için geçerlidir. Değer sabit olabilir, sayfa adresinden gelebilir veya başka bir Form kaydından okunabilir."
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.NoFilter",
"en": "No filter defined; the endpoint returns the whole collection.",
"tr": "Tanımlı filtre yok; endpoint tüm koleksiyonu döndürür."
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.FilterColumn",
"en": "Column",
"tr": "Sütun"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.FilterValue",
"en": "Value",
"tr": "Değer"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.FilterUrlParam",
"en": "URL parameter name",
"tr": "Adres parametresi adı"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.FilterMasterColumn",
"en": "Master column",
"tr": "Ana kayıt sütunu"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.FilterPreviewValue",
"en": "Designer preview value",
"tr": "Tasarımcı önizleme değeri"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.FilterRequired",
"en": "Required: cancel the request while the value is empty",
"tr": "Zorunlu: değer boşken istek gönderilmesin"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.FilterSourceStatic",
"en": "Static",
"tr": "Sabit"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.FilterSourceQuery",
"en": "URL query",
"tr": "Adres sorgusu"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.FilterSourceRoute",
"en": "URL path",
"tr": "Adres yolu"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.FilterSourceRecord",
"en": "Form record",
"tr": "Form kaydı"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.RequiredFilterPreviewMissing",
"en": "A required filter has no preview value, so the request was not sent.",
"tr": "Zorunlu bir filtrenin önizleme değeri yok, bu yüzden istek gönderilmedi."
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.AcceptsChildren", "key": "App.DeveloperKitComponentDesigner.AcceptsChildren",
@ -19629,8 +19731,8 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.BoundToSqlDataSource", "key": "App.DeveloperKitComponentDesigner.BoundToSqlDataSource",
"en": "This component is bound to a SqlDataSource record; fields come from the columns in the Select (GET) response.", "en": "This component is bound to a Form record; fields come from the columns in the Select (GET) response.",
"tr": "Bu komponent SqlDataSource kaydına bağlıdır; alanlar Select (GET) cevabındaki sütunlardan gelir." "tr": "Bu komponent Form kaydına bağlıdır; alanlar Select (GET) cevabındaki sütunlardan gelir."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
@ -19653,8 +19755,8 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.ClearNonSqlBinding", "key": "App.DeveloperKitComponentDesigner.ClearNonSqlBinding",
"en": "Non-SqlDataSource binding — clear", "en": "Non-Form binding — clear",
"tr": "SqlDataSource dışı bağlantı — temizle" "tr": "Form dışı bağlantı — temizle"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
@ -19665,8 +19767,8 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.CommandSlotsHint", "key": "App.DeveloperKitComponentDesigner.CommandSlotsHint",
"en": "Define endpoints in the Data workspace and bind them to command slots here. Select the components you drop inside and bind them to a column by choosing the SqlDataSource record source from the Data tab.", "en": "Define endpoints in the Data workspace and bind them to command slots here. Select the components you drop inside and bind them to a column by choosing the Form record source from the Data tab.",
"tr": "Endpointleri Data çalışma alanında tanımlayın, burada komut yuvalarına bağlayın. İçine bıraktığınız komponentleri seçip Data sekmesinden SqlDataSource kaydı kaynağını seçerek sütuna bağlayabilirsiniz." "tr": "Endpointleri Data çalışma alanında tanımlayın, burada komut yuvalarına bağlayın. İçine bıraktığınız komponentleri seçip Data sekmesinden Form kaydı kaynağını seçerek sütuna bağlayabilirsiniz."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
@ -19719,14 +19821,14 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.DefineAndRunSelect", "key": "App.DeveloperKitComponentDesigner.DefineAndRunSelect",
"en": "Define and run the SqlDataSource Select endpoint.", "en": "Define and run the Form Select endpoint.",
"tr": "SqlDataSource Select endpointini tanımlayıp çalıştırın." "tr": "Form Select endpointini tanımlayıp çalıştırın."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.DefineSelectEndpointFirst", "key": "App.DeveloperKitComponentDesigner.DefineSelectEndpointFirst",
"en": "This component is inside a SqlDataSource. To bind it to columns, first select the SqlDataSource and define the Select (GET) endpoint from the Data tab.", "en": "This component is inside a Form. To bind it to columns, first select the Form and define the Select (GET) endpoint from the Data tab.",
"tr": "Bu komponent bir SqlDataSource içinde. Sütunlara bağlanabilmesi için önce SqlDataSourceu seçip Data sekmesinden Select (GET) endpointini tanımlayın." "tr": "Bu komponent bir Form içinde. Sütunlara bağlanabilmesi için önce Formu seçip Data sekmesinden Select (GET) endpointini tanımlayın."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
@ -19791,8 +19893,8 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.EndpointNeedsKey", "key": "App.DeveloperKitComponentDesigner.EndpointNeedsKey",
"en": "This endpoint expects a key parameter. Enter a preview key value on the SqlDataSource.", "en": "This endpoint expects a key parameter. Enter a preview key value on the Form.",
"tr": "Bu endpoint bir key parametresi bekliyor. SqlDataSource üzerinde önizleme key değeri girin." "tr": "Bu endpoint bir key parametresi bekliyor. Form üzerinde önizleme key değeri girin."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
@ -19893,8 +19995,8 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.GetPostHint", "key": "App.DeveloperKitComponentDesigner.GetPostHint",
"en": "GET sources are read when the page opens; POST/PUT/DELETE sources only run when bound to a SqlDataSource command slot and are not triggered from the design screen.", "en": "GET sources are read when the page opens; POST/PUT/DELETE sources only run when bound to a Form command slot and are not triggered from the design screen.",
"tr": "GET kaynakları sayfa açılışında okunur; POST/PUT/DELETE kaynakları yalnızca bir SqlDataSource komut yuvasına bağlandığında çalışır ve tasarım ekranından tetiklenmez." "tr": "GET kaynakları sayfa açılışında okunur; POST/PUT/DELETE kaynakları yalnızca bir Form komut yuvasına bağlandığında çalışır ve tasarım ekranından tetiklenmez."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
@ -19995,8 +20097,8 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.MethodNotRunnableBindToSql", "key": "App.DeveloperKitComponentDesigner.MethodNotRunnableBindToSql",
"en": "{method} endpoints are not run from the design screen; bind them to a SqlDataSource command.", "en": "{method} endpoints are not run from the design screen; bind them to a Form command.",
"tr": "{method} endpointleri tasarım ekranından çalıştırılmaz; SqlDataSource komutuna bağlayın." "tr": "{method} endpointleri tasarım ekranından çalıştırılmaz; Form komutuna bağlayın."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
@ -20193,8 +20295,8 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.RecordFieldHint", "key": "App.DeveloperKitComponentDesigner.RecordFieldHint",
"en": "The SqlDataSource column the selection is read from and saved to.", "en": "The Form column the selection is read from and saved to.",
"tr": "Seçimin okunacağı ve kaydedileceği SqlDataSource sütunu." "tr": "Seçimin okunacağı ve kaydedileceği Form sütunu."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
@ -20349,8 +20451,8 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.SqlDataSourceRecord", "key": "App.DeveloperKitComponentDesigner.SqlDataSourceRecord",
"en": "SqlDataSource record · {field}", "en": "Form record · {field}",
"tr": "SqlDataSource kaydı · {field}" "tr": "Form kaydı · {field}"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
@ -24759,8 +24861,8 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogGroup.SqlDataSource", "key": "App.CatalogGroup.SqlDataSource",
"en": "SqlDataSource", "en": "Form",
"tr": "SqlDataSource" "tr": "Form"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
@ -24906,6 +25008,12 @@
"en": "New record / editing, busy and error state.", "en": "New record / editing, busy and error state.",
"tr": "Yeni kayıt / düzenleme, meşgul ve hata durumu." "tr": "Yeni kayıt / düzenleme, meşgul ve hata durumu."
}, },
{
"resourceName": "Platform",
"key": "App.CatalogSqlDataSource.SqlDataSourceTitleDescription",
"en": "Header text of the card wrapping the container; leave empty to hide the header. A value starting with :: is resolved as a localization key.",
"tr": "Kabı saran kartın başlık metni; boş bırakılırsa başlık gizlenir. :: ile başlayan değer dil anahtarı olarak çözümlenir."
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogSqlDataSource.SqlDataSourceTitle", "key": "App.CatalogSqlDataSource.SqlDataSourceTitle",
@ -25707,8 +25815,8 @@
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.VisualDesigner.SqlRefRequired", "key": "App.VisualDesigner.SqlRefRequired",
"en": "This rule requires a SqlDataSource ref name.", "en": "This rule requires a Form ref name.",
"tr": "Bu kural bir SqlDataSource ref adı gerektiriyor." "tr": "Bu kural bir Form ref adı gerektiriyor."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",

View file

@ -7,7 +7,14 @@ namespace Sozsoft.Platform.Domain.DeveloperKit;
public interface IDynamicEntityManager public interface IDynamicEntityManager
{ {
Task<List<object>?> GetEntityListAsync(string entityName); /// <param name="filters">
/// Column filters read from the request query string. The key is the column
/// name, optionally suffixed with an operator (<c>Name.contains</c>); every
/// column is validated against the table schema before it reaches the SQL.
/// </param>
Task<List<object>?> GetEntityListAsync(
string entityName,
IReadOnlyDictionary<string, string?>? filters = null);
Task<object?> GetEntityByIdAsync(string entityName, Guid id); Task<object?> GetEntityByIdAsync(string entityName, Guid id);
Task<object?> CreateEntityAsync(string entityName, JsonElement data); Task<object?> CreateEntityAsync(string entityName, JsonElement data);
Task<object?> UpdateEntityAsync(string entityName, Guid id, JsonElement data); Task<object?> UpdateEntityAsync(string entityName, Guid id, JsonElement data);

View file

@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Volo.Abp;
using Volo.Abp.EntityFrameworkCore; using Volo.Abp.EntityFrameworkCore;
public class DynamicEntityManager : IDynamicEntityManager public class DynamicEntityManager : IDynamicEntityManager
@ -19,17 +20,172 @@ public class DynamicEntityManager : IDynamicEntityManager
_dbContextProvider = dbContextProvider; _dbContextProvider = dbContextProvider;
} }
public async Task<List<object>?> GetEntityListAsync(string entityName) public async Task<List<object>?> GetEntityListAsync(
string entityName,
IReadOnlyDictionary<string, string?>? filters = null)
{ {
var dbContext = await _dbContextProvider.GetDbContextAsync(); var dbContext = await _dbContextProvider.GetDbContextAsync();
var isPostgreSql = IsPostgreSql(dbContext); var isPostgreSql = IsPostgreSql(dbContext);
var tableName = QuoteIdentifier(entityName, isPostgreSql); var tableName = QuoteIdentifier(entityName, isPostgreSql);
var idDeletedColumn = QuoteIdentifier("IsDeleted", isPostgreSql); var idDeletedColumn = QuoteIdentifier("IsDeleted", isPostgreSql);
var hasIsDeleted = await ColumnExistsAsync(entityName, "IsDeleted"); var hasIsDeleted = await ColumnExistsAsync(entityName, "IsDeleted");
var query = hasIsDeleted
? $"SELECT * FROM {tableName} WHERE {idDeletedColumn} = {FalseLiteral(isPostgreSql)} OR {idDeletedColumn} IS NULL" var conditions = new List<string>();
if (hasIsDeleted)
conditions.Add($"({idDeletedColumn} = {FalseLiteral(isPostgreSql)} OR {idDeletedColumn} IS NULL)");
var parameters = new List<object>();
if (filters is { Count: > 0 })
{
var columns = await GetColumnNamesAsync(entityName);
foreach (var filter in filters)
{
var condition = BuildFilterCondition(filter.Key, filter.Value, entityName, columns, isPostgreSql, parameters);
if (condition != null)
conditions.Add(condition);
}
}
var query = conditions.Count > 0
? $"SELECT * FROM {tableName} WHERE {string.Join(" AND ", conditions)}"
: $"SELECT * FROM {tableName}"; : $"SELECT * FROM {tableName}";
return await ExecuteRawQueryAsync(query); return await ExecuteRawQueryAsync(query, parameters);
}
/// <summary>
/// Query string entry to SQL predicate, or <c>null</c> when the entry carries
/// no filter (a paging parameter, or an operator whose value is empty). The
/// column is matched against the real table schema and only ever reaches the
/// SQL as a quoted identifier; the compared value is always a parameter, so
/// neither half of a filter can carry injected SQL.
/// </summary>
private static string? BuildFilterCondition(
string key,
string? rawValue,
string entityName,
IReadOnlyDictionary<string, string> columns,
bool isPostgreSql,
List<object> parameters)
{
if (string.IsNullOrWhiteSpace(key) || ReservedQueryKeys.Contains(key))
return null;
var separator = key.LastIndexOf('.');
var columnName = separator > 0 ? key[..separator] : key;
var operatorName = separator > 0 ? key[(separator + 1)..].ToLowerInvariant() : "eq";
// An unknown column is a configuration mistake, not a filter to ignore:
// dropping it silently would return the whole table as if it had matched.
if (!columns.TryGetValue(columnName, out var actualColumn))
throw new UserFriendlyException($"'{columnName}' is not a column of {entityName}.");
var column = QuoteIdentifier(actualColumn, isPostgreSql);
if (operatorName == "isnull")
return $"{column} IS NULL";
if (operatorName == "notnull")
return $"{column} IS NOT NULL";
var value = rawValue?.Trim();
if (string.IsNullOrEmpty(value))
return null;
switch (operatorName)
{
case "eq":
return $"{column} = {AddParameter(parameters, ToFilterValue(value))}";
case "ne":
return $"{column} <> {AddParameter(parameters, ToFilterValue(value))}";
case "gt":
return $"{column} > {AddParameter(parameters, ToFilterValue(value))}";
case "gte":
return $"{column} >= {AddParameter(parameters, ToFilterValue(value))}";
case "lt":
return $"{column} < {AddParameter(parameters, ToFilterValue(value))}";
case "lte":
return $"{column} <= {AddParameter(parameters, ToFilterValue(value))}";
case "contains":
return LikeCondition(column, "%" + EscapeLike(value) + "%", isPostgreSql, parameters);
case "startswith":
return LikeCondition(column, EscapeLike(value) + "%", isPostgreSql, parameters);
case "endswith":
return LikeCondition(column, "%" + EscapeLike(value), isPostgreSql, parameters);
case "in":
var entries = value
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(entry => AddParameter(parameters, ToFilterValue(entry)))
.ToList();
return entries.Count == 0 ? null : $"{column} IN ({string.Join(", ", entries)})";
default:
throw new UserFriendlyException($"'{operatorName}' is not a supported filter operator.");
}
}
/// <summary>
/// Case insensitive LIKE. SQL Server collations are case insensitive by
/// default, PostgreSQL ones are not, so it uses ILIKE there. The column is
/// cast to text so a non-string column (a Guid key) can still be searched.
/// </summary>
private static string LikeCondition(string column, string pattern, bool isPostgreSql, List<object> parameters)
{
var placeholder = AddParameter(parameters, pattern);
return isPostgreSql
? $"{column}::text ILIKE {placeholder} ESCAPE '\\'"
: $"CAST({column} AS NVARCHAR(MAX)) LIKE {placeholder} ESCAPE '\\'";
}
/// <summary>Wildcards typed by the user are matched literally, not as patterns.</summary>
private static string EscapeLike(string value)
{
return value
.Replace("\\", "\\\\")
.Replace("%", "\\%")
.Replace("_", "\\_")
.Replace("[", "\\[");
}
/// <summary>
/// A query string value is always text; typing it here is what lets a Guid
/// column be compared against a Guid rather than against its string form,
/// which the provider rejects outright.
/// </summary>
private static object ToFilterValue(string value)
{
if (Guid.TryParse(value, out var guid)) return guid;
if (bool.TryParse(value, out var boolean)) return boolean;
if (long.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var integer)) return integer;
if (decimal.TryParse(value, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var number)) return number;
if (DateTime.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind, out var date)) return date;
return value;
}
/// <summary>Query string keys that carry paging/sorting, not a column filter.</summary>
private static readonly HashSet<string> ReservedQueryKeys = new(StringComparer.OrdinalIgnoreCase)
{
"skipCount",
"maxResultCount",
"sorting",
"api-version",
"_",
};
/// <summary>Real column names of a table, keyed case insensitively.</summary>
private async Task<Dictionary<string, string>> GetColumnNamesAsync(string tableName)
{
var dbContext = await _dbContextProvider.GetDbContextAsync();
var isPostgreSql = IsPostgreSql(dbContext);
var query = isPostgreSql
? $"SELECT column_name FROM information_schema.columns WHERE table_schema NOT IN ('pg_catalog', 'information_schema') AND table_name = '{tableName.Replace("'", "''")}'"
: $"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '{tableName.Replace("'", "''")}'";
var rows = await ExecuteRawQueryAsync(query);
var columns = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
if (row is not Dictionary<string, object?> item) continue;
var name = item.Values.FirstOrDefault()?.ToString();
if (!string.IsNullOrWhiteSpace(name)) columns[name] = name;
}
return columns;
} }
public async Task<object?> GetEntityByIdAsync(string entityName, Guid id) public async Task<object?> GetEntityByIdAsync(string entityName, Guid id)
@ -167,7 +323,13 @@ public class DynamicEntityManager : IDynamicEntityManager
} }
} }
private async Task<List<object>> ExecuteRawQueryAsync(string query) /// <summary>
/// <paramref name="parameters"/> are registered by <see cref="AddParameter"/>,
/// so the query text carries <c>{0}</c> style placeholders. This path reads
/// through a raw command rather than through <c>ExecuteSqlRaw</c>, so the
/// placeholders are rewritten to real command parameters here.
/// </summary>
private async Task<List<object>> ExecuteRawQueryAsync(string query, IReadOnlyList<object>? parameters = null)
{ {
var dbContext = await _dbContextProvider.GetDbContextAsync(); var dbContext = await _dbContextProvider.GetDbContextAsync();
var connection = dbContext.Database.GetDbConnection(); var connection = dbContext.Database.GetDbConnection();
@ -177,6 +339,14 @@ public class DynamicEntityManager : IDynamicEntityManager
{ {
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
command.CommandText = query; command.CommandText = query;
for (var index = 0; index < (parameters?.Count ?? 0); index++)
{
var parameter = command.CreateParameter();
parameter.ParameterName = "p" + index;
parameter.Value = parameters![index] ?? DBNull.Value;
command.Parameters.Add(parameter);
command.CommandText = command.CommandText.Replace("{" + index + "}", "@p" + index);
}
command.Transaction = dbContext.Database.CurrentTransaction?.GetDbTransaction(); command.Transaction = dbContext.Database.CurrentTransaction?.GetDbTransaction();
using var reader = await command.ExecuteReaderAsync(); using var reader = await command.ExecuteReaderAsync();

View file

@ -0,0 +1,36 @@
{
"EntityName": "AbpUserRoles",
"GeneratedAt": "2026-08-19T09:53:39.2444186Z",
"Endpoints": [
{
"Method": "POST",
"Path": "/api/app/crudendpoint/AbpUserRoles",
"OperationType": "Create",
"IsActive": true
},
{
"Method": "DELETE",
"Path": "/api/app/crudendpoint/AbpUserRoles/{id}",
"OperationType": "Delete",
"IsActive": true
},
{
"Method": "GET",
"Path": "/api/app/crudendpoint/AbpUserRoles/{id}",
"OperationType": "GetById",
"IsActive": true
},
{
"Method": "GET",
"Path": "/api/app/crudendpoint/AbpUserRoles",
"OperationType": "GetList",
"IsActive": true
},
{
"Method": "PUT",
"Path": "/api/app/crudendpoint/AbpUserRoles/{id}",
"OperationType": "Update",
"IsActive": true
}
]
}

File diff suppressed because one or more lines are too long

View file

@ -28,9 +28,21 @@ interface PlatformViewHostProps {
view: PlatformViewName view: PlatformViewName
listFormCode: string listFormCode: string
height?: string height?: string
/**
* Filter of the hosted view, carried the same way a standalone list carries it:
* a `filter` entry holding a DevExtreme expression. The designer builds it from
* static values, the page URL or a Form record, so an embedded view is filtered
* without the list form itself being changed.
*/
searchParams?: URLSearchParams
} }
const PlatformViewHost = ({ view, listFormCode, height = '520px' }: PlatformViewHostProps) => { const PlatformViewHost = ({
view,
listFormCode,
height = '520px',
searchParams,
}: PlatformViewHostProps) => {
const [gridDto, setGridDto] = useState<GridDto>() const [gridDto, setGridDto] = useState<GridDto>()
const [error, setError] = useState('') const [error, setError] = useState('')
@ -68,15 +80,16 @@ const PlatformViewHost = ({ view, listFormCode, height = '520px' }: PlatformView
if (error) return <div className="rounded-lg bg-red-50 p-4 text-sm text-red-700">{error}</div> if (error) return <div className="rounded-lg bg-red-50 p-4 text-sm text-red-700">{error}</div>
if (!gridDto) return <Loading loading /> if (!gridDto) return <Loading loading />
const sharedProps = { listFormCode, isSubForm: true, gridDto } const sharedProps = { listFormCode, isSubForm: true, gridDto, searchParams }
const content = (() => { const content = (() => {
switch (view) { switch (view) {
case 'List': case 'List':
return ( return (
<List <List
gridDto={gridDto}
isSubForm isSubForm
gridDto={gridDto}
listFormCode={listFormCode} listFormCode={listFormCode}
searchParams={searchParams}
onRefreshGridDto={refreshGridDto} onRefreshGridDto={refreshGridDto}
/> />
) )

View file

@ -9,8 +9,10 @@ import { formatLocaleValue } from '@/utils/localeFormat'
import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa' import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa'
import { beginDesignerDragSoon, endDesignerDrag, useDesignerDrag } from './designerDrag' import { beginDesignerDragSoon, endDesignerDrag, useDesignerDrag } from './designerDrag'
import { import {
buildDesignerFilterParams,
buildSqlDefaultRecord, buildSqlDefaultRecord,
fromDesignerDate, fromDesignerDate,
getDesignerNodeFilters,
getDesignerBindingColumns, getDesignerBindingColumns,
getDesignerCollectionProperty, getDesignerCollectionProperty,
getDesignerTabSlot, getDesignerTabSlot,
@ -22,15 +24,19 @@ import {
isDesignerDateProperty, isDesignerDateProperty,
isDesignerOptionComponent, isDesignerOptionComponent,
isSqlDataSourceNode, isSqlDataSourceNode,
LEGACY_SQL_DATA_SOURCE_TYPE,
normalizeDesignerKeyList, normalizeDesignerKeyList,
readSqlDataSourceField, readSqlDataSourceField,
resolveDesignerDropdownTitle, resolveDesignerDropdownTitle,
resolveDesignerPreviewFilterValue,
resolveDesignerTabValue, resolveDesignerTabValue,
resolveSqlDataSourceRows, resolveSqlDataSourceRows,
setDesignerRecordField, setDesignerRecordField,
SQL_DATA_SOURCE_TYPE,
SQL_DEFAULT_VALUE_PROP, SQL_DEFAULT_VALUE_PROP,
toDesignerDate, toDesignerDate,
type DesignerBinding, type DesignerBinding,
type DesignerDataSourceFilter,
type DesignerNode, type DesignerNode,
type SqlDataSourceMode, type SqlDataSourceMode,
} from './types' } from './types'
@ -886,6 +892,14 @@ const SqlDataSourceView = ({
// Save follows the explicit mode, exactly like the generated runtime does. // Save follows the explicit mode, exactly like the generated runtime does.
const canSave = mode === 'new' ? canInsert : canUpdate const canSave = mode === 'new' ? canInsert : canUpdate
const designTimeTitle = translate('::App.VisualDesignerCanvas.DesignTimeNoCall') const designTimeTitle = translate('::App.VisualDesignerCanvas.DesignTimeNoCall')
// A filled Title turns the container into a Card; the header extra carries the
// key of the record on screen, which is the only identity the user can check.
const cardTitle = String(
resolveStaticLanguageKeys(node.props.title ?? '', translate) ?? '',
).trim()
const headerExtra = hasKey
? `${keyField}: ${String(keyValue)}`
: translate('::App.VisualDesignerCanvas.NewRecord')
const toolbarButton = ( const toolbarButton = (
label: string, label: string,
@ -915,7 +929,7 @@ const SqlDataSourceView = ({
</button> </button>
) )
return ( const body = (
<div <div
ref={hostRef} ref={hostRef}
className={String(node.props.className || '')} className={String(node.props.className || '')}
@ -923,7 +937,7 @@ const SqlDataSourceView = ({
> >
{interactive && ( {interactive && (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-dashed border-sky-300 bg-sky-50 px-2.5 py-1.5 text-[10px] text-sky-800 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-200"> <div className="flex flex-wrap items-center gap-2 rounded-md border border-dashed border-sky-300 bg-sky-50 px-2.5 py-1.5 text-[10px] text-sky-800 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-200">
<span className="font-semibold uppercase tracking-wider">SqlDataSource</span> <span className="font-semibold uppercase tracking-wider">{SQL_DATA_SOURCE_TYPE}</span>
<span> <span>
key: <code>{keyField}</code> key: <code>{keyField}</code>
</span> </span>
@ -1033,6 +1047,16 @@ const SqlDataSourceView = ({
)} )}
</div> </div>
) )
return (
<UiKit.Card
bodyClass="p-4"
header={cardTitle || undefined}
headerExtra={cardTitle ? headerExtra : undefined}
>
{body}
</UiKit.Card>
)
} }
/** /**
@ -1061,6 +1085,31 @@ const resolveNodeContent = (
return typeof boundChildren === 'object' ? JSON.stringify(boundChildren) : String(boundChildren) return typeof boundChildren === 'object' ? JSON.stringify(boundChildren) : String(boundChildren)
} }
/**
* Platform view in preview mode. Its filters are resolved the same way the data
* source preview resolves them a static value as typed, anything else from the
* sampling value and handed to the view as the `filter` expression it already
* understands. A required filter without a sampling value renders nothing, which
* is what the generated page does too.
*/
const PlatformViewPreview = ({ node }: { node: DesignerNode }) => {
const filterKey = JSON.stringify(getDesignerNodeFilters(node))
const params = React.useMemo(() => {
const filters = JSON.parse(filterKey) as DesignerDataSourceFilter[]
if (!filters.length) return undefined
return buildDesignerFilterParams(filters, resolveDesignerPreviewFilterValue)
}, [filterKey])
if (params === null) return null
return (
<PlatformViewHost
height={String(node.props.height || '420px')}
listFormCode={String(node.props.listFormCode || '')}
searchParams={params}
view={PLATFORM_VIEW_NAMES[node.type] || (node.type as PlatformViewName)}
/>
)
}
const renderElement = ( const renderElement = (
node: DesignerNode, node: DesignerNode,
children: React.ReactNode, children: React.ReactNode,
@ -1162,13 +1211,7 @@ const renderElement = (
} }
if (node.kind === 'platform') { if (node.kind === 'platform') {
if (interactive) return <PlatformPlaceholder node={node} /> if (interactive) return <PlatformPlaceholder node={node} />
return ( return <PlatformViewPreview node={node} />
<PlatformViewHost
height={String(node.props.height || '420px')}
listFormCode={String(node.props.listFormCode || '')}
view={PLATFORM_VIEW_NAMES[node.type] || (node.type as PlatformViewName)}
/>
)
} }
// Own content and child nodes coexist, exactly as the generator emits them: // Own content and child nodes coexist, exactly as the generator emits them:
// the text first, then the nodes dropped underneath it. // the text first, then the nodes dropped underneath it.
@ -1364,7 +1407,8 @@ const NodeView = ({
'Timeline', 'Timeline',
// Dropped components land in whichever tab is open. // Dropped components land in whichever tab is open.
'Tabs', 'Tabs',
'SqlDataSource', SQL_DATA_SOURCE_TYPE,
LEGACY_SQL_DATA_SOURCE_TYPE,
].includes(node.type) ].includes(node.type)
/* ---- Sürükleme geri bildirimi ---------------------------------- */ /* ---- Sürükleme geri bildirimi ---------------------------------- */

View file

@ -3,6 +3,7 @@ import generatedComponentProps from './generated/componentProps.json'
import { import {
DESIGNER_DATA_COMPONENT_NAMES, DESIGNER_DATA_COMPONENT_NAMES,
SQL_DATA_SOURCE_EVENTS, SQL_DATA_SOURCE_EVENTS,
LEGACY_SQL_DATA_SOURCE_TYPE,
SQL_DATA_SOURCE_TYPE, SQL_DATA_SOURCE_TYPE,
getDesignerCollectionProperty, getDesignerCollectionProperty,
isDesignerOptionComponent, isDesignerOptionComponent,
@ -326,7 +327,7 @@ refs.{{sql}}.setFields({ UnitPrice: fiyat, Stock: stok })`,
console.log(secilen, refs.{{ref}}.getLabel())`, console.log(secilen, refs.{{ref}}.getLabel())`,
}, },
{ {
group: 'SqlDataSource', group: 'App.CatalogGroup.SqlDataSource',
title: 'App.CatalogSqlDataSource.SqlDataSourceTitle', title: 'App.CatalogSqlDataSource.SqlDataSourceTitle',
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription', description: 'App.CatalogSqlDataSource.SqlDataSourceDescription',
code: `await refs.{{sql}}.save() code: `await refs.{{sql}}.save()
@ -335,7 +336,7 @@ console.log(secilen, refs.{{ref}}.getLabel())`,
// refs.{{sql}}.newRecord()`, // refs.{{sql}}.newRecord()`,
}, },
{ {
group: 'SqlDataSource', group: 'App.CatalogGroup.SqlDataSource',
title: 'App.CatalogSqlDataSource.SqlDataSourceTitle2', title: 'App.CatalogSqlDataSource.SqlDataSourceTitle2',
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription2', description: 'App.CatalogSqlDataSource.SqlDataSourceDescription2',
code: `const record = refs.{{sql}}.getRecord() code: `const record = refs.{{sql}}.getRecord()
@ -343,14 +344,14 @@ console.log(record)
refs.{{sql}}.setField('name', 'Yeni ad')`, refs.{{sql}}.setField('name', 'Yeni ad')`,
}, },
{ {
group: 'SqlDataSource', group: 'App.CatalogGroup.SqlDataSource',
title: 'App.CatalogSqlDataSource.SqlDataSourceTitle3', title: 'App.CatalogSqlDataSource.SqlDataSourceTitle3',
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription3', description: 'App.CatalogSqlDataSource.SqlDataSourceDescription3',
code: `const rows = refs.{{sql}}.getRows() code: `const rows = refs.{{sql}}.getRows()
refs.{{sql}}.goToRow(rows.length - 1)`, refs.{{sql}}.goToRow(rows.length - 1)`,
}, },
{ {
group: 'SqlDataSource', group: 'App.CatalogGroup.SqlDataSource',
title: 'App.CatalogSqlDataSource.SqlDataSourceTitle4', title: 'App.CatalogSqlDataSource.SqlDataSourceTitle4',
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription4', description: 'App.CatalogSqlDataSource.SqlDataSourceDescription4',
code: `const record = refs.{{sql}}.getRecord() code: `const record = refs.{{sql}}.getRecord()
@ -361,7 +362,7 @@ if (!record.name) {
await refs.{{sql}}.save()`, await refs.{{sql}}.save()`,
}, },
{ {
group: 'SqlDataSource', group: 'App.CatalogGroup.SqlDataSource',
title: 'App.CatalogSqlDataSource.SqlDataSourceTitle5', title: 'App.CatalogSqlDataSource.SqlDataSourceTitle5',
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription5', description: 'App.CatalogSqlDataSource.SqlDataSourceDescription5',
code: `console.log(refs.{{sql}}.getMode(), refs.{{sql}}.isBusy(), refs.{{sql}}.getError())`, code: `console.log(refs.{{sql}}.getMode(), refs.{{sql}}.isBusy(), refs.{{sql}}.getError())`,
@ -397,7 +398,11 @@ console.log(id)`,
] ]
export const getDesignerEventSnippet = (componentName: string, eventName: string) => export const getDesignerEventSnippet = (componentName: string, eventName: string) =>
DESIGNER_EVENT_SNIPPETS[componentName]?.[eventName] || // A page saved before the container was renamed still carries the old type,
// and its event scripts deserve the same starting snippet.
DESIGNER_EVENT_SNIPPETS[
componentName === LEGACY_SQL_DATA_SOURCE_TYPE ? SQL_DATA_SOURCE_TYPE : componentName
]?.[eventName] ||
`// ${componentName}.${eventName}(event)\nconsole.log(event)` `// ${componentName}.${eventName}(event)\nconsole.log(event)`
const COLLECTION_ITEMS_PROPERTY = { const COLLECTION_ITEMS_PROPERTY = {
name: 'items', name: 'items',
@ -756,6 +761,16 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription', description: 'App.CatalogSqlDataSource.SqlDataSourceDescription',
acceptsChildren: true, acceptsChildren: true,
properties: [ properties: [
// Filled in, the container renders inside a Card whose header carries this
// text; empty, the Card header is dropped. A `::` prefixed value is a
// localization key and is resolved through the active language.
{
name: 'title',
type: 'string',
value: '',
category: 'properties',
description: 'App.CatalogSqlDataSource.SqlDataSourceTitleDescription',
},
{ {
name: 'selectEndpoint', name: 'selectEndpoint',
type: 'string', type: 'string',

View file

@ -3,6 +3,8 @@ import {
getDesignerBindingColumns, getDesignerBindingColumns,
getDesignerCollectionProperty, getDesignerCollectionProperty,
getDesignerTabSlotValue, getDesignerTabSlotValue,
getDesignerDataSourceFilters,
getDesignerNodeFilters,
getSqlDataSourceEndpointId, getSqlDataSourceEndpointId,
getSqlDataSourceKeyField, getSqlDataSourceKeyField,
getSqlDataSourceKeyParam, getSqlDataSourceKeyParam,
@ -15,11 +17,15 @@ import {
isDesignerDateProperty, isDesignerDateProperty,
isDesignerOptionComponent, isDesignerOptionComponent,
isSqlDataSourceNode, isSqlDataSourceNode,
isValuelessDesignerFilter,
DESIGNER_FILTER_DX_OPERATORS,
toDesignerFilterParamName,
normalizeDesignerKeyList, normalizeDesignerKeyList,
DESIGNER_DROPDOWN_PLACEHOLDER, DESIGNER_DROPDOWN_PLACEHOLDER,
SQL_DATA_SOURCE_SLOTS, SQL_DATA_SOURCE_SLOTS,
SQL_DEFAULT_VALUE_PROP, SQL_DEFAULT_VALUE_PROP,
type DesignerDataSource, type DesignerDataSource,
type DesignerDataSourceFilter,
type DesignerDocument, type DesignerDocument,
type DesignerNode, type DesignerNode,
} from './types' } from './types'
@ -312,6 +318,8 @@ const sqlIdentifiers = (node: DesignerNode) => {
index: `sqlIndex_${identifier}`, index: `sqlIndex_${identifier}`,
setIndex: `setSqlIndex_${identifier}`, setIndex: `setSqlIndex_${identifier}`,
selectUrl: `sqlSelectUrl_${identifier}`, selectUrl: `sqlSelectUrl_${identifier}`,
filters: `sqlFilters_${identifier}`,
filterKey: `sqlFilterKey_${identifier}`,
urlKey: `sqlUrlKey_${identifier}`, urlKey: `sqlUrlKey_${identifier}`,
setField: `sqlSetField_${identifier}`, setField: `sqlSetField_${identifier}`,
reload: `sqlReload_${identifier}`, reload: `sqlReload_${identifier}`,
@ -364,6 +372,68 @@ const sqlErrorEventCall = (node: DesignerNode, action: string) => {
: '' : ''
} }
/**
* Runtime expression a filter reads its value from. A `record` filter addresses
* another SqlDataSource by its ref, which is what turns a master container and a
* detail Grid into a filtered pair without a line of script.
*/
const filterValueExpression = (
filter: DesignerDataSourceFilter,
sqlRecordByRef: Map<string, string>,
) => {
if (filter.source === 'static') return JSON.stringify(filter.value)
if (filter.source === 'query' || filter.source === 'route') {
return `readDesignerUrlValue(${JSON.stringify(filter.source)}, ${JSON.stringify(filter.value)})`
}
const [ref, ...rest] = String(filter.value).split('.')
const record = sqlRecordByRef.get(ref)
const column = rest.join('.')
// A ref that no longer exists resolves to nothing; a required filter then
// cancels the request instead of loading the whole collection.
if (!record || !column) return '""'
return `readSqlField(${record}, ${JSON.stringify(column)})`
}
/**
* Filter descriptor array `buildPlatformFilterParams` consumes. A platform view
* is filtered through a DevExtreme expression rather than through query
* parameters, so the operator is emitted in its DevExtreme form.
*/
const platformFilterEntriesExpression = (
node: DesignerNode,
sqlRecordByRef: Map<string, string>,
) => {
const filters = getDesignerNodeFilters(node)
if (!filters.length) return ''
return `[${filters
.map(
(filter) =>
`{ field: ${JSON.stringify(filter.field)}, operator: ${JSON.stringify(
DESIGNER_FILTER_DX_OPERATORS[filter.operator],
)}, kind: ${JSON.stringify(filter.operator)}, value: ${
isValuelessDesignerFilter(filter) ? '""' : filterValueExpression(filter, sqlRecordByRef)
}, required: ${Boolean(filter.required)} }`,
)
.join(', ')}]`
}
/** Filter descriptor array `buildFilteredUrl` consumes, or `''` when there is none. */
const filterEntriesExpression = (
source: DesignerDataSource | undefined,
sqlRecordByRef: Map<string, string>,
) => {
const filters = getDesignerDataSourceFilters(source)
if (!filters.length) return ''
return `[${filters
.map(
(filter) =>
`{ param: ${JSON.stringify(toDesignerFilterParamName(filter))}, value: ${
isValuelessDesignerFilter(filter) ? '"true"' : filterValueExpression(filter, sqlRecordByRef)
}, required: ${Boolean(filter.required)} }`,
)
.join(', ')}]`
}
/** `{ url, method }` literal of an endpoint slot, or `null` when it is unset. */ /** `{ url, method }` literal of an endpoint slot, or `null` when it is unset. */
const sqlSlotLiteral = ( const sqlSlotLiteral = (
node: DesignerNode, node: DesignerNode,
@ -378,12 +448,17 @@ const sqlSlotLiteral = (
return `{ url: ${JSON.stringify(source.url.trim())}, method: ${JSON.stringify(method)}, responsePath: ${JSON.stringify(source.responsePath || '')} }` return `{ url: ${JSON.stringify(source.url.trim())}, method: ${JSON.stringify(method)}, responsePath: ${JSON.stringify(source.responsePath || '')} }`
} }
const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[]) => { const sqlDataSourceHooks = (
node: DesignerNode,
dataSources: DesignerDataSource[],
sqlRecordByRef: Map<string, string>,
) => {
const names = sqlIdentifiers(node) const names = sqlIdentifiers(node)
const keyField = getSqlDataSourceKeyField(node) const keyField = getSqlDataSourceKeyField(node)
const selectSource = dataSources.find( const selectSource = dataSources.find(
(candidate) => candidate.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'), (candidate) => candidate.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'),
) )
const selectFilters = filterEntriesExpression(selectSource, sqlRecordByRef)
// The Select endpoint already owns a fetch hook, so the container reuses that // The Select endpoint already owns a fetch hook, so the container reuses that
// state instead of issuing a second request for the same URL. // state instead of issuing a second request for the same URL.
const selectData = selectSource ? `data_${safeIdentifier(selectSource.id)}` : 'null' const selectData = selectSource ? `data_${safeIdentifier(selectSource.id)}` : 'null'
@ -423,12 +498,31 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
// The Select key can come from the page URL, which is how a detail page reads // The Select key can come from the page URL, which is how a detail page reads
// /api/app/orders/{id} or /api/app/orders?id=… for a single record. // /api/app/orders/{id} or /api/app/orders?id=… for a single record.
const ${names.urlKey} = readUrlKey(${JSON.stringify(keySource)}, ${names.keyParam}) const ${names.urlKey} = readUrlKey(${JSON.stringify(keySource)}, ${names.keyParam})
const ${names.selectUrl} = React.useMemo(() => { ${
selectFilters
? ` // Column filters configured on the Select source. They are rebuilt on every
// render so a master record change reaches the URL, and the memo below is keyed
// on their serialized form rather than on each value.
const ${names.filters} = ${selectFilters}
const ${names.filterKey} = JSON.stringify(${names.filters})
`
: ''
} const ${names.selectUrl} = React.useMemo(() => {
if (!${names.slot('selectEndpoint')}) return "" if (!${names.slot('selectEndpoint')}) return ""
if (!${names.urlKey}) return ${names.slot('selectEndpoint')}.url const base = ${names.urlKey}
const bound = bindSqlUrl(${names.slot('selectEndpoint')}.url, { [${names.keyParam}]: ${names.urlKey} }, ${names.keyParam}) ? (() => {
return bound.keyBound ? bound.url : appendQueryParam(bound.url, ${names.keyParam}, ${names.urlKey}) const bound = bindSqlUrl(${names.slot('selectEndpoint')}.url, { [${names.keyParam}]: ${names.urlKey} }, ${names.keyParam})
}, [${names.urlKey}]) return bound.keyBound ? bound.url : appendQueryParam(bound.url, ${names.keyParam}, ${names.urlKey})
})()
: ${names.slot('selectEndpoint')}.url
${
selectFilters
? `// An unmet required filter yields no URL at all, which stops the Select
// from loading the unfiltered collection.
return buildFilteredUrl(base, ${names.filters})`
: 'return base'
}
}, [${names.urlKey}${selectFilters ? `, ${names.filterKey}` : ''}])
// A key in the page URL always narrows the result: a list endpoint ignores the // A key in the page URL always narrows the result: a list endpoint ignores the
// parameter server side, so the requested record is picked out here. Without a // parameter server side, so the requested record is picked out here. Without a
// key every row is kept and the navigation below takes over. // key every row is kept and the navigation below takes over.
@ -630,38 +724,49 @@ const sqlDataSourceToCode = (node: DesignerNode, level: number, itemVariable?: s
const names = sqlIdentifiers(node) const names = sqlIdentifiers(node)
const formScope: FormScope = { sourceId: node.id, setterName: names.setField } const formScope: FormScope = { sourceId: node.id, setterName: names.setField }
const children = node.children const children = node.children
.map((child) => nodeToCode(child, level + 1, itemVariable, formScope)) .map((child) => nodeToCode(child, level + 2, itemVariable, formScope))
.join('\n') .join('\n')
const className = JSON.stringify(String(node.props.className || '')) const className = JSON.stringify(String(node.props.className || ''))
const style = `{ display: "flex", flexDirection: "column", gap: ${Number(node.props.gap) || 0} }` const style = `{ display: "flex", flexDirection: "column", gap: ${Number(node.props.gap) || 0} }`
const plainButtonClass = `${SQL_TOOLBAR_BUTTON_CLASS} border border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:text-slate-300` const plainButtonClass = `${SQL_TOOLBAR_BUTTON_CLASS} border border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:text-slate-300`
// Navigation appears on its own whenever there is more than one record to walk. // Navigation appears on its own whenever there is more than one record to walk.
const navigation = ` const navigation = `
${indent(`{${names.rows}.length > 1 ? (`, level + 2)} ${indent(`{${names.rows}.length > 1 ? (`, level + 3)}
${indent('<>', level + 3)} ${indent('<>', level + 4)}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} <= 0} onClick={${names.previous}}>Önceki</button>`, level + 4)} ${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} <= 0} onClick={${names.previous}}>Önceki</button>`, level + 5)}
${indent(`<span className="text-xs text-slate-500">{\`\${${names.index} + 1} / \${${names.rows}.length}\`}</span>`, level + 4)} ${indent(`<span className="text-xs text-slate-500">{\`\${${names.index} + 1} / \${${names.rows}.length}\`}</span>`, level + 5)}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} >= ${names.rows}.length - 1} onClick={${names.next}}>Sonraki</button>`, level + 4)} ${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} >= ${names.rows}.length - 1} onClick={${names.next}}>Sonraki</button>`, level + 5)}
${indent('<span className="mx-1 h-5 w-px bg-slate-300 dark:bg-slate-700" />', level + 4)} ${indent('<span className="mx-1 h-5 w-px bg-slate-300 dark:bg-slate-700" />', level + 5)}
${indent('</>', level + 3)} ${indent('</>', level + 4)}
${indent(') : null}', level + 2)}` ${indent(') : null}', level + 3)}`
const toolbar = const toolbar =
node.props.showToolbar === false node.props.showToolbar === false
? '' ? ''
: ` : `
${indent('<div className="flex flex-wrap items-center gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">', level + 1)}${navigation} ${indent('<div className="flex flex-wrap items-center gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">', level + 2)}${navigation}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('insertEndpoint')}} onClick={${names.create}}>Yeni</button>`, level + 2)} ${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('insertEndpoint')}} onClick={${names.create}}>Yeni</button>`, level + 3)}
${indent(`<button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-sky-600 text-white hover:bg-sky-700" disabled={${names.busy} || !(${names.mode} === "new" ? ${names.slot('insertEndpoint')} : ${names.slot('updateEndpoint')})} onClick={() => { void ${names.save}() }}>Kaydet</button>`, level + 2)} ${indent(`<button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-sky-600 text-white hover:bg-sky-700" disabled={${names.busy} || !(${names.mode} === "new" ? ${names.slot('insertEndpoint')} : ${names.slot('updateEndpoint')})} onClick={() => { void ${names.save}() }}>Kaydet</button>`, level + 3)}
${indent(`<button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-red-600 text-white hover:bg-red-700" disabled={${names.busy} || !${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new"} onClick={() => { void ${names.remove}() }}>Sil</button>`, level + 2)} ${indent(`<button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-red-600 text-white hover:bg-red-700" disabled={${names.busy} || !${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new"} onClick={() => { void ${names.remove}() }}>Sil</button>`, level + 3)}
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('selectEndpoint')}} onClick={() => { void ${names.refresh}() }}>Yenile</button>`, level + 2)} ${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('selectEndpoint')}} onClick={() => { void ${names.refresh}() }}>Yenile</button>`, level + 3)}
${indent(`<span className="ml-auto text-[10px] uppercase tracking-wider text-slate-400">{${names.mode} === "new" ? "Yeni kayıt" : "Düzenleme"}</span>`, level + 2)} ${indent(`<span className="ml-auto text-[10px] uppercase tracking-wider text-slate-400">{${names.mode} === "new" ? "Yeni kayıt" : "Düzenleme"}</span>`, level + 3)}
${indent('</div>', level + 1)}` ${indent('</div>', level + 2)}`
const error = ` const error = `
${indent(`{${names.error} ? <div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-950 dark:text-red-200">{${names.error}}</div> : null}`, level + 1)}` ${indent(`{${names.error} ? <div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-950 dark:text-red-200">{${names.error}}</div> : null}`, level + 2)}`
// A filled Title turns the container into a Card header; a `::` value is a
// localization key, so `staticValueExpression` emits a translate() call for it
// and the header follows the active language. The header extra carries the key
// of the record on screen.
const title = String(node.props.title ?? '').trim()
const headerExtra = `{${names.hasKey} ? ${names.key} + ": " + readSqlField(${names.record}, ${names.key}) : ${names.mode} === "new" ? "Yeni kayıt" : ""}`
const cardStart = title
? `<UiKit.Card bodyClass="p-4" header={${staticValueExpression(title)}} headerExtra={<span className="text-xs text-slate-500 dark:text-slate-400">${headerExtra}</span>}>`
: '<UiKit.Card bodyClass="p-4">'
return `${indent(`<div ref={${names.host}} className=${className} style={${style}}>`, level)} return `${indent(cardStart, level)}
${indent(`<div ref={${names.host}} className=${className} style={${style}}>`, level + 1)}
${children}${toolbar}${error} ${children}${toolbar}${error}
${indent('</div>', level)}` ${indent('</div>', level + 1)}
${indent('</UiKit.Card>', level)}`
} }
/** /**
@ -810,6 +915,9 @@ ${indent('</UiKit.Table>', level)}`
const overflowClass = overflow ? 'overflow-auto' : 'overflow-visible' const overflowClass = overflow ? 'overflow-auto' : 'overflow-visible'
const table = `${indent('{(() => {', level + 1)} const table = `${indent('{(() => {', level + 1)}
${indent(`const ${itemsVariable} = ${collectionExpression}`, level + 2)} ${indent(`const ${itemsVariable} = ${collectionExpression}`, level + 2)}
${indent(`// Nothing loaded yet — an endpoint still in flight, or a required filter`, level + 2)}
${indent(`// holding the request back — is an empty grid, not a binding error.`, level + 2)}
${indent(`if (${itemsVariable} === null || ${itemsVariable} === undefined) return null`, level + 2)}
${indent(`if (!Array.isArray(${itemsVariable})) return <div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">Grid items bağlantısı bir koleksiyon döndürmelidir.</div>`, level + 2)} ${indent(`if (!Array.isArray(${itemsVariable})) return <div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">Grid items bağlantısı bir koleksiyon döndürmelidir.</div>`, level + 2)}
${indent(`const ${columnsVariable} = ${columnsExpression}`, level + 2)} ${indent(`const ${columnsVariable} = ${columnsExpression}`, level + 2)}
${indent(`if (!${columnsVariable}.length) return <div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-4 text-center text-xs text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">Preview için en az bir sütun seçin.</div>`, level + 2)} ${indent(`if (!${columnsVariable}.length) return <div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-4 text-center text-xs text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">Preview için en az bir sütun seçin.</div>`, level + 2)}
@ -843,20 +951,29 @@ ${indent('})()}', level + 1)}`
.join('\n') .join('\n')
const repeatedChildren = node.children.length const repeatedChildren = node.children.length
? node.children ? node.children
.map((child) => nodeToCode(child, level + 3, repeatedItemVariable, formScope)) .map((child) => nodeToCode(child, level + 4, repeatedItemVariable, formScope))
.join('\n') .join('\n')
: indent( : indent(
`<pre className="min-w-0 overflow-auto rounded-lg border border-slate-200 bg-white p-3 text-xs text-slate-800 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">{typeof ${repeatedItemVariable} === "string" ? ${repeatedItemVariable} : JSON.stringify(${repeatedItemVariable}, null, 2)}</pre>`, `<pre className="min-w-0 overflow-auto rounded-lg border border-slate-200 bg-white p-3 text-xs text-slate-800 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">{typeof ${repeatedItemVariable} === "string" ? ${repeatedItemVariable} : JSON.stringify(${repeatedItemVariable}, null, 2)}</pre>`,
level + 3, level + 4,
) )
// The collection is read once into a local: it is tested for three cases
// before it is mapped, and repeating the expression for each of them made
// the emitted line unreadable.
const repeatedRowsVariable = `rows_${safeIdentifier(node.id)}`
const children = collectionExpression const children = collectionExpression
? `${indent(`{Array.isArray(${collectionExpression}) ? ${collectionExpression}.map((${repeatedItemVariable}, index) => (`, level + 1)} ? `${indent('{(() => {', level + 1)}
${indent(`<React.Fragment key={${repeatedItemVariable}?.id ?? index}>`, level + 2)} ${indent(`const ${repeatedRowsVariable} = ${collectionExpression}`, level + 2)}
${indent(`// Nothing loaded yet — an endpoint still in flight, or a required filter`, level + 2)}
${indent(`// holding the request back — is an empty grid, not a binding error.`, level + 2)}
${indent(`if (${repeatedRowsVariable} === null || ${repeatedRowsVariable} === undefined) return null`, level + 2)}
${indent(`if (!Array.isArray(${repeatedRowsVariable})) return <div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">Grid items bağlantısı bir koleksiyon döndürmelidir.</div>`, level + 2)}
${indent(`return ${repeatedRowsVariable}.map((${repeatedItemVariable}, index) => (`, level + 2)}
${indent(`<React.Fragment key={${repeatedItemVariable}?.id ?? index}>`, level + 3)}
${repeatedChildren} ${repeatedChildren}
${indent('</React.Fragment>', level + 2)} ${indent('</React.Fragment>', level + 3)}
${indent(')) : (', level + 1)} ${indent('))', level + 2)}
${indent('<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">Grid items bağlantısı bir koleksiyon döndürmelidir.</div>', level + 2)} ${indent('})()}', level + 1)}`
${indent(')}', level + 1)}`
: staticChildren : staticChildren
return `${indent(gridStart, level)}\n${children}\n${indent('</div>', level)}` return `${indent(gridStart, level)}\n${children}\n${indent('</div>', level)}`
} }
@ -873,10 +990,15 @@ ${indent(')}', level + 1)}`
PivotView: 'Pivot', PivotView: 'Pivot',
ChartView: 'Chart', ChartView: 'Chart',
} }
return indent( const platformParams = `platformParams_${safeIdentifier(node.id)}`
`<PlatformViewHost view=${JSON.stringify(viewNames[node.type] || node.type)} listFormCode=${JSON.stringify(String(node.props.listFormCode || ''))} height=${JSON.stringify(String(node.props.height || '420px'))} />`, const hasFilters = getDesignerNodeFilters(node).length > 0
level, const host = `<PlatformViewHost view=${JSON.stringify(viewNames[node.type] || node.type)} listFormCode=${JSON.stringify(String(node.props.listFormCode || ''))} height=${JSON.stringify(String(node.props.height || '420px'))}${hasFilters ? ` searchParams={${platformParams}}` : ''} />`
) if (!hasFilters) return indent(host, level)
// A required filter without a value renders nothing: showing the unfiltered
// list instead would read as a filter that is not applied.
return `${indent(`{${platformParams} === null ? null : (`, level)}
${indent(host, level + 1)}
${indent(')}', level)}`
} }
if (node.type === 'Menu') { if (node.type === 'Menu') {
@ -1259,6 +1381,8 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
let hasDatePicker = false let hasDatePicker = false
let hasTabs = false let hasTabs = false
const sqlDataSourceNodes: DesignerNode[] = [] const sqlDataSourceNodes: DesignerNode[] = []
/** Platform views carrying their own filters; they get a params hook each. */
const platformFilterNodes: DesignerNode[] = []
/** Sources loaded by a SqlDataSource; their own mount fetch would duplicate it. */ /** Sources loaded by a SqlDataSource; their own mount fetch would duplicate it. */
const sqlManagedSelectSourceIds = new Set<string>() const sqlManagedSelectSourceIds = new Set<string>()
@ -1270,6 +1394,9 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
if (node.type === 'Grid') hasDataTable = true if (node.type === 'Grid') hasDataTable = true
if (node.type === 'Tabs') hasTabs = true if (node.type === 'Tabs') hasTabs = true
if (isDesignerDateComponent(node.type)) hasDatePicker = true if (isDesignerDateComponent(node.type)) hasDatePicker = true
if (node.kind === 'platform' && getDesignerNodeFilters(node).length) {
platformFilterNodes.push(node)
}
if (isSqlDataSourceNode(node.type)) { if (isSqlDataSourceNode(node.type)) {
sqlDataSourceNodes.push(node) sqlDataSourceNodes.push(node)
const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint') const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint')
@ -1374,6 +1501,18 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
visit(document.nodes) visit(document.nodes)
const dataSources = document.dataSources || [] const dataSources = document.dataSources || []
// Ref → record state variable of every SqlDataSource on the page. A `record`
// filter addresses its master through this map, so a detail source can be
// filtered by the row the container currently shows.
const sqlRecordByRef = new Map<string, string>(
sqlDataSourceNodes
.filter((node) => node.ref)
.map((node) => [String(node.ref), sqlIdentifiers(node).record] as const),
)
const hasDataSourceFilters = dataSources.some(
(source) => getDesignerDataSourceFilters(source).length > 0,
)
const hasFilters = hasDataSourceFilters || platformFilterNodes.length > 0
const selectHelpers = hasSelect const selectHelpers = hasSelect
? ` const toSelectOptions = (value, labelPath = "", valuePath = "") => { ? ` const toSelectOptions = (value, labelPath = "", valuePath = "") => {
if (!Array.isArray(value)) return [] if (!Array.isArray(value)) return []
@ -1588,8 +1727,81 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
} }
const toSqlErrorMessage = (error) => error?.response?.data?.error?.message || error?.response?.data?.message || error?.message || "İşlem tamamlanamadı."` const toSqlErrorMessage = (error) => error?.response?.data?.error?.message || error?.response?.data?.message || error?.message || "İşlem tamamlanamadı."`
: '' : ''
const filterHelpers = hasFilters
? ` // Value of a filter that reads the page URL: a query string parameter, or a
// path segment — the one after a segment named like the parameter, otherwise
// the last one, which is the /orders/{id} detail page shape.
const readDesignerUrlValue = (kind, name) => {
if (typeof window === "undefined") return ""
if (kind === "query") return new URLSearchParams(window.location.search).get(name) ?? ""
const segments = window.location.pathname.split("/").filter(Boolean)
if (!segments.length) return ""
const index = name ? segments.findIndex((segment) => segment.toLowerCase() === String(name).toLowerCase()) : -1
if (index >= 0 && index < segments.length - 1) return decodeURIComponent(segments[index + 1])
return decodeURIComponent(segments[segments.length - 1])
}
// Filters are appended as query parameters: \`Column\` for an equality and
// \`Column.operator\` for anything else, which is what the endpoint parses.
// Returns "" when a required filter has no value yet — the caller must skip the
// request instead of loading the unfiltered collection.
const buildFilteredUrl = (url, filters) => {
const parts = []
for (const filter of filters || []) {
const value = filter.value === undefined || filter.value === null ? "" : String(filter.value)
if (!value) {
if (filter.required) return ""
continue
}
parts.push(encodeURIComponent(filter.param) + "=" + encodeURIComponent(value))
}
if (!parts.length) return url
return url + (url.includes("?") ? "&" : "?") + parts.join("&")
}`
: ''
// A platform view is filtered through the same `filter` expression a list form
// page carries in its address, so an embedded view needs no change to the list
// form itself. Returns null when a required filter has no value yet.
const platformFilterHelpers = platformFilterNodes.length
? ` const buildPlatformFilterParams = (filters) => {
const expressions = []
for (const filter of filters || []) {
if (filter.kind === "isnull" || filter.kind === "notnull") {
expressions.push([filter.field, filter.operator, null])
continue
}
const value = filter.value === undefined || filter.value === null ? "" : String(filter.value)
if (!value) {
if (filter.required) return null
continue
}
if (filter.kind === "in") {
const entries = value.split(",").map((entry) => entry.trim()).filter(Boolean).map((entry) => [filter.field, "=", entry])
if (!entries.length) continue
expressions.push(entries.reduce((result, entry) => (result ? [result, "or", entry] : entry), null))
continue
}
expressions.push([filter.field, filter.operator, value])
}
const params = new URLSearchParams()
const merged = expressions.reduce((result, expression) => (result ? [result, "and", expression] : expression), null)
if (merged) params.set("filter", JSON.stringify(merged))
return params
}`
: ''
// Declared after the SqlDataSource hooks: a filter may read the record of the
// container the view sits next to.
const platformFilterHooks = platformFilterNodes
.map((node) => {
const identifier = safeIdentifier(node.id)
return ` const platformFilters_${identifier} = ${platformFilterEntriesExpression(node, sqlRecordByRef)}
const platformKey_${identifier} = JSON.stringify(platformFilters_${identifier})
// The views keep the params object in their own state and write back to it, so
// it must stay the same object until a filter value actually changes.
const platformParams_${identifier} = React.useMemo(() => buildPlatformFilterParams(platformFilters_${identifier}), [platformKey_${identifier}])`
})
.join('\n\n')
const sqlHooks = sqlDataSourceNodes const sqlHooks = sqlDataSourceNodes
.map((node) => sqlDataSourceHooks(node, dataSources)) .map((node) => sqlDataSourceHooks(node, dataSources, sqlRecordByRef))
.join('\n\n') .join('\n\n')
const dataHelpers = const dataHelpers =
dataSources.length || hasDataTable || sqlDataSourceNodes.length dataSources.length || hasDataTable || sqlDataSourceNodes.length
@ -1602,7 +1814,10 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
return value?.result === undefined ? undefined : readPath(value.result, path) return value?.result === undefined ? undefined : readPath(value.result, path)
}` }`
: '' : ''
const dataHooks = dataSources // The state of every source is declared before the SqlDataSource hooks, which
// write through the setters; the fetch effects come after them, because a
// filtered source may read the record a container owns.
const dataStateHooks = dataSources
.map((source) => { .map((source) => {
const identifier = safeIdentifier(source.id) const identifier = safeIdentifier(source.id)
// POST/PUT/DELETE sources are only ever invoked by a SqlDataSource command; // POST/PUT/DELETE sources are only ever invoked by a SqlDataSource command;
@ -1610,21 +1825,48 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) { if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) {
return ` const [data_${identifier}] = React.useState(null)` return ` const [data_${identifier}] = React.useState(null)`
} }
return ` const [data_${identifier}, setData_${identifier}] = React.useState(null)`
})
.join('\n')
const dataFetchHooks = dataSources
.map((source) => {
const identifier = safeIdentifier(source.id)
if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) return ''
// A GetById style URL is not fetched on mount — requesting a literal `{id}` // A GetById style URL is not fetched on mount — requesting a literal `{id}`
// is a guaranteed 400 — and neither is a source the owning SqlDataSource // is a guaranteed 400 — and neither is a source the owning SqlDataSource
// loads itself. Both still need the setter that container writes through. // loads itself, through the setter declared above.
if (hasSqlDataSourceUrlParams(source.url) || sqlManagedSelectSourceIds.has(source.id)) { if (hasSqlDataSourceUrlParams(source.url) || sqlManagedSelectSourceIds.has(source.id)) {
return ` const [data_${identifier}, setData_${identifier}] = React.useState(null)` return ''
} }
return ` const [data_${identifier}, setData_${identifier}] = React.useState(null) const entries = filterEntriesExpression(source, sqlRecordByRef)
React.useEffect(() => { // Without filters the source keeps its previous mount-only fetch, byte for
// byte; with them the effect re-runs whenever a filter value changes.
if (!entries) {
return ` React.useEffect(() => {
let active = true let active = true
apiService.fetchData({ url: ${JSON.stringify(source.url)}, method: 'GET' }).then((response) => { apiService.fetchData({ url: ${JSON.stringify(source.url)}, method: 'GET' }).then((response) => {
if (active) setData_${identifier}(getByPath(response.data, ${JSON.stringify(source.responsePath || '')})) if (active) setData_${identifier}(getByPath(response.data, ${JSON.stringify(source.responsePath || '')}))
}).catch((error) => console.error(${JSON.stringify(`${source.name} endpoint error:`)}, error)) }).catch((error) => console.error(${JSON.stringify(`${source.name} endpoint error:`)}, error))
return () => { active = false } return () => { active = false }
}, [])` }, [])`
}
return ` const filters_${identifier} = ${entries}
const filterKey_${identifier} = JSON.stringify(filters_${identifier})
React.useEffect(() => {
const url = buildFilteredUrl(${JSON.stringify(source.url)}, filters_${identifier})
// An unmet required filter leaves the component empty on purpose.
if (!url) {
setData_${identifier}(null)
return
}
let active = true
apiService.fetchData({ url, method: 'GET' }).then((response) => {
if (active) setData_${identifier}(getByPath(response.data, ${JSON.stringify(source.responsePath || '')}))
}).catch((error) => console.error(${JSON.stringify(`${source.name} endpoint error:`)}, error))
return () => { active = false }
}, [filterKey_${identifier}])`
}) })
.filter(Boolean)
.join('\n\n') .join('\n\n')
// Declared after the data/SQL/runtime state it reads, and before the handlers // Declared after the data/SQL/runtime state it reads, and before the handlers
@ -1641,5 +1883,5 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
const designerBackup = encodeURIComponent(JSON.stringify(document)) const designerBackup = encodeURIComponent(JSON.stringify(document))
return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[dataHelpers, scriptHelpers,selectHelpers, dropdownHelpers, selectValueHelpers, selectMenuHelpers, localeHelpers, dateHelpers, tabHelpers, sqlHelpers, dataHooks, sqlHooks, ...runtimeStateHooks, refRuntime, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n </>\n )\n}\n\nexport default ${componentName}\n` return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[dataHelpers, scriptHelpers,selectHelpers, dropdownHelpers, selectValueHelpers, selectMenuHelpers, localeHelpers, dateHelpers, tabHelpers, sqlHelpers, filterHelpers, platformFilterHelpers, dataStateHooks, sqlHooks, dataFetchHooks, platformFilterHooks, ...runtimeStateHooks, refRuntime, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n </>\n )\n}\n\nexport default ${componentName}\n`
} }

View file

@ -40,12 +40,246 @@ export const toDesignerHttpMethod = (value: unknown): DesignerHttpMethod => {
: 'GET' : 'GET'
} }
/**
* Comparison a filter applies to its column. `eq` is sent as a bare query
* parameter (`?RoleId=…`), every other operator carries its name as a suffix
* (`?Name.contains=…`) which is exactly what the CrudEndpoint GetList reads.
*/
export const DESIGNER_FILTER_OPERATORS = [
'eq',
'ne',
'contains',
'startswith',
'endswith',
'gt',
'gte',
'lt',
'lte',
'in',
'isnull',
'notnull',
] as const
export type DesignerFilterOperator = (typeof DESIGNER_FILTER_OPERATORS)[number]
/** Operators that compare against nothing; their value input is hidden. */
export const DESIGNER_VALUELESS_FILTER_OPERATORS: readonly DesignerFilterOperator[] = [
'isnull',
'notnull',
]
/**
* Where the filter reads its value from at runtime:
* - `static` the literal typed in the designer
* - `query` a query string parameter of the page URL (`?id=…`)
* - `route` a path segment of the page URL (last segment, or a named one)
* - `record` a column of another SqlDataSource's active record, which is what
* makes a master/detail page work without a line of code
*/
export const DESIGNER_FILTER_SOURCES = ['static', 'query', 'route', 'record'] as const
export type DesignerFilterSource = (typeof DESIGNER_FILTER_SOURCES)[number]
export interface DesignerDataSourceFilter {
id: string
/** Column the endpoint filters on. */
field: string
operator: DesignerFilterOperator
source: DesignerFilterSource
/**
* `static` the literal value · `query`/`route` the URL parameter name ·
* `record` `<sqlDataSourceRef>.<column>` of the master container.
*/
value: string
/**
* Sampling value used by the designer preview, where there is no page URL and
* no master record yet. Never emitted into the generated component.
*/
previewValue?: string
/** When set, an empty resolved value cancels the request instead of loading everything. */
required?: boolean
}
export interface DesignerDataSource { export interface DesignerDataSource {
id: string id: string
name: string name: string
method: DesignerHttpMethod method: DesignerHttpMethod
url: string url: string
responsePath: string responsePath: string
/** Query parameters applied to every call of this source; empty for most sources. */
filters?: DesignerDataSourceFilter[]
}
/**
* Blank filter row, ready to be edited in the inspector. It starts out required:
* a filter whose value is not there yet should hold the request back, because
* loading the unfiltered collection instead reads as a filter that does not work.
*/
export const createDesignerFilter = (id: string): DesignerDataSourceFilter => ({
id,
field: '',
operator: 'eq',
source: 'static',
value: '',
previewValue: '',
required: true,
})
export const toDesignerFilterOperator = (value: unknown): DesignerFilterOperator => {
const operator = String(value ?? '').trim().toLowerCase()
return (DESIGNER_FILTER_OPERATORS as readonly string[]).includes(operator)
? (operator as DesignerFilterOperator)
: 'eq'
}
export const toDesignerFilterSource = (value: unknown): DesignerFilterSource => {
const source = String(value ?? '').trim().toLowerCase()
return (DESIGNER_FILTER_SOURCES as readonly string[]).includes(source)
? (source as DesignerFilterSource)
: 'static'
}
/**
* DevExtreme operator each filter maps to. Platform views (List, Grid, Tree, )
* are filtered through a `filter` expression rather than through query
* parameters, which is the same channel a standalone list form already uses.
* `in` becomes an or-chain of equalities and the null operators compare against
* null, so both are handled by the builders rather than by this table.
*/
export const DESIGNER_FILTER_DX_OPERATORS: Record<DesignerFilterOperator, string> = {
eq: '=',
ne: '<>',
contains: 'contains',
startswith: 'startswith',
endswith: 'endswith',
gt: '>',
gte: '>=',
lt: '<',
lte: '<=',
in: '=',
isnull: '=',
notnull: '<>',
}
/** Normalized filter rows of anything that carries a `filters` list. */
export const normalizeDesignerFilters = (value: unknown): DesignerDataSourceFilter[] =>
(Array.isArray(value) ? (value as DesignerDataSourceFilter[]) : [])
.filter((filter) => filter && String(filter.field || '').trim())
.map((filter) => ({
...filter,
field: String(filter.field).trim(),
operator: toDesignerFilterOperator(filter.operator),
source: toDesignerFilterSource(filter.source),
value: String(filter.value ?? ''),
}))
/**
* Filters of a platform view. They live on the node rather than on a data source
* because the view owns its own data: it is driven by a list form code, not by an
* endpoint declared in the Data workspace.
*/
export const getDesignerNodeFilters = (node: { props: Record<string, unknown> }) =>
normalizeDesignerFilters(node.props?.filters)
/** One filter as a DevExtreme triplet, or `null` when it compares against nothing. */
const toDesignerFilterExpression = (filter: DesignerDataSourceFilter, value: string): unknown => {
if (filter.operator === 'isnull') return [filter.field, '=', null]
if (filter.operator === 'notnull') return [filter.field, '<>', null]
if (filter.operator === 'in') {
const entries = value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => [filter.field, '=', entry])
if (!entries.length) return null
return entries.reduce<unknown>((result, entry) => (result ? [result, 'or', entry] : entry), null)
}
return [filter.field, DESIGNER_FILTER_DX_OPERATORS[filter.operator], value]
}
/**
* `filter` search parameter of a platform view, or `null` when a required filter
* has no value the caller must then render nothing rather than the unfiltered
* list.
*/
export const buildDesignerFilterParams = (
filters: DesignerDataSourceFilter[],
resolveValue: (filter: DesignerDataSourceFilter) => string,
): URLSearchParams | null => {
const expressions: unknown[] = []
for (const filter of filters) {
if (!isValuelessDesignerFilter(filter)) {
const value = resolveValue(filter)
if (!value) {
if (filter.required) return null
continue
}
const expression = toDesignerFilterExpression(filter, value)
if (expression) expressions.push(expression)
continue
}
expressions.push(toDesignerFilterExpression(filter, ''))
}
const params = new URLSearchParams()
const merged = expressions.reduce<unknown>(
(result, expression) => (result ? [result, 'and', expression] : expression),
null,
)
if (merged) params.set('filter', JSON.stringify(merged))
return params
}
/** Filters of a source, normalized: an incomplete row is dropped, not emitted. */
export const getDesignerDataSourceFilters = (
source?: DesignerDataSource | null,
): DesignerDataSourceFilter[] => normalizeDesignerFilters(source?.filters)
export const isValuelessDesignerFilter = (filter: DesignerDataSourceFilter) =>
DESIGNER_VALUELESS_FILTER_OPERATORS.includes(filter.operator)
/** Query parameter name of a filter: `Column` for `eq`, `Column.op` otherwise. */
export const toDesignerFilterParamName = (filter: DesignerDataSourceFilter) =>
filter.operator === 'eq' ? filter.field : `${filter.field}.${filter.operator}`
/**
* Value the designer preview uses. Only a static filter has a real value at
* design time; everything else falls back to the sampling value, so an
* unconfigured preview filter simply drops out of the request.
*/
export const resolveDesignerPreviewFilterValue = (filter: DesignerDataSourceFilter) =>
(filter.source === 'static' ? filter.value : (filter.previewValue ?? '')).trim()
/**
* Query string of a source for the designer preview. Returns `null` when a
* required filter has no sampling value the caller must skip the request
* rather than fetch the unfiltered collection.
*/
export const buildDesignerPreviewQuery = (source: DesignerDataSource): string | null => {
const parts: string[] = []
for (const filter of getDesignerDataSourceFilters(source)) {
if (isValuelessDesignerFilter(filter)) {
parts.push(`${encodeURIComponent(toDesignerFilterParamName(filter))}=true`)
continue
}
const value = resolveDesignerPreviewFilterValue(filter)
if (!value) {
if (filter.required) return null
continue
}
parts.push(
`${encodeURIComponent(toDesignerFilterParamName(filter))}=${encodeURIComponent(value)}`,
)
}
return parts.join('&')
}
/** Source URL with the preview filters applied, or `null` when it must not run. */
export const buildDesignerPreviewUrl = (source: DesignerDataSource, url?: string) => {
const target = (url ?? source.url).trim()
const query = buildDesignerPreviewQuery(source)
if (query === null) return null
if (!query) return target
return target + (target.includes('?') ? '&' : '?') + query
} }
export interface DesignerNode { export interface DesignerNode {
@ -428,9 +662,17 @@ export const resolveDesignerResponse = (value: unknown, responsePath: string): u
* which is also the name of the record state emitted by the code generator so * which is also the name of the record state emitted by the code generator so
* the existing binding machinery keeps working unchanged. * the existing binding machinery keeps working unchanged.
*/ */
export const SQL_DATA_SOURCE_TYPE = 'SqlDataSource' export const SQL_DATA_SOURCE_TYPE = 'Form'
export const isSqlDataSourceNode = (type?: string) => type === SQL_DATA_SOURCE_TYPE /**
* The container used to be called `SqlDataSource`, and that name is what every
* page saved before the rename carries in its document. It is still accepted, so
* an existing page keeps rendering, keeps generating code and keeps its bindings.
*/
export const LEGACY_SQL_DATA_SOURCE_TYPE = 'SqlDataSource'
export const isSqlDataSourceNode = (type?: string) =>
type === SQL_DATA_SOURCE_TYPE || type === LEGACY_SQL_DATA_SOURCE_TYPE
export interface SqlDataSourceSlot { export interface SqlDataSourceSlot {
property: 'selectEndpoint' | 'insertEndpoint' | 'updateEndpoint' | 'deleteEndpoint' property: 'selectEndpoint' | 'insertEndpoint' | 'updateEndpoint' | 'deleteEndpoint'

View file

@ -35,6 +35,9 @@ import { useComponents } from '@/contexts/ComponentContext'
import type { CrudEndpoint, CustomComponent } from '@/proxy/developerKit/models' import type { CrudEndpoint, CustomComponent } from '@/proxy/developerKit/models'
import { ROUTES_ENUM } from '@/routes/route.constant' import { ROUTES_ENUM } from '@/routes/route.constant'
import apiService from '@/services/api.service' import apiService from '@/services/api.service'
import { getList } from '@/services/form.service'
import type { GridDto } from '@/proxy/form/models'
import { getListForms } from '@/services/admin/list-form.service'
import { developerKitService } from '@/services/developerKit.service' import { developerKitService } from '@/services/developerKit.service'
import { Button, Notification, toast } from '@/components/ui' import { Button, Notification, toast } from '@/components/ui'
import StyleModal from '@/components/codeLayout/StyleModal' import StyleModal from '@/components/codeLayout/StyleModal'
@ -78,6 +81,7 @@ import {
isDesignerOptionComponent, isDesignerOptionComponent,
isDesignerTabularComponent, isDesignerTabularComponent,
isSqlDataSourceNode, isSqlDataSourceNode,
LEGACY_SQL_DATA_SOURCE_TYPE,
resolveDesignerResponse, resolveDesignerResponse,
toDesignerHttpMethod, toDesignerHttpMethod,
walkDesignerNodes, walkDesignerNodes,
@ -87,11 +91,21 @@ import {
DESIGNER_HTTP_METHODS, DESIGNER_HTTP_METHODS,
SQL_DATA_SOURCE_KEY_SOURCES, SQL_DATA_SOURCE_KEY_SOURCES,
SQL_DATA_SOURCE_SLOTS, SQL_DATA_SOURCE_SLOTS,
SQL_DATA_SOURCE_TYPE,
SQL_DEFAULT_VALUE_PROP, SQL_DEFAULT_VALUE_PROP,
SQL_DEFAULT_VALUE_TOKENS, SQL_DEFAULT_VALUE_TOKENS,
type DesignerComponentDefinition, type DesignerComponentDefinition,
type DesignerBinding, type DesignerBinding,
buildDesignerPreviewUrl,
createDesignerFilter,
DESIGNER_FILTER_OPERATORS,
DESIGNER_FILTER_SOURCES,
getDesignerDataSourceFilters,
isValuelessDesignerFilter,
type DesignerDataSource, type DesignerDataSource,
type DesignerDataSourceFilter,
type DesignerFilterOperator,
type DesignerFilterSource,
type DesignerDocument, type DesignerDocument,
type DesignerHttpMethod, type DesignerHttpMethod,
type DesignerNode, type DesignerNode,
@ -634,6 +648,10 @@ const normalizeDesignerDocument = (document: DesignerDocument): DesignerDocument
} }
return { return {
...node, ...node,
// The container used to be called SqlDataSource. Renaming it on load is
// what makes an existing page show `Form` everywhere it names its own
// type — the toolbox, the layer tree and the inspector all read it.
type: node.type === LEGACY_SQL_DATA_SOURCE_TYPE ? SQL_DATA_SOURCE_TYPE : node.type,
props, props,
events, events,
bindings, bindings,
@ -1178,6 +1196,25 @@ const PropertyEditor = ({
) )
} }
/**
* Operator labels. They are symbols and SQL keywords rather than sentences, so
* they read the same in every language the designer runs in.
*/
const FILTER_OPERATOR_LABELS: Record<string, string> = {
eq: '=',
ne: '≠',
contains: '⊃ contains',
startswith: 'starts',
endswith: 'ends',
gt: '>',
gte: '≥',
lt: '<',
lte: '≤',
in: 'in (a,b)',
isnull: 'is null',
notnull: 'is not null',
}
const VisualComponentDesigner = () => { const VisualComponentDesigner = () => {
const { id } = useParams() const { id } = useParams()
const { translate } = useLocalization() const { translate } = useLocalization()
@ -1512,6 +1549,110 @@ const VisualComponentDesigner = () => {
}) })
return records return records
}, [dataSourceSamples, document.nodes]) }, [dataSourceSamples, document.nodes])
/**
* SqlDataSource containers a filter can read its value from. Only a named
* container is addressable: the generated code resolves the master through its
* ref, exactly like a script does.
*/
const sqlContainerRefs = useMemo(() => {
const refs: { ref: string; id: string }[] = []
walkDesignerNodes(document.nodes, (node) => {
if (isSqlDataSourceNode(node.type) && node.ref) refs.push({ ref: node.ref, id: node.id })
})
return refs
}, [document.nodes])
/**
* Filters belong to the data source, not to the component bound to it, so every
* component reading that endpoint is filtered the same way which is what makes
* a master/detail page a matter of configuration.
*/
/**
* List forms a platform view can be pointed at, and the fields of the one it is
* pointed at. Both are looked up on demand only a selected platform view needs
* them and cached, so switching between views costs no extra request.
*/
const [listFormCodes, setListFormCodes] = useState<string[]>([])
const [listFormFields, setListFormFields] = useState<Record<string, string[]>>({})
const platformListFormCode =
selectedNode?.kind === 'platform' ? String(selectedNode.props.listFormCode || '') : ''
useEffect(() => {
if (selectedNode?.kind !== 'platform' || listFormCodes.length) return
let active = true
void getListForms({ maxResultCount: 1000, skipCount: 0, sorting: 'listFormCode' })
.then((response) => {
if (!active) return
const codes = (response?.data?.items || [])
.map((item) => String(item.listFormCode || '').trim())
.filter(Boolean)
setListFormCodes([...new Set(codes)].sort((left, right) => left.localeCompare(right)))
})
.catch((error) => console.error('List form list could not be loaded:', error))
return () => {
active = false
}
}, [selectedNode?.kind, listFormCodes.length])
useEffect(() => {
if (!platformListFormCode || listFormFields[platformListFormCode]) return
let active = true
// `getList` swallows its own errors and can resolve with the error payload, so
// the response is read defensively rather than through its declared shape.
void Promise.resolve(getList({ listFormCode: platformListFormCode }))
.then((response: { data?: GridDto } | undefined) => {
if (!active) return
const fields = (response?.data?.columnFormats || [])
.map((column: { fieldName?: string }) => String(column.fieldName || '').trim())
.filter(Boolean)
setListFormFields((current) => ({
...current,
// The key field is filterable but is not always a listed column.
[platformListFormCode]: [
...new Set(
[String(response?.data?.gridOptions?.keyFieldName || '').trim(), ...fields].filter(
Boolean,
),
),
].sort((left, right) => left.localeCompare(right)),
}))
})
.catch((error: unknown) => console.error('List form fields could not be loaded:', error))
return () => {
active = false
}
}, [platformListFormCode, listFormFields])
/**
* Whether a property is the list form code of the selected platform view. Until
* the list arrives the property stays a text box, so a code can still be typed.
*/
const isPlatformListFormProperty = (propertyName: string) =>
propertyName === 'listFormCode' &&
selectedNode?.kind === 'platform' &&
listFormCodes.length > 0
/** Per source debounce of the filter preview request. */
const filterPreviewTimers = useRef<Record<string, number>>({})
// Mirrors of the two memos a preview filter resolves against; see
// `withPreviewFilterValues` for why they are read through refs.
const sqlPreviewRecords = useRef<Record<string, unknown>>({})
const sqlPreviewRefs = useRef<{ ref: string; id: string }[]>([])
sqlPreviewRecords.current = sqlDataSourceRecords
sqlPreviewRefs.current = sqlContainerRefs
const updateDataSourceFilters = useCallback(
(sourceId: string, next: DesignerDataSourceFilter[]) => {
commitDocument((current) => ({
...current,
dataSources: current.dataSources.map((source) =>
source.id === sourceId ? { ...source, filters: next } : source,
),
}))
},
[commitDocument],
)
const previewDataValues = useMemo( const previewDataValues = useMemo(
() => ({ ...dataSourceSamples, ...sqlDataSourceRecords }), () => ({ ...dataSourceSamples, ...sqlDataSourceRecords }),
[dataSourceSamples, sqlDataSourceRecords], [dataSourceSamples, sqlDataSourceRecords],
@ -1577,14 +1718,21 @@ const VisualComponentDesigner = () => {
) )
const selectedIsDataComponent = Boolean( const selectedIsDataComponent = Boolean(
selectedNode && selectedNode &&
(isDataCategoryComponent(selectedDefinition) || selectedIsSqlDataSource || sqlScopeNode), // A platform view is data driven too: its list form supplies the rows and
// the Data tab is where its filters are configured.
(isDataCategoryComponent(selectedDefinition) ||
selectedNode.kind === 'platform' ||
selectedIsSqlDataSource ||
sqlScopeNode),
) )
const selectedProperties = useMemo<DesignerPropertyInfo[]>(() => { const selectedProperties = useMemo<DesignerPropertyInfo[]>(() => {
if (!selectedNode) return [] if (!selectedNode) return []
const definedProperties = selectedDefinition?.properties || [] const definedProperties = selectedDefinition?.properties || []
const definedNames = new Set(definedProperties.map((property) => property.name)) const definedNames = new Set(definedProperties.map((property) => property.name))
const storedProperties = Object.entries(selectedNode.props) const storedProperties = Object.entries(selectedNode.props)
.filter(([propertyName]) => !definedNames.has(propertyName)) // `filters` is edited in the Data tab; as a raw JSON textarea here it is
// only a way to corrupt it.
.filter(([propertyName]) => !definedNames.has(propertyName) && propertyName !== 'filters')
.map(([propertyName, value]) => ({ .map(([propertyName, value]) => ({
name: propertyName, name: propertyName,
type: (Array.isArray(value) type: (Array.isArray(value)
@ -1828,6 +1976,34 @@ const VisualComponentDesigner = () => {
setDataPanelSourceId(dataSource.id) setDataPanelSourceId(dataSource.id)
} }
/**
* A `record` filter has no typed value: at design time it is resolved from the
* column of the master container's sampled record, so a master/detail pair
* previews without the designer having to repeat the key by hand. An explicit
* sampling value still wins.
*
* The latest records are read through a ref because `testDataSource` is created
* once; a closed over copy would sample against the record of an earlier run.
*/
const withPreviewFilterValues = useCallback((source: DesignerDataSource): DesignerDataSource => {
const filters = source.filters || []
if (!filters.length) return source
return {
...source,
filters: filters.map((filter) => {
if (filter.source !== 'record' || String(filter.previewValue ?? '').trim()) return filter
const [ref = '', ...columnParts] = String(filter.value || '').split('.')
const column = columnParts.join('.')
const nodeId = sqlPreviewRefs.current.find((item) => item.ref === ref)?.id
const record = nodeId ? sqlPreviewRecords.current[nodeId] : undefined
const value = column ? getDesignerValueByPath(record, column) : undefined
return value === undefined || value === null || value === ''
? filter
: { ...filter, previewValue: String(value) }
}),
}
}, [])
/** /**
* `urlOverride` carries a URL whose `{id}` was already filled in a GetById * `urlOverride` carries a URL whose `{id}` was already filled in a GetById
* endpoint cannot be sampled otherwise, and without a sample the designer has * endpoint cannot be sampled otherwise, and without a sample the designer has
@ -1857,7 +2033,28 @@ const VisualComponentDesigner = () => {
message: translate('::App.DeveloperKitComponentDesigner.SendingRequest'), message: translate('::App.DeveloperKitComponentDesigner.SendingRequest'),
}, },
})) }))
const requestUrl = (urlOverride ?? source.url).trim() // Design time preview runs with the filters applied: a static one uses its
// real value, a record one the column of the master's sampled record, and a
// URL one the sampling value typed next to it.
const previewUrl = buildDesignerPreviewUrl(withPreviewFilterValues(source), urlOverride)
if (previewUrl === null) {
// The stale sample has to go with it, otherwise the canvas keeps painting
// the rows of the last unfiltered run as if they had passed the filter.
setDataSourceSamples((current) => {
const next = { ...current }
delete next[source.id]
return next
})
setDataTestResults((current) => ({
...current,
[source.id]: {
status: 'error',
message: translate('::App.DeveloperKitComponentDesigner.RequiredFilterPreviewMissing'),
},
}))
return
}
const requestUrl = previewUrl.trim()
try { try {
if (!requestUrl) { if (!requestUrl) {
throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointUrlRequired')) throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointUrlRequired'))
@ -1867,7 +2064,7 @@ const VisualComponentDesigner = () => {
translate('::App.DeveloperKitComponentDesigner.EndpointMustStartWithApi'), translate('::App.DeveloperKitComponentDesigner.EndpointMustStartWithApi'),
) )
} }
if (!isRunnableDataSourceUrl(requestUrl)) { if (!isRunnableDataSourceUrl((urlOverride ?? source.url).trim())) {
throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointNotRunnable')) throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointNotRunnable'))
} }
if (hasSqlDataSourceUrlParams(requestUrl)) { if (hasSqlDataSourceUrlParams(requestUrl)) {
@ -3188,6 +3385,312 @@ const VisualComponentDesigner = () => {
) )
} }
/**
* Column filters of a data source. They are sent as query parameters `Column`
* for an equality, `Column.operator` for anything else which is what the CRUD
* GetList endpoint parses, so any column can be filtered, not just the key.
*
* A value comes from a static literal, from the page URL (query string or route
* segment) or from the record of another SqlDataSource, which is how a detail
* list follows the master container without a line of script.
*/
/**
* Filters of a data source: they belong to the endpoint, so every component
* bound to it is filtered the same way.
*/
const renderDataSourceFilters = (source?: DesignerDataSource | null) => {
if (!source || source.method !== 'GET') return null
const sample = dataSourceSamples[source.id]
const sampleRow = Array.isArray(sample)
? sample[0]
: sample &&
typeof sample === 'object' &&
Array.isArray((sample as Record<string, unknown>).items)
? ((sample as Record<string, unknown>).items as unknown[])[0]
: sample
const columns =
sampleRow && typeof sampleRow === 'object' && !Array.isArray(sampleRow)
? Object.keys(sampleRow as Record<string, unknown>)
: []
return renderFilterEditor({
scopeId: source.id,
filters: (source.filters || []) as DesignerDataSourceFilter[],
columns,
onChange: (next) => {
updateDataSourceFilters(source.id, next)
// The preview is only meaningful with the new filters applied, but a value
// is typed one character at a time — so the sample is refreshed once the
// typing stops instead of on every keystroke.
window.clearTimeout(filterPreviewTimers.current[source.id])
filterPreviewTimers.current[source.id] = window.setTimeout(
() => void testDataSource({ ...source, filters: next }),
600,
)
},
})
}
/**
* Filters of a platform view. The view owns its data through its list form, so
* the filters live on the node and are carried into the view as the same
* `filter` expression a list form page already reads from its address.
*/
const renderPlatformViewFilters = (node?: DesignerNode | null) => {
if (!node || node.kind !== 'platform') return null
const fields = listFormFields[String(node.props.listFormCode || '')] || []
return renderFilterEditor({
scopeId: node.id,
filters: (Array.isArray(node.props.filters)
? node.props.filters
: []) as DesignerDataSourceFilter[],
columns: fields,
// The list form knows its fields, and a name that is not one of them is
// dropped server side without a trace — so the column is picked, not typed.
strictColumns: fields.length > 0,
onChange: (next) => updateNodeProp(node.id, 'filters', next),
})
}
const renderFilterEditor = ({
scopeId,
filters,
columns,
strictColumns = false,
onChange,
}: {
scopeId: string
filters: DesignerDataSourceFilter[]
/** Column names offered as suggestions; empty when nothing was sampled. */
columns: string[]
/** The columns are the complete list, so the field is picked rather than typed. */
strictColumns?: boolean
onChange: (next: DesignerDataSourceFilter[]) => void
}) => {
const listId = `filter-columns-${scopeId}`
const writeFilters = onChange
const patchFilter = (id: string, updates: Partial<DesignerDataSourceFilter>) =>
writeFilters(filters.map((filter) => (filter.id === id ? { ...filter, ...updates } : filter)))
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-1 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.Filters')}
</span>
<button
className="rounded border border-sky-300 px-2 py-0.5 text-[10px] font-semibold text-sky-600 hover:bg-sky-50 dark:border-sky-800 dark:hover:bg-sky-950"
type="button"
onClick={() =>
writeFilters([...filters, createDesignerFilter(`filter_${createDesignerId().slice(-8)}`)])
}
>
+ {translate('::App.DeveloperKitComponentDesigner.AddFilter')}
</button>
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
{translate('::App.DeveloperKitComponentDesigner.FiltersHint')}
</p>
<datalist id={listId}>
{columns.map((column) => (
<option key={column} value={column} />
))}
</datalist>
{!filters.length && (
<p className="rounded bg-slate-50 p-2 text-[10px] leading-4 text-slate-500 dark:bg-slate-950">
{translate('::App.DeveloperKitComponentDesigner.NoFilter')}
</p>
)}
<div className="space-y-2">
{filters.map((filter) => {
const valueless = isValuelessDesignerFilter({
...filter,
operator: filter.operator as DesignerFilterOperator,
})
const [masterRef = '', ...columnParts] = String(filter.value || '').split('.')
const masterColumn = columnParts.join('.')
const masterRecord = sqlDataSourceRecords[
sqlContainerRefs.find((item) => item.ref === masterRef)?.id || ''
]
const masterColumns =
masterRecord && typeof masterRecord === 'object' && !Array.isArray(masterRecord)
? Object.keys(masterRecord as Record<string, unknown>)
: []
return (
<div
key={filter.id}
className="space-y-1.5 rounded-md border border-slate-200 bg-slate-50 p-2 dark:border-slate-800 dark:bg-slate-950"
>
<div className="flex items-center gap-1.5">
{strictColumns ? (
<select
className="min-w-0 flex-1 rounded border border-slate-300 bg-white px-1 py-1 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={filter.field || ''}
onChange={(event) => patchFilter(filter.id, { field: event.target.value })}
>
<option value="">
{translate('::App.DeveloperKitComponentDesigner.FilterColumn')}
</option>
{/* A field configured before the list form changed is kept in
the list, so switching forms does not silently blank it. */}
{(filter.field && !columns.includes(filter.field)
? [filter.field, ...columns]
: columns
).map((column) => (
<option key={column} value={column}>
{column}
</option>
))}
</select>
) : (
<input
className="min-w-0 flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[10px] dark:border-slate-700 dark:bg-slate-900"
list={listId}
placeholder={translate('::App.DeveloperKitComponentDesigner.FilterColumn')}
value={filter.field || ''}
onChange={(event) => patchFilter(filter.id, { field: event.target.value })}
/>
)}
<select
className="w-24 rounded border border-slate-300 bg-white px-1 py-1 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={filter.operator}
onChange={(event) =>
patchFilter(filter.id, {
operator: event.target.value as DesignerFilterOperator,
})
}
>
{DESIGNER_FILTER_OPERATORS.map((operator) => (
<option key={operator} value={operator}>
{FILTER_OPERATOR_LABELS[operator]}
</option>
))}
</select>
<button
className="rounded p-1 text-slate-400 hover:text-red-600"
title={translate('::App.Platform.Delete')}
type="button"
onClick={() =>
writeFilters(filters.filter((entry) => entry.id !== filter.id))
}
>
<FaTrash size={10} />
</button>
</div>
{!valueless && (
<div className="flex items-center gap-1.5">
<select
className="w-24 rounded border border-slate-300 bg-white px-1 py-1 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={filter.source}
onChange={(event) =>
patchFilter(filter.id, {
source: event.target.value as DesignerFilterSource,
value: '',
})
}
>
{DESIGNER_FILTER_SOURCES.map((item) => (
<option key={item} value={item}>
{translate(
`::App.DeveloperKitComponentDesigner.FilterSource${
item.charAt(0).toUpperCase() + item.slice(1)
}`,
)}
</option>
))}
</select>
{filter.source !== 'record' && (
<input
className="min-w-0 flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[10px] dark:border-slate-700 dark:bg-slate-900"
placeholder={translate(
filter.source === 'static'
? '::App.DeveloperKitComponentDesigner.FilterValue'
: '::App.DeveloperKitComponentDesigner.FilterUrlParam',
)}
value={filter.value || ''}
onChange={(event) => patchFilter(filter.id, { value: event.target.value })}
/>
)}
</div>
)}
{/* The master reference needs a row of its own: the container and
the column it is read from are two picks, and squeezing them
next to the source selector left neither of them usable. */}
{!valueless && filter.source === 'record' && (
<div className="flex items-center gap-1.5">
<select
className="min-w-0 flex-1 rounded border border-slate-300 bg-white px-1 py-1 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={masterRef}
onChange={(event) =>
patchFilter(filter.id, {
value: masterColumn ? `${event.target.value}.${masterColumn}` : event.target.value,
})
}
>
<option value="">
{translate('::App.DeveloperKitComponentDesigner.FilterMaster')}
</option>
{sqlContainerRefs.map((item) => (
<option key={item.ref} value={item.ref}>
{item.ref}
</option>
))}
</select>
<input
className="min-w-0 flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[10px] dark:border-slate-700 dark:bg-slate-900"
list={`${listId}-${filter.id}-master`}
placeholder={translate(
'::App.DeveloperKitComponentDesigner.FilterMasterColumn',
)}
value={masterColumn}
onChange={(event) =>
patchFilter(filter.id, { value: `${masterRef}.${event.target.value}` })
}
/>
<datalist id={`${listId}-${filter.id}-master`}>
{masterColumns.map((column) => (
<option key={column} value={column} />
))}
</datalist>
</div>
)}
{/* Both halves are needed; without the column the filter resolves
to nothing and silently stops filtering. */}
{!valueless && filter.source === 'record' && (!masterRef || !masterColumn) && (
<p className="text-[10px] leading-4 text-amber-600">
{translate('::App.DeveloperKitComponentDesigner.FilterMasterIncomplete')}
</p>
)}
{!valueless && filter.source !== 'static' && (
<input
className="w-full rounded border border-dashed border-slate-300 bg-white px-2 py-1 text-[10px] dark:border-slate-700 dark:bg-slate-900"
placeholder={translate(
'::App.DeveloperKitComponentDesigner.FilterPreviewValue',
)}
value={filter.previewValue || ''}
onChange={(event) =>
patchFilter(filter.id, { previewValue: event.target.value })
}
/>
)}
<label className="flex cursor-pointer items-center gap-1.5 text-[10px] text-slate-500">
<input
checked={Boolean(filter.required)}
className="h-3 w-3 accent-sky-600"
type="checkbox"
onChange={(event) =>
patchFilter(filter.id, { required: event.target.checked })
}
/>
{translate('::App.DeveloperKitComponentDesigner.FilterRequired')}
</label>
</div>
)
})}
</div>
</div>
)
}
/** /**
* SqlDataSource command panel: the ASP.NET style Select/Insert/Update/Delete * SqlDataSource command panel: the ASP.NET style Select/Insert/Update/Delete
* slots. Each slot only accepts a data source declared with the matching HTTP * slots. Each slot only accepts a data source declared with the matching HTTP
@ -3331,6 +3834,7 @@ const VisualComponentDesigner = () => {
onChange={(event) => updateSelectedProp('keyParamName', event.target.value)} onChange={(event) => updateSelectedProp('keyParamName', event.target.value)}
/> />
</div> </div>
{renderDataSourceFilters(selectSource)}
{selectNeedsPreviewKey && ( {selectNeedsPreviewKey && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800"> <div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400"> <div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
@ -3567,13 +4071,19 @@ const VisualComponentDesigner = () => {
const renderDataInspector = () => { const renderDataInspector = () => {
if (selectedNode?.kind === 'platform') { if (selectedNode?.kind === 'platform') {
return ( return (
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-xs leading-5 text-sky-800 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200"> <div className="space-y-3">
<strong className="block"> <div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-xs leading-5 text-sky-800 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
{translate('::App.DeveloperKitComponentDesigner.PlatformDataModel')} <strong className="block">
</strong> {translate('::App.DeveloperKitComponentDesigner.PlatformDataModel')}
{translate('::App.DeveloperKitComponentDesigner.PlatformDataModelHint', { </strong>
type: selectedNode.type, {translate('::App.DeveloperKitComponentDesigner.PlatformDataModelHint', {
})} type: selectedNode.type,
})}
</div>
{/* The list form supplies the data; the filters narrow it, so a view can
follow a Form record or a page parameter without the list form being
duplicated for every case. */}
{renderPlatformViewFilters(selectedNode)}
</div> </div>
) )
} }
@ -3681,6 +4191,8 @@ const VisualComponentDesigner = () => {
</label> </label>
</div> </div>
{!sqlScopeLocked && renderDataSourceFilters(activeDataSource)}
{isOptionDataComponent(selectedNode?.type) && selectConfiguration} {isOptionDataComponent(selectedNode?.type) && selectConfiguration}
{isTabularDataComponent(selectedNode?.type) && {isTabularDataComponent(selectedNode?.type) &&
@ -4761,9 +5273,19 @@ const VisualComponentDesigner = () => {
<PropertyEditor <PropertyEditor
componentType={selectedNode?.type} componentType={selectedNode?.type}
name={property.name} name={property.name}
type={property.type} // The list forms are known, so a platform view picks
// its code instead of having it typed from memory.
type={
isPlatformListFormProperty(property.name)
? 'select'
: property.type
}
value={propertyValue} value={propertyValue}
options={property.options} options={
isPlatformListFormProperty(property.name)
? listFormCodes
: property.options
}
onChange={(value) => updateSelectedProp(property.name, value)} onChange={(value) => updateSelectedProp(property.name, value)}
/> />
</div> </div>

View file

@ -39,6 +39,8 @@ interface ChartProps extends CommonProps, Meta {
refreshData?: () => Promise<void> refreshData?: () => Promise<void>
gridDto?: GridDto gridDto?: GridDto
refreshGridDto?: () => Promise<void> refreshGridDto?: () => Promise<void>
/** Filter carrier of an embedded chart; see the same prop on List. */
searchParams?: URLSearchParams
} }
type LookupDisplayValues = Record<string, Map<string, string>> type LookupDisplayValues = Record<string, Map<string, string>>
@ -52,7 +54,9 @@ const Chart = (props: ChartProps) => {
const isPwaMode = usePWA() const isPwaMode = usePWA()
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
const [searchParams] = useSearchParams() const [routeSearchParams] = useSearchParams()
// An embedded chart is filtered by its host; a standalone one by the page address.
const searchParams = props.searchParams ?? routeSearchParams
const [chartOptions, setChartOptions] = useState<any>() const [chartOptions, setChartOptions] = useState<any>()
const { createSelectDataSource } = useListFormCustomDataSource({} as any) const { createSelectDataSource } = useListFormCustomDataSource({} as any)
const { loadLookupDisplayValues } = useListFormColumns({ const { loadLookupDisplayValues } = useListFormColumns({
@ -101,6 +105,12 @@ const Chart = (props: ChartProps) => {
const [urlSearchParams, setUrlSearchParams] = useState<URLSearchParams>( const [urlSearchParams, setUrlSearchParams] = useState<URLSearchParams>(
searchParams ? new URLSearchParams(searchParams) : new URLSearchParams(), searchParams ? new URLSearchParams(searchParams) : new URLSearchParams(),
) )
// The state is seeded once, so a host that refilters the chart — a designer page
// whose Form record changed — would otherwise keep querying with the filter the
// chart was mounted with.
useEffect(() => {
setUrlSearchParams(new URLSearchParams(searchParams ?? undefined))
}, [searchParams])
const lookupColumns = useMemo<GridColumnData[]>( const lookupColumns = useMemo<GridColumnData[]>(
() => () =>

View file

@ -86,6 +86,12 @@ interface ListProps {
gridDto?: GridDto gridDto?: GridDto
isSubForm?: boolean isSubForm?: boolean
onRefreshGridDto?: () => Promise<void> | void onRefreshGridDto?: () => Promise<void> | void
/**
* Filter carrier of a list embedded in a page. A standalone list reads the
* page address, but an embedded one is filtered by its host the designer
* builds these from static values, the URL or a Form record.
*/
searchParams?: URLSearchParams
} }
const List: React.FC<ListProps> = ({ const List: React.FC<ListProps> = ({
@ -93,10 +99,12 @@ const List: React.FC<ListProps> = ({
gridDto: externalGridDto, gridDto: externalGridDto,
isSubForm = false, isSubForm = false,
onRefreshGridDto, onRefreshGridDto,
searchParams: externalSearchParams,
}) => { }) => {
const { listFormCode: routeListFormCode = '' } = useParams() const { listFormCode: routeListFormCode = '' } = useParams()
const listFormCode = externalListFormCode || routeListFormCode const listFormCode = externalListFormCode || routeListFormCode
const [searchParams] = useSearchParams() const [routeSearchParams] = useSearchParams()
const searchParams = externalSearchParams ?? routeSearchParams
const { translate } = useLocalization() const { translate } = useLocalization()
const mode = useStoreState((state) => state.theme.mode) const mode = useStoreState((state) => state.theme.mode)