diff --git a/Tests/KeystoneTests/Tests/Features/Posts/TagSelectionTests.swift b/Tests/KeystoneTests/Tests/Features/Posts/TagSelectionTests.swift index a9458f9db47a..9e7ad951aa36 100644 --- a/Tests/KeystoneTests/Tests/Features/Posts/TagSelectionTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Posts/TagSelectionTests.swift @@ -114,17 +114,30 @@ struct TagSelectionTests { // MARK: - Selection callback @Test - func selectionCallbackFiltersOutPendingItems() async { + func selectionCallbackEmitsPendingItemsImmediately() async { + // Regression coverage for the missing-tags bug: if the user publishes before the + // async search/create completes, the parent's tag list must still contain the + // typed name. The callback must therefore fire synchronously on `addNewTag` with + // the pending (`id == 0`) term so the parent never observes an empty selection. let mock = MockService(tags: ["Foo", "Bar"]) var callbackTags: [TagsViewModel.SelectedTerm] = [] - let viewModel = TagsViewModel(taxonomy: nil, service: mock, mode: .selection(onSelectedTagsChanged: { tags in - callbackTags = tags - })) + let viewModel = TagsViewModel( + taxonomy: nil, + service: mock, + mode: .selection(onSelectedTagsChanged: { tags in + callbackTags = tags + }) + ) + + // Do not await — we want to observe the state before the create task can run. + let task = viewModel.addNewTag(named: "Baz") - _ = viewModel.addNewTag(named: "Baz") + #expect(callbackTags.count == 1) + #expect(callbackTags[0].name == "Baz") + #expect(callbackTags[0].id == 0) + #expect(callbackTags[0].isPending) - // The pending item (id == 0) should be filtered out of the callback - #expect(callbackTags.isEmpty) + await task?.value } @Test @@ -132,9 +145,13 @@ struct TagSelectionTests { let mock = MockService(tags: ["Foo", "Bar"]) let tags = await mock.tags var callbackTags: [TagsViewModel.SelectedTerm] = [] - let viewModel = TagsViewModel(taxonomy: nil, service: mock, mode: .selection(onSelectedTagsChanged: { tags in - callbackTags = tags - })) + let viewModel = TagsViewModel( + taxonomy: nil, + service: mock, + mode: .selection(onSelectedTagsChanged: { tags in + callbackTags = tags + }) + ) viewModel.toggleSelection(for: tags[0]) @@ -217,7 +234,11 @@ private actor MockService: TaxonomyServiceProtocol { let lowercasedName = name.lowercased() if tags.contains(where: { $0.name.lowercased() == lowercasedName }) { - let error = NSError(domain: "MockService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Tag already exists"]) + let error = NSError( + domain: "MockService", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Tag already exists"] + ) throw error } @@ -235,7 +256,11 @@ private actor MockService: TaxonomyServiceProtocol { return newTag } - func updateTag(_ term: AnyTermWithViewContext, name: String, description: String) async throws -> AnyTermWithViewContext { + func updateTag( + _ term: AnyTermWithViewContext, + name: String, + description: String + ) async throws -> AnyTermWithViewContext { guard let index = tags.firstIndex(where: { $0.id == term.id }) else { let error = NSError(domain: "MockService", code: -2, userInfo: [NSLocalizedDescriptionKey: "Tag not found"]) throw error diff --git a/WordPress/Classes/ViewRelated/Tags/TagsViewModel.swift b/WordPress/Classes/ViewRelated/Tags/TagsViewModel.swift index 7d6303e2b684..4e2366e2c160 100644 --- a/WordPress/Classes/ViewRelated/Tags/TagsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Tags/TagsViewModel.swift @@ -30,7 +30,12 @@ class TagsViewModel: ObservableObject { @Published private(set) var selectedTags: [SelectedTerm] { didSet { if case .selection(let onSelectedTagsChanged) = mode { - onSelectedTagsChanged?(selectedTags.filter { !$0.isPending }) + // Emit the full selection, including pending (`id == 0`) terms. + // Filtering them here would silently drop user-typed tag names from the parent's + // selection state if publish/save runs before the async search/create completes. + // Consumers that require resolved IDs (e.g. custom-post REST flows) resolve them + // at save time via `TermResolutionService.resolveIDs(for:)`. + onSelectedTagsChanged?(selectedTags) } } } @@ -56,11 +61,27 @@ class TagsViewModel: ObservableObject { self.init(taxonomy: nil, service: TagsService(blog: blog), selectedTerms: selectedTags, mode: mode) } - convenience init(blog: Blog, client: WordPressClient, taxonomy: SiteTaxonomy, selectedTerms: [SelectedTerm] = [], mode: TagsViewMode) { - self.init(taxonomy: taxonomy, service: AnyTermService(client: client, endpoint: taxonomy.endpoint), selectedTerms: selectedTerms, mode: mode) + convenience init( + blog: Blog, + client: WordPressClient, + taxonomy: SiteTaxonomy, + selectedTerms: [SelectedTerm] = [], + mode: TagsViewMode + ) { + self.init( + taxonomy: taxonomy, + service: AnyTermService(client: client, endpoint: taxonomy.endpoint), + selectedTerms: selectedTerms, + mode: mode + ) } - init(taxonomy: SiteTaxonomy?, service: TaxonomyServiceProtocol, selectedTerms: [SelectedTerm] = [], mode: TagsViewMode) { + init( + taxonomy: SiteTaxonomy?, + service: TaxonomyServiceProtocol, + selectedTerms: [SelectedTerm] = [], + mode: TagsViewMode + ) { self.taxonomy = taxonomy self.tagsService = service self.mode = mode @@ -85,7 +106,7 @@ class TagsViewModel: ObservableObject { private func loadInitialTags() async { isLoading = true - defer { isLoading = false} + defer { isLoading = false } error = nil @@ -135,7 +156,7 @@ class TagsViewModel: ObservableObject { let remoteTags = try await tagsService.searchTags(with: searchText) return try await TagsPaginatedResponse { _ in - return TagsPaginatedResponse.Page( + TagsPaginatedResponse.Page( items: remoteTags, total: remoteTags.count, hasMore: false, @@ -171,7 +192,8 @@ class TagsViewModel: ObservableObject { do { let newTag: AnyTermWithViewContext if let existing = try await tagsService.searchTags(with: name) - .first(where: { $0.name.compare(name, options: .caseInsensitive) == .orderedSame }) { + .first(where: { $0.name.compare(name, options: .caseInsensitive) == .orderedSame }) + { newTag = existing } else { newTag = try await tagsService.createTag(name: name, description: "") @@ -190,7 +212,7 @@ class TagsViewModel: ObservableObject { } func isSelected(_ term: AnyTermWithViewContext) -> Bool { - return selectedTagsSet.contains(term.name.lowercased()) + selectedTagsSet.contains(term.name.lowercased()) } func isNotSelected(_ term: AnyTermWithViewContext) -> Bool {