Skip to content

Commit 75f9510

Browse files
nbradburyclaude
andauthored
Pages list: hierarchy (phase 3) (#22966)
* Pages list: Phase 2 search & author filter Adds debounced search and a per-author filter to the wordpress-rs pages list, mirroring the post-list playbook from PostRsListViewModel. Search: typing past 3 characters (200 ms debounce) tears down all tab collections and rebuilds the active tab against PostListFilter with status = ALL_STATUSES and a non-null search term. Results render a status label per row so users can tell which bucket each hit came from. Closing search clears the query and immediately re-initializes the active tab without waiting for the debounce. Author filter: gated on isUsingWpComRestApi + hasCapabilityEditOthersPages + isSingleUserSite == false (consistent with the legacy PagesViewModel and PostRsListViewModel). Selection persists via the shared appPrefsWrapper.postListAuthorSelection pref. Display names are resolved through the existing entity-agnostic PostRsRestClient.fetchUserDisplayNames and skipped when filtering by Me. Analytics: tracks PAGES_LIST_SEARCH_ACCESSED, PAGES_LIST_ITEM_SELECTED, and the pre-existing PAGES_LIST_AUTHOR_FILTER_CHANGED. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: satisfy detekt ReturnCount in resolveAuthorNames Fold the _site null guard into the combined early-return so the function stays under detekt's two-return limit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: align search debounce with post list Bump SEARCH_DEBOUNCE_MS from 200 to 250 to match PostRsListViewModel, so users who use both lists see the same input latency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: use separate author filter pref from post list Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: simplify Phase 2 additions Drop unused PageRsUiModel.status field, replace buildString header with listOfNotNull/joinToString, and inline single-use findPage helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: add new-page FAB Adds a FloatingActionButton to the wordpress-rs pages list that opens the editor for a new page via ActivityLauncher.addNewPageForResult, matching the legacy PagesFragment behavior. The FAB is hidden during search but visible on all four tabs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: route FAB through Modal Layout Picker Wire the wordpress-rs pages list FAB through ModalLayoutPickerViewModel when the block editor is the default for new posts and the Jetpack feature-removal phase allows template selection, matching the legacy PagesFragment flow. Falls back to launching a blank page directly when the picker can't be shown. Also tracks PAGES_ADD_PAGE on FAB tap for analytics parity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: drop site payload from CreateNewPage event The activity already reads viewModel.site directly when handling the MLP result; making the event itself a data object unifies that access path and removes the redundant parameter-threading. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: animate FAB and add tooltip Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: drop _site backing field and sort imports Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: toast on trashed page tap Previously tapping a page on the TRASHED tab was a silent no-op, and during search a trashed result surfaced on a non-trashed tab would fall through and try to open the editor. Track an isTrashed flag on the UI model and emit a toast in both cases; the real move-to-draft flow is deferred to phase 3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: simplify isAuthorFilterSupported and speed up author merge Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: extract proceedOpenPage to drop a return statement Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: drop redundant search seed and author-guard null check Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: Phase 3 hierarchy and virtual homepage/posts-page rows Switches PageTabUiState.pages to a sealed PageRsListItem (Real with indentLevel, Virtual for the site's homepage / posts page). Hierarchy flattening is a pure helper used only on PUBLISHED outside of search and Me-filter; virtuals are prepended and their real rows hidden. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: switch to TITLE ASC to match legacy ordering PUBLISHED, DRAFTS, and TRASHED now use WpApiParamPostsOrderBy.TITLE + ASC so the new list mirrors the legacy alphabetical pre-sort preserved by PR #22896. SCHEDULED stays on DATE ASC until the legacy date-divider grouping (Today / Tomorrow / Next week) lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: merge site guard to satisfy ReturnCount Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pages list: reword TODO comment to satisfy detekt ForbiddenComment Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pages list: apply simplifications from branch review - Reuse isSearch local instead of re-reading the search query - Skip no-op copy when no author name to preserve - Drop redundant empty-list guard in flattenToTree - Collapse KeyboardOptions.Default.copy to direct constructor Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pages list: cancel in-flight collection jobs on teardown Collection-scoped work (init, refresh, load-more, observer callbacks) now runs in a child scope that clearCollections() cancels and replaces, so a job resuming after a search/filter teardown can't touch a closed collection or write stale error state into the rebuilt tabs. Generic catch blocks rethrow CancellationException so cancellation isn't reported as a load failure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pages list: close orphaned collection when creation is cancelled mid-flight Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Pages list: handle parent cycles in tree builder and guard listInfo call Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Pages list: apply branch simplifications Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Pages list: note per-page actions menu gap in row composable Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 70f4da8 commit 75f9510

8 files changed

Lines changed: 648 additions & 229 deletions

File tree

WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListTab.kt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,46 @@ package org.wordpress.android.ui.pagesrs
33
import org.wordpress.android.R
44
import uniffi.wp_api.PostStatus
55
import uniffi.wp_api.WpApiParamOrder
6+
import uniffi.wp_api.WpApiParamPostsOrderBy
67

78
internal enum class PageRsListTab(
89
val labelResId: Int,
910
val emptyMessageResId: Int,
1011
val statuses: List<PostStatus>,
12+
val orderBy: WpApiParamPostsOrderBy,
1113
val order: WpApiParamOrder
1214
) {
15+
// PUBLISHED / DRAFTS / TRASHED are alphabetical to match the legacy pages list
16+
// (see PR #22896). Sibling order within the published hierarchy comes from this sort.
1317
PUBLISHED(
1418
labelResId = R.string.pages_published,
1519
emptyMessageResId = R.string.pages_empty_published,
1620
statuses = listOf(PostStatus.Publish, PostStatus.Private),
17-
order = WpApiParamOrder.DESC
21+
orderBy = WpApiParamPostsOrderBy.TITLE,
22+
order = WpApiParamOrder.ASC
1823
),
1924
DRAFTS(
2025
labelResId = R.string.pages_drafts,
2126
emptyMessageResId = R.string.pages_empty_drafts,
2227
statuses = listOf(PostStatus.Draft, PostStatus.Pending),
23-
order = WpApiParamOrder.DESC
28+
orderBy = WpApiParamPostsOrderBy.TITLE,
29+
order = WpApiParamOrder.ASC
2430
),
31+
// SCHEDULED stays chronological so "next to publish" is at the top; legacy groups by
32+
// date with dividers, but until those land an alphabetical sort here would be useless.
33+
// Follow-up: add date-divider grouping (e.g. Today / Tomorrow / Next week) to match legacy.
2534
SCHEDULED(
2635
labelResId = R.string.pages_scheduled,
2736
emptyMessageResId = R.string.pages_empty_scheduled,
2837
statuses = listOf(PostStatus.Future),
38+
orderBy = WpApiParamPostsOrderBy.DATE,
2939
order = WpApiParamOrder.ASC
3040
),
3141
TRASHED(
3242
labelResId = R.string.pages_trashed,
3343
emptyMessageResId = R.string.pages_empty_trashed,
3444
statuses = listOf(PostStatus.Trash),
35-
order = WpApiParamOrder.DESC
45+
orderBy = WpApiParamPostsOrderBy.TITLE,
46+
order = WpApiParamOrder.ASC
3647
);
3748
}

WordPress/src/main/java/org/wordpress/android/ui/pagesrs/PageRsListUiState.kt

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import uniffi.wp_mobile.FullEntityAnyPostWithEditContext
1212
import uniffi.wp_mobile.PostItemState
1313

1414
internal data class PageTabUiState(
15-
val pages: List<PageRsUiModel> = emptyList(),
15+
val pages: List<PageRsListItem> = emptyList(),
1616
val isLoading: Boolean = false,
1717
val isRefreshing: Boolean = false,
1818
val isLoadingMore: Boolean = false,
@@ -21,6 +21,28 @@ internal data class PageTabUiState(
2121
val isAuthError: Boolean = false
2222
)
2323

24+
internal sealed interface PageRsListItem {
25+
val page: PageRsUiModel
26+
val stableKey: String
27+
val remotePageId: Long get() = page.remotePageId
28+
29+
data class Real(
30+
override val page: PageRsUiModel,
31+
val indentLevel: Int = 0
32+
) : PageRsListItem {
33+
override val stableKey: String get() = "real:${page.remotePageId}"
34+
}
35+
36+
data class Virtual(
37+
val kind: Kind,
38+
override val page: PageRsUiModel
39+
) : PageRsListItem {
40+
override val stableKey: String get() = "virtual:$kind"
41+
42+
enum class Kind { HOMEPAGE, POSTS_PAGE }
43+
}
44+
}
45+
2446
internal enum class PageRsDisplayState {
2547
NORMAL,
2648
FETCHING_WITH_DATA,
@@ -31,6 +53,7 @@ internal enum class PageRsDisplayState {
3153

3254
internal data class PageRsUiModel(
3355
val remotePageId: Long,
56+
val parentId: Long = 0L,
3457
val title: String,
3558
val excerpt: String,
3659
val date: String,
@@ -77,6 +100,7 @@ private fun FullEntityAnyPostWithEditContext.toPageUiModel(
77100
val page: AnyPostWithEditContext = data
78101
return PageRsUiModel(
79102
remotePageId = page.id,
103+
parentId = page.parent ?: 0L,
80104
title = page.title?.raw?.takeIf { it.isNotBlank() }
81105
?: page.title?.rendered
82106
?: "",
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package org.wordpress.android.ui.pagesrs
2+
3+
internal const val MAX_INDENT_LEVEL = 3
4+
5+
/**
6+
* Assembles the final row list for a pages tab:
7+
* - When [applyHierarchy] is true, [pages] are DFS-ordered as parent → children with
8+
* [PageRsListItem.Real.indentLevel] capped at [MAX_INDENT_LEVEL]; children whose
9+
* parent isn't visible in the set are promoted to roots so they still render.
10+
* - When hierarchy is on, the pages identified by [pageOnFront] and [pageForPosts]
11+
* (when set and present in [pages]) are prepended as Virtual rows and hidden from
12+
* their normal sorted position.
13+
* - When [applyHierarchy] is false, [pages] are wrapped as flat Real rows with
14+
* indentLevel = 0 and no virtuals.
15+
*/
16+
internal fun buildRows(
17+
pages: List<PageRsUiModel>,
18+
applyHierarchy: Boolean,
19+
pageOnFront: Long,
20+
pageForPosts: Long
21+
): List<PageRsListItem> {
22+
if (!applyHierarchy) {
23+
return pages.map { PageRsListItem.Real(it) }
24+
}
25+
val byId = pages.associateBy { it.remotePageId }
26+
val homepage = pageOnFront.takeIf { it != 0L }?.let { byId[it] }
27+
val postsPage = pageForPosts.takeIf { it != 0L }?.let { byId[it] }
28+
val hiddenIds = setOfNotNull(homepage?.remotePageId, postsPage?.remotePageId)
29+
val visible = pages.filterNot { it.remotePageId in hiddenIds }
30+
val tree = flattenToTree(visible)
31+
return buildList {
32+
homepage?.let { add(PageRsListItem.Virtual(PageRsListItem.Virtual.Kind.HOMEPAGE, it)) }
33+
postsPage?.let { add(PageRsListItem.Virtual(PageRsListItem.Virtual.Kind.POSTS_PAGE, it)) }
34+
addAll(tree)
35+
}
36+
}
37+
38+
internal fun flattenToTree(pages: List<PageRsUiModel>): List<PageRsListItem.Real> {
39+
val byId = pages.associateBy { it.remotePageId }
40+
val childrenByParent = pages
41+
.filter { it.parentId != 0L && it.parentId in byId }
42+
.groupBy { it.parentId }
43+
val roots = pages.filter { it.parentId == 0L || it.parentId !in byId }
44+
45+
val result = ArrayList<PageRsListItem.Real>(pages.size)
46+
val visited = HashSet<Long>(pages.size)
47+
val stack = ArrayDeque<Pair<PageRsUiModel, Int>>()
48+
roots.asReversed().forEach { stack.addLast(it to 0) }
49+
while (stack.isNotEmpty()) {
50+
val (page, depth) = stack.removeLast()
51+
if (!visited.add(page.remotePageId)) continue
52+
result.add(PageRsListItem.Real(page, minOf(depth, MAX_INDENT_LEVEL)))
53+
childrenByParent[page.remotePageId]?.asReversed()?.forEach { child ->
54+
stack.addLast(child to depth + 1)
55+
}
56+
}
57+
// Pages caught in a parent cycle (self-parented, or in a loop of parent references) are
58+
// unreachable from any root; append them as flat rows so corrupt data can't drop pages.
59+
pages.filterNot { it.remotePageId in visited }
60+
.mapTo(result) { PageRsListItem.Real(it) }
61+
return result
62+
}

0 commit comments

Comments
 (0)