fix: scope boards API calls to resolved workspace
This commit is contained in:
@@ -50,6 +50,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
|
|||||||
- The API key is stored in app preferences together with the base URL.
|
- The API key is stored in app preferences together with the base URL.
|
||||||
- No migration is performed from prior Credential Manager storage, so users must re-enter their API key one time after upgrading.
|
- No migration is performed from prior Credential Manager storage, so users must re-enter their API key one time after upgrading.
|
||||||
- On success, the view stores the URL and API key pair in preferences and moves over to the boards view.
|
- On success, the view stores the URL and API key pair in preferences and moves over to the boards view.
|
||||||
|
- On successful manual sign-in, the stored workspace id is cleared so the boards flow can resolve a fresh default workspace for the account.
|
||||||
- If there is a URL and API Key pair stored, the view tries to authenticate the user through the API automatically and proceeds to the boards view instantly without showing the login screen if successful.
|
- If there is a URL and API Key pair stored, the view tries to authenticate the user through the API automatically and proceeds to the boards view instantly without showing the login screen if successful.
|
||||||
- If startup authentication fails due to invalid credentials then the stored API key is invalidated; transient connectivity/server failures keep the stored key and return to login.
|
- If startup authentication fails due to invalid credentials then the stored API key is invalidated; transient connectivity/server failures keep the stored key and return to login.
|
||||||
- Current status: implemented in `MainActivity` with XML views and navigation into `BoardsActivity`.
|
- Current status: implemented in `MainActivity` with XML views and navigation into `BoardsActivity`.
|
||||||
@@ -71,7 +72,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
|
|||||||
- Long-pressing an existing board shows a modal dialog asking the user if they want to delete the board, with buttons for "Cancel" and "Delete".
|
- Long-pressing an existing board shows a modal dialog asking the user if they want to delete the board, with buttons for "Cancel" and "Delete".
|
||||||
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
|
- Pressing "Delete" in the modal dialog MUST show a second confirmation modal asking if the user is sure, with buttons for "Cancel" and "I'm sure".
|
||||||
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
|
- Only on pressing "I'm sure" in the second confirmation modal dialog should a board delete request be sent to the API.
|
||||||
- Current status: implemented in `BoardsActivity` using XML Views, `RecyclerView`, `SwipeRefreshLayout`, and a `BoardsViewModel`/`BoardsRepository` flow. The screen includes auto-refresh on entry, pull-to-refresh, create board dialog with optional template selector, and two-step board delete confirmation. Board taps navigate to `BoardDetailPlaceholderActivity` while full board detail is still pending.
|
- Current status: implemented in `BoardsActivity` using XML Views, `RecyclerView`, `SwipeRefreshLayout`, and a `BoardsViewModel`/`BoardsRepository` flow. The screen includes auto-refresh on entry, pull-to-refresh, create board dialog with optional template selector, and two-step board delete confirmation. Board taps navigate to `BoardDetailPlaceholderActivity` while full board detail is still pending. API calls are workspace-scoped; when no workspace is stored the app resolves workspaces through the API, stores the first workspace id as default, and uses it for board listing, template listing (`type=template`), and board creation.
|
||||||
|
|
||||||
**Board detail view**
|
**Board detail view**
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
|||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class BoardsFlowTest {
|
class BoardsFlowTest {
|
||||||
@@ -127,6 +128,14 @@ class BoardsFlowTest {
|
|||||||
override fun clearBaseUrl() {
|
override fun clearBaseUrl() {
|
||||||
baseUrl = null
|
baseUrl = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getWorkspaceId(): String? = "ws-1"
|
||||||
|
|
||||||
|
override fun saveWorkspaceId(workspaceId: String) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearWorkspaceId() {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class InMemoryApiKeyStore(
|
private class InMemoryApiKeyStore(
|
||||||
@@ -151,13 +160,25 @@ class BoardsFlowTest {
|
|||||||
) : KanbnApiClient {
|
) : KanbnApiClient {
|
||||||
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
|
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
|
||||||
|
|
||||||
override suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardSummary>> {
|
override suspend fun listWorkspaces(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
): BoardsApiResult<List<WorkspaceSummary>> {
|
||||||
|
return BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun listBoards(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
|
): BoardsApiResult<List<BoardSummary>> {
|
||||||
return BoardsApiResult.Success(boards.toList())
|
return BoardsApiResult.Success(boards.toList())
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun listBoardTemplates(
|
override suspend fun listBoardTemplates(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
): BoardsApiResult<List<BoardTemplate>> {
|
): BoardsApiResult<List<BoardTemplate>> {
|
||||||
return BoardsApiResult.Success(templates)
|
return BoardsApiResult.Success(templates)
|
||||||
}
|
}
|
||||||
@@ -165,6 +186,7 @@ class BoardsFlowTest {
|
|||||||
override suspend fun createBoard(
|
override suspend fun createBoard(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
name: String,
|
name: String,
|
||||||
templateId: String?,
|
templateId: String?,
|
||||||
): BoardsApiResult<BoardSummary> {
|
): BoardsApiResult<BoardSummary> {
|
||||||
|
|||||||
@@ -163,6 +163,14 @@ class LoginFlowTest {
|
|||||||
override fun clearBaseUrl() {
|
override fun clearBaseUrl() {
|
||||||
baseUrl = null
|
baseUrl = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getWorkspaceId(): String? = null
|
||||||
|
|
||||||
|
override fun saveWorkspaceId(workspaceId: String) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearWorkspaceId() {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class InMemoryApiKeyStore(
|
private class InMemoryApiKeyStore(
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
if (saveKeyResult.isSuccess) {
|
if (saveKeyResult.isSuccess) {
|
||||||
sessionStore.saveBaseUrl(normalizedBaseUrl)
|
sessionStore.saveBaseUrl(normalizedBaseUrl)
|
||||||
|
sessionStore.clearWorkspaceId()
|
||||||
openBoards()
|
openBoards()
|
||||||
} else {
|
} else {
|
||||||
loginProgress.visibility = View.GONE
|
loginProgress.visibility = View.GONE
|
||||||
|
|||||||
@@ -10,21 +10,35 @@ import kotlinx.coroutines.withContext
|
|||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
import space.hackenslacker.kanbn4droid.app.boards.BoardTemplate
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||||
|
import space.hackenslacker.kanbn4droid.app.boards.WorkspaceSummary
|
||||||
|
|
||||||
interface KanbnApiClient {
|
interface KanbnApiClient {
|
||||||
suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult
|
suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult
|
||||||
|
|
||||||
suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardSummary>> {
|
suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult<List<WorkspaceSummary>> {
|
||||||
|
return BoardsApiResult.Failure("Workspace listing is not implemented.")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun listBoards(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
|
): BoardsApiResult<List<BoardSummary>> {
|
||||||
return BoardsApiResult.Failure("Boards listing is not implemented.")
|
return BoardsApiResult.Failure("Boards listing is not implemented.")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun listBoardTemplates(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardTemplate>> {
|
suspend fun listBoardTemplates(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
|
): BoardsApiResult<List<BoardTemplate>> {
|
||||||
return BoardsApiResult.Failure("Board templates listing is not implemented.")
|
return BoardsApiResult.Failure("Board templates listing is not implemented.")
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createBoard(
|
suspend fun createBoard(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
name: String,
|
name: String,
|
||||||
templateId: String?,
|
templateId: String?,
|
||||||
): BoardsApiResult<BoardSummary> {
|
): BoardsApiResult<BoardSummary> {
|
||||||
@@ -38,6 +52,23 @@ interface KanbnApiClient {
|
|||||||
|
|
||||||
class HttpKanbnApiClient : KanbnApiClient {
|
class HttpKanbnApiClient : KanbnApiClient {
|
||||||
|
|
||||||
|
override suspend fun listWorkspaces(baseUrl: String, apiKey: String): BoardsApiResult<List<WorkspaceSummary>> {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
request(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = "/api/v1/workspaces",
|
||||||
|
method = "GET",
|
||||||
|
apiKey = apiKey,
|
||||||
|
) { code, body ->
|
||||||
|
if (code in 200..299) {
|
||||||
|
BoardsApiResult.Success(parseWorkspaces(body))
|
||||||
|
} else {
|
||||||
|
BoardsApiResult.Failure(serverMessage(body, code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult {
|
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
val endpoint = "${baseUrl.trimEnd('/')}/api/v1/health"
|
val endpoint = "${baseUrl.trimEnd('/')}/api/v1/health"
|
||||||
@@ -67,11 +98,15 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardSummary>> {
|
override suspend fun listBoards(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
|
): BoardsApiResult<List<BoardSummary>> {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
request(
|
request(
|
||||||
baseUrl = baseUrl,
|
baseUrl = baseUrl,
|
||||||
path = "/api/v1/boards",
|
path = "/api/v1/workspaces/$workspaceId/boards",
|
||||||
method = "GET",
|
method = "GET",
|
||||||
apiKey = apiKey,
|
apiKey = apiKey,
|
||||||
) { code, body ->
|
) { code, body ->
|
||||||
@@ -87,11 +122,12 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
override suspend fun listBoardTemplates(
|
override suspend fun listBoardTemplates(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
): BoardsApiResult<List<BoardTemplate>> {
|
): BoardsApiResult<List<BoardTemplate>> {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
request(
|
request(
|
||||||
baseUrl = baseUrl,
|
baseUrl = baseUrl,
|
||||||
path = "/api/v1/board-templates",
|
path = "/api/v1/workspaces/$workspaceId/boards?type=template",
|
||||||
method = "GET",
|
method = "GET",
|
||||||
apiKey = apiKey,
|
apiKey = apiKey,
|
||||||
) { code, body ->
|
) { code, body ->
|
||||||
@@ -107,18 +143,22 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
override suspend fun createBoard(
|
override suspend fun createBoard(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
name: String,
|
name: String,
|
||||||
templateId: String?,
|
templateId: String?,
|
||||||
): BoardsApiResult<BoardSummary> {
|
): BoardsApiResult<BoardSummary> {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
val payload = JSONObject().put("title", name)
|
val payload = JSONObject()
|
||||||
|
.put("name", name)
|
||||||
|
.put("lists", JSONArray())
|
||||||
|
.put("labels", JSONArray())
|
||||||
if (!templateId.isNullOrBlank()) {
|
if (!templateId.isNullOrBlank()) {
|
||||||
payload.put("template_id", templateId)
|
payload.put("sourceBoardPublicId", templateId)
|
||||||
}
|
}
|
||||||
|
|
||||||
request(
|
request(
|
||||||
baseUrl = baseUrl,
|
baseUrl = baseUrl,
|
||||||
path = "/api/v1/boards",
|
path = "/api/v1/workspaces/$workspaceId/boards",
|
||||||
method = "POST",
|
method = "POST",
|
||||||
apiKey = apiKey,
|
apiKey = apiKey,
|
||||||
body = payload.toString(),
|
body = payload.toString(),
|
||||||
@@ -222,9 +262,14 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
val boards = mutableListOf<BoardSummary>()
|
val boards = mutableListOf<BoardSummary>()
|
||||||
for (index in 0 until array.length()) {
|
for (index in 0 until array.length()) {
|
||||||
val item = array.optJSONObject(index) ?: continue
|
val item = array.optJSONObject(index) ?: continue
|
||||||
val id = item.opt("id")?.toString().orEmpty()
|
val id = item.opt("id")?.toString().orEmpty().ifBlank {
|
||||||
|
item.optString("publicId")
|
||||||
|
.ifBlank { item.optString("public_id") }
|
||||||
|
}
|
||||||
val title = item.optString("title").ifBlank {
|
val title = item.optString("title").ifBlank {
|
||||||
item.optString("name").ifBlank { "Board" }
|
item.optString("name").ifBlank {
|
||||||
|
item.optString("slug").ifBlank { "Board" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (id.isNotBlank()) {
|
if (id.isNotBlank()) {
|
||||||
boards += BoardSummary(id = id, title = title)
|
boards += BoardSummary(id = id, title = title)
|
||||||
@@ -239,7 +284,11 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val root = JSONObject(body)
|
val root = JSONObject(body)
|
||||||
val id = root.opt("id")?.toString().orEmpty().ifBlank { root.opt("board_id")?.toString().orEmpty() }
|
val id = root.opt("id")?.toString().orEmpty().ifBlank {
|
||||||
|
root.optString("publicId")
|
||||||
|
.ifBlank { root.optString("public_id") }
|
||||||
|
.ifBlank { root.opt("board_id")?.toString().orEmpty() }
|
||||||
|
}
|
||||||
val title = root.optString("title").ifBlank { root.optString("name").ifBlank { fallbackName } }
|
val title = root.optString("title").ifBlank { root.optString("name").ifBlank { fallbackName } }
|
||||||
return BoardSummary(id = if (id.isBlank()) "new" else id, title = title)
|
return BoardSummary(id = if (id.isBlank()) "new" else id, title = title)
|
||||||
}
|
}
|
||||||
@@ -262,7 +311,10 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
val templates = mutableListOf<BoardTemplate>()
|
val templates = mutableListOf<BoardTemplate>()
|
||||||
for (index in 0 until array.length()) {
|
for (index in 0 until array.length()) {
|
||||||
val item = array.optJSONObject(index) ?: continue
|
val item = array.optJSONObject(index) ?: continue
|
||||||
val id = item.opt("id")?.toString().orEmpty()
|
val id = item.opt("id")?.toString().orEmpty().ifBlank {
|
||||||
|
item.optString("publicId")
|
||||||
|
.ifBlank { item.optString("public_id") }
|
||||||
|
}
|
||||||
val name = item.optString("name").ifBlank {
|
val name = item.optString("name").ifBlank {
|
||||||
item.optString("title").ifBlank { "Template" }
|
item.optString("title").ifBlank { "Template" }
|
||||||
}
|
}
|
||||||
@@ -273,6 +325,42 @@ class HttpKanbnApiClient : KanbnApiClient {
|
|||||||
return templates
|
return templates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseWorkspaces(body: String): List<WorkspaceSummary> {
|
||||||
|
if (body.isBlank()) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val trimmed = body.trim()
|
||||||
|
val array = if (trimmed.startsWith("[")) {
|
||||||
|
JSONArray(trimmed)
|
||||||
|
} else {
|
||||||
|
val root = JSONObject(trimmed)
|
||||||
|
listOf("workspaces", "items", "data")
|
||||||
|
.firstNotNullOfOrNull { root.optJSONArray(it) }
|
||||||
|
?: JSONArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
val workspaces = mutableListOf<WorkspaceSummary>()
|
||||||
|
for (index in 0 until array.length()) {
|
||||||
|
val item = array.optJSONObject(index) ?: continue
|
||||||
|
val workspace = item.optJSONObject("workspace") ?: item
|
||||||
|
val id = workspace.opt("id")?.toString().orEmpty().ifBlank {
|
||||||
|
workspace.optString("publicId")
|
||||||
|
.ifBlank { workspace.optString("public_id") }
|
||||||
|
}
|
||||||
|
if (id.isBlank()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val name = workspace.optString("name")
|
||||||
|
.ifBlank { workspace.optString("title") }
|
||||||
|
.ifBlank { workspace.optString("slug") }
|
||||||
|
.ifBlank { "Workspace" }
|
||||||
|
workspaces += WorkspaceSummary(id = id, name = name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspaces
|
||||||
|
}
|
||||||
|
|
||||||
private fun serverMessage(body: String, code: Int): String {
|
private fun serverMessage(body: String, code: Int): String {
|
||||||
if (body.isBlank()) {
|
if (body.isBlank()) {
|
||||||
return "Server error: $code"
|
return "Server error: $code"
|
||||||
|
|||||||
@@ -13,12 +13,23 @@ class SessionPreferences(context: Context) : SessionStore {
|
|||||||
preferences.edit().putString(KEY_BASE_URL, url).apply()
|
preferences.edit().putString(KEY_BASE_URL, url).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getWorkspaceId(): String? = preferences.getString(KEY_WORKSPACE_ID, null)
|
||||||
|
|
||||||
|
override fun saveWorkspaceId(workspaceId: String) {
|
||||||
|
preferences.edit().putString(KEY_WORKSPACE_ID, workspaceId).apply()
|
||||||
|
}
|
||||||
|
|
||||||
override fun clearBaseUrl() {
|
override fun clearBaseUrl() {
|
||||||
preferences.edit().remove(KEY_BASE_URL).apply()
|
preferences.edit().remove(KEY_BASE_URL).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun clearWorkspaceId() {
|
||||||
|
preferences.edit().remove(KEY_WORKSPACE_ID).apply()
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
private const val PREFS_NAME = "kanbn_session"
|
private const val PREFS_NAME = "kanbn_session"
|
||||||
private const val KEY_BASE_URL = "base_url"
|
private const val KEY_BASE_URL = "base_url"
|
||||||
|
private const val KEY_WORKSPACE_ID = "workspace_id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,8 @@ package space.hackenslacker.kanbn4droid.app.auth
|
|||||||
interface SessionStore {
|
interface SessionStore {
|
||||||
fun getBaseUrl(): String?
|
fun getBaseUrl(): String?
|
||||||
fun saveBaseUrl(url: String)
|
fun saveBaseUrl(url: String)
|
||||||
|
fun getWorkspaceId(): String?
|
||||||
|
fun saveWorkspaceId(workspaceId: String)
|
||||||
fun clearBaseUrl()
|
fun clearBaseUrl()
|
||||||
|
fun clearWorkspaceId()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ data class BoardTemplate(
|
|||||||
val name: String,
|
val name: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class WorkspaceSummary(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
sealed interface BoardsApiResult<out T> {
|
sealed interface BoardsApiResult<out T> {
|
||||||
data class Success<T>(val value: T) : BoardsApiResult<T>
|
data class Success<T>(val value: T) : BoardsApiResult<T>
|
||||||
data class Failure(val message: String) : BoardsApiResult<Nothing>
|
data class Failure(val message: String) : BoardsApiResult<Nothing>
|
||||||
|
|||||||
@@ -14,47 +14,96 @@ class BoardsRepository(
|
|||||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
suspend fun listBoards(): BoardsApiResult<List<BoardSummary>> {
|
suspend fun listBoards(): BoardsApiResult<List<BoardSummary>> {
|
||||||
val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
|
val session = when (val sessionResult = session()) {
|
||||||
return apiClient.listBoards(session.baseUrl, session.apiKey)
|
is BoardsApiResult.Success -> sessionResult.value
|
||||||
|
is BoardsApiResult.Failure -> return sessionResult
|
||||||
|
}
|
||||||
|
return apiClient.listBoards(
|
||||||
|
baseUrl = session.baseUrl,
|
||||||
|
apiKey = session.apiKey,
|
||||||
|
workspaceId = session.workspaceId,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun listTemplates(): BoardsApiResult<List<BoardTemplate>> {
|
suspend fun listTemplates(): BoardsApiResult<List<BoardTemplate>> {
|
||||||
val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
|
val session = when (val sessionResult = session()) {
|
||||||
return apiClient.listBoardTemplates(session.baseUrl, session.apiKey)
|
is BoardsApiResult.Success -> sessionResult.value
|
||||||
|
is BoardsApiResult.Failure -> return sessionResult
|
||||||
|
}
|
||||||
|
return apiClient.listBoardTemplates(
|
||||||
|
baseUrl = session.baseUrl,
|
||||||
|
apiKey = session.apiKey,
|
||||||
|
workspaceId = session.workspaceId,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createBoard(name: String, templateId: String?): BoardsApiResult<BoardSummary> {
|
suspend fun createBoard(name: String, templateId: String?): BoardsApiResult<BoardSummary> {
|
||||||
val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
|
val session = when (val sessionResult = session()) {
|
||||||
|
is BoardsApiResult.Success -> sessionResult.value
|
||||||
|
is BoardsApiResult.Failure -> return sessionResult
|
||||||
|
}
|
||||||
if (name.isBlank()) {
|
if (name.isBlank()) {
|
||||||
return BoardsApiResult.Failure("Board name is required")
|
return BoardsApiResult.Failure("Board name is required")
|
||||||
}
|
}
|
||||||
return apiClient.createBoard(
|
return apiClient.createBoard(
|
||||||
baseUrl = session.baseUrl,
|
baseUrl = session.baseUrl,
|
||||||
apiKey = session.apiKey,
|
apiKey = session.apiKey,
|
||||||
|
workspaceId = session.workspaceId,
|
||||||
name = name.trim(),
|
name = name.trim(),
|
||||||
templateId = templateId,
|
templateId = templateId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteBoard(boardId: String): BoardsApiResult<Unit> {
|
suspend fun deleteBoard(boardId: String): BoardsApiResult<Unit> {
|
||||||
val session = session() ?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
|
val session = when (val sessionResult = session()) {
|
||||||
|
is BoardsApiResult.Success -> sessionResult.value
|
||||||
|
is BoardsApiResult.Failure -> return sessionResult
|
||||||
|
}
|
||||||
if (boardId.isBlank()) {
|
if (boardId.isBlank()) {
|
||||||
return BoardsApiResult.Failure("Board id is required")
|
return BoardsApiResult.Failure("Board id is required")
|
||||||
}
|
}
|
||||||
return apiClient.deleteBoard(session.baseUrl, session.apiKey, boardId)
|
return apiClient.deleteBoard(session.baseUrl, session.apiKey, boardId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun session(): SessionSnapshot? {
|
private suspend fun session(): BoardsApiResult<SessionSnapshot> {
|
||||||
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() } ?: return null
|
val baseUrl = sessionStore.getBaseUrl()?.takeIf { it.isNotBlank() }
|
||||||
|
?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
|
||||||
val apiKey = withContext(ioDispatcher) {
|
val apiKey = withContext(ioDispatcher) {
|
||||||
apiKeyStore.getApiKey(baseUrl)
|
apiKeyStore.getApiKey(baseUrl)
|
||||||
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return null
|
}.getOrNull()?.takeIf { it.isNotBlank() }
|
||||||
|
?: return BoardsApiResult.Failure("Missing session. Please sign in again.")
|
||||||
|
|
||||||
return SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey)
|
val workspaceId = when (val workspaceResult = resolveWorkspaceId(baseUrl = baseUrl, apiKey = apiKey)) {
|
||||||
|
is BoardsApiResult.Success -> workspaceResult.value
|
||||||
|
is BoardsApiResult.Failure -> return workspaceResult
|
||||||
|
}
|
||||||
|
|
||||||
|
return BoardsApiResult.Success(
|
||||||
|
SessionSnapshot(baseUrl = baseUrl, apiKey = apiKey, workspaceId = workspaceId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveWorkspaceId(baseUrl: String, apiKey: String): BoardsApiResult<String> {
|
||||||
|
val storedWorkspaceId = sessionStore.getWorkspaceId()?.takeIf { it.isNotBlank() }
|
||||||
|
if (storedWorkspaceId != null) {
|
||||||
|
return BoardsApiResult.Success(storedWorkspaceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (val workspacesResult = apiClient.listWorkspaces(baseUrl, apiKey)) {
|
||||||
|
is BoardsApiResult.Success -> {
|
||||||
|
val first = workspacesResult.value.firstOrNull()?.id
|
||||||
|
?: return BoardsApiResult.Failure("No workspaces available for this account.")
|
||||||
|
sessionStore.saveWorkspaceId(first)
|
||||||
|
BoardsApiResult.Success(first)
|
||||||
|
}
|
||||||
|
|
||||||
|
is BoardsApiResult.Failure -> workspacesResult
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class SessionSnapshot(
|
private data class SessionSnapshot(
|
||||||
val baseUrl: String,
|
val baseUrl: String,
|
||||||
val apiKey: String,
|
val apiKey: String,
|
||||||
|
val workspaceId: String,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class BoardsRepositoryTest {
|
|||||||
listBoardsResult = BoardsApiResult.Success(
|
listBoardsResult = BoardsApiResult.Success(
|
||||||
listOf(BoardSummary("1", "Alpha"), BoardSummary("2", "Beta")),
|
listOf(BoardSummary("1", "Alpha"), BoardSummary("2", "Beta")),
|
||||||
)
|
)
|
||||||
|
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
|
||||||
}
|
}
|
||||||
|
|
||||||
val repository = BoardsRepository(
|
val repository = BoardsRepository(
|
||||||
@@ -44,12 +45,74 @@ class BoardsRepositoryTest {
|
|||||||
val boards = (result as BoardsApiResult.Success).value
|
val boards = (result as BoardsApiResult.Success).value
|
||||||
assertEquals(2, boards.size)
|
assertEquals(2, boards.size)
|
||||||
assertEquals("Alpha", boards[0].title)
|
assertEquals("Alpha", boards[0].title)
|
||||||
|
assertEquals("ws-1", fakeApi.lastWorkspaceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listBoardsUsesStoredWorkspaceWhenAvailable() = runTest {
|
||||||
|
val fakeApi = FakeBoardsApiClient().apply {
|
||||||
|
listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha")))
|
||||||
|
}
|
||||||
|
|
||||||
|
val repository = BoardsRepository(
|
||||||
|
sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-stored"),
|
||||||
|
apiKeyStore = InMemoryApiKeyStore("api"),
|
||||||
|
apiClient = fakeApi,
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.listBoards()
|
||||||
|
|
||||||
|
assertEquals("ws-stored", fakeApi.lastWorkspaceId)
|
||||||
|
assertEquals(0, fakeApi.listWorkspacesCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listBoardsResolvesFirstWorkspaceWhenMissingInSession() = runTest {
|
||||||
|
val fakeApi = FakeBoardsApiClient().apply {
|
||||||
|
workspacesResult = BoardsApiResult.Success(
|
||||||
|
listOf(WorkspaceSummary("ws-first", "Alpha"), WorkspaceSummary("ws-second", "Beta")),
|
||||||
|
)
|
||||||
|
listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha")))
|
||||||
|
}
|
||||||
|
val sessionStore = InMemorySessionStore("https://kan.bn/")
|
||||||
|
|
||||||
|
val repository = BoardsRepository(
|
||||||
|
sessionStore = sessionStore,
|
||||||
|
apiKeyStore = InMemoryApiKeyStore("api"),
|
||||||
|
apiClient = fakeApi,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = repository.listBoards()
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Success)
|
||||||
|
assertEquals("ws-first", sessionStore.getWorkspaceId())
|
||||||
|
assertEquals("ws-first", fakeApi.lastWorkspaceId)
|
||||||
|
assertEquals(1, fakeApi.listWorkspacesCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listBoardsFailsWhenNoWorkspacesReturned() = runTest {
|
||||||
|
val fakeApi = FakeBoardsApiClient().apply {
|
||||||
|
workspacesResult = BoardsApiResult.Success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
val repository = BoardsRepository(
|
||||||
|
sessionStore = InMemorySessionStore("https://kan.bn/"),
|
||||||
|
apiKeyStore = InMemoryApiKeyStore("api"),
|
||||||
|
apiClient = fakeApi,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = repository.listBoards()
|
||||||
|
|
||||||
|
assertTrue(result is BoardsApiResult.Failure)
|
||||||
|
assertEquals("No workspaces available for this account.", (result as BoardsApiResult.Failure).message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun createBoardTrimsNameAndPassesTemplateId() = runTest {
|
fun createBoardTrimsNameAndPassesTemplateId() = runTest {
|
||||||
val fakeApi = FakeBoardsApiClient().apply {
|
val fakeApi = FakeBoardsApiClient().apply {
|
||||||
createBoardResult = BoardsApiResult.Success(BoardSummary("33", "Roadmap"))
|
createBoardResult = BoardsApiResult.Success(BoardSummary("33", "Roadmap"))
|
||||||
|
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
|
||||||
}
|
}
|
||||||
|
|
||||||
val repository = BoardsRepository(
|
val repository = BoardsRepository(
|
||||||
@@ -63,12 +126,14 @@ class BoardsRepositoryTest {
|
|||||||
assertTrue(result is BoardsApiResult.Success)
|
assertTrue(result is BoardsApiResult.Success)
|
||||||
assertEquals("Roadmap", fakeApi.lastCreateName)
|
assertEquals("Roadmap", fakeApi.lastCreateName)
|
||||||
assertEquals("tpl-1", fakeApi.lastCreateTemplateId)
|
assertEquals("tpl-1", fakeApi.lastCreateTemplateId)
|
||||||
|
assertEquals("ws-1", fakeApi.lastCreateWorkspaceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun deleteBoardPassesBoardIdToApi() = runTest {
|
fun deleteBoardPassesBoardIdToApi() = runTest {
|
||||||
val fakeApi = FakeBoardsApiClient().apply {
|
val fakeApi = FakeBoardsApiClient().apply {
|
||||||
deleteBoardResult = BoardsApiResult.Success(Unit)
|
deleteBoardResult = BoardsApiResult.Success(Unit)
|
||||||
|
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
|
||||||
}
|
}
|
||||||
|
|
||||||
val repository = BoardsRepository(
|
val repository = BoardsRepository(
|
||||||
@@ -83,15 +148,28 @@ class BoardsRepositoryTest {
|
|||||||
assertEquals("42", fakeApi.lastDeletedId)
|
assertEquals("42", fakeApi.lastDeletedId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private class InMemorySessionStore(private var baseUrl: String?) : SessionStore {
|
private class InMemorySessionStore(
|
||||||
|
private var baseUrl: String?,
|
||||||
|
private var workspaceId: String? = null,
|
||||||
|
) : SessionStore {
|
||||||
override fun getBaseUrl(): String? = baseUrl
|
override fun getBaseUrl(): String? = baseUrl
|
||||||
override fun saveBaseUrl(url: String) {
|
override fun saveBaseUrl(url: String) {
|
||||||
baseUrl = url
|
baseUrl = url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getWorkspaceId(): String? = workspaceId
|
||||||
|
|
||||||
|
override fun saveWorkspaceId(workspaceId: String) {
|
||||||
|
this.workspaceId = workspaceId
|
||||||
|
}
|
||||||
|
|
||||||
override fun clearBaseUrl() {
|
override fun clearBaseUrl() {
|
||||||
baseUrl = null
|
baseUrl = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun clearWorkspaceId() {
|
||||||
|
workspaceId = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class InMemoryApiKeyStore(private var apiKey: String?) : ApiKeyStore {
|
private class InMemoryApiKeyStore(private var apiKey: String?) : ApiKeyStore {
|
||||||
@@ -111,32 +189,54 @@ class BoardsRepositoryTest {
|
|||||||
private class FakeBoardsApiClient : KanbnApiClient {
|
private class FakeBoardsApiClient : KanbnApiClient {
|
||||||
var listBoardsResult: BoardsApiResult<List<BoardSummary>> = BoardsApiResult.Success(emptyList())
|
var listBoardsResult: BoardsApiResult<List<BoardSummary>> = BoardsApiResult.Success(emptyList())
|
||||||
var listTemplatesResult: BoardsApiResult<List<BoardTemplate>> = BoardsApiResult.Success(emptyList())
|
var listTemplatesResult: BoardsApiResult<List<BoardTemplate>> = BoardsApiResult.Success(emptyList())
|
||||||
|
var workspacesResult: BoardsApiResult<List<WorkspaceSummary>> =
|
||||||
|
BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
|
||||||
var createBoardResult: BoardsApiResult<BoardSummary> = BoardsApiResult.Success(BoardSummary("new", "New"))
|
var createBoardResult: BoardsApiResult<BoardSummary> = BoardsApiResult.Success(BoardSummary("new", "New"))
|
||||||
var deleteBoardResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
|
var deleteBoardResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
|
||||||
|
|
||||||
|
var listWorkspacesCalls: Int = 0
|
||||||
|
var lastWorkspaceId: String? = null
|
||||||
var lastCreateName: String? = null
|
var lastCreateName: String? = null
|
||||||
var lastCreateTemplateId: String? = null
|
var lastCreateTemplateId: String? = null
|
||||||
|
var lastCreateWorkspaceId: String? = null
|
||||||
var lastDeletedId: String? = null
|
var lastDeletedId: String? = null
|
||||||
|
|
||||||
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
|
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
|
||||||
|
|
||||||
override suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardSummary>> {
|
override suspend fun listWorkspaces(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
): BoardsApiResult<List<WorkspaceSummary>> {
|
||||||
|
listWorkspacesCalls += 1
|
||||||
|
return workspacesResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun listBoards(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
|
): BoardsApiResult<List<BoardSummary>> {
|
||||||
|
lastWorkspaceId = workspaceId
|
||||||
return listBoardsResult
|
return listBoardsResult
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun listBoardTemplates(
|
override suspend fun listBoardTemplates(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
): BoardsApiResult<List<BoardTemplate>> {
|
): BoardsApiResult<List<BoardTemplate>> {
|
||||||
|
lastWorkspaceId = workspaceId
|
||||||
return listTemplatesResult
|
return listTemplatesResult
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createBoard(
|
override suspend fun createBoard(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
name: String,
|
name: String,
|
||||||
templateId: String?,
|
templateId: String?,
|
||||||
): BoardsApiResult<BoardSummary> {
|
): BoardsApiResult<BoardSummary> {
|
||||||
|
lastCreateWorkspaceId = workspaceId
|
||||||
lastCreateName = name
|
lastCreateName = name
|
||||||
lastCreateTemplateId = templateId
|
lastCreateTemplateId = templateId
|
||||||
return createBoardResult
|
return createBoardResult
|
||||||
|
|||||||
@@ -127,9 +127,17 @@ class BoardsViewModelTest {
|
|||||||
baseUrl = url
|
baseUrl = url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getWorkspaceId(): String? = "ws-1"
|
||||||
|
|
||||||
|
override fun saveWorkspaceId(workspaceId: String) {
|
||||||
|
}
|
||||||
|
|
||||||
override fun clearBaseUrl() {
|
override fun clearBaseUrl() {
|
||||||
baseUrl = null
|
baseUrl = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun clearWorkspaceId() {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class InMemoryApiKeyStore(private var apiKey: String?) : ApiKeyStore {
|
private class InMemoryApiKeyStore(private var apiKey: String?) : ApiKeyStore {
|
||||||
@@ -156,17 +164,26 @@ class BoardsViewModelTest {
|
|||||||
|
|
||||||
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
|
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = AuthResult.Success
|
||||||
|
|
||||||
override suspend fun listBoards(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardSummary>> {
|
override suspend fun listBoards(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
|
): BoardsApiResult<List<BoardSummary>> {
|
||||||
return listBoardsResult
|
return listBoardsResult
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun listBoardTemplates(baseUrl: String, apiKey: String): BoardsApiResult<List<BoardTemplate>> {
|
override suspend fun listBoardTemplates(
|
||||||
|
baseUrl: String,
|
||||||
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
|
): BoardsApiResult<List<BoardTemplate>> {
|
||||||
return listTemplatesResult
|
return listTemplatesResult
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createBoard(
|
override suspend fun createBoard(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
|
workspaceId: String,
|
||||||
name: String,
|
name: String,
|
||||||
templateId: String?,
|
templateId: String?,
|
||||||
): BoardsApiResult<BoardSummary> {
|
): BoardsApiResult<BoardSummary> {
|
||||||
|
|||||||
Reference in New Issue
Block a user