Merge pull request #1223 from Hy4ri/fix/issue-1212-bulk-completion-evidence

fix(#1212): require evidence for bulk Kanban completion
This commit is contained in:
m57 authored and GitHub committed 2026-09-22 09:02:58 +03:00
commit d92abd18aa
4 files changed
+109 -7

No files matched your search

@@ -151,6 +151,7 @@ fun KanbanScreen(
var isMultiSelectMode by remember { mutableStateOf(false) }
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
var showBulkMoveDialog by remember { mutableStateOf(false) }
var showBulkCompleteDialog by remember { mutableStateOf(false) }
var showBulkAssignDialog by remember { mutableStateOf(false) }
var showBulkDeleteDialog by remember { mutableStateOf(false) }
@@ -1052,9 +1053,13 @@ fun KanbanScreen(
writableMoveColumns.forEach { col ->
TextButton(
onClick = {
viewModel.bulkMove(selectedTaskIds.toList(), col.name) { failed ->
selectedTaskIds = failed
if (failed.isEmpty()) isMultiSelectMode = false
if (col.name.equals("done", ignoreCase = true)) {
showBulkCompleteDialog = true
} else {
viewModel.bulkMove(selectedTaskIds.toList(), col.name) { failed ->
selectedTaskIds = failed
if (failed.isEmpty()) isMultiSelectMode = false
}
}
showBulkMoveDialog = false
},
@@ -1074,6 +1079,24 @@ fun KanbanScreen(
)
}
if (showBulkCompleteDialog) {
BulkCompleteTasksDialog(
taskCount = selectedTaskIds.size,
onConfirm = { summary ->
viewModel.bulkMove(
taskIds = selectedTaskIds.toList(),
targetStatus = "done",
summary = summary,
) { failed ->
selectedTaskIds = failed
if (failed.isEmpty()) isMultiSelectMode = false
}
showBulkCompleteDialog = false
},
onDismiss = { showBulkCompleteDialog = false },
)
}
if (showBulkAssignDialog) {
AlertDialog(
onDismissRequest = { showBulkAssignDialog = false },
@@ -1431,6 +1454,43 @@ private fun CompleteTaskDialog(
)
}
@Composable
private fun BulkCompleteTasksDialog(
taskCount: Int,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var summary by remember { mutableStateOf("") }
val trimmedSummary = summary.trim()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.kanban_bulk_complete_title, taskCount)) },
text = {
OutlinedTextField(
value = summary,
onValueChange = { summary = it },
label = { Text(stringResource(R.string.kanban_complete_summary_label)) },
placeholder = { Text(stringResource(R.string.kanban_complete_summary_hint)) },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
},
confirmButton = {
Button(
onClick = { onConfirm(trimmedSummary) },
enabled = trimmedSummary.isNotEmpty(),
) {
Text(stringResource(R.string.kanban_action_complete))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
private fun KanbanTaskAction.labelRes(): Int =
when (this) {
KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage
@@ -618,12 +618,26 @@ class KanbanViewModel(
fun bulkMove(
taskIds: List<String>,
targetStatus: String,
summary: String? = null,
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
) {
val board = _uiState.value.selectedBoard ?: return
val completionSummary = summary?.trim()?.takeIf { it.isNotEmpty() }
if (targetStatus.equals("done", ignoreCase = true) && completionSummary == null) {
_uiState.update { it.copy(toastMessage = "Completion summary is required") }
onComplete?.invoke(taskIds.toSet())
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, status = targetStatus))) {
val body =
BulkTasksBody(
ids = taskIds,
status = targetStatus,
summary = completionSummary,
result = completionSummary,
)
when (val res = repository.bulkTasks(board.id, body)) {
is NetworkResult.Success -> {
val results = res.data.results
val succeeded = results.filter { it.ok }.map { it.id }.toSet()
+1
View File
@@ -443,6 +443,7 @@
<string name="kanban_bulk_unassign">Unassign</string>
<string name="kanban_bulk_delete">Delete</string>
<string name="kanban_bulk_delete_confirm">Delete %1$d selected tasks? This cannot be undone.</string>
<string name="kanban_bulk_complete_title">Complete %1$d tasks</string>
<string name="kanban_export_board">Export Board</string>
<string name="kanban_import_board">Import Board</string>
<string name="kanban_runtime_working">Working</string>
@@ -510,7 +510,7 @@ class KanbanMutationTest {
testDispatcher.scheduler.advanceUntilIdle()
var reportedFailed: Set<String>? = null
vm.bulkMove(listOf("t_1", "t_2"), "done") { failed ->
vm.bulkMove(listOf("t_1", "t_2"), "done", summary = "Finished selected tasks") { failed ->
reportedFailed = failed
}
testDispatcher.scheduler.advanceUntilIdle()
@@ -565,18 +565,45 @@ class KanbanMutationTest {
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
vm.bulkMove(listOf("t_1", "t_2"), "done")
vm.bulkMove(listOf("t_1", "t_2"), "done", summary = "Finished selected tasks")
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.bulkTasks(
"dev",
match { it.ids == listOf("t_1", "t_2") && it.status == "done" },
match {
it.ids == listOf("t_1", "t_2") &&
it.status == "done" &&
it.summary == "Finished selected tasks" &&
it.result == "Finished selected tasks"
},
)
}
assertEquals("Moved 2 tasks to done", vm.uiState.value.toastMessage)
}
@Test
fun testBulkMoveToDoneRejectsEmptyCompletionEvidence() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
coEvery { mockRepository.getBoard("dev") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("done", emptyList()))))
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
var reportedFailed: Set<String>? = null
vm.bulkMove(listOf("t_1", "t_2"), "done", summary = " ") { failed ->
reportedFailed = failed
}
testDispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 0) { mockRepository.bulkTasks(any(), any()) }
assertEquals(setOf("t_1", "t_2"), reportedFailed)
assertEquals("Completion summary is required", vm.uiState.value.toastMessage)
}
@Test
fun testBulkArchiveTasksSuccess() =
runTest(testDispatcher) {