app

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

commit 81bd94667797153b7be8159d2144c2a1bcd61535
parent 82353b2e2157ce69e994357fd675816605a495e2
Author: triesap <tyson@radroots.org>
Date:   Sat,  1 Aug 2026 18:28:41 +0000

accounts: implement confirmed account removal

- track one explicit pending removal target
- support cancellation and reject mismatched confirmation
- preserve selection when removing an unselected account
- select deterministic neighbors when removing the active account

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountsReducer.kt | 47++++++++++++++++++++++++++++++++++++++++++++++-
Mapp/desktop/src/test/kotlin/org/radroots/studio/accounts/state/AccountsReducerTest.kt | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 162 insertions(+), 1 deletion(-)

diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountsReducer.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountsReducer.kt @@ -38,8 +38,13 @@ class AccountsReducer( accountId = action.accountId, loginStatus = LoginStatus.LoggedOut, ) + is AccountsAction.RequestRemoveAccount -> requestRemoval(state, action) + AccountsAction.CancelRemoveAccount -> state.copy( + pendingRemovalAccountId = null, + problem = null, + ) + is AccountsAction.ConfirmRemoveAccount -> confirmRemoval(state, action) AccountsAction.DismissProblem -> state.copy(problem = null) - else -> state } return nextState.requireValid() } @@ -103,4 +108,44 @@ class AccountsReducer( problem = null, ) } + + private fun requestRemoval( + state: AccountsState, + action: AccountsAction.RequestRemoveAccount, + ): AccountsState = if (state.accounts.any { it.id == action.accountId }) { + state.copy( + pendingRemovalAccountId = action.accountId, + problem = null, + ) + } else { + state.copy(problem = AccountsProblem.AccountNotFound(action.accountId)) + } + + private fun confirmRemoval( + state: AccountsState, + action: AccountsAction.ConfirmRemoveAccount, + ): AccountsState { + if (state.pendingRemovalAccountId != action.accountId) { + return state.copy( + problem = AccountsProblem.RemovalTargetMismatch( + expectedAccountId = state.pendingRemovalAccountId, + actualAccountId = action.accountId, + ), + ) + } + + val removedIndex = state.accounts.indexOfFirst { it.id == action.accountId } + val remainingAccounts = state.accounts.filterNot { it.id == action.accountId } + val selectedAccountId = if (state.selectedAccountId != action.accountId) { + state.selectedAccountId + } else { + remainingAccounts.getOrNull(removedIndex)?.id ?: remainingAccounts.lastOrNull()?.id + } + return state.copy( + accounts = remainingAccounts, + selectedAccountId = selectedAccountId, + pendingRemovalAccountId = null, + problem = null, + ) + } } diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/state/AccountsReducerTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/state/AccountsReducerTest.kt @@ -172,6 +172,114 @@ class AccountsReducerTest { } } + @Test + fun removalRequestTargetsAnExistingAccountAndCanBeCancelled() { + val first = testAccount() + val second = testAccount(id = "account-2", displayName = "Second Account") + val base = testAccountsState(first, second) + val reducer = reducerWithIds("unused") + + val firstRequest = reducer.reduce( + base, + AccountsAction.RequestRemoveAccount(first.id), + ) + val replacedRequest = reducer.reduce( + firstRequest, + AccountsAction.RequestRemoveAccount(second.id), + ) + val missingId = AccountId("missing") + val missingRequest = reducer.reduce( + replacedRequest, + AccountsAction.RequestRemoveAccount(missingId), + ) + val cancelled = reducer.reduce( + replacedRequest.copy(problem = AccountsProblem.InvalidServerUrl), + AccountsAction.CancelRemoveAccount, + ) + + assertEquals(first.id, firstRequest.pendingRemovalAccountId) + assertEquals(second.id, replacedRequest.pendingRemovalAccountId) + assertEquals(second.id, missingRequest.pendingRemovalAccountId) + assertEquals(AccountsProblem.AccountNotFound(missingId), missingRequest.problem) + assertEquals(null, cancelled.pendingRemovalAccountId) + assertEquals(null, cancelled.problem) + assertEquals(base.accounts, cancelled.accounts) + } + + @Test + fun confirmationRequiresThePendingExplicitTarget() { + val first = testAccount() + val second = testAccount(id = "account-2", displayName = "Second Account") + val reducer = reducerWithIds("unused") + val base = testAccountsState(first, second) + + val withoutRequest = reducer.reduce( + base, + AccountsAction.ConfirmRemoveAccount(first.id), + ) + val requested = reducer.reduce( + base, + AccountsAction.RequestRemoveAccount(first.id), + ) + val mismatch = reducer.reduce( + requested, + AccountsAction.ConfirmRemoveAccount(second.id), + ) + + assertEquals( + AccountsProblem.RemovalTargetMismatch(null, first.id), + withoutRequest.problem, + ) + assertEquals(base.accounts, withoutRequest.accounts) + assertEquals( + AccountsProblem.RemovalTargetMismatch(first.id, second.id), + mismatch.problem, + ) + assertEquals(requested.accounts, mismatch.accounts) + assertEquals(first.id, mismatch.pendingRemovalAccountId) + } + + @Test + fun removingSelectedAccountChoosesTheDeterministicNeighbor() { + val first = testAccount() + val second = testAccount(id = "account-2", displayName = "Second Account") + val third = testAccount(id = "account-3", displayName = "Third Account") + val reducer = reducerWithIds("unused") + + val removedFirst = reducer.removeSelected(testAccountsState(first, second, third), first.id) + val removedMiddle = reducer.removeSelected( + testAccountsState(first, second, third, selectedAccountId = second.id), + second.id, + ) + val removedFinal = reducer.removeSelected( + testAccountsState(first, second, third, selectedAccountId = third.id), + third.id, + ) + + assertEquals(listOf(second, third), removedFirst.accounts) + assertEquals(second.id, removedFirst.selectedAccountId) + assertEquals(listOf(first, third), removedMiddle.accounts) + assertEquals(third.id, removedMiddle.selectedAccountId) + assertEquals(listOf(first, second), removedFinal.accounts) + assertEquals(second.id, removedFinal.selectedAccountId) + } + + @Test + fun removingUnselectedOrFinalAccountPreservesValidSelection() { + val first = testAccount() + val second = testAccount(id = "account-2", displayName = "Second Account") + val reducer = reducerWithIds("unused") + + val unselected = reducer.removeSelected(testAccountsState(first, second), second.id) + val finalAccount = reducer.removeSelected(testAccountsState(first), first.id) + + assertEquals(listOf(first), unselected.accounts) + assertEquals(first.id, unselected.selectedAccountId) + assertEquals(emptyList(), finalAccount.accounts) + assertEquals(null, finalAccount.selectedAccountId) + assertEquals(null, finalAccount.pendingRemovalAccountId) + } + private fun draftState( displayName: String, serverUrl: String, @@ -184,6 +292,14 @@ class AccountsReducerTest { private fun reducerWithIds(vararg ids: String) = AccountsReducer(RecordingIdFactory(*ids)) + + private fun AccountsReducer.removeSelected( + state: AccountsState, + accountId: AccountId, + ): AccountsState { + val requested = reduce(state, AccountsAction.RequestRemoveAccount(accountId)) + return reduce(requested, AccountsAction.ConfirmRemoveAccount(accountId)) + } } private class RecordingIdFactory(