Custom Component ve Wizard için CRUD yetkiler tanımlandı

This commit is contained in:
Sedat ÖZTÜRK 2026-08-21 15:21:58 +03:00
parent 90a66562e7
commit baef565dd4
17 changed files with 1155 additions and 523 deletions

View file

@ -215,7 +215,7 @@ public class CustomEndpointAppService : PlatformAppService
/// <summary> /// <summary>
/// Body parameters of a POST/PUT call. Both form encoded and JSON payloads are /// 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. /// while older callers keep posting forms.
/// </summary> /// </summary>
private async Task<Dictionary<string, object>> ReadBodyValuesAsync() private async Task<Dictionary<string, object>> ReadBodyValuesAsync()

View file

@ -159,34 +159,42 @@ public class ListFormWizardAppService(
inserted.PermissionNames.Add(permRead.Name); inserted.PermissionNames.Add(permRead.Name);
} }
// Read altındaki alt yetkiler: (suffix, dil anahtarı) // Read altındaki alt yetkiler: (suffix, dil anahtarı). CRUD üçlüsü her iki
var childPermissions = new[] // 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), ("Create", WizardConsts.LangKeyCreate),
("Update", WizardConsts.LangKeyUpdate), ("Update", WizardConsts.LangKeyUpdate),
("Delete", WizardConsts.LangKeyDelete), ("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), ("Export", WizardConsts.LangKeyExport),
("Import", WizardConsts.LangKeyImport), ("Import", WizardConsts.LangKeyImport),
("Note", WizardConsts.LangKeyNote), ("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 }; 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)) if (existingPerms.Any(a => a.Name == permissionName))
continue; continue;
await repoPerm.InsertAsync( await repoPerm.InsertAsync(
new PermissionDefinitionRecord(GuidGenerator.Create(), groupName, permissionName, permRead.Name, langKey, true, MultiTenancySides.Both), new PermissionDefinitionRecord(GuidGenerator.Create(), groupName, permissionName, permRead.Name, langKey, true, MultiTenancySides.Both),
autoSave: false); autoSave: false);
inserted.PermissionNames.Add(permissionName); inserted.PermissionNames.Add(permissionName);
}
} }
// Permission Grants - Bulk Insert (only missing ones) // Permission Grants - Bulk Insert (only missing ones)

View file

@ -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.", "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." "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", "resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.NoFilter", "key": "App.DeveloperKitComponentDesigner.NoFilter",
@ -19772,7 +19784,7 @@
}, },
{ {
"resourceName": "Platform", "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.", "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." "tr": "Bu komponent Form kaydına bağlıdır; alanlar Select (GET) cevabındaki sütunlardan gelir."
}, },
@ -20576,7 +20588,7 @@
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.SqlDataSourceRecord", "key": "App.DeveloperKitComponentDesigner.FormRecord",
"en": "Form record · {field}", "en": "Form record · {field}",
"tr": "Form kaydı · {field}" "tr": "Form kaydı · {field}"
}, },
@ -24966,6 +24978,12 @@
"en": "Sözsoft dynamic Gantt view", "en": "Sözsoft dynamic Gantt view",
"tr": "Sözsoft dinamik Gantt görünümü" "tr": "Sözsoft dinamik Gantt görünümü"
}, },
{
"resourceName": "Platform",
"key": "App.CatalogGroup.Form",
"en": "Form",
"tr": "Form"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogGroup.ApiCalls", "key": "App.CatalogGroup.ApiCalls",
@ -24984,12 +25002,6 @@
"en": "Event and page", "en": "Event and page",
"tr": "Event ve sayfa" "tr": "Event ve sayfa"
}, },
{
"resourceName": "Platform",
"key": "App.CatalogGroup.SqlDataSource",
"en": "Form",
"tr": "Form"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogHoverable.HoverableDescription", "key": "App.CatalogHoverable.HoverableDescription",
@ -25100,73 +25112,73 @@
}, },
{ {
"resourceName": "Platform", "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", "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" "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", "resourceName": "Platform",
"key": "App.CatalogSqlDataSource.SqlDataSourceDescription2", "key": "App.CatalogForm.FormDescription2",
"en": "The field name is the column name in the Select result.", "en": "The field name is the column name in the Select result.",
"tr": "Alan adı Select sonucundaki sütun adıdır." "tr": "Alan adı Select sonucundaki sütun adıdır."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogSqlDataSource.SqlDataSourceDescription3", "key": "App.CatalogForm.FormDescription3",
"en": "When Select returns more than one row.", "en": "When Select returns more than one row.",
"tr": "Select birden fazla satır döndürdüğünde." "tr": "Select birden fazla satır döndürdüğünde."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogSqlDataSource.SqlDataSourceDescription4", "key": "App.CatalogForm.FormDescription4",
"en": "Stops the operation if a required field is empty.", "en": "Stops the operation if a required field is empty.",
"tr": "Zorunlu alan boşsa işlemi durdurur." "tr": "Zorunlu alan boşsa işlemi durdurur."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogSqlDataSource.SqlDataSourceDescription5", "key": "App.CatalogForm.FormDescription5",
"en": "New record / editing, busy and error state.", "en": "New record / editing, busy and error state.",
"tr": "Yeni kayıt / düzenleme, meşgul ve hata durumu." "tr": "Yeni kayıt / düzenleme, meşgul ve hata durumu."
}, },
{ {
"resourceName": "Platform", "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.", "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." "tr": "Kabı saran kartın başlık metni; boş bırakılırsa başlık gizlenir. :: ile başlayan değer dil anahtarı olarak çözümlenir."
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogSqlDataSource.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.", "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." "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", "resourceName": "Platform",
"key": "App.CatalogSqlDataSource.SqlDataSourceTitle", "key": "App.CatalogForm.FormTitle",
"en": "Save / delete / reload", "en": "Save / delete / reload",
"tr": "Kaydet / sil / yenile" "tr": "Kaydet / sil / yenile"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogSqlDataSource.SqlDataSourceTitle2", "key": "App.CatalogForm.FormTitle2",
"en": "Reading and writing the active record", "en": "Reading and writing the active record",
"tr": "Aktif kaydı okuma ve yazma" "tr": "Aktif kaydı okuma ve yazma"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogSqlDataSource.SqlDataSourceTitle3", "key": "App.CatalogForm.FormTitle3",
"en": "Navigating between records", "en": "Navigating between records",
"tr": "Kayıtlar arasında gezinme" "tr": "Kayıtlar arasında gezinme"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogSqlDataSource.SqlDataSourceTitle4", "key": "App.CatalogForm.FormTitle4",
"en": "Validating before saving", "en": "Validating before saving",
"tr": "Kaydetmeden önce doğrulama" "tr": "Kaydetmeden önce doğrulama"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.CatalogSqlDataSource.SqlDataSourceTitle5", "key": "App.CatalogForm.FormTitle5",
"en": "Status information", "en": "Status information",
"tr": "Durum bilgisi" "tr": "Durum bilgisi"
}, },
@ -25596,12 +25608,6 @@
"en": "Error occurred", "en": "Error occurred",
"tr": "Hata oluştu" "tr": "Hata oluştu"
}, },
{
"resourceName": "Platform",
"key": "App.EventOnError.OnErrorLabel2",
"en": "Last URL segment (/…/5)",
"tr": "URL son segmenti (/…/5)"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.EventOnFieldChange.OnFieldChangeDescription", "key": "App.EventOnFieldChange.OnFieldChangeDescription",
@ -26753,6 +26759,126 @@
"key": "App.ListForm.WizardImportRollbackFailed", "key": "App.ListForm.WizardImportRollbackFailed",
"en": "Rollback could not be completed", "en": "Rollback could not be completed",
"tr": "Geri alma tamamlanamadı" "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."
} }
] ]
} }

View file

@ -820,6 +820,24 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
Visible = true, Visible = true,
IsActive = 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, ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(PlatformConsts.IdentityPermissions.Roles.Create, PlatformConsts.IdentityPermissions.Roles.Default, PlatformConsts.IdentityPermissions.Roles.Update, true, true, false), PermissionJson = DefaultFieldPermissionJson(PlatformConsts.IdentityPermissions.Roles.Create, PlatformConsts.IdentityPermissions.Roles.Default, PlatformConsts.IdentityPermissions.Roles.Update, true, true, false),
PivotSettingsJson = DefaultPivotSettingsJson PivotSettingsJson = DefaultPivotSettingsJson

View file

