Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# AGENTS.md — Constellation Mobile SDK

## Project Overview

Kotlin Multiplatform (KMP) SDK for embedding Pega Constellation forms into mobile apps.
Targets Android, iOS, JVM/Desktop. Group: `com.pega.constellation.sdk.kmp`.

### Module Layout

| Module | Purpose |
|---|---|
| `core` | Domain models, interfaces, component system (publishable) |
| `engine-webview` | WebView-based engine with platform implementations (publishable) |
| `engine-mock` | Mock engine for testing |
| `ui-components-cmp` | Compose Multiplatform UI widgets (publishable) |
| `ui-renderer-cmp` | Renderers bridging core components to UI (publishable) |
| `test` | Integration test infrastructure and mocks |
| `samples/` | Sample Android, desktop, and iOS apps |
| `scripts/` | JavaScript bridge layer (pure ES modules, no bundler) |

## Build Commands

```bash
# Full build
./gradlew clean build

# Build + publish to local Maven
./gradlew clean publishToMavenLocal

# Build a single module
./gradlew :core:build
./gradlew :engine-webview:build
```

## Test Commands

Tests require an Android emulator/device or iOS simulator. Download CDN fixtures first:

```bash
cd test/src/commonMain/composeResources/files/responses && ./download_js_files.sh
```

### Android

```bash
# All SDK instrumented tests
./gradlew :test:connectedAndroidTest

# All sample app UI tests
./gradlew :samples:android-cmp-app:connectedAndroidTest

# Single test class
./gradlew :test:connectedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.pega.constellation.sdk.kmp.test.ConstellationSdkTest

# Single test method
./gradlew :test:connectedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.pega.constellation.sdk.kmp.test.ConstellationSdkTest#test_initialization
```

### iOS

```bash
xcodebuild -project samples/swiftui-components-app/UITest/UITest.xcodeproj \
-scheme UITest -destination "platform=iOS Simulator,name=<sim>" test

# Single test
xcodebuild ... -only-testing:"UITest/TestCaseProcessing/testCaseProcessing" test
```

---

## Kotlin Code Style

### Formatting & Imports

- 4-space indentation (no tabs); `kotlin.code.style=official` in `gradle.properties`
- One primary class/interface per file; file name matches the class name
- No explicit `public` modifier — rely on Kotlin's default public visibility
- Imports: alphabetically ordered, no blank-line separators, no wildcard imports
- Exception: iOS platform APIs like `platform.WebKit.*`
- Order: android/androidx → project (`com.pega.*`) → kotlin → kotlinx → third-party → java

### Naming & Visibility

- Classes/Interfaces: PascalCase (`FlowContainerComponent`)
- Functions: camelCase; Composables: PascalCase; factory funcs: `for`/`create` prefix
- Variables: camelCase; constants: SCREAMING_SNAKE_CASE in `companion object`
- Enums: SCREAMING_SNAKE_CASE; value classes: PascalCase (`ComponentId`)
- Test methods: `test_` prefix + snake_case (`test_initialization()`)
- `internal` for impl classes; `private set` on mutable Compose state; no explicit `public`

### Error Handling

- **Sealed classes** for domain state: `State.Ready`, `State.Error`, `EngineEvent.Error`
- **`EngineError` interface** with concrete types: `JsError`, `InternalError`
- **`runCatching`/`onFailure`** for defensive handling around component updates
- **Null safety with early returns**: `val x = y as? Type ?: return`
- **`Log` object** (`Log.i`, `Log.w`, `Log.e`) for non-critical failures — never swallow silently

### Patterns

- `StateFlow`/`MutableStateFlow` for observable state; `SharedFlow` for events
- `CoroutineScope(Dispatchers.Main + SupervisorJob())` for lifecycle-scoped coroutines
- `mutableStateOf` (Compose) for component property state, not StateFlow
- `fun interface` for SAM types: `EngineEventHandler`, `ComponentProducer`
- `companion object` with `private const val TAG` and factory methods
- `@JvmInline value class` for type-safe identifiers; `lateinit var` for deferred init
- KDoc on public API interfaces/classes with `@param`/`@property` tags; minimal docs on internals

