\n {(() => {\n const gridItems_cmp_mt023r5u_rb2jv = (Array.isArray(getByPath(data_source_tq_veb4m, \"\")) ? getByPath(data_source_tq_veb4m, \"\") : Array.isArray(data_source_tq_veb4m) ? data_source_tq_veb4m : null)\n // Nothing loaded yet — an endpoint still in flight, or a required filter\n // holding the request back — is an empty grid, not a binding error.\n if (gridItems_cmp_mt023r5u_rb2jv === null || gridItems_cmp_mt023r5u_rb2jv === undefined) return null\n if (!Array.isArray(gridItems_cmp_mt023r5u_rb2jv)) return
Grid items bağlantısı bir koleksiyon döndürmelidir.
\n {(() => {\n const gridItems_cmp_mt023r5u_rb2jv = (Array.isArray(getByPath(data_source_tq_veb4m, \"\")) ? getByPath(data_source_tq_veb4m, \"\") : Array.isArray(data_source_tq_veb4m) ? data_source_tq_veb4m : null)\n // Nothing loaded yet — an endpoint still in flight, or a required filter\n // holding the request back — is an empty grid, not a binding error.\n if (gridItems_cmp_mt023r5u_rb2jv === null || gridItems_cmp_mt023r5u_rb2jv === undefined) return null\n if (!Array.isArray(gridItems_cmp_mt023r5u_rb2jv)) return
Grid items bağlantısı bir koleksiyon döndürmelidir.
+ {nodes.map((node, index) => (
+
+ ))}
+ {/* Sürükleme sürerken kök seviye açık bir hedef olur; aksi halde bir
konteynerin dışına çıkmanın görünür bir yolu yok. */}
- {drag && interactive && (
-
- {[
- // 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]) => (
-
- ))}
+ {drag && interactive && (
+
+ {[
+ // 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]) => (
+
+ ))}
+
+ )}
+
+
+ )}
+
+
+
)
}
diff --git a/ui/src/components/visualDesigner/catalog.ts b/ui/src/components/visualDesigner/catalog.ts
index 2ff4670c..704ee8e9 100644
--- a/ui/src/components/visualDesigner/catalog.ts
+++ b/ui/src/components/visualDesigner/catalog.ts
@@ -2,9 +2,9 @@ import { CUSTOM_COMPONENTS, HTML_ELEMENTS } from '@/components/codeLayout/data/c
import generatedComponentProps from './generated/componentProps.json'
import {
DESIGNER_DATA_COMPONENT_NAMES,
- SQL_DATA_SOURCE_EVENTS,
- LEGACY_SQL_DATA_SOURCE_TYPE,
- SQL_DATA_SOURCE_TYPE,
+ FORM_EVENTS,
+ FORM_SLOTS,
+ FORM_TYPE,
getDesignerCollectionProperty,
isDesignerOptionComponent,
isDesignerTabularComponent,
@@ -162,7 +162,7 @@ const selectedValue = event?.value ?? null
const selectedLabel = event?.label ?? ''
console.log({ selectedValue, selectedLabel })`,
},
- [SQL_DATA_SOURCE_TYPE]: {
+ [FORM_TYPE]: {
onLoad: `// Select cevabı forma yerleştikten sonra çalışır.
// event: { rows, count, record, index }
if (!event.count) notify('Kayıt bulunamadı', 'warning')`,
@@ -217,7 +217,7 @@ export interface DesignerScriptRecipe {
export const DESIGNER_SCRIPT_RECIPE_GROUPS = [
'App.CatalogGroup.ComponentAccess',
'App.CatalogGroup.ApiCalls',
- 'App.CatalogGroup.SqlDataSource',
+ 'App.CatalogGroup.Form',
'App.CatalogGroup.EventAndPage',
] as const
@@ -354,33 +354,33 @@ refs.{{sql}}.setFields({ UnitPrice: fiyat, Stock: stok })`,
console.log(secilen, refs.{{ref}}.getLabel())`,
},
{
- group: 'App.CatalogGroup.SqlDataSource',
- title: 'App.CatalogSqlDataSource.SqlDataSourceTitle',
- description: 'App.CatalogSqlDataSource.SqlDataSourceDescription',
+ group: 'App.CatalogGroup.Form',
+ title: 'App.CatalogForm.FormTitle',
+ description: 'App.CatalogForm.FormDescription',
code: `await refs.{{sql}}.save()
// await refs.{{sql}}.remove()
// await refs.{{sql}}.reload()
// refs.{{sql}}.newRecord()`,
},
{
- group: 'App.CatalogGroup.SqlDataSource',
- title: 'App.CatalogSqlDataSource.SqlDataSourceTitle2',
- description: 'App.CatalogSqlDataSource.SqlDataSourceDescription2',
+ group: 'App.CatalogGroup.Form',
+ title: 'App.CatalogForm.FormTitle2',
+ description: 'App.CatalogForm.FormDescription2',
code: `const record = refs.{{sql}}.getRecord()
console.log(record)
refs.{{sql}}.setField('name', 'Yeni ad')`,
},
{
- group: 'App.CatalogGroup.SqlDataSource',
- title: 'App.CatalogSqlDataSource.SqlDataSourceTitle3',
- description: 'App.CatalogSqlDataSource.SqlDataSourceDescription3',
+ group: 'App.CatalogGroup.Form',
+ title: 'App.CatalogForm.FormTitle3',
+ description: 'App.CatalogForm.FormDescription3',
code: `const rows = refs.{{sql}}.getRows()
refs.{{sql}}.goToRow(rows.length - 1)`,
},
{
- group: 'App.CatalogGroup.SqlDataSource',
- title: 'App.CatalogSqlDataSource.SqlDataSourceTitle4',
- description: 'App.CatalogSqlDataSource.SqlDataSourceDescription4',
+ group: 'App.CatalogGroup.Form',
+ title: 'App.CatalogForm.FormTitle4',
+ description: 'App.CatalogForm.FormDescription4',
code: `const record = refs.{{sql}}.getRecord()
if (!record.name) {
notify('Ad alanı zorunludur', 'warning')
@@ -389,9 +389,9 @@ if (!record.name) {
await refs.{{sql}}.save()`,
},
{
- group: 'App.CatalogGroup.SqlDataSource',
- title: 'App.CatalogSqlDataSource.SqlDataSourceTitle5',
- description: 'App.CatalogSqlDataSource.SqlDataSourceDescription5',
+ group: 'App.CatalogGroup.Form',
+ title: 'App.CatalogForm.FormTitle5',
+ description: 'App.CatalogForm.FormDescription5',
code: `console.log(refs.{{sql}}.getMode(), refs.{{sql}}.isBusy(), refs.{{sql}}.getError())`,
},
{
@@ -425,11 +425,7 @@ console.log(id)`,
]
export const getDesignerEventSnippet = (componentName: string, eventName: string) =>
- // A page saved before the container was renamed still carries the old type,
- // and its event scripts deserve the same starting snippet.
- DESIGNER_EVENT_SNIPPETS[
- componentName === LEGACY_SQL_DATA_SOURCE_TYPE ? SQL_DATA_SOURCE_TYPE : componentName
- ]?.[eventName] ||
+ DESIGNER_EVENT_SNIPPETS[componentName]?.[eventName] ||
`// ${componentName}.${eventName}(event)\nconsole.log(event)`
const COLLECTION_ITEMS_PROPERTY = {
name: 'items',
@@ -901,18 +897,18 @@ export const PLATFORM_COMPONENTS: DesignerComponentDefinition[] = [
]
/**
- * ASP.NET's SqlDataSource + FormView in one component: it owns the four CRUD
+ * ASP.NET's Form + FormView in one component: it owns the four CRUD
* endpoints and acts as a container, so every component dropped inside it can
* bind to a column of the Select result and write back through Save/Delete.
* The endpoints are configured from the inspector's Data tab, not here.
*/
-export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
- name: SQL_DATA_SOURCE_TYPE,
+export const FORM_DEFINITION: DesignerComponentDefinition = {
+ name: FORM_TYPE,
icon: 'Database',
category: 'data',
kind: 'layout',
toolboxGroup: 'data',
- description: 'App.CatalogSqlDataSource.SqlDataSourceDescription',
+ description: 'App.CatalogForm.FormDescription',
acceptsChildren: true,
properties: [
// Filled in, the container renders inside a Card whose header carries this
@@ -923,7 +919,7 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
type: 'string',
value: '',
category: 'properties',
- description: 'App.CatalogSqlDataSource.SqlDataSourceTitleDescription',
+ description: 'App.CatalogForm.FormTitleDescription',
},
{
name: 'selectEndpoint',
@@ -953,6 +949,16 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
category: 'properties',
description: 'App.CatalogString.StringDescription4',
},
+ // Permission of each command, configured next to its endpoint in the Data
+ // tab. Empty leaves the command open: a bound URL is then enough for the
+ // button to appear, which is how every container built so far behaves.
+ ...FORM_SLOTS.map((slot) => ({
+ name: slot.permissionProperty,
+ type: 'string' as const,
+ value: '',
+ category: 'properties' as const,
+ description: 'App.CatalogForm.FormPermissionDescription',
+ })),
{
name: 'keyFieldName',
type: 'string',
@@ -1011,7 +1017,7 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
type: 'number',
value: 1,
category: 'styling',
- description: 'App.CatalogSqlDataSource.SqlDataSourceColumnCountDescription',
+ description: 'App.CatalogForm.FormColumnCountDescription',
},
{
name: 'gap',
@@ -1023,7 +1029,7 @@ export const SQL_DATA_SOURCE_DEFINITION: DesignerComponentDefinition = {
// Record lifecycle. `category: 'events'` keeps them out of the Properties
// panel and moves them into the JavaScript tab, where they are edited as
// scripts rather than as values.
- ...SQL_DATA_SOURCE_EVENTS.map((event) => ({
+ ...FORM_EVENTS.map((event) => ({
name: event.name,
type: 'function' as const,
value: '',
@@ -1138,7 +1144,7 @@ export const getDesignerCatalog = (customNames: string[] = []): DesignerComponen
return [
...DESIGNER_EXTRAS,
- SQL_DATA_SOURCE_DEFINITION,
+ FORM_DEFINITION,
...PLATFORM_COMPONENTS,
...html,
...ui,
diff --git a/ui/src/components/visualDesigner/codeGenerator.ts b/ui/src/components/visualDesigner/codeGenerator.ts
index b2f752a3..344a449e 100644
--- a/ui/src/components/visualDesigner/codeGenerator.ts
+++ b/ui/src/components/visualDesigner/codeGenerator.ts
@@ -5,16 +5,19 @@ import {
getDesignerTabSlotValue,
getDesignerDataSourceFilters,
getDesignerNodeFilters,
- getSqlDataSourceColumnCount,
+ getDesignerNodePermission,
+ resolveFormPermission,
+ DESIGNER_PERMISSION_PROP,
+ getFormColumnCount,
getDesignerColumnCaptions,
getUsableDesignerColumnLookups,
- getSqlDataSourceEndpointId,
- getSqlDataSourceKeyField,
- getSqlDataSourceKeyParam,
- getSqlDataSourceKeySource,
- getSqlDataSourceScriptedEvents,
+ getFormEndpointId,
+ getFormKeyField,
+ getFormKeyParam,
+ getFormKeySource,
+ getFormScriptedEvents,
getSqlFormValueProperty,
- hasSqlDataSourceUrlParams,
+ hasFormUrlParams,
isDesignerDateComponent,
isDesignerDateOnlyComponent,
isDesignerDateProperty,
@@ -22,13 +25,13 @@ import {
DESIGNER_ICON_PROPERTIES,
DESIGNER_SELECT_COMPONENT_PROPERTY,
flexRowStyle,
- isSqlDataSourceNode,
+ isFormNode,
isValuelessDesignerFilter,
DESIGNER_FILTER_DX_OPERATORS,
toDesignerFilterParamName,
normalizeDesignerKeyList,
DESIGNER_DROPDOWN_PLACEHOLDER,
- SQL_DATA_SOURCE_SLOTS,
+ FORM_SLOTS,
SQL_DEFAULT_VALUE_PROP,
type DesignerDataSource,
type DesignerDataSourceFilter,
@@ -37,7 +40,7 @@ import {
} from './types'
/**
- * Record scope opened by a SqlDataSource. The record state is named
+ * Record scope opened by a Form. The record state is named
* `data_`, which is exactly what `bindingExpression` emits for a binding
* whose `sourceId` is the container's node id — so children bind to columns
* through the regular binding machinery and write back through `setterName`.
@@ -60,6 +63,40 @@ const safeIdentifier = (value: string) => {
*/
let refRuntimeEnabled = false
+/**
+ * Wizard permission of the component being generated. It is the base of the
+ * `Otomatik` mode, so it is set for the whole pass rather than threaded through
+ * every emitter. Empty for a component no menu opens.
+ */
+let autoPermissionCode = ''
+
+/**
+ * Guard expression of a permission name, or `''` when nothing guards it. An
+ * unguarded command keeps its previous behaviour: the bound URL alone decides
+ * whether its button is there.
+ */
+const permissionGuardExpression = (permission: string) =>
+ permission ? `designerHasPermission(${JSON.stringify(permission)})` : ''
+
+/**
+ * Guard of a node's own visibility permission. A component whose data the user
+ * is not allowed to read is not shown disabled — it is not shown.
+ */
+const nodePermissionGuard = (node: DesignerNode) =>
+ permissionGuardExpression(getDesignerNodePermission(node))
+
+/** True when any command of any Form or any node carries a permission. */
+const documentUsesPermissions = (nodes: DesignerNode[]): boolean =>
+ nodes.some(
+ (node) =>
+ Boolean(getDesignerNodePermission(node)) ||
+ (isFormNode(node.type) &&
+ FORM_SLOTS.some((slot) =>
+ resolveFormPermission(node, slot, autoPermissionCode),
+ )) ||
+ documentUsesPermissions(node.children),
+ )
+
/** Prop that turns a component off; react-select spells it differently. */
const getDisabledProperty = (type: string) => (type === 'Select' ? 'isDisabled' : 'disabled')
@@ -165,7 +202,7 @@ const getRuntimeStateSpec = (node: DesignerNode): RuntimeStateSpec | null => {
}
}
-/** Prop of a node that the enclosing SqlDataSource writes the record back from. */
+/** Prop of a node that the enclosing Form writes the record back from. */
const getFormScopeField = (node: DesignerNode, formScope?: FormScope) => {
if (!formScope) return null
const propertyName = getSqlFormValueProperty(node, formScope.sourceId)
@@ -220,7 +257,7 @@ const propsToCode = (
const runtimeState = getRuntimeStateSpec(node)
const formField = getFormScopeField(node, formScope)
const hasBoundInputValue = node.type === 'Input' && Boolean(node.bindings?.value?.sourceId)
- // Inside a SqlDataSource the bound value is editable: the change is written to
+ // Inside a Form the bound value is editable: the change is written to
// the record, so the input must not be forced read-only.
const hasInputChangeHandler = Boolean(node.events?.onChange?.trim()) || Boolean(formField)
const props = Object.entries(node.props)
@@ -228,12 +265,14 @@ const propsToCode = (
([key, value]) =>
key !== 'children' &&
key !== runtimeState?.propertyName &&
- // Seeds a new SqlDataSource record; it is not a prop of the component.
+ // Seeds a new Form record; it is not a prop of the component.
key !== SQL_DEFAULT_VALUE_PROP &&
+ // Decides whether the node is rendered; it is not a prop of the component.
+ key !== DESIGNER_PERMISSION_PROP &&
!omitted.has(key) &&
!(node.type === 'Checkbox' && key === 'defaultChecked' && 'checked' in node.props) &&
!(hasBoundInputValue && !hasInputChangeHandler && key === 'readOnly') &&
- // A field the SqlDataSource writes back must stay editable, whatever the
+ // A field the Form writes back must stay editable, whatever the
// node happens to carry from the toolbox defaults.
!(formField && (key === 'readOnly' || key === 'disabled')) &&
!node.bindings?.[key]?.sourceId &&
@@ -352,14 +391,18 @@ const sqlIdentifiers = (node: DesignerNode) => {
recordRef: `sqlRecordRef_${identifier}`,
modeRef: `sqlModeRef_${identifier}`,
slot: (property: string) => `sql${property.replace(/Endpoint$/, '')}_${identifier}`,
+ // Permission flags of the four commands. Always declared, so the markup can
+ // reference them without knowing whether an endpoint carries a permission.
+ can: (property: string) =>
+ `sqlCan${property.replace(/Endpoint$/, '').replace(/^./, (letter) => letter.toUpperCase())}_${identifier}`,
}
}
-/** Handler name of a scripted SqlDataSource event, or `''` when it has no script. */
+/** Handler name of a scripted Form event, or `''` when it has no script. */
const sqlEventHandler = (node: DesignerNode, eventName: string) =>
node.events?.[eventName]?.trim() ? `handle_${safeIdentifier(node.id)}_${eventName}` : ''
-const hasSqlEvents = (node: DesignerNode) => getSqlDataSourceScriptedEvents(node).length > 0
+const hasSqlEvents = (node: DesignerNode) => getFormScriptedEvents(node).length > 0
/**
* `await`ed call of an event script, emitted only when the event carries one.
@@ -390,7 +433,7 @@ const sqlErrorEventCall = (node: DesignerNode, action: string) => {
/**
* Runtime expression a filter reads its value from. A `record` filter addresses
- * another SqlDataSource by its ref, which is what turns a master container and a
+ * another Form by its ref, which is what turns a master container and a
* detail Grid into a filtered pair without a line of script.
*/
const filterValueExpression = (
@@ -458,21 +501,21 @@ const sqlSlotLiteral = (
dataSources: DesignerDataSource[],
) => {
const source = dataSources.find(
- (candidate) => candidate.id === getSqlDataSourceEndpointId(node, property),
+ (candidate) => candidate.id === getFormEndpointId(node, property),
)
if (!source?.url.trim()) return 'null'
return `{ url: ${JSON.stringify(source.url.trim())}, method: ${JSON.stringify(method)}, responsePath: ${JSON.stringify(source.responsePath || '')} }`
}
-const sqlDataSourceHooks = (
+const formHooks = (
node: DesignerNode,
dataSources: DesignerDataSource[],
sqlRecordByRef: Map,
) => {
const names = sqlIdentifiers(node)
- const keyField = getSqlDataSourceKeyField(node)
+ const keyField = getFormKeyField(node)
const selectSource = dataSources.find(
- (candidate) => candidate.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'),
+ (candidate) => candidate.id === getFormEndpointId(node, 'selectEndpoint'),
)
const selectFilters = filterEntriesExpression(selectSource, sqlRecordByRef)
// The Select endpoint already owns a fetch hook, so the container reuses that
@@ -484,16 +527,27 @@ const sqlDataSourceHooks = (
selectSource?.method === 'GET' && selectSource.url.trim().startsWith('/api/')
? `setData_${safeIdentifier(selectSource.id)}`
: ''
- const keySource = getSqlDataSourceKeySource(node)
+ const keySource = getFormKeySource(node)
const rowsExpression = `toSqlRows(${selectData}, ${JSON.stringify(String(node.props.collectionPath ?? ''))})`
- const slots = SQL_DATA_SOURCE_SLOTS.map(
+ const slots = FORM_SLOTS.map(
(slot) =>
` const ${names.slot(slot.property)} = ${sqlSlotLiteral(node, slot.property, slot.method, dataSources)}`,
).join('\n')
+ // One flag per command. An endpoint without a permission is always allowed, so
+ // a container configured before permissions existed behaves as it always did.
+ const permissions = FORM_SLOTS.map(
+ (slot) =>
+ ` const ${names.can(slot.property)} = ${
+ permissionGuardExpression(
+ resolveFormPermission(node, slot, autoPermissionCode),
+ ) || 'true'
+ }`,
+ ).join('\n')
return `${slots}
+${permissions}
const ${names.key} = ${JSON.stringify(keyField)}
- const ${names.keyParam} = ${JSON.stringify(getSqlDataSourceKeyParam(node))}
+ const ${names.keyParam} = ${JSON.stringify(getFormKeyParam(node))}
const [${names.record}, ${names.setRecord}] = React.useState({})
const [${names.original}, ${names.setOriginal}] = React.useState({})
const [${names.mode}, ${names.setMode}] = React.useState("edit")
@@ -601,11 +655,14 @@ ${
const ${names.reload} = React.useCallback(async () => {${
setSelectData
? `
+ // Without the Read permission the container is not rendered at all; the
+ // request is dropped here so it is not fired for a form nobody can see.
+ if (!${names.can('selectEndpoint')}) return
if (!${names.selectUrl}) return
// Calling a URL that still holds a placeholder is a guaranteed 400, so the
// missing key is reported in the component instead.
if (hasSqlUrlParams(${names.selectUrl})) {
- throw new Error("Select endpointi " + ${names.keyParam} + " parametresini bekliyor; sayfa adresinde bulunamadı.")
+ throw new Error(${translateExpression('::App.Form.MissingKeyParam', `{ 0: ${names.keyParam} }`)})
}
const response = await apiService.fetchData({ url: ${names.selectUrl}, method: "GET" })
${setSelectData}(getByPath(response.data, ${names.slot('selectEndpoint')}.responsePath))`
@@ -626,6 +683,7 @@ ${
}
}, [${names.reload}])
const ${names.create} = React.useCallback(${sqlEventHandler(node, 'onNewRecord') ? 'async ' : ''}() => {
+ if (!${names.can('insertEndpoint')}) return
${names.setError}("")
// Defaults are resolved on every New, so an @today column carries the day the
// record was opened rather than the day the component was generated.
@@ -646,14 +704,17 @@ ${
const ${names.save} = async () => {
const isNew = ${names.mode} === "new"
const target = isNew ? ${names.slot('insertEndpoint')} : ${names.slot('updateEndpoint')}
+ // The button is already hidden without the permission; a script calling save
+ // through a ref must not be able to walk around it either.
+ if (!(isNew ? ${names.can('insertEndpoint')} : ${names.can('updateEndpoint')})) return
if (!target) {
- ${names.setError}(isNew ? "Insert için POST endpointi tanımlı değil." : "Update için PUT endpointi tanımlı değil.")
+ ${names.setError}(isNew ? ${translateExpression('::App.Form.InsertEndpointMissing')} : ${translateExpression('::App.Form.UpdateEndpointMissing')})
return
}
// Update carries only the edited columns plus the key; Insert sends the record.
const payload = isNew ? ${names.record} : toSqlChanges(${names.original}, ${names.record}, ${names.key})
if (!isNew && Object.keys(payload).filter((column) => column.toLowerCase() !== String(${names.key}).toLowerCase()).length === 0) {
- ${names.setError}("Kaydedilecek bir değişiklik yok.")
+ ${names.setError}(${translateExpression('::App.Form.NoChanges')})
return
}${
sqlEventHandler(node, 'onBeforeSave')
@@ -691,7 +752,7 @@ ${
}
}
const ${names.remove} = async () => {
- if (!${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return${
+ if (!${names.can('deleteEndpoint')} || !${names.slot('deleteEndpoint')} || !${names.hasKey} || ${names.mode} === "new") return${
sqlEventHandler(node, 'onBeforeDelete')
? `
// Asked before the confirm dialog, so a script can block the delete without
@@ -700,7 +761,7 @@ ${
: ''
}
// Deleting cannot be undone from the form, so it always asks first.
- const confirmMessage = ${names.key} + " = " + readSqlField(${names.record}, ${names.key}) + " kaydı silinecek.\\n\\nOnaylıyor musunuz?"
+ const confirmMessage = ${translateExpression('::App.Form.DeleteConfirm', `{ 0: ${names.key} + " = " + readSqlField(${names.record}, ${names.key}) }`)}
if (typeof window !== "undefined" && !window.confirm(confirmMessage)) return
${names.setBusy}(true)
${names.setError}("")
@@ -733,13 +794,22 @@ ${
}`
}
+/**
+ * `translate()` call for a fixed localization key. The container's own chrome —
+ * its buttons, its mode badge, its error messages — is platform text, so it
+ * follows the active language exactly like the rest of the application instead
+ * of being frozen in the language the component was designed in.
+ */
+const translateExpression = (key: string, params?: string) =>
+ `translate(${JSON.stringify(key)}${params ? `, ${params}` : ''})`
+
const SQL_TOOLBAR_BUTTON_CLASS =
'rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-40'
-const sqlDataSourceToCode = (node: DesignerNode, level: number, itemVariable?: string) => {
+const formToCode = (node: DesignerNode, level: number, itemVariable?: string) => {
const names = sqlIdentifiers(node)
const formScope: FormScope = { sourceId: node.id, setterName: names.setField }
- const columnCount = getSqlDataSourceColumnCount(node)
+ const columnCount = getFormColumnCount(node)
const gap = Number(node.props.gap) || 0
// Children sit one level deeper once they are wrapped in the column grid.
const children = node.children
@@ -750,19 +820,24 @@ const sqlDataSourceToCode = (node: DesignerNode, level: number, itemVariable?: s
// The toolbar and the error line stay in the outer column, so only the fields
// are laid out side by side. A single column emits no wrapper at all, which
// keeps the markup of every page saved before the setting existed unchanged.
- const gridStyle = `{ display: "grid", gridTemplateColumns: "repeat(${columnCount}, minmax(0, 1fr))", gap: ${gap}, alignItems: "start" }`
+ // Columns come from utility classes rather than from an inline
+ // `gridTemplateColumns`, because an inline value cannot carry a breakpoint: a
+ // two column form has to fall back to a single column on a phone, where two
+ // fields side by side leave neither of them readable.
+ const gridClassName = `grid grid-cols-1 md:grid-cols-${columnCount}`
+ const gridStyle = `{ gap: ${gap}, alignItems: "start" }`
const content =
columnCount > 1
- ? `${indent(`
`, level + 2)}\n${children}\n${indent('
', level + 2)}`
+ ? `${indent(`
`, level + 2)}\n${children}\n${indent('
', level + 2)}`
: children
const plainButtonClass = `${SQL_TOOLBAR_BUTTON_CLASS} border border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:text-slate-300`
// Navigation appears on its own whenever there is more than one record to walk.
const navigation = `
${indent(`{${names.rows}.length > 1 ? (`, level + 3)}
${indent('<>', level + 4)}
-${indent(``, level + 5)}
+${indent(``, level + 5)}
${indent(`{\`\${${names.index} + 1} / \${${names.rows}.length}\`}`, level + 5)}
-${indent(``, level + 5)}
+${indent(``, level + 5)}
${indent('', level + 5)}
${indent('>', level + 4)}
${indent(') : null}', level + 3)}`
@@ -771,11 +846,11 @@ ${indent(') : null}', level + 3)}`
? ''
: `
${indent('
`, level + 1)}
+ // Read permission decides whether the container exists on the page at all: a
+ // form whose records the user may not see is not rendered empty, it is not
+ // rendered.
+ return `${indent(`{${names.can('selectEndpoint')} ? (`, level)}
+${indent(cardStart, level + 1)}
+${indent(`
+ )
+ }
+
+ useEffect(() => {
+ loadPermissionOptions()
+ }, [loadPermissionOptions])
+
+ useEffect(() => {
+ const route = normalizeRoutePath(component?.routePath)
+ if (!route) {
+ setWizardPermissionCode(null)
+ return
+ }
+ let cancelled = false
+ getMenus()
+ .then((response) => {
+ if (cancelled) return
+ const menu = (response.data?.items || []).find(
+ (item) => normalizeRoutePath(item.url) === route,
+ )
+ setWizardPermissionCode(menu?.requiredPermissionName?.trim() || '')
+ })
+ .catch((error) => {
+ // Leave the stored code in place: an unreadable menu list is not proof
+ // that the component has no permission.
+ console.error('Menü yetkisi okunamadı:', error)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [component?.routePath])
+
const openCatalogSourceEditor = (item: DataSourceCatalogItem) => {
if (item.originType !== 'component') return
setCatalogSourceEditor({ item, draft: { ...item.source } })
@@ -2655,7 +2849,7 @@ const VisualComponentDesigner = () => {
)
const propsToSave =
document.sourceMode === 'visual'
- ? JSON.stringify({ ...existingProps, visualDesigner: document })
+ ? JSON.stringify({ ...existingProps, visualDesigner: documentToGenerate })
: component.props
const update = {
name: component.name,
@@ -2693,7 +2887,16 @@ const VisualComponentDesigner = () => {
setIsSaving(false)
}
},
- [collectDependencies, component, document, generatedCode, id, manualCode, updateComponent],
+ [
+ collectDependencies,
+ component,
+ document,
+ documentToGenerate,
+ generatedCode,
+ id,
+ manualCode,
+ updateComponent,
+ ],
)
useEffect(() => {
@@ -2786,24 +2989,24 @@ const VisualComponentDesigner = () => {
*/
useEffect(() => {
if (!id || loadedId !== id) return
- // A GetById Select is sampled through the owning SqlDataSource's preview key,
+ // A GetById Select is sampled through the owning Form's preview key,
// since there is no page URL to read the real key from while designing.
const previewUrls = new Map()
walkDesignerNodes(document.nodes, (node) => {
- if (!isSqlDataSourceNode(node.type)) return
- const previewKey = getSqlDataSourcePreviewKey(node)
+ if (!isFormNode(node.type)) return
+ const previewKey = getFormPreviewKey(node)
if (!previewKey) return
const source = document.dataSources.find(
- (item) => item.id === getSqlDataSourceEndpointId(node, 'selectEndpoint'),
+ (item) => item.id === getFormEndpointId(node, 'selectEndpoint'),
)
if (!source) return
- const parameterName = getSqlDataSourceKeyParam(node)
- const bound = bindSqlDataSourceUrl(source.url, { [parameterName]: previewKey }, parameterName)
+ const parameterName = getFormKeyParam(node)
+ const bound = bindFormUrl(source.url, { [parameterName]: previewKey }, parameterName)
previewUrls.set(
source.id,
bound.keyBound
? bound.url
- : appendSqlDataSourceQueryParam(bound.url, parameterName, previewKey),
+ : appendFormQueryParam(bound.url, parameterName, previewKey),
)
})
@@ -2812,7 +3015,7 @@ const VisualComponentDesigner = () => {
if (dataSourceSamples[source.id] !== undefined || dataTestResults[source.id]) return
const requestUrl = previewUrls.get(source.id) ?? source.url
// An unresolved `{id}` would just 404; it waits for a preview key instead.
- if (hasSqlDataSourceUrlParams(requestUrl) || !isRunnableDataSourceUrl(requestUrl)) return
+ if (hasFormUrlParams(requestUrl) || !isRunnableDataSourceUrl(requestUrl)) return
void testDataSource(source, false, requestUrl)
})
}, [
@@ -2902,7 +3105,7 @@ const VisualComponentDesigner = () => {
setSelectDataModes((current) => ({ ...current, [selectedId]: mode }))
if (isTabularDataComponent(selectedNode?.type)) {
// Only a source the picker lists can be bound; a POST/PUT entry or the
- // SqlDataSource record would silently produce an unusable collection.
+ // Form record would silently produce an unusable collection.
const source = activeDataSource || inspectorDataSources[0]
commitDocument((current) => ({
...current,
@@ -3586,7 +3789,7 @@ const VisualComponentDesigner = () => {
{/*
Label ve Value ekranı sürer; buradaki sütunlar görünmez ama
seçenek nesnesiyle birlikte taşınır, böylece bir script onları
- başka bir komponente ya da SqlDataSource kaydına yazabilir.
+ başka bir komponente ya da Form kaydına yazabilir.
*/}
{selectCollectionSample && selectColumnFields.length > 0 && (
@@ -3921,7 +4124,7 @@ const VisualComponentDesigner = () => {
* GetList endpoint parses, so any column can be filtered, not just the key.
*
* A value comes from a static literal, from the page URL (query string or route
- * segment) or from the record of another SqlDataSource, which is how a detail
+ * segment) or from the record of another Form, which is how a detail
* list follows the master container without a line of script.
*/
/**
@@ -4053,7 +4256,7 @@ const VisualComponentDesigner = () => {
})
const [masterRef = '', ...columnParts] = String(filter.value || '').split('.')
const masterColumn = columnParts.join('.')
- const masterRecord = sqlDataSourceRecords[
+ const masterRecord = formRecords[
sqlContainerRefs.find((item) => item.ref === masterRef)?.id || ''
]
const masterColumns =
@@ -4251,12 +4454,22 @@ const VisualComponentDesigner = () => {
}
/**
- * SqlDataSource command panel: the ASP.NET style Select/Insert/Update/Delete
+ * Form command panel: the ASP.NET style Select/Insert/Update/Delete
* slots. Each slot only accepts a data source declared with the matching HTTP
* method, which is what enables the Save/Delete buttons at runtime.
*/
- const renderSqlDataSourceConfiguration = () => {
- if (!selectedNode || !selectedIsSqlDataSource) return null
+ /** Key kaynağı seçenekleri; etiketleri dil anahtarı olarak saklanır. */
+ const keySourceOptions = useMemo(
+ () =>
+ FORM_KEY_SOURCES.map((item) => ({
+ value: item.value,
+ label: translate('::' + item.label),
+ })),
+ [translate],
+ )
+
+ const renderFormConfiguration = () => {
+ if (!selectedNode || !selectedIsForm) return null
const selectSourceId = String(selectedNode.props.selectEndpoint || '')
const selectSample = selectSourceId ? dataSourceSamples[selectSourceId] : undefined
const collectionPaths = selectSample
@@ -4267,12 +4480,12 @@ const VisualComponentDesigner = () => {
.map((field) => field.path),
]
: ['']
- const record = sqlDataSourceRecords[selectedNode.id]
+ const record = formRecords[selectedNode.id]
const recordFields = record === undefined ? [] : discoverDataFields(record)
const selectSource = document.dataSources.find((source) => source.id === selectSourceId)
// A GetById URL cannot be sampled at design time without a stand-in key.
const selectNeedsPreviewKey = Boolean(
- selectSource && hasSqlDataSourceUrlParams(selectSource.url),
+ selectSource && hasFormUrlParams(selectSource.url),
)
return (
@@ -4280,14 +4493,17 @@ const VisualComponentDesigner = () => {