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>
......
...@@ -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 }
......