Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 37 additions & 12 deletions Tests/KeystoneTests/Tests/Features/Posts/TagSelectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,27 +114,44 @@ 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
func selectionCallbackDeliversConfirmedItems() async {
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])

Expand Down Expand Up @@ -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
}

Expand All @@ -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
Expand Down
38 changes: 30 additions & 8 deletions WordPress/Classes/ViewRelated/Tags/TagsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand All @@ -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
Expand All @@ -85,7 +106,7 @@ class TagsViewModel: ObservableObject {

private func loadInitialTags() async {
isLoading = true
defer { isLoading = false}
defer { isLoading = false }

error = nil

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: "")
Expand All @@ -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 {
Expand Down