Skip to content
20 changes: 19 additions & 1 deletion Modules/Sources/WordPressCore/WordPressClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,28 @@ public actor WordPressClient {
}
}

/// Returns whether the current user has the specified capability.
///
/// Uses the cached current user data, so this typically does not trigger a network request.
///
/// - Parameter capability: The capability to check.
/// - Returns: `true` if the user has the capability, `false` otherwise.
public func currentUserCan(_ capability: UserCapability) async throws -> Bool {
let user = try await fetchCurrentUser()
return user.capabilities.hasCap(capability: capability)
}

/// Fetches the site settings, using the cached value if available.
///
/// If the cached task has failed, creates a new task and retries the fetch.
public func fetchSiteSettings() async throws -> SiteSettingsWithEditContext {
/// Pass `forceRefresh: true` to bypass the cache and refetch from the server —
/// callers should do this when they know the server-side settings may have changed
/// outside this client (e.g. on pull-to-refresh).
public func fetchSiteSettings(forceRefresh: Bool = false) async throws -> SiteSettingsWithEditContext {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do this, I think we need to invalidate (or reassign) loadSiteSettingsTask (or make an async accessor?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I don't quite understand the issue. Do you mean explicty cancel the task like:

if forceRefresh {
  self.loadSiteSettingsTask?.cancel()
  self.loadSiteSettingsTask = ...
  ...
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would work, though it's probably better to just let the original finish and await the result?

The idea is that if the user pulls to refresh more than once, do we want multiple in-flight network requests?

if forceRefresh {
self.loadSiteSettingsTask = newSiteSettingsTask()
return try await self.loadSiteSettingsTask.value
}
switch await self.loadSiteSettingsTask.result {
case .success(let settings): return settings
case .failure:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Testing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't seem to be executed by anything – it doesn't show up in Buildkite and Claude is indicating it's not wired up by the Xcode project file

@testable import WordPress

@Suite("markPageRoles")
struct MarkPageRolesTests {

private func makeItem(id: Int64) -> CustomPostCollectionItem {
let post = CustomPostCollectionDisplayPost(
date: Date(),
title: "Page \(id)",
content: nil,
status: .publish
)
return CustomPostCollectionItem(id: id, post: post, state: .loading)
}

@Test("marks homepage and posts page on separate items")
func marksHomepageAndPostsPage() {
var items = [makeItem(id: 1), makeItem(id: 2), makeItem(id: 3)]
items.markPageRoles(homepageID: 1, postsPageID: 2)
#expect(items[0].pageRole == .homepage)
#expect(items[1].pageRole == .postsPage)
#expect(items[2].pageRole == nil)
}

@Test("nil IDs result in no roles assigned")
func nilIDs() {
var items = [makeItem(id: 1), makeItem(id: 2)]
items.markPageRoles(homepageID: nil, postsPageID: nil)
#expect(items[0].pageRole == nil)
#expect(items[1].pageRole == nil)
}

@Test("only homepage ID provided")
func onlyHomepageID() {
var items = [makeItem(id: 1), makeItem(id: 2)]
items.markPageRoles(homepageID: 1, postsPageID: nil)
#expect(items[0].pageRole == .homepage)
#expect(items[1].pageRole == nil)
}

@Test("only posts page ID provided")
func onlyPostsPageID() {
var items = [makeItem(id: 1), makeItem(id: 2)]
items.markPageRoles(homepageID: nil, postsPageID: 2)
#expect(items[0].pageRole == nil)
#expect(items[1].pageRole == .postsPage)
}

@Test("ID not found in items does nothing")
func idNotFound() {
var items = [makeItem(id: 1)]
items.markPageRoles(homepageID: 99, postsPageID: 100)
#expect(items[0].pageRole == nil)
}

@Test("same ID for both roles assigns postsPage (last-write wins)")
func sameIDForBothRoles() {
var items = [makeItem(id: 1)]
items.markPageRoles(homepageID: 1, postsPageID: 1)
#expect(items[0].pageRole == .postsPage)
}
}
127 changes: 112 additions & 15 deletions WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ struct CustomPostListView<Header: View>: View {
)
.overlay {
if viewModel.shouldDisplayEmptyView {
let emptyText = details.labels.notFound.isEmpty
let emptyText =
details.labels.notFound.isEmpty
? String.localizedStringWithFormat(Strings.emptyStateMessage, details.name)
: details.labels.notFound
EmptyStateView(emptyText, systemImage: "doc.text")
Expand Down Expand Up @@ -307,7 +308,8 @@ private struct PaginatedList<Header: View>: View {
Text(verbatim: SharedStrings.Button.retry)
}
.buttonStyle(.borderedProminent)
}.frame(maxWidth: .infinity, alignment: .center)
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
Expand Down Expand Up @@ -354,7 +356,12 @@ private struct ForEachContent: View {
} else if showsPostActions {
button
.contextMenu {
PostActionMenuContent(post: fullPost, viewModel: viewModel, onDuplicate: onDuplicate)
PostActionMenuContent(
post: fullPost,
pageRole: item.pageRole,
viewModel: viewModel,
onDuplicate: onDuplicate
)
}
.swipeActions(edge: .leading) {
if fullPost.status == .publish {
Expand Down Expand Up @@ -392,7 +399,12 @@ private struct ForEachContent: View {
}
}
.overlay(alignment: .topTrailing) {
PostActionMenu(post: fullPost, viewModel: viewModel, onDuplicate: onDuplicate)
PostActionMenu(
post: fullPost,
pageRole: item.pageRole,
viewModel: viewModel,
onDuplicate: onDuplicate
)
.offset(y: -6)
}
} else {
Expand Down Expand Up @@ -459,12 +471,13 @@ private struct ForEachContentWithIndentation: View {

private struct PostActionMenu: View {
let post: AnyPostWithEditContext
let pageRole: PageRole?
let viewModel: CustomPostListViewModel
let onDuplicate: (AnyPostWithEditContext) -> Void

var body: some View {
Menu {
PostActionMenuContent(post: post, viewModel: viewModel, onDuplicate: onDuplicate)
PostActionMenuContent(post: post, pageRole: pageRole, viewModel: viewModel, onDuplicate: onDuplicate)
} label: {
Image(systemName: "ellipsis")
.font(.body)
Expand All @@ -477,15 +490,29 @@ private struct PostActionMenu: View {

private struct PostActionMenuContent: View {
let post: AnyPostWithEditContext
let pageRole: PageRole?
let viewModel: CustomPostListViewModel
let onDuplicate: (AnyPostWithEditContext) -> Void

var body: some View {
primarySection
pageAttributesSection
navigationSection
trashSection
}

@ViewBuilder
private var pageAttributesSection: some View {
if viewModel.canChangePageAttributes, post.status == .publish {
PageAttributeMenuSection(
pageRole: pageRole,
onSetHomepage: { Task { await viewModel.setAsHomepage(post) } },
onSetPostsPage: { Task { await viewModel.setAsPostsPage(post) } },
onSetRegularPage: { Task { await viewModel.setAsRegularPage(post) } }
)
}
}

@ViewBuilder
private var primarySection: some View {
Section {
Expand Down Expand Up @@ -538,13 +565,16 @@ private struct PostActionMenuContent: View {
private var trashSection: some View {
Section {
if post.status != .trash {
Button(role: .destructive, action: {
if post.status == .publish {
viewModel.confirmTrash(post)
} else {
Task { await viewModel.trashPost(post) }
Button(
role: .destructive,
action: {
if post.status == .publish {
viewModel.confirmTrash(post)
} else {
Task { await viewModel.trashPost(post) }
}
}
}) {
) {
Label(Strings.moveToTrash, systemImage: "trash")
}
} else {
Expand All @@ -556,6 +586,37 @@ private struct PostActionMenuContent: View {
}
}

private struct PageAttributeMenuSection: View {
let pageRole: PageRole?
let onSetHomepage: () -> Void
let onSetPostsPage: () -> Void
let onSetRegularPage: () -> Void

var body: some View {
Section {
Menu {
if pageRole != .homepage {
Button(action: onSetHomepage) {
Label(Strings.setHomepage, systemImage: "house")
}
}
if pageRole != .postsPage {
Button(action: onSetPostsPage) {
Label(Strings.setPostsPage, systemImage: "text.word.spacing")
}
}
if pageRole == .postsPage {
Button(action: onSetRegularPage) {
Label(Strings.setRegularPage, systemImage: "arrow.uturn.backward")
}
}
} label: {
Label(Strings.pageAttributes, systemImage: "doc")
}
}
}
}

private struct PostContent: View {
let post: CustomPostCollectionDisplayPost
let client: WordPressClient?
Expand All @@ -566,7 +627,7 @@ private struct PostContent: View {
header
content
footer
homepageBadge
pageRoleBadge
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
Expand Down Expand Up @@ -614,14 +675,24 @@ private struct PostContent: View {
}

@ViewBuilder
private var homepageBadge: some View {
if post.isHomepage {
private var pageRoleBadge: some View {
switch post.pageRole {
case .homepage:
HStack(spacing: 2) {
Image(systemName: "house.fill")
Text(verbatim: Strings.homepageBadge)
}
.font(.footnote)
.foregroundStyle(.secondary)
case .postsPage:
HStack(spacing: 2) {
Image(systemName: "paragraphsign")
Text(verbatim: Strings.postsPageBadge)
}
.font(.footnote)
.foregroundStyle(.secondary)
case nil:
EmptyView()
}
}
}
Expand All @@ -646,7 +717,8 @@ private enum Strings {
static let emptyStateMessage = NSLocalizedString(
"customPostList.emptyState.message",
value: "No %1$@",
comment: "Empty state message when no custom posts exist. %1$@ is the post type name (e.g., 'Podcasts', 'Products')."
comment:
"Empty state message when no custom posts exist. %1$@ is the post type name (e.g., 'Podcasts', 'Products')."
)
static let homepageBadge = NSLocalizedString(
"customPostList.badge.homepage",
Expand Down Expand Up @@ -708,6 +780,31 @@ private enum Strings {
value: "Delete",
comment: "Short label for the swipe action to permanently delete a trashed post. Keep this translation short."
)
static let setHomepage = NSLocalizedString(
"customPostList.action.setHomepage",
value: "Set as Homepage",
comment: "Menu action to set a page as the site homepage"
)
static let setPostsPage = NSLocalizedString(
"customPostList.action.setPostsPage",
value: "Set as Posts Page",
comment: "Menu action to set a page as the posts page"
)
static let setRegularPage = NSLocalizedString(
"customPostList.action.setRegularPage",
value: "Set as Regular Page",
comment: "Menu action to remove the posts page designation from a page"
)
static let pageAttributes = NSLocalizedString(
"customPostList.action.pageAttributes",
value: "Page Attributes",
comment: "Label for the page attributes submenu in the context menu"
)
static let postsPageBadge = NSLocalizedString(
"customPostList.badge.postsPage",
value: "Posts page",
comment: "Badge label shown on the posts page row in the custom post list for pages"
)
}

// MARK: - Previews
Expand Down
Loading