Custom Component Default Value özelliği
This commit is contained in:
parent
3aa74318c2
commit
010594ac27
13 changed files with 555 additions and 147 deletions
|
|
@ -18,7 +18,11 @@ public interface ICrudEndpointAppService : ICrudAppService<
|
||||||
/// Aktif baslatilacak operasyonlarin virgulle ayrilmis listesi
|
/// Aktif baslatilacak operasyonlarin virgulle ayrilmis listesi
|
||||||
/// (GetList, GetById, Create, Update, Delete). Bos birakilirsa hepsi aktif olur.
|
/// (GetList, GetById, Create, Update, Delete). Bos birakilirsa hepsi aktif olur.
|
||||||
/// </param>
|
/// </param>
|
||||||
Task<PagedResultDto<CrudEndpointDto>> GenerateCrudEndpointsAsync(string entityName, string operationTypes = null);
|
/// <param name="objectType">
|
||||||
|
/// Veritabani nesnesinin turu (table, view, procedure, function). Tablo disindaki
|
||||||
|
/// nesneler icin yalnizca okuma endpoint'leri (GetList, GetById) uretilir.
|
||||||
|
/// </param>
|
||||||
|
Task<PagedResultDto<CrudEndpointDto>> GenerateCrudEndpointsAsync(string entityName, string operationTypes = null, string objectType = null);
|
||||||
Task DeleteByEntityAsync(string entityName);
|
Task DeleteByEntityAsync(string entityName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,10 @@ public class CrudEndpointGenerateAppService : CrudAppService<
|
||||||
[Authorize(PlatformConsts.AppCodes.SqlQueryManagers.CrudEndpoints)]
|
[Authorize(PlatformConsts.AppCodes.SqlQueryManagers.CrudEndpoints)]
|
||||||
public virtual async Task<PagedResultDto<CrudEndpointDto>> GenerateCrudEndpointsAsync(
|
public virtual async Task<PagedResultDto<CrudEndpointDto>> GenerateCrudEndpointsAsync(
|
||||||
string entityName,
|
string entityName,
|
||||||
[FromQuery] string operationTypes = null)
|
[FromQuery] string operationTypes = null,
|
||||||
|
[FromQuery] string objectType = null)
|
||||||
{
|
{
|
||||||
// Bes endpoint her zaman uretilir; operationTypes verilmisse yalnizca
|
// Tablolar icin bes endpoint uretilir; operationTypes verilmisse yalnizca
|
||||||
// listedekiler aktif baslar, digerleri pasif olarak kaydedilir ve
|
// listedekiler aktif baslar, digerleri pasif olarak kaydedilir ve
|
||||||
// kullanici diyalogdan sonradan acabilir. Bos/null ise hepsi aktiftir.
|
// kullanici diyalogdan sonradan acabilir. Bos/null ise hepsi aktiftir.
|
||||||
var activeOperations = operationTypes
|
var activeOperations = operationTypes
|
||||||
|
|
@ -85,6 +86,11 @@ public class CrudEndpointGenerateAppService : CrudAppService<
|
||||||
bool IsActiveOperation(string operationType) =>
|
bool IsActiveOperation(string operationType) =>
|
||||||
activeOperations == null || activeOperations.Count == 0 || activeOperations.Contains(operationType);
|
activeOperations == null || activeOperations.Count == 0 || activeOperations.Contains(operationType);
|
||||||
|
|
||||||
|
// View / stored procedure / function yazma islemlerini desteklemez;
|
||||||
|
// bu nesneler icin yalnizca okuma endpoint'leri (GetList, GetById) uretilir.
|
||||||
|
var isReadOnlyObject = !string.IsNullOrWhiteSpace(objectType)
|
||||||
|
&& !string.Equals(objectType.Trim(), "table", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// CRUD endpointleri oluştur
|
// CRUD endpointleri oluştur
|
||||||
var endpoints = new List<CrudEndpoint>
|
var endpoints = new List<CrudEndpoint>
|
||||||
{
|
{
|
||||||
|
|
@ -130,6 +136,13 @@ public class CrudEndpointGenerateAppService : CrudAppService<
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isReadOnlyObject)
|
||||||
|
{
|
||||||
|
endpoints = endpoints
|
||||||
|
.Where(x => x.OperationType is "GetList" or "GetById")
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
// Var olanları sil
|
// Var olanları sil
|
||||||
var existingEndpoints = await Repository
|
var existingEndpoints = await Repository
|
||||||
.GetListAsync(x => x.EntityName == entityName);
|
.GetListAsync(x => x.EntityName == entityName);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{
|
{
|
||||||
"Languages": [
|
"Languages": [
|
||||||
{
|
{
|
||||||
"cultureName": "ar",
|
"cultureName": "ar",
|
||||||
|
|
@ -19698,6 +19698,24 @@
|
||||||
"en": "The Data tab can only be used on components in the Data category.",
|
"en": "The Data tab can only be used on components in the Data category.",
|
||||||
"tr": "Data sekmesi yalnızca Data kategorisindeki komponentlerde kullanılabilir."
|
"tr": "Data sekmesi yalnızca Data kategorisindeki komponentlerde kullanılabilir."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.DeveloperKitComponentDesigner.DefaultValue",
|
||||||
|
"en": "Default value (new record)",
|
||||||
|
"tr": "Varsayılan değer (yeni kayıt)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.DeveloperKitComponentDesigner.DefaultValueHint",
|
||||||
|
"en": "Written to the {column} column only when a new record is opened; an existing record keeps its own value. @today and @now are resolved at that moment.",
|
||||||
|
"tr": "Yalnızca yeni kayıt açıldığında {column} sütununa yazılır; mevcut kayıt kendi değerini korur. @today ve @now o anda hesaplanır."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.DeveloperKitComponentDesigner.NoDefaultValue",
|
||||||
|
"en": "No default value",
|
||||||
|
"tr": "Varsayılan değer yok"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "App.DeveloperKitComponentDesigner.DefineAndRunSelect",
|
"key": "App.DeveloperKitComponentDesigner.DefineAndRunSelect",
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,9 @@ public class DynamicEntityManager : IDynamicEntityManager
|
||||||
var hasIsDeleted = await ColumnExistsAsync(entityName, "IsDeleted");
|
var hasIsDeleted = await ColumnExistsAsync(entityName, "IsDeleted");
|
||||||
var hasCreationTime = await ColumnExistsAsync(entityName, "CreationTime");
|
var hasCreationTime = await ColumnExistsAsync(entityName, "CreationTime");
|
||||||
|
|
||||||
|
var parameters = new List<object>();
|
||||||
var columns = new List<string> { QuoteIdentifier("Id", isPostgreSql) };
|
var columns = new List<string> { QuoteIdentifier("Id", isPostgreSql) };
|
||||||
var values = new List<string> { $"'{newId}'" };
|
var values = new List<string> { AddParameter(parameters, newId) };
|
||||||
|
|
||||||
if (hasCreationTime) { columns.Add(QuoteIdentifier("CreationTime", isPostgreSql)); values.Add(UtcNowExpression(isPostgreSql)); }
|
if (hasCreationTime) { columns.Add(QuoteIdentifier("CreationTime", isPostgreSql)); values.Add(UtcNowExpression(isPostgreSql)); }
|
||||||
if (hasIsDeleted) { columns.Add(QuoteIdentifier("IsDeleted", isPostgreSql)); values.Add(FalseLiteral(isPostgreSql)); }
|
if (hasIsDeleted) { columns.Add(QuoteIdentifier("IsDeleted", isPostgreSql)); values.Add(FalseLiteral(isPostgreSql)); }
|
||||||
|
|
@ -67,12 +68,12 @@ public class DynamicEntityManager : IDynamicEntityManager
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
columns.Add(QuoteIdentifier(prop.Name, isPostgreSql));
|
columns.Add(QuoteIdentifier(prop.Name, isPostgreSql));
|
||||||
values.Add(FormatValueForSql(prop.Value, isPostgreSql));
|
values.Add(AddParameter(parameters, ToSqlParameterValue(prop.Value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
var insertQuery = $"INSERT INTO {QuoteIdentifier(entityName, isPostgreSql)} ({string.Join(", ", columns)}) VALUES ({string.Join(", ", values)})";
|
var insertQuery = $"INSERT INTO {QuoteIdentifier(entityName, isPostgreSql)} ({string.Join(", ", columns)}) VALUES ({string.Join(", ", values)})";
|
||||||
|
|
||||||
await dbContext.Database.ExecuteSqlRawAsync(insertQuery);
|
await dbContext.Database.ExecuteSqlRawAsync(insertQuery, parameters.ToArray());
|
||||||
|
|
||||||
return await GetEntityByIdAsync(entityName, newId);
|
return await GetEntityByIdAsync(entityName, newId);
|
||||||
}
|
}
|
||||||
|
|
@ -85,6 +86,7 @@ public class DynamicEntityManager : IDynamicEntityManager
|
||||||
if (existing == null)
|
if (existing == null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
var parameters = new List<object>();
|
||||||
var setParts = new List<string>();
|
var setParts = new List<string>();
|
||||||
|
|
||||||
var hasLastModification = await ColumnExistsAsync(entityName, "LastModificationTime");
|
var hasLastModification = await ColumnExistsAsync(entityName, "LastModificationTime");
|
||||||
|
|
@ -96,12 +98,12 @@ public class DynamicEntityManager : IDynamicEntityManager
|
||||||
if (prop.NameEquals("id") || prop.NameEquals("Id"))
|
if (prop.NameEquals("id") || prop.NameEquals("Id"))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
setParts.Add($"{QuoteIdentifier(prop.Name, isPostgreSql)} = {FormatValueForSql(prop.Value, isPostgreSql)}");
|
setParts.Add($"{QuoteIdentifier(prop.Name, isPostgreSql)} = {AddParameter(parameters, ToSqlParameterValue(prop.Value))}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var updateQuery = $"UPDATE {QuoteIdentifier(entityName, isPostgreSql)} SET {string.Join(", ", setParts)} WHERE {QuoteIdentifier("Id", isPostgreSql)} = '{id}'";
|
var updateQuery = $"UPDATE {QuoteIdentifier(entityName, isPostgreSql)} SET {string.Join(", ", setParts)} WHERE {QuoteIdentifier("Id", isPostgreSql)} = {AddParameter(parameters, id)}";
|
||||||
|
|
||||||
await dbContext.Database.ExecuteSqlRawAsync(updateQuery);
|
await dbContext.Database.ExecuteSqlRawAsync(updateQuery, parameters.ToArray());
|
||||||
|
|
||||||
return await GetEntityByIdAsync(entityName, id);
|
return await GetEntityByIdAsync(entityName, id);
|
||||||
}
|
}
|
||||||
|
|
@ -196,19 +198,37 @@ public class DynamicEntityManager : IDynamicEntityManager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatValueForSql(JsonElement value, bool isPostgreSql)
|
/// <summary>
|
||||||
|
/// Registers a value and returns its placeholder. The value never reaches the
|
||||||
|
/// SQL text: <c>ExecuteSqlRaw</c> parses that text as a composite format
|
||||||
|
/// string, so an inlined JSON literal such as <c>'{}'</c> is read as a format
|
||||||
|
/// hole and throws a FormatException before the command is ever built.
|
||||||
|
/// Parameterising is also what keeps the column values injection safe.
|
||||||
|
/// </summary>
|
||||||
|
private static string AddParameter(List<object> parameters, object? value)
|
||||||
|
{
|
||||||
|
parameters.Add(value ?? DBNull.Value);
|
||||||
|
return "{" + (parameters.Count - 1).ToString(System.Globalization.CultureInfo.InvariantCulture) + "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JSON value → parameter value. An object or an array is handed over as its
|
||||||
|
/// raw JSON text, which is exactly what a JSON column (ExtraProperties) holds;
|
||||||
|
/// it used to be discarded as NULL.
|
||||||
|
/// </summary>
|
||||||
|
private static object? ToSqlParameterValue(JsonElement value)
|
||||||
{
|
{
|
||||||
return value.ValueKind switch
|
return value.ValueKind switch
|
||||||
{
|
{
|
||||||
JsonValueKind.Number when value.TryGetInt64(out var l) => l.ToString(),
|
JsonValueKind.Number when value.TryGetInt64(out var l) => l,
|
||||||
JsonValueKind.Number when value.TryGetDecimal(out var d) => d.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
JsonValueKind.Number when value.TryGetDecimal(out var d) => d,
|
||||||
JsonValueKind.True => TrueLiteral(isPostgreSql),
|
JsonValueKind.True => true,
|
||||||
JsonValueKind.False => FalseLiteral(isPostgreSql),
|
JsonValueKind.False => false,
|
||||||
JsonValueKind.Null => "NULL",
|
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
||||||
JsonValueKind.String when value.TryGetGuid(out var g) => $"'{g}'",
|
JsonValueKind.String when value.TryGetGuid(out var g) => g,
|
||||||
JsonValueKind.String when value.TryGetDateTime(out var dt) => $"'{dt:yyyy-MM-dd HH:mm:ss}'",
|
JsonValueKind.String when value.TryGetDateTime(out var dt) => dt,
|
||||||
JsonValueKind.String => $"'{value.GetString()?.Replace("'", "''")}'" ,
|
JsonValueKind.String => value.GetString(),
|
||||||
_ => "NULL",
|
_ => value.GetRawText(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -9,6 +9,7 @@ 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 {
|
||||||
|
buildSqlDefaultRecord,
|
||||||
fromDesignerDate,
|
fromDesignerDate,
|
||||||
getDesignerBindingColumns,
|
getDesignerBindingColumns,
|
||||||
getDesignerCollectionProperty,
|
getDesignerCollectionProperty,
|
||||||
|
|
@ -16,6 +17,7 @@ import {
|
||||||
getDesignerValueByPath,
|
getDesignerValueByPath,
|
||||||
getSqlDataSourceEndpointId,
|
getSqlDataSourceEndpointId,
|
||||||
getSqlDataSourceKeyField,
|
getSqlDataSourceKeyField,
|
||||||
|
getSqlFormValueProperty,
|
||||||
isDesignerDateComponent,
|
isDesignerDateComponent,
|
||||||
isDesignerDateProperty,
|
isDesignerDateProperty,
|
||||||
isDesignerOptionComponent,
|
isDesignerOptionComponent,
|
||||||
|
|
@ -25,6 +27,8 @@ import {
|
||||||
resolveDesignerDropdownTitle,
|
resolveDesignerDropdownTitle,
|
||||||
resolveDesignerTabValue,
|
resolveDesignerTabValue,
|
||||||
resolveSqlDataSourceRows,
|
resolveSqlDataSourceRows,
|
||||||
|
setDesignerRecordField,
|
||||||
|
SQL_DEFAULT_VALUE_PROP,
|
||||||
toDesignerDate,
|
toDesignerDate,
|
||||||
type DesignerBinding,
|
type DesignerBinding,
|
||||||
type DesignerNode,
|
type DesignerNode,
|
||||||
|
|
@ -465,11 +469,7 @@ const getTabOptions = (props: Record<string, unknown>) =>
|
||||||
|
|
||||||
/** Prop of a node that a SqlDataSource scope is allowed to write back to. */
|
/** Prop of a node that a SqlDataSource scope is allowed to write back to. */
|
||||||
const getFormScopeProperty = (node: DesignerNode, formScope?: DesignerFormScope) =>
|
const getFormScopeProperty = (node: DesignerNode, formScope?: DesignerFormScope) =>
|
||||||
formScope
|
getSqlFormValueProperty(node, formScope?.sourceId)
|
||||||
? (['value', 'checked'] as const).find(
|
|
||||||
(propertyName) => node.bindings?.[propertyName]?.sourceId === formScope.sourceId,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const getPreviewProps = (
|
const getPreviewProps = (
|
||||||
node: DesignerNode,
|
node: DesignerNode,
|
||||||
|
|
@ -483,6 +483,9 @@ const getPreviewProps = (
|
||||||
) => {
|
) => {
|
||||||
const props: Record<string, unknown> = {}
|
const props: Record<string, unknown> = {}
|
||||||
Object.entries(node.props).forEach(([key, value]) => {
|
Object.entries(node.props).forEach(([key, value]) => {
|
||||||
|
// Design time metadata, not a component prop: it seeds a new record instead
|
||||||
|
// of being rendered.
|
||||||
|
if (key === SQL_DEFAULT_VALUE_PROP) return
|
||||||
if (key === 'children' || key === 'html' || (value === '' && key !== 'value')) return
|
if (key === 'children' || key === 'html' || (value === '' && key !== 'value')) return
|
||||||
if (key.startsWith('on') && typeof value === 'string') return
|
if (key.startsWith('on') && typeof value === 'string') return
|
||||||
props[key] = resolveStaticLanguageKeys(value, translate)
|
props[key] = resolveStaticLanguageKeys(value, translate)
|
||||||
|
|
@ -802,24 +805,19 @@ const GridDataTablePreview = ({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deep set on a dot path, used by both the canvas and the generated runtime. */
|
/**
|
||||||
const setRecordField = (
|
* A new record starts at the top of the form: the first enabled editor takes the
|
||||||
record: Record<string, unknown>,
|
* cursor once React has painted the emptied fields.
|
||||||
path: string,
|
*/
|
||||||
value: unknown,
|
const focusFirstFormField = (host: React.RefObject<HTMLElement | null>) => {
|
||||||
): Record<string, unknown> => {
|
window.requestAnimationFrame(() => {
|
||||||
const keys = path.split('.').filter(Boolean)
|
const field = host.current?.querySelector<HTMLElement>(
|
||||||
if (!keys.length) return record
|
"input:not([type='hidden']):not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly]), select:not([disabled])",
|
||||||
const next = { ...record }
|
)
|
||||||
let target = next
|
if (!field) return
|
||||||
for (const key of keys.slice(0, -1)) {
|
field.focus()
|
||||||
const child = target[key]
|
if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) field.select()
|
||||||
const branch = child && typeof child === 'object' && !Array.isArray(child) ? { ...child } : {}
|
})
|
||||||
target[key] = branch
|
|
||||||
target = branch as Record<string, unknown>
|
|
||||||
}
|
|
||||||
target[keys[keys.length - 1]] = value
|
|
||||||
return next
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SqlDataSourceView = ({
|
const SqlDataSourceView = ({
|
||||||
|
|
@ -844,6 +842,8 @@ const SqlDataSourceView = ({
|
||||||
() => (selectId ? resolveSqlDataSourceRows(dataValues[selectId], collectionPath) : []),
|
() => (selectId ? resolveSqlDataSourceRows(dataValues[selectId], collectionPath) : []),
|
||||||
[collectionPath, dataValues, selectId],
|
[collectionPath, dataValues, selectId],
|
||||||
)
|
)
|
||||||
|
// Container element, so New can hand the cursor to the first field of the form.
|
||||||
|
const hostRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
const [rowIndex, setRowIndex] = React.useState(0)
|
const [rowIndex, setRowIndex] = React.useState(0)
|
||||||
const [mode, setMode] = React.useState<SqlDataSourceMode>('edit')
|
const [mode, setMode] = React.useState<SqlDataSourceMode>('edit')
|
||||||
// Holds the whole record while editing, so New mode can show an empty form
|
// Holds the whole record while editing, so New mode can show an empty form
|
||||||
|
|
@ -864,7 +864,7 @@ const SqlDataSourceView = ({
|
||||||
() => ({
|
() => ({
|
||||||
sourceId: node.id,
|
sourceId: node.id,
|
||||||
onFieldChange: (path, value) =>
|
onFieldChange: (path, value) =>
|
||||||
setDraft((current) => setRecordField(current ?? activeRow, path, value)),
|
setDraft((current) => setDesignerRecordField(current ?? activeRow, path, value)),
|
||||||
}),
|
}),
|
||||||
[activeRow, node.id],
|
[activeRow, node.id],
|
||||||
)
|
)
|
||||||
|
|
@ -917,6 +917,7 @@ const SqlDataSourceView = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={hostRef}
|
||||||
className={String(node.props.className || '')}
|
className={String(node.props.className || '')}
|
||||||
style={{ display: 'flex', flexDirection: 'column', gap: Number(node.props.gap) || 0 }}
|
style={{ display: 'flex', flexDirection: 'column', gap: Number(node.props.gap) || 0 }}
|
||||||
>
|
>
|
||||||
|
|
@ -996,8 +997,11 @@ const SqlDataSourceView = ({
|
||||||
'plain',
|
'plain',
|
||||||
translate('::App.VisualDesignerCanvas.SelectInsertEndpoint'),
|
translate('::App.VisualDesignerCanvas.SelectInsertEndpoint'),
|
||||||
() => {
|
() => {
|
||||||
setDraft({})
|
// New mode starts from the defaults declared on the bound children,
|
||||||
|
// exactly like the generated runtime does.
|
||||||
|
setDraft(buildSqlDefaultRecord(node))
|
||||||
setMode('new')
|
setMode('new')
|
||||||
|
focusFirstFormField(hostRef)
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
{toolbarButton(
|
{toolbarButton(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
collectSqlDefaultFields,
|
||||||
getDesignerBindingColumns,
|
getDesignerBindingColumns,
|
||||||
getDesignerCollectionProperty,
|
getDesignerCollectionProperty,
|
||||||
getDesignerTabSlotValue,
|
getDesignerTabSlotValue,
|
||||||
|
|
@ -7,6 +8,7 @@ import {
|
||||||
getSqlDataSourceKeyParam,
|
getSqlDataSourceKeyParam,
|
||||||
getSqlDataSourceKeySource,
|
getSqlDataSourceKeySource,
|
||||||
getSqlDataSourceScriptedEvents,
|
getSqlDataSourceScriptedEvents,
|
||||||
|
getSqlFormValueProperty,
|
||||||
hasSqlDataSourceUrlParams,
|
hasSqlDataSourceUrlParams,
|
||||||
isDesignerDateComponent,
|
isDesignerDateComponent,
|
||||||
isDesignerDateOnlyComponent,
|
isDesignerDateOnlyComponent,
|
||||||
|
|
@ -16,6 +18,7 @@ import {
|
||||||
normalizeDesignerKeyList,
|
normalizeDesignerKeyList,
|
||||||
DESIGNER_DROPDOWN_PLACEHOLDER,
|
DESIGNER_DROPDOWN_PLACEHOLDER,
|
||||||
SQL_DATA_SOURCE_SLOTS,
|
SQL_DATA_SOURCE_SLOTS,
|
||||||
|
SQL_DEFAULT_VALUE_PROP,
|
||||||
type DesignerDataSource,
|
type DesignerDataSource,
|
||||||
type DesignerDocument,
|
type DesignerDocument,
|
||||||
type DesignerNode,
|
type DesignerNode,
|
||||||
|
|
@ -153,9 +156,7 @@ const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => {
|
||||||
/** Prop of a node that the enclosing SqlDataSource writes the record back from. */
|
/** Prop of a node that the enclosing SqlDataSource writes the record back from. */
|
||||||
const getFormScopeField = (node: DesignerNode, formScope?: FormScope) => {
|
const getFormScopeField = (node: DesignerNode, formScope?: FormScope) => {
|
||||||
if (!formScope) return null
|
if (!formScope) return null
|
||||||
const propertyName = (['value', 'checked'] as const).find(
|
const propertyName = getSqlFormValueProperty(node, formScope.sourceId)
|
||||||
(name) => node.bindings?.[name]?.sourceId === formScope.sourceId,
|
|
||||||
)
|
|
||||||
if (!propertyName) return null
|
if (!propertyName) return null
|
||||||
return {
|
return {
|
||||||
propertyName,
|
propertyName,
|
||||||
|
|
@ -215,6 +216,8 @@ const propsToCode = (
|
||||||
([key, value]) =>
|
([key, value]) =>
|
||||||
key !== 'children' &&
|
key !== 'children' &&
|
||||||
key !== runtimeState?.propertyName &&
|
key !== runtimeState?.propertyName &&
|
||||||
|
// Seeds a new SqlDataSource record; it is not a prop of the component.
|
||||||
|
key !== SQL_DEFAULT_VALUE_PROP &&
|
||||||
!omitted.has(key) &&
|
!omitted.has(key) &&
|
||||||
!(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) &&
|
!(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) &&
|
||||||
!(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') &&
|
!(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') &&
|
||||||
|
|
@ -314,6 +317,8 @@ const sqlIdentifiers = (node: DesignerNode) => {
|
||||||
reload: `sqlReload_${identifier}`,
|
reload: `sqlReload_${identifier}`,
|
||||||
refresh: `sqlRefresh_${identifier}`,
|
refresh: `sqlRefresh_${identifier}`,
|
||||||
create: `sqlNew_${identifier}`,
|
create: `sqlNew_${identifier}`,
|
||||||
|
draft: `sqlDraft_${identifier}`,
|
||||||
|
host: `sqlHost_${identifier}`,
|
||||||
save: `sqlSave_${identifier}`,
|
save: `sqlSave_${identifier}`,
|
||||||
remove: `sqlDelete_${identifier}`,
|
remove: `sqlDelete_${identifier}`,
|
||||||
previous: `sqlPrev_${identifier}`,
|
previous: `sqlPrev_${identifier}`,
|
||||||
|
|
@ -403,7 +408,9 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
|
||||||
const [${names.mode}, ${names.setMode}] = React.useState("edit")
|
const [${names.mode}, ${names.setMode}] = React.useState("edit")
|
||||||
const [${names.index}, ${names.setIndex}] = React.useState(0)
|
const [${names.index}, ${names.setIndex}] = React.useState(0)
|
||||||
const [${names.busy}, ${names.setBusy}] = React.useState(false)
|
const [${names.busy}, ${names.setBusy}] = React.useState(false)
|
||||||
const [${names.error}, ${names.setError}] = React.useState("")${
|
const [${names.error}, ${names.setError}] = React.useState("")
|
||||||
|
// Container element, so New can hand the cursor to the first field of the form.
|
||||||
|
const ${names.host} = React.useRef(null)${
|
||||||
hasSqlEvents(node)
|
hasSqlEvents(node)
|
||||||
? `
|
? `
|
||||||
// Event scripts are declared after these hooks, so they read the record and the
|
// Event scripts are declared after these hooks, so they read the record and the
|
||||||
|
|
@ -510,12 +517,17 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
|
||||||
}, [${names.reload}])
|
}, [${names.reload}])
|
||||||
const ${names.create} = React.useCallback(${sqlEventHandler(node, 'onNewRecord') ? 'async ' : ''}() => {
|
const ${names.create} = React.useCallback(${sqlEventHandler(node, 'onNewRecord') ? 'async ' : ''}() => {
|
||||||
${names.setError}("")
|
${names.setError}("")
|
||||||
${names.setRecord}({})
|
// Defaults are resolved on every New, so an @today column carries the day the
|
||||||
|
// record was opened rather than the day the component was generated.
|
||||||
|
const ${names.draft} = buildSqlDefaults(${JSON.stringify(collectSqlDefaultFields(node))})
|
||||||
|
${names.setRecord}(${names.draft})
|
||||||
${names.setOriginal}({})
|
${names.setOriginal}({})
|
||||||
${names.setMode}("new")${
|
${names.setMode}("new")
|
||||||
|
// After the re-render that paints the empty form, not before it.
|
||||||
|
focusFirstSqlField(${names.host})${
|
||||||
sqlEventHandler(node, 'onNewRecord')
|
sqlEventHandler(node, 'onNewRecord')
|
||||||
? `
|
? `
|
||||||
${sqlEventCall(node, 'onNewRecord', '{ record: {} }')}`
|
${sqlEventCall(node, 'onNewRecord', `{ record: ${names.draft} }`)}`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -647,7 +659,7 @@ ${indent('</div>', level + 1)}`
|
||||||
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 + 1)}`
|
||||||
|
|
||||||
return `${indent(`<div className=${className} style={${style}}>`, level)}
|
return `${indent(`<div ref={${names.host}} className=${className} style={${style}}>`, level)}
|
||||||
${children}${toolbar}${error}
|
${children}${toolbar}${error}
|
||||||
${indent('</div>', level)}`
|
${indent('</div>', level)}`
|
||||||
}
|
}
|
||||||
|
|
@ -1553,6 +1565,27 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
||||||
if (!bound.keyBound && hasKey && target.method !== "POST") request.params = { [keyField]: keyValue }
|
if (!bound.keyBound && hasKey && target.method !== "POST") request.params = { [keyField]: keyValue }
|
||||||
return apiService.fetchData(request)
|
return apiService.fetchData(request)
|
||||||
}
|
}
|
||||||
|
// A new record starts at the top of the form: the first enabled editor takes the
|
||||||
|
// cursor, so a Yeni click is immediately followed by typing.
|
||||||
|
const focusFirstSqlField = (host) => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const field = host.current?.querySelector("input:not([type='hidden']):not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly]), select:not([disabled])")
|
||||||
|
if (!field) return
|
||||||
|
field.focus()
|
||||||
|
if (typeof field.select === "function") field.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Defaults declared on the bound children; only a new record starts from them.
|
||||||
|
const buildSqlDefaults = (fields) => fields.reduce((record, field) => setSqlField(record, field.path, resolveSqlDefault(field)), {})
|
||||||
|
const resolveSqlDefault = (field) => {
|
||||||
|
if (field.token !== "today" && field.token !== "now") return field.value
|
||||||
|
const now = new Date()
|
||||||
|
const pad = (value) => String(value).padStart(2, "0")
|
||||||
|
const day = now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate())
|
||||||
|
if (field.token === "today" || field.dateOnly) return day
|
||||||
|
return day + "T" + pad(now.getHours()) + ":" + pad(now.getMinutes()) + ":" + pad(now.getSeconds())
|
||||||
|
}
|
||||||
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 sqlHooks = sqlDataSourceNodes
|
const sqlHooks = sqlDataSourceNodes
|
||||||
|
|
|
||||||
|
|
@ -734,3 +734,147 @@ export const normalizeDesignerKeyList = (value: unknown): string[] => {
|
||||||
.map(String)
|
.map(String)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value a bound field starts with when the container opens a **new** record. It
|
||||||
|
* is kept on the child node — the component that owns the column binding — so it
|
||||||
|
* is edited where the field itself is configured, and it is never emitted as a
|
||||||
|
* React prop: an existing record always wins over it.
|
||||||
|
*/
|
||||||
|
export const SQL_DEFAULT_VALUE_PROP = 'sqlDefaultValue'
|
||||||
|
|
||||||
|
/** Props a SqlDataSource scope reads from and writes back to the record. */
|
||||||
|
export const SQL_FORM_VALUE_PROPERTIES = ['value', 'checked'] as const
|
||||||
|
|
||||||
|
export type SqlFormValueProperty = (typeof SQL_FORM_VALUE_PROPERTIES)[number]
|
||||||
|
|
||||||
|
/** Which prop of `node` is bound to the record of the SqlDataSource `sourceId`. */
|
||||||
|
export const getSqlFormValueProperty = (
|
||||||
|
node: DesignerNode,
|
||||||
|
sourceId?: string,
|
||||||
|
): SqlFormValueProperty | undefined =>
|
||||||
|
sourceId
|
||||||
|
? SQL_FORM_VALUE_PROPERTIES.find((name) => node.bindings?.[name]?.sourceId === sourceId)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
/** Toggles carry their record value in `checked`; every other editor in `value`. */
|
||||||
|
const SQL_CHECKED_COMPONENTS = new Set(['Checkbox', 'Switcher'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prop of `node` that holds the record value. An existing binding decides it; an
|
||||||
|
* unbound node falls back to the prop it actually exposes, so a field can be
|
||||||
|
* configured before it has ever been bound.
|
||||||
|
*/
|
||||||
|
export const getSqlRecordProperty = (
|
||||||
|
node: DesignerNode,
|
||||||
|
sourceId: string | undefined,
|
||||||
|
propertyNames: Set<string>,
|
||||||
|
): SqlFormValueProperty | undefined => {
|
||||||
|
const bound = getSqlFormValueProperty(node, sourceId)
|
||||||
|
if (bound) return bound
|
||||||
|
const preferred: SqlFormValueProperty = SQL_CHECKED_COMPONENTS.has(node.type)
|
||||||
|
? 'checked'
|
||||||
|
: 'value'
|
||||||
|
if (propertyNames.has(preferred)) return preferred
|
||||||
|
return SQL_FORM_VALUE_PROPERTIES.find((name) => propertyNames.has(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defaults that cannot be stored as a literal because they depend on the moment
|
||||||
|
* the record is opened. Written as `@today` / `@now` in the property panel.
|
||||||
|
*/
|
||||||
|
export const SQL_DEFAULT_VALUE_TOKENS = ['today', 'now'] as const
|
||||||
|
|
||||||
|
export type SqlDefaultValueToken = (typeof SQL_DEFAULT_VALUE_TOKENS)[number]
|
||||||
|
|
||||||
|
const readSqlDefaultToken = (value: unknown): SqlDefaultValueToken | undefined => {
|
||||||
|
if (typeof value !== 'string' || !value.trim().startsWith('@')) return undefined
|
||||||
|
const token = value.trim().slice(1).toLocaleLowerCase('en')
|
||||||
|
return (SQL_DEFAULT_VALUE_TOKENS as readonly string[]).includes(token)
|
||||||
|
? (token as SqlDefaultValueToken)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One record column seeded on New, in the form the generated runtime also uses. */
|
||||||
|
export interface SqlDefaultField {
|
||||||
|
path: string
|
||||||
|
value: unknown
|
||||||
|
token?: SqlDefaultValueToken
|
||||||
|
dateOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw panel input → the value that actually reaches the record column. */
|
||||||
|
const toSqlDefaultValue = (node: DesignerNode, property: SqlFormValueProperty, raw: unknown) => {
|
||||||
|
if (property === 'checked') return raw === true || raw === 'true' || raw === 1 || raw === '1'
|
||||||
|
if (typeof raw !== 'string') return raw
|
||||||
|
// A number input handed a string would send `"12"` to the endpoint, and the
|
||||||
|
// component itself renders it as text — both are wrong for a numeric column.
|
||||||
|
if (node.props?.type === 'number' && raw.trim() && !Number.isNaN(Number(raw))) {
|
||||||
|
return Number(raw)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveSqlDefaultField = (field: SqlDefaultField): unknown => {
|
||||||
|
if (!field.token) return field.value
|
||||||
|
const now = new Date()
|
||||||
|
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
return fromDesignerDate(
|
||||||
|
field.token === 'today' ? day : now,
|
||||||
|
field.token === 'today' || field.dateOnly ? 'DatePicker' : 'DateTimepicker',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every default declared by the descendants of a SqlDataSource. A nested
|
||||||
|
* container owns its own record, so its subtree is left to it.
|
||||||
|
*/
|
||||||
|
export const collectSqlDefaultFields = (node: DesignerNode): SqlDefaultField[] => {
|
||||||
|
const fields: SqlDefaultField[] = []
|
||||||
|
const visit = (children: DesignerNode[]) => {
|
||||||
|
children.forEach((child) => {
|
||||||
|
if (isSqlDataSourceNode(child.type)) return
|
||||||
|
const property = getSqlFormValueProperty(child, node.id)
|
||||||
|
const raw = child.props?.[SQL_DEFAULT_VALUE_PROP]
|
||||||
|
const path = property ? String(child.bindings[property].path ?? '').trim() : ''
|
||||||
|
if (property && path && raw !== undefined && raw !== null && raw !== '') {
|
||||||
|
const token = readSqlDefaultToken(raw)
|
||||||
|
fields.push({
|
||||||
|
path,
|
||||||
|
value: token ? null : toSqlDefaultValue(child, property, raw),
|
||||||
|
...(token ? { token, dateOnly: isDesignerDateOnlyComponent(child.type) } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
visit(child.children || [])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
visit(node.children || [])
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep set on a dot path; shared by the canvas and the generated runtime. */
|
||||||
|
export const setDesignerRecordField = (
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
path: string,
|
||||||
|
value: unknown,
|
||||||
|
): Record<string, unknown> => {
|
||||||
|
const keys = path.split('.').filter(Boolean)
|
||||||
|
if (!keys.length) return record
|
||||||
|
const next = { ...record }
|
||||||
|
let target = next
|
||||||
|
for (const key of keys.slice(0, -1)) {
|
||||||
|
const child = target[key]
|
||||||
|
const branch = child && typeof child === 'object' && !Array.isArray(child) ? { ...child } : {}
|
||||||
|
target[key] = branch
|
||||||
|
target = branch as Record<string, unknown>
|
||||||
|
}
|
||||||
|
target[keys[keys.length - 1]] = value
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a SqlDataSource opens New mode with. Empty when nothing declares one. */
|
||||||
|
export const buildSqlDefaultRecord = (node: DesignerNode): Record<string, unknown> =>
|
||||||
|
collectSqlDefaultFields(node).reduce<Record<string, unknown>>(
|
||||||
|
(record, field) => setDesignerRecordField(record, field.path, resolveSqlDefaultField(field)),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -78,15 +78,22 @@ class DeveloperKitService {
|
||||||
/**
|
/**
|
||||||
* @param operationTypes Aktif baslatilacak operasyonlar; verilmezse hepsi aktif uretilir.
|
* @param operationTypes Aktif baslatilacak operasyonlar; verilmezse hepsi aktif uretilir.
|
||||||
* Listede olmayanlar pasif olarak kaydedilir.
|
* Listede olmayanlar pasif olarak kaydedilir.
|
||||||
|
* @param objectType Veritabani nesnesinin turu. Tablo disindaki nesneler icin
|
||||||
|
* (view/procedure/function) yalnizca GetList ve GetById uretilir.
|
||||||
*/
|
*/
|
||||||
async generateCrudEndpoints(
|
async generateCrudEndpoints(
|
||||||
entityName: string,
|
entityName: string,
|
||||||
operationTypes?: string[],
|
operationTypes?: string[],
|
||||||
|
objectType?: string,
|
||||||
): Promise<PagedResultDto<CrudEndpoint>> {
|
): Promise<PagedResultDto<CrudEndpoint>> {
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (operationTypes?.length) params.operationTypes = operationTypes.join(',')
|
||||||
|
if (objectType) params.objectType = objectType
|
||||||
|
|
||||||
const response = await apiService.fetchData<PagedResultDto<CrudEndpoint>>({
|
const response = await apiService.fetchData<PagedResultDto<CrudEndpoint>>({
|
||||||
url: `/api/app/crud-endpoint-generate/generate-crud-endpoints/${entityName}`,
|
url: `/api/app/crud-endpoint-generate/generate-crud-endpoints/${entityName}`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
params: operationTypes?.length ? { operationTypes: operationTypes.join(',') } : undefined,
|
params: Object.keys(params).length > 0 ? params : undefined,
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,15 @@ export const CRUD_OPERATION_TYPES = [
|
||||||
|
|
||||||
export type CrudOperationType = (typeof CRUD_OPERATION_TYPES)[number]
|
export type CrudOperationType = (typeof CRUD_OPERATION_TYPES)[number]
|
||||||
|
|
||||||
|
/** Endpoint uretilebilen SQL nesne turleri. */
|
||||||
|
export type CrudObjectType = 'table' | 'view'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View yazma islemlerini desteklemez; bu nesneler icin yalnizca okuma
|
||||||
|
* endpoint'leri uretilir. Backend de ayni kisiti objectType parametresiyle uygular.
|
||||||
|
*/
|
||||||
|
export const READONLY_OPERATION_TYPES: CrudOperationType[] = ['GetList', 'GetById']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Endpoint'lerde kullanilan entity adini turetir.
|
* Endpoint'lerde kullanilan entity adini turetir.
|
||||||
*
|
*
|
||||||
|
|
@ -165,9 +174,11 @@ export const CrudEndpointBadges = ({ endpoints, buildTitle, onClick }: CrudEndpo
|
||||||
|
|
||||||
interface CrudEndpointDialogProps {
|
interface CrudEndpointDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
/** Endpoint'leri yonetilecek tablo; entity adi tablo adindan turetilir. */
|
/** Endpoint'leri yonetilecek tablo/view; entity adi nesne adindan turetilir. */
|
||||||
tableName: string
|
tableName: string
|
||||||
schemaName?: string
|
schemaName?: string
|
||||||
|
/** Nesne turu; 'view' verildiginde yalnizca GetList/GetById uretilir. */
|
||||||
|
objectType?: CrudObjectType
|
||||||
/** Verilirse istek govdesi tablonun gercek kolonlarindan uretilir. */
|
/** Verilirse istek govdesi tablonun gercek kolonlarindan uretilir. */
|
||||||
dataSourceCode?: string | null
|
dataSourceCode?: string | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
|
@ -247,6 +258,7 @@ const CrudEndpointDialog = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
tableName,
|
tableName,
|
||||||
schemaName,
|
schemaName,
|
||||||
|
objectType = 'table',
|
||||||
dataSourceCode,
|
dataSourceCode,
|
||||||
onClose,
|
onClose,
|
||||||
onChanged,
|
onChanged,
|
||||||
|
|
@ -255,6 +267,7 @@ const CrudEndpointDialog = ({
|
||||||
const { checkPermission } = usePermission()
|
const { checkPermission } = usePermission()
|
||||||
const canManageCrudEndpoints = checkPermission(CRUD_ENDPOINT_PERMISSION)
|
const canManageCrudEndpoints = checkPermission(CRUD_ENDPOINT_PERMISSION)
|
||||||
const entityName = toEntityName(tableName)
|
const entityName = toEntityName(tableName)
|
||||||
|
const isReadOnlyObject = objectType === 'view'
|
||||||
|
|
||||||
const [endpoints, setEndpoints] = useState<CrudEndpoint[]>([])
|
const [endpoints, setEndpoints] = useState<CrudEndpoint[]>([])
|
||||||
const [columns, setColumns] = useState<DatabaseColumnDto[]>([])
|
const [columns, setColumns] = useState<DatabaseColumnDto[]>([])
|
||||||
|
|
@ -323,7 +336,7 @@ const CrudEndpointDialog = ({
|
||||||
|
|
||||||
// Govde sablonu icin tablo kolonlari; datasource bilinmiyorsa genel sablon kullanilir.
|
// Govde sablonu icin tablo kolonlari; datasource bilinmiyorsa genel sablon kullanilir.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || !dataSourceCode) {
|
if (!isOpen || !dataSourceCode || isReadOnlyObject) {
|
||||||
setColumns([])
|
setColumns([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -342,7 +355,7 @@ const CrudEndpointDialog = ({
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [isOpen, dataSourceCode, schemaName, tableName])
|
}, [isOpen, dataSourceCode, schemaName, tableName, isReadOnlyObject])
|
||||||
|
|
||||||
const sampleBodies = useMemo(() => {
|
const sampleBodies = useMemo(() => {
|
||||||
if (columns.length === 0) return { create: GENERIC_BODY, update: GENERIC_BODY }
|
if (columns.length === 0) return { create: GENERIC_BODY, update: GENERIC_BODY }
|
||||||
|
|
@ -393,7 +406,7 @@ const CrudEndpointDialog = ({
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
setGenerating(true)
|
setGenerating(true)
|
||||||
try {
|
try {
|
||||||
const result = await developerKitService.generateCrudEndpoints(entityName)
|
const result = await developerKitService.generateCrudEndpoints(entityName, undefined, objectType)
|
||||||
publish(result.items || [])
|
publish(result.items || [])
|
||||||
pushSuccess(translate('::App.Platform.OperationCompleted'))
|
pushSuccess(translate('::App.Platform.OperationCompleted'))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -541,7 +554,17 @@ const CrudEndpointDialog = ({
|
||||||
{schemaName ? `${schemaName}.${tableName}` : tableName}
|
{schemaName ? `${schemaName}.${tableName}` : tableName}
|
||||||
{' — '}
|
{' — '}
|
||||||
{entityName}
|
{entityName}
|
||||||
|
{isReadOnlyObject && (
|
||||||
|
<span className="ml-2 rounded border border-purple-200 bg-purple-50 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-purple-700 dark:border-purple-800 dark:bg-purple-900/30 dark:text-purple-300">
|
||||||
|
view
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
{isReadOnlyObject && (
|
||||||
|
<p className="mt-1 mb-0 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{READONLY_OPERATION_TYPES.join(' / ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-[180px] max-h-[55vh] overflow-y-auto">
|
<div className="min-h-[180px] max-h-[55vh] overflow-y-auto">
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,12 @@ import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
import { Notification, toast } from '@/components/ui'
|
import { Notification, toast } from '@/components/ui'
|
||||||
import { CrudEndpointBadges, getErrorMessage, toEntityName } from './CrudEndpointDialog'
|
import {
|
||||||
|
CrudEndpointBadges,
|
||||||
|
getErrorMessage,
|
||||||
|
toEntityName,
|
||||||
|
type CrudObjectType,
|
||||||
|
} from './CrudEndpointDialog'
|
||||||
import { developerKitService } from '@/services/developerKit.service'
|
import { developerKitService } from '@/services/developerKit.service'
|
||||||
|
|
||||||
type FolderKey = 'tables' | 'views' | 'procedures' | 'functions'
|
type FolderKey = 'tables' | 'views' | 'procedures' | 'functions'
|
||||||
|
|
@ -44,10 +49,14 @@ interface SqlObjectExplorerProps {
|
||||||
onDesignTable?: (schemaName: string, tableName: string) => void
|
onDesignTable?: (schemaName: string, tableName: string) => void
|
||||||
onNewTable?: () => void
|
onNewTable?: () => void
|
||||||
onSelectedObjectsChange?: (objects: SqlExplorerSelectedObject[]) => void
|
onSelectedObjectsChange?: (objects: SqlExplorerSelectedObject[]) => void
|
||||||
/** Entity adina gore acilmis CRUD endpoint'leri; tablo satirlarinda ikon olarak gosterilir. */
|
/** Entity adina gore acilmis CRUD endpoint'leri; tablo/view satirlarinda ikon olarak gosterilir. */
|
||||||
crudEndpointsByEntity?: Map<string, CrudEndpoint[]>
|
crudEndpointsByEntity?: Map<string, CrudEndpoint[]>
|
||||||
onManageCrudEndpoints?: (schemaName: string, tableName: string) => void
|
onManageCrudEndpoints?: (
|
||||||
/** Secili tablolar icin CRUD endpoint uretir; verilmezse buton gosterilmez. */
|
schemaName: string,
|
||||||
|
objectName: string,
|
||||||
|
objectType: CrudObjectType,
|
||||||
|
) => void
|
||||||
|
/** Secili tablo/view'ler icin CRUD endpoint uretir; verilmezse buton gosterilmez. */
|
||||||
onGenerateCrudEndpoints?: () => void
|
onGenerateCrudEndpoints?: () => void
|
||||||
isGeneratingCrudEndpoints?: boolean
|
isGeneratingCrudEndpoints?: boolean
|
||||||
/** Kopyalama / dogrudan SQL diyalogunu acar; verilmezse buton gosterilmez. */
|
/** Kopyalama / dogrudan SQL diyalogunu acar; verilmezse buton gosterilmez. */
|
||||||
|
|
@ -378,15 +387,14 @@ const SqlObjectExplorer = ({
|
||||||
// Non-blocking: object drop succeeded even if seed file cleanup fails.
|
// Non-blocking: object drop succeeded even if seed file cleanup fails.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tablo dusurulduyse ona ait CRUD endpoint'leri de anlamsiz kalir; kayitlar
|
// Tablo/view dusurulduyse ona ait CRUD endpoint'leri de anlamsiz kalir; kayitlar
|
||||||
// silinince backend crud/{EntityName}.json seed dosyasini da kaldirir.
|
// silinince backend crud/{EntityName}.json seed dosyasini da kaldirir.
|
||||||
const droppedTables = dropped.filter((node) => node.folder === 'tables')
|
const droppedCrudObjects = dropped.filter((node) => getCrudObjectType(node) !== null)
|
||||||
if (droppedTables.length > 0) {
|
if (droppedCrudObjects.length > 0) {
|
||||||
for (const node of droppedTables) {
|
for (const node of droppedCrudObjects) {
|
||||||
try {
|
try {
|
||||||
const table = node.data as DatabaseTableDto
|
|
||||||
await developerKitService.deleteGeneratedEndpointsByEntity(
|
await developerKitService.deleteGeneratedEndpointsByEntity(
|
||||||
toEntityName(table.tableName),
|
toEntityName(getCrudObjectName(node).objectName),
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
// Non-blocking: izin yoksa ya da endpoint yoksa drop yine basarilidir.
|
// Non-blocking: izin yoksa ya da endpoint yoksa drop yine basarilidir.
|
||||||
|
|
@ -439,10 +447,32 @@ const SqlObjectExplorer = ({
|
||||||
return <FaColumns className="text-gray-400" />
|
return <FaColumns className="text-gray-400" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint uretilebilen nesneler: tablolar (tam CRUD) ve view'ler (GetList/GetById).
|
||||||
|
* Stored procedure ve function calisma zamaninda desteklenmedigi icin disaridadir.
|
||||||
|
*/
|
||||||
|
const getCrudObjectType = (node: TreeNode): CrudObjectType | null => {
|
||||||
|
if (node.type !== 'object') return null
|
||||||
|
if (node.folder === 'tables') return 'table'
|
||||||
|
if (node.folder === 'views') return 'view'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tablo/view dugumunun sema + nesne adi; endpoint yonetimi bu ikiliyle calisir. */
|
||||||
|
const getCrudObjectName = (node: TreeNode): { schemaName: string; objectName: string } =>
|
||||||
|
node.folder === 'tables'
|
||||||
|
? {
|
||||||
|
schemaName: (node.data as DatabaseTableDto).schemaName,
|
||||||
|
objectName: (node.data as DatabaseTableDto).tableName,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
schemaName: (node.data as SqlNativeObjectDto).schemaName,
|
||||||
|
objectName: (node.data as SqlNativeObjectDto).objectName,
|
||||||
|
}
|
||||||
|
|
||||||
const getNodeCrudEndpoints = (node: TreeNode): CrudEndpoint[] => {
|
const getNodeCrudEndpoints = (node: TreeNode): CrudEndpoint[] => {
|
||||||
if (node.type !== 'object' || node.folder !== 'tables' || !crudEndpointsByEntity) return []
|
if (!crudEndpointsByEntity || !getCrudObjectType(node)) return []
|
||||||
const table = node.data as DatabaseTableDto
|
return crudEndpointsByEntity.get(toEntityName(getCrudObjectName(node).objectName)) ?? []
|
||||||
return crudEndpointsByEntity.get(toEntityName(table.tableName)) ?? []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildCrudEndpointTitle = (endpoint: CrudEndpoint) =>
|
const buildCrudEndpointTitle = (endpoint: CrudEndpoint) =>
|
||||||
|
|
@ -491,8 +521,10 @@ const SqlObjectExplorer = ({
|
||||||
onClick={
|
onClick={
|
||||||
onManageCrudEndpoints
|
onManageCrudEndpoints
|
||||||
? () => {
|
? () => {
|
||||||
const table = node.data as DatabaseTableDto
|
const objectType = getCrudObjectType(node)
|
||||||
onManageCrudEndpoints(table.schemaName, table.tableName)
|
if (!objectType) return
|
||||||
|
const { schemaName, objectName } = getCrudObjectName(node)
|
||||||
|
onManageCrudEndpoints(schemaName, objectName, objectType)
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
@ -522,9 +554,9 @@ const SqlObjectExplorer = ({
|
||||||
|
|
||||||
const filteredTree = filterTree(treeData, filterText)
|
const filteredTree = filterTree(treeData, filterText)
|
||||||
|
|
||||||
/** CRUD endpoint uretimi yalnizca tablolar icin anlamli. */
|
/** CRUD endpoint uretimi tablolar ve view'ler icin anlamli. */
|
||||||
const selectedTableCount = getSelectedNodes(treeData, selectedObjectIds).filter(
|
const selectedCrudObjectCount = getSelectedNodes(treeData, selectedObjectIds).filter(
|
||||||
(node) => node.folder === 'tables',
|
(node) => getCrudObjectType(node) !== null,
|
||||||
).length
|
).length
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -547,6 +579,7 @@ const SqlObjectExplorer = ({
|
||||||
const ctxNode = contextMenu.node
|
const ctxNode = contextMenu.node
|
||||||
const isTableObj = ctxNode?.type === 'object' && ctxNode.folder === 'tables'
|
const isTableObj = ctxNode?.type === 'object' && ctxNode.folder === 'tables'
|
||||||
const isNativeObj = ctxNode?.type === 'object' && ctxNode.folder !== 'tables'
|
const isNativeObj = ctxNode?.type === 'object' && ctxNode.folder !== 'tables'
|
||||||
|
const ctxCrudObjectType = ctxNode ? getCrudObjectType(ctxNode) : null
|
||||||
const isTablesDir = ctxNode?.id === 'tables'
|
const isTablesDir = ctxNode?.id === 'tables'
|
||||||
const isViewsDir = ctxNode?.id === 'views'
|
const isViewsDir = ctxNode?.id === 'views'
|
||||||
const isProcsDir = ctxNode?.id === 'procedures'
|
const isProcsDir = ctxNode?.id === 'procedures'
|
||||||
|
|
@ -599,7 +632,7 @@ const SqlObjectExplorer = ({
|
||||||
{translate('::App.SqlQueryManager.DropSelected')}
|
{translate('::App.SqlQueryManager.DropSelected')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onGenerateCrudEndpoints && selectedTableCount > 0 && (
|
{onGenerateCrudEndpoints && selectedCrudObjectCount > 0 && (
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
|
|
@ -697,14 +730,14 @@ const SqlObjectExplorer = ({
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isTableObj && onManageCrudEndpoints && (
|
{ctxCrudObjectType && onManageCrudEndpoints && (
|
||||||
<Button
|
<Button
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="none"
|
shape="none"
|
||||||
className="w-full !h-auto !justify-start !rounded-none !px-4 !py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm flex items-center gap-2"
|
className="w-full !h-auto !justify-start !rounded-none !px-4 !py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm flex items-center gap-2"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const t = ctxNode!.data as DatabaseTableDto
|
const { schemaName, objectName } = getCrudObjectName(ctxNode!)
|
||||||
onManageCrudEndpoints(t.schemaName, t.tableName)
|
onManageCrudEndpoints(schemaName, objectName, ctxCrudObjectType)
|
||||||
closeCtx()
|
closeCtx()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import CrudEndpointDialog, {
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
groupEndpointsByEntity,
|
groupEndpointsByEntity,
|
||||||
toEntityName,
|
toEntityName,
|
||||||
|
type CrudObjectType,
|
||||||
} from './CrudEndpointDialog'
|
} from './CrudEndpointDialog'
|
||||||
import { usePermission } from '@/utils/hooks/usePermission'
|
import { usePermission } from '@/utils/hooks/usePermission'
|
||||||
|
|
||||||
|
|
@ -96,9 +97,10 @@ const SqlQueryManager = () => {
|
||||||
const [copyDialogMode, setCopyDialogMode] = useState<'objects' | 'sql'>('objects')
|
const [copyDialogMode, setCopyDialogMode] = useState<'objects' | 'sql'>('objects')
|
||||||
const [sqlScriptForCopy, setSqlScriptForCopy] = useState('')
|
const [sqlScriptForCopy, setSqlScriptForCopy] = useState('')
|
||||||
const [crudEndpoints, setCrudEndpoints] = useState<CrudEndpoint[]>([])
|
const [crudEndpoints, setCrudEndpoints] = useState<CrudEndpoint[]>([])
|
||||||
const [crudDialogTable, setCrudDialogTable] = useState<{
|
const [crudDialogObject, setCrudDialogObject] = useState<{
|
||||||
schemaName: string
|
schemaName: string
|
||||||
tableName: string
|
objectName: string
|
||||||
|
objectType: CrudObjectType
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [isGeneratingCrud, setIsGeneratingCrud] = useState(false)
|
const [isGeneratingCrud, setIsGeneratingCrud] = useState(false)
|
||||||
|
|
||||||
|
|
@ -154,26 +156,37 @@ const SqlQueryManager = () => {
|
||||||
setCrudEndpoints((prev) => [...prev.filter((ep) => ep.entityName !== entityName), ...endpoints])
|
setCrudEndpoints((prev) => [...prev.filter((ep) => ep.entityName !== entityName), ...endpoints])
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const selectedExplorerTables = useMemo(
|
/**
|
||||||
() => selectedExplorerObjects.filter((obj) => obj.objectType === 'table'),
|
* Endpoint uretilebilen secimler: tablolar (tam CRUD) ve view'ler (GetList/GetById).
|
||||||
|
* Stored procedure ve function calisma zamaninda desteklenmedigi icin disaridadir.
|
||||||
|
*/
|
||||||
|
const selectedCrudObjects = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedExplorerObjects.filter(
|
||||||
|
(obj) => obj.objectType === 'table' || obj.objectType === 'view',
|
||||||
|
),
|
||||||
[selectedExplorerObjects],
|
[selectedExplorerObjects],
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Explorer'da secili tum tablolar icin CRUD endpoint'lerini uretir. */
|
/** Explorer'da secili tum tablo/view'ler icin CRUD endpoint'lerini uretir. */
|
||||||
const handleGenerateCrudEndpoints = async () => {
|
const handleGenerateCrudEndpoints = async () => {
|
||||||
if (!canManageCrudEndpoints || selectedExplorerTables.length === 0) return
|
if (!canManageCrudEndpoints || selectedCrudObjects.length === 0) return
|
||||||
|
|
||||||
setIsGeneratingCrud(true)
|
setIsGeneratingCrud(true)
|
||||||
try {
|
try {
|
||||||
const failures: string[] = []
|
const failures: string[] = []
|
||||||
|
|
||||||
for (const table of selectedExplorerTables) {
|
for (const object of selectedCrudObjects) {
|
||||||
const entityName = toEntityName(table.objectName)
|
const entityName = toEntityName(object.objectName)
|
||||||
try {
|
try {
|
||||||
const result = await developerKitService.generateCrudEndpoints(entityName)
|
const result = await developerKitService.generateCrudEndpoints(
|
||||||
|
entityName,
|
||||||
|
undefined,
|
||||||
|
object.objectType,
|
||||||
|
)
|
||||||
applyEntityEndpoints(entityName, result.items || [])
|
applyEntityEndpoints(entityName, result.items || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures.push(`${table.fullName}: ${getErrorMessage(error, entityName)}`)
|
failures.push(`${object.fullName}: ${getErrorMessage(error, entityName)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,7 +201,7 @@ const SqlQueryManager = () => {
|
||||||
>
|
>
|
||||||
{failures.length > 0
|
{failures.length > 0
|
||||||
? failures.join(' | ')
|
? failures.join(' | ')
|
||||||
: `${translate('::App.Platform.OperationCompleted')} (${selectedExplorerTables.length})`}
|
: `${translate('::App.Platform.OperationCompleted')} (${selectedCrudObjects.length})`}
|
||||||
</Notification>,
|
</Notification>,
|
||||||
{ placement: 'bottom-end' },
|
{ placement: 'bottom-end' },
|
||||||
)
|
)
|
||||||
|
|
@ -197,9 +210,12 @@ const SqlQueryManager = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleManageCrudEndpoints = useCallback((schemaName: string, tableName: string) => {
|
const handleManageCrudEndpoints = useCallback(
|
||||||
setCrudDialogTable({ schemaName, tableName })
|
(schemaName: string, objectName: string, objectType: CrudObjectType) => {
|
||||||
}, [])
|
setCrudDialogObject({ schemaName, objectName, objectType })
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const handleDataSourceChange = useCallback((dataSource: DataSourceDto) => {
|
const handleDataSourceChange = useCallback((dataSource: DataSourceDto) => {
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
|
|
@ -1273,13 +1289,14 @@ GO`,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CRUD Endpoint Dialog */}
|
{/* CRUD Endpoint Dialog */}
|
||||||
{crudDialogTable && canManageCrudEndpoints && (
|
{crudDialogObject && canManageCrudEndpoints && (
|
||||||
<CrudEndpointDialog
|
<CrudEndpointDialog
|
||||||
isOpen
|
isOpen
|
||||||
schemaName={crudDialogTable.schemaName}
|
schemaName={crudDialogObject.schemaName}
|
||||||
tableName={crudDialogTable.tableName}
|
tableName={crudDialogObject.objectName}
|
||||||
|
objectType={crudDialogObject.objectType}
|
||||||
dataSourceCode={state.selectedDataSource}
|
dataSourceCode={state.selectedDataSource}
|
||||||
onClose={() => setCrudDialogTable(null)}
|
onClose={() => setCrudDialogObject(null)}
|
||||||
onChanged={applyEntityEndpoints}
|
onChanged={applyEntityEndpoints}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,9 @@ import {
|
||||||
getSqlDataSourceKeySource,
|
getSqlDataSourceKeySource,
|
||||||
getSqlDataSourcePreviewKey,
|
getSqlDataSourcePreviewKey,
|
||||||
getSqlDataSourceRecord,
|
getSqlDataSourceRecord,
|
||||||
|
getSqlRecordProperty,
|
||||||
|
isDesignerDateComponent,
|
||||||
|
isDesignerDateOnlyComponent,
|
||||||
isDesignerOptionComponent,
|
isDesignerOptionComponent,
|
||||||
isDesignerTabularComponent,
|
isDesignerTabularComponent,
|
||||||
isSqlDataSourceNode,
|
isSqlDataSourceNode,
|
||||||
|
|
@ -84,6 +87,8 @@ 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_DEFAULT_VALUE_PROP,
|
||||||
|
SQL_DEFAULT_VALUE_TOKENS,
|
||||||
type DesignerComponentDefinition,
|
type DesignerComponentDefinition,
|
||||||
type DesignerBinding,
|
type DesignerBinding,
|
||||||
type DesignerDataSource,
|
type DesignerDataSource,
|
||||||
|
|
@ -91,6 +96,7 @@ import {
|
||||||
type DesignerHttpMethod,
|
type DesignerHttpMethod,
|
||||||
type DesignerNode,
|
type DesignerNode,
|
||||||
type DesignerPropertyInfo,
|
type DesignerPropertyInfo,
|
||||||
|
type SqlFormValueProperty,
|
||||||
} from '@/components/visualDesigner/types'
|
} from '@/components/visualDesigner/types'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
|
|
||||||
|
|
@ -3421,55 +3427,140 @@ const VisualComponentDesigner = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Value binding for an option component inside a SqlDataSource. Its option list
|
* Every column the record can offer, including the ones a sampled row cannot
|
||||||
* still comes from its own GET endpoint above; this only says which record
|
* reveal: an object column (`extraProperties`) has no leaf to discover, and a
|
||||||
* column the selection reads from and writes back to.
|
* column added after the last Select run is not in the sample at all. Both stay
|
||||||
|
* typeable, so a field is never blocked by a stale or empty sample.
|
||||||
*/
|
*/
|
||||||
const renderSqlRecordValueBinding = () => {
|
const sqlRecordColumns = useMemo(() => {
|
||||||
if (!sqlScopeNode || !selectedNode) return null
|
const record = sqlScopeNode ? sqlDataSourceRecords[sqlScopeNode.id] : undefined
|
||||||
if (!['AutoComplete', 'Radio.Group', 'Select'].includes(selectedNode.type)) return null
|
if (!record || typeof record !== 'object') return []
|
||||||
const record = sqlDataSourceRecords[sqlScopeNode.id]
|
const columns = new Map<string, string>()
|
||||||
const columns =
|
Object.entries(record as Record<string, unknown>).forEach(([path, value]) =>
|
||||||
record === undefined
|
columns.set(path, getDataValueType(value)),
|
||||||
? []
|
)
|
||||||
: discoverDataFields(record).filter((field) => !['array', 'object'].includes(field.type))
|
discoverDataFields(record)
|
||||||
const binding = selectedNode.bindings?.value
|
.filter((field) => field.type !== 'array')
|
||||||
const current = binding?.sourceId === sqlScopeNode.id ? binding.path : ''
|
.forEach((field) => columns.set(field.path, field.type))
|
||||||
|
return [...columns.entries()]
|
||||||
|
.map(([path, type]) => ({ path, type }))
|
||||||
|
.sort((left, right) => left.path.localeCompare(right.path))
|
||||||
|
}, [sqlDataSourceRecords, sqlScopeNode])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prop a SqlDataSource reads from and writes back to. An already bound node
|
||||||
|
* keeps its prop; an unbound one is offered the prop it actually carries, which
|
||||||
|
* is `checked` for the toggles and `value` for every other editor.
|
||||||
|
*/
|
||||||
|
const sqlRecordProperty = useMemo<SqlFormValueProperty | undefined>(() => {
|
||||||
|
if (!sqlScopeNode || !selectedNode) return undefined
|
||||||
|
const names = new Set((selectedDefinition?.properties || []).map((property) => property.name))
|
||||||
|
return getSqlRecordProperty(selectedNode, sqlScopeNode.id, names)
|
||||||
|
}, [selectedDefinition, selectedNode, sqlScopeNode])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record column and its new-record default, for anything editable dropped into
|
||||||
|
* a SqlDataSource. The column is a free text field backed by a datalist rather
|
||||||
|
* than a dropdown: the sampled row is a convenience, not the list of columns
|
||||||
|
* that exist.
|
||||||
|
*/
|
||||||
|
const renderSqlRecordField = () => {
|
||||||
|
if (!sqlScopeNode || !selectedNode || !sqlRecordProperty) return null
|
||||||
|
const binding = selectedNode.bindings?.[sqlRecordProperty]
|
||||||
|
const column = binding?.sourceId === sqlScopeNode.id ? binding.path : ''
|
||||||
|
const rawDefault = selectedNode.props?.[SQL_DEFAULT_VALUE_PROP]
|
||||||
|
const currentDefault = rawDefault === undefined || rawDefault === null ? '' : String(rawDefault)
|
||||||
|
const columnListId = `sql-columns-${sqlScopeNode.id}`
|
||||||
|
// A picker gets a real date editor, unless it holds a token — `@today` is not
|
||||||
|
// a date the browser can render, and typing it needs a plain text field.
|
||||||
|
const isDateDefault = isDesignerDateComponent(selectedNode.type) && !currentDefault.startsWith('@')
|
||||||
|
const defaultInputType = isDateDefault
|
||||||
|
? isDesignerDateOnlyComponent(selectedNode.type)
|
||||||
|
? 'date'
|
||||||
|
: 'datetime-local'
|
||||||
|
: selectedNode.props?.type === 'number'
|
||||||
|
? 'number'
|
||||||
|
: 'text'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
|
<div className="space-y-3 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
|
||||||
|
<div>
|
||||||
<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">
|
||||||
{translate('::App.DeveloperKitComponentDesigner.RecordField')}
|
{translate('::App.DeveloperKitComponentDesigner.RecordField')} · {sqlRecordProperty}
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-2 text-[10px] leading-4 text-slate-500">
|
<p className="mb-2 text-[10px] leading-4 text-slate-500">
|
||||||
{translate('::App.DeveloperKitComponentDesigner.RecordFieldHint')}
|
{translate('::App.DeveloperKitComponentDesigner.RecordFieldHint')}
|
||||||
</p>
|
</p>
|
||||||
<select
|
<input
|
||||||
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
|
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
|
||||||
value={current}
|
list={columnListId}
|
||||||
onChange={(event) =>
|
placeholder={translate('::App.DeveloperKitComponentDesigner.DoNotBindToRecord')}
|
||||||
updateSelectedBinding(
|
value={column}
|
||||||
'value',
|
onChange={(event) => {
|
||||||
event.target.value ? sqlScopeNode.id : '',
|
const path = event.target.value.trim()
|
||||||
event.target.value,
|
updateSelectedBinding(sqlRecordProperty, path ? sqlScopeNode.id : '', path)
|
||||||
)
|
}}
|
||||||
}
|
/>
|
||||||
>
|
<datalist id={columnListId}>
|
||||||
<option value="">
|
{sqlRecordColumns.map((column) => (
|
||||||
{translate('::App.DeveloperKitComponentDesigner.DoNotBindToRecord')}
|
<option key={column.path} label={column.type} value={column.path} />
|
||||||
</option>
|
|
||||||
{columns.map((field) => (
|
|
||||||
<option key={field.path} value={field.path}>
|
|
||||||
{field.path} · {field.type}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</datalist>
|
||||||
{!columns.length && (
|
{!sqlRecordColumns.length && (
|
||||||
<p className="mt-1.5 text-[10px] leading-4 text-amber-600">
|
<p className="mt-1.5 text-[10px] leading-4 text-amber-600">
|
||||||
{translate('::App.DeveloperKitComponentDesigner.DefineAndRunSelect')}
|
{translate('::App.DeveloperKitComponentDesigner.DefineAndRunSelect')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
|
||||||
|
{translate('::App.DeveloperKitComponentDesigner.DefaultValue')}
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-[10px] leading-4 text-slate-500">
|
||||||
|
{translate('::App.DeveloperKitComponentDesigner.DefaultValueHint', {
|
||||||
|
column: column || '—',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{sqlRecordProperty === 'checked' ? (
|
||||||
|
<select
|
||||||
|
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
disabled={!column}
|
||||||
|
value={currentDefault}
|
||||||
|
onChange={(event) => updateSelectedProp(SQL_DEFAULT_VALUE_PROP, event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{translate('::App.DeveloperKitComponentDesigner.NoDefaultValue')}
|
||||||
|
</option>
|
||||||
|
<option value="true">{translate('::App.Platform.Yes')}</option>
|
||||||
|
<option value="false">{translate('::App.Platform.No')}</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
disabled={!column}
|
||||||
|
placeholder={translate('::App.DeveloperKitComponentDesigner.NoDefaultValue')}
|
||||||
|
type={defaultInputType}
|
||||||
|
value={
|
||||||
|
defaultInputType === 'datetime-local' ? currentDefault.slice(0, 16) : currentDefault
|
||||||
|
}
|
||||||
|
onChange={(event) => updateSelectedProp(SQL_DEFAULT_VALUE_PROP, event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
{SQL_DEFAULT_VALUE_TOKENS.map((token) => (
|
||||||
|
<button
|
||||||
|
key={token}
|
||||||
|
className="rounded border border-slate-300 px-1.5 py-0.5 font-mono text-[9px] text-slate-600 hover:border-sky-400 hover:text-sky-700 disabled:opacity-40 dark:border-slate-700 dark:text-slate-300"
|
||||||
|
disabled={!column || sqlRecordProperty === 'checked'}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateSelectedProp(SQL_DEFAULT_VALUE_PROP, `@${token}`)}
|
||||||
|
>
|
||||||
|
@{token}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3498,7 +3589,7 @@ const VisualComponentDesigner = () => {
|
||||||
{isOptionDataComponent(selectedNode?.type) && selectConfiguration}
|
{isOptionDataComponent(selectedNode?.type) && selectConfiguration}
|
||||||
{isTabularDataComponent(selectedNode?.type) && renderTabularDataConfiguration()}
|
{isTabularDataComponent(selectedNode?.type) && renderTabularDataConfiguration()}
|
||||||
{isTabularDataComponent(selectedNode?.type) && renderTabularColumnConfiguration()}
|
{isTabularDataComponent(selectedNode?.type) && renderTabularColumnConfiguration()}
|
||||||
{renderSqlRecordValueBinding()}
|
{renderSqlRecordField()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -3511,6 +3602,7 @@ const VisualComponentDesigner = () => {
|
||||||
<div className="rounded-lg border border-dashed border-slate-300 p-3 text-xs leading-5 text-slate-500 dark:border-slate-700">
|
<div className="rounded-lg border border-dashed border-slate-300 p-3 text-xs leading-5 text-slate-500 dark:border-slate-700">
|
||||||
{translate('::App.DeveloperKitComponentDesigner.NoSelectableGetEndpoint')}
|
{translate('::App.DeveloperKitComponentDesigner.NoSelectableGetEndpoint')}
|
||||||
</div>
|
</div>
|
||||||
|
{renderSqlRecordField()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -3812,7 +3904,7 @@ const VisualComponentDesigner = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renderSqlRecordValueBinding()}
|
{renderSqlRecordField()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue