Skip to content

Commit 248aae8

Browse files
TASK-1884601: multiselect mode for checkbox (#125)
Co-authored-by: tomaszmlocek-pega <tomasz.mlocek@pega.com>
1 parent df8b21e commit 248aae8

File tree

10 files changed

+559
-69
lines changed

10 files changed

+559
-69
lines changed

AGENTS.md

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# AGENTS.md — Constellation Mobile SDK
2+
3+
## General most important rules
4+
1. Do not hallucinate. If you don't know something please ask.
5+
2. Do not change the logic of the code while doing migrations or refactoring, unless explicitly asked for.
6+
7+
## Project Overview
8+
9+
Kotlin Multiplatform (KMP) SDK for embedding Pega Constellation forms into mobile apps.
10+
Targets Android, iOS, JVM/Desktop. Group: `com.pega.constellation.sdk.kmp`.
11+
12+
### Module Layout
13+
14+
| Module | Purpose |
15+
|---|---|
16+
| `core` | Domain models, interfaces, component system (publishable) |
17+
| `engine-webview` | WebView-based engine with platform implementations (publishable) |
18+
| `engine-mock` | Mock engine for testing |
19+
| `ui-components-cmp` | Compose Multiplatform UI widgets (publishable) |
20+
| `ui-renderer-cmp` | Renderers bridging core components to UI (publishable) |
21+
| `test` | Integration test infrastructure and mocks |
22+
| `samples/` | Sample Android, desktop, and iOS apps |
23+
| `scripts/` | JavaScript bridge layer (pure ES modules, no bundler) |
24+
25+
## Build Commands
26+
27+
```bash
28+
# Full build
29+
./gradlew clean build
30+
31+
# Build + publish to local Maven
32+
./gradlew clean publishToMavenLocal
33+
34+
# Build a single module
35+
./gradlew :core:build
36+
./gradlew :engine-webview:build
37+
```
38+
39+
## Test Commands
40+
41+
Tests require an Android emulator/device or iOS simulator. Download CDN fixtures first:
42+
43+
```bash
44+
cd test/src/commonMain/composeResources/files/responses && ./download_js_files.sh
45+
```
46+
47+
### Android
48+
49+
```bash
50+
# All SDK instrumented tests
51+
./gradlew :test:connectedAndroidTest
52+
53+
# All sample app UI tests
54+
./gradlew :samples:android-cmp-app:connectedAndroidTest
55+
56+
# Single test class
57+
./gradlew :test:connectedAndroidTest \
58+
-Pandroid.testInstrumentationRunnerArguments.class=com.pega.constellation.sdk.kmp.test.ConstellationSdkTest
59+
60+
# Single test method
61+
./gradlew :test:connectedAndroidTest \
62+
-Pandroid.testInstrumentationRunnerArguments.class=com.pega.constellation.sdk.kmp.test.ConstellationSdkTest#test_initialization
63+
```
64+
65+
### iOS
66+
67+
```bash
68+
xcodebuild -project samples/swiftui-components-app/UITest/UITest.xcodeproj \
69+
-scheme UITest -destination "platform=iOS Simulator,name=<sim>" test
70+
71+
# Single test
72+
xcodebuild ... -only-testing:"UITest/TestCaseProcessing/testCaseProcessing" test
73+
```
74+
75+
---
76+
77+
## Kotlin Code Style
78+
79+
### Formatting & Imports
80+
81+
- 4-space indentation (no tabs); `kotlin.code.style=official` in `gradle.properties`
82+
- One primary class/interface per file; file name matches the class name
83+
- No explicit `public` modifier — rely on Kotlin's default public visibility
84+
- Imports: alphabetically ordered, no blank-line separators, no wildcard imports
85+
- Exception: iOS platform APIs like `platform.WebKit.*`
86+
- Order: android/androidx → project (`com.pega.*`) → kotlin → kotlinx → third-party → java
87+
88+
### Naming & Visibility
89+
90+
- Classes/Interfaces: PascalCase (`FlowContainerComponent`)
91+
- Functions: camelCase; Composables: PascalCase; factory funcs: `for`/`create` prefix
92+
- Variables: camelCase; constants: SCREAMING_SNAKE_CASE in `companion object`
93+
- Enums: SCREAMING_SNAKE_CASE; value classes: PascalCase (`ComponentId`)
94+
- Test methods: `test_` prefix + snake_case (`test_initialization()`)
95+
- `internal` for impl classes; `private set` on mutable Compose state; no explicit `public`
96+
97+
### Error Handling
98+
99+
- **Sealed classes** for domain state: `State.Ready`, `State.Error`, `EngineEvent.Error`
100+
- **`EngineError` interface** with concrete types: `JsError`, `InternalError`
101+
- **`runCatching`/`onFailure`** for defensive handling around component updates
102+
- **Null safety with early returns**: `val x = y as? Type ?: return`
103+
- **`Log` object** (`Log.i`, `Log.w`, `Log.e`) for non-critical failures — never swallow silently
104+
105+
### Patterns
106+
107+
- `StateFlow`/`MutableStateFlow` for observable state; `SharedFlow` for events
108+
- `CoroutineScope(Dispatchers.Main + SupervisorJob())` for lifecycle-scoped coroutines
109+
- `mutableStateOf` (Compose) for component property state, not StateFlow
110+
- `fun interface` for SAM types: `EngineEventHandler`, `ComponentProducer`
111+
- `companion object` with `private const val TAG` and factory methods
112+
- `@JvmInline value class` for type-safe identifiers; `lateinit var` for deferred init
113+
- KDoc on public API interfaces/classes with `@param`/`@property` tags; minimal docs on internals
114+
115+
---
116+
117+
## JavaScript Code Style (scripts/)
118+
119+
The `scripts/` directory contains a pure ES module JavaScript codebase (no bundler, no npm).
120+
This is a bridge layer loaded into a WebView at runtime.
121+
122+
### Formatting
123+
124+
- 4-space indentation (`.prettierrc`: `tabWidth: 4, useTabs: false`)
125+
- Double quotes for strings; semicolons at end of statements
126+
- Max line length: 120 (`.editorconfig`)
127+
- Relative imports with explicit `.js` extension: `import { BaseComponent } from "../../base.component.js";`
128+
- No bare module specifiers, no npm packages; one import per line
129+
130+
### File & Class Naming
131+
132+
- Files: `kebab-case.component.js` (e.g., `text-input.component.js`)
133+
- Classes: PascalCase matching file name (e.g., `TextInputComponent`)
134+
- Component type string: PascalCase without "Component" suffix (`this.type = "TextInput"`)
135+
136+
### Component Lifecycle
137+
138+
All components extend `BaseComponent`. Required methods in order:
139+
140+
1. **`constructor(componentsManager, pConn)`** — call `super(componentsManager, pConn)`, set `this.type`
141+
2. **`init()`** — register via `this.jsComponentPConnect.registerAndSubscribeComponent(this, this.checkAndUpdate)`, call `this.componentsManager.onComponentAdded(this)`, then `this.checkAndUpdate()`
142+
3. **`destroy()`** — call `super.destroy()`, unsubscribe, call `this.componentsManager.onComponentRemoved(this)`
143+
4. **`update(pConn, ...)`** — guard with equality check, reassign, call `this.checkAndUpdate()`
144+
5. **`checkAndUpdate()`** — call `this.jsComponentPConnect.shouldComponentUpdate(this)`, if true call `this.#updateSelf()`
145+
6. **`#updateSelf()`** — resolve props from `this.pConn`, build state, call `this.#sendPropsUpdate()`
146+
7. **`#sendPropsUpdate()`** — set `this.props = { ... }`, call `this.componentsManager.onComponentPropsUpdate(this)`
147+
148+
### Key Conventions
149+
150+
- **Private methods** use `#` prefix: `#updateSelf()`, `#sendPropsUpdate()`
151+
- **No TypeScript** in new code — the `.ts` files are legacy Angular originals kept as reference
152+
- Component registration: `scripts/dxcomponents/mappings/sdk-pega-component-map.js`
153+
- TAG-based logging: `const TAG = "ClassName";` with `Utils.log(TAG, ...)` or `console.warn`
154+
- Guard clauses with early return for null/undefined checks
155+
- `try/catch` only at system boundaries (bridge calls, JSON parsing)
156+
- String interpolation with template literals: `` `@P .${context}` ``
157+
158+
### Angular TS → JS Migration Rules
159+
160+
When migrating `.ts` (Angular) components to `.js`:
161+
1. Remove all Angular imports, decorators (`@Component`, `@Input`), and TypeScript types
162+
2. Remove interfaces and type annotations
163+
3. Remove all Angular lifecycle interfaces
164+
4. For simple fields components extend `FieldBaseComponent`" if possible and reasonable.
165+
5. For Container components extend `ContainerBaseComponent` if possible and reasonable.
166+
6. For rest of components extend `BaseComponent`
167+
7. `ngOnInit``init()` with `componentsManager.onComponentAdded(this)`
168+
8. `ngOnDestroy``destroy()` with `super.destroy()` and `componentsManager.onComponentRemoved(this)`
169+
9. Merge `onStateChange` into `checkAndUpdate()`
170+
10. `updateSelf``#updateSelf()` (private); add `#sendPropsUpdate()` at end
171+
11. Add `update(pConn, ...)` method for external re-rendering
172+
12. Replace `this.pConn$``this.pConn`; remove all `as any` casts
173+
13. Normalize quotes to double quotes
174+
14. If there is some external dependency try to look in additional provided ts files and copy its content to migrated js. If nothing is found try to find existing equivalent in js files or create mocked implementation.
175+
15. When migrating parts of code prefer copy-paste over code modification.
176+
16. Never change logic of migrated components.
177+
178+
### Writing Unit-tests for JS components
179+
1. this.pConn should be mocked
180+
2. PCore should be mocked if needed
181+
3. please test the following methods:
182+
- init()
183+
- update(pConn)
184+
- updateSelf()
185+
- fieldOnChange(value, event)
186+
- fieldOnBlur(value,event)
187+
- onEvent(event)
188+
4. test interactions with this.componentManager
189+
5. test interactions with this.pConn
190+
6. test interactions with PCore (if needed)
191+
7. evaluate props sent in onComponentPropsUpdate() method of componentManager in one test if resonable

core/src/commonMain/kotlin/com/pega/constellation/sdk/kmp/core/api/ComponentEvent.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,18 @@ data class ComponentEvent(
5050
)
5151
)
5252

