d6d8f38c by Ean Schuessler

Improve ARIA grid rendering and action result feedback

- Fix column extraction to include fields with both search and display widgets
- Add all ID fields to row ref as pipe-delimited key=value pairs for unambiguous LLM parsing
- Show NULL values as '(none)' in grid cells for consistency
- Add ARIA-compliant action result feedback (role=alert for errors, role=status for success)
- Pass actionResult through BrowseScreens ARIA mode
- Hide batch_operations tool until fixed
- Default renderMode to 'aria' instead of 'compact'
1 parent 33352aca
...@@ -154,6 +154,7 @@ ...@@ -154,6 +154,7 @@
154 154
155 <#-- Evaluate any 'set' nodes from widget-template-include before getting options --> 155 <#-- Evaluate any 'set' nodes from widget-template-include before getting options -->
156 <#-- These set variables like enumTypeId needed by entity-options --> 156 <#-- These set variables like enumTypeId needed by entity-options -->
157 <#-- Note: set nodes are appended to fieldSubNode after template expansion -->
157 <#assign setNodes = fieldSubNode["set"]!> 158 <#assign setNodes = fieldSubNode["set"]!>
158 <#list setNodes as setNode> 159 <#list setNodes as setNode>
159 <#if setNode["@field"]?has_content> 160 <#if setNode["@field"]?has_content>
...@@ -162,18 +163,18 @@ ...@@ -162,18 +163,18 @@
162 </#list> 163 </#list>
163 <#-- Get dropdown options - pass the drop-down node, not fieldSubNode --> 164 <#-- Get dropdown options - pass the drop-down node, not fieldSubNode -->
164 <#assign dropdownOptions = sri.getFieldOptions(dropdownNode)!> 165 <#assign dropdownOptions = sri.getFieldOptions(dropdownNode)!>
166 <#assign skipTruncation = (ec.context.mcpFullOptions!false) == true>
165 <#if (dropdownOptions?size!0) gt 0> 167 <#if (dropdownOptions?size!0) gt 0>
166 <#-- Build options list from the LinkedHashMap --> 168 <#-- Build options list from the LinkedHashMap -->
167 <#-- Truncate if > 10 unless mcpFullOptions is set (for get_screen_details) --> 169 <#-- Truncate if > 10 unless mcpFullOptions is set (for get_screen_details) -->
168 <#assign optionsList = []> 170 <#assign optionsList = []>
169 <#assign totalOptions = dropdownOptions?size> 171 <#assign totalOptions = dropdownOptions?size>
170 <#assign skipTruncation = (ec.context.mcpFullOptions!false) == true>
171 <#assign optionLimit = skipTruncation?then(999999, 10)> 172 <#assign optionLimit = skipTruncation?then(999999, 10)>
172 <#assign optionCount = 0> 173 <#assign optionCount = 0>
173 <#list (dropdownOptions.keySet())! as optKey> 174 <#-- Use entrySet() to iterate Java LinkedHashMap - avoids FreeMarker exposing method names as keys -->
175 <#list dropdownOptions.entrySet() as entry>
174 <#if optionCount lt optionLimit> 176 <#if optionCount lt optionLimit>
175 <#assign optLabel = (dropdownOptions.get(optKey))!optKey> 177 <#assign optionsList = optionsList + [{"value": entry.getKey(), "label": entry.getValue()}]>
176 <#assign optionsList = optionsList + [{"value": optKey, "label": optLabel}]>
177 </#if> 178 </#if>
178 <#assign optionCount = optionCount + 1> 179 <#assign optionCount = optionCount + 1>
179 </#list> 180 </#list>
......
...@@ -228,8 +228,8 @@ ...@@ -228,8 +228,8 @@
228 def internalToolMappings = [ 228 def internalToolMappings = [
229 "moqui_search_screens": "McpServices.mcp#SearchScreens", 229 "moqui_search_screens": "McpServices.mcp#SearchScreens",
230 "moqui_get_screen_details": "McpServices.mcp#GetScreenDetails", 230 "moqui_get_screen_details": "McpServices.mcp#GetScreenDetails",
231 "moqui_get_help": "McpServices.mcp#GetHelp", 231 "moqui_get_help": "McpServices.mcp#GetHelp"
232 "moqui_batch_operations": "McpServices.mcp#BatchOperations" 232 // "moqui_batch_operations": "McpServices.mcp#BatchOperations" - hidden until fixed
233 ] 233 ]
234 234
235 def targetServiceName = internalToolMappings[name] 235 def targetServiceName = internalToolMappings[name]
...@@ -601,7 +601,7 @@ ...@@ -601,7 +601,7 @@
601 <parameter name="path" required="true"/> 601 <parameter name="path" required="true"/>
602 <parameter name="parameters" type="Map"><description>Parameters to pass to screen</description></parameter> 602 <parameter name="parameters" type="Map"><description>Parameters to pass to screen</description></parameter>
603 <parameter name="action"><description>Action being processed: if not null, use real screen rendering instead of test mock</description></parameter> 603 <parameter name="action"><description>Action being processed: if not null, use real screen rendering instead of test mock</description></parameter>
604 <parameter name="renderMode" default="compact"><description>Render mode: compact (default), aria (accessibility tree), text, html</description></parameter> 604 <parameter name="renderMode" default="aria"><description>Render mode: aria (default, accessibility tree), text, html</description></parameter>
605 <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter> 605 <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter>
606 </in-parameters> 606 </in-parameters>
607 <out-parameters> 607 <out-parameters>
...@@ -967,31 +967,75 @@ def convertToAriaTree = { semanticState, targetScreenPath -> ...@@ -967,31 +967,75 @@ def convertToAriaTree = { semanticState, targetScreenPath ->
967 } 967 }
968 968
969 // Add column info (only non-searchable display columns) 969 // Add column info (only non-searchable display columns)
970 def displayColumns = formData.fields?.findAll { !it.searchable }?.collect { it.title ?: it.name } 970 def displayColumns = formData.fields?.findAll { it.type != "hidden" && it.type != "display" && it.type != "link" && !it.name?.endsWith("Button") }?.collect { it.title ?: it.name }
971 if (displayColumns) gridNode.columns = displayColumns 971 if (displayColumns) gridNode.columns = displayColumns
972 972
973 // Add all rows with detail (no truncation - model needs complete data) 973 // Add all rows with cell values (like Playwright accessibility tree)
974 if (listData instanceof List && listData.size() > 0) { 974 if (listData instanceof List && listData.size() > 0) {
975 gridNode.children = [] 975 gridNode.children = []
976
977 // Get display field definitions (non-hidden, non-display, non-link fields)
978 def displayFields = formData.fields?.findAll {
979 it.type != 'hidden' && it.type != 'display' && it.type != 'link' &&
980 it.name != 'submitButton' && !it.name?.endsWith('Button')
981 } ?: []
982
976 listData.each { row -> 983 listData.each { row ->
977 def rowNode = [role: "row"] 984 def rowNode = [role: "row"]
978 // Extract display ID - prefer human-readable pseudoId for display 985
979 def displayId = row.pseudoId ?: row.toProductId ?: row.productFeatureId ?: 986 // Build cells array with actual values
980 row.partyId ?: row.orderId ?: row.id 987 def cells = []
981 // Avoid using productId as ref when it's the same for all rows (e.g., feature list) 988 displayFields.each { field ->
982 if (!displayId && row.productId) { 989 def fieldName = field.name
983 // Only use productId if there's no better identifier 990 def rawValue = row[fieldName]
984 displayId = row.productId 991 def cellNode = [role: "gridcell", name: field.title ?: fieldName]
992
993 // Resolve enum values to display descriptions, show empty when null
994 if (fieldName.endsWith('EnumId')) {
995 if (rawValue) {
996 def enumVal = ec.entity.find("moqui.basic.Enumeration")
997 .condition("enumId", rawValue).useCache(true).one()
998 cellNode.value = enumVal?.description ?: rawValue
999 } else {
1000 cellNode.value = "(none)"
1001 }
1002 } else {
1003 cellNode.value = rawValue != null && rawValue != '' ? rawValue.toString() : "(none)"
1004 }
1005 cells << cellNode
985 } 1006 }
1007 if (cells) rowNode.children = cells
1008
1009 // Extract display ID for human-readable name
1010 def displayId = row.pseudoId ?: row.toProductId ?: row.productFeatureId ?:
1011 row.partyId ?: row.orderId ?: row.id ?: row.productId
1012
986 def name = row.combinedName ?: row.name ?: row.productName ?: 1013 def name = row.combinedName ?: row.name ?: row.productName ?:
987 row.description ?: row.abbrev 1014 row.description ?: row.abbrev
988 if (displayId) rowNode.ref = displayId 1015
1016 // Build ref from all ID fields in the row data
1017 // Ideally we'd use hidden fields from form metadata, but form-list hidden fields
1018 // aren't reliably captured yet. Using *Id heuristic as pragmatic fallback.
1019 // All fields use key=value format for unambiguous parsing by LLMs.
1020 def pkFields = []
1021 row.keySet().sort().each { fieldName ->
1022 if (fieldName.endsWith('Id')) {
1023 def pkValue = row[fieldName]
1024 if (pkValue) {
1025 pkFields << "${fieldName}=${pkValue}"
1026 }
1027 }
1028 }
1029
1030 // Build ref as pipe-delimited key=value pairs
1031 if (pkFields) {
1032 rowNode.ref = pkFields.join(' | ')
1033 } else if (displayId) {
1034 rowNode.ref = displayId.toString()
1035 }
989 if (name) rowNode.name = name 1036 if (name) rowNode.name = name
990 if (displayId && name && displayId != name) rowNode.description = displayId
991 1037
992 // Extract the primary key from the row's link URL (if any) 1038 // Extract primary key from row's link URL (actual entity ID for service calls)
993 // This is the actual entity ID that services need, which may differ from pseudoId
994 // Match by displayId in path OR by link text (handles pseudoId != productId case)
995 def rowLink = data.links?.find { link -> 1039 def rowLink = data.links?.find { link ->
996 link.type == "navigation" && link.path?.contains("Edit") && ( 1040 link.type == "navigation" && link.path?.contains("Edit") && (
997 link.path?.contains(displayId?.toString()) || 1041 link.path?.contains(displayId?.toString()) ||
...@@ -1110,6 +1154,17 @@ def convertToCompactFormat = { semanticState, targetScreenPath -> ...@@ -1110,6 +1154,17 @@ def convertToCompactFormat = { semanticState, targetScreenPath ->
1110 def actions = semanticState.actions ?: [] 1154 def actions = semanticState.actions ?: []
1111 def params = semanticState.parameters ?: [:] 1155 def params = semanticState.parameters ?: [:]
1112 1156
1157 // Cache for enum lookups to avoid repeated queries
1158 def enumCache = [:]
1159 def resolveEnumValue = { enumId ->
1160 if (!enumId) return null
1161 if (enumCache.containsKey(enumId)) return enumCache[enumId]
1162 def enumVal = ec.entity.find("moqui.basic.Enumeration").condition("enumId", enumId).useCache(true).one()
1163 def desc = enumVal?.description ?: enumId
1164 enumCache[enumId] = desc
1165 return desc
1166 }
1167
1113 def result = [ 1168 def result = [
1114 screen: targetScreenPath?.split('/')?.last() ?: "Screen" 1169 screen: targetScreenPath?.split('/')?.last() ?: "Screen"
1115 ] 1170 ]
...@@ -1218,8 +1273,8 @@ def convertToCompactFormat = { semanticState, targetScreenPath -> ...@@ -1218,8 +1273,8 @@ def convertToCompactFormat = { semanticState, targetScreenPath ->
1218 searchParams << paramInfo 1273 searchParams << paramInfo
1219 } 1274 }
1220 1275
1221 // Column names (display columns only, not search fields) 1276 // Column names (all non-hidden display fields - include fields even if they have search filters)
1222 def columns = formData.fields?.findAll { it.type != "hidden" && !it.searchable }?.collect { it.title ?: it.name } 1277 def columns = formData.fields?.findAll { it.type != "hidden" && it.type != "display" && it.type != "link" && !it.name?.endsWith("Button") }?.collect { it.title ?: it.name }
1223 if (columns) gridInfo.columns = columns 1278 if (columns) gridInfo.columns = columns
1224 1279
1225 // Rows with key data and links 1280 // Rows with key data and links
...@@ -1243,44 +1298,24 @@ def convertToCompactFormat = { semanticState, targetScreenPath -> ...@@ -1243,44 +1298,24 @@ def convertToCompactFormat = { semanticState, targetScreenPath ->
1243 if (displayId) rowInfo.id = displayId 1298 if (displayId) rowInfo.id = displayId
1244 if (name && name != displayId) rowInfo.name = name 1299 if (name && name != displayId) rowInfo.name = name
1245 1300
1246 // Add key display values (2-3 additional fields beyond id/name) 1301 // Add 3-4 additional display fields using form's column order
1247 // Priority: status, type, date, amount, role, class fields 1302 // This respects the screen designer's intent as defined in form-list-column
1248 def keyFieldPriority = [
1249 'statusId', 'status', 'productTypeEnumId', 'productAssocTypeEnumId',
1250 'communicationEventTypeId', 'roleTypeId', 'partyClassificationId',
1251 'orderPartStatusId', 'placedDate', 'entryDate', 'fromDate', 'thruDate',
1252 'grandTotal', 'quantity', 'price', 'amount',
1253 'username', 'emailAddress', 'fromPartyId', 'toPartyId'
1254 ]
1255
1256 def extraFields = [:] 1303 def extraFields = [:]
1257 def extraCount = 0 1304 def extraCount = 0
1258 for (fieldName in keyFieldPriority) { 1305 for (fieldDef in formData.fields?.take(6)) {
1259 if (extraCount >= 3) break 1306 if (extraCount >= 4) break
1307 def fieldName = fieldDef.name
1308 if (fieldDef.type == 'hidden' || fieldDef.type == 'display') continue
1309 if (fieldName in ['id', 'pseudoId', 'name', 'productId', 'partyId', 'submitButton', 'removeButton']) continue
1260 def value = row[fieldName] 1310 def value = row[fieldName]
1261 if (value != null && value != '' && value != id && value != name) { 1311 if (value != null && value != '' && value != id && value != name) {
1262 // Simplify enumId suffixes for display 1312 // Resolve enum values to display descriptions
1263 def displayKey = fieldName.replaceAll(/EnumId$/, '').replaceAll(/Id$/, '') 1313 def displayValue = fieldName.endsWith('EnumId') ? resolveEnumValue(value) : value.toString()
1264 extraFields[displayKey] = value.toString() 1314 extraFields[fieldName.replaceAll(/EnumId$/, '').replaceAll(/Id$/, '')] = displayValue
1265 extraCount++ 1315 extraCount++
1266 } 1316 }
1267 } 1317 }
1268 1318
1269 // If no priority fields found, add first 2-3 non-empty visible fields
1270 if (extraCount == 0) {
1271 for (fieldDef in formData.fields?.take(8)) {
1272 if (extraCount >= 3) break
1273 def fieldName = fieldDef.name
1274 if (fieldDef.type == 'hidden') continue
1275 if (fieldName in ['id', 'pseudoId', 'name', 'productId', 'partyId', 'submitButton']) continue
1276 def value = row[fieldName]
1277 if (value != null && value != '' && value != id && value != name) {
1278 extraFields[fieldName] = value.toString()
1279 extraCount++
1280 }
1281 }
1282 }
1283
1284 if (extraFields) rowInfo.data = extraFields 1319 if (extraFields) rowInfo.data = extraFields
1285 1320
1286 // Find link for this row and extract the primary key for service calls 1321 // Find link for this row and extract the primary key for service calls
...@@ -1298,7 +1333,7 @@ def convertToCompactFormat = { semanticState, targetScreenPath -> ...@@ -1298,7 +1333,7 @@ def convertToCompactFormat = { semanticState, targetScreenPath ->
1298 def linkPath = (editLink ?: rowLinks[0]).path 1333 def linkPath = (editLink ?: rowLinks[0]).path
1299 rowInfo.link = linkPath 1334 rowInfo.link = linkPath
1300 1335
1301 // Extract the primary key from the link URL (e.g., productId=100204) 1336 // Extract primary key from link URL (actual entity ID for service calls)
1302 // This is the actual entity ID that services need, which may differ from pseudoId 1337 // This is the actual entity ID that services need, which may differ from pseudoId
1303 def matcher = linkPath =~ /(\w+Id)=([^&]+)/ 1338 def matcher = linkPath =~ /(\w+Id)=([^&]+)/
1304 if (matcher.find()) { 1339 if (matcher.find()) {
...@@ -1912,6 +1947,28 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1912,6 +1947,28 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1912 } else if (renderMode == "aria" && semanticState) { 1947 } else if (renderMode == "aria" && semanticState) {
1913 // Return ARIA accessibility tree format 1948 // Return ARIA accessibility tree format
1914 def ariaTree = convertToAriaTree(semanticState, screenPath) 1949 def ariaTree = convertToAriaTree(semanticState, screenPath)
1950
1951 // Add action result as ARIA status/alert node (per ARIA live region roles)
1952 // role="alert" for errors (urgent), role="status" for success (advisory)
1953 if (actionResult) {
1954 def statusNode = [
1955 role: actionResult.status == "error" ? "alert" : "status",
1956 name: actionResult.action,
1957 description: actionResult.message
1958 ]
1959 if (actionResult.validationErrors) {
1960 statusNode.children = actionResult.validationErrors.collect { ve ->
1961 [role: "listitem", name: ve.field ?: "error", description: ve.message]
1962 }
1963 }
1964 // Insert at beginning of children so it's immediately visible
1965 if (ariaTree.children) {
1966 ariaTree.children = [statusNode] + ariaTree.children
1967 } else {
1968 ariaTree.children = [statusNode]
1969 }
1970 }
1971
1915 def ariaResult = [ 1972 def ariaResult = [
1916 screenPath: screenPath, 1973 screenPath: screenPath,
1917 aria: ariaTree 1974 aria: ariaTree
...@@ -1923,11 +1980,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -1923,11 +1980,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
1923 if (summary) ariaResult.summary = summary 1980 if (summary) ariaResult.summary = summary
1924 } 1981 }
1925 1982
1926 // Add action result if an action was executed
1927 if (actionResult) {
1928 ariaResult.actionResult = actionResult
1929 }
1930
1931 content << [ 1983 content << [
1932 type: "text", 1984 type: "text",
1933 text: new groovy.json.JsonBuilder(ariaResult).toString() 1985 text: new groovy.json.JsonBuilder(ariaResult).toString()
...@@ -2266,11 +2318,11 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -2266,11 +2318,11 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
2266 </service> 2318 </service>
2267 2319
2268 <service verb="mcp" noun="BrowseScreens" authenticate="false" allow-remote="true" transaction-timeout="60"> 2320 <service verb="mcp" noun="BrowseScreens" authenticate="false" allow-remote="true" transaction-timeout="60">
2269 <description>Browse Moqui screens hierarchically to discover functionality. Renders screen content with renderMode='mcp' by default. Supports action parameter for form submission and transitions.</description> 2321 <description>Browse Moqui screens hierarchically to discover functionality. Renders screen content as ARIA accessibility tree by default. Supports action parameter for form submission and transitions.</description>
2270 <in-parameters> 2322 <in-parameters>
2271 <parameter name="path" required="false"><description>Screen path to browse (e.g. 'PopCommerce'). Leave empty for root apps.</description></parameter> 2323 <parameter name="path" required="false"><description>Screen path to browse (e.g. 'PopCommerce'). Leave empty for root apps.</description></parameter>
2272 <parameter name="action"><description>Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name</description></parameter> 2324 <parameter name="action"><description>Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name</description></parameter>
2273 <parameter name="renderMode" default="compact"><description>Render mode: compact (default), aria (accessibility tree), text, html</description></parameter> 2325 <parameter name="renderMode" default="aria"><description>Render mode: aria (default, accessibility tree), text, html</description></parameter>
2274 <parameter name="parameters" type="Map"><description>Parameters to pass to screen during rendering or action</description></parameter> 2326 <parameter name="parameters" type="Map"><description>Parameters to pass to screen during rendering or action</description></parameter>
2275 <parameter name="sessionId"/> 2327 <parameter name="sessionId"/>
2276 </in-parameters> 2328 </in-parameters>
...@@ -2549,22 +2601,13 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -2549,22 +2601,13 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
2549 // Render current screen if not root browsing 2601 // Render current screen if not root browsing
2550 def renderedContent = null 2602 def renderedContent = null
2551 def renderError = null 2603 def renderError = null
2552 def actualRenderMode = renderMode ?: "compact" 2604 def actualRenderMode = renderMode ?: "aria"
2553 2605
2554 def resultMap = [ 2606 def resultMap = [
2555 currentPath: currentPath, 2607 currentPath: currentPath,
2556 subscreens: subscreens, 2608 subscreens: subscreens,
2557 renderMode: actualRenderMode 2609 renderMode: actualRenderMode
2558 ] 2610 ]
2559
2560 // Add global navigation - these are always available regardless of current app
2561 // Mirrors the MyAccountNav component in the web UI
2562 resultMap.globalNav = [
2563 [name: "My Notifications", path: "apps/my/User/Notifications", icon: "info"],
2564 [name: "My Messages", path: "apps/my/User/Messages/FindMessage", icon: "message"],
2565 [name: "My Calendar", path: "apps/my/User/Calendar/MyCalendar", icon: "calendar"],
2566 [name: "My Tasks", path: "apps/my/User/Task/MyTasks", icon: "tasks"]
2567 ]
2568 2611
2569 if (currentPath != "root") { 2612 if (currentPath != "root") {
2570 try { 2613 try {
...@@ -2611,6 +2654,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -2611,6 +2654,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
2611 } else if (actualRenderMode == "aria" && resultObj && resultObj.aria) { 2654 } else if (actualRenderMode == "aria" && resultObj && resultObj.aria) {
2612 resultMap.aria = resultObj.aria 2655 resultMap.aria = resultObj.aria
2613 if (resultObj.summary) resultMap.summary = resultObj.summary 2656 if (resultObj.summary) resultMap.summary = resultObj.summary
2657 if (resultObj.actionResult) resultMap.actionResult = resultObj.actionResult
2614 ec.logger.info("BrowseScreens: ARIA mode - passing through aria tree for ${currentPath}") 2658 ec.logger.info("BrowseScreens: ARIA mode - passing through aria tree for ${currentPath}")
2615 } else if (resultObj && resultObj.semanticState) { 2659 } else if (resultObj && resultObj.semanticState) {
2616 resultMap.semanticState = resultObj.semanticState 2660 resultMap.semanticState = resultObj.semanticState
...@@ -2887,13 +2931,13 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -2887,13 +2931,13 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
2887 [ 2931 [
2888 name: "moqui_browse_screens", 2932 name: "moqui_browse_screens",
2889 title: "Browse Screens", 2933 title: "Browse Screens",
2890 description: "Browse Moqui screen hierarchy, process actions, and render screen content. Input 'path' (empty for root). Default renderMode is 'compact'.", 2934 description: "Browse Moqui screen hierarchy, process actions, and render screen content. Input 'path' (empty for root). Default renderMode is 'aria'.",
2891 inputSchema: [ 2935 inputSchema: [
2892 type: "object", 2936 type: "object",
2893 properties: [ 2937 properties: [
2894 "path": [type: "string", description: "Path to browse (e.g. 'PopCommerce')"], 2938 "path": [type: "string", description: "Path to browse (e.g. 'PopCommerce')"],
2895 "action": [type: "string", description: "Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name"], 2939 "action": [type: "string", description: "Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name"],
2896 "renderMode": [type: "string", description: "Render mode: compact (default), aria (accessibility tree), text, html"], 2940 "renderMode": [type: "string", description: "Render mode: aria (default, accessibility tree), text, html"],
2897 "parameters": [type: "object", description: "Parameters to pass to screen during rendering or action"] 2941 "parameters": [type: "object", description: "Parameters to pass to screen during rendering or action"]
2898 ] 2942 ]
2899 ] 2943 ]
...@@ -2936,33 +2980,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) ...@@ -2936,33 +2980,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
2936 required: ["uri"] 2980 required: ["uri"]
2937 ] 2981 ]
2938 ], 2982 ],
2939 [ 2983 // moqui_batch_operations - hidden until fixed
2940 name: "moqui_batch_operations", 2984 //
2941 title: "Batch Operations",
2942 description: "Execute multiple screen operations in sequence. Stops on first error. Returns results for each operation.",
2943 inputSchema: [
2944 type: "object",
2945 properties: [
2946 "operations": [
2947 type: "array",
2948 description: "Array of operations to execute in sequence",
2949 items: [
2950 type: "object",
2951 properties: [
2952 "id": [type: "string", description: "Optional operation identifier for result tracking"],
2953 "path": [type: "string", description: "Screen path"],
2954 "action": [type: "string", description: "Action/transition to execute"],
2955 "parameters": [type: "object", description: "Parameters for the action"]
2956 ],
2957 required: ["path", "action"]
2958 ]
2959 ],
2960 "stopOnError": [type: "boolean", description: "Stop execution on first error (default: true)"],
2961 "returnLastOnly": [type: "boolean", description: "Return only last operation result (default: false)"]
2962 ],
2963 required: ["operations"]
2964 ]
2965 ],
2966 [ 2985 [
2967 name: "prompts_list", 2986 name: "prompts_list",
2968 title: "List Prompts", 2987 title: "List Prompts",
......
...@@ -17,9 +17,7 @@ import groovy.json.JsonSlurper ...@@ -17,9 +17,7 @@ import groovy.json.JsonSlurper
17 class McpFieldOptionsService { 17 class McpFieldOptionsService {
18 18
19 static service(String path, String fieldName, Map parameters, ExecutionContext ec) { 19 static service(String path, String fieldName, Map parameters, ExecutionContext ec) {
20 ec.logger.info("======== MCP GetScreenDetails CALLED - CODE VERSION 3 (ScreenTest) =======")
21 if (!path) throw new IllegalArgumentException("path is required") 20 if (!path) throw new IllegalArgumentException("path is required")
22 ec.logger.info("MCP GetScreenDetails: screen ${path}, field ${fieldName ?: 'all'}")
23 21
24 def result = [screenPath: path, fields: [:]] 22 def result = [screenPath: path, fields: [:]]
25 try { 23 try {
...@@ -30,15 +28,13 @@ class McpFieldOptionsService { ...@@ -30,15 +28,13 @@ class McpFieldOptionsService {
30 .parameters([path: path, parameters: mergedParams, renderMode: "mcp", sessionId: null]) 28 .parameters([path: path, parameters: mergedParams, renderMode: "mcp", sessionId: null])
31 .call() 29 .call()
32 30
33 ec.logger.info("=== browseResult: ${browseResult != null}, result exists: ${browseResult?.result != null} ===")
34
35 if (!browseResult?.result?.content) { 31 if (!browseResult?.result?.content) {
36 ec.logger.warn("No content from ScreenAsMcpTool") 32 ec.logger.warn("GetScreenDetails: No content from ScreenAsMcpTool for path ${path}")
37 return result + [error: "No content from ScreenAsMcpTool"] 33 return result + [error: "No content from ScreenAsMcpTool"]
38 } 34 }
39 def rawText = browseResult.result.content[0].text 35 def rawText = browseResult.result.content[0].text
40 if (!rawText || !rawText.startsWith("{")) { 36 if (!rawText || !rawText.startsWith("{")) {
41 ec.logger.warn("Invalid JSON from ScreenAsMcpTool") 37 ec.logger.warn("GetScreenDetails: Invalid JSON from ScreenAsMcpTool for path ${path}")
42 return result + [error: "Invalid JSON from ScreenAsMcpTool"] 38 return result + [error: "Invalid JSON from ScreenAsMcpTool"]
43 } 39 }
44 40
...@@ -47,14 +43,13 @@ class McpFieldOptionsService { ...@@ -47,14 +43,13 @@ class McpFieldOptionsService {
47 def formMetadata = semanticState?.data?.formMetadata 43 def formMetadata = semanticState?.data?.formMetadata
48 44
49 if (!(formMetadata instanceof Map)) { 45 if (!(formMetadata instanceof Map)) {
50 ec.logger.warn("formMetadata is not a Map: ${formMetadata?.class}") 46 ec.logger.warn("GetScreenDetails: formMetadata is not a Map for path ${path}")
51 return result + [error: "No form metadata found"] 47 return result + [error: "No form metadata found"]
52 } 48 }
53 49
54 def allFields = [:] 50 def allFields = [:]
55 ec.logger.info("=== Processing formMetadata with ${formMetadata.size()} forms ===") 51
56 formMetadata.each { formName, formItem -> 52 formMetadata.each { formName, formItem ->
57 ec.logger.info("=== Processing form: ${formName}, hasFields: ${formItem?.fields != null} ===")
58 if (!(formItem instanceof Map) || !formItem.fields) return 53 if (!(formItem instanceof Map) || !formItem.fields) return
59 formItem.fields.each { field -> 54 formItem.fields.each { field ->
60 if (!(field instanceof Map) || !field.name) return 55 if (!(field instanceof Map) || !field.name) return
...@@ -70,14 +65,27 @@ class McpFieldOptionsService { ...@@ -70,14 +65,27 @@ class McpFieldOptionsService {
70 def dynamicOptions = field.dynamicOptions 65 def dynamicOptions = field.dynamicOptions
71 if (dynamicOptions instanceof Map) { 66 if (dynamicOptions instanceof Map) {
72 fieldInfo.dynamicOptions = dynamicOptions 67 fieldInfo.dynamicOptions = dynamicOptions
73 ec.logger.info("Found dynamicOptions for field ${field.name}: ${dynamicOptions}")
74 try { 68 try {
75 fetchOptions(fieldInfo, path, parameters, dynamicOptions, ec) 69 fetchOptions(fieldInfo, path, parameters, dynamicOptions, ec)
76 } catch (Exception e) { 70 } catch (Exception e) {
77 ec.logger.warn("Failed to fetch options for ${field.name}: ${e.message}", e) 71 ec.logger.warn("GetScreenDetails: Failed to fetch options for ${field.name}: ${e.message}")
78 fieldInfo.optionsError = e.message 72 fieldInfo.optionsError = e.message
79 } 73 }
80 } 74 }
75
76 // Merge fields with same name - prefer version with options
77 // This handles cases where a field appears in both search and edit forms
78 def existingField = allFields[field.name]
79 if (existingField) {
80 // Keep existing options if new field has none
81 if (existingField.options && !fieldInfo.options) {
82 fieldInfo.options = existingField.options
83 }
84 // Merge dynamicOptions if existing has them
85 if (existingField.dynamicOptions && !fieldInfo.dynamicOptions) {
86 fieldInfo.dynamicOptions = existingField.dynamicOptions
87 }
88 }
81 allFields[field.name] = fieldInfo 89 allFields[field.name] = fieldInfo
82 } 90 }
83 } 91 }
...@@ -102,12 +110,8 @@ class McpFieldOptionsService { ...@@ -102,12 +110,8 @@ class McpFieldOptionsService {
102 * and capture the raw JSON response - exactly how ScreenRenderImpl.getFieldOptions() works. 110 * and capture the raw JSON response - exactly how ScreenRenderImpl.getFieldOptions() works.
103 */ 111 */
104 private static void fetchOptions(Map fieldInfo, String path, Map parameters, Map dynamicOptions, ExecutionContext ec) { 112 private static void fetchOptions(Map fieldInfo, String path, Map parameters, Map dynamicOptions, ExecutionContext ec) {
105 ec.logger.info("=== fetchOptions START: ${fieldInfo.name} ===")
106 def transitionName = dynamicOptions.transition 113 def transitionName = dynamicOptions.transition
107 if (!transitionName) { 114 if (!transitionName) return
108 ec.logger.info("No transition specified for dynamic options")
109 return
110 }
111 115
112 def optionParams = [:] 116 def optionParams = [:]
113 117
...@@ -135,24 +139,17 @@ class McpFieldOptionsService { ...@@ -135,24 +139,17 @@ class McpFieldOptionsService {
135 } 139 }
136 } 140 }
137 141
138 // 2. Handle serverSearch fields 142 // 2. Handle serverSearch fields - skip if no search term provided (matches framework behavior)
139 // If serverSearch is true AND no term is provided, skip fetching (matches framework behavior)
140 // The framework's getFieldOptions() skips server-search fields entirely for initial load
141 def isServerSearch = dynamicOptions.serverSearch == true || dynamicOptions.serverSearch == "true" 143 def isServerSearch = dynamicOptions.serverSearch == true || dynamicOptions.serverSearch == "true"
142 if (isServerSearch) { 144 if (isServerSearch) {
143 if (parameters?.term != null && parameters.term.toString().length() > 0) { 145 if (parameters?.term != null && parameters.term.toString().length() > 0) {
144 optionParams.term = parameters.term 146 optionParams.term = parameters.term
145 } else { 147 } else {
146 // Skip fetching options for server-search fields without a term 148 return // Skip server-search fields without a term
147 ec.logger.info("Skipping server-search field ${fieldInfo.name} - no term provided")
148 return
149 } 149 }
150 } 150 }
151 151
152 // 3. Use CustomScreenTestImpl with skipJsonSerialize to call the transition 152 // 3. Use CustomScreenTestImpl with skipJsonSerialize to call the transition
153 // This is exactly how ScreenRenderImpl.getFieldOptions() works in the framework
154 ec.logger.info("Calling transition ${transitionName} via CustomScreenTestImpl with skipJsonSerialize=true, params: ${optionParams}")
155
156 try { 153 try {
157 def ecfi = (ExecutionContextFactoryImpl) ec.factory 154 def ecfi = (ExecutionContextFactoryImpl) ec.factory
158 155
...@@ -166,9 +163,6 @@ class McpFieldOptionsService { ...@@ -166,9 +163,6 @@ class McpFieldOptionsService {
166 fullPath.split('/').each { if (it && it.trim()) pathSegments.add(it) } 163 fullPath.split('/').each { if (it && it.trim()) pathSegments.add(it) }
167 164
168 // Component-based resolution (same as ScreenAsMcpTool) 165 // Component-based resolution (same as ScreenAsMcpTool)
169 // Path like "PopCommerce/PopCommerceAdmin/Party/FindParty/transition" becomes:
170 // - rootScreen: component://PopCommerce/screen/PopCommerceAdmin.xml
171 // - testScreenPath: Party/FindParty/transition
172 def rootScreen = "component://webroot/screen/webroot.xml" 166 def rootScreen = "component://webroot/screen/webroot.xml"
173 def testScreenPath = fullPath 167 def testScreenPath = fullPath
174 168
...@@ -178,7 +172,6 @@ class McpFieldOptionsService { ...@@ -178,7 +172,6 @@ class McpFieldOptionsService {
178 def compRootLoc = "component://${componentName}/screen/${rootScreenName}.xml" 172 def compRootLoc = "component://${componentName}/screen/${rootScreenName}.xml"
179 173
180 if (ec.resource.getLocationReference(compRootLoc).exists) { 174 if (ec.resource.getLocationReference(compRootLoc).exists) {
181 ec.logger.info("fetchOptions: Using component root: ${compRootLoc}")
182 rootScreen = compRootLoc 175 rootScreen = compRootLoc
183 testScreenPath = pathSegments.size() > 2 ? pathSegments[2..-1].join('/') : "" 176 testScreenPath = pathSegments.size() > 2 ? pathSegments[2..-1].join('/') : ""
184 } 177 }
...@@ -190,12 +183,10 @@ class McpFieldOptionsService { ...@@ -190,12 +183,10 @@ class McpFieldOptionsService {
190 .skipJsonSerialize(true) 183 .skipJsonSerialize(true)
191 .auth(ec.user.username) 184 .auth(ec.user.username)
192 185
193 ec.logger.info("Rendering transition path: ${testScreenPath} (from root: ${rootScreen})")
194 def str = screenTest.render(testScreenPath, optionParams, "GET") 186 def str = screenTest.render(testScreenPath, optionParams, "GET")
195 187
196 // Get JSON object directly (like web UI does) 188 // Get JSON object directly (like web UI does)
197 def jsonObj = str.getJsonObject() 189 def jsonObj = str.getJsonObject()
198 ec.logger.info("Transition returned jsonObj: ${jsonObj?.getClass()?.simpleName}, size: ${jsonObj instanceof Collection ? jsonObj.size() : 'N/A'}")
199 190
200 // Extract value-field and label-field from dynamic-options config 191 // Extract value-field and label-field from dynamic-options config
201 def valueField = dynamicOptions.valueField ?: dynamicOptions.'value-field' ?: 'value' 192 def valueField = dynamicOptions.valueField ?: dynamicOptions.'value-field' ?: 'value'
...@@ -239,20 +230,10 @@ class McpFieldOptionsService { ...@@ -239,20 +230,10 @@ class McpFieldOptionsService {
239 [value: entryObj, label: entryObj?.toString()] 230 [value: entryObj, label: entryObj?.toString()]
240 } 231 }
241 }.findAll { it.value != null } 232 }.findAll { it.value != null }
242
243 ec.logger.info("Successfully extracted ${fieldInfo.options.size()} autocomplete options via ScreenTest")
244 } else {
245 ec.logger.info("No options found in transition response")
246
247 // Check if there was output but no JSON (might be an error)
248 def output = str.getOutput()
249 if (output && output.length() > 0 && output.length() < 500) {
250 ec.logger.warn("Transition output (no JSON): ${output}")
251 }
252 } 233 }
253 234
254 } catch (Exception e) { 235 } catch (Exception e) {
255 ec.logger.warn("Error calling transition ${transitionName}: ${e.message}", e) 236 ec.logger.warn("GetScreenDetails: Error calling transition ${transitionName}: ${e.message}")
256 fieldInfo.optionsError = "Transition call failed: ${e.message}" 237 fieldInfo.optionsError = "Transition call failed: ${e.message}"
257 } 238 }
258 } 239 }
......