Skip to content

Commit 236a51c

Browse files
authored
Custom Posts: Add Set as Homepage / Posts Page actions (#25575)
* Format files in preparation for page attribute actions * Replace isHomepage with PageRole enum in custom post list Consolidate homepage and posts page tracking into a single PageRole enum. Update HomepageSetting to track both homepage and posts page IDs from site settings. * Add setAsHomepage/setAsPostsPage/setAsRegularPage actions Add view model methods that update site settings via the WordPress core REST API with optimistic local state updates and rollback on failure. * Add Page Attributes submenu and posts page badge Show Set as Homepage, Set as Posts Page, and Set as Regular Page actions in a Page Attributes submenu for published pages. Add a "Posts page" badge alongside the existing "Homepage" badge using the consolidated pageRole property. * Gate page attribute actions on manage_options capability Add currentUserCan(_:) to WordPressClient for checking user capabilities via the cached current user data. Only show the Page Attributes submenu when the user has the manage_options capability. * Refetch site settings on pull-to-refresh WordPressClient.fetchSiteSettings now takes a forceRefresh flag that bypasses the loadSiteSettingsTask cache and re-runs the network fetch. CustomPostListViewModel passes forceRefresh through from pullToRefresh, so the Pages list picks up homepage and posts-page assignments changed outside the app without requiring an app relaunch. * Show page-role badges on the All tab The mark-page-roles guard checked for .custom("any"), a legacy form that predates the .any case wordpress-rs now exposes. The All tab uses .any, so the predicate never matched and the Homepage / Posts page badges never rendered there. * Move PageRoleTests into the WordPressTest target's synchronized folder * Consolidate page-role success notices into a single string
1 parent f039b67 commit 236a51c

4 files changed

Lines changed: 389 additions & 53 deletions

File tree

Modules/Sources/WordPressCore/WordPressClient.swift

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,10 +270,28 @@ public actor WordPressClient {
270270
}
271271
}
272272

