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:
4 files changed
+109
-7
No files matched your search
@@ -151,6 +151,7 @@ fun KanbanScreen(
|
|||||||
var isMultiSelectMode by remember { mutableStateOf(false) }
|
var isMultiSelectMode by remember { mutableStateOf(false) }
|
||||||
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
|
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
|
||||||
var showBulkMoveDialog by remember { mutableStateOf(false) }
|
var showBulkMoveDialog by remember { mutableStateOf(false) }
|
||||||
|
var showBulkCompleteDialog by remember { mutableStateOf(false) }
|
||||||
var showBulkAssignDialog by remember { mutableStateOf(false) }
|
var showBulkAssignDialog by remember { mutableStateOf(false) }
|
||||||
var showBulkDeleteDialog by remember { mutableStateOf(false) }
|
var showBulkDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -1052,9 +1053,13 @@ fun KanbanScreen(
|
|||||||
writableMoveColumns.forEach { col ->
|
writableMoveColumns.forEach { col ->
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.bulkMove(selectedTaskIds.toList(), col.name) { failed ->
|
if (col.name.equals("done", ignoreCase = true)) {
|
||||||
selectedTaskIds = failed
|
showBulkCompleteDialog = true
|
||||||
if (failed.isEmpty()) isMultiSelectMode = false
|
} else {
|
||||||
|
viewModel.bulkMove(selectedTaskIds.toList(), col.name) { failed ->
|
||||||
|
selectedTaskIds = failed
|
||||||
|
if (failed.isEmpty()) isMultiSelectMode = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
showBulkMoveDialog = 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) {
|
if (showBulkAssignDialog) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showBulkAssignDialog = false },
|
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 =
|
private fun KanbanTaskAction.labelRes(): Int =
|
||||||
when (this) {
|
when (this) {
|
||||||
KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage
|
KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage
|
||||||
|
|||||||
@@ -618,12 +618,26 @@ class KanbanViewModel(
|
|||||||
fun bulkMove(
|
fun bulkMove(
|
||||||
taskIds: List<String>,
|
taskIds: List<String>,
|
||||||
targetStatus: String,
|
targetStatus: String,
|
||||||
|
summary: String? = null,
|
||||||
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
|
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val board = _uiState.value.selectedBoard ?: return
|
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 {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true) }
|
_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 -> {
|
is NetworkResult.Success -> {
|
||||||
val results = res.data.results
|
val results = res.data.results
|
||||||
val succeeded = results.filter { it.ok }.map { it.id }.toSet()
|
val succeeded = results.filter { it.ok }.map { it.id }.toSet()
|
||||||
|
|||||||
@@ -443,6 +443,7 @@
|
|||||||
<string name="kanban_bulk_unassign">Unassign</string>
|
<string name="kanban_bulk_unassign">Unassign</string>
|
||||||
<string name="kanban_bulk_delete">Delete</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_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_export_board">Export Board</string>
|
||||||
<string name="kanban_import_board">Import Board</string>
|
<string name="kanban_import_board">Import Board</string>
|
||||||
<string name="kanban_runtime_working">Working</string>
|
<string name="kanban_runtime_working">Working</string>
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ class KanbanMutationTest {
|
|||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
var reportedFailed: Set<String>? = null
|
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
|
reportedFailed = failed
|
||||||
}
|
}
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
@@ -565,18 +565,45 @@ class KanbanMutationTest {
|
|||||||
vm.selectBoard(board)
|
vm.selectBoard(board)
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
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()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
coVerify {
|
coVerify {
|
||||||
mockRepository.bulkTasks(
|
mockRepository.bulkTasks(
|
||||||
"dev",
|
"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)
|
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
|
@Test
|
||||||
fun testBulkArchiveTasksSuccess() =
|
fun testBulkArchiveTasksSuccess() =
|
||||||
runTest(testDispatcher) {
|
runTest(testDispatcher) {
|
||||||
|
|||||||
Reference in new issue
Block a user