test: add Sage client sample app
This commit is contained in:
parent
4993e550db
commit
d9b545577c
65
test/sageclient/README.md
Normal file
65
test/sageclient/README.md
Normal file
@ -0,0 +1,65 @@
|
||||
# Sage desktop client sample
|
||||
|
||||
This directory is an independent Compose Desktop sample app for Sage on top of the
|
||||
root `bricks-mp` shared module. It is intentionally **not** included from the
|
||||
root `settings.gradle.kts`, so normal library builds are unchanged.
|
||||
|
||||
## Build
|
||||
|
||||
From any directory:
|
||||
|
||||
```bash
|
||||
/path/to/bricks-mp/test/sageclient/build.sh
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
1. resolves its own directory,
|
||||
2. checks that `java` is available and is JDK 17+,
|
||||
3. uses the repository Gradle wrapper when present, otherwise system `gradle`,
|
||||
4. runs `gradle build` for this standalone sample.
|
||||
|
||||
Extra Gradle arguments can be appended, for example:
|
||||
|
||||
```bash
|
||||
./test/sageclient/build.sh --info
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd /path/to/bricks-mp/test/sageclient
|
||||
../../gradlew run \
|
||||
-Dsage.baseUrl=http://localhost:8080 \
|
||||
-Dsage.centerUi=/center.ui
|
||||
```
|
||||
|
||||
Optional properties:
|
||||
|
||||
- `sage.baseUrl` defaults to `http://localhost:8080`
|
||||
- `sage.centerUi` defaults to `/center.ui`
|
||||
- `sage.loginAction` defaults to `/rbac/user/login`
|
||||
- `sage.loginUi` defaults to `/rbac/user/login.ui`
|
||||
- `sage.username` / `sage.password` prefill the login form
|
||||
- `sage.lang` overrides the default JVM locale tag
|
||||
|
||||
## What the sample demonstrates
|
||||
|
||||
- Uses the generic `BricksHttp` client from the shared module for Sage login and
|
||||
`center.ui` loading. Sage-specific bootstrapping lives here, not in the shared
|
||||
library package.
|
||||
- Loads `center.ui` via `ActionDispatcher.dispatch(BricksBind(actiontype =
|
||||
"urlwidget", ...))`, so HTTP handling stays in the shared ActionDispatcher /
|
||||
BricksHttp flow.
|
||||
- `BricksHttp` automatically appends the correct WebBricks query parameters for
|
||||
`.ui` / `.dspy` backend requests:
|
||||
`_webbricks_`, `_width`, `_height`, `_is_mobile`, `_lang`.
|
||||
- `BricksHttp` surfaces HTTP 403, 401 and 3xx (including 301) as
|
||||
`BricksHttpException`. `ActionDispatcher` handles them generically:
|
||||
- 403 loads the configured login UI (`sage.loginUi`) in a dialog,
|
||||
- 401 shows the server response as an unauthorized message,
|
||||
- 3xx follows the `Location` header as a UI navigation target.
|
||||
|
||||
The sample is wired as a Gradle composite build through `includeBuild("../..")`
|
||||
and depends on the root project module using `implementation("com.bricks.mp:shared")`
|
||||
with dependency substitution to `:shared`.
|
||||
34
test/sageclient/build.gradle.kts
Normal file
34
test/sageclient/build.gradle.kts
Normal file
@ -0,0 +1,34 @@
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.jetbrains.compose)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
sourceSets {
|
||||
jvmMain.dependencies {
|
||||
implementation("com.bricks.mp:shared")
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.ui)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "com.bricks.test.sageclient.MainKt"
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "sageclient"
|
||||
packageVersion = "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
38
test/sageclient/build.sh
Executable file
38
test/sageclient/build.sh
Executable file
@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$SCRIPT_DIR"
|
||||
ROOT_DIR="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
if ! command -v java >/dev/null 2>&1; then
|
||||
cat >&2 <<'EOF'
|
||||
ERROR: java was not found in PATH.
|
||||
Please install JDK 17 or newer, then rerun this script.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
JAVA_VERSION_OUTPUT="$(java -version 2>&1 | head -n 1)"
|
||||
JAVA_VERSION="$(printf '%s\n' "$JAVA_VERSION_OUTPUT" | sed -E 's/.*version "([0-9]+)(\.[0-9]+)?.*/\1/')"
|
||||
if ! [[ "$JAVA_VERSION" =~ ^[0-9]+$ ]]; then
|
||||
echo "WARNING: Unable to parse Java version from: $JAVA_VERSION_OUTPUT" >&2
|
||||
elif [ "$JAVA_VERSION" -lt 17 ]; then
|
||||
echo "ERROR: JDK 17 or newer is required. Found: $JAVA_VERSION_OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -x "$ROOT_DIR/gradlew" ]; then
|
||||
GRADLE_CMD=("$ROOT_DIR/gradlew")
|
||||
elif command -v gradle >/dev/null 2>&1; then
|
||||
GRADLE_CMD=(gradle)
|
||||
else
|
||||
cat >&2 <<'EOF'
|
||||
ERROR: Neither root gradlew nor system gradle was found.
|
||||
Run from a checkout that contains the Gradle wrapper, or install Gradle.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
"${GRADLE_CMD[@]}" --no-configuration-cache build "$@"
|
||||
13
test/sageclient/gradle/libs.versions.toml
Normal file
13
test/sageclient/gradle/libs.versions.toml
Normal file
@ -0,0 +1,13 @@
|
||||
[versions]
|
||||
compose = "1.7.3"
|
||||
compose-plugin = "1.7.3"
|
||||
kotlin = "2.1.0"
|
||||
coroutines = "1.9.0"
|
||||
|
||||
[libraries]
|
||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||
|
||||
[plugins]
|
||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
|
||||
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||
26
test/sageclient/settings.gradle.kts
Normal file
26
test/sageclient/settings.gradle.kts
Normal file
@ -0,0 +1,26 @@
|
||||
rootProject.name = "bricks-mp-sageclient-sample"
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||
}
|
||||
}
|
||||
|
||||
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
||||
|
||||
includeBuild("../..") {
|
||||
dependencySubstitution {
|
||||
substitute(module("com.bricks.mp:shared")).using(project(":shared"))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,294 @@
|
||||
package com.bricks.test.sageclient
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import androidx.compose.ui.window.application
|
||||
import com.bricks.mp.actions.ActionDispatcher
|
||||
import com.bricks.mp.core.BricksBind
|
||||
import com.bricks.mp.core.BricksContext
|
||||
import com.bricks.mp.core.BricksHttp
|
||||
import com.bricks.mp.core.BricksHttpException
|
||||
import com.bricks.mp.core.BricksWidget
|
||||
import com.bricks.mp.core.RenderWidget
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.event.ComponentAdapter
|
||||
import java.awt.event.ComponentEvent
|
||||
import java.util.Locale
|
||||
|
||||
private const val DEFAULT_BASE_URL = "http://localhost:8080"
|
||||
private const val DEFAULT_CENTER_UI = "/center.ui"
|
||||
private const val DEFAULT_LOGIN_ACTION = "/rbac/user/login"
|
||||
private const val DEFAULT_LOGIN_UI = "/rbac/user/login.ui"
|
||||
|
||||
fun main() = application {
|
||||
val context = remember { BricksContext() }
|
||||
val http = remember { BricksHttp(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val windowState = remember { WindowState(width = 1280.dp, height = 800.dp) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.baseUrl = System.getProperty("sage.baseUrl", DEFAULT_BASE_URL)
|
||||
}
|
||||
|
||||
Window(
|
||||
onCloseRequest = {
|
||||
http.close()
|
||||
exitApplication()
|
||||
},
|
||||
title = "Sage Bricks Client",
|
||||
state = windowState
|
||||
) {
|
||||
MaterialTheme {
|
||||
val density = LocalDensity.current
|
||||
val lang = remember { System.getProperty("sage.lang", Locale.getDefault().toLanguageTag()) }
|
||||
val fallbackWidthPx = with(density) { windowState.size.width.roundToPx() }
|
||||
val fallbackHeightPx = with(density) { windowState.size.height.roundToPx() }
|
||||
var windowSizePx by remember { mutableStateOf(IntSize(fallbackWidthPx, fallbackHeightPx)) }
|
||||
var dialogWidget by remember { mutableStateOf<BricksWidget?>(null) }
|
||||
var message by remember { mutableStateOf<Triple<String, String, Boolean>?>(null) }
|
||||
|
||||
DisposableEffect(window) {
|
||||
fun updateWindowSize() {
|
||||
val size = window.size
|
||||
if (size.width > 0 && size.height > 0) {
|
||||
windowSizePx = IntSize(size.width, size.height)
|
||||
}
|
||||
}
|
||||
|
||||
val listener = object : ComponentAdapter() {
|
||||
override fun componentResized(e: ComponentEvent) = updateWindowSize()
|
||||
override fun componentShown(e: ComponentEvent) = updateWindowSize()
|
||||
}
|
||||
|
||||
updateWindowSize()
|
||||
window.addComponentListener(listener)
|
||||
onDispose { window.removeComponentListener(listener) }
|
||||
}
|
||||
|
||||
LaunchedEffect(windowSizePx, lang) {
|
||||
http.updateRequestContext(
|
||||
width = windowSizePx.width.takeIf { it > 0 } ?: fallbackWidthPx,
|
||||
height = windowSizePx.height.takeIf { it > 0 } ?: fallbackHeightPx,
|
||||
isMobile = false,
|
||||
lang = lang
|
||||
)
|
||||
}
|
||||
|
||||
val actionDispatcher = remember(context, http, scope) {
|
||||
ActionDispatcher(context = context, http = http, scope = scope).apply {
|
||||
loginUiPath = System.getProperty("sage.loginUi", DEFAULT_LOGIN_UI)
|
||||
onWidgetLoaded = { widget -> context.setCurrentWidget(widget) }
|
||||
onDialog = { widget, show -> dialogWidget = if (show) widget else null }
|
||||
onMessage = { title, body, isError -> message = Triple(title, body, isError) }
|
||||
}
|
||||
}
|
||||
|
||||
val currentWidget by context.currentWidget.collectAsState()
|
||||
SageClientScreen(
|
||||
baseUrl = context.baseUrl,
|
||||
widget = currentWidget,
|
||||
actionDispatcher = actionDispatcher,
|
||||
onBaseUrlChange = { context.baseUrl = it.trimEnd('/') },
|
||||
onLoadCenter = {
|
||||
actionDispatcher.dispatch(
|
||||
BricksBind(
|
||||
event = "click",
|
||||
actiontype = "urlwidget",
|
||||
url = System.getProperty("sage.centerUi", DEFAULT_CENTER_UI)
|
||||
)
|
||||
)
|
||||
},
|
||||
onLogin = { username, password ->
|
||||
scope.launch {
|
||||
loginAndLoadCenter(
|
||||
context = context,
|
||||
http = http,
|
||||
actionDispatcher = actionDispatcher,
|
||||
username = username,
|
||||
password = password,
|
||||
onMessage = { title, body, isError -> message = Triple(title, body, isError) }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
dialogWidget?.let { widget ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { dialogWidget = null },
|
||||
title = { Text("Login required") },
|
||||
text = { RenderWidget(widget, actionDispatcher) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { dialogWidget = null }) { Text("Close") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
message?.let { (title, body, _) ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { message = null },
|
||||
title = { Text(title) },
|
||||
text = { Text(body) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { message = null }) { Text("OK") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SageClientScreen(
|
||||
baseUrl: String,
|
||||
widget: BricksWidget?,
|
||||
actionDispatcher: ActionDispatcher,
|
||||
onBaseUrlChange: (String) -> Unit,
|
||||
onLoadCenter: () -> Unit,
|
||||
onLogin: (String, String) -> Unit
|
||||
) {
|
||||
var editableBaseUrl by remember(baseUrl) { mutableStateOf(baseUrl) }
|
||||
var username by remember { mutableStateOf(System.getProperty("sage.username", "")) }
|
||||
var password by remember { mutableStateOf(System.getProperty("sage.password", "")) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(title = { Text("Sage Bricks Client") })
|
||||
}
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = editableBaseUrl,
|
||||
onValueChange = {
|
||||
editableBaseUrl = it
|
||||
onBaseUrlChange(it)
|
||||
},
|
||||
label = { Text("Sage base URL") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = onLoadCenter, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Text("Load center.ui")
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("Username") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = { onLogin(username, password) }, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Text("Login + load")
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxSize().padding(12.dp)) {
|
||||
if (widget == null) {
|
||||
Text(
|
||||
"Click Load center.ui to fetch Sage UI. " +
|
||||
"BricksHttp automatically appends WebBricks params " +
|
||||
"(_webbricks_, _width, _height, _is_mobile, _lang) for .ui/.dspy requests. " +
|
||||
"HTTP 403/401/301 responses are surfaced to ActionDispatcher/BricksHttp handling."
|
||||
)
|
||||
} else {
|
||||
RenderWidget(
|
||||
widget = widget,
|
||||
actionDispatcher = actionDispatcher,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loginAndLoadCenter(
|
||||
context: BricksContext,
|
||||
http: BricksHttp,
|
||||
actionDispatcher: ActionDispatcher,
|
||||
username: String,
|
||||
password: String,
|
||||
onMessage: (String, String, Boolean) -> Unit
|
||||
) {
|
||||
val loginAction = System.getProperty("sage.loginAction", DEFAULT_LOGIN_ACTION)
|
||||
runCatching {
|
||||
http.postForm(
|
||||
url = context.entireUrl(loginAction),
|
||||
form = mapOf(
|
||||
"username" to username,
|
||||
"password" to password
|
||||
),
|
||||
authToken = context.authToken
|
||||
)
|
||||
}.onSuccess {
|
||||
onMessage("Login", "Login request completed; loading center.ui", false)
|
||||
actionDispatcher.dispatch(
|
||||
BricksBind(
|
||||
event = "login-success",
|
||||
actiontype = "urlwidget",
|
||||
url = System.getProperty("sage.centerUi", DEFAULT_CENTER_UI)
|
||||
)
|
||||
)
|
||||
}.onFailure { error ->
|
||||
handleLoadFailure("Login failed", error, onMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleLoadFailure(
|
||||
title: String,
|
||||
error: Throwable,
|
||||
onMessage: (String, String, Boolean) -> Unit,
|
||||
hint: String = ""
|
||||
) {
|
||||
if (error is BricksHttpException) {
|
||||
val location = error.location?.let { "\nLocation: $it" }.orEmpty()
|
||||
val extra = if (hint.isBlank()) "" else "\n$hint"
|
||||
onMessage(title, "HTTP ${error.statusCode}: ${error.responseBody}$location$extra", true)
|
||||
} else {
|
||||
onMessage(title, error.message ?: error.toString(), true)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user