53+
fun forItemClick(itemIndex: Int, isSelected: Boolean) =
54+
ComponentEvent(
55+
type = CLICK_ITEM_EVENT,
56+
componentData = mapOf(
57+
"clickedItemIndex" to itemIndex.toString(),
58+
"isSelected" to isSelected.toString()
59+
)
60+
)
61+
5362
private const val FIELD_CHANGE = "FieldChange"
5463
private const val FIELD_CHANGE_WITH_FOCUS = "FieldChangeWithFocus"
5564
private const val ACTION_BUTTON_CLICK = "ActionButtonClick"
65+
private const val CLICK_ITEM_EVENT = "ClickItem"
5666
}
5767
}

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

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,9 @@ class ListViewComponent(context: ComponentContext) : BaseComponent(context) {
4949
}
5050

5151
fun onItemClick(itemIndex: Int, isSelected: Boolean) {
52-
context.sendComponentEvent(clickItemEvent(itemIndex, isSelected))
52+
context.sendComponentEvent(ComponentEvent.forItemClick(itemIndex, isSelected))
5353
}
5454

55-
private fun clickItemEvent(itemIndex: Int, isSelected: Boolean) =
56-
ComponentEvent(
57-
type = CLICK_ITEM_EVENT,
58-
componentData = mapOf(
59-
"clickedItemIndex" to itemIndex.toString(),
60-
"isSelected" to isSelected.toString()
61-
)
62-
)
63-
6455
private fun JsonObject.selectionMode() =
6556
getString("selectionMode").toSelectionMode()
6657