273+
/// Returns whether the current user has the specified capability.
274+
///
275+
/// Uses the cached current user data, so this typically does not trigger a network request.
276+
///
277+
/// - Parameter capability: The capability to check.
278+
/// - Returns: `true` if the user has the capability, `false` otherwise.
279+
public func currentUserCan(_ capability: UserCapability) async throws -> Bool {
280+
let user = try await fetchCurrentUser()
281+
return user.capabilities.hasCap(capability: capability)
282+
}
283+
273284
/// Fetches the site settings, using the cached value if available.
274285
///
275286
/// If the cached task has failed, creates a new task and retries the fetch.
276-
public func fetchSiteSettings() async throws -> SiteSettingsWithEditContext {
287+
/// Pass `forceRefresh: true` to bypass the cache and refetch from the server —
288+
/// callers should do this when they know the server-side settings may have changed
289+
/// outside this client (e.g. on pull-to-refresh).
290+
public func fetchSiteSettings(forceRefresh: Bool = false) async throws -> SiteSettingsWithEditContext {
291+
if forceRefresh {
292+
self.loadSiteSettingsTask = newSiteSettingsTask()
293+
return try await self.loadSiteSettingsTask.value
294+
}
277295
switch await self.loadSiteSettingsTask.result {
278296
case .success(let settings): return settings
279297
case .failure:
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import Testing
2+
@testable import WordPress
3+
4+
@Suite("markPageRoles")
5+
struct MarkPageRolesTests {
6+
7+
private func makeItem(id: Int64) -> CustomPostCollectionItem {
8+
let post = CustomPostCollectionDisplayPost(
9+
date: Date(),
10+
title: "Page \(id)",
11+
content: nil,
12+
status: .publish
13+
)
14+
return CustomPostCollectionItem(id: id, post: post, state: .loading)
15+
}
16+
17+
@Test("marks homepage and posts page on separate items")
18+
func marksHomepageAndPostsPage() {
19+
var items = [makeItem(id: 1), makeItem(id: 2), makeItem(id: 3)]
20+
items.markPageRoles(homepageID: 1, postsPageID: 2)
21+
#expect(items[0].pageRole == .homepage)
22+
#expect(items[1].pageRole == .postsPage)
23+
#expect(items[2].pageRole == nil)
24+
}
25+
26+
@Test("nil IDs result in no roles assigned")
27+
func nilIDs() {
28+
var items = [makeItem(id: 1), makeItem(id: 2)]
29+
items.markPageRoles(homepageID: nil, postsPageID: nil)
30+
#expect(items[0].pageRole == nil)
31+
#expect(items[1].pageRole == nil)
32+
}
33+
34+
@Test("only homepage ID provided")
35+
func onlyHomepageID() {
36+
var items = [makeItem(id: 1), makeItem(id: 2)]
37+
items.markPageRoles(homepageID: 1, postsPageID: nil)
38+
#expect(items[0].pageRole == .homepage)
39+
#expect(items[1].pageRole == nil)
40+
}
41+
42+
@Test("only posts page ID provided")
43+
func onlyPostsPageID() {
44+
var items = [makeItem(id: 1), makeItem(id: 2)]
45+
items.markPageRoles(homepageID: nil, postsPageID: 2)
46+
#expect(items[0].pageRole == nil)
47+
#expect(items[1].pageRole == .postsPage)
48+
}
49+
50+
@Test("ID not found in items does nothing")
51+
func idNotFound() {
52+
var items = [makeItem(id: 1)]
53+
items.markPageRoles(homepageID: 99, postsPageID: 100)
54+
#expect(items[0].pageRole == nil)
55+
}
56+
57+
@Test("same ID for both roles assigns postsPage (last-write wins)")
58+
func sameIDForBothRoles() {
59+
var items = [makeItem(id: 1)]
60+
items.markPageRoles(homepageID: 1, postsPageID: 1)
61+
#expect(items[0].pageRole == .postsPage)
62+
}
63+
}

WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift

Lines changed: 112 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ struct CustomPostListView<Header: View>: View {
8080
)
8181
.overlay {
8282
if viewModel.shouldDisplayEmptyView {
83-
let emptyText = details.labels.notFound.isEmpty
83+
let emptyText =
84+
details.labels.notFound.isEmpty
8485
? String.localizedStringWithFormat(Strings.emptyStateMessage, details.name)
8586
: details.labels.notFound
8687
EmptyStateView(emptyText, systemImage: "doc.text")
@@ -307,7 +308,8 @@ private struct PaginatedList<Header: View>: View {
307308
Text(verbatim: SharedStrings.Button.retry)
308309
}
309310
.buttonStyle(.borderedProminent)
310-
}.frame(maxWidth: .infinity, alignment: .center)
311+
}
312+
.frame(maxWidth: .infinity, alignment: .center)
311313
}
312314
}
313315
}
@@ -354,7 +356,12 @@ private struct ForEachContent: View {
354356
} else if showsPostActions {
355357
button
356358
.contextMenu {
357-
PostActionMenuContent(post: fullPost, viewModel: viewModel, onDuplicate: onDuplicate)
359+
PostActionMenuContent(
360+
post: fullPost,
361+
pageRole: item.pageRole,
362+
viewModel: viewModel,
363+
onDuplicate: onDuplicate
364+
)
358365
}
359366
.swipeActions(edge: .leading) {
360367
if fullPost.status == .publish {
@@ -392,7 +399,12 @@ private struct ForEachContent: View {
392399
}
393400
}
394401
.overlay(alignment: .topTrailing) {
395-
PostActionMenu(post: fullPost, viewModel: viewModel, onDuplicate: onDuplicate)
402+
PostActionMenu(
403+
post: fullPost,
404+
pageRole: item.pageRole,
405+
viewModel: viewModel,
406+
onDuplicate: onDuplicate
407+
)
396408
.offset(y: -6)
397409
}
398410
} else {
@@ -459,12 +471,13 @@ private struct ForEachContentWithIndentation: View {
459471

460472
private struct PostActionMenu: View {
461473
let post: AnyPostWithEditContext
474+
let pageRole: PageRole?
462475
let viewModel: CustomPostListViewModel
463476
let onDuplicate: (AnyPostWithEditContext) -> Void
464477

465478
var body: some View {
466479
Menu {
467-
PostActionMenuContent(post: post, viewModel: viewModel, onDuplicate: onDuplicate)
480+
PostActionMenuContent(post: post, pageRole: pageRole, viewModel: viewModel, onDuplicate: onDuplicate)
468481
} label: {
469482
Image(systemName: "ellipsis")
470483
.font(.body)
@@ -477,15 +490,29 @@ private struct PostActionMenu: View {
477490

478491
private struct PostActionMenuContent: View {
479492
let post: AnyPostWithEditContext
493+
let pageRole: PageRole?
480494
let viewModel: CustomPostListViewModel
481495
let onDuplicate: (AnyPostWithEditContext) -> Void
482496

483497
var body: some View {
484498
primarySection
499+
pageAttributesSection
485500
navigationSection
486501
trashSection
487502
}
488503

504+
@ViewBuilder
505+
private var pageAttributesSection: some View {
506+
if viewModel.canChangePageAttributes, post.status == .publish {
507+
PageAttributeMenuSection(
508+
pageRole: pageRole,
509+
onSetHomepage: { Task { await viewModel.setAsHomepage(post) } },
510+
onSetPostsPage: { Task { await viewModel.setAsPostsPage(post) } },
511+
onSetRegularPage: { Task { await viewModel.setAsRegularPage(post) } }
512+
)
513+
}
514+
}
515+
489516
@ViewBuilder
490517
private var primarySection: some View {
491518
Section {
@@ -538,13 +565,16 @@ private struct PostActionMenuContent: View {
538565
private var trashSection: some View {
539566
Section {
540567
if post.status != .trash {
541-
Button(role: .destructive, action: {
542-
if post.status == .publish {
543-
viewModel.confirmTrash(post)
544-
} else {
545-
Task { await viewModel.trashPost(post) }
568+
Button(
569+
role: .destructive,
570+
action: {
571+
if post.status == .publish {
572+
viewModel.confirmTrash(post)
573+
} else {
574+
Task { await viewModel.trashPost(post) }
575+
}
546576
}
547-
}) {
577+
) {
548578
Label(Strings.moveToTrash, systemImage: "trash")
549579
}
550580
} else {
@@ -556,6 +586,37 @@ private struct PostActionMenuContent: View {
556586
}
557587
}
558588

589+
private struct PageAttributeMenuSection: View {
590+
let pageRole: PageRole?
591+
let onSetHomepage: () -> Void
592+
let onSetPostsPage: () -> Void
593+
let onSetRegularPage: () -> Void
594+
595+
var body: some View {
596+
Section {
597+
Menu {
598+
if pageRole != .homepage {
599+
Button(action: onSetHomepage) {
600+
Label(Strings.setHomepage, systemImage: "house")
601+
}
602+
}
603+
if pageRole != .postsPage {
604+
Button(action: onSetPostsPage) {
605+
Label(Strings.setPostsPage, systemImage: "text.word.spacing")
606+
}
607+
}
608+
if pageRole == .postsPage {
609+
Button(action: onSetRegularPage) {
610+
Label(Strings.setRegularPage, systemImage: "arrow.uturn.backward")
611+
}
612+
}
613+
} label: {
614+
Label(Strings.pageAttributes, systemImage: "doc")
615+
}
616+
}
617+
}
618+
}
619+
559620
private struct PostContent: View {
560621
let post: CustomPostCollectionDisplayPost
561622
let client: WordPressClient?
@@ -566,7 +627,7 @@ private struct PostContent: View {
566627
header
567628
content
568629
footer
569-
homepageBadge
630+
pageRoleBadge
570631
}
571632
.frame(maxWidth: .infinity, alignment: .leading)
572633
.contentShape(Rectangle())
@@ -614,14 +675,24 @@ private struct PostContent: View {
614675
}
615676

616677
@ViewBuilder
617-
private var homepageBadge: some View {
618-
if post.isHomepage {
678+
private var pageRoleBadge: some View {
679+
switch post.pageRole {
680+
case .homepage:
619681
HStack(spacing: 2) {
620682
Image(systemName: "house.fill")
621683
Text(verbatim: Strings.homepageBadge)
622684
}
623685
.font(.footnote)
624686
.foregroundStyle(.secondary)
687+
case .postsPage:
688+
HStack(spacing: 2) {
689+
Image(systemName: "paragraphsign")
690+
Text(verbatim: Strings.postsPageBadge)
691+
}
692+
.font(.footnote)
693+
.foregroundStyle(.secondary)
694+
case nil:
695+
EmptyView()
625696
}
626697
}
627698
}
@@ -646,7 +717,8 @@ private enum Strings {
646717
static let emptyStateMessage = NSLocalizedString(
647718
"customPostList.emptyState.message",
648719
value: "No %1$@",
649-
comment: "Empty state message when no custom posts exist. %1$@ is the post type name (e.g., 'Podcasts', 'Products')."
720+
comment:
721+
"Empty state message when no custom posts exist. %1$@ is the post type name (e.g., 'Podcasts', 'Products')."
650722
)
651723
static let homepageBadge = NSLocalizedString(
652724
"customPostList.badge.homepage",
@@ -708,6 +780,31 @@ private enum Strings {
708780
value: "Delete",
709781
comment: "Short label for the swipe action to permanently delete a trashed post. Keep this translation short."
710782
)
783+
static let setHomepage = NSLocalizedString(
784+
"customPostList.action.setHomepage",
785+
value: "Set as Homepage",
786+
comment: "Menu action to set a page as the site homepage"
787+
)
788+
static let setPostsPage = NSLocalizedString(
789+
"customPostList.action.setPostsPage",
790+
value: "Set as Posts Page",
791+
comment: "Menu action to set a page as the posts page"
792+
)
793+
static let setRegularPage = NSLocalizedString(
794+
"customPostList.action.setRegularPage",
795+
value: "Set as Regular Page",
796+
comment: "Menu action to remove the posts page designation from a page"
797+
)
798+
static let pageAttributes = NSLocalizedString(
799+
"customPostList.action.pageAttributes",
800+
value: "Page Attributes",
801+
comment: "Label for the page attributes submenu in the context menu"
802+
)
803+
static let postsPageBadge = NSLocalizedString(
804+
"customPostList.badge.postsPage",
805+
value: "Posts page",
806+
comment: "Badge label shown on the posts page row in the custom post list for pages"
807+
)
711808
}
712809

713810
// MARK: - Previews

0 commit comments

Comments
 (0)