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'
Showing
3 changed files
with
143 additions
and
142 deletions
| ... | @@ -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 | } | ... | ... |
-
Please register or sign in to post a comment