@@ -77,9 +68,11 @@ class ListViewComponent(context: ComponentContext) : BaseComponent(context) {
7768
}
7869
element.value.toFoldedItemContent(accumulator, nextPath)
7970
}
71+
8072
is JsonArray -> jsonArray.foldIndexed(initialResult) { index, accumulator, element ->
8173
element.toFoldedItemContent(accumulator, "$currentPath[$index]")
8274
}
75+
8376
else -> initialResult + mapOf(currentPath to jsonPrimitive.content)
8477
}
8578

@@ -95,6 +88,5 @@ class ListViewComponent(context: ComponentContext) : BaseComponent(context) {
9588

9689
companion object {
9790
private const val TAG = "ListViewComponent"
98-
private const val CLICK_ITEM_EVENT = "ClickItem"
9991
}
10092
}

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

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import androidx.compose.runtime.getValue
44
import androidx.compose.runtime.mutableStateOf
55
import androidx.compose.runtime.setValue
66
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.getBoolean
9+
import com.pega.constellation.sdk.kmp.core.components.getJSONArray
10+
import com.pega.constellation.sdk.kmp.core.components.getJsonObject
11+
import com.pega.constellation.sdk.kmp.core.components.getString
712
import com.pega.constellation.sdk.kmp.core.components.optBoolean
813
import com.pega.constellation.sdk.kmp.core.components.optString
914
import kotlinx.serialization.json.JsonObject
@@ -18,11 +23,47 @@ class CheckboxComponent(context: ComponentContext) : FieldComponent(context) {
1823
var hideLabel: Boolean by mutableStateOf(false)
1924
private set
2025

26+
var selectionMode: SelectionMode by mutableStateOf(SelectionMode.SINGLE)
27+
private set
28+
var checkboxGroupOptions: List<Option> by mutableStateOf(emptyList())
29+
private set
30+
2131
override fun applyProps(props: JsonObject) {
2232
super.applyProps(props)
23-
caption = props.optString("caption")
24-
trueLabel = props.optString("trueLabel", default = "True")
25-
falseLabel = props.optString("falseLabel", default = "False")
26-
hideLabel = props.optBoolean("hideLabel", default = false)
33+
34+
selectionMode = SelectionMode.valueOf(
35+
props.optString("selectionMode", default = "single").uppercase()
36+
)
37+
if (selectionMode == SelectionMode.SINGLE) {
38+
caption = props.optString("caption")
39+
trueLabel = props.optString("trueLabel", default = "True")
40+
falseLabel = props.optString("falseLabel", default = "False")
41+
hideLabel = props.optBoolean("hideLabel", default = false)
42+
} else {
43+
checkboxGroupOptions = props.getJSONArray("items").mapWithIndex { index ->
44+
getJsonObject(index).let {
45+
Option(
46+
key = it.getString("key"),
47+
text = it.getString("text"),
48+
value = it.getString("value"),
49+
selected = it.getBoolean("selected"))
50+
}
51+
}
52+
}
53+
}
54+
55+
fun onOptionClick(itemIndex: Int, isSelected: Boolean) {
56+
context.sendComponentEvent(ComponentEvent.forItemClick(itemIndex, isSelected))
57+
}
58+
59+
data class Option(
60+
val key: String,
61+
val text: String,
62+
val value: String,
63+
val selected: Boolean
64+
)
65+
66+
enum class SelectionMode {
67+
SINGLE, MULTI
2768
}
2869
}

0 commit comments

Comments
 (0)