diff --git a/docs/ui-contract-inventory/relational.fragment.json b/docs/ui-contract-inventory/relational.fragment.json index 16cf08f98..02ba0f81c 100644 --- a/docs/ui-contract-inventory/relational.fragment.json +++ b/docs/ui-contract-inventory/relational.fragment.json @@ -47,37 +47,37 @@ "basis": "backend-promise", "statement": "The UI describes one output row per unique combination of chosen group keys, with summary columns.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2274-2306" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2197-2229" ] }, { "basis": "observed-ui", "statement": "The client candidate builder does not enforce a non-empty key list, but requires at least one summary and valid unique output names.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:979-999" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:902-922" ] }, { "basis": "observed-ui", "statement": "COUNT_ROWS needs no field input; other summary kinds use compatible stage columns.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3311-3392" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3234-3315" ] }, { "basis": "backend-promise", "statement": "The selected missing-key policy is presented as running before summaries: GROUP combines absent/null keys, EXCLUDE removes rows missing any key, ERROR stops.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3301-3308", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3408-3427" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3224-3231", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3331-3350" ] }, { "basis": "observed-ui", "statement": "Related field choices are disabled when selected fields conflict across routes; coding identity cannot combine with semantic/coded keys.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:988-992", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3123-3168" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:911-915", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3046-3091" ] } ], @@ -86,14 +86,15 @@ "ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx:3251-3309", "ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx:3476-3500", "ui/packages/loom-ui/src/features/ExplorerBuilder/components/RowDefinitionSettingsPanel.tsx:872-897", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2270-2327", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:554-660", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:979-1022", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1962-1979", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3311-3392", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3404-3427", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2740-2782", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3842-3891", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2193-2250", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx:78-95", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:496-583", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:902-945", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1885-1902", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3234-3315", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3327-3350", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2663-2705", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx:213-262", "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionWorkspace/useConstructionLifecycle.ts:233-252", "ui/packages/loom-ui/src/api.ts:1674-1678" ] @@ -115,24 +116,24 @@ "basis": "observed-ui", "statement": "Every chosen source field becomes a group key; the default summary counts source records.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1082-1146" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1005-1069" ] }, { "basis": "observed-ui", "statement": "A source-field group can only be proposed from the source projection stage.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1127-1146", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1962-1971" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1050-1069", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1885-1894" ] } ], "evidence": [ "ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx:3476-3483", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2049-2066", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1082-1146", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1962-1971", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2308-2327" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1972-1989", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1005-1069", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1885-1894", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2231-2250" ], "semantic_domains": [ { @@ -152,8 +153,8 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2049-2066", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1082-1119" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1972-1989", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1005-1042" ] }, { @@ -172,8 +173,8 @@ "maximum": null }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1102-1110", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1127-1145" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1025-1033", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1050-1068" ] }, { @@ -191,7 +192,7 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1111-1119" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1034-1042" ] } ] @@ -227,9 +228,9 @@ "maximum": null }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2970-2975", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3051-3072", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3123-3169" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2893-2898", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2974-2995", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3046-3092" ] }, { @@ -248,7 +249,7 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3046-3072" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2969-2995" ] }, { @@ -267,7 +268,7 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:988-990" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:911-913" ] }, { @@ -289,8 +290,8 @@ "maximum": null }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3311-3341", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:985-998" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3234-3264", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:908-921" ] }, { @@ -310,7 +311,7 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3404-3427" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3327-3350" ] }, { @@ -333,8 +334,8 @@ "maximum": null }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2740-2782", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2811-2831" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2663-2705", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2734-2754" ] }, { @@ -356,8 +357,8 @@ "maximum": 50 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3635-3654", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3883-3890" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3558-3577", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx:254-261" ] }, { @@ -376,8 +377,8 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:255-261", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:988-998" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:251-257", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:911-921" ] } ], @@ -386,33 +387,33 @@ "basis": "backend-promise", "statement": "The UI describes one output row per unique combination of chosen group keys, with summary columns; a zero-key summary is not rejected by the client candidate builder.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2274-2306", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:979-999" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2197-2229", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:902-922" ] }, { "basis": "observed-ui", "statement": "The candidate builder requires at least one summary and validates unique physical output names; COUNT_ROWS has no field input, other summaries use compatible stage columns.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:985-998", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3311-3392" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:908-921", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3234-3315" ] }, { "basis": "backend-promise", "statement": "The selected missing-key policy is presented as running before summaries: GROUP combines absent/null keys, EXCLUDE removes rows missing any key, ERROR stops.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3301-3308", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3408-3427" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3224-3231", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3331-3350" ] }, { "basis": "observed-ui", "statement": "Group field choices using related occurrences are disabled when saved or selected fields conflict across routes; a coding-identity key cannot be combined with semantic/coded keys.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:988-992", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3123-3168", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3046-3072" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:911-915", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3046-3091", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2969-2995" ] } ] @@ -454,10 +455,10 @@ }, "evidence": [ "ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx:3483-3491", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:451-455", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3953-3968", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:447-451", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx:324-339", "ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx:3653-3665", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4182-4240" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3668-3726" ] }, { @@ -477,8 +478,8 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4035-4058", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4066-4071" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx:406-429", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx:437-442" ] }, { @@ -499,8 +500,8 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4000-4011", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:913-925" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx:371-382", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:836-848" ] }, { @@ -522,8 +523,8 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4012-4034", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:683-692" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx:383-405", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:606-615" ] }, { @@ -542,7 +543,7 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:663-692" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:586-615" ] } ], @@ -564,8 +565,8 @@ "ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx:3398-3450", "ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx:3653-3665", "ui/packages/loom-ui/src/features/ExplorerBuilder/components/RowDefinitionSettingsPanel.tsx:1011-1021", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2037-2039", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4182-4240" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1960-1962", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3668-3726" ] }, { @@ -583,25 +584,25 @@ "basis": "observed-ui", "statement": "The candidate requires exactly one selected list input and a selected empty-list policy; it requires valid unique output names.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:696-708", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1215-1227" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:619-631", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1138-1150" ] }, { "basis": "backend-promise", "statement": "The UI describes each list item as a row for its input record and repeats other fields. For empty or missing lists, ERROR stops, EXCLUDE drops the input row, or PRESERVE_PARENT keeps it with an empty item.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3986-3994", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3989-3998", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4035-4058" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx:357-365", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx:360-369", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx:406-429" ] }, { "basis": "backend-promise", "statement": "Optional item position output is a zero-based integer column; other current-stage columns remain as passthrough outputs.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:663-694", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4012-4034" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:586-617", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx:383-405" ] } ], @@ -611,10 +612,10 @@ "ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx:4454-4456", "ui/packages/loom-ui/src/features/ExplorerBuilder/components/PreviewTable.tsx:546-557", "ui/packages/loom-ui/src/features/ExplorerBuilder/components/PreviewTable.tsx:726-735", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:663-708", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1215-1227", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3953-3968", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3986-4058", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:586-631", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1138-1150", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx:324-339", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx:357-429", "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionWorkspace/useConstructionLifecycle.ts:233-252", "ui/packages/loom-ui/src/api.ts:1674-1678" ] @@ -638,24 +639,24 @@ "basis": "backend-promise", "statement": "The result has one row per observed code and exposes system, version, code, and source-record count.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2714-2714", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1182-1206" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2637-2637", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1105-1129" ] }, { "basis": "observed-ui", "statement": "Applying the step replaces current columns with the coded-group outputs; incomplete coding-path selections cannot be proposed.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1178-1180", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1182-1206" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1101-1103", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1105-1129" ] } ], "evidence": [ "ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx:3476-3478", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2308-2327", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1149-1206", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1977-1979" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2231-2250", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1072-1129", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1900-1902" ], "semantic_domains": [ { @@ -676,7 +677,7 @@ }, "evidence": [ "ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx:3476-3478", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1178-1180" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1101-1103" ] }, { @@ -695,8 +696,8 @@ "maximum": 4 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1160-1170", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1178-1206" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1083-1093", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1101-1129" ] }, { @@ -714,8 +715,8 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1160-1170", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2715-2734" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1083-1093", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2638-2657" ] } ], @@ -724,15 +725,15 @@ "basis": "observed-ui", "statement": "The form only creates a candidate when the selected codingPath matches a server choice and exactly four output columns are present.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1178-1180" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1101-1103" ] }, { "basis": "backend-promise", "statement": "The UI describes the result as one row per observed code with system, version, code, and source-record count; applying replaces current columns with these outputs.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2714-2714", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1182-1206" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2637-2637", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1105-1129" ] } ], @@ -926,8 +927,8 @@ "maximum": null }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4244-4250", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4323-4348" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3730-3736", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3809-3834" ] }, { @@ -945,7 +946,7 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4349-4355" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3835-3841" ] }, { @@ -963,7 +964,7 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4357-4363" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3843-3849" ] }, { @@ -986,8 +987,8 @@ }, "evidence": [ "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionWorkspace/useConstructionLifecycle.ts:377-425", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4375-4391", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1240-1245" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3861-3877", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1163-1168" ] }, { @@ -1008,7 +1009,7 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4454-4468" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3940-3954" ] }, { @@ -1025,7 +1026,7 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4470-4475" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3956-3961" ] }, { @@ -1047,7 +1048,7 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4477-4486" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3963-3972" ] }, { @@ -1065,8 +1066,8 @@ "maximum": null }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4242-4250", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1962-1989" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3728-3736", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1885-1912" ] } ], @@ -1075,9 +1076,9 @@ "basis": "observed-ui", "statement": "The Pivot editor owns an accessible Finding categories progress state for the current request identity during debounce and while its discovery owner has not published a matching terminal state or adopted result. It hides prior-pair categories, rejects superseded callbacks by revision/context/owner/stage identity, hides progress while disabled, and retains unrelated form choices across discovery failure and retry.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2130-2160", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2219-2234", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2363-2471", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2053-2083", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2142-2157", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2286-2394", "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.unit.test.tsx:4475-4633" ] }, @@ -1085,24 +1086,24 @@ "basis": "observed-ui", "statement": "At least one group key and included category are required; category/value roles differ; every discovered category must be selected to form a new pivot candidate.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1240-1245", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4381-4389", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1985-1989" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1163-1168", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3867-3875", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1908-1912" ] }, { "basis": "backend-promise", "statement": "Each selected category becomes an output column while group columns identify output rows; duplicate, missing-cell, and unlisted-category behavior is explicit.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:821-833", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4454-4486" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:744-756", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3940-3972" ] }, { "basis": "observed-ui", "statement": "Only 50 matching categories are rendered at once; no client selection-count maximum is imposed, and discovery may return a server limit.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4379-4391", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3865-3877", "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionWorkspace/useConstructionLifecycle.ts:419-425" ] } @@ -1110,9 +1111,9 @@ "evidence": [ "ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx:3486-3505", "ui/packages/loom-ui/src/features/ExplorerBuilder/components/RowDefinitionSettingsPanel.tsx:979-1000", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:821-833", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1228-1253", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1953-1994", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:744-756", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1151-1176", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1876-1917", "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionWorkspace/useConstructionLifecycle.ts:377-425", "ui/packages/loom-ui/src/api.ts:1652-1667", "ui/packages/loom-ui/src/api.ts:1674-1678" @@ -1248,9 +1249,9 @@ } ], "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2331-2343", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2254-2266", "ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx:3483-3505", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1040-1072", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:963-995", "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/CodedPivotEditor.tsx:108-127", "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/CodedPivotEditor.tsx:249-277" ], @@ -1288,8 +1289,8 @@ "maximum": null }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4561-4676", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1255-1277" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4047-4162", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1178-1200" ] }, { @@ -1313,8 +1314,8 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4651-4676", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1265-1270" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4137-4162", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1188-1193" ] }, { @@ -1333,8 +1334,8 @@ "maximum": 2 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:835-860", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1255-1277" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:758-783", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1178-1200" ] }, { @@ -1351,7 +1352,7 @@ "maximum": 1 }, "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4632-4635" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4118-4121" ] } ], @@ -1369,8 +1370,8 @@ "basis": "observed-ui", "statement": "Signed source pickers distinguish no listed fields, no populated eligible fields, and eligible populated fields already bound elsewhere. Own bindings count as selectable only when visible and solely bound in the lane; unavailable source-lane reasons stay separate. Existing live field carriers suppress duplicate signed choices only when authoritative field identity resolves unambiguously; stale or ambiguous carriers retain the signed-choice path, and saved bindings are not rewritten. The source-level explanation is derived independently of input rows, including when no inputs have been selected.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4896-4911", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4958-4970", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4382-4397", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:4444-4456", "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/existingFieldReuse.ts:22-44", "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/sourcePickerExplanations.ts:22-46", "ui/packages/loom-ui/src/features/ExplorerBuilder/components/SourcePickerExplanation.tsx:5-43" @@ -1380,7 +1381,7 @@ "basis": "observed-ui", "statement": "At least one scalar input is required; inputs are unique; NULL and MISSING cannot be key values; output names must be complete and unique.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1260-1270" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1183-1193" ] }, { @@ -1388,15 +1389,15 @@ "statement": "The UI describes one output row per selected input column, with source-name and value outputs; other columns repeat.", "evidence": [ "ui/packages/loom-ui/src/features/ExplorerBuilder/components/RowDefinitionSettingsPanel.tsx:1002-1013", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:835-860" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:758-783" ] } ], "evidence": [ "ui/packages/loom-ui/src/features/ExplorerBuilder/components/RowDefinitionSettingsPanel.tsx:979-1015", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2354-2364", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:852-860", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1255-1277" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2277-2287", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:775-783", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:1178-1200" ] }, { @@ -1706,7 +1707,7 @@ "evidence": [ "ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx:3507-3521", "ui/packages/loom-ui/src/features/ExplorerBuilder/BuilderWorkspace.tsx:3240-3249", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2371-2381", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2294-2304", "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/RelatedExpandEditor.tsx:79-151", "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/RelatedExpandEditor.tsx:278-310", "ui/packages/loom-ui/src/api.ts:1553-1564", @@ -2911,7 +2912,7 @@ "ui_path": "Group and summarize > Summaries", "state": "SUM and MEAN remain in the summary selector but are disabled when there are no numeric columns.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3317-3341" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:3240-3264" ], "basis": "observed-ui" }, @@ -2930,8 +2931,8 @@ "ui_path": "Reshape editor > Reshape table choices", "state": "Turn categories into columns and Turn columns into rows remain rendered disabled with capability reasons when unsupported; unsupported category/policy choices are also disabled in their lists.", "evidence": [ - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2329-2364", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2496-2511" + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2252-2287", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx:2419-2434" ], "basis": "observed-ui" }, diff --git a/docs/ui-contract-inventory/ui-supported-affordances.json b/docs/ui-contract-inventory/ui-supported-affordances.json index 18445449f..39d6542b1 100644 --- a/docs/ui-contract-inventory/ui-supported-affordances.json +++ b/docs/ui-contract-inventory/ui-supported-affordances.json @@ -7,7 +7,7 @@ }, { "path": "docs/ui-contract-inventory/relational.fragment.json", - "sha256": "f0d2e2edac2ab7d7751812f7bec189780f1a47054f711517ffaa9d14c84f9934" + "sha256": "7f65cd0a212d156bb815cdd3e03f7ce43f16c07c2bfe7bed6141a7c04ef75575" }, { "path": "docs/ui-contract-inventory/lifecycle.fragment.json", @@ -18,7 +18,7 @@ "sha256": "f2d2920a2fefa4587d260fff09ae4a0af0c1f4b94d286c1c8a15b088d46421f1" } ], - "expandedSha256": "3d1de6bf8e531684f7c1b5d62c82dd49daebbbad7be2f871e3249bb3882e50c2", + "expandedSha256": "40bb5e9d9549ee49426795cd0f557084d5e34f81fee1a275a5b63964e9daac27", "metadata": { "schemaVersion": 1, "status": "source-backed-provisional; model-and-adapter-coverage-pending", @@ -316,12 +316,16 @@ }, { "path": "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx", - "sha256": "a3f66597ebe7cf9d5f81d04f537e7f3a291b359817f24e670750c2949449f006" + "sha256": "067873e6a16954ff9a07f8ce4ff387c7ce1b5fdff452c956be5fb5c4e0cb9f0b" }, { "path": "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/FilterRowsEditor.tsx", "sha256": "617371f77b152fe96cda55b8753abcf8584bd2ffa0a351061d8513a8cde83562" }, + { + "path": "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx", + "sha256": "1d5ca8307d5d2ea56dadab5596dbd3409a2275ad9ce334adfb6f709549aac8b8" + }, { "path": "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/RelatedEligibilityEditor.tsx", "sha256": "1998bc7643e4ddbe0a11b57a508f66cc774b8c090b06b4b7b8f12a94653e4aa8" diff --git a/ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx b/ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx index 02ddf60d0..fdadd9669 100644 --- a/ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx +++ b/ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { useQuery } from '../../../react'; import type { LoomClient } from '../../../api'; import type { Construction, @@ -9,14 +8,11 @@ import type { ConstructionTableScalar, ConstructionStep, ExplorerBuilderCatalog, - FrameSourceOption, - SemanticInventoryBrowseResponse, - SemanticInventoryItem, } from '../../../types'; import { constructionSchema } from '../../../types'; import { RelatedExpandEditor, type RelatedExpandQueryOwner } from './RelatedExpandEditor'; import { CodedPivotEditor } from './CodedPivotEditor'; -import { semanticConceptLabel } from '../catalogItems'; +import { GroupCodedValuePicker } from './GroupCodedValuePicker'; import { classifySourcePickerEmptiness, sourcePickerBoundSet } from './sourcePickerExplanations'; import { existingColumnForFieldPath } from './existingFieldReuse'; import { SourcePickerExplanation } from '../components/SourcePickerExplanation'; @@ -67,7 +63,7 @@ type PivotOperation = Extract; type ReshapeColumn = ConstructionStageDescriptor['columns'][number]; -type ReshapeStage = ConstructionStageDescriptor; +export type ReshapeStage = ConstructionStageDescriptor; type ReshapeCapabilities = ConstructionCapabilitiesResponse; type SourceInputChoice = NonNullable['choices'][number]; type AvailableSourceField = { @@ -76,7 +72,7 @@ type AvailableSourceField = { }; type StageCapability = { readonly supported: boolean; readonly reason: string; readonly reasonCode?: string }; -type GroupKey = { +export type GroupKey = { readonly inputColumnId: string; readonly outputColumnId: string; readonly name: string; @@ -128,7 +124,7 @@ type GroupSemanticKey = { readonly scopeLabel: string; }; -type GroupCodedCategory = { +export type GroupCodedCategory = { readonly key: string; readonly familyKey: string; readonly system: string; @@ -230,7 +226,7 @@ type UnpivotForm = { export type ConstructionReshapeStep = ConstructionStep; -type GroupForm = { +export type GroupForm = { readonly kind: 'group'; readonly stepId: string; readonly missingKeyPolicy: 'GROUP' | 'EXCLUDE' | 'ERROR'; @@ -387,7 +383,7 @@ type AwaitingPivotDiscovery = PendingPivotDiscovery & { export const CONSTRUCTION_RESHAPE_EDITABLE_KINDS = ['PIVOT', 'CODED_PIVOT', 'UNPIVOT', 'GROUP', 'CODED_GROUP', 'EXPAND', 'RELATED_EXPAND'] as const; -const createOpaqueId = (prefix: string): string => { +export const createOpaqueId = (prefix: string): string => { const randomPart = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2); return `${prefix}_${randomPart}`; }; @@ -494,83 +490,10 @@ const normalizedName = (value: string): string => { return /^[0-9]/.test(normalized) ? `column_${normalized}` : normalized; }; -const codedFamilyKey = (bindingId: string, sourcePath: string): string => `${bindingId}\u0000${sourcePath}`; -const codedCategoryKey = (familyKey: string, system: string, code: string): string => `${familyKey}\u0000${system}\u0000${code}`; -const INITIAL_VISIBLE_CODED_CHOICES = 10; -const CODED_INVENTORY_READINESS_POLL_INTERVAL_MS = 1_000; +export const codedFamilyKey = (bindingId: string, sourcePath: string): string => `${bindingId}\u0000${sourcePath}`; +export const codedCategoryKey = (familyKey: string, system: string, code: string): string => `${familyKey}\u0000${system}\u0000${code}`; -const waitForCodedInventoryReadinessPoll = (signal: AbortSignal): Promise => new Promise((resolve) => { - if (signal.aborted) { - resolve(); - return; - } - let timer: number | undefined; - const finish = () => { - if (timer !== undefined) window.clearTimeout(timer); - signal.removeEventListener('abort', finish); - resolve(); - }; - timer = window.setTimeout(finish, CODED_INVENTORY_READINESS_POLL_INTERVAL_MS); - signal.addEventListener('abort', finish, { once: true }); -}); - -const availableGroupCodedEntry = ( - item: SemanticInventoryItem, - sources: ReadonlyArray, -) => { - const source = sources.find((candidate) => candidate.resourceType === item.resourceType - && candidate.bindingId === item.bindingId && candidate.sourcePath === item.sourcePath); - const semanticSource = item.constructionChoice?.source; - if (!source || !item.code || item.codingVersion || !item.constructionChoice || item.constructionChoice.route.length !== 0 - || semanticSource?.kind !== 'SEMANTIC' || semanticSource.resourceType !== source.resourceType - || semanticSource.bindingId !== source.bindingId || semanticSource.sourcePath !== source.sourcePath - || semanticSource.code !== item.code || (semanticSource.system ?? '') !== item.system - || item.readiness.status !== 'READY' && item.readiness.status !== 'READY_WITH_WARNING') return undefined; - return { item, source, familyKey: codedFamilyKey(source.bindingId, source.sourcePath) }; -}; - -type GroupCodedSourceDiscovery = { - readonly scopeKey: string; - readonly sources: ReadonlyArray; - readonly complete: boolean; - readonly truncated: boolean; - readonly loading: boolean; -}; - -type GroupCodedInventoryReadiness = { - readonly scopeKey: string; - readonly contextToken: string; - readonly buildId: string; - readonly state: SemanticInventoryBrowseResponse['state']; -}; - -type GroupCodedInventoryFamily = { - readonly source: FrameSourceOption; - readonly entries: SemanticInventoryItem[]; - readonly seenCursors: Set; - started: boolean; - cursor?: string; - exhausted: boolean; -}; - -type GroupCodedInventoryCheckpoint = { - readonly scopeKey: string; - readonly families: Map; - contextToken?: string; - buildId?: string; - initialized: boolean; - loadedRound: number; -}; - -type GroupCodedInventoryView = { - readonly scopeKey: string; - readonly sourceKey: string; - readonly entries: ReadonlyArray; - readonly hasMore: boolean; - readonly complete: boolean; -}; - -const uniqueName = (base: string, used: ReadonlySet): string => { +export const uniqueName = (base: string, used: ReadonlySet): string => { const normalized = normalizedName(base); if (!used.has(normalized)) return normalized; let suffix = 2; @@ -1778,7 +1701,7 @@ const allDiscoveredPivotCategoriesSelected = ( return discovery.categories.every((category) => selectedIdentities.has(scalarIdentity(category.key))); }; -const getUsedGroupNames = (form: GroupForm, excludingColumnId?: string): ReadonlySet => +export const getUsedGroupNames = (form: GroupForm, excludingColumnId?: string): ReadonlySet => new Set([ ...form.keys.filter((key) => key.outputColumnId !== excludingColumnId).map((key) => key.name.trim().toLowerCase()), ...form.fieldKeys.filter((key) => key.outputColumnId !== excludingColumnId).map((key) => key.name.trim().toLowerCase()), @@ -1787,7 +1710,7 @@ const getUsedGroupNames = (form: GroupForm, excludingColumnId?: string): Readonl ...form.rowValues.filter((value) => value.outputColumnId !== excludingColumnId).map((value) => value.name.trim().toLowerCase()), ]); -const ensureGroupCodedPassthroughs = ( +export const ensureGroupCodedPassthroughs = ( form: GroupForm, stage: ReshapeStage, inputColumnIDs: ReadonlyArray, @@ -3725,443 +3648,6 @@ const GroupEditor = (props: { ); }; -const GroupCodedValuePicker = (props: { - readonly context: NonNullable; - readonly form: GroupForm; - readonly stage: ReshapeStage; - readonly disabled: boolean; - readonly onChange: (form: GroupForm) => void; -}) => { - const { client, project, explorerId, authResourcePath, snapshotToken, outputId, rowRoot } = props.context; - const [pageRound, setPageRound] = useState(0); - const [sourceProgress, setSourceProgress] = useState(); - const [inventoryProgress, setInventoryProgress] = useState(); - const [readinessProgress, setReadinessProgress] = useState(); - const checkpointRef = useRef(undefined); - const requestedRoundRef = useRef(0); - const currentForm = useRef(props.form); - currentForm.current = props.form; - const changeForm = useRef(props.onChange); - changeForm.current = props.onChange; - - const sourceScopeKey = JSON.stringify([project, explorerId, authResourcePath, snapshotToken, outputId, rowRoot]); - const readinessQuery = useQuery(async (signal) => { - const browseReadiness = () => client.browseSemanticInventory({ - project, explorerId, authResourcePath, snapshotToken, rowRoot, limit: 1, - }, signal); - let response = await browseReadiness(); - while (response.state === 'running' && !signal.aborted) { - setReadinessProgress({ - scopeKey: sourceScopeKey, - contextToken: response.contextToken, - buildId: response.buildId, - state: response.state, - }); - await waitForCodedInventoryReadinessPoll(signal); - if (!signal.aborted) response = await browseReadiness(); - } - return { - scopeKey: sourceScopeKey, - contextToken: response.contextToken, - buildId: response.buildId, - state: response.state, - } satisfies GroupCodedInventoryReadiness; - }, [client, sourceScopeKey]); - const readiness = readinessQuery.data?.scopeKey === sourceScopeKey - ? readinessQuery.data - : readinessProgress?.scopeKey === sourceScopeKey ? readinessProgress : undefined; - const inventoryReady = Boolean(readiness?.buildId && readiness.state === 'complete'); - const inventoryScopeKey = JSON.stringify([sourceScopeKey, readiness?.contextToken, readiness?.buildId]); - const sourcesQuery = useQuery(async (signal) => { - const sources = new Map(); - const seenCursors = new Set(); - let cursor: string | undefined; - let complete = false; - let truncated = false; - do { - const response = await client.browseFrameSourceOptions({ - project, explorerId, authResourcePath, snapshotToken, outputId, resourceType: rowRoot, limit: 50, - ...(cursor ? { cursor } : {}), - }, signal); - if (response.snapshotToken !== snapshotToken || response.outputId !== outputId) { - throw new Error('Coded source fields were returned for a different table snapshot. Reload the table and retry.'); - } - for (const source of response.sources) { - if (source.route.length === 0 && source.resourceType === rowRoot) sources.set(source.choiceId, source); - } - complete = response.complete; - truncated = truncated || response.truncated; - cursor = response.nextCursor; - const progress = { - scopeKey: inventoryScopeKey, - sources: [...sources.values()], - complete: complete && !cursor, - truncated, - loading: Boolean(cursor), - } satisfies GroupCodedSourceDiscovery; - if (!signal.aborted) setSourceProgress(progress); - if (cursor && seenCursors.has(cursor)) throw new Error('Coded source fields could not be completely listed. Reopen the editor to retry.'); - if (cursor) seenCursors.add(cursor); - } while (cursor && !signal.aborted); - return { - scopeKey: inventoryScopeKey, - sources: [...sources.values()], - complete: complete && !cursor, - truncated, - loading: false, - } satisfies GroupCodedSourceDiscovery; - }, [client, inventoryScopeKey], inventoryReady); - - const sourceDiscovery = sourcesQuery.data?.scopeKey === inventoryScopeKey - ? sourcesQuery.data - : sourceProgress?.scopeKey === inventoryScopeKey ? sourceProgress : undefined; - const sources = sourceDiscovery?.sources ?? []; - const sourceKey = JSON.stringify(sources.map((source) => [source.choiceId, source.bindingId, source.sourcePath])); - const currentInventoryScopeKey = useRef(inventoryScopeKey); - currentInventoryScopeKey.current = inventoryScopeKey; - const inventoryQuery = useQuery(async (signal) => { - let checkpoint = checkpointRef.current; - if (!checkpoint || checkpoint.scopeKey !== inventoryScopeKey - || checkpoint.contextToken !== readiness?.contextToken || checkpoint.buildId !== readiness?.buildId) { - checkpoint = { - scopeKey: inventoryScopeKey, - families: new Map(), - contextToken: readiness?.contextToken, - buildId: readiness?.buildId, - initialized: false, - loadedRound: 0, - }; - checkpointRef.current = checkpoint; - } - const currentCheckpoint = checkpoint; - for (const source of sources) { - const existing = currentCheckpoint.families.get(source.choiceId); - if (existing && (existing.source.resourceType !== source.resourceType - || existing.source.bindingId !== source.bindingId || existing.source.sourcePath !== source.sourcePath)) { - throw new Error('The direct coded source changed while it was loading. Reload the table and retry.'); - } - if (!existing) currentCheckpoint.families.set(source.choiceId, { - source, entries: [], seenCursors: new Set(), started: false, exhausted: false, - }); - } - const makeView = (): GroupCodedInventoryView => { - const families = [...currentCheckpoint.families.values()]; - return { - scopeKey: inventoryScopeKey, - sourceKey, - entries: families.flatMap((family) => family.entries), - hasMore: families.some((family) => Boolean(family.cursor)), - complete: sourceDiscovery?.complete === true && !sourceDiscovery.truncated && families.every((family) => family.exhausted), - }; - }; - const publish = () => setInventoryProgress(makeView()); - const fetchPage = async (family: GroupCodedInventoryFamily) => { - if (signal.aborted || family.exhausted) return; - const response = await client.browseSemanticInventory({ - project, explorerId, authResourcePath, snapshotToken, outputId, rowRoot, - sourceChoiceId: family.source.choiceId, - ...(family.cursor ? { cursor: family.cursor } : {}), - limit: 50, - }, signal); - if (signal.aborted) return; - if (response.state !== 'complete') { - throw new Error(`Coded values for ${family.source.title} are not confirmed by a complete inventory. Wait for the catalog to be ready, then reopen the editor.`); - } - if (currentCheckpoint.contextToken !== response.contextToken - || currentCheckpoint.buildId !== response.buildId) { - throw new Error('The coded-value inventory changed while it was loading. Reload the table and retry.'); - } - family.entries.push(...response.entries.filter((item) => item.resourceType === family.source.resourceType - && item.bindingId === family.source.bindingId && item.sourcePath === family.source.sourcePath)); - family.cursor = response.nextCursor; - family.exhausted = !response.nextCursor; - family.started = true; - if (response.nextCursor) { - if (family.seenCursors.has(response.nextCursor)) { - throw new Error(`Coded values for ${family.source.title} could not be completely loaded. Reopen the editor to retry.`); - } - family.seenCursors.add(response.nextCursor); - } - }; - const unstarted = [...currentCheckpoint.families.values()].filter((family) => !family.started); - if (unstarted.length > 0) { - await Promise.all(unstarted.map(fetchPage)); - if (signal.aborted) return makeView(); - publish(); - } - if (!currentCheckpoint.initialized || unstarted.length > 0) { - while (!signal.aborted) { - const visibleKeys = new Set(makeView().entries.flatMap((item) => { - const entry = availableGroupCodedEntry(item, sources); - return entry ? [codedCategoryKey(entry.familyKey, item.system, item.code)] : []; - })); - if (visibleKeys.size >= INITIAL_VISIBLE_CODED_CHOICES) break; - const pending = [...currentCheckpoint.families.values()].filter((family) => !family.exhausted); - if (pending.length === 0) break; - await Promise.all(pending.map(fetchPage)); - if (signal.aborted) return makeView(); - publish(); - } - if (signal.aborted) return makeView(); - currentCheckpoint.initialized = true; - currentCheckpoint.loadedRound = pageRound; - } else if (pageRound > currentCheckpoint.loadedRound) { - for (let round = currentCheckpoint.loadedRound; round < pageRound && !signal.aborted; round += 1) { - const pending = [...currentCheckpoint.families.values()].filter((family) => Boolean(family.cursor)); - if (pending.length === 0) break; - await Promise.all(pending.map(fetchPage)); - if (signal.aborted) return makeView(); - currentCheckpoint.loadedRound = round + 1; - publish(); - } - } - return makeView(); - }, [client, inventoryScopeKey, readiness?.contextToken, readiness?.buildId, sourceKey, sourceDiscovery?.complete, sourceDiscovery?.truncated, sourceDiscovery?.loading, pageRound], - inventoryReady && ((sourceDiscovery !== undefined && sources.length > 0) || sourceDiscovery?.complete === true)); - - const inventoryView = inventoryQuery.data?.scopeKey === inventoryScopeKey && inventoryQuery.data.sourceKey === sourceKey - ? inventoryQuery.data - : inventoryProgress?.scopeKey === inventoryScopeKey && inventoryProgress.sourceKey === sourceKey ? inventoryProgress : undefined; - const inventory = inventoryView?.entries ?? []; - const available = useMemo(() => { - const entries = new Map>>(); - for (const item of inventory) { - const entry = availableGroupCodedEntry(item, sources); - if (entry) entries.set(codedCategoryKey(entry.familyKey, item.system, item.code), entry); - } - return [...entries.values()]; - }, [inventory, sources]); - - const savedCategoryKey = props.form.codedCategories.filter((category) => category.choice === 'saved') - .map((category) => category.key).join('\u0001'); - const savedCategoryQuery = useQuery(async (signal) => { - const unresolved = currentForm.current.codedCategories.filter((category) => category.choice === 'saved'); - const hydrated = await Promise.all(unresolved.map(async (category) => { - const source = sources.find((candidate) => candidate.resourceType === rowRoot - && codedFamilyKey(candidate.bindingId, candidate.sourcePath) === category.familyKey); - if (!source) return { key: category.key, value: undefined, pending: !sourceDiscovery?.complete }; - try { - const response = await client.browseSemanticInventory({ - project, explorerId, authResourcePath, snapshotToken, outputId, rowRoot, - sourceChoiceId: source.choiceId, query: category.code, limit: 50, - }, signal); - if (response.state !== 'complete') { - throw new Error('The saved coded value is not confirmed by a complete inventory. Wait for the catalog to be ready, then reopen the editor.'); - } - if (response.contextToken !== readiness?.contextToken || response.buildId !== readiness?.buildId) { - throw new Error('The coded-value inventory changed while it was loading. Reload the table and retry.'); - } - const item = response.entries.find((candidate) => candidate.system === category.system && candidate.code === category.code - && availableGroupCodedEntry(candidate, [source])); - return { key: category.key, value: item?.constructionChoice ? { - sourceChoiceId: source.choiceId, - categoryChoiceId: item.constructionChoice.choiceId, - display: semanticConceptLabel(item), - valueType: item.valueType, - } : undefined, pending: false }; - } catch (reason: unknown) { - return { - key: category.key, - value: undefined, - pending: false, - error: reason instanceof Error ? reason.message : 'A saved coded value could not be reauthorized.', - }; - } - })); - if (signal.aborted || currentInventoryScopeKey.current !== inventoryScopeKey) return { unavailableKeys: [], error: undefined }; - const byKey = new Map(hydrated.flatMap((item) => item.value ? [[item.key, item.value] as const] : [])); - const form = currentForm.current; - const savedKeys = new Set(form.codedCategories.filter((category) => category.choice === 'saved').map((category) => category.key)); - let changed = false; - const codedCategories = form.codedCategories.map((category): GroupCodedCategory => { - if (category.choice !== 'saved') return category; - const item = byKey.get(category.key); - if (!item) return category; - changed = true; - return { - ...category, - choice: 'ready', - sourceChoiceId: item.sourceChoiceId, - categoryChoiceId: item.categoryChoiceId, - display: item.display, - valueType: item.valueType, - }; - }); - if (changed) changeForm.current({ ...form, codedCategories }); - return { - unavailableKeys: hydrated.filter((item) => savedKeys.has(item.key) && !item.value && !item.pending).map((item) => item.key), - error: hydrated.find((item) => item.error)?.error, - }; - }, [client, inventoryScopeKey, sourceKey, sourceDiscovery?.complete, sourceDiscovery?.loading, savedCategoryKey], - inventoryReady && sourceDiscovery !== undefined && savedCategoryKey.length > 0); - const unavailableSavedKeys = new Set(savedCategoryQuery.data?.unavailableKeys ?? []); - - const selectedFamily = props.form.codedCategories[0]?.familyKey; - const removeSelectedCategory = (category: GroupCodedCategory) => { - props.onChange({ - ...props.form, - codedCategories: props.form.codedCategories.filter((candidate) => candidate.key !== category.key), - keys: props.form.keys.filter((groupKey) => groupKey.outputColumnId !== category.groupOutputColumnId), - }); - }; - const toggle = (entry: (typeof available)[number]) => { - const { item, source, familyKey } = entry; - const key = codedCategoryKey(familyKey, item.system, item.code); - const prior = props.form.codedCategories.find((category) => category.key === key); - if (prior) { - removeSelectedCategory(prior); - return; - } - if (selectedFamily && selectedFamily !== familyKey || props.form.codedCategories.length >= 50 || !item.constructionChoice) return; - const label = semanticConceptLabel(item); - const usedNames = getUsedGroupNames(props.form); - const groupOutputName = uniqueName(item.code, usedNames); - const helperOutputName = uniqueName(item.code, new Set([ - ...props.stage.columns.map((column) => column.name.toLowerCase()), - ...props.form.codedCategories.map((category) => category.helperOutputName.toLowerCase()), - ])); - const groupKey: GroupKey = { - inputColumnId: createOpaqueId('coded-group-input'), - outputColumnId: createOpaqueId('coded-group-output'), - name: groupOutputName, - label, - }; - const codedCategory: GroupCodedCategory = { - choice: 'ready', - key, - familyKey, - system: item.system, - code: item.code, - display: label, - valueType: item.valueType, - sourceChoiceId: source.choiceId, - categoryChoiceId: item.constructionChoice.choiceId, - helperOutputColumnId: groupKey.inputColumnId, - groupOutputColumnId: groupKey.outputColumnId, - groupOutputName: groupKey.name, - groupOutputLabel: groupKey.label, - helperOutputName, - helperOutputLabel: label, - }; - const codedCategories = [...props.form.codedCategories, codedCategory]; - const nextForm = { ...props.form, codedCategories, keys: [...props.form.keys, groupKey] }; - props.onChange({ - ...nextForm, - codedPassthroughs: ensureGroupCodedPassthroughs(nextForm, props.stage, [ - ...nextForm.keys.flatMap((key) => codedCategories.some((category) => category.helperOutputColumnId === key.inputColumnId) ? [] : [key.inputColumnId]), - ...nextForm.aggregates.flatMap((aggregate) => aggregate.operation === 'COUNT_ROWS' ? [] : [aggregate.inputColumnId]), - ...nextForm.rowValues.flatMap((value) => 'inputColumnId' in value ? [value.inputColumnId] : []), - ]), - }); - }; - const loadNextPage = () => { - if (!inventoryView?.hasMore || inventoryQuery.isLoading || pageRound !== requestedRoundRef.current) return; - requestedRoundRef.current = pageRound + 1; - setPageRound(pageRound + 1); - }; - const onChoicesScroll = (event: React.UIEvent) => { - const element = event.currentTarget; - if (element.scrollHeight - element.scrollTop - element.clientHeight <= 24) loadNextPage(); - }; - const sourceListIncomplete = Boolean(sourceDiscovery && !sourceDiscovery.loading && (!sourceDiscovery.complete || sourceDiscovery.truncated)); - const error = readinessQuery.error ?? sourcesQuery.error ?? inventoryQuery.error ?? savedCategoryQuery.data?.error; - const errorText = error instanceof Error ? error.message : error ? 'Coded values could not be loaded.' : undefined; - const choicesLoading = readinessQuery.isLoading || sourcesQuery.isLoading || inventoryQuery.isLoading || savedCategoryQuery.isLoading; - if (inventoryReady && !choicesLoading && !errorText && !sourceListIncomplete && inventoryView?.complete && available.length === 0 - && props.form.codedCategories.length === 0) return null; - - return ( -
-
-

Coded values

-

Choose coded values to group by.

-
- {readinessQuery.isLoading && !readiness ?

Checking coded-value availability…

: null} - {!readinessQuery.error && readiness && !inventoryReady ? ( -

- {!readiness.buildId - ? 'No current coded-value catalog is available.' - : `The coded-value catalog is ${readiness.state}. Choices will appear when it is complete.`} -

- ) : null} - {inventoryReady && choicesLoading ?

Loading coded values…

: null} - {errorText ?

{errorText}

: null} - {sourceListIncomplete && available.length > 0 ?

The direct source-field list may be incomplete; additional coded choices may exist.

: null} - {selectedFamily && props.form.codedCategories.some((category) => category.familyKey !== selectedFamily) ? ( -

These selected concepts come from different source fields. A group can use coded values from one direct source field at a time; remove the conflicting selection to continue.

- ) : selectedFamily ? ( -

Selected source field: {sources.find((source) => codedFamilyKey(source.bindingId, source.sourcePath) === selectedFamily)?.title ?? 'saved direct source'}. Choose coded values from this source field.

- ) : null} -
- {sources.map((source) => { - const sourceEntries = available.filter((entry) => entry.source.choiceId === source.choiceId); - if (sourceEntries.length === 0 && !inventoryView?.complete) return null; - return ( -
-
{source.title}
- {sourceEntries.map((entry) => { - const { item, familyKey } = entry; - const key = codedCategoryKey(familyKey, item.system, item.code); - const selected = props.form.codedCategories.some((category) => category.key === key); - const familyConflict = Boolean(selectedFamily && selectedFamily !== familyKey); - return ( - - ); - })} -
- ); - })} - {props.form.codedCategories.filter((category) => !available.some((entry) => - category.key === codedCategoryKey(entry.familyKey, entry.item.system, entry.item.code))).map((category) => { - const familyConflict = Boolean(selectedFamily && selectedFamily !== category.familyKey); - return ( - - ); - })} - {inventoryView?.hasMore ?

Scroll for more choices from these source fields.

: null} -
- {inventoryReady && !choicesLoading && !errorText && available.length === 0 ? ( -

- {sourceListIncomplete - ? 'No ready coded values were found among the listed direct source fields; the source list may be incomplete.' - : inventoryView?.complete - ? 'No ready coded values are available from these direct source fields.' - : 'Coded values from direct source fields are still loading.'} -

- ) : null} - {props.form.codedCategories.length > 0 ?

{props.form.codedCategories.length} coded value{props.form.codedCategories.length === 1 ? '' : 's'} selected.

: null} -
- ); -}; - const groupAggregateKindFromInput = (value: string): GroupAggregateKind | undefined => { switch (value) { case 'COUNT_ROWS': return 'COUNT_ROWS'; diff --git a/ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx b/ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx new file mode 100644 index 000000000..f42c2f380 --- /dev/null +++ b/ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/GroupCodedValuePicker.tsx @@ -0,0 +1,534 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { useQuery } from '../../../react'; +import type { + FrameSourceOption, + SemanticInventoryBrowseResponse, + SemanticInventoryItem, +} from '../../../types'; +import { semanticConceptLabel } from '../catalogItems'; +import { + codedCategoryKey, + codedFamilyKey, + createOpaqueId, + ensureGroupCodedPassthroughs, + getUsedGroupNames, + uniqueName, + type ConstructionReshapeEditorProps, + type GroupCodedCategory, + type GroupForm, + type GroupKey, + type ReshapeStage, +} from './ConstructionReshapeEditor'; + +const INITIAL_VISIBLE_CODED_CHOICES = 10; +const CODED_INVENTORY_READINESS_POLL_INTERVAL_MS = 1_000; + +const waitForCodedInventoryReadinessPoll = (signal: AbortSignal): Promise => new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + let timer: number | undefined; + const finish = () => { + if (timer !== undefined) window.clearTimeout(timer); + signal.removeEventListener('abort', finish); + resolve(); + }; + timer = window.setTimeout(finish, CODED_INVENTORY_READINESS_POLL_INTERVAL_MS); + signal.addEventListener('abort', finish, { once: true }); +}); + +const availableGroupCodedEntry = ( + item: SemanticInventoryItem, + sources: ReadonlyArray, +) => { + const source = sources.find((candidate) => candidate.resourceType === item.resourceType + && candidate.bindingId === item.bindingId && candidate.sourcePath === item.sourcePath); + const semanticSource = item.constructionChoice?.source; + if (!source || !item.code || item.codingVersion || !item.constructionChoice || item.constructionChoice.route.length !== 0 + || semanticSource?.kind !== 'SEMANTIC' || semanticSource.resourceType !== source.resourceType + || semanticSource.bindingId !== source.bindingId || semanticSource.sourcePath !== source.sourcePath + || semanticSource.code !== item.code || (semanticSource.system ?? '') !== item.system + || item.readiness.status !== 'READY' && item.readiness.status !== 'READY_WITH_WARNING') return undefined; + return { item, source, familyKey: codedFamilyKey(source.bindingId, source.sourcePath) }; +}; + +type GroupCodedSourceDiscovery = { + readonly scopeKey: string; + readonly sources: ReadonlyArray; + readonly complete: boolean; + readonly truncated: boolean; + readonly loading: boolean; +}; + +type GroupCodedInventoryReadiness = { + readonly scopeKey: string; + readonly contextToken: string; + readonly buildId: string; + readonly state: SemanticInventoryBrowseResponse['state']; +}; + +type GroupCodedInventoryFamily = { + readonly source: FrameSourceOption; + readonly entries: SemanticInventoryItem[]; + readonly seenCursors: Set; + started: boolean; + cursor?: string; + exhausted: boolean; +}; + +type GroupCodedInventoryCheckpoint = { + readonly scopeKey: string; + readonly families: Map; + contextToken?: string; + buildId?: string; + initialized: boolean; + loadedRound: number; +}; + +type GroupCodedInventoryView = { + readonly scopeKey: string; + readonly sourceKey: string; + readonly entries: ReadonlyArray; + readonly hasMore: boolean; + readonly complete: boolean; +}; + +export type GroupCodedValuePickerProps = { + readonly context: NonNullable; + readonly form: GroupForm; + readonly stage: ReshapeStage; + readonly disabled: boolean; + readonly onChange: (form: GroupForm) => void; +}; + +export const GroupCodedValuePicker = (props: GroupCodedValuePickerProps) => { + const { client, project, explorerId, authResourcePath, snapshotToken, outputId, rowRoot } = props.context; + const [pageRound, setPageRound] = useState(0); + const [sourceProgress, setSourceProgress] = useState(); + const [inventoryProgress, setInventoryProgress] = useState(); + const [readinessProgress, setReadinessProgress] = useState(); + const checkpointRef = useRef(undefined); + const requestedRoundRef = useRef(0); + const currentForm = useRef(props.form); + currentForm.current = props.form; + const changeForm = useRef(props.onChange); + changeForm.current = props.onChange; + + const sourceScopeKey = JSON.stringify([project, explorerId, authResourcePath, snapshotToken, outputId, rowRoot]); + const readinessQuery = useQuery(async (signal) => { + const browseReadiness = () => client.browseSemanticInventory({ + project, explorerId, authResourcePath, snapshotToken, rowRoot, limit: 1, + }, signal); + let response = await browseReadiness(); + while (response.state === 'running' && !signal.aborted) { + setReadinessProgress({ + scopeKey: sourceScopeKey, + contextToken: response.contextToken, + buildId: response.buildId, + state: response.state, + }); + await waitForCodedInventoryReadinessPoll(signal); + if (!signal.aborted) response = await browseReadiness(); + } + return { + scopeKey: sourceScopeKey, + contextToken: response.contextToken, + buildId: response.buildId, + state: response.state, + } satisfies GroupCodedInventoryReadiness; + }, [client, sourceScopeKey]); + const readiness = readinessQuery.data?.scopeKey === sourceScopeKey + ? readinessQuery.data + : readinessProgress?.scopeKey === sourceScopeKey ? readinessProgress : undefined; + const inventoryReady = Boolean(readiness?.buildId && readiness.state === 'complete'); + const inventoryScopeKey = JSON.stringify([sourceScopeKey, readiness?.contextToken, readiness?.buildId]); + const sourcesQuery = useQuery(async (signal) => { + const sources = new Map(); + const seenCursors = new Set(); + let cursor: string | undefined; + let complete = false; + let truncated = false; + do { + const response = await client.browseFrameSourceOptions({ + project, explorerId, authResourcePath, snapshotToken, outputId, resourceType: rowRoot, limit: 50, + ...(cursor ? { cursor } : {}), + }, signal); + if (response.snapshotToken !== snapshotToken || response.outputId !== outputId) { + throw new Error('Coded source fields were returned for a different table snapshot. Reload the table and retry.'); + } + for (const source of response.sources) { + if (source.route.length === 0 && source.resourceType === rowRoot) sources.set(source.choiceId, source); + } + complete = response.complete; + truncated = truncated || response.truncated; + cursor = response.nextCursor; + const progress = { + scopeKey: inventoryScopeKey, + sources: [...sources.values()], + complete: complete && !cursor, + truncated, + loading: Boolean(cursor), + } satisfies GroupCodedSourceDiscovery; + if (!signal.aborted) setSourceProgress(progress); + if (cursor && seenCursors.has(cursor)) throw new Error('Coded source fields could not be completely listed. Reopen the editor to retry.'); + if (cursor) seenCursors.add(cursor); + } while (cursor && !signal.aborted); + return { + scopeKey: inventoryScopeKey, + sources: [...sources.values()], + complete: complete && !cursor, + truncated, + loading: false, + } satisfies GroupCodedSourceDiscovery; + }, [client, inventoryScopeKey], inventoryReady); + + const sourceDiscovery = sourcesQuery.data?.scopeKey === inventoryScopeKey + ? sourcesQuery.data + : sourceProgress?.scopeKey === inventoryScopeKey ? sourceProgress : undefined; + const sources = sourceDiscovery?.sources ?? []; + const sourceKey = JSON.stringify(sources.map((source) => [source.choiceId, source.bindingId, source.sourcePath])); + const currentInventoryScopeKey = useRef(inventoryScopeKey); + currentInventoryScopeKey.current = inventoryScopeKey; + const inventoryQuery = useQuery(async (signal) => { + let checkpoint = checkpointRef.current; + if (!checkpoint || checkpoint.scopeKey !== inventoryScopeKey + || checkpoint.contextToken !== readiness?.contextToken || checkpoint.buildId !== readiness?.buildId) { + checkpoint = { + scopeKey: inventoryScopeKey, + families: new Map(), + contextToken: readiness?.contextToken, + buildId: readiness?.buildId, + initialized: false, + loadedRound: 0, + }; + checkpointRef.current = checkpoint; + } + const currentCheckpoint = checkpoint; + for (const source of sources) { + const existing = currentCheckpoint.families.get(source.choiceId); + if (existing && (existing.source.resourceType !== source.resourceType + || existing.source.bindingId !== source.bindingId || existing.source.sourcePath !== source.sourcePath)) { + throw new Error('The direct coded source changed while it was loading. Reload the table and retry.'); + } + if (!existing) currentCheckpoint.families.set(source.choiceId, { + source, entries: [], seenCursors: new Set(), started: false, exhausted: false, + }); + } + const makeView = (): GroupCodedInventoryView => { + const families = [...currentCheckpoint.families.values()]; + return { + scopeKey: inventoryScopeKey, + sourceKey, + entries: families.flatMap((family) => family.entries), + hasMore: families.some((family) => Boolean(family.cursor)), + complete: sourceDiscovery?.complete === true && !sourceDiscovery.truncated && families.every((family) => family.exhausted), + }; + }; + const publish = () => setInventoryProgress(makeView()); + const fetchPage = async (family: GroupCodedInventoryFamily) => { + if (signal.aborted || family.exhausted) return; + const response = await client.browseSemanticInventory({ + project, explorerId, authResourcePath, snapshotToken, outputId, rowRoot, + sourceChoiceId: family.source.choiceId, + ...(family.cursor ? { cursor: family.cursor } : {}), + limit: 50, + }, signal); + if (signal.aborted) return; + if (response.state !== 'complete') { + throw new Error(`Coded values for ${family.source.title} are not confirmed by a complete inventory. Wait for the catalog to be ready, then reopen the editor.`); + } + if (currentCheckpoint.contextToken !== response.contextToken + || currentCheckpoint.buildId !== response.buildId) { + throw new Error('The coded-value inventory changed while it was loading. Reload the table and retry.'); + } + family.entries.push(...response.entries.filter((item) => item.resourceType === family.source.resourceType + && item.bindingId === family.source.bindingId && item.sourcePath === family.source.sourcePath)); + family.cursor = response.nextCursor; + family.exhausted = !response.nextCursor; + family.started = true; + if (response.nextCursor) { + if (family.seenCursors.has(response.nextCursor)) { + throw new Error(`Coded values for ${family.source.title} could not be completely loaded. Reopen the editor to retry.`); + } + family.seenCursors.add(response.nextCursor); + } + }; + const unstarted = [...currentCheckpoint.families.values()].filter((family) => !family.started); + if (unstarted.length > 0) { + await Promise.all(unstarted.map(fetchPage)); + if (signal.aborted) return makeView(); + publish(); + } + if (!currentCheckpoint.initialized || unstarted.length > 0) { + while (!signal.aborted) { + const visibleKeys = new Set(makeView().entries.flatMap((item) => { + const entry = availableGroupCodedEntry(item, sources); + return entry ? [codedCategoryKey(entry.familyKey, item.system, item.code)] : []; + })); + if (visibleKeys.size >= INITIAL_VISIBLE_CODED_CHOICES) break; + const pending = [...currentCheckpoint.families.values()].filter((family) => !family.exhausted); + if (pending.length === 0) break; + await Promise.all(pending.map(fetchPage)); + if (signal.aborted) return makeView(); + publish(); + } + if (signal.aborted) return makeView(); + currentCheckpoint.initialized = true; + currentCheckpoint.loadedRound = pageRound; + } else if (pageRound > currentCheckpoint.loadedRound) { + for (let round = currentCheckpoint.loadedRound; round < pageRound && !signal.aborted; round += 1) { + const pending = [...currentCheckpoint.families.values()].filter((family) => Boolean(family.cursor)); + if (pending.length === 0) break; + await Promise.all(pending.map(fetchPage)); + if (signal.aborted) return makeView(); + currentCheckpoint.loadedRound = round + 1; + publish(); + } + } + return makeView(); + }, [client, inventoryScopeKey, readiness?.contextToken, readiness?.buildId, sourceKey, sourceDiscovery?.complete, sourceDiscovery?.truncated, sourceDiscovery?.loading, pageRound], + inventoryReady && ((sourceDiscovery !== undefined && sources.length > 0) || sourceDiscovery?.complete === true)); + + const inventoryView = inventoryQuery.data?.scopeKey === inventoryScopeKey && inventoryQuery.data.sourceKey === sourceKey + ? inventoryQuery.data + : inventoryProgress?.scopeKey === inventoryScopeKey && inventoryProgress.sourceKey === sourceKey ? inventoryProgress : undefined; + const inventory = inventoryView?.entries ?? []; + const available = useMemo(() => { + const entries = new Map>>(); + for (const item of inventory) { + const entry = availableGroupCodedEntry(item, sources); + if (entry) entries.set(codedCategoryKey(entry.familyKey, item.system, item.code), entry); + } + return [...entries.values()]; + }, [inventory, sources]); + + const savedCategoryKey = props.form.codedCategories.filter((category) => category.choice === 'saved') + .map((category) => category.key).join('\u0001'); + const savedCategoryQuery = useQuery(async (signal) => { + const unresolved = currentForm.current.codedCategories.filter((category) => category.choice === 'saved'); + const hydrated = await Promise.all(unresolved.map(async (category) => { + const source = sources.find((candidate) => candidate.resourceType === rowRoot + && codedFamilyKey(candidate.bindingId, candidate.sourcePath) === category.familyKey); + if (!source) return { key: category.key, value: undefined, pending: !sourceDiscovery?.complete }; + try { + const response = await client.browseSemanticInventory({ + project, explorerId, authResourcePath, snapshotToken, outputId, rowRoot, + sourceChoiceId: source.choiceId, query: category.code, limit: 50, + }, signal); + if (response.state !== 'complete') { + throw new Error('The saved coded value is not confirmed by a complete inventory. Wait for the catalog to be ready, then reopen the editor.'); + } + if (response.contextToken !== readiness?.contextToken || response.buildId !== readiness?.buildId) { + throw new Error('The coded-value inventory changed while it was loading. Reload the table and retry.'); + } + const item = response.entries.find((candidate) => candidate.system === category.system && candidate.code === category.code + && availableGroupCodedEntry(candidate, [source])); + return { key: category.key, value: item?.constructionChoice ? { + sourceChoiceId: source.choiceId, + categoryChoiceId: item.constructionChoice.choiceId, + display: semanticConceptLabel(item), + valueType: item.valueType, + } : undefined, pending: false }; + } catch (reason: unknown) { + return { + key: category.key, + value: undefined, + pending: false, + error: reason instanceof Error ? reason.message : 'A saved coded value could not be reauthorized.', + }; + } + })); + if (signal.aborted || currentInventoryScopeKey.current !== inventoryScopeKey) return { unavailableKeys: [], error: undefined }; + const byKey = new Map(hydrated.flatMap((item) => item.value ? [[item.key, item.value] as const] : [])); + const form = currentForm.current; + const savedKeys = new Set(form.codedCategories.filter((category) => category.choice === 'saved').map((category) => category.key)); + let changed = false; + const codedCategories = form.codedCategories.map((category): GroupCodedCategory => { + if (category.choice !== 'saved') return category; + const item = byKey.get(category.key); + if (!item) return category; + changed = true; + return { + ...category, + choice: 'ready', + sourceChoiceId: item.sourceChoiceId, + categoryChoiceId: item.categoryChoiceId, + display: item.display, + valueType: item.valueType, + }; + }); + if (changed) changeForm.current({ ...form, codedCategories }); + return { + unavailableKeys: hydrated.filter((item) => savedKeys.has(item.key) && !item.value && !item.pending).map((item) => item.key), + error: hydrated.find((item) => item.error)?.error, + }; + }, [client, inventoryScopeKey, sourceKey, sourceDiscovery?.complete, sourceDiscovery?.loading, savedCategoryKey], + inventoryReady && sourceDiscovery !== undefined && savedCategoryKey.length > 0); + const unavailableSavedKeys = new Set(savedCategoryQuery.data?.unavailableKeys ?? []); + + const selectedFamily = props.form.codedCategories[0]?.familyKey; + const removeSelectedCategory = (category: GroupCodedCategory) => { + props.onChange({ + ...props.form, + codedCategories: props.form.codedCategories.filter((candidate) => candidate.key !== category.key), + keys: props.form.keys.filter((groupKey) => groupKey.outputColumnId !== category.groupOutputColumnId), + }); + }; + const toggle = (entry: (typeof available)[number]) => { + const { item, source, familyKey } = entry; + const key = codedCategoryKey(familyKey, item.system, item.code); + const prior = props.form.codedCategories.find((category) => category.key === key); + if (prior) { + removeSelectedCategory(prior); + return; + } + if (selectedFamily && selectedFamily !== familyKey || props.form.codedCategories.length >= 50 || !item.constructionChoice) return; + const label = semanticConceptLabel(item); + const usedNames = getUsedGroupNames(props.form); + const groupOutputName = uniqueName(item.code, usedNames); + const helperOutputName = uniqueName(item.code, new Set([ + ...props.stage.columns.map((column) => column.name.toLowerCase()), + ...props.form.codedCategories.map((category) => category.helperOutputName.toLowerCase()), + ])); + const groupKey: GroupKey = { + inputColumnId: createOpaqueId('coded-group-input'), + outputColumnId: createOpaqueId('coded-group-output'), + name: groupOutputName, + label, + }; + const codedCategory: GroupCodedCategory = { + choice: 'ready', + key, + familyKey, + system: item.system, + code: item.code, + display: label, + valueType: item.valueType, + sourceChoiceId: source.choiceId, + categoryChoiceId: item.constructionChoice.choiceId, + helperOutputColumnId: groupKey.inputColumnId, + groupOutputColumnId: groupKey.outputColumnId, + groupOutputName: groupKey.name, + groupOutputLabel: groupKey.label, + helperOutputName, + helperOutputLabel: label, + }; + const codedCategories = [...props.form.codedCategories, codedCategory]; + const nextForm = { ...props.form, codedCategories, keys: [...props.form.keys, groupKey] }; + props.onChange({ + ...nextForm, + codedPassthroughs: ensureGroupCodedPassthroughs(nextForm, props.stage, [ + ...nextForm.keys.flatMap((key) => codedCategories.some((category) => category.helperOutputColumnId === key.inputColumnId) ? [] : [key.inputColumnId]), + ...nextForm.aggregates.flatMap((aggregate) => aggregate.operation === 'COUNT_ROWS' ? [] : [aggregate.inputColumnId]), + ...nextForm.rowValues.flatMap((value) => 'inputColumnId' in value ? [value.inputColumnId] : []), + ]), + }); + }; + const loadNextPage = () => { + if (!inventoryView?.hasMore || inventoryQuery.isLoading || pageRound !== requestedRoundRef.current) return; + requestedRoundRef.current = pageRound + 1; + setPageRound(pageRound + 1); + }; + const onChoicesScroll = (event: React.UIEvent) => { + const element = event.currentTarget; + if (element.scrollHeight - element.scrollTop - element.clientHeight <= 24) loadNextPage(); + }; + const sourceListIncomplete = Boolean(sourceDiscovery && !sourceDiscovery.loading && (!sourceDiscovery.complete || sourceDiscovery.truncated)); + const error = readinessQuery.error ?? sourcesQuery.error ?? inventoryQuery.error ?? savedCategoryQuery.data?.error; + const errorText = error instanceof Error ? error.message : error ? 'Coded values could not be loaded.' : undefined; + const choicesLoading = readinessQuery.isLoading || sourcesQuery.isLoading || inventoryQuery.isLoading || savedCategoryQuery.isLoading; + if (inventoryReady && !choicesLoading && !errorText && !sourceListIncomplete && inventoryView?.complete && available.length === 0 + && props.form.codedCategories.length === 0) return null; + + return ( +
+
+

Coded values

+

Choose coded values to group by.

+
+ {readinessQuery.isLoading && !readiness ?

Checking coded-value availability…

: null} + {!readinessQuery.error && readiness && !inventoryReady ? ( +

+ {!readiness.buildId + ? 'No current coded-value catalog is available.' + : `The coded-value catalog is ${readiness.state}. Choices will appear when it is complete.`} +

+ ) : null} + {inventoryReady && choicesLoading ?

Loading coded values…

: null} + {errorText ?

{errorText}

: null} + {sourceListIncomplete && available.length > 0 ?

The direct source-field list may be incomplete; additional coded choices may exist.

: null} + {selectedFamily && props.form.codedCategories.some((category) => category.familyKey !== selectedFamily) ? ( +

These selected concepts come from different source fields. A group can use coded values from one direct source field at a time; remove the conflicting selection to continue.

+ ) : selectedFamily ? ( +

Selected source field: {sources.find((source) => codedFamilyKey(source.bindingId, source.sourcePath) === selectedFamily)?.title ?? 'saved direct source'}. Choose coded values from this source field.

+ ) : null} +
+ {sources.map((source) => { + const sourceEntries = available.filter((entry) => entry.source.choiceId === source.choiceId); + if (sourceEntries.length === 0 && !inventoryView?.complete) return null; + return ( +
+
{source.title}
+ {sourceEntries.map((entry) => { + const { item, familyKey } = entry; + const key = codedCategoryKey(familyKey, item.system, item.code); + const selected = props.form.codedCategories.some((category) => category.key === key); + const familyConflict = Boolean(selectedFamily && selectedFamily !== familyKey); + return ( + + ); + })} +
+ ); + })} + {props.form.codedCategories.filter((category) => !available.some((entry) => + category.key === codedCategoryKey(entry.familyKey, entry.item.system, entry.item.code))).map((category) => { + const familyConflict = Boolean(selectedFamily && selectedFamily !== category.familyKey); + return ( + + ); + })} + {inventoryView?.hasMore ?

Scroll for more choices from these source fields.

: null} +
+ {inventoryReady && !choicesLoading && !errorText && available.length === 0 ? ( +

+ {sourceListIncomplete + ? 'No ready coded values were found among the listed direct source fields; the source list may be incomplete.' + : inventoryView?.complete + ? 'No ready coded values are available from these direct source fields.' + : 'Coded values from direct source fields are still loading.'} +

+ ) : null} + {props.form.codedCategories.length > 0 ?

{props.form.codedCategories.length} coded value{props.form.codedCategories.length === 1 ? '' : 's'} selected.

: null} +
+ ); +}; diff --git a/verification/builder-interactions/reviewed-sources.json b/verification/builder-interactions/reviewed-sources.json index 219b0c3e4..ac0905e62 100644 --- a/verification/builder-interactions/reviewed-sources.json +++ b/verification/builder-interactions/reviewed-sources.json @@ -19,7 +19,7 @@ "internal/explorer/service.go": "2239c3c7093f390962961d8c45f120c8fbd1afda500a683a1efbc92960666e83", "internal/explorer/lifecycle/construction_proposal.go": "425bd54ec28a7d5162371d268d793e415af4fbc63e498ff6aea18cf5e88f5956", "internal/explorer/authoringv2/construction_change.go": "0ff459d136a393a7b10f502c592f621762b8e4a5a5302c43052390c093cdf816", - "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx": "a3f66597ebe7cf9d5f81d04f537e7f3a291b359817f24e670750c2949449f006", + "ui/packages/loom-ui/src/features/ExplorerBuilder/constructionOperations/ConstructionReshapeEditor.tsx": "067873e6a16954ff9a07f8ce4ff387c7ce1b5fdff452c956be5fb5c4e0cb9f0b", "internal/explorer/lifecycle/population_routes.go": "3f36ba75f855c0c0756471834dc297b00ac4c435b5905bf0baadcbbcdc9cedd7", "internal/explorer/lifecycle/population.go": "4aa83f5ed13e12b931c878ed60c5ff67ef1ef10f4be7c174324206c5b0b80eb1", "internal/explorer/capability/population_route_choice.go": "7244704d39016bfa1d368a1972f583ac22a3d9c4c1efd548325566b8d3dd3d23",