Grid komponentindeki sütunlarını SelectBox olarak gösterilmesi
This commit is contained in:
parent
61234dec3f
commit
4003dc1845
10 changed files with 834 additions and 115 deletions
|
|
@ -20418,6 +20418,90 @@
|
|||
"en": "Select a component from the canvas to see its properties.",
|
||||
"tr": "Property’leri görmek için canvas’tan 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",
|
||||
"key": "App.DeveloperKitComponentDesigner.SelectEndpoint",
|
||||
|
|
@ -22986,12 +23070,6 @@
|
|||
"en": "On form open",
|
||||
"tr": "Form açılırken"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ScriptBuilderOpenUrl.OpenUrlLabel",
|
||||
"en": "Open URL",
|
||||
"tr": "URL aç"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ScriptBuilderOpenUrl.OpenUrlSummary",
|
||||
|
|
|
|||
36
configs/seeds/host/crud/AbpUsers.json
Normal file
36
configs/seeds/host/crud/AbpUsers.json
Normal 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
|
|
@ -28,6 +28,8 @@ interface PlatformViewHostProps {
|
|||
view: PlatformViewName
|
||||
listFormCode: 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:
|
||||
* a `filter` entry holding a DevExtreme expression. The designer builds it from
|
||||
|
|
@ -41,6 +43,7 @@ const PlatformViewHost = ({
|
|||
view,
|
||||
listFormCode,
|
||||
height = '520px',
|
||||
className = '',
|
||||
searchParams,
|
||||
}: PlatformViewHostProps) => {
|
||||
const [gridDto, setGridDto] = useState<GridDto>()
|
||||
|
|
@ -72,12 +75,16 @@ const PlatformViewHost = ({
|
|||
|
||||
if (!listFormCode.trim()) {
|
||||
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 property’sini ayarlayın.
|
||||
</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 />
|
||||
|
||||
const sharedProps = { listFormCode, isSubForm: true, gridDto, searchParams }
|
||||
|
|
@ -119,7 +126,7 @@ const PlatformViewHost = ({
|
|||
})()
|
||||
|
||||
return (
|
||||
<div className="overflow-auto" style={{ minHeight: height }}>
|
||||
<div className={`overflow-auto ${className}`} style={{ minHeight: height }}>
|
||||
<Suspense fallback={<Loading loading />}>{content}</Suspense>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ import {
|
|||
getDesignerValueByPath,
|
||||
getSqlDataSourceEndpointId,
|
||||
getSqlDataSourceColumnCount,
|
||||
getDesignerColumnCaptions,
|
||||
getUsableDesignerColumnLookups,
|
||||
buildDesignerLookupIndex,
|
||||
resolveDesignerLookupText,
|
||||
getSqlDataSourceKeyField,
|
||||
getSqlFormValueProperty,
|
||||
isDesignerDateComponent,
|
||||
|
|
@ -672,7 +676,7 @@ const PlatformPlaceholder = ({ node }: { node: DesignerNode }) => {
|
|||
|
||||
return (
|
||||
<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') }}
|
||||
>
|
||||
<div>
|
||||
|
|
@ -740,16 +744,22 @@ const getGridCellText = (value: unknown) =>
|
|||
|
||||
const GridDataTablePreview = ({
|
||||
borderlessRow = false,
|
||||
captions,
|
||||
compact = false,
|
||||
hoverable = true,
|
||||
items,
|
||||
lookupIndexes,
|
||||
overflow = true,
|
||||
selectedColumns,
|
||||
}: {
|
||||
borderlessRow?: boolean
|
||||
/** Header override per column; a `::` value is a localization key. */
|
||||
captions?: Record<string, string>
|
||||
compact?: boolean
|
||||
hoverable?: boolean
|
||||
items: unknown[]
|
||||
/** Value → text map per column, for the columns configured as a lookup. */
|
||||
lookupIndexes?: Record<string, Map<string, unknown>>
|
||||
overflow?: boolean
|
||||
selectedColumns?: string[]
|
||||
}) => {
|
||||
|
|
@ -780,7 +790,11 @@ const GridDataTablePreview = ({
|
|||
key={column}
|
||||
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>
|
||||
))}
|
||||
</tr>
|
||||
|
|
@ -793,7 +807,14 @@ const GridDataTablePreview = ({
|
|||
>
|
||||
{columns.map((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 (
|
||||
<td
|
||||
key={column}
|
||||
|
|
@ -1119,6 +1140,7 @@ const PlatformViewPreview = ({ node }: { node: DesignerNode }) => {
|
|||
if (params === null) return null
|
||||
return (
|
||||
<PlatformViewHost
|
||||
className={String(node.props.className || '')}
|
||||
height={String(node.props.height || '420px')}
|
||||
listFormCode={String(node.props.listFormCode || '')}
|
||||
searchParams={params}
|
||||
|
|
@ -1524,6 +1546,19 @@ const NodeView = ({
|
|||
const hasChildrenBinding = Boolean(node.bindings?.children?.sourceId)
|
||||
const hasVisibleChildren = node.children.length > 0 || hasStaticChildren || hasChildrenBinding
|
||||
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
|
||||
? getBindingValue(itemsBinding, dataValues, currentItem)
|
||||
: node.props.items
|
||||
|
|
@ -1787,8 +1822,10 @@ const NodeView = ({
|
|||
key={`grid_data_${node.id}`}
|
||||
borderlessRow={Boolean(node.props.borderlessRow)}
|
||||
compact={Boolean(node.props.compact)}
|
||||
captions={getDesignerColumnCaptions(node)}
|
||||
hoverable={node.props.hoverable !== false}
|
||||
items={repeatedItems}
|
||||
lookupIndexes={gridLookupIndexes}
|
||||
overflow={node.props.overflow !== false}
|
||||
selectedColumns={
|
||||
Array.isArray(node.props.dataColumns)
|
||||
|
|
@ -1842,8 +1879,10 @@ const NodeView = ({
|
|||
key={`grid_data_${node.id}`}
|
||||
borderlessRow={Boolean(node.props.borderlessRow)}
|
||||
compact={Boolean(node.props.compact)}
|
||||
captions={getDesignerColumnCaptions(node)}
|
||||
hoverable={node.props.hoverable !== false}
|
||||
items={repeatedItems}
|
||||
lookupIndexes={gridLookupIndexes}
|
||||
overflow={node.props.overflow !== false}
|
||||
selectedColumns={
|
||||
Array.isArray(node.props.dataColumns)
|
||||
|
|
|
|||
|
|
@ -721,6 +721,7 @@ const platformDefinition = (
|
|||
properties: [
|
||||
{ name: 'listFormCode', type: 'string', value: '', category: 'properties', required: true },
|
||||
{ name: 'height', type: 'string', value: height, category: 'styling' },
|
||||
{ name: 'className', type: 'string', value: '', category: 'styling' },
|
||||
],
|
||||
hooks: [],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import {
|
|||
getDesignerDataSourceFilters,
|
||||
getDesignerNodeFilters,
|
||||
getSqlDataSourceColumnCount,
|
||||
getDesignerColumnCaptions,
|
||||
getUsableDesignerColumnLookups,
|
||||
getSqlDataSourceEndpointId,
|
||||
getSqlDataSourceKeyField,
|
||||
getSqlDataSourceKeyParam,
|
||||
|
|
@ -909,6 +911,28 @@ ${indent('</UiKit.Table>', level)}`
|
|||
const configuredColumns = Array.isArray(node.props.dataColumns)
|
||||
? node.props.dataColumns.filter((column): column is string => typeof column === 'string')
|
||||
: 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
|
||||
? JSON.stringify(configuredColumns)
|
||||
: `${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 (!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(`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(`<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('<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('<tbody>', level + 5)}
|
||||
${indent(`{${itemsVariable}.map((${itemVariable}, rowIndex) => (`, level + 6)}
|
||||
${indent(`<tr key={rowIndex} className=${JSON.stringify(rowClass)}>`, level + 7)}
|
||||
${indent(`{${columnsVariable}.map((column) => {`, level + 8)}
|
||||
${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('})}', level + 8)}
|
||||
${indent('</tr>', level + 7)}
|
||||
|
|
@ -1004,7 +1029,10 @@ ${indent('})()}', level + 1)}`
|
|||
}
|
||||
const platformParams = `platformParams_${safeIdentifier(node.id)}`
|
||||
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)
|
||||
// A required filter without a value renders nothing: showing the unfiltered
|
||||
// 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[] = []
|
||||
let hasSelect = false
|
||||
let hasDataTable = false
|
||||
/** A Grid column shows a related text instead of its raw key. */
|
||||
let hasColumnLookup = false
|
||||
let hasDropdown = false
|
||||
let hasSelectComponent = false
|
||||
let hasDatePicker = false
|
||||
|
|
@ -1404,6 +1434,7 @@ export const generateDesignerCode = (name: string, document: DesignerDocument) =
|
|||
if (node.type === 'Select') hasSelectComponent = true
|
||||
if (node.type === 'Dropdown') hasDropdown = true
|
||||
if (node.type === 'Grid') hasDataTable = true
|
||||
if (Object.keys(getUsableDesignerColumnLookups(node)).length) hasColumnLookup = true
|
||||
if (node.type === 'Tabs') hasTabs = true
|
||||
if (isDesignerDateComponent(node.type)) hasDatePicker = true
|
||||
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 ?? "") : ""
|
||||
}`
|
||||
: ''
|
||||
// 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
|
||||
// here — the culture is published on `<html lang>` because a runtime compiled
|
||||
// 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))
|
||||
|
||||
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`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -637,6 +637,99 @@ export const getDesignerValueByPath = (value: unknown, path: string): unknown =>
|
|||
}, 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 => {
|
||||
if (!responsePath.trim()) return value
|
||||
|
||||
|
|
|
|||
|
|
@ -600,7 +600,7 @@ export const recipes: Recipe[] = [
|
|||
},
|
||||
{
|
||||
id: 'openUrl',
|
||||
label: 'App.ScriptBuilderOpenUrl.OpenUrlLabel',
|
||||
label: 'App.Platform.OpenUrl',
|
||||
group: 'interaction',
|
||||
summary: 'App.ScriptBuilderOpenUrl.OpenUrlSummary',
|
||||
example: "openUrl('/report?id={Id}')",
|
||||
|
|
|
|||
|
|
@ -97,6 +97,9 @@ import {
|
|||
type DesignerComponentDefinition,
|
||||
type DesignerBinding,
|
||||
buildDesignerPreviewUrl,
|
||||
getDesignerColumnCaptions,
|
||||
getDesignerColumnLookups,
|
||||
type DesignerColumnLookup,
|
||||
createDesignerFilter,
|
||||
DESIGNER_FILTER_OPERATORS,
|
||||
DESIGNER_FILTER_SOURCES,
|
||||
|
|
@ -384,13 +387,26 @@ const removeNodeTree = (nodes: DesignerNode[], id: 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,
|
||||
props,
|
||||
bindings: Object.fromEntries(
|
||||
Object.entries(node.bindings || {}).filter(([, binding]) => binding.sourceId !== sourceId),
|
||||
),
|
||||
children: removeDataSourceBindings(node.children, sourceId),
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
const insertNodeTree = (
|
||||
nodes: DesignerNode[],
|
||||
|
|
@ -1244,6 +1260,14 @@ const VisualComponentDesigner = () => {
|
|||
const [savedDesignerFingerprint, setSavedDesignerFingerprint] = useState('')
|
||||
const [dataTestResults, setDataTestResults] = useState<Record<string, DataTestResult>>({})
|
||||
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 [selectDataModes, setSelectDataModes] = useState<Record<string, 'static' | 'endpoint'>>({})
|
||||
const [staticCollectionDrafts, setStaticCollectionDrafts] = useState<Record<string, string>>({})
|
||||
|
|
@ -1258,6 +1282,12 @@ const VisualComponentDesigner = () => {
|
|||
const [catalogSourceTestResult, setCatalogSourceTestResult] = useState<DataTestResult | 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)
|
||||
/** Toolbox'ta hangi komponentin sürüklendiğini işaretlemek için. */
|
||||
const activeDrag = useDesignerDrag()
|
||||
|
|
@ -1730,9 +1760,15 @@ const VisualComponentDesigner = () => {
|
|||
const definedProperties = selectedDefinition?.properties || []
|
||||
const definedNames = new Set(definedProperties.map((property) => property.name))
|
||||
const storedProperties = Object.entries(selectedNode.props)
|
||||
// `filters` is edited in the Data tab; as a raw JSON textarea here it is
|
||||
// only a way to corrupt it.
|
||||
.filter(([propertyName]) => !definedNames.has(propertyName) && propertyName !== 'filters')
|
||||
// `filters` and `columnLookups` are edited in the Data tab; as a raw JSON
|
||||
// textarea here they are only a way to corrupt them.
|
||||
.filter(
|
||||
([propertyName]) =>
|
||||
!definedNames.has(propertyName) &&
|
||||
propertyName !== 'filters' &&
|
||||
propertyName !== 'columnLookups' &&
|
||||
propertyName !== 'columnCaptions',
|
||||
)
|
||||
.map(([propertyName, value]) => ({
|
||||
name: propertyName,
|
||||
type: (Array.isArray(value)
|
||||
|
|
@ -1832,7 +1868,30 @@ const VisualComponentDesigner = () => {
|
|||
|
||||
return [...items.values()]
|
||||
}, [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) => {
|
||||
if (!selectedId) return
|
||||
|
|
@ -1925,19 +1984,37 @@ const VisualComponentDesigner = () => {
|
|||
// GetById is attached to a SqlDataSource command slot from the inspector.
|
||||
const bindsCollection =
|
||||
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 (!bindsCollection) {
|
||||
if (bindsSelection && selectedId) {
|
||||
updateSelectedBinding(collectionProperty, existingSource.id, '')
|
||||
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
|
||||
setDataPanelSourceId(existingSource.id)
|
||||
return
|
||||
}
|
||||
if (selectedId && isTabularDataComponent(selectedNode?.type)) {
|
||||
updateSelectedBinding('items', existingSource.id, '')
|
||||
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
|
||||
} else if (selectedId && isOptionDataComponent(selectedNode?.type)) {
|
||||
updateSelectedBinding(getOptionDataProperty(selectedNode?.type), existingSource.id, '')
|
||||
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
|
||||
}
|
||||
setDataPanelSourceId(existingSource.id)
|
||||
// Nothing to add and nothing to bind, so the button would look broken
|
||||
// without a word about why it did nothing.
|
||||
toast.push(
|
||||
<Notification type="info" duration={2400}>
|
||||
{translate('::App.DeveloperKitComponentDesigner.EndpointAlreadyAttached')}
|
||||
</Notification>,
|
||||
{ placement: 'bottom-end' },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1949,32 +2026,21 @@ const VisualComponentDesigner = () => {
|
|||
...current,
|
||||
dataSources: [...current.dataSources, dataSource],
|
||||
nodes:
|
||||
bindsCollection &&
|
||||
selectedId &&
|
||||
(isTabularDataComponent(selectedNode?.type) || isOptionDataComponent(selectedNode?.type))
|
||||
bindsSelection && selectedId
|
||||
? updateNodeTree(current.nodes, selectedId, (node) => ({
|
||||
...node,
|
||||
bindings: {
|
||||
...node.bindings,
|
||||
[isTabularDataComponent(selectedNode?.type)
|
||||
? 'items'
|
||||
: getOptionDataProperty(selectedNode?.type)]: {
|
||||
sourceId: dataSource.id,
|
||||
path: '',
|
||||
},
|
||||
[collectionProperty]: { sourceId: dataSource.id, path: '' },
|
||||
},
|
||||
}))
|
||||
: current.nodes,
|
||||
}))
|
||||
if (
|
||||
bindsCollection &&
|
||||
selectedId &&
|
||||
(isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type))
|
||||
) {
|
||||
if (bindsSelection && selectedId) {
|
||||
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
|
||||
}
|
||||
setDataPanelSourceId(dataSource.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* endpoint cannot be sampled otherwise, and without a sample the designer has
|
||||
|
|
@ -2052,6 +2147,10 @@ const VisualComponentDesigner = () => {
|
|||
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
|
||||
}
|
||||
const requestUrl = previewUrl.trim()
|
||||
|
|
@ -2086,6 +2185,7 @@ const VisualComponentDesigner = () => {
|
|||
[source.id]: { status: 'success', message },
|
||||
}))
|
||||
setDataSourceSamples((current) => ({ ...current, [source.id]: result }))
|
||||
setDataSourceShapes((current) => ({ ...current, [source.id]: result }))
|
||||
if (showResult) setEndpointResultModal({ source, result })
|
||||
} catch (error) {
|
||||
setDataSourceSamples((current) => {
|
||||
|
|
@ -2099,7 +2199,7 @@ const VisualComponentDesigner = () => {
|
|||
}))
|
||||
}
|
||||
},
|
||||
[],
|
||||
[probeDataSourceShape],
|
||||
)
|
||||
|
||||
const persistCatalogOwnerDocument = useCallback(
|
||||
|
|
@ -2271,7 +2371,6 @@ const VisualComponentDesigner = () => {
|
|||
dataSources: [...document.dataSources.filter((source) => source.id !== draft.id), draft],
|
||||
}
|
||||
setDocument(localDocument)
|
||||
setDataPanelSourceId(draft.id)
|
||||
setComponentDetails((current) => {
|
||||
if (!current || current.id !== id) return current
|
||||
return { ...current, ...update, lastModificationTime: new Date().toISOString() }
|
||||
|
|
@ -2596,7 +2695,14 @@ const VisualComponentDesigner = () => {
|
|||
}, [filteredCatalog])
|
||||
|
||||
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
|
||||
|
|
@ -2725,6 +2831,10 @@ const VisualComponentDesigner = () => {
|
|||
const bindings = { ...(node.bindings || {}) }
|
||||
const props = { ...node.props }
|
||||
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) {
|
||||
bindings.items = { sourceId: source.id, path: '' }
|
||||
} else {
|
||||
|
|
@ -2783,7 +2893,11 @@ const VisualComponentDesigner = () => {
|
|||
.filter((field) => !['array', 'object'].includes(field.type))
|
||||
.map((field) => field.path)
|
||||
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 }
|
||||
}),
|
||||
}))
|
||||
|
|
@ -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(() => {
|
||||
if (
|
||||
!selectedId ||
|
||||
|
|
@ -2955,14 +3139,68 @@ const VisualComponentDesigner = () => {
|
|||
|
||||
const renderReusableDataSources = (compact = false) => {
|
||||
const visibleSources = reusableDataSources
|
||||
const hasFilter = Boolean(endpointSearch.trim()) || endpointMethodFilter !== 'all' || endpointAttachedOnly
|
||||
return (
|
||||
<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 ? (
|
||||
<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')}
|
||||
</p>
|
||||
) : 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
|
||||
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"
|
||||
|
|
@ -3015,10 +3253,15 @@ const VisualComponentDesigner = () => {
|
|||
)}
|
||||
</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">
|
||||
{translate('::App.DeveloperKitComponentDesigner.NoMoreEndpoints')}
|
||||
{translate(
|
||||
hasFilter
|
||||
? '::App.DeveloperKitComponentDesigner.NoMatchingEndpoint'
|
||||
: '::App.DeveloperKitComponentDesigner.NoMoreEndpoints',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{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 = () => {
|
||||
if (!isTabularDataComponent(selectedNode?.type)) return null
|
||||
return (
|
||||
|
|
@ -3400,7 +3789,13 @@ const VisualComponentDesigner = () => {
|
|||
*/
|
||||
const renderDataSourceFilters = (source?: DesignerDataSource | 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)
|
||||
? sample[0]
|
||||
: sample &&
|
||||
|
|
@ -4099,6 +4494,7 @@ const VisualComponentDesigner = () => {
|
|||
{isOptionDataComponent(selectedNode?.type) && selectConfiguration}
|
||||
{isTabularDataComponent(selectedNode?.type) && renderTabularDataConfiguration()}
|
||||
{isTabularDataComponent(selectedNode?.type) && renderTabularColumnConfiguration()}
|
||||
{isTabularDataComponent(selectedNode?.type) && renderColumnLookupConfiguration()}
|
||||
{renderSqlRecordField()}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -4154,6 +4550,8 @@ const VisualComponentDesigner = () => {
|
|||
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
|
||||
const props = { ...node.props }
|
||||
delete props.dataColumns
|
||||
delete props.columnLookups
|
||||
delete props.columnCaptions
|
||||
return {
|
||||
...node,
|
||||
props,
|
||||
|
|
@ -4304,6 +4702,8 @@ const VisualComponentDesigner = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{isTabularDataComponent(selectedNode?.type) && renderColumnLookupConfiguration()}
|
||||
|
||||
{!isOptionDataComponent(selectedNode?.type) &&
|
||||
!isTabularDataComponent(selectedNode?.type) && (
|
||||
<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">
|
||||
{translate('::App.DeveloperKitComponentDesigner.EndpointSettingsHint')}
|
||||
</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="mb-3">
|
||||
<h3 className="text-sm font-semibold text-slate-800 dark:text-white">
|
||||
|
|
|
|||
Loading…
Reference in a new issue