commit 304ab41943581ccc97cb9b629482934206ca9612
parent 5ad8ff2d3c25165ea2ba2f9fd2872021945615f1
Author: triesap <tyson@radroots.org>
Date: Sat, 1 Aug 2026 18:35:30 +0000
ui: add the validated account form
- expose controlled display name and server URL inputs
- dispatch typed draft and account submission actions
- render typed account problems as plain user feedback
- prove valid and invalid form behavior through Compose tests
Diffstat:
2 files changed, 164 insertions(+), 0 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsScreen.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsScreen.kt
@@ -10,15 +10,19 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import org.radroots.studio.accounts.model.Account
import org.radroots.studio.accounts.model.AccountsAction
import org.radroots.studio.accounts.model.AccountsState
+import org.radroots.studio.accounts.model.AccountsProblem
@Composable
fun AccountsScreen(
@@ -34,6 +38,14 @@ fun AccountsScreen(
) {
BasicText("radroots")
BasicText("Accounts")
+ AddAccountForm(state = state, onAction = onAction)
+
+ state.problem?.let { problem ->
+ BasicText(
+ text = problemMessage(problem),
+ modifier = Modifier.testTag("accounts-problem"),
+ )
+ }
if (state.accounts.isEmpty()) {
BasicText(
@@ -79,6 +91,78 @@ fun AccountsScreen(
}
@Composable
+private fun AddAccountForm(
+ state: AccountsState,
+ onAction: (AccountsAction) -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ BasicText("Add account")
+ BasicTextField(
+ value = state.addDraft.displayName,
+ onValueChange = { value ->
+ onAction(AccountsAction.EditAddDisplayName(value))
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("add-display-name")
+ .padding(8.dp),
+ decorationBox = { innerTextField ->
+ if (state.addDraft.displayName.isEmpty()) {
+ BasicText("Display name")
+ }
+ innerTextField()
+ },
+ )
+ BasicTextField(
+ value = state.addDraft.serverUrl,
+ onValueChange = { value ->
+ onAction(AccountsAction.EditAddServerUrl(value))
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("add-server-url")
+ .padding(8.dp),
+ decorationBox = { innerTextField ->
+ if (state.addDraft.serverUrl.isEmpty()) {
+ BasicText("Server URL")
+ }
+ innerTextField()
+ },
+ )
+ TextAction(
+ text = "Add Account",
+ testTag = "add-submit",
+ onClick = { onAction(AccountsAction.SubmitAddAccount) },
+ )
+ }
+}
+
+@Composable
+private fun TextAction(
+ text: String,
+ testTag: String,
+ onClick: () -> Unit,
+) {
+ BasicText(
+ text = text,
+ modifier = Modifier
+ .semantics { role = Role.Button }
+ .testTag(testTag)
+ .clickable(onClick = onClick)
+ .padding(8.dp),
+ )
+}
+
+private fun problemMessage(problem: AccountsProblem): String = when (problem) {
+ AccountsProblem.BlankDisplayName -> "Display name is required."
+ AccountsProblem.InvalidServerUrl -> "Enter a valid HTTP or HTTPS server URL."
+ AccountsProblem.InvalidGeneratedAccountId -> "Could not create an account ID."
+ is AccountsProblem.DuplicateAccountId -> "That account ID already exists."
+ is AccountsProblem.AccountNotFound -> "That account no longer exists."
+ is AccountsProblem.RemovalTargetMismatch -> "The removal target changed."
+}
+
+@Composable
private fun AccountRow(
account: Account,
isSelected: Boolean,
diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsScreenTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsScreenTest.kt
@@ -6,10 +6,20 @@ import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runComposeUiTest
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import kotlin.test.Test
import kotlin.test.assertEquals
import org.radroots.studio.accounts.model.AccountsAction
+import org.radroots.studio.accounts.model.AccountId
+import org.radroots.studio.accounts.model.AccountsProblem
+import org.radroots.studio.accounts.model.AccountsState
+import org.radroots.studio.accounts.state.AccountsReducer
+import org.radroots.studio.accounts.state.AccountsStore
import org.radroots.studio.accounts.testAccount
import org.radroots.studio.accounts.testAccountsState
@@ -54,4 +64,74 @@ class AccountsScreenTest {
actions,
)
}
+
+ @Test
+ fun addFormEmitsTypedDraftAndSubmitActions() = runComposeUiTest {
+ val actions = mutableListOf<AccountsAction>()
+ setContent {
+ var state by remember { mutableStateOf(AccountsState()) }
+ AccountsScreen(
+ state = state,
+ onAction = { action ->
+ actions.add(action)
+ state = when (action) {
+ is AccountsAction.EditAddDisplayName -> state.copy(
+ addDraft = state.addDraft.copy(displayName = action.value),
+ )
+ is AccountsAction.EditAddServerUrl -> state.copy(
+ addDraft = state.addDraft.copy(serverUrl = action.value),
+ )
+ else -> state
+ }
+ },
+ )
+ }
+
+ onNodeWithTag("add-display-name").performTextInput("Farm Account")
+ onNodeWithTag("add-server-url").performTextInput("https://farm.example.test")
+ onNodeWithTag("add-submit").performClick()
+
+ assertEquals(
+ listOf(
+ AccountsAction.EditAddDisplayName("Farm Account"),
+ AccountsAction.EditAddServerUrl("https://farm.example.test"),
+ AccountsAction.SubmitAddAccount,
+ ),
+ actions,
+ )
+ }
+
+ @Test
+ fun problemIsDisplayedWithoutChangingTheProvidedState() = runComposeUiTest {
+ setContent {
+ AccountsScreen(
+ state = AccountsState(problem = AccountsProblem.InvalidServerUrl),
+ onAction = {},
+ )
+ }
+
+ onNodeWithTag("accounts-problem").assertIsDisplayed()
+ onNodeWithText("Enter a valid HTTP or HTTPS server URL.").assertIsDisplayed()
+ }
+
+ @Test
+ fun integratedFormAddsAndSelectsAnAccount() = runComposeUiTest {
+ val store = AccountsStore(
+ initialState = AccountsState(),
+ reducer = AccountsReducer { AccountId("account-1") },
+ )
+ setContent {
+ AccountsScreen(
+ state = store.state.value,
+ onAction = store::dispatch,
+ )
+ }
+
+ onNodeWithTag("add-display-name").performTextInput("Farm Account")
+ onNodeWithTag("add-server-url").performTextInput("https://farm.example.test")
+ onNodeWithTag("add-submit").performClick()
+
+ onNodeWithTag("account-row:account-1").assertIsDisplayed()
+ onNodeWithTag("account-row:account-1").assertIsSelected()
+ }
}