diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index cf0ec9d..843ee4b 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -351,6 +351,8 @@ private fun MainShell( onEditSchema = { tabNav.navigate(Routes.listSchema(listId)) }, onOpenWatchers = { tabNav.navigate(Routes.listWatchers(listId)) }, onOpenShare = { tabNav.navigate(Routes.listShare(listId)) }, + // Breadcrumb hops and newly created child lists open a detail route. + onOpenList = { id -> tabNav.navigate(Routes.listDetail(id)) }, ) } composable( diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt index 922ebe9..a51f3bb 100644 --- a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt @@ -34,7 +34,12 @@ class ListDetailScreenTest { ), ) - private fun setScreen(state: ListDetailUiState, onOpenShare: () -> Unit = {}) { + private fun setScreen( + state: ListDetailUiState, + onOpenShare: () -> Unit = {}, + onOpenList: (String) -> Unit = {}, + onNewChildList: () -> Unit = {}, + ) { composeRule.setContent { InterlinedListTheme { ListDetailScreen( @@ -45,6 +50,8 @@ class ListDetailScreenTest { onDeleteRow = {}, onDeleteList = {}, onOpenShare = onOpenShare, + onOpenList = onOpenList, + onNewChildList = onNewChildList, ) } } @@ -101,4 +108,62 @@ class ListDetailScreenTest { composeRule.onNodeWithTag(ListDetailTestTags.SHARE).assertIsDisplayed().performClick() assert(shared) } + + @Test + fun rendersParentBreadcrumb_andOpensAnAncestor() { + var opened: String? = null + setScreen( + ListDetailUiState( + summary = ListSummary("L1", "Chapter 3", null, 0, null, false, null, "P1"), + schema = schema, + rows = emptyList(), + isLoading = false, + breadcrumb = listOf( + ListSummary("ROOT", "Book", null, 0, null, false, null, null), + ListSummary("P1", "Part 2", null, 0, null, false, null, "ROOT"), + ), + ), + onOpenList = { opened = it }, + ) + + composeRule.onNodeWithTag(ListDetailTestTags.BREADCRUMB).assertIsDisplayed() + composeRule.onNodeWithText("Book").assertIsDisplayed() + composeRule.onNodeWithText("Part 2").assertIsDisplayed() + + composeRule.onNodeWithTag(ListDetailTestTags.crumb("ROOT")).performClick() + assert(opened == "ROOT") + } + + @Test + fun hidesBreadcrumb_forARootList() { + setScreen( + ListDetailUiState( + summary = ListSummary("L1", "Reading", null, 0, null, false, null), + schema = schema, + rows = emptyList(), + isLoading = false, + ), + ) + + composeRule.onNodeWithTag(ListDetailTestTags.BREADCRUMB).assertDoesNotExist() + } + + @Test + fun overflowOffersANewChildList() { + var requested = false + setScreen( + ListDetailUiState( + summary = ListSummary("L1", "Reading", null, 0, null, false, null), + schema = schema, + rows = emptyList(), + isLoading = false, + ), + onNewChildList = { requested = true }, + ) + + composeRule.onNodeWithTag(ListDetailTestTags.OVERFLOW).performClick() + composeRule.onNodeWithTag(ListDetailTestTags.NEW_CHILD).performClick() + + assert(requested) + } } diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt index 6cbb302..e6d5568 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt @@ -24,6 +24,7 @@ import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.ListSource import com.interlinedlist.android.feature.lists.domain.ListSummary import com.interlinedlist.android.feature.lists.domain.Paged import com.interlinedlist.android.feature.lists.domain.RefreshResult @@ -38,6 +39,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import javax.inject.Inject @@ -92,15 +94,33 @@ class DefaultListsRepository @Inject constructor( title: String, description: String?, isPublic: Boolean, + parentId: String?, + folderId: String?, + messageId: String?, + initialRows: List>?, + metadata: JsonObject?, + source: ListSource?, ): ApiResult = withContext(dispatchers.io) { - val body = CreateListRequest(title = title, description = description, isPublic = isPublic) + val body = CreateListRequest( + title = title, + description = description, + isPublic = isPublic, + parentId = parentId, + folderId = folderId, + messageId = messageId, + // Starter rows go out in the same shape a row write uses. + initialRows = initialRows?.map { JsonObject(it.toJsonData()) }, + metadata = metadata, + source = source?.wire, + ) when (val result = safeApiCall(json) { api.createList(body) }) { is ApiResult.Success -> { val dto = result.data.list ?: result.data.data ?: return@withContext ApiResult.Success( ListSummary( id = "", title = title, description = description, - itemCount = 0, folderId = null, isPublic = isPublic, updatedAt = null, + itemCount = 0, folderId = folderId, isPublic = isPublic, + updatedAt = null, parentId = parentId, ), ) val summary = ListMapper.summaryFromDto(dto) @@ -111,6 +131,45 @@ class DefaultListsRepository @Inject constructor( } } + override suspend fun createListFromMessage( + messageId: String, + title: String, + description: String?, + ): ApiResult = + createList(title = title, description = description, messageId = messageId) + + override suspend fun getParentChain(parentId: String): ApiResult> = + withContext(dispatchers.io) { + val ancestors = mutableListOf() // nearest parent first + val visited = mutableSetOf() + // The server may inline the next level under `parent`; when it does, + // that level costs no request. + var inlined: ListDto? = null + var nextId: String? = parentId + + while (ancestors.size < MAX_PARENT_CHAIN) { + // A level already seen means the tree loops: stop before spending + // another request on it. + val dto = inlined?.takeIf { visited.add(it.id) } + ?: nextId?.takeIf { visited.add(it) }?.let { id -> + when (val result = safeApiCall(json) { api.getList(id) }) { + is ApiResult.Success -> result.data.list ?: result.data.data + // A level we cannot fetch truncates the breadcrumb; only a + // chain that resolved nothing at all is reported as a failure. + is ApiResult.Failure -> + if (ancestors.isEmpty()) return@withContext result else null + } + } ?: break + + ancestors += ListMapper.summaryFromDto(dto) + inlined = dto.parent + nextId = dto.parentId + } + + listDao.upsertAll(ancestors.map(ListMapper::summaryToEntity)) + ApiResult.Success(ancestors.reversed()) // root → immediate parent + } + override suspend fun updateList( id: String, title: String?, @@ -411,6 +470,11 @@ class DefaultListsRepository @Inject constructor( private fun Map.toJsonData(): Map = filterValues { it.isNotBlank() } .mapValues { (_, value) -> JsonPrimitive(value) as JsonElement } + + private companion object { + /** Safety net for a breadcrumb walk: deep nesting is not worth the requests. */ + const val MAX_PARENT_CHAIN = 10 + } } /** Builds a [Paged] from the response's pagination block, tolerating its absence. */ diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListMapper.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListMapper.kt index 6ce6bc2..5c71757 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListMapper.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListMapper.kt @@ -21,6 +21,7 @@ object ListMapper { folderId = dto.folderId, isPublic = dto.isPublic, updatedAt = dto.updatedAt, + parentId = dto.parentId ?: dto.parent?.id, ) fun summaryToEntity(summary: ListSummary): CachedListEntity = CachedListEntity( @@ -31,6 +32,7 @@ object ListMapper { folderId = summary.folderId, isPublic = summary.isPublic, updatedAt = summary.updatedAt, + parentId = summary.parentId, ) fun summaryFromEntity(entity: CachedListEntity): ListSummary = ListSummary( @@ -41,6 +43,7 @@ object ListMapper { folderId = entity.folderId, isPublic = entity.isPublic, updatedAt = entity.updatedAt, + parentId = entity.parentId, ) fun folderFromDto(dto: FolderDto): ListFolder = ListFolder( diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt index 3ce1e28..a3a2579 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt @@ -7,6 +7,7 @@ import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.ListSource import com.interlinedlist.android.feature.lists.domain.ListSummary import com.interlinedlist.android.feature.lists.domain.Paged import com.interlinedlist.android.feature.lists.domain.RefreshResult @@ -18,6 +19,7 @@ import com.interlinedlist.android.feature.lists.domain.Watcher import com.interlinedlist.android.feature.lists.domain.WatcherCandidate import com.interlinedlist.android.feature.lists.domain.WatcherRole import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.json.JsonObject /** * Offline-first access to the Lists domain. The index streams from Room (the @@ -38,8 +40,54 @@ interface ListsRepository { /** Server-side search (not cached) by title/description. */ suspend fun searchLists(query: String, limit: Int = DEFAULT_PAGE_SIZE): ApiResult> - /** Creates a list; caches the result and returns its summary. */ - suspend fun createList(title: String, description: String?, isPublic: Boolean): ApiResult + /** + * Creates a list; caches the result and returns its summary. + * + * Everything past [isPublic] is an optional creation option the API accepts + * and is omitted from the request when null, so callers that only need a + * title/description send exactly what they always sent: + * - [parentId] nests the new list under an existing one (see [getParentChain]). + * - [folderId] files it into a folder. + * - [messageId] records the message the list was created from. + * - [initialRows] seeds starter rows, each a column-key → value map, exactly + * as [addRow] sends a row. + * - [metadata] is a free-form JSON object passed through untouched. + * - [source] marks where the rows come from (defaults to the server's own). + */ + suspend fun createList( + title: String, + description: String? = null, + isPublic: Boolean = false, + parentId: String? = null, + folderId: String? = null, + messageId: String? = null, + initialRows: List>? = null, + metadata: JsonObject? = null, + source: ListSource? = null, + ): ApiResult + + /** + * Creates a list from a message: the message becomes the new list's + * description and the API keeps the link via `messageId`. + * + * The entry point that offers this on a message lives in `:feature:messages` + * (see the cross-object "create from" work); this is the call it makes. + */ + suspend fun createListFromMessage( + messageId: String, + title: String, + description: String?, + ): ApiResult + + /** + * Resolves a list's ancestry for breadcrumb navigation: fetches [parentId] + * and keeps walking up, returning the chain ordered root → immediate parent. + * + * A level the server cannot serve truncates the chain rather than failing, + * since a breadcrumb is navigation decoration; a failure is only reported + * when nothing at all could be resolved. + */ + suspend fun getParentChain(parentId: String): ApiResult> /** * Updates a list's metadata (title/description/visibility/folder). Only the diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/CachedListEntity.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/CachedListEntity.kt index ccdb761..3621805 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/CachedListEntity.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/CachedListEntity.kt @@ -16,4 +16,6 @@ data class CachedListEntity( val folderId: String?, val isPublic: Boolean, val updatedAt: String?, + /** Parent list id — kept so a cached list still knows where it sits in the tree. */ + val parentId: String? = null, ) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListsDatabase.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListsDatabase.kt index 772b9d3..d6be1cd 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListsDatabase.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListsDatabase.kt @@ -10,7 +10,8 @@ import androidx.room.RoomDatabase */ @Database( entities = [CachedListEntity::class], - version = 1, + // v2 adds CachedListEntity.parentId (list tree / breadcrumb). + version = 2, exportSchema = false, ) abstract class ListsDatabase : RoomDatabase() { diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ListDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ListDtos.kt index c4bd4ea..15a293c 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ListDtos.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ListDtos.kt @@ -2,6 +2,7 @@ package com.interlinedlist.android.feature.lists.data.remote.dto import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject /** * Wire models for the Lists API. Field names follow the InterlinedList REST @@ -16,7 +17,14 @@ import kotlinx.serialization.json.JsonElement * @Serializable shapes. */ -/** A list envelope as returned by index and detail endpoints. */ +/** + * A list envelope as returned by index and detail endpoints. + * + * `GET /api/lists` returns each row with its tree links: `parentId` plus a nested + * `parent` object (null at the root) and a `children` array. The nested [parent] + * is modelled recursively so the breadcrumb can consume whatever depth the server + * chooses to send and only fetch the levels it did not. + */ @Serializable data class ListDto( val id: String, @@ -28,6 +36,18 @@ data class ListDto( val folderId: String? = null, val isPublic: Boolean = false, val updatedAt: String? = null, + /** The list this one hangs under; `null` for a root list. */ + val parentId: String? = null, + /** The parent as an object, when the server inlines it. */ + val parent: ListDto? = null, + /** Direct children, when the server inlines them. */ + val children: List? = null, + /** The message this list was created from, if any. */ + val messageId: String? = null, + /** Free-form pass-through object; kept raw so no keys are lost. */ + val metadata: JsonElement? = null, + /** Where the rows come from — `"local"`, `"github"`, … (see `ListSource`). */ + val source: String? = null, // Detail responses may inline the schema; the mapper handles either shape. val schema: JsonElement? = null, ) @@ -62,13 +82,41 @@ data class ListEnvelope( val data: ListDto? = null, ) -/** Body for `POST /api/lists`. `schema` is a serialised DSL string per the API. */ +/** + * Body for `POST /api/lists`. + * + * Every field beyond [title] is optional and defaults to `null` so it is + * **omitted** from the JSON rather than sent as an explicit `null` — a caller + * that only wants a title still produces `{"title":"…"}`. + * + * - [schema] is the List Schema DSL **object** (`{ name, description?, fields[] }`), + * per the help centre's Lists API reference — not a serialised string (that form + * belongs to `PUT /api/lists/{id}/schema`). + * - [initialRows] are starter rows; each is the same flat, column-keyed object + * that `POST /api/lists/{id}/data` sends under `data`. The OpenAPI spec types + * this field as a bare `string` and the help centre does not document it at all, + * so the element shape follows the list-data API rather than the generated spec. + * - [metadata] is undocumented and free-form, so it is kept as [JsonObject] and + * passed through with every key intact. + * - [source] is the low-cardinality list source string — `"local"` or `"github"` + * (see [com.interlinedlist.android.feature.lists.domain.ListSource]). + * - [folderId] is a real column on a list, but neither the OpenAPI create schema + * nor the help centre lists it as a *create* field: filing a list into a folder + * is documented on `PUT /api/lists/{id}`. It is sent when supplied; a caller that + * must be certain should follow up with an update. + */ @Serializable data class CreateListRequest( val title: String, val description: String? = null, - val schema: String? = null, + val schema: JsonObject? = null, val isPublic: Boolean = false, + val parentId: String? = null, + val folderId: String? = null, + val messageId: String? = null, + val initialRows: List? = null, + val metadata: JsonObject? = null, + val source: String? = null, ) /** Body for `PUT /api/lists/{id}` — partial metadata updates. */ diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSource.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSource.kt new file mode 100644 index 0000000..e115d6e --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSource.kt @@ -0,0 +1,17 @@ +package com.interlinedlist.android.feature.lists.domain + +/** + * Where a list's rows come from. The API stores this as a short string on the + * list row (`source`), observed as `"local"` for ordinary lists; `"github"` marks + * a repository-backed list that `POST /api/lists/{id}/refresh` re-syncs. + * + * Modelled as an enum so callers pass a value the API recognises instead of a + * free-form string. + */ +enum class ListSource(val wire: String) { + /** A normal list whose rows are edited in the app. */ + LOCAL("local"), + + /** A list mirrored from a GitHub repository. */ + GITHUB("github"), +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSummary.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSummary.kt index 0e40a9c..1e878a3 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSummary.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSummary.kt @@ -13,4 +13,10 @@ data class ListSummary( val folderId: String?, val isPublic: Boolean, val updatedAt: String?, + /** + * The list this one hangs under, if any. Lists form a tree, so the detail + * screen walks this up to render a breadcrumb + * ([com.interlinedlist.android.feature.lists.data.ListsRepository.getParentChain]). + */ + val parentId: String? = null, ) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt index 6656ad1..ca52a36 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt @@ -1,5 +1,6 @@ package com.interlinedlist.android.feature.lists.ui.detail +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -73,7 +74,10 @@ object ListDetailTestTags { const val EDIT_SCHEMA = "listDetailEditSchema" const val WATCHERS = "listDetailWatchers" const val SHARE = "listDetailShare" + const val NEW_CHILD = "listDetailNewChild" + const val BREADCRUMB = "listDetailBreadcrumb" fun row(id: String) = "listDetailRow_$id" + fun crumb(id: String) = "listDetailCrumb_$id" } /** @@ -91,6 +95,7 @@ fun ListDetailRoute( onOpenWatchers: () -> Unit, modifier: Modifier = Modifier, onOpenShare: () -> Unit = {}, + onOpenList: (String) -> Unit = {}, viewModel: ListDetailViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -123,6 +128,8 @@ fun ListDetailRoute( onEditSchema = onEditSchema, onOpenWatchers = onOpenWatchers, onOpenShare = onOpenShare, + onOpenList = onOpenList, + onNewChildList = { viewModel.createChildList(onOpenList) }, snackbarHostState = snackbarHostState, modifier = modifier, ) @@ -188,6 +195,8 @@ fun ListDetailScreen( onEditSchema: () -> Unit = {}, onOpenWatchers: () -> Unit = {}, onOpenShare: () -> Unit = {}, + onOpenList: (String) -> Unit = {}, + onNewChildList: () -> Unit = {}, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { var menuOpen by remember { mutableStateOf(false) } @@ -230,6 +239,11 @@ fun ListDetailScreen( onClick = { menuOpen = false; onEditSchema() }, modifier = Modifier.testTag(ListDetailTestTags.EDIT_SCHEMA), ) + DropdownMenuItem( + text = { Text("New child list") }, + onClick = { menuOpen = false; onNewChildList() }, + modifier = Modifier.testTag(ListDetailTestTags.NEW_CHILD), + ) DropdownMenuItem( text = { Text("Watchers") }, onClick = { menuOpen = false; onOpenWatchers() }, @@ -274,6 +288,7 @@ fun ListDetailScreen( ) { Text(state.errorMessage) } else -> Column(Modifier.padding(padding)) { + Breadcrumb(ancestors = state.breadcrumb, onOpenList = onOpenList) if (!state.summary?.description.isNullOrBlank()) { Text( text = state.summary!!.description!!, @@ -294,6 +309,49 @@ fun ListDetailScreen( } } +/** + * Where this list sits in the list tree: its ancestors, root first, each one a tap + * away. Nothing is drawn for a root list. + */ +@Composable +private fun Breadcrumb(ancestors: List, onOpenList: (String) -> Unit) { + if (ancestors.isEmpty()) return + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 4.dp) + .testTag(ListDetailTestTags.BREADCRUMB), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + ancestors.forEachIndexed { index, ancestor -> + if (index > 0) { + Text( + text = "/", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = ancestor.title.ifBlank { "Untitled list" }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .clickable { onOpenList(ancestor.id) } + .testTag(ListDetailTestTags.crumb(ancestor.id)), + ) + } + Text( + text = "/", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + /** * Renders rows generically against the schema: a header of field labels and one * card per row projecting each field's value. Nothing about the columns is diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt index 356f3f9..23fdde1 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt @@ -31,6 +31,8 @@ data class ListDetailUiState( val isRefreshing: Boolean = false, val refreshMessage: String? = null, val isEditingMetadata: Boolean = false, + /** Ancestors of this list, root first — empty for a root list. */ + val breadcrumb: List = emptyList(), ) { val title: String get() = summary?.title.orEmpty() val isEmpty: Boolean get() = rows.isEmpty() && !isLoading && errorMessage == null @@ -60,13 +62,16 @@ class ListDetailViewModel @Inject constructor( _uiState.update { it.copy(isLoading = true, errorMessage = null, subscriptionRequired = false) } viewModelScope.launch { when (val result = repository.getListDetail(listId)) { - is ApiResult.Success -> _uiState.update { - it.copy( - summary = result.data.summary, - schema = result.data.schema, - rows = result.data.rows, - isLoading = false, - ) + is ApiResult.Success -> { + _uiState.update { + it.copy( + summary = result.data.summary, + schema = result.data.schema, + rows = result.data.rows, + isLoading = false, + ) + } + loadBreadcrumb(result.data.summary) } is ApiResult.Failure -> _uiState.update { it.copy( @@ -176,14 +181,62 @@ class ListDetailViewModel @Inject constructor( private fun reload() { viewModelScope.launch { when (val result = repository.getListDetail(listId)) { - is ApiResult.Success -> _uiState.update { + is ApiResult.Success -> { + _uiState.update { + it.copy( + summary = result.data.summary, + schema = result.data.schema, + rows = result.data.rows, + ) + } + loadBreadcrumb(result.data.summary) + } + is ApiResult.Failure -> Unit // Keep the existing rows; refresh already succeeded. + } + } + } + + /** + * Walks the list's parent chain so the screen can show where it sits in the + * tree. Purely navigational: a failure leaves the breadcrumb empty rather than + * putting an error in front of a list that loaded fine. + */ + private fun loadBreadcrumb(summary: ListSummary) { + val parentId = summary.parentId + if (parentId == null) { + _uiState.update { it.copy(breadcrumb = emptyList()) } + return + } + viewModelScope.launch { + when (val result = repository.getParentChain(parentId)) { + is ApiResult.Success -> _uiState.update { it.copy(breadcrumb = result.data) } + is ApiResult.Failure -> _uiState.update { it.copy(breadcrumb = emptyList()) } + } + } + } + + /** + * Creates a new list nested under this one and hands its id back so the caller + * can open it. The child starts with the same placeholder title the index uses; + * it is renamed from the detail screen like any other list. + */ + fun createChildList(onCreated: (String) -> Unit = {}) { + val parent = _uiState.value.summary ?: return + if (_uiState.value.isSaving) return + _uiState.update { it.copy(isSaving = true) } + viewModelScope.launch { + when (val result = repository.createList(title = NEW_CHILD_TITLE, parentId = parent.id)) { + is ApiResult.Success -> { + _uiState.update { it.copy(isSaving = false) } + onCreated(result.data.id) + } + is ApiResult.Failure -> _uiState.update { it.copy( - summary = result.data.summary, - schema = result.data.schema, - rows = result.data.rows, + isSaving = false, + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, ) } - is ApiResult.Failure -> Unit // Keep the existing rows; refresh already succeeded. } } } @@ -249,4 +302,8 @@ class ListDetailViewModel @Inject constructor( } fun clearError() = _uiState.update { it.copy(errorMessage = null) } + + private companion object { + const val NEW_CHILD_TITLE = "New list" + } } diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt index ddf2f13..29dd5d7 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt @@ -9,6 +9,7 @@ import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.ListSource import com.interlinedlist.android.feature.lists.domain.ListSummary import com.interlinedlist.android.feature.lists.domain.Paged import com.interlinedlist.android.feature.lists.domain.RefreshResult @@ -21,6 +22,7 @@ import com.interlinedlist.android.feature.lists.domain.WatcherCandidate import com.interlinedlist.android.feature.lists.domain.WatcherRole import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.json.JsonObject /** * In-memory [ListsRepository] for ViewModel tests. The cache is a StateFlow so @@ -36,6 +38,7 @@ class FakeListsRepository : ListsRepository { var loadMoreResult: ApiResult> = refreshResult var searchResult: ApiResult> = ApiResult.Success(emptyList()) var createResult: ApiResult? = null + var parentChainResult: ApiResult> = ApiResult.Success(emptyList()) var updateListResult: ApiResult? = null var deleteResult: ApiResult = ApiResult.Success(Unit) var detailResult: ApiResult? = null @@ -74,6 +77,14 @@ class FakeListsRepository : ListsRepository { var refreshCount = 0 var loadMoreCount = 0 + var parentChainCount = 0 + var lastParentChainId: String? = null + var lastCreateParentId: String? = null + var lastCreateFolderId: String? = null + var lastCreateMessageId: String? = null + var lastCreateInitialRows: List>? = null + var lastCreateMetadata: JsonObject? = null + var lastCreateSource: ListSource? = null var updateListCount = 0 var updateFolderCount = 0 var deleteFolderCount = 0 @@ -113,10 +124,39 @@ class FakeListsRepository : ListsRepository { override suspend fun searchLists(query: String, limit: Int): ApiResult> = searchResult - override suspend fun createList(title: String, description: String?, isPublic: Boolean): ApiResult = - createResult ?: ApiResult.Success( - ListSummary("new", title, description, 0, null, isPublic, null), + override suspend fun createList( + title: String, + description: String?, + isPublic: Boolean, + parentId: String?, + folderId: String?, + messageId: String?, + initialRows: List>?, + metadata: JsonObject?, + source: ListSource?, + ): ApiResult { + lastCreateParentId = parentId + lastCreateFolderId = folderId + lastCreateMessageId = messageId + lastCreateInitialRows = initialRows + lastCreateMetadata = metadata + lastCreateSource = source + return createResult ?: ApiResult.Success( + ListSummary("new", title, description, 0, folderId, isPublic, null, parentId), ) + } + + override suspend fun createListFromMessage( + messageId: String, + title: String, + description: String?, + ): ApiResult = createList(title = title, description = description, messageId = messageId) + + override suspend fun getParentChain(parentId: String): ApiResult> { + parentChainCount++ + lastParentChainId = parentId + return parentChainResult + } override suspend fun updateList( id: String, diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/CreateListRequestTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/CreateListRequestTest.kt new file mode 100644 index 0000000..3477e3b --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/CreateListRequestTest.kt @@ -0,0 +1,137 @@ +package com.interlinedlist.android.feature.lists.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.lists.data.remote.dto.CreateListRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.RowWriteRequest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import org.junit.Test + +/** + * The wire contract for `POST /api/lists`. The API accepts more creation options + * than the app used to send, and every one of them must be *omitted* — not sent + * as `null` — when the caller does not supply it, so existing callers keep + * producing exactly the body they produced before. + */ +class CreateListRequestTest { + + // Same configuration the app's Retrofit converter uses (see NetworkModule). + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } + + private fun encode(request: CreateListRequest): JsonObject = + json.encodeToJsonElement(request).jsonObject + + @Test + fun `omits every optional creation option when it is not supplied`() { + val body = encode(CreateListRequest(title = "Books")) + + assertThat(body.keys).containsExactly("title") + assertThat(body.keys).containsNoneOf("parentId", "folderId", "messageId") + assertThat(body.keys).containsNoneOf("initialRows", "metadata", "source", "schema") + } + + @Test + fun `omits the options a caller explicitly passes as null`() { + val body = encode( + CreateListRequest( + title = "Books", + description = null, + parentId = null, + folderId = null, + messageId = null, + initialRows = null, + metadata = null, + source = null, + ), + ) + + assertThat(body.keys).containsExactly("title") + } + + @Test + fun `carries every creation option the API accepts when supplied`() { + val body = encode( + CreateListRequest( + title = "Books", + description = "My reading backlog.", + isPublic = true, + parentId = "lst_parent", + folderId = "fld_1", + messageId = "msg_42", + source = "github", + metadata = buildJsonObject { put("colour", "blue") }, + initialRows = listOf(buildJsonObject { put("title", "Dune") }), + schema = buildJsonObject { put("name", "Books") }, + ), + ) + + assertThat(body["title"]).isEqualTo(JsonPrimitive("Books")) + assertThat(body["description"]).isEqualTo(JsonPrimitive("My reading backlog.")) + assertThat(body["isPublic"]).isEqualTo(JsonPrimitive(true)) + assertThat(body["parentId"]).isEqualTo(JsonPrimitive("lst_parent")) + assertThat(body["folderId"]).isEqualTo(JsonPrimitive("fld_1")) + assertThat(body["messageId"]).isEqualTo(JsonPrimitive("msg_42")) + assertThat(body["source"]).isEqualTo(JsonPrimitive("github")) + assertThat(body["metadata"]?.jsonObject?.get("colour")).isEqualTo(JsonPrimitive("blue")) + assertThat(body["initialRows"]?.jsonArray).hasSize(1) + assertThat(body["schema"]?.jsonObject?.get("name")).isEqualTo(JsonPrimitive("Books")) + } + + @Test + fun `initialRows serialise as the row payloads the list-data API expects`() { + val row = buildJsonObject { + put("title", "Dune") + put("year", 1965) + put("read", false) + } + + val initialRows = encode( + CreateListRequest(title = "Books", initialRows = listOf(row)), + )["initialRows"]!!.jsonArray + + // `POST /api/lists/{id}/data` sends exactly this per-row shape under `data`: + // a flat object keyed by schema field key. + val rowWrite = json.encodeToJsonElement(RowWriteRequest(row)).jsonObject["data"] + assertThat(initialRows).isEqualTo(JsonArray(listOf(rowWrite!!))) + } + + @Test + fun `metadata round-trips an arbitrary JSON object without losing keys`() { + val metadata = buildJsonObject { + put("colour", "blue") + put("pinned", true) + put("rank", 3) + put("archivedAt", JsonPrimitive(null as String?)) + put("tags", buildJsonArray { add(JsonPrimitive("a")); add(JsonPrimitive("b")) }) + put( + "nested", + buildJsonObject { + put("deep", buildJsonObject { put("key", "value") }) + }, + ) + } + + val encoded = json.encodeToString(CreateListRequest.serializer(), CreateListRequest("Books", metadata = metadata)) + val decoded = json.decodeFromString(CreateListRequest.serializer(), encoded) + + assertThat(decoded.metadata).isEqualTo(metadata) + // Every key survives the round-trip, including explicit nulls and nesting. + assertThat(decoded.metadata!!.keys) + .containsExactly("colour", "pinned", "rank", "archivedAt", "tags", "nested") + assertThat(decoded.metadata!!["nested"]?.jsonObject?.get("deep")?.jsonObject?.get("key")) + .isEqualTo(JsonPrimitive("value")) + assertThat(encoded).contains("\"archivedAt\":null") + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryCreationTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryCreationTest.kt new file mode 100644 index 0000000..8d8c308 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryCreationTest.kt @@ -0,0 +1,266 @@ +package com.interlinedlist.android.feature.lists.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.data.local.CachedListEntity +import com.interlinedlist.android.feature.lists.data.local.ListDao +import com.interlinedlist.android.feature.lists.data.remote.ListsApi +import com.interlinedlist.android.feature.lists.domain.ListSource +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * Creation options (`parentId`, `folderId`, `messageId`, `initialRows`, + * `metadata`, `source`) and parent-chain resolution, driven through the real + * Retrofit/OkHttp stack so the assertions are about the bytes that reach the API. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultListsRepositoryCreationTest { + + private lateinit var server: MockWebServer + private lateinit var api: ListsApi + private lateinit var dao: FakeCreationDao + private lateinit var repository: DefaultListsRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + private val dispatcher = StandardTestDispatcher() + + private val testDispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(ListsApi::class.java) + dao = FakeCreationDao() + repository = DefaultListsRepository(api, dao, json, testDispatchers) + } + + @After + fun tearDown() = server.shutdown() + + private fun RecordedRequest.bodyJson(): JsonObject = + json.parseToJsonElement(body.readUtf8()).jsonObject + + private fun enqueueCreated(id: String = "lst_new", extra: String = "") { + server.enqueue( + MockResponse().setResponseCode(201).setBody( + """{ "message": "created", "data": { "id": "$id", "title": "Books"$extra } }""", + ), + ) + } + + @Test + fun `createList sends only the fields the caller supplied`() = runTest(dispatcher) { + enqueueCreated() + + repository.createList(title = "Books", description = "Backlog", isPublic = false) + + val body = server.takeRequest().bodyJson() + assertThat(body["title"]).isEqualTo(JsonPrimitive("Books")) + assertThat(body["description"]).isEqualTo(JsonPrimitive("Backlog")) + // Untouched options must not appear at all — not even as explicit nulls. + assertThat(body.keys).containsNoneOf("parentId", "folderId", "messageId") + assertThat(body.keys).containsNoneOf("initialRows", "metadata", "source") + } + + @Test + fun `createList sends every creation option the API accepts`() = runTest(dispatcher) { + enqueueCreated(extra = """, "parentId": "lst_parent" """) + + val result = repository.createList( + title = "Books", + description = "Backlog", + isPublic = true, + parentId = "lst_parent", + folderId = "fld_1", + messageId = "msg_42", + initialRows = listOf(mapOf("title" to "Dune", "year" to "1965")), + metadata = buildJsonObject { put("colour", "blue") }, + source = ListSource.GITHUB, + ) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val body = server.takeRequest().bodyJson() + assertThat(body["parentId"]).isEqualTo(JsonPrimitive("lst_parent")) + assertThat(body["folderId"]).isEqualTo(JsonPrimitive("fld_1")) + assertThat(body["messageId"]).isEqualTo(JsonPrimitive("msg_42")) + assertThat(body["isPublic"]).isEqualTo(JsonPrimitive(true)) + assertThat(body["source"]).isEqualTo(JsonPrimitive("github")) + assertThat(body["metadata"]?.jsonObject?.get("colour")).isEqualTo(JsonPrimitive("blue")) + // Rows go out in the same shape `POST /api/lists/{id}/data` uses: a flat + // object per row, keyed by schema field key. + val rows = body["initialRows"]!!.jsonArray + assertThat(rows).hasSize(1) + assertThat(rows[0].jsonObject["title"]).isEqualTo(JsonPrimitive("Dune")) + assertThat(rows[0].jsonObject["year"]).isEqualTo(JsonPrimitive("1965")) + // The created child is cached with its parent so the breadcrumb can resolve. + assertThat((result as ApiResult.Success).data.parentId).isEqualTo("lst_parent") + } + + @Test + fun `createList drops blank row values like the row write path does`() = runTest(dispatcher) { + enqueueCreated() + + repository.createList( + title = "Books", + initialRows = listOf(mapOf("title" to "Dune", "notes" to " ")), + ) + + val rows = server.takeRequest().bodyJson()["initialRows"]!!.jsonArray + assertThat(rows[0].jsonObject.keys).containsExactly("title") + } + + @Test + fun `createListFromMessage posts the message id with the message as the description`() = + runTest(dispatcher) { + enqueueCreated(extra = """, "messageId": "msg_42" """) + + repository.createListFromMessage( + messageId = "msg_42", + title = "Reading picks", + description = "The message body", + ) + + val body = server.takeRequest().bodyJson() + assertThat(body["messageId"]).isEqualTo(JsonPrimitive("msg_42")) + assertThat(body["title"]).isEqualTo(JsonPrimitive("Reading picks")) + assertThat(body["description"]).isEqualTo(JsonPrimitive("The message body")) + } + + @Test + fun `getParentChain walks a multi-level chain and returns it root first`() = runTest(dispatcher) { + // parent → grandparent → root, each fetched in turn. + server.enqueue(MockResponse().setBody("""{ "data": { "id": "p", "title": "Parent", "parentId": "gp" } }""")) + server.enqueue(MockResponse().setBody("""{ "data": { "id": "gp", "title": "Grandparent", "parentId": "root" } }""")) + server.enqueue(MockResponse().setBody("""{ "data": { "id": "root", "title": "Root" } }""")) + + val result = repository.getParentChain("p") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val chain = (result as ApiResult.Success).data + assertThat(chain.map { it.id }).containsExactly("root", "gp", "p").inOrder() + assertThat(chain.map { it.title }).containsExactly("Root", "Grandparent", "Parent").inOrder() + assertThat(server.takeRequest().path).isEqualTo("/api/lists/p") + assertThat(server.takeRequest().path).isEqualTo("/api/lists/gp") + assertThat(server.takeRequest().path).isEqualTo("/api/lists/root") + } + + @Test + fun `getParentChain uses a nested parent object instead of another request`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { + "data": { + "id": "p", "title": "Parent", "parentId": "root", + "parent": { "id": "root", "title": "Root", "parentId": null } + } + } + """.trimIndent(), + ), + ) + + val result = repository.getParentChain("p") + + val chain = (result as ApiResult.Success).data + assertThat(chain.map { it.id }).containsExactly("root", "p").inOrder() + // The nested `parent` the server sent spares a round trip. + assertThat(server.requestCount).isEqualTo(1) + } + + @Test + fun `getParentChain stops on a cycle`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("""{ "data": { "id": "a", "title": "A", "parentId": "b" } }""")) + server.enqueue(MockResponse().setBody("""{ "data": { "id": "b", "title": "B", "parentId": "a" } }""")) + + val chain = (repository.getParentChain("a") as ApiResult.Success).data + + assertThat(chain.map { it.id }).containsExactly("b", "a").inOrder() + assertThat(server.requestCount).isEqualTo(2) + } + + @Test + fun `getParentChain truncates when an ancestor cannot be fetched`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("""{ "data": { "id": "p", "title": "Parent", "parentId": "gp" } }""")) + server.enqueue(MockResponse().setResponseCode(404).setBody("""{ "error": "not found", "code": "not_found" }""")) + + val chain = (repository.getParentChain("p") as ApiResult.Success).data + + // A broken link shortens the breadcrumb; it does not fail the screen. + assertThat(chain.map { it.id }).containsExactly("p") + } + + @Test + fun `getParentChain reports the failure when nothing could be resolved`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(404).setBody("""{ "error": "not found", "code": "not_found" }""")) + + assertThat(repository.getParentChain("p")).isInstanceOf(ApiResult.Failure::class.java) + } + + @Test + fun `getListDetail exposes the parent id so the breadcrumb can be resolved`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("""{ "data": { "id": "c", "title": "Child", "parentId": "p" } }""")) + server.enqueue(MockResponse().setBody("""[ { "key": "title", "type": "text" } ]""")) + server.enqueue(MockResponse().setBody("""{ "data": [] }""")) + + val detail = (repository.getListDetail("c") as ApiResult.Success).data + + assertThat(detail.summary.parentId).isEqualTo("p") + // The cache keeps the parent link too, so an offline open still shows it. + assertThat(dao.cached.single().parentId).isEqualTo("p") + } +} + +/** In-memory [ListDao] backed by a StateFlow, for JVM repository tests. */ +private class FakeCreationDao : ListDao { + private val state = MutableStateFlow>(emptyList()) + + val cached: List get() = state.value + + override fun observeLists(): Flow> = state + + override suspend fun upsertAll(lists: List) { + val byId = state.value.associateBy { it.id }.toMutableMap() + lists.forEach { byId[it.id] = it } + state.value = byId.values.toList() + } + + override suspend fun upsert(list: CachedListEntity) = upsertAll(listOf(list)) + + override suspend fun deleteById(id: String) { + state.value = state.value.filterNot { it.id == id } + } + + override suspend fun clear() { + state.value = emptyList() + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt index 14701ab..a76bb5c 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt @@ -235,4 +235,93 @@ class ListDetailViewModelTest { assertThat(vm.uiState.value.errorMessage).isNotNull() assertThat(vm.uiState.value.refreshMessage).isNull() } + + // --- Parent chain (breadcrumb) + child lists ----------------------------- + + private fun child(parentId: String?) = ListDetail( + summary = ListSummary("L1", "Reading", "Books", 0, null, false, null, parentId), + schema = schema, + rows = emptyList(), + ) + + @Test + fun `resolves the breadcrumb for a child list, root first`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(child(parentId = "P1")) + parentChainResult = ApiResult.Success( + listOf( + ListSummary("ROOT", "Root", null, 0, null, false, null, null), + ListSummary("P1", "Parent", null, 0, null, false, null, "ROOT"), + ), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(repo.lastParentChainId).isEqualTo("P1") + assertThat(vm.uiState.value.breadcrumb.map { it.id }).containsExactly("ROOT", "P1").inOrder() + } + + @Test + fun `does not resolve a breadcrumb for a root list`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { detailResult = ApiResult.Success(child(parentId = null)) } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(repo.parentChainCount).isEqualTo(0) + assertThat(vm.uiState.value.breadcrumb).isEmpty() + } + + @Test + fun `a failed breadcrumb leaves the list usable`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(child(parentId = "P1")) + parentChainResult = FakeListsRepository.subscriptionFailure() + } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.breadcrumb).isEmpty() + assertThat(vm.uiState.value.errorMessage).isNull() + assertThat(vm.uiState.value.summary).isNotNull() + } + + @Test + fun `createChildList creates the new list under the current one`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(child(parentId = null)) + createResult = ApiResult.Success( + ListSummary("L2", "New list", null, 0, null, false, null, "L1"), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + var opened: String? = null + vm.createChildList { opened = it } + advanceUntilIdle() + + assertThat(repo.lastCreateParentId).isEqualTo("L1") + assertThat(opened).isEqualTo("L2") + assertThat(vm.uiState.value.isSaving).isFalse() + } + + @Test + fun `createChildList surfaces a failure without navigating`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(child(parentId = null)) + createResult = FakeListsRepository.subscriptionFailure() + } + val vm = viewModel(repo) + advanceUntilIdle() + + var opened: String? = null + vm.createChildList { opened = it } + advanceUntilIdle() + + assertThat(opened).isNull() + assertThat(vm.uiState.value.errorMessage).isNotNull() + assertThat(vm.uiState.value.subscriptionRequired).isTrue() + assertThat(vm.uiState.value.isSaving).isFalse() + } }