diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsHomepageSettings.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsHomepageSettings.kt new file mode 100644 index 000000000000..a6b78faec98c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsHomepageSettings.kt @@ -0,0 +1,115 @@ +package org.wordpress.android.ui.pagesrs + +import kotlinx.coroutines.CancellationException +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.SiteActionBuilder +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.SiteSettingsUpdateParams +import javax.inject.Inject + +/** + * Updates a site's homepage settings (page shown on front / page for posts) through the + * wordpress-rs `/wp/v2/settings` endpoint, so it works on both WP.com and self-hosted + * application-password sites — unlike FluxC's SiteOptionsStore, which is WP.com-only. + * + * Mirrors SiteOptionsStore's semantics: the update is rejected unless the site shows a + * static page on front, and assigning a page to one slot clears it from the other (core's + * settings endpoint does not auto-clear). Current values are read live from the server + * rather than from [SiteModel], whose homepage fields aren't reliably populated for + * self-hosted sites. On success the shared [SiteModel] is updated in place and + * re-dispatched so the rest of the app (virtual rows, My Site, the legacy list) stays + * in sync. + */ +internal class PageRsHomepageSettings @Inject constructor( + private val wpApiClientProvider: WpApiClientProvider, + private val dispatcher: Dispatcher, +) { + sealed interface Result { + data object Success : Result + + /** The site's theme shows latest posts on front, so homepage pages can't be set. */ + data object StaticHomepageDisabled : Result + + data class Error(val message: String?) : Result + } + + suspend fun setHomepage(site: SiteModel, pageId: Long): Result = + update(site, HomepageTarget.PAGE_ON_FRONT, pageId) + + suspend fun setPostsPage(site: SiteModel, pageId: Long): Result = + update(site, HomepageTarget.PAGE_FOR_POSTS, pageId) + + @Suppress("TooGenericExceptionCaught", "ReturnCount") + private suspend fun update(site: SiteModel, target: HomepageTarget, pageId: Long): Result { + try { + val client = wpApiClientProvider.getWpApiClient(site) + + val current = when ( + val response = client.request { it.siteSettings().retrieveWithEditContext() } + ) { + is WpRequestResult.Success -> response.response.data + else -> return response.toError() + } + + val params = computeHomepageUpdateParams( + showOnFront = current.showOnFront, + currentPageOnFront = current.pageOnFront.toLong(), + currentPageForPosts = current.pageForPosts.toLong(), + target = target, + pageId = pageId + ) ?: return Result.StaticHomepageDisabled + + val updated = when ( + val response = client.request { it.siteSettings().update(params) } + ) { + is WpRequestResult.Success -> response.response.data + else -> return response.toError() + } + + site.pageOnFront = updated.pageOnFront.toLong() + site.pageForPosts = updated.pageForPosts.toLong() + site.showOnFront = updated.showOnFront + dispatcher.dispatch(SiteActionBuilder.newUpdateSiteAction(site)) + return Result.Success + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + return Result.Error(e.message) + } + } + + private fun WpRequestResult<*>.toError() = Result.Error( + (this as? WpRequestResult.WpError<*>)?.errorMessage + ) +} + +internal enum class HomepageTarget { PAGE_ON_FRONT, PAGE_FOR_POSTS } + +/** + * Builds the settings update for assigning [pageId] to [target], or returns null when the + * site doesn't show a static page on front. Both homepage fields are always sent: the page + * being assigned, and the other slot — cleared when it currently holds the same page. + */ +internal fun computeHomepageUpdateParams( + showOnFront: String, + currentPageOnFront: Long, + currentPageForPosts: Long, + target: HomepageTarget, + pageId: Long +): SiteSettingsUpdateParams? { + if (showOnFront != SHOW_ON_FRONT_PAGE) return null + return when (target) { + HomepageTarget.PAGE_ON_FRONT -> SiteSettingsUpdateParams( + pageOnFront = pageId.toULong(), + pageForPosts = (if (currentPageForPosts == pageId) 0L else currentPageForPosts).toULong() + ) + HomepageTarget.PAGE_FOR_POSTS -> SiteSettingsUpdateParams( + pageOnFront = (if (currentPageOnFront == pageId) 0L else currentPageOnFront).toULong(), + pageForPosts = pageId.toULong() + ) + } +} + +private const val SHOW_ON_FRONT_PAGE = "page" diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListEvent.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListEvent.kt index c892a035052e..2c3ee5a1059f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListEvent.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListEvent.kt @@ -11,6 +11,20 @@ internal sealed interface PageRsListEvent { data object CreateNewPage : PageRsListEvent + data class ViewPage(val url: String) : PageRsListEvent + + data class SharePage( + val url: String, + val title: String + ) : PageRsListEvent + + data class CopyPageUrl(val url: String) : PageRsListEvent + + data class PromoteWithBlaze( + val site: SiteModel, + val page: PostModel + ) : PageRsListEvent + data class ShowToast( val messageResId: Int ) : PageRsListEvent diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListUiState.kt index 34f038fb07d6..7791fd479a13 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListUiState.kt @@ -1,5 +1,6 @@ package org.wordpress.android.ui.pagesrs +import androidx.annotation.DrawableRes import androidx.annotation.StringRes import org.wordpress.android.R import org.wordpress.android.ui.postsrs.PostRsDateFormatter @@ -11,6 +12,31 @@ import uniffi.wp_api.PostStatus import uniffi.wp_mobile.FullEntityAnyPostWithEditContext import uniffi.wp_mobile.PostItemState +/** A destructive or status-changing action awaiting user confirmation in a dialog. */ +internal sealed interface PageRsListConfirmation { + data class Trash(val pageId: Long) : PageRsListConfirmation + data class Delete(val pageId: Long, val pageTitle: String) : PageRsListConfirmation + data class MoveToDraft(val pageId: Long) : PageRsListConfirmation +} + +internal data class PageRsConfirmationDialogState( + val pending: PageRsListConfirmation? = null, + val onConfirm: () -> Unit = {}, + val onDismiss: () -> Unit = {} +) + +/** State for the "Set Parent" bottom sheet. [candidates] excludes the page and its descendants. */ +internal data class PageRsParentPickerState( + val pageId: Long, + val currentParentId: Long, + val candidates: List +) + +internal data class PageRsParentCandidate( + val id: Long, + val title: String +) + internal data class PageTabUiState( val pages: List = emptyList(), val isLoading: Boolean = false, @@ -58,14 +84,41 @@ internal data class PageRsUiModel( val excerpt: String, val date: String, val lastModified: String = "", + val link: String = "", + val hasPassword: Boolean = false, + val status: PostStatus? = null, @StringRes val statusLabelResId: Int = 0, val authorId: Long = 0L, val authorDisplayName: String? = null, val isTrashed: Boolean = false, + val actions: List = emptyList(), val badges: List = emptyList(), val displayState: PageRsDisplayState = PageRsDisplayState.NORMAL ) +internal enum class PageRsMenuAction( + @StringRes val labelResId: Int, + @DrawableRes val iconResId: Int, + val isDestructive: Boolean = false +) { + VIEW(R.string.pages_view, R.drawable.gb_ic_external), + SET_PARENT(R.string.set_parent, R.drawable.gb_ic_pages_set_as_parent), + SET_AS_HOMEPAGE(R.string.pages_set_as_homepage, R.drawable.gb_ic_home_page_24dp), + SET_AS_POSTS_PAGE(R.string.pages_set_as_posts_page, R.drawable.ic_posts_white_24dp), + PUBLISH_NOW(R.string.pages_publish_now, R.drawable.gb_ic_globe), + MOVE_TO_DRAFT(R.string.pages_move_to_draft, R.drawable.gb_ic_move_to), + DUPLICATE(R.string.button_copy, R.drawable.gb_ic_copy), + SHARE(R.string.button_share, R.drawable.gb_ic_share), + COPY_URL(R.string.page_rs_copy_url, R.drawable.ic_attachment_link), + BLAZE(R.string.pages_promote_with_blaze, R.drawable.ic_blaze_flame_24dp), + TRASH(R.string.pages_move_to_trash, R.drawable.gb_ic_trash, isDestructive = true), + DELETE_PERMANENTLY( + R.string.pages_delete_permanently, + R.drawable.gb_ic_trash, + isDestructive = true + ), +} + internal fun PostItemState.toPageUiModel( pageId: Long, showStatus: Boolean = false @@ -111,6 +164,9 @@ private fun FullEntityAnyPostWithEditContext.toPageUiModel( ).let { HtmlUtils.fastStripHtml(it).trim() }, date = PostRsDateFormatter.format(page.dateGmt, page.status), lastModified = DateTimeUtils.iso8601UTCFromDate(page.modifiedGmt), + link = page.link, + hasPassword = !page.password.isNullOrEmpty(), + status = page.status, statusLabelResId = if (showStatus) page.status.toLabel() else 0, authorId = page.author ?: 0L, isTrashed = page.status is PostStatus.Trash, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsMenuActions.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsMenuActions.kt new file mode 100644 index 000000000000..f63c2c64a80c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsMenuActions.kt @@ -0,0 +1,60 @@ +package org.wordpress.android.ui.pagesrs + +import uniffi.wp_api.PostStatus + +/** + * Computes the overflow-menu actions for a page row, keyed off the page's own status (not the + * tab) so search results — which mix statuses — get the right menu. Mirrors the visibility + * rules of the legacy pages list (CreatePageListItemActionsUseCase): the homepage cannot be + * trashed or drafted, "Set as Homepage / Posts Page" only appear when [canManageHomepage] + * (for WP.com sites that means manage-options capability plus a static page on front; for + * self-hosted sites those fields aren't synced, so the actions are offered and verified at + * execution time), and Blaze requires an eligible site and a non-password-protected + * published page. + */ +@Suppress("LongParameterList") +internal fun computePageMenuActions( + status: PostStatus?, + isHomepage: Boolean, + isPostsPage: Boolean, + hasPassword: Boolean, + isBlazeEligibleSite: Boolean, + canManageHomepage: Boolean +): List = when (status) { + is PostStatus.Publish, is PostStatus.Private -> buildList { + add(PageRsMenuAction.VIEW) + add(PageRsMenuAction.SET_PARENT) + if (canManageHomepage && !isHomepage) add(PageRsMenuAction.SET_AS_HOMEPAGE) + if (canManageHomepage && !isPostsPage) add(PageRsMenuAction.SET_AS_POSTS_PAGE) + if (!isHomepage) add(PageRsMenuAction.MOVE_TO_DRAFT) + add(PageRsMenuAction.DUPLICATE) + add(PageRsMenuAction.SHARE) + add(PageRsMenuAction.COPY_URL) + if (isBlazeEligibleSite && !hasPassword && status is PostStatus.Publish) { + add(PageRsMenuAction.BLAZE) + } + if (!isHomepage) add(PageRsMenuAction.TRASH) + } + is PostStatus.Draft, is PostStatus.Pending -> buildList { + add(PageRsMenuAction.VIEW) + add(PageRsMenuAction.SET_PARENT) + add(PageRsMenuAction.PUBLISH_NOW) + add(PageRsMenuAction.DUPLICATE) + add(PageRsMenuAction.SHARE) + add(PageRsMenuAction.COPY_URL) + add(PageRsMenuAction.TRASH) + } + is PostStatus.Future -> buildList { + add(PageRsMenuAction.VIEW) + add(PageRsMenuAction.SET_PARENT) + add(PageRsMenuAction.SHARE) + add(PageRsMenuAction.COPY_URL) + add(PageRsMenuAction.MOVE_TO_DRAFT) + add(PageRsMenuAction.TRASH) + } + is PostStatus.Trash -> listOf( + PageRsMenuAction.MOVE_TO_DRAFT, + PageRsMenuAction.DELETE_PERMANENTLY + ) + is PostStatus.Any, is PostStatus.Custom, null -> emptyList() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListActivity.kt index 392f33bfef15..de4c4da7b05a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListActivity.kt @@ -1,7 +1,9 @@ package org.wordpress.android.ui.pagesrs +import android.content.ClipData import android.content.Context import android.content.Intent +import android.os.Build import android.os.Bundle import androidx.activity.viewModels import androidx.compose.runtime.collectAsState @@ -12,9 +14,11 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.ui.ActivityLauncher import org.wordpress.android.ui.PagePostCreationSourcesDetail.PAGE_FROM_PAGES_LIST +import org.wordpress.android.ui.blaze.BlazeFlowSource import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalPhaseHelper import org.wordpress.android.ui.main.BaseAppCompatActivity @@ -22,6 +26,7 @@ import org.wordpress.android.ui.mlp.ModalLayoutPickerFragment import org.wordpress.android.ui.mlp.ModalLayoutPickerFragment.Companion.MODAL_LAYOUT_PICKER_TAG import org.wordpress.android.ui.pagesrs.screens.PagesRsListScreen import org.wordpress.android.util.ToastUtils +import org.wordpress.android.util.extensions.clipboardManager import org.wordpress.android.util.extensions.setContent import org.wordpress.android.viewmodel.mlp.ModalLayoutPickerViewModel import org.wordpress.android.viewmodel.observeEvent @@ -48,6 +53,8 @@ class PagesRsListActivity : BaseAppCompatActivity() { val isOpeningPage by viewModel.isOpeningPage.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState() val authorFilter by viewModel.authorFilter.collectAsState() + val pendingConfirmation by viewModel.pendingConfirmation.collectAsState() + val parentPicker by viewModel.parentPicker.collectAsState() AppThemeM3 { PagesRsListScreen( tabStates = tabStates, @@ -57,6 +64,12 @@ class PagesRsListActivity : BaseAppCompatActivity() { authorFilter = authorFilter, isAuthorFilterSupported = viewModel.isAuthorFilterSupported, avatarUrl = viewModel.avatarUrl, + confirmationDialog = PageRsConfirmationDialogState( + pending = pendingConfirmation, + onConfirm = viewModel::onConfirmPendingAction, + onDismiss = viewModel::onDismissPendingAction + ), + parentPicker = parentPicker, snackbarMessages = viewModel.snackbarMessages, onSearchOpen = viewModel::onSearchOpen, onSearchQueryChanged = viewModel::onSearchQueryChanged, @@ -68,6 +81,9 @@ class PagesRsListActivity : BaseAppCompatActivity() { onLoadMore = viewModel::loadMorePages, onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, onPageClick = viewModel::openPage, + onPageMenuAction = viewModel::onPageMenuAction, + onParentSelected = viewModel::onParentSelected, + onParentPickerDismissed = viewModel::onParentPickerDismissed, onAddNewPage = viewModel::onAddNewPage ) } @@ -87,11 +103,25 @@ class PagesRsListActivity : BaseAppCompatActivity() { is PageRsListEvent.EditPage -> ActivityLauncher.editPostOrPageForResult(this, event.site, event.page) is PageRsListEvent.CreateNewPage -> startCreatePageFlow() + is PageRsListEvent.ViewPage -> ActivityLauncher.openUrlExternal(this, event.url) + is PageRsListEvent.SharePage -> + ActivityLauncher.openShareIntent(this, event.url, event.title) + is PageRsListEvent.CopyPageUrl -> copyUrlToClipboard(event.url) + is PageRsListEvent.PromoteWithBlaze -> + ActivityLauncher.openPromoteWithBlaze(this, event.page, BlazeFlowSource.PAGES_LIST) is PageRsListEvent.ShowToast -> ToastUtils.showToast(this, event.messageResId) is PageRsListEvent.Finish -> finish() } } + private fun copyUrlToClipboard(url: String) { + clipboardManager?.setPrimaryClip(ClipData.newPlainText(CLIPBOARD_URL_LABEL, url)) + // Android 13+ shows its own confirmation UI when the clipboard changes. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + ToastUtils.showToast(this, R.string.media_edit_copy_url_toast) + } + } + private fun startCreatePageFlow() { if (mlpViewModel.canShowModalLayoutPicker() && jetpackFeatureRemovalPhaseHelper.shouldShowTemplateSelectionInPages() @@ -126,6 +156,8 @@ class PagesRsListActivity : BaseAppCompatActivity() { } companion object { + private const val CLIPBOARD_URL_LABEL = "Page URL" + fun createIntent(context: Context) = Intent(context, PagesRsListActivity::class.java) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt index bea8b8f0aeb5..b02d33b1e6f6 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModel.kt @@ -22,10 +22,18 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker.Stat +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteHomepageSettings.ShowOnFront import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.post.PostStatus as FluxCPostStatus import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.fluxc.store.PostStore +import org.wordpress.android.fluxc.store.PostStore.OnPostUploaded +import org.wordpress.android.ui.blaze.BlazeFeatureUtils import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.posts.AuthorFilterSelection import org.wordpress.android.ui.postsrs.PostRsErrorUtils @@ -41,7 +49,10 @@ import rs.wordpress.cache.kotlin.ObservableMetadataCollection import rs.wordpress.cache.kotlin.getObservablePostMetadataCollectionWithEditContext import rs.wordpress.cache.kotlin.hasMorePages import uniffi.wp_api.PostEndpointType +import uniffi.wp_api.PostStatus +import uniffi.wp_api.PostUpdateParams import uniffi.wp_mobile.PostListFilter +import uniffi.wp_mobile.PostService import uniffi.wp_mobile_cache.ListState import javax.inject.Inject @@ -50,8 +61,12 @@ import javax.inject.Inject internal class PagesRsListViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, private val serviceProvider: WpServiceProvider, + private val dispatcher: Dispatcher, private val restClient: PostRsRestClient, private val resourceProvider: ResourceProvider, + private val postStore: PostStore, + private val homepageSettings: PageRsHomepageSettings, + private val blazeFeatureUtils: BlazeFeatureUtils, private val fluxCBridge: PageRsFluxCBridge, private val networkUtilsWrapper: NetworkUtilsWrapper, private val accountStore: AccountStore, @@ -84,6 +99,12 @@ internal class PagesRsListViewModel @Inject constructor( private val _snackbarMessages = Channel(Channel.BUFFERED) val snackbarMessages = _snackbarMessages.receiveAsFlow() + private val _pendingConfirmation = MutableStateFlow(null) + val pendingConfirmation: StateFlow = _pendingConfirmation.asStateFlow() + + private val _parentPicker = MutableStateFlow(null) + val parentPicker: StateFlow = _parentPicker.asStateFlow() + val site: SiteModel? = selectedSiteRepository.getSelectedSite() val avatarUrl: String? = accountStore.account?.avatarUrl @@ -103,6 +124,7 @@ internal class PagesRsListViewModel @Inject constructor( val authorFilter: StateFlow = _authorFilter.asStateFlow() init { + dispatcher.register(this) if (site == null) { _events.trySend(PageRsListEvent.ShowToast(R.string.blog_not_found)) _events.trySend(PageRsListEvent.Finish) @@ -284,6 +306,29 @@ internal class PagesRsListViewModel @Inject constructor( viewModelScope.coroutineContext + SupervisorJob(viewModelScope.coroutineContext.job) ) + /** + * Fired by FluxC when UploadService finishes uploading a post/page — e.g. publishing a + * duplicated page from the editor, which happens in the background after the editor + * closes. The wordpress-rs collections don't see FluxC uploads, so refresh the tabs to + * pick up the change. + */ + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun onPostUploaded(event: OnPostUploaded) { + val post = event.post ?: return + if (!post.isPage || post.localSiteId != site?.id || event.isError) return + refreshAllTabs() + } + + /** Refreshes all currently initialized tabs. */ + @MainThread + fun refreshAllTabs() { + restClient.clearCaches() + collections.keys.toList().forEach { tab -> + refreshTab(tab) + } + } + @MainThread fun refreshTab(tab: PageRsListTab, isUserRefresh: Boolean = false) { val collection = collections[tab] ?: run { @@ -370,8 +415,8 @@ internal class PagesRsListViewModel @Inject constructor( /** * Bridges the page into FluxC's database and emits [PageRsListEvent.EditPage] to open - * it in the editor. Trashed pages are ignored for Phase 1 (Phase 4 will add the - * "move to draft" confirmation flow). + * it in the editor. Trashed pages can't be edited, so tapping one asks the user to + * move it back to drafts first. */ @MainThread fun openPage(remotePageId: Long, tab: PageRsListTab) { @@ -385,7 +430,7 @@ internal class PagesRsListViewModel @Inject constructor( when { tab == PageRsListTab.TRASHED || page?.isTrashed == true -> - _events.trySend(PageRsListEvent.ShowToast(R.string.pages_list_item_trashed)) + _pendingConfirmation.value = PageRsListConfirmation.MoveToDraft(remotePageId) checkNetwork() -> proceedOpenPage(site, remotePageId, page?.lastModified) } } @@ -428,6 +473,397 @@ internal class PagesRsListViewModel @Inject constructor( _events.trySend(PageRsListEvent.CreateNewPage) } + /** Routes an overflow-menu action tap to the appropriate event, dialog, or mutation. */ + @MainThread + @Suppress("ReturnCount") + fun onPageMenuAction(remotePageId: Long, action: PageRsMenuAction) { + val site = this.site ?: return + analyticsTracker.track( + Stat.PAGES_OPTIONS_PRESSED, + site, + mapOf(TRACKS_OPTION_NAME to action.toAnalyticsAction()) + ) + val page = findPage(remotePageId) + + when (action) { + PageRsMenuAction.VIEW -> { + val url = page?.link?.takeIf { it.isNotBlank() } ?: return logMissingLink(remotePageId) + _events.trySend(PageRsListEvent.ViewPage(url)) + } + PageRsMenuAction.SHARE -> { + val url = page?.link?.takeIf { it.isNotBlank() } ?: return logMissingLink(remotePageId) + _events.trySend(PageRsListEvent.SharePage(url, page.title)) + } + PageRsMenuAction.COPY_URL -> { + val url = page?.link?.takeIf { it.isNotBlank() } ?: return logMissingLink(remotePageId) + _events.trySend(PageRsListEvent.CopyPageUrl(url)) + } + PageRsMenuAction.SET_PARENT -> openParentPicker(remotePageId) + PageRsMenuAction.SET_AS_HOMEPAGE -> setAsHomepage(site, remotePageId) + PageRsMenuAction.SET_AS_POSTS_PAGE -> setAsPostsPage(site, remotePageId) + PageRsMenuAction.PUBLISH_NOW -> publishPage(remotePageId) + PageRsMenuAction.MOVE_TO_DRAFT -> movePageToDraft(remotePageId) + PageRsMenuAction.DUPLICATE -> duplicatePage(site, remotePageId) + PageRsMenuAction.BLAZE -> bridgeAndPromote(site, remotePageId) + PageRsMenuAction.TRASH -> + _pendingConfirmation.value = PageRsListConfirmation.Trash(remotePageId) + PageRsMenuAction.DELETE_PERMANENTLY -> + _pendingConfirmation.value = + PageRsListConfirmation.Delete(remotePageId, page?.title.orEmpty()) + } + } + + @MainThread + fun onConfirmPendingAction() { + when (val confirmation = _pendingConfirmation.value) { + is PageRsListConfirmation.Trash -> trashPage(confirmation.pageId) + is PageRsListConfirmation.Delete -> deletePage(confirmation.pageId) + is PageRsListConfirmation.MoveToDraft -> moveToDraftAndEdit(confirmation.pageId) + null -> Unit + } + _pendingConfirmation.value = null + } + + @MainThread + fun onDismissPendingAction() { + _pendingConfirmation.value = null + } + + /** + * Opens the "Set Parent" bottom sheet. Candidates are the published pages currently + * loaded in any tab, excluding the page itself and its descendants (re-parenting a page + * under its own subtree would create a cycle), matching the legacy parent picker rules. + * + * Known limitation: only pages already loaded into the tabs are offered, so on large + * sites pages beyond the loaded ones can't be chosen and the sheet's search field only + * filters the loaded candidates. A follow-up could page through the full list instead. + */ + @MainThread + fun openParentPicker(remotePageId: Long) { + val allPages = _tabStates.value.values + .flatMap { state -> state.pages.map { it.page } } + .distinctBy { it.remotePageId } + val published = allPages + .filter { it.status is PostStatus.Publish || it.status is PostStatus.Private } + val page = findPage(remotePageId) ?: return + // Descendants are collected across pages of every status: a published descendant + // reached through a draft intermediate must still be excluded to prevent a cycle. + val descendantIds = collectDescendantIds(remotePageId, allPages) + val candidates = published + .filter { it.remotePageId != remotePageId && it.remotePageId !in descendantIds } + .map { PageRsParentCandidate(it.remotePageId, it.title) } + _parentPicker.value = PageRsParentPickerState( + pageId = remotePageId, + currentParentId = page.parentId, + candidates = candidates + ) + } + + @MainThread + fun onParentPickerDismissed() { + _parentPicker.value = null + } + + @MainThread + fun onParentSelected(parentId: Long) { + val picker = _parentPicker.value ?: return + _parentPicker.value = null + val site = this.site + if (parentId == picker.currentParentId || site == null) return + executePageMutation( + successMessageResId = R.string.page_parent_changed, + errorMessageResId = R.string.page_parent_change_error, + logTag = "Set parent", + onSuccess = { + analyticsTracker.track( + Stat.PAGES_SET_PARENT_CHANGES_SAVED, + site, + mapOf( + TRACKS_PAGE_ID to picker.pageId, + TRACKS_NEW_PARENT_ID to parentId + ) + ) + } + ) { service -> + service.updatePost( + PostEndpointType.Pages, picker.pageId, + PostUpdateParams(parent = parentId, meta = null) + ) + } + } + + private fun collectDescendantIds(rootId: Long, pages: List): Set { + val childrenByParent = pages.groupBy { it.parentId } + val descendants = mutableSetOf() + val queue = ArrayDeque(listOf(rootId)) + while (queue.isNotEmpty()) { + val parentId = queue.removeFirst() + childrenByParent[parentId]?.forEach { child -> + if (descendants.add(child.remotePageId)) queue.addLast(child.remotePageId) + } + } + return descendants + } + + private fun trashPage(pageId: Long) = executePageMutation( + successMessageResId = R.string.page_moved_to_trash, + errorMessageResId = R.string.page_status_change_error, + logTag = "Trash" + ) { service -> + service.trashPost(PostEndpointType.Pages, pageId) + } + + private fun deletePage(pageId: Long) = executePageMutation( + successMessageResId = R.string.page_permanently_deleted, + errorMessageResId = R.string.page_delete_error, + logTag = "Delete" + ) { service -> + service.deletePostPermanently(PostEndpointType.Pages, pageId) + } + + private fun publishPage(pageId: Long) = executePageMutation( + successMessageResId = R.string.page_published, + errorMessageResId = R.string.page_status_change_error, + logTag = "Publish" + ) { service -> + service.updatePost( + PostEndpointType.Pages, pageId, + pageStatusUpdate(PostStatus.Publish) + ) + } + + private fun movePageToDraft(pageId: Long) = executePageMutation( + successMessageResId = R.string.page_moved_to_draft, + errorMessageResId = R.string.page_status_change_error, + logTag = "Move to draft" + ) { service -> + service.updatePost( + PostEndpointType.Pages, pageId, + pageStatusUpdate(PostStatus.Draft) + ) + } + + /** + * Moves a trashed page back to drafts and opens it in the editor — the flow behind + * tapping a trashed page, which can't be edited in place. + */ + @Suppress("TooGenericExceptionCaught") + private fun moveToDraftAndEdit(pageId: Long) { + val site = this.site ?: return + if (!checkNetwork()) return + analyticsTracker.track( + Stat.PAGES_LIST_ITEM_SELECTED, + site, + mapOf( + TRACKS_ACTION to "move_to_draft", + TRACKS_PAGE_ID to pageId + ) + ) + updateTabUiState(PageRsListTab.TRASHED) { copy(isRefreshing = true) } + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + serviceProvider.getService(site).posts().updatePost( + PostEndpointType.Pages, pageId, + pageStatusUpdate(PostStatus.Draft) + ) + } + val page = bridgePageOrNull(site, pageId) + if (page != null) { + _events.trySend(PageRsListEvent.EditPage(site, page)) + } else { + _events.trySend(PageRsListEvent.ShowToast(R.string.page_moved_to_draft)) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.e(AppLog.T.PAGES, "Move to draft failed", e) + _snackbarMessages.trySend( + SnackbarMessage(friendlyErrorMessage(e, R.string.page_status_change_error)) + ) + } finally { + updateTabUiState(PageRsListTab.TRASHED) { copy(isRefreshing = false) } + } + } + } + + private fun setAsHomepage(site: SiteModel, pageId: Long) { + if (!checkNetwork()) return + updateHomepageSettings( + successMessageResId = R.string.page_homepage_successfully_updated, + cannotSetMessageResId = R.string.page_cannot_set_homepage, + errorMessageResId = R.string.page_homepage_update_failed + ) { + homepageSettings.setHomepage(site, pageId) + } + } + + private fun setAsPostsPage(site: SiteModel, pageId: Long) { + if (!checkNetwork()) return + updateHomepageSettings( + successMessageResId = R.string.page_posts_page_successfully_updated, + cannotSetMessageResId = R.string.page_cannot_set_posts_page, + errorMessageResId = R.string.page_posts_page_update_failed + ) { + homepageSettings.setPostsPage(site, pageId) + } + } + + /** + * Runs a homepage-settings update via [PageRsHomepageSettings], which syncs the shared + * [SiteModel] on success. The published tab is then re-rendered so the virtual + * Homepage / Posts Page rows reflect the new assignment. + */ + private fun updateHomepageSettings( + successMessageResId: Int, + cannotSetMessageResId: Int, + errorMessageResId: Int, + operation: suspend () -> PageRsHomepageSettings.Result + ) { + viewModelScope.launch { + when (val result = withContext(Dispatchers.IO) { operation() }) { + is PageRsHomepageSettings.Result.Success -> { + _snackbarMessages.trySend( + SnackbarMessage(resourceProvider.getString(successMessageResId)) + ) + launchCollectionJob { loadItemsForTab(PageRsListTab.PUBLISHED) } + } + is PageRsHomepageSettings.Result.StaticHomepageDisabled -> + _snackbarMessages.trySend( + SnackbarMessage(resourceProvider.getString(cannotSetMessageResId)) + ) + is PageRsHomepageSettings.Result.Error -> { + AppLog.w(AppLog.T.PAGES, "Homepage settings update failed: ${result.message}") + _snackbarMessages.trySend( + SnackbarMessage(resourceProvider.getString(errorMessageResId)) + ) + } + } + } + } + + /** + * Duplicates a page by bridging it into FluxC and opening the editor with a new local + * draft carrying the same title and content. + */ + @Suppress("TooGenericExceptionCaught") + private fun duplicatePage(site: SiteModel, remotePageId: Long) { + if (!checkNetwork()) return + _isOpeningPage.value = true + viewModelScope.launch { + try { + val lastModified = findPage(remotePageId)?.lastModified + val pageToCopy = withContext(Dispatchers.IO) { + fluxCBridge.fetchAndBridge(remotePageId, site, lastModified) + } + val newPage = postStore.instantiatePostModel( + site, + true, + pageToCopy.title, + pageToCopy.content, + FluxCPostStatus.DRAFT.toString(), + pageToCopy.categoryIdList, + pageToCopy.postFormat, + true + ) + _events.trySend(PageRsListEvent.EditPage(site, newPage)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.e(AppLog.T.PAGES, "Duplicate page failed", e) + _snackbarMessages.trySend( + SnackbarMessage(friendlyErrorMessage(e, R.string.page_not_found)) + ) + } finally { + _isOpeningPage.value = false + } + } + } + + /** Bridges the page into FluxC and opens the Blaze promotion flow for it. */ + @Suppress("TooGenericExceptionCaught") + private fun bridgeAndPromote(site: SiteModel, remotePageId: Long) { + if (!checkNetwork()) return + _isOpeningPage.value = true + viewModelScope.launch { + try { + val page = bridgePageOrNull(site, remotePageId) + if (page != null) { + _events.trySend(PageRsListEvent.PromoteWithBlaze(site, page)) + } + } finally { + _isOpeningPage.value = false + } + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun bridgePageOrNull(site: SiteModel, remotePageId: Long) = try { + val lastModified = findPage(remotePageId)?.lastModified + withContext(Dispatchers.IO) { + fluxCBridge.fetchAndBridge(remotePageId, site, lastModified) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.e(AppLog.T.PAGES, "Bridge page failed", e) + _snackbarMessages.trySend( + SnackbarMessage(friendlyErrorMessage(e, R.string.page_not_found)) + ) + null + } + + /** Creates a [PostUpdateParams] for changing a page's status. */ + private fun pageStatusUpdate(status: PostStatus) = PostUpdateParams(status = status, meta = null) + + /** + * Executes a page mutation (trash, delete, status change, set parent) with standard + * error handling, handing [operation] the page service for the selected site. The + * wordpress-rs cache notifies the observable collections after the call, so the + * affected tabs re-render without a manual refresh. + */ + @Suppress("TooGenericExceptionCaught") + private fun executePageMutation( + successMessageResId: Int, + errorMessageResId: Int, + logTag: String, + onSuccess: () -> Unit = {}, + operation: suspend (PostService) -> Unit + ) { + val site = this.site ?: return + if (!checkNetwork()) return + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { operation(serviceProvider.getService(site).posts()) } + onSuccess() + _snackbarMessages.trySend( + SnackbarMessage(resourceProvider.getString(successMessageResId)) + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLog.e(AppLog.T.PAGES, "$logTag failed", e) + _snackbarMessages.trySend( + SnackbarMessage(friendlyErrorMessage(e, errorMessageResId)) + ) + } + } + } + + /** Searches all tab states for a [PageRsUiModel] matching [remotePageId]. */ + private fun findPage(remotePageId: Long): PageRsUiModel? { + for (state in _tabStates.value.values) { + for (item in state.pages) { + if (item.remotePageId == remotePageId) return item.page + } + } + return null + } + + private fun logMissingLink(remotePageId: Long) { + AppLog.w(AppLog.T.PAGES, "No link for page $remotePageId") + } + private fun checkNetwork(): Boolean { if (!networkUtilsWrapper.isNetworkAvailable()) { _snackbarMessages.trySend( @@ -478,7 +914,7 @@ internal class PagesRsListViewModel @Inject constructor( applyHierarchy = applyHierarchy, pageOnFront = currentSite?.pageOnFront ?: 0L, pageForPosts = currentSite?.pageForPosts ?: 0L - ) + ).map { row -> row.withMenuActions(currentSite) } updateTabUiState(tab) { copy(pages = rows, isLoading = false, error = null, isAuthError = false) } @@ -521,6 +957,35 @@ internal class PagesRsListViewModel @Inject constructor( } } + private fun PageRsListItem.withMenuActions(site: SiteModel?): PageRsListItem { + val pageOnFront = site?.pageOnFront ?: 0L + val pageForPosts = site?.pageForPosts ?: 0L + // WP.com capabilities and showOnFront are synced reliably, so the homepage actions + // are hidden when they can't succeed, matching the legacy list. For self-hosted + // application-password sites neither field is reliably populated, so the actions + // are offered and the server enforces the rules (a 403 or the static-homepage + // check surfaces as a snackbar). + val canManageHomepage = site != null && if (site.isUsingWpComRestApi) { + site.hasCapabilityManageOptions && site.showOnFront == ShowOnFront.PAGE.value + } else { + true + } + val actions = computePageMenuActions( + status = page.status, + isHomepage = pageOnFront != 0L && page.remotePageId == pageOnFront, + isPostsPage = pageForPosts != 0L && page.remotePageId == pageForPosts, + hasPassword = page.hasPassword, + isBlazeEligibleSite = site != null && blazeFeatureUtils.isSiteBlazeEligible(site), + canManageHomepage = canManageHomepage + ) + if (actions == page.actions) return this + val updated = page.copy(actions = actions) + return when (this) { + is PageRsListItem.Real -> copy(page = updated) + is PageRsListItem.Virtual -> copy(page = updated) + } + } + private fun PageRsListItem.withResolvedAuthor(names: Map): PageRsListItem { val name = names[page.authorId] ?: return this val updated = page.copy(authorDisplayName = name) @@ -608,8 +1073,9 @@ internal class PagesRsListViewModel @Inject constructor( _tabStates.value = emptyMap() } - override fun onCleared() { + public override fun onCleared() { super.onCleared() + dispatcher.unregister(this) clearCollections() } @@ -624,5 +1090,23 @@ internal class PagesRsListViewModel @Inject constructor( private const val TRACKS_ACTION = "action" private const val TRACKS_ACTION_EDIT = "edit" private const val TRACKS_PAGE_ID = "page_id" + private const val TRACKS_OPTION_NAME = "option_name" + private const val TRACKS_NEW_PARENT_ID = "new_parent_id" } } + +/** Tracks values matching the legacy pages list (PagesViewModel.trackMenuSelectionEvent). */ +private fun PageRsMenuAction.toAnalyticsAction(): String = when (this) { + PageRsMenuAction.VIEW -> "view" + PageRsMenuAction.SET_PARENT -> "set_parent" + PageRsMenuAction.SET_AS_HOMEPAGE -> "set_homepage" + PageRsMenuAction.SET_AS_POSTS_PAGE -> "set_posts_page" + PageRsMenuAction.PUBLISH_NOW -> "publish_now" + PageRsMenuAction.MOVE_TO_DRAFT -> "move_to_draft" + PageRsMenuAction.DUPLICATE -> "copy" + PageRsMenuAction.SHARE -> "share" + PageRsMenuAction.COPY_URL -> "copy_url" + PageRsMenuAction.BLAZE -> "promote_with_blaze" + PageRsMenuAction.TRASH -> "move_to_bin" + PageRsMenuAction.DELETE_PERMANENTLY -> "delete_permanently" +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsParentPickerSheet.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsParentPickerSheet.kt new file mode 100644 index 000000000000..a6752fea342c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsParentPickerSheet.kt @@ -0,0 +1,116 @@ +package org.wordpress.android.ui.pagesrs.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.pagesrs.PageRsParentPickerState + +/** + * Bottom sheet for choosing a page's parent: a "Top level" entry followed by the eligible + * published pages, filterable with the search field. The current parent shows a check mark. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PageRsParentPickerSheet( + state: PageRsParentPickerState, + onParentSelected: (Long) -> Unit, + onDismiss: () -> Unit +) { + var query by remember { mutableStateOf("") } + val filteredCandidates = state.candidates.filter { + query.isBlank() || it.title.contains(query, ignoreCase = true) + } + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.set_parent), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + TextField( + value = query, + onValueChange = { query = it }, + placeholder = { Text(stringResource(R.string.search)) }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + LazyColumn(modifier = Modifier.fillMaxWidth()) { + if (query.isBlank()) { + item(key = "top_level") { + ParentCandidateRow( + title = stringResource(R.string.top_level), + isSelected = state.currentParentId == 0L, + onClick = { onParentSelected(0L) } + ) + } + } + items(items = filteredCandidates, key = { it.id }) { candidate -> + ParentCandidateRow( + title = candidate.title.ifBlank { + stringResource(R.string.untitled_in_parentheses) + }, + isSelected = state.currentParentId == candidate.id, + onClick = { onParentSelected(candidate.id) } + ) + } + } + } + } +} + +@Composable +private fun ParentCandidateRow( + title: String, + isSelected: Boolean, + onClick: () -> Unit +) { + ListItem( + headlineContent = { Text(title) }, + trailingContent = { + if (isSelected) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsRow.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsRow.kt index b016c260bbda..1d5e6afdf8a0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsRow.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsRow.kt @@ -1,7 +1,9 @@ package org.wordpress.android.ui.pagesrs.screens import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row @@ -15,32 +17,41 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Article import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator 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.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.wordpress.android.R import org.wordpress.android.ui.pagesrs.PageRsDisplayState import org.wordpress.android.ui.pagesrs.PageRsListItem +import org.wordpress.android.ui.pagesrs.PageRsMenuAction import org.wordpress.android.ui.pagesrs.PageRsUiModel import org.wordpress.android.ui.postsrs.screens.PlaceholderItem -// Follow-up: add the per-page actions menu (view, set as homepage, set parent, move to draft, -// trash, etc.) that the legacy pages list offers on each row. @Composable internal fun PageRsRow( item: PageRsListItem, onClick: () -> Unit, + onMenuAction: (PageRsMenuAction) -> Unit, modifier: Modifier = Modifier ) { val page = item.page @@ -56,6 +67,7 @@ internal fun PageRsRow( indentLevel = indentLevel, virtualKind = virtualKind, onClick = onClick, + onMenuAction = onMenuAction, modifier = modifier ) } @@ -67,6 +79,7 @@ private fun PageContentItem( indentLevel: Int, virtualKind: PageRsListItem.Virtual.Kind?, onClick: () -> Unit, + onMenuAction: (PageRsMenuAction) -> Unit, modifier: Modifier = Modifier ) { Card( @@ -95,7 +108,7 @@ private fun PageContentItem( ) Spacer(modifier = Modifier.width(12.dp)) } - Column(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { val virtualLabel = virtualKind?.let { stringResource(it.labelResId()) } val statusLabel = page.statusLabelResId.takeIf { it != 0 }?.let { stringResource(it) } val bullet = stringResource(R.string.bullet_with_spaces) @@ -134,6 +147,10 @@ private fun PageContentItem( ) } } + if (page.actions.isNotEmpty()) { + Spacer(modifier = Modifier.width(8.dp)) + PageMenuButton(actions = page.actions, onAction = onMenuAction) + } } if (page.displayState == PageRsDisplayState.FETCHING_WITH_DATA) { LinearProgressIndicator( @@ -147,6 +164,57 @@ private fun PageContentItem( } } +@Composable +private fun PageMenuButton( + actions: List, + onAction: (PageRsMenuAction) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .size(width = 40.dp, height = 48.dp) + .clickable( + role = Role.Button, + onClickLabel = stringResource(R.string.more), + onClick = { expanded = true } + ), + contentAlignment = Alignment.CenterEnd + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + actions.forEach { action -> + val color = if (action.isDestructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + } + DropdownMenuItem( + text = { + Text(text = stringResource(action.labelResId), color = color) + }, + onClick = { + expanded = false + onAction(action) + }, + leadingIcon = { + Icon( + painter = painterResource(action.iconResId), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = color + ) + } + ) + } + } + } +} + @Composable private fun BadgeRow(badges: List, modifier: Modifier = Modifier) { FlowRow( @@ -211,7 +279,8 @@ private fun PreviewPageItem() { date = "Dec 15, 2025" ) ), - onClick = {} + onClick = {}, + onMenuAction = {} ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsTabListScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsTabListScreen.kt index c59d6a371943..b2db699f2cba 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsTabListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PageRsTabListScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.distinctUntilChanged import org.wordpress.android.R import org.wordpress.android.ui.pagesrs.PageRsListItem +import org.wordpress.android.ui.pagesrs.PageRsMenuAction import org.wordpress.android.ui.pagesrs.PageTabUiState import org.wordpress.android.ui.postsrs.screens.PlaceholderItem @@ -43,6 +44,7 @@ internal fun PageRsTabListScreen( onRefresh: () -> Unit, onLoadMore: () -> Unit, onPageClick: (Long) -> Unit, + onPageMenuAction: (Long, PageRsMenuAction) -> Unit, modifier: Modifier = Modifier, isSearchIdle: Boolean = false, isSearching: Boolean = false @@ -86,7 +88,8 @@ internal fun PageRsTabListScreen( isLoadingMore = state.isLoadingMore, canLoadMore = state.canLoadMore, onLoadMore = onLoadMore, - onPageClick = onPageClick + onPageClick = onPageClick, + onPageMenuAction = onPageMenuAction ) } } @@ -98,7 +101,8 @@ private fun PageListContent( isLoadingMore: Boolean, canLoadMore: Boolean, onLoadMore: () -> Unit, - onPageClick: (Long) -> Unit + onPageClick: (Long) -> Unit, + onPageMenuAction: (Long, PageRsMenuAction) -> Unit ) { val listState = rememberLazyListState() @@ -125,6 +129,7 @@ private fun PageListContent( PageRsRow( item = item, onClick = { onPageClick(item.remotePageId) }, + onMenuAction = { action -> onPageMenuAction(item.remotePageId, action) }, modifier = Modifier.animateItem() ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PagesRsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PagesRsListScreen.kt index 6ff7c516e30f..03e0dc158bd9 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PagesRsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/pagesrs/screens/PagesRsListScreen.kt @@ -1,6 +1,7 @@ package org.wordpress.android.ui.pagesrs.screens import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -26,6 +27,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.Person +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -42,6 +44,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TooltipAnchorPosition @@ -76,7 +79,11 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.withIndex import kotlinx.coroutines.launch import org.wordpress.android.R +import org.wordpress.android.ui.pagesrs.PageRsConfirmationDialogState +import org.wordpress.android.ui.pagesrs.PageRsListConfirmation import org.wordpress.android.ui.pagesrs.PageRsListTab +import org.wordpress.android.ui.pagesrs.PageRsMenuAction +import org.wordpress.android.ui.pagesrs.PageRsParentPickerState import org.wordpress.android.ui.pagesrs.PageTabUiState import org.wordpress.android.ui.pagesrs.PagesRsListViewModel.Companion.MIN_SEARCH_QUERY_LENGTH import org.wordpress.android.ui.posts.AuthorFilterSelection @@ -93,6 +100,8 @@ internal fun PagesRsListScreen( authorFilter: AuthorFilterSelection, isAuthorFilterSupported: Boolean, avatarUrl: String?, + confirmationDialog: PageRsConfirmationDialogState, + parentPicker: PageRsParentPickerState?, snackbarMessages: Flow = emptyFlow(), onSearchOpen: () -> Unit, onSearchQueryChanged: (String, PageRsListTab) -> Unit, @@ -104,6 +113,9 @@ internal fun PagesRsListScreen( onLoadMore: (PageRsListTab) -> Unit, onNavigateBack: () -> Unit, onPageClick: (Long, PageRsListTab) -> Unit, + onPageMenuAction: (Long, PageRsMenuAction) -> Unit, + onParentSelected: (Long) -> Unit, + onParentPickerDismissed: () -> Unit, onAddNewPage: () -> Unit ) { val tabs = PageRsListTab.entries @@ -249,12 +261,23 @@ internal fun PagesRsListScreen( isSearching = isSearchActive && searchQuery.length >= MIN_SEARCH_QUERY_LENGTH, onRefresh = { onRefreshTab(tab) }, onLoadMore = { onLoadMore(tab) }, - onPageClick = { pageId -> onPageClick(pageId, tab) } + onPageClick = { pageId -> onPageClick(pageId, tab) }, + onPageMenuAction = onPageMenuAction ) } } } + PageConfirmationDialogHost(confirmationDialog) + + if (parentPicker != null) { + PageRsParentPickerSheet( + state = parentPicker, + onParentSelected = onParentSelected, + onDismiss = onParentPickerDismissed + ) + } + if (isOpeningPage) { Box( modifier = Modifier @@ -270,6 +293,67 @@ internal fun PagesRsListScreen( } } +@Composable +private fun PageConfirmationDialogHost(confirmationDialog: PageRsConfirmationDialogState) { + when (val pending = confirmationDialog.pending) { + is PageRsListConfirmation.Trash -> ConfirmationDialog( + titleResId = R.string.trash, + message = stringResource(R.string.page_rs_confirm_trash_message), + onConfirm = confirmationDialog.onConfirm, + onDismiss = confirmationDialog.onDismiss + ) + is PageRsListConfirmation.Delete -> ConfirmationDialog( + titleResId = R.string.delete_page, + message = stringResource(R.string.page_delete_dialog_message, pending.pageTitle), + confirmTextResId = R.string.delete, + isDestructive = true, + onConfirm = confirmationDialog.onConfirm, + onDismiss = confirmationDialog.onDismiss + ) + is PageRsListConfirmation.MoveToDraft -> ConfirmationDialog( + titleResId = R.string.page_rs_move_trashed_page_to_draft_dialog_title, + message = stringResource(R.string.page_rs_move_trashed_page_to_draft_dialog_message), + confirmTextResId = R.string.pages_move_to_draft, + onConfirm = confirmationDialog.onConfirm, + onDismiss = confirmationDialog.onDismiss + ) + null -> {} + } +} + +@Composable +private fun ConfirmationDialog( + @StringRes titleResId: Int, + message: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + @StringRes confirmTextResId: Int = titleResId, + isDestructive: Boolean = false +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(titleResId)) }, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + stringResource(confirmTextResId), + color = if (isDestructive) { + MaterialTheme.colorScheme.error + } else { + Color.Unspecified + } + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AddPageFab(visible: Boolean, onClick: () -> Unit) { diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index c36f71332de7..cd098edbe71d 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1029,6 +1029,10 @@ Show pages list in a more modern UI for WordPress.com and self-hosted sites with application passwords Failed to load post Failed to load page + Copy URL + Are you sure you want to trash this page? + Move page to Drafts? + Trashed pages can\'t be edited. Do you want to change the status of this page to \"draft\" so you can work on it? Are you sure you want to trash this post? This will permanently delete this post. This action cannot be undone. Post moved to trash @@ -3593,7 +3597,6 @@ You don\'t have any scheduled pages You don\'t have any trashed pages The selected page is not available - This page is in the trash Cancel upload We cannot open pages at the moment. Please try again later %1$s · diff --git a/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PageRsHomepageSettingsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PageRsHomepageSettingsTest.kt new file mode 100644 index 000000000000..247af83ae434 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PageRsHomepageSettingsTest.kt @@ -0,0 +1,62 @@ +package org.wordpress.android.ui.pagesrs + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +internal class PageRsHomepageSettingsTest { + @Test + fun `no update when site shows latest posts on front`() { + val params = computeParams(showOnFront = "posts", target = HomepageTarget.PAGE_ON_FRONT) + + assertThat(params).isNull() + } + + @Test + fun `setting homepage keeps an unrelated posts page`() { + val params = computeParams(target = HomepageTarget.PAGE_ON_FRONT, pageId = 5L) + + assertThat(params?.pageOnFront).isEqualTo(5uL) + assertThat(params?.pageForPosts).isEqualTo(POSTS_PAGE_ID.toULong()) + } + + @Test + fun `setting the current posts page as homepage clears the posts page`() { + val params = computeParams(target = HomepageTarget.PAGE_ON_FRONT, pageId = POSTS_PAGE_ID) + + assertThat(params?.pageOnFront).isEqualTo(POSTS_PAGE_ID.toULong()) + assertThat(params?.pageForPosts).isEqualTo(0uL) + } + + @Test + fun `setting posts page keeps an unrelated homepage`() { + val params = computeParams(target = HomepageTarget.PAGE_FOR_POSTS, pageId = 5L) + + assertThat(params?.pageForPosts).isEqualTo(5uL) + assertThat(params?.pageOnFront).isEqualTo(HOMEPAGE_ID.toULong()) + } + + @Test + fun `setting the current homepage as posts page clears the homepage`() { + val params = computeParams(target = HomepageTarget.PAGE_FOR_POSTS, pageId = HOMEPAGE_ID) + + assertThat(params?.pageForPosts).isEqualTo(HOMEPAGE_ID.toULong()) + assertThat(params?.pageOnFront).isEqualTo(0uL) + } + + private fun computeParams( + showOnFront: String = "page", + target: HomepageTarget, + pageId: Long = 5L + ) = computeHomepageUpdateParams( + showOnFront = showOnFront, + currentPageOnFront = HOMEPAGE_ID, + currentPageForPosts = POSTS_PAGE_ID, + target = target, + pageId = pageId + ) + + companion object { + private const val HOMEPAGE_ID = 10L + private const val POSTS_PAGE_ID = 20L + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PageRsMenuActionsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PageRsMenuActionsTest.kt new file mode 100644 index 000000000000..df01b6a4dace --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PageRsMenuActionsTest.kt @@ -0,0 +1,145 @@ +package org.wordpress.android.ui.pagesrs + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import uniffi.wp_api.PostStatus + +internal class PageRsMenuActionsTest { + @Test + fun `published page gets the full action set`() { + val actions = computeActions(status = PostStatus.Publish) + + assertThat(actions).containsExactly( + PageRsMenuAction.VIEW, + PageRsMenuAction.SET_PARENT, + PageRsMenuAction.SET_AS_HOMEPAGE, + PageRsMenuAction.SET_AS_POSTS_PAGE, + PageRsMenuAction.MOVE_TO_DRAFT, + PageRsMenuAction.DUPLICATE, + PageRsMenuAction.SHARE, + PageRsMenuAction.COPY_URL, + PageRsMenuAction.BLAZE, + PageRsMenuAction.TRASH + ) + } + + @Test + fun `homepage cannot be trashed, drafted, or set as homepage again`() { + val actions = computeActions(status = PostStatus.Publish, isHomepage = true) + + assertThat(actions).doesNotContain( + PageRsMenuAction.TRASH, + PageRsMenuAction.MOVE_TO_DRAFT, + PageRsMenuAction.SET_AS_HOMEPAGE + ) + assertThat(actions).contains(PageRsMenuAction.SET_AS_POSTS_PAGE) + } + + @Test + fun `posts page cannot be set as posts page again`() { + val actions = computeActions(status = PostStatus.Publish, isPostsPage = true) + + assertThat(actions).doesNotContain(PageRsMenuAction.SET_AS_POSTS_PAGE) + assertThat(actions).contains(PageRsMenuAction.SET_AS_HOMEPAGE) + assertThat(actions).contains(PageRsMenuAction.TRASH) + } + + @Test + fun `homepage settings hidden when site cannot manage homepage`() { + val actions = computeActions(status = PostStatus.Publish, canManageHomepage = false) + + assertThat(actions).doesNotContain( + PageRsMenuAction.SET_AS_HOMEPAGE, + PageRsMenuAction.SET_AS_POSTS_PAGE + ) + } + + @Test + fun `blaze hidden for password protected page`() { + val actions = computeActions(status = PostStatus.Publish, hasPassword = true) + + assertThat(actions).doesNotContain(PageRsMenuAction.BLAZE) + } + + @Test + fun `blaze hidden for ineligible site`() { + val actions = computeActions(status = PostStatus.Publish, isBlazeEligibleSite = false) + + assertThat(actions).doesNotContain(PageRsMenuAction.BLAZE) + } + + @Test + fun `blaze hidden for private page`() { + val actions = computeActions(status = PostStatus.Private) + + assertThat(actions).doesNotContain(PageRsMenuAction.BLAZE) + } + + @Test + fun `draft page gets publish now but not move to draft`() { + val actions = computeActions(status = PostStatus.Draft) + + assertThat(actions).containsExactly( + PageRsMenuAction.VIEW, + PageRsMenuAction.SET_PARENT, + PageRsMenuAction.PUBLISH_NOW, + PageRsMenuAction.DUPLICATE, + PageRsMenuAction.SHARE, + PageRsMenuAction.COPY_URL, + PageRsMenuAction.TRASH + ) + } + + @Test + fun `pending page gets the draft action set`() { + assertThat(computeActions(status = PostStatus.Pending)) + .isEqualTo(computeActions(status = PostStatus.Draft)) + } + + @Test + fun `scheduled page can move to draft but not publish`() { + val actions = computeActions(status = PostStatus.Future) + + assertThat(actions).containsExactly( + PageRsMenuAction.VIEW, + PageRsMenuAction.SET_PARENT, + PageRsMenuAction.SHARE, + PageRsMenuAction.COPY_URL, + PageRsMenuAction.MOVE_TO_DRAFT, + PageRsMenuAction.TRASH + ) + } + + @Test + fun `trashed page can only be drafted or deleted`() { + val actions = computeActions(status = PostStatus.Trash) + + assertThat(actions).containsExactly( + PageRsMenuAction.MOVE_TO_DRAFT, + PageRsMenuAction.DELETE_PERMANENTLY + ) + } + + @Test + fun `unknown status gets no actions`() { + assertThat(computeActions(status = null)).isEmpty() + assertThat(computeActions(status = PostStatus.Any)).isEmpty() + assertThat(computeActions(status = PostStatus.Custom("custom"))).isEmpty() + } + + private fun computeActions( + status: PostStatus?, + isHomepage: Boolean = false, + isPostsPage: Boolean = false, + hasPassword: Boolean = false, + isBlazeEligibleSite: Boolean = true, + canManageHomepage: Boolean = true + ) = computePageMenuActions( + status = status, + isHomepage = isHomepage, + isPostsPage = isPostsPage, + hasPassword = hasPassword, + isBlazeEligibleSite = isBlazeEligibleSite, + canManageHomepage = canManageHomepage + ) +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModelTest.kt index aa3eeee4b037..bf84eb05abe5 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/pagesrs/PagesRsListViewModelTest.kt @@ -19,8 +19,13 @@ import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker.Stat +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.PostModel import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.fluxc.store.PostStore +import org.wordpress.android.fluxc.store.PostStore.OnPostUploaded +import org.wordpress.android.ui.blaze.BlazeFeatureUtils import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.posts.AuthorFilterSelection import org.wordpress.android.ui.postsrs.data.PostRsRestClient @@ -34,8 +39,12 @@ import org.wordpress.android.viewmodel.ResourceProvider internal class PagesRsListViewModelTest : BaseUnitTest(StandardTestDispatcher()) { @Mock lateinit var selectedSiteRepository: SelectedSiteRepository @Mock lateinit var serviceProvider: WpServiceProvider + @Mock lateinit var dispatcher: Dispatcher @Mock lateinit var restClient: PostRsRestClient @Mock lateinit var resourceProvider: ResourceProvider + @Mock lateinit var postStore: PostStore + @Mock lateinit var homepageSettings: PageRsHomepageSettings + @Mock lateinit var blazeFeatureUtils: BlazeFeatureUtils @Mock lateinit var fluxCBridge: PageRsFluxCBridge @Mock lateinit var networkUtilsWrapper: NetworkUtilsWrapper @Mock lateinit var accountStore: AccountStore @@ -64,8 +73,12 @@ internal class PagesRsListViewModelTest : BaseUnitTest(StandardTestDispatcher()) private fun createViewModel() = PagesRsListViewModel( selectedSiteRepository = selectedSiteRepository, serviceProvider = serviceProvider, + dispatcher = dispatcher, restClient = restClient, resourceProvider = resourceProvider, + postStore = postStore, + homepageSettings = homepageSettings, + blazeFeatureUtils = blazeFeatureUtils, fluxCBridge = fluxCBridge, networkUtilsWrapper = networkUtilsWrapper, accountStore = accountStore, @@ -260,18 +273,23 @@ internal class PagesRsListViewModelTest : BaseUnitTest(StandardTestDispatcher()) } @Test - fun `openPage on trashed tab emits trashed toast and does not track edit`() = test { + fun `openPage on trashed tab asks to move the page to draft`() { val viewModel = createViewModel() - viewModel.events.test { - viewModel.openPage(remotePageId = 42L, tab = PageRsListTab.TRASHED) + viewModel.openPage(remotePageId = 42L, tab = PageRsListTab.TRASHED) - assertThat(awaitItem()).isEqualTo( - PageRsListEvent.ShowToast(R.string.pages_list_item_trashed) - ) - cancelAndIgnoreRemainingEvents() - } + assertThat(viewModel.pendingConfirmation.value) + .isEqualTo(PageRsListConfirmation.MoveToDraft(42L)) assertThat(viewModel.isOpeningPage.value).isFalse() + } + + @Test + fun `dismissing the move to draft confirmation does not track item selected`() { + val viewModel = createViewModel() + viewModel.openPage(remotePageId = 42L, tab = PageRsListTab.TRASHED) + + viewModel.onDismissPendingAction() + verify(analyticsTracker, never()).track( eq(Stat.PAGES_LIST_ITEM_SELECTED), any(), @@ -279,6 +297,223 @@ internal class PagesRsListViewModelTest : BaseUnitTest(StandardTestDispatcher()) ) } + @Test + fun `registers with the dispatcher on init and unregisters on clear`() { + val viewModel = createViewModel() + verify(dispatcher).register(viewModel) + + viewModel.onCleared() + + verify(dispatcher).unregister(viewModel) + } + + @Test + fun `onPostUploaded for a page of the selected site refreshes the tabs`() { + val viewModel = createViewModel() + + viewModel.onPostUploaded(OnPostUploaded(pageUpload(), false)) + + verify(restClient).clearCaches() + } + + @Test + fun `onPostUploaded ignores posts`() { + val viewModel = createViewModel() + + viewModel.onPostUploaded(OnPostUploaded(pageUpload().apply { setIsPage(false) }, false)) + + verify(restClient, never()).clearCaches() + } + + @Test + fun `onPostUploaded ignores pages from other sites`() { + val viewModel = createViewModel() + + viewModel.onPostUploaded(OnPostUploaded(pageUpload().apply { setLocalSiteId(99) }, false)) + + verify(restClient, never()).clearCaches() + } + + @Test + fun `onPostUploaded ignores failed uploads`() { + val viewModel = createViewModel() + val event = OnPostUploaded(pageUpload(), false).apply { + error = PostStore.PostError(PostStore.PostErrorType.GENERIC_ERROR) + } + + viewModel.onPostUploaded(event) + + verify(restClient, never()).clearCaches() + } + + private fun pageUpload() = PostModel().apply { + setIsPage(true) + setLocalSiteId(site.id) + } + + @Test + fun `onPageMenuAction TRASH sets Trash confirmation`() { + val viewModel = createViewModel() + + viewModel.onPageMenuAction(42L, PageRsMenuAction.TRASH) + + assertThat(viewModel.pendingConfirmation.value) + .isEqualTo(PageRsListConfirmation.Trash(42L)) + } + + @Test + fun `onPageMenuAction DELETE_PERMANENTLY sets Delete confirmation`() { + val viewModel = createViewModel() + + viewModel.onPageMenuAction(42L, PageRsMenuAction.DELETE_PERMANENTLY) + + assertThat(viewModel.pendingConfirmation.value) + .isEqualTo(PageRsListConfirmation.Delete(42L, "")) + } + + @Test + fun `onPageMenuAction tracks PAGES_OPTIONS_PRESSED`() { + val viewModel = createViewModel() + + viewModel.onPageMenuAction(42L, PageRsMenuAction.TRASH) + + verify(analyticsTracker).track( + eq(Stat.PAGES_OPTIONS_PRESSED), + eq(site), + eq(mapOf("option_name" to "move_to_bin")) + ) + } + + @Test + fun `onDismissPendingAction clears pending confirmation`() { + val viewModel = createViewModel() + viewModel.onPageMenuAction(42L, PageRsMenuAction.TRASH) + + viewModel.onDismissPendingAction() + + assertThat(viewModel.pendingConfirmation.value).isNull() + } + + @Test + fun `onConfirmPendingAction clears pending confirmation`() { + val viewModel = createViewModel() + viewModel.onPageMenuAction(42L, PageRsMenuAction.TRASH) + + viewModel.onConfirmPendingAction() + + assertThat(viewModel.pendingConfirmation.value).isNull() + } + + @Test + fun `onConfirmPendingAction shows snackbar when offline`() = test { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + whenever(resourceProvider.getString(R.string.no_network_message)) + .thenReturn("No network") + val viewModel = createViewModel() + viewModel.onPageMenuAction(42L, PageRsMenuAction.TRASH) + + viewModel.snackbarMessages.test { + viewModel.onConfirmPendingAction() + + assertThat(awaitItem().message).isEqualTo("No network") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `onPageMenuAction VIEW with no loaded page emits nothing`() = test { + val viewModel = createViewModel() + + viewModel.events.test { + viewModel.onPageMenuAction(42L, PageRsMenuAction.VIEW) + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `onPageMenuAction SET_AS_HOMEPAGE without static homepage shows snackbar`() = test { + whenever(homepageSettings.setHomepage(site, 42L)) + .thenReturn(PageRsHomepageSettings.Result.StaticHomepageDisabled) + whenever(resourceProvider.getString(R.string.page_cannot_set_homepage)) + .thenReturn("Cannot set homepage") + val viewModel = createViewModel() + + viewModel.snackbarMessages.test { + viewModel.onPageMenuAction(42L, PageRsMenuAction.SET_AS_HOMEPAGE) + + assertThat(awaitItem().message).isEqualTo("Cannot set homepage") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `onPageMenuAction SET_AS_POSTS_PAGE without static homepage shows snackbar`() = test { + whenever(homepageSettings.setPostsPage(site, 42L)) + .thenReturn(PageRsHomepageSettings.Result.StaticHomepageDisabled) + whenever(resourceProvider.getString(R.string.page_cannot_set_posts_page)) + .thenReturn("Cannot set posts page") + val viewModel = createViewModel() + + viewModel.snackbarMessages.test { + viewModel.onPageMenuAction(42L, PageRsMenuAction.SET_AS_POSTS_PAGE) + + assertThat(awaitItem().message).isEqualTo("Cannot set posts page") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `onPageMenuAction SET_AS_HOMEPAGE success shows confirmation snackbar`() = test { + whenever(homepageSettings.setHomepage(site, 42L)) + .thenReturn(PageRsHomepageSettings.Result.Success) + whenever(resourceProvider.getString(R.string.page_homepage_successfully_updated)) + .thenReturn("Homepage updated") + val viewModel = createViewModel() + + viewModel.snackbarMessages.test { + viewModel.onPageMenuAction(42L, PageRsMenuAction.SET_AS_HOMEPAGE) + + assertThat(awaitItem().message).isEqualTo("Homepage updated") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `onPageMenuAction SET_AS_HOMEPAGE failure shows error snackbar`() = test { + whenever(homepageSettings.setHomepage(site, 42L)) + .thenReturn(PageRsHomepageSettings.Result.Error("403")) + whenever(resourceProvider.getString(R.string.page_homepage_update_failed)) + .thenReturn("Homepage update failed") + val viewModel = createViewModel() + + viewModel.snackbarMessages.test { + viewModel.onPageMenuAction(42L, PageRsMenuAction.SET_AS_HOMEPAGE) + + assertThat(awaitItem().message).isEqualTo("Homepage update failed") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `openParentPicker without loaded page does nothing`() { + val viewModel = createViewModel() + + viewModel.openParentPicker(42L) + + assertThat(viewModel.parentPicker.value).isNull() + } + + @Test + fun `onParentPickerDismissed clears picker state`() { + val viewModel = createViewModel() + + viewModel.onParentPickerDismissed() + + assertThat(viewModel.parentPicker.value).isNull() + } + @Test fun `loadMorePages no-ops when collection not initialized`() { val viewModel = createViewModel()