>
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}
))}
@@ -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 (
{
if (params === null) return null
return (
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)
diff --git a/ui/src/components/visualDesigner/catalog.ts b/ui/src/components/visualDesigner/catalog.ts
index 6d054498..070282ae 100644
--- a/ui/src/components/visualDesigner/catalog.ts
+++ b/ui/src/components/visualDesigner/catalog.ts
@@ -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: [],
})
diff --git a/ui/src/components/visualDesigner/codeGenerator.ts b/ui/src/components/visualDesigner/codeGenerator.ts
index 6471e5f8..23ae1356 100644
--- a/ui/src/components/visualDesigner/codeGenerator.ts
+++ b/ui/src/components/visualDesigner/codeGenerator.ts
@@ -6,6 +6,8 @@ import {
getDesignerDataSourceFilters,
getDesignerNodeFilters,
getSqlDataSourceColumnCount,
+ getDesignerColumnCaptions,
+ getUsableDesignerColumnLookups,
getSqlDataSourceEndpointId,
getSqlDataSourceKeyField,
getSqlDataSourceKeyParam,
@@ -909,6 +911,28 @@ ${indent('', 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 Grid items bağlantısı bir koleksiyon döndürmelidir.
`, level + 2)}
${indent(`const ${columnsVariable} = ${columnsExpression}`, level + 2)}
-${indent(`if (!${columnsVariable}.length) return Preview için en az bir sütun seçin.
`, 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 Preview için en az bir sütun seçin.
`, level + 2)}
${indent('return (', level + 2)}
${indent(``, level + 3)}
${indent(`
`, level + 4)}
${indent('', level + 5)}
-${indent(`{${columnsVariable}.map((column) => {column === "value" ? "Value" : column} )}`, level + 6)}
+${indent(`{${columnsVariable}.map((column) => {${captionEntries.length ? `${captionsVariable}[column] ?? ` : ''}(column === "value" ? "Value" : column)} )}`, level + 6)}
${indent(' ', level + 5)}
${indent('', level + 5)}
${indent(`{${itemsVariable}.map((${itemVariable}, rowIndex) => (`, level + 6)}
${indent(``, 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 {text} `, level + 9)}
${indent('})}', level + 8)}
${indent(' ', level + 7)}
@@ -1004,7 +1029,10 @@ ${indent('})()}', level + 1)}`
}
const platformParams = `platformParams_${safeIdentifier(node.id)}`
const hasFilters = getDesignerNodeFilters(node).length > 0
- const host = ` `
+ // 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 = ` `
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 `` 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`
}
diff --git a/ui/src/components/visualDesigner/types.ts b/ui/src/components/visualDesigner/types.ts
index 5e2ecdf7..8f6f8bf5 100644
--- a/ui/src/components/visualDesigner/types.ts
+++ b/ui/src/components/visualDesigner/types.ts
@@ -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
+
+/**
+ * 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).forEach(([column, value]) => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return
+ const entry = value as Record
+ 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 => {
+ const raw = node?.props?.columnCaptions
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
+ const captions: Record = {}
+ Object.entries(raw as Record).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()
+ 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, 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
diff --git a/ui/src/views/admin/listForm/edit/json-row-operations/editor-script/scriptRecipes.ts b/ui/src/views/admin/listForm/edit/json-row-operations/editor-script/scriptRecipes.ts
index cfd2a184..a7ce4825 100644
--- a/ui/src/views/admin/listForm/edit/json-row-operations/editor-script/scriptRecipes.ts
+++ b/ui/src/views/admin/listForm/edit/json-row-operations/editor-script/scriptRecipes.ts
@@ -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}')",
diff --git a/ui/src/views/developerKit/VisualComponentDesigner.tsx b/ui/src/views/developerKit/VisualComponentDesigner.tsx
index 84af0acf..23a2f617 100644
--- a/ui/src/views/developerKit/VisualComponentDesigner.tsx
+++ b/ui/src/views/developerKit/VisualComponentDesigner.tsx
@@ -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) => ({
- ...node,
- bindings: Object.fromEntries(
- Object.entries(node.bindings || {}).filter(([, binding]) => binding.sourceId !== sourceId),
- ),
- children: removeDataSourceBindings(node.children, sourceId),
- }))
+ 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>({})
const [dataSourceSamples, setDataSourceSamples] = useState>({})
+ /**
+ * 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>({})
const [dataPanelSourceId, setDataPanelSourceId] = useState('')
const [selectDataModes, setSelectDataModes] = useState>({})
const [staticCollectionDrafts, setStaticCollectionDrafts] = useState>({})
@@ -1258,6 +1282,12 @@ const VisualComponentDesigner = () => {
const [catalogSourceTestResult, setCatalogSourceTestResult] = useState(
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(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(
+
+ {translate('::App.DeveloperKitComponentDesigner.EndpointAlreadyAttached')}
+ ,
+ { placement: 'bottom-end' },
+ )
return
}
@@ -1949,31 +2026,20 @@ 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)
}
- 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
* 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 = { ...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 | null) => {
+ const next: Record = { ...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,70 +3139,129 @@ const VisualComponentDesigner = () => {
const renderReusableDataSources = (compact = false) => {
const visibleSources = reusableDataSources
+ const hasFilter = Boolean(endpointSearch.trim()) || endpointMethodFilter !== 'all' || endpointAttachedOnly
return (
+ {/* Search first, then the list: with a large catalog the filter is the
+ primary control, not an afterthought at the bottom. */}
+
+
+
+ setEndpointSearch(event.target.value)}
+ />
+
+
+ {(['all', ...DESIGNER_HTTP_METHODS] as const).map((method) => (
+ setEndpointMethodFilter(method)}
+ >
+ {method === 'all'
+ ? translate('::App.DeveloperKitComponentDesigner.AllMethods')
+ : method}
+
+ ))}
+
+
+ setEndpointAttachedOnly(event.target.checked)}
+ />
+ {translate('::App.DeveloperKitComponentDesigner.AttachedOnly')}
+
+
+ {visibleSources.length} / {dataSourceCatalog.length}
+
+
{endpointCatalogLoading && !visibleSources.length ? (
{translate('::App.DeveloperKitComponentDesigner.LoadingSavedEndpoints')}
) : visibleSources.length ? (
- visibleSources.map((item) => (
-
-
-
- {item.source.name}
-
-
- {item.source.method} {item.source.url}
-
- {!compact && (
-
-
{item.origin}
- {item.attached && (
-
- Ekli
-
- )}
+ // 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.
+
+ {visibleSources.map((item) => (
+
+
+
+ {item.source.name}
- )}
+
+ {item.source.method} {item.source.url}
+
+ {!compact && (
+
+ {item.origin}
+ {item.attached && (
+
+ Ekli
+
+ )}
+
+ )}
+
+
+ addReusableDataSource(item)}
+ >
+ {translate('::App.DeveloperKitComponentDesigner.Use')}
+
+ {item.originType === 'component' && (
+ <>
+ openCatalogSourceEditor(item)}
+ >
+
+
+ void deleteCatalogSource(item)}
+ >
+
+
+ >
+ )}
+
-
- addReusableDataSource(item)}
- >
- {translate('::App.DeveloperKitComponentDesigner.Use')}
-
- {item.originType === 'component' && (
- <>
- openCatalogSourceEditor(item)}
- >
-
-
- void deleteCatalogSource(item)}
- >
-
-
- >
- )}
-
-
- ))
+ ))}
+
) : (
- {translate('::App.DeveloperKitComponentDesigner.NoMoreEndpoints')}
+ {translate(
+ hasFilter
+ ? '::App.DeveloperKitComponentDesigner.NoMatchingEndpoint'
+ : '::App.DeveloperKitComponentDesigner.NoMoreEndpoints',
+ )}
)}
{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 (
+
+
+ {translate('::App.DeveloperKitComponentDesigner.ColumnDisplay')}
+
+
+ {translate('::App.DeveloperKitComponentDesigner.ColumnDisplayHint')}
+
+
+ {translate('::App.DeveloperKitComponentDesigner.ColumnCaptionHint')}
+
+
+ {columns.map((column) => {
+ const lookup = gridColumnLookups[column]
+ const collectionPaths = lookup?.sourceId ? getLookupCollectionPaths(lookup.sourceId) : []
+ const rowFields = lookup?.sourceId
+ ? getLookupRowFields(lookup.sourceId, lookup.path)
+ : []
+ return (
+
+
+ {column}
+
+ {/* 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. */}
+
writeColumnCaption(column, event.target.value)}
+ />
+
+ writeColumnLookup(column, lookup ? null : {})}
+ />
+
+ {translate('::App.DeveloperKitComponentDesigner.ShowRelatedText')}
+
+
+ {lookup && (
+
+
selectColumnLookupSource(column, event.target.value)}
+ >
+
+ {translate('::App.DeveloperKitComponentDesigner.SelectEndpoint')}
+
+ {bindableDataSources.map((source) => (
+
+ {source.name}
+
+ ))}
+
+ {lookup.sourceId && collectionPaths.length > 1 && (
+
+ writeColumnLookup(column, {
+ path: event.target.value,
+ valueField: '',
+ textField: '',
+ })
+ }
+ >
+
+ {translate('::App.DeveloperKitComponentDesigner.SelectCollection')}
+
+ {collectionPaths.map((path) => (
+
+ {path ||
+ translate('::App.DeveloperKitComponentDesigner.WholeResponseArray')}
+
+ ))}
+
+ )}
+ {lookup.sourceId && (
+
+ {(
+ [
+ ['valueField', '::App.DeveloperKitComponentDesigner.LookupValueField'],
+ ['textField', '::App.DeveloperKitComponentDesigner.LookupTextField'],
+ ] as const
+ ).map(([field, label]) => (
+
+
+ {translate(label)}
+
+
+ writeColumnLookup(column, { [field]: event.target.value })
+ }
+ >
+
+ {rowFields.length
+ ? translate('::App.DeveloperKitComponentDesigner.SelectField')
+ : translate(
+ '::App.DeveloperKitComponentDesigner.NoColumnInResponse',
+ )}
+
+ {rowFields.map((path) => (
+
+ {path}
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ )
+ })}
+
+
+ )
+ }
+
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()}
)
@@ -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 = () => {
)}
+ {isTabularDataComponent(selectedNode?.type) && renderColumnLookupConfiguration()}
+
{!isOptionDataComponent(selectedNode?.type) &&
!isTabularDataComponent(selectedNode?.type) && (
@@ -4817,7 +5217,7 @@ const VisualComponentDesigner = () => {
{translate('::App.DeveloperKitComponentDesigner.EndpointSettingsHint')}
- {(reusableDataSources.length > 0 || endpointCatalogError) && (
+ {(dataSourceCatalog.length > 0 || endpointCatalogError) && (