---

## JavaScript Code Style (scripts/)

The `scripts/` directory contains a pure ES module JavaScript codebase (no bundler, no npm).
This is a bridge layer loaded into a WebView at runtime.

### Formatting

- 4-space indentation (`.prettierrc`: `tabWidth: 4, useTabs: false`)
- Double quotes for strings; semicolons at end of statements
- Max line length: 120 (`.editorconfig`)
- Relative imports with explicit `.js` extension: `import { BaseComponent } from "../../base.component.js";`
- No bare module specifiers, no npm packages; one import per line

### File & Class Naming

- Files: `kebab-case.component.js` (e.g., `text-input.component.js`)
- Classes: PascalCase matching file name (e.g., `TextInputComponent`)
- Component type string: PascalCase without "Component" suffix (`this.type = "TextInput"`)

### Component Lifecycle

All components extend `BaseComponent`. Required methods in order:

1. **`constructor(componentsManager, pConn)`** — call `super(componentsManager, pConn)`, set `this.type`
2. **`init()`** — register via `this.jsComponentPConnect.registerAndSubscribeComponent(this, this.checkAndUpdate)`, call `this.componentsManager.onComponentAdded(this)`, then `this.checkAndUpdate()`
3. **`destroy()`** — call `super.destroy()`, unsubscribe, call `this.componentsManager.onComponentRemoved(this)`
4. **`update(pConn, ...)`** — guard with equality check, reassign, call `this.checkAndUpdate()`
5. **`checkAndUpdate()`** — call `this.jsComponentPConnect.shouldComponentUpdate(this)`, if true call `this.#updateSelf()`
6. **`#updateSelf()`** — resolve props from `this.pConn`, build state, call `this.#sendPropsUpdate()`
7. **`#sendPropsUpdate()`** — set `this.props = { ... }`, call `this.componentsManager.onComponentPropsUpdate(this)`

### Key Conventions

- **Private methods** use `#` prefix: `#updateSelf()`, `#sendPropsUpdate()`
- **No TypeScript** in new code — the `.ts` files are legacy Angular originals kept as reference
- Component registration: `scripts/dxcomponents/mappings/sdk-pega-component-map.js`
- TAG-based logging: `const TAG = "ClassName";` with `Utils.log(TAG, ...)` or `console.warn`
- Guard clauses with early return for null/undefined checks
- `try/catch` only at system boundaries (bridge calls, JSON parsing)
- String interpolation with template literals: `` `@P .${context}` ``

### TS → JS Migration Rules

When migrating `.ts` (Angular) components to `.js`:
1. Remove all Angular imports, decorators (`@Component`, `@Input`), and TypeScript types
2. Remove interfaces and type annotations
3. Remove all Angular lifecycle interfaces
4. For simple fields components extend `FieldBaseComponent`" if possible and reasonable.
5. For Container components extend `ContainerBaseComponent` if possible and reasonable.
6. For rest of components extend `BaseComponent`
7. `ngOnInit` → `init()` with `componentsManager.onComponentAdded(this)`
8. `ngOnDestroy` → `destroy()` with `super.destroy()` and `componentsManager.onComponentRemoved(this)`
9. Merge `onStateChange` into `checkAndUpdate()`
10. `updateSelf` → `#updateSelf()` (private); add `#sendPropsUpdate()` at end
11. Add `update(pConn, ...)` method for external re-rendering
12. Replace `this.pConn$` → `this.pConn`; remove all `as any` casts
13. Normalize quotes to double quotes
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.
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,18 @@ data class ComponentEvent(
)
)

fun forItemClick(itemIndex: Int, isSelected: Boolean) =
ComponentEvent(
type = CLICK_ITEM_EVENT,
componentData = mapOf(
"clickedItemIndex" to itemIndex.toString(),
"isSelected" to isSelected.toString()
)
)

