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.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import java.time.LocalDate
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
@@ -23,7 +24,33 @@ data class BoardDetailUiState(
|
|||||||
val selectedCardIds: Set<String> = emptySet(),
|
val selectedCardIds: Set<String> = emptySet(),
|
||||||
val editingListId: String? = null,
|
val editingListId: String? = null,
|
||||||
val editingListTitle: String = "",
|
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 {
|
sealed interface BoardDetailUiEvent {
|
||||||
data class NavigateToCardPlaceholder(val cardId: String) : BoardDetailUiEvent
|
data class NavigateToCardPlaceholder(val cardId: String) : BoardDetailUiEvent
|
||||||
@@ -33,6 +60,15 @@ sealed interface BoardDetailUiEvent {
|
|||||||
|
|
||||||
interface BoardDetailDataSource {
|
interface BoardDetailDataSource {
|
||||||
suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail>
|
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 moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult
|
||||||
suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult
|
suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult
|
||||||
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit>
|
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit>
|
||||||
@@ -45,6 +81,26 @@ internal class BoardDetailRepositoryDataSource(
|
|||||||
return repository.getBoardDetail(boardId)
|
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 {
|
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
||||||
return repository.moveCards(cardIds, targetListId)
|
return repository.moveCards(cardIds, targetListId)
|
||||||
}
|
}
|
||||||
@@ -129,6 +185,304 @@ class BoardDetailViewModel(
|
|||||||
return true
|
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) {
|
fun moveSelectedCards(targetListId: String) {
|
||||||
val snapshot = _uiState.value
|
val snapshot = _uiState.value
|
||||||
if (snapshot.isMutating) {
|
if (snapshot.isMutating) {
|
||||||
@@ -191,9 +545,9 @@ class BoardDetailViewModel(
|
|||||||
when (val result = repository.renameList(editingListId, trimmedTitle)) {
|
when (val result = repository.renameList(editingListId, trimmedTitle)) {
|
||||||
is BoardsApiResult.Success -> {
|
is BoardsApiResult.Success -> {
|
||||||
_uiState.update { it.copy(editingListId = null, editingListTitle = "") }
|
_uiState.update { it.copy(editingListId = null, editingListTitle = "") }
|
||||||
val reloadFailureMessage = tryReloadDetailAndReconcile()
|
val reloadResult = reloadDetailAndReconcile()
|
||||||
_uiState.update { it.copy(isMutating = false) }
|
_uiState.update { it.copy(isMutating = false) }
|
||||||
if (reloadFailureMessage != null) {
|
if (reloadResult is BoardsApiResult.Failure) {
|
||||||
_events.emit(
|
_events.emit(
|
||||||
BoardDetailUiEvent.ShowWarning(
|
BoardDetailUiEvent.ShowWarning(
|
||||||
"Changes applied, but refresh failed. Pull to refresh.",
|
"Changes applied, but refresh failed. Pull to refresh.",
|
||||||
@@ -276,9 +630,9 @@ class BoardDetailViewModel(
|
|||||||
when (val result = mutation(selectedIds)) {
|
when (val result = mutation(selectedIds)) {
|
||||||
is CardBatchMutationResult.Success -> {
|
is CardBatchMutationResult.Success -> {
|
||||||
_uiState.update { it.copy(selectedCardIds = emptySet()) }
|
_uiState.update { it.copy(selectedCardIds = emptySet()) }
|
||||||
val reloadFailureMessage = tryReloadDetailAndReconcile()
|
val reloadResult = reloadDetailAndReconcile()
|
||||||
_uiState.update { it.copy(isMutating = false) }
|
_uiState.update { it.copy(isMutating = false) }
|
||||||
if (reloadFailureMessage != null) {
|
if (reloadResult is BoardsApiResult.Failure) {
|
||||||
_events.emit(
|
_events.emit(
|
||||||
BoardDetailUiEvent.ShowWarning(
|
BoardDetailUiEvent.ShowWarning(
|
||||||
"Changes applied, but refresh failed. Pull to refresh.",
|
"Changes applied, but refresh failed. Pull to refresh.",
|
||||||
@@ -288,8 +642,8 @@ class BoardDetailViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is CardBatchMutationResult.PartialSuccess -> {
|
is CardBatchMutationResult.PartialSuccess -> {
|
||||||
val reloadFailureMessage = tryReloadDetailAndReconcile()
|
val reloadResult = reloadDetailAndReconcile()
|
||||||
if (reloadFailureMessage == null) {
|
if (reloadResult is BoardsApiResult.Success) {
|
||||||
val visibleIds = allVisibleCardIds(_uiState.value.boardDetail)
|
val visibleIds = allVisibleCardIds(_uiState.value.boardDetail)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(selectedCardIds = result.failedCardIds.intersect(visibleIds))
|
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)) {
|
return when (val result = repository.getBoardDetail(boardId)) {
|
||||||
is BoardsApiResult.Success -> {
|
is BoardsApiResult.Success -> {
|
||||||
_uiState.update { reconcileWithNewDetail(it, result.value) }
|
_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,
|
selectedCardIds = prunedSelection,
|
||||||
editingListId = if (hasEditedList) current.editingListId else null,
|
editingListId = if (hasEditedList) current.editingListId else null,
|
||||||
editingListTitle = if (hasEditedList) current.editingListTitle else "",
|
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()
|
.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(
|
class Factory(
|
||||||
private val boardId: String,
|
private val boardId: String,
|
||||||
private val repository: BoardDetailRepository,
|
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.resetMain
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import java.time.LocalDate
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
@@ -49,7 +51,7 @@ class BoardDetailViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun backPressClearsSelectionWhenSelectionActive() = runTest {
|
fun backPressWithSelection_clearsSelectionAndReturnsTrue() = runTest {
|
||||||
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
|
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
|
||||||
|
|
||||||
viewModel.onCardLongPressed("card-1")
|
viewModel.onCardLongPressed("card-1")
|
||||||
@@ -58,6 +60,338 @@ class BoardDetailViewModelTest {
|
|||||||
assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty())
|
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
|
@Test
|
||||||
fun cardTapWithoutSelectionEmitsNavigateEvent() = runTest {
|
fun cardTapWithoutSelectionEmitsNavigateEvent() = runTest {
|
||||||
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
|
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
|
||||||
@@ -506,15 +840,26 @@ class BoardDetailViewModelTest {
|
|||||||
|
|
||||||
private class FakeBoardDetailDataSource(
|
private class FakeBoardDetailDataSource(
|
||||||
val boardDetailResults: ArrayDeque<BoardsApiResult<BoardDetail>> = ArrayDeque(),
|
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 moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
|
||||||
var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
|
var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
|
||||||
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit),
|
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit),
|
||||||
var moveGate: CompletableDeferred<Unit>? = null,
|
var moveGate: CompletableDeferred<Unit>? = null,
|
||||||
var renameGate: CompletableDeferred<Unit>? = null,
|
var renameGate: CompletableDeferred<Unit>? = null,
|
||||||
) : BoardDetailDataSource {
|
) : BoardDetailDataSource {
|
||||||
|
var createListCalls: Int = 0
|
||||||
|
var createCardCalls: Int = 0
|
||||||
var moveCalls: Int = 0
|
var moveCalls: Int = 0
|
||||||
var deleteCalls: Int = 0
|
var deleteCalls: Int = 0
|
||||||
var renameCalls: 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 lastRenameListId: String? = null
|
||||||
var lastRenameTitle: 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 {
|
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
||||||
moveCalls += 1
|
moveCalls += 1
|
||||||
moveGate?.await()
|
moveGate?.await()
|
||||||
@@ -561,6 +929,14 @@ class BoardDetailViewModelTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun detailWithoutLists(): BoardDetail {
|
||||||
|
return BoardDetail(
|
||||||
|
id = "board-1",
|
||||||
|
title = "Board",
|
||||||
|
lists = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun detailWithTwoLists(): BoardDetail {
|
fun detailWithTwoLists(): BoardDetail {
|
||||||
return BoardDetail(
|
return BoardDetail(
|
||||||
id = "board-1",
|
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 {
|
fun detailWithThreeLists(): BoardDetail {
|
||||||
return BoardDetail(
|
return BoardDetail(
|
||||||
id = "board-1",
|
id = "board-1",
|
||||||
|
|||||||
Reference in New Issue
Block a user