Skip to content

Commit d19dacf

Browse files
authored
Improve TimeZonePickerViewController (#24612)
* Rework TimeZonePickerViewController using SwiftUI * Increase cell height * Improve design for suggested timezones * Update release notse * Update TimeZoneSelectorViewModelTests * Remove some duplication * Fix StateObject usage in TimeZoneSelectorView * Update unit tests * Remove the old viewmodel
1 parent bcb3e07 commit d19dacf

12 files changed

+370
-646
lines changed

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
26.0
22
-----
33
* [**] Add new “Subscribers” screen that shows both your email and Reader subscribers [#24513]
4+
* [*] Improve search in TimeZone Picker for more accurate results [#24612]
45
* [*] Fix an issue with “Stats / Subscribers” sometimes not showing the latest email subscribers [#24513]
56
* [*] Fix an issue with "Stats" / "Subscribers" / "Emails" showing html encoded characters [#24513]
67
* [*] Add search to “Jetpack Activity List” and display actors and dates [#24597]
Lines changed: 107 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -1,189 +1,152 @@
1+
import Testing
12
import Foundation
2-
import XCTest
33
import WordPressKit
4-
import WordPressUI
54

65
@testable import WordPress
7-
@testable import WordPressData
86

9-
class TimeZoneSelectorViewModelTests: CoreDataTestCase {
7+
@MainActor
8+
struct TimeZoneSelectorViewModelTests {
9+
@Test func initAndCheckInitialState() async {
10+
let service = MockTimeZoneService()
11+
let viewModel = TimeZoneSelectorViewModel(service: service)
1012

11-
private var viewModel: TimeZoneSelectorViewModel!
12-
13-
private var timeZoneGroups: [TimeZoneGroup]!
13+
#expect(viewModel.sections.isEmpty)
14+
#expect(!viewModel.isLoading)
15+
#expect(viewModel.error == nil)
16+
}
1417

15-
override func setUp() {
16-
super.setUp()
18+
@Test func filteredSectionsWithEmptySearchText() async {
19+
let mockGroups = createMockTimeZoneGroups()
20+
let service = MockTimeZoneService(timeZoneGroups: mockGroups)
21+
let viewModel = TimeZoneSelectorViewModel(service: service)
1722

18-
// Given TimeZoneGroups
19-
// When new ViewModel created with TimeZoneStore with state=loaded
20-
loadTimeZoneGroupsIntoViewModel()
21-
}
23+
await viewModel.loadTimezones()
2224

23-
override func tearDown() {
24-
viewModel = nil
25-
timeZoneGroups = nil
25+
let filtered = viewModel.filteredSections(searchText: "")
2626

27-
super.tearDown()
27+
#expect(filtered.count == viewModel.sections.count)
28+
#expect(filtered == viewModel.sections)
2829
}
2930

30-
func loadTimeZoneGroupsIntoViewModel(selectedValue: String = "", filter: String? = nil) {
31-
timeZoneGroups = [timeZoneGroup()]
31+
@Test func filteredSectionsWithMatchingSearchText() async {
32+
let mockGroups = createMockTimeZoneGroups()
33+
let service = MockTimeZoneService(timeZoneGroups: mockGroups)
34+
let viewModel = TimeZoneSelectorViewModel(service: service)
3235

33-
let loaded = TimeZoneStoreState.loaded(timeZoneGroups)
34-
viewModel = TimeZoneSelectorViewModel(
35-
state: TimeZoneSelectorViewModel.State.with(storeState: loaded),
36-
selectedValue: selectedValue,
37-
filter: filter)
38-
}
36+
await viewModel.loadTimezones()
3937

40-
func testReady() throws {
41-
switch viewModel.state {
42-
case .loading:
43-
XCTFail()
44-
case .ready(let groups):
45-
// Then viewModel should be ready
46-
XCTAssertEqual(groups.count, timeZoneGroups.count)
47-
case .error:
48-
XCTFail()
49-
}
50-
}
38+
let filtered = viewModel.filteredSections(searchText: "Addis")
5139

52-
func testGroups() {
53-
// Then viewModel allTimeZonesGroup() count is equal to mock data count
54-
XCTAssertEqual(viewModel.groups.count, timeZoneGroups.count)
40+
#expect(filtered.count == 1)
41+
#expect(filtered[0].name == "Africa")
42+
#expect(filtered[0].timezones.count == 1)
43+
#expect(filtered[0].timezones[0].timezone.label == "Addis Ababa")
5544
}
5645

57-
func testFilteredGroupsExists() {
58-
// When user types "Addis" which exists
59-
loadTimeZoneGroupsIntoViewModel(filter: "Addis")
46+
@Test func filteredSectionsWithNonMatchingSearchText() async {
47+
let mockGroups = createMockTimeZoneGroups()
48+
let service = MockTimeZoneService(timeZoneGroups: mockGroups)
49+
let viewModel = TimeZoneSelectorViewModel(service: service)
6050

61-
// Then viewModel filteredGroups should be Addis_Ababa
62-
let filteredGroups = viewModel.filteredGroups
63-
XCTAssertEqual(filteredGroups.count, 1)
51+
await viewModel.loadTimezones()
6452

65-
let timeZoneGroup: TimeZoneGroup = filteredGroups[0]
66-
XCTAssertEqual(timeZoneGroup.timezones.count, 1)
67-
XCTAssertEqual(timeZoneGroup.name, "Africa")
53+
let filtered = viewModel.filteredSections(searchText: "NoTimeZoneForThisFilter")
6854

69-
let timeZone: WPTimeZone = timeZoneGroup.timezones[0]
70-
XCTAssertEqual(timeZone.label, Constants.timeZoneTestTuple3.label)
71-
XCTAssertEqual(timeZone.value, Constants.timeZoneTestTuple3.value)
55+
#expect(filtered.isEmpty)
7256
}
7357

74-
func testFilteredGroupsDoesNotExist() {
75-
// When user types an invalid filter
76-
loadTimeZoneGroupsIntoViewModel(filter: "NoTimeZoneForThisFilter")
58+
@Test func loadTimezonesSuccess() async {
59+
// Create mock data
60+
let mockGroups = [
61+
TimeZoneGroup(name: "Africa", timezones: [
62+
NamedTimeZone(label: "Abidjan", value: "Africa/Abidjan"),
63+
NamedTimeZone(label: "Accra", value: "Africa/Accra")
64+
]),
65+
TimeZoneGroup(name: "America", timezones: [
66+
NamedTimeZone(label: "New York", value: "America/New_York")
67+
])
68+
]
7769

78-
// Then viewModel filteredGroups will be empty
79-
let filteredGroups = viewModel.filteredGroups
80-
XCTAssertEqual(filteredGroups.count, 0)
81-
}
70+
let service = MockTimeZoneService(timeZoneGroups: mockGroups)
71+
let viewModel = TimeZoneSelectorViewModel(service: service)
8272

83-
func testGetTimeZoneForIdentifier() {
84-
// When TimeZoneIdentifier = "Africa/Addis_Ababa"
85-
// Then "Africa/Addis_Ababa" WPTimeZone returned
86-
guard let timeZone = viewModel.getTimeZoneForIdentifier(Constants.timeZoneTestTuple3.value) else {
87-
XCTFail()
88-
return
89-
}
73+
#expect(viewModel.sections.isEmpty)
74+
#expect(!viewModel.isLoading)
9075

91-
XCTAssertNotNil(timeZone)
92-
XCTAssertEqual(timeZone.label, Constants.timeZoneTestTuple3.label)
93-
XCTAssertEqual(timeZone.value, Constants.timeZoneTestTuple3.value)
94-
}
76+
await viewModel.loadTimezones()
9577

96-
func testTableViewModel() {
97-
// When viewModel has no selected value
98-
let immuTable: ImmuTable = viewModel.tableViewModel(selectionHandler: { (selectedTimezone) in
99-
})
100-
101-
// Then section count = 1
102-
let sections = immuTable.sections
103-
XCTAssertNotNil(sections)
104-
XCTAssertEqual(sections.count, 1)
105-
106-
// Then rows count = 3
107-
let section: ImmuTableSection = sections[0]
108-
let rows = section.rows
109-
XCTAssertNotNil(rows)
110-
XCTAssertEqual(rows.count, 3)
78+
#expect(!viewModel.isLoading)
79+
#expect(viewModel.error == nil)
80+
#expect(viewModel.sections.count == 2)
81+
#expect(viewModel.sections[0].name == "Africa")
82+
#expect(viewModel.sections[0].timezones.count == 2)
83+
#expect(viewModel.sections[1].name == "America")
84+
#expect(viewModel.sections[1].timezones.count == 1)
11185
}
11286

113-
func testTableViewModelSelectedValue() {
114-
// When selectedValue = "Africa/Addis_Ababa"
115-
loadTimeZoneGroupsIntoViewModel(selectedValue: Constants.timeZoneTestTuple3.value)
87+
@Test func loadTimezonesError() async {
88+
let service = MockTimeZoneService(shouldThrowError: true)
89+
let viewModel = TimeZoneSelectorViewModel(service: service)
11690

117-
// Then selectedValue should be Addis_Ababa
118-
XCTAssertEqual(viewModel.selectedValue, Constants.timeZoneTestTuple3.value)
119-
}
91+
#expect(viewModel.error == nil)
92+
#expect(!viewModel.isLoading)
12093

121-
func testNoResultsViewModelLoading() {
122-
// Given viewModel
123-
// When loading
124-
viewModel = TimeZoneSelectorViewModel(
125-
state: TimeZoneSelectorViewModel.State.with(storeState: TimeZoneStoreState.loading),
126-
selectedValue: "",
127-
filter: nil)
128-
129-
// Then noResultsViewModel exists
130-
guard let noResultsVCModel: NoResultsViewController.Model = viewModel.noResultsViewModel else {
131-
XCTFail()
132-
return
133-
}
134-
135-
// Then accessoryView exists
136-
XCTAssertNotNil(noResultsVCModel.accessoryView)
94+
await viewModel.loadTimezones()
13795

138-
// Then noResultsViewModel title is loading
139-
XCTAssertEqual(noResultsVCModel.titleText, TimeZoneSelectorViewModel.LocalizedText.loadingTitle)
96+
#expect(!viewModel.isLoading)
97+
#expect(viewModel.error != nil)
98+
#expect(viewModel.sections.isEmpty)
14099
}
141100

142-
func testNoResultsViewModelReady() {
143-
// Given ViewModel
144-
// When ViewModel state is ready
101+
@Test func loadTimezonesUpdatesSuggestion() async {
102+
// Mock timezone groups containing device's current timezone
103+
let deviceTimezone = TimeZone.current.identifier
104+
let mockGroups = [
105+
TimeZoneGroup(name: "Test", timezones: [
106+
NamedTimeZone(label: "Test Zone", value: deviceTimezone)
107+
])
108+
]
145109

146-
// Then noResultsViewModel is nil
147-
XCTAssertNil(viewModel.noResultsViewModel)
148-
}
110+
let service = MockTimeZoneService(timeZoneGroups: mockGroups)
111+
let viewModel = TimeZoneSelectorViewModel(service: service)
149112

150-
func testNoResultsViewModelError() {
151-
// Given ViewModel
152-
// When ViewModel state is error
153-
viewModel = TimeZoneSelectorViewModel(
154-
state: TimeZoneSelectorViewModel.State.with(storeState: TimeZoneStoreState.error(testError())),
155-
selectedValue: "",
156-
filter: nil)
157-
158-
// Then noResultsViewModel exists
159-
guard let noResultsVCModel: NoResultsViewController.Model = viewModel.noResultsViewModel else {
160-
XCTFail()
161-
return
162-
}
113+
#expect(viewModel.suggestedTimezoneRowViewModel == nil)
163114

164-
// Then noResultsViewModel title is No Connection
165-
XCTAssertEqual(noResultsVCModel.titleText, TimeZoneSelectorViewModel.LocalizedText.noConnectionTitle)
166-
}
115+
await viewModel.loadTimezones()
167116

168-
func timeZoneGroup() -> TimeZoneGroup {
169-
var zones = [WPTimeZone]()
170-
zones.append(NamedTimeZone(label: Constants.timeZoneTestTuple1.label, value: Constants.timeZoneTestTuple1.value))
171-
zones.append(NamedTimeZone(label: Constants.timeZoneTestTuple2.label, value: Constants.timeZoneTestTuple2.value))
172-
zones.append(NamedTimeZone(label: Constants.timeZoneTestTuple3.label, value: Constants.timeZoneTestTuple3.value))
173-
return TimeZoneGroup(name: "Africa", timezones: zones)
117+
#expect(viewModel.suggestedTimezoneRowViewModel != nil)
118+
#expect(viewModel.suggestedTimezoneRowViewModel?.timezone.value.caseInsensitiveCompare(deviceTimezone) == .orderedSame)
174119
}
175120

121+
// MARK: - Helpers
122+
123+
private func createMockTimeZoneGroups() -> [TimeZoneGroup] {
124+
[
125+
TimeZoneGroup(name: "Africa", timezones: [
126+
NamedTimeZone(label: "Abidjan", value: "Africa/Abidjan"),
127+
NamedTimeZone(label: "Accra", value: "Africa/Accra"),
128+
NamedTimeZone(label: "Addis Ababa", value: "Africa/Addis_Ababa")
129+
]),
130+
TimeZoneGroup(name: "America", timezones: [
131+
NamedTimeZone(label: "New York", value: "America/New_York"),
132+
NamedTimeZone(label: "Los Angeles", value: "America/Los_Angeles")
133+
])
134+
]
135+
}
176136
}
177137

178-
private extension TimeZoneSelectorViewModelTests {
179-
enum DecodingError: Error {
180-
case decodingFailed
138+
private struct MockTimeZoneService: TimeZoneServiceProtocol {
139+
var shouldThrowError = false
140+
var timeZoneGroups: [TimeZoneGroup] = []
141+
142+
func timezones() async throws -> [TimeZoneGroup] {
143+
if shouldThrowError {
144+
throw MockError.testError
145+
}
146+
return timeZoneGroups
181147
}
182148

183-
enum Constants {
184-
typealias timeZoneTestTuple = (label: String, value: String)
185-
static let timeZoneTestTuple1: timeZoneTestTuple = (label: "Abidjan", value: "Africa/Abidjan")
186-
static let timeZoneTestTuple2: timeZoneTestTuple = (label: "Accra", value: "Africa/Accra")
187-
static let timeZoneTestTuple3: timeZoneTestTuple = (label: "Addis Ababa", value: "Africa/Addis_Ababa")
149+
enum MockError: Error {
150+
case testError
188151
}
189152
}

WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,14 @@ extension SiteSettingsViewController {
102102
}
103103

104104
@objc public func showTimezoneSelector() {
105-
let controller = TimeZoneSelectorViewController(selectedValue: timezoneValue) { [weak self] (newValue) in
106-
self?.navigationController?.popViewController(animated: true)
105+
let view = TimeZoneSelectorView(selectedValue: timezoneValue) { [weak self] newValue in
107106
self?.blog.settings?.gmtOffset = newValue.gmtOffset as NSNumber?
108107
self?.blog.settings?.timezoneString = newValue.timezoneString
109108
self?.saveSettings()
110109
self?.trackSettingsChange(fieldName: "timezone",
111110
value: newValue.value as Any)
112111
}
112+
let controller = UIHostingController(rootView: view)
113113
navigationController?.pushViewController(controller, animated: true)
114114
}
115115

0 commit comments

Comments
 (0)