private const val FIELD_CHANGE = "FieldChange"
private const val FIELD_CHANGE_WITH_FOCUS = "FieldChangeWithFocus"
private const val ACTION_BUTTON_CLICK = "ActionButtonClick"
private const val CLICK_ITEM_EVENT = "ClickItem"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,9 @@ class ListViewComponent(context: ComponentContext) : BaseComponent(context) {
}

fun onItemClick(itemIndex: Int, isSelected: Boolean) {
context.sendComponentEvent(clickItemEvent(itemIndex, isSelected))
context.sendComponentEvent(ComponentEvent.forItemClick(itemIndex, isSelected))
}

private fun clickItemEvent(itemIndex: Int, isSelected: Boolean) =
ComponentEvent(
type = CLICK_ITEM_EVENT,
componentData = mapOf(
"clickedItemIndex" to itemIndex.toString(),
"isSelected" to isSelected.toString()
)
)

private fun JsonObject.selectionMode() =
getString("selectionMode").toSelectionMode()

Expand All @@ -77,9 +68,11 @@ class ListViewComponent(context: ComponentContext) : BaseComponent(context) {
}
element.value.toFoldedItemContent(accumulator, nextPath)
}

is JsonArray -> jsonArray.foldIndexed(initialResult) { index, accumulator, element ->
element.toFoldedItemContent(accumulator, "$currentPath[$index]")
}

else -> initialResult + mapOf(currentPath to jsonPrimitive.content)
}

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

companion object {
private const val TAG = "ListViewComponent"
private const val CLICK_ITEM_EVENT = "ClickItem"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.pega.constellation.sdk.kmp.core.api.ComponentContext
import com.pega.constellation.sdk.kmp.core.api.ComponentEvent
import com.pega.constellation.sdk.kmp.core.components.getBoolean
import com.pega.constellation.sdk.kmp.core.components.getJSONArray
import com.pega.constellation.sdk.kmp.core.components.getJsonObject
import com.pega.constellation.sdk.kmp.core.components.getString
import com.pega.constellation.sdk.kmp.core.components.optBoolean
import com.pega.constellation.sdk.kmp.core.components.optString
import kotlinx.serialization.json.JsonObject
Expand All @@ -18,11 +23,47 @@ class CheckboxComponent(context: ComponentContext) : FieldComponent(context) {
var hideLabel: Boolean by mutableStateOf(false)
private set

var selectionMode: SelectionMode by mutableStateOf(SelectionMode.SINGLE)
private set
var checkboxGroupOptions: List<Option> by mutableStateOf(emptyList())
private set

override fun applyProps(props: JsonObject) {
super.applyProps(props)
caption = props.optString("caption")
trueLabel = props.optString("trueLabel", default = "True")
falseLabel = props.optString("falseLabel", default = "False")
hideLabel = props.optBoolean("hideLabel", default = false)

selectionMode = SelectionMode.valueOf(
props.optString("selectionMode", default = "single").uppercase()
)
if (selectionMode == SelectionMode.SINGLE) {
caption = props.optString("caption")
trueLabel = props.optString("trueLabel", default = "True")
falseLabel = props.optString("falseLabel", default = "False")
hideLabel = props.optBoolean("hideLabel", default = false)
} else {
checkboxGroupOptions = props.getJSONArray("items").mapWithIndex { index ->
getJsonObject(index).let {
Option(
key = it.getString("key"),
text = it.getString("text"),
value = it.getString("value"),
selected = it.getBoolean("selected"))
}
}
}
}

fun onOptionClick(itemIndex: Int, isSelected: Boolean) {
context.sendComponentEvent(ComponentEvent.forItemClick(itemIndex, isSelected))
}

data class Option(
val key: String,
val text: String,
val value: String,
val selected: Boolean
)

enum class SelectionMode {
SINGLE, MULTI
}
}
Loading
Loading