@ -201,21 +201,23 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency
permRead = await _repoPerm.InsertAsync(new PermissionDefinitionRecord( permRead = await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
Guid.NewGuid(), groupName, code, null, nameLangKey, true, MultiTenancySides.Both), autoSave: true); 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 (!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)) if (!existingPerms.Any(a => a.Name == permExportName))
await _repoPerm.InsertAsync(new PermissionDefinitionRecord( await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
Guid.NewGuid(), groupName, permExportName, permRead.Name, WizardConsts.LangKeyExport, true, MultiTenancySides.Both), autoSave: true); Guid.NewGuid(), groupName, permExportName, permRead.Name, WizardConsts.LangKeyExport, true, MultiTenancySides.Both), autoSave: true);

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
{ {
"Wizard": { "Wizard": {
"ComponentKind": 1, "ComponentKind": 1,
"WizardName": "RoleList", "WizardName": "RoleList",
@ -136,7 +136,10 @@
], ],
"PermissionGroupNames": [], "PermissionGroupNames": [],
"PermissionNames": [ "PermissionNames": [
"App.Wizard.RoleList" "App.Wizard.RoleList",
"App.Wizard.RoleList.Create",
"App.Wizard.RoleList.Update",
"App.Wizard.RoleList.Delete"
], ],
"MenuCodes": [ "MenuCodes": [
"App.Wizard.RoleList" "App.Wizard.RoleList"

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import axios from 'axios' import axios from 'axios'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import apiService from '@/services/api.service' import apiService from '@/services/api.service'
@ -9,6 +9,8 @@ import {
toErrorMessage, toErrorMessage,
type BabelLike, type BabelLike,
} from '@/contexts/componentRuntime' } from '@/contexts/componentRuntime'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { usePermission } from '@/utils/hooks/usePermission'
import { useComponents } from '../../contexts/ComponentContext' import { useComponents } from '../../contexts/ComponentContext'
import ErrorBoundary from './ErrorBoundary' import ErrorBoundary from './ErrorBoundary'
import PlatformIcon from './PlatformIcon' import PlatformIcon from './PlatformIcon'
@ -76,6 +78,23 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = ({
const [Component, setComponent] = useState<React.ComponentType<any> | null>(null) const [Component, setComponent] = useState<React.ComponentType<any> | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { getComponentByName, components } = useComponents() 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -126,7 +145,13 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = ({
const component = compileComponent( const component = compileComponent(
entry.code, entry.code,
{ React, ...staticComponents, ...deps }, {
React,
...staticComponents,
checkPermission: stableCheckPermission,
translate: stableTranslate,
...deps,
},
Babel, Babel,
) )
if (!component) throw new Error(`Component ${name} bir default export döndürmüyor`) if (!component) throw new Error(`Component ${name} bir default export döndürmüyor`)
@ -149,7 +174,14 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = ({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [componentName, externalDeps, components, getComponentByName]) }, [
componentName,
externalDeps,
components,
getComponentByName,
stableCheckPermission,
stableTranslate,
])
if (error) { if (error) {
return ( return (

View file

@ -2,19 +2,19 @@ import ScriptBuilderDialog from '@/components/scriptBuilder/ScriptBuilderDialog'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { createDesignerScriptDialect } from './designerScriptDialect' import { createDesignerScriptDialect } from './designerScriptDialect'
import type { SqlDataSourceEvent } from './types' import type { FormEvent } from './types'
export type DesignerScriptBuilderDialogProps = { export type DesignerScriptBuilderDialogProps = {
isOpen: boolean isOpen: boolean
value?: string value?: string
/** Düzenlenen event: başlıkta ve event yolu seçicilerinde kullanılır. */ /** Düzenlenen event: başlıkta ve event yolu seçicilerinde kullanılır. */
eventName: string eventName: string
eventInfo?: SqlDataSourceEvent eventInfo?: FormEvent
/** Event'in sahibi komponentin adı, yalnızca başlık için. */ /** Event'in sahibi komponentin adı, yalnızca başlık için. */
componentLabel?: string componentLabel?: string
/** Kayıt tariflerinin yazacağı SqlDataSource ref adı. */ /** Kayıt tariflerinin yazacağı Form ref adı. */
sqlRef: string sqlRef: string
/** SqlDataSource Select sonucundaki sütun adları. */ /** Form Select sonucundaki sütun adları. */
recordFields: string[] recordFields: string[]
/** Sayfadaki tüm ref adları. */ /** Sayfadaki tüm ref adları. */
refNames: string[] refNames: string[]

View file

@ -20,13 +20,13 @@ import {
getDesignerCollectionProperty, getDesignerCollectionProperty,
getDesignerTabSlot, getDesignerTabSlot,
getDesignerValueByPath, getDesignerValueByPath,
getSqlDataSourceEndpointId, getFormEndpointId,
getSqlDataSourceColumnCount, getFormColumnCount,
getDesignerColumnCaptions, getDesignerColumnCaptions,
getUsableDesignerColumnLookups, getUsableDesignerColumnLookups,
buildDesignerLookupIndex, buildDesignerLookupIndex,
resolveDesignerLookupText, resolveDesignerLookupText,
getSqlDataSourceKeyField, getFormKeyField,
getSqlFormValueProperty, getSqlFormValueProperty,
isDesignerDateComponent, isDesignerDateComponent,
isDesignerDateProperty, isDesignerDateProperty,
@ -34,26 +34,26 @@ import {
DESIGNER_ICON_PROPERTIES, DESIGNER_ICON_PROPERTIES,
DESIGNER_SELECT_COMPONENT_PROPERTY, DESIGNER_SELECT_COMPONENT_PROPERTY,
flexRowStyle, flexRowStyle,
isSqlDataSourceNode, isFormNode,
LEGACY_SQL_DATA_SOURCE_TYPE,
normalizeDesignerKeyList, normalizeDesignerKeyList,
readSqlDataSourceField, readFormField,
resolveDesignerDropdownTitle, resolveDesignerDropdownTitle,
resolveDesignerPreviewFilterValue, resolveDesignerPreviewFilterValue,
resolveDesignerTabValue, resolveDesignerTabValue,
resolveSqlDataSourceRows, resolveFormRows,
setDesignerRecordField, setDesignerRecordField,
SQL_DATA_SOURCE_TYPE, FORM_TYPE,
SQL_DEFAULT_VALUE_PROP, SQL_DEFAULT_VALUE_PROP,
toDesignerDate, toDesignerDate,
type DesignerBinding, type DesignerBinding,
type DesignerDataSourceFilter, type DesignerDataSourceFilter,
type DesignerDocument,
type DesignerNode, type DesignerNode,
type SqlDataSourceMode, type FormMode,
} from './types' } 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 * 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. * 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 onNodePropChange?: (id: string, propertyName: string, value: unknown) => void
renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode
dataValues?: Record<string, unknown> 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< class PreviewBoundary extends React.Component<
@ -270,7 +276,7 @@ const buildDesignerRefs = (store: DesignerRefStore | null) => {
store.patch(ref, { props: { readOnly: readOnly !== false } }), store.patch(ref, { props: { readOnly: readOnly !== false } }),
setText: (text: unknown) => store.patch(ref, { props: { children: text } }), setText: (text: unknown) => store.patch(ref, { props: { children: text } }),
reset: () => store.patch(ref, { props: null as unknown as Record<string, unknown> }), reset: () => store.patch(ref, { props: null as unknown as Record<string, unknown> }),
...(isSqlDataSourceNode(node.type) ...(isFormNode(node.type)
? { ? {
getRecord: () => ({}), getRecord: () => ({}),
setRecord: designTimeOnly('setRecord'), setRecord: designTimeOnly('setRecord'),
@ -282,7 +288,7 @@ const buildDesignerRefs = (store: DesignerRefStore | null) => {
getChanges: () => ({}), getChanges: () => ({}),
hasChanges: () => false, hasChanges: () => false,
getKey: () => undefined, getKey: () => undefined,
getKeyField: () => getSqlDataSourceKeyField(node), getKeyField: () => getFormKeyField(node),
getRows: () => [], getRows: () => [],
getRowCount: () => 0, getRowCount: () => 0,
getIndex: () => 0, getIndex: () => 0,
@ -484,7 +490,7 @@ const getSelectMenuProps = (props: Record<string, unknown>) => {
const getTabOptions = (props: Record<string, unknown>) => const getTabOptions = (props: Record<string, unknown>) =>
Array.isArray(props.items) ? (props.items as Array<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) => const getFormScopeProperty = (node: DesignerNode, formScope?: DesignerFormScope) =>
getSqlFormValueProperty(node, formScope?.sourceId) getSqlFormValueProperty(node, formScope?.sourceId)
@ -623,7 +629,7 @@ const getPreviewProps = (
: value && typeof value === 'object' && 'target' in value : value && typeof value === 'object' && 'target' in value
? Boolean((value as { target?: { checked?: unknown } }).target?.checked) ? Boolean((value as { target?: { checked?: unknown } }).target?.checked)
: Boolean(value) : 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 // node's static prop — otherwise typing into a bound Input would be discarded
// on the next render because the binding always wins. // on the next render because the binding always wins.
const formScopeProperty = getFormScopeProperty(node, formScope) 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, node,
dataValues, dataValues,
interactive, interactive,
@ -883,18 +897,22 @@ const SqlDataSourceView = ({
) => React.ReactNode ) => React.ReactNode
}) => { }) => {
const { translate } = useLocalization() const { translate } = useLocalization()
const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint') const selectId = getFormEndpointId(node, 'selectEndpoint')
const keyField = getSqlDataSourceKeyField(node) const keyField = getFormKeyField(node)
const collectionPath = String(node.props.collectionPath ?? '') 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( const rows = React.useMemo(
() => (selectId ? resolveSqlDataSourceRows(dataValues[selectId], collectionPath) : []), () => (selectId ? resolveFormRows(dataValues[selectId], collectionPath) : []),
[collectionPath, dataValues, selectId], [collectionPath, dataValues, selectId],
) )
// Container element, so New can hand the cursor to the first field of the form. // Container element, so New can hand the cursor to the first field of the form.
const hostRef = React.useRef<HTMLDivElement | null>(null) const hostRef = React.useRef<HTMLDivElement | null>(null)
const [rowIndex, setRowIndex] = React.useState(0) const [rowIndex, setRowIndex] = React.useState(0)
const [mode, setMode] = React.useState<SqlDataSourceMode>('edit') const [mode, setMode] = React.useState<FormMode>('edit')
// Holds the whole record while editing, so New mode can show an empty form // Holds the whole record while editing, so New mode can show an empty form
// instead of falling back to the loaded row. // instead of falling back to the loaded row.
const [draft, setDraft] = React.useState<Record<string, unknown> | null>(null) const [draft, setDraft] = React.useState<Record<string, unknown> | null>(null)
@ -927,11 +945,11 @@ const SqlDataSourceView = ({
setDraft(null) setDraft(null)
setMode('edit') setMode('edit')
} }
const keyValue = readSqlDataSourceField(record, keyField) const keyValue = readFormField(record, keyField)
const hasKey = keyValue !== undefined && keyValue !== null && keyValue !== '' const hasKey = keyValue !== undefined && keyValue !== null && keyValue !== ''
const canInsert = Boolean(getSqlDataSourceEndpointId(node, 'insertEndpoint')) const canInsert = Boolean(getFormEndpointId(node, 'insertEndpoint'))
const canUpdate = Boolean(getSqlDataSourceEndpointId(node, 'updateEndpoint')) const canUpdate = Boolean(getFormEndpointId(node, 'updateEndpoint'))
const canDelete = Boolean(getSqlDataSourceEndpointId(node, 'deleteEndpoint')) const canDelete = Boolean(getFormEndpointId(node, 'deleteEndpoint'))
// Save follows the explicit mode, exactly like the generated runtime does. // Save follows the explicit mode, exactly like the generated runtime does.
const canSave = mode === 'new' ? canInsert : canUpdate const canSave = mode === 'new' ? canInsert : canUpdate
const designTimeTitle = translate('::App.VisualDesignerCanvas.DesignTimeNoCall') const designTimeTitle = translate('::App.VisualDesignerCanvas.DesignTimeNoCall')
@ -980,7 +998,7 @@ const SqlDataSourceView = ({
> >
{interactive && ( {interactive && (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-dashed border-sky-300 bg-sky-50 px-2.5 py-1.5 text-[10px] text-sky-800 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-200"> <div className="flex flex-wrap items-center gap-2 rounded-md border border-dashed border-sky-300 bg-sky-50 px-2.5 py-1.5 text-[10px] text-sky-800 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-200">
<span className="font-semibold uppercase tracking-wider">{SQL_DATA_SOURCE_TYPE}</span> <span className="font-semibold uppercase tracking-wider">{FORM_TYPE}</span>
<span> <span>
key: <code>{keyField}</code> key: <code>{keyField}</code>
</span> </span>
@ -991,9 +1009,7 @@ const SqlDataSourceView = ({
</span> </span>
<span className="rounded bg-sky-600 px-1.5 py-0.5 font-semibold text-white"> <span className="rounded bg-sky-600 px-1.5 py-0.5 font-semibold text-white">
{translate( {translate(
mode === 'new' mode === 'new' ? '::App.VisualDesignerCanvas.NewRecord' : '::App.Platform.Editing',
? '::App.VisualDesignerCanvas.NewRecord'
: '::App.Platform.Editing',
)} )}
</span> </span>
{rows.length > 1 && ( {rows.length > 1 && (
@ -1111,7 +1127,14 @@ const SqlDataSourceView = ({
<UiKit.Card <UiKit.Card
bodyClass="p-4" bodyClass="p-4"
header={cardTitle || undefined} 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} {body}
</UiKit.Card> </UiKit.Card>
@ -1185,7 +1208,7 @@ const renderElement = (
) => { ) => {
// Built in NodeView so the container can own the record state and expose it to // Built in NodeView so the container can own the record state and expose it to
// its children through an augmented `dataValues` map. // its children through an augmented `dataValues` map.
if (isSqlDataSourceNode(node.type)) return <>{children}</> if (isFormNode(node.type)) return <>{children}</>
if (node.type === 'Spacer') { if (node.type === 'Spacer') {
return ( return (
<div <div
@ -1471,8 +1494,7 @@ const NodeView = ({
'Timeline', 'Timeline',
// Dropped components land in whichever tab is open. // Dropped components land in whichever tab is open.
'Tabs', 'Tabs',
SQL_DATA_SOURCE_TYPE, FORM_TYPE,
LEGACY_SQL_DATA_SOURCE_TYPE,
].includes(node.type) ].includes(node.type)
/* ---- Sürükleme geri bildirimi ---------------------------------- */ /* ---- Sürükleme geri bildirimi ---------------------------------- */
@ -1630,8 +1652,8 @@ const NodeView = ({
)), )),
) )
const children = renderChildNodes(dataValues, formScope) const children = renderChildNodes(dataValues, formScope)
const sqlDataSourceContent = isSqlDataSourceNode(node.type) ? ( const formContent = isFormNode(node.type) ? (
<SqlDataSourceView <FormView
dataValues={dataValues} dataValues={dataValues}
interactive={interactive} interactive={interactive}
node={node} node={node}
@ -1834,8 +1856,8 @@ const NodeView = ({
</tbody> </tbody>
</UiKit.Table> </UiKit.Table>
) : null ) : null
const renderedChildren = sqlDataSourceContent const renderedChildren = formContent
? sqlDataSourceContent ? formContent
: node.type === 'Tabs' : node.type === 'Tabs'
? tabsContent ? tabsContent
: node.type === 'Table' : node.type === 'Table'
@ -2158,8 +2180,8 @@ const NodeView = ({
acceptsDroppedChildren && acceptsDroppedChildren &&
node.type !== 'Card' && node.type !== 'Card' &&
node.type !== 'Tabs' && node.type !== 'Tabs' &&
// SqlDataSource renders its own placeholder below the toolbar. // Form renders its own placeholder below the toolbar.
!isSqlDataSourceNode(node.type) && ( !isFormNode(node.type) && (
<div <div
className="m-2 rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400" className="m-2 rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400"
{...containerDropZoneProps} {...containerDropZoneProps}
@ -2188,6 +2210,7 @@ const VisualCanvas = ({
onNodePropChange, onNodePropChange,
renderCustomComponent, renderCustomComponent,
dataValues = {}, dataValues = {},
canvasWidth = 'responsive',
}: VisualCanvasProps) => { }: VisualCanvasProps) => {
const { translate } = useLocalization() const { translate } = useLocalization()
const previewDataValues = dataValues const previewDataValues = dataValues
@ -2249,127 +2272,129 @@ const VisualCanvas = ({
) )
return ( return (
<DesignerRefContext.Provider value={refStore}> <CanvasWidthContext.Provider value={canvasWidth}>
<div <DesignerRefContext.Provider value={refStore}>
className={`min-h-full p-8 text-slate-900 transition dark:text-slate-100 ${ <div
drag && interactive ? 'bg-sky-50/40 dark:bg-sky-950/20' : '' 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()} onClick={() => interactive && onSelect?.('')}
onDrop={(event) => { onDragOver={(event) => interactive && event.preventDefault()}
if (!interactive) return onDrop={(event) => {
event.preventDefault() if (!interactive) return
endDesignerDrag() event.preventDefault()
const raw = endDesignerDrag()
event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || const raw =
event.dataTransfer.getData('text/plain') event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
if (!raw) return event.dataTransfer.getData('text/plain')
const payload = JSON.parse(raw) if (!raw) return
if (payload.source === 'library') { const payload = JSON.parse(raw)
onDropComponent?.(payload.name, null) if (payload.source === 'library') {
} else if (payload.source === 'canvas' && payload.nodeId) { onDropComponent?.(payload.name, null)
// Dropping on empty canvas takes the node out of its container. } else if (payload.source === 'canvas' && payload.nodeId) {
onMoveIntoContainer?.(payload.nodeId, null) // 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"> {drag && interactive && (
<span className="rounded-full bg-slate-900/90 px-3 py-1 text-[11px] font-medium text-white shadow-lg"> <div className="pointer-events-none sticky top-0 z-40 mb-3 flex items-center justify-center">
<b>{drag.label || drag.name}</b>{' '} <span className="rounded-full bg-slate-900/90 px-3 py-1 text-[11px] font-medium text-white shadow-lg">
{translate('::App.VisualDesignerCanvas.DraggingHint')} <b>{drag.label || drag.name}</b>{' '}
</span> {translate('::App.VisualDesignerCanvas.DraggingHint')}
</div> </span>
)} </div>
{nodes.length ? ( )}
<div className="space-y-4"> {nodes.length ? (
{nodes.map((node, index) => ( <div className="space-y-4">
<NodeView {nodes.map((node, index) => (
key={node.id} <NodeView
isRoot key={node.id}
node={node} isRoot
index={index} node={node}
siblingCount={nodes.length} index={index}
selectedId={selectedId} siblingCount={nodes.length}
interactive={interactive} selectedId={selectedId}
renderCustomComponent={renderCustomComponent} interactive={interactive}
dataValues={previewDataValues} renderCustomComponent={renderCustomComponent}
onSelect={onSelect} dataValues={previewDataValues}
onDropComponent={onDropComponent} onSelect={onSelect}
onDropComponentBeside={onDropComponentBeside} onDropComponent={onDropComponent}
onMoveIntoContainer={onMoveIntoContainer} onDropComponentBeside={onDropComponentBeside}
onMove={onMove} onMoveIntoContainer={onMoveIntoContainer}
onReorder={onReorder} onMove={onMove}
onDuplicate={onDuplicate} onReorder={onReorder}
onDelete={onDelete} onDuplicate={onDuplicate}
onNodePropChange={onNodePropChange} onDelete={onDelete}
/> onNodePropChange={onNodePropChange}
))} />
{/* Sürükleme sürerken kök seviye açık bir hedef olur; aksi halde bir ))}
{/* 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. */} konteynerin dışına çıkmanın görünür bir yolu yok. */}
{drag && interactive && ( {drag && interactive && (
<div <div
className={`rounded-lg border border-dashed px-3 py-4 text-center text-xs transition ${ className={`rounded-lg border border-dashed px-3 py-4 text-center text-xs transition ${
rootDropZone.over rootDropZone.over
? 'border-emerald-500 bg-emerald-50 font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200' ? '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' : 'border-sky-400 bg-sky-50/60 text-sky-600 dark:bg-sky-950/40 dark:text-sky-300'
}`} }`}
{...rootDropZone.props} {...rootDropZone.props}
> >
{translate('::App.VisualDesignerCanvas.DropAtPageEnd')} {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>
))}
</div> </div>
)} )}
</div> </div>
</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 ${
</DesignerRefContext.Provider> 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>
) )
} }

View file

@ -2,9 +2,9 @@ import { CUSTOM_COMPONENTS, HTML_ELEMENTS } from '@/components/codeLayout/data/c
import generatedComponentProps from './generated/componentProps.json' import generatedComponentProps from './generated/componentProps.json'
import { import {
DESIGNER_DATA_COMPONENT_NAMES, DESIGNER_DATA_COMPONENT_NAMES,
SQL_DATA_SOURCE_EVENTS, FORM_EVENTS,
LEGACY_SQL_DATA_SOURCE_TYPE, FORM_SLOTS,
SQL_DATA_SOURCE_TYPE, FORM_TYPE,
getDesignerCollectionProperty, getDesignerCollectionProperty,
isDesignerOptionComponent, isDesignerOptionComponent,
isDesignerTabularComponent, isDesignerTabularComponent,
@ -162,7 +162,7 @@ const selectedValue = event?.value ?? null
const selectedLabel = event?.label ?? '' const selectedLabel = event?.label ?? ''
console.log({ selectedValue, selectedLabel })`, console.log({ selectedValue, selectedLabel })`,
}, },
[SQL_DATA_SOURCE_TYPE]: { [FORM_TYPE]: {
onLoad: `// Select cevabı forma yerleştikten sonra çalışır. onLoad: `// Select cevabı forma yerleştikten sonra çalışır.
// event: { rows, count, record, index } // event: { rows, count, record, index }
if (!event.count) notify('Kayıt bulunamadı', 'warning')`, if (!event.count) notify('Kayıt bulunamadı', 'warning')`,
@ -217,7 +217,7 @@ export interface DesignerScriptRecipe {
export const DESIGNER_SCRIPT_RECIPE_GROUPS = [ export const DESIGNER_SCRIPT_RECIPE_GROUPS = [
'App.CatalogGroup.ComponentAccess', 'App.CatalogGroup.ComponentAccess',
'App.CatalogGroup.ApiCalls', 'App.CatalogGroup.ApiCalls',
'App.CatalogGroup.SqlDataSource', 'App.CatalogGroup.Form',
'App.CatalogGroup.EventAndPage', 'App.CatalogGroup.EventAndPage',
] as const ] as const
@ -354,33 +354,33 @@ refs.{{sql}}.setFields({ UnitPrice: fiyat, Stock: stok })`,
console.log(secilen, refs.{{ref}}.getLabel())`, console.log(secilen, refs.{{ref}}.getLabel())`,
}, },
{ {
group: 'App.CatalogGroup.SqlDataSource', group: 'App.CatalogGroup.Form',
title: 'App.CatalogSqlDataSource.SqlDataSourceTitle', title: 'App.CatalogForm.FormTitle',
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription', description: 'App.CatalogForm.FormDescription',
code: `await refs.{{sql}}.save() code: `await refs.{{sql}}.save()
// await refs.{{sql}}.remove() // await refs.{{sql}}.remove()
// await refs.{{sql}}.reload() // await refs.{{sql}}.reload()
// refs.{{sql}}.newRecord()`, // refs.{{sql}}.newRecord()`,
}, },
{ {
group: 'App.CatalogGroup.SqlDataSource', group: 'App.CatalogGroup.Form',
title: 'App.CatalogSqlDataSource.SqlDataSourceTitle2', title: 'App.CatalogForm.FormTitle2',
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription2', description: 'App.CatalogForm.FormDescription2',
code: `const record = refs.{{sql}}.getRecord() code: `const record = refs.{{sql}}.getRecord()
console.log(record) console.log(record)
refs.{{sql}}.setField('name', 'Yeni ad')`, refs.{{sql}}.setField('name', 'Yeni ad')`,
}, },
{ {
group: 'App.CatalogGroup.SqlDataSource', group: 'App.CatalogGroup.Form',
title: 'App.CatalogSqlDataSource.SqlDataSourceTitle3', title: 'App.CatalogForm.FormTitle3',
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription3', description: 'App.CatalogForm.FormDescription3',
code: `const rows = refs.{{sql}}.getRows() code: `const rows = refs.{{sql}}.getRows()
refs.{{sql}}.goToRow(rows.length - 1)`, refs.{{sql}}.goToRow(rows.length - 1)`,
}, },
{ {
group: 'App.CatalogGroup.SqlDataSource', group: 'App.CatalogGroup.Form',
title: 'App.CatalogSqlDataSource.SqlDataSourceTitle4', title: 'App.CatalogForm.FormTitle4',
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription4', description: 'App.CatalogForm.FormDescription4',
code: `const record = refs.{{sql}}.getRecord() code: `const record = refs.{{sql}}.getRecord()
if (!record.name) { if (!record.name) {
notify('Ad alanı zorunludur', 'warning') notify('Ad alanı zorunludur', 'warning')
@ -389,9 +389,9 @@ if (!record.name) {
await refs.{{sql}}.save()`, await refs.{{sql}}.save()`,
}, },
{ {
group: 'App.CatalogGroup.SqlDataSource', group: 'App.CatalogGroup.Form',
title: 'App.CatalogSqlDataSource.SqlDataSourceTitle5', title: 'App.CatalogForm.FormTitle5',
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription5', description: 'App.CatalogForm.FormDescription5',
code: `console.log(refs.{{sql}}.getMode(), refs.{{sql}}.isBusy(), refs.{{sql}}.getError())`, 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) => export const getDesignerEventSnippet = (componentName: string, eventName: string) =>
// A page saved before the container was renamed still carries the old type, DESIGNER_EVENT_SNIPPETS[componentName]?.[eventName] ||
// and its event scripts deserve the same starting snippet.
DESIGNER_EVENT_SNIPPETS[
componentName === LEGACY_SQL_DATA_SOURCE_TYPE ? SQL_DATA_SOURCE_TYPE : componentName
]?.[eventName] ||
`// ${componentName}.${eventName}(event)\nconsole.log(event)` `// ${componentName}.${eventName}(event)\nconsole.log(event)`
const COLLECTION_ITEMS_PROPERTY = { const COLLECTION_ITEMS_PROPERTY = {
name: 'items', name: 'items',
@ -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 * 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. * 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. * The endpoints are configured from the inspector's Data tab, not here.
*/ */
export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = { export const FORM_DEFINITION: DesignerComponentDefinition = {
name: SQL_DATA_SOURCE_TYPE, name: FORM_TYPE,
icon: 'Database', icon: 'Database',
category: 'data', category: 'data',
kind: 'layout', kind: 'layout',
toolboxGroup: 'data', toolboxGroup: 'data',
description: 'App.CatalogSqlDataSource.SqlDataSourceDescription', description: 'App.CatalogForm.FormDescription',
acceptsChildren: true, acceptsChildren: true,
properties: [ properties: [
// Filled in, the container renders inside a Card whose header carries this // 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', type: 'string',
value: '', value: '',
category: 'properties', category: 'properties',
description: 'App.CatalogSqlDataSource.SqlDataSourceTitleDescription', description: 'App.CatalogForm.FormTitleDescription',
}, },
{ {
name: 'selectEndpoint', name: 'selectEndpoint',
@ -953,6 +949,16 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
category: 'properties', category: 'properties',
description: 'App.CatalogString.StringDescription4', 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', name: 'keyFieldName',
type: 'string', type: 'string',
@ -1011,7 +1017,7 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
type: 'number', type: 'number',
value: 1, value: 1,
category: 'styling', category: 'styling',
description: 'App.CatalogSqlDataSource.SqlDataSourceColumnCountDescription', description: 'App.CatalogForm.FormColumnCountDescription',
}, },
{ {
name: 'gap', name: 'gap',
@ -1023,7 +1029,7 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
// Record lifecycle. `category: 'events'` keeps them out of the Properties // Record lifecycle. `category: 'events'` keeps them out of the Properties
// panel and moves them into the JavaScript tab, where they are edited as // panel and moves them into the JavaScript tab, where they are edited as
// scripts rather than as values. // scripts rather than as values.
...SQL_DATA_SOURCE_EVENTS.map((event) => ({ ...FORM_EVENTS.map((event) => ({
name: event.name, name: event.name,
type: 'function' as const, type: 'function' as const,
value: '', value: '',
@ -1138,7 +1144,7 @@ export const getDesignerCatalog = (customNames: string[] = []): DesignerComponen
return [ return [
...DESIGNER_EXTRAS, ...DESIGNER_EXTRAS,
SQL_DATA_SOURCE_DEFINITION, FORM_DEFINITION,
...PLATFORM_COMPONENTS, ...PLATFORM_COMPONENTS,
...html, ...html,
...ui, ...ui,

View file

@ -5,16 +5,19 @@ import {
getDesignerTabSlotValue, getDesignerTabSlotValue,
getDesignerDataSourceFilters, getDesignerDataSourceFilters,
getDesignerNodeFilters, getDesignerNodeFilters,
getSqlDataSourceColumnCount, getDesignerNodePermission,
resolveFormPermission,
DESIGNER_PERMISSION_PROP,
getFormColumnCount,
getDesignerColumnCaptions, getDesignerColumnCaptions,
getUsableDesignerColumnLookups, getUsableDesignerColumnLookups,
getSqlDataSourceEndpointId, getFormEndpointId,
getSqlDataSourceKeyField, getFormKeyField,
getSqlDataSourceKeyParam, getFormKeyParam,
getSqlDataSourceKeySource, getFormKeySource,
getSqlDataSourceScriptedEvents, getFormScriptedEvents,
getSqlFormValueProperty, getSqlFormValueProperty,
hasSqlDataSourceUrlParams, hasFormUrlParams,
isDesignerDateComponent, isDesignerDateComponent,
isDesignerDateOnlyComponent, isDesignerDateOnlyComponent,
isDesignerDateProperty, isDesignerDateProperty,
@ -22,13 +25,13 @@ import {
DESIGNER_ICON_PROPERTIES, DESIGNER_ICON_PROPERTIES,
DESIGNER_SELECT_COMPONENT_PROPERTY, DESIGNER_SELECT_COMPONENT_PROPERTY,
flexRowStyle, flexRowStyle,
isSqlDataSourceNode, isFormNode,
isValuelessDesignerFilter, isValuelessDesignerFilter,
DESIGNER_FILTER_DX_OPERATORS, DESIGNER_FILTER_DX_OPERATORS,
toDesignerFilterParamName, toDesignerFilterParamName,
normalizeDesignerKeyList, normalizeDesignerKeyList,
DESIGNER_DROPDOWN_PLACEHOLDER, DESIGNER_DROPDOWN_PLACEHOLDER,
SQL_DATA_SOURCE_SLOTS, FORM_SLOTS,
SQL_DEFAULT_VALUE_PROP, SQL_DEFAULT_VALUE_PROP,
type DesignerDataSource, type DesignerDataSource,
type DesignerDataSourceFilter, type DesignerDataSourceFilter,
@ -37,7 +40,7 @@ import {
} from './types' } 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 * `data_<nodeId>`, which is exactly what `bindingExpression` emits for a binding
* whose `sourceId` is the container's node id so children bind to columns * whose `sourceId` is the container's node id so children bind to columns
* through the regular binding machinery and write back through `setterName`. * through the regular binding machinery and write back through `setterName`.
@ -60,6 +63,40 @@ const safeIdentifier = (value: string) => {
*/ */
let refRuntimeEnabled = false 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. */ /** Prop that turns a component off; react-select spells it differently. */
const getDisabledProperty = (type: string) => (type === 'Select' ? 'isDisabled' : 'disabled') 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) => { const getFormScopeField = (node: DesignerNode, formScope?: FormScope) => {
if (!formScope) return null if (!formScope) return null
const propertyName = getSqlFormValueProperty(node, formScope.sourceId) const propertyName = getSqlFormValueProperty(node, formScope.sourceId)
@ -220,7 +257,7 @@ const propsToCode = (
const runtimeState = getRuntimeStateSpec(node) const runtimeState = getRuntimeStateSpec(node)
const formField = getFormScopeField(node, formScope) const formField = getFormScopeField(node, formScope)
const hasBoundInputValue = node.type === 'Input' && Boolean(node.bindings?.value?.sourceId) 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. // the record, so the input must not be forced read-only.
const hasInputChangeHandler = Boolean(node.events?.onChange?.trim()) || Boolean(formField) const hasInputChangeHandler = Boolean(node.events?.onChange?.trim()) || Boolean(formField)
const props = Object.entries(node.props) const props = Object.entries(node.props)
@ -228,12 +265,14 @@ const propsToCode = (
([key, value]) => ([key, value]) =>
key !== 'children' && key !== 'children' &&
key !== runtimeState?.propertyName && key !== runtimeState?.propertyName &&
// Seeds a new SqlDataSource record; it is not a prop of the component. // Seeds a new Form record; it is not a prop of the component.
key !== SQL_DEFAULT_VALUE_PROP && 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) && !omitted.has(key) &&
!(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) && !(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) &&
!(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') && !(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') &&
// 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. // node happens to carry from the toolbox defaults.
!(formField && (key === 'readOnly' || key === 'disabled')) && !(formField && (key === 'readOnly' || key === 'disabled')) &&
!node.bindings?.[key]?.sourceId && !node.bindings?.[key]?.sourceId &&
@ -352,14 +391,18 @@ const sqlIdentifiers = (node: DesignerNode) => {
recordRef: `sqlRecordRef_${identifier}`, recordRef: `sqlRecordRef_${identifier}`,
modeRef: `sqlModeRef_${identifier}`, modeRef: `sqlModeRef_${identifier}`,
slot: (property: string) => `sql${property.replace(/Endpoint$/, '')}_${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) => const sqlEventHandler = (node: DesignerNode, eventName: string) =>
node.events?.[eventName]?.trim() ? `handle_${safeIdentifier(node.id)}_${eventName}` : '' 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. * `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 * 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. * detail Grid into a filtered pair without a line of script.
*/ */
const filterValueExpression = ( const filterValueExpression = (
@ -458,21 +501,21 @@ const sqlSlotLiteral = (
dataSources: DesignerDataSource[], dataSources: DesignerDataSource[],
) => { ) => {
const source = dataSources.find( const source = dataSources.find(
(candidate) => candidate.id === getSqlDataSourceEndpointId(node, property), (candidate) => candidate.id === getFormEndpointId(node, property),
) )
if (!source?.url.trim()) return 'null' if (!source?.url.trim()) return 'null'
return `{ url: ${JSON.stringify(source.url.trim())}, method: ${JSON.stringify(method)}, responsePath: ${JSON.stringify(source.responsePath || '')} }` return `{ url: ${JSON.stringify(source.url.trim())}, method: ${JSON.stringify(method)}, responsePath: ${JSON.stringify(source.responsePath || '')} }`
} }
const sqlDataSourceHooks = ( const formHooks = (
node: DesignerNode, node: DesignerNode,
dataSources: DesignerDataSource[], dataSources: DesignerDataSource[],
sqlRecordByRef: Map<string, string>, sqlRecordByRef: Map<string, string>,
) => { ) => {
const names = sqlIdentifiers(node) const names = sqlIdentifiers(node)
const keyField = getSqlDataSourceKeyField(node) const keyField = getFormKeyField(node)
const selectSource = dataSources.find( const selectSource = dataSources.find(
(candidate) => candidate.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'), (candidate) => candidate.id === getFormEndpointId(node, 'selectEndpoint'),
) )
const selectFilters = filterEntriesExpression(selectSource, sqlRecordByRef) const selectFilters = filterEntriesExpression(selectSource, sqlRecordByRef)
// The Select endpoint already owns a fetch hook, so the container reuses that // The Select endpoint already owns a fetch hook, so the container reuses that
@ -484,16 +527,27 @@ const sqlDataSourceHooks = (
selectSource?.method === 'GET' && selectSource.url.trim().startsWith('/api/') selectSource?.method === 'GET' && selectSource.url.trim().startsWith('/api/')
? `setData_${safeIdentifier(selectSource.id)}` ? `setData_${safeIdentifier(selectSource.id)}`
: '' : ''
const keySource = getSqlDataSourceKeySource(node) const keySource = getFormKeySource(node)
const rowsExpression = `toSqlRows(${selectData}, ${JSON.stringify(String(node.props.collectionPath ?? ''))})` const rowsExpression = `toSqlRows(${selectData}, ${JSON.stringify(String(node.props.collectionPath ?? ''))})`
const slots = SQL_DATA_SOURCE_SLOTS.map( const slots = FORM_SLOTS.map(
(slot) => (slot) =>
` const ${names.slot(slot.property)} = ${sqlSlotLiteral(node, slot.property, slot.method, dataSources)}`, ` const ${names.slot(slot.property)} = ${sqlSlotLiteral(node, slot.property, slot.method, dataSources)}`,
).join('\n') ).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} return `${slots}
${permissions}
const ${names.key} = ${JSON.stringify(keyField)} 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.record}, ${names.setRecord}] = React.useState({})
const [${names.original}, ${names.setOriginal}] = React.useState({}) const [${names.original}, ${names.setOriginal}] = React.useState({})
const [${names.mode}, ${names.setMode}] = React.useState("edit") const [${names.mode}, ${names.setMode}] = React.useState("edit")
@ -601,11 +655,14 @@ ${
const ${names.reload} = React.useCallback(async () => {${ const ${names.reload} = React.useCallback(async () => {${
setSelectData 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 if (!${names.selectUrl}) return
// Calling a URL that still holds a placeholder is a guaranteed 400, so the // Calling a URL that still holds a placeholder is a guaranteed 400, so the
// missing key is reported in the component instead. // missing key is reported in the component instead.
if (hasSqlUrlParams(${names.selectUrl})) { 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" }) const response = await apiService.fetchData({ url: ${names.selectUrl}, method: "GET" })
${setSelectData}(getByPath(response.data, ${names.slot('selectEndpoint')}.responsePath))` ${setSelectData}(getByPath(response.data, ${names.slot('selectEndpoint')}.responsePath))`
@ -626,6 +683,7 @@ ${
} }
}, [${names.reload}]) }, [${names.reload}])
const ${names.create} = React.useCallback(${sqlEventHandler(node, 'onNewRecord') ? 'async ' : ''}() => { const ${names.create} = React.useCallback(${sqlEventHandler(node, 'onNewRecord') ? 'async ' : ''}() => {
if (!${names.can('insertEndpoint')}) return
${names.setError}("") ${names.setError}("")
// Defaults are resolved on every New, so an @today column carries the day the // 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. // record was opened rather than the day the component was generated.
@ -646,14 +704,17 @@ ${
const ${names.save} = async () => { const ${names.save} = async () => {
const isNew = ${names.mode} === "new" const isNew = ${names.mode} === "new"
const target = isNew ? ${names.slot('insertEndpoint')} : ${names.slot('updateEndpoint')} 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) { 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 return
} }
// Update carries only the edited columns plus the key; Insert sends the record. // 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}) 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) { 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 return
}${ }${
sqlEventHandler(node, 'onBeforeSave') sqlEventHandler(node, 'onBeforeSave')
@ -691,7 +752,7 @@ ${
} }
} }
const ${names.remove} = async () => { 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') sqlEventHandler(node, 'onBeforeDelete')
? ` ? `
// Asked before the confirm dialog, so a script can block the delete without // 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. // 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 if (typeof window !== "undefined" && !window.confirm(confirmMessage)) return
${names.setBusy}(true) ${names.setBusy}(true)
${names.setError}("") ${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 = const SQL_TOOLBAR_BUTTON_CLASS =
'rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-40' '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 names = sqlIdentifiers(node)
const formScope: FormScope = { sourceId: node.id, setterName: names.setField } const formScope: FormScope = { sourceId: node.id, setterName: names.setField }
const columnCount = getSqlDataSourceColumnCount(node) const columnCount = getFormColumnCount(node)
const gap = Number(node.props.gap) || 0 const gap = Number(node.props.gap) || 0
// Children sit one level deeper once they are wrapped in the column grid. // Children sit one level deeper once they are wrapped in the column grid.
const children = node.children 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 // 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 // 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. // 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 = const content =
columnCount > 1 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 : 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` const plainButtonClass = `${SQL_TOOLBAR_BUTTON_CLASS} border border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:text-slate-300`
// Navigation appears on its own whenever there is more than one record to walk. // Navigation appears on its own whenever there is more than one record to walk.
const navigation = ` const navigation = `
${indent(`{${names.rows}.length > 1 ? (`, level + 3)} ${indent(`{${names.rows}.length > 1 ? (`, level + 3)}
${indent('<>', level + 4)} ${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(`<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('<span className="mx-1 h-5 w-px bg-slate-300 dark:bg-slate-700" />', level + 5)}
${indent('</>', level + 4)} ${indent('</>', level + 4)}
${indent(') : null}', level + 3)}` ${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('<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(`{${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(`<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(`{(${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(`<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(`{${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(`<button type="button" className="${plainButtonClass}" disabled={${names.busy} || !${names.slot('selectEndpoint')}} onClick={() => { void ${names.refresh}() }}>Yenile</button>`, 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" ? "Yeni kayıt" : "Düzenleme"}</span>`, 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)}` ${indent('</div>', level + 2)}`
const error = ` const error = `
${indent(`{${names.error} ? <div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-950 dark:text-red-200">{${names.error}}</div> : null}`, level + 2)}` ${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 // and the header follows the active language. The header extra carries the key
// of the record on screen. // of the record on screen.
const title = String(node.props.title ?? '').trim() 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 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">' : '<UiKit.Card bodyClass="p-4">'
return `${indent(cardStart, level)} // Read permission decides whether the container exists on the page at all: a
${indent(`<div ref={${names.host}} className=${className} style={${style}}>`, level + 1)} // 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} ${content}${toolbar}${error}
${indent('</div>', level + 1)} ${indent('</div>', level + 2)}
${indent('</UiKit.Card>', level)}` ${indent('</UiKit.Card>', level + 1)}
${indent(') : null}', level)}`
} }
/** /**
@ -803,15 +888,22 @@ ${indent('</UiKit.Card>', level)}`
* JSX child. * JSX child.
*/ */
const withVisibilityGuard = (code: string, node: DesignerNode, level: number) => { 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 trimmed = code.trim()
const body = const body =
trimmed.startsWith('{') && trimmed.endsWith('}') trimmed.startsWith('{') && trimmed.endsWith('}')
? code.replace(/^(\s*)\{/, '$1(').replace(/\}(\s*)$/, ')$1') ? code.replace(/^(\s*)\{/, '$1(').replace(/\}(\s*)$/, ')$1')
: code : code
return `${indent(`{designerRefHidden(${JSON.stringify(node.ref)}) ? null : (`, level)} return `${indent(`{${conditions.join(' && ')} ? (`, level)}
${body} ${body}
${indent(')}', level)}` ${indent(') : null}', level)}`
} }
const nodeToCode = ( const nodeToCode = (
@ -821,7 +913,12 @@ const nodeToCode = (
formScope?: FormScope, formScope?: FormScope,
): string => ): string =>
withVisibilityGuard( withVisibilityGuard(
renderNodeToCode(node, isRefControlled(node) ? level + 1 : level, itemVariable, formScope), renderNodeToCode(
node,
isRefControlled(node) || nodePermissionGuard(node) ? level + 1 : level,
itemVariable,
formScope,
),
node, node,
level, level,
) )
@ -832,7 +929,7 @@ const renderNodeToCode = (
itemVariable?: string, itemVariable?: string,
formScope?: FormScope, formScope?: FormScope,
): string => { ): string => {
if (isSqlDataSourceNode(node.type)) return sqlDataSourceToCode(node, level, itemVariable) if (isFormNode(node.type)) return formToCode(node, level, itemVariable)
if (node.type === 'Spacer') { if (node.type === 'Spacer') {
return indent( return indent(
`<div aria-hidden="true" className=${JSON.stringify(String(node.props.className || ''))} style={{ height: ${Number(node.props.height) || 24} }} />`, `<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 delete groupProps.readOnly
const groupPropsCode = propsToCode(groupNode, itemVariable, [], formScope) const groupPropsCode = propsToCode(groupNode, itemVariable, [], formScope)
const optionsExpression = optionCollectionExpression(node, itemVariable) 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 = const tag =
@ -1220,7 +1317,7 @@ interface RefTarget {
/** /**
* `refs.<name>` entry of a node: the value accessor is wired to whichever state * `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. * runtime state, or the ref override store for a component that has neither.
*/ */
const refTargetFor = ( const refTargetFor = (
@ -1230,7 +1327,7 @@ const refTargetFor = (
): RefTarget => { ): RefTarget => {
const ref = node.ref as string const ref = node.ref as string
const extras: string[] = [] const extras: string[] = []
if (isSqlDataSourceNode(node.type)) { if (isFormNode(node.type)) {
const names = sqlIdentifiers(node) const names = sqlIdentifiers(node)
extras.push( extras.push(
` getRecord: () => ${names.record}`, ` 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), 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), remove: (url, params) => apiService.fetchData({ url, method: "DELETE", params }).then((response) => response.data),
request: (config) => apiService.fetchData(config).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( 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) const scripts = collectDesignerScripts(document)
// Only pages whose scripts address a ref pay for the ref runtime. // Only pages whose scripts address a ref pay for the ref runtime.
refRuntimeEnabled = /\brefs\b/.test(scripts) refRuntimeEnabled = /\brefs\b/.test(scripts)
autoPermissionCode = String(document.permissionCode ?? '').trim()
const scriptHelpers = [ const scriptHelpers = [
/\bapi\b/.test(scripts) ? SCRIPT_API_CODE : '', /\bapi\b/.test(scripts) ? SCRIPT_API_CODE : '',
/\bnotify\b/.test(scripts) ? SCRIPT_NOTIFY_CODE : '', /\bnotify\b/.test(scripts) ? SCRIPT_NOTIFY_CODE : '',
@ -1425,10 +1523,10 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
let hasSelectComponent = false let hasSelectComponent = false
let hasDatePicker = false let hasDatePicker = false
let hasTabs = false let hasTabs = false
const sqlDataSourceNodes: DesignerNode[] = [] const formNodes: DesignerNode[] = []
/** Platform views carrying their own filters; they get a params hook each. */ /** Platform views carrying their own filters; they get a params hook each. */
const platformFilterNodes: DesignerNode[] = [] 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 sqlManagedSelectSourceIds = new Set<string>()
const visit = (nodes: DesignerNode[], formScope?: FormScope) => { const visit = (nodes: DesignerNode[], formScope?: FormScope) => {
@ -1443,9 +1541,9 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
if (node.kind === 'platform' && getDesignerNodeFilters(node).length) { if (node.kind === 'platform' && getDesignerNodeFilters(node).length) {
platformFilterNodes.push(node) platformFilterNodes.push(node)
} }
if (isSqlDataSourceNode(node.type)) { if (isFormNode(node.type)) {
sqlDataSourceNodes.push(node) formNodes.push(node)
const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint') const selectId = getFormEndpointId(node, 'selectEndpoint')
if (selectId) sqlManagedSelectSourceIds.add(selectId) if (selectId) sqlManagedSelectSourceIds.add(selectId)
} }
const formField = getFormScopeField(node, formScope) const formField = getFormScopeField(node, formScope)
@ -1462,11 +1560,11 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
` const [${runtimeState.stateName}, ${runtimeState.setterName}] = React.useState(${initialExpression})`, ` 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 // 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. // can veto a command or finish an API call before the save continues.
if (isSqlDataSourceNode(node.type)) { if (isFormNode(node.type)) {
getSqlDataSourceScriptedEvents(node).forEach((event) => { getFormScriptedEvents(node).forEach((event) => {
const script = node.events[event.name].trim() const script = node.events[event.name].trim()
handlers.push( handlers.push(
` const handle_${safeIdentifier(node.id)}_${event.name} = async (event) => {\n${indent(script, 2)}\n }`, ` 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) => { eventNames.forEach((eventName) => {
const script = node.events[eventName]?.trim() || '' const script = node.events[eventName]?.trim() || ''
// A form scoped field replaces the local state update: the edit belongs // 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 = const formUpdate =
formField?.eventName === eventName formField?.eventName === eventName
? `${formField.setterName}(${JSON.stringify(formField.path)}, ${ ? `${formField.setterName}(${JSON.stringify(formField.path)}, ${
@ -1538,7 +1636,7 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
}) })
visit( visit(
node.children, node.children,
isSqlDataSourceNode(node.type) isFormNode(node.type)
? { sourceId: node.id, setterName: sqlIdentifiers(node).setField } ? { sourceId: node.id, setterName: sqlIdentifiers(node).setField }
: formScope, : formScope,
) )
@ -1547,11 +1645,11 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
visit(document.nodes) visit(document.nodes)
const dataSources = document.dataSources || [] const dataSources = document.dataSources || []
// Ref → record state variable of every SqlDataSource on the page. A `record` // 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 // filter addresses its master through this map, so a detail source can be
// filtered by the row the container currently shows. // filtered by the row the container currently shows.
const sqlRecordByRef = new Map<string, string>( const sqlRecordByRef = new Map<string, string>(
sqlDataSourceNodes formNodes
.filter((node) => node.ref) .filter((node) => node.ref)
.map((node) => [String(node.ref), sqlIdentifiers(node).record] as const), .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 valueKey = ["value", "eventKey", "id", "code", "key"].find((key) => item[key] !== undefined)
const optionLabel = labelPath ? getByPath(item, labelPath) : item[labelKey ?? primitiveKeys[0]] const optionLabel = labelPath ? getByPath(item, labelPath) : item[labelKey ?? primitiveKeys[0]]
const optionValue = valuePath ? getByPath(item, valuePath) : item[valueKey ?? primitiveKeys[1] ?? 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 }) }, 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. // binding and the actual Insert/Update/Delete calls.
const sqlHelpers = sqlDataSourceNodes.length const sqlHelpers = formNodes.length
? ` const readSqlField = (record, field) => { ? ` const readSqlField = (record, field) => {
if (!record || typeof record !== "object" || Array.isArray(record)) return undefined if (!record || typeof record !== "object" || Array.isArray(record)) return undefined
if (field in record) return record[field] 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 if (field.token === "today" || field.dateOnly) return day
return day + "T" + pad(now.getHours()) + ":" + pad(now.getMinutes()) + ":" + pad(now.getSeconds()) 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 const filterHelpers = hasFilters
? ` // Value of a filter that reads the page URL: a query string parameter, or a ? ` // 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 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. // container the view sits next to.
const platformFilterHooks = platformFilterNodes const platformFilterHooks = platformFilterNodes
.map((node) => { .map((node) => {
@ -1862,11 +1960,21 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
const platformParams_${identifier} = React.useMemo(() => buildPlatformFilterParams(platformFilters_${identifier}), [platformKey_${identifier}])` const platformParams_${identifier} = React.useMemo(() => buildPlatformFilterParams(platformFilters_${identifier}), [platformKey_${identifier}])`
}) })
.join('\n\n') .join('\n\n')
const sqlHooks = sqlDataSourceNodes const sqlHooks = formNodes
.map((node) => sqlDataSourceHooks(node, dataSources, sqlRecordByRef)) .map((node) => formHooks(node, dataSources, sqlRecordByRef))
.join('\n\n') .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 = const dataHelpers =
dataSources.length || hasDataTable || sqlDataSourceNodes.length dataSources.length || hasDataTable || formNodes.length
? ` const getByPath = (value, path) => { ? ` const getByPath = (value, path) => {
if (!path) return value if (!path) return value
const readPath = (target, targetPath) => targetPath.split('.').filter(Boolean).reduce((current, key) => current?.[key], target) 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) 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 // write through the setters; the fetch effects come after them, because a
// filtered source may read the record a container owns. // filtered source may read the record a container owns.
const dataStateHooks = dataSources const dataStateHooks = dataSources
.map((source) => { .map((source) => {
const identifier = safeIdentifier(source.id) const identifier = safeIdentifier(source.id)
// POST/PUT/DELETE sources are only ever invoked by a SqlDataSource command; // POST/PUT/DELETE sources are only ever invoked by a Form command;
// they hold no readable state and must never be written to. // they hold no readable state and must never be written to.
if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) { if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) {
return ` const [data_${identifier}] = React.useState(null)` return ` const [data_${identifier}] = React.useState(null)`
@ -1895,9 +2003,9 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
const identifier = safeIdentifier(source.id) const identifier = safeIdentifier(source.id)
if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) return '' if (source.method !== 'GET' || !source.url.trim().startsWith('/api/')) return ''
// A GetById style URL is not fetched on mount — requesting a literal `{id}` // A GetById style URL is not fetched on mount — requesting a literal `{id}`
// is a guaranteed 400 — and neither is a source the owning SqlDataSource // is a guaranteed 400 — and neither is a source the owning Form
// loads itself, through the setter declared above. // loads itself, through the setter declared above.
if (hasSqlDataSourceUrlParams(source.url) || sqlManagedSelectSourceIds.has(source.id)) { if (hasFormUrlParams(source.url) || sqlManagedSelectSourceIds.has(source.id)) {
return '' return ''
} }
const entries = filterEntriesExpression(source, sqlRecordByRef) const entries = filterEntriesExpression(source, sqlRecordByRef)
@ -1945,5 +2053,5 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
const designerBackup = encodeURIComponent(JSON.stringify(document)) const designerBackup = encodeURIComponent(JSON.stringify(document))
return `/*__SOZSOFT_VISUAL_DESIGNER__${designerBackup}__*/\nconst ${componentName} = () => {\n${[dataHelpers, 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`
} }

View file

@ -34,7 +34,7 @@ import {
type DesignerScriptRule, type DesignerScriptRule,
type TranslateFn, type TranslateFn,
} from './designerScriptRecipes' } from './designerScriptRecipes'
import type { SqlDataSourceEvent } from './types' import type { FormEvent } from './types'
const groupIcons: Record<DesignerRecipeGroupKey, ScriptRecipeGroup['icon']> = { const groupIcons: Record<DesignerRecipeGroupKey, ScriptRecipeGroup['icon']> = {
record: FaDatabase, record: FaDatabase,
@ -73,7 +73,7 @@ const toParam = (param: DesignerRecipeParam): ScriptRecipeParam => ({
/** Kod editöründe tek tıkla eklenebilen çok satırlı kalıplar. */ /** Kod editöründe tek tıkla eklenebilen çok satırlı kalıplar. */
const snippets = (sqlRef: string): ScriptSnippet[] => { const snippets = (sqlRef: string): ScriptSnippet[] => {
const sql = sqlRef || 'sqlDataSource1' const sql = sqlRef || 'form1'
return [ return [
{ {
label: 'App.SnippetCalcOnFieldChange.CalcOnFieldChangeLabel', label: 'App.SnippetCalcOnFieldChange.CalcOnFieldChangeLabel',
@ -133,7 +133,7 @@ const snippets = (sqlRef: string): ScriptSnippet[] => {
} }
export type DesignerScriptDialectContext = { export type DesignerScriptDialectContext = {
/** Kayıt tariflerinin yazacağı SqlDataSource ref adı. */ /** Kayıt tariflerinin yazacağı Form ref adı. */
sqlRef: string sqlRef: string
/** Select sonucundaki sütun adları. */ /** Select sonucundaki sütun adları. */
recordFields: string[] recordFields: string[]
@ -141,7 +141,7 @@ export type DesignerScriptDialectContext = {
refNames: string[] refNames: string[]
/** Düzenlenen event. */ /** Düzenlenen event. */
eventName: string eventName: string
eventInfo?: SqlDataSourceEvent eventInfo?: FormEvent
/** Event'in sahibi komponentin adı; başlıkta gösterilir. */ /** Event'in sahibi komponentin adı; başlıkta gösterilir. */
componentLabel?: string componentLabel?: string
/** `useLocalization` hook'undan gelen çeviri fonksiyonu. */ /** `useLocalization` hook'undan gelen çeviri fonksiyonu. */

View file

@ -72,7 +72,7 @@ export type DesignerRecipeGroupKey = 'record' | 'component' | 'interaction' | 'i
/** /**
* Kuralın kod üretirken ihtiyaç duyduğu sayfa bilgisi. `sqlRef` düzenlenen * 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. * üzerinden yazar, böylece aynı tarif başka bir sayfada da çalışır.
*/ */
export type DesignerScriptContext = { export type DesignerScriptContext = {
@ -90,7 +90,7 @@ export type DesignerRecipe = {
conditionIsArgument?: boolean conditionIsArgument?: boolean
/** Yalnızca iptal edilebilir event'lerde (onBeforeSave/onBeforeDelete) anlamlı. */ /** Yalnızca iptal edilebilir event'lerde (onBeforeSave/onBeforeDelete) anlamlı. */
cancelableOnly?: boolean 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 needsSqlRef?: boolean
params: DesignerRecipeParam[] params: DesignerRecipeParam[]
build: (rule: DesignerScriptRule, context: DesignerScriptContext) => string build: (rule: DesignerScriptRule, context: DesignerScriptContext) => string
@ -333,7 +333,7 @@ export const designerRecipes: DesignerRecipe[] = [
group: 'record', group: 'record',
needsSqlRef: true, needsSqlRef: true,
summary: 'App.VisualDesigner.Script.SetField.Summary', summary: 'App.VisualDesigner.Script.SetField.Summary',
example: "refs.sqlDataSource1.setField('Status', 'Taslak')", example: "refs.form1.setField('Status', 'Taslak')",
params: [ params: [
{ key: 'target', label: 'App.VisualDesigner.Script.Target.Label', type: 'recordField' }, { key: 'target', label: 'App.VisualDesigner.Script.Target.Label', type: 'recordField' },
{ {
@ -359,7 +359,7 @@ export const designerRecipes: DesignerRecipe[] = [
needsSqlRef: true, needsSqlRef: true,
summary: 'App.VisualDesigner.Script.Multiply.Summary', summary: 'App.VisualDesigner.Script.Multiply.Summary',
example: 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: [ params: [
{ key: 'left', label: 'App.VisualDesigner.Script.Left.Label', type: 'recordField' }, { key: 'left', label: 'App.VisualDesigner.Script.Left.Label', type: 'recordField' },
{ key: 'right', label: 'App.VisualDesigner.Script.Right.Label', type: 'recordField' }, { key: 'right', label: 'App.VisualDesigner.Script.Right.Label', type: 'recordField' },
@ -394,7 +394,7 @@ export const designerRecipes: DesignerRecipe[] = [
needsSqlRef: true, needsSqlRef: true,
summary: 'App.VisualDesigner.Script.SumFields.Summary', summary: 'App.VisualDesigner.Script.SumFields.Summary',
example: 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: [ params: [
{ key: 'target', label: 'App.VisualDesigner.Script.Target.Label3', type: 'recordField' }, { key: 'target', label: 'App.VisualDesigner.Script.Target.Label3', type: 'recordField' },
{ key: 'sources', label: 'App.VisualDesigner.Script.Sources.Label', type: 'fieldList' }, { key: 'sources', label: 'App.VisualDesigner.Script.Sources.Label', type: 'fieldList' },
@ -424,14 +424,14 @@ export const designerRecipes: DesignerRecipe[] = [
needsSqlRef: true, needsSqlRef: true,
summary: 'App.VisualDesigner.Script.Formula.Summary', summary: 'App.VisualDesigner.Script.Formula.Summary',
example: 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: [ params: [
{ key: 'target', label: 'App.VisualDesigner.Script.Target.Label4', type: 'recordField' }, { key: 'target', label: 'App.VisualDesigner.Script.Target.Label4', type: 'recordField' },
{ {
key: 'expression', key: 'expression',
label: 'App.VisualDesigner.Script.Expression.Label', label: 'App.VisualDesigner.Script.Expression.Label',
type: 'text', 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', help: 'App.VisualDesigner.Script.Expression.Help',
}, },
], ],
@ -449,7 +449,7 @@ export const designerRecipes: DesignerRecipe[] = [
group: 'record', group: 'record',
needsSqlRef: true, needsSqlRef: true,
summary: 'App.VisualDesigner.Script.CopyFromEvent.Summary', summary: 'App.VisualDesigner.Script.CopyFromEvent.Summary',
example: "refs.sqlDataSource1.setField('OldValue', event?.previous)", example: "refs.form1.setField('OldValue', event?.previous)",
params: [ params: [
{ {
key: 'source', key: 'source',
@ -473,7 +473,7 @@ export const designerRecipes: DesignerRecipe[] = [
group: 'record', group: 'record',
needsSqlRef: true, needsSqlRef: true,
summary: 'App.VisualDesigner.Script.ClearFields.Summary', summary: 'App.VisualDesigner.Script.ClearFields.Summary',
example: "refs.sqlDataSource1.clearFields('City', 'District')", example: "refs.form1.clearFields('City', 'District')",
params: [ params: [
{ key: 'sources', label: 'App.VisualDesigner.Script.Sources.Label2', type: 'fieldList' }, { key: 'sources', label: 'App.VisualDesigner.Script.Sources.Label2', type: 'fieldList' },
], ],
@ -491,7 +491,7 @@ export const designerRecipes: DesignerRecipe[] = [
needsSqlRef: true, needsSqlRef: true,
summary: 'App.VisualDesigner.Script.DayDiff.Summary', summary: 'App.VisualDesigner.Script.DayDiff.Summary',
example: 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: [ params: [
{ key: 'start', label: 'App.VisualDesigner.Script.Start.Label', type: 'recordField' }, { key: 'start', label: 'App.VisualDesigner.Script.Start.Label', type: 'recordField' },
{ key: 'end', label: 'App.VisualDesigner.Script.End.Label', type: 'recordField' }, { key: 'end', label: 'App.VisualDesigner.Script.End.Label', type: 'recordField' },
@ -515,7 +515,7 @@ export const designerRecipes: DesignerRecipe[] = [
group: 'record', group: 'record',
needsSqlRef: true, needsSqlRef: true,
summary: 'App.VisualDesigner.Script.Save.Summary', summary: 'App.VisualDesigner.Script.Save.Summary',
example: 'await refs.sqlDataSource1.save()', example: 'await refs.form1.save()',
params: [], params: [],
build: (_rule, context) => `await refs.${context.sqlRef}.save()`, build: (_rule, context) => `await refs.${context.sqlRef}.save()`,
describe: (_rule, t) => t('::App.VisualDesignerDescribe.Save'), describe: (_rule, t) => t('::App.VisualDesignerDescribe.Save'),
@ -526,7 +526,7 @@ export const designerRecipes: DesignerRecipe[] = [
group: 'record', group: 'record',
needsSqlRef: true, needsSqlRef: true,
summary: 'App.VisualDesigner.Script.Remove.Summary', summary: 'App.VisualDesigner.Script.Remove.Summary',
example: 'await refs.sqlDataSource1.remove()', example: 'await refs.form1.remove()',
params: [], params: [],
build: (_rule, context) => `await refs.${context.sqlRef}.remove()`, build: (_rule, context) => `await refs.${context.sqlRef}.remove()`,
describe: (_rule, t) => t('::App.VisualDesignerDescribe.Delete'), describe: (_rule, t) => t('::App.VisualDesignerDescribe.Delete'),
@ -537,7 +537,7 @@ export const designerRecipes: DesignerRecipe[] = [
group: 'record', group: 'record',
needsSqlRef: true, needsSqlRef: true,
summary: 'App.VisualDesigner.Script.Reload.Summary', summary: 'App.VisualDesigner.Script.Reload.Summary',
example: 'await refs.sqlDataSource1.reload()', example: 'await refs.form1.reload()',
params: [], params: [],
build: (_rule, context) => `await refs.${context.sqlRef}.reload()`, build: (_rule, context) => `await refs.${context.sqlRef}.reload()`,
describe: (_rule, t) => t('::App.VisualDesignerDescribe.Reload'), describe: (_rule, t) => t('::App.VisualDesignerDescribe.Reload'),
@ -548,7 +548,7 @@ export const designerRecipes: DesignerRecipe[] = [
group: 'record', group: 'record',
needsSqlRef: true, needsSqlRef: true,
summary: 'App.VisualDesigner.Script.NewRecord.Summary', summary: 'App.VisualDesigner.Script.NewRecord.Summary',
example: 'refs.sqlDataSource1.newRecord()', example: 'refs.form1.newRecord()',
params: [], params: [],
build: (_rule, context) => `refs.${context.sqlRef}.newRecord()`, build: (_rule, context) => `refs.${context.sqlRef}.newRecord()`,
describe: (_rule, t) => t('::App.VisualDesignerDescribe.NewRecord'), describe: (_rule, t) => t('::App.VisualDesignerDescribe.NewRecord'),
@ -559,7 +559,7 @@ export const designerRecipes: DesignerRecipe[] = [
group: 'record', group: 'record',
needsSqlRef: true, needsSqlRef: true,
summary: 'App.VisualDesigner.Script.GoToRow.Summary', summary: 'App.VisualDesigner.Script.GoToRow.Summary',
example: 'refs.sqlDataSource1.lastRow()', example: 'refs.form1.lastRow()',
params: [ params: [
{ {
key: 'target', key: 'target',
@ -603,7 +603,7 @@ export const designerRecipes: DesignerRecipe[] = [
group: 'record', group: 'record',
needsSqlRef: true, needsSqlRef: true,
summary: 'App.VisualDesigner.Script.SelectColumnToField.Summary', 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: [ params: [
{ key: 'ref', label: 'App.VisualDesigner.Script.SelectComponent.Label', type: 'refName' }, { key: 'ref', label: 'App.VisualDesigner.Script.SelectComponent.Label', type: 'refName' },
{ {
@ -677,7 +677,7 @@ export const designerRecipes: DesignerRecipe[] = [
conditionIsArgument: true, conditionIsArgument: true,
summary: 'App.VisualDesigner.Script.RefVisible.Summary', summary: 'App.VisualDesigner.Script.RefVisible.Summary',
example: example:
"refs.card1.setVisible(String(refs.sqlDataSource1.getField('Status') ?? '') === 'Aktif')", "refs.card1.setVisible(String(refs.form1.getField('Status') ?? '') === 'Aktif')",
params: [ params: [
{ key: 'ref', label: 'App.VisualDesigner.Script.Ref.Label', type: 'refName' }, { key: 'ref', label: 'App.VisualDesigner.Script.Ref.Label', type: 'refName' },
{ {
@ -713,7 +713,7 @@ export const designerRecipes: DesignerRecipe[] = [
group: 'component', group: 'component',
conditionIsArgument: true, conditionIsArgument: true,
summary: 'App.VisualDesigner.Script.RefEnabled.Summary', summary: 'App.VisualDesigner.Script.RefEnabled.Summary',
example: 'refs.button1.setEnabled(refs.sqlDataSource1.hasChanges())', example: 'refs.button1.setEnabled(refs.form1.hasChanges())',
params: [ params: [
{ key: 'ref', label: 'App.VisualDesigner.Script.Ref.Label', type: 'refName' }, { key: 'ref', label: 'App.VisualDesigner.Script.Ref.Label', type: 'refName' },
{ {
@ -749,7 +749,7 @@ export const designerRecipes: DesignerRecipe[] = [
group: 'component', group: 'component',
conditionIsArgument: true, conditionIsArgument: true,
summary: 'App.VisualDesigner.Script.RefReadOnly.Summary', summary: 'App.VisualDesigner.Script.RefReadOnly.Summary',
example: "refs.input1.setReadOnly(refs.sqlDataSource1.getMode() !== 'new')", example: "refs.input1.setReadOnly(refs.form1.getMode() !== 'new')",
params: [ params: [
{ key: 'ref', label: 'App.VisualDesigner.Script.Ref.Label', type: 'refName' }, { key: 'ref', label: 'App.VisualDesigner.Script.Ref.Label', type: 'refName' },
{ {
@ -926,7 +926,7 @@ export const designerRecipes: DesignerRecipe[] = [
group: 'integration', group: 'integration',
needsSqlRef: true, needsSqlRef: true,
summary: 'App.VisualDesigner.Script.ApiToField.Summary', 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: [ params: [
{ key: 'target', label: 'App.VisualDesigner.Script.Target.Label8', type: 'recordField' }, { key: 'target', label: 'App.VisualDesigner.Script.Target.Label8', type: 'recordField' },
{ {
@ -964,7 +964,7 @@ export const designerRecipes: DesignerRecipe[] = [
label: 'App.VisualDesigner.Script.ApiPost.Label', label: 'App.VisualDesigner.Script.ApiPost.Label',
group: 'integration', group: 'integration',
summary: 'App.VisualDesigner.Script.ApiPost.Summary', 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: [ params: [
{ {
key: 'method', key: 'method',

View file

@ -11,7 +11,7 @@ export interface DesignerBinding {
/** /**
* Label ve Value dışında, seçilen kaydın script ile okunabilmesi istenen * 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 * 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[] 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 * 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 * insert/update/delete command of a Form, mirroring ASP.NET's
* SqlDataSource Select/Insert/Update/DeleteCommand pairs. * Form Select/Insert/Update/DeleteCommand pairs.
*/ */
export const DESIGNER_HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const 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 * - `static` the literal typed in the designer
* - `query` a query string parameter of the page URL (`?id=…`) * - `query` a query string parameter of the page URL (`?id=…`)
* - `route` a path segment of the page URL (last segment, or a named one) * - `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 * makes a master/detail page work without a line of code
*/ */
export const DESIGNER_FILTER_SOURCES = ['static', 'query', 'route', 'record'] as const export const DESIGNER_FILTER_SOURCES = ['static', 'query', 'route', 'record'] as const
@ -88,7 +88,7 @@ export interface DesignerDataSourceFilter {
source: DesignerFilterSource source: DesignerFilterSource
/** /**
* `static` the literal value · `query`/`route` the URL parameter name · * `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 value: string
/** /**
@ -110,6 +110,18 @@ export interface DesignerDataSource {
filters?: DesignerDataSourceFilter[] 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: * 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 * a filter whose value is not there yet should hold the request back, because
@ -310,6 +322,38 @@ export interface DesignerDocument {
onMount: string onMount: string
} }
dataSources: DesignerDataSource[] 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) => export const getDesignerDataComponent = (type?: string) =>
type ? DESIGNER_DATA_COMPONENTS[type] : undefined type ? DESIGNER_DATA_COMPONENTS[type] : undefined
export const isDesignerDataComponent = (type?: string) => Boolean(getDesignerDataComponent(type))
export const isDesignerOptionComponent = (type?: string) => export const isDesignerOptionComponent = (type?: string) =>
getDesignerDataComponent(type)?.dataKind === 'option' 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) 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) => { export const toDesignerRefBase = (type: string) => {
const camelCased = String(type || '') const camelCased = String(type || '')
.replace(/[^A-Za-z0-9]+([A-Za-z0-9])?/g, (_match, next?: string) => .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 * 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 * 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 * which is also the name of the record state emitted by the code generator so
* the existing binding machinery keeps working unchanged. * the existing binding machinery keeps working unchanged.
*/ */
export const SQL_DATA_SOURCE_TYPE = 'Form' export const FORM_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'
/** /**
* Layout of a FlexRow. A single column is a plain flex row - items keep their own * 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. */ /** Props whose stored value is an icon name rather than an element. */
export const DESIGNER_ICON_PROPERTIES = new Set(['icon', 'customIcon']) export const DESIGNER_ICON_PROPERTIES = new Set(['icon', 'customIcon'])
export const isSqlDataSourceNode = (type?: string) => export const isFormNode = (type?: string) => type === FORM_TYPE
type === SQL_DATA_SOURCE_TYPE || type === LEGACY_SQL_DATA_SOURCE_TYPE
export interface SqlDataSourceSlot { export interface FormSlot {
property: 'selectEndpoint' | 'insertEndpoint' | 'updateEndpoint' | 'deleteEndpoint' 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 method: DesignerHttpMethod
label: string label: string
description: 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', property: 'selectEndpoint',
permissionProperty: 'selectPermission',
method: 'GET', method: 'GET',
label: 'Select (GET)', label: 'Select (GET)',
description: 'App.EventX.XDescription', description: 'App.EventX.XDescription',
}, },
{ {
property: 'insertEndpoint', property: 'insertEndpoint',
permissionProperty: 'insertPermission',
method: 'POST', method: 'POST',
label: 'Insert (POST)', label: 'Insert (POST)',
description: 'App.EventX.XDescription2', description: 'App.EventX.XDescription2',
}, },
{ {
property: 'updateEndpoint', property: 'updateEndpoint',
permissionProperty: 'updatePermission',
method: 'PUT', method: 'PUT',
label: 'Update (PUT)', label: 'Update (PUT)',
description: 'App.EventX.XDescription3', description: 'App.EventX.XDescription3',
}, },
{ {
property: 'deleteEndpoint', property: 'deleteEndpoint',
permissionProperty: 'deletePermission',
method: 'DELETE', method: 'DELETE',
label: 'Delete (DELETE)', label: 'Delete (DELETE)',
description: 'App.EventX.XDescription4', description: 'App.EventX.XDescription4',
}, },
] as const ] 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 * 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 * 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 * 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. * is the only way a Save or a Delete can be vetoed from configuration.
*/ */
export interface SqlDataSourceEvent { export interface FormEvent {
name: string name: string
label: string label: string
description: string description: string
@ -855,7 +906,7 @@ export interface SqlDataSourceEvent {
payload: { key: string; description: string }[] payload: { key: string; description: string }[]
} }
export const SQL_DATA_SOURCE_EVENTS: readonly SqlDataSourceEvent[] = [ export const FORM_EVENTS: readonly FormEvent[] = [
{ {
name: 'onLoad', name: 'onLoad',
label: 'App.EventOnLoad.OnLoadLabel', label: 'App.EventOnLoad.OnLoadLabel',
@ -957,21 +1008,17 @@ export const SQL_DATA_SOURCE_EVENTS: readonly SqlDataSourceEvent[] = [
}, },
] as const ] as const
export const SQL_DATA_SOURCE_EVENT_NAMES: readonly string[] = SQL_DATA_SOURCE_EVENTS.map( export const getFormEvent = (name: string) =>
(event) => event.name, FORM_EVENTS.find((event) => event.name === name)
)
export const getSqlDataSourceEvent = (name: string) => /** Scripted events of a Form node, in lifecycle order. */
SQL_DATA_SOURCE_EVENTS.find((event) => event.name === name) 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 getFormEndpointId = (node: DesignerNode, property: string) =>
export const getSqlDataSourceScriptedEvents = (node: DesignerNode) =>
SQL_DATA_SOURCE_EVENTS.filter((event) => Boolean(node.events?.[event.name]?.trim()))
export const getSqlDataSourceEndpointId = (node: DesignerNode, property: string) =>
String(node.props?.[property] ?? '').trim() String(node.props?.[property] ?? '').trim()
export const getSqlDataSourceKeyField = (node: DesignerNode) => export const getFormKeyField = (node: DesignerNode) =>
String(node.props?.keyFieldName ?? '').trim() || 'id' 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 * 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. * 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))) 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 * result to the requested record all without any extra configuration, which is
* why the query string is the default rather than an opt in. * why the query string is the default rather than an opt in.
*/ */
export const SQL_DATA_SOURCE_KEY_SOURCES = [ /** Etiketler dil anahtarıdır; gösterildikleri yerde translate edilirler. */
{ value: 'query', label: 'URL query string (?id=…)' }, export const FORM_KEY_SOURCES = [
{ value: 'route', label: 'App.EventOnError.OnErrorLabel2' }, { value: 'query', label: 'App.DeveloperKitComponentDesigner.KeySourceQuery' },
{ value: 'route', label: 'App.DeveloperKitComponentDesigner.KeySourceRoute' },
] as const ] 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' String(node.props?.keySource ?? '') === 'route' ? 'route' : 'query'
/** Name of the URL parameter; defaults to the key column. */ /** Name of the URL parameter; defaults to the key column. */
export const getSqlDataSourceKeyParam = (node: DesignerNode) => export const getFormKeyParam = (node: DesignerNode) =>
String(node.props?.keyParamName ?? '').trim() || getSqlDataSourceKeyField(node) String(node.props?.keyParamName ?? '').trim() || getFormKeyField(node)
/** /**
* Design time only. A GetById endpoint cannot be sampled in the designer there * 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 * 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. * 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() String(node.props?.previewKeyValue ?? '').trim()
/** Edit mode drives which command Save issues, independently of the key value. */ /** 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. */ /** 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 if (!record || typeof record !== 'object' || Array.isArray(record)) return undefined
const source = record as Record<string, unknown> const source = record as Record<string, unknown>
if (field in source) return source[field] 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 * result set so a detail endpoint works without extra configuration; ABP list
* envelopes (`{ items: [] }`) are unwrapped. * envelopes (`{ items: [] }`) are unwrapped.
*/ */
export const resolveSqlDataSourceRows = ( export const resolveFormRows = (
sample: unknown, sample: unknown,
collectionPath = '', collectionPath = '',
): Array<Record<string, unknown>> => { ): Array<Record<string, unknown>> => {
@ -1047,14 +1095,14 @@ export const resolveSqlDataSourceRows = (
) )
} }
export const getSqlDataSourceRecord = ( export const getFormRecord = (
node: DesignerNode, node: DesignerNode,
dataValues: Record<string, unknown>, dataValues: Record<string, unknown>,
rowIndex = 0, rowIndex = 0,
): Record<string, unknown> => { ): Record<string, unknown> => {
const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint') const selectId = getFormEndpointId(node, 'selectEndpoint')
if (!selectId) return {} if (!selectId) return {}
const rows = resolveSqlDataSourceRows( const rows = resolveFormRows(
dataValues[selectId], dataValues[selectId],
String(node.props?.collectionPath ?? ''), 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. * 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. */ /** `/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) /\{[^}]+\}|(?<=\/):[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)}` `${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 let keyBound = false
const boundUrl = url.replace( const boundUrl = url.replace(
/\{([^}]+)\}|(?<=\/):([A-Za-z_][A-Za-z0-9_]*)/g, /\{([^}]+)\}|(?<=\/):([A-Za-z_][A-Za-z0-9_]*)/g,
(match, braced?: string, colon?: string) => { (match, braced?: string, colon?: string) => {
const name = (braced || colon || '').trim() const name = (braced || colon || '').trim()
if (!name) return match 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 (value === undefined || value === null || value === '') return match
if (name.toLocaleLowerCase('en') === keyField.toLocaleLowerCase('en')) keyBound = true if (name.toLocaleLowerCase('en') === keyField.toLocaleLowerCase('en')) keyBound = true
return encodeURIComponent(String(value)) return encodeURIComponent(String(value))
@ -1120,12 +1168,12 @@ export const normalizeDesignerKeyList = (value: unknown): string[] => {
*/ */
export const SQL_DEFAULT_VALUE_PROP = 'sqlDefaultValue' 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 const SQL_FORM_VALUE_PROPERTIES = ['value', 'checked'] as const
export type SqlFormValueProperty = (typeof SQL_FORM_VALUE_PROPERTIES)[number] 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 = ( export const getSqlFormValueProperty = (
node: DesignerNode, node: DesignerNode,
sourceId?: string, 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. * container owns its own record, so its subtree is left to it.
*/ */
export const collectSqlDefaultFields = (node: DesignerNode): SqlDefaultField[] => { export const collectSqlDefaultFields = (node: DesignerNode): SqlDefaultField[] => {
const fields: SqlDefaultField[] = [] const fields: SqlDefaultField[] = []
const visit = (children: DesignerNode[]) => { const visit = (children: DesignerNode[]) => {
children.forEach((child) => { children.forEach((child) => {
if (isSqlDataSourceNode(child.type)) return if (isFormNode(child.type)) return
const property = getSqlFormValueProperty(child, node.id) const property = getSqlFormValueProperty(child, node.id)
const raw = child.props?.[SQL_DEFAULT_VALUE_PROP] const raw = child.props?.[SQL_DEFAULT_VALUE_PROP]
const path = property ? String(child.bindings[property].path ?? '').trim() : '' const path = property ? String(child.bindings[property].path ?? '').trim() : ''
@ -1249,7 +1297,7 @@ export const setDesignerRecordField = (
return next 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> => export const buildSqlDefaultRecord = (node: DesignerNode): Record<string, unknown> =>
collectSqlDefaultFields(node).reduce<Record<string, unknown>>( collectSqlDefaultFields(node).reduce<Record<string, unknown>>(
(record, field) => setDesignerRecordField(record, field.path, resolveSqlDefaultField(field)), (record, field) => setDesignerRecordField(record, field.path, resolveSqlDefaultField(field)),

View file

@ -9,6 +9,7 @@ import ErrorBoundary from '@/components/componentEditor/ErrorBoundary'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
import { useStoreState } from '@/store/store' import { useStoreState } from '@/store/store'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { usePermission } from '@/utils/hooks/usePermission'
import React, { import React, {
createContext, createContext,
useContext, useContext,
@ -96,6 +97,7 @@ export const useComponents = () => {
export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { translate } = useLocalization() const { translate } = useLocalization()
const { checkPermission } = usePermission()
const applicationConfig = useStoreState((state) => state.abpConfig?.config) const applicationConfig = useStoreState((state) => state.abpConfig?.config)
const extraProperties = applicationConfig?.extraProperties 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 () => { const refreshComponents = useCallback(async () => {
if (!applicationConfig) return if (!applicationConfig) return
@ -296,6 +308,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
DOMPurify, DOMPurify,
axios, axios,
translate: stableTranslate, translate: stableTranslate,
checkPermission: stableCheckPermission,
}) })
const { registry, errors } = compileComponentBundle( const { registry, errors } = compileComponentBundle(
@ -323,7 +336,7 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
return () => { return () => {
cancelled = true cancelled = true
} }
}, [compilationSignature, stableTranslate]) }, [compilationSignature, stableCheckPermission, stableTranslate])
const renderComponent = useCallback( const renderComponent = useCallback(
(name: string, props: ComponentProps = {}) => { (name: string, props: ComponentProps = {}) => {

View file

@ -58,7 +58,7 @@ import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
import DesignerScriptBuilderDialog from '@/components/visualDesigner/DesignerScriptBuilderDialog' import DesignerScriptBuilderDialog from '@/components/visualDesigner/DesignerScriptBuilderDialog'
import { import {
collectDesignerRefs, collectDesignerRefs,
getSqlDataSourceEvent, getFormEvent,
createDesignerId, createDesignerId,
createEmptyDesignerDocument, createEmptyDesignerDocument,
findDesignerNode, findDesignerNode,
@ -69,30 +69,32 @@ import {
getDesignerBindingColumns, getDesignerBindingColumns,
getDesignerCollectionProperty, getDesignerCollectionProperty,
getDesignerValueByPath, getDesignerValueByPath,
getSqlDataSourceEndpointId, getFormEndpointId,
getSqlDataSourceKeyField, getFormKeyField,
getSqlDataSourceKeyParam, getFormKeyParam,
getSqlDataSourceKeySource, getFormKeySource,
getSqlDataSourcePreviewKey, getFormPreviewKey,
getSqlDataSourceRecord, getFormRecord,
getSqlRecordProperty, getSqlRecordProperty,
isDesignerDateComponent, isDesignerDateComponent,
isDesignerDateOnlyComponent, isDesignerDateOnlyComponent,
DESIGNER_ICON_PROPERTIES, DESIGNER_ICON_PROPERTIES,
isDesignerOptionComponent, isDesignerOptionComponent,
isDesignerTabularComponent, isDesignerTabularComponent,
isSqlDataSourceNode, isFormNode,
LEGACY_SQL_DATA_SOURCE_TYPE,
resolveDesignerResponse, resolveDesignerResponse,
toDesignerHttpMethod, toDesignerHttpMethod,
walkDesignerNodes, walkDesignerNodes,
appendSqlDataSourceQueryParam, appendFormQueryParam,
bindSqlDataSourceUrl, bindFormUrl,
hasSqlDataSourceUrlParams, hasFormUrlParams,
DESIGNER_HTTP_METHODS, DESIGNER_HTTP_METHODS,
SQL_DATA_SOURCE_KEY_SOURCES, DESIGNER_PERMISSION_PROP,
SQL_DATA_SOURCE_SLOTS, FORM_AUTO_SUFFIXES,
SQL_DATA_SOURCE_TYPE, getDesignerNodePermission,
getFormPermission,
FORM_KEY_SOURCES,
FORM_SLOTS,
SQL_DEFAULT_VALUE_PROP, SQL_DEFAULT_VALUE_PROP,
SQL_DEFAULT_VALUE_TOKENS, SQL_DEFAULT_VALUE_TOKENS,
type DesignerComponentDefinition, type DesignerComponentDefinition,
@ -104,7 +106,6 @@ import {
createDesignerFilter, createDesignerFilter,
DESIGNER_FILTER_OPERATORS, DESIGNER_FILTER_OPERATORS,
DESIGNER_FILTER_SOURCES, DESIGNER_FILTER_SOURCES,
getDesignerDataSourceFilters,
isValuelessDesignerFilter, isValuelessDesignerFilter,
type DesignerDataSource, type DesignerDataSource,
type DesignerDataSourceFilter, type DesignerDataSourceFilter,
@ -119,6 +120,10 @@ import {
import { IconPickerField } from '@/views/shared/MenuAddDialog' import { IconPickerField } from '@/views/shared/MenuAddDialog'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import Input from '@/components/ui/Input' 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 WorkspaceTab = 'design' | 'data' | 'code' | 'javascript'
type InspectorTab = 'properties' | 'data' | 'tree' type InspectorTab = 'properties' | 'data' | 'tree'
@ -158,7 +163,7 @@ const DATA_BINDABLE_PROPERTY_NAMES = new Set([
'loading', '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 * 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. * 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 isRunnableDataSourceUrl = (url: string) => {
const normalizedUrl = url.trim().replace(/\/+$/, '') const normalizedUrl = url.trim().replace(/\/+$/, '')
return normalizedUrl.startsWith('/api/') && normalizedUrl !== '/api/app' return normalizedUrl.startsWith('/api/') && normalizedUrl !== '/api/app'
@ -636,7 +656,6 @@ const definitionToNode = (
* toolbox, the layer tree and the inspector all read it. * toolbox, the layer tree and the inspector all read it.
*/ */
const LEGACY_NODE_TYPES: Record<string, string> = { const LEGACY_NODE_TYPES: Record<string, string> = {
[LEGACY_SQL_DATA_SOURCE_TYPE]: SQL_DATA_SOURCE_TYPE,
TwoColumns: 'FlexRow', TwoColumns: 'FlexRow',
SidebarContent: 'FlexRow', SidebarContent: 'FlexRow',
HeaderContent: 'PageContainer', HeaderContent: 'PageContainer',
@ -1346,6 +1365,28 @@ const VisualComponentDesigner = () => {
const [catalogSourceTestResult, setCatalogSourceTestResult] = useState<DataTestResult | null>( const [catalogSourceTestResult, setCatalogSourceTestResult] = useState<DataTestResult | null>(
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. */ /** Free text filter over the reusable endpoint catalog. */
const [endpointSearch, setEndpointSearch] = useState('') const [endpointSearch, setEndpointSearch] = useState('')
const [endpointMethodFilter, setEndpointMethodFilter] = useState<'all' | DesignerHttpMethod>( const [endpointMethodFilter, setEndpointMethodFilter] = useState<'all' | DesignerHttpMethod>(
@ -1455,7 +1496,19 @@ const VisualComponentDesigner = () => {
redoStack.current = [] redoStack.current = []
}, [catalogByName, component, id, loadedId]) }, [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 effectiveCode = document.sourceMode === 'visual' ? generatedCode : manualCode
const isDirty = const isDirty =
effectiveCode.trim() !== savedCode.trim() || effectiveCode.trim() !== savedCode.trim() ||
@ -1602,17 +1655,17 @@ const VisualComponentDesigner = () => {
const resolveRecipeCode = useCallback( const resolveRecipeCode = useCallback(
(code: string) => { (code: string) => {
const entries = [...documentRefs.entries()] const entries = [...documentRefs.entries()]
const sqlRef = entries.find(([, node]) => isSqlDataSourceNode(node.type))?.[0] const sqlRef = entries.find(([, node]) => isFormNode(node.type))?.[0]
const selectedRef = const selectedRef =
selectedNode && !isSqlDataSourceNode(selectedNode.type) ? selectedNode.ref : undefined selectedNode && !isFormNode(selectedNode.type) ? selectedNode.ref : undefined
const componentRef = const componentRef =
selectedRef || selectedRef ||
entries.find(([, node]) => !isSqlDataSourceNode(node.type) && node.kind === 'ui')?.[0] || entries.find(([, node]) => !isFormNode(node.type) && node.kind === 'ui')?.[0] ||
entries.find(([, node]) => !isSqlDataSourceNode(node.type))?.[0] entries.find(([, node]) => !isFormNode(node.type))?.[0]
const url = document.dataSources.find((source) => source.method === 'GET')?.url const url = document.dataSources.find((source) => source.method === 'GET')?.url
return code return code
.replace(/\{\{ref\}\}/g, componentRef || 'button1') .replace(/\{\{ref\}\}/g, componentRef || 'button1')
.replace(/\{\{sql\}\}/g, sqlRef || 'sqlDataSource1') .replace(/\{\{sql\}\}/g, sqlRef || 'form1')
.replace(/\{\{url\}\}/g, url || '/api/app/orders') .replace(/\{\{url\}\}/g, url || '/api/app/orders')
}, },
[document.dataSources, documentRefs, selectedNode], [document.dataSources, documentRefs, selectedNode],
@ -1630,28 +1683,28 @@ const VisualComponentDesigner = () => {
) )
}, [scriptApiSearch, translate]) }, [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 * 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. * merged into the canvas data values and into the binding inspector samples.
*/ */
const sqlDataSourceRecords = useMemo(() => { const formRecords = useMemo(() => {
const records: Record<string, unknown> = {} const records: Record<string, unknown> = {}
walkDesignerNodes(document.nodes, (node) => { walkDesignerNodes(document.nodes, (node) => {
if (isSqlDataSourceNode(node.type)) { if (isFormNode(node.type)) {
records[node.id] = getSqlDataSourceRecord(node, dataSourceSamples) records[node.id] = getFormRecord(node, dataSourceSamples)
} }
}) })
return records return records
}, [dataSourceSamples, document.nodes]) }, [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 * container is addressable: the generated code resolves the master through its
* ref, exactly like a script does. * ref, exactly like a script does.
*/ */
const sqlContainerRefs = useMemo(() => { const sqlContainerRefs = useMemo(() => {
const refs: { ref: string; id: string }[] = [] const refs: { ref: string; id: string }[] = []
walkDesignerNodes(document.nodes, (node) => { 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 return refs
}, [document.nodes]) }, [document.nodes])
@ -1732,7 +1785,7 @@ const VisualComponentDesigner = () => {
// `withPreviewFilterValues` for why they are read through refs. // `withPreviewFilterValues` for why they are read through refs.
const sqlPreviewRecords = useRef<Record<string, unknown>>({}) const sqlPreviewRecords = useRef<Record<string, unknown>>({})
const sqlPreviewRefs = useRef<{ ref: string; id: string }[]>([]) const sqlPreviewRefs = useRef<{ ref: string; id: string }[]>([])
sqlPreviewRecords.current = sqlDataSourceRecords sqlPreviewRecords.current = formRecords
sqlPreviewRefs.current = sqlContainerRefs sqlPreviewRefs.current = sqlContainerRefs
const updateDataSourceFilters = useCallback( const updateDataSourceFilters = useCallback(
@ -1748,17 +1801,17 @@ const VisualComponentDesigner = () => {
) )
const previewDataValues = useMemo( const previewDataValues = useMemo(
() => ({ ...dataSourceSamples, ...sqlDataSourceRecords }), () => ({ ...dataSourceSamples, ...formRecords }),
[dataSourceSamples, sqlDataSourceRecords], [dataSourceSamples, formRecords],
) )
const selectedAncestors = useMemo( const selectedAncestors = useMemo(
() => findDesignerAncestors(document.nodes, selectedId) || [], () => findDesignerAncestors(document.nodes, selectedId) || [],
[document.nodes, selectedId], [document.nodes, selectedId],
) )
const selectedIsSqlDataSource = isSqlDataSourceNode(selectedNode?.type) const selectedIsForm = isFormNode(selectedNode?.type)
/** Nearest SqlDataSource above the selection; its record is bindable. */ /** Nearest Form above the selection; its record is bindable. */
const sqlScopeNode = useMemo( const sqlScopeNode = useMemo(
() => [...selectedAncestors].reverse().find((node) => isSqlDataSourceNode(node.type)), () => [...selectedAncestors].reverse().find((node) => isFormNode(node.type)),
[selectedAncestors], [selectedAncestors],
) )
/** /**
@ -1770,8 +1823,8 @@ const VisualComponentDesigner = () => {
sqlScopeNode sqlScopeNode
? { ? {
id: sqlScopeNode.id, id: sqlScopeNode.id,
name: translate('::App.DeveloperKitComponentDesigner.SqlDataSourceRecord', { name: translate('::App.DeveloperKitComponentDesigner.FormRecord', {
field: getSqlDataSourceKeyField(sqlScopeNode), field: getFormKeyField(sqlScopeNode),
}), }),
method: 'GET', method: 'GET',
url: '', url: '',
@ -1783,12 +1836,12 @@ const VisualComponentDesigner = () => {
/** /**
* Only a GET endpoint returns rows, so only GET can feed a property binding * 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 * 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( const bindableDataSources = useMemo(
() => () =>
document.dataSources.filter( document.dataSources.filter(
(source) => source.method === 'GET' && !hasSqlDataSourceUrlParams(source.url), (source) => source.method === 'GET' && !hasFormUrlParams(source.url),
), ),
[document.dataSources], [document.dataSources],
) )
@ -1799,7 +1852,7 @@ const VisualComponentDesigner = () => {
) )
/** /**
* A collection component keeps managing its own list endpoint even inside a * 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 * two different things. Everything else inside the container is a record field
* and may only bind to the container's Select result. * 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. // the Data tab is where its filters are configured.
(isDataCategoryComponent(selectedDefinition) || (isDataCategoryComponent(selectedDefinition) ||
selectedNode.kind === 'platform' || selectedNode.kind === 'platform' ||
selectedIsSqlDataSource || selectedIsForm ||
sqlScopeNode), sqlScopeNode),
) )
const selectedProperties = useMemo<DesignerPropertyInfo[]>(() => { const selectedProperties = useMemo<DesignerPropertyInfo[]>(() => {
@ -1906,7 +1959,7 @@ const VisualComponentDesigner = () => {
generatedEndpoints generatedEndpoints
// Every active endpoint is offered, including GetById: its `{id}` is filled // 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) .filter((endpoint) => endpoint.isActive)
.forEach((endpoint) => { .forEach((endpoint) => {
const method = toDesignerHttpMethod(endpoint.method) const method = toDesignerHttpMethod(endpoint.method)
@ -2045,9 +2098,9 @@ const VisualComponentDesigner = () => {
source.responsePath.trim() === catalogItem.source.responsePath.trim(), source.responsePath.trim() === catalogItem.source.responsePath.trim(),
) )
// Only a directly callable GET source produces rows. A write endpoint or a // 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 = const bindsCollection =
catalogItem.source.method === 'GET' && !hasSqlDataSourceUrlParams(catalogItem.source.url) catalogItem.source.method === 'GET' && !hasFormUrlParams(catalogItem.source.url)
const collectionProperty = const collectionProperty =
selectedId && isTabularDataComponent(selectedNode?.type) selectedId && isTabularDataComponent(selectedNode?.type)
? 'items' ? 'items'
@ -2149,7 +2202,7 @@ const VisualComponentDesigner = () => {
const probeUrl = buildDesignerPreviewUrl(withPreviewFilterValues(relaxed), urlOverride)?.trim() const probeUrl = buildDesignerPreviewUrl(withPreviewFilterValues(relaxed), urlOverride)?.trim()
if (!probeUrl || !probeUrl.startsWith('/api/')) return if (!probeUrl || !probeUrl.startsWith('/api/')) return
if (!isRunnableDataSourceUrl((urlOverride ?? source.url).trim())) return if (!isRunnableDataSourceUrl((urlOverride ?? source.url).trim())) return
if (hasSqlDataSourceUrlParams(probeUrl)) return if (hasFormUrlParams(probeUrl)) return
try { try {
const response = await apiService.fetchData({ method: 'GET', url: probeUrl }) const response = await apiService.fetchData({ method: 'GET', url: probeUrl })
const result = resolveDesignerResponse(response.data, source.responsePath) 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 * `urlOverride` carries a URL whose `{id}` was already filled in a GetById
* endpoint cannot be sampled otherwise, and without a sample the designer has * endpoint cannot be sampled otherwise, and without a sample the designer has
* no columns to offer the components inside the SqlDataSource. * no columns to offer the components inside the Form.
*/ */
const testDataSource = useCallback( const testDataSource = useCallback(
async (source: DesignerDataSource, showResult = false, urlOverride?: string) => { async (source: DesignerDataSource, showResult = false, urlOverride?: string) => {
@ -2230,7 +2283,7 @@ const VisualComponentDesigner = () => {
if (!isRunnableDataSourceUrl((urlOverride ?? source.url).trim())) { if (!isRunnableDataSourceUrl((urlOverride ?? source.url).trim())) {
throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointNotRunnable')) throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointNotRunnable'))
} }
if (hasSqlDataSourceUrlParams(requestUrl)) { if (hasFormUrlParams(requestUrl)) {
throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointNeedsKey')) throw new Error(translate('::App.DeveloperKitComponentDesigner.EndpointNeedsKey'))
} }
const response = await apiService.fetchData({ method: source.method, url: requestUrl }) const response = await apiService.fetchData({ method: source.method, url: requestUrl })
@ -2307,6 +2360,147 @@ const VisualComponentDesigner = () => {
[componentDetails, components, updateComponent], [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) => { const openCatalogSourceEditor = (item: DataSourceCatalogItem) => {
if (item.originType !== 'component') return if (item.originType !== 'component') return
setCatalogSourceEditor({ item, draft: { ...item.source } }) setCatalogSourceEditor({ item, draft: { ...item.source } })
@ -2655,7 +2849,7 @@ const VisualComponentDesigner = () => {
) )
const propsToSave = const propsToSave =
document.sourceMode === 'visual' document.sourceMode === 'visual'
? JSON.stringify({ ...existingProps, visualDesigner: document }) ? JSON.stringify({ ...existingProps, visualDesigner: documentToGenerate })
: component.props : component.props
const update = { const update = {
name: component.name, name: component.name,
@ -2693,7 +2887,16 @@ const VisualComponentDesigner = () => {
setIsSaving(false) setIsSaving(false)
} }
}, },
[collectDependencies, component, document, generatedCode, id, manualCode, updateComponent], [
collectDependencies,
component,
document,
documentToGenerate,
generatedCode,
id,
manualCode,
updateComponent,
],
) )
useEffect(() => { useEffect(() => {
@ -2786,24 +2989,24 @@ const VisualComponentDesigner = () => {
*/ */
useEffect(() => { useEffect(() => {
if (!id || loadedId !== id) return 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. // since there is no page URL to read the real key from while designing.
const previewUrls = new Map<string, string>() const previewUrls = new Map<string, string>()
walkDesignerNodes(document.nodes, (node) => { walkDesignerNodes(document.nodes, (node) => {
if (!isSqlDataSourceNode(node.type)) return if (!isFormNode(node.type)) return
const previewKey = getSqlDataSourcePreviewKey(node) const previewKey = getFormPreviewKey(node)
if (!previewKey) return if (!previewKey) return
const source = document.dataSources.find( const source = document.dataSources.find(
(item) => item.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'), (item) => item.id === getFormEndpointId(node, 'selectEndpoint'),
) )
if (!source) return if (!source) return
const parameterName = getSqlDataSourceKeyParam(node) const parameterName = getFormKeyParam(node)
const bound = bindSqlDataSourceUrl(source.url, { [parameterName]: previewKey }, parameterName) const bound = bindFormUrl(source.url, { [parameterName]: previewKey }, parameterName)
previewUrls.set( previewUrls.set(
source.id, source.id,
bound.keyBound bound.keyBound
? bound.url ? 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 if (dataSourceSamples[source.id] !== undefined || dataTestResults[source.id]) return
const requestUrl = previewUrls.get(source.id) ?? source.url const requestUrl = previewUrls.get(source.id) ?? source.url
// An unresolved `{id}` would just 404; it waits for a preview key instead. // 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) void testDataSource(source, false, requestUrl)
}) })
}, [ }, [
@ -2902,7 +3105,7 @@ const VisualComponentDesigner = () => {
setSelectDataModes((current) => ({ ...current, [selectedId]: mode })) setSelectDataModes((current) => ({ ...current, [selectedId]: mode }))
if (isTabularDataComponent(selectedNode?.type)) { if (isTabularDataComponent(selectedNode?.type)) {
// Only a source the picker lists can be bound; a POST/PUT entry or the // 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] const source = activeDataSource || inspectorDataSources[0]
commitDocument((current) => ({ commitDocument((current) => ({
...current, ...current,
@ -3586,7 +3789,7 @@ const VisualComponentDesigner = () => {
{/* {/*
Label ve Value ekranı sürer; buradaki sütunlar görünmez ama 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ı 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 && ( {selectCollectionSample && selectColumnFields.length > 0 && (
<div> <div>
@ -3921,7 +4124,7 @@ const VisualComponentDesigner = () => {
* GetList endpoint parses, so any column can be filtered, not just the key. * 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 * 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. * list follows the master container without a line of script.
*/ */
/** /**
@ -4053,7 +4256,7 @@ const VisualComponentDesigner = () => {
}) })
const [masterRef = '', ...columnParts] = String(filter.value || '').split('.') const [masterRef = '', ...columnParts] = String(filter.value || '').split('.')
const masterColumn = columnParts.join('.') const masterColumn = columnParts.join('.')
const masterRecord = sqlDataSourceRecords[ const masterRecord = formRecords[
sqlContainerRefs.find((item) => item.ref === masterRef)?.id || '' sqlContainerRefs.find((item) => item.ref === masterRef)?.id || ''
] ]
const masterColumns = 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 * slots. Each slot only accepts a data source declared with the matching HTTP
* method, which is what enables the Save/Delete buttons at runtime. * method, which is what enables the Save/Delete buttons at runtime.
*/ */
const renderSqlDataSourceConfiguration = () => { /** Key kaynağı seçenekleri; etiketleri dil anahtarı olarak saklanır. */
if (!selectedNode || !selectedIsSqlDataSource) return null 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 selectSourceId = String(selectedNode.props.selectEndpoint || '')
const selectSample = selectSourceId ? dataSourceSamples[selectSourceId] : undefined const selectSample = selectSourceId ? dataSourceSamples[selectSourceId] : undefined
const collectionPaths = selectSample const collectionPaths = selectSample
@ -4267,12 +4480,12 @@ const VisualComponentDesigner = () => {
.map((field) => field.path), .map((field) => field.path),
] ]
: [''] : ['']
const record = sqlDataSourceRecords[selectedNode.id] const record = formRecords[selectedNode.id]
const recordFields = record === undefined ? [] : discoverDataFields(record) const recordFields = record === undefined ? [] : discoverDataFields(record)
const selectSource = document.dataSources.find((source) => source.id === selectSourceId) const selectSource = document.dataSources.find((source) => source.id === selectSourceId)
// A GetById URL cannot be sampled at design time without a stand-in key. // A GetById URL cannot be sampled at design time without a stand-in key.
const selectNeedsPreviewKey = Boolean( const selectNeedsPreviewKey = Boolean(
selectSource && hasSqlDataSourceUrlParams(selectSource.url), selectSource && hasFormUrlParams(selectSource.url),
) )
return ( 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"> <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')} {translate('::App.DeveloperKitComponentDesigner.CommandSlotsHint')}
</div> </div>
{SQL_DATA_SOURCE_SLOTS.map((slot) => { {FORM_SLOTS.map((slot) => {
const options = const options =
slot.method === 'GET' slot.method === 'GET'
? selectSlotDataSources ? selectSlotDataSources
: document.dataSources.filter((source) => source.method === slot.method) : document.dataSources.filter((source) => source.method === slot.method)
const currentValue = String(selectedNode.props[slot.property] || '') const currentValue = String(selectedNode.props[slot.property] || '')
return ( 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} key={slot.property}
className="block rounded-lg border border-slate-200 p-3 dark:border-slate-800" 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 && ( {!options.length && (
<p className="mt-1.5 text-[10px] leading-4 text-amber-600"> <p className="mt-1.5 text-[10px] leading-4 text-amber-600">
{translate('::App.DeveloperKitComponentDesigner.NoEndpointForMethod', { {translate('::App.DeveloperKitComponentDesigner.NoEndpointForMethod', {
@ -4339,7 +4564,7 @@ const VisualComponentDesigner = () => {
})} })}
</p> </p>
)} )}
</label> </div>
) )
})} })}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@ -4348,7 +4573,7 @@ const VisualComponentDesigner = () => {
Key field Key field
</span> </span>
<Input <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" 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" placeholder="id"
value={String(selectedNode.props.keyFieldName ?? '')} value={String(selectedNode.props.keyFieldName ?? '')}
@ -4393,9 +4618,9 @@ const VisualComponentDesigner = () => {
className="mb-2 w-full" className="mb-2 w-full"
maxMenuHeight={200} maxMenuHeight={200}
menuPortalTarget={window.document.body} menuPortalTarget={window.document.body}
options={SQL_DATA_SOURCE_KEY_SOURCES} options={keySourceOptions}
value={SQL_DATA_SOURCE_KEY_SOURCES.filter( value={keySourceOptions.filter(
(item) => item.value === getSqlDataSourceKeySource(selectedNode), (item) => item.value === getFormKeySource(selectedNode),
)} )}
onChange={(option) => option && updateSelectedProp('keySource', option.value)} onChange={(option) => option && updateSelectedProp('keySource', option.value)}
/> />
@ -4403,7 +4628,7 @@ const VisualComponentDesigner = () => {
unstyle 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" 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', { placeholder={translate('::App.DeveloperKitComponentDesigner.KeyParamPlaceholder', {
field: getSqlDataSourceKeyField(selectedNode), field: getFormKeyField(selectedNode),
})} })}
value={String(selectedNode.props.keyParamName ?? '')} value={String(selectedNode.props.keyParamName ?? '')}
onChange={(event) => updateSelectedProp('keyParamName', event.target.value)} 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, * component is bound to and, when the container has no Select endpoint yet,
* why no column can be picked. * why no column can be picked.
*/ */
const renderSqlScopeNotice = () => { const renderSqlScopeNotice = () => {
if (!sqlScopeNode) return null if (!sqlScopeNode) return null
const hasSelect = Boolean(getSqlDataSourceEndpointId(sqlScopeNode, 'selectEndpoint')) const hasSelect = Boolean(getFormEndpointId(sqlScopeNode, 'selectEndpoint'))
const record = sqlDataSourceRecords[sqlScopeNode.id] const record = formRecords[sqlScopeNode.id]
const hasColumns = record !== undefined && discoverDataFields(record).length > 0 const hasColumns = record !== undefined && discoverDataFields(record).length > 0
if (!hasSelect) { if (!hasSelect) {
@ -4501,7 +4726,7 @@ const VisualComponentDesigner = () => {
} }
return ( 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"> <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')}`} {!hasColumns && ` ${translate('::App.DeveloperKitComponentDesigner.RunSelectOnce')}`}
</div> </div>
) )
@ -4514,7 +4739,7 @@ const VisualComponentDesigner = () => {
* typeable, so a field is never blocked by a stale or empty sample. * typeable, so a field is never blocked by a stale or empty sample.
*/ */
const sqlRecordColumns = useMemo(() => { const sqlRecordColumns = useMemo(() => {
const record = sqlScopeNode ? sqlDataSourceRecords[sqlScopeNode.id] : undefined const record = sqlScopeNode ? formRecords[sqlScopeNode.id] : undefined
if (!record || typeof record !== 'object') return [] if (!record || typeof record !== 'object') return []
const columns = new Map<string, string>() const columns = new Map<string, string>()
Object.entries(record as Record<string, unknown>).forEach(([path, value]) => Object.entries(record as Record<string, unknown>).forEach(([path, value]) =>
@ -4526,10 +4751,10 @@ const VisualComponentDesigner = () => {
return [...columns.entries()] return [...columns.entries()]
.map(([path, type]) => ({ path, type })) .map(([path, type]) => ({ path, type }))
.sort((left, right) => left.path.localeCompare(right.path)) .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 * 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. * 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 * 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 * than a dropdown: the sampled row is a convenience, not the list of columns
* that exist. * that exist.
*/ */
@ -4676,7 +4901,7 @@ const VisualComponentDesigner = () => {
</div> </div>
) )
} }
if (selectedIsSqlDataSource) return renderSqlDataSourceConfiguration() if (selectedIsForm) return renderFormConfiguration()
const scopeNotice = renderSqlScopeNotice() const scopeNotice = renderSqlScopeNotice()
const selectModeSelector = renderSelectDataModeSelector() const selectModeSelector = renderSelectDataModeSelector()
const selectConfiguration = renderSelectDataConfiguration() const selectConfiguration = renderSelectDataConfiguration()
@ -4711,7 +4936,7 @@ const VisualComponentDesigner = () => {
<div className="space-y-3"> <div className="space-y-3">
{scopeNotice} {scopeNotice}
{selectModeSelector} {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. */} 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"> <div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<label className="block"> <label className="block">
@ -4781,6 +5006,21 @@ const VisualComponentDesigner = () => {
/> />
)} )}
</label> </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> </div>
{!sqlScopeLocked && renderDataSourceFilters(activeDataSource)} {!sqlScopeLocked && renderDataSourceFilters(activeDataSource)}
@ -4927,7 +5167,7 @@ const VisualComponentDesigner = () => {
const currentBindingSource = document.dataSources.find( const currentBindingSource = document.dataSources.find(
(source) => source.id === currentBinding?.sourceId, (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 // point at an unrelated endpoint; the dropdown cannot show it, so
// it is called out explicitly instead of failing silently. // it is called out explicitly instead of failing silently.
const foreignBinding = const foreignBinding =
@ -5063,6 +5303,7 @@ const VisualComponentDesigner = () => {
const canvas = ( const canvas = (
<VisualCanvas <VisualCanvas
interactive interactive
canvasWidth={document.canvas.width}
nodes={document.nodes} nodes={document.nodes}
selectedId={selectedId} selectedId={selectedId}
dataValues={previewDataValues} 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 * record (the node itself, or the nearest one above it) plus the columns and
* ref names its rules can address. * ref names its rules can address.
*/ */
@ -5121,14 +5362,14 @@ const VisualComponentDesigner = () => {
? findDesignerNode(document.nodes, scriptBuilderTarget.nodeId) ? findDesignerNode(document.nodes, scriptBuilderTarget.nodeId)
: null : null
const scriptBuilderSqlNode = scriptBuilderNode const scriptBuilderSqlNode = scriptBuilderNode
? isSqlDataSourceNode(scriptBuilderNode.type) ? isFormNode(scriptBuilderNode.type)
? scriptBuilderNode ? scriptBuilderNode
: [...(findDesignerAncestors(document.nodes, scriptBuilderNode.id) || [])] : [...(findDesignerAncestors(document.nodes, scriptBuilderNode.id) || [])]
.reverse() .reverse()
.find((node) => isSqlDataSourceNode(node.type)) .find((node) => isFormNode(node.type))
: undefined : undefined
const scriptBuilderRecordFields = scriptBuilderSqlNode const scriptBuilderRecordFields = scriptBuilderSqlNode
? Object.keys((sqlDataSourceRecords[scriptBuilderSqlNode.id] as Record<string, unknown>) || {}) ? Object.keys((formRecords[scriptBuilderSqlNode.id] as Record<string, unknown>) || {})
: [] : []
const endpointResultText = endpointResultModal const endpointResultText = endpointResultModal
@ -5210,10 +5451,11 @@ const VisualComponentDesigner = () => {
{translate('::App.Platform.Reset')} {translate('::App.Platform.Reset')}
</Button> </Button>
<Button <Button
color="green-600"
icon={<FaEye />} icon={<FaEye />}
size="sm" size="sm"
title={translate('::App.DeveloperKitComponentDesigner.OpenInNewTab')} title={translate('::App.DeveloperKitComponentDesigner.OpenInNewTab')}
variant="plain" variant="solid"
onClick={() => { onClick={() => {
const routePath = component.routePath.startsWith('/') const routePath = component.routePath.startsWith('/')
? component.routePath ? component.routePath
@ -5221,7 +5463,7 @@ const VisualComponentDesigner = () => {
window.open(routePath, '_blank') window.open(routePath, '_blank')
}} }}
> >
{translate('::App.Platform.OpenUrl')} {translate('::App.Listform.ListformField.Preview')}
</Button> </Button>
<Button <Button
loading={isSaving} loading={isSaving}
@ -5600,18 +5842,18 @@ const VisualComponentDesigner = () => {
<span <span
className="min-w-0 truncate" className="min-w-0 truncate"
title={ title={
getSqlDataSourceEvent(eventName)?.description getFormEvent(eventName)?.description
? translate( ? translate(
'::' + getSqlDataSourceEvent(eventName)!.description, '::' + getFormEvent(eventName)!.description,
) )
: undefined : undefined
} }
> >
<code>{eventName}(event)</code> <code>{eventName}(event)</code>
{getSqlDataSourceEvent(eventName) && {getFormEvent(eventName) &&
isSqlDataSourceNode(activeJavaScriptTarget.node.type) && ( isFormNode(activeJavaScriptTarget.node.type) && (
<span className="ml-2 font-normal text-slate-400"> <span className="ml-2 font-normal text-slate-400">
{getSqlDataSourceEvent(eventName)?.label} {getFormEvent(eventName)?.label}
</span> </span>
)} )}
</span> </span>
@ -5815,11 +6057,12 @@ const VisualComponentDesigner = () => {
['items', 'data', 'columns', 'dataColumns'].includes(property.name) ['items', 'data', 'columns', 'dataColumns'].includes(property.name)
) && ) &&
// Command slots and the key/collection settings are // 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', 'keyFieldName',
'collectionPath', 'collectionPath',
'keySource', 'keySource',
@ -6279,7 +6522,7 @@ const VisualComponentDesigner = () => {
<DesignerScriptBuilderDialog <DesignerScriptBuilderDialog
isOpen isOpen
componentLabel={scriptBuilderNode.ref || scriptBuilderNode.type} componentLabel={scriptBuilderNode.ref || scriptBuilderNode.type}
eventInfo={getSqlDataSourceEvent(scriptBuilderTarget.eventName)} eventInfo={getFormEvent(scriptBuilderTarget.eventName)}
eventName={scriptBuilderTarget.eventName} eventName={scriptBuilderTarget.eventName}
recordFields={scriptBuilderRecordFields} recordFields={scriptBuilderRecordFields}
refNames={[...documentRefs.keys()]} refNames={[...documentRefs.keys()]}