app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsScreen.kt | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsScreenTest.kt | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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() + } }