feat: add board detail create and local filter viewmodel state
This commit is contained in:
@@ -3,6 +3,7 @@ package space.hackenslacker.kanbn4droid.app.boarddetail
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import java.time.LocalDate
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
@@ -23,7 +24,33 @@ data class BoardDetailUiState(
|
||||
val selectedCardIds: Set<String> = emptySet(),
|
||||
val editingListId: String? = null,
|
||||
val editingListTitle: String = "",
|
||||
)
|
||||
val pendingTagFilterIds: Set<String> = emptySet(),
|
||||
val pendingTitleQuery: String = "",
|
||||
val activeTagFilterIds: Set<String> = emptySet(),
|
||||
val activeTitleQuery: String = "",
|
||||
val isFabChooserOpen: Boolean = false,
|
||||
val isAddListDialogOpen: Boolean = false,
|
||||
val isAddCardDialogOpen: Boolean = false,
|
||||
val isFilterDialogOpen: Boolean = false,
|
||||
val isSearchDialogOpen: Boolean = false,
|
||||
val addListTitleDraft: String = "",
|
||||
val addCardTitleDraft: String = "",
|
||||
val addCardDescriptionDraft: String = "",
|
||||
val addCardDueDate: LocalDate? = null,
|
||||
val addCardSelectedTagIds: Set<String> = emptySet(),
|
||||
) {
|
||||
val canAddCard: Boolean
|
||||
get() = boardDetail?.lists?.isNotEmpty() == true
|
||||
|
||||
val addCardDisabledMessage: String?
|
||||
get() = if (canAddCard) null else "Add a list before creating cards."
|
||||
|
||||
val filteredBoardDetail: BoardDetail?
|
||||
get() = boardDetail?.withFilteredCards(
|
||||
tagFilterIds = activeTagFilterIds,
|
||||
titleQuery = activeTitleQuery,
|
||||
)
|
||||
}
|
||||
|
||||
sealed interface BoardDetailUiEvent {
|
||||
data class NavigateToCardPlaceholder(val cardId: String) : BoardDetailUiEvent
|
||||
@@ -33,6 +60,15 @@ sealed interface BoardDetailUiEvent {
|
||||
|
||||
interface BoardDetailDataSource {
|
||||
suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail>
|
||||
suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef>
|
||||
suspend fun createCard(
|
||||
listPublicId: String,
|
||||
title: String,
|
||||
description: String?,
|
||||
dueDate: LocalDate?,
|
||||
tagPublicIds: Collection<String>,
|
||||
): BoardsApiResult<CreatedEntityRef>
|
||||
|
||||
suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult
|
||||
suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult
|
||||
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit>
|
||||
@@ -45,6 +81,26 @@ internal class BoardDetailRepositoryDataSource(
|
||||
return repository.getBoardDetail(boardId)
|
||||
}
|
||||
|
||||
override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
|
||||
return repository.createList(boardPublicId, title)
|
||||
}
|
||||
|
||||
override suspend fun createCard(
|
||||
listPublicId: String,
|
||||
title: String,
|
||||
description: String?,
|
||||
dueDate: LocalDate?,
|
||||
tagPublicIds: Collection<String>,
|
||||
): BoardsApiResult<CreatedEntityRef> {
|
||||
return repository.createCard(
|
||||
listPublicId = listPublicId,
|
||||
title = title,
|
||||
description = description,
|
||||
dueDate = dueDate,
|
||||
tagPublicIds = tagPublicIds,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
||||
return repository.moveCards(cardIds, targetListId)
|
||||
}
|
||||
@@ -129,6 +185,304 @@ class BoardDetailViewModel(
|
||||
return true
|
||||
}
|
||||
|
||||
fun openFabChooser() {
|
||||
_uiState.update { it.copy(isFabChooserOpen = true) }
|
||||
}
|
||||
|
||||
fun closeFabChooser() {
|
||||
_uiState.update { it.copy(isFabChooserOpen = false) }
|
||||
}
|
||||
|
||||
fun openAddListDialog() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isFabChooserOpen = false,
|
||||
isAddListDialogOpen = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAddListTitle(title: String) {
|
||||
_uiState.update { it.copy(addListTitleDraft = title) }
|
||||
}
|
||||
|
||||
fun cancelAddListDialog() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isAddListDialogOpen = false,
|
||||
addListTitleDraft = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createList() {
|
||||
val snapshot = _uiState.value
|
||||
if (snapshot.isMutating) {
|
||||
return
|
||||
}
|
||||
val detail = snapshot.boardDetail ?: return
|
||||
val title = snapshot.addListTitleDraft.trim()
|
||||
if (title.isBlank()) {
|
||||
viewModelScope.launch {
|
||||
_events.emit(BoardDetailUiEvent.ShowServerError("List title is required"))
|
||||
}
|
||||
return
|
||||
}
|
||||
val expectedIndex = detail.lists.size
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isMutating = true) }
|
||||
when (val result = repository.createList(detail.id, title)) {
|
||||
is BoardsApiResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isAddListDialogOpen = false,
|
||||
addListTitleDraft = "",
|
||||
)
|
||||
}
|
||||
when (val reload = reloadDetailAndReconcile()) {
|
||||
is BoardsApiResult.Success -> {
|
||||
_uiState.update { it.copy(isMutating = false) }
|
||||
val warning = verifyCreatedList(result.value.publicId, expectedIndex, reload.value)
|
||||
if (warning != null) {
|
||||
_events.emit(BoardDetailUiEvent.ShowWarning(warning))
|
||||
}
|
||||
}
|
||||
|
||||
is BoardsApiResult.Failure -> {
|
||||
_uiState.update { it.copy(isMutating = false) }
|
||||
_events.emit(
|
||||
BoardDetailUiEvent.ShowWarning(
|
||||
"Changes applied, but refresh failed. Pull to refresh.",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is BoardsApiResult.Failure -> {
|
||||
_uiState.update { it.copy(isMutating = false) }
|
||||
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openAddCardDialog() {
|
||||
val snapshot = _uiState.value
|
||||
if (!snapshot.canAddCard) {
|
||||
_uiState.update { it.copy(isFabChooserOpen = false, isAddCardDialogOpen = false) }
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isFabChooserOpen = false,
|
||||
isAddCardDialogOpen = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateAddCardTitle(title: String) {
|
||||
_uiState.update { it.copy(addCardTitleDraft = title) }
|
||||
}
|
||||
|
||||
fun updateAddCardDescription(description: String) {
|
||||
_uiState.update { it.copy(addCardDescriptionDraft = description) }
|
||||
}
|
||||
|
||||
fun setDueDate(dueDate: LocalDate) {
|
||||
_uiState.update { it.copy(addCardDueDate = dueDate) }
|
||||
}
|
||||
|
||||
fun clearDueDate() {
|
||||
_uiState.update { it.copy(addCardDueDate = null) }
|
||||
}
|
||||
|
||||
fun toggleAddCardTag(tagId: String) {
|
||||
val normalizedTagId = tagId.trim()
|
||||
if (normalizedTagId.isBlank()) {
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
val next = it.addCardSelectedTagIds.toMutableSet()
|
||||
if (!next.add(normalizedTagId)) {
|
||||
next.remove(normalizedTagId)
|
||||
}
|
||||
it.copy(addCardSelectedTagIds = next)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelAddCardDialog() {
|
||||
_uiState.update { it.resetAddCardDrafts().copy(isAddCardDialogOpen = false) }
|
||||
}
|
||||
|
||||
fun createCard() {
|
||||
val snapshot = _uiState.value
|
||||
if (snapshot.isMutating) {
|
||||
return
|
||||
}
|
||||
val detail = snapshot.boardDetail ?: return
|
||||
val currentList = detail.lists.getOrNull(snapshot.currentPageIndex) ?: return
|
||||
val title = snapshot.addCardTitleDraft.trim()
|
||||
if (title.isBlank()) {
|
||||
viewModelScope.launch {
|
||||
_events.emit(BoardDetailUiEvent.ShowServerError("Card title is required"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isMutating = true) }
|
||||
when (
|
||||
val result = repository.createCard(
|
||||
listPublicId = currentList.id,
|
||||
title = title,
|
||||
description = snapshot.addCardDescriptionDraft,
|
||||
dueDate = snapshot.addCardDueDate,
|
||||
tagPublicIds = snapshot.addCardSelectedTagIds,
|
||||
)
|
||||
) {
|
||||
is BoardsApiResult.Success -> {
|
||||
_uiState.update {
|
||||
it.resetAddCardDrafts().copy(isAddCardDialogOpen = false)
|
||||
}
|
||||
when (val reload = reloadDetailAndReconcile()) {
|
||||
is BoardsApiResult.Success -> {
|
||||
_uiState.update { it.copy(isMutating = false) }
|
||||
val warning = verifyCreatedCard(
|
||||
createdCardId = result.value.publicId,
|
||||
targetListId = currentList.id,
|
||||
detail = reload.value,
|
||||
)
|
||||
if (warning != null) {
|
||||
_events.emit(BoardDetailUiEvent.ShowWarning(warning))
|
||||
}
|
||||
}
|
||||
|
||||
is BoardsApiResult.Failure -> {
|
||||
_uiState.update { it.copy(isMutating = false) }
|
||||
_events.emit(
|
||||
BoardDetailUiEvent.ShowWarning(
|
||||
"Changes applied, but refresh failed. Pull to refresh.",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is BoardsApiResult.Failure -> {
|
||||
_uiState.update { it.copy(isMutating = false) }
|
||||
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openFilterDialog() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isFilterDialogOpen = true,
|
||||
pendingTagFilterIds = it.activeTagFilterIds,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePendingTagFilterIds(tagIds: Set<String>) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
pendingTagFilterIds = tagIds
|
||||
.map { id -> id.trim() }
|
||||
.filter { id -> id.isNotBlank() }
|
||||
.toSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun applyFilterDialog() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
activeTagFilterIds = it.pendingTagFilterIds,
|
||||
isFilterDialogOpen = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelFilterDialog() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
pendingTagFilterIds = it.activeTagFilterIds,
|
||||
isFilterDialogOpen = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onTagFilterIconTapped() {
|
||||
val snapshot = _uiState.value
|
||||
if (snapshot.activeTagFilterIds.isNotEmpty()) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
activeTagFilterIds = emptySet(),
|
||||
pendingTagFilterIds = emptySet(),
|
||||
isFilterDialogOpen = false,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
openFilterDialog()
|
||||
}
|
||||
|
||||
fun openSearchDialog() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isSearchDialogOpen = true,
|
||||
pendingTitleQuery = it.activeTitleQuery,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePendingTitleQuery(query: String) {
|
||||
_uiState.update { it.copy(pendingTitleQuery = query) }
|
||||
}
|
||||
|
||||
fun applySearchDialog() {
|
||||
_uiState.update {
|
||||
val trimmed = it.pendingTitleQuery.trim()
|
||||
it.copy(
|
||||
activeTitleQuery = trimmed,
|
||||
pendingTitleQuery = trimmed,
|
||||
isSearchDialogOpen = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelSearchDialog() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
pendingTitleQuery = it.activeTitleQuery,
|
||||
isSearchDialogOpen = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onSearchIconTapped() {
|
||||
val snapshot = _uiState.value
|
||||
if (snapshot.activeTitleQuery.isNotBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
activeTitleQuery = "",
|
||||
pendingTitleQuery = "",
|
||||
isSearchDialogOpen = false,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
openSearchDialog()
|
||||
}
|
||||
|
||||
fun moveSelectedCards(targetListId: String) {
|
||||
val snapshot = _uiState.value
|
||||
if (snapshot.isMutating) {
|
||||
@@ -191,9 +545,9 @@ class BoardDetailViewModel(
|
||||
when (val result = repository.renameList(editingListId, trimmedTitle)) {
|
||||
is BoardsApiResult.Success -> {
|
||||
_uiState.update { it.copy(editingListId = null, editingListTitle = "") }
|
||||
val reloadFailureMessage = tryReloadDetailAndReconcile()
|
||||
val reloadResult = reloadDetailAndReconcile()
|
||||
_uiState.update { it.copy(isMutating = false) }
|
||||
if (reloadFailureMessage != null) {
|
||||
if (reloadResult is BoardsApiResult.Failure) {
|
||||
_events.emit(
|
||||
BoardDetailUiEvent.ShowWarning(
|
||||
"Changes applied, but refresh failed. Pull to refresh.",
|
||||
@@ -276,9 +630,9 @@ class BoardDetailViewModel(
|
||||
when (val result = mutation(selectedIds)) {
|
||||
is CardBatchMutationResult.Success -> {
|
||||
_uiState.update { it.copy(selectedCardIds = emptySet()) }
|
||||
val reloadFailureMessage = tryReloadDetailAndReconcile()
|
||||
val reloadResult = reloadDetailAndReconcile()
|
||||
_uiState.update { it.copy(isMutating = false) }
|
||||
if (reloadFailureMessage != null) {
|
||||
if (reloadResult is BoardsApiResult.Failure) {
|
||||
_events.emit(
|
||||
BoardDetailUiEvent.ShowWarning(
|
||||
"Changes applied, but refresh failed. Pull to refresh.",
|
||||
@@ -288,8 +642,8 @@ class BoardDetailViewModel(
|
||||
}
|
||||
|
||||
is CardBatchMutationResult.PartialSuccess -> {
|
||||
val reloadFailureMessage = tryReloadDetailAndReconcile()
|
||||
if (reloadFailureMessage == null) {
|
||||
val reloadResult = reloadDetailAndReconcile()
|
||||
if (reloadResult is BoardsApiResult.Success) {
|
||||
val visibleIds = allVisibleCardIds(_uiState.value.boardDetail)
|
||||
_uiState.update {
|
||||
it.copy(selectedCardIds = result.failedCardIds.intersect(visibleIds))
|
||||
@@ -325,14 +679,14 @@ class BoardDetailViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun tryReloadDetailAndReconcile(): String? {
|
||||
private suspend fun reloadDetailAndReconcile(): BoardsApiResult<BoardDetail> {
|
||||
return when (val result = repository.getBoardDetail(boardId)) {
|
||||
is BoardsApiResult.Success -> {
|
||||
_uiState.update { reconcileWithNewDetail(it, result.value) }
|
||||
null
|
||||
result
|
||||
}
|
||||
|
||||
is BoardsApiResult.Failure -> result.message
|
||||
is BoardsApiResult.Failure -> result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,6 +712,8 @@ class BoardDetailViewModel(
|
||||
selectedCardIds = prunedSelection,
|
||||
editingListId = if (hasEditedList) current.editingListId else null,
|
||||
editingListTitle = if (hasEditedList) current.editingListTitle else "",
|
||||
pendingTagFilterIds = current.pendingTagFilterIds.intersect(availableTagIds(detail)),
|
||||
activeTagFilterIds = current.activeTagFilterIds.intersect(availableTagIds(detail)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -377,6 +733,50 @@ class BoardDetailViewModel(
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private fun availableTagIds(detail: BoardDetail): Set<String> {
|
||||
return detail.lists
|
||||
.asSequence()
|
||||
.flatMap { list -> list.cards.asSequence() }
|
||||
.flatMap { card -> card.tags.asSequence() }
|
||||
.map { tag -> tag.id }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private fun verifyCreatedList(createdListId: String?, expectedIndex: Int, detail: BoardDetail): String? {
|
||||
val normalizedId = createdListId?.trim().orEmpty()
|
||||
if (normalizedId.isBlank()) {
|
||||
return CREATE_NON_DETERMINISTIC_WARNING
|
||||
}
|
||||
val actualIndex = detail.lists.indexOfFirst { list -> list.id == normalizedId }
|
||||
if (actualIndex < 0) {
|
||||
return CREATE_NON_DETERMINISTIC_WARNING
|
||||
}
|
||||
return if (actualIndex != expectedIndex) CREATE_LIST_PLACEMENT_WARNING else null
|
||||
}
|
||||
|
||||
private fun verifyCreatedCard(createdCardId: String?, targetListId: String, detail: BoardDetail): String? {
|
||||
val normalizedId = createdCardId?.trim().orEmpty()
|
||||
if (normalizedId.isBlank()) {
|
||||
return CREATE_NON_DETERMINISTIC_WARNING
|
||||
}
|
||||
val list = detail.lists.firstOrNull { it.id == targetListId }
|
||||
?: return CREATE_NON_DETERMINISTIC_WARNING
|
||||
val actualIndex = list.cards.indexOfFirst { card -> card.id == normalizedId }
|
||||
if (actualIndex < 0) {
|
||||
return CREATE_NON_DETERMINISTIC_WARNING
|
||||
}
|
||||
return if (actualIndex != 0) CREATE_CARD_PLACEMENT_WARNING else null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CREATE_LIST_PLACEMENT_WARNING =
|
||||
"List created, but server ordering differed. Pull to refresh if needed."
|
||||
const val CREATE_CARD_PLACEMENT_WARNING =
|
||||
"Card created, but server ordering differed. Pull to refresh if needed."
|
||||
const val CREATE_NON_DETERMINISTIC_WARNING =
|
||||
"Created item could not be deterministically verified. Pull to refresh if needed."
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val boardId: String,
|
||||
private val repository: BoardDetailRepository,
|
||||
@@ -393,3 +793,39 @@ class BoardDetailViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun BoardDetailUiState.resetAddCardDrafts(): BoardDetailUiState {
|
||||
return copy(
|
||||
addCardTitleDraft = "",
|
||||
addCardDescriptionDraft = "",
|
||||
addCardDueDate = null,
|
||||
addCardSelectedTagIds = emptySet(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun BoardDetail.withFilteredCards(tagFilterIds: Set<String>, titleQuery: String): BoardDetail {
|
||||
val normalizedTitleQuery = titleQuery.trim()
|
||||
if (tagFilterIds.isEmpty() && normalizedTitleQuery.isEmpty()) {
|
||||
return this
|
||||
}
|
||||
|
||||
return copy(
|
||||
lists = lists.map { list ->
|
||||
list.copy(
|
||||
cards = list.cards.filter { card ->
|
||||
val matchesTags = if (tagFilterIds.isEmpty()) {
|
||||
true
|
||||
} else {
|
||||
card.tags.any { tag -> tag.id in tagFilterIds }
|
||||
}
|
||||
val matchesTitle = if (normalizedTitleQuery.isEmpty()) {
|
||||
true
|
||||
} else {
|
||||
card.title.contains(normalizedTitleQuery, ignoreCase = true)
|
||||
}
|
||||
matchesTags && matchesTitle
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import java.time.LocalDate
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
@@ -49,7 +51,7 @@ class BoardDetailViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backPressClearsSelectionWhenSelectionActive() = runTest {
|
||||
fun backPressWithSelection_clearsSelectionAndReturnsTrue() = runTest {
|
||||
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
|
||||
|
||||
viewModel.onCardLongPressed("card-1")
|
||||
@@ -58,6 +60,338 @@ class BoardDetailViewModelTest {
|
||||
assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openAddCardWhenNoLists_setsCanAddCardFalse_andMessage() = runTest {
|
||||
val viewModel = newLoadedViewModel(
|
||||
this,
|
||||
FakeBoardDetailDataSource(),
|
||||
detailWithoutLists(),
|
||||
)
|
||||
|
||||
viewModel.openAddCardDialog()
|
||||
|
||||
assertFalse(viewModel.uiState.value.canAddCard)
|
||||
assertEquals("Add a list before creating cards.", viewModel.uiState.value.addCardDisabledMessage)
|
||||
assertFalse(viewModel.uiState.value.isAddCardDialogOpen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applyFilters_tagAnyAndQueryAnd_keepsAllLists_filtersCardsOnly() = runTest {
|
||||
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
|
||||
|
||||
viewModel.openFilterDialog()
|
||||
viewModel.updatePendingTagFilterIds(setOf("tag-b"))
|
||||
viewModel.applyFilterDialog()
|
||||
viewModel.openSearchDialog()
|
||||
viewModel.updatePendingTitleQuery("duke")
|
||||
viewModel.applySearchDialog()
|
||||
|
||||
val filtered = viewModel.uiState.value.filteredBoardDetail
|
||||
assertEquals(2, filtered?.lists?.size)
|
||||
assertEquals(listOf("card-2"), filtered?.lists?.get(0)?.cards?.map { it.id })
|
||||
assertTrue(filtered?.lists?.get(1)?.cards?.isEmpty() == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tapActiveTagFilterIcon_clearsTagFilter_withoutOpeningDialog() = runTest {
|
||||
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
|
||||
|
||||
viewModel.openFilterDialog()
|
||||
viewModel.updatePendingTagFilterIds(setOf("tag-a"))
|
||||
viewModel.applyFilterDialog()
|
||||
viewModel.onTagFilterIconTapped()
|
||||
|
||||
assertTrue(viewModel.uiState.value.activeTagFilterIds.isEmpty())
|
||||
assertTrue(viewModel.uiState.value.pendingTagFilterIds.isEmpty())
|
||||
assertFalse(viewModel.uiState.value.isFilterDialogOpen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancelFilterDialog_restoresPendingFromActive() = runTest {
|
||||
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
|
||||
|
||||
viewModel.openFilterDialog()
|
||||
viewModel.updatePendingTagFilterIds(setOf("tag-a"))
|
||||
viewModel.applyFilterDialog()
|
||||
viewModel.openFilterDialog()
|
||||
viewModel.updatePendingTagFilterIds(setOf("tag-b"))
|
||||
viewModel.cancelFilterDialog()
|
||||
|
||||
assertEquals(setOf("tag-a"), viewModel.uiState.value.activeTagFilterIds)
|
||||
assertEquals(setOf("tag-a"), viewModel.uiState.value.pendingTagFilterIds)
|
||||
assertFalse(viewModel.uiState.value.isFilterDialogOpen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancelAndSuccess_resetAddDrafts() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithSingleList()),
|
||||
BoardsApiResult.Success(detailWithSingleListAndAppendedList()),
|
||||
BoardsApiResult.Success(detailWithSingleListAndNewTopCard()),
|
||||
),
|
||||
),
|
||||
createListResult = BoardsApiResult.Success(CreatedEntityRef("list-new")),
|
||||
createCardResult = BoardsApiResult.Success(CreatedEntityRef("card-new")),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
|
||||
viewModel.openAddListDialog()
|
||||
viewModel.updateAddListTitle("Temp list")
|
||||
viewModel.cancelAddListDialog()
|
||||
assertEquals("", viewModel.uiState.value.addListTitleDraft)
|
||||
assertFalse(viewModel.uiState.value.isAddListDialogOpen)
|
||||
|
||||
viewModel.openAddCardDialog()
|
||||
viewModel.updateAddCardTitle("Temp card")
|
||||
viewModel.updateAddCardDescription("temp")
|
||||
viewModel.toggleAddCardTag("tag-a")
|
||||
viewModel.setDueDate(LocalDate.of(2026, 1, 2))
|
||||
viewModel.cancelAddCardDialog()
|
||||
assertEquals("", viewModel.uiState.value.addCardTitleDraft)
|
||||
assertEquals("", viewModel.uiState.value.addCardDescriptionDraft)
|
||||
assertTrue(viewModel.uiState.value.addCardSelectedTagIds.isEmpty())
|
||||
assertNull(viewModel.uiState.value.addCardDueDate)
|
||||
assertFalse(viewModel.uiState.value.isAddCardDialogOpen)
|
||||
|
||||
viewModel.openAddListDialog()
|
||||
viewModel.updateAddListTitle("New list")
|
||||
viewModel.createList()
|
||||
advanceUntilIdle()
|
||||
assertEquals("", viewModel.uiState.value.addListTitleDraft)
|
||||
assertFalse(viewModel.uiState.value.isAddListDialogOpen)
|
||||
|
||||
viewModel.openAddCardDialog()
|
||||
viewModel.updateAddCardTitle("New card")
|
||||
viewModel.updateAddCardDescription("desc")
|
||||
viewModel.toggleAddCardTag("tag-a")
|
||||
viewModel.setDueDate(LocalDate.of(2026, 2, 3))
|
||||
viewModel.createCard()
|
||||
advanceUntilIdle()
|
||||
assertEquals("", viewModel.uiState.value.addCardTitleDraft)
|
||||
assertEquals("", viewModel.uiState.value.addCardDescriptionDraft)
|
||||
assertTrue(viewModel.uiState.value.addCardSelectedTagIds.isEmpty())
|
||||
assertNull(viewModel.uiState.value.addCardDueDate)
|
||||
assertFalse(viewModel.uiState.value.isAddCardDialogOpen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createFailure_keepsAddDialogsOpenWithDrafts() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
|
||||
createListResult = BoardsApiResult.Failure("List create failed"),
|
||||
createCardResult = BoardsApiResult.Failure("Card create failed"),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
|
||||
viewModel.openAddListDialog()
|
||||
viewModel.updateAddListTitle("My list")
|
||||
viewModel.createList()
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.uiState.value.isAddListDialogOpen)
|
||||
assertEquals("My list", viewModel.uiState.value.addListTitleDraft)
|
||||
|
||||
viewModel.openAddCardDialog()
|
||||
viewModel.updateAddCardTitle("My card")
|
||||
viewModel.updateAddCardDescription("desc")
|
||||
viewModel.toggleAddCardTag("tag-a")
|
||||
viewModel.setDueDate(LocalDate.of(2026, 3, 4))
|
||||
viewModel.createCard()
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.uiState.value.isAddCardDialogOpen)
|
||||
assertEquals("My card", viewModel.uiState.value.addCardTitleDraft)
|
||||
assertEquals("desc", viewModel.uiState.value.addCardDescriptionDraft)
|
||||
assertEquals(setOf("tag-a"), viewModel.uiState.value.addCardSelectedTagIds)
|
||||
assertEquals(LocalDate.of(2026, 3, 4), viewModel.uiState.value.addCardDueDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createList_blankTitle_rejectsBeforeRepositoryCall() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.openAddListDialog()
|
||||
viewModel.updateAddListTitle(" ")
|
||||
viewModel.createList()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(0, repository.createListCalls)
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.ShowServerError)
|
||||
assertEquals("List title is required", (event as BoardDetailUiEvent.ShowServerError).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createCard_blankTitle_rejectsBeforeRepositoryCall() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.openAddCardDialog()
|
||||
viewModel.updateAddCardTitle(" ")
|
||||
viewModel.createCard()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(0, repository.createCardCalls)
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.ShowServerError)
|
||||
assertEquals("Card title is required", (event as BoardDetailUiEvent.ShowServerError).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setDueDate_updatesCardDraftDueDate() = runTest {
|
||||
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithSingleList())
|
||||
|
||||
viewModel.openAddCardDialog()
|
||||
viewModel.setDueDate(LocalDate.of(2026, 4, 5))
|
||||
|
||||
assertEquals(LocalDate.of(2026, 4, 5), viewModel.uiState.value.addCardDueDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearDueDate_resetsCardDraftDueDate() = runTest {
|
||||
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithSingleList())
|
||||
|
||||
viewModel.openAddCardDialog()
|
||||
viewModel.setDueDate(LocalDate.of(2026, 4, 5))
|
||||
viewModel.clearDueDate()
|
||||
|
||||
assertNull(viewModel.uiState.value.addCardDueDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createListPlacementMismatch_emitsExactWarning() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithSingleList()),
|
||||
BoardsApiResult.Success(detailWithListPlacementMismatch()),
|
||||
),
|
||||
),
|
||||
createListResult = BoardsApiResult.Success(CreatedEntityRef("list-new")),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.openAddListDialog()
|
||||
viewModel.updateAddListTitle("New list")
|
||||
viewModel.createList()
|
||||
advanceUntilIdle()
|
||||
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.ShowWarning)
|
||||
assertEquals(
|
||||
"List created, but server ordering differed. Pull to refresh if needed.",
|
||||
(event as BoardDetailUiEvent.ShowWarning).message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createCardPlacementMismatch_emitsExactWarning() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithSingleList()),
|
||||
BoardsApiResult.Success(detailWithCardPlacementMismatch()),
|
||||
),
|
||||
),
|
||||
createCardResult = BoardsApiResult.Success(CreatedEntityRef("card-new")),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.openAddCardDialog()
|
||||
viewModel.updateAddCardTitle("New card")
|
||||
viewModel.createCard()
|
||||
advanceUntilIdle()
|
||||
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.ShowWarning)
|
||||
assertEquals(
|
||||
"Card created, but server ordering differed. Pull to refresh if needed.",
|
||||
(event as BoardDetailUiEvent.ShowWarning).message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createFallbackNonDeterministic_emitsExactWarning() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithSingleList()),
|
||||
BoardsApiResult.Success(detailWithSingleListAndAppendedList()),
|
||||
),
|
||||
),
|
||||
createListResult = BoardsApiResult.Success(CreatedEntityRef(null)),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.openAddListDialog()
|
||||
viewModel.updateAddListTitle("New list")
|
||||
viewModel.createList()
|
||||
advanceUntilIdle()
|
||||
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.ShowWarning)
|
||||
assertEquals(
|
||||
"Created item could not be deterministically verified. Pull to refresh if needed.",
|
||||
(event as BoardDetailUiEvent.ShowWarning).message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createSuccess_refreshFailure_emitsExactWarning() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithSingleList()),
|
||||
BoardsApiResult.Failure("refresh failed"),
|
||||
),
|
||||
),
|
||||
createListResult = BoardsApiResult.Success(CreatedEntityRef("list-new")),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.openAddListDialog()
|
||||
viewModel.updateAddListTitle("New list")
|
||||
viewModel.createList()
|
||||
advanceUntilIdle()
|
||||
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.ShowWarning)
|
||||
assertEquals(
|
||||
"Changes applied, but refresh failed. Pull to refresh.",
|
||||
(event as BoardDetailUiEvent.ShowWarning).message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applySearch_withWhitespaceOnlyQuery_trimsToEmptyAndClearsActiveState() = runTest {
|
||||
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture())
|
||||
|
||||
viewModel.openSearchDialog()
|
||||
viewModel.updatePendingTitleQuery("Duke")
|
||||
viewModel.applySearchDialog()
|
||||
viewModel.openSearchDialog()
|
||||
viewModel.updatePendingTitleQuery(" ")
|
||||
viewModel.applySearchDialog()
|
||||
|
||||
assertEquals("", viewModel.uiState.value.activeTitleQuery)
|
||||
assertEquals("", viewModel.uiState.value.pendingTitleQuery)
|
||||
val filtered = viewModel.uiState.value.filteredBoardDetail
|
||||
assertEquals(2, filtered?.lists?.size)
|
||||
assertEquals(2, filtered?.lists?.get(0)?.cards?.size)
|
||||
assertEquals(1, filtered?.lists?.get(1)?.cards?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cardTapWithoutSelectionEmitsNavigateEvent() = runTest {
|
||||
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
|
||||
@@ -506,15 +840,26 @@ class BoardDetailViewModelTest {
|
||||
|
||||
private class FakeBoardDetailDataSource(
|
||||
val boardDetailResults: ArrayDeque<BoardsApiResult<BoardDetail>> = ArrayDeque(),
|
||||
var createListResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("list-created")),
|
||||
var createCardResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("card-created")),
|
||||
var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
|
||||
var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
|
||||
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit),
|
||||
var moveGate: CompletableDeferred<Unit>? = null,
|
||||
var renameGate: CompletableDeferred<Unit>? = null,
|
||||
) : BoardDetailDataSource {
|
||||
var createListCalls: Int = 0
|
||||
var createCardCalls: Int = 0
|
||||
var moveCalls: Int = 0
|
||||
var deleteCalls: Int = 0
|
||||
var renameCalls: Int = 0
|
||||
var lastCreateListBoardId: String? = null
|
||||
var lastCreateListTitle: String? = null
|
||||
var lastCreateCardListId: String? = null
|
||||
var lastCreateCardTitle: String? = null
|
||||
var lastCreateCardDescription: String? = null
|
||||
var lastCreateCardDueDate: LocalDate? = null
|
||||
var lastCreateCardTagIds: List<String> = emptyList()
|
||||
var lastRenameListId: String? = null
|
||||
var lastRenameTitle: String? = null
|
||||
|
||||
@@ -526,6 +871,29 @@ class BoardDetailViewModelTest {
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
|
||||
createListCalls += 1
|
||||
lastCreateListBoardId = boardPublicId
|
||||
lastCreateListTitle = title
|
||||
return createListResult
|
||||
}
|
||||
|
||||
override suspend fun createCard(
|
||||
listPublicId: String,
|
||||
title: String,
|
||||
description: String?,
|
||||
dueDate: LocalDate?,
|
||||
tagPublicIds: Collection<String>,
|
||||
): BoardsApiResult<CreatedEntityRef> {
|
||||
createCardCalls += 1
|
||||
lastCreateCardListId = listPublicId
|
||||
lastCreateCardTitle = title
|
||||
lastCreateCardDescription = description
|
||||
lastCreateCardDueDate = dueDate
|
||||
lastCreateCardTagIds = tagPublicIds.toList()
|
||||
return createCardResult
|
||||
}
|
||||
|
||||
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
||||
moveCalls += 1
|
||||
moveGate?.await()
|
||||
@@ -561,6 +929,14 @@ class BoardDetailViewModelTest {
|
||||
)
|
||||
}
|
||||
|
||||
fun detailWithoutLists(): BoardDetail {
|
||||
return BoardDetail(
|
||||
id = "board-1",
|
||||
title = "Board",
|
||||
lists = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
fun detailWithTwoLists(): BoardDetail {
|
||||
return BoardDetail(
|
||||
id = "board-1",
|
||||
@@ -583,6 +959,117 @@ class BoardDetailViewModelTest {
|
||||
)
|
||||
}
|
||||
|
||||
fun detailWithFilterFixture(): BoardDetail {
|
||||
return BoardDetail(
|
||||
id = "board-1",
|
||||
title = "Board",
|
||||
lists = listOf(
|
||||
BoardListDetail(
|
||||
id = "list-1",
|
||||
title = "To Do",
|
||||
cards = listOf(
|
||||
BoardCardSummary(
|
||||
id = "card-1",
|
||||
title = "Alpha",
|
||||
tags = listOf(BoardTagSummary("tag-a", "A", "#111111")),
|
||||
dueAtEpochMillis = null,
|
||||
),
|
||||
BoardCardSummary(
|
||||
id = "card-2",
|
||||
title = "Duke Nukem",
|
||||
tags = listOf(BoardTagSummary("tag-b", "B", "#222222")),
|
||||
dueAtEpochMillis = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
BoardListDetail(
|
||||
id = "list-2",
|
||||
title = "Doing",
|
||||
cards = listOf(
|
||||
BoardCardSummary(
|
||||
id = "card-3",
|
||||
title = "Bravo",
|
||||
tags = listOf(BoardTagSummary("tag-b", "B", "#222222")),
|
||||
dueAtEpochMillis = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun detailWithSingleListAndAppendedList(): BoardDetail {
|
||||
return BoardDetail(
|
||||
id = "board-1",
|
||||
title = "Board",
|
||||
lists = listOf(
|
||||
BoardListDetail(
|
||||
id = "list-1",
|
||||
title = "To Do",
|
||||
cards = listOf(BoardCardSummary("card-1", "Card 1", emptyList(), null)),
|
||||
),
|
||||
BoardListDetail(
|
||||
id = "list-new",
|
||||
title = "New list",
|
||||
cards = emptyList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun detailWithSingleListAndNewTopCard(): BoardDetail {
|
||||
return BoardDetail(
|
||||
id = "board-1",
|
||||
title = "Board",
|
||||
lists = listOf(
|
||||
BoardListDetail(
|
||||
id = "list-1",
|
||||
title = "To Do",
|
||||
cards = listOf(
|
||||
BoardCardSummary("card-new", "New card", emptyList(), null),
|
||||
BoardCardSummary("card-1", "Card 1", emptyList(), null),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun detailWithListPlacementMismatch(): BoardDetail {
|
||||
return BoardDetail(
|
||||
id = "board-1",
|
||||
title = "Board",
|
||||
lists = listOf(
|
||||
BoardListDetail(
|
||||
id = "list-new",
|
||||
title = "New list",
|
||||
cards = emptyList(),
|
||||
),
|
||||
BoardListDetail(
|
||||
id = "list-1",
|
||||
title = "To Do",
|
||||
cards = listOf(BoardCardSummary("card-1", "Card 1", emptyList(), null)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun detailWithCardPlacementMismatch(): BoardDetail {
|
||||
return BoardDetail(
|
||||
id = "board-1",
|
||||
title = "Board",
|
||||
lists = listOf(
|
||||
BoardListDetail(
|
||||
id = "list-1",
|
||||
title = "To Do",
|
||||
cards = listOf(
|
||||
BoardCardSummary("card-1", "Card 1", emptyList(), null),
|
||||
BoardCardSummary("card-new", "New card", emptyList(), null),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun detailWithThreeLists(): BoardDetail {
|
||||
return BoardDetail(
|
||||
id = "board-1",
|
||||
|
||||
Reference in New Issue
Block a user