Custom Component ve Wizard için CRUD yetkiler tanımlandı
This commit is contained in:
parent
90a66562e7
commit
baef565dd4
17 changed files with 1155 additions and 523 deletions
|
|
@ -215,7 +215,7 @@ public class CustomEndpointAppService : PlatformAppService
|
|||
|
||||
/// <summary>
|
||||
/// Body parameters of a POST/PUT call. Both form encoded and JSON payloads are
|
||||
/// accepted: the visual designer's SqlDataSource sends the edited record as JSON,
|
||||
/// accepted: the visual designer's Form sends the edited record as JSON,
|
||||
/// while older callers keep posting forms.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<string, object>> ReadBodyValuesAsync()
|
||||
|
|
|
|||
|
|
@ -159,34 +159,42 @@ public class ListFormWizardAppService(
|
|||
inserted.PermissionNames.Add(permRead.Name);
|
||||
}
|
||||
|
||||
// Read altındaki alt yetkiler: (suffix, dil anahtarı)
|
||||
var childPermissions = new[]
|
||||
// Read altındaki alt yetkiler: (suffix, dil anahtarı). CRUD üçlüsü her iki
|
||||
// yolda da üretilir; Custom component de Form üzerinden kayıt
|
||||
// ekler, günceller ve siler, ve tasarımcı butonları bu yetkilere bağlar.
|
||||
var crudPermissions = new[]
|
||||
{
|
||||
("Create", WizardConsts.LangKeyCreate),
|
||||
("Update", WizardConsts.LangKeyUpdate),
|
||||
("Delete", WizardConsts.LangKeyDelete),
|
||||
};
|
||||
|
||||
// Export/Import/Note yalnızca ListForm ekosisteminin sunduğu işlemlerdir;
|
||||
// Custom yolunda karşılığı olmadığı için üretilmez.
|
||||
var listOnlyPermissions = new[]
|
||||
{
|
||||
("Export", WizardConsts.LangKeyExport),
|
||||
("Import", WizardConsts.LangKeyImport),
|
||||
("Note", WizardConsts.LangKeyNote),
|
||||
};
|
||||
|
||||
// Custom yolunda kayıt üzerinde CRUD yapılmadığı için yalnızca read izni üretilir.
|
||||
var childPermissions = isCustomComponent
|
||||
? crudPermissions
|
||||
: [.. crudPermissions, .. listOnlyPermissions];
|
||||
|
||||
var permissionNames = new List<string> { permRead.Name };
|
||||
if (!isCustomComponent)
|
||||
foreach (var (suffix, langKey) in childPermissions)
|
||||
{
|
||||
foreach (var (suffix, langKey) in childPermissions)
|
||||
{
|
||||
var permissionName = $"{code}.{suffix}";
|
||||
permissionNames.Add(permissionName);
|
||||
var permissionName = $"{code}.{suffix}";
|
||||
permissionNames.Add(permissionName);
|
||||
|
||||
if (existingPerms.Any(a => a.Name == permissionName))
|
||||
continue;
|
||||
if (existingPerms.Any(a => a.Name == permissionName))
|
||||
continue;
|
||||
|
||||
await repoPerm.InsertAsync(
|
||||
new PermissionDefinitionRecord(GuidGenerator.Create(), groupName, permissionName, permRead.Name, langKey, true, MultiTenancySides.Both),
|
||||
autoSave: false);
|
||||
inserted.PermissionNames.Add(permissionName);
|
||||
}
|
||||
await repoPerm.InsertAsync(
|
||||
new PermissionDefinitionRecord(GuidGenerator.Create(), groupName, permissionName, permRead.Name, langKey, true, MultiTenancySides.Both),
|
||||
autoSave: false);
|
||||
inserted.PermissionNames.Add(permissionName);
|
||||
}
|
||||
|
||||
// Permission Grants - Bulk Insert (only missing ones)
|
||||
|
|
|
|||
|
|
@ -19602,6 +19602,18 @@
|
|||
"en": "Filters are sent as query parameters to the endpoint and apply to every component bound to it. The value can be static, come from the page URL, or come from the record of another Form.",
|
||||
"tr": "Filtreler endpointe query parametresi olarak gönderilir ve bu endpointe bağlı tüm komponentler için geçerlidir. Değer sabit olabilir, sayfa adresinden gelebilir veya başka bir Form kaydından okunabilir."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.KeySourceQuery",
|
||||
"en": "URL query string (?id=…)",
|
||||
"tr": "URL query string (?id=…)"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.KeySourceRoute",
|
||||
"en": "Last URL segment (/…/5)",
|
||||
"tr": "URL son segmenti (/…/5)"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.NoFilter",
|
||||
|
|
@ -19772,7 +19784,7 @@
|
|||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.BoundToSqlDataSource",
|
||||
"key": "App.DeveloperKitComponentDesigner.BoundToForm",
|
||||
"en": "This component is bound to a Form record; fields come from the columns in the Select (GET) response.",
|
||||
"tr": "Bu komponent Form kaydına bağlıdır; alanlar Select (GET) cevabındaki sütunlardan gelir."
|
||||
},
|
||||
|
|
@ -20576,7 +20588,7 @@
|
|||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.SqlDataSourceRecord",
|
||||
"key": "App.DeveloperKitComponentDesigner.FormRecord",
|
||||
"en": "Form record · {field}",
|
||||
"tr": "Form kaydı · {field}"
|
||||
},
|
||||
|
|
@ -24966,6 +24978,12 @@
|
|||
"en": "Sözsoft dynamic Gantt view",
|
||||
"tr": "Sözsoft dinamik Gantt görünümü"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogGroup.Form",
|
||||
"en": "Form",
|
||||
"tr": "Form"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogGroup.ApiCalls",
|
||||
|
|
@ -24984,12 +25002,6 @@
|
|||
"en": "Event and page",
|
||||
"tr": "Event ve sayfa"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogGroup.SqlDataSource",
|
||||
"en": "Form",
|
||||
"tr": "Form"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogHoverable.HoverableDescription",
|
||||
|
|
@ -25100,73 +25112,73 @@
|
|||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogSqlDataSource.SqlDataSourceDescription",
|
||||
"key": "App.CatalogForm.FormDescription",
|
||||
"en": "A data container that manages GET/POST/PUT/DELETE endpoints on a single record; the components dropped inside bind to its columns",
|
||||
"tr": "GET/POST/PUT/DELETE endpointlerini tek kayıt üzerinde yöneten veri kabı; içine bırakılan komponentler sütunlara bağlanır"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogSqlDataSource.SqlDataSourceDescription2",
|
||||
"key": "App.CatalogForm.FormDescription2",
|
||||
"en": "The field name is the column name in the Select result.",
|
||||
"tr": "Alan adı Select sonucundaki sütun adıdır."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogSqlDataSource.SqlDataSourceDescription3",
|
||||
"key": "App.CatalogForm.FormDescription3",
|
||||
"en": "When Select returns more than one row.",
|
||||
"tr": "Select birden fazla satır döndürdüğünde."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogSqlDataSource.SqlDataSourceDescription4",
|
||||
"key": "App.CatalogForm.FormDescription4",
|
||||
"en": "Stops the operation if a required field is empty.",
|
||||
"tr": "Zorunlu alan boşsa işlemi durdurur."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogSqlDataSource.SqlDataSourceDescription5",
|
||||
"key": "App.CatalogForm.FormDescription5",
|
||||
"en": "New record / editing, busy and error state.",
|
||||
"tr": "Yeni kayıt / düzenleme, meşgul ve hata durumu."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogSqlDataSource.SqlDataSourceTitleDescription",
|
||||
"key": "App.CatalogForm.FormTitleDescription",
|
||||
"en": "Header text of the card wrapping the container; leave empty to hide the header. A value starting with :: is resolved as a localization key.",
|
||||
"tr": "Kabı saran kartın başlık metni; boş bırakılırsa başlık gizlenir. :: ile başlayan değer dil anahtarı olarak çözümlenir."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogSqlDataSource.SqlDataSourceColumnCountDescription",
|
||||
"key": "App.CatalogForm.FormColumnCountDescription",
|
||||
"en": "How many components are placed side by side inside the form. 1 keeps every component on its own row; 2 places two components per row and wraps to the next one.",
|
||||
"tr": "Form içindeki bileşenlerin yan yana kaç sütun halinde dizileceği. 1 seçilirse her bileşen alt alta durur; 2 seçilirse her satıra iki bileşen yerleşir ve sonraki satıra geçer."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogSqlDataSource.SqlDataSourceTitle",
|
||||
"key": "App.CatalogForm.FormTitle",
|
||||
"en": "Save / delete / reload",
|
||||
"tr": "Kaydet / sil / yenile"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogSqlDataSource.SqlDataSourceTitle2",
|
||||
"key": "App.CatalogForm.FormTitle2",
|
||||
"en": "Reading and writing the active record",
|
||||
"tr": "Aktif kaydı okuma ve yazma"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogSqlDataSource.SqlDataSourceTitle3",
|
||||
"key": "App.CatalogForm.FormTitle3",
|
||||
"en": "Navigating between records",
|
||||
"tr": "Kayıtlar arasında gezinme"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogSqlDataSource.SqlDataSourceTitle4",
|
||||
"key": "App.CatalogForm.FormTitle4",
|
||||
"en": "Validating before saving",
|
||||
"tr": "Kaydetmeden önce doğrulama"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogSqlDataSource.SqlDataSourceTitle5",
|
||||
"key": "App.CatalogForm.FormTitle5",
|
||||
"en": "Status information",
|
||||
"tr": "Durum bilgisi"
|
||||
},
|
||||
|
|
@ -25596,12 +25608,6 @@
|
|||
"en": "Error occurred",
|
||||
"tr": "Hata oluştu"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.EventOnError.OnErrorLabel2",
|
||||
"en": "Last URL segment (/…/5)",
|
||||
"tr": "URL son segmenti (/…/5)"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.EventOnFieldChange.OnFieldChangeDescription",
|
||||
|
|
@ -26753,6 +26759,126 @@
|
|||
"key": "App.ListForm.WizardImportRollbackFailed",
|
||||
"en": "Rollback could not be completed",
|
||||
"tr": "Geri alma tamamlanamadı"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.EndpointPermission",
|
||||
"en": "Permission",
|
||||
"tr": "Yetki"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.UseCustomPermission",
|
||||
"en": "Use custom permission: {permission}",
|
||||
"tr": "Özel yetki kullan: {permission}"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.PermissionHintRead",
|
||||
"en": "Read permission: set, the whole container is hidden from users without it.",
|
||||
"tr": "Read yetkisi: doluysa yetki verilmemiş kullanıcıda konteyner hiç gösterilmez."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.PermissionHintInsert",
|
||||
"en": "Insert permission: empty means the endpoint is enough; set, the New button is hidden from users without it.",
|
||||
"tr": "Insert yetkisi: boşsa endpoint yeterli, doluysa yetki verilmemiş kullanıcıda Yeni butonu görünmez."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.PermissionHintUpdate",
|
||||
"en": "Update permission: empty means the endpoint is enough; set, the Save button is hidden from users without it.",
|
||||
"tr": "Update yetkisi: boşsa endpoint yeterli, doluysa yetki verilmemiş kullanıcıda Kaydet butonu görünmez."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.PermissionHintDelete",
|
||||
"en": "Delete permission: empty means the endpoint is enough; set, the Delete button is hidden from users without it.",
|
||||
"tr": "Delete yetkisi: boşsa endpoint yeterli, doluysa yetki verilmemiş kullanıcıda Sil butonu görünmez."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.PermissionHintComponentAuto",
|
||||
"en": "Automatic: no separate check is made for this component; the page's own menu permission decides.",
|
||||
"tr": "Otomatik: bu komponent için ayrıca yetki kontrolü yapılmaz; sayfanın kendi menü yetkisi geçerlidir."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.PermissionHintComponent",
|
||||
"en": "The component is not rendered for anyone without the permission picked above.",
|
||||
"tr": "Yukarıda seçilen yetkiye sahip olmayan kullanıcıya komponent hiç gösterilmez."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.PermissionTypeAuto",
|
||||
"en": "Automatic",
|
||||
"tr": "Otomatik"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.PermissionTypeCustom",
|
||||
"en": "Custom",
|
||||
"tr": "Custom"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.PermissionHintAuto",
|
||||
"en": "Automatic: the permission the Wizard created for this component is used (read for Select, .Create / .Update / .Delete for the commands). If no menu opens this component, nothing is checked.",
|
||||
"tr": "Otomatik: Wizard'ın bu component için ürettiği yetki kullanılır (Select için read, komutlar için .Create / .Update / .Delete). Component'i açan bir menü yoksa kontrol yapılmaz."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.DeveloperKitComponentDesigner.PermissionPlaceholder",
|
||||
"en": "Pick a permission",
|
||||
"tr": "Bir yetki seçin"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Platform.NewRecord",
|
||||
"en": "New record",
|
||||
"tr": "Yeni kayıt"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Form.NoChanges",
|
||||
"en": "There is nothing to save.",
|
||||
"tr": "Kaydedilecek bir değişiklik yok."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Form.InsertEndpointMissing",
|
||||
"en": "No POST endpoint is defined for Insert.",
|
||||
"tr": "Insert için POST endpointi tanımlı değil."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Form.UpdateEndpointMissing",
|
||||
"en": "No PUT endpoint is defined for Update.",
|
||||
"tr": "Update için PUT endpointi tanımlı değil."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Form.MissingKeyParam",
|
||||
"en": "The Select endpoint expects the {0} parameter; it was not found in the page address.",
|
||||
"tr": "Select endpointi {0} parametresini bekliyor; sayfa adresinde bulunamadı."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Form.DeleteConfirm",
|
||||
"en": "The record {0} will be deleted.\n\nDo you confirm?",
|
||||
"tr": "{0} kaydı silinecek.\n\nOnaylıyor musunuz?"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Platform.OptionFallback",
|
||||
"en": "Option {0}",
|
||||
"tr": "Seçenek {0}"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.CatalogForm.FormPermissionDescription",
|
||||
"en": "Permission guarding this command. Empty leaves it open: the bound endpoint alone is enough for its button.",
|
||||
"tr": "Bu komutu koruyan yetki. Boş bırakılırsa komut serbesttir: butonun görünmesi için bağlı endpoint yeterlidir."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -820,6 +820,24 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
|
|||
Visible = true,
|
||||
IsActive = true,
|
||||
|
||||
ColumnCustomizationJson = DefaultColumnCustomizationJson,
|
||||
PermissionJson = DefaultFieldPermissionJson(PlatformConsts.IdentityPermissions.Roles.Create, PlatformConsts.IdentityPermissions.Roles.Default, PlatformConsts.IdentityPermissions.Roles.Update, true, true, false),
|
||||
PivotSettingsJson = DefaultPivotSettingsJson
|
||||
},
|
||||
new() {
|
||||
ListFormCode = listForm.ListFormCode,
|
||||
RoleId = null,
|
||||
UserId = null,
|
||||
CultureName = LanguageCodes.En,
|
||||
SourceDbType = DbType.Guid,
|
||||
FieldName = "Id",
|
||||
ColSpan = 1,
|
||||
CaptionName = "App.Listform.ListformField.Id",
|
||||
Width = 0,
|
||||
ListOrderNo = 4,
|
||||
Visible = false,
|
||||
IsActive = true,
|
||||
|
||||
ColumnCustomizationJson = DefaultColumnCustomizationJson,
|
||||
PermissionJson = DefaultFieldPermissionJson(PlatformConsts.IdentityPermissions.Roles.Create, PlatformConsts.IdentityPermissions.Roles.Default, PlatformConsts.IdentityPermissions.Roles.Update, true, true, false),
|
||||
PivotSettingsJson = DefaultPivotSettingsJson
|
||||
|
|
|
|||
|
|
@ -201,21 +201,23 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency
|
|||
permRead = await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
|
||||
Guid.NewGuid(), groupName, code, null, nameLangKey, true, MultiTenancySides.Both), autoSave: true);
|
||||
|
||||
// Custom yolunda kayıt üzerinde CRUD yapılmadığı için yalnızca read izni üretilir.
|
||||
// CRUD üçlüsü her iki yolda da üretilir: Custom component de Form
|
||||
// üzerinden kayıt ekler, günceller ve siler (AppService.Create ile aynı davranış).
|
||||
if (!existingPerms.Any(a => a.Name == permCreateName))
|
||||
await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
|
||||
Guid.NewGuid(), groupName, permCreateName, permRead.Name, WizardConsts.LangKeyCreate, true, MultiTenancySides.Both), autoSave: true);
|
||||
|
||||
if (!existingPerms.Any(a => a.Name == permUpdateName))
|
||||
await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
|
||||
Guid.NewGuid(), groupName, permUpdateName, permRead.Name, WizardConsts.LangKeyUpdate, true, MultiTenancySides.Both), autoSave: true);
|
||||
|
||||
if (!existingPerms.Any(a => a.Name == permDeleteName))
|
||||
await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
|
||||
Guid.NewGuid(), groupName, permDeleteName, permRead.Name, WizardConsts.LangKeyDelete, true, MultiTenancySides.Both), autoSave: true);
|
||||
|
||||
// Export/Import/Note yalnızca ListForm ekosisteminde karşılığı olan işlemlerdir.
|
||||
if (!isCustomComponent)
|
||||
{
|
||||
if (!existingPerms.Any(a => a.Name == permCreateName))
|
||||
await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
|
||||
Guid.NewGuid(), groupName, permCreateName, permRead.Name, WizardConsts.LangKeyCreate, true, MultiTenancySides.Both), autoSave: true);
|
||||
|
||||
if (!existingPerms.Any(a => a.Name == permUpdateName))
|
||||
await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
|
||||
Guid.NewGuid(), groupName, permUpdateName, permRead.Name, WizardConsts.LangKeyUpdate, true, MultiTenancySides.Both), autoSave: true);
|
||||
|
||||
if (!existingPerms.Any(a => a.Name == permDeleteName))
|
||||
await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
|
||||
Guid.NewGuid(), groupName, permDeleteName, permRead.Name, WizardConsts.LangKeyDelete, true, MultiTenancySides.Both), autoSave: true);
|
||||
|
||||
if (!existingPerms.Any(a => a.Name == permExportName))
|
||||
await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
|
||||
Guid.NewGuid(), groupName, permExportName, permRead.Name, WizardConsts.LangKeyExport, true, MultiTenancySides.Both), autoSave: true);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
{
|
||||
"Wizard": {
|
||||
"ComponentKind": 1,
|
||||
"WizardName": "RoleList",
|
||||
|
|
@ -136,7 +136,10 @@
|
|||
],
|
||||
"PermissionGroupNames": [],
|
||||
"PermissionNames": [
|
||||
"App.Wizard.RoleList"
|
||||
"App.Wizard.RoleList",
|
||||
"App.Wizard.RoleList.Create",
|
||||
"App.Wizard.RoleList.Update",
|
||||
"App.Wizard.RoleList.Delete"
|
||||
],
|
||||
"MenuCodes": [
|
||||
"App.Wizard.RoleList"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import DOMPurify from 'dompurify'
|
||||
import apiService from '@/services/api.service'
|
||||
|
|
@ -9,6 +9,8 @@ import {
|
|||
toErrorMessage,
|
||||
type BabelLike,
|
||||
} from '@/contexts/componentRuntime'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { usePermission } from '@/utils/hooks/usePermission'
|
||||
import { useComponents } from '../../contexts/ComponentContext'
|
||||
import ErrorBoundary from './ErrorBoundary'
|
||||
import PlatformIcon from './PlatformIcon'
|
||||
|
|
@ -76,6 +78,23 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = ({
|
|||
const [Component, setComponent] = useState<React.ComponentType<any> | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { getComponentByName, components } = useComponents()
|
||||
const { checkPermission } = usePermission()
|
||||
const { translate } = useLocalization()
|
||||
|
||||
// Read through refs so the granted policies and the active language can change
|
||||
// without recompiling the component; generated code calls both on every render.
|
||||
const checkPermissionRef = useRef(checkPermission)
|
||||
checkPermissionRef.current = checkPermission
|
||||
const stableCheckPermission = useCallback(
|
||||
(permission?: string) => checkPermissionRef.current(permission),
|
||||
[],
|
||||
)
|
||||
const translateRef = useRef(translate)
|
||||
translateRef.current = translate
|
||||
const stableTranslate = useCallback(
|
||||
(...args: Parameters<typeof translate>) => translateRef.current(...args),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -126,7 +145,13 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = ({
|
|||
|
||||
const component = compileComponent(
|
||||
entry.code,
|
||||
{ React, ...staticComponents, ...deps },
|
||||
{
|
||||
React,
|
||||
...staticComponents,
|
||||
checkPermission: stableCheckPermission,
|
||||
translate: stableTranslate,
|
||||
...deps,
|
||||
},
|
||||
Babel,
|
||||
)
|
||||
if (!component) throw new Error(`Component ${name} bir default export döndürmüyor`)
|
||||
|
|
@ -149,7 +174,14 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = ({
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [componentName, externalDeps, components, getComponentByName])
|
||||
}, [
|
||||
componentName,
|
||||
externalDeps,
|
||||
components,
|
||||
getComponentByName,
|
||||
stableCheckPermission,
|
||||
stableTranslate,
|
||||
])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,19 +2,19 @@ import ScriptBuilderDialog from '@/components/scriptBuilder/ScriptBuilderDialog'
|
|||
import { useMemo } from 'react'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { createDesignerScriptDialect } from './designerScriptDialect'
|
||||
import type { SqlDataSourceEvent } from './types'
|
||||
import type { FormEvent } from './types'
|
||||
|
||||
export type DesignerScriptBuilderDialogProps = {
|
||||
isOpen: boolean
|
||||
value?: string
|
||||
/** Düzenlenen event: başlıkta ve event yolu seçicilerinde kullanılır. */
|
||||
eventName: string
|
||||
eventInfo?: SqlDataSourceEvent
|
||||
eventInfo?: FormEvent
|
||||
/** Event'in sahibi komponentin adı, yalnızca başlık için. */
|
||||
componentLabel?: string
|
||||
/** Kayıt tariflerinin yazacağı SqlDataSource ref adı. */
|
||||
/** Kayıt tariflerinin yazacağı Form ref adı. */
|
||||
sqlRef: string
|
||||
/** SqlDataSource Select sonucundaki sütun adları. */
|
||||
/** Form Select sonucundaki sütun adları. */
|
||||
recordFields: string[]
|
||||
/** Sayfadaki tüm ref adları. */
|
||||
refNames: string[]
|
||||
|
|
|
|||
|
|
@ -20,13 +20,13 @@ import {
|
|||
getDesignerCollectionProperty,
|
||||
getDesignerTabSlot,
|
||||
getDesignerValueByPath,
|
||||
getSqlDataSourceEndpointId,
|
||||
getSqlDataSourceColumnCount,
|
||||
getFormEndpointId,
|
||||
getFormColumnCount,
|
||||
getDesignerColumnCaptions,
|
||||
getUsableDesignerColumnLookups,
|
||||
buildDesignerLookupIndex,
|
||||
resolveDesignerLookupText,
|
||||
getSqlDataSourceKeyField,
|
||||
getFormKeyField,
|
||||
getSqlFormValueProperty,
|
||||
isDesignerDateComponent,
|
||||
isDesignerDateProperty,
|
||||
|
|
@ -34,26 +34,26 @@ import {
|
|||
DESIGNER_ICON_PROPERTIES,
|
||||
DESIGNER_SELECT_COMPONENT_PROPERTY,
|
||||
flexRowStyle,
|
||||
isSqlDataSourceNode,
|
||||
LEGACY_SQL_DATA_SOURCE_TYPE,
|
||||
isFormNode,
|
||||
normalizeDesignerKeyList,
|
||||
readSqlDataSourceField,
|
||||
readFormField,
|
||||
resolveDesignerDropdownTitle,
|
||||
resolveDesignerPreviewFilterValue,
|
||||
resolveDesignerTabValue,
|
||||
resolveSqlDataSourceRows,
|
||||
resolveFormRows,
|
||||
setDesignerRecordField,
|
||||
SQL_DATA_SOURCE_TYPE,
|
||||
FORM_TYPE,
|
||||
SQL_DEFAULT_VALUE_PROP,
|
||||
toDesignerDate,
|
||||
type DesignerBinding,
|
||||
type DesignerDataSourceFilter,
|
||||
type DesignerDocument,
|
||||
type DesignerNode,
|
||||
type SqlDataSourceMode,
|
||||
type FormMode,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Editing scope opened by a SqlDataSource: a descendant whose `value`/`checked`
|
||||
* Editing scope opened by a Form: a descendant whose `value`/`checked`
|
||||
* is bound to `sourceId` writes back into the record instead of into its own
|
||||
* static prop, which is what makes the container behave like an ASP.NET FormView.
|
||||
*/
|
||||
|
|
@ -173,6 +173,12 @@ interface VisualCanvasProps {
|
|||
onNodePropChange?: (id: string, propertyName: string, value: unknown) => void
|
||||
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode
|
||||
dataValues?: Record<string, unknown>
|
||||
/**
|
||||
* Device the canvas is simulating. A Form lays its fields out in one column on
|
||||
* a phone however many columns are configured, and the preview has to show
|
||||
* that rather than leave the designer guessing.
|
||||
*/
|
||||
canvasWidth?: DesignerDocument['canvas']['width']
|
||||
}
|
||||
|
||||
class PreviewBoundary extends React.Component<
|
||||
|
|
@ -270,7 +276,7 @@ const buildDesignerRefs = (store: DesignerRefStore | null) => {
|
|||
store.patch(ref, { props: { readOnly: readOnly !== false } }),
|
||||
setText: (text: unknown) => store.patch(ref, { props: { children: text } }),
|
||||
reset: () => store.patch(ref, { props: null as unknown as Record<string, unknown> }),
|
||||
...(isSqlDataSourceNode(node.type)
|
||||
...(isFormNode(node.type)
|
||||
? {
|
||||
getRecord: () => ({}),
|
||||
setRecord: designTimeOnly('setRecord'),
|
||||
|
|
@ -282,7 +288,7 @@ const buildDesignerRefs = (store: DesignerRefStore | null) => {
|
|||
getChanges: () => ({}),
|
||||
hasChanges: () => false,
|
||||
getKey: () => undefined,
|
||||
getKeyField: () => getSqlDataSourceKeyField(node),
|
||||
getKeyField: () => getFormKeyField(node),
|
||||
getRows: () => [],
|
||||
getRowCount: () => 0,
|
||||
getIndex: () => 0,
|
||||
|
|
@ -484,7 +490,7 @@ const getSelectMenuProps = (props: Record<string, unknown>) => {
|
|||
const getTabOptions = (props: Record<string, unknown>) =>
|
||||
Array.isArray(props.items) ? (props.items as Array<Record<string, unknown>>) : []
|
||||
|
||||
/** Prop of a node that a SqlDataSource scope is allowed to write back to. */
|
||||
/** Prop of a node that a Form scope is allowed to write back to. */
|
||||
const getFormScopeProperty = (node: DesignerNode, formScope?: DesignerFormScope) =>
|
||||
getSqlFormValueProperty(node, formScope?.sourceId)
|
||||
|
||||
|
|
@ -623,7 +629,7 @@ const getPreviewProps = (
|
|||
: value && typeof value === 'object' && 'target' in value
|
||||
? Boolean((value as { target?: { checked?: unknown } }).target?.checked)
|
||||
: Boolean(value)
|
||||
// Inside a SqlDataSource the edited value belongs to the record, not to the
|
||||
// Inside a Form the edited value belongs to the record, not to the
|
||||
// node's static prop — otherwise typing into a bound Input would be discarded
|
||||
// on the next render because the binding always wins.
|
||||
const formScopeProperty = getFormScopeProperty(node, formScope)
|
||||
|
|
@ -868,7 +874,15 @@ const focusFirstFormField = (host: React.RefObject<HTMLElement | null>) => {
|
|||
})
|
||||
}
|
||||
|
||||
const SqlDataSourceView = ({
|
||||
/**
|
||||
* Device the canvas frame is simulating. The runtime lays a Form out from CSS
|
||||
* breakpoints, which answer to the browser window rather than to the frame the
|
||||
* designer is looking at — so the preview reads the simulated device from here
|
||||
* instead, and shows what a phone would actually get.
|
||||
*/
|
||||
const CanvasWidthContext = React.createContext<DesignerDocument['canvas']['width']>('responsive')
|
||||
|
||||
const FormView = ({
|
||||
node,
|
||||
dataValues,
|
||||
interactive,
|
||||
|
|
@ -883,18 +897,22 @@ const SqlDataSourceView = ({
|
|||
) => React.ReactNode
|
||||
}) => {
|
||||
const { translate } = useLocalization()
|
||||
const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint')
|
||||
const keyField = getSqlDataSourceKeyField(node)
|
||||
const selectId = getFormEndpointId(node, 'selectEndpoint')
|
||||
const keyField = getFormKeyField(node)
|
||||
const collectionPath = String(node.props.collectionPath ?? '')
|
||||
const columnCount = getSqlDataSourceColumnCount(node)
|
||||
const canvasWidth = React.useContext(CanvasWidthContext)
|
||||
const isNarrowCanvas = canvasWidth === 'mobile'
|
||||
// Matches the generated component: below the `md` breakpoint every field takes
|
||||
// the full width, however many columns are configured.
|
||||
const columnCount = isNarrowCanvas ? 1 : getFormColumnCount(node)
|
||||
const rows = React.useMemo(
|
||||
() => (selectId ? resolveSqlDataSourceRows(dataValues[selectId], collectionPath) : []),
|
||||
() => (selectId ? resolveFormRows(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')
|
||||
const [mode, setMode] = React.useState<FormMode>('edit')
|
||||
// Holds the whole record while editing, so New mode can show an empty form
|
||||
// instead of falling back to the loaded row.
|
||||
const [draft, setDraft] = React.useState<Record<string, unknown> | null>(null)
|
||||
|
|
@ -927,11 +945,11 @@ const SqlDataSourceView = ({
|
|||
setDraft(null)
|
||||
setMode('edit')
|
||||
}
|
||||
const keyValue = readSqlDataSourceField(record, keyField)
|
||||
const keyValue = readFormField(record, keyField)
|
||||
const hasKey = keyValue !== undefined && keyValue !== null && keyValue !== ''
|
||||
const canInsert = Boolean(getSqlDataSourceEndpointId(node, 'insertEndpoint'))
|
||||
const canUpdate = Boolean(getSqlDataSourceEndpointId(node, 'updateEndpoint'))
|
||||
const canDelete = Boolean(getSqlDataSourceEndpointId(node, 'deleteEndpoint'))
|
||||
const canInsert = Boolean(getFormEndpointId(node, 'insertEndpoint'))
|
||||
const canUpdate = Boolean(getFormEndpointId(node, 'updateEndpoint'))
|
||||
const canDelete = Boolean(getFormEndpointId(node, 'deleteEndpoint'))
|
||||
// Save follows the explicit mode, exactly like the generated runtime does.
|
||||
const canSave = mode === 'new' ? canInsert : canUpdate
|
||||
const designTimeTitle = translate('::App.VisualDesignerCanvas.DesignTimeNoCall')
|
||||
|
|
@ -980,7 +998,7 @@ const SqlDataSourceView = ({
|
|||
>
|
||||
{interactive && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-dashed border-sky-300 bg-sky-50 px-2.5 py-1.5 text-[10px] text-sky-800 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-200">
|
||||
<span className="font-semibold uppercase tracking-wider">{SQL_DATA_SOURCE_TYPE}</span>
|
||||
<span className="font-semibold uppercase tracking-wider">{FORM_TYPE}</span>
|
||||
<span>
|
||||
key: <code>{keyField}</code>
|
||||
</span>
|
||||
|
|
@ -991,9 +1009,7 @@ const SqlDataSourceView = ({
|
|||
</span>
|
||||
<span className="rounded bg-sky-600 px-1.5 py-0.5 font-semibold text-white">
|
||||
{translate(
|
||||
mode === 'new'
|
||||
? '::App.VisualDesignerCanvas.NewRecord'
|
||||
: '::App.Platform.Editing',
|
||||
mode === 'new' ? '::App.VisualDesignerCanvas.NewRecord' : '::App.Platform.Editing',
|
||||
)}
|
||||
</span>
|
||||
{rows.length > 1 && (
|
||||
|
|
@ -1111,7 +1127,14 @@ const SqlDataSourceView = ({
|
|||
<UiKit.Card
|
||||
bodyClass="p-4"
|
||||
header={cardTitle || undefined}
|
||||
headerExtra={cardTitle ? headerExtra : undefined}
|
||||
// Mirrors the generated component: title and record key stack until the
|
||||
// row is wide enough to hold both.
|
||||
headerClass={
|
||||
isNarrowCanvas
|
||||
? 'flex-col items-start gap-1'
|
||||
: 'flex-col items-start gap-1 sm:flex-row sm:items-center sm:justify-between'
|
||||
}
|
||||
headerExtra={cardTitle ? <span className="break-all">{headerExtra}</span> : undefined}
|
||||
>
|
||||
{body}
|
||||
</UiKit.Card>
|
||||
|
|
@ -1185,7 +1208,7 @@ const renderElement = (
|
|||
) => {
|
||||
// Built in NodeView so the container can own the record state and expose it to
|
||||
// its children through an augmented `dataValues` map.
|
||||
if (isSqlDataSourceNode(node.type)) return <>{children}</>
|
||||
if (isFormNode(node.type)) return <>{children}</>
|
||||
if (node.type === 'Spacer') {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -1471,8 +1494,7 @@ const NodeView = ({
|
|||
'Timeline',
|
||||
// Dropped components land in whichever tab is open.
|
||||
'Tabs',
|
||||
SQL_DATA_SOURCE_TYPE,
|
||||
LEGACY_SQL_DATA_SOURCE_TYPE,
|
||||
FORM_TYPE,
|
||||
].includes(node.type)
|
||||
|
||||
/* ---- Sürükleme geri bildirimi ---------------------------------- */
|
||||
|
|
@ -1630,8 +1652,8 @@ const NodeView = ({
|
|||
)),
|
||||
)
|
||||
const children = renderChildNodes(dataValues, formScope)
|
||||
const sqlDataSourceContent = isSqlDataSourceNode(node.type) ? (
|
||||
<SqlDataSourceView
|
||||
const formContent = isFormNode(node.type) ? (
|
||||
<FormView
|
||||
dataValues={dataValues}
|
||||
interactive={interactive}
|
||||
node={node}
|
||||
|
|
@ -1834,8 +1856,8 @@ const NodeView = ({
|
|||
</tbody>
|
||||
</UiKit.Table>
|
||||
) : null
|
||||
const renderedChildren = sqlDataSourceContent
|
||||
? sqlDataSourceContent
|
||||
const renderedChildren = formContent
|
||||
? formContent
|
||||
: node.type === 'Tabs'
|
||||
? tabsContent
|
||||
: node.type === 'Table'
|
||||
|
|
@ -2158,8 +2180,8 @@ const NodeView = ({
|
|||
acceptsDroppedChildren &&
|
||||
node.type !== 'Card' &&
|
||||
node.type !== 'Tabs' &&
|
||||
// SqlDataSource renders its own placeholder below the toolbar.
|
||||
!isSqlDataSourceNode(node.type) && (
|
||||
// Form renders its own placeholder below the toolbar.
|
||||
!isFormNode(node.type) && (
|
||||
<div
|
||||
className="m-2 rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400"
|
||||
{...containerDropZoneProps}
|
||||
|
|
@ -2188,6 +2210,7 @@ const VisualCanvas = ({
|
|||
onNodePropChange,
|
||||
renderCustomComponent,
|
||||
dataValues = {},
|
||||
canvasWidth = 'responsive',
|
||||
}: VisualCanvasProps) => {
|
||||
const { translate } = useLocalization()
|
||||
const previewDataValues = dataValues
|
||||
|
|
@ -2249,127 +2272,129 @@ const VisualCanvas = ({
|
|||
)
|
||||
|
||||
return (
|
||||
<DesignerRefContext.Provider value={refStore}>
|
||||
<div
|
||||
className={`min-h-full p-8 text-slate-900 transition dark:text-slate-100 ${
|
||||
drag && interactive ? 'bg-sky-50/40 dark:bg-sky-950/20' : ''
|
||||
}`}
|
||||
onClick={() => interactive && onSelect?.('')}
|
||||
onDragOver={(event) => interactive && event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
endDesignerDrag()
|
||||
const raw =
|
||||
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
|
||||
event.dataTransfer.getData('text/plain')
|
||||
if (!raw) return
|
||||
const payload = JSON.parse(raw)
|
||||
if (payload.source === 'library') {
|
||||
onDropComponent?.(payload.name, null)
|
||||
} else if (payload.source === 'canvas' && payload.nodeId) {
|
||||
// Dropping on empty canvas takes the node out of its container.
|
||||
onMoveIntoContainer?.(payload.nodeId, null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drag && interactive && (
|
||||
<div className="pointer-events-none sticky top-0 z-40 mb-3 flex items-center justify-center">
|
||||
<span className="rounded-full bg-slate-900/90 px-3 py-1 text-[11px] font-medium text-white shadow-lg">
|
||||
<b>{drag.label || drag.name}</b>{' '}
|
||||
{translate('::App.VisualDesignerCanvas.DraggingHint')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{nodes.length ? (
|
||||
<div className="space-y-4">
|
||||
{nodes.map((node, index) => (
|
||||
<NodeView
|
||||
key={node.id}
|
||||
isRoot
|
||||
node={node}
|
||||
index={index}
|
||||
siblingCount={nodes.length}
|
||||
selectedId={selectedId}
|
||||
interactive={interactive}
|
||||
renderCustomComponent={renderCustomComponent}
|
||||
dataValues={previewDataValues}
|
||||
onSelect={onSelect}
|
||||
onDropComponent={onDropComponent}
|
||||
onDropComponentBeside={onDropComponentBeside}
|
||||
onMoveIntoContainer={onMoveIntoContainer}
|
||||
onMove={onMove}
|
||||
onReorder={onReorder}
|
||||
onDuplicate={onDuplicate}
|
||||
onDelete={onDelete}
|
||||
onNodePropChange={onNodePropChange}
|
||||
/>
|
||||
))}
|
||||
{/* Sürükleme sürerken kök seviye açık bir hedef olur; aksi halde bir
|
||||
<CanvasWidthContext.Provider value={canvasWidth}>
|
||||
<DesignerRefContext.Provider value={refStore}>
|
||||
<div
|
||||
className={`min-h-full p-8 text-slate-900 transition dark:text-slate-100 ${
|
||||
drag && interactive ? 'bg-sky-50/40 dark:bg-sky-950/20' : ''
|
||||
}`}
|
||||
onClick={() => interactive && onSelect?.('')}
|
||||
onDragOver={(event) => interactive && event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
if (!interactive) return
|
||||
event.preventDefault()
|
||||
endDesignerDrag()
|
||||
const raw =
|
||||
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
|
||||
event.dataTransfer.getData('text/plain')
|
||||
if (!raw) return
|
||||
const payload = JSON.parse(raw)
|
||||
if (payload.source === 'library') {
|
||||
onDropComponent?.(payload.name, null)
|
||||
} else if (payload.source === 'canvas' && payload.nodeId) {
|
||||
// Dropping on empty canvas takes the node out of its container.
|
||||
onMoveIntoContainer?.(payload.nodeId, null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drag && interactive && (
|
||||
<div className="pointer-events-none sticky top-0 z-40 mb-3 flex items-center justify-center">
|
||||
<span className="rounded-full bg-slate-900/90 px-3 py-1 text-[11px] font-medium text-white shadow-lg">
|
||||
<b>{drag.label || drag.name}</b>{' '}
|
||||
{translate('::App.VisualDesignerCanvas.DraggingHint')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{nodes.length ? (
|
||||
<div className="space-y-4">
|
||||
{nodes.map((node, index) => (
|
||||
<NodeView
|
||||
key={node.id}
|
||||
isRoot
|
||||
node={node}
|
||||
index={index}
|
||||
siblingCount={nodes.length}
|
||||
selectedId={selectedId}
|
||||
interactive={interactive}
|
||||
renderCustomComponent={renderCustomComponent}
|
||||
dataValues={previewDataValues}
|
||||
onSelect={onSelect}
|
||||
onDropComponent={onDropComponent}
|
||||
onDropComponentBeside={onDropComponentBeside}
|
||||
onMoveIntoContainer={onMoveIntoContainer}
|
||||
onMove={onMove}
|
||||
onReorder={onReorder}
|
||||
onDuplicate={onDuplicate}
|
||||
onDelete={onDelete}
|
||||
onNodePropChange={onNodePropChange}
|
||||
/>
|
||||
))}
|
||||
{/* Sürükleme sürerken kök seviye açık bir hedef olur; aksi halde bir
|
||||
konteynerin dışına çıkmanın görünür bir yolu yok. */}
|
||||
{drag && interactive && (
|
||||
<div
|
||||
className={`rounded-lg border border-dashed px-3 py-4 text-center text-xs transition ${
|
||||
rootDropZone.over
|
||||
? 'border-emerald-500 bg-emerald-50 font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200'
|
||||
: 'border-sky-400 bg-sky-50/60 text-sky-600 dark:bg-sky-950/40 dark:text-sky-300'
|
||||
}`}
|
||||
{...rootDropZone.props}
|
||||
>
|
||||
{translate('::App.VisualDesignerCanvas.DropAtPageEnd')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`flex min-h-[520px] items-center justify-center rounded-xl border bg-white/70 text-center transition dark:bg-slate-900/70 ${
|
||||
rootDropZone.over
|
||||
? 'border-2 border-dashed border-emerald-500 bg-emerald-50/70 dark:bg-emerald-950/40'
|
||||
: drag && interactive
|
||||
? 'border-2 border-dashed border-sky-400'
|
||||
: 'border-slate-300 dark:border-slate-700'
|
||||
}`}
|
||||
{...(interactive ? rootDropZone.props : {})}
|
||||
>
|
||||
<div className="w-full max-w-2xl px-6">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-100 text-2xl text-sky-600">
|
||||
+
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-700 dark:text-slate-200">
|
||||
{translate('::App.VisualDesignerCanvas.ChoosePageLayout')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{translate('::App.VisualDesignerCanvas.ChoosePageLayoutHint')}
|
||||
</p>
|
||||
{interactive && (
|
||||
<div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{[
|
||||
// The component name itself is the label, so a layout is
|
||||
// called the same here as it is in the toolbox.
|
||||
['PageContainer', '□'],
|
||||
['FlexRow', '▥'],
|
||||
['Table', '▦'],
|
||||
].map(([name, icon]) => (
|
||||
<button
|
||||
key={name}
|
||||
className="rounded-lg border border-slate-200 bg-white p-3 text-xs font-semibold text-slate-600 shadow-sm transition hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onDropComponent?.(name, null)
|
||||
}}
|
||||
>
|
||||
<span className="mb-1 block text-xl text-sky-500">{icon}</span>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
{drag && interactive && (
|
||||
<div
|
||||
className={`rounded-lg border border-dashed px-3 py-4 text-center text-xs transition ${
|
||||
rootDropZone.over
|
||||
? 'border-emerald-500 bg-emerald-50 font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200'
|
||||
: 'border-sky-400 bg-sky-50/60 text-sky-600 dark:bg-sky-950/40 dark:text-sky-300'
|
||||
}`}
|
||||
{...rootDropZone.props}
|
||||
>
|
||||
{translate('::App.VisualDesignerCanvas.DropAtPageEnd')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DesignerRefContext.Provider>
|
||||
) : (
|
||||
<div
|
||||
className={`flex min-h-[520px] items-center justify-center rounded-xl border bg-white/70 text-center transition dark:bg-slate-900/70 ${
|
||||
rootDropZone.over
|
||||
? 'border-2 border-dashed border-emerald-500 bg-emerald-50/70 dark:bg-emerald-950/40'
|
||||
: drag && interactive
|
||||
? 'border-2 border-dashed border-sky-400'
|
||||
: 'border-slate-300 dark:border-slate-700'
|
||||
}`}
|
||||
{...(interactive ? rootDropZone.props : {})}
|
||||
>
|
||||
<div className="w-full max-w-2xl px-6">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-100 text-2xl text-sky-600">
|
||||
+
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-700 dark:text-slate-200">
|
||||
{translate('::App.VisualDesignerCanvas.ChoosePageLayout')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{translate('::App.VisualDesignerCanvas.ChoosePageLayoutHint')}
|
||||
</p>
|
||||
{interactive && (
|
||||
<div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{[
|
||||
// The component name itself is the label, so a layout is
|
||||
// called the same here as it is in the toolbox.
|
||||
['PageContainer', '□'],
|
||||
['FlexRow', '▥'],
|
||||
['Table', '▦'],
|
||||
].map(([name, icon]) => (
|
||||
<button
|
||||
key={name}
|
||||
className="rounded-lg border border-slate-200 bg-white p-3 text-xs font-semibold text-slate-600 shadow-sm transition hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onDropComponent?.(name, null)
|
||||
}}
|
||||
>
|
||||
<span className="mb-1 block text-xl text-sky-500">{icon}</span>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DesignerRefContext.Provider>
|
||||
</CanvasWidthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { CUSTOM_COMPONENTS, HTML_ELEMENTS } from '@/components/codeLayout/data/c
|
|||
import generatedComponentProps from './generated/componentProps.json'
|
||||
import {
|
||||
DESIGNER_DATA_COMPONENT_NAMES,
|
||||
SQL_DATA_SOURCE_EVENTS,
|
||||
LEGACY_SQL_DATA_SOURCE_TYPE,
|
||||
SQL_DATA_SOURCE_TYPE,
|
||||
FORM_EVENTS,
|
||||
FORM_SLOTS,
|
||||
FORM_TYPE,
|
||||
getDesignerCollectionProperty,
|
||||
isDesignerOptionComponent,
|
||||
isDesignerTabularComponent,
|
||||
|
|
@ -162,7 +162,7 @@ const selectedValue = event?.value ?? null
|
|||
const selectedLabel = event?.label ?? ''
|
||||
console.log({ selectedValue, selectedLabel })`,
|
||||
},
|
||||
[SQL_DATA_SOURCE_TYPE]: {
|
||||
[FORM_TYPE]: {
|
||||
onLoad: `// Select cevabı forma yerleştikten sonra çalışır.
|
||||
// event: { rows, count, record, index }
|
||||
if (!event.count) notify('Kayıt bulunamadı', 'warning')`,
|
||||
|
|
@ -217,7 +217,7 @@ export interface DesignerScriptRecipe {
|
|||
export const DESIGNER_SCRIPT_RECIPE_GROUPS = [
|
||||
'App.CatalogGroup.ComponentAccess',
|
||||
'App.CatalogGroup.ApiCalls',
|
||||
'App.CatalogGroup.SqlDataSource',
|
||||
'App.CatalogGroup.Form',
|
||||
'App.CatalogGroup.EventAndPage',
|
||||
] as const
|
||||
|
||||
|
|
@ -354,33 +354,33 @@ refs.{{sql}}.setFields({ UnitPrice: fiyat, Stock: stok })`,
|
|||
console.log(secilen, refs.{{ref}}.getLabel())`,
|
||||
},
|
||||
{
|
||||
group: 'App.CatalogGroup.SqlDataSource',
|
||||
title: 'App.CatalogSqlDataSource.SqlDataSourceTitle',
|
||||
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription',
|
||||
group: 'App.CatalogGroup.Form',
|
||||
title: 'App.CatalogForm.FormTitle',
|
||||
description: 'App.CatalogForm.FormDescription',
|
||||
code: `await refs.{{sql}}.save()
|
||||
// await refs.{{sql}}.remove()
|
||||
// await refs.{{sql}}.reload()
|
||||
// refs.{{sql}}.newRecord()`,
|
||||
},
|
||||
{
|
||||
group: 'App.CatalogGroup.SqlDataSource',
|
||||
title: 'App.CatalogSqlDataSource.SqlDataSourceTitle2',
|
||||
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription2',
|
||||
group: 'App.CatalogGroup.Form',
|
||||
title: 'App.CatalogForm.FormTitle2',
|
||||
description: 'App.CatalogForm.FormDescription2',
|
||||
code: `const record = refs.{{sql}}.getRecord()
|
||||
console.log(record)
|
||||
refs.{{sql}}.setField('name', 'Yeni ad')`,
|
||||
},
|
||||
{
|
||||
group: 'App.CatalogGroup.SqlDataSource',
|
||||
title: 'App.CatalogSqlDataSource.SqlDataSourceTitle3',
|
||||
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription3',
|
||||
group: 'App.CatalogGroup.Form',
|
||||
title: 'App.CatalogForm.FormTitle3',
|
||||
description: 'App.CatalogForm.FormDescription3',
|
||||
code: `const rows = refs.{{sql}}.getRows()
|
||||
refs.{{sql}}.goToRow(rows.length - 1)`,
|
||||
},
|
||||
{
|
||||
group: 'App.CatalogGroup.SqlDataSource',
|
||||
title: 'App.CatalogSqlDataSource.SqlDataSourceTitle4',
|
||||
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription4',
|
||||
group: 'App.CatalogGroup.Form',
|
||||
title: 'App.CatalogForm.FormTitle4',
|
||||
description: 'App.CatalogForm.FormDescription4',
|
||||
code: `const record = refs.{{sql}}.getRecord()
|
||||
if (!record.name) {
|
||||
notify('Ad alanı zorunludur', 'warning')
|
||||
|
|
@ -389,9 +389,9 @@ if (!record.name) {
|
|||
await refs.{{sql}}.save()`,
|
||||
},
|
||||
{
|
||||
group: 'App.CatalogGroup.SqlDataSource',
|
||||
title: 'App.CatalogSqlDataSource.SqlDataSourceTitle5',
|
||||
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription5',
|
||||
group: 'App.CatalogGroup.Form',
|
||||
title: 'App.CatalogForm.FormTitle5',
|
||||
description: 'App.CatalogForm.FormDescription5',
|
||||
code: `console.log(refs.{{sql}}.getMode(), refs.{{sql}}.isBusy(), refs.{{sql}}.getError())`,
|
||||
},
|
||||
{
|
||||
|
|
@ -425,11 +425,7 @@ console.log(id)`,
|
|||
]
|
||||
|
||||
export const getDesignerEventSnippet = (componentName: string, eventName: string) =>
|
||||
// A page saved before the container was renamed still carries the old type,
|
||||
// and its event scripts deserve the same starting snippet.
|
||||
DESIGNER_EVENT_SNIPPETS[
|
||||
componentName === LEGACY_SQL_DATA_SOURCE_TYPE ? SQL_DATA_SOURCE_TYPE : componentName
|
||||
]?.[eventName] ||
|
||||
DESIGNER_EVENT_SNIPPETS[componentName]?.[eventName] ||
|
||||
`// ${componentName}.${eventName}(event)\nconsole.log(event)`
|
||||
const COLLECTION_ITEMS_PROPERTY = {
|
||||
name: 'items',
|
||||
|
|
@ -901,18 +897,18 @@ export const PLATFORM_COMPONENTS: DesignerComponentDefinition[] = [
|
|||
]
|
||||
|
||||
/**
|
||||
* ASP.NET's SqlDataSource + FormView in one component: it owns the four CRUD
|
||||
* ASP.NET's Form + FormView in one component: it owns the four CRUD
|
||||
* endpoints and acts as a container, so every component dropped inside it can
|
||||
* bind to a column of the Select result and write back through Save/Delete.
|
||||
* The endpoints are configured from the inspector's Data tab, not here.
|
||||
*/
|
||||
export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
|
||||
name: SQL_DATA_SOURCE_TYPE,
|
||||
export const FORM_DEFINITION: DesignerComponentDefinition = {
|
||||
name: FORM_TYPE,
|
||||
icon: 'Database',
|
||||
category: 'data',
|
||||
kind: 'layout',
|
||||
toolboxGroup: 'data',
|
||||
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription',
|
||||
description: 'App.CatalogForm.FormDescription',
|
||||
acceptsChildren: true,
|
||||
properties: [
|
||||
// Filled in, the container renders inside a Card whose header carries this
|
||||
|
|
@ -923,7 +919,7 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
|
|||
type: 'string',
|
||||
value: '',
|
||||
category: 'properties',
|
||||
description: 'App.CatalogSqlDataSource.SqlDataSourceTitleDescription',
|
||||
description: 'App.CatalogForm.FormTitleDescription',
|
||||
},
|
||||
{
|
||||
name: 'selectEndpoint',
|
||||
|
|
@ -953,6 +949,16 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
|
|||
category: 'properties',
|
||||
description: 'App.CatalogString.StringDescription4',
|
||||
},
|
||||
// Permission of each command, configured next to its endpoint in the Data
|
||||
// tab. Empty leaves the command open: a bound URL is then enough for the
|
||||
// button to appear, which is how every container built so far behaves.
|
||||
...FORM_SLOTS.map((slot) => ({
|
||||
name: slot.permissionProperty,
|
||||
type: 'string' as const,
|
||||
value: '',
|
||||
category: 'properties' as const,
|
||||
description: 'App.CatalogForm.FormPermissionDescription',
|
||||
})),
|
||||
{
|
||||
name: 'keyFieldName',
|
||||
type: 'string',
|
||||
|
|
@ -1011,7 +1017,7 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
|
|||
type: 'number',
|
||||
value: 1,
|
||||
category: 'styling',
|
||||
description: 'App.CatalogSqlDataSource.SqlDataSourceColumnCountDescription',
|
||||
description: 'App.CatalogForm.FormColumnCountDescription',
|
||||
},
|
||||
{
|
||||
name: 'gap',
|
||||
|
|
@ -1023,7 +1029,7 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
|
|||
// Record lifecycle. `category: 'events'` keeps them out of the Properties
|
||||
// panel and moves them into the JavaScript tab, where they are edited as
|
||||
// scripts rather than as values.
|
||||
...SQL_DATA_SOURCE_EVENTS.map((event) => ({
|
||||
...FORM_EVENTS.map((event) => ({
|
||||
name: event.name,
|
||||
type: 'function' as const,
|
||||
value: '',
|
||||
|
|
@ -1138,7 +1144,7 @@ export const getDesignerCatalog = (customNames: string[] = []): DesignerComponen
|
|||
|
||||
return [
|
||||
...DESIGNER_EXTRAS,
|
||||
SQL_DATA_SOURCE_DEFINITION,
|
||||
FORM_DEFINITION,
|
||||
...PLATFORM_COMPONENTS,
|
||||
...html,
|
||||
...ui,
|
||||
|
|
|
|||
|
|
@ -5,16 +5,19 @@ import {
|
|||
getDesignerTabSlotValue,
|
||||
getDesignerDataSourceFilters,
|
||||
getDesignerNodeFilters,
|
||||
getSqlDataSourceColumnCount,
|
||||
getDesignerNodePermission,
|
||||
resolveFormPermission,
|
||||
DESIGNER_PERMISSION_PROP,
|
||||
getFormColumnCount,
|
||||
getDesignerColumnCaptions,
|
||||
getUsableDesignerColumnLookups,
|
||||
getSqlDataSourceEndpointId,
|
||||
getSqlDataSourceKeyField,
|
||||
getSqlDataSourceKeyParam,
|
||||
getSqlDataSourceKeySource,
|
||||
getSqlDataSourceScriptedEvents,
|
||||
getFormEndpointId,
|
||||
getFormKeyField,
|
||||
getFormKeyParam,
|
||||
getFormKeySource,
|
||||
getFormScriptedEvents,
|
||||
getSqlFormValueProperty,
|
||||
hasSqlDataSourceUrlParams,
|
||||
hasFormUrlParams,
|
||||
isDesignerDateComponent,
|
||||
isDesignerDateOnlyComponent,
|
||||
isDesignerDateProperty,
|
||||
|
|
@ -22,13 +25,13 @@ import {
|
|||
DESIGNER_ICON_PROPERTIES,
|
||||
DESIGNER_SELECT_COMPONENT_PROPERTY,
|
||||
flexRowStyle,
|
||||
isSqlDataSourceNode,
|
||||
isFormNode,
|
||||
isValuelessDesignerFilter,
|
||||
DESIGNER_FILTER_DX_OPERATORS,
|
||||
toDesignerFilterParamName,
|
||||
normalizeDesignerKeyList,
|
||||
DESIGNER_DROPDOWN_PLACEHOLDER,
|
||||
SQL_DATA_SOURCE_SLOTS,
|
||||
FORM_SLOTS,
|
||||
SQL_DEFAULT_VALUE_PROP,
|
||||
type DesignerDataSource,
|
||||
type DesignerDataSourceFilter,
|
||||
|
|
@ -37,7 +40,7 @@ import {
|
|||
} from './types'
|
||||
|
||||
/**
|
||||
* Record scope opened by a SqlDataSource. The record state is named
|
||||
* Record scope opened by a Form. The record state is named
|
||||
* `data_<nodeId>`, which is exactly what `bindingExpression` emits for a binding
|
||||
* whose `sourceId` is the container's node id — so children bind to columns
|
||||
* through the regular binding machinery and write back through `setterName`.
|
||||
|
|
@ -60,6 +63,40 @@ const safeIdentifier = (value: string) => {
|
|||
*/
|
||||
let refRuntimeEnabled = false
|
||||
|
||||
/**
|
||||
* Wizard permission of the component being generated. It is the base of the
|
||||
* `Otomatik` mode, so it is set for the whole pass rather than threaded through
|
||||
* every emitter. Empty for a component no menu opens.
|
||||
*/
|
||||
let autoPermissionCode = ''
|
||||
|
||||
/**
|
||||
* Guard expression of a permission name, or `''` when nothing guards it. An
|
||||
* unguarded command keeps its previous behaviour: the bound URL alone decides
|
||||
* whether its button is there.
|
||||
*/
|
||||
const permissionGuardExpression = (permission: string) =>
|
||||
permission ? `designerHasPermission(${JSON.stringify(permission)})` : ''
|
||||
|
||||
/**
|
||||
* Guard of a node's own visibility permission. A component whose data the user
|
||||
* is not allowed to read is not shown disabled — it is not shown.
|
||||
*/
|
||||
const nodePermissionGuard = (node: DesignerNode) =>
|
||||
permissionGuardExpression(getDesignerNodePermission(node))
|
||||
|
||||
/** True when any command of any Form or any node carries a permission. */
|
||||
const documentUsesPermissions = (nodes: DesignerNode[]): boolean =>
|
||||
nodes.some(
|
||||
(node) =>
|
||||
Boolean(getDesignerNodePermission(node)) ||
|
||||
(isFormNode(node.type) &&
|
||||
FORM_SLOTS.some((slot) =>
|
||||
resolveFormPermission(node, slot, autoPermissionCode),
|
||||
)) ||
|
||||
documentUsesPermissions(node.children),
|
||||
)
|
||||
|
||||
/** Prop that turns a component off; react-select spells it differently. */
|
||||
const getDisabledProperty = (type: string) => (type === 'Select' ? 'isDisabled' : 'disabled')
|
||||
|
||||
|
|
@ -165,7 +202,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 Form writes the record back from. */
|
||||
const getFormScopeField = (node: DesignerNode, formScope?: FormScope) => {
|
||||
if (!formScope) return null
|
||||
const propertyName = getSqlFormValueProperty(node, formScope.sourceId)
|
||||
|
|
@ -220,7 +257,7 @@ const propsToCode = (
|
|||
const runtimeState = getRuntimeStateSpec(node)
|
||||
const formField = getFormScopeField(node, formScope)
|
||||
const hasBoundInputValue = node.type === 'Input' && Boolean(node.bindings?.value?.sourceId)
|
||||
// Inside a SqlDataSource the bound value is editable: the change is written to
|
||||
// Inside a Form the bound value is editable: the change is written to
|
||||
// the record, so the input must not be forced read-only.
|
||||
const hasInputChangeHandler = Boolean(node.events?.onChange?.trim()) || Boolean(formField)
|
||||
const props = Object.entries(node.props)
|
||||
|
|
@ -228,12 +265,14 @@ const propsToCode = (
|
|||
([key, value]) =>
|
||||
key !== 'children' &&
|
||||
key !== runtimeState?.propertyName &&
|
||||
// Seeds a new SqlDataSource record; it is not a prop of the component.
|
||||
// Seeds a new Form record; it is not a prop of the component.
|
||||
key !== SQL_DEFAULT_VALUE_PROP &&
|
||||
// Decides whether the node is rendered; it is not a prop of the component.
|
||||
key !== DESIGNER_PERMISSION_PROP &&
|
||||
!omitted.has(key) &&
|
||||
!(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) &&
|
||||
!(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') &&
|
||||
// A field the SqlDataSource writes back must stay editable, whatever the
|
||||
// A field the Form writes back must stay editable, whatever the
|
||||
// node happens to carry from the toolbox defaults.
|
||||
!(formField && (key === 'readOnly' || key === 'disabled')) &&
|
||||
!node.bindings?.[key]?.sourceId &&
|
||||
|
|
@ -352,14 +391,18 @@ const sqlIdentifiers = (node: DesignerNode) => {
|
|||
recordRef: `sqlRecordRef_${identifier}`,
|
||||
modeRef: `sqlModeRef_${identifier}`,
|
||||
slot: (property: string) => `sql${property.replace(/Endpoint$/, '')}_${identifier}`,
|
||||
// Permission flags of the four commands. Always declared, so the markup can
|
||||
// reference them without knowing whether an endpoint carries a permission.
|
||||
can: (property: string) =>
|
||||
`sqlCan${property.replace(/Endpoint$/, '').replace(/^./, (letter) => letter.toUpperCase())}_${identifier}`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Handler name of a scripted SqlDataSource event, or `''` when it has no script. */
|
||||
/** Handler name of a scripted Form event, or `''` when it has no script. */
|
||||
const sqlEventHandler = (node: DesignerNode, eventName: string) =>
|
||||
node.events?.[eventName]?.trim() ? `handle_${safeIdentifier(node.id)}_${eventName}` : ''
|
||||
|
||||
const hasSqlEvents = (node: DesignerNode) => getSqlDataSourceScriptedEvents(node).length > 0
|
||||
const hasSqlEvents = (node: DesignerNode) => getFormScriptedEvents(node).length > 0
|
||||
|
||||
/**
|
||||
* `await`ed call of an event script, emitted only when the event carries one.
|
||||
|
|
@ -390,7 +433,7 @@ const sqlErrorEventCall = (node: DesignerNode, action: string) => {
|
|||
|
||||
/**
|
||||
* Runtime expression a filter reads its value from. A `record` filter addresses
|
||||
* another SqlDataSource by its ref, which is what turns a master container and a
|
||||
* another Form by its ref, which is what turns a master container and a
|
||||
* detail Grid into a filtered pair without a line of script.
|
||||
*/
|
||||
const filterValueExpression = (
|
||||
|
|
@ -458,21 +501,21 @@ const sqlSlotLiteral = (
|
|||
dataSources: DesignerDataSource[],
|
||||
) => {
|
||||
const source = dataSources.find(
|
||||
(candidate) => candidate.id === getSqlDataSourceEndpointId(node, property),
|
||||
(candidate) => candidate.id === getFormEndpointId(node, property),
|
||||
)
|
||||
if (!source?.url.trim()) return 'null'
|
||||
return `{ url: ${JSON.stringify(source.url.trim())}, method: ${JSON.stringify(method)}, responsePath: ${JSON.stringify(source.responsePath || '')} }`
|
||||
}
|
||||
|
||||
const sqlDataSourceHooks = (
|
||||
const formHooks = (
|
||||
node: DesignerNode,
|
||||
dataSources: DesignerDataSource[],
|
||||
sqlRecordByRef: Map<string, string>,
|
||||
) => {
|
||||
const names = sqlIdentifiers(node)
|
||||
const keyField = getSqlDataSourceKeyField(node)
|
||||
const keyField = getFormKeyField(node)
|
||||
const selectSource = dataSources.find(
|
||||
(candidate) => candidate.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'),
|
||||
(candidate) => candidate.id === getFormEndpointId(node, 'selectEndpoint'),
|
||||
)
|
||||
const selectFilters = filterEntriesExpression(selectSource, sqlRecordByRef)
|
||||
// The Select endpoint already owns a fetch hook, so the container reuses that
|
||||
|
|
@ -484,16 +527,27 @@ const sqlDataSourceHooks = (
|
|||
selectSource?.method === 'GET' && selectSource.url.trim().startsWith('/api/')
|
||||
? `setData_${safeIdentifier(selectSource.id)}`
|
||||
: ''
|
||||
const keySource = getSqlDataSourceKeySource(node)
|
||||
const keySource = getFormKeySource(node)
|
||||
const rowsExpression = `toSqlRows(${selectData}, ${JSON.stringify(String(node.props.collectionPath ?? ''))})`
|
||||
const slots = SQL_DATA_SOURCE_SLOTS.map(
|
||||
const slots = FORM_SLOTS.map(
|
||||
(slot) =>
|
||||
` const ${names.slot(slot.property)} = ${sqlSlotLiteral(node, slot.property, slot.method, dataSources)}`,
|
||||
).join('\n')
|
||||
// One flag per command. An endpoint without a permission is always allowed, so
|
||||
// a container configured before permissions existed behaves as it always did.
|
||||
const permissions = FORM_SLOTS.map(
|
||||
(slot) =>
|
||||
` const ${names.can(slot.property)} = ${
|
||||
permissionGuardExpression(
|
||||
resolveFormPermission(node, slot, autoPermissionCode),
|
||||
) || 'true'
|
||||
}`,
|
||||
).join('\n')
|
||||
|
||||
return `${slots}
|
||||
${permissions}
|
||||
const ${names.key} = ${JSON.stringify(keyField)}
|
||||
const ${names.keyParam} = ${JSON.stringify(getSqlDataSourceKeyParam(node))}
|
||||
const ${names.keyParam} = ${JSON.stringify(getFormKeyParam(node))}
|
||||
const [${names.record}, ${names.setRecord}] = React.useState({})
|
||||
const [${names.original}, ${names.setOriginal}] = React.useState({})
|
||||
const [${names.mode}, ${names.setMode}] = React.useState("edit")
|
||||
|
|
@ -601,11 +655,14 @@ ${
|
|||
const ${names.reload} = React.useCallback(async () => {${
|
||||
setSelectData
|
||||
? `
|
||||
// Without the Read permission the container is not rendered at all; the
|
||||
// request is dropped here so it is not fired for a form nobody can see.
|
||||
if (!${names.can('selectEndpoint')}) return
|
||||
if (!${names.selectUrl}) return
|
||||
// Calling a URL that still holds a placeholder is a guaranteed 400, so the
|
||||
// missing key is reported in the component instead.
|
||||
if (hasSqlUrlParams(${names.selectUrl})) {
|
||||
throw new Error("Select endpointi " + ${names.keyParam} + " parametresini bekliyor; sayfa adresinde bulunamadı.")
|
||||
throw new Error(${translateExpression('::App.Form.MissingKeyParam', `{ 0: ${names.keyParam} }`)})
|
||||
}
|
||||
const response = await apiService.fetchData({ url: ${names.selectUrl}, method: "GET" })
|
||||
${setSelectData}(getByPath(response.data, ${names.slot('selectEndpoint')}.responsePath))`
|
||||
|
|
@ -626,6 +683,7 @@ ${
|
|||
}
|
||||
}, [${names.reload}])
|
||||
const ${names.create} = React.useCallback(${sqlEventHandler(node, 'onNewRecord') ? 'async ' : ''}() => {
|
||||
if (!${names.can('insertEndpoint')}) return
|
||||
${names.setError}("")
|
||||
// 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.
|
||||
|
|
@ -646,14 +704,17 @@ ${
|
|||
const ${names.save} = async () => {
|
||||
const isNew = ${names.mode} === "new"
|
||||
const target = isNew ? ${names.slot('insertEndpoint')} : ${names.slot('updateEndpoint')}
|
||||
// The button is already hidden without the permission; a script calling save
|
||||
// through a ref must not be able to walk around it either.
|
||||
if (!(isNew ? ${names.can('insertEndpoint')} : ${names.can('updateEndpoint')})) return
|
||||
if (!target) {
|
||||
${names.setError}(isNew ? "Insert için POST endpointi tanımlı değil." : "Update için PUT endpointi tanımlı değil.")
|
||||
${names.setError}(isNew ? ${translateExpression('::App.Form.InsertEndpointMissing')} : ${translateExpression('::App.Form.UpdateEndpointMissing')})
|
||||
return
|
||||
}
|
||||
// Update carries only the edited columns plus the key; Insert sends the record.
|
||||
const payload = isNew ? ${names.record} : toSqlChanges(${names.original}, ${names.record}, ${names.key})
|
||||
if (!isNew && Object.keys(payload).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length === 0) {
|
||||
${names.setError}("Kaydedilecek bir değişiklik yok.")
|
||||
${names.setError}(${translateExpression('::App.Form.NoChanges')})
|
||||
return
|
||||
}${
|
||||
sqlEventHandler(node, 'onBeforeSave')
|
||||
|
|
@ -691,7 +752,7 @@ ${
|
|||
}
|
||||
}
|
||||
const ${names.remove} = async () => {
|
||||
if (!${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return${
|
||||
if (!${names.can('deleteEndpoint')} || !${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return${
|
||||
sqlEventHandler(node, 'onBeforeDelete')
|
||||
? `
|
||||
// Asked before the confirm dialog, so a script can block the delete without
|
||||
|
|
@ -700,7 +761,7 @@ ${
|
|||
: ''
|
||||
}
|
||||
// Deleting cannot be undone from the form, so it always asks first.
|
||||
const confirmMessage = ${names.key} + " = " + readSqlField(${names.record}, ${names.key}) + " kaydı silinecek.\\n\\nOnaylıyor musunuz?"
|
||||
const confirmMessage = ${translateExpression('::App.Form.DeleteConfirm', `{ 0: ${names.key} + " = " + readSqlField(${names.record}, ${names.key}) }`)}
|
||||
if (typeof window !== "undefined" && !window.confirm(confirmMessage)) return
|
||||
${names.setBusy}(true)
|
||||
${names.setError}("")
|
||||
|
|
@ -733,13 +794,22 @@ ${
|
|||
}`
|
||||
}
|
||||
|
||||
/**
|
||||
* `translate()` call for a fixed localization key. The container's own chrome —
|
||||
* its buttons, its mode badge, its error messages — is platform text, so it
|
||||
* follows the active language exactly like the rest of the application instead
|
||||
* of being frozen in the language the component was designed in.
|
||||
*/
|
||||
const translateExpression = (key: string, params?: string) =>
|
||||
`translate(${JSON.stringify(key)}${params ? `, ${params}` : ''})`
|
||||
|
||||
const SQL_TOOLBAR_BUTTON_CLASS =
|
||||
'rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-40'
|
||||
|
||||
const sqlDataSourceToCode = (node: DesignerNode, level: number, itemVariable?: string) => {
|
||||
const formToCode = (node: DesignerNode, level: number, itemVariable?: string) => {
|
||||
const names = sqlIdentifiers(node)
|
||||
const formScope: FormScope = { sourceId: node.id, setterName: names.setField }
|
||||
const columnCount = getSqlDataSourceColumnCount(node)
|
||||
const columnCount = getFormColumnCount(node)
|
||||
const gap = Number(node.props.gap) || 0
|
||||
// Children sit one level deeper once they are wrapped in the column grid.
|
||||
const children = node.children
|
||||
|
|
@ -750,19 +820,24 @@ const sqlDataSourceToCode = (node: DesignerNode, level: number, itemVariable?: s
|
|||
// The toolbar and the error line stay in the outer column, so only the fields
|
||||
// are laid out side by side. A single column emits no wrapper at all, which
|
||||
// keeps the markup of every page saved before the setting existed unchanged.
|
||||
const gridStyle = `{ display: "grid", gridTemplateColumns: "repeat(${columnCount}, minmax(0, 1fr))", gap: ${gap}, alignItems: "start" }`
|
||||
// Columns come from utility classes rather than from an inline
|
||||
// `gridTemplateColumns`, because an inline value cannot carry a breakpoint: a
|
||||
// two column form has to fall back to a single column on a phone, where two
|
||||
// fields side by side leave neither of them readable.
|
||||
const gridClassName = `grid grid-cols-1 md:grid-cols-${columnCount}`
|
||||
const gridStyle = `{ gap: ${gap}, alignItems: "start" }`
|
||||
const content =
|
||||
columnCount > 1
|
||||
? `${indent(`<div style={${gridStyle}}>`, level + 2)}\n${children}\n${indent('</div>', level + 2)}`
|
||||
? `${indent(`<div className="${gridClassName}" style={${gridStyle}}>`, level + 2)}\n${children}\n${indent('</div>', level + 2)}`
|
||||
: children
|
||||
const plainButtonClass = `${SQL_TOOLBAR_BUTTON_CLASS} border border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:text-slate-300`
|
||||
// Navigation appears on its own whenever there is more than one record to walk.
|
||||
const navigation = `
|
||||
${indent(`{${names.rows}.length > 1 ? (`, level + 3)}
|
||||
${indent('<>', level + 4)}
|
||||
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} <= 0} onClick={${names.previous}}>Önceki</button>`, level + 5)}
|
||||
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} <= 0} onClick={${names.previous}}>{${translateExpression('::App.Platform.Previous')}}</button>`, level + 5)}
|
||||
${indent(`<span className="text-xs text-slate-500">{\`\${${names.index} + 1} / \${${names.rows}.length}\`}</span>`, level + 5)}
|
||||
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} >= ${names.rows}.length - 1} onClick={${names.next}}>Sonraki</button>`, level + 5)}
|
||||
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || ${names.index} >= ${names.rows}.length - 1} onClick={${names.next}}>{${translateExpression('::App.Platform.Next')}}</button>`, level + 5)}
|
||||
${indent('<span className="mx-1 h-5 w-px bg-slate-300 dark:bg-slate-700" />', level + 5)}
|
||||
${indent('</>', level + 4)}
|
||||
${indent(') : null}', level + 3)}`
|
||||
|
|
@ -771,11 +846,11 @@ ${indent(') : null}', level + 3)}`
|
|||
? ''
|
||||
: `
|
||||
${indent('<div className="flex flex-wrap items-center gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">', level + 2)}${navigation}
|
||||
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('insertEndpoint')}} onClick={${names.create}}>Yeni</button>`, level + 3)}
|
||||
${indent(`<button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-sky-600 text-white hover:bg-sky-700" disabled={${names.busy} || !(${names.mode} === "new" ? ${names.slot('insertEndpoint')} : ${names.slot('updateEndpoint')})} onClick={() => { void ${names.save}() }}>Kaydet</button>`, level + 3)}
|
||||
${indent(`<button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-red-600 text-white hover:bg-red-700" disabled={${names.busy} || !${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new"} onClick={() => { void ${names.remove}() }}>Sil</button>`, level + 3)}
|
||||
${indent(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('selectEndpoint')}} onClick={() => { void ${names.refresh}() }}>Yenile</button>`, level + 3)}
|
||||
${indent(`<span className="ml-auto text-[10px] uppercase tracking-wider text-slate-400">{${names.mode} === "new" ? "Yeni kayıt" : "Düzenleme"}</span>`, level + 3)}
|
||||
${indent(`{${names.slot('insertEndpoint')} && ${names.can('insertEndpoint')} ? <button type="button" className="${plainButtonClass}" disabled={${names.busy}} onClick={${names.create}}>{${translateExpression('::App.Platform.New')}}</button> : null}`, level + 3)}
|
||||
${indent(`{(${names.mode} === "new" ? ${names.slot('insertEndpoint')} && ${names.can('insertEndpoint')} : ${names.slot('updateEndpoint')} && ${names.can('updateEndpoint')}) ? <button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-sky-600 text-white hover:bg-sky-700" disabled={${names.busy}} onClick={() => { void ${names.save}() }}>{${translateExpression('::App.Platform.Save')}}</button> : null}`, level + 3)}
|
||||
${indent(`{${names.slot('deleteEndpoint')} && ${names.can('deleteEndpoint')} ? <button type="button" className="${SQL_TOOLBAR_BUTTON_CLASS} bg-red-600 text-white hover:bg-red-700" disabled={${names.busy} || !${names.hasKey} || ${names.mode} === "new"} onClick={() => { void ${names.remove}() }}>{${translateExpression('::App.Platform.Delete')}}</button> : null}`, level + 3)}
|
||||
${indent(`{${names.slot('selectEndpoint')} && ${names.can('selectEndpoint')} ? <button type="button" className="${plainButtonClass}" disabled={${names.busy}} onClick={() => { void ${names.refresh}() }}>{${translateExpression('::App.Platform.Refresh')}}</button> : null}`, level + 3)}
|
||||
${indent(`<span className="ml-auto text-[10px] uppercase tracking-wider text-slate-400">{${names.mode} === "new" ? ${translateExpression('::App.Platform.NewRecord')} : ${translateExpression('::App.Platform.Editing')}}</span>`, level + 3)}
|
||||
${indent('</div>', level + 2)}`
|
||||
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 + 2)}`
|
||||
|
|
@ -784,16 +859,26 @@ ${indent(`{${names.error} ? <div className="rounded-md bg-red-50 px-3 py-2 text-
|
|||
// and the header follows the active language. The header extra carries the key
|
||||
// of the record on screen.
|
||||
const title = String(node.props.title ?? '').trim()
|
||||
const headerExtra = `{${names.hasKey} ? ${names.key} + ": " + readSqlField(${names.record}, ${names.key}) : ${names.mode} === "new" ? "Yeni kayıt" : ""}`
|
||||
const headerExtra = `{${names.hasKey} ? ${names.key} + ": " + readSqlField(${names.record}, ${names.key}) : ${names.mode} === "new" ? ${translateExpression('::App.Platform.NewRecord')} : ""}`
|
||||
// The Card header puts the title and the record key on one row. On a phone the
|
||||
// key is long enough to squeeze the title out of the line, so the two stack
|
||||
// until there is room for both.
|
||||
const headerClass =
|
||||
'flex-col items-start gap-1 sm:flex-row sm:items-center sm:justify-between'
|
||||
const cardStart = title
|
||||
? `<UiKit.Card bodyClass="p-4" header={${staticValueExpression(title)}} headerExtra={<span className="text-xs text-slate-500 dark:text-slate-400">${headerExtra}</span>}>`
|
||||
? `<UiKit.Card bodyClass="p-4" headerClass=${JSON.stringify(headerClass)} header={${staticValueExpression(title)}} headerExtra={<span className="text-xs text-slate-500 dark:text-slate-400 break-all">${headerExtra}</span>}>`
|
||||
: '<UiKit.Card bodyClass="p-4">'
|
||||
|
||||
return `${indent(cardStart, level)}
|
||||
${indent(`<div ref={${names.host}} className=${className} style={${style}}>`, level + 1)}
|
||||
// Read permission decides whether the container exists on the page at all: a
|
||||
// form whose records the user may not see is not rendered empty, it is not
|
||||
// rendered.
|
||||
return `${indent(`{${names.can('selectEndpoint')} ? (`, level)}
|
||||
${indent(cardStart, level + 1)}
|
||||
${indent(`<div ref={${names.host}} className=${className} style={${style}}>`, level + 2)}
|
||||
${content}${toolbar}${error}
|
||||
${indent('</div>', level + 1)}
|
||||
${indent('</UiKit.Card>', level)}`
|
||||
${indent('</div>', level + 2)}
|
||||
${indent('</UiKit.Card>', level + 1)}
|
||||
${indent(') : null}', level)}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -803,15 +888,22 @@ ${indent('</UiKit.Card>', level)}`
|
|||
* JSX child.
|
||||
*/
|
||||
const withVisibilityGuard = (code: string, node: DesignerNode, level: number) => {
|
||||
if (!isRefControlled(node)) return code
|
||||
// A component the user has no permission for is not rendered — that guard sits
|
||||
// next to the ref guard, so both share one wrapper.
|
||||
const permissionGuard = nodePermissionGuard(node)
|
||||
const conditions = [
|
||||
...(isRefControlled(node) ? [`!designerRefHidden(${JSON.stringify(node.ref)})`] : []),
|
||||
...(permissionGuard ? [permissionGuard] : []),
|
||||
]
|
||||
if (!conditions.length) return code
|
||||
const trimmed = code.trim()
|
||||
const body =
|
||||
trimmed.startsWith('{') && trimmed.endsWith('}')
|
||||
? code.replace(/^(\s*)\{/, '$1(').replace(/\}(\s*)$/, ')$1')
|
||||
: code
|
||||
return `${indent(`{designerRefHidden(${JSON.stringify(node.ref)}) ? null : (`, level)}
|
||||
return `${indent(`{${conditions.join(' && ')} ? (`, level)}
|
||||
${body}
|
||||
${indent(')}', level)}`
|
||||
${indent(') : null}', level)}`
|
||||
}
|
||||
|
||||
const nodeToCode = (
|
||||
|
|
@ -821,7 +913,12 @@ const nodeToCode = (
|
|||
formScope?: FormScope,
|
||||
): string =>
|
||||
withVisibilityGuard(
|
||||
renderNodeToCode(node, isRefControlled(node) ? level + 1 : level, itemVariable, formScope),
|
||||
renderNodeToCode(
|
||||
node,
|
||||
isRefControlled(node) || nodePermissionGuard(node) ? level + 1 : level,
|
||||
itemVariable,
|
||||
formScope,
|
||||
),
|
||||
node,
|
||||
level,
|
||||
)
|
||||
|
|
@ -832,7 +929,7 @@ const renderNodeToCode = (
|
|||
itemVariable?: string,
|
||||
formScope?: FormScope,
|
||||
): string => {
|
||||
if (isSqlDataSourceNode(node.type)) return sqlDataSourceToCode(node, level, itemVariable)
|
||||
if (isFormNode(node.type)) return formToCode(node, level, itemVariable)
|
||||
if (node.type === 'Spacer') {
|
||||
return indent(
|
||||
`<div aria-hidden="true" className=${JSON.stringify(String(node.props.className || ''))} style={{ height: ${Number(node.props.height) || 24} }} />`,
|
||||
|
|
@ -1157,7 +1254,7 @@ ${indent('})()}', level)}`
|
|||
delete groupProps.readOnly
|
||||
const groupPropsCode = propsToCode(groupNode, itemVariable, [], formScope)
|
||||
const optionsExpression = optionCollectionExpression(node, itemVariable)
|
||||
return `${indent(`<UiKit.Radio.Group${groupPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Radio key={String(option.value ?? optionIndex)} value={option.value ?? optionIndex} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? \`Seçenek \${optionIndex + 1}\`)}</UiKit.Radio>)}`, level + 1)}\n${indent('</UiKit.Radio.Group>', level)}`
|
||||
return `${indent(`<UiKit.Radio.Group${groupPropsCode}>`, level)}\n${indent(`{${optionsExpression}.map((option, optionIndex) => <UiKit.Radio key={String(option.value ?? optionIndex)} value={option.value ?? optionIndex} disabled={Boolean(option.disabled)}>{String(option.label ?? option.value ?? ${translateExpression("::App.Platform.OptionFallback", "{ 0: optionIndex + 1 }")})}</UiKit.Radio>)}`, level + 1)}\n${indent('</UiKit.Radio.Group>', level)}`
|
||||
}
|
||||
|
||||
const tag =
|
||||
|
|
@ -1220,7 +1317,7 @@ interface RefTarget {
|
|||
|
||||
/**
|
||||
* `refs.<name>` entry of a node: the value accessor is wired to whichever state
|
||||
* actually owns the value — the enclosing SqlDataSource record, the node's own
|
||||
* actually owns the value — the enclosing Form record, the node's own
|
||||
* runtime state, or the ref override store for a component that has neither.
|
||||
*/
|
||||
const refTargetFor = (
|
||||
|
|
@ -1230,7 +1327,7 @@ const refTargetFor = (
|
|||
): RefTarget => {
|
||||
const ref = node.ref as string
|
||||
const extras: string[] = []
|
||||
if (isSqlDataSourceNode(node.type)) {
|
||||
if (isFormNode(node.type)) {
|
||||
const names = sqlIdentifiers(node)
|
||||
extras.push(
|
||||
` getRecord: () => ${names.record}`,
|
||||
|
|
@ -1395,7 +1492,7 @@ const SCRIPT_API_CODE = ` const api = {
|
|||
delete: (url, params) => apiService.fetchData({ url, method: "DELETE", params }).then((response) => response.data),
|
||||
remove: (url, params) => apiService.fetchData({ url, method: "DELETE", params }).then((response) => response.data),
|
||||
request: (config) => apiService.fetchData(config).then((response) => response.data),
|
||||
errorMessage: (error) => error?.response?.data?.error?.message || error?.response?.data?.message || error?.message || "İşlem tamamlanamadı.",
|
||||
errorMessage: (error) => error?.response?.data?.error?.message || error?.response?.data?.message || error?.message || ${translateExpression('::App.Platform.IslemBasarisiz')},
|
||||
}`
|
||||
|
||||
const SCRIPT_NOTIFY_CODE = ` const notify = (message, type = "info") => toast.push(
|
||||
|
|
@ -1408,6 +1505,7 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
const scripts = collectDesignerScripts(document)
|
||||
// Only pages whose scripts address a ref pay for the ref runtime.
|
||||
refRuntimeEnabled = /\brefs\b/.test(scripts)
|
||||
autoPermissionCode = String(document.permissionCode ?? '').trim()
|
||||
const scriptHelpers = [
|
||||
/\bapi\b/.test(scripts) ? SCRIPT_API_CODE : '',
|
||||
/\bnotify\b/.test(scripts) ? SCRIPT_NOTIFY_CODE : '',
|
||||
|
|
@ -1425,10 +1523,10 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
let hasSelectComponent = false
|
||||
let hasDatePicker = false
|
||||
let hasTabs = false
|
||||
const sqlDataSourceNodes: DesignerNode[] = []
|
||||
const formNodes: DesignerNode[] = []
|
||||
/** Platform views carrying their own filters; they get a params hook each. */
|
||||
const platformFilterNodes: DesignerNode[] = []
|
||||
/** Sources loaded by a SqlDataSource; their own mount fetch would duplicate it. */
|
||||
/** Sources loaded by a Form; their own mount fetch would duplicate it. */
|
||||
const sqlManagedSelectSourceIds = new Set<string>()
|
||||
|
||||
const visit = (nodes: DesignerNode[], formScope?: FormScope) => {
|
||||
|
|
@ -1443,9 +1541,9 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
if (node.kind === 'platform' && getDesignerNodeFilters(node).length) {
|
||||
platformFilterNodes.push(node)
|
||||
}
|
||||
if (isSqlDataSourceNode(node.type)) {
|
||||
sqlDataSourceNodes.push(node)
|
||||
const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint')
|
||||
if (isFormNode(node.type)) {
|
||||
formNodes.push(node)
|
||||
const selectId = getFormEndpointId(node, 'selectEndpoint')
|
||||
if (selectId) sqlManagedSelectSourceIds.add(selectId)
|
||||
}
|
||||
const formField = getFormScopeField(node, formScope)
|
||||
|
|
@ -1462,11 +1560,11 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
` const [${runtimeState.stateName}, ${runtimeState.setterName}] = React.useState(${initialExpression})`,
|
||||
)
|
||||
}
|
||||
// A SqlDataSource event is not a React prop: it is called by the container's
|
||||
// A Form event is not a React prop: it is called by the container's
|
||||
// own runtime with a single payload object, and always awaited so a script
|
||||
// can veto a command or finish an API call before the save continues.
|
||||
if (isSqlDataSourceNode(node.type)) {
|
||||
getSqlDataSourceScriptedEvents(node).forEach((event) => {
|
||||
if (isFormNode(node.type)) {
|
||||
getFormScriptedEvents(node).forEach((event) => {
|
||||
const script = node.events[event.name].trim()
|
||||
handlers.push(
|
||||
` const handle_${safeIdentifier(node.id)}_${event.name} = async (event) => {\n${indent(script, 2)}\n }`,
|
||||
|
|
@ -1485,7 +1583,7 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
eventNames.forEach((eventName) => {
|
||||
const script = node.events[eventName]?.trim() || ''
|
||||
// A form scoped field replaces the local state update: the edit belongs
|
||||
// to the SqlDataSource record, which is what Save posts back.
|
||||
// to the Form record, which is what Save posts back.
|
||||
const formUpdate =
|
||||
formField?.eventName === eventName
|
||||
? `${formField.setterName}(${JSON.stringify(formField.path)}, ${
|
||||
|
|
@ -1538,7 +1636,7 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
})
|
||||
visit(
|
||||
node.children,
|
||||
isSqlDataSourceNode(node.type)
|
||||
isFormNode(node.type)
|
||||
? { sourceId: node.id, setterName: sqlIdentifiers(node).setField }
|
||||
: formScope,
|
||||
)
|
||||
|
|
@ -1547,11 +1645,11 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
visit(document.nodes)
|
||||
|
||||
const dataSources = document.dataSources || []
|
||||
// Ref → record state variable of every SqlDataSource on the page. A `record`
|
||||
// Ref → record state variable of every Form on the page. A `record`
|
||||
// filter addresses its master through this map, so a detail source can be
|
||||
// filtered by the row the container currently shows.
|
||||
const sqlRecordByRef = new Map<string, string>(
|
||||
sqlDataSourceNodes
|
||||
formNodes
|
||||
.filter((node) => node.ref)
|
||||
.map((node) => [String(node.ref), sqlIdentifiers(node).record] as const),
|
||||
)
|
||||
|
|
@ -1569,7 +1667,7 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
const valueKey = ["value", "eventKey", "id", "code", "key"].find((key) => item[key] !== undefined)
|
||||
const optionLabel = labelPath ? getByPath(item, labelPath) : item[labelKey ?? primitiveKeys[0]]
|
||||
const optionValue = valuePath ? getByPath(item, valuePath) : item[valueKey ?? primitiveKeys[1] ?? primitiveKeys[0]]
|
||||
return { ...item, label: String(optionLabel ?? \`Seçenek \${index + 1}\`), value: optionValue ?? index }
|
||||
return { ...item, label: String(optionLabel ?? ${translateExpression("::App.Platform.OptionFallback", "{ 0: index + 1 }")}), value: optionValue ?? index }
|
||||
})
|
||||
}`
|
||||
: ''
|
||||
|
|
@ -1684,9 +1782,9 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
styles: { menuPortal: (base) => ({ ...base, zIndex: 60 }) },
|
||||
}`
|
||||
: ''
|
||||
// Shared SqlDataSource runtime: row extraction, record writes, URL parameter
|
||||
// Shared Form runtime: row extraction, record writes, URL parameter
|
||||
// binding and the actual Insert/Update/Delete calls.
|
||||
const sqlHelpers = sqlDataSourceNodes.length
|
||||
const sqlHelpers = formNodes.length
|
||||
? ` const readSqlField = (record, field) => {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) return undefined
|
||||
if (field in record) return record[field]
|
||||
|
|
@ -1787,7 +1885,7 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
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 || ${translateExpression('::App.Platform.IslemBasarisiz')}`
|
||||
: ''
|
||||
const filterHelpers = hasFilters
|
||||
? ` // Value of a filter that reads the page URL: a query string parameter, or a
|
||||
|
|
@ -1850,7 +1948,7 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
return params
|
||||
}`
|
||||
: ''
|
||||
// Declared after the SqlDataSource hooks: a filter may read the record of the
|
||||
// Declared after the Form hooks: a filter may read the record of the
|
||||
// container the view sits next to.
|
||||
const platformFilterHooks = platformFilterNodes
|
||||
.map((node) => {
|
||||
|
|
@ -1862,11 +1960,21 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
const platformParams_${identifier} = React.useMemo(() => buildPlatformFilterParams(platformFilters_${identifier}), [platformKey_${identifier}])`
|
||||
})
|
||||
.join('\n\n')
|
||||
const sqlHooks = sqlDataSourceNodes
|
||||
.map((node) => sqlDataSourceHooks(node, dataSources, sqlRecordByRef))
|
||||
const sqlHooks = formNodes
|
||||
.map((node) => formHooks(node, dataSources, sqlRecordByRef))
|
||||
.join('\n\n')
|
||||
// `checkPermission` is injected by the component runtime. The typeof guard
|
||||
// keeps a generated component runnable in an older host that does not inject
|
||||
// it yet — there the page behaves as it did before permissions existed.
|
||||
const permissionHelpers = documentUsesPermissions(document.nodes)
|
||||
? ` const designerHasPermission = (permission) => {
|
||||
const name = String(permission || "").trim()
|
||||
if (!name) return true
|
||||
return typeof checkPermission === "function" ? checkPermission(name) === true : true
|
||||
}`
|
||||
: ''
|
||||
const dataHelpers =
|
||||
dataSources.length || hasDataTable || sqlDataSourceNodes.length
|
||||
dataSources.length || hasDataTable || formNodes.length
|
||||
? ` const getByPath = (value, path) => {
|
||||
if (!path) return value
|
||||
const readPath = (target, targetPath) => targetPath.split('.').filter(Boolean).reduce((current, key) => current?.[key], target)
|
||||
|
|
@ -1876,13 +1984,13 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
return value?.result === undefined ? undefined : readPath(value.result, path)
|
||||
}`
|
||||
: ''
|
||||
// The state of every source is declared before the SqlDataSource hooks, which
|
||||
// The state of every source is declared before the Form hooks, which
|
||||
// write through the setters; the fetch effects come after them, because a
|
||||
// filtered source may read the record a container owns.
|
||||
const dataStateHooks = dataSources
|
||||
.map((source) => {
|
||||
const identifier = safeIdentifier(source.id)
|
||||
// POST/PUT/DELETE sources are only ever invoked by a SqlDataSource command;
|
||||
// POST/PUT/DELETE sources are only ever invoked by a Form command;
|
||||
// they hold no readable state and must never be written to.
|
||||
if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) {
|
||||
return ` const [data_${identifier}] = React.useState(null)`
|
||||
|
|
@ -1895,9 +2003,9 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
const identifier = safeIdentifier(source.id)
|
||||
if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) return ''
|
||||
// A GetById style URL is not fetched on mount — requesting a literal `{id}`
|
||||
// is a guaranteed 400 — and neither is a source the owning SqlDataSource
|
||||
// is a guaranteed 400 — and neither is a source the owning Form
|
||||
// loads itself, through the setter declared above.
|
||||
if (hasSqlDataSourceUrlParams(source.url) || sqlManagedSelectSourceIds.has(source.id)) {
|
||||
if (hasFormUrlParams(source.url) || sqlManagedSelectSourceIds.has(source.id)) {
|
||||
return ''
|
||||
}
|
||||
const entries = filterEntriesExpression(source, sqlRecordByRef)
|
||||
|
|
@ -1945,5 +2053,5 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
|
||||
const designerBackup = encodeURIComponent(JSON.stringify(document))
|
||||
|
||||
return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[dataHelpers, lookupHelpers, scriptHelpers,selectHelpers, dropdownHelpers, selectValueHelpers, selectMenuHelpers, localeHelpers, dateHelpers, tabHelpers, sqlHelpers, filterHelpers, platformFilterHelpers, dataStateHooks, sqlHooks, dataFetchHooks, platformFilterHooks, ...runtimeStateHooks, refRuntime, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n </>\n )\n}\n\nexport default ${componentName}\n`
|
||||
return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[permissionHelpers, dataHelpers, lookupHelpers, scriptHelpers,selectHelpers, dropdownHelpers, selectValueHelpers, selectMenuHelpers, localeHelpers, dateHelpers, tabHelpers, sqlHelpers, filterHelpers, platformFilterHelpers, dataStateHooks, sqlHooks, dataFetchHooks, platformFilterHooks, ...runtimeStateHooks, refRuntime, mount, ...handlers].filter(Boolean).join('\n\n')}\n\n return (\n <>\n${body}\n </>\n )\n}\n\nexport default ${componentName}\n`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
type DesignerScriptRule,
|
||||
type TranslateFn,
|
||||
} from './designerScriptRecipes'
|
||||
import type { SqlDataSourceEvent } from './types'
|
||||
import type { FormEvent } from './types'
|
||||
|
||||
const groupIcons: Record<DesignerRecipeGroupKey, ScriptRecipeGroup['icon']> = {
|
||||
record: FaDatabase,
|
||||
|
|
@ -73,7 +73,7 @@ const toParam = (param: DesignerRecipeParam): ScriptRecipeParam => ({
|
|||
|
||||
/** Kod editöründe tek tıkla eklenebilen çok satırlı kalıplar. */
|
||||
const snippets = (sqlRef: string): ScriptSnippet[] => {
|
||||
const sql = sqlRef || 'sqlDataSource1'
|
||||
const sql = sqlRef || 'form1'
|
||||
return [
|
||||
{
|
||||
label: 'App.SnippetCalcOnFieldChange.CalcOnFieldChangeLabel',
|
||||
|
|
@ -133,7 +133,7 @@ const snippets = (sqlRef: string): ScriptSnippet[] => {
|
|||
}
|
||||
|
||||
export type DesignerScriptDialectContext = {
|
||||
/** Kayıt tariflerinin yazacağı SqlDataSource ref adı. */
|
||||
/** Kayıt tariflerinin yazacağı Form ref adı. */
|
||||
sqlRef: string
|
||||
/** Select sonucundaki sütun adları. */
|
||||
recordFields: string[]
|
||||
|
|
@ -141,7 +141,7 @@ export type DesignerScriptDialectContext = {
|
|||
refNames: string[]
|
||||
/** Düzenlenen event. */
|
||||
eventName: string
|
||||
eventInfo?: SqlDataSourceEvent
|
||||
eventInfo?: FormEvent
|
||||
/** Event'in sahibi komponentin adı; başlıkta gösterilir. */
|
||||
componentLabel?: string
|
||||
/** `useLocalization` hook'undan gelen çeviri fonksiyonu. */
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export type DesignerRecipeGroupKey = 'record' | 'component' | 'interaction' | 'i
|
|||
|
||||
/**
|
||||
* Kuralın kod üretirken ihtiyaç duyduğu sayfa bilgisi. `sqlRef` düzenlenen
|
||||
* event'in sahibi olan SqlDataSource'un ref adıdır; kayıt tarifleri bunun
|
||||
* event'in sahibi olan Form'un ref adıdır; kayıt tarifleri bunun
|
||||
* üzerinden yazar, böylece aynı tarif başka bir sayfada da çalışır.
|
||||
*/
|
||||
export type DesignerScriptContext = {
|
||||
|
|
@ -90,7 +90,7 @@ export type DesignerRecipe = {
|
|||
conditionIsArgument?: boolean
|
||||
/** Yalnızca iptal edilebilir event'lerde (onBeforeSave/onBeforeDelete) anlamlı. */
|
||||
cancelableOnly?: boolean
|
||||
/** SqlDataSource kaydına yazan tarifler bir sql ref'i olmadan tamamlanamaz. */
|
||||
/** Form kaydına yazan tarifler bir sql ref'i olmadan tamamlanamaz. */
|
||||
needsSqlRef?: boolean
|
||||
params: DesignerRecipeParam[]
|
||||
build: (rule: DesignerScriptRule, context: DesignerScriptContext) => string
|
||||
|
|
@ -333,7 +333,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
group: 'record',
|
||||
needsSqlRef: true,
|
||||
summary: 'App.VisualDesigner.Script.SetField.Summary',
|
||||
example: "refs.sqlDataSource1.setField('Status', 'Taslak')",
|
||||
example: "refs.form1.setField('Status', 'Taslak')",
|
||||
params: [
|
||||
{ key: 'target', label: 'App.VisualDesigner.Script.Target.Label', type: 'recordField' },
|
||||
{
|
||||
|
|
@ -359,7 +359,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
needsSqlRef: true,
|
||||
summary: 'App.VisualDesigner.Script.Multiply.Summary',
|
||||
example:
|
||||
"refs.sqlDataSource1.setField('TotalAmount', Math.round(Number(refs.sqlDataSource1.getField('Quantity') || 0) * Number(refs.sqlDataSource1.getField('UnitPrice') || 0) * 100) / 100)",
|
||||
"refs.form1.setField('TotalAmount', Math.round(Number(refs.form1.getField('Quantity') || 0) * Number(refs.form1.getField('UnitPrice') || 0) * 100) / 100)",
|
||||
params: [
|
||||
{ key: 'left', label: 'App.VisualDesigner.Script.Left.Label', type: 'recordField' },
|
||||
{ key: 'right', label: 'App.VisualDesigner.Script.Right.Label', type: 'recordField' },
|
||||
|
|
@ -394,7 +394,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
needsSqlRef: true,
|
||||
summary: 'App.VisualDesigner.Script.SumFields.Summary',
|
||||
example:
|
||||
"refs.sqlDataSource1.setField('Total', Number(refs.sqlDataSource1.getField('Net') || 0) + Number(refs.sqlDataSource1.getField('Tax') || 0))",
|
||||
"refs.form1.setField('Total', Number(refs.form1.getField('Net') || 0) + Number(refs.form1.getField('Tax') || 0))",
|
||||
params: [
|
||||
{ key: 'target', label: 'App.VisualDesigner.Script.Target.Label3', type: 'recordField' },
|
||||
{ key: 'sources', label: 'App.VisualDesigner.Script.Sources.Label', type: 'fieldList' },
|
||||
|
|
@ -424,14 +424,14 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
needsSqlRef: true,
|
||||
summary: 'App.VisualDesigner.Script.Formula.Summary',
|
||||
example:
|
||||
"refs.sqlDataSource1.setField('Net', Number(refs.sqlDataSource1.getField('Gross') || 0) * 0.82)",
|
||||
"refs.form1.setField('Net', Number(refs.form1.getField('Gross') || 0) * 0.82)",
|
||||
params: [
|
||||
{ key: 'target', label: 'App.VisualDesigner.Script.Target.Label4', type: 'recordField' },
|
||||
{
|
||||
key: 'expression',
|
||||
label: 'App.VisualDesigner.Script.Expression.Label',
|
||||
type: 'text',
|
||||
placeholder: "Number(refs.sqlDataSource1.getField('Gross') || 0) * 0.18",
|
||||
placeholder: "Number(refs.form1.getField('Gross') || 0) * 0.18",
|
||||
help: 'App.VisualDesigner.Script.Expression.Help',
|
||||
},
|
||||
],
|
||||
|
|
@ -449,7 +449,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
group: 'record',
|
||||
needsSqlRef: true,
|
||||
summary: 'App.VisualDesigner.Script.CopyFromEvent.Summary',
|
||||
example: "refs.sqlDataSource1.setField('OldValue', event?.previous)",
|
||||
example: "refs.form1.setField('OldValue', event?.previous)",
|
||||
params: [
|
||||
{
|
||||
key: 'source',
|
||||
|
|
@ -473,7 +473,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
group: 'record',
|
||||
needsSqlRef: true,
|
||||
summary: 'App.VisualDesigner.Script.ClearFields.Summary',
|
||||
example: "refs.sqlDataSource1.clearFields('City', 'District')",
|
||||
example: "refs.form1.clearFields('City', 'District')",
|
||||
params: [
|
||||
{ key: 'sources', label: 'App.VisualDesigner.Script.Sources.Label2', type: 'fieldList' },
|
||||
],
|
||||
|
|
@ -491,7 +491,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
needsSqlRef: true,
|
||||
summary: 'App.VisualDesigner.Script.DayDiff.Summary',
|
||||
example:
|
||||
"refs.sqlDataSource1.setField('TotalDays', Math.round((new Date(refs.sqlDataSource1.getField('EndDate')) - new Date(refs.sqlDataSource1.getField('StartDate'))) / 86400000) + 1)",
|
||||
"refs.form1.setField('TotalDays', Math.round((new Date(refs.form1.getField('EndDate')) - new Date(refs.form1.getField('StartDate'))) / 86400000) + 1)",
|
||||
params: [
|
||||
{ key: 'start', label: 'App.VisualDesigner.Script.Start.Label', type: 'recordField' },
|
||||
{ key: 'end', label: 'App.VisualDesigner.Script.End.Label', type: 'recordField' },
|
||||
|
|
@ -515,7 +515,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
group: 'record',
|
||||
needsSqlRef: true,
|
||||
summary: 'App.VisualDesigner.Script.Save.Summary',
|
||||
example: 'await refs.sqlDataSource1.save()',
|
||||
example: 'await refs.form1.save()',
|
||||
params: [],
|
||||
build: (_rule, context) => `await refs.${context.sqlRef}.save()`,
|
||||
describe: (_rule, t) => t('::App.VisualDesignerDescribe.Save'),
|
||||
|
|
@ -526,7 +526,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
group: 'record',
|
||||
needsSqlRef: true,
|
||||
summary: 'App.VisualDesigner.Script.Remove.Summary',
|
||||
example: 'await refs.sqlDataSource1.remove()',
|
||||
example: 'await refs.form1.remove()',
|
||||
params: [],
|
||||
build: (_rule, context) => `await refs.${context.sqlRef}.remove()`,
|
||||
describe: (_rule, t) => t('::App.VisualDesignerDescribe.Delete'),
|
||||
|
|
@ -537,7 +537,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
group: 'record',
|
||||
needsSqlRef: true,
|
||||
summary: 'App.VisualDesigner.Script.Reload.Summary',
|
||||
example: 'await refs.sqlDataSource1.reload()',
|
||||
example: 'await refs.form1.reload()',
|
||||
params: [],
|
||||
build: (_rule, context) => `await refs.${context.sqlRef}.reload()`,
|
||||
describe: (_rule, t) => t('::App.VisualDesignerDescribe.Reload'),
|
||||
|
|
@ -548,7 +548,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
group: 'record',
|
||||
needsSqlRef: true,
|
||||
summary: 'App.VisualDesigner.Script.NewRecord.Summary',
|
||||
example: 'refs.sqlDataSource1.newRecord()',
|
||||
example: 'refs.form1.newRecord()',
|
||||
params: [],
|
||||
build: (_rule, context) => `refs.${context.sqlRef}.newRecord()`,
|
||||
describe: (_rule, t) => t('::App.VisualDesignerDescribe.NewRecord'),
|
||||
|
|
@ -559,7 +559,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
group: 'record',
|
||||
needsSqlRef: true,
|
||||
summary: 'App.VisualDesigner.Script.GoToRow.Summary',
|
||||
example: 'refs.sqlDataSource1.lastRow()',
|
||||
example: 'refs.form1.lastRow()',
|
||||
params: [
|
||||
{
|
||||
key: 'target',
|
||||
|
|
@ -603,7 +603,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
group: 'record',
|
||||
needsSqlRef: true,
|
||||
summary: 'App.VisualDesigner.Script.SelectColumnToField.Summary',
|
||||
example: "refs.sqlDataSource1.setField('UnitPrice', refs.select1.getColumn('unitPrice'))",
|
||||
example: "refs.form1.setField('UnitPrice', refs.select1.getColumn('unitPrice'))",
|
||||
params: [
|
||||
{ key: 'ref', label: 'App.VisualDesigner.Script.SelectComponent.Label', type: 'refName' },
|
||||
{
|
||||
|
|
@ -677,7 +677,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
conditionIsArgument: true,
|
||||
summary: 'App.VisualDesigner.Script.RefVisible.Summary',
|
||||
example:
|
||||
"refs.card1.setVisible(String(refs.sqlDataSource1.getField('Status') ?? '') === 'Aktif')",
|
||||
"refs.card1.setVisible(String(refs.form1.getField('Status') ?? '') === 'Aktif')",
|
||||
params: [
|
||||
{ key: 'ref', label: 'App.VisualDesigner.Script.Ref.Label', type: 'refName' },
|
||||
{
|
||||
|
|
@ -713,7 +713,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
group: 'component',
|
||||
conditionIsArgument: true,
|
||||
summary: 'App.VisualDesigner.Script.RefEnabled.Summary',
|
||||
example: 'refs.button1.setEnabled(refs.sqlDataSource1.hasChanges())',
|
||||
example: 'refs.button1.setEnabled(refs.form1.hasChanges())',
|
||||
params: [
|
||||
{ key: 'ref', label: 'App.VisualDesigner.Script.Ref.Label', type: 'refName' },
|
||||
{
|
||||
|
|
@ -749,7 +749,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
group: 'component',
|
||||
conditionIsArgument: true,
|
||||
summary: 'App.VisualDesigner.Script.RefReadOnly.Summary',
|
||||
example: "refs.input1.setReadOnly(refs.sqlDataSource1.getMode() !== 'new')",
|
||||
example: "refs.input1.setReadOnly(refs.form1.getMode() !== 'new')",
|
||||
params: [
|
||||
{ key: 'ref', label: 'App.VisualDesigner.Script.Ref.Label', type: 'refName' },
|
||||
{
|
||||
|
|
@ -926,7 +926,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
group: 'integration',
|
||||
needsSqlRef: true,
|
||||
summary: 'App.VisualDesigner.Script.ApiToField.Summary',
|
||||
example: "refs.sqlDataSource1.setField('Title', (await api.get('/api/app/customer/1'))?.name)",
|
||||
example: "refs.form1.setField('Title', (await api.get('/api/app/customer/1'))?.name)",
|
||||
params: [
|
||||
{ key: 'target', label: 'App.VisualDesigner.Script.Target.Label8', type: 'recordField' },
|
||||
{
|
||||
|
|
@ -964,7 +964,7 @@ export const designerRecipes: DesignerRecipe[] = [
|
|||
label: 'App.VisualDesigner.Script.ApiPost.Label',
|
||||
group: 'integration',
|
||||
summary: 'App.VisualDesigner.Script.ApiPost.Summary',
|
||||
example: "await api.post('/api/app/order/approve', refs.sqlDataSource1.getRecord())",
|
||||
example: "await api.post('/api/app/order/approve', refs.form1.getRecord())",
|
||||
params: [
|
||||
{
|
||||
key: 'method',
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export interface DesignerBinding {
|
|||
/**
|
||||
* Label ve Value dışında, seçilen kaydın script ile okunabilmesi istenen
|
||||
* sütunlar. Ekranda hiçbiri görünmez; `refs.<ad>.getColumn('sutun')` ile
|
||||
* okunup başka bir komponentin değerine ya da SqlDataSource kaydına yazılır.
|
||||
* okunup başka bir komponentin değerine ya da Form kaydına yazılır.
|
||||
*/
|
||||
columns?: string[]
|
||||
}
|
||||
|
|
@ -24,8 +24,8 @@ export const getDesignerBindingColumns = (binding?: DesignerBinding): string[] =
|
|||
|
||||
/**
|
||||
* Data sources are no longer read-only: an endpoint can also be attached as the
|
||||
* insert/update/delete command of a SqlDataSource, mirroring ASP.NET's
|
||||
* SqlDataSource Select/Insert/Update/DeleteCommand pairs.
|
||||
* insert/update/delete command of a Form, mirroring ASP.NET's
|
||||
* Form Select/Insert/Update/DeleteCommand pairs.
|
||||
*/
|
||||
export const DESIGNER_HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ export const DESIGNER_VALUELESS_FILTER_OPERATORS: readonly DesignerFilterOperato
|
|||
* - `static` → the literal typed in the designer
|
||||
* - `query` → a query string parameter of the page URL (`?id=…`)
|
||||
* - `route` → a path segment of the page URL (last segment, or a named one)
|
||||
* - `record` → a column of another SqlDataSource's active record, which is what
|
||||
* - `record` → a column of another Form's active record, which is what
|
||||
* makes a master/detail page work without a line of code
|
||||
*/
|
||||
export const DESIGNER_FILTER_SOURCES = ['static', 'query', 'route', 'record'] as const
|
||||
|
|
@ -88,7 +88,7 @@ export interface DesignerDataSourceFilter {
|
|||
source: DesignerFilterSource
|
||||
/**
|
||||
* `static` → the literal value · `query`/`route` → the URL parameter name ·
|
||||
* `record` → `<sqlDataSourceRef>.<column>` of the master container.
|
||||
* `record` → `<formRef>.<column>` of the master container.
|
||||
*/
|
||||
value: string
|
||||
/**
|
||||
|
|
@ -110,6 +110,18 @@ export interface DesignerDataSource {
|
|||
filters?: DesignerDataSourceFilter[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission (or custom permission) that has to be granted before a node is
|
||||
* rendered. It lives next to the endpoint the component reads, so a Grid whose
|
||||
* data the user may not see is not drawn at all. Empty — the usual case — means
|
||||
* the component is shown to everyone who can open the page.
|
||||
*/
|
||||
export const DESIGNER_PERMISSION_PROP = 'designerPermission'
|
||||
|
||||
/** Visibility permission of a node, or `''` when it is unguarded. */
|
||||
export const getDesignerNodePermission = (node: { props: Record<string, unknown> }) =>
|
||||
String(node.props?.[DESIGNER_PERMISSION_PROP] ?? '').trim()
|
||||
|
||||
/**
|
||||
* Blank filter row, ready to be edited in the inspector. It starts out required:
|
||||
* a filter whose value is not there yet should hold the request back, because
|
||||
|
|
@ -310,6 +322,38 @@ export interface DesignerDocument {
|
|||
onMount: string
|
||||
}
|
||||
dataSources: DesignerDataSource[]
|
||||
/**
|
||||
* Read permission the Wizard created for this component — the same one its
|
||||
* menu entry is guarded by. It is the base of the `Otomatik` permission mode:
|
||||
* the Insert/Update/Delete commands resolve to `<code>.Create`, `.Update` and
|
||||
* `.Delete`, which is exactly the family the Wizard seeds. Empty for a
|
||||
* component that no menu opens; `Otomatik` then checks nothing.
|
||||
*/
|
||||
permissionCode?: string
|
||||
}
|
||||
|
||||
/** Permission suffix each Form command resolves to in `Otomatik` mode. */
|
||||
export const FORM_AUTO_SUFFIXES: Record<string, string> = {
|
||||
selectPermission: '',
|
||||
insertPermission: '.Create',
|
||||
updatePermission: '.Update',
|
||||
deletePermission: '.Delete',
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission a command is actually checked against: the custom one when the
|
||||
* designer picked one, otherwise the Wizard managed name. Empty when neither is
|
||||
* available, which leaves the command open.
|
||||
*/
|
||||
export const resolveFormPermission = (
|
||||
node: { props: Record<string, unknown> },
|
||||
slot: FormSlot,
|
||||
permissionCode?: string,
|
||||
) => {
|
||||
const custom = getFormPermission(node, slot)
|
||||
if (custom) return custom
|
||||
const code = String(permissionCode ?? '').trim()
|
||||
return code ? `${code}${FORM_AUTO_SUFFIXES[slot.permissionProperty] ?? ''}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -344,8 +388,6 @@ export const DESIGNER_DATA_COMPONENT_NAMES = new Set(Object.keys(DESIGNER_DATA_C
|
|||
export const getDesignerDataComponent = (type?: string) =>
|
||||
type ? DESIGNER_DATA_COMPONENTS[type] : undefined
|
||||
|
||||
export const isDesignerDataComponent = (type?: string) => Boolean(getDesignerDataComponent(type))
|
||||
|
||||
export const isDesignerOptionComponent = (type?: string) =>
|
||||
getDesignerDataComponent(type)?.dataKind === 'option'
|
||||
|
||||
|
|
@ -522,7 +564,7 @@ const DESIGNER_REF_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|||
|
||||
export const isValidDesignerRef = (value: string) => DESIGNER_REF_PATTERN.test(value)
|
||||
|
||||
/** `Radio.Group` → `radioGroup`, `SqlDataSource` → `sqlDataSource`, `div` → `div`. */
|
||||
/** `Radio.Group` → `radioGroup`, `Form` → `form`, `div` → `div`. */
|
||||
export const toDesignerRefBase = (type: string) => {
|
||||
const camelCased = String(type || '')
|
||||
.replace(/[^A-Za-z0-9]+([A-Za-z0-9])?/g, (_match, next?: string) =>
|
||||
|
|
@ -751,18 +793,11 @@ export const resolveDesignerResponse = (value: unknown, responsePath: string): u
|
|||
/**
|
||||
* ASP.NET style data container: it owns the Select/Insert/Update/Delete endpoints
|
||||
* and exposes the current record to every component dropped inside it. A child
|
||||
* binds to a column by using the SqlDataSource node id as its binding `sourceId`,
|
||||
* binds to a column by using the Form node id as its binding `sourceId`,
|
||||
* which is also the name of the record state emitted by the code generator — so
|
||||
* the existing binding machinery keeps working unchanged.
|
||||
*/
|
||||
export const SQL_DATA_SOURCE_TYPE = 'Form'
|
||||
|
||||
/**
|
||||
* The container used to be called `SqlDataSource`, and that name is what every
|
||||
* page saved before the rename carries in its document. It is still accepted, so
|
||||
* an existing page keeps rendering, keeps generating code and keeps its bindings.
|
||||
*/
|
||||
export const LEGACY_SQL_DATA_SOURCE_TYPE = 'SqlDataSource'
|
||||
export const FORM_TYPE = 'Form'
|
||||
|
||||
/**
|
||||
* Layout of a FlexRow. A single column is a plain flex row - items keep their own
|
||||
|
|
@ -798,46 +833,62 @@ export const DESIGNER_SELECT_COMPONENT_PROPERTY = 'componentAs'
|
|||
/** Props whose stored value is an icon name rather than an element. */
|
||||
export const DESIGNER_ICON_PROPERTIES = new Set(['icon', 'customIcon'])
|
||||
|
||||
export const isSqlDataSourceNode = (type?: string) =>
|
||||
type === SQL_DATA_SOURCE_TYPE || type === LEGACY_SQL_DATA_SOURCE_TYPE
|
||||
export const isFormNode = (type?: string) => type === FORM_TYPE
|
||||
|
||||
export interface SqlDataSourceSlot {
|
||||
export interface FormSlot {
|
||||
property: 'selectEndpoint' | 'insertEndpoint' | 'updateEndpoint' | 'deleteEndpoint'
|
||||
/**
|
||||
* Prop holding the permission this command is guarded by. Empty — the default
|
||||
* — leaves the command open, so a bound URL alone is enough for its button to
|
||||
* appear, exactly as before permissions existed.
|
||||
*/
|
||||
permissionProperty:
|
||||
| 'selectPermission'
|
||||
| 'insertPermission'
|
||||
| 'updatePermission'
|
||||
| 'deletePermission'
|
||||
method: DesignerHttpMethod
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const SQL_DATA_SOURCE_SLOTS: readonly SqlDataSourceSlot[] = [
|
||||
/** Permission a Form command is guarded by, or `''` when it is open. */
|
||||
export const getFormPermission = (
|
||||
node: { props: Record<string, unknown> },
|
||||
slot: FormSlot,
|
||||
) => String(node.props?.[slot.permissionProperty] ?? '').trim()
|
||||
|
||||
export const FORM_SLOTS: readonly FormSlot[] = [
|
||||
{
|
||||
property: 'selectEndpoint',
|
||||
permissionProperty: 'selectPermission',
|
||||
method: 'GET',
|
||||
label: 'Select (GET)',
|
||||
description: 'App.EventX.XDescription',
|
||||
},
|
||||
{
|
||||
property: 'insertEndpoint',
|
||||
permissionProperty: 'insertPermission',
|
||||
method: 'POST',
|
||||
label: 'Insert (POST)',
|
||||
description: 'App.EventX.XDescription2',
|
||||
},
|
||||
{
|
||||
property: 'updateEndpoint',
|
||||
permissionProperty: 'updatePermission',
|
||||
method: 'PUT',
|
||||
label: 'Update (PUT)',
|
||||
description: 'App.EventX.XDescription3',
|
||||
},
|
||||
{
|
||||
property: 'deleteEndpoint',
|
||||
permissionProperty: 'deletePermission',
|
||||
method: 'DELETE',
|
||||
label: 'Delete (DELETE)',
|
||||
description: 'App.EventX.XDescription4',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const getSqlDataSourceSlot = (property: string) =>
|
||||
SQL_DATA_SOURCE_SLOTS.find((slot) => slot.property === property)
|
||||
|
||||
/**
|
||||
* Lifecycle of the record the container owns. Every entry is emitted by the code
|
||||
* generator as a handler that receives a single `event` object, so a script never
|
||||
|
|
@ -846,7 +897,7 @@ export const getSqlDataSourceSlot = (property: string) =>
|
|||
* A `cancelable` event stops the command when the script returns `false` — that
|
||||
* is the only way a Save or a Delete can be vetoed from configuration.
|
||||
*/
|
||||
export interface SqlDataSourceEvent {
|
||||
export interface FormEvent {
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
|
|
@ -855,7 +906,7 @@ export interface SqlDataSourceEvent {
|
|||
payload: { key: string; description: string }[]
|
||||
}
|
||||
|
||||
export const SQL_DATA_SOURCE_EVENTS: readonly SqlDataSourceEvent[] = [
|
||||
export const FORM_EVENTS: readonly FormEvent[] = [
|
||||
{
|
||||
name: 'onLoad',
|
||||
label: 'App.EventOnLoad.OnLoadLabel',
|
||||
|
|
@ -957,21 +1008,17 @@ export const SQL_DATA_SOURCE_EVENTS: readonly SqlDataSourceEvent[] = [
|
|||
},
|
||||
] as const
|
||||
|
||||
export const SQL_DATA_SOURCE_EVENT_NAMES: readonly string[] = SQL_DATA_SOURCE_EVENTS.map(
|
||||
(event) => event.name,
|
||||
)
|
||||
export const getFormEvent = (name: string) =>
|
||||
FORM_EVENTS.find((event) => event.name === name)
|
||||
|
||||
export const getSqlDataSourceEvent = (name: string) =>
|
||||
SQL_DATA_SOURCE_EVENTS.find((event) => event.name === name)
|
||||
/** Scripted events of a Form node, in lifecycle order. */
|
||||
export const getFormScriptedEvents = (node: DesignerNode) =>
|
||||
FORM_EVENTS.filter((event) => Boolean(node.events?.[event.name]?.trim()))
|
||||
|
||||
/** Scripted events of a SqlDataSource node, in lifecycle order. */
|
||||
export const getSqlDataSourceScriptedEvents = (node: DesignerNode) =>
|
||||
SQL_DATA_SOURCE_EVENTS.filter((event) => Boolean(node.events?.[event.name]?.trim()))
|
||||
|
||||
export const getSqlDataSourceEndpointId = (node: DesignerNode, property: string) =>
|
||||
export const getFormEndpointId = (node: DesignerNode, property: string) =>
|
||||
String(node.props?.[property] ?? '').trim()
|
||||
|
||||
export const getSqlDataSourceKeyField = (node: DesignerNode) =>
|
||||
export const getFormKeyField = (node: DesignerNode) =>
|
||||
String(node.props?.keyFieldName ?? '').trim() || 'id'
|
||||
|
||||
/**
|
||||
|
|
@ -979,7 +1026,7 @@ export const getSqlDataSourceKeyField = (node: DesignerNode) =>
|
|||
* the setting existed carry no value at all, so the fallback is 1 — every child
|
||||
* on its own row, which is exactly how those pages already look.
|
||||
*/
|
||||
export const getSqlDataSourceColumnCount = (node: DesignerNode) =>
|
||||
export const getFormColumnCount = (node: DesignerNode) =>
|
||||
Math.min(12, Math.max(1, Math.floor(Number(node.props?.columnCount) || 1)))
|
||||
|
||||
/**
|
||||
|
|
@ -988,33 +1035,34 @@ export const getSqlDataSourceColumnCount = (node: DesignerNode) =>
|
|||
* result to the requested record — all without any extra configuration, which is
|
||||
* why the query string is the default rather than an opt in.
|
||||
*/
|
||||
export const SQL_DATA_SOURCE_KEY_SOURCES = [
|
||||
{ value: 'query', label: 'URL query string (?id=…)' },
|
||||
{ value: 'route', label: 'App.EventOnError.OnErrorLabel2' },
|
||||
/** Etiketler dil anahtarıdır; gösterildikleri yerde translate edilirler. */
|
||||
export const FORM_KEY_SOURCES = [
|
||||
{ value: 'query', label: 'App.DeveloperKitComponentDesigner.KeySourceQuery' },
|
||||
{ value: 'route', label: 'App.DeveloperKitComponentDesigner.KeySourceRoute' },
|
||||
] as const
|
||||
|
||||
export type SqlDataSourceKeySource = (typeof SQL_DATA_SOURCE_KEY_SOURCES)[number]['value']
|
||||
export type FormKeySource = (typeof FORM_KEY_SOURCES)[number]['value']
|
||||
|
||||
export const getSqlDataSourceKeySource = (node: DesignerNode): SqlDataSourceKeySource =>
|
||||
export const getFormKeySource = (node: DesignerNode): FormKeySource =>
|
||||
String(node.props?.keySource ?? '') === 'route' ? 'route' : 'query'
|
||||
|
||||
/** Name of the URL parameter; defaults to the key column. */
|
||||
export const getSqlDataSourceKeyParam = (node: DesignerNode) =>
|
||||
String(node.props?.keyParamName ?? '').trim() || getSqlDataSourceKeyField(node)
|
||||
export const getFormKeyParam = (node: DesignerNode) =>
|
||||
String(node.props?.keyParamName ?? '').trim() || getFormKeyField(node)
|
||||
|
||||
/**
|
||||
* Design time only. A GetById endpoint cannot be sampled in the designer — there
|
||||
* is no page URL to read the key from — so the columns stay unknown and children
|
||||
* have nothing to bind to. This value stands in for the key while designing.
|
||||
*/
|
||||
export const getSqlDataSourcePreviewKey = (node: DesignerNode) =>
|
||||
export const getFormPreviewKey = (node: DesignerNode) =>
|
||||
String(node.props?.previewKeyValue ?? '').trim()
|
||||
|
||||
/** Edit mode drives which command Save issues, independently of the key value. */
|
||||
export type SqlDataSourceMode = 'new' | 'edit'
|
||||
export type FormMode = 'new' | 'edit'
|
||||
|
||||
/** Case insensitive column read; SQL results rarely match the configured casing. */
|
||||
export const readSqlDataSourceField = (record: unknown, field: string): unknown => {
|
||||
export const readFormField = (record: unknown, field: string): unknown => {
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) return undefined
|
||||
const source = record as Record<string, unknown>
|
||||
if (field in source) return source[field]
|
||||
|
|
@ -1029,7 +1077,7 @@ export const readSqlDataSourceField = (record: unknown, field: string): unknown
|
|||
* result set so a detail endpoint works without extra configuration; ABP list
|
||||
* envelopes (`{ items: [] }`) are unwrapped.
|
||||
*/
|
||||
export const resolveSqlDataSourceRows = (
|
||||
export const resolveFormRows = (
|
||||
sample: unknown,
|
||||
collectionPath = '',
|
||||
): Array<Record<string, unknown>> => {
|
||||
|
|
@ -1047,14 +1095,14 @@ export const resolveSqlDataSourceRows = (
|
|||
)
|
||||
}
|
||||
|
||||
export const getSqlDataSourceRecord = (
|
||||
export const getFormRecord = (
|
||||
node: DesignerNode,
|
||||
dataValues: Record<string, unknown>,
|
||||
rowIndex = 0,
|
||||
): Record<string, unknown> => {
|
||||
const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint')
|
||||
const selectId = getFormEndpointId(node, 'selectEndpoint')
|
||||
if (!selectId) return {}
|
||||
const rows = resolveSqlDataSourceRows(
|
||||
const rows = resolveFormRows(
|
||||
dataValues[selectId],
|
||||
String(node.props?.collectionPath ?? ''),
|
||||
)
|
||||
|
|
@ -1067,20 +1115,20 @@ export const getSqlDataSourceRecord = (
|
|||
* the caller knows if it still has to be sent as a query parameter.
|
||||
*/
|
||||
/** `/api/app/orders/{id}` — a URL that cannot be called until a key fills it in. */
|
||||
export const hasSqlDataSourceUrlParams = (url: string) =>
|
||||
export const hasFormUrlParams = (url: string) =>
|
||||
/\{[^}]+\}|(?<=\/):[A-Za-z_][A-Za-z0-9_]*/.test(url)
|
||||
|
||||
export const appendSqlDataSourceQueryParam = (url: string, name: string, value: string) =>
|
||||
export const appendFormQueryParam = (url: string, name: string, value: string) =>
|
||||
`${url}${url.includes('?') ? '&' : '?'}${encodeURIComponent(name)}=${encodeURIComponent(value)}`
|
||||
|
||||
export const bindSqlDataSourceUrl = (url: string, record: unknown, keyField: string) => {
|
||||
export const bindFormUrl = (url: string, record: unknown, keyField: string) => {
|
||||
let keyBound = false
|
||||
const boundUrl = url.replace(
|
||||
/\{([^}]+)\}|(?<=\/):([A-Za-z_][A-Za-z0-9_]*)/g,
|
||||
(match, braced?: string, colon?: string) => {
|
||||
const name = (braced || colon || '').trim()
|
||||
if (!name) return match
|
||||
const value = readSqlDataSourceField(record, name) ?? readSqlDataSourceField(record, keyField)
|
||||
const value = readFormField(record, name) ?? readFormField(record, keyField)
|
||||
if (value === undefined || value === null || value === '') return match
|
||||
if (name.toLocaleLowerCase('en') === keyField.toLocaleLowerCase('en')) keyBound = true
|
||||
return encodeURIComponent(String(value))
|
||||
|
|
@ -1120,12 +1168,12 @@ export const normalizeDesignerKeyList = (value: unknown): string[] => {
|
|||
*/
|
||||
export const SQL_DEFAULT_VALUE_PROP = 'sqlDefaultValue'
|
||||
|
||||
/** Props a SqlDataSource scope reads from and writes back to the record. */
|
||||
/** Props a Form 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`. */
|
||||
/** Which prop of `node` is bound to the record of the Form `sourceId`. */
|
||||
export const getSqlFormValueProperty = (
|
||||
node: DesignerNode,
|
||||
sourceId?: string,
|
||||
|
|
@ -1203,14 +1251,14 @@ export const resolveSqlDefaultField = (field: SqlDefaultField): unknown => {
|
|||
}
|
||||
|
||||
/**
|
||||
* Every default declared by the descendants of a SqlDataSource. A nested
|
||||
* Every default declared by the descendants of a Form. 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
|
||||
if (isFormNode(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() : ''
|
||||
|
|
@ -1249,7 +1297,7 @@ export const setDesignerRecordField = (
|
|||
return next
|
||||
}
|
||||
|
||||
/** Record a SqlDataSource opens New mode with. Empty when nothing declares one. */
|
||||
/** Record a Form 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)),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import ErrorBoundary from '@/components/componentEditor/ErrorBoundary'
|
|||
import DOMPurify from 'dompurify'
|
||||
import { useStoreState } from '@/store/store'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { usePermission } from '@/utils/hooks/usePermission'
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
|
|
@ -96,6 +97,7 @@ export const useComponents = () => {
|
|||
|
||||
export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { translate } = useLocalization()
|
||||
const { checkPermission } = usePermission()
|
||||
const applicationConfig = useStoreState((state) => state.abpConfig?.config)
|
||||
const extraProperties = applicationConfig?.extraProperties
|
||||
|
||||
|
|
@ -118,6 +120,16 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
[],
|
||||
)
|
||||
|
||||
// Same treatment for the permission check: a runtime component asks it whether
|
||||
// a CRUD button may be shown, and the granted policies must not force the
|
||||
// whole bundle to be recompiled every time the configuration store updates.
|
||||
const checkPermissionRef = useRef(checkPermission)
|
||||
checkPermissionRef.current = checkPermission
|
||||
const stableCheckPermission = useCallback(
|
||||
(permission?: string) => checkPermissionRef.current(permission),
|
||||
[],
|
||||
)
|
||||
|
||||
const refreshComponents = useCallback(async () => {
|
||||
if (!applicationConfig) return
|
||||
|
||||
|
|
@ -296,6 +308,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
DOMPurify,
|
||||
axios,
|
||||
translate: stableTranslate,
|
||||
checkPermission: stableCheckPermission,
|
||||
})
|
||||
|
||||
const { registry, errors } = compileComponentBundle(
|
||||
|
|
@ -323,7 +336,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [compilationSignature, stableTranslate])
|
||||
}, [compilationSignature, stableCheckPermission, stableTranslate])
|
||||
|
||||
const renderComponent = useCallback(
|
||||
(name: string, props: ComponentProps = {}) => {
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
|
|||
import DesignerScriptBuilderDialog from '@/components/visualDesigner/DesignerScriptBuilderDialog'
|
||||
import {
|
||||
collectDesignerRefs,
|
||||
getSqlDataSourceEvent,
|
||||
getFormEvent,
|
||||
createDesignerId,
|
||||
createEmptyDesignerDocument,
|
||||
findDesignerNode,
|
||||
|
|
@ -69,30 +69,32 @@ import {
|
|||
getDesignerBindingColumns,
|
||||
getDesignerCollectionProperty,
|
||||
getDesignerValueByPath,
|
||||
getSqlDataSourceEndpointId,
|
||||
getSqlDataSourceKeyField,
|
||||
getSqlDataSourceKeyParam,
|
||||
getSqlDataSourceKeySource,
|
||||
getSqlDataSourcePreviewKey,
|
||||
getSqlDataSourceRecord,
|
||||
getFormEndpointId,
|
||||
getFormKeyField,
|
||||
getFormKeyParam,
|
||||
getFormKeySource,
|
||||
getFormPreviewKey,
|
||||
getFormRecord,
|
||||
getSqlRecordProperty,
|
||||
isDesignerDateComponent,
|
||||
isDesignerDateOnlyComponent,
|
||||
DESIGNER_ICON_PROPERTIES,
|
||||
isDesignerOptionComponent,
|
||||
isDesignerTabularComponent,
|
||||
isSqlDataSourceNode,
|
||||
LEGACY_SQL_DATA_SOURCE_TYPE,
|
||||
isFormNode,
|
||||
resolveDesignerResponse,
|
||||
toDesignerHttpMethod,
|
||||
walkDesignerNodes,
|
||||
appendSqlDataSourceQueryParam,
|
||||
bindSqlDataSourceUrl,
|
||||
hasSqlDataSourceUrlParams,
|
||||
appendFormQueryParam,
|
||||
bindFormUrl,
|
||||
hasFormUrlParams,
|
||||
DESIGNER_HTTP_METHODS,
|
||||
SQL_DATA_SOURCE_KEY_SOURCES,
|
||||
SQL_DATA_SOURCE_SLOTS,
|
||||
SQL_DATA_SOURCE_TYPE,
|
||||
DESIGNER_PERMISSION_PROP,
|
||||
FORM_AUTO_SUFFIXES,
|
||||
getDesignerNodePermission,
|
||||
getFormPermission,
|
||||
FORM_KEY_SOURCES,
|
||||
FORM_SLOTS,
|
||||
SQL_DEFAULT_VALUE_PROP,
|
||||
SQL_DEFAULT_VALUE_TOKENS,
|
||||
type DesignerComponentDefinition,
|
||||
|
|
@ -104,7 +106,6 @@ import {
|
|||
createDesignerFilter,
|
||||
DESIGNER_FILTER_OPERATORS,
|
||||
DESIGNER_FILTER_SOURCES,
|
||||
getDesignerDataSourceFilters,
|
||||
isValuelessDesignerFilter,
|
||||
type DesignerDataSource,
|
||||
type DesignerDataSourceFilter,
|
||||
|
|
@ -119,6 +120,10 @@ import {
|
|||
import { IconPickerField } from '@/views/shared/MenuAddDialog'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import Input from '@/components/ui/Input'
|
||||
import CreatableSelect from 'react-select/creatable'
|
||||
import { getPermissionsList } from '@/services/identity.service'
|
||||
import { getMenus } from '@/services/menu.service'
|
||||
import type { PermissionDefinitionRecord } from '@/proxy/admin/models'
|
||||
|
||||
type WorkspaceTab = 'design' | 'data' | 'code' | 'javascript'
|
||||
type InspectorTab = 'properties' | 'data' | 'tree'
|
||||
|
|
@ -158,7 +163,7 @@ const DATA_BINDABLE_PROPERTY_NAMES = new Set([
|
|||
'loading',
|
||||
])
|
||||
/**
|
||||
* Properties worth binding to a SqlDataSource record column, most used first.
|
||||
* Properties worth binding to a Form record column, most used first.
|
||||
* A component exposes dozens of props; offering every one of them turns the
|
||||
* panel into noise, so only the ones that actually carry record data are listed.
|
||||
*/
|
||||
|
|
@ -337,6 +342,21 @@ const getSaveErrorMessage = (error: unknown, translate: TranslateFn) => {
|
|||
)
|
||||
}
|
||||
|
||||
/** Hint under a permission field, worded for what that command actually does. */
|
||||
const PERMISSION_HINT_KEYS: Record<string, string> = {
|
||||
selectPermission: '::App.DeveloperKitComponentDesigner.PermissionHintRead',
|
||||
insertPermission: '::App.DeveloperKitComponentDesigner.PermissionHintInsert',
|
||||
updatePermission: '::App.DeveloperKitComponentDesigner.PermissionHintUpdate',
|
||||
deletePermission: '::App.DeveloperKitComponentDesigner.PermissionHintDelete',
|
||||
}
|
||||
|
||||
/** Route comparison that ignores a leading slash and letter case. */
|
||||
const normalizeRoutePath = (value?: string | null) =>
|
||||
String(value ?? '')
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.toLocaleLowerCase('tr')
|
||||
|
||||
const isRunnableDataSourceUrl = (url: string) => {
|
||||
const normalizedUrl = url.trim().replace(/\/+$/, '')
|
||||
return normalizedUrl.startsWith('/api/') && normalizedUrl !== '/api/app'
|
||||
|
|
@ -636,7 +656,6 @@ const definitionToNode = (
|
|||
* toolbox, the layer tree and the inspector all read it.
|
||||
*/
|
||||
const LEGACY_NODE_TYPES: Record<string, string> = {
|
||||
[LEGACY_SQL_DATA_SOURCE_TYPE]: SQL_DATA_SOURCE_TYPE,
|
||||
TwoColumns: 'FlexRow',
|
||||
SidebarContent: 'FlexRow',
|
||||
HeaderContent: 'PageContainer',
|
||||
|
|
@ -1346,6 +1365,28 @@ const VisualComponentDesigner = () => {
|
|||
const [catalogSourceTestResult, setCatalogSourceTestResult] = useState<DataTestResult | null>(
|
||||
null,
|
||||
)
|
||||
/**
|
||||
* Permission names offered while an endpoint is being edited. Loaded once, on
|
||||
* the first time the editor is opened: the list is the tenant's whole
|
||||
* permission tree, custom permissions included, and most designer sessions
|
||||
* never open the endpoint editor at all.
|
||||
*/
|
||||
const [permissionOptions, setPermissionOptions] = useState<string[]>([])
|
||||
/** `Custom` chosen but no permission picked yet, keyed by `<nodeId>:<prop>`. */
|
||||
const [permissionModes, setPermissionModes] = useState<Record<string, 'auto' | 'custom'>>({})
|
||||
/**
|
||||
* Read permission of the menu that opens this component — the one the Wizard
|
||||
* created. `Otomatik` resolves against it, so the commands land on the same
|
||||
* `<code>.Create/.Update/.Delete` family the Wizard seeds.
|
||||
*/
|
||||
/**
|
||||
* `null` until the lookup finishes: a document must not lose the permission
|
||||
* code it was saved with just because the menu list has not arrived yet — or
|
||||
* could not be read at all — which would silently turn every `Otomatik`
|
||||
* command back into an unchecked one.
|
||||
*/
|
||||
const [wizardPermissionCode, setWizardPermissionCode] = useState<string | null>(null)
|
||||
const permissionOptionsLoaded = useRef(false)
|
||||
/** Free text filter over the reusable endpoint catalog. */
|
||||
const [endpointSearch, setEndpointSearch] = useState('')
|
||||
const [endpointMethodFilter, setEndpointMethodFilter] = useState<'all' | DesignerHttpMethod>(
|
||||
|
|
@ -1455,7 +1496,19 @@ const VisualComponentDesigner = () => {
|
|||
redoStack.current = []
|
||||
}, [catalogByName, component, id, loadedId])
|
||||
|
||||
const generatedCode = useMemo(() => generateDesignerCode(name, document), [document, name])
|
||||
/**
|
||||
* The permission code travels with the saved document so the runtime — which
|
||||
* regenerates the component from its stored schema, without the menu list —
|
||||
* resolves `Otomatik` to the same names the designer showed.
|
||||
*/
|
||||
const documentToGenerate = useMemo(
|
||||
() => ({ ...document, permissionCode: wizardPermissionCode ?? document.permissionCode }),
|
||||
[document, wizardPermissionCode],
|
||||
)
|
||||
const generatedCode = useMemo(
|
||||
() => generateDesignerCode(name, documentToGenerate),
|
||||
[documentToGenerate, name],
|
||||
)
|
||||
const effectiveCode = document.sourceMode === 'visual' ? generatedCode : manualCode
|
||||
const isDirty =
|
||||
effectiveCode.trim() !== savedCode.trim() ||
|
||||
|
|
@ -1602,17 +1655,17 @@ const VisualComponentDesigner = () => {
|
|||
const resolveRecipeCode = useCallback(
|
||||
(code: string) => {
|
||||
const entries = [...documentRefs.entries()]
|
||||
const sqlRef = entries.find(([, node]) => isSqlDataSourceNode(node.type))?.[0]
|
||||
const sqlRef = entries.find(([, node]) => isFormNode(node.type))?.[0]
|
||||
const selectedRef =
|
||||
selectedNode && !isSqlDataSourceNode(selectedNode.type) ? selectedNode.ref : undefined
|
||||
selectedNode && !isFormNode(selectedNode.type) ? selectedNode.ref : undefined
|
||||
const componentRef =
|
||||
selectedRef ||
|
||||
entries.find(([, node]) => !isSqlDataSourceNode(node.type) && node.kind === 'ui')?.[0] ||
|
||||
entries.find(([, node]) => !isSqlDataSourceNode(node.type))?.[0]
|
||||
entries.find(([, node]) => !isFormNode(node.type) && node.kind === 'ui')?.[0] ||
|
||||
entries.find(([, node]) => !isFormNode(node.type))?.[0]
|
||||
const url = document.dataSources.find((source) => source.method === 'GET')?.url
|
||||
return code
|
||||
.replace(/\{\{ref\}\}/g, componentRef || 'button1')
|
||||
.replace(/\{\{sql\}\}/g, sqlRef || 'sqlDataSource1')
|
||||
.replace(/\{\{sql\}\}/g, sqlRef || 'form1')
|
||||
.replace(/\{\{url\}\}/g, url || '/api/app/orders')
|
||||
},
|
||||
[document.dataSources, documentRefs, selectedNode],
|
||||
|
|
@ -1630,28 +1683,28 @@ const VisualComponentDesigner = () => {
|
|||
)
|
||||
}, [scriptApiSearch, translate])
|
||||
/**
|
||||
* Record of every SqlDataSource on the canvas, derived from the tested Select
|
||||
* Record of every Form on the canvas, derived from the tested Select
|
||||
* endpoint. Children bind to it through the container's node id, so it is
|
||||
* merged into the canvas data values and into the binding inspector samples.
|
||||
*/
|
||||
const sqlDataSourceRecords = useMemo(() => {
|
||||
const formRecords = useMemo(() => {
|
||||
const records: Record<string, unknown> = {}
|
||||
walkDesignerNodes(document.nodes, (node) => {
|
||||
if (isSqlDataSourceNode(node.type)) {
|
||||
records[node.id] = getSqlDataSourceRecord(node, dataSourceSamples)
|
||||
if (isFormNode(node.type)) {
|
||||
records[node.id] = getFormRecord(node, dataSourceSamples)
|
||||
}
|
||||
})
|
||||
return records
|
||||
}, [dataSourceSamples, document.nodes])
|
||||
/**
|
||||
* SqlDataSource containers a filter can read its value from. Only a named
|
||||
* Form containers a filter can read its value from. Only a named
|
||||
* container is addressable: the generated code resolves the master through its
|
||||
* ref, exactly like a script does.
|
||||
*/
|
||||
const sqlContainerRefs = useMemo(() => {
|
||||
const refs: { ref: string; id: string }[] = []
|
||||
walkDesignerNodes(document.nodes, (node) => {
|
||||
if (isSqlDataSourceNode(node.type) && node.ref) refs.push({ ref: node.ref, id: node.id })
|
||||
if (isFormNode(node.type) && node.ref) refs.push({ ref: node.ref, id: node.id })
|
||||
})
|
||||
return refs
|
||||
}, [document.nodes])
|
||||
|
|
@ -1732,7 +1785,7 @@ const VisualComponentDesigner = () => {
|
|||
// `withPreviewFilterValues` for why they are read through refs.
|
||||
const sqlPreviewRecords = useRef<Record<string, unknown>>({})
|
||||
const sqlPreviewRefs = useRef<{ ref: string; id: string }[]>([])
|
||||
sqlPreviewRecords.current = sqlDataSourceRecords
|
||||
sqlPreviewRecords.current = formRecords
|
||||
sqlPreviewRefs.current = sqlContainerRefs
|
||||
|
||||
const updateDataSourceFilters = useCallback(
|
||||
|
|
@ -1748,17 +1801,17 @@ const VisualComponentDesigner = () => {
|
|||
)
|
||||
|
||||
const previewDataValues = useMemo(
|
||||
() => ({ ...dataSourceSamples, ...sqlDataSourceRecords }),
|
||||
[dataSourceSamples, sqlDataSourceRecords],
|
||||
() => ({ ...dataSourceSamples, ...formRecords }),
|
||||
[dataSourceSamples, formRecords],
|
||||
)
|
||||
const selectedAncestors = useMemo(
|
||||
() => findDesignerAncestors(document.nodes, selectedId) || [],
|
||||
[document.nodes, selectedId],
|
||||
)
|
||||
const selectedIsSqlDataSource = isSqlDataSourceNode(selectedNode?.type)
|
||||
/** Nearest SqlDataSource above the selection; its record is bindable. */
|
||||
const selectedIsForm = isFormNode(selectedNode?.type)
|
||||
/** Nearest Form above the selection; its record is bindable. */
|
||||
const sqlScopeNode = useMemo(
|
||||
() => [...selectedAncestors].reverse().find((node) => isSqlDataSourceNode(node.type)),
|
||||
() => [...selectedAncestors].reverse().find((node) => isFormNode(node.type)),
|
||||
[selectedAncestors],
|
||||
)
|
||||
/**
|
||||
|
|
@ -1770,8 +1823,8 @@ const VisualComponentDesigner = () => {
|
|||
sqlScopeNode
|
||||
? {
|
||||
id: sqlScopeNode.id,
|
||||
name: translate('::App.DeveloperKitComponentDesigner.SqlDataSourceRecord', {
|
||||
field: getSqlDataSourceKeyField(sqlScopeNode),
|
||||
name: translate('::App.DeveloperKitComponentDesigner.FormRecord', {
|
||||
field: getFormKeyField(sqlScopeNode),
|
||||
}),
|
||||
method: 'GET',
|
||||
url: '',
|
||||
|
|
@ -1783,12 +1836,12 @@ const VisualComponentDesigner = () => {
|
|||
/**
|
||||
* Only a GET endpoint returns rows, so only GET can feed a property binding —
|
||||
* and only one that is directly callable: a `{id}` URL has no key to fill it
|
||||
* with outside a SqlDataSource, so it cannot back a Grid or a Select list.
|
||||
* with outside a Form, so it cannot back a Grid or a Select list.
|
||||
*/
|
||||
const bindableDataSources = useMemo(
|
||||
() =>
|
||||
document.dataSources.filter(
|
||||
(source) => source.method === 'GET' && !hasSqlDataSourceUrlParams(source.url),
|
||||
(source) => source.method === 'GET' && !hasFormUrlParams(source.url),
|
||||
),
|
||||
[document.dataSources],
|
||||
)
|
||||
|
|
@ -1799,7 +1852,7 @@ const VisualComponentDesigner = () => {
|
|||
)
|
||||
/**
|
||||
* A collection component keeps managing its own list endpoint even inside a
|
||||
* SqlDataSource — the option list of a Select and the record the form edits are
|
||||
* Form — the option list of a Select and the record the form edits are
|
||||
* two different things. Everything else inside the container is a record field
|
||||
* and may only bind to the container's Select result.
|
||||
*/
|
||||
|
|
@ -1816,7 +1869,7 @@ const VisualComponentDesigner = () => {
|
|||
// the Data tab is where its filters are configured.
|
||||
(isDataCategoryComponent(selectedDefinition) ||
|
||||
selectedNode.kind === 'platform' ||
|
||||
selectedIsSqlDataSource ||
|
||||
selectedIsForm ||
|
||||
sqlScopeNode),
|
||||
)
|
||||
const selectedProperties = useMemo<DesignerPropertyInfo[]>(() => {
|
||||
|
|
@ -1906,7 +1959,7 @@ const VisualComponentDesigner = () => {
|
|||
|
||||
generatedEndpoints
|
||||
// Every active endpoint is offered, including GetById: its `{id}` is filled
|
||||
// by the SqlDataSource from the page URL, exactly like Update and Delete.
|
||||
// by the Form from the page URL, exactly like Update and Delete.
|
||||
.filter((endpoint) => endpoint.isActive)
|
||||
.forEach((endpoint) => {
|
||||
const method = toDesignerHttpMethod(endpoint.method)
|
||||
|
|
@ -2045,9 +2098,9 @@ const VisualComponentDesigner = () => {
|
|||
source.responsePath.trim() === catalogItem.source.responsePath.trim(),
|
||||
)
|
||||
// Only a directly callable GET source produces rows. A write endpoint or a
|
||||
// GetById is attached to a SqlDataSource command slot from the inspector.
|
||||
// GetById is attached to a Form command slot from the inspector.
|
||||
const bindsCollection =
|
||||
catalogItem.source.method === 'GET' && !hasSqlDataSourceUrlParams(catalogItem.source.url)
|
||||
catalogItem.source.method === 'GET' && !hasFormUrlParams(catalogItem.source.url)
|
||||
const collectionProperty =
|
||||
selectedId && isTabularDataComponent(selectedNode?.type)
|
||||
? 'items'
|
||||
|
|
@ -2149,7 +2202,7 @@ const VisualComponentDesigner = () => {
|
|||
const probeUrl = buildDesignerPreviewUrl(withPreviewFilterValues(relaxed), urlOverride)?.trim()
|
||||
if (!probeUrl || !probeUrl.startsWith('/api/')) return
|
||||
if (!isRunnableDataSourceUrl((urlOverride ?? source.url).trim())) return
|
||||
if (hasSqlDataSourceUrlParams(probeUrl)) return
|
||||
if (hasFormUrlParams(probeUrl)) return
|
||||
try {
|
||||
const response = await apiService.fetchData({ method: 'GET', url: probeUrl })
|
||||
const result = resolveDesignerResponse(response.data, source.responsePath)
|
||||
|
|
@ -2166,7 +2219,7 @@ const VisualComponentDesigner = () => {
|
|||
/**
|
||||
* `urlOverride` carries a URL whose `{id}` was already filled in — a GetById
|
||||
* endpoint cannot be sampled otherwise, and without a sample the designer has
|
||||
* no columns to offer the components inside the SqlDataSource.
|
||||
* no columns to offer the components inside the Form.
|
||||
*/
|
||||
const testDataSource = useCallback(
|
||||
async (source: DesignerDataSource, showResult = false, urlOverride?: string) => {
|
||||
|
|
@ -2230,7 +2283,7 @@ const VisualComponentDesigner = () => {
|
|||
if (!isRunnableDataSourceUrl((urlOverride ?? source.url).trim())) {
|
||||
throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointNotRunnable'))
|
||||
}
|
||||
if (hasSqlDataSourceUrlParams(requestUrl)) {
|
||||
if (hasFormUrlParams(requestUrl)) {
|
||||
throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointNeedsKey'))
|
||||
}
|
||||
const response = await apiService.fetchData({ method: source.method, url: requestUrl })
|
||||
|
|
@ -2307,6 +2360,147 @@ const VisualComponentDesigner = () => {
|
|||
[componentDetails, components, updateComponent],
|
||||
)
|
||||
|
||||
/** Fills the permission picker the first time an endpoint editor is opened. */
|
||||
const loadPermissionOptions = useCallback(() => {
|
||||
if (permissionOptionsLoaded.current) return
|
||||
permissionOptionsLoaded.current = true
|
||||
getPermissionsList()
|
||||
.then((response) => {
|
||||
const names = (response.data || [])
|
||||
.map((permission: PermissionDefinitionRecord) => permission.name)
|
||||
.filter((permissionName): permissionName is string => Boolean(permissionName))
|
||||
setPermissionOptions([...new Set(names)].sort((left, right) => left.localeCompare(right)))
|
||||
})
|
||||
.catch((error) => {
|
||||
// A missing list is not fatal: the field still accepts a typed name.
|
||||
permissionOptionsLoaded.current = false
|
||||
console.error('Yetki listesi alınamadı:', error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Permission field of an endpoint, in two steps. `Otomatik` — the default —
|
||||
* checks nothing in the browser: the endpoint's own authorization decides, so
|
||||
* a bound URL is all a button needs. `Custom` reveals the permission list,
|
||||
* which holds every permission the platform knows (the ones generated for the
|
||||
* modules as well as the custom ones); the picked name then decides whether
|
||||
* the button, or the whole component, is rendered at all.
|
||||
*
|
||||
* The mode is not stored: an empty permission *is* `Otomatik`. Only the
|
||||
* unfinished state — `Custom` picked, nothing chosen yet — lives in component
|
||||
* state, keyed by node and property.
|
||||
*/
|
||||
const orderedPermissionOptions = useMemo(() => {
|
||||
const code = wizardPermissionCode ?? document.permissionCode ?? ''
|
||||
if (!code) return permissionOptions
|
||||
// Derived from the slot table rather than spelled out here: the suffixes the
|
||||
// Wizard seeds are declared once, and `Otomatik` resolves against the same map.
|
||||
const family = FORM_SLOTS.map(
|
||||
(slot) => `${code}${FORM_AUTO_SUFFIXES[slot.permissionProperty] ?? ''}`,
|
||||
).filter((permission) => permissionOptions.includes(permission))
|
||||
const rest = permissionOptions.filter((permission) => !family.includes(permission))
|
||||
return [...family, ...rest]
|
||||
}, [document.permissionCode, permissionOptions, wizardPermissionCode])
|
||||
|
||||
const renderPermissionPicker = (
|
||||
modeKey: string,
|
||||
value: string,
|
||||
onChange: (permission: string) => void,
|
||||
hints: { auto: string; custom: string },
|
||||
) => {
|
||||
const mode = value ? 'custom' : permissionModes[modeKey] || 'auto'
|
||||
const modeOptions = [
|
||||
{
|
||||
value: 'auto',
|
||||
label: translate('::App.DeveloperKitComponentDesigner.PermissionTypeAuto'),
|
||||
},
|
||||
{
|
||||
value: 'custom',
|
||||
label: translate('::App.DeveloperKitComponentDesigner.PermissionTypeCustom'),
|
||||
},
|
||||
]
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
{translate('::App.DeveloperKitComponentDesigner.EndpointPermission')}
|
||||
</span>
|
||||
<Select
|
||||
className="w-full"
|
||||
maxMenuHeight={200}
|
||||
menuPortalTarget={window.document.body}
|
||||
options={modeOptions}
|
||||
size="xs"
|
||||
value={modeOptions.find((option) => option.value === mode)}
|
||||
onChange={(option) => {
|
||||
const nextMode = option?.value === 'custom' ? 'custom' : 'auto'
|
||||
setPermissionModes((current) => ({ ...current, [modeKey]: nextMode }))
|
||||
// Leaving Custom drops the permission, so the document never keeps a
|
||||
// name that is no longer being checked.
|
||||
if (nextMode === 'auto' && value) onChange('')
|
||||
}}
|
||||
/>
|
||||
{mode === 'custom' && (
|
||||
<Select
|
||||
isClearable
|
||||
className="mt-1.5 w-full"
|
||||
componentAs={CreatableSelect}
|
||||
/* The component's own Wizard family first: in a catalog of hundreds
|
||||
of permissions those four are what a designer reaches for. */
|
||||
formatCreateLabel={(input: string) =>
|
||||
translate('::App.DeveloperKitComponentDesigner.UseCustomPermission', {
|
||||
permission: input,
|
||||
})
|
||||
}
|
||||
maxMenuHeight={200}
|
||||
menuPortalTarget={window.document.body}
|
||||
options={orderedPermissionOptions.map((permission) => ({
|
||||
value: permission,
|
||||
label: permission,
|
||||
}))}
|
||||
placeholder={translate('::App.DeveloperKitComponentDesigner.PermissionPlaceholder')}
|
||||
size="xs"
|
||||
value={value ? { value, label: value } : null}
|
||||
onChange={(option) =>
|
||||
onChange((option as { value?: string } | null)?.value?.trim() || '')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] leading-4 text-slate-500">
|
||||
{translate(mode === 'custom' ? hints.custom : hints.auto)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadPermissionOptions()
|
||||
}, [loadPermissionOptions])
|
||||
|
||||
useEffect(() => {
|
||||
const route = normalizeRoutePath(component?.routePath)
|
||||
if (!route) {
|
||||
setWizardPermissionCode(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
getMenus()
|
||||
.then((response) => {
|
||||
if (cancelled) return
|
||||
const menu = (response.data?.items || []).find(
|
||||
(item) => normalizeRoutePath(item.url) === route,
|
||||
)
|
||||
setWizardPermissionCode(menu?.requiredPermissionName?.trim() || '')
|
||||
})
|
||||
.catch((error) => {
|
||||
// Leave the stored code in place: an unreadable menu list is not proof
|
||||
// that the component has no permission.
|
||||
console.error('Menü yetkisi okunamadı:', error)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [component?.routePath])
|
||||
|
||||
const openCatalogSourceEditor = (item: DataSourceCatalogItem) => {
|
||||
if (item.originType !== 'component') return
|
||||
setCatalogSourceEditor({ item, draft: { ...item.source } })
|
||||
|
|
@ -2655,7 +2849,7 @@ const VisualComponentDesigner = () => {
|
|||
)
|
||||
const propsToSave =
|
||||
document.sourceMode === 'visual'
|
||||
? JSON.stringify({ ...existingProps, visualDesigner: document })
|
||||
? JSON.stringify({ ...existingProps, visualDesigner: documentToGenerate })
|
||||
: component.props
|
||||
const update = {
|
||||
name: component.name,
|
||||
|
|
@ -2693,7 +2887,16 @@ const VisualComponentDesigner = () => {
|
|||
setIsSaving(false)
|
||||
}
|
||||
},
|
||||
[collectDependencies, component, document, generatedCode, id, manualCode, updateComponent],
|
||||
[
|
||||
collectDependencies,
|
||||
component,
|
||||
document,
|
||||
documentToGenerate,
|
||||
generatedCode,
|
||||
id,
|
||||
manualCode,
|
||||
updateComponent,
|
||||
],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -2786,24 +2989,24 @@ const VisualComponentDesigner = () => {
|
|||
*/
|
||||
useEffect(() => {
|
||||
if (!id || loadedId !== id) return
|
||||
// A GetById Select is sampled through the owning SqlDataSource's preview key,
|
||||
// A GetById Select is sampled through the owning Form's preview key,
|
||||
// since there is no page URL to read the real key from while designing.
|
||||
const previewUrls = new Map<string, string>()
|
||||
walkDesignerNodes(document.nodes, (node) => {
|
||||
if (!isSqlDataSourceNode(node.type)) return
|
||||
const previewKey = getSqlDataSourcePreviewKey(node)
|
||||
if (!isFormNode(node.type)) return
|
||||
const previewKey = getFormPreviewKey(node)
|
||||
if (!previewKey) return
|
||||
const source = document.dataSources.find(
|
||||
(item) => item.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'),
|
||||
(item) => item.id === getFormEndpointId(node, 'selectEndpoint'),
|
||||
)
|
||||
if (!source) return
|
||||
const parameterName = getSqlDataSourceKeyParam(node)
|
||||
const bound = bindSqlDataSourceUrl(source.url, { [parameterName]: previewKey }, parameterName)
|
||||
const parameterName = getFormKeyParam(node)
|
||||
const bound = bindFormUrl(source.url, { [parameterName]: previewKey }, parameterName)
|
||||
previewUrls.set(
|
||||
source.id,
|
||||
bound.keyBound
|
||||
? bound.url
|
||||
: appendSqlDataSourceQueryParam(bound.url, parameterName, previewKey),
|
||||
: appendFormQueryParam(bound.url, parameterName, previewKey),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -2812,7 +3015,7 @@ const VisualComponentDesigner = () => {
|
|||
if (dataSourceSamples[source.id] !== undefined || dataTestResults[source.id]) return
|
||||
const requestUrl = previewUrls.get(source.id) ?? source.url
|
||||
// An unresolved `{id}` would just 404; it waits for a preview key instead.
|
||||
if (hasSqlDataSourceUrlParams(requestUrl) || !isRunnableDataSourceUrl(requestUrl)) return
|
||||
if (hasFormUrlParams(requestUrl) || !isRunnableDataSourceUrl(requestUrl)) return
|
||||
void testDataSource(source, false, requestUrl)
|
||||
})
|
||||
}, [
|
||||
|
|
@ -2902,7 +3105,7 @@ const VisualComponentDesigner = () => {
|
|||
setSelectDataModes((current) => ({ ...current, [selectedId]: mode }))
|
||||
if (isTabularDataComponent(selectedNode?.type)) {
|
||||
// Only a source the picker lists can be bound; a POST/PUT entry or the
|
||||
// SqlDataSource record would silently produce an unusable collection.
|
||||
// Form record would silently produce an unusable collection.
|
||||
const source = activeDataSource || inspectorDataSources[0]
|
||||
commitDocument((current) => ({
|
||||
...current,
|
||||
|
|
@ -3586,7 +3789,7 @@ const VisualComponentDesigner = () => {
|
|||
{/*
|
||||
Label ve Value ekranı sürer; buradaki sütunlar görünmez ama
|
||||
seçenek nesnesiyle birlikte taşınır, böylece bir script onları
|
||||
başka bir komponente ya da SqlDataSource kaydına yazabilir.
|
||||
başka bir komponente ya da Form kaydına yazabilir.
|
||||
*/}
|
||||
{selectCollectionSample && selectColumnFields.length > 0 && (
|
||||
<div>
|
||||
|
|
@ -3921,7 +4124,7 @@ const VisualComponentDesigner = () => {
|
|||
* GetList endpoint parses, so any column can be filtered, not just the key.
|
||||
*
|
||||
* A value comes from a static literal, from the page URL (query string or route
|
||||
* segment) or from the record of another SqlDataSource, which is how a detail
|
||||
* segment) or from the record of another Form, which is how a detail
|
||||
* list follows the master container without a line of script.
|
||||
*/
|
||||
/**
|
||||
|
|
@ -4053,7 +4256,7 @@ const VisualComponentDesigner = () => {
|
|||
})
|
||||
const [masterRef = '', ...columnParts] = String(filter.value || '').split('.')
|
||||
const masterColumn = columnParts.join('.')
|
||||
const masterRecord = sqlDataSourceRecords[
|
||||
const masterRecord = formRecords[
|
||||
sqlContainerRefs.find((item) => item.ref === masterRef)?.id || ''
|
||||
]
|
||||
const masterColumns =
|
||||
|
|
@ -4251,12 +4454,22 @@ const VisualComponentDesigner = () => {
|
|||
}
|
||||
|
||||
/**
|
||||
* SqlDataSource command panel: the ASP.NET style Select/Insert/Update/Delete
|
||||
* Form command panel: the ASP.NET style Select/Insert/Update/Delete
|
||||
* slots. Each slot only accepts a data source declared with the matching HTTP
|
||||
* method, which is what enables the Save/Delete buttons at runtime.
|
||||
*/
|
||||
const renderSqlDataSourceConfiguration = () => {
|
||||
if (!selectedNode || !selectedIsSqlDataSource) return null
|
||||
/** Key kaynağı seçenekleri; etiketleri dil anahtarı olarak saklanır. */
|
||||
const keySourceOptions = useMemo(
|
||||
() =>
|
||||
FORM_KEY_SOURCES.map((item) => ({
|
||||
value: item.value,
|
||||
label: translate('::' + item.label),
|
||||
})),
|
||||
[translate],
|
||||
)
|
||||
|
||||
const renderFormConfiguration = () => {
|
||||
if (!selectedNode || !selectedIsForm) return null
|
||||
const selectSourceId = String(selectedNode.props.selectEndpoint || '')
|
||||
const selectSample = selectSourceId ? dataSourceSamples[selectSourceId] : undefined
|
||||
const collectionPaths = selectSample
|
||||
|
|
@ -4267,12 +4480,12 @@ const VisualComponentDesigner = () => {
|
|||
.map((field) => field.path),
|
||||
]
|
||||
: ['']
|
||||
const record = sqlDataSourceRecords[selectedNode.id]
|
||||
const record = formRecords[selectedNode.id]
|
||||
const recordFields = record === undefined ? [] : discoverDataFields(record)
|
||||
const selectSource = document.dataSources.find((source) => source.id === selectSourceId)
|
||||
// A GetById URL cannot be sampled at design time without a stand-in key.
|
||||
const selectNeedsPreviewKey = Boolean(
|
||||
selectSource && hasSqlDataSourceUrlParams(selectSource.url),
|
||||
selectSource && hasFormUrlParams(selectSource.url),
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -4280,14 +4493,17 @@ const VisualComponentDesigner = () => {
|
|||
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-[10px] leading-4 text-sky-900 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
|
||||
{translate('::App.DeveloperKitComponentDesigner.CommandSlotsHint')}
|
||||
</div>
|
||||
{SQL_DATA_SOURCE_SLOTS.map((slot) => {
|
||||
{FORM_SLOTS.map((slot) => {
|
||||
const options =
|
||||
slot.method === 'GET'
|
||||
? selectSlotDataSources
|
||||
: document.dataSources.filter((source) => source.method === slot.method)
|
||||
const currentValue = String(selectedNode.props[slot.property] || '')
|
||||
return (
|
||||
<label
|
||||
// Deliberately not a <label>: it holds two selects, and a label
|
||||
// forwards every click to the first control in it — which would
|
||||
// close the permission menu the instant it opens.
|
||||
<div
|
||||
key={slot.property}
|
||||
className="block rounded-lg border border-slate-200 p-3 dark:border-slate-800"
|
||||
>
|
||||
|
|
@ -4332,6 +4548,15 @@ const VisualComponentDesigner = () => {
|
|||
}
|
||||
}}
|
||||
/>
|
||||
{renderPermissionPicker(
|
||||
`${selectedNode.id}:${slot.permissionProperty}`,
|
||||
getFormPermission(selectedNode, slot),
|
||||
(permission) => updateSelectedProp(slot.permissionProperty, permission),
|
||||
{
|
||||
auto: '::App.DeveloperKitComponentDesigner.PermissionHintAuto',
|
||||
custom: PERMISSION_HINT_KEYS[slot.permissionProperty],
|
||||
},
|
||||
)}
|
||||
{!options.length && (
|
||||
<p className="mt-1.5 text-[10px] leading-4 text-amber-600">
|
||||
{translate('::App.DeveloperKitComponentDesigner.NoEndpointForMethod', {
|
||||
|
|
@ -4339,7 +4564,7 @@ const VisualComponentDesigner = () => {
|
|||
})}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
|
@ -4348,7 +4573,7 @@ const VisualComponentDesigner = () => {
|
|||
Key field
|
||||
</span>
|
||||
<Input
|
||||
unstyle
|
||||
size="xs"
|
||||
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"
|
||||
placeholder="id"
|
||||
value={String(selectedNode.props.keyFieldName ?? '')}
|
||||
|
|
@ -4393,9 +4618,9 @@ const VisualComponentDesigner = () => {
|
|||
className="mb-2 w-full"
|
||||
maxMenuHeight={200}
|
||||
menuPortalTarget={window.document.body}
|
||||
options={SQL_DATA_SOURCE_KEY_SOURCES}
|
||||
value={SQL_DATA_SOURCE_KEY_SOURCES.filter(
|
||||
(item) => item.value === getSqlDataSourceKeySource(selectedNode),
|
||||
options={keySourceOptions}
|
||||
value={keySourceOptions.filter(
|
||||
(item) => item.value === getFormKeySource(selectedNode),
|
||||
)}
|
||||
onChange={(option) => option && updateSelectedProp('keySource', option.value)}
|
||||
/>
|
||||
|
|
@ -4403,7 +4628,7 @@ const VisualComponentDesigner = () => {
|
|||
unstyle
|
||||
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"
|
||||
placeholder={translate('::App.DeveloperKitComponentDesigner.KeyParamPlaceholder', {
|
||||
field: getSqlDataSourceKeyField(selectedNode),
|
||||
field: getFormKeyField(selectedNode),
|
||||
})}
|
||||
value={String(selectedNode.props.keyParamName ?? '')}
|
||||
onChange={(event) => updateSelectedProp('keyParamName', event.target.value)}
|
||||
|
|
@ -4482,14 +4707,14 @@ const VisualComponentDesigner = () => {
|
|||
}
|
||||
|
||||
/**
|
||||
* Banner shown for anything inside a SqlDataSource: it states which record the
|
||||
* Banner shown for anything inside a Form: it states which record the
|
||||
* component is bound to and, when the container has no Select endpoint yet,
|
||||
* why no column can be picked.
|
||||
*/
|
||||
const renderSqlScopeNotice = () => {
|
||||
if (!sqlScopeNode) return null
|
||||
const hasSelect = Boolean(getSqlDataSourceEndpointId(sqlScopeNode, 'selectEndpoint'))
|
||||
const record = sqlDataSourceRecords[sqlScopeNode.id]
|
||||
const hasSelect = Boolean(getFormEndpointId(sqlScopeNode, 'selectEndpoint'))
|
||||
const record = formRecords[sqlScopeNode.id]
|
||||
const hasColumns = record !== undefined && discoverDataFields(record).length > 0
|
||||
|
||||
if (!hasSelect) {
|
||||
|
|
@ -4501,7 +4726,7 @@ const VisualComponentDesigner = () => {
|
|||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-[10px] leading-4 text-sky-900 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
|
||||
{translate('::App.DeveloperKitComponentDesigner.BoundToSqlDataSource')}
|
||||
{translate('::App.DeveloperKitComponentDesigner.BoundToForm')}
|
||||
{!hasColumns && ` ${translate('::App.DeveloperKitComponentDesigner.RunSelectOnce')}`}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -4514,7 +4739,7 @@ const VisualComponentDesigner = () => {
|
|||
* typeable, so a field is never blocked by a stale or empty sample.
|
||||
*/
|
||||
const sqlRecordColumns = useMemo(() => {
|
||||
const record = sqlScopeNode ? sqlDataSourceRecords[sqlScopeNode.id] : undefined
|
||||
const record = sqlScopeNode ? formRecords[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]) =>
|
||||
|
|
@ -4526,10 +4751,10 @@ const VisualComponentDesigner = () => {
|
|||
return [...columns.entries()]
|
||||
.map(([path, type]) => ({ path, type }))
|
||||
.sort((left, right) => left.path.localeCompare(right.path))
|
||||
}, [sqlDataSourceRecords, sqlScopeNode])
|
||||
}, [formRecords, sqlScopeNode])
|
||||
|
||||
/**
|
||||
* The prop a SqlDataSource reads from and writes back to. An already bound node
|
||||
* The prop a Form 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.
|
||||
*/
|
||||
|
|
@ -4541,7 +4766,7 @@ const VisualComponentDesigner = () => {
|
|||
|
||||
/**
|
||||
* 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
|
||||
* a Form. 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.
|
||||
*/
|
||||
|
|
@ -4676,7 +4901,7 @@ const VisualComponentDesigner = () => {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
if (selectedIsSqlDataSource) return renderSqlDataSourceConfiguration()
|
||||
if (selectedIsForm) return renderFormConfiguration()
|
||||
const scopeNotice = renderSqlScopeNotice()
|
||||
const selectModeSelector = renderSelectDataModeSelector()
|
||||
const selectConfiguration = renderSelectDataConfiguration()
|
||||
|
|
@ -4711,7 +4936,7 @@ const VisualComponentDesigner = () => {
|
|||
<div className="space-y-3">
|
||||
{scopeNotice}
|
||||
{selectModeSelector}
|
||||
{/* Inside a SqlDataSource a record field has exactly one legal source, so
|
||||
{/* Inside a Form a record field has exactly one legal source, so
|
||||
the picker is replaced by a read-only statement of that source. */}
|
||||
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
|
||||
<label className="block">
|
||||
|
|
@ -4781,6 +5006,21 @@ const VisualComponentDesigner = () => {
|
|||
/>
|
||||
)}
|
||||
</label>
|
||||
{/* Read permission of the component itself: without it the component is
|
||||
not rendered, which is what hides a Grid whose rows the user may
|
||||
not see. */}
|
||||
{selectedNode &&
|
||||
renderPermissionPicker(
|
||||
`${selectedNode.id}:${DESIGNER_PERMISSION_PROP}`,
|
||||
getDesignerNodePermission(selectedNode),
|
||||
(permission) => updateSelectedProp(DESIGNER_PERMISSION_PROP, permission),
|
||||
{
|
||||
// A plain component has no Wizard managed command of its own, so
|
||||
// `Otomatik` here means the page's menu permission decides.
|
||||
auto: '::App.DeveloperKitComponentDesigner.PermissionHintComponentAuto',
|
||||
custom: '::App.DeveloperKitComponentDesigner.PermissionHintComponent',
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!sqlScopeLocked && renderDataSourceFilters(activeDataSource)}
|
||||
|
|
@ -4927,7 +5167,7 @@ const VisualComponentDesigner = () => {
|
|||
const currentBindingSource = document.dataSources.find(
|
||||
(source) => source.id === currentBinding?.sourceId,
|
||||
)
|
||||
// A binding saved before the SqlDataSource lock existed can still
|
||||
// A binding saved before the Form lock existed can still
|
||||
// point at an unrelated endpoint; the dropdown cannot show it, so
|
||||
// it is called out explicitly instead of failing silently.
|
||||
const foreignBinding =
|
||||
|
|
@ -5063,6 +5303,7 @@ const VisualComponentDesigner = () => {
|
|||
const canvas = (
|
||||
<VisualCanvas
|
||||
interactive
|
||||
canvasWidth={document.canvas.width}
|
||||
nodes={document.nodes}
|
||||
selectedId={selectedId}
|
||||
dataValues={previewDataValues}
|
||||
|
|
@ -5113,7 +5354,7 @@ const VisualComponentDesigner = () => {
|
|||
)
|
||||
}
|
||||
/**
|
||||
* Builder context of the event being edited: the SqlDataSource that owns the
|
||||
* Builder context of the event being edited: the Form that owns the
|
||||
* record (the node itself, or the nearest one above it) plus the columns and
|
||||
* ref names its rules can address.
|
||||
*/
|
||||
|
|
@ -5121,14 +5362,14 @@ const VisualComponentDesigner = () => {
|
|||
? findDesignerNode(document.nodes, scriptBuilderTarget.nodeId)
|
||||
: null
|
||||
const scriptBuilderSqlNode = scriptBuilderNode
|
||||
? isSqlDataSourceNode(scriptBuilderNode.type)
|
||||
? isFormNode(scriptBuilderNode.type)
|
||||
? scriptBuilderNode
|
||||
: [...(findDesignerAncestors(document.nodes, scriptBuilderNode.id) || [])]
|
||||
.reverse()
|
||||
.find((node) => isSqlDataSourceNode(node.type))
|
||||
.find((node) => isFormNode(node.type))
|
||||
: undefined
|
||||
const scriptBuilderRecordFields = scriptBuilderSqlNode
|
||||
? Object.keys((sqlDataSourceRecords[scriptBuilderSqlNode.id] as Record<string, unknown>) || {})
|
||||
? Object.keys((formRecords[scriptBuilderSqlNode.id] as Record<string, unknown>) || {})
|
||||
: []
|
||||
|
||||
const endpointResultText = endpointResultModal
|
||||
|
|
@ -5210,10 +5451,11 @@ const VisualComponentDesigner = () => {
|
|||
{translate('::App.Platform.Reset')}
|
||||
</Button>
|
||||
<Button
|
||||
color="green-600"
|
||||
icon={<FaEye />}
|
||||
size="sm"
|
||||
title={translate('::App.DeveloperKitComponentDesigner.OpenInNewTab')}
|
||||
variant="plain"
|
||||
variant="solid"
|
||||
onClick={() => {
|
||||
const routePath = component.routePath.startsWith('/')
|
||||
? component.routePath
|
||||
|
|
@ -5221,7 +5463,7 @@ const VisualComponentDesigner = () => {
|
|||
window.open(routePath, '_blank')
|
||||
}}
|
||||
>
|
||||
{translate('::App.Platform.OpenUrl')}
|
||||
{translate('::App.Listform.ListformField.Preview')}
|
||||
</Button>
|
||||
<Button
|
||||
loading={isSaving}
|
||||
|
|
@ -5600,18 +5842,18 @@ const VisualComponentDesigner = () => {
|
|||
<span
|
||||
className="min-w-0 truncate"
|
||||
title={
|
||||
getSqlDataSourceEvent(eventName)?.description
|
||||
getFormEvent(eventName)?.description
|
||||
? translate(
|
||||
'::' + getSqlDataSourceEvent(eventName)!.description,
|
||||
'::' + getFormEvent(eventName)!.description,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<code>{eventName}(event)</code>
|
||||
{getSqlDataSourceEvent(eventName) &&
|
||||
isSqlDataSourceNode(activeJavaScriptTarget.node.type) && (
|
||||
{getFormEvent(eventName) &&
|
||||
isFormNode(activeJavaScriptTarget.node.type) && (
|
||||
<span className="ml-2 font-normal text-slate-400">
|
||||
{getSqlDataSourceEvent(eventName)?.label}
|
||||
{getFormEvent(eventName)?.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
|
@ -5815,11 +6057,12 @@ const VisualComponentDesigner = () => {
|
|||
['items', 'data', 'columns', 'dataColumns'].includes(property.name)
|
||||
) &&
|
||||
// Command slots and the key/collection settings are
|
||||
// owned by the SqlDataSource panel in the Data tab.
|
||||
// owned by the Form panel in the Data tab.
|
||||
!(
|
||||
selectedIsSqlDataSource &&
|
||||
selectedIsForm &&
|
||||
[
|
||||
...SQL_DATA_SOURCE_SLOTS.map((slot) => slot.property),
|
||||
...FORM_SLOTS.map((slot) => slot.property),
|
||||
...FORM_SLOTS.map((slot) => slot.permissionProperty),
|
||||
'keyFieldName',
|
||||
'collectionPath',
|
||||
'keySource',
|
||||
|
|
@ -6279,7 +6522,7 @@ const VisualComponentDesigner = () => {
|
|||
<DesignerScriptBuilderDialog
|
||||
isOpen
|
||||
componentLabel={scriptBuilderNode.ref || scriptBuilderNode.type}
|
||||
eventInfo={getSqlDataSourceEvent(scriptBuilderTarget.eventName)}
|
||||
eventInfo={getFormEvent(scriptBuilderTarget.eventName)}
|
||||
eventName={scriptBuilderTarget.eventName}
|
||||
recordFields={scriptBuilderRecordFields}
|
||||
refNames={[...documentRefs.keys()]}
|
||||
|
|
|
|||
Loading…
Reference in a new issue