Skip to content

Commit ef3065a

Browse files
TASK-1881605: Multiselect component.
1 parent a02b784 commit ef3065a

File tree

13 files changed

+1027
-5
lines changed

13 files changed

+1027
-5
lines changed

core/src/commonMain/kotlin/com/pega/constellation/sdk/kmp/core/components/ComponentRegistry.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import com.pega.constellation.sdk.kmp.core.components.ComponentTypes.Group
2020
import com.pega.constellation.sdk.kmp.core.components.ComponentTypes.Integer
2121
import com.pega.constellation.sdk.kmp.core.components.ComponentTypes.ListView
2222
import com.pega.constellation.sdk.kmp.core.components.ComponentTypes.ModalViewContainer
23+
import com.pega.constellation.sdk.kmp.core.components.ComponentTypes.Multiselect
2324
import com.pega.constellation.sdk.kmp.core.components.ComponentTypes.OneColumn
2425
import com.pega.constellation.sdk.kmp.core.components.ComponentTypes.Phone
2526
import com.pega.constellation.sdk.kmp.core.components.ComponentTypes.RadioButtons
@@ -71,6 +72,7 @@ import com.pega.constellation.sdk.kmp.core.components.fields.UrlComponent
7172
import com.pega.constellation.sdk.kmp.core.components.widgets.ActionButtonsComponent
7273
import com.pega.constellation.sdk.kmp.core.components.widgets.AlertBannerComponent
7374
import com.pega.constellation.sdk.kmp.core.components.widgets.AutoCompleteComponent
75+
import com.pega.constellation.sdk.kmp.core.components.widgets.MultiselectComponent
7476
import com.pega.constellation.sdk.kmp.core.components.widgets.UnsupportedComponent
7577
import com.pega.constellation.sdk.kmp.core.api.ComponentDefinition as Def
7678

@@ -102,6 +104,7 @@ object ComponentRegistry {
102104
Def(Integer) { IntegerComponent(it) },
103105
Def(ListView) { ListViewComponent(it) },
104106
Def(ModalViewContainer) { ModalViewContainerComponent(it) },
107+
Def(Multiselect) { MultiselectComponent(it) },
105108
Def(OneColumn) { OneColumnComponent(it) },
106109
Def(Phone) { PhoneComponent(it) },
107110
Def(RadioButtons) { RadioButtonsComponent(it) },

core/src/commonMain/kotlin/com/pega/constellation/sdk/kmp/core/components/ComponentTypes.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ object ComponentTypes {
4848
val ActionButtons = ComponentType("ActionButtons")
4949
val AlertBanner = ComponentType("AlertBanner")
5050
val AutoComplete = ComponentType("AutoComplete")
51+
val Multiselect = ComponentType("Multiselect")
5152

5253
// unsupported
5354
val Unsupported = ComponentType("Unsupported")
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.pega.constellation.sdk.kmp.core.components.widgets
2+
3+
import androidx.compose.runtime.getValue
4+
import androidx.compose.runtime.mutableStateOf
5+
import androidx.compose.runtime.setValue
6+
import com.pega.constellation.sdk.kmp.core.api.ComponentContext
7+
import com.pega.constellation.sdk.kmp.core.api.ComponentEvent
8+
import com.pega.constellation.sdk.kmp.core.components.fields.SelectableComponent
9+
import kotlinx.serialization.json.jsonArray
10+
import kotlinx.serialization.json.jsonPrimitive
11+
import kotlinx.serialization.json.JsonObject
12+
13+
class MultiselectComponent(context: ComponentContext) : SelectableComponent(context) {
14+
var selectedKeys: List<String> by mutableStateOf(emptyList())
15+
private set
16+
17+
override fun applyProps(props: JsonObject) {
18+
super.applyProps(props)
19+
selectedKeys = props["selectedKeys"]?.jsonArray
20+
?.map { it.jsonPrimitive.content }
21+
?: emptyList()
22+
}
23+
24+
fun addSelection(key: String) {
25+
if (selectedKeys.contains(key)) return
26+
selectedKeys = selectedKeys + key
27+
context.sendComponentEvent(ComponentEvent.multiselectAddItem(key))
28+
}
29+
30+
fun removeSelection(key: String) {
31+
if (!selectedKeys.contains(key)) return
32+
selectedKeys = selectedKeys - key
33+
context.sendComponentEvent(ComponentEvent.multiselectRemoveItem(key))
34+
}
35+
}
36+
37+
private fun ComponentEvent.Companion.multiselectAddItem(key: String) =
38+
ComponentEvent("MultiselectEvent", eventData = mapOf("type" to "addItem", "key" to key))
39+
40+
private fun ComponentEvent.Companion.multiselectRemoveItem(key: String) =
41+
ComponentEvent("MultiselectEvent", eventData = mapOf("type" to "removeItem", "key" to key))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.pega.constellation.sdk.kmp.samples.androidcmpapp.test.cases
2+
3+
import androidx.compose.ui.test.ExperimentalTestApi
4+
import androidx.compose.ui.test.onNodeWithText
5+
import androidx.compose.ui.test.performClick
6+
import androidx.compose.ui.test.runComposeUiTest
7+
import com.pega.constellation.sdk.kmp.samples.androidcmpapp.test.ComposeTest
8+
import com.pega.constellation.sdk.kmp.samples.androidcmpapp.test.waitForNode
9+
import com.pega.constellation.sdk.kmp.test.mock.PegaVersion
10+
import kotlin.test.Test
11+
12+
@OptIn(ExperimentalTestApi::class)
13+
class DataReferenceMultiSelectTest : ComposeTest(PegaVersion.v25_1) {
14+
15+
@Test
16+
fun test_multiselect_cars() = runComposeUiTest {
17+
setupApp("O40M3A-MarekCo-Work-DataReferenceMultiSelectTest")
18+
19+
// Create case
20+
onNodeWithText("New Service").performClick()
21+
22+
// Wait for multiselect to appear
23+
waitForNode("Cars Selection")
24+
25+
// Open dropdown by clicking on the field
26+
onNodeWithText("Cars Selection").performClick()
27+
28+
// Verify options are loaded from D_carsList
29+
waitForNode("Focus")
30+
waitForNode("Corolla")
31+
waitForNode("126p")
32+
waitForNode("Octavia")
33+
34+
onNodeWithText("Focus").performClick()
35+
onNodeWithText("Octavia").performClick()
36+
37+
// Close dropdown by clicking on the field again
38+
onNodeWithText("Cars Selection").performClick()
39+
waitForNode("Focus, Octavia")
40+
41+
onNodeWithText("Submit").performClick()
42+
waitForNode("Thanks for registration")
43+
}
44+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { SelectBaseComponent } from "./select-base.component.js";
2+
3+
export class MultiselectComponent extends SelectBaseComponent {
4+
selectionList = "";
5+
selectionKey = "";
6+
primaryField = "";
7+
referenceListPath = "";
8+
pageInstructionsInitialized = false;
9+
compositeKeys = [];
10+
rawRecords = {};
11+
12+
updateSelf() {
13+
this.updateBaseProps();
14+
15+
const configProps = this.pConn.getConfigProps();
16+
this.selectionList = configProps.selectionList ?? "";
17+
this.selectionKey = configProps.selectionKey ?? "";
18+
this.primaryField = configProps.primaryField ?? "";
19+
this.referenceListPath = configProps.referenceList ?? "";
20+
21+
if (this.referenceListPath) {
22+
this.pConn.setReferenceList(this.selectionList);
23+
}
24+
25+
if (!this.pageInstructionsInitialized && this.selectionList) {
26+
this.pageInstructionsInitialized = true;
27+
this.compositeKeys = this.getCompositeKeys();
28+
this.pConn.getListActions().initDefaultPageInstructions(this.selectionList, this.compositeKeys);
29+
}
30+
31+
this.props.selectedKeys = this.getSelectedKeys();
32+
33+
if (!this.props.options) {
34+
this.props.options = [];
35+
}
36+
37+
this.componentsManager.onComponentPropsUpdate(this);
38+
this.loadOptions(configProps.parameters);
39+
}
40+
41+
loadOptions(parameters) {
42+
PCore.getDataApiUtils()
43+
.getData(this.referenceListPath, { dataViewParameters: parameters ?? {} }, "")
44+
.then((res) => {
45+
const records = res?.data?.data || [];
46+
const keyProp = this.normalizePropName(this.selectionKey);
47+
const labelProp = this.normalizePropName(this.primaryField);
48+
49+
this.rawRecords = {};
50+
records.forEach((entry) => {
51+
const k = entry[keyProp];
52+
if (k != null) {
53+
this.rawRecords[k.toString()] = entry;
54+
}
55+
});
56+
57+
this.props.options = records
58+
.filter((item) => item[keyProp] != null)
59+
.map((item) => ({
60+
key: item[keyProp].toString(),
61+
value: item[keyProp],
62+
label: (item[labelProp] ?? item[keyProp]).toString(),
63+
}));
64+
65+
this.props.selectedKeys = this.getSelectedKeys();
66+
this.componentsManager.onComponentPropsUpdate(this);
67+
})
68+
.catch((err) => {
69+
console.warn("MultiselectComponent", "Error loading options:", err);
70+
});
71+
}
72+
73+
onEvent(event) {
74+
if (event.type === "MultiselectEvent") {
75+
this.onMultiselectEvent(event.eventData ?? {});
76+
return;
77+
}
78+
super.onEvent(event);
79+
}
80+
81+
onMultiselectEvent(eventData) {
82+
switch (eventData.type) {
83+
case "addItem":
84+
this.addItem(eventData.key);
85+
break;
86+
case "removeItem":
87+
this.removeItem(eventData.key);
88+
break;
89+
default:
90+
console.warn("MultiselectComponent", "Unexpected event:", eventData.type);
91+
}
92+
}
93+
94+
addItem(key) {
95+
if (!key || !this.selectionKey) return;
96+
97+
const selectedOption = this.props.options?.find((option) => option.key === key);
98+
if (!selectedOption) return;
99+
100+
this.ensureReferenceList();
101+
102+
const actualProperty = this.normalizePropName(this.selectionKey);
103+
const displayProperty = this.normalizePropName(this.primaryField);
104+
const rows = this.pConn.getValue(this.getSelectionPath()) || [];
105+
const startIndex = Array.isArray(rows) ? rows.length : 0;
106+
107+
// Build content with key, label, and composite key values from the raw record
108+
const content = { [actualProperty]: selectedOption.value, [displayProperty]: selectedOption.label };
109+
const rawRecord = this.rawRecords[key] || {};
110+
this.compositeKeys.forEach((prop) => {
111+
if (rawRecord[prop] !== undefined) {
112+
content[prop] = rawRecord[prop];
113+
}
114+
});
115+
116+
const nonFormProperties = Object.keys(content).filter((k) => k !== actualProperty);
117+
118+
const listActions = this.pConn.getListActions();
119+
listActions.insert({ ...content, nonFormProperties }, startIndex);
120+
listActions.update(content, startIndex);
121+
}
122+
123+
removeItem(key) {
124+
if (!key || !this.selectionKey) return;
125+
126+
this.ensureReferenceList();
127+
128+
const actualProperty = this.normalizePropName(this.selectionKey);
129+
const rows = this.pConn.getValue(this.getSelectionPath()) || [];
130+
const index = Array.isArray(rows) ? rows.findIndex((row) => String(row[actualProperty]) === key) : -1;
131+
132+
if (index >= 0) {
133+
this.pConn.getListActions().deleteEntry(index);
134+
}
135+
}
136+
137+
getSelectedKeys() {
138+
if (!this.selectionKey || !this.selectionList) return [];
139+
140+
const selectionData = this.pConn.getValue(this.getSelectionPath()) || [];
141+
if (!Array.isArray(selectionData)) return [];
142+
143+
const keyProperty = this.normalizePropName(this.selectionKey);
144+
return selectionData
145+
.map((item) => item?.[keyProperty])
146+
.filter((val) => val != null)
147+
.map((val) => val.toString());
148+
}
149+
150+
getSelectionPath() {
151+
return `${this.pConn.getPageReference()}${this.selectionList}`;
152+
}
153+
154+
getCompositeKeys() {
155+
const metadata = this.pConn.getFieldMetadata(this.selectionList) || {};
156+
const { datasource: { parameters = {} } = {} } = metadata;
157+
const keys = [];
158+
Object.values(parameters).forEach((param) => {
159+
if (this.isSelfReferencedProperty(param, this.selectionList)) {
160+
keys.push(param.substring(param.lastIndexOf(".") + 1));
161+
}
162+
});
163+
return keys;
164+
}
165+
166+
ensureReferenceList() {
167+
if (this.selectionList && this.pConn.getReferenceList() !== this.selectionList) {
168+
this.pConn.setReferenceList(this.selectionList);
169+
this.compositeKeys = this.getCompositeKeys();
170+
this.pConn.getListActions().initDefaultPageInstructions(this.selectionList, this.compositeKeys);
171+
}
172+
}
173+
174+
isSelfReferencedProperty(param, referenceProp) {
175+
const [, parentPropName] = param.split(".");
176+
const referencePropParent = referenceProp?.split(".").pop();
177+
return parentPropName === referencePropParent;
178+
}
179+
180+
normalizePropName(propertyName) {
181+
return propertyName?.startsWith(".") ? propertyName.substring(1) : propertyName;
182+
}
183+
}

scripts/dxcomponents/mappings/sdk-pega-component-map.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import { FieldGroupTemplateComponent } from "../components/containers/templates/
7171
import { ListViewComponent } from "../components/containers/templates/listview/list-view.component.js";
7272
// import { MultiReferenceReadonlyComponent } from './_components/template/multi-reference-readonly/multi-reference-readonly.component';
7373
// import { MultiselectComponent } from './_components/field/multiselect/multiselect.component';
74+
import { MultiselectComponent } from "../components/fields/multiselect.component.js";
7475
// import { NarrowWideFormComponent } from './_components/template/narrow-wide-form/narrow-wide-form.component';
7576
import { OneColumnComponent } from "../components/containers/one-column.component.js";
7677
// import { OneColumnPageComponent } from './_components/template/one-column-page/one-column-page.component';
@@ -189,7 +190,7 @@ const pegaSdkComponentMap = {
189190
// MaterialUtility: MaterialUtilityComponent,
190191
ModalViewContainer: ModalViewContainerComponent,
191192
// MultiReferenceReadOnly: MultiReferenceReadonlyComponent,
192-
// Multiselect: MultiselectComponent,
193+
Multiselect: MultiselectComponent,
193194
// MultiStep: MultiStepComponent,
194195
// // 'NarrowWide': NarrowWideFormComponent,
195196
// NarrowWideDetails: DetailsNarrowWideComponent,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{
2+
"data": {
3+
"caseInfo": {
4+
"caseTypeID": "O40M3A-MarekCo-Work-DataReferenceMultiSelectTest",
5+
"owner": "user@marco",
6+
"availableActions": [{
7+
"name": "Edit details",
8+
"links": {
9+
"open": {
10+
"rel": "self",
11+
"href": "/cases/O40M3A-MAREKCO-WORK D-17009/actions/pyUpdateCaseDetails",
12+
"type": "GET",
13+
"title": "Get case action details"
14+
}
15+
},
16+
"ID": "pyUpdateCaseDetails",
17+
"type": "Case"
18+
}, {
19+
"name": "Change stage",
20+
"links": {
21+
"open": {
22+
"rel": "self",
23+
"href": "/cases/O40M3A-MAREKCO-WORK D-17009/actions/pyChangeStage",
24+
"type": "GET",
25+
"title": "Get case action details"
26+
}
27+
},
28+
"ID": "pyChangeStage",
29+
"type": "Case"
30+
}],
31+
"associations": {
32+
"follows": false
33+
},
34+
"lastUpdatedBy": "user@marco",
35+
"hasNewAttachments": false,
36+
"businessID": "D-17009",
37+
"sla": {
38+
"goal": "",
39+
"deadline": ""
40+
},
41+
"WidgetsToRefresh": ["TaskList"],
42+
"caseTypeName": "DataReferenceMultiSelectTest",
43+
"urgency": "10",
44+
"createTime": "2026-03-31T09:50:43.750Z",
45+
"createdBy": "user@marco",
46+
"name": "DataReferenceMultiSelectTest",
47+
"stages": [{
48+
"entryTime": "2026-03-31T09:50:43.757Z",
49+
"name": "Stage 1",
50+
"links": {
51+
"open": {
52+
"rel": "self",
53+
"href": "/cases/O40M3A-MAREKCO-WORK D-17009/stages/PRIM0",
54+
"type": "PUT",
55+
"title": "Jump to this stage"
56+
}
57+
},
58+
"visited_status": "completed",
59+
"ID": "PRIM0",
60+
"type": "Primary",
61+
"transitionType": "create"
62+
}],
63+
"ID": "O40M3A-MAREKCO-WORK D-17009",
64+
"caseTypeIcon": "cmicons/pycase.svg",
65+
"status": "New",
66+
"stageID": "PRIM0",
67+
"stageLabel": "Stage 1",
68+
"lastUpdateTime": "2026-03-31T09:50:59.659Z",
69+
"content": {}
70+
}
71+
},
72+
"confirmationNote": "Thank you! The next step in this case has been routed appropriately."
73+
}

0 commit comments

Comments
 (0)