From d384ff31860aa5ca4eae0aacd93a42d97d8d28db Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:50:36 -0700 Subject: [PATCH] =?UTF-8?q?feat(lists):=20GitHub-backed=20lists=20?= =?UTF-8?q?=E2=80=94=20repo=20picker,=20repo=20link,=20refresh,=20locked?= =?UTF-8?q?=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A list can now be created from a GitHub repository's issues, and the detail screen operates one honestly. Create: the "New list" sheet gains a Local / GitHub-backed choice. The GitHub half picks a repository (optionally scoped to an organisation via `GET /api/github/orgs` → `GET /api/github/repos?org=`) and sends `source: "github"` with `githubRepo` and `githubSource: "issues"`. The `owner/repo` form is validated client-side because the server refuses anything else with `400 bad_request` before it ever reaches GitHub. Repo link: a GitHub-backed list links `owner/repo issues` under its title. When the repository is private on GitHub the link carries a **Private repo** tag whose copy says, explicitly, that it describes the *repository* and not the list, and warns that an invited collaborator may still meet a GitHub sign-in or "not found" page. An unrecorded visibility (`githubRepoPrivate: null`) shows no tag rather than being presented as public. Visibility is re-read from each list payload, so a sync picks up a repository that changed. Refresh: `POST /api/lists/{id}/refresh` is surfaced as "Refresh from GitHub", and only on a GitHub-backed list — the endpoint 400s on a local one. Rows are issues: the schema mapper now reads `isReadOnly` (and the ListProperty `propertyKey`/`propertyName`/`validationRules` spellings the synthetic GitHub schema uses), so `Issue #`, `Link`, `Created` and `Updated` are shown but never sent. The row form names the operation a save performs, using `GET /api/github/repos/{owner}/{repo}/next-issue-number` to say which issue is about to be opened, and delete is labelled as closing the issue. Schema editor: locked to parent-only for these lists. The fixed columns render read-only with an explanation, add/remove/retype and save are refused in the ViewModel as well as hidden, and a parent-list picker (`PUT /api/lists/{id}` with `parentId`) is the one edit that remains — silently allowing a schema edit would only produce a confusing server error. Unlinked GitHub is handled as a first-class state: an unlinked account (400 "GitHub account not linked") and a refused token (401 `github_error`) each get their own explanation and a route to the existing connected-accounts screen, rather than an empty picker. OAuth linking itself (#39) is not attempted. `:feature:lists` calls `/api/github/…` through its own Retrofit interface and DTOs rather than depending on `:feature:integrations`, since no feature module in this repo depends on another. Extends #54's `CreateListRequest` rather than duplicating it: `githubRepo` and `githubSource` join the existing `parentId`/`folderId`/`messageId`/`initialRows`/ `metadata`/`source` options, all still omitted when absent. Tests: create sends `githubRepo`/`githubSource` (and a local create still sends neither); the schema editor is locked for a GitHub-backed list and unlocked for a local one; refresh updates rows and is never spent on a local list; the Private repo tag renders from `githubRepoPrivate` and its copy is pinned to name the repository, not the list. Closes #50 --- .../navigation/InterlinedListNavHost.kt | 14 + .../lists/ui/detail/GithubRepoLinkRowTest.kt | 152 ++++++++ .../feature/lists/ui/list/ListsScreenTest.kt | 2 +- .../feature/lists/ui/list/NewListSheetTest.kt | 126 +++++++ .../lists/ui/schema/SchemaEditorScreenTest.kt | 69 +++- .../lists/data/DefaultListsRepository.kt | 44 ++- .../feature/lists/data/GithubMapper.kt | 38 ++ .../feature/lists/data/GithubRepository.kt | 65 ++++ .../android/feature/lists/data/ListMapper.kt | 12 + .../feature/lists/data/ListsRepository.kt | 30 +- .../feature/lists/data/SchemaMapper.kt | 20 +- .../lists/data/local/CachedListEntity.kt | 5 + .../feature/lists/data/local/ListsDatabase.kt | 3 +- .../feature/lists/data/remote/GithubApi.kt | 42 +++ .../lists/data/remote/dto/GithubDtos.kt | 65 ++++ .../feature/lists/data/remote/dto/ListDtos.kt | 29 +- .../android/feature/lists/di/ListsModule.kt | 17 + .../feature/lists/domain/GithubRepo.kt | 87 +++++ .../feature/lists/domain/ListSchema.kt | 5 + .../feature/lists/domain/ListSource.kt | 7 + .../feature/lists/domain/ListSummary.kt | 19 +- .../lists/ui/detail/GithubRepoLinkRow.kt | 116 ++++++ .../lists/ui/detail/ListDetailScreen.kt | 51 ++- .../lists/ui/detail/ListDetailViewModel.kt | 35 ++ .../feature/lists/ui/detail/RowEditor.kt | 68 +++- .../lists/ui/github/GithubLinkProblem.kt | 68 ++++ .../feature/lists/ui/list/ListsScreen.kt | 50 ++- .../feature/lists/ui/list/ListsViewModel.kt | 15 - .../feature/lists/ui/list/NewListSheet.kt | 354 ++++++++++++++++++ .../feature/lists/ui/list/NewListViewModel.kt | 188 ++++++++++ .../lists/ui/schema/SchemaEditorScreen.kt | 154 +++++++- .../lists/ui/schema/SchemaEditorViewModel.kt | 92 ++++- .../feature/lists/FakeGithubRepository.kt | 39 ++ .../feature/lists/FakeListsRepository.kt | 48 ++- .../lists/data/DefaultGithubRepositoryTest.kt | 170 +++++++++ .../data/DefaultListsRepositoryGithubTest.kt | 260 +++++++++++++ .../lists/domain/GithubRepoLinkTest.kt | 67 ++++ .../ui/detail/ListDetailViewModelTest.kt | 117 +++++- .../lists/ui/list/ListsViewModelTest.kt | 13 - .../lists/ui/list/NewListViewModelTest.kt | 234 ++++++++++++ .../ui/schema/SchemaEditorViewModelTest.kt | 106 ++++++ 41 files changed, 3004 insertions(+), 92 deletions(-) create mode 100644 feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/GithubRepoLinkRowTest.kt create mode 100644 feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListSheetTest.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/GithubMapper.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/GithubRepository.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/GithubApi.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/GithubDtos.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/GithubRepo.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/GithubRepoLinkRow.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/github/GithubLinkProblem.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListSheet.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListViewModel.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeGithubRepository.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultGithubRepositoryTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryGithubTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/domain/GithubRepoLinkTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListViewModelTest.kt 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 89172ef..f8131f9 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -1,5 +1,7 @@ package com.interlinedlist.android.navigation +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.List @@ -344,6 +346,10 @@ private fun MainShell( onOpenConnections = { tabNav.navigate(Routes.LIST_CONNECTIONS) }, onOpenSharedWithMe = { tabNav.navigate(Routes.LISTS_SHARED_WITH_ME) }, onOpenFolders = { tabNav.navigate(Routes.LIST_FOLDERS) }, + // A GitHub-backed list needs GitHub linked with the Issues + // scope; linking is an OAuth flow this app does not drive, so + // the picker routes to the existing connected-accounts screen. + onOpenConnectedAccounts = { tabNav.navigate(Routes.INTEGRATIONS_ACCOUNTS) }, ) } composable( @@ -351,6 +357,7 @@ private fun MainShell( arguments = listOf(navArgument("listId") { type = NavType.StringType }), ) { entry -> val listId = entry.arguments?.getString("listId").orEmpty() + val context = LocalContext.current ListDetailRoute( onBack = { tabNav.popBackStack() }, onListDeleted = { tabNav.popBackStack() }, @@ -359,6 +366,13 @@ private fun MainShell( onOpenShare = { tabNav.navigate(Routes.listShare(listId)) }, // Breadcrumb hops and newly created child lists open a detail route. onOpenList = { id -> tabNav.navigate(Routes.listDetail(id)) }, + // The repository link under a GitHub-backed list's title opens + // that repo's issues page in the browser, as the web app does. + onOpenRepo = { url -> + runCatching { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + }, ) } composable( diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/GithubRepoLinkRowTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/GithubRepoLinkRowTest.kt new file mode 100644 index 0000000..429ad8d --- /dev/null +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/GithubRepoLinkRowTest.kt @@ -0,0 +1,152 @@ +package com.interlinedlist.android.feature.lists.ui.detail + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.GithubRepoLink +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 org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The repository link under a GitHub-backed list's title, and its **Private repo** + * tag — which describes the repository's visibility on GitHub, never the list's. + */ +@RunWith(AndroidJUnit4::class) +class GithubRepoLinkRowTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun setRow(isPrivate: Boolean?, onOpenRepo: (String) -> Unit = {}) { + composeRule.setContent { + InterlinedListTheme { + GithubRepoLinkRow( + repo = "octocat/Hello-World", + githubRepoPrivate = isPrivate, + onOpenRepo = onOpenRepo, + ) + } + } + } + + @Test + fun linksTheRepositoryIssuesPage() { + var opened: String? = null + setRow(isPrivate = false) { opened = it } + + composeRule.onNodeWithTag(GithubRepoLinkTestTags.LINK).assertIsDisplayed() + composeRule.onNodeWithText("octocat/Hello-World issues").assertIsDisplayed() + + composeRule.onNodeWithTag(GithubRepoLinkTestTags.LINK).performClick() + assertThat(opened).isEqualTo("https://github.com/octocat/Hello-World/issues") + } + + @Test + fun showsThePrivateRepoTag_whenTheRepositoryIsPrivate() { + setRow(isPrivate = true) + + composeRule.onNodeWithTag(GithubRepoLinkTestTags.PRIVATE_TAG).assertIsDisplayed() + composeRule.onNodeWithText(GithubRepoLink.PRIVATE_TAG).assertIsDisplayed() + } + + @Test + fun theTagExplainsTheRepositoryIsPrivate_notTheList() { + setRow(isPrivate = true) + + composeRule.onNodeWithTag(GithubRepoLinkTestTags.PRIVATE_TAG).performClick() + + composeRule.onNodeWithTag(GithubRepoLinkTestTags.PRIVATE_EXPLANATION).assertIsDisplayed() + // The copy names the repository, and says the list's visibility is separate. + val explanation = GithubRepoLink.PRIVATE_TAG_EXPLANATION + assertThat(explanation).contains("This repository is private on GitHub") + assertThat(explanation).contains("separate from who can see this list") + composeRule.onNodeWithText(explanation).assertIsDisplayed() + } + + @Test + fun showsNoTag_whenTheRepositoryIsPublic() { + setRow(isPrivate = false) + + composeRule.onNodeWithTag(GithubRepoLinkTestTags.PRIVATE_TAG).assertDoesNotExist() + } + + @Test + fun showsNoTag_whenTheRepositoryVisibilityIsNotYetKnown() { + // A list that has not synced since the tag existed: unknown is never + // presented as public, and it is never presented as private either. + setRow(isPrivate = null) + + composeRule.onNodeWithTag(GithubRepoLinkTestTags.LINK).assertIsDisplayed() + composeRule.onNodeWithTag(GithubRepoLinkTestTags.PRIVATE_TAG).assertDoesNotExist() + } + + @Test + fun rendersUnderTheTitleOfAGithubBackedListOnly() { + val githubList = ListSummary( + id = "L1", + title = "Repo issues", + description = null, + itemCount = 0, + folderId = null, + isPublic = false, + updatedAt = null, + parentId = null, + source = ListSource.GITHUB, + githubRepo = "octocat/Hello-World", + githubRepoPrivate = true, + ) + composeRule.setContent { + InterlinedListTheme { + ListDetailScreen( + state = ListDetailUiState( + summary = githubList, + schema = ListSchema.EMPTY, + isLoading = false, + ), + onBack = {}, + onAddRow = {}, + onEditRow = {}, + onDeleteRow = {}, + onDeleteList = {}, + ) + } + } + + composeRule.onNodeWithTag(GithubRepoLinkTestTags.ROW).assertIsDisplayed() + composeRule.onNodeWithTag(GithubRepoLinkTestTags.PRIVATE_TAG).assertIsDisplayed() + // Refresh from GitHub is offered here and nowhere else. + composeRule.onNodeWithTag(ListDetailTestTags.REFRESH).assertIsDisplayed() + } + + @Test + fun aLocalListShowsNoRepoLinkAndNoRefresh() { + composeRule.setContent { + InterlinedListTheme { + ListDetailScreen( + state = ListDetailUiState( + summary = ListSummary("L1", "Reading", null, 0, null, false, null), + schema = ListSchema.EMPTY, + isLoading = false, + ), + onBack = {}, + onAddRow = {}, + onEditRow = {}, + onDeleteRow = {}, + onDeleteList = {}, + ) + } + } + + composeRule.onNodeWithTag(GithubRepoLinkTestTags.ROW).assertDoesNotExist() + composeRule.onNodeWithTag(ListDetailTestTags.REFRESH).assertDoesNotExist() + } +} diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreenTest.kt index 7d82738..4be9604 100644 --- a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreenTest.kt +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreenTest.kt @@ -34,7 +34,7 @@ class ListsScreenTest { onOpenConnections = {}, onSearchQueryChange = {}, onLoadMore = {}, - onCreateList = {}, + onNewList = {}, ) } } diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListSheetTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListSheetTest.kt new file mode 100644 index 0000000..b05b6f5 --- /dev/null +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListSheetTest.kt @@ -0,0 +1,126 @@ +package com.interlinedlist.android.feature.lists.ui.list + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.GithubRepo +import com.interlinedlist.android.feature.lists.ui.github.GithubLinkProblem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The "New list" sheet, in particular the GitHub half: picking a repository, and + * what a user without GitHub linked is shown instead of a broken picker. + */ +@RunWith(AndroidJUnit4::class) +class NewListSheetTest { + + @get:Rule + val composeRule = createComposeRule() + + private val helloWorld = GithubRepo("octocat", "Hello-World") + private val secretPlans = GithubRepo("acme", "secret-plans", isPrivate = true) + + private fun setSheet( + state: NewListUiState, + onSelectRepo: (GithubRepo) -> Unit = {}, + onOpenConnectedAccounts: () -> Unit = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + NewListSheet( + state = state, + onSelectKind = {}, + onTitleChange = {}, + onPublicChange = {}, + onRepoQueryChange = {}, + onSelectRepo = onSelectRepo, + onSelectOrg = {}, + onCreate = {}, + onCancel = {}, + onOpenConnectedAccounts = onOpenConnectedAccounts, + ) + } + } + } + + @Test + fun listsRepositories_andReportsTheOneThatIsPicked() { + var picked: GithubRepo? = null + setSheet( + NewListUiState( + kind = NewListKind.GITHUB, + repos = listOf(helloWorld, secretPlans), + ), + onSelectRepo = { picked = it }, + ) + + composeRule.onNodeWithTag(NewListTestTags.repo("octocat/Hello-World")).assertIsDisplayed() + composeRule.onNodeWithTag(NewListTestTags.repo("acme/secret-plans")).performClick() + + assertThat(picked).isEqualTo(secretPlans) + } + + @Test + fun explainsAnUnlinkedAccount_andRoutesToConnectedAccounts() { + var routed = false + setSheet( + NewListUiState(kind = NewListKind.GITHUB, linkProblem = GithubLinkProblem.NOT_LINKED), + onOpenConnectedAccounts = { routed = true }, + ) + + composeRule.onNodeWithTag(NewListTestTags.LINK_PROBLEM).assertIsDisplayed() + // No picker at all, rather than an empty one. + composeRule.onNodeWithTag(NewListTestTags.REPO_SEARCH).assertDoesNotExist() + composeRule.onNodeWithTag(NewListTestTags.CREATE).assertIsNotEnabled() + + composeRule.onNodeWithTag(NewListTestTags.LINK_ACTION).performClick() + assertThat(routed).isTrue() + } + + @Test + fun explainsAnEmptyRepoList_asAnOrgApprovalProblem() { + setSheet(NewListUiState(kind = NewListKind.GITHUB, repos = emptyList())) + + composeRule.onNodeWithTag(NewListTestTags.REPO_EMPTY).assertIsDisplayed() + } + + @Test + fun theTitleFieldWaitsForARepositoryToBeChosen() { + setSheet(NewListUiState(kind = NewListKind.GITHUB, repos = listOf(helloWorld))) + + // Nothing to title until there is a repository behind the list. + composeRule.onNodeWithTag(NewListTestTags.TITLE).assertDoesNotExist() + composeRule.onNodeWithTag(NewListTestTags.CREATE).assertIsNotEnabled() + } + + @Test + fun theTitleFieldAppearsOnceARepositoryIsChosen() { + setSheet( + NewListUiState( + kind = NewListKind.GITHUB, + repos = listOf(helloWorld), + selectedRepo = helloWorld, + title = "Hello-World", + ), + ) + + composeRule.onNodeWithTag(NewListTestTags.TITLE).assertIsDisplayed() + composeRule.onNodeWithTag(NewListTestTags.VISIBILITY).assertIsDisplayed() + } + + @Test + fun aLocalListNeedsNoRepositoryPicker() { + setSheet(NewListUiState(kind = NewListKind.LOCAL, title = "Books")) + + composeRule.onNodeWithTag(NewListTestTags.TITLE).assertIsDisplayed() + composeRule.onNodeWithTag(NewListTestTags.REPO_SEARCH).assertDoesNotExist() + composeRule.onNodeWithTag(NewListTestTags.LINK_PROBLEM).assertDoesNotExist() + } +} diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreenTest.kt index 1e6e218..07ac355 100644 --- a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreenTest.kt +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreenTest.kt @@ -4,9 +4,12 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.lists.domain.FieldType +import com.interlinedlist.android.feature.lists.domain.ListSummary import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -21,7 +24,10 @@ class SchemaEditorScreenTest { @get:Rule val composeRule = createComposeRule() - private fun setScreen(state: SchemaEditorUiState) { + private fun setScreen( + state: SchemaEditorUiState, + onSelectParent: (String) -> Unit = {}, + ) { composeRule.setContent { InterlinedListTheme { SchemaEditorScreen( @@ -33,6 +39,7 @@ class SchemaEditorScreenTest { onLabelChange = { _, _ -> }, onTypeChange = { _, _ -> }, onSave = {}, + onSelectParent = onSelectParent, ) } } @@ -64,4 +71,64 @@ class SchemaEditorScreenTest { composeRule.onNodeWithTag(SchemaEditorTestTags.PROGRESS).assertIsDisplayed() } + + @Test + fun locksTheEditor_forAGithubBackedList() { + setScreen( + SchemaEditorUiState( + columns = listOf( + EditableColumn(0, "number", "Issue #", FieldType.NUMBER, readOnly = true), + EditableColumn(1, "title", "Title", FieldType.TEXT), + ), + isLoading = false, + isSchemaLocked = true, + githubRepo = "octocat/Hello-World", + parentOptions = listOf(ListSummary("L2", "Projects", null, 0, null, false, null)), + ), + ) + + // The columns are visible but there is nothing to edit or save with. + composeRule.onNodeWithTag(SchemaEditorTestTags.LOCKED_NOTICE).assertIsDisplayed() + composeRule.onNodeWithTag(SchemaEditorTestTags.column(0)).assertIsDisplayed() + composeRule.onNodeWithTag(SchemaEditorTestTags.ADD_COLUMN).assertDoesNotExist() + composeRule.onNodeWithTag(SchemaEditorTestTags.SAVE).assertDoesNotExist() + composeRule.onNodeWithTag(SchemaEditorTestTags.key(1)).assertDoesNotExist() + } + + @Test + fun offersTheParentList_asTheOnlyEditOnALockedSchema() { + var chosen: String? = null + setScreen( + SchemaEditorUiState( + columns = listOf(EditableColumn(0, "title", "Title", FieldType.TEXT)), + isLoading = false, + isSchemaLocked = true, + githubRepo = "octocat/Hello-World", + parentOptions = listOf(ListSummary("L2", "Projects", null, 0, null, false, null)), + ), + onSelectParent = { chosen = it }, + ) + + composeRule.onNodeWithTag(SchemaEditorTestTags.PARENT_PICKER).assertIsDisplayed() + composeRule.onNodeWithTag(SchemaEditorTestTags.parentOption("L2")).performClick() + + assertThat(chosen).isEqualTo("L2") + } + + @Test + fun keepsTheEditorEditable_forALocalList() { + setScreen( + SchemaEditorUiState( + columns = listOf(EditableColumn(0, "title", "Title", FieldType.TEXT)), + isLoading = false, + isSchemaLocked = false, + ), + ) + + composeRule.onNodeWithTag(SchemaEditorTestTags.ADD_COLUMN).assertIsDisplayed() + composeRule.onNodeWithTag(SchemaEditorTestTags.SAVE).assertIsDisplayed() + composeRule.onNodeWithTag(SchemaEditorTestTags.key(0)).assertIsDisplayed() + composeRule.onNodeWithTag(SchemaEditorTestTags.LOCKED_NOTICE).assertDoesNotExist() + composeRule.onNodeWithTag(SchemaEditorTestTags.PARENT_PICKER).assertDoesNotExist() + } } 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 686aed4..b1cfae4 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 @@ -23,6 +23,7 @@ import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateSchemaRequ import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateViewRequest import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateWatcherRoleRequest import com.interlinedlist.android.feature.lists.domain.Contributor +import com.interlinedlist.android.feature.lists.domain.GITHUB_SOURCE_ISSUES import com.interlinedlist.android.feature.lists.domain.ListConnection import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder @@ -42,6 +43,7 @@ import com.interlinedlist.android.feature.lists.domain.SharedListResolution 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 com.interlinedlist.android.feature.lists.domain.isValidGithubRepo import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext @@ -107,6 +109,8 @@ class DefaultListsRepository @Inject constructor( initialRows: List>?, metadata: JsonObject?, source: ListSource?, + githubRepo: String?, + githubSource: String?, ): ApiResult = withContext(dispatchers.io) { val body = CreateListRequest( title = title, @@ -119,6 +123,8 @@ class DefaultListsRepository @Inject constructor( initialRows = initialRows?.map { JsonObject(it.toJsonData()) }, metadata = metadata, source = source?.wire, + githubRepo = githubRepo, + githubSource = githubSource, ) when (val result = safeApiCall(json) { api.createList(body) }) { is ApiResult.Success -> { @@ -128,6 +134,8 @@ class DefaultListsRepository @Inject constructor( id = "", title = title, description = description, itemCount = 0, folderId = folderId, isPublic = isPublic, updatedAt = null, parentId = parentId, + source = source ?: ListSource.LOCAL, + githubRepo = githubRepo, ), ) val summary = ListMapper.summaryFromDto(dto) @@ -138,6 +146,30 @@ class DefaultListsRepository @Inject constructor( } } + override suspend fun createGithubList( + repo: String, + title: String, + isPublic: Boolean, + parentId: String?, + ): ApiResult { + val trimmed = repo.trim() + // The server's own rule, checked before spending a request: + // `githubRepo is required for GitHub-backed lists (format: owner/repo)`. + if (!isValidGithubRepo(trimmed)) { + return ApiResult.Failure( + AppError.Unknown("Pick a repository in owner/repo form."), + ) + } + return createList( + title = title.trim().ifBlank { trimmed.substringAfter('/') }, + isPublic = isPublic, + parentId = parentId, + source = ListSource.GITHUB, + githubRepo = trimmed, + githubSource = GITHUB_SOURCE_ISSUES, + ) + } + override suspend fun createListFromMessage( messageId: String, title: String, @@ -183,12 +215,14 @@ class DefaultListsRepository @Inject constructor( description: String?, isPublic: Boolean?, folderId: String?, + parentId: String?, ): ApiResult = withContext(dispatchers.io) { val body = UpdateListRequest( title = title, description = description, isPublic = isPublic, folderId = folderId, + parentId = parentId, ) when (val result = safeApiCall(json) { api.updateList(id, body) }) { is ApiResult.Success -> { @@ -229,10 +263,16 @@ class DefaultListsRepository @Inject constructor( } // 2) Schema (dynamic DSL). Prefer the dedicated endpoint; fall back to - // any schema inlined on the list payload. + // any schema inlined on the list payload. A GitHub-backed list has + // no stored schema — its fixed issue columns arrive inlined under + // `properties` — so an empty answer falls back too, not just a + // failed one. + val inlined = listDto.schema ?: listDto.properties val schema: ListSchema = when (val schemaResult = safeApiCall(json) { api.getSchema(id) }) { is ApiResult.Success -> SchemaMapper.fromJson(schemaResult.data) - is ApiResult.Failure -> SchemaMapper.fromJson(listDto.schema) + .takeIf { !it.isEmpty } + ?: SchemaMapper.fromJson(inlined) + is ApiResult.Failure -> SchemaMapper.fromJson(inlined) } // 3) First page of rows. diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/GithubMapper.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/GithubMapper.kt new file mode 100644 index 0000000..3f87034 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/GithubMapper.kt @@ -0,0 +1,38 @@ +package com.interlinedlist.android.feature.lists.data + +import com.interlinedlist.android.feature.lists.data.remote.dto.GithubOrgDto +import com.interlinedlist.android.feature.lists.data.remote.dto.GithubRepoDto +import com.interlinedlist.android.feature.lists.domain.GithubOrg +import com.interlinedlist.android.feature.lists.domain.GithubRepo + +/** + * Maps the lenient GitHub proxy DTOs to domain models. A repository that cannot + * yield an owner **and** a name is dropped rather than shown half-populated: the + * picker's whole output is an `owner/repo` string, and one that is missing a half + * would be rejected by `POST /api/lists`. + */ +object GithubMapper { + + fun repoOrNull(dto: GithubRepoDto): GithubRepo? { + val owner = dto.owner?.login?.nonBlank() + ?: dto.ownerLogin?.nonBlank() + ?: dto.fullName?.substringBefore('/', missingDelimiterValue = "")?.nonBlank() + val name = dto.name?.nonBlank() + ?: dto.fullName?.substringAfter('/', missingDelimiterValue = "")?.nonBlank() + if (owner == null || name == null) return null + return GithubRepo( + owner = owner, + name = name, + isPrivate = dto.private ?: false, + description = dto.description?.nonBlank(), + ) + } + + /** An org without a login cannot be used as `?org=`, so it is dropped. */ + fun orgOrNull(dto: GithubOrgDto): GithubOrg? { + val login = dto.login?.nonBlank() ?: dto.slug?.nonBlank() ?: dto.name?.nonBlank() ?: return null + return GithubOrg(login = login, avatarUrl = dto.avatarUrl?.nonBlank()) + } + + private fun String.nonBlank(): String? = takeIf { it.isNotBlank() } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/GithubRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/GithubRepository.kt new file mode 100644 index 0000000..0a2e4b9 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/GithubRepository.kt @@ -0,0 +1,65 @@ +package com.interlinedlist.android.feature.lists.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.map +import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.lists.data.remote.GithubApi +import com.interlinedlist.android.feature.lists.domain.GithubOrg +import com.interlinedlist.android.feature.lists.domain.GithubRepo +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject + +/** + * Read access to the user's GitHub account, only as far as creating and running a + * GitHub-backed list needs it. + * + * Kept separate from [ListsRepository] because it speaks to a different API + * family (`/api/github/…`, a proxy over GitHub) with its own failure modes — an + * unlinked account, a token without the Issues scope, an org that has not + * approved the OAuth app — none of which mean anything to the Lists endpoints. + */ +interface GithubRepository { + + /** Organisations the linked account belongs to; scopes [getRepos]. */ + suspend fun getOrgs(): ApiResult> + + /** Repositories the linked account can reach, optionally one org's worth. */ + suspend fun getRepos(org: String? = null): ApiResult> + + /** + * The number the next issue opened on [repo] will get, so the row form can + * tell the user which issue they are about to create. [repo] is `"owner/name"`. + * Null when the server answered without a number. + */ + suspend fun getNextIssueNumber(repo: String): ApiResult +} + +class DefaultGithubRepository @Inject constructor( + private val api: GithubApi, + private val json: Json, + private val dispatchers: DispatcherProvider, +) : GithubRepository { + + override suspend fun getOrgs(): ApiResult> = withContext(dispatchers.io) { + safeApiCall(json) { api.getOrgs() } + .map { orgs -> orgs.mapNotNull(GithubMapper::orgOrNull) } + } + + override suspend fun getRepos(org: String?): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getRepos(org?.takeIf { it.isNotBlank() }) } + .map { repos -> repos.mapNotNull(GithubMapper::repoOrNull) } + } + + override suspend fun getNextIssueNumber(repo: String): ApiResult = + withContext(dispatchers.io) { + val owner = repo.substringBefore('/', missingDelimiterValue = "") + val name = repo.substringAfter('/', missingDelimiterValue = "") + if (owner.isBlank() || name.isBlank()) { + return@withContext ApiResult.Success(null) + } + safeApiCall(json) { api.getNextIssueNumber(owner, name) }.map { it.resolved } + } +} 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 5c71757..faec6ea 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 @@ -4,6 +4,7 @@ import com.interlinedlist.android.feature.lists.data.local.CachedListEntity import com.interlinedlist.android.feature.lists.data.remote.dto.FolderDto import com.interlinedlist.android.feature.lists.data.remote.dto.ListDto import com.interlinedlist.android.feature.lists.domain.ListFolder +import com.interlinedlist.android.feature.lists.domain.ListSource import com.interlinedlist.android.feature.lists.domain.ListSummary /** DTO/entity ↔ domain mapping for list summaries and folders. */ @@ -22,6 +23,11 @@ object ListMapper { isPublic = dto.isPublic, updatedAt = dto.updatedAt, parentId = dto.parentId ?: dto.parent?.id, + source = ListSource.fromWire(dto.source), + githubRepo = dto.githubRepo?.takeIf { it.isNotBlank() }, + // Left null when the server has not recorded it: an unknown repository + // visibility must never be rendered as "public". + githubRepoPrivate = dto.githubRepoPrivate, ) fun summaryToEntity(summary: ListSummary): CachedListEntity = CachedListEntity( @@ -33,6 +39,9 @@ object ListMapper { isPublic = summary.isPublic, updatedAt = summary.updatedAt, parentId = summary.parentId, + source = summary.source.wire, + githubRepo = summary.githubRepo, + githubRepoPrivate = summary.githubRepoPrivate, ) fun summaryFromEntity(entity: CachedListEntity): ListSummary = ListSummary( @@ -44,6 +53,9 @@ object ListMapper { isPublic = entity.isPublic, updatedAt = entity.updatedAt, parentId = entity.parentId, + source = ListSource.fromWire(entity.source), + githubRepo = entity.githubRepo, + githubRepoPrivate = entity.githubRepoPrivate, ) 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 89678b6..1db045b 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 @@ -56,6 +56,9 @@ interface ListsRepository { * 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). + * - [githubRepo] (`"owner/repo"`) and [githubSource] configure a + * [ListSource.GITHUB] list; see [createGithubList], which is what callers + * should reach for. */ suspend fun createList( title: String, @@ -67,6 +70,23 @@ interface ListsRepository { initialRows: List>? = null, metadata: JsonObject? = null, source: ListSource? = null, + githubRepo: String? = null, + githubSource: String? = null, + ): ApiResult + + /** + * Creates a list backed by a GitHub repository's issues. + * + * Sends `source: "github"` together with [repo] as `githubRepo` and + * `githubSource: "issues"`; the server rejects a GitHub source without a + * well-formed `owner/repo` (`400 bad_request`), so [repo] is validated here + * before the request is spent. + */ + suspend fun createGithubList( + repo: String, + title: String, + isPublic: Boolean = false, + parentId: String? = null, ): ApiResult /** @@ -93,9 +113,12 @@ interface ListsRepository { suspend fun getParentChain(parentId: String): ApiResult> /** - * Updates a list's metadata (title/description/visibility/folder). Only the - * supplied fields change; the returned summary reflects the server's echo and - * the cache is updated to match. + * Updates a list's metadata (title/description/visibility/folder/parent). + * Only the supplied fields change; the returned summary reflects the server's + * echo and the cache is updated to match. + * + * [parentId] is the one editable property of a GitHub-backed list, whose + * columns are fixed by GitHub. */ suspend fun updateList( id: String, @@ -103,6 +126,7 @@ interface ListsRepository { description: String? = null, isPublic: Boolean? = null, folderId: String? = null, + parentId: String? = null, ): ApiResult /** Deletes a list and evicts it from the cache. */ diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapper.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapper.kt index e7a0191..1621adc 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapper.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapper.kt @@ -110,19 +110,31 @@ object SchemaMapper { val obj = value as? JsonObject ?: return null val key = keyHint ?: obj.string("key") + ?: obj.string("propertyKey") ?: obj.string("name") ?: obj.string("id") ?: return null val label = obj.string("label") + ?: obj.string("propertyName") ?: obj.string("title") ?: obj.string("name")?.takeIf { keyHint != null } ?: humanize(key) - val type = FieldType.fromDsl(obj.string("type") ?: obj.string("fieldType")) - val required = obj["required"]?.let { (it as? JsonPrimitive)?.booleanOrNull } ?: false + val type = FieldType.fromDsl( + obj.string("type") ?: obj.string("propertyType") ?: obj.string("fieldType"), + ) + val required = obj.boolean("required") ?: obj.boolean("isRequired") ?: false + // GitHub-backed lists send `isReadOnly` on their synthetic columns + // (`number`, `url`, `created_at`, `updated_at`); the row endpoints reject + // writes to them, so the flag has to survive into the schema. + val readOnly = obj.boolean("isReadOnly") ?: obj.boolean("readOnly") ?: false val options = (obj["options"] as? JsonArray) ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + // A select's allowed values may arrive nested under `validationRules`. + ?: (obj["validationRules"] as? JsonObject) + ?.let { it["options"] as? JsonArray } + ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } ?: emptyList() return SchemaField( @@ -131,12 +143,16 @@ object SchemaMapper { type = type, required = required, options = options, + readOnly = readOnly, ) } private fun JsonObject.string(name: String): String? = (this[name] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } + private fun JsonObject.boolean(name: String): Boolean? = + (this[name] as? JsonPrimitive)?.booleanOrNull + /** Turns a raw key like `first_name`/`firstName` into a readable `First Name`. */ private fun humanize(key: String): String { val spaced = key 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 3621805..0abff17 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 @@ -18,4 +18,9 @@ data class CachedListEntity( val updatedAt: String?, /** Parent list id — kept so a cached list still knows where it sits in the tree. */ val parentId: String? = null, + /** `ListSource.wire` — cached so an offline index still marks GitHub lists. */ + val source: String? = null, + val githubRepo: String? = null, + /** Repository visibility on GitHub; null while unknown. */ + val githubRepoPrivate: Boolean? = 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 d6be1cd..500d7c3 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 @@ -11,7 +11,8 @@ import androidx.room.RoomDatabase @Database( entities = [CachedListEntity::class], // v2 adds CachedListEntity.parentId (list tree / breadcrumb). - version = 2, + // v3 adds source/githubRepo/githubRepoPrivate (GitHub-backed lists). + version = 3, exportSchema = false, ) abstract class ListsDatabase : RoomDatabase() { diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/GithubApi.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/GithubApi.kt new file mode 100644 index 0000000..71e2366 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/GithubApi.kt @@ -0,0 +1,42 @@ +package com.interlinedlist.android.feature.lists.data.remote + +import com.interlinedlist.android.feature.lists.data.remote.dto.GithubOrgDto +import com.interlinedlist.android.feature.lists.data.remote.dto.GithubRepoDto +import com.interlinedlist.android.feature.lists.data.remote.dto.NextIssueNumberDto +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * The slice of the InterlinedList GitHub proxy that GitHub-backed lists need. + * + * Only the read endpoints are here. The issue *writes* — create on add, update on + * edit, close on delete — are performed by the server behind + * `POST`/`PUT`/`DELETE /api/lists/{id}/data`, so the client maps a row edit to an + * issue operation by writing the row, not by calling GitHub itself. + * + * With GitHub unlinked these answer `400 { "error": "GitHub account not linked" }`; + * with a linked-but-unauthorised token they answer `401 { "code": "github_error" }`. + * Both are translated for the UI by + * [com.interlinedlist.android.feature.lists.ui.github.toGithubLinkProblem]. + */ +interface GithubApi { + + /** + * Repositories the linked account can reach, across all affiliations. + * [org] restricts the result to one organisation's repositories. + */ + @GET("api/github/repos") + suspend fun getRepos(@Query("org") org: String? = null): List + + /** Organisations the linked account belongs to — the repo picker's filter. */ + @GET("api/github/orgs") + suspend fun getOrgs(): List + + /** `max(issue number) + 1` for a repository, pull requests excluded. */ + @GET("api/github/repos/{owner}/{repo}/next-issue-number") + suspend fun getNextIssueNumber( + @Path("owner") owner: String, + @Path("repo") repo: String, + ): NextIssueNumberDto +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/GithubDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/GithubDtos.kt new file mode 100644 index 0000000..07a3098 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/GithubDtos.kt @@ -0,0 +1,65 @@ +package com.interlinedlist.android.feature.lists.data.remote.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Wire models for the `/api/github/…` proxy, as far as GitHub-backed lists need + * it: picking a repository to create a list from, scoping that picker to an + * organisation, and reading the number a newly created issue will get. + * + * These live here rather than being shared with `:feature:integrations` because + * every feature module in this repo owns its own DTOs and Retrofit interface and + * no feature module depends on another. + * + * The endpoints are thin proxies over the GitHub REST API and the OpenAPI spec + * types their bodies as a bare `object`, so every field is optional and both the + * nested GitHub shapes (`owner: { login }`) and any flattened variant are + * accepted. `GET /api/github/repos` and `GET /api/github/orgs` were confirmed + * live to return a **bare array**, not an envelope. + */ + +/** + * A repository. `full_name` ("owner/name") is used to recover the owner/name pair + * when `owner` is absent. + */ +@Serializable +data class GithubRepoDto( + val name: String? = null, + @SerialName("full_name") val fullName: String? = null, + val owner: GithubOwnerDto? = null, + @SerialName("ownerLogin") val ownerLogin: String? = null, + val private: Boolean? = null, + val description: String? = null, +) + +@Serializable +data class GithubOwnerDto( + val login: String? = null, +) + +/** + * An organisation. GitHub names it `login`; `name`/`slug` are accepted as + * fallbacks in case the proxy reshapes it. + */ +@Serializable +data class GithubOrgDto( + val login: String? = null, + val name: String? = null, + val slug: String? = null, + @SerialName("avatar_url") val avatarUrl: String? = null, +) + +/** + * `GET /api/github/repos/{owner}/{repo}/next-issue-number` → + * `{ "nextNumber": 11231 }` (confirmed live). `nextIssueNumber`/`number` are + * accepted as fallbacks since the shape is unmodelled in the spec. + */ +@Serializable +data class NextIssueNumberDto( + val nextNumber: Int? = null, + val nextIssueNumber: Int? = null, + val number: Int? = null, +) { + val resolved: Int? get() = nextNumber ?: nextIssueNumber ?: number +} 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 15a293c..832663a 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 @@ -48,8 +48,22 @@ data class ListDto( val metadata: JsonElement? = null, /** Where the rows come from — `"local"`, `"github"`, … (see `ListSource`). */ val source: String? = null, + /** `"owner/repo"` backing a GitHub list; null on a local one. */ + val githubRepo: String? = null, + /** + * Whether the backing **repository** is private on GitHub. Re-read on every + * sync, and absent (null) until a list has synced at least once — which is + * why it stays nullable rather than defaulting to `false`. + */ + val githubRepoPrivate: Boolean? = null, // Detail responses may inline the schema; the mapper handles either shape. val schema: JsonElement? = null, + /** + * Column definitions inlined on a detail response. A GitHub-backed list has + * no stored schema — `GET /api/lists/{id}` returns its fixed nine issue + * columns here — so this is the only place that list's schema can be read. + */ + val properties: JsonElement? = null, ) /** Pagination block shared by list endpoints. */ @@ -100,6 +114,11 @@ data class ListEnvelope( * 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]). + * - [githubRepo] (`"owner/repo"`) is **required** when [source] is `"github"`: the + * server answers `400 githubRepo is required for GitHub-backed lists (format: + * owner/repo)` without it. [githubSource] names which part of the repository the + * rows mirror and is optional; issues are the documented mapping + * ([com.interlinedlist.android.feature.lists.domain.GITHUB_SOURCE_ISSUES]). * - [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 @@ -117,13 +136,21 @@ data class CreateListRequest( val initialRows: List? = null, val metadata: JsonObject? = null, val source: String? = null, + val githubRepo: String? = null, + val githubSource: String? = null, ) -/** Body for `PUT /api/lists/{id}` — partial metadata updates. */ +/** + * Body for `PUT /api/lists/{id}` — partial metadata updates. + * + * [parentId] re-parents the list. It is the one thing a GitHub-backed list's + * schema editor may change, since those lists' columns are fixed by GitHub. + */ @Serializable data class UpdateListRequest( val title: String? = null, val description: String? = null, val folderId: String? = null, val isPublic: Boolean? = null, + val parentId: String? = null, ) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/di/ListsModule.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/di/ListsModule.kt index 0fa1b69..b33acc1 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/di/ListsModule.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/di/ListsModule.kt @@ -4,10 +4,13 @@ import android.content.Context import androidx.room.Room import com.interlinedlist.android.core.datastore.SessionStore import com.interlinedlist.android.feature.lists.data.CurrentUserIdProvider +import com.interlinedlist.android.feature.lists.data.DefaultGithubRepository import com.interlinedlist.android.feature.lists.data.DefaultListsRepository +import com.interlinedlist.android.feature.lists.data.GithubRepository import com.interlinedlist.android.feature.lists.data.ListsRepository import com.interlinedlist.android.feature.lists.data.local.ListDao import com.interlinedlist.android.feature.lists.data.local.ListsDatabase +import com.interlinedlist.android.feature.lists.data.remote.GithubApi import com.interlinedlist.android.feature.lists.data.remote.ListsApi import dagger.Binds import dagger.Module @@ -26,6 +29,15 @@ abstract class ListsRepositoryModule { @Binds @Singleton abstract fun bindListsRepository(impl: DefaultListsRepository): ListsRepository + + /** + * The GitHub proxy this module calls directly. `:feature:integrations` has its + * own GitHub surface, but no feature module depends on another, so the repo + * picker reaches `/api/github/…` from here rather than across a module edge. + */ + @Binds + @Singleton + abstract fun bindGithubRepository(impl: DefaultGithubRepository): GithubRepository } /** Provides this module's Retrofit API and its own Room database + DAO. */ @@ -38,6 +50,11 @@ object ListsDataModule { fun provideListsApi(retrofit: Retrofit): ListsApi = retrofit.create(ListsApi::class.java) + @Provides + @Singleton + fun provideGithubApi(retrofit: Retrofit): GithubApi = + retrofit.create(GithubApi::class.java) + @Provides @Singleton fun provideListsDatabase(@ApplicationContext context: Context): ListsDatabase = diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/GithubRepo.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/GithubRepo.kt new file mode 100644 index 0000000..0cf03de --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/GithubRepo.kt @@ -0,0 +1,87 @@ +package com.interlinedlist.android.feature.lists.domain + +/** + * A GitHub repository the linked account can reach, as returned by + * `GET /api/github/repos` (optionally scoped with `?org=`). + * + * [isPrivate] is the repository's visibility **on GitHub** and has nothing to do + * with whether an InterlinedList list is public — see [GithubRepoLink] for the + * copy that keeps the two apart. + */ +data class GithubRepo( + val owner: String, + val name: String, + val isPrivate: Boolean = false, + val description: String? = null, +) { + /** `"owner/name"` — the form `githubRepo` on `POST /api/lists` expects. */ + val fullName: String get() = "$owner/$name" +} + +/** + * An organisation the linked GitHub account belongs to + * (`GET /api/github/orgs`). Used to scope the repo picker: an account with many + * repositories is far easier to search one org at a time, and `?org=` is the only + * server-side filter the proxy offers. + */ +data class GithubOrg( + val login: String, + val avatarUrl: String? = null, +) + +/** + * The repository link shown under a GitHub-backed list's title, and the copy that + * goes with it. + * + * The wording here is deliberate. The **Private repo** tag describes the + * *repository's* visibility on GitHub, not the list's — the two are set + * separately, and somebody invited to the list may well have no access to the + * repository. Saying "private list" here would tell people the opposite of the + * truth about who can see their data, so the copy names the repository + * explicitly and warns what GitHub will show a visitor who lacks access. + */ +object GithubRepoLink { + + /** The tag shown beside the link when the repository is private on GitHub. */ + const val PRIVATE_TAG: String = "Private repo" + + /** + * Why the tag is there. Names the repository (not the list) as the private + * thing, and says what a collaborator without repo access will actually hit. + */ + const val PRIVATE_TAG_EXPLANATION: String = + "This repository is private on GitHub. That is separate from who can see this list: " + + "someone you invite to the list may still be asked to sign in, or see a " + + "\"not found\" page, when they open the repository. Repository access is granted " + + "on GitHub, not in InterlinedList." + + /** Label for the link itself, e.g. `"octocat/Hello-World issues"`. */ + fun label(repo: String): String = "$repo issues" + + /** The repository's issues page, which the link opens on GitHub. */ + fun issuesUrl(repo: String): String = "https://github.com/$repo/issues" + + /** + * Whether a repository is known to be private. `githubRepoPrivate` is only + * recorded from the first sync onward, so an **unknown** (null) visibility + * shows no tag at all rather than being presented as public. + */ + fun showsPrivateTag(githubRepoPrivate: Boolean?): Boolean = githubRepoPrivate == true +} + +/** + * The `githubSource` sent on `POST /api/lists`. A GitHub-backed list mirrors a + * repository's **issues** — that is the only mapping the API documents (rows are + * issues; adding a row opens one, deleting closes it). + */ +const val GITHUB_SOURCE_ISSUES: String = "issues" + +/** + * Whether [repo] is in the `owner/repo` form the API requires. The server rejects + * anything else with `400 githubRepo is required for GitHub-backed lists (format: + * owner/repo)`, so the picker checks before spending a request. + */ +fun isValidGithubRepo(repo: String): Boolean { + val parts = repo.trim().split('/') + return parts.size == 2 && parts.all { it.isNotBlank() && !it.contains(' ') } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSchema.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSchema.kt index ecbe23a..c9a9402 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSchema.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSchema.kt @@ -24,6 +24,10 @@ data class ListSchema( * @param type controls rendering and the form input used to edit the value. * @param required whether the add/edit form should treat the field as mandatory. * @param options selectable values for [FieldType.SELECT] fields. + * @param readOnly the server assigns this column's value and rejects writes to + * it. GitHub-backed lists mark `number`, `url`, `created_at` and `updated_at` + * this way (`isReadOnly` on the synthetic schema), so the row form must show + * them without offering an input and must not send them back. */ data class SchemaField( val key: String, @@ -31,6 +35,7 @@ data class SchemaField( val type: FieldType, val required: Boolean = false, val options: List = emptyList(), + val readOnly: Boolean = false, ) /** 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 index e115d6e..c155842 100644 --- 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 @@ -14,4 +14,11 @@ enum class ListSource(val wire: String) { /** A list mirrored from a GitHub repository. */ GITHUB("github"), + ; + + companion object { + /** Maps the API's `source` string to a [ListSource], defaulting to [LOCAL]. */ + fun fromWire(raw: String?): ListSource = + entries.firstOrNull { it.wire.equals(raw?.trim(), ignoreCase = true) } ?: LOCAL + } } 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 1e878a3..8762737 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 @@ -19,4 +19,21 @@ data class ListSummary( * ([com.interlinedlist.android.feature.lists.data.ListsRepository.getParentChain]). */ val parentId: String? = null, -) + /** Where the rows come from; defaults to [ListSource.LOCAL] as the API does. */ + val source: ListSource = ListSource.LOCAL, + /** `"owner/repo"` for a GitHub-backed list, else null. */ + val githubRepo: String? = null, + /** + * Whether the backing **repository** is private on GitHub — not whether this + * list is private, which is [isPublic]. Null until the list's first sync + * records it; an unknown visibility is never presented as public. + */ + val githubRepoPrivate: Boolean? = null, +) { + /** + * True when this list mirrors a GitHub repository's issues. Such a list has a + * fixed schema and its rows are issues, so the UI locks the schema editor and + * offers "Refresh from GitHub" instead of ordinary column editing. + */ + val isGithubBacked: Boolean get() = source == ListSource.GITHUB && !githubRepo.isNullOrBlank() +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/GithubRepoLinkRow.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/GithubRepoLinkRow.kt new file mode 100644 index 0000000..d17b3d4 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/GithubRepoLinkRow.kt @@ -0,0 +1,116 @@ +package com.interlinedlist.android.feature.lists.ui.detail + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.interlinedlist.android.feature.lists.domain.GithubRepoLink + +/** Stable test tags for the repository link under a GitHub-backed list's title. */ +object GithubRepoLinkTestTags { + const val ROW = "listGithubRepoLink" + const val LINK = "listGithubRepoLinkText" + const val PRIVATE_TAG = "listGithubRepoPrivateTag" + const val PRIVATE_EXPLANATION = "listGithubRepoPrivateExplanation" +} + +/** + * The repository a GitHub-backed list came from, linked under the list title — + * `owner/repo issues`, opening that repository's issues page on GitHub. + * + * When [githubRepoPrivate] is true the link carries a **Private repo** tag. + * That tag is about the *repository's* visibility on GitHub, never the list's: + * the two are set separately, so someone invited to this list may have no access + * to the repository and will be met by a GitHub sign-in or "not found" page. The + * explanation is one tap away rather than hidden, because getting this backwards + * is how people end up wrong about who can see their data. + * + * A null [githubRepoPrivate] means the visibility has not been recorded yet (a + * list that has not synced since the tag existed), so no tag is shown — an + * unknown visibility is never presented as public. + */ +@Composable +fun GithubRepoLinkRow( + repo: String, + githubRepoPrivate: Boolean?, + onOpenRepo: (String) -> Unit, + modifier: Modifier = Modifier, +) { + if (repo.isBlank()) return + var explanationShown by remember(repo) { mutableStateOf(false) } + val isPrivate = GithubRepoLink.showsPrivateTag(githubRepoPrivate) + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .testTag(GithubRepoLinkTestTags.ROW), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = GithubRepoLink.label(repo), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .clickable { onOpenRepo(GithubRepoLink.issuesUrl(repo)) } + .testTag(GithubRepoLinkTestTags.LINK), + ) + if (isPrivate) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .clickable { explanationShown = !explanationShown } + .testTag(GithubRepoLinkTestTags.PRIVATE_TAG), + ) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = GithubRepoLink.PRIVATE_TAG, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + if (isPrivate && explanationShown) { + Text( + text = GithubRepoLink.PRIVATE_TAG_EXPLANATION, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(top = 4.dp) + .testTag(GithubRepoLinkTestTags.PRIVATE_EXPLANATION), + ) + } + } +} 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 4e28bd8..1bb46bf 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 @@ -97,6 +97,7 @@ fun ListDetailRoute( modifier: Modifier = Modifier, onOpenShare: () -> Unit = {}, onOpenList: (String) -> Unit = {}, + onOpenRepo: (String) -> Unit = {}, viewModel: ListDetailViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -130,6 +131,7 @@ fun ListDetailRoute( onOpenWatchers = onOpenWatchers, onOpenShare = onOpenShare, onOpenList = onOpenList, + onOpenRepo = onOpenRepo, onNewChildList = { viewModel.createChildList(onOpenList) }, snackbarHostState = snackbarHostState, // Saved views have their own ViewModel on the same nav entry, so the @@ -149,6 +151,8 @@ fun ListDetailRoute( schema = state.schema, row = liveRow, isSaving = state.isSaving, + githubRepo = state.summary?.takeIf { it.isGithubBacked }?.githubRepo, + nextIssueNumber = state.nextIssueNumber, onSave = { values -> when (target) { EditorTarget.New -> viewModel.addRow(values) { editing = null } @@ -205,6 +209,7 @@ fun ListDetailScreen( onOpenWatchers: () -> Unit = {}, onOpenShare: () -> Unit = {}, onOpenList: (String) -> Unit = {}, + onOpenRepo: (String) -> Unit = {}, onNewChildList: () -> Unit = {}, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, viewSwitcher: @Composable () -> Unit = {}, @@ -222,16 +227,23 @@ fun ListDetailScreen( } }, actions = { - if (state.isRefreshing) { - CircularProgressIndicator( - Modifier - .padding(horizontal = 12.dp) - .height(20.dp) - .width(20.dp), - ) - } else { - IconButton(onClick = onRefresh, modifier = Modifier.testTag(ListDetailTestTags.REFRESH)) { - Icon(Icons.Default.Refresh, contentDescription = "Refresh from source") + // `POST /api/lists/{id}/refresh` only means anything for a + // GitHub-backed list, so the action appears only there. + if (state.isGithubBacked) { + if (state.isRefreshing) { + CircularProgressIndicator( + Modifier + .padding(horizontal = 12.dp) + .height(20.dp) + .width(20.dp), + ) + } else { + IconButton( + onClick = onRefresh, + modifier = Modifier.testTag(ListDetailTestTags.REFRESH), + ) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh from GitHub") + } } } IconButton( @@ -245,7 +257,9 @@ fun ListDetailScreen( modifier = Modifier.testTag(ListDetailTestTags.EDIT_LIST), ) DropdownMenuItem( - text = { Text("Edit columns") }, + // A GitHub-backed list's columns are fixed by GitHub; + // the same screen then offers the parent list only. + text = { Text(if (state.isGithubBacked) "Columns & parent" else "Edit columns") }, onClick = { menuOpen = false; onEditSchema() }, modifier = Modifier.testTag(ListDetailTestTags.EDIT_SCHEMA), ) @@ -299,6 +313,13 @@ fun ListDetailScreen( else -> Column(Modifier.padding(padding)) { Breadcrumb(ancestors = state.breadcrumb, onOpenList = onOpenList) + state.summary?.takeIf { it.isGithubBacked }?.let { summary -> + GithubRepoLinkRow( + repo = summary.githubRepo.orEmpty(), + githubRepoPrivate = summary.githubRepoPrivate, + onOpenRepo = onOpenRepo, + ) + } viewSwitcher() if (!state.summary?.description.isNullOrBlank()) { Text( @@ -314,6 +335,9 @@ fun ListDetailScreen( isEmpty = state.isEmpty, onEditRow = onEditRow, onDeleteRow = onDeleteRow, + // On a GitHub-backed list a delete closes the issue rather + // than removing anything, so the affordance says so. + deleteLabel = if (state.isGithubBacked) "Close issue on GitHub" else "Delete row", ) } } @@ -375,6 +399,7 @@ private fun SchemaTable( isEmpty: Boolean, onEditRow: (ListRow) -> Unit, onDeleteRow: (String) -> Unit, + deleteLabel: String = "Delete row", ) { if (isEmpty) { Centered(Modifier.testTag(ListDetailTestTags.EMPTY)) { @@ -408,6 +433,7 @@ private fun SchemaTable( row = row, onClick = { onEditRow(row) }, onDelete = { onDeleteRow(row.id) }, + deleteLabel = deleteLabel, ) } } @@ -420,6 +446,7 @@ private fun RowCard( row: ListRow, onClick: () -> Unit, onDelete: () -> Unit, + deleteLabel: String = "Delete row", ) { Card( onClick = onClick, @@ -438,7 +465,7 @@ private fun RowCard( } } IconButton(onClick = onDelete) { - Icon(Icons.Default.Delete, contentDescription = "Delete row") + Icon(Icons.Default.Delete, contentDescription = deleteLabel) } } } 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 23fdde1..a054794 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 @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.data.GithubRepository import com.interlinedlist.android.feature.lists.data.ListsRepository import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema @@ -33,9 +34,17 @@ data class ListDetailUiState( val isEditingMetadata: Boolean = false, /** Ancestors of this list, root first — empty for a root list. */ val breadcrumb: List = emptyList(), + /** + * The issue number a newly added row will get, for a GitHub-backed list. + * Null when unknown or not applicable — the row form simply omits the hint. + */ + val nextIssueNumber: Int? = null, ) { val title: String get() = summary?.title.orEmpty() val isEmpty: Boolean get() = rows.isEmpty() && !isLoading && errorMessage == null + + /** True when this list mirrors a GitHub repository's issues. */ + val isGithubBacked: Boolean get() = summary?.isGithubBacked == true } /** The nav argument key the detail route reads its list id from. */ @@ -44,6 +53,7 @@ const val LIST_ID_ARG = "listId" @HiltViewModel class ListDetailViewModel @Inject constructor( private val repository: ListsRepository, + private val githubRepository: GithubRepository, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -72,6 +82,7 @@ class ListDetailViewModel @Inject constructor( ) } loadBreadcrumb(result.data.summary) + loadNextIssueNumber(result.data.summary) } is ApiResult.Failure -> _uiState.update { it.copy( @@ -155,6 +166,8 @@ class ListDetailViewModel @Inject constructor( */ fun refreshFromGithub() { if (_uiState.value.isRefreshing) return + // The endpoint 400s on a local list; do not spend the request. + if (!_uiState.value.isGithubBacked) return _uiState.update { it.copy(isRefreshing = true, errorMessage = null, refreshMessage = null) } viewModelScope.launch { when (val result = repository.refreshGithubList(listId)) { @@ -190,12 +203,34 @@ class ListDetailViewModel @Inject constructor( ) } loadBreadcrumb(result.data.summary) + loadNextIssueNumber(result.data.summary) } is ApiResult.Failure -> Unit // Keep the existing rows; refresh already succeeded. } } } + /** + * For a GitHub-backed list, asks GitHub which number the next issue will take + * so the add-row form can name the issue the user is about to open. + * + * Purely informational: a failure (or a repo the token cannot see) just leaves + * the hint off rather than blocking a row the server would accept anyway. + */ + private fun loadNextIssueNumber(summary: ListSummary) { + val repo = summary.githubRepo?.takeIf { summary.isGithubBacked } + if (repo == null) { + _uiState.update { it.copy(nextIssueNumber = null) } + return + } + viewModelScope.launch { + when (val result = githubRepository.getNextIssueNumber(repo)) { + is ApiResult.Success -> _uiState.update { it.copy(nextIssueNumber = result.data) } + is ApiResult.Failure -> _uiState.update { it.copy(nextIssueNumber = null) } + } + } + } + /** * 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 diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/RowEditor.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/RowEditor.kt index 7007444..9189dfb 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/RowEditor.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/RowEditor.kt @@ -33,7 +33,9 @@ import com.interlinedlist.android.feature.lists.domain.SchemaField object RowEditorTestTags { const val SAVE = "rowEditorSave" const val CANCEL = "rowEditorCancel" + const val GITHUB_HINT = "rowEditorGithubHint" fun field(key: String) = "rowField_$key" + fun readOnlyField(key: String) = "rowFieldReadOnly_$key" } /** @@ -43,6 +45,12 @@ object RowEditorTestTags { * * Values are collected as strings keyed by field key and returned to [onSave]; * the repository serialises them into the row's dynamic `data` map. + * + * On a GitHub-backed list a row *is* an issue: saving a new row opens one and + * saving an existing row updates it. [githubRepo] (and [nextIssueNumber], when + * known) name that consequence up front. Read-only columns — `Issue #`, `Link`, + * `Created`, `Updated`, which GitHub assigns — are shown but never sent, since + * the row endpoints reject writes to them. */ @Composable fun RowEditor( @@ -52,11 +60,14 @@ fun RowEditor( onSave: (Map) -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier, + githubRepo: String? = null, + nextIssueNumber: Int? = null, ) { - // One editable value per schema field, seeded from the row when editing. + val editableFields = schema.fields.filterNot { it.readOnly } + // One editable value per writable schema field, seeded from the row when editing. val values = remember(row, schema) { mutableStateMapOf().apply { - schema.fields.forEach { field -> put(field.key, row?.valueFor(field.key).orEmpty()) } + editableFields.forEach { field -> put(field.key, row?.valueFor(field.key).orEmpty()) } } } @@ -72,14 +83,31 @@ fun RowEditor( style = androidx.compose.material3.MaterialTheme.typography.titleLarge, ) - schema.fields.forEach { field -> - FieldInput( - field = field, - value = values[field.key].orEmpty(), - onValueChange = { values[field.key] = it }, + if (!githubRepo.isNullOrBlank()) { + Text( + text = githubIssueHint( + repo = githubRepo, + isNewRow = row == null, + nextIssueNumber = nextIssueNumber, + ), + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(RowEditorTestTags.GITHUB_HINT), ) } + schema.fields.forEach { field -> + if (field.readOnly) { + ReadOnlyField(field = field, value = row?.valueFor(field.key).orEmpty()) + } else { + FieldInput( + field = field, + value = values[field.key].orEmpty(), + onValueChange = { values[field.key] = it }, + ) + } + } + Spacer(Modifier.height(8.dp)) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { TextButton( @@ -95,6 +123,32 @@ fun RowEditor( } } +/** + * What saving this row will do on GitHub. Named explicitly because "add row" and + * "open a public issue in someone's repository" are not the same expectation. + */ +internal fun githubIssueHint(repo: String, isNewRow: Boolean, nextIssueNumber: Int?): String = when { + !isNewRow -> "Saving updates the matching issue in $repo." + nextIssueNumber != null -> "Saving opens issue #$nextIssueNumber in $repo." + else -> "Saving opens a new issue in $repo." +} + +/** A column GitHub owns: shown for context, never edited and never sent back. */ +@Composable +private fun ReadOnlyField(field: SchemaField, value: String) { + Column(Modifier.testTag(RowEditorTestTags.readOnlyField(field.key))) { + Text( + text = field.label, + style = androidx.compose.material3.MaterialTheme.typography.labelMedium, + color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value.ifBlank { "—" }, + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + ) + } +} + /** Renders the input control matched to the field's type. */ @Composable private fun FieldInput( diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/github/GithubLinkProblem.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/github/GithubLinkProblem.kt new file mode 100644 index 0000000..88821f4 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/github/GithubLinkProblem.kt @@ -0,0 +1,68 @@ +package com.interlinedlist.android.feature.lists.ui.github + +import com.interlinedlist.android.core.common.result.AppError + +/** + * Why the repo picker cannot show repositories, in terms a user can act on. + * + * A GitHub-backed list needs a linked GitHub account with the **Issues** scope. + * When that is missing the picker must explain what to do rather than render an + * empty, broken control. Linking itself happens in the browser OAuth flow, so the + * only action this module can offer is a route to the existing connected-accounts + * screen. + */ +enum class GithubLinkProblem(val title: String, val explanation: String, val actionLabel: String) { + + /** + * No GitHub identity at all. Every `/api/github/…` endpoint answers + * `400 { "error": "GitHub account not linked" }`, which `safeApiCall` surfaces + * as an [AppError.Unknown] carrying that message. + */ + NOT_LINKED( + title = "Connect GitHub first", + explanation = "A GitHub-backed list mirrors a repository's issues, so it needs a " + + "linked GitHub account with the Issues scope. Connect GitHub from your connected " + + "accounts, then come back and pick a repository.", + actionLabel = "Open connected accounts", + ), + + /** + * Linked, but GitHub refused the token — typically an account linked for + * sign-in only, without the Issues scope, or a revoked/expired grant. The + * proxy forwards GitHub's own status, so this arrives as a 401 with + * `code: "github_error"`. + */ + NEEDS_ISSUES_SCOPE( + title = "Reconnect GitHub for Issues", + explanation = "GitHub refused the linked account. If you linked GitHub only for " + + "signing in, reconnect it and grant the Issues scope so InterlinedList can read " + + "and open issues on your behalf.", + actionLabel = "Open connected accounts", + ), + ; +} + +/** + * Classifies a failure from the GitHub proxy, or returns null when the failure is + * an ordinary one (offline, server error) that the usual error message covers. + * + * The unlinked case is recognised by message because the API reports it as a + * `400`, which `safeApiCall` does not map to a dedicated [AppError] type. + */ +fun AppError.toGithubLinkProblem(): GithubLinkProblem? { + val text = message.orEmpty() + val mentionsGithub = text.contains("github", ignoreCase = true) + return when { + mentionsGithub && ( + text.contains("not linked", ignoreCase = true) || + text.contains("not connected", ignoreCase = true) + ) -> GithubLinkProblem.NOT_LINKED + + // GitHub itself refused the linked identity; the proxy forwards its 401. + this is AppError.Unauthorized -> GithubLinkProblem.NEEDS_ISSUES_SCOPE + + this is AppError.Forbidden -> GithubLinkProblem.NEEDS_ISSUES_SCOPE + + else -> null + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt index 2e9cefa..a42bed8 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt @@ -25,12 +25,17 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -59,7 +64,13 @@ object ListsTestTags { /** * Hilt-wired entry point for the Lists index. [onOpenList] receives the tapped * list's id so the app can navigate to the detail route. + * + * [onOpenConnectedAccounts] is the escape hatch when someone tries to create a + * GitHub-backed list without GitHub linked: OAuth linking happens in the browser, + * so the sheet routes to the existing connected-accounts screen rather than + * pretending it can fix it here. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ListsRoute( onOpenList: (String) -> Unit, @@ -67,9 +78,15 @@ fun ListsRoute( modifier: Modifier = Modifier, onOpenSharedWithMe: () -> Unit = {}, onOpenFolders: () -> Unit = {}, + onOpenConnectedAccounts: () -> Unit = {}, viewModel: ListsViewModel = hiltViewModel(), + newListViewModel: NewListViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val newListState by newListViewModel.uiState.collectAsStateWithLifecycle() + var creating by rememberSaveable { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ListsScreen( state = state, onOpenList = onOpenList, @@ -78,9 +95,34 @@ fun ListsRoute( onOpenFolders = onOpenFolders, onSearchQueryChange = viewModel::onSearchQueryChange, onLoadMore = viewModel::loadMore, - onCreateList = { title -> viewModel.createList(title, description = null, onCreated = { onOpenList(it.id) }) }, + onNewList = { creating = true }, modifier = modifier, ) + + if (creating) { + ModalBottomSheet( + onDismissRequest = { creating = false; newListViewModel.reset() }, + sheetState = sheetState, + ) { + NewListSheet( + state = newListState, + onSelectKind = newListViewModel::selectKind, + onTitleChange = newListViewModel::onTitleChange, + onPublicChange = newListViewModel::onPublicChange, + onRepoQueryChange = newListViewModel::onRepoQueryChange, + onSelectRepo = newListViewModel::selectRepo, + onSelectOrg = newListViewModel::selectOrg, + onCreate = { + newListViewModel.create { summary -> + creating = false + onOpenList(summary.id) + } + }, + onCancel = { creating = false; newListViewModel.reset() }, + onOpenConnectedAccounts = { creating = false; onOpenConnectedAccounts() }, + ) + } + } } /** Stateless lists index — loading / empty / error / subscription / content states. */ @@ -92,7 +134,7 @@ fun ListsScreen( onOpenConnections: () -> Unit, onSearchQueryChange: (String) -> Unit, onLoadMore: () -> Unit, - onCreateList: (String) -> Unit, + onNewList: () -> Unit, modifier: Modifier = Modifier, onOpenSharedWithMe: () -> Unit = {}, onOpenFolders: () -> Unit = {}, @@ -124,7 +166,7 @@ fun ListsScreen( floatingActionButton = { if (!state.subscriptionRequired) { ExtendedFloatingActionButton( - onClick = { onCreateList("New list") }, + onClick = onNewList, icon = { Icon(Icons.Default.Add, contentDescription = null) }, text = { Text("New list") }, modifier = Modifier.testTag(ListsTestTags.CREATE_FAB), @@ -325,7 +367,7 @@ private fun ListsScreenPreview() { onOpenConnections = {}, onSearchQueryChange = {}, onLoadMore = {}, - onCreateList = {}, + onNewList = {}, ) } } diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModel.kt index bd1ba47..3253004 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModel.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModel.kt @@ -110,21 +110,6 @@ class ListsViewModel @Inject constructor( } } - fun createList(title: String, description: String?, onCreated: (ListSummary) -> Unit = {}) { - if (title.isBlank()) return - viewModelScope.launch { - when (val result = repository.createList(title.trim(), description?.trim()?.ifBlank { null }, isPublic = false)) { - is ApiResult.Success -> onCreated(result.data) - is ApiResult.Failure -> _uiState.update { - it.copy( - errorMessage = result.error.toUserMessage(), - subscriptionRequired = result.error.isSubscriptionGate, - ) - } - } - } - } - fun deleteList(id: String) { viewModelScope.launch { when (val result = repository.deleteList(id)) { diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListSheet.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListSheet.kt new file mode 100644 index 0000000..0fc4851 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListSheet.kt @@ -0,0 +1,354 @@ +package com.interlinedlist.android.feature.lists.ui.list + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.interlinedlist.android.feature.lists.domain.GithubRepo +import com.interlinedlist.android.feature.lists.domain.GithubRepoLink +import com.interlinedlist.android.feature.lists.ui.github.GithubLinkProblem + +/** Stable test tags for the "New list" sheet. */ +object NewListTestTags { + const val SHEET = "newListSheet" + const val KIND_LOCAL = "newListKindLocal" + const val KIND_GITHUB = "newListKindGithub" + const val TITLE = "newListTitle" + const val VISIBILITY = "newListVisibility" + const val CREATE = "newListCreate" + const val CANCEL = "newListCancel" + const val ERROR = "newListError" + const val REPO_SEARCH = "newListRepoSearch" + const val REPO_PROGRESS = "newListRepoProgress" + const val REPO_EMPTY = "newListRepoEmpty" + const val LINK_PROBLEM = "newListLinkProblem" + const val LINK_ACTION = "newListLinkAction" + fun repo(fullName: String) = "newListRepo_$fullName" + fun org(login: String) = "newListOrg_$login" +} + +/** + * Create form for a new list: a local one, or one backed by a GitHub repository's + * issues. + * + * The GitHub half needs a linked GitHub account with the Issues scope. Linking + * happens in the browser OAuth flow, which this app does not drive, so when the + * account is unlinked or refused the picker is replaced by an explanation and a + * route to the connected-accounts screen — never an empty, silently broken list. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewListSheet( + state: NewListUiState, + onSelectKind: (NewListKind) -> Unit, + onTitleChange: (String) -> Unit, + onPublicChange: (Boolean) -> Unit, + onRepoQueryChange: (String) -> Unit, + onSelectRepo: (GithubRepo) -> Unit, + onSelectOrg: (String?) -> Unit, + onCreate: () -> Unit, + onCancel: () -> Unit, + onOpenConnectedAccounts: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .testTag(NewListTestTags.SHEET), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("New list", style = MaterialTheme.typography.titleLarge) + + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + SegmentedButton( + selected = state.kind == NewListKind.LOCAL, + onClick = { onSelectKind(NewListKind.LOCAL) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + modifier = Modifier.testTag(NewListTestTags.KIND_LOCAL), + ) { Text("Local list") } + SegmentedButton( + selected = state.kind == NewListKind.GITHUB, + onClick = { onSelectKind(NewListKind.GITHUB) }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + modifier = Modifier.testTag(NewListTestTags.KIND_GITHUB), + ) { Text("GitHub-backed") } + } + + if (state.kind == NewListKind.GITHUB) { + RepoPicker( + state = state, + onRepoQueryChange = onRepoQueryChange, + onSelectRepo = onSelectRepo, + onSelectOrg = onSelectOrg, + onOpenConnectedAccounts = onOpenConnectedAccounts, + ) + } + + // With no repository chosen there is nothing to title yet. + if (state.kind == NewListKind.LOCAL || state.selectedRepo != null) { + OutlinedTextField( + value = state.title, + onValueChange = onTitleChange, + label = { Text("Title") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(NewListTestTags.TITLE), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text("Public list", style = MaterialTheme.typography.bodyLarge) + Text( + text = if (state.kind == NewListKind.GITHUB) { + // Two different visibilities; do not let them blur. + "Who can see this list on InterlinedList. The repository's own " + + "visibility is set on GitHub and is not changed here." + } else { + "Anyone with the link can view" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = state.isPublic, + onCheckedChange = onPublicChange, + modifier = Modifier.testTag(NewListTestTags.VISIBILITY), + ) + } + } + + if (state.errorMessage != null) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .testTag(NewListTestTags.ERROR), + ) + } + + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TextButton( + onClick = onCancel, + modifier = Modifier.testTag(NewListTestTags.CANCEL), + ) { Text("Cancel") } + Button( + onClick = onCreate, + enabled = state.canCreate, + modifier = Modifier.testTag(NewListTestTags.CREATE), + ) { Text("Create") } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RepoPicker( + state: NewListUiState, + onRepoQueryChange: (String) -> Unit, + onSelectRepo: (GithubRepo) -> Unit, + onSelectOrg: (String?) -> Unit, + onOpenConnectedAccounts: () -> Unit, +) { + val problem = state.linkProblem + if (problem != null) { + LinkProblemPanel(problem, onOpenConnectedAccounts) + return + } + + Text( + "Rows mirror the repository's issues: adding a row opens an issue, editing " + + "updates it, deleting closes it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (state.orgs.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = state.selectedOrg == null, + onClick = { onSelectOrg(null) }, + label = { Text("All") }, + modifier = Modifier.testTag(NewListTestTags.org("all")), + ) + state.orgs.forEach { org -> + FilterChip( + selected = state.selectedOrg == org.login, + onClick = { onSelectOrg(org.login) }, + label = { Text(org.login) }, + modifier = Modifier.testTag(NewListTestTags.org(org.login)), + ) + } + } + } + + OutlinedTextField( + value = state.repoQuery, + onValueChange = onRepoQueryChange, + label = { Text("Find a repository") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(NewListTestTags.REPO_SEARCH), + ) + + when { + state.isLoadingRepos -> Box( + Modifier.fillMaxWidth().padding(24.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(Modifier.size(24.dp).testTag(NewListTestTags.REPO_PROGRESS)) } + + state.hasNoRepos -> Text( + text = "No repositories came back for this account. If the repository belongs " + + "to an organisation, that organisation has to approve InterlinedList on " + + "GitHub before its repositories appear here.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(NewListTestTags.REPO_EMPTY), + ) + + else -> LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 260.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.visibleRepos, key = { it.fullName }) { repo -> + RepoRow( + repo = repo, + selected = state.selectedRepo?.fullName == repo.fullName, + onClick = { onSelectRepo(repo) }, + ) + } + } + } +} + +@Composable +private fun RepoRow(repo: GithubRepo, selected: Boolean, onClick: () -> Unit) { + Card( + colors = CardDefaults.cardColors( + containerColor = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .testTag(NewListTestTags.repo(repo.fullName)), + ) { + Column(Modifier.padding(12.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = repo.fullName, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (repo.isPrivate) { + // The repository is private on GitHub — not the list. + Icon( + Icons.Default.Lock, + contentDescription = GithubRepoLink.PRIVATE_TAG, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = GithubRepoLink.PRIVATE_TAG, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (!repo.description.isNullOrBlank()) { + Text( + text = repo.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +/** Replaces the picker when GitHub is unlinked or refuses the linked account. */ +@Composable +private fun LinkProblemPanel(problem: GithubLinkProblem, onOpenConnectedAccounts: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .testTag(NewListTestTags.LINK_PROBLEM), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(problem.title, style = MaterialTheme.typography.titleMedium) + Text( + text = problem.explanation, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + AssistChip( + onClick = onOpenConnectedAccounts, + label = { Text(problem.actionLabel) }, + modifier = Modifier.testTag(NewListTestTags.LINK_ACTION), + ) + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListViewModel.kt new file mode 100644 index 0000000..f0ad164 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListViewModel.kt @@ -0,0 +1,188 @@ +package com.interlinedlist.android.feature.lists.ui.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.data.GithubRepository +import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.domain.GithubOrg +import com.interlinedlist.android.feature.lists.domain.GithubRepo +import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.ui.github.GithubLinkProblem +import com.interlinedlist.android.feature.lists.ui.github.toGithubLinkProblem +import com.interlinedlist.android.feature.lists.ui.isSubscriptionGate +import com.interlinedlist.android.feature.lists.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Which kind of list the "New list" sheet is creating. */ +enum class NewListKind { LOCAL, GITHUB } + +/** + * State of the "New list" sheet, covering both kinds. The GitHub half only loads + * once the user switches to it, so someone creating a local list never pays for a + * GitHub round trip. + */ +data class NewListUiState( + val kind: NewListKind = NewListKind.LOCAL, + val title: String = "", + val isPublic: Boolean = false, + val isSaving: Boolean = false, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, + // --- GitHub half ----------------------------------------------------- + val isLoadingRepos: Boolean = false, + val orgs: List = emptyList(), + /** `null` = "All repositories"; otherwise the org login passed as `?org=`. */ + val selectedOrg: String? = null, + val repos: List = emptyList(), + val repoQuery: String = "", + val selectedRepo: GithubRepo? = null, + /** Set when GitHub is unlinked or refuses the token; blocks the picker with an explanation. */ + val linkProblem: GithubLinkProblem? = null, +) { + /** Repositories matching the filter box, owner and name both searched. */ + val visibleRepos: List + get() = repoQuery.trim().takeIf { it.isNotEmpty() }?.let { q -> + repos.filter { it.fullName.contains(q, ignoreCase = true) } + } ?: repos + + /** + * True when the linked account has no reachable repositories. Usually means + * the OAuth app has not been approved for the user's organisations. + */ + val hasNoRepos: Boolean + get() = kind == NewListKind.GITHUB && !isLoadingRepos && linkProblem == null && repos.isEmpty() + + val canCreate: Boolean + get() = !isSaving && when (kind) { + NewListKind.LOCAL -> title.isNotBlank() + NewListKind.GITHUB -> selectedRepo != null + } +} + +/** + * Backs the "New list" sheet: a local list (title + visibility) or a + * GitHub-backed one (pick a repository whose issues become the rows). + * + * It owns the repo picker rather than [ListsViewModel] so the index keeps its + * single job, and so nothing GitHub-related is fetched until the sheet asks. + */ +@HiltViewModel +class NewListViewModel @Inject constructor( + private val listsRepository: ListsRepository, + private val githubRepository: GithubRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(NewListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun selectKind(kind: NewListKind) { + if (_uiState.value.kind == kind) return + _uiState.update { it.copy(kind = kind, errorMessage = null) } + // Repos are fetched lazily, and only once. + if (kind == NewListKind.GITHUB && _uiState.value.repos.isEmpty()) loadRepos() + } + + fun onTitleChange(title: String) = _uiState.update { it.copy(title = title) } + + fun onPublicChange(isPublic: Boolean) = _uiState.update { it.copy(isPublic = isPublic) } + + fun onRepoQueryChange(query: String) = _uiState.update { it.copy(repoQuery = query) } + + /** + * Picks a repository. The title defaults to the repository name (as the web + * app does) unless the user has already typed one. + */ + fun selectRepo(repo: GithubRepo) = _uiState.update { state -> + state.copy( + selectedRepo = repo, + title = state.title.ifBlank { repo.name }, + ) + } + + /** Scopes the picker to one organisation (`?org=`), or to everything when null. */ + fun selectOrg(login: String?) { + if (_uiState.value.selectedOrg == login) return + _uiState.update { it.copy(selectedOrg = login, selectedRepo = null) } + loadRepos() + } + + /** + * Loads the organisations and repositories the linked account can reach. + * + * The org list is decoration for the filter: if it fails, the picker still + * works unscoped, so only the repo call can put the sheet into an error state. + */ + fun loadRepos() { + _uiState.update { it.copy(isLoadingRepos = true, errorMessage = null, linkProblem = null) } + viewModelScope.launch { + if (_uiState.value.orgs.isEmpty()) { + when (val orgs = githubRepository.getOrgs()) { + is ApiResult.Success -> _uiState.update { it.copy(orgs = orgs.data) } + is ApiResult.Failure -> Unit // Filter is optional; keep going. + } + } + when (val result = githubRepository.getRepos(_uiState.value.selectedOrg)) { + is ApiResult.Success -> _uiState.update { + it.copy(isLoadingRepos = false, repos = result.data) + } + is ApiResult.Failure -> { + val problem = result.error.toGithubLinkProblem() + _uiState.update { + it.copy( + isLoadingRepos = false, + repos = emptyList(), + linkProblem = problem, + // A link problem is explained by its own panel; only + // ordinary failures need the generic message. + errorMessage = if (problem == null) result.error.toUserMessage() else null, + ) + } + } + } + } + } + + /** Creates the list described by the current state and hands it to [onCreated]. */ + fun create(onCreated: (ListSummary) -> Unit) { + val state = _uiState.value + if (!state.canCreate) return + _uiState.update { it.copy(isSaving = true, errorMessage = null, subscriptionRequired = false) } + viewModelScope.launch { + val result = when (state.kind) { + NewListKind.LOCAL -> listsRepository.createList( + title = state.title.trim(), + isPublic = state.isPublic, + ) + NewListKind.GITHUB -> listsRepository.createGithubList( + repo = state.selectedRepo!!.fullName, + title = state.title.trim().ifBlank { state.selectedRepo.name }, + isPublic = state.isPublic, + ) + } + when (result) { + is ApiResult.Success -> { + _uiState.value = NewListUiState() // Ready for the next one. + onCreated(result.data) + } + is ApiResult.Failure -> _uiState.update { + it.copy( + isSaving = false, + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ) + } + } + } + } + + fun reset() { + _uiState.value = NewListUiState() + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreen.kt index b058aba..6aa7f09 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreen.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreen.kt @@ -41,6 +41,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.lists.domain.FieldType +import com.interlinedlist.android.feature.lists.domain.ListSummary /** Stable test tags for the schema editor. */ object SchemaEditorTestTags { @@ -50,9 +51,12 @@ object SchemaEditorTestTags { const val PROGRESS = "schemaEditorProgress" const val ERROR = "schemaEditorError" const val SUBSCRIPTION = "schemaEditorSubscription" + const val LOCKED_NOTICE = "schemaEditorLockedNotice" + const val PARENT_PICKER = "schemaEditorParentPicker" fun column(uiId: Long) = "schemaColumn_$uiId" fun key(uiId: Long) = "schemaColumnKey_$uiId" fun remove(uiId: Long) = "schemaColumnRemove_$uiId" + fun parentOption(id: String) = "schemaEditorParent_$id" } /** @@ -83,6 +87,7 @@ fun SchemaEditorRoute( onLabelChange = viewModel::updateLabel, onTypeChange = viewModel::updateType, onSave = { viewModel.save() }, + onSelectParent = viewModel::setParent, modifier = modifier, ) } @@ -100,28 +105,33 @@ fun SchemaEditorScreen( onTypeChange: (Long, FieldType) -> Unit, onSave: () -> Unit, modifier: Modifier = Modifier, + onSelectParent: (String) -> Unit = {}, ) { Scaffold( modifier = modifier.fillMaxSize(), topBar = { TopAppBar( - title = { Text("Edit columns") }, + title = { Text(if (state.isSchemaLocked) "Columns & parent" else "Edit columns") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, actions = { - TextButton( - onClick = onSave, - enabled = state.canSave, - modifier = Modifier.testTag(SchemaEditorTestTags.SAVE), - ) { Text("Save") } + // Nothing to save on a locked schema: the parent picker + // persists on selection. + if (state.canEditColumns) { + TextButton( + onClick = onSave, + enabled = state.canSave, + modifier = Modifier.testTag(SchemaEditorTestTags.SAVE), + ) { Text("Save") } + } }, ) }, floatingActionButton = { - if (!state.subscriptionRequired && !state.isLoading) { + if (state.canEditColumns && !state.subscriptionRequired && !state.isLoading) { ExtendedFloatingActionButton( onClick = onAddColumn, icon = { Icon(Icons.Default.Add, contentDescription = null) }, @@ -161,16 +171,31 @@ fun SchemaEditorScreen( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + if (state.isSchemaLocked) { + item { LockedNotice(state.githubRepo) } + item { + ParentPicker( + parentId = state.parentId, + options = state.parentOptions, + enabled = !state.isSaving, + onSelectParent = onSelectParent, + ) + } + } items(state.columns, key = { it.uiId }) { column -> - ColumnCard( - column = column, - onKeyChange = { onKeyChange(column.uiId, it) }, - onLabelChange = { onLabelChange(column.uiId, it) }, - onTypeChange = { onTypeChange(column.uiId, it) }, - onRemove = { onRemoveColumn(column.uiId) }, - ) + if (state.canEditColumns) { + ColumnCard( + column = column, + onKeyChange = { onKeyChange(column.uiId, it) }, + onLabelChange = { onLabelChange(column.uiId, it) }, + onTypeChange = { onTypeChange(column.uiId, it) }, + onRemove = { onRemoveColumn(column.uiId) }, + ) + } else { + LockedColumnCard(column) + } } - if (state.columns.isEmpty()) { + if (state.columns.isEmpty() && state.canEditColumns) { item { Text( "No columns yet. Use Add column to define this list's shape.", @@ -185,6 +210,105 @@ fun SchemaEditorScreen( } } +/** + * Why the columns cannot be edited. A GitHub-backed list's schema is the fixed + * set of issue fields, so saying so plainly beats letting someone edit a form + * whose save the server will refuse. + */ +@Composable +private fun LockedNotice(githubRepo: String?) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(SchemaEditorTestTags.LOCKED_NOTICE), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Columns are set by GitHub", style = MaterialTheme.typography.titleSmall) + Text( + text = buildString { + append("This list mirrors issues") + if (!githubRepo.isNullOrBlank()) append(" in $githubRepo") + append( + ", so its columns are fixed: title, body, labels, assignees and " + + "state come from GitHub and cannot be changed here. The parent " + + "list is the one thing you can still change.", + ) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** A fixed GitHub column, rendered for reference only. */ +@Composable +private fun LockedColumnCard(column: EditableColumn) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(SchemaEditorTestTags.column(column.uiId)), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(Modifier.weight(1f)) { + Text(column.label.ifBlank { column.key }, style = MaterialTheme.typography.bodyLarge) + Text( + text = column.key + " · " + column.type.name.lowercase(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (column.readOnly) { + Text( + text = "Set by GitHub", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** The only edit a locked schema allows: where this list hangs in the tree. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ParentPicker( + parentId: String?, + options: List, + enabled: Boolean, + onSelectParent: (String) -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(SchemaEditorTestTags.PARENT_PICKER), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Parent list", style = MaterialTheme.typography.titleSmall) + if (options.isEmpty()) { + Text( + "No other lists to nest this one under yet.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + options.forEach { option -> + FilterChip( + selected = option.id == parentId, + enabled = enabled, + onClick = { onSelectParent(option.id) }, + label = { Text(option.title.ifBlank { "Untitled list" }) }, + modifier = Modifier.testTag(SchemaEditorTestTags.parentOption(option.id)), + ) + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ColumnCard( diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModel.kt index dda47fa..d945f34 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModel.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModel.kt @@ -7,6 +7,7 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.lists.data.ListsRepository import com.interlinedlist.android.feature.lists.domain.FieldType import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.ListSummary import com.interlinedlist.android.feature.lists.domain.SchemaField import com.interlinedlist.android.feature.lists.ui.isSubscriptionGate import com.interlinedlist.android.feature.lists.ui.toUserMessage @@ -31,6 +32,8 @@ data class EditableColumn( val key: String = "", val label: String = "", val type: FieldType = FieldType.TEXT, + /** The server assigns this column's value (GitHub's `number`, `url`, …). */ + val readOnly: Boolean = false, ) { /** True once the column has a usable key to persist. */ val isComplete: Boolean get() = key.isNotBlank() @@ -44,9 +47,29 @@ data class SchemaEditorUiState( val errorMessage: String? = null, val subscriptionRequired: Boolean = false, val saved: Boolean = false, + /** + * True for a GitHub-backed list, whose columns are the fixed GitHub issue + * fields. The editor is then **locked to the parent list**: the columns are + * shown read-only and only [parentId] can change. Letting someone edit a + * fixed schema would produce a confusing server error at save time instead of + * an honest "you cannot change this" up front. + */ + val isSchemaLocked: Boolean = false, + /** `"owner/repo"` behind a locked schema, for the explanation copy. */ + val githubRepo: String? = null, + /** The list this one currently hangs under, if any. */ + val parentId: String? = null, + /** Other lists that could be this list's parent. */ + val parentOptions: List = emptyList(), ) { - /** Save is allowed once at least one column has a key and nothing is in flight. */ - val canSave: Boolean get() = !isSaving && columns.any { it.isComplete } + /** + * Save is allowed once at least one column has a key and nothing is in + * flight — and never on a locked schema. + */ + val canSave: Boolean get() = !isSchemaLocked && !isSaving && columns.any { it.isComplete } + + /** Columns may be added/removed/retyped only on an unlocked schema. */ + val canEditColumns: Boolean get() = !isSchemaLocked } @HiltViewModel @@ -66,6 +89,22 @@ class SchemaEditorViewModel @Inject constructor( init { load() + observeParentOptions() + } + + /** + * Candidate parent lists, straight from the offline-first cache — the picker + * is the only edit a locked (GitHub-backed) schema still allows, and it does + * not warrant its own network call. A list cannot parent itself. + */ + private fun observeParentOptions() { + viewModelScope.launch { + repository.observeLists().collect { lists -> + _uiState.update { state -> + state.copy(parentOptions = lists.filterNot { it.id == listId }) + } + } + } } fun load() { @@ -73,9 +112,13 @@ class SchemaEditorViewModel @Inject constructor( viewModelScope.launch { when (val result = repository.getListDetail(listId)) { is ApiResult.Success -> _uiState.update { + val summary = result.data.summary it.copy( columns = result.data.schema.fields.map(::toEditable), isLoading = false, + isSchemaLocked = summary.isGithubBacked, + githubRepo = summary.githubRepo, + parentId = summary.parentId, ) } is ApiResult.Failure -> _uiState.update { @@ -89,12 +132,14 @@ class SchemaEditorViewModel @Inject constructor( } } - fun addColumn() = _uiState.update { - it.copy(columns = it.columns + EditableColumn(uiId = nextUiId++)) + fun addColumn() { + if (_uiState.value.isSchemaLocked) return + _uiState.update { it.copy(columns = it.columns + EditableColumn(uiId = nextUiId++)) } } - fun removeColumn(uiId: Long) = _uiState.update { - it.copy(columns = it.columns.filterNot { column -> column.uiId == uiId }) + fun removeColumn(uiId: Long) { + if (_uiState.value.isSchemaLocked) return + _uiState.update { it.copy(columns = it.columns.filterNot { column -> column.uiId == uiId }) } } fun updateKey(uiId: Long, key: String) = mutate(uiId) { it.copy(key = key) } @@ -103,7 +148,34 @@ class SchemaEditorViewModel @Inject constructor( fun updateType(uiId: Long, type: FieldType) = mutate(uiId) { it.copy(type = type) } + /** + * Re-parents the list. This is the whole of what a locked schema allows, and + * it works the same on an unlocked one. + */ + fun setParent(parentId: String, onDone: () -> Unit = {}) { + if (_uiState.value.isSaving || parentId == _uiState.value.parentId) return + _uiState.update { it.copy(isSaving = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.updateList(id = listId, parentId = parentId)) { + is ApiResult.Success -> { + _uiState.update { it.copy(isSaving = false, parentId = result.data.parentId ?: parentId) } + onDone() + } + is ApiResult.Failure -> _uiState.update { + it.copy( + isSaving = false, + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ) + } + } + } + } + fun save(onSaved: () -> Unit = {}) { + // A GitHub-backed list's columns are GitHub's; the server would reject + // the write, so it is refused here with the UI already saying why. + if (_uiState.value.isSchemaLocked) return val schema = toSchema() if (schema.isEmpty) return _uiState.update { it.copy(isSaving = true, errorMessage = null) } @@ -150,9 +222,13 @@ class SchemaEditorViewModel @Inject constructor( key = field.key, label = field.label, type = field.type, + readOnly = field.readOnly, ) - private fun mutate(uiId: Long, transform: (EditableColumn) -> EditableColumn) = _uiState.update { state -> - state.copy(columns = state.columns.map { if (it.uiId == uiId) transform(it) else it }) + private fun mutate(uiId: Long, transform: (EditableColumn) -> EditableColumn) { + if (_uiState.value.isSchemaLocked) return + _uiState.update { state -> + state.copy(columns = state.columns.map { if (it.uiId == uiId) transform(it) else it }) + } } } diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeGithubRepository.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeGithubRepository.kt new file mode 100644 index 0000000..222d70a --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeGithubRepository.kt @@ -0,0 +1,39 @@ +package com.interlinedlist.android.feature.lists + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.data.GithubRepository +import com.interlinedlist.android.feature.lists.domain.GithubOrg +import com.interlinedlist.android.feature.lists.domain.GithubRepo + +/** + * In-memory [GithubRepository] for ViewModel tests. Each call's result is + * configurable so the unlinked / refused / empty-repo paths can be exercised + * without a network. + */ +class FakeGithubRepository : GithubRepository { + + var orgsResult: ApiResult> = ApiResult.Success(emptyList()) + var reposResult: ApiResult> = ApiResult.Success(emptyList()) + var nextIssueNumberResult: ApiResult = ApiResult.Success(null) + + var orgsCount = 0 + var reposCount = 0 + var lastReposOrg: String? = null + var lastNextIssueRepo: String? = null + + override suspend fun getOrgs(): ApiResult> { + orgsCount++ + return orgsResult + } + + override suspend fun getRepos(org: String?): ApiResult> { + reposCount++ + lastReposOrg = org + return reposResult + } + + override suspend fun getNextIssueNumber(repo: String): ApiResult { + lastNextIssueRepo = repo + return nextIssueNumberResult + } +} 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 ce115ed..f8634b9 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 @@ -4,6 +4,7 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.lists.data.ListsRepository import com.interlinedlist.android.feature.lists.domain.Contributor +import com.interlinedlist.android.feature.lists.domain.GITHUB_SOURCE_ISSUES import com.interlinedlist.android.feature.lists.domain.ListConnection import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder @@ -23,6 +24,7 @@ import com.interlinedlist.android.feature.lists.domain.SharedListResolution 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 com.interlinedlist.android.feature.lists.domain.isValidGithubRepo import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.json.JsonObject @@ -95,6 +97,9 @@ class FakeListsRepository : ListsRepository { var lastCreateInitialRows: List>? = null var lastCreateMetadata: JsonObject? = null var lastCreateSource: ListSource? = null + var lastCreateGithubRepo: String? = null + var lastCreateGithubSource: String? = null + var lastUpdatedParentId: String? = null var updateListCount = 0 var updateFolderCount = 0 var deleteFolderCount = 0 @@ -153,6 +158,8 @@ class FakeListsRepository : ListsRepository { initialRows: List>?, metadata: JsonObject?, source: ListSource?, + githubRepo: String?, + githubSource: String?, ): ApiResult { lastCreateParentId = parentId lastCreateFolderId = folderId @@ -160,8 +167,41 @@ class FakeListsRepository : ListsRepository { lastCreateInitialRows = initialRows lastCreateMetadata = metadata lastCreateSource = source + lastCreateGithubRepo = githubRepo + lastCreateGithubSource = githubSource return createResult ?: ApiResult.Success( - ListSummary("new", title, description, 0, folderId, isPublic, null, parentId), + ListSummary( + id = "new", + title = title, + description = description, + itemCount = 0, + folderId = folderId, + isPublic = isPublic, + updatedAt = null, + parentId = parentId, + source = source ?: ListSource.LOCAL, + githubRepo = githubRepo, + ), + ) + } + + /** Mirrors the real repository: validates the repo then delegates to [createList]. */ + override suspend fun createGithubList( + repo: String, + title: String, + isPublic: Boolean, + parentId: String?, + ): ApiResult { + if (!isValidGithubRepo(repo)) { + return ApiResult.Failure(AppError.Unknown("Pick a repository in owner/repo form.")) + } + return createList( + title = title, + isPublic = isPublic, + parentId = parentId, + source = ListSource.GITHUB, + githubRepo = repo, + githubSource = GITHUB_SOURCE_ISSUES, ) } @@ -183,11 +223,13 @@ class FakeListsRepository : ListsRepository { description: String?, isPublic: Boolean?, folderId: String?, + parentId: String?, ): ApiResult { updateListCount++ lastUpdatedTitle = title lastUpdatedDescription = description lastUpdatedIsPublic = isPublic + lastUpdatedParentId = parentId val result = updateListResult ?: run { val current = cache.value.firstOrNull { it.id == id } ApiResult.Success( @@ -199,6 +241,10 @@ class FakeListsRepository : ListsRepository { folderId = folderId ?: current?.folderId, isPublic = isPublic ?: current?.isPublic ?: false, updatedAt = current?.updatedAt, + parentId = parentId ?: current?.parentId, + source = current?.source ?: ListSource.LOCAL, + githubRepo = current?.githubRepo, + githubRepoPrivate = current?.githubRepoPrivate, ), ) } diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultGithubRepositoryTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultGithubRepositoryTest.kt new file mode 100644 index 0000000..c8db5dc --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultGithubRepositoryTest.kt @@ -0,0 +1,170 @@ +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.remote.GithubApi +import com.interlinedlist.android.feature.lists.ui.github.GithubLinkProblem +import com.interlinedlist.android.feature.lists.ui.github.toGithubLinkProblem +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * The GitHub proxy calls the repo picker makes. Both `/api/github/repos` and + * `/api/github/orgs` return a **bare array** (confirmed against the live API), + * and `next-issue-number` returns `{ "nextNumber": n }`. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultGithubRepositoryTest { + + private lateinit var server: MockWebServer + private lateinit var repository: DefaultGithubRepository + + 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() } + val api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(GithubApi::class.java) + repository = DefaultGithubRepository(api, json, testDispatchers) + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `getRepos maps the bare GitHub array and keeps repository visibility`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + [ + { "name": "Hello-World", "owner": { "login": "octocat" }, "private": false, + "description": "My first repo" }, + { "full_name": "acme/secret-plans", "private": true } + ] + """.trimIndent(), + ), + ) + + val repos = (repository.getRepos() as ApiResult.Success).data + + assertThat(repos.map { it.fullName }) + .containsExactly("octocat/Hello-World", "acme/secret-plans").inOrder() + assertThat(repos[0].isPrivate).isFalse() + // Recovered from `full_name` when `owner`/`name` are absent. + assertThat(repos[1].owner).isEqualTo("acme") + assertThat(repos[1].isPrivate).isTrue() + assertThat(server.takeRequest().path).isEqualTo("/api/github/repos") + } + + @Test + fun `getRepos scopes to one organization with the org query parameter`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("[]")) + + repository.getRepos(org = "acme") + + assertThat(server.takeRequest().path).isEqualTo("/api/github/repos?org=acme") + } + + @Test + fun `getRepos drops a repository it cannot identify`() = runTest(dispatcher) { + // No owner and no full_name — the picker's whole output is "owner/repo", + // and a half-identified one would only be rejected by POST /api/lists. + server.enqueue(MockResponse().setBody("""[ { "private": false }, { "full_name": "a/b" } ]""")) + + val repos = (repository.getRepos() as ApiResult.Success).data + + assertThat(repos.map { it.fullName }).containsExactly("a/b") + } + + @Test + fun `getOrgs maps the organization logins`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """[ { "login": "acme", "avatar_url": "https://x/y.png" }, { "login": "" } ]""", + ), + ) + + val orgs = (repository.getOrgs() as ApiResult.Success).data + + assertThat(orgs.map { it.login }).containsExactly("acme") + assertThat(orgs.first().avatarUrl).isEqualTo("https://x/y.png") + assertThat(server.takeRequest().path).isEqualTo("/api/github/orgs") + } + + @Test + fun `getNextIssueNumber reads nextNumber for the owner and repo`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("""{ "nextNumber": 11231 }""")) + + val next = (repository.getNextIssueNumber("octocat/Hello-World") as ApiResult.Success).data + + assertThat(next).isEqualTo(11231) + assertThat(server.takeRequest().path) + .isEqualTo("/api/github/repos/octocat/Hello-World/next-issue-number") + } + + @Test + fun `getNextIssueNumber answers null for a malformed repo without a request`() = runTest(dispatcher) { + val next = (repository.getNextIssueNumber("nosuchslash") as ApiResult.Success).data + + assertThat(next).isNull() + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `an unlinked GitHub account is reported as something the user can fix`() = runTest(dispatcher) { + // Every /api/github/… endpoint answers 400 with this body when unlinked. + server.enqueue( + MockResponse().setResponseCode(400) + .setBody("""{ "error": "GitHub account not linked" }"""), + ) + + val failure = repository.getRepos() as ApiResult.Failure + + assertThat(failure.error.toGithubLinkProblem()).isEqualTo(GithubLinkProblem.NOT_LINKED) + } + + @Test + fun `a refused GitHub token asks the user to reconnect for Issues`() = runTest(dispatcher) { + // The proxy forwards GitHub's own 401 (observed: code "github_error"). + server.enqueue( + MockResponse().setResponseCode(401) + .setBody("""{ "error": "Unauthorized", "code": "github_error" }"""), + ) + + val failure = repository.getRepos() as ApiResult.Failure + + assertThat(failure.error.toGithubLinkProblem()).isEqualTo(GithubLinkProblem.NEEDS_ISSUES_SCOPE) + } + + @Test + fun `an ordinary failure is not mistaken for a linking problem`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(500).setBody("""{ "error": "boom" }""")) + + val failure = repository.getRepos() as ApiResult.Failure + + assertThat(failure.error.toGithubLinkProblem()).isNull() + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryGithubTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryGithubTest.kt new file mode 100644 index 0000000..f53e743 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryGithubTest.kt @@ -0,0 +1,260 @@ +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.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * GitHub-backed lists at the repository layer, driven through the real + * Retrofit/OkHttp stack so the assertions are about the bytes that reach the API. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultListsRepositoryGithubTest { + + private lateinit var server: MockWebServer + private lateinit var api: ListsApi + private lateinit var dao: FakeGithubDao + 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 = FakeGithubDao() + repository = DefaultListsRepository(api, dao, json, testDispatchers) + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `createGithubList sends source, githubRepo and githubSource`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(201).setBody( + """ + { "message": "created", "data": { + "id": "lst_gh01", "title": "Hello-World", "source": "github", + "githubRepo": "octocat/Hello-World", "githubRepoPrivate": false } } + """.trimIndent(), + ), + ) + + val result = repository.createGithubList( + repo = "octocat/Hello-World", + title = "Hello-World", + isPublic = false, + ) + + val body = server.takeRequest().body.readUtf8().let(json::parseToJsonElement).jsonObject + assertThat(body["source"]).isEqualTo(JsonPrimitive("github")) + assertThat(body["githubRepo"]).isEqualTo(JsonPrimitive("octocat/Hello-World")) + // Rows mirror issues — the only mapping the API documents. + assertThat(body["githubSource"]).isEqualTo(JsonPrimitive("issues")) + assertThat(body["title"]).isEqualTo(JsonPrimitive("Hello-World")) + + val summary = (result as ApiResult.Success).data + assertThat(summary.source).isEqualTo(ListSource.GITHUB) + assertThat(summary.githubRepo).isEqualTo("octocat/Hello-World") + assertThat(summary.isGithubBacked).isTrue() + } + + @Test + fun `createGithubList falls back to the repo name when no title is given`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(201) + .setBody("""{ "data": { "id": "lst_gh01", "title": "Hello-World" } }"""), + ) + + repository.createGithubList(repo = "octocat/Hello-World", title = " ") + + val body = server.takeRequest().body.readUtf8().let(json::parseToJsonElement).jsonObject + assertThat(body["title"]).isEqualTo(JsonPrimitive("Hello-World")) + } + + @Test + fun `createGithubList refuses a malformed repo without spending a request`() = runTest(dispatcher) { + // The server's own rule: `githubRepo … (format: owner/repo)`. + val result = repository.createGithubList(repo = "nosuchslash", title = "Issues") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `an ordinary createList still sends no github fields`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(201).setBody("""{ "data": { "id": "lst_1", "title": "Books" } }"""), + ) + + repository.createList(title = "Books") + + val body = server.takeRequest().body.readUtf8().let(json::parseToJsonElement).jsonObject + assertThat(body.keys).containsNoneOf("githubRepo", "githubSource", "source") + } + + @Test + fun `a list response carries source, repo and repository visibility`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { "lists": [ + { "id": "lst_gh01", "title": "Repo issues", "source": "github", + "githubRepo": "octocat/Hello-World", "githubRepoPrivate": true }, + { "id": "lst_1", "title": "Books", "source": "local" } + ], "pagination": { "total": 2, "limit": 20, "offset": 0, "hasMore": false } } + """.trimIndent(), + ), + ) + + val page = (repository.refreshLists() as ApiResult.Success).data + + val gh = page.items.first() + assertThat(gh.source).isEqualTo(ListSource.GITHUB) + assertThat(gh.githubRepo).isEqualTo("octocat/Hello-World") + assertThat(gh.githubRepoPrivate).isTrue() + assertThat(gh.isGithubBacked).isTrue() + + val local = page.items[1] + assertThat(local.source).isEqualTo(ListSource.LOCAL) + assertThat(local.isGithubBacked).isFalse() + // Visibility the server never recorded stays unknown, not "public". + assertThat(local.githubRepoPrivate).isNull() + + // And it all survives the offline cache, so an offline open still shows the tag. + val cached = ListMapper.summaryFromEntity(dao.cached.first { it.id == "lst_gh01" }) + assertThat(cached.isGithubBacked).isTrue() + assertThat(cached.githubRepoPrivate).isTrue() + } + + @Test + fun `getListDetail reads a GitHub list's fixed columns from the inlined properties`() = + runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { "data": { + "id": "lst_gh01", "title": "Repo issues", "source": "github", + "githubRepo": "octocat/Hello-World", "githubRepoPrivate": true, + "properties": [ + { "id": "gh_number", "propertyKey": "number", "propertyName": "Issue #", + "propertyType": "number", "isReadOnly": true }, + { "id": "gh_title", "propertyKey": "title", "propertyName": "Title", + "propertyType": "text", "isRequired": true, "isReadOnly": false }, + { "id": "gh_state", "propertyKey": "state", "propertyName": "State", + "propertyType": "select", "isReadOnly": false, + "validationRules": { "options": ["open", "closed"] } } + ] } } + """.trimIndent(), + ), + ) + // A GitHub list has no stored schema: the dedicated endpoint says nothing. + server.enqueue(MockResponse().setResponseCode(404).setBody("""{ "error": "not found" }""")) + server.enqueue(MockResponse().setBody("""{ "data": [] }""")) + + val detail = (repository.getListDetail("lst_gh01") as ApiResult.Success).data + + assertThat(detail.schema.fields.map { it.key }) + .containsExactly("number", "title", "state").inOrder() + val number = detail.schema.fields.first() + assertThat(number.label).isEqualTo("Issue #") + // GitHub assigns it, so the row form must not offer it as an input. + assertThat(number.readOnly).isTrue() + assertThat(detail.schema.fields[1].readOnly).isFalse() + assertThat(detail.schema.fields[1].required).isTrue() + assertThat(detail.schema.fields[2].options).containsExactly("open", "closed").inOrder() + assertThat(detail.summary.isGithubBacked).isTrue() + } + + @Test + fun `refreshGithubList posts to the refresh route and reports what changed`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody("""{ "success": true, "added": 2, "updated": 1, "removed": 0 }"""), + ) + + val result = (repository.refreshGithubList("lst_gh01") as ApiResult.Success).data + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/lists/lst_gh01/refresh") + assertThat(result.summary).isEqualTo("2 added, 1 updated") + } + + @Test + fun `updateList can re-parent a list`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """{ "data": { "id": "lst_gh01", "title": "Repo issues", "parentId": "lst_parent" } }""", + ), + ) + + val result = repository.updateList(id = "lst_gh01", parentId = "lst_parent") + + val body = server.takeRequest().body.readUtf8().let(json::parseToJsonElement).jsonObject + // Only the parent moves; nothing the caller left alone is asserted. + assertThat(body.keys).containsExactly("parentId") + assertThat((result as ApiResult.Success).data.parentId).isEqualTo("lst_parent") + } +} + +/** In-memory [ListDao] backed by a StateFlow, for JVM repository tests. */ +private class FakeGithubDao : 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() + } + + override suspend fun replaceAll(lists: List) { + state.value = lists + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/domain/GithubRepoLinkTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/domain/GithubRepoLinkTest.kt new file mode 100644 index 0000000..5f46845 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/domain/GithubRepoLinkTest.kt @@ -0,0 +1,67 @@ +package com.interlinedlist.android.feature.lists.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * The repository link and its **Private repo** tag. + * + * The tag describes the *repository's* visibility on GitHub, not the list's, and + * the two are set separately. Copy that blurs them tells a user the opposite of + * the truth about who can see their data, so the wording is pinned here rather + * than left to a reviewer's eye. + */ +class GithubRepoLinkTest { + + @Test + fun `the private tag names the repository, never the list`() { + val explanation = GithubRepoLink.PRIVATE_TAG_EXPLANATION + + assertThat(GithubRepoLink.PRIVATE_TAG).isEqualTo("Private repo") + // It is the repository that is private. + assertThat(explanation).contains("This repository is private on GitHub") + // And it says so about the list explicitly, so the two cannot be confused. + assertThat(explanation).contains("separate from who can see this list") + // It never claims the list itself is private. + assertThat(explanation).doesNotContain("private list") + assertThat(explanation).doesNotContain("This list is private") + } + + @Test + fun `the explanation warns what a collaborator without repo access will hit`() { + val explanation = GithubRepoLink.PRIVATE_TAG_EXPLANATION + + assertThat(explanation).contains("sign in") + assertThat(explanation).contains("not found") + // And where access actually comes from. + assertThat(explanation).contains("granted on GitHub") + } + + @Test + fun `the tag shows only when the repository is known to be private`() { + assertThat(GithubRepoLink.showsPrivateTag(true)).isTrue() + assertThat(GithubRepoLink.showsPrivateTag(false)).isFalse() + // Unknown visibility (a list that has not synced since the tag existed) + // shows no tag — it is never presented as public. + assertThat(GithubRepoLink.showsPrivateTag(null)).isFalse() + } + + @Test + fun `the link points at the repository's issues page`() { + assertThat(GithubRepoLink.label("octocat/Hello-World")).isEqualTo("octocat/Hello-World issues") + assertThat(GithubRepoLink.issuesUrl("octocat/Hello-World")) + .isEqualTo("https://github.com/octocat/Hello-World/issues") + } + + @Test + fun `repo validation matches the server's owner slash repo rule`() { + assertThat(isValidGithubRepo("octocat/Hello-World")).isTrue() + assertThat(isValidGithubRepo(" octocat/Hello-World ")).isTrue() + // The server answers 400 for anything else. + assertThat(isValidGithubRepo("nosuchslash")).isFalse() + assertThat(isValidGithubRepo("too/many/slashes")).isFalse() + assertThat(isValidGithubRepo("/Hello-World")).isFalse() + assertThat(isValidGithubRepo("octocat/")).isFalse() + assertThat(isValidGithubRepo("")).isFalse() + } +} 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 a76bb5c..deb0304 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 @@ -3,11 +3,13 @@ package com.interlinedlist.android.feature.lists.ui.detail import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.FakeGithubRepository import com.interlinedlist.android.feature.lists.FakeListsRepository import com.interlinedlist.android.feature.lists.domain.FieldType import com.interlinedlist.android.feature.lists.domain.ListDetail 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.RefreshResult import com.interlinedlist.android.feature.lists.domain.SchemaField @@ -40,8 +42,29 @@ class ListDetailViewModelTest { rows = rows, ) - private fun viewModel(repo: FakeListsRepository) = - ListDetailViewModel(repo, SavedStateHandle(mapOf(LIST_ID_ARG to "L1"))) + /** The same list, but mirroring a GitHub repository's issues. */ + private fun githubDetail(rows: List) = ListDetail( + summary = ListSummary( + id = "L1", + title = "Repo issues", + description = null, + itemCount = rows.size, + folderId = null, + isPublic = false, + updatedAt = null, + parentId = null, + source = ListSource.GITHUB, + githubRepo = "octocat/Hello-World", + githubRepoPrivate = true, + ), + schema = schema, + rows = rows, + ) + + private fun viewModel( + repo: FakeListsRepository, + github: FakeGithubRepository = FakeGithubRepository(), + ) = ListDetailViewModel(repo, github, SavedStateHandle(mapOf(LIST_ID_ARG to "L1"))) @Before fun setUp() = Dispatchers.setMain(dispatcher) @@ -200,7 +223,7 @@ class ListDetailViewModelTest { @Test fun `refreshFromGithub surfaces a summary and reloads the rows`() = runTest(dispatcher) { val repo = FakeListsRepository().apply { - detailResult = ApiResult.Success(detail(emptyList())) + detailResult = ApiResult.Success(githubDetail(emptyList())) refreshGithubResult = ApiResult.Success( RefreshResult(message = null, added = 2, updated = 0, removed = 0), ) @@ -209,7 +232,7 @@ class ListDetailViewModelTest { advanceUntilIdle() // After the refresh, the reload returns freshly-synced rows. - repo.detailResult = ApiResult.Success(detail(listOf(ListRow("r1", mapOf("title" to "Synced"))))) + repo.detailResult = ApiResult.Success(githubDetail(listOf(ListRow("r1", mapOf("title" to "Synced"))))) vm.refreshFromGithub() advanceUntilIdle() @@ -222,7 +245,7 @@ class ListDetailViewModelTest { @Test fun `refreshFromGithub failure surfaces an error and clears the spinner`() = runTest(dispatcher) { val repo = FakeListsRepository().apply { - detailResult = ApiResult.Success(detail(emptyList())) + detailResult = ApiResult.Success(githubDetail(emptyList())) refreshGithubResult = FakeListsRepository.subscriptionFailure() } val vm = viewModel(repo) @@ -236,6 +259,90 @@ class ListDetailViewModelTest { assertThat(vm.uiState.value.refreshMessage).isNull() } + @Test + fun `refresh is not offered or spent on a local list`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(detail(emptyList())) + } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.isGithubBacked).isFalse() + vm.refreshFromGithub() + advanceUntilIdle() + + // `POST /api/lists/{id}/refresh` 400s on a local list; do not call it. + assertThat(repo.refreshGithubCount).isEqualTo(0) + } + + @Test + fun `a GitHub-backed list exposes its repository and repository visibility`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(githubDetail(emptyList())) + } + val vm = viewModel(repo) + advanceUntilIdle() + + val summary = vm.uiState.value.summary!! + assertThat(vm.uiState.value.isGithubBacked).isTrue() + assertThat(summary.githubRepo).isEqualTo("octocat/Hello-World") + // The repository is private on GitHub; the list itself is merely not public. + assertThat(summary.githubRepoPrivate).isTrue() + assertThat(summary.isPublic).isFalse() + } + + @Test + fun `the next issue number is fetched for a GitHub-backed list`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(githubDetail(emptyList())) + } + val github = FakeGithubRepository().apply { + nextIssueNumberResult = ApiResult.Success(42) + } + val vm = viewModel(repo, github) + advanceUntilIdle() + + assertThat(github.lastNextIssueRepo).isEqualTo("octocat/Hello-World") + assertThat(vm.uiState.value.nextIssueNumber).isEqualTo(42) + } + + @Test + fun `a local list never asks GitHub for an issue number`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { detailResult = ApiResult.Success(detail(emptyList())) } + val github = FakeGithubRepository() + val vm = viewModel(repo, github) + advanceUntilIdle() + + assertThat(github.lastNextIssueRepo).isNull() + assertThat(vm.uiState.value.nextIssueNumber).isNull() + } + + @Test + fun `an unavailable issue number just drops the hint`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(githubDetail(emptyList())) + } + val github = FakeGithubRepository().apply { + nextIssueNumberResult = FakeListsRepository.subscriptionFailure() + } + val vm = viewModel(repo, github) + advanceUntilIdle() + + // Informational only: the list still loaded fine. + assertThat(vm.uiState.value.nextIssueNumber).isNull() + assertThat(vm.uiState.value.errorMessage).isNull() + } + + @Test + fun `the row form names the issue operation a save performs`() { + assertThat(githubIssueHint("octocat/Hello-World", isNewRow = true, nextIssueNumber = 42)) + .isEqualTo("Saving opens issue #42 in octocat/Hello-World.") + assertThat(githubIssueHint("octocat/Hello-World", isNewRow = true, nextIssueNumber = null)) + .isEqualTo("Saving opens a new issue in octocat/Hello-World.") + assertThat(githubIssueHint("octocat/Hello-World", isNewRow = false, nextIssueNumber = 42)) + .isEqualTo("Saving updates the matching issue in octocat/Hello-World.") + } + // --- Parent chain (breadcrumb) + child lists ----------------------------- private fun child(parentId: String?) = ListDetail( diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModelTest.kt index d369a25..56792a0 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModelTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModelTest.kt @@ -111,19 +111,6 @@ class ListsViewModelTest { assertThat(repo.loadMoreCount).isEqualTo(0) } - @Test - fun `createList reports the created list id via callback`() = runTest(dispatcher) { - val repo = FakeListsRepository() - val vm = ListsViewModel(repo) - advanceUntilIdle() - - var createdId: String? = null - vm.createList("Groceries", null) { createdId = it.id } - advanceUntilIdle() - - assertThat(createdId).isEqualTo("new") - } - @Test fun `search shows server results and clearing restores the cached index`() = runTest(dispatcher) { val repo = FakeListsRepository().apply { diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListViewModelTest.kt new file mode 100644 index 0000000..c7be847 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/list/NewListViewModelTest.kt @@ -0,0 +1,234 @@ +package com.interlinedlist.android.feature.lists.ui.list + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.lists.FakeGithubRepository +import com.interlinedlist.android.feature.lists.FakeListsRepository +import com.interlinedlist.android.feature.lists.domain.GithubOrg +import com.interlinedlist.android.feature.lists.domain.GithubRepo +import com.interlinedlist.android.feature.lists.domain.ListSource +import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.ui.github.GithubLinkProblem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * The "New list" sheet: a local list, or one backed by a GitHub repository. + * + * The GitHub half has to behave sanely for a user who has not linked GitHub — the + * picker must explain itself rather than render an empty control. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NewListViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private val helloWorld = GithubRepo(owner = "octocat", name = "Hello-World") + private val secretPlans = GithubRepo(owner = "acme", name = "secret-plans", isPrivate = true) + + private fun viewModel( + lists: FakeListsRepository = FakeListsRepository(), + github: FakeGithubRepository = FakeGithubRepository(), + ) = NewListViewModel(lists, github) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `creating a GitHub-backed list sends the repo as a github source`() = runTest(dispatcher) { + val lists = FakeListsRepository() + val github = FakeGithubRepository().apply { + reposResult = ApiResult.Success(listOf(helloWorld)) + } + val vm = viewModel(lists, github) + + vm.selectKind(NewListKind.GITHUB) + advanceUntilIdle() + vm.selectRepo(helloWorld) + + var created: ListSummary? = null + vm.create { created = it } + advanceUntilIdle() + + assertThat(lists.lastCreateSource).isEqualTo(ListSource.GITHUB) + assertThat(lists.lastCreateGithubRepo).isEqualTo("octocat/Hello-World") + assertThat(lists.lastCreateGithubSource).isEqualTo("issues") + assertThat(created).isNotNull() + } + + @Test + fun `picking a repository defaults the title to the repository name`() = runTest(dispatcher) { + val vm = viewModel() + + vm.selectRepo(helloWorld) + + assertThat(vm.uiState.value.title).isEqualTo("Hello-World") + assertThat(vm.uiState.value.canCreate).isTrue() + } + + @Test + fun `a title the user already typed is not overwritten by the repo name`() = runTest(dispatcher) { + val vm = viewModel() + + vm.onTitleChange("Roadmap") + vm.selectRepo(helloWorld) + + assertThat(vm.uiState.value.title).isEqualTo("Roadmap") + } + + @Test + fun `creating a local list sends no github fields`() = runTest(dispatcher) { + val lists = FakeListsRepository() + val vm = viewModel(lists) + + vm.onTitleChange("Books") + vm.create { } + advanceUntilIdle() + + assertThat(lists.lastCreateSource).isNull() + assertThat(lists.lastCreateGithubRepo).isNull() + assertThat(lists.lastCreateGithubSource).isNull() + } + + @Test + fun `nothing GitHub is fetched until the GitHub tab is opened`() = runTest(dispatcher) { + val github = FakeGithubRepository() + val vm = viewModel(github = github) + advanceUntilIdle() + + assertThat(github.reposCount).isEqualTo(0) + + vm.selectKind(NewListKind.GITHUB) + advanceUntilIdle() + + assertThat(github.reposCount).isEqualTo(1) + } + + @Test + fun `an unlinked GitHub account explains itself instead of showing an empty picker`() = + runTest(dispatcher) { + val github = FakeGithubRepository().apply { + reposResult = ApiResult.Failure(AppError.Unknown("GitHub account not linked")) + } + val vm = viewModel(github = github) + + vm.selectKind(NewListKind.GITHUB) + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.linkProblem).isEqualTo(GithubLinkProblem.NOT_LINKED) + // The panel carries the explanation, so no duplicate generic error. + assertThat(state.errorMessage).isNull() + assertThat(state.canCreate).isFalse() + // "No repositories" must not be claimed when we never got a list. + assertThat(state.hasNoRepos).isFalse() + } + + @Test + fun `a GitHub token without the Issues scope routes the user to reconnect`() = runTest(dispatcher) { + val github = FakeGithubRepository().apply { + reposResult = ApiResult.Failure(AppError.Unauthorized("Unauthorized")) + } + val vm = viewModel(github = github) + + vm.selectKind(NewListKind.GITHUB) + advanceUntilIdle() + + assertThat(vm.uiState.value.linkProblem).isEqualTo(GithubLinkProblem.NEEDS_ISSUES_SCOPE) + } + + @Test + fun `a linked account with no reachable repositories is called out as such`() = runTest(dispatcher) { + val vm = viewModel(github = FakeGithubRepository()) + + vm.selectKind(NewListKind.GITHUB) + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.linkProblem).isNull() + assertThat(state.hasNoRepos).isTrue() + } + + @Test + fun `choosing an organization re-scopes the repo query`() = runTest(dispatcher) { + val github = FakeGithubRepository().apply { + orgsResult = ApiResult.Success(listOf(GithubOrg("acme"))) + reposResult = ApiResult.Success(listOf(helloWorld, secretPlans)) + } + val vm = viewModel(github = github) + + vm.selectKind(NewListKind.GITHUB) + advanceUntilIdle() + assertThat(vm.uiState.value.orgs.map { it.login }).containsExactly("acme") + assertThat(github.lastReposOrg).isNull() + + vm.selectOrg("acme") + advanceUntilIdle() + + assertThat(github.lastReposOrg).isEqualTo("acme") + // The org list is fetched once, not again per scope change. + assertThat(github.orgsCount).isEqualTo(1) + } + + @Test + fun `the repo filter searches owner and name`() = runTest(dispatcher) { + val github = FakeGithubRepository().apply { + reposResult = ApiResult.Success(listOf(helloWorld, secretPlans)) + } + val vm = viewModel(github = github) + + vm.selectKind(NewListKind.GITHUB) + advanceUntilIdle() + + vm.onRepoQueryChange("acme") + assertThat(vm.uiState.value.visibleRepos.map { it.fullName }).containsExactly("acme/secret-plans") + + vm.onRepoQueryChange("hello") + assertThat(vm.uiState.value.visibleRepos.map { it.fullName }).containsExactly("octocat/Hello-World") + } + + @Test + fun `a failed create keeps the form filled and surfaces the reason`() = runTest(dispatcher) { + val lists = FakeListsRepository().apply { + createResult = ApiResult.Failure(AppError.SubscriptionRequired("Subscribe to create lists.")) + } + val vm = viewModel(lists) + + vm.onTitleChange("Books") + vm.create { } + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.title).isEqualTo("Books") + assertThat(state.subscriptionRequired).isTrue() + assertThat(state.errorMessage).isNotNull() + } + + @Test + fun `create is refused until there is something to create`() = runTest(dispatcher) { + val lists = FakeListsRepository() + val vm = viewModel(lists) + + // Local with no title. + vm.create { } + advanceUntilIdle() + assertThat(lists.lastCreateGithubRepo).isNull() + assertThat(vm.uiState.value.canCreate).isFalse() + + // GitHub with no repository chosen. + vm.selectKind(NewListKind.GITHUB) + vm.onTitleChange("Anything") + advanceUntilIdle() + assertThat(vm.uiState.value.canCreate).isFalse() + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModelTest.kt index 6fe54ff..e6b2191 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModelTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModelTest.kt @@ -7,6 +7,7 @@ import com.interlinedlist.android.feature.lists.FakeListsRepository import com.interlinedlist.android.feature.lists.domain.FieldType import com.interlinedlist.android.feature.lists.domain.ListDetail 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.SchemaField import kotlinx.coroutines.Dispatchers @@ -31,6 +32,33 @@ class SchemaEditorViewModelTest { rows = emptyList(), ) + /** The nine fixed GitHub issue columns, abbreviated to three. */ + private val githubSchema = ListSchema( + listOf( + SchemaField("number", "Issue #", FieldType.NUMBER, readOnly = true), + SchemaField("title", "Title", FieldType.TEXT, required = true), + SchemaField("state", "State", FieldType.SELECT, options = listOf("open", "closed")), + ), + ) + + private fun githubDetail() = ListDetail( + summary = ListSummary( + id = "L1", + title = "Repo issues", + description = null, + itemCount = 0, + folderId = null, + isPublic = false, + updatedAt = null, + parentId = null, + source = ListSource.GITHUB, + githubRepo = "octocat/Hello-World", + githubRepoPrivate = true, + ), + schema = githubSchema, + rows = emptyList(), + ) + private fun viewModel(repo: FakeListsRepository) = SchemaEditorViewModel(repo, SavedStateHandle(mapOf(SCHEMA_LIST_ID_ARG to "L1"))) @@ -129,4 +157,82 @@ class SchemaEditorViewModelTest { assertThat(vm.uiState.value.subscriptionRequired).isTrue() assertThat(vm.uiState.value.errorMessage).isNotNull() } + + // --- GitHub-backed lists: the schema is fixed, parent-only --------------- + + @Test + fun `a GitHub-backed list locks the schema editor`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { detailResult = ApiResult.Success(githubDetail()) } + val vm = viewModel(repo) + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.isSchemaLocked).isTrue() + assertThat(state.canEditColumns).isFalse() + assertThat(state.canSave).isFalse() + // The fixed columns are still shown, so the user can see what they get. + assertThat(state.columns.map { it.key }).containsExactly("number", "title", "state").inOrder() + assertThat(state.columns.first().readOnly).isTrue() + assertThat(state.githubRepo).isEqualTo("octocat/Hello-World") + } + + @Test + fun `a local list leaves the schema editor unlocked`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success( + detail(ListSchema(listOf(SchemaField("title", "Title", FieldType.TEXT)))), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.isSchemaLocked).isFalse() + assertThat(state.canEditColumns).isTrue() + assertThat(state.canSave).isTrue() + } + + @Test + fun `a locked editor refuses every column edit rather than failing at save`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { detailResult = ApiResult.Success(githubDetail()) } + val vm = viewModel(repo) + advanceUntilIdle() + val before = vm.uiState.value.columns + + vm.addColumn() + vm.updateKey(before.first().uiId, "hijacked") + vm.updateLabel(before.first().uiId, "Hijacked") + vm.updateType(before.first().uiId, FieldType.BOOLEAN) + vm.removeColumn(before.first().uiId) + vm.save() + advanceUntilIdle() + + assertThat(vm.uiState.value.columns).isEqualTo(before) + // Nothing reaches the server, so there is no confusing server-side error. + assertThat(repo.updateSchemaCount).isEqualTo(0) + assertThat(vm.uiState.value.saved).isFalse() + } + + @Test + fun `the parent list is the one thing a locked editor can change`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { detailResult = ApiResult.Success(githubDetail()) } + repo.cache.value = listOf( + ListSummary("L1", "Repo issues", null, 0, null, false, null), + ListSummary("L2", "Projects", null, 0, null, false, null), + ) + val vm = viewModel(repo) + advanceUntilIdle() + + // A list cannot be its own parent. + assertThat(vm.uiState.value.parentOptions.map { it.id }).containsExactly("L2") + + vm.setParent("L2") + advanceUntilIdle() + + assertThat(repo.lastUpdatedParentId).isEqualTo("L2") + assertThat(vm.uiState.value.parentId).isEqualTo("L2") + // Only the parent moved; the title and visibility were not asserted. + assertThat(repo.lastUpdatedTitle).isNull() + assertThat(repo.lastUpdatedIsPublic).isNull() + } }