diff --git a/Sources/WordPressData/Swift/Page.swift b/Sources/WordPressData/Swift/Page.swift index b1999359fab9..7e877673ea30 100644 --- a/Sources/WordPressData/Swift/Page.swift +++ b/Sources/WordPressData/Swift/Page.swift @@ -45,9 +45,9 @@ public class Page: AbstractPost { @objc public var isSiteHomepage: Bool { guard let postID, - let homepageID = blog.homepagePageID, - let homepageType = blog.homepageType, - homepageType == .page else { + let homepageID = blog.homepagePageID, + let homepageType = blog.homepageType, + homepageType == .page else { return false } @@ -56,12 +56,29 @@ public class Page: AbstractPost { @objc public var isSitePostsPage: Bool { guard let postID, - let postsPageID = blog.homepagePostsPageID, - let homepageType = blog.homepageType, - homepageType == .page else { + let postsPageID = blog.homepagePostsPageID, + let homepageType = blog.homepageType, + homepageType == .page else { return false } return postsPageID == postID.intValue } + + // MARK: - Parent Page + + /// Returns the display text for the parent page + public static func parentPageText(in context: NSManagedObjectContext, parentID: NSNumber) -> String? { + guard parentID.intValue > 0 else { + return nil + } + let request = NSFetchRequest(entityName: Page.entityName()) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "postID == %@", parentID) + + guard let parent = try? context.fetch(request).first else { + return nil + } + return parent.titleForDisplay() + } } diff --git a/WordPress/Classes/ViewRelated/Pages/Controllers/ParentPageSettingsViewController.swift b/WordPress/Classes/ViewRelated/Pages/Controllers/ParentPageSettingsViewController.swift index af5bcca3fb31..3142ba9e3ce1 100644 --- a/WordPress/Classes/ViewRelated/Pages/Controllers/ParentPageSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Pages/Controllers/ParentPageSettingsViewController.swift @@ -51,6 +51,9 @@ class ParentPageSettingsViewController: UIViewController { private var filteredRows: [ImmuTableSection]! private var selectedPage: Page! + /// Called when the parent page selection changes + var onSelectionChanged: ((Page?) -> Void)? + override func viewDidLoad() { super.viewDidLoad() @@ -205,7 +208,16 @@ extension ParentPageSettingsViewController: UITableViewDelegate { guard let row = sections[indexPath.section].rows[indexPath.row] as? Row else { return } - selectedPage.parentID = row.page?.postID + + // Call the new closure if available + onSelectionChanged?(row.page) + + // Maintain backward compatibility: update the page's parentID directly + // if onSelectionChanged is not set + if onSelectionChanged == nil { + selectedPage.parentID = row.page?.postID + } + tableView.reloadData() } } diff --git a/WordPress/Classes/ViewRelated/Post/ParentPagePicker.swift b/WordPress/Classes/ViewRelated/Post/ParentPagePicker.swift new file mode 100644 index 000000000000..acc7f2116455 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/ParentPagePicker.swift @@ -0,0 +1,110 @@ +import SwiftUI +import CoreData +import WordPressData +import WordPressShared +import WordPressUI + +@MainActor +struct ParentPagePicker: View { + private let blog: Blog + private let currentPage: Page + private let onSelection: (Page?) -> Void + + @State private var isLoading = true + @State private var pages: [Page] = [] + @State private var error: Error? + + init(blog: Blog, currentPage: Page, onSelection: @escaping (Page?) -> Void) { + self.blog = blog + self.currentPage = currentPage + self.onSelection = onSelection + } + + var body: some View { + Group { + if isLoading { + ProgressView() + } else if let error { + EmptyStateView.failure(error: error) { + Task { await loadPages() } + } + } else { + ParentPageSettingsViewControllerWrapper( + pages: pages, + selectedPage: currentPage, + onSelection: onSelection + ) + .ignoresSafeArea() + } + } + .navigationTitle(Strings.title) + .navigationBarTitleDisplayMode(.inline) + .task { + await loadPages() + } + } + + private func loadPages() async { + do { + let request = NSFetchRequest(entityName: Page.entityName()) + let filter = PostListFilter.publishedFilter() + request.predicate = filter.predicate(for: blog, author: .everyone) + request.sortDescriptors = filter.sortDescriptors + + let context = ContextManager.shared.mainContext + var pages = try await PostRepository().buildPageTree(request: request) + .map { pageID, hierarchyIndex in + let page = try context.existingObject(with: pageID) + page.hierarchyIndex = hierarchyIndex + return page + } + + // Remove the current page from the list (can't be its own parent) + if let index = pages.firstIndex(of: currentPage) { + pages = pages.remove(from: index) + } + + self.pages = pages + self.isLoading = false + } catch { + wpAssertionFailure("Failed to fetch pages", userInfo: ["error": "\(error)"]) // This should never happen + self.error = error + self.isLoading = false + } + } +} + +// MARK: - UIViewControllerRepresentable Wrapper + +private struct ParentPageSettingsViewControllerWrapper: UIViewControllerRepresentable { + let pages: [Page] + let selectedPage: Page + let onSelection: (Page?) -> Void + + func makeUIViewController(context: Context) -> ParentPageSettingsViewController { + guard let viewController = ParentPageSettingsViewController.make( + with: pages, + selectedPage: selectedPage + ) as? ParentPageSettingsViewController else { + fatalError("Expected ParentPageSettingsViewController") + } + viewController.onSelectionChanged = { selectedParentPage in + onSelection(selectedParentPage) + } + return viewController + } + + func updateUIViewController(_ uiViewController: ParentPageSettingsViewController, context: Context) { + // No updates needed + } +} + +// MARK: - Localized Strings + +private enum Strings { + static let title = NSLocalizedString( + "parentPagePicker.title", + value: "Parent Page", + comment: "Title for the parent page picker screen" + ) +} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift index db351fe0616d..e93d1e09fee4 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettings.swift @@ -69,6 +69,9 @@ struct PostSettings: Hashable { /// Applies the settings to an AbstractPost instance. /// Only updates properties that have actually changed. func apply(to post: AbstractPost) { + if post.mt_excerpt != excerpt { + post.mt_excerpt = excerpt + } if post.wp_slug != slug { post.wp_slug = slug } @@ -116,6 +119,23 @@ struct PostSettings: Hashable { } post.categories = Set(selectedCategories) } + + // Update post format + if post.postFormat != postFormat { + post.postFormat = postFormat + } + + // Update sticky post setting + if post.isStickyPost != isStickyPost { + post.isStickyPost = isStickyPost + } + } + + // Apply page-specific settings + if let page = post as? Page { + if page.parentID?.intValue != parentPageID { + page.parentID = parentPageID.map { NSNumber(value: $0) } + } } } diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift index d27b8251cc5a..2d28e01d80c3 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsView.swift @@ -48,6 +48,8 @@ private struct PostSettingsView: View { if viewModel.isPost { taxonomySection } + excerptSection + moreOptionsSection } .disabled(viewModel.isSaving) .toolbar { @@ -121,9 +123,7 @@ private struct PostSettingsView: View { if viewModel.isMultiAuthorBlog { authorRow } - if viewModel.isDraftOrPending { - pendingReviewRow - } else { + if !viewModel.isDraftOrPending { publishDateRow visibilityRow } @@ -221,6 +221,76 @@ private struct PostSettingsView: View { } .tint(.primary) } + + // MARK: - "Excerpt" Section + + @ViewBuilder + private var excerptSection: some View { + Section(Strings.excerptHeader) { + SettingsTextEditor(text: $viewModel.settings.excerpt) + } + } + + // MARK: - "More Options" Section + + @ViewBuilder + private var moreOptionsSection: some View { + Section(Strings.moreOptionsHeader) { + slugRow + if viewModel.isDraftOrPending { + pendingReviewRow + } + if viewModel.isPost { + postFormatRow + } + if !viewModel.isPost { + parentPageRow + } + } + } + + private var postFormatRow: some View { + NavigationLink { + PostFormatPicker(post: viewModel.post as! Post) { format in + viewModel.settings.postFormat = format + viewModel.viewController?.navigationController?.popViewController(animated: true) + } + } label: { + SettingsRow(Strings.postFormatLabel, value: viewModel.postFormatText) + } + } + + private var parentPageRow: some View { + NavigationLink { + if let page = viewModel.post as? Page { + ParentPagePicker( + blog: viewModel.post.blog, + currentPage: page, + onSelection: { selectedParentPage in + viewModel.settings.parentPageID = selectedParentPage?.postID?.intValue + viewModel.viewController?.navigationController?.popViewController(animated: true) + } + ) + } + } label: { + SettingsRow(Strings.parentPageLabel, value: viewModel.parentPageText ?? Strings.topLevelPage) + } + } + + private var slugRow: some View { + NavigationLink { + SettingsTextFieldView( + title: Strings.slugLabel, + text: $viewModel.settings.slug, + placeholder: Strings.slugPlaceholder, + hint: Strings.slugHint + ) + .autocapitalization(.none) + .autocorrectionDisabled() + } label: { + SettingsRow(Strings.slugLabel, value: viewModel.slugText) + } + } } @MainActor @@ -245,6 +315,21 @@ private struct PostSettingsAuthorRow: View { } } +/// A text editor that is displayed with two-lines when empty and grows up to +/// a certain height limit as you add more text. +@MainActor +private struct SettingsTextEditor: View { + @Binding var text: String + + @ScaledMetric(relativeTo: .body) var height = 84 + + var body: some View { + TextEditor(text: $text) + .frame(height: height) + .listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 0, trailing: 16)) + } +} + @MainActor private struct SettingsRow: View { let title: String @@ -265,6 +350,32 @@ private struct SettingsRow: View { } } +@MainActor +private struct SettingsTextFieldView: View { + let title: String + @Binding var text: String + let placeholder: String + let hint: String + + @FocusState private var isFocused: Bool + + var body: some View { + Form { + Section { + TextField(placeholder, text: $text) + .focused($isFocused) + } footer: { + Text(hint) + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + isFocused = true + } + } +} + private enum Strings { static let generalHeader = NSLocalizedString( "postSettings.section.general", @@ -272,12 +383,6 @@ private enum Strings { comment: "Section header for General settings in Post Settings" ) - static let moreOptionsHeader = NSLocalizedString( - "postSettings.section.moreOptions", - value: "More Options", - comment: "Section header for More Options in Post Settings" - ) - static let authorLabel = NSLocalizedString( "postSettings.author.label", value: "Author", @@ -302,18 +407,6 @@ private enum Strings { comment: "Label for the pending review toggle in Post Settings" ) - static let slugLabel = NSLocalizedString( - "postSettings.slug.label", - value: "Slug", - comment: "Label for the slug field. Should be the same as WP core." - ) - - static let slugPlaceholder = NSLocalizedString( - "postSettings.slug.placeholder", - value: "Enter slug", - comment: "Placeholder text for the slug field" - ) - static let discardChangesTitle = NSLocalizedString( "postSettings.discardChanges.title", value: "Discard Changes?", @@ -355,4 +448,52 @@ private enum Strings { value: "Tags", comment: "Label for the tags field. Should be the same as WP core." ) + + static let excerptHeader = NSLocalizedString( + "postSettings.excerpt.header", + value: "Excerpt", + comment: "Section header for Excerpt in Post Settings" + ) + + static let moreOptionsHeader = NSLocalizedString( + "postSettings.moreOptions.header", + value: "More Options", + comment: "Section header for More Options in Post Settings. Should use the same translation as core WP." + ) + + static let postFormatLabel = NSLocalizedString( + "postSettings.postFormat.label", + value: "Post Format", + comment: "Label for the post format field. Should be the same as WP core." + ) + + static let parentPageLabel = NSLocalizedString( + "postSettings.parentPage.label", + value: "Parent Page", + comment: "Label for the parent page field" + ) + + static let topLevelPage = NSLocalizedString( + "postSettings.parentPage.topLevel", + value: "Top level", + comment: "Cell title for the Top Level option case" + ) + + static let slugLabel = NSLocalizedString( + "postSettings.slug.label", + value: "Slug", + comment: "Label for the slug field. Should be the same as WP core." + ) + + static let slugPlaceholder = NSLocalizedString( + "postSettings.slug.placeholder", + value: "Enter slug", + comment: "Placeholder for the slug field" + ) + + static let slugHint = NSLocalizedString( + "postSettings.slug.hint", + value: "The slug is the URL-friendly version of the post title.", + comment: "Hint text for the slug field. Should be the same as the text displayed if the user clicks the (i) in Slug in Calypso." + ) } diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift index 9ed478343f13..55eccbecbfd8 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift @@ -21,6 +21,7 @@ final class PostSettingsViewModel: ObservableObject { @Published private(set) var hasChanges = false @Published private(set) var categoriesText = "" @Published private(set) var tagsText = "" + @Published private(set) var parentPageText: String? @Published var isShowingDeletedAlert = false @@ -64,6 +65,15 @@ final class PostSettingsViewModel: ObservableObject { .localizedTitle } + var slugText: String { + settings.slug.isEmpty ? (post.suggested_slug ?? "") : settings.slug + } + + var postFormatText: String { + guard let post = post as? Post else { return "" } + return post.blog.postFormatText(fromSlug: settings.postFormat) ?? NSLocalizedString("Standard", comment: "Default post format") + } + var timeZone: TimeZone { post.blog.timeZone ?? TimeZone.current } @@ -105,6 +115,8 @@ final class PostSettingsViewModel: ObservableObject { // Initialize cached text values refresh(with: settings) + + WPAnalytics.track(.postSettingsShown) } private func refresh(with settings: PostSettings) { @@ -112,6 +124,15 @@ final class PostSettingsViewModel: ObservableObject { categoriesText = settings.makeCategoriesText(for: post) .stringByDecodingXMLCharacters() tagsText = settings.makeTagsText() + + // Update parent page text for pages + if let page = post as? Page, + let context = page.managedObjectContext, + let parentPageID = settings.parentPageID { + parentPageText = Page.parentPageText(in: context, parentID: NSNumber(value: parentPageID)) + } else { + parentPageText = nil + } } func buttonCancelTapped() { @@ -220,6 +241,24 @@ final class PostSettingsViewModel: ObservableObject { let action = new.featuredImageID == nil ? "removed" : "changed" WPAnalytics.track(.editorPostFeaturedImageChanged, properties: ["via": "settings", "action": action]) } + if old.excerpt != new.excerpt { + track(.editorPostExcerptChanged) + } + if old.slug != new.slug { + track(.editorPostSlugChanged) + } + if old.status != new.status { + if (old.status == .pending) != (new.status == .pending) { + track(.editorPostPendingReviewChanged) + } + } + if old.password != new.password { + // Password protection is a visibility change + track(.editorPostVisibilityChanged) + } + if old.isStickyPost != new.isStickyPost { + track(.editorPostStickyChanged) + } } private func track(_ event: WPAnalyticsEvent) {