Grid komponentindeki sütunlarını SelectBox olarak gösterilmesi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-20 09:42:23 +03:00
parent 61234dec3f
commit 4003dc1845
10 changed files with 834 additions and 115 deletions

View file

@ -20418,6 +20418,90 @@
"en": "Select a component from the canvas to see its properties.", "en": "Select a component from the canvas to see its properties.",
"tr": "Propertyleri görmek için canvastan bir bileşen seçin." "tr": "Propertyleri görmek için canvastan bir bileşen seçin."
}, },
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.ColumnDisplay",
"en": "Column display",
"tr": "Sütun görünümü"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.ColumnDisplayHint",
"en": "A column holding a key can be resolved through another endpoint, so the related text is shown in place of the id.",
"tr": "Anahtar taşıyan bir sütun başka bir endpoint üzerinden çözümlenebilir; hücrede id yerine ilgili metin gösterilir."
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.ShowRelatedText",
"en": "show related text",
"tr": "bağlı metni göster"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.SelectCollection",
"en": "Select collection",
"tr": "Koleksiyon seçin"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.LookupValueField",
"en": "Value column",
"tr": "Değer sütunu"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.LookupTextField",
"en": "Text column",
"tr": "Metin sütunu"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.SelectField",
"en": "Select column",
"tr": "Sütun seçin"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.SearchEndpoint",
"en": "Search by name, address, method or origin",
"tr": "Ad, adres, metot veya kaynağa göre ara"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.NoMatchingEndpoint",
"en": "No endpoint matches the search.",
"tr": "Aramayla eşleşen endpoint yok."
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.AttachedOnly",
"en": "Attached only",
"tr": "Yalnız ekli"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.AllMethods",
"en": "All",
"tr": "Tümü"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.EndpointAlreadyAttached",
"en": "This endpoint is already attached to the component.",
"tr": "Bu endpoint zaten componente ekli."
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.ColumnCaptionPlaceholder",
"en": "Header ({column})",
"tr": "Başlık ({column})"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.ColumnCaptionHint",
"en": "Left empty the column keeps its own name. A value starting with :: is resolved as a localization key.",
"tr": "Boş bırakılırsa sütun kendi adını korur. :: ile başlayan değer dil anahtarı olarak çözümlenir."
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.DeveloperKitComponentDesigner.SelectEndpoint", "key": "App.DeveloperKitComponentDesigner.SelectEndpoint",
@ -22986,12 +23070,6 @@
"en": "On form open", "en": "On form open",
"tr": "Form açılırken" "tr": "Form açılırken"
}, },
{
"resourceName": "Platform",
"key": "App.ScriptBuilderOpenUrl.OpenUrlLabel",
"en": "Open URL",
"tr": "URL aç"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.ScriptBuilderOpenUrl.OpenUrlSummary", "key": "App.ScriptBuilderOpenUrl.OpenUrlSummary",

View file

@ -0,0 +1,36 @@
{
"EntityName": "AbpUsers",
"GeneratedAt": "2026-08-20T06:13:28.1235691Z",
"Endpoints": [
{
"Method": "POST",
"Path": "/api/app/crudendpoint/AbpUsers",
"OperationType": "Create",
"IsActive": true
},
{
"Method": "DELETE",
"Path": "/api/app/crudendpoint/AbpUsers/{id}",
"OperationType": "Delete",
"IsActive": true
},
{
"Method": "GET",
"Path": "/api/app/crudendpoint/AbpUsers/{id}",
"OperationType": "GetById",
"IsActive": true
},
{
"Method": "GET",
"Path": "/api/app/crudendpoint/AbpUsers",
"OperationType": "GetList",
"IsActive": true
},
{
"Method": "PUT",
"Path": "/api/app/crudendpoint/AbpUsers/{id}",
"OperationType": "Update",
"IsActive": true
}
]
}

File diff suppressed because one or more lines are too long

View file

@ -28,6 +28,8 @@ interface PlatformViewHostProps {
view: PlatformViewName view: PlatformViewName
listFormCode: string listFormCode: string
height?: string height?: string
/** Utility classes of the wrapper, so an embedded view can carry page spacing. */
className?: string
/** /**
* Filter of the hosted view, carried the same way a standalone list carries it: * Filter of the hosted view, carried the same way a standalone list carries it:
* a `filter` entry holding a DevExtreme expression. The designer builds it from * a `filter` entry holding a DevExtreme expression. The designer builds it from
@ -41,6 +43,7 @@ const PlatformViewHost = ({
view, view,
listFormCode, listFormCode,
height = '520px', height = '520px',
className = '',
searchParams, searchParams,
}: PlatformViewHostProps) => { }: PlatformViewHostProps) => {
const [gridDto, setGridDto] = useState<GridDto>() const [gridDto, setGridDto] = useState<GridDto>()
@ -72,12 +75,16 @@ const PlatformViewHost = ({
if (!listFormCode.trim()) { if (!listFormCode.trim()) {
return ( return (
<div className="rounded-lg border border-dashed border-amber-300 bg-amber-50 p-5 text-sm text-amber-800"> <div
className={`rounded-lg border border-dashed border-amber-300 bg-amber-50 p-5 text-sm text-amber-800 ${className}`}
>
{view} için listFormCode propertysini ayarlayın. {view} için listFormCode propertysini ayarlayın.
</div> </div>
) )
} }
if (error) return <div className="rounded-lg bg-red-50 p-4 text-sm text-red-700">{error}</div> if (error) {
return <div className={`rounded-lg bg-red-50 p-4 text-sm text-red-700 ${className}`}>{error}</div>
}
if (!gridDto) return <Loading loading /> if (!gridDto) return <Loading loading />
const sharedProps = { listFormCode, isSubForm: true, gridDto, searchParams } const sharedProps = { listFormCode, isSubForm: true, gridDto, searchParams }
@ -119,7 +126,7 @@ const PlatformViewHost = ({
})() })()
return ( return (
<div className="overflow-auto" style={{ minHeight: height }}> <div className={`overflow-auto ${className}`} style={{ minHeight: height }}>
<Suspense fallback={<Loading loading />}>{content}</Suspense> <Suspense fallback={<Loading loading />}>{content}</Suspense>
</div> </div>
) )

View file

@ -19,6 +19,10 @@ import {
getDesignerValueByPath, getDesignerValueByPath,
getSqlDataSourceEndpointId, getSqlDataSourceEndpointId,
getSqlDataSourceColumnCount, getSqlDataSourceColumnCount,
getDesignerColumnCaptions,
getUsableDesignerColumnLookups,
buildDesignerLookupIndex,
resolveDesignerLookupText,
getSqlDataSourceKeyField, getSqlDataSourceKeyField,
getSqlFormValueProperty, getSqlFormValueProperty,
isDesignerDateComponent, isDesignerDateComponent,
@ -672,7 +676,7 @@ const PlatformPlaceholder = ({ node }: { node: DesignerNode }) => {
return ( return (
<div <div
className="flex min-h-40 items-center justify-center rounded-lg border border-dashed border-sky-300 bg-gradient-to-br from-sky-50 to-indigo-50 p-6 text-center dark:border-sky-800 dark:from-slate-900 dark:to-sky-950" className={`flex min-h-40 items-center justify-center rounded-lg border border-dashed border-sky-300 bg-gradient-to-br from-sky-50 to-indigo-50 p-6 text-center dark:border-sky-800 dark:from-slate-900 dark:to-sky-950 ${String(node.props.className || '')}`}
style={{ minHeight: String(node.props.height || '320px') }} style={{ minHeight: String(node.props.height || '320px') }}
> >
<div> <div>
@ -740,16 +744,22 @@ const getGridCellText = (value: unknown) =>
const GridDataTablePreview = ({ const GridDataTablePreview = ({
borderlessRow = false, borderlessRow = false,
captions,
compact = false, compact = false,
hoverable = true, hoverable = true,
items, items,
lookupIndexes,
overflow = true, overflow = true,
selectedColumns, selectedColumns,
}: { }: {
borderlessRow?: boolean borderlessRow?: boolean
/** Header override per column; a `::` value is a localization key. */
captions?: Record<string, string>
compact?: boolean compact?: boolean
hoverable?: boolean hoverable?: boolean
items: unknown[] items: unknown[]
/** Value → text map per column, for the columns configured as a lookup. */
lookupIndexes?: Record<string, Map<string, unknown>>
overflow?: boolean overflow?: boolean
selectedColumns?: string[] selectedColumns?: string[]
}) => { }) => {
@ -780,7 +790,11 @@ const GridDataTablePreview = ({
key={column} key={column}
className={`whitespace-nowrap border-b border-slate-200 dark:border-slate-700 ${compact ? 'px-2 py-1' : 'px-3 py-2'}`} className={`whitespace-nowrap border-b border-slate-200 dark:border-slate-700 ${compact ? 'px-2 py-1' : 'px-3 py-2'}`}
> >
{column === 'value' ? 'Value' : column} {captions?.[column]
? String(resolveStaticLanguageKeys(captions[column], translate))
: column === 'value'
? 'Value'
: column}
</th> </th>
))} ))}
</tr> </tr>
@ -793,7 +807,14 @@ const GridDataTablePreview = ({
> >
{columns.map((column) => { {columns.map((column) => {
const value = column === 'value' ? item : getDesignerValueByPath(item, column) const value = column === 'value' ? item : getDesignerValueByPath(item, column)
const text = getGridCellText(value) // A column with a lookup paints the matched option text; a value
// with no matching row keeps showing the raw id rather than an
// empty cell, so a broken mapping stays visible.
const lookupIndex = lookupIndexes?.[column]
const lookupText = lookupIndex
? resolveDesignerLookupText(lookupIndex, value)
: undefined
const text = getGridCellText(lookupText === undefined ? value : lookupText)
return ( return (
<td <td
key={column} key={column}
@ -1119,6 +1140,7 @@ const PlatformViewPreview = ({ node }: { node: DesignerNode }) => {
if (params === null) return null if (params === null) return null
return ( return (
<PlatformViewHost <PlatformViewHost
className={String(node.props.className || '')}
height={String(node.props.height || '420px')} height={String(node.props.height || '420px')}
listFormCode={String(node.props.listFormCode || '')} listFormCode={String(node.props.listFormCode || '')}
searchParams={params} searchParams={params}
@ -1524,6 +1546,19 @@ const NodeView = ({
const hasChildrenBinding = Boolean(node.bindings?.children?.sourceId) const hasChildrenBinding = Boolean(node.bindings?.children?.sourceId)
const hasVisibleChildren = node.children.length > 0 || hasStaticChildren || hasChildrenBinding const hasVisibleChildren = node.children.length > 0 || hasStaticChildren || hasChildrenBinding
const itemsBinding = node.type === 'Grid' ? node.bindings?.items : undefined const itemsBinding = node.type === 'Grid' ? node.bindings?.items : undefined
// Option rows of every column configured as a lookup. They come from the same
// sampled data the rest of the canvas paints from, so a mapping can be checked
// while designing instead of only after the component is generated.
const gridLookupIndexes = React.useMemo(
() =>
Object.fromEntries(
Object.entries(getUsableDesignerColumnLookups(node)).map(([column, lookup]) => [
column,
buildDesignerLookupIndex(dataValues[lookup.sourceId], lookup),
]),
),
[dataValues, node],
)
const boundItems = itemsBinding?.sourceId const boundItems = itemsBinding?.sourceId
? getBindingValue(itemsBinding, dataValues, currentItem) ? getBindingValue(itemsBinding, dataValues, currentItem)
: node.props.items : node.props.items
@ -1787,8 +1822,10 @@ const NodeView = ({
key={`grid_data_${node.id}`} key={`grid_data_${node.id}`}
borderlessRow={Boolean(node.props.borderlessRow)} borderlessRow={Boolean(node.props.borderlessRow)}
compact={Boolean(node.props.compact)} compact={Boolean(node.props.compact)}
captions={getDesignerColumnCaptions(node)}
hoverable={node.props.hoverable !== false} hoverable={node.props.hoverable !== false}
items={repeatedItems} items={repeatedItems}
lookupIndexes={gridLookupIndexes}
overflow={node.props.overflow !== false} overflow={node.props.overflow !== false}
selectedColumns={ selectedColumns={
Array.isArray(node.props.dataColumns) Array.isArray(node.props.dataColumns)
@ -1842,8 +1879,10 @@ const NodeView = ({
key={`grid_data_${node.id}`} key={`grid_data_${node.id}`}
borderlessRow={Boolean(node.props.borderlessRow)} borderlessRow={Boolean(node.props.borderlessRow)}
compact={Boolean(node.props.compact)} compact={Boolean(node.props.compact)}
captions={getDesignerColumnCaptions(node)}
hoverable={node.props.hoverable !== false} hoverable={node.props.hoverable !== false}
items={repeatedItems} items={repeatedItems}
lookupIndexes={gridLookupIndexes}
overflow={node.props.overflow !== false} overflow={node.props.overflow !== false}
selectedColumns={ selectedColumns={
Array.isArray(node.props.dataColumns) Array.isArray(node.props.dataColumns)

View file

@ -721,6 +721,7 @@ const platformDefinition = (
properties: [ properties: [
{ name: 'listFormCode', type: 'string', value: '', category: 'properties', required: true }, { name: 'listFormCode', type: 'string', value: '', category: 'properties', required: true },
{ name: 'height', type: 'string', value: height, category: 'styling' }, { name: 'height', type: 'string', value: height, category: 'styling' },
{ name: 'className', type: 'string', value: '', category: 'styling' },
], ],
hooks: [], hooks: [],
}) })

View file

@ -6,6 +6,8 @@ import {
getDesignerDataSourceFilters, getDesignerDataSourceFilters,
getDesignerNodeFilters, getDesignerNodeFilters,
getSqlDataSourceColumnCount, getSqlDataSourceColumnCount,
getDesignerColumnCaptions,
getUsableDesignerColumnLookups,
getSqlDataSourceEndpointId, getSqlDataSourceEndpointId,
getSqlDataSourceKeyField, getSqlDataSourceKeyField,
getSqlDataSourceKeyParam, getSqlDataSourceKeyParam,
@ -909,6 +911,28 @@ ${indent('</UiKit.Table>', level)}`
const configuredColumns = Array.isArray(node.props.dataColumns) const configuredColumns = Array.isArray(node.props.dataColumns)
? node.props.dataColumns.filter((column): column is string => typeof column === 'string') ? node.props.dataColumns.filter((column): column is string => typeof column === 'string')
: null : null
// Columns configured to show a related text instead of the raw key. The
// option rows come from a data source of the page, which already has its
// own state and mount fetch, so nothing extra is requested for them.
const lookupsVariable = `gridLookups_${identifier}`
const lookupEntries = Object.entries(getUsableDesignerColumnLookups(node)).filter(
([column]) => !configuredColumns || configuredColumns.includes(column),
)
// Header overrides. `staticValueExpression` turns a `::` value into a
// translate() call, so a caption follows the active language.
const captionsVariable = `gridCaptions_${identifier}`
const captionEntries = Object.entries(getDesignerColumnCaptions(node)).filter(
([column]) => !configuredColumns || configuredColumns.includes(column),
)
const captionsLiteral = `{ ${captionEntries
.map(([column, caption]) => `${JSON.stringify(column)}: ${staticValueExpression(caption)}`)
.join(', ')} }`
const lookupsLiteral = `{ ${lookupEntries
.map(
([column, lookup]) =>
`${JSON.stringify(column)}: { rows: data_${safeIdentifier(lookup.sourceId)}, path: ${JSON.stringify(lookup.path)}, value: ${JSON.stringify(lookup.valueField)}, text: ${JSON.stringify(lookup.textField)} }`,
)
.join(', ')} }`
const columnsExpression = configuredColumns const columnsExpression = configuredColumns
? JSON.stringify(configuredColumns) ? JSON.stringify(configuredColumns)
: `${itemsVariable}[0] && typeof ${itemsVariable}[0] === "object" && !Array.isArray(${itemsVariable}[0]) ? Object.keys(${itemsVariable}[0]) : ["value"]` : `${itemsVariable}[0] && typeof ${itemsVariable}[0] === "object" && !Array.isArray(${itemsVariable}[0]) ? Object.keys(${itemsVariable}[0]) : ["value"]`
@ -932,19 +956,20 @@ ${indent(`// holding the request back — is an empty grid, not a binding error.
${indent(`if (${itemsVariable} === null || ${itemsVariable} === undefined) return null`, level + 2)} ${indent(`if (${itemsVariable} === null || ${itemsVariable} === undefined) return null`, level + 2)}
${indent(`if (!Array.isArray(${itemsVariable})) return <div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">Grid items bağlantısı bir koleksiyon döndürmelidir.</div>`, level + 2)} ${indent(`if (!Array.isArray(${itemsVariable})) return <div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">Grid items bağlantısı bir koleksiyon döndürmelidir.</div>`, level + 2)}
${indent(`const ${columnsVariable} = ${columnsExpression}`, level + 2)} ${indent(`const ${columnsVariable} = ${columnsExpression}`, level + 2)}
${indent(`if (!${columnsVariable}.length) return <div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-4 text-center text-xs text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">Preview için en az bir sütun seçin.</div>`, level + 2)} ${captionEntries.length ? `${indent(`const ${captionsVariable} = ${captionsLiteral}`, level + 2)}\n` : ''}${lookupEntries.length ? `${indent(`const ${lookupsVariable} = ${lookupsLiteral}`, level + 2)}\n` : ''}${indent(`if (!${columnsVariable}.length) return <div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-4 text-center text-xs text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">Preview için en az bir sütun seçin.</div>`, level + 2)}
${indent('return (', level + 2)} ${indent('return (', level + 2)}
${indent(`<div className="min-w-0 ${overflowClass} rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900" style={{ gridColumn: "1 / -1" }}>`, level + 3)} ${indent(`<div className="min-w-0 ${overflowClass} rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900" style={{ gridColumn: "1 / -1" }}>`, level + 3)}
${indent(`<table className="w-full border-collapse text-left ${tableTextClass}">`, level + 4)} ${indent(`<table className="w-full border-collapse text-left ${tableTextClass}">`, level + 4)}
${indent('<thead className="bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200"><tr>', level + 5)} ${indent('<thead className="bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200"><tr>', level + 5)}
${indent(`{${columnsVariable}.map((column) => <th key={column} className="whitespace-nowrap border-b border-slate-200 dark:border-slate-700 ${cellSpacingClass}">{column === "value" ? "Value" : column}</th>)}`, level + 6)} ${indent(`{${columnsVariable}.map((column) => <th key={column} className="whitespace-nowrap border-b border-slate-200 dark:border-slate-700 ${cellSpacingClass}">{${captionEntries.length ? `${captionsVariable}[column] ?? ` : ''}(column === "value" ? "Value" : column)}</th>)}`, level + 6)}
${indent('</tr></thead>', level + 5)} ${indent('</tr></thead>', level + 5)}
${indent('<tbody>', level + 5)} ${indent('<tbody>', level + 5)}
${indent(`{${itemsVariable}.map((${itemVariable}, rowIndex) => (`, level + 6)} ${indent(`{${itemsVariable}.map((${itemVariable}, rowIndex) => (`, level + 6)}
${indent(`<tr key={rowIndex} className=${JSON.stringify(rowClass)}>`, level + 7)} ${indent(`<tr key={rowIndex} className=${JSON.stringify(rowClass)}>`, level + 7)}
${indent(`{${columnsVariable}.map((column) => {`, level + 8)} ${indent(`{${columnsVariable}.map((column) => {`, level + 8)}
${indent(`const ${valueVariable} = column === "value" ? ${itemVariable} : getByPath(${itemVariable}, column)`, level + 9)} ${indent(`const ${valueVariable} = column === "value" ? ${itemVariable} : getByPath(${itemVariable}, column)`, level + 9)}
${indent(`const text = typeof ${valueVariable} === "object" && ${valueVariable} !== null && !(${valueVariable} instanceof Date) ? JSON.stringify(${valueVariable}) : formatLocaleValue(${valueVariable}) || "—"`, level + 9)} ${lookupEntries.length ? `${indent(`const lookedUp = resolveLookupText(${lookupsVariable}[column], ${valueVariable})`, level + 9)}\n` : ''}${indent(`const cellText = ${lookupEntries.length ? `lookedUp === undefined ? ${valueVariable} : lookedUp` : valueVariable}`, level + 9)}
${indent(`const text = typeof cellText === "object" && cellText !== null && !(cellText instanceof Date) ? JSON.stringify(cellText) : formatLocaleValue(cellText) || "—"`, level + 9)}
${indent(`return <td key={column} className="max-w-64 truncate text-slate-800 dark:text-slate-100 ${cellSpacingClass}" title={text}>{text}</td>`, level + 9)} ${indent(`return <td key={column} className="max-w-64 truncate text-slate-800 dark:text-slate-100 ${cellSpacingClass}" title={text}>{text}</td>`, level + 9)}
${indent('})}', level + 8)} ${indent('})}', level + 8)}
${indent('</tr>', level + 7)} ${indent('</tr>', level + 7)}
@ -1004,7 +1029,10 @@ ${indent('})()}', level + 1)}`
} }
const platformParams = `platformParams_${safeIdentifier(node.id)}` const platformParams = `platformParams_${safeIdentifier(node.id)}`
const hasFilters = getDesignerNodeFilters(node).length > 0 const hasFilters = getDesignerNodeFilters(node).length > 0
const host = `<PlatformViewHost view=${JSON.stringify(viewNames[node.type] || node.type)} listFormCode=${JSON.stringify(String(node.props.listFormCode || ''))} height=${JSON.stringify(String(node.props.height || '420px'))}${hasFilters ? ` searchParams={${platformParams}}` : ''} />` // The class list is only emitted when it carries something, so an untouched
// view keeps generating exactly the markup it did before.
const platformClassName = String(node.props.className || '').trim()
const host = `<PlatformViewHost view=${JSON.stringify(viewNames[node.type] || node.type)} listFormCode=${JSON.stringify(String(node.props.listFormCode || ''))} height=${JSON.stringify(String(node.props.height || '420px'))}${platformClassName ? ` className=${JSON.stringify(platformClassName)}` : ''}${hasFilters ? ` searchParams={${platformParams}}` : ''} />`
if (!hasFilters) return indent(host, level) if (!hasFilters) return indent(host, level)
// A required filter without a value renders nothing: showing the unfiltered // A required filter without a value renders nothing: showing the unfiltered
// list instead would read as a filter that is not applied. // list instead would read as a filter that is not applied.
@ -1388,6 +1416,8 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
const runtimeStateHooks: string[] = [] const runtimeStateHooks: string[] = []
let hasSelect = false let hasSelect = false
let hasDataTable = false let hasDataTable = false
/** A Grid column shows a related text instead of its raw key. */
let hasColumnLookup = false
let hasDropdown = false let hasDropdown = false
let hasSelectComponent = false let hasSelectComponent = false
let hasDatePicker = false let hasDatePicker = false
@ -1404,6 +1434,7 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
if (node.type === 'Select') hasSelectComponent = true if (node.type === 'Select') hasSelectComponent = true
if (node.type === 'Dropdown') hasDropdown = true if (node.type === 'Dropdown') hasDropdown = true
if (node.type === 'Grid') hasDataTable = true if (node.type === 'Grid') hasDataTable = true
if (Object.keys(getUsableDesignerColumnLookups(node)).length) hasColumnLookup = true
if (node.type === 'Tabs') hasTabs = true if (node.type === 'Tabs') hasTabs = true
if (isDesignerDateComponent(node.type)) hasDatePicker = true if (isDesignerDateComponent(node.type)) hasDatePicker = true
if (node.kind === 'platform' && getDesignerNodeFilters(node).length) { if (node.kind === 'platform' && getDesignerNodeFilters(node).length) {
@ -1566,6 +1597,22 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
return options.length ? String(options[0]?.value ?? "") : "" return options.length ? String(options[0]?.value ?? "") : ""
}` }`
: '' : ''
// A lookup column matches its raw value against the option rows of another
// endpoint and prints the related text. The key is stringified on both sides
// because an id arrives as a number in one response and as a string in the
// other often enough that a strict compare would silently show nothing.
const lookupHelpers = hasColumnLookup
? ` const resolveLookupText = (lookup, value) => {
if (!lookup || value === null || value === undefined || value === "") return undefined
const rows = getByPath(lookup.rows, lookup.path)
if (!Array.isArray(rows)) return undefined
const key = String(value)
const row = rows.find((item) => item && typeof item === "object" && String(getByPath(item, lookup.value) ?? "") === key)
if (!row) return undefined
const text = getByPath(row, lookup.text)
return text === null || text === undefined || text === "" ? undefined : text
}`
: ''
// Grid cells print raw endpoint columns, so dates and decimals are localised // Grid cells print raw endpoint columns, so dates and decimals are localised
// here — the culture is published on `<html lang>` because a runtime compiled // here — the culture is published on `<html lang>` because a runtime compiled
// component has neither hooks nor imports to reach the store. // component has neither hooks nor imports to reach the store.
@ -1895,5 +1942,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, 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${[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

@ -637,6 +637,99 @@ export const getDesignerValueByPath = (value: unknown, path: string): unknown =>
}, value) }, value)
} }
/**
* Display rule of a single tabular column that carries a foreign key. The raw
* cell value is matched against `valueField` of the rows the lookup endpoint
* returns and the `textField` of the matching row is painted instead, so a
* `UserId` column reads as `system` rather than as a guid.
*/
export interface DesignerColumnLookup {
/** Data source the option rows are read from. */
sourceId: string
/** Collection inside that response; empty means the response is the array. */
path: string
/** Column the cell value is matched on. */
valueField: string
/** Column painted in place of the cell value. */
textField: string
}
export type DesignerColumnLookupMap = Record<string, DesignerColumnLookup>
/**
* Lookups configured on a node, normalized. A half filled row is kept in the
* document it is still being edited in the inspector but never reaches the
* canvas or the generated code, which both go through `isDesignerColumnLookup`.
*/
export const getDesignerColumnLookups = (node?: DesignerNode | null): DesignerColumnLookupMap => {
const raw = node?.props?.columnLookups
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
const lookups: DesignerColumnLookupMap = {}
Object.entries(raw as Record<string, unknown>).forEach(([column, value]) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return
const entry = value as Record<string, unknown>
lookups[column] = {
sourceId: String(entry.sourceId ?? ''),
path: String(entry.path ?? ''),
valueField: String(entry.valueField ?? ''),
textField: String(entry.textField ?? ''),
}
})
return lookups
}
export const isDesignerColumnLookup = (lookup?: DesignerColumnLookup | null) =>
Boolean(lookup?.sourceId && lookup.valueField && lookup.textField)
/**
* Header text per tabular column. An empty entry means the column keeps its own
* name, which is why only the filled ones are kept a caption is an override,
* not a copy of the field path. A `::` prefixed value is a localization key and
* is resolved through the active language, both on the canvas and at runtime.
*/
export const getDesignerColumnCaptions = (node?: DesignerNode | null): Record<string, string> => {
const raw = node?.props?.columnCaptions
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
const captions: Record<string, string> = {}
Object.entries(raw as Record<string, unknown>).forEach(([column, value]) => {
const caption = String(value ?? '').trim()
if (caption) captions[column] = caption
})
return captions
}
/** Only the lookups that can actually resolve a value. */
export const getUsableDesignerColumnLookups = (node?: DesignerNode | null): DesignerColumnLookupMap =>
Object.fromEntries(
Object.entries(getDesignerColumnLookups(node)).filter(([, lookup]) =>
isDesignerColumnLookup(lookup),
),
)
/**
* Value text map of one lookup. Keys are stringified, because an id is just as
* likely to arrive as a number in the row and as a string in the option list.
*/
export const buildDesignerLookupIndex = (rows: unknown, lookup: DesignerColumnLookup) => {
const index = new Map<string, unknown>()
const collection = getDesignerValueByPath(rows, lookup.path)
if (!Array.isArray(collection)) return index
collection.forEach((row) => {
if (!row || typeof row !== 'object') return
const key = getDesignerValueByPath(row, lookup.valueField)
if (key === null || key === undefined || key === '') return
index.set(String(key), getDesignerValueByPath(row, lookup.textField))
})
return index
}
/** Looked up text, or `undefined` when the value has no matching option row. */
export const resolveDesignerLookupText = (index: Map<string, unknown>, value: unknown) => {
if (value === null || value === undefined || value === '') return undefined
const text = index.get(String(value))
return text === null || text === undefined || text === '' ? undefined : text
}
export const resolveDesignerResponse = (value: unknown, responsePath: string): unknown => { export const resolveDesignerResponse = (value: unknown, responsePath: string): unknown => {
if (!responsePath.trim()) return value if (!responsePath.trim()) return value

View file

@ -600,7 +600,7 @@ export const recipes: Recipe[] = [
}, },
{ {
id: 'openUrl', id: 'openUrl',
label: 'App.ScriptBuilderOpenUrl.OpenUrlLabel', label: 'App.Platform.OpenUrl',
group: 'interaction', group: 'interaction',
summary: 'App.ScriptBuilderOpenUrl.OpenUrlSummary', summary: 'App.ScriptBuilderOpenUrl.OpenUrlSummary',
example: "openUrl('/report?id={Id}')", example: "openUrl('/report?id={Id}')",

View file

@ -97,6 +97,9 @@ import {
type DesignerComponentDefinition, type DesignerComponentDefinition,
type DesignerBinding, type DesignerBinding,
buildDesignerPreviewUrl, buildDesignerPreviewUrl,
getDesignerColumnCaptions,
getDesignerColumnLookups,
type DesignerColumnLookup,
createDesignerFilter, createDesignerFilter,
DESIGNER_FILTER_OPERATORS, DESIGNER_FILTER_OPERATORS,
DESIGNER_FILTER_SOURCES, DESIGNER_FILTER_SOURCES,
@ -384,13 +387,26 @@ const removeNodeTree = (nodes: DesignerNode[], id: string): DesignerNode[] =>
})) }))
const removeDataSourceBindings = (nodes: DesignerNode[], sourceId: string): DesignerNode[] => const removeDataSourceBindings = (nodes: DesignerNode[], sourceId: string): DesignerNode[] =>
nodes.map((node) => ({ nodes.map((node) => {
const props = { ...node.props }
// A column lookup names its endpoint the same way a binding does, and the
// generated grid reads that endpoint's state directly — a mapping left
// behind by a detached source would compile into an undefined variable.
const lookups = Object.fromEntries(
Object.entries(getDesignerColumnLookups(node)).filter(
([, lookup]) => lookup.sourceId !== sourceId,
),
)
if (props.columnLookups) props.columnLookups = lookups
return {
...node, ...node,
props,
bindings: Object.fromEntries( bindings: Object.fromEntries(
Object.entries(node.bindings || {}).filter(([, binding]) => binding.sourceId !== sourceId), Object.entries(node.bindings || {}).filter(([, binding]) => binding.sourceId !== sourceId),
), ),
children: removeDataSourceBindings(node.children, sourceId), children: removeDataSourceBindings(node.children, sourceId),
})) }
})
const insertNodeTree = ( const insertNodeTree = (
nodes: DesignerNode[], nodes: DesignerNode[],
@ -1244,6 +1260,14 @@ const VisualComponentDesigner = () => {
const [savedDesignerFingerprint, setSavedDesignerFingerprint] = useState('') const [savedDesignerFingerprint, setSavedDesignerFingerprint] = useState('')
const [dataTestResults, setDataTestResults] = useState<Record<string, DataTestResult>>({}) const [dataTestResults, setDataTestResults] = useState<Record<string, DataTestResult>>({})
const [dataSourceSamples, setDataSourceSamples] = useState<Record<string, unknown>>({}) const [dataSourceSamples, setDataSourceSamples] = useState<Record<string, unknown>>({})
/**
* Last known response shape of every endpoint, kept apart from the samples the
* canvas paints from. A required filter without a sampling value cancels the
* request correctly, so no unfiltered rows are shown but the field list,
* the collection picker and the column settings describe the endpoint, not the
* current result, so they keep reading this instead of going blank.
*/
const [dataSourceShapes, setDataSourceShapes] = useState<Record<string, unknown>>({})
const [dataPanelSourceId, setDataPanelSourceId] = useState('') const [dataPanelSourceId, setDataPanelSourceId] = useState('')
const [selectDataModes, setSelectDataModes] = useState<Record<string, 'static' | 'endpoint'>>({}) const [selectDataModes, setSelectDataModes] = useState<Record<string, 'static' | 'endpoint'>>({})
const [staticCollectionDrafts, setStaticCollectionDrafts] = useState<Record<string, string>>({}) const [staticCollectionDrafts, setStaticCollectionDrafts] = useState<Record<string, string>>({})
@ -1258,6 +1282,12 @@ const VisualComponentDesigner = () => {
const [catalogSourceTestResult, setCatalogSourceTestResult] = useState<DataTestResult | null>( const [catalogSourceTestResult, setCatalogSourceTestResult] = useState<DataTestResult | null>(
null, null,
) )
/** Free text filter over the reusable endpoint catalog. */
const [endpointSearch, setEndpointSearch] = useState('')
const [endpointMethodFilter, setEndpointMethodFilter] = useState<'all' | DesignerHttpMethod>(
'all',
)
const [endpointAttachedOnly, setEndpointAttachedOnly] = useState(false)
const [endpointResultModal, setEndpointResultModal] = useState<EndpointResultModal | null>(null) const [endpointResultModal, setEndpointResultModal] = useState<EndpointResultModal | null>(null)
/** Toolbox'ta hangi komponentin sürüklendiğini işaretlemek için. */ /** Toolbox'ta hangi komponentin sürüklendiğini işaretlemek için. */
const activeDrag = useDesignerDrag() const activeDrag = useDesignerDrag()
@ -1730,9 +1760,15 @@ const VisualComponentDesigner = () => {
const definedProperties = selectedDefinition?.properties || [] const definedProperties = selectedDefinition?.properties || []
const definedNames = new Set(definedProperties.map((property) => property.name)) const definedNames = new Set(definedProperties.map((property) => property.name))
const storedProperties = Object.entries(selectedNode.props) const storedProperties = Object.entries(selectedNode.props)
// `filters` is edited in the Data tab; as a raw JSON textarea here it is // `filters` and `columnLookups` are edited in the Data tab; as a raw JSON
// only a way to corrupt it. // textarea here they are only a way to corrupt them.
.filter(([propertyName]) => !definedNames.has(propertyName) && propertyName !== 'filters') .filter(
([propertyName]) =>
!definedNames.has(propertyName) &&
propertyName !== 'filters' &&
propertyName !== 'columnLookups' &&
propertyName !== 'columnCaptions',
)
.map(([propertyName, value]) => ({ .map(([propertyName, value]) => ({
name: propertyName, name: propertyName,
type: (Array.isArray(value) type: (Array.isArray(value)
@ -1832,7 +1868,30 @@ const VisualComponentDesigner = () => {
return [...items.values()] return [...items.values()]
}, [componentDetails, components, document.dataSources, generatedEndpoints, id]) }, [componentDetails, components, document.dataSources, generatedEndpoints, id])
const reusableDataSources = useMemo(() => dataSourceCatalog, [dataSourceCatalog]) /**
* The catalog of a mature tenant runs to hundreds of endpoints, so the list is
* searched rather than scrolled: every whitespace separated word has to appear
* somewhere in the name, the URL, the method or the origin.
*/
const reusableDataSources = useMemo(() => {
const terms = endpointSearch
.toLocaleLowerCase('tr')
.split(/\s+/)
.filter(Boolean)
return dataSourceCatalog
.filter((item) => endpointMethodFilter === 'all' || item.source.method === endpointMethodFilter)
.filter((item) => !endpointAttachedOnly || item.attached)
.filter((item) => {
if (!terms.length) return true
const haystack = `${item.source.name} ${item.source.method} ${item.source.url} ${item.origin}`.toLocaleLowerCase(
'tr',
)
return terms.every((term) => haystack.includes(term))
})
// Alphabetical rather than catalog order: the operations of one entity are
// named alike, so sorting is what puts them next to each other.
.sort((left, right) => left.source.name.localeCompare(right.source.name, 'tr'))
}, [dataSourceCatalog, endpointAttachedOnly, endpointMethodFilter, endpointSearch])
const updateSelectedProp = (propertyName: string, value: unknown) => { const updateSelectedProp = (propertyName: string, value: unknown) => {
if (!selectedId) return if (!selectedId) return
@ -1925,19 +1984,37 @@ const VisualComponentDesigner = () => {
// GetById is attached to a SqlDataSource command slot from the inspector. // GetById is attached to a SqlDataSource command slot from the inspector.
const bindsCollection = const bindsCollection =
catalogItem.source.method === 'GET' && !hasSqlDataSourceUrlParams(catalogItem.source.url) catalogItem.source.method === 'GET' && !hasSqlDataSourceUrlParams(catalogItem.source.url)
const collectionProperty =
selectedId && isTabularDataComponent(selectedNode?.type)
? 'items'
: selectedId && isOptionDataComponent(selectedNode?.type)
? getOptionDataProperty(selectedNode?.type)
: ''
/**
* Attaching an endpoint to the page must not retarget a component that is
* already reading a different one the selection here is incidental, the
* user is working on the endpoint list, not on the component. An unbound
* data component is still wired up, since there is nothing to lose.
*/
const bindsSelection =
bindsCollection &&
Boolean(collectionProperty) &&
!selectedNode?.bindings?.[collectionProperty]?.sourceId
if (existingSource) { if (existingSource) {
if (!bindsCollection) { if (bindsSelection && selectedId) {
updateSelectedBinding(collectionProperty, existingSource.id, '')
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
setDataPanelSourceId(existingSource.id) setDataPanelSourceId(existingSource.id)
return return
} }
if (selectedId && isTabularDataComponent(selectedNode?.type)) { // Nothing to add and nothing to bind, so the button would look broken
updateSelectedBinding('items', existingSource.id, '') // without a word about why it did nothing.
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' })) toast.push(
} else if (selectedId && isOptionDataComponent(selectedNode?.type)) { <Notification type="info" duration={2400}>
updateSelectedBinding(getOptionDataProperty(selectedNode?.type), existingSource.id, '') {translate('::App.DeveloperKitComponentDesigner.EndpointAlreadyAttached')}
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' })) </Notification>,
} { placement: 'bottom-end' },
setDataPanelSourceId(existingSource.id) )
return return
} }
@ -1949,32 +2026,21 @@ const VisualComponentDesigner = () => {
...current, ...current,
dataSources: [...current.dataSources, dataSource], dataSources: [...current.dataSources, dataSource],
nodes: nodes:
bindsCollection && bindsSelection && selectedId
selectedId &&
(isTabularDataComponent(selectedNode?.type) || isOptionDataComponent(selectedNode?.type))
? updateNodeTree(current.nodes, selectedId, (node) => ({ ? updateNodeTree(current.nodes, selectedId, (node) => ({
...node, ...node,
bindings: { bindings: {
...node.bindings, ...node.bindings,
[isTabularDataComponent(selectedNode?.type) [collectionProperty]: { sourceId: dataSource.id, path: '' },
? 'items'
: getOptionDataProperty(selectedNode?.type)]: {
sourceId: dataSource.id,
path: '',
},
}, },
})) }))
: current.nodes, : current.nodes,
})) }))
if ( if (bindsSelection && selectedId) {
bindsCollection &&
selectedId &&
(isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type))
) {
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' })) setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
}
setDataPanelSourceId(dataSource.id) setDataPanelSourceId(dataSource.id)
} }
}
/** /**
* A `record` filter has no typed value: at design time it is resolved from the * A `record` filter has no typed value: at design time it is resolved from the
@ -2004,6 +2070,35 @@ const VisualComponentDesigner = () => {
} }
}, []) }, [])
/**
* Learns the columns of an endpoint whose preview is blocked by a required
* filter, by running it once with the unmet filters dropped. The result is
* deliberately kept out of `dataSourceSamples`: it would be a collection that
* never passed the filter, and the canvas must not paint it as if it had.
*/
const probeDataSourceShape = useCallback(
async (source: DesignerDataSource, urlOverride?: string) => {
const relaxed = {
...source,
filters: (source.filters || []).map((filter) => ({ ...filter, required: false })),
}
const probeUrl = buildDesignerPreviewUrl(withPreviewFilterValues(relaxed), urlOverride)?.trim()
if (!probeUrl || !probeUrl.startsWith('/api/')) return
if (!isRunnableDataSourceUrl((urlOverride ?? source.url).trim())) return
if (hasSqlDataSourceUrlParams(probeUrl)) return
try {
const response = await apiService.fetchData({ method: 'GET', url: probeUrl })
const result = resolveDesignerResponse(response.data, source.responsePath)
if (result === undefined) return
setDataSourceShapes((current) => ({ ...current, [source.id]: result }))
} catch {
// The shape simply stays unknown; the panels fall back to their empty
// state, exactly as they did before the endpoint was ever reachable.
}
},
[withPreviewFilterValues],
)
/** /**
* `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
@ -2052,6 +2147,10 @@ const VisualComponentDesigner = () => {
message: translate('::App.DeveloperKitComponentDesigner.RequiredFilterPreviewMissing'), message: translate('::App.DeveloperKitComponentDesigner.RequiredFilterPreviewMissing'),
}, },
})) }))
// The rows must not be shown, but the columns still have to be: the
// unmet filters are dropped for one probe request whose result only ever
// reaches the shape cache, never the canvas.
await probeDataSourceShape(source, urlOverride)
return return
} }
const requestUrl = previewUrl.trim() const requestUrl = previewUrl.trim()
@ -2086,6 +2185,7 @@ const VisualComponentDesigner = () => {
[source.id]: { status: 'success', message }, [source.id]: { status: 'success', message },
})) }))
setDataSourceSamples((current) => ({ ...current, [source.id]: result })) setDataSourceSamples((current) => ({ ...current, [source.id]: result }))
setDataSourceShapes((current) => ({ ...current, [source.id]: result }))
if (showResult) setEndpointResultModal({ source, result }) if (showResult) setEndpointResultModal({ source, result })
} catch (error) { } catch (error) {
setDataSourceSamples((current) => { setDataSourceSamples((current) => {
@ -2099,7 +2199,7 @@ const VisualComponentDesigner = () => {
})) }))
} }
}, },
[], [probeDataSourceShape],
) )
const persistCatalogOwnerDocument = useCallback( const persistCatalogOwnerDocument = useCallback(
@ -2271,7 +2371,6 @@ const VisualComponentDesigner = () => {
dataSources: [...document.dataSources.filter((source) => source.id !== draft.id), draft], dataSources: [...document.dataSources.filter((source) => source.id !== draft.id), draft],
} }
setDocument(localDocument) setDocument(localDocument)
setDataPanelSourceId(draft.id)
setComponentDetails((current) => { setComponentDetails((current) => {
if (!current || current.id !== id) return current if (!current || current.id !== id) return current
return { ...current, ...update, lastModificationTime: new Date().toISOString() } return { ...current, ...update, lastModificationTime: new Date().toISOString() }
@ -2596,7 +2695,14 @@ const VisualComponentDesigner = () => {
}, [filteredCatalog]) }, [filteredCatalog])
const activeDataSource = inspectorDataSources.find((source) => source.id === dataPanelSourceId) const activeDataSource = inspectorDataSources.find((source) => source.id === dataPanelSourceId)
const activeDataSample = activeDataSource ? previewDataValues[activeDataSource.id] : undefined // Falls back to the shape cache so the field list, the collection picker and
// the column settings stay usable while a required filter is holding the live
// request back — configuring the filter is exactly when they are needed.
const activeDataSample = activeDataSource
? previewDataValues[activeDataSource.id] !== undefined
? previewDataValues[activeDataSource.id]
: dataSourceShapes[activeDataSource.id]
: undefined
/** /**
* Reads every GET endpoint that has no sample yet: the ones stored with the * Reads every GET endpoint that has no sample yet: the ones stored with the
@ -2725,6 +2831,10 @@ const VisualComponentDesigner = () => {
const bindings = { ...(node.bindings || {}) } const bindings = { ...(node.bindings || {}) }
const props = { ...node.props } const props = { ...node.props }
delete props.dataColumns delete props.dataColumns
// The lookups and captions are keyed by column; a different source
// means different columns, so stale entries would match nothing.
delete props.columnLookups
delete props.columnCaptions
if (mode === 'endpoint' && source) { if (mode === 'endpoint' && source) {
bindings.items = { sourceId: source.id, path: '' } bindings.items = { sourceId: source.id, path: '' }
} else { } else {
@ -2783,7 +2893,11 @@ const VisualComponentDesigner = () => {
.filter((field) => !['array', 'object'].includes(field.type)) .filter((field) => !['array', 'object'].includes(field.type))
.map((field) => field.path) .map((field) => field.path)
const props: Record<string, unknown> = { ...node.props, items: value } const props: Record<string, unknown> = { ...node.props, items: value }
if (previousFields.join('|') !== nextFields.join('|')) delete props.dataColumns if (previousFields.join('|') !== nextFields.join('|')) {
delete props.dataColumns
delete props.columnLookups
delete props.columnCaptions
}
return { ...node, bindings, props } return { ...node, bindings, props }
}), }),
})) }))
@ -2852,6 +2966,76 @@ const VisualComponentDesigner = () => {
) )
} }
/**
* Display rules of the columns that carry a foreign key. They live on the node
* rather than on the endpoint, because the same endpoint can feed two grids
* that resolve different columns.
*/
const gridColumnLookups = getDesignerColumnLookups(selectedNode)
/** Header overrides; an emptied box removes the entry rather than storing ''. */
const gridColumnCaptions = getDesignerColumnCaptions(selectedNode)
const writeColumnCaption = (column: string, caption: string) => {
const next = { ...gridColumnCaptions }
if (caption.trim()) next[column] = caption
else delete next[column]
updateSelectedProp('columnCaptions', next)
}
/** Sample of a lookup endpoint; the shape cache stands in for a blocked run. */
const getLookupSample = (sourceId: string) =>
previewDataValues[sourceId] !== undefined
? previewDataValues[sourceId]
: dataSourceShapes[sourceId]
const getLookupCollectionPaths = (sourceId: string) => {
const sample = getLookupSample(sourceId)
if (sample === undefined) return []
const paths = discoverDataFields(sample)
.filter((field) => field.type === 'array')
.map((field) => field.path)
return Array.isArray(sample) ? ['', ...paths] : paths
}
const getLookupRowFields = (sourceId: string, path: string) => {
const collection = getDesignerValueByPath(getLookupSample(sourceId), path)
const row = Array.isArray(collection) ? collection[0] : undefined
if (!row || typeof row !== 'object' || Array.isArray(row)) return []
return discoverDataFields([row])
.filter((field) => !['array', 'object'].includes(field.type))
.map((field) => field.path)
}
const writeColumnLookup = (column: string, patch: Partial<DesignerColumnLookup> | null) => {
const next: Record<string, DesignerColumnLookup> = { ...gridColumnLookups }
if (patch === null) delete next[column]
else {
const base: DesignerColumnLookup = next[column] ?? {
sourceId: '',
path: '',
valueField: '',
textField: '',
}
next[column] = { ...base, ...patch }
}
updateSelectedProp('columnLookups', next)
}
/**
* Picking the endpoint clears the field choices made against the previous one,
* samples it when it has never run, and settles on the only collection it
* carries a response with a single array needs no path decision.
*/
const selectColumnLookupSource = (column: string, sourceId: string) => {
if (!sourceId) {
writeColumnLookup(column, null)
return
}
const source = document.dataSources.find((item) => item.id === sourceId)
if (source && getLookupSample(sourceId) === undefined) void testDataSource(source)
const paths = getLookupCollectionPaths(sourceId)
writeColumnLookup(column, {
sourceId,
path: paths.length === 1 ? paths[0] : '',
valueField: '',
textField: '',
})
}
useEffect(() => { useEffect(() => {
if ( if (
!selectedId || !selectedId ||
@ -2955,14 +3139,68 @@ const VisualComponentDesigner = () => {
const renderReusableDataSources = (compact = false) => { const renderReusableDataSources = (compact = false) => {
const visibleSources = reusableDataSources const visibleSources = reusableDataSources
const hasFilter = Boolean(endpointSearch.trim()) || endpointMethodFilter !== 'all' || endpointAttachedOnly
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{/* Search first, then the list: with a large catalog the filter is the
primary control, not an afterthought at the bottom. */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-0 flex-1">
<FaSearch className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[10px] text-slate-400" />
<input
className="w-full rounded-md border border-slate-300 bg-white py-2 pl-7 pr-2.5 text-xs dark:border-slate-700 dark:bg-slate-900"
placeholder={translate('::App.DeveloperKitComponentDesigner.SearchEndpoint')}
type="search"
value={endpointSearch}
onChange={(event) => setEndpointSearch(event.target.value)}
/>
</div>
<div className="flex items-center gap-1 rounded-md bg-slate-100 p-1 dark:bg-slate-950">
{(['all', ...DESIGNER_HTTP_METHODS] as const).map((method) => (
<button
key={method}
className={`rounded px-2 py-1 text-[10px] font-semibold uppercase ${
endpointMethodFilter === method
? 'bg-white text-sky-700 shadow-sm dark:bg-slate-800 dark:text-sky-300'
: 'text-slate-500'
}`}
type="button"
onClick={() => setEndpointMethodFilter(method)}
>
{method === 'all'
? translate('::App.DeveloperKitComponentDesigner.AllMethods')
: method}
</button>
))}
</div>
<label className="flex cursor-pointer items-center gap-1.5 text-[10px] font-semibold text-slate-500">
<input
checked={endpointAttachedOnly}
className="h-3 w-3 accent-sky-600"
type="checkbox"
onChange={(event) => setEndpointAttachedOnly(event.target.checked)}
/>
{translate('::App.DeveloperKitComponentDesigner.AttachedOnly')}
</label>
<span className="shrink-0 rounded bg-slate-100 px-2 py-1 text-[10px] font-semibold text-slate-500 dark:bg-slate-950">
{visibleSources.length} / {dataSourceCatalog.length}
</span>
</div>
{endpointCatalogLoading && !visibleSources.length ? ( {endpointCatalogLoading && !visibleSources.length ? (
<p className="rounded-lg border border-dashed border-slate-300 p-3 text-center text-xs text-slate-500 dark:border-slate-700"> <p className="rounded-lg border border-dashed border-slate-300 p-3 text-center text-xs text-slate-500 dark:border-slate-700">
{translate('::App.DeveloperKitComponentDesigner.LoadingSavedEndpoints')} {translate('::App.DeveloperKitComponentDesigner.LoadingSavedEndpoints')}
</p> </p>
) : visibleSources.length ? ( ) : visibleSources.length ? (
visibleSources.map((item) => ( // The workspace panel is full width, so the cards are laid out in as
// many columns as fit; the inspector keeps its single narrow column.
<div
className={
compact
? 'space-y-2'
: 'grid gap-2 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4'
}
>
{visibleSources.map((item) => (
<div <div
key={item.key} key={item.key}
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white p-2.5 dark:border-slate-800 dark:bg-slate-900" className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white p-2.5 dark:border-slate-800 dark:bg-slate-900"
@ -3015,10 +3253,15 @@ const VisualComponentDesigner = () => {
)} )}
</div> </div>
</div> </div>
)) ))}
</div>
) : ( ) : (
<p className="rounded-lg border border-dashed border-slate-300 p-3 text-center text-xs leading-5 text-slate-500 dark:border-slate-700"> <p className="rounded-lg border border-dashed border-slate-300 p-3 text-center text-xs leading-5 text-slate-500 dark:border-slate-700">
{translate('::App.DeveloperKitComponentDesigner.NoMoreEndpoints')} {translate(
hasFilter
? '::App.DeveloperKitComponentDesigner.NoMatchingEndpoint'
: '::App.DeveloperKitComponentDesigner.NoMoreEndpoints',
)}
</p> </p>
)} )}
{endpointCatalogError && ( {endpointCatalogError && (
@ -3338,6 +3581,152 @@ const VisualComponentDesigner = () => {
) )
} }
/**
* Per column display rule: a column holding a key can be resolved through
* another endpoint and painted as the related text instead. It reads like the
* option list of a select box a value column and a text column which is
* exactly what the cell then shows.
*/
const renderColumnLookupConfiguration = () => {
if (!isTabularDataComponent(selectedNode?.type)) return null
const columns = selectedGridColumns.length
? selectedGridColumns
: Object.keys(gridColumnLookups)
if (!columns.length) return null
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.ColumnDisplay')}
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.ColumnDisplayHint')}
</p>
<p className="mb-2 text-[10px] leading-4 text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.ColumnCaptionHint')}
</p>
<div className="space-y-2">
{columns.map((column) => {
const lookup = gridColumnLookups[column]
const collectionPaths = lookup?.sourceId ? getLookupCollectionPaths(lookup.sourceId) : []
const rowFields = lookup?.sourceId
? getLookupRowFields(lookup.sourceId, lookup.path)
: []
return (
<div
key={column}
className="rounded border border-slate-200 bg-slate-50 p-2 dark:border-slate-800 dark:bg-slate-950"
>
<code className="block truncate text-[10px] text-sky-700 dark:text-sky-300">
{column}
</code>
{/* The header text is independent of the lookup: a column can be
renamed without being resolved, and resolved without being
renamed. Left empty it keeps the field name. */}
<input
className="mt-1.5 w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-[10px] dark:border-slate-700 dark:bg-slate-900"
placeholder={translate(
'::App.DeveloperKitComponentDesigner.ColumnCaptionPlaceholder',
{ column },
)}
value={gridColumnCaptions[column] ?? ''}
onChange={(event) => writeColumnCaption(column, event.target.value)}
/>
<label className="mt-1.5 flex cursor-pointer items-center gap-2">
<input
checked={Boolean(lookup)}
className="h-3 w-3 accent-sky-600"
type="checkbox"
onChange={() => writeColumnLookup(column, lookup ? null : {})}
/>
<span className="min-w-0 flex-1 text-[9px] text-slate-400">
{translate('::App.DeveloperKitComponentDesigner.ShowRelatedText')}
</span>
</label>
{lookup && (
<div className="mt-2 space-y-1.5">
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={lookup.sourceId}
onChange={(event) => selectColumnLookupSource(column, event.target.value)}
>
<option value="">
{translate('::App.DeveloperKitComponentDesigner.SelectEndpoint')}
</option>
{bindableDataSources.map((source) => (
<option key={source.id} value={source.id}>
{source.name}
</option>
))}
</select>
{lookup.sourceId && collectionPaths.length > 1 && (
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={lookup.path}
onChange={(event) =>
writeColumnLookup(column, {
path: event.target.value,
valueField: '',
textField: '',
})
}
>
<option value="">
{translate('::App.DeveloperKitComponentDesigner.SelectCollection')}
</option>
{collectionPaths.map((path) => (
<option key={path || '__root__'} value={path}>
{path ||
translate('::App.DeveloperKitComponentDesigner.WholeResponseArray')}
</option>
))}
</select>
)}
{lookup.sourceId && (
<div className="grid grid-cols-2 gap-1.5">
{(
[
['valueField', '::App.DeveloperKitComponentDesigner.LookupValueField'],
['textField', '::App.DeveloperKitComponentDesigner.LookupTextField'],
] as const
).map(([field, label]) => (
<label key={field} className="block">
<span className="mb-0.5 block text-[9px] text-slate-400">
{translate(label)}
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-1.5 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={lookup[field]}
onChange={(event) =>
writeColumnLookup(column, { [field]: event.target.value })
}
>
<option value="">
{rowFields.length
? translate('::App.DeveloperKitComponentDesigner.SelectField')
: translate(
'::App.DeveloperKitComponentDesigner.NoColumnInResponse',
)}
</option>
{rowFields.map((path) => (
<option key={path} value={path}>
{path}
</option>
))}
</select>
</label>
))}
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
const renderTabularColumnConfiguration = () => { const renderTabularColumnConfiguration = () => {
if (!isTabularDataComponent(selectedNode?.type)) return null if (!isTabularDataComponent(selectedNode?.type)) return null
return ( return (
@ -3400,7 +3789,13 @@ const VisualComponentDesigner = () => {
*/ */
const renderDataSourceFilters = (source?: DesignerDataSource | null) => { const renderDataSourceFilters = (source?: DesignerDataSource | null) => {
if (!source || source.method !== 'GET') return null if (!source || source.method !== 'GET') return null
const sample = dataSourceSamples[source.id] // The filter columns are a property of the endpoint, so they are read from
// the shape cache when the live sample is gone — otherwise adding the first
// required filter would remove the very list the next one is picked from.
const sample =
dataSourceSamples[source.id] !== undefined
? dataSourceSamples[source.id]
: dataSourceShapes[source.id]
const sampleRow = Array.isArray(sample) const sampleRow = Array.isArray(sample)
? sample[0] ? sample[0]
: sample && : sample &&
@ -4099,6 +4494,7 @@ const VisualComponentDesigner = () => {
{isOptionDataComponent(selectedNode?.type) && selectConfiguration} {isOptionDataComponent(selectedNode?.type) && selectConfiguration}
{isTabularDataComponent(selectedNode?.type) && renderTabularDataConfiguration()} {isTabularDataComponent(selectedNode?.type) && renderTabularDataConfiguration()}
{isTabularDataComponent(selectedNode?.type) && renderTabularColumnConfiguration()} {isTabularDataComponent(selectedNode?.type) && renderTabularColumnConfiguration()}
{isTabularDataComponent(selectedNode?.type) && renderColumnLookupConfiguration()}
{renderSqlRecordField()} {renderSqlRecordField()}
</div> </div>
) )
@ -4154,6 +4550,8 @@ const VisualComponentDesigner = () => {
nodes: updateNodeTree(current.nodes, selectedId, (node) => { nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const props = { ...node.props } const props = { ...node.props }
delete props.dataColumns delete props.dataColumns
delete props.columnLookups
delete props.columnCaptions
return { return {
...node, ...node,
props, props,
@ -4304,6 +4702,8 @@ const VisualComponentDesigner = () => {
</div> </div>
)} )}
{isTabularDataComponent(selectedNode?.type) && renderColumnLookupConfiguration()}
{!isOptionDataComponent(selectedNode?.type) && {!isOptionDataComponent(selectedNode?.type) &&
!isTabularDataComponent(selectedNode?.type) && ( !isTabularDataComponent(selectedNode?.type) && (
<div> <div>
@ -4817,7 +5217,7 @@ const VisualComponentDesigner = () => {
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-xs 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-xs text-sky-900 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
{translate('::App.DeveloperKitComponentDesigner.EndpointSettingsHint')} {translate('::App.DeveloperKitComponentDesigner.EndpointSettingsHint')}
</div> </div>
{(reusableDataSources.length > 0 || endpointCatalogError) && ( {(dataSourceCatalog.length > 0 || endpointCatalogError) && (
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900"> <div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-3"> <div className="mb-3">
<h3 className="text-sm font-semibold text-slate-800 dark:text-white"> <h3 className="text-sm font-semibold text-slate-800 dark:text-white">