feat: add board detail create list and card api calls

This commit is contained in:
2026-03-16 13:34:59 -04:00
parent 80d4c40f10
commit 995a6dcae7
3 changed files with 192 additions and 0 deletions

View File

@@ -12,6 +12,7 @@ import space.hackenslacker.kanbn4droid.app.boarddetail.BoardCardSummary
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary
import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
@@ -62,6 +63,28 @@ interface KanbnApiClient {
return BoardsApiResult.Failure("List rename is not implemented.")
}
suspend fun createList(
baseUrl: String,
apiKey: String,
boardPublicId: String,
title: String,
appendIndex: Int,
): BoardsApiResult<CreatedEntityRef> {
return BoardsApiResult.Failure("List creation is not implemented.")
}
suspend fun createCard(
baseUrl: String,
apiKey: String,
listPublicId: String,
title: String,
description: String?,
dueDate: String?,
tagPublicIds: List<String>,
): BoardsApiResult<CreatedEntityRef> {
return BoardsApiResult.Failure("Card creation is not implemented.")
}
suspend fun moveCard(baseUrl: String, apiKey: String, cardId: String, targetListId: String): BoardsApiResult<Unit> {
return BoardsApiResult.Failure("Card move is not implemented.")
}
@@ -269,6 +292,71 @@ class HttpKanbnApiClient : KanbnApiClient {
}
}
override suspend fun createList(
baseUrl: String,
apiKey: String,
boardPublicId: String,
title: String,
appendIndex: Int,
): BoardsApiResult<CreatedEntityRef> {
return withContext(Dispatchers.IO) {
val payload = JSONObject()
.put("boardPublicId", boardPublicId)
.put("name", title)
.put("index", appendIndex)
request(
baseUrl = baseUrl,
path = "/api/v1/lists",
method = "POST",
apiKey = apiKey,
body = payload.toString(),
) { code, body ->
if (code in 200..299) {
parseCreatedEntityRef(body)
?.let { BoardsApiResult.Success(it) }
?: BoardsApiResult.Failure("Malformed create list response.")
} else {
BoardsApiResult.Failure(serverMessage(body, code))
}
}
}
}
override suspend fun createCard(
baseUrl: String,
apiKey: String,
listPublicId: String,
title: String,
description: String?,
dueDate: String?,
tagPublicIds: List<String>,
): BoardsApiResult<CreatedEntityRef> {
return withContext(Dispatchers.IO) {
val payload = JSONObject()
.put("listPublicId", listPublicId)
.put("title", title)
.put("description", description ?: "")
.put("dueDate", dueDate)
.put("index", 0)
.put("labelPublicIds", JSONArray(tagPublicIds))
request(
baseUrl = baseUrl,
path = "/api/v1/cards",
method = "POST",
apiKey = apiKey,
body = payload.toString(),
) { code, body ->
if (code in 200..299) {
parseCreatedEntityRef(body)
?.let { BoardsApiResult.Success(it) }
?: BoardsApiResult.Failure("Malformed create card response.")
} else {
BoardsApiResult.Failure(serverMessage(body, code))
}
}
}
}
override suspend fun moveCard(
baseUrl: String,
apiKey: String,
@@ -667,6 +755,27 @@ class HttpKanbnApiClient : KanbnApiClient {
return LabelDetail(id = id, colorHex = colorHex)
}
private fun parseCreatedEntityRef(body: String): CreatedEntityRef? {
if (body.isBlank()) {
return null
}
val root = parseJsonObject(body) ?: return null
val data = root["data"]
val entity = when (data) {
is Map<*, *> -> {
(data["card"] as? Map<*, *>)
?: (data["list"] as? Map<*, *>)
?: data
}
else -> root
}
val publicId = extractString(entity, "publicId", "public_id", "id")
return CreatedEntityRef(publicId = publicId.ifBlank { null })
}
private fun parseLists(board: Map<*, *>): List<BoardListDetail> {
return extractObjectArray(board, "lists", "items", "data").mapNotNull { rawList ->
val id = extractId(rawList)

View File

@@ -69,6 +69,46 @@ class BoardDetailRepository(
)
}
suspend fun createCard(
listId: String,
title: String,
description: String?,
dueDate: String?,
tagIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef> {
val normalizedListId = listId.trim()
if (normalizedListId.isBlank()) {
return BoardsApiResult.Failure("List id is required")
}
val normalizedTitle = title.trim()
if (normalizedTitle.isBlank()) {
return BoardsApiResult.Failure("Card title is required")
}
val normalizedDescription = description?.trim()?.takeIf { it.isNotBlank() }
val normalizedDueDate = dueDate?.trim()?.takeIf { it.isNotBlank() }
val normalizedTagIds = tagIds
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
val session = when (val sessionResult = session()) {
is BoardsApiResult.Success -> sessionResult.value
is BoardsApiResult.Failure -> return sessionResult
}
return apiClient.createCard(
baseUrl = session.baseUrl,
apiKey = session.apiKey,
listPublicId = normalizedListId,
title = normalizedTitle,
description = normalizedDescription,
dueDate = normalizedDueDate,
tagPublicIds = normalizedTagIds,
)
}
suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
val normalizedTargetListId = targetListId.trim()
if (normalizedTargetListId.isBlank()) {

View File

@@ -207,6 +207,23 @@ class BoardDetailRepositoryTest {
assertEquals("New title", apiClient.lastListTitle)
}
@Test
fun createCard_callsApiWithPublicIdsAndTopIndex() = runTest {
val apiClient = FakeBoardDetailApiClient()
val repository = createRepository(apiClient = apiClient)
val result = repository.createCard(
listId = " list-1 ",
title = " Card title ",
description = "Description",
dueDate = null,
tagIds = listOf(" tag-1 ", "", "tag-2"),
)
assertTrue(result is BoardsApiResult.Success<*>)
assertEquals("list-1", apiClient.lastCreateCardListPublicId)
}
@Test
fun getBoardDetailValidatesBoardId() = runTest {
val repository = createRepository()
@@ -454,6 +471,8 @@ class BoardDetailRepositoryTest {
BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
var boardDetailResult: BoardsApiResult<BoardDetail> = BoardsApiResult.Success(sampleBoardDetail())
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
var createListResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("list-new"))
var createCardResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("card-new"))
var moveOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
var deleteOutcomes: Map<String, BoardsApiResult<Unit>> = emptyMap()
var labelByIdResults: Map<String, BoardsApiResult<LabelDetail>> = emptyMap()
@@ -466,6 +485,7 @@ class BoardDetailRepositoryTest {
var deletedCardIds: MutableList<String> = mutableListOf()
var lastMoveTargetListId: String? = null
var getLabelByPublicIdCalls: MutableList<String> = mutableListOf()
var lastCreateCardListPublicId: String? = null
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
@@ -497,6 +517,29 @@ class BoardDetailRepositoryTest {
return renameListResult
}
override suspend fun createList(
baseUrl: String,
apiKey: String,
boardPublicId: String,
title: String,
appendIndex: Int,
): BoardsApiResult<CreatedEntityRef> {
return createListResult
}
override suspend fun createCard(
baseUrl: String,
apiKey: String,
listPublicId: String,
title: String,
description: String?,
dueDate: String?,
tagPublicIds: List<String>,
): BoardsApiResult<CreatedEntityRef> {
lastCreateCardListPublicId = listPublicId
return createCardResult
}
override suspend fun moveCard(
baseUrl: String,
apiKey: String,