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.",
|
"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."
|
"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",
|
"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",
|
||||||
|
|
|
||||||
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
|
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 property’sini ayarlayın.
|
{view} için listFormCode property’sini 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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}')",
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
...node,
|
const props = { ...node.props }
|
||||||
bindings: Object.fromEntries(
|
// A column lookup names its endpoint the same way a binding does, and the
|
||||||
Object.entries(node.bindings || {}).filter(([, binding]) => binding.sourceId !== sourceId),
|
// generated grid reads that endpoint's state directly — a mapping left
|
||||||
),
|
// behind by a detached source would compile into an undefined variable.
|
||||||
children: removeDataSourceBindings(node.children, sourceId),
|
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 = (
|
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,31 +2026,20 @@ 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -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,70 +3139,129 @@ 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
|
||||||
<div
|
// many columns as fit; the inspector keeps its single narrow column.
|
||||||
key={item.key}
|
<div
|
||||||
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={
|
||||||
>
|
compact
|
||||||
<div className="min-w-0 flex-1">
|
? 'space-y-2'
|
||||||
<div className="truncate text-xs font-semibold text-slate-700 dark:text-slate-200">
|
: 'grid gap-2 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4'
|
||||||
{item.source.name}
|
}
|
||||||
</div>
|
>
|
||||||
<div className="truncate font-mono text-[9px] text-sky-600">
|
{visibleSources.map((item) => (
|
||||||
{item.source.method} {item.source.url}
|
<div
|
||||||
</div>
|
key={item.key}
|
||||||
{!compact && (
|
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"
|
||||||
<div className="flex items-center gap-1.5 text-[9px] text-slate-400">
|
>
|
||||||
<span>{item.origin}</span>
|
<div className="min-w-0 flex-1">
|
||||||
{item.attached && (
|
<div className="truncate text-xs font-semibold text-slate-700 dark:text-slate-200">
|
||||||
<span className="rounded bg-emerald-100 px-1 py-0.5 font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
{item.source.name}
|
||||||
Ekli
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="truncate font-mono text-[9px] text-sky-600">
|
||||||
|
{item.source.method} {item.source.url}
|
||||||
|
</div>
|
||||||
|
{!compact && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[9px] text-slate-400">
|
||||||
|
<span>{item.origin}</span>
|
||||||
|
{item.attached && (
|
||||||
|
<span className="rounded bg-emerald-100 px-1 py-0.5 font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
||||||
|
Ekli
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-sky-600 px-2.5 py-1.5 text-[10px] font-semibold text-white hover:bg-sky-700"
|
||||||
|
type="button"
|
||||||
|
onClick={() => addReusableDataSource(item)}
|
||||||
|
>
|
||||||
|
{translate('::App.DeveloperKitComponentDesigner.Use')}
|
||||||
|
</button>
|
||||||
|
{item.originType === 'component' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-slate-200 p-1.5 text-slate-500 hover:border-amber-400 hover:text-amber-600 dark:border-slate-700"
|
||||||
|
title={translate('::App.DeveloperKitComponentDesigner.EditEndpoint')}
|
||||||
|
type="button"
|
||||||
|
onClick={() => openCatalogSourceEditor(item)}
|
||||||
|
>
|
||||||
|
<FaEdit />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-slate-200 p-1.5 text-slate-500 hover:border-red-400 hover:text-red-600 dark:border-slate-700"
|
||||||
|
title={translate('::App.DeveloperKitComponentDesigner.DeleteEndpoint')}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void deleteCatalogSource(item)}
|
||||||
|
>
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
))}
|
||||||
<button
|
</div>
|
||||||
className="rounded-md bg-sky-600 px-2.5 py-1.5 text-[10px] font-semibold text-white hover:bg-sky-700"
|
|
||||||
type="button"
|
|
||||||
onClick={() => addReusableDataSource(item)}
|
|
||||||
>
|
|
||||||
{translate('::App.DeveloperKitComponentDesigner.Use')}
|
|
||||||
</button>
|
|
||||||
{item.originType === 'component' && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="rounded-md border border-slate-200 p-1.5 text-slate-500 hover:border-amber-400 hover:text-amber-600 dark:border-slate-700"
|
|
||||||
title={translate('::App.DeveloperKitComponentDesigner.EditEndpoint')}
|
|
||||||
type="button"
|
|
||||||
onClick={() => openCatalogSourceEditor(item)}
|
|
||||||
>
|
|
||||||
<FaEdit />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded-md border border-slate-200 p-1.5 text-slate-500 hover:border-red-400 hover:text-red-600 dark:border-slate-700"
|
|
||||||
title={translate('::App.DeveloperKitComponentDesigner.DeleteEndpoint')}
|
|
||||||
type="button"
|
|
||||||
onClick={() => void deleteCatalogSource(item)}
|
|
||||||
>
|
|
||||||
<FaTrash />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue