diff --git a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift index 6af5f54beec0..564c0704eadf 100644 --- a/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift +++ b/Modules/Sources/WordPressUI/Views/DataView/DataViewPaginatedResponse.swift @@ -119,4 +119,18 @@ public final class DataViewPaginatedResponse: self.total = total - 1 } } + + public func replace(_ item: Element) { + guard let index = items.firstIndex(where: { $0.id == item.id }) else { + return + } + items[index] = item + } + + public func prepend(_ newItems: [Element]) { + self.items = newItems + self.items + if let total { + self.total = total + newItems.count + } + } } diff --git a/WordPress/Classes/ViewRelated/Blog/Site Management/SiteTagsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Management/SiteTagsViewController.swift deleted file mode 100644 index 90806a9ddccd..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Site Management/SiteTagsViewController.swift +++ /dev/null @@ -1,441 +0,0 @@ -import UIKit -import SwiftUI -import Gridicons -import WordPressData -import WordPressUI -import WordPressShared - -final class SiteTagsViewController: UITableViewController { - private struct TableConstants { - static let cellIdentifier = "TitleBadgeDisclosureCell" - static let accesibilityIdentifier = "SiteTagsList" - } - private let blog: Blog - - private var isSearching = false - - private lazy var context: NSManagedObjectContext = { - return ContextManager.shared.mainContext - }() - - private lazy var defaultPredicate: NSPredicate = { - return NSPredicate(format: "blog.blogID = %@", blog.dotComID!) - }() - - private let sortDescriptors: [NSSortDescriptor] = { - return [NSSortDescriptor(key: "name", ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare(_:)))] - }() - - private lazy var resultsController: NSFetchedResultsController = { - let request = NSFetchRequest(entityName: "PostTag") - request.sortDescriptors = self.sortDescriptors - - let frc = NSFetchedResultsController(fetchRequest: request, managedObjectContext: self.context, sectionNameKeyPath: nil, cacheName: nil) - frc.delegate = self - return frc - }() - - private lazy var searchController: UISearchController = { - let controller = UISearchController(searchResultsController: nil) - controller.hidesNavigationBarDuringPresentation = false - controller.obscuresBackgroundDuringPresentation = false - controller.searchResultsUpdater = self - controller.delegate = self - return controller - }() - - private var stateView: UIView? { - didSet { - oldValue?.removeFromSuperview() - if let stateView { - tableView.addSubview(stateView) - stateView.translatesAutoresizingMaskIntoConstraints = false - tableView.pinSubviewToSafeArea(stateView) - } - } - } - - private var isPerformingInitialSync = false - - init(blog: Blog) { - self.blog = blog - super.init(style: .plain) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - title = NSLocalizedString("Tags", comment: "Label for the Tags Section in the Blog Settings") - setupTableView() - refreshTags() - refreshResultsController(predicate: defaultPredicate) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - refreshNoResultsView() - } - - private func setupTableView() { - tableView.accessibilityIdentifier = TableConstants.accesibilityIdentifier - tableView.tableFooterView = UIView(frame: .zero) - tableView.register(TitleBadgeDisclosureCell.defaultNib, forCellReuseIdentifier: TableConstants.cellIdentifier) - setupRefreshControl() - } - - private func setupRefreshControl() { - if refreshControl == nil { - refreshControl = UIRefreshControl() - refreshControl?.backgroundColor = .systemBackground - refreshControl?.addTarget(self, action: #selector(refreshTags), for: .valueChanged) - } - } - - private func deactivateRefreshControl() { - refreshControl = nil - } - - @objc private func refreshResultsController(predicate: NSPredicate) { - resultsController.fetchRequest.predicate = predicate - resultsController.fetchRequest.sortDescriptors = sortDescriptors - do { - try resultsController.performFetch() - tableView.reloadData() - refreshNoResultsView() - } catch { - tagsFailedLoading(error: error) - } - } - - @objc private func refreshTags() { - isPerformingInitialSync = true - let tagsService = PostTagService(managedObjectContext: ContextManager.shared.mainContext) - tagsService.syncTags(for: blog, success: { [weak self] tags in - self?.isPerformingInitialSync = false - self?.refreshControl?.endRefreshing() - self?.refreshNoResultsView() - }, failure: { [weak self] error in - self?.tagsFailedLoading(error: error) - }) - } - - private func showRightBarButton(_ show: Bool) { - if show { - navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(createTag)) - } else { - navigationItem.rightBarButtonItem = nil - } - } - - @objc private func createTag() { - navigate(to: nil) - } - - func tagsFailedLoading(error: Error) { - DDLogError("Tag management. Error loading tags for \(String(describing: blog.url)): \(error)") - } -} - -// MARK: - Table view datasource -extension SiteTagsViewController { - override func numberOfSections(in tableView: UITableView) -> Int { - resultsController.sections?.count ?? 0 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - resultsController.sections?[section].numberOfObjects ?? 0 - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: TableConstants.cellIdentifier, for: indexPath) as? TitleBadgeDisclosureCell, let tag = tagAtIndexPath(indexPath) else { - return TitleBadgeDisclosureCell() - } - - cell.name = tag.name?.stringByDecodingXMLCharacters() - - if let count = tag.postCount?.intValue, count > 0 { - cell.count = count - } - - return cell - } - - fileprivate func tagAtIndexPath(_ indexPath: IndexPath) -> PostTag? { - resultsController.object(at: indexPath) as? PostTag - } - - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - true - } - - override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { - guard let selectedTag = tagAtIndexPath(indexPath) else { - return - } - delete(selectedTag) - } - - private func delete(_ tag: PostTag) { - let tagsService = PostTagService(managedObjectContext: ContextManager.shared.mainContext) - refreshControl?.beginRefreshing() - tagsService.delete(tag, for: blog, success: { [weak self] in - self?.refreshControl?.endRefreshing() - self?.tableView.reloadData() - }, failure: { [weak self] error in - self?.refreshControl?.endRefreshing() - }) - } - - private func save(_ tag: PostTag) { - let tagsService = PostTagService(managedObjectContext: ContextManager.shared.mainContext) - refreshControl?.beginRefreshing() - tagsService.save(tag, for: blog, success: { [weak self] tag in - self?.refreshControl?.endRefreshing() - self?.tableView.reloadData() - self?.refreshNoResultsView() - }, failure: { error in - self.refreshControl?.endRefreshing() - }) - } -} - -// MARK: - Table view delegate -extension SiteTagsViewController { - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let selectedTag = tagAtIndexPath(indexPath) else { - return - } - navigate(to: selectedTag) - } -} - -// MARK: - Fetched results delegate -extension SiteTagsViewController: NSFetchedResultsControllerDelegate { - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - refreshNoResultsView() - } -} - -// MARK: - Navigation to Tag details -extension SiteTagsViewController { - fileprivate func navigate(to tag: PostTag?) { - let titleSectionHeader = NSLocalizedString("Tag", comment: "Section header for tag name in Tag Details View.") - let subtitleSectionHeader = NSLocalizedString("Description", comment: "Section header for tag name in Tag Details View.") - let titleErrorFooter = NSLocalizedString("Name Required", comment: "Error to be displayed when a tag is empty") - let content = SettingsTitleSubtitleController.Content(title: tag?.name, - subtitle: tag?.tagDescription, - titleHeader: titleSectionHeader, - subtitleHeader: subtitleSectionHeader, - titleErrorFooter: titleErrorFooter) - let confirmationContent = confirmation() - let tagDetailsView = SettingsTitleSubtitleController(content: content, confirmation: confirmationContent) - - tagDetailsView.setAction { [weak self] updatedData in - self?.navigationController?.popViewController(animated: true) - - guard let tag else { - return - } - - self?.delete(tag) - } - - tagDetailsView.setUpdate { [weak self] updatedData in - guard let tag else { - self?.addTag(data: updatedData) - return - } - - guard self?.tagWasUpdated(tag: tag, updatedTag: updatedData) == true else { - return - } - - self?.updateTag(tag, updatedData: updatedData) - - } - - navigationController?.pushViewController(tagDetailsView, animated: true) - } - - private func addTag(data: SettingsTitleSubtitleController.Content) { - if let existingTag = existingTagForData(data) { - displayAlertForExistingTag(existingTag) - return - } - guard let newTag = NSEntityDescription.insertNewObject(forEntityName: PostTag.entityName(), into: ContextManager.shared.mainContext) as? PostTag else { - return - } - - newTag.name = data.title - newTag.tagDescription = data.subtitle - - save(newTag) - WPAnalytics.trackSettingsChange("site_tags", fieldName: "add_tag") - } - - private func updateTag(_ tag: PostTag, updatedData: SettingsTitleSubtitleController.Content) { - // Lets check that we are not updating a tag to a name that already exists - if let existingTag = existingTagForData(updatedData), - existingTag != tag { - displayAlertForExistingTag(existingTag) - return - } - - tag.name = updatedData.title - tag.tagDescription = updatedData.subtitle - - save(tag) - WPAnalytics.trackSettingsChange("site_tags", fieldName: "edit_tag") - } - - private func existingTagForData(_ data: SettingsTitleSubtitleController.Content) -> PostTag? { - guard let title = data.title else { - return nil - } - let request = NSFetchRequest(entityName: "PostTag") - request.predicate = NSPredicate(format: "blog.blogID = %@ AND name = %@", blog.dotComID!, title) - request.fetchLimit = 1 - guard let results = (try? context.fetch(request)) as? [PostTag] else { - return nil - } - return results.first - } - - fileprivate func displayAlertForExistingTag(_ tag: PostTag) { - let title = NSLocalizedString("Tag already exists", - comment: "Title of the alert indicating that a tag with that name already exists.") - let tagName = tag.name ?? "" - let message = String(format: NSLocalizedString("A tag named '%@' already exists.", - comment: "Message of the alert indicating that a tag with that name already exists. The placeholder is the name of the tag"), - tagName) - - let acceptTitle = SharedStrings.Button.ok - let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) - alertController.addDefaultActionWithTitle(acceptTitle) - present(alertController, animated: true) - } - - private func tagWasUpdated(tag: PostTag, updatedTag: SettingsTitleSubtitleController.Content) -> Bool { - if tag.name == updatedTag.title && tag.tagDescription == updatedTag.subtitle { - return false - } - - return true - } - - private func confirmation() -> SettingsTitleSubtitleController.Confirmation { - let confirmationTitle = NSLocalizedString("Delete this tag", comment: "Delete Tag confirmation action title") - let confirmationSubtitle = NSLocalizedString("Are you sure you want to delete this tag?", comment: "Message asking for confirmation on tag deletion") - let actionTitle = NSLocalizedString("Delete", comment: "Delete") - let cancelTitle = NSLocalizedString("Cancel", comment: "Alert dismissal title") - let trashIcon = UIImage(systemName: "trash") ?? UIImage() - - return SettingsTitleSubtitleController.Confirmation(title: confirmationTitle, - subtitle: confirmationSubtitle, - actionTitle: actionTitle, - cancelTitle: cancelTitle, - icon: trashIcon, - isDestructiveAction: true) - } -} - -// MARK: - Empty state handling - -private extension SiteTagsViewController { - - func refreshNoResultsView() { - let noResults = resultsController.fetchedObjects?.count == 0 - showRightBarButton(!noResults) - - if noResults { - showNoResults() - } else { - hideNoResults() - } - } - - func showNoResults() { - if isSearching { - showNoSearchResultsView() - return - } - - if isPerformingInitialSync { - showLoadingView() - return - } - - showEmptyResultsView() - } - - func showLoadingView() { - stateView = UIHostingView(view: ProgressView()) - navigationItem.searchController = nil - } - - func showEmptyResultsView() { - stateView = UIHostingView(view: EmptyStateView(label: { - Label(NoResultsText.noTagsTitle, image: "wp-illustration-empty-results") - }, description: { - Text(NoResultsText.noTagsMessage) - }, actions: { - Button(NoResultsText.createButtonTitle) { [weak self] in - self?.createTag() - } - .buttonStyle(.primary) - })) - navigationItem.searchController = nil - } - - func showNoSearchResultsView() { - stateView = UIHostingView(view: VStack { - EmptyStateView.search() - .padding(.top, 50) - Spacer() - }) - } - - func hideNoResults() { - stateView = nil - navigationItem.searchController = searchController - navigationItem.hidesSearchBarWhenScrolling = false - tableView.reloadData() - } - - struct NoResultsText { - static let noTagsTitle = NSLocalizedString("You don't have any tags", comment: "Empty state. Tags management (Settings > Writing > Tags)") - static let noTagsMessage = NSLocalizedString("Tags created here can be quickly added to new posts", comment: "Displayed when the user views tags in blog settings and there are no tags") - static let createButtonTitle = NSLocalizedString("Create a Tag", comment: "Title of the button in the placeholder for an empty list of blog tags.") - } -} - -// MARK: - SearchResultsUpdater -extension SiteTagsViewController: UISearchResultsUpdating { - func updateSearchResults(for searchController: UISearchController) { - guard let text = searchController.searchBar.text, text != "" else { - refreshResultsController(predicate: defaultPredicate) - return - } - - let filterPredicate = NSPredicate(format: "blog.blogID = %@ AND name contains [cd] %@", blog.dotComID!, text) - refreshResultsController(predicate: filterPredicate) - } -} - -// MARK: - UISearchControllerDelegate Conformance -extension SiteTagsViewController: UISearchControllerDelegate { - func willPresentSearchController(_ searchController: UISearchController) { - isSearching = true - deactivateRefreshControl() - } - - func willDismissSearchController(_ searchController: UISearchController) { - isSearching = false - setupRefreshControl() - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift index 4604a40f4669..c14cdf9c6048 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift @@ -48,7 +48,7 @@ extension SiteSettingsViewController { } @objc public func showTagList() { - let tagsVC = SiteTagsViewController(blog: blog) + let tagsVC = TagsViewController(blog: blog) navigationController?.pushViewController(tagsVC, animated: true) } diff --git a/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController.swift b/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController.swift index d6bcf60848b0..aab75bde3050 100644 --- a/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Prepublishing/PrepublishingViewController.swift @@ -319,7 +319,7 @@ final class PrepublishingViewController: UIViewController, UITableViewDataSource private func didTapTagCell() { let post = post as! Post - let tagPickerViewController = TagsViewController(blog: post.blog, selectedTags: post.tags) {[weak self] tags in + let tagPickerViewController = TagsViewController(blog: post.blog, selectedTags: post.tags) { [weak self] tags in guard let self else { return } WPAnalytics.track(.editorPostTagsChanged, properties: Constants.analyticsDefaultProperty) diff --git a/WordPress/Classes/ViewRelated/Tags/EditTagView.swift b/WordPress/Classes/ViewRelated/Tags/EditTagView.swift new file mode 100644 index 000000000000..00b2aeb79d57 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Tags/EditTagView.swift @@ -0,0 +1,215 @@ +import SwiftUI +import WordPressUI +import WordPressKit +import WordPressData +import SVProgressHUD + +struct EditTagView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel: EditTagViewModel + + init(tag: RemotePostTag?, tagsService: TagsService) { + self._viewModel = StateObject(wrappedValue: EditTagViewModel(tag: tag, tagsService: tagsService)) + } + + var body: some View { + Form { + Section(Strings.tagSectionHeader) { + HStack { + TextField(Strings.tagNamePlaceholder, text: $viewModel.tagName) + .textFieldStyle(.plain) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .keyboardType(.default) + + if !viewModel.tagName.isEmpty { + Button(action: { + viewModel.tagName = "" + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + } + } + } + + Section(Strings.descriptionSectionHeader) { + TextField(Strings.descriptionPlaceholder, text: $viewModel.tagDescription, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(5...15) + } + + if viewModel.isExistingTag { + Section { + Button(action: { + viewModel.showDeleteConfirmation = true + }) { + Text(SharedStrings.Button.delete) + .foregroundColor(.red) + } + } + } + } + .navigationTitle(viewModel.navigationTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(SharedStrings.Button.save) { + Task { + let success = await viewModel.saveTag() + if success { + dismiss() + } + } + } + .disabled(viewModel.tagName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .confirmationDialog( + Strings.deleteConfirmationTitle, + isPresented: $viewModel.showDeleteConfirmation, + titleVisibility: .visible + ) { + Button(SharedStrings.Button.delete, role: .destructive) { + Task { + let success = await viewModel.deleteTag() + if success { + dismiss() + } + } + } + Button(SharedStrings.Button.cancel, role: .cancel) { } + } message: { + Text(Strings.deleteConfirmationMessage) + } + .alert(SharedStrings.Error.generic, isPresented: $viewModel.showError) { + Button(SharedStrings.Button.ok) { } + } message: { + Text(viewModel.errorMessage) + } + } +} + +@MainActor +class EditTagViewModel: ObservableObject { + @Published var tagName: String + @Published var tagDescription: String + @Published var showDeleteConfirmation = false + @Published var showError = false + @Published var errorMessage = "" + + private let originalTag: RemotePostTag? + private let tagsService: TagsService + + var isExistingTag: Bool { + originalTag != nil + } + + var navigationTitle: String { + originalTag?.name ?? Strings.newTagTitle + } + + init(tag: RemotePostTag?, tagsService: TagsService) { + self.originalTag = tag + self.tagsService = tagsService + self.tagName = tag?.name ?? "" + self.tagDescription = tag?.tagDescription ?? "" + } + + func deleteTag() async -> Bool { + guard let tag = originalTag else { return false } + + SVProgressHUD.show() + defer { SVProgressHUD.dismiss() } + + do { + try await tagsService.deleteTag(tag) + + // Post notification to update the UI + NotificationCenter.default.post( + name: .tagDeleted, + object: nil, + userInfo: [TagNotificationUserInfoKeys.tagID: tag.tagID ?? 0] + ) + return true + } catch { + errorMessage = error.localizedDescription + showError = true + return false + } + } + + func saveTag() async -> Bool { + SVProgressHUD.show() + defer { SVProgressHUD.dismiss() } + + let tagToSave: RemotePostTag + if let existingTag = originalTag { + tagToSave = existingTag + } else { + tagToSave = RemotePostTag() + } + + tagToSave.name = tagName.trimmingCharacters(in: .whitespacesAndNewlines) + tagToSave.tagDescription = tagDescription.trimmingCharacters(in: .whitespacesAndNewlines) + + do { + let savedTag = try await tagsService.saveTag(tagToSave) + + NotificationCenter.default.post( + name: originalTag == nil ? .tagCreated : .tagUpdated, + object: nil, + userInfo: [TagNotificationUserInfoKeys.tag: savedTag] + ) + return true + } catch { + errorMessage = error.localizedDescription + showError = true + return false + } + } +} + +private enum Strings { + static let tagSectionHeader = NSLocalizedString( + "edit.tag.section.tag", + value: "Tag", + comment: "Section header for tag name in edit tag view" + ) + + static let descriptionSectionHeader = NSLocalizedString( + "edit.tag.section.description", + value: "Description", + comment: "Section header for tag description in edit tag view" + ) + + static let tagNamePlaceholder = NSLocalizedString( + "edit.tag.name.placeholder", + value: "Tag name", + comment: "Placeholder text for tag name field" + ) + + static let descriptionPlaceholder = NSLocalizedString( + "edit.tag.description.placeholder", + value: "Add a description...", + comment: "Placeholder text for tag description field" + ) + + static let newTagTitle = NSLocalizedString( + "edit.tag.new.title", + value: "New Tag", + comment: "Navigation title for new tag creation" + ) + + static let deleteConfirmationTitle = NSLocalizedString( + "edit.tag.delete.confirmation.title", + value: "Delete Tag", + comment: "Title for delete tag confirmation dialog" + ) + + static let deleteConfirmationMessage = NSLocalizedString( + "edit.tag.delete.confirmation.message", + value: "Are you sure you want to delete this tag?", + comment: "Message for delete tag confirmation dialog" + ) +} diff --git a/WordPress/Classes/ViewRelated/Tags/TagsService.swift b/WordPress/Classes/ViewRelated/Tags/TagsService.swift index b9e464c38114..850b10549a52 100644 --- a/WordPress/Classes/ViewRelated/Tags/TagsService.swift +++ b/WordPress/Classes/ViewRelated/Tags/TagsService.swift @@ -21,7 +21,12 @@ class TagsService { return nil } - func getTags(number: Int = 100, offset: Int = 0) async throws -> [RemotePostTag] { + func getTags( + number: Int = 100, + offset: Int = 0, + orderBy: RemoteTaxonomyPagingResultsOrdering = .byName, + order: RemoteTaxonomyPagingResultsOrder = .orderAscending + ) async throws -> [RemotePostTag] { guard let remote else { throw TagsServiceError.noRemoteService } @@ -56,8 +61,68 @@ class TagsService { }) } } + + func deleteTag(_ tag: RemotePostTag) async throws { + guard let remote else { + throw TagsServiceError.noRemoteService + } + + guard tag.tagID != nil else { + throw TagsServiceError.invalidTag + } + + return try await withCheckedThrowingContinuation { continuation in + remote.delete(tag, success: { + continuation.resume() + }, failure: { error in + continuation.resume(throwing: error) + }) + } + } + + func saveTag(_ tag: RemotePostTag) async throws -> RemotePostTag { + guard let remote else { + throw TagsServiceError.noRemoteService + } + + return try await withCheckedThrowingContinuation { continuation in + if tag.tagID == nil { + remote.createTag(tag, success: { savedTag in + continuation.resume(returning: savedTag) + }, failure: { error in + continuation.resume(throwing: error) + }) + } else { + remote.update(tag, success: { savedTag in + continuation.resume(returning: savedTag) + }, failure: { error in + continuation.resume(throwing: error) + }) + } + } + } } enum TagsServiceError: Error { case noRemoteService + case invalidTag +} + +extension TagsServiceError: LocalizedError { + var errorDescription: String? { + switch self { + case .noRemoteService: + return NSLocalizedString( + "tags.error.no_remote_service", + value: "Unable to connect to your site. Please check your connection and try again.", + comment: "Error message when the tags service cannot connect to the remote site" + ) + case .invalidTag: + return NSLocalizedString( + "tags.error.invalid_tag", + value: "The tag information is invalid. Please try again.", + comment: "Error message when tag data is invalid" + ) + } + } } diff --git a/WordPress/Classes/ViewRelated/Tags/TagsView.swift b/WordPress/Classes/ViewRelated/Tags/TagsView.swift index 6e2a44c026a8..c02ef3544160 100644 --- a/WordPress/Classes/ViewRelated/Tags/TagsView.swift +++ b/WordPress/Classes/ViewRelated/Tags/TagsView.swift @@ -6,9 +6,13 @@ import WordPressData struct TagsView: View { @ObservedObject var viewModel: TagsViewModel + @State private var showingAddTagModal = false + var body: some View { VStack(alignment: .leading, spacing: 0) { - SelectedTagsView(viewModel: viewModel) + if case .selection = viewModel.mode { + SelectedTagsView(viewModel: viewModel) + } if !viewModel.searchText.isEmpty { TagsSearchView(viewModel: viewModel) @@ -19,6 +23,29 @@ struct TagsView: View { .navigationTitle(Strings.title) .searchable(text: $viewModel.searchText) .textInputAutocapitalization(.never) + .toolbar { + if case .browse = viewModel.mode { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + showingAddTagModal = true + }) { + Image(systemName: "plus") + } + } + } + } + .sheet(isPresented: $showingAddTagModal) { + NavigationView { + EditTagView(tag: nil, tagsService: viewModel.tagsService) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(SharedStrings.Button.cancel) { + showingAddTagModal = false + } + } + } + } + } } } @@ -28,9 +55,7 @@ private struct TagsListView: View { var body: some View { List { if let response = viewModel.response { - DataViewPaginatedForEach(response: response) { tag in - TagRowView(tag: tag, viewModel: viewModel) - } + TagsPaginatedForEach(response: response, viewModel: viewModel) } } .listStyle(.plain) @@ -68,9 +93,7 @@ private struct TagsSearchView: View { searchText: viewModel.searchText, search: viewModel.search ) { response in - DataViewPaginatedForEach(response: response) { tag in - TagRowView(tag: tag, viewModel: viewModel) - } + TagsPaginatedForEach(response: response, viewModel: viewModel) } } } @@ -83,6 +106,33 @@ private struct TagsPaginatedForEach: View { DataViewPaginatedForEach(response: response) { tag in TagRowView(tag: tag, viewModel: viewModel) } + .onReceive(NotificationCenter.default.publisher(for: .tagDeleted)) { notification in + tagDeleted(userInfo: notification.userInfo) + } + .onReceive(NotificationCenter.default.publisher(for: .tagCreated)) { notification in + tagCreated(userInfo: notification.userInfo) + } + .onReceive(NotificationCenter.default.publisher(for: .tagUpdated)) { notification in + tagUpdated(userInfo: notification.userInfo) + } + } + + private func tagDeleted(userInfo: [AnyHashable: Any]?) { + if let tagID = userInfo?[TagNotificationUserInfoKeys.tagID] as? NSNumber { + response.deleteItem(withID: tagID.intValue) + } + } + + private func tagCreated(userInfo: [AnyHashable: Any]?) { + if let tag = userInfo?[TagNotificationUserInfoKeys.tag] as? RemotePostTag { + response.prepend([tag]) + } + } + + private func tagUpdated(userInfo: [AnyHashable: Any]?) { + if let tag = userInfo?[TagNotificationUserInfoKeys.tag] as? RemotePostTag { + response.replace(tag) + } } } @@ -200,6 +250,35 @@ private struct TagRowView: View { let tag: RemotePostTag @ObservedObject var viewModel: TagsViewModel + var body: some View { + Group { + if case .browse = viewModel.mode { + NavigationLink(destination: EditTagView(tag: tag, tagsService: viewModel.tagsService)) { + TagRowContent(tag: tag, showPostCount: true, isSelected: false) + } + } else { + TagRowContent(tag: tag, showPostCount: false, isSelected: viewModel.isSelected(tag)) + .contentShape(Rectangle()) + .onTapGesture { + switch viewModel.mode { + case .selection: + viewModel.toggleSelection(for: tag) + case .pickOne(let onTagTapped): + onTagTapped(tag) + case .browse: + break + } + } + } + } + } +} + +private struct TagRowContent: View { + let tag: RemotePostTag + let showPostCount: Bool + let isSelected: Bool + var body: some View { HStack { Text(tag.name ?? "") @@ -207,15 +286,22 @@ private struct TagRowView: View { Spacer() - if viewModel.isSelected(tag) { + if showPostCount, let postCount = tag.postCount?.intValue, postCount > 0 { + Text("\(tag.postCount ?? 0)") + .font(.caption) + .foregroundColor(.accentColor) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .frame(minWidth: 20, minHeight: 20) + .overlay( + Capsule() + .stroke(Color.accentColor, lineWidth: 1) + ) + } else if isSelected { Image(systemName: "checkmark") .foregroundColor(.accentColor) } } - .contentShape(Rectangle()) - .onTapGesture { - viewModel.toggleSelection(for: tag) - } } } @@ -248,11 +334,23 @@ private enum Strings { class TagsViewController: UIHostingController { let viewModel: TagsViewModel - init(blog: Blog, selectedTags: String? = nil, onSelectedTagsChanged: ((String) -> Void)? = nil) { - viewModel = TagsViewModel(blog: blog, selectedTags: selectedTags, onSelectedTagsChanged: onSelectedTagsChanged) + init(blog: Blog, selectedTags: String? = nil, mode: TagsViewMode) { + viewModel = TagsViewModel(blog: blog, selectedTags: selectedTags, mode: mode) super.init(rootView: .init(viewModel: viewModel)) } + convenience init(blog: Blog, selectedTags: String? = nil, onSelectedTagsChanged: ((String) -> Void)? = nil) { + self.init(blog: blog, selectedTags: selectedTags, mode: .selection(onSelectedTagsChanged: onSelectedTagsChanged)) + } + + convenience init(blog: Blog, onTagTapped: @escaping (RemotePostTag) -> Void) { + self.init(blog: blog, mode: .pickOne(onTagTapped: onTagTapped)) + } + + convenience init(blog: Blog) { + self.init(blog: blog, mode: .browse) + } + @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/WordPress/Classes/ViewRelated/Tags/TagsViewModel.swift b/WordPress/Classes/ViewRelated/Tags/TagsViewModel.swift index b6d12809382e..656efa0cbb2f 100644 --- a/WordPress/Classes/ViewRelated/Tags/TagsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Tags/TagsViewModel.swift @@ -5,29 +5,44 @@ import WordPressUI typealias TagsPaginatedResponse = DataViewPaginatedResponse +enum TagsViewMode { + case selection(onSelectedTagsChanged: ((String) -> Void)?) + case pickOne(onTagTapped: (RemotePostTag) -> Void) + case browse +} + @MainActor class TagsViewModel: ObservableObject { @Published var searchText = "" - @Published var response: TagsPaginatedResponse? - @Published var isLoading = false - @Published var error: Error? + @Published private(set) var response: TagsPaginatedResponse? + @Published private(set) var isLoading = false + @Published private(set) var error: Error? @Published private(set) var selectedTags: [String] = [] { didSet { - onSelectedTagsChanged?(selectedTags.joined(separator: ", ")) + if case .selection(let onSelectedTagsChanged) = mode { + onSelectedTagsChanged?(selectedTags.joined(separator: ", ")) + } } } private var selectedTagsSet: Set = [] - private let tagsService: TagsService - var onSelectedTagsChanged: ((String) -> Void)? + let tagsService: TagsService + let mode: TagsViewMode + + var isBrowseMode: Bool { + if case .browse = mode { + return true + } + return false + } - init(blog: Blog, selectedTags: String? = nil, onSelectedTagsChanged: ((String) -> Void)? = nil) { + init(blog: Blog, selectedTags: String? = nil, mode: TagsViewMode) { self.tagsService = TagsService(blog: blog) + self.mode = mode self.selectedTags = selectedTags?.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? [] self.selectedTagsSet = Set(self.selectedTags) - self.onSelectedTagsChanged = onSelectedTagsChanged } func onAppear() { @@ -37,7 +52,6 @@ class TagsViewModel: ObservableObject { } } - @MainActor func refresh() async { response = nil error = nil @@ -57,7 +71,12 @@ class TagsViewModel: ObservableObject { } let offset = pageIndex ?? 0 - let remoteTags = try await self.tagsService.getTags(number: 100, offset: offset) + let remoteTags = try await self.tagsService.getTags( + number: 100, + offset: offset, + orderBy: self.isBrowseMode ? .byCount : .byName, + order: self.isBrowseMode ? .orderDescending : .orderAscending + ) let hasMore = remoteTags.count == 100 let nextPage = hasMore ? offset + 100 : nil @@ -110,3 +129,17 @@ class TagsViewModel: ObservableObject { selectedTags.removeAll { $0 == tagName } } } + +extension Foundation.Notification.Name { + @MainActor + static let tagDeleted = Foundation.Notification.Name("tagDeleted") + @MainActor + static let tagCreated = Foundation.Notification.Name("tagCreated") + @MainActor + static let tagUpdated = Foundation.Notification.Name("tagUpdated") +} + +struct TagNotificationUserInfoKeys { + static let tagID = "tagID" + static let tag = "tag" +}