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
|
||||
/// (GetList, GetById, Create, Update, Delete). Bos birakilirsa hepsi aktif olur.
|
||||
/// </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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,9 +73,10 @@ public class CrudEndpointGenerateAppService : CrudAppService<
|
|||
[Authorize(PlatformConsts.AppCodes.SqlQueryManagers.CrudEndpoints)]
|
||||
public virtual async Task<PagedResultDto<CrudEndpointDto>> GenerateCrudEndpointsAsync(
|
||||
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
|
||||
// kullanici diyalogdan sonradan acabilir. Bos/null ise hepsi aktiftir.
|
||||
var activeOperations = operationTypes
|
||||
|
|
@ -85,6 +86,11 @@ public class CrudEndpointGenerateAppService : CrudAppService<
|
|||
bool IsActiveOperation(string 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
|
||||
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 existingEndpoints = await Repository
|
||||
.GetListAsync(x => x.EntityName == entityName);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
{
|
||||
"Languages": [
|
||||
{
|
||||
"cultureName": "ar",
|
||||
|
|
@ -19698,6 +19698,24 @@
|
|||
"en": "The Data tab can only be used on components in the Data category.",
|
||||
"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",
|
||||
"key": "App.DeveloperKitComponentDesigner.DefineAndRunSelect",
|
||||
|
|
|
|||
|
|
@ -55,8 +55,9 @@ public class DynamicEntityManager : IDynamicEntityManager
|
|||
var hasIsDeleted = await ColumnExistsAsync(entityName, "IsDeleted");
|
||||
var hasCreationTime = await ColumnExistsAsync(entityName, "CreationTime");
|
||||
|
||||
var parameters = new List<object>();
|
||||
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 (hasIsDeleted) { columns.Add(QuoteIdentifier("IsDeleted", isPostgreSql)); values.Add(FalseLiteral(isPostgreSql)); }
|
||||
|
|
@ -67,12 +68,12 @@ public class DynamicEntityManager : IDynamicEntityManager
|
|||
continue;
|
||||
|
||||
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)})";
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(insertQuery);
|
||||
await dbContext.Database.ExecuteSqlRawAsync(insertQuery, parameters.ToArray());
|
||||
|
||||
return await GetEntityByIdAsync(entityName, newId);
|
||||
}
|
||||
|
|
@ -85,6 +86,7 @@ public class DynamicEntityManager : IDynamicEntityManager
|
|||
if (existing == null)
|
||||
return null;
|
||||
|
||||
var parameters = new List<object>();
|
||||
var setParts = new List<string>();
|
||||
|
||||
var hasLastModification = await ColumnExistsAsync(entityName, "LastModificationTime");
|
||||
|
|
@ -96,12 +98,12 @@ public class DynamicEntityManager : IDynamicEntityManager
|
|||
if (prop.NameEquals("id") || prop.NameEquals("Id"))
|
||||
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);
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
JsonValueKind.Number when value.TryGetInt64(out var l) => l.ToString(),
|
||||
JsonValueKind.Number when value.TryGetDecimal(out var d) => d.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
JsonValueKind.True => TrueLiteral(isPostgreSql),
|
||||
JsonValueKind.False => FalseLiteral(isPostgreSql),
|
||||
JsonValueKind.Null => "NULL",
|
||||
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 => $"'{value.GetString()?.Replace("'", "''")}'" ,
|
||||
_ => "NULL",
|
||||
JsonValueKind.Number when value.TryGetInt64(out var l) => l,
|
||||
JsonValueKind.Number when value.TryGetDecimal(out var d) => d,
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
||||
JsonValueKind.String when value.TryGetGuid(out var g) => g,
|
||||
JsonValueKind.String when value.TryGetDateTime(out var dt) => dt,
|
||||
JsonValueKind.String => value.GetString(),
|
||||
_ => 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 { beginDesignerDragSoon, endDesignerDrag, useDesignerDrag } from './designerDrag'
|
||||
import {
|
||||
buildSqlDefaultRecord,
|
||||
fromDesignerDate,
|
||||
getDesignerBindingColumns,
|
||||
getDesignerCollectionProperty,
|
||||
|
|
@ -16,6 +17,7 @@ import {
|
|||
getDesignerValueByPath,
|
||||
getSqlDataSourceEndpointId,
|
||||
getSqlDataSourceKeyField,
|
||||
getSqlFormValueProperty,
|
||||
isDesignerDateComponent,
|
||||
isDesignerDateProperty,
|
||||
isDesignerOptionComponent,
|
||||
|
|
@ -25,6 +27,8 @@ import {
|
|||
resolveDesignerDropdownTitle,
|
||||
resolveDesignerTabValue,
|
||||
resolveSqlDataSourceRows,
|
||||
setDesignerRecordField,
|
||||
SQL_DEFAULT_VALUE_PROP,
|
||||
toDesignerDate,
|
||||
type DesignerBinding,
|
||||
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. */
|
||||
const getFormScopeProperty = (node: DesignerNode, formScope?: DesignerFormScope) =>
|
||||
formScope
|
||||
? (['value', 'checked'] as const).find(
|
||||
(propertyName) => node.bindings?.[propertyName]?.sourceId === formScope.sourceId,
|
||||
)
|
||||
: undefined
|
||||
getSqlFormValueProperty(node, formScope?.sourceId)
|
||||
|
||||
const getPreviewProps = (
|
||||
node: DesignerNode,
|
||||
|
|
@ -483,6 +483,9 @@ const getPreviewProps = (
|
|||
) => {
|
||||
const props: Record<string, unknown> = {}
|
||||
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.startsWith('on') && typeof value === 'string') return
|
||||
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 = (
|
||||
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
|
||||
/**
|
||||
* A new record starts at the top of the form: the first enabled editor takes the
|
||||
* cursor once React has painted the emptied fields.
|
||||
*/
|
||||
const focusFirstFormField = (host: React.RefObject<HTMLElement | null>) => {
|
||||
window.requestAnimationFrame(() => {
|
||||
const field = host.current?.querySelector<HTMLElement>(
|
||||
"input:not([type='hidden']):not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly]), select:not([disabled])",
|
||||
)
|
||||
if (!field) return
|
||||
field.focus()
|
||||
if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) field.select()
|
||||
})
|
||||
}
|
||||
|
||||
const SqlDataSourceView = ({
|
||||
|
|
@ -844,6 +842,8 @@ const SqlDataSourceView = ({
|
|||
() => (selectId ? resolveSqlDataSourceRows(dataValues[selectId], collectionPath) : []),
|
||||
[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 [mode, setMode] = React.useState<SqlDataSourceMode>('edit')
|
||||
// Holds the whole record while editing, so New mode can show an empty form
|
||||
|
|
@ -864,7 +864,7 @@ const SqlDataSourceView = ({
|
|||
() => ({
|
||||
sourceId: node.id,
|
||||
onFieldChange: (path, value) =>
|
||||
setDraft((current) => setRecordField(current ?? activeRow, path, value)),
|
||||
setDraft((current) => setDesignerRecordField(current ?? activeRow, path, value)),
|
||||
}),
|
||||
[activeRow, node.id],
|
||||
)
|
||||
|
|
@ -917,6 +917,7 @@ const SqlDataSourceView = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className={String(node.props.className || '')}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: Number(node.props.gap) || 0 }}
|
||||
>
|
||||
|
|
@ -996,8 +997,11 @@ const SqlDataSourceView = ({
|
|||
'plain',
|
||||
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')
|
||||
focusFirstFormField(hostRef)
|
||||
},
|
||||
)}
|
||||
{toolbarButton(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
collectSqlDefaultFields,
|
||||
getDesignerBindingColumns,
|
||||
getDesignerCollectionProperty,
|
||||
getDesignerTabSlotValue,
|
||||
|
|
@ -7,6 +8,7 @@ import {
|
|||
getSqlDataSourceKeyParam,
|
||||
getSqlDataSourceKeySource,
|
||||
getSqlDataSourceScriptedEvents,
|
||||
getSqlFormValueProperty,
|
||||
hasSqlDataSourceUrlParams,
|
||||
isDesignerDateComponent,
|
||||
isDesignerDateOnlyComponent,
|
||||
|
|
@ -16,6 +18,7 @@ import {
|
|||
normalizeDesignerKeyList,
|
||||
DESIGNER_DROPDOWN_PLACEHOLDER,
|
||||
SQL_DATA_SOURCE_SLOTS,
|
||||
SQL_DEFAULT_VALUE_PROP,
|
||||
type DesignerDataSource,
|
||||
type DesignerDocument,
|
||||
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. */
|
||||
const getFormScopeField = (node: DesignerNode, formScope?: FormScope) => {
|
||||
if (!formScope) return null
|
||||
const propertyName = (['value', 'checked'] as const).find(
|
||||
(name) => node.bindings?.[name]?.sourceId === formScope.sourceId,
|
||||
)
|
||||
const propertyName = getSqlFormValueProperty(node, formScope.sourceId)
|
||||
if (!propertyName) return null
|
||||
return {
|
||||
propertyName,
|
||||
|
|
@ -215,6 +216,8 @@ const propsToCode = (
|
|||
([key, value]) =>
|
||||
key !== 'children' &&
|
||||
key !== runtimeState?.propertyName &&
|
||||
// Seeds a new SqlDataSource record; it is not a prop of the component.
|
||||
key !== SQL_DEFAULT_VALUE_PROP &&
|
||||
!omitted.has(key) &&
|
||||
!(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) &&
|
||||
!(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') &&
|
||||
|
|
@ -314,6 +317,8 @@ const sqlIdentifiers = (node: DesignerNode) => {
|
|||
reload: `sqlReload_${identifier}`,
|
||||
refresh: `sqlRefresh_${identifier}`,
|
||||
create: `sqlNew_${identifier}`,
|
||||
draft: `sqlDraft_${identifier}`,
|
||||
host: `sqlHost_${identifier}`,
|
||||
save: `sqlSave_${identifier}`,
|
||||
remove: `sqlDelete_${identifier}`,
|
||||
previous: `sqlPrev_${identifier}`,
|
||||
|
|
@ -403,7 +408,9 @@ const sqlDataSourceHooks = (node: DesignerNode, dataSources: DesignerDataSource[
|
|||
const [${names.mode}, ${names.setMode}] = React.useState("edit")
|
||||
const [${names.index}, ${names.setIndex}] = React.useState(0)
|
||||
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)
|
||||
? `
|
||||
// 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}])
|
||||
const ${names.create} = React.useCallback(${sqlEventHandler(node, 'onNewRecord') ? 'async ' : ''}() => {
|
||||
${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.setMode}("new")${
|
||||
${names.setMode}("new")
|
||||
// After the re-render that paints the empty form, not before it.
|
||||
focusFirstSqlField(${names.host})${
|
||||
sqlEventHandler(node, 'onNewRecord')
|
||||
? `
|
||||
${sqlEventCall(node, 'onNewRecord', '{ record: {} }')}`
|
||||
${sqlEventCall(node, 'onNewRecord', `{ record: ${names.draft} }`)}`
|
||||
: ''
|
||||
}
|
||||
}, [])
|
||||
|
|
@ -647,7 +659,7 @@ ${indent('</div>', level + 1)}`
|
|||
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)}`
|
||||
|
||||
return `${indent(`<div className=${className} style={${style}}>`, level)}
|
||||
return `${indent(`<div ref={${names.host}} className=${className} style={${style}}>`, level)}
|
||||
${children}${toolbar}${error}
|
||||
${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 }
|
||||
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 sqlHooks = sqlDataSourceNodes
|
||||
|
|
|
|||
|
|
@ -734,3 +734,147 @@ export const normalizeDesignerKeyList = (value: unknown): string[] => {
|
|||
.map(String)
|
||||
.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.
|
||||
* Listede olmayanlar pasif olarak kaydedilir.
|
||||
* @param objectType Veritabani nesnesinin turu. Tablo disindaki nesneler icin
|
||||
* (view/procedure/function) yalnizca GetList ve GetById uretilir.
|
||||
*/
|
||||
async generateCrudEndpoints(
|
||||
entityName: string,
|
||||
operationTypes?: string[],
|
||||
objectType?: string,
|
||||
): 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>>({
|
||||
url: `/api/app/crud-endpoint-generate/generate-crud-endpoints/${entityName}`,
|
||||
method: 'POST',
|
||||
params: operationTypes?.length ? { operationTypes: operationTypes.join(',') } : undefined,
|
||||
params: Object.keys(params).length > 0 ? params : undefined,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,15 @@ export const CRUD_OPERATION_TYPES = [
|
|||
|
||||
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.
|
||||
*
|
||||
|
|
@ -165,9 +174,11 @@ export const CrudEndpointBadges = ({ endpoints, buildTitle, onClick }: CrudEndpo
|
|||
|
||||
interface CrudEndpointDialogProps {
|
||||
isOpen: boolean
|
||||
/** Endpoint'leri yonetilecek tablo; entity adi tablo adindan turetilir. */
|
||||
/** Endpoint'leri yonetilecek tablo/view; entity adi nesne adindan turetilir. */
|
||||
tableName: string
|
||||
schemaName?: string
|
||||
/** Nesne turu; 'view' verildiginde yalnizca GetList/GetById uretilir. */
|
||||
objectType?: CrudObjectType
|
||||
/** Verilirse istek govdesi tablonun gercek kolonlarindan uretilir. */
|
||||
dataSourceCode?: string | null
|
||||
onClose: () => void
|
||||
|
|
@ -247,6 +258,7 @@ const CrudEndpointDialog = ({
|
|||
isOpen,
|
||||
tableName,
|
||||
schemaName,
|
||||
objectType = 'table',
|
||||
dataSourceCode,
|
||||
onClose,
|
||||
onChanged,
|
||||
|
|
@ -255,6 +267,7 @@ const CrudEndpointDialog = ({
|
|||
const { checkPermission } = usePermission()
|
||||
const canManageCrudEndpoints = checkPermission(CRUD_ENDPOINT_PERMISSION)
|
||||
const entityName = toEntityName(tableName)
|
||||
const isReadOnlyObject = objectType === 'view'
|
||||
|
||||
const [endpoints, setEndpoints] = useState<CrudEndpoint[]>([])
|
||||
const [columns, setColumns] = useState<DatabaseColumnDto[]>([])
|
||||
|
|
@ -323,7 +336,7 @@ const CrudEndpointDialog = ({
|
|||
|
||||
// Govde sablonu icin tablo kolonlari; datasource bilinmiyorsa genel sablon kullanilir.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !dataSourceCode) {
|
||||
if (!isOpen || !dataSourceCode || isReadOnlyObject) {
|
||||
setColumns([])
|
||||
return
|
||||
}
|
||||
|
|
@ -342,7 +355,7 @@ const CrudEndpointDialog = ({
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isOpen, dataSourceCode, schemaName, tableName])
|
||||
}, [isOpen, dataSourceCode, schemaName, tableName, isReadOnlyObject])
|
||||
|
||||
const sampleBodies = useMemo(() => {
|
||||
if (columns.length === 0) return { create: GENERIC_BODY, update: GENERIC_BODY }
|
||||
|
|
@ -393,7 +406,7 @@ const CrudEndpointDialog = ({
|
|||
const handleGenerate = async () => {
|
||||
setGenerating(true)
|
||||
try {
|
||||
const result = await developerKitService.generateCrudEndpoints(entityName)
|
||||
const result = await developerKitService.generateCrudEndpoints(entityName, undefined, objectType)
|
||||
publish(result.items || [])
|
||||
pushSuccess(translate('::App.Platform.OperationCompleted'))
|
||||
} catch (err) {
|
||||
|
|
@ -541,7 +554,17 @@ const CrudEndpointDialog = ({
|
|||
{schemaName ? `${schemaName}.${tableName}` : tableName}
|
||||
{' — '}
|
||||
{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>
|
||||
{isReadOnlyObject && (
|
||||
<p className="mt-1 mb-0 text-xs text-gray-500 dark:text-gray-400">
|
||||
{READONLY_OPERATION_TYPES.join(' / ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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 Button from '@/components/ui/Button'
|
||||
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'
|
||||
|
||||
type FolderKey = 'tables' | 'views' | 'procedures' | 'functions'
|
||||
|
|
@ -44,10 +49,14 @@ interface SqlObjectExplorerProps {
|
|||
onDesignTable?: (schemaName: string, tableName: string) => void
|
||||
onNewTable?: () => 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[]>
|
||||
onManageCrudEndpoints?: (schemaName: string, tableName: string) => void
|
||||
/** Secili tablolar icin CRUD endpoint uretir; verilmezse buton gosterilmez. */
|
||||
onManageCrudEndpoints?: (
|
||||
schemaName: string,
|
||||
objectName: string,
|
||||
objectType: CrudObjectType,
|
||||
) => void
|
||||
/** Secili tablo/view'ler icin CRUD endpoint uretir; verilmezse buton gosterilmez. */
|
||||
onGenerateCrudEndpoints?: () => void
|
||||
isGeneratingCrudEndpoints?: boolean
|
||||
/** 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.
|
||||
}
|
||||
|
||||
// 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.
|
||||
const droppedTables = dropped.filter((node) => node.folder === 'tables')
|
||||
if (droppedTables.length > 0) {
|
||||
for (const node of droppedTables) {
|
||||
const droppedCrudObjects = dropped.filter((node) => getCrudObjectType(node) !== null)
|
||||
if (droppedCrudObjects.length > 0) {
|
||||
for (const node of droppedCrudObjects) {
|
||||
try {
|
||||
const table = node.data as DatabaseTableDto
|
||||
await developerKitService.deleteGeneratedEndpointsByEntity(
|
||||
toEntityName(table.tableName),
|
||||
toEntityName(getCrudObjectName(node).objectName),
|
||||
)
|
||||
} catch {
|
||||
// Non-blocking: izin yoksa ya da endpoint yoksa drop yine basarilidir.
|
||||
|
|
@ -439,10 +447,32 @@ const SqlObjectExplorer = ({
|
|||
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[] => {
|
||||
if (node.type !== 'object' || node.folder !== 'tables' || !crudEndpointsByEntity) return []
|
||||
const table = node.data as DatabaseTableDto
|
||||
return crudEndpointsByEntity.get(toEntityName(table.tableName)) ?? []
|
||||
if (!crudEndpointsByEntity || !getCrudObjectType(node)) return []
|
||||
return crudEndpointsByEntity.get(toEntityName(getCrudObjectName(node).objectName)) ?? []
|
||||
}
|
||||
|
||||
const buildCrudEndpointTitle = (endpoint: CrudEndpoint) =>
|
||||
|
|
@ -491,8 +521,10 @@ const SqlObjectExplorer = ({
|
|||
onClick={
|
||||
onManageCrudEndpoints
|
||||
? () => {
|
||||
const table = node.data as DatabaseTableDto
|
||||
onManageCrudEndpoints(table.schemaName, table.tableName)
|
||||
const objectType = getCrudObjectType(node)
|
||||
if (!objectType) return
|
||||
const { schemaName, objectName } = getCrudObjectName(node)
|
||||
onManageCrudEndpoints(schemaName, objectName, objectType)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
|
@ -522,9 +554,9 @@ const SqlObjectExplorer = ({
|
|||
|
||||
const filteredTree = filterTree(treeData, filterText)
|
||||
|
||||
/** CRUD endpoint uretimi yalnizca tablolar icin anlamli. */
|
||||
const selectedTableCount = getSelectedNodes(treeData, selectedObjectIds).filter(
|
||||
(node) => node.folder === 'tables',
|
||||
/** CRUD endpoint uretimi tablolar ve view'ler icin anlamli. */
|
||||
const selectedCrudObjectCount = getSelectedNodes(treeData, selectedObjectIds).filter(
|
||||
(node) => getCrudObjectType(node) !== null,
|
||||
).length
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -547,6 +579,7 @@ const SqlObjectExplorer = ({
|
|||
const ctxNode = contextMenu.node
|
||||
const isTableObj = 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 isViewsDir = ctxNode?.id === 'views'
|
||||
const isProcsDir = ctxNode?.id === 'procedures'
|
||||
|
|
@ -599,7 +632,7 @@ const SqlObjectExplorer = ({
|
|||
{translate('::App.SqlQueryManager.DropSelected')}
|
||||
</Button>
|
||||
)}
|
||||
{onGenerateCrudEndpoints && selectedTableCount > 0 && (
|
||||
{onGenerateCrudEndpoints && selectedCrudObjectCount > 0 && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="plain"
|
||||
|
|
@ -697,14 +730,14 @@ const SqlObjectExplorer = ({
|
|||
</Button>
|
||||
)}
|
||||
|
||||
{isTableObj && onManageCrudEndpoints && (
|
||||
{ctxCrudObjectType && onManageCrudEndpoints && (
|
||||
<Button
|
||||
variant="plain"
|
||||
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"
|
||||
onClick={() => {
|
||||
const t = ctxNode!.data as DatabaseTableDto
|
||||
onManageCrudEndpoints(t.schemaName, t.tableName)
|
||||
const { schemaName, objectName } = getCrudObjectName(ctxNode!)
|
||||
onManageCrudEndpoints(schemaName, objectName, ctxCrudObjectType)
|
||||
closeCtx()
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import CrudEndpointDialog, {
|
|||
getErrorMessage,
|
||||
groupEndpointsByEntity,
|
||||
toEntityName,
|
||||
type CrudObjectType,
|
||||
} from './CrudEndpointDialog'
|
||||
import { usePermission } from '@/utils/hooks/usePermission'
|
||||
|
||||
|
|
@ -96,9 +97,10 @@ const SqlQueryManager = () => {
|
|||
const [copyDialogMode, setCopyDialogMode] = useState<'objects' | 'sql'>('objects')
|
||||
const [sqlScriptForCopy, setSqlScriptForCopy] = useState('')
|
||||
const [crudEndpoints, setCrudEndpoints] = useState<CrudEndpoint[]>([])
|
||||
const [crudDialogTable, setCrudDialogTable] = useState<{
|
||||
const [crudDialogObject, setCrudDialogObject] = useState<{
|
||||
schemaName: string
|
||||
tableName: string
|
||||
objectName: string
|
||||
objectType: CrudObjectType
|
||||
} | null>(null)
|
||||
const [isGeneratingCrud, setIsGeneratingCrud] = useState(false)
|
||||
|
||||
|
|
@ -154,26 +156,37 @@ const SqlQueryManager = () => {
|
|||
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],
|
||||
)
|
||||
|
||||
/** 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 () => {
|
||||
if (!canManageCrudEndpoints || selectedExplorerTables.length === 0) return
|
||||
if (!canManageCrudEndpoints || selectedCrudObjects.length === 0) return
|
||||
|
||||
setIsGeneratingCrud(true)
|
||||
try {
|
||||
const failures: string[] = []
|
||||
|
||||
for (const table of selectedExplorerTables) {
|
||||
const entityName = toEntityName(table.objectName)
|
||||
for (const object of selectedCrudObjects) {
|
||||
const entityName = toEntityName(object.objectName)
|
||||
try {
|
||||
const result = await developerKitService.generateCrudEndpoints(entityName)
|
||||
const result = await developerKitService.generateCrudEndpoints(
|
||||
entityName,
|
||||
undefined,
|
||||
object.objectType,
|
||||
)
|
||||
applyEntityEndpoints(entityName, result.items || [])
|
||||
} 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.join(' | ')
|
||||
: `${translate('::App.Platform.OperationCompleted')} (${selectedExplorerTables.length})`}
|
||||
: `${translate('::App.Platform.OperationCompleted')} (${selectedCrudObjects.length})`}
|
||||
</Notification>,
|
||||
{ placement: 'bottom-end' },
|
||||
)
|
||||
|
|
@ -197,9 +210,12 @@ const SqlQueryManager = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const handleManageCrudEndpoints = useCallback((schemaName: string, tableName: string) => {
|
||||
setCrudDialogTable({ schemaName, tableName })
|
||||
}, [])
|
||||
const handleManageCrudEndpoints = useCallback(
|
||||
(schemaName: string, objectName: string, objectType: CrudObjectType) => {
|
||||
setCrudDialogObject({ schemaName, objectName, objectType })
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleDataSourceChange = useCallback((dataSource: DataSourceDto) => {
|
||||
setState((prev) => ({
|
||||
|
|
@ -1273,13 +1289,14 @@ GO`,
|
|||
)}
|
||||
|
||||
{/* CRUD Endpoint Dialog */}
|
||||
{crudDialogTable && canManageCrudEndpoints && (
|
||||
{crudDialogObject && canManageCrudEndpoints && (
|
||||
<CrudEndpointDialog
|
||||
isOpen
|
||||
schemaName={crudDialogTable.schemaName}
|
||||
tableName={crudDialogTable.tableName}
|
||||
schemaName={crudDialogObject.schemaName}
|
||||
tableName={crudDialogObject.objectName}
|
||||
objectType={crudDialogObject.objectType}
|
||||
dataSourceCode={state.selectedDataSource}
|
||||
onClose={() => setCrudDialogTable(null)}
|
||||
onClose={() => setCrudDialogObject(null)}
|
||||
onChanged={applyEntityEndpoints}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,9 @@ import {
|
|||
getSqlDataSourceKeySource,
|
||||
getSqlDataSourcePreviewKey,
|
||||
getSqlDataSourceRecord,
|
||||
getSqlRecordProperty,
|
||||
isDesignerDateComponent,
|
||||
isDesignerDateOnlyComponent,
|
||||
isDesignerOptionComponent,
|
||||
isDesignerTabularComponent,
|
||||
isSqlDataSourceNode,
|
||||
|
|
@ -84,6 +87,8 @@ import {
|
|||
DESIGNER_HTTP_METHODS,
|
||||
SQL_DATA_SOURCE_KEY_SOURCES,
|
||||
SQL_DATA_SOURCE_SLOTS,
|
||||
SQL_DEFAULT_VALUE_PROP,
|
||||
SQL_DEFAULT_VALUE_TOKENS,
|
||||
type DesignerComponentDefinition,
|
||||
type DesignerBinding,
|
||||
type DesignerDataSource,
|
||||
|
|
@ -91,6 +96,7 @@ import {
|
|||
type DesignerHttpMethod,
|
||||
type DesignerNode,
|
||||
type DesignerPropertyInfo,
|
||||
type SqlFormValueProperty,
|
||||
} from '@/components/visualDesigner/types'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
|
||||
|
|
@ -3421,55 +3427,140 @@ const VisualComponentDesigner = () => {
|
|||
}
|
||||
|
||||
/**
|
||||
* Value binding for an option component inside a SqlDataSource. Its option list
|
||||
* still comes from its own GET endpoint above; this only says which record
|
||||
* column the selection reads from and writes back to.
|
||||
* Every column the record can offer, including the ones a sampled row cannot
|
||||
* reveal: an object column (`extraProperties`) has no leaf to discover, and a
|
||||
* 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 = () => {
|
||||
if (!sqlScopeNode || !selectedNode) return null
|
||||
if (!['AutoComplete', 'Radio.Group', 'Select'].includes(selectedNode.type)) return null
|
||||
const record = sqlDataSourceRecords[sqlScopeNode.id]
|
||||
const columns =
|
||||
record === undefined
|
||||
? []
|
||||
: discoverDataFields(record).filter((field) => !['array', 'object'].includes(field.type))
|
||||
const binding = selectedNode.bindings?.value
|
||||
const current = binding?.sourceId === sqlScopeNode.id ? binding.path : ''
|
||||
const sqlRecordColumns = useMemo(() => {
|
||||
const record = sqlScopeNode ? sqlDataSourceRecords[sqlScopeNode.id] : undefined
|
||||
if (!record || typeof record !== 'object') return []
|
||||
const columns = new Map<string, string>()
|
||||
Object.entries(record as Record<string, unknown>).forEach(([path, value]) =>
|
||||
columns.set(path, getDataValueType(value)),
|
||||
)
|
||||
discoverDataFields(record)
|
||||
.filter((field) => field.type !== 'array')
|
||||
.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 (
|
||||
<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">
|
||||
{translate('::App.DeveloperKitComponentDesigner.RecordField')}
|
||||
{translate('::App.DeveloperKitComponentDesigner.RecordField')} · {sqlRecordProperty}
|
||||
</div>
|
||||
<p className="mb-2 text-[10px] leading-4 text-slate-500">
|
||||
{translate('::App.DeveloperKitComponentDesigner.RecordFieldHint')}
|
||||
</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"
|
||||
value={current}
|
||||
onChange={(event) =>
|
||||
updateSelectedBinding(
|
||||
'value',
|
||||
event.target.value ? sqlScopeNode.id : '',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{translate('::App.DeveloperKitComponentDesigner.DoNotBindToRecord')}
|
||||
</option>
|
||||
{columns.map((field) => (
|
||||
<option key={field.path} value={field.path}>
|
||||
{field.path} · {field.type}
|
||||
</option>
|
||||
list={columnListId}
|
||||
placeholder={translate('::App.DeveloperKitComponentDesigner.DoNotBindToRecord')}
|
||||
value={column}
|
||||
onChange={(event) => {
|
||||
const path = event.target.value.trim()
|
||||
updateSelectedBinding(sqlRecordProperty, path ? sqlScopeNode.id : '', path)
|
||||
}}
|
||||
/>
|
||||
<datalist id={columnListId}>
|
||||
{sqlRecordColumns.map((column) => (
|
||||
<option key={column.path} label={column.type} value={column.path} />
|
||||
))}
|
||||
</select>
|
||||
{!columns.length && (
|
||||
</datalist>
|
||||
{!sqlRecordColumns.length && (
|
||||
<p className="mt-1.5 text-[10px] leading-4 text-amber-600">
|
||||
{translate('::App.DeveloperKitComponentDesigner.DefineAndRunSelect')}
|
||||
</p>
|
||||
)}
|
||||
</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}
|
||||
{isTabularDataComponent(selectedNode?.type) && renderTabularDataConfiguration()}
|
||||
{isTabularDataComponent(selectedNode?.type) && renderTabularColumnConfiguration()}
|
||||
{renderSqlRecordValueBinding()}
|
||||
{renderSqlRecordField()}
|
||||
</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">
|
||||
{translate('::App.DeveloperKitComponentDesigner.NoSelectableGetEndpoint')}
|
||||
</div>
|
||||
{renderSqlRecordField()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3812,7 +3904,7 @@ const VisualComponentDesigner = () => {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{renderSqlRecordValueBinding()}
|
||||
{renderSqlRecordField()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue