diff --git a/Kickstarter-iOS/Features/PaginationExample/PaginationExampleView.swift b/Kickstarter-iOS/Features/PaginationExample/PaginationExampleView.swift new file mode 100644 index 0000000000..eeb69283fd --- /dev/null +++ b/Kickstarter-iOS/Features/PaginationExample/PaginationExampleView.swift @@ -0,0 +1,83 @@ +import KsApi +import SwiftUI + +private struct PaginationExampleProjectCell: View { + let title: String + var body: some View { + Text(title) + .padding(.all, 10) + } +} + +private struct PaginationExampleProjectList: View { + @Binding var projectIdsAndTitles: [(Int, String)] + @Binding var showProgressView: Bool + @Binding var statusText: String + + let onRefresh: () -> Void + let onDidShowProgressView: () -> Void + + var body: some View { + HStack { + Spacer() + Text("👉 \(statusText)") + Spacer() + } + .padding(EdgeInsets(top: 20, leading: 0, bottom: 20, trailing: 0)) + .background(.yellow) + List { + ForEach(projectIdsAndTitles, id: \.0) { + let title = $0.1 + PaginationExampleProjectCell(title: title) + } + if showProgressView { + HStack { + Spacer() + Text("Loading 😉") + .onAppear { + onDidShowProgressView() + } + Spacer() + } + .background(.yellow) + } + } + .refreshable { + onRefresh() + } + } +} + +public struct PaginationExampleView: View { + @StateObject private var viewModel = PaginationExampleViewModel() + + public var body: some View { + // Note that PaginationExampleProjectList is decoupled from the view model; + // all the information it needs is passed in via bindings. + // This makes it easy to write a preview! + PaginationExampleProjectList( + projectIdsAndTitles: $viewModel.projectIdsAndTitles, + showProgressView: $viewModel.showProgressView, + statusText: $viewModel.statusText, + onRefresh: { + viewModel.didRefresh() + }, + onDidShowProgressView: { + viewModel.didShowProgressView() + } + ) + } +} + +#Preview { + PaginationExampleProjectList( + projectIdsAndTitles: .constant([ + (1, "Cool project one"), + (2, "Cool project two"), + (3, "Cool project three") + ]), + showProgressView: .constant(true), + statusText: .constant("Example status text"), + onRefresh: {}, onDidShowProgressView: {} + ) +} diff --git a/Kickstarter-iOS/Features/PaginationExample/PaginationExampleViewModel.swift b/Kickstarter-iOS/Features/PaginationExample/PaginationExampleViewModel.swift new file mode 100644 index 0000000000..e4573dc1dc --- /dev/null +++ b/Kickstarter-iOS/Features/PaginationExample/PaginationExampleViewModel.swift @@ -0,0 +1,85 @@ +import Combine +import Foundation +import KsApi +import Library + +extension Project: Identifiable {} + +internal class PaginationExampleViewModel: ObservableObject { + var paginator: Paginator + + @Published var projectIdsAndTitles: [(Int, String)] = [] + @Published var showProgressView: Bool = true + @Published var statusText: String = "" + + init() { + self.paginator = Paginator( + valuesFromEnvelope: { + $0.projects + }, + cursorFromEnvelope: { + $0.urls.api.moreProjects + }, + requestFromParams: { + AppEnvironment.current.apiService.fetchDiscovery_combine(params: $0) + }, + requestFromCursor: { + AppEnvironment.current.apiService.fetchDiscovery_combine(paginationUrl: $0) + } + ) + + self.paginator.$values.map { projects in + projects.map { ($0.id, $0.name) } + }.assign(to: &$projectIdsAndTitles) + + let canLoadMore = self.paginator.$state.map { state in + state == .someLoaded || state == .unloaded + } + + Publishers.CombineLatest(self.paginator.$isLoading, canLoadMore) + .map { isLoading, canLoadMore in + isLoading || canLoadMore + }.assign(to: &$showProgressView) + + self.paginator.$state.map { [weak self] state in + switch state { + case .error: + let errorText = self?.paginator.error?.errorMessages.first ?? "Unknown error" + return "Error: \(errorText)" + case .unloaded: + return "Waiting to load" + case .someLoaded: + let count = self?.paginator.values.count ?? 0 + return "Got \(count) results; more are available" + case .allLoaded: + return "Loaded all results" + case .empty: + return "No results" + } + } + .assign(to: &$statusText) + } + + var searchParams: DiscoveryParams { + var params = DiscoveryParams.defaults + params.staffPicks = true + params.sort = .magic + return params + } + + func didShowProgressView() { + if self.paginator.isLoading { + return + } + + if self.paginator.state == .someLoaded { + self.paginator.requestNextPage() + } else if self.paginator.state == .unloaded { + self.paginator.requestFirstPage(withParams: self.searchParams) + } + } + + func didRefresh() { + self.paginator.requestFirstPage(withParams: self.searchParams) + } +} diff --git a/Kickstarter.xcodeproj/project.pbxproj b/Kickstarter.xcodeproj/project.pbxproj index aa92ef4ba9..e682a03b07 100644 --- a/Kickstarter.xcodeproj/project.pbxproj +++ b/Kickstarter.xcodeproj/project.pbxproj @@ -1500,9 +1500,13 @@ E10D06632ACF385E00470B5C /* FetchBackerProjectsQuery.json in Resources */ = {isa = PBXBuildFile; fileRef = E10D06622ACF385E00470B5C /* FetchBackerProjectsQuery.json */; }; E10D06652AD48C9C00470B5C /* FetchBackerProjectsQueryRequestForTests.graphql_test in Resources */ = {isa = PBXBuildFile; fileRef = E10D06642AD48C9C00470B5C /* FetchBackerProjectsQueryRequestForTests.graphql_test */; }; E10F75E82B6937FA00024AD1 /* PKCETest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EEED2A2B686829009976D9 /* PKCETest.swift */; }; + E118351F2B75639F007B42E6 /* PaginationExampleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118351E2B75639F007B42E6 /* PaginationExampleViewModel.swift */; }; E11CFE4B2B6C42CE00497375 /* OAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CFE492B6C41B400497375 /* OAuth.swift */; }; E170B9112B20E83B001BEDD7 /* MockGraphQLClient+CombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E170B9102B20E83B001BEDD7 /* MockGraphQLClient+CombineTests.swift */; }; E17611E22B73D9A400DF2F50 /* Data+PKCE.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17611E12B73D9A400DF2F50 /* Data+PKCE.swift */; }; + E17611E02B7287CF00DF2F50 /* PaginationExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CFE4E2B7162A400497375 /* PaginationExampleView.swift */; }; + E17611E42B751E8100DF2F50 /* Paginator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17611E32B751E8100DF2F50 /* Paginator.swift */; }; + E17611E62B75242A00DF2F50 /* PaginatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17611E52B75242A00DF2F50 /* PaginatorTests.swift */; }; E1A1491E2ACDD76800F49709 /* FetchBackerProjectsQuery.graphql in Resources */ = {isa = PBXBuildFile; fileRef = E1A1491D2ACDD76700F49709 /* FetchBackerProjectsQuery.graphql */; }; E1A149202ACDD7BF00F49709 /* FetchProjectsEnvelope+FetchBackerProjectsQueryData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1491F2ACDD7BF00F49709 /* FetchProjectsEnvelope+FetchBackerProjectsQueryData.swift */; }; E1A149222ACE013100F49709 /* FetchProjectsEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A149212ACE013100F49709 /* FetchProjectsEnvelope.swift */; }; @@ -3082,9 +3086,13 @@ E10BE8E52B151CC800F73DC9 /* BlockUserInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUserInputTests.swift; sourceTree = ""; }; E10D06622ACF385E00470B5C /* FetchBackerProjectsQuery.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = FetchBackerProjectsQuery.json; sourceTree = ""; }; E10D06642AD48C9C00470B5C /* FetchBackerProjectsQueryRequestForTests.graphql_test */ = {isa = PBXFileReference; lastKnownFileType = text; path = FetchBackerProjectsQueryRequestForTests.graphql_test; sourceTree = ""; }; + E118351E2B75639F007B42E6 /* PaginationExampleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationExampleViewModel.swift; sourceTree = ""; }; E11CFE492B6C41B400497375 /* OAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuth.swift; sourceTree = ""; }; + E11CFE4E2B7162A400497375 /* PaginationExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationExampleView.swift; sourceTree = ""; }; E170B9102B20E83B001BEDD7 /* MockGraphQLClient+CombineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MockGraphQLClient+CombineTests.swift"; sourceTree = ""; }; E17611E12B73D9A400DF2F50 /* Data+PKCE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+PKCE.swift"; sourceTree = ""; }; + E17611E32B751E8100DF2F50 /* Paginator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Paginator.swift; sourceTree = ""; }; + E17611E52B75242A00DF2F50 /* PaginatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginatorTests.swift; sourceTree = ""; }; E1889D8D2B6065D6004FBE21 /* CombineTestObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineTestObserverTests.swift; sourceTree = ""; }; E1A1491D2ACDD76700F49709 /* FetchBackerProjectsQuery.graphql */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = FetchBackerProjectsQuery.graphql; sourceTree = ""; }; E1A1491F2ACDD7BF00F49709 /* FetchProjectsEnvelope+FetchBackerProjectsQueryData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "FetchProjectsEnvelope+FetchBackerProjectsQueryData.swift"; sourceTree = ""; }; @@ -4673,6 +4681,7 @@ 1937A6ED28C92EC700DD732D /* MessageBanner */, 1937A6EE28C92ED400DD732D /* MessageDialog */, 19A97D2D28C7FF330031B857 /* MessageThreads */, + E11835202B75799F007B42E6 /* PaginationExample */, 19A97D3228C8001C0031B857 /* PaymentMethods */, 19A97D3828C801AB0031B857 /* PillCollectionView_DEPRECATED_09_06_2022 */, 1937A6F128C92F0C00DD732D /* PledgeAmount */, @@ -6027,6 +6036,8 @@ 94C92E7B2659EDBF00A96818 /* PaddingLabel.swift */, A77D7B061CBAAF5D0077586B /* Paginate.swift */, A7ED1F1C1E830FDC00BFFA01 /* PaginateTests.swift */, + E17611E32B751E8100DF2F50 /* Paginator.swift */, + E17611E52B75242A00DF2F50 /* PaginatorTests.swift */, 373AB25C222A0D8900769FC2 /* PasswordValidation.swift */, 373AB25E222A0DAC00769FC2 /* PasswordValidationTests.swift */, 7703B4232321844900169EF3 /* PKPaymentRequest+Helpers.swift */, @@ -6903,6 +6914,15 @@ path = RichPushNotifications; sourceTree = ""; }; + E11835202B75799F007B42E6 /* PaginationExample */ = { + isa = PBXGroup; + children = ( + E11CFE4E2B7162A400497375 /* PaginationExampleView.swift */, + E118351E2B75639F007B42E6 /* PaginationExampleViewModel.swift */, + ); + path = PaginationExample; + sourceTree = ""; + }; E1A149252ACE060E00F49709 /* templates */ = { isa = PBXGroup; children = ( @@ -7617,6 +7637,7 @@ 37DEC1E72257C9F30051EF9B /* PledgeViewModel.swift in Sources */, A75CFB081CCE7FCF004CD5FA /* StaticTableViewCell.swift in Sources */, 8A3BF51923F5C347002AD818 /* CoreTelephonyNetworkInfoType.swift in Sources */, + E17611E42B751E8100DF2F50 /* Paginator.swift in Sources */, 597073521D05FE6B00B00444 /* ProjectNotificationsViewModel.swift in Sources */, 8A45168D24B3D02700D8CAEF /* RewardAddOnSelectionViewModel.swift in Sources */, 77F6E73721222E97005A5C55 /* SettingsCellType.swift in Sources */, @@ -7784,6 +7805,7 @@ 8AD9CF1424C8D46800F77223 /* PledgeShippingSummaryViewModelTests.swift in Sources */, 77D19FF22406D5A40058FC8E /* CategorySelectionViewModelTests.swift in Sources */, 3706408822A8A6F200889CBD /* PledgeShippingLocationViewModelTests.swift in Sources */, + E17611E62B75242A00DF2F50 /* PaginatorTests.swift in Sources */, 8AFB8C99233E9A1F006779B5 /* CreatePaymentSourceInput+ConstructorTests.swift in Sources */, A7ED1F2B1E830FDC00BFFA01 /* IsValidEmailTests.swift in Sources */, 37C7B81723187BAC00C78278 /* ShippingRuleCellViewModelTests.swift in Sources */, @@ -8021,6 +8043,7 @@ 771E3C632289DBA8003E7CF1 /* SheetOverlayViewController.swift in Sources */, 0146E3231CC0296900082C5B /* FacebookConfirmationViewController.swift in Sources */, 20BCBEB0264DAA4B00510EDF /* CommentComposerView.swift in Sources */, + E118351F2B75639F007B42E6 /* PaginationExampleViewModel.swift in Sources */, 94114D5B265305210063E8F6 /* CommentPostFailedCell.swift in Sources */, D79F0F712102973A00D3B32C /* SettingsPrivacyDeleteOrRequestCell.swift in Sources */, 379C00012242DAFF00F6F0C2 /* WebViewController.swift in Sources */, @@ -8141,6 +8164,7 @@ 37059843226F79A700BDA6E3 /* PledgeShippingLocationViewController.swift in Sources */, 01940B291D467ECE0074FCE3 /* HelpWebViewController.swift in Sources */, 8AA3DB35250AE46D009AC8EA /* SettingsAccountViewModel.swift in Sources */, + E17611E02B7287CF00DF2F50 /* PaginationExampleView.swift in Sources */, 8AB87DF3243FF22B006D7451 /* PledgePaymentMethodAddCell.swift in Sources */, 473DE014273C551C0033331D /* ProjectRisksDisclaimerCell.swift in Sources */, D6C3845B210B9AC400ADB671 /* SettingsNewslettersTopCell.swift in Sources */, diff --git a/KsApi/MockService.swift b/KsApi/MockService.swift index c3208d9f67..c24c2de92b 100644 --- a/KsApi/MockService.swift +++ b/KsApi/MockService.swift @@ -1736,6 +1736,14 @@ + "\(draft.update.id)/preview" ) } + + func fetchDiscovery_combine(params _: DiscoveryParams) -> AnyPublisher { + return Empty(completeImmediately: false).eraseToAnyPublisher() + } + + func fetchDiscovery_combine(paginationUrl _: String) -> AnyPublisher { + Empty(completeImmediately: false).eraseToAnyPublisher() + } } private extension MockService { diff --git a/KsApi/Service+RequestHelpers.swift b/KsApi/Service+RequestHelpers.swift index d2b27e31e1..3bdb6d536f 100644 --- a/KsApi/Service+RequestHelpers.swift +++ b/KsApi/Service+RequestHelpers.swift @@ -7,8 +7,8 @@ import ReactiveSwift extension Service { private static let session = URLSession(configuration: .default) - func request(_ route: Route) - -> SignalProducer { + func request(_ route: Route) + -> SignalProducer { let properties = route.requestProperties guard let URL = URL(string: properties.path, relativeTo: self.serverConfig.apiBaseUrl as URL) else { @@ -25,7 +25,7 @@ extension Service { .flatMap(self.decodeModelToSignal) } - func request(_ route: Route) -> AnyPublisher { + func request(_ route: Route) -> AnyPublisher { let properties = route.requestProperties guard let URL = URL(string: properties.path, relativeTo: self.serverConfig.apiBaseUrl as URL) else { @@ -38,24 +38,11 @@ extension Service { preparedRequest(forURL: URL, method: properties.method, query: properties.query), uploading: properties.file.map { ($1, $0.rawValue) }, and: self.perimeterXClient - ).tryMap { data in - let result: Result = self.decodeModelToResult(data: data, ofType: M.self) - - switch result { - case let .success(value): - return value - case let .failure(error): - throw error - } - } - .compactMap { $0 } - .mapError { error in - error as! ErrorEnvelope - }.eraseToAnyPublisher() + ).handle_combine_dataResponse(service: self) } - func requestPaginationDecodable(_ paginationUrl: String) - -> SignalProducer { + func requestPaginationDecodable(_ paginationUrl: String) + -> SignalProducer { guard let paginationUrl = URL(string: paginationUrl) else { return .init(error: .invalidPaginationUrl) } @@ -64,4 +51,36 @@ extension Service { .rac_dataResponse(preparedRequest(forURL: paginationUrl), and: self.perimeterXClient) .flatMap(self.decodeModelToSignal) } + + func requestPaginationDecodable(_ paginationUrl: String) + -> AnyPublisher { + guard let paginationUrl = URL(string: paginationUrl) else { + fatalError("Invalid pagination URL \(paginationUrl)") + } + + return Service.session + .combine_dataResponse(preparedRequest(forURL: paginationUrl), and: self.perimeterXClient) + .handle_combine_dataResponse(service: self) + } +} + +extension Publisher where Output == Data, Failure == ErrorEnvelope { + func handle_combine_dataResponse(service: Service) -> AnyPublisher { + return self + .tryMap { data in + let result: Result = service + .decodeModelToResult(data: data, ofType: Model.self) + + switch result { + case let .success(value): + return value + case let .failure(error): + throw error + } + } + .compactMap { $0 } + .mapError { error in + error as! ErrorEnvelope + }.eraseToAnyPublisher() + } } diff --git a/KsApi/Service.swift b/KsApi/Service.swift index d7bc29f2fa..f8b6b7ca7e 100644 --- a/KsApi/Service.swift +++ b/KsApi/Service.swift @@ -339,6 +339,16 @@ public struct Service: ServiceType { return request(.discover(params)) } + public func fetchDiscovery_combine(paginationUrl: String) + -> AnyPublisher { + return requestPaginationDecodable(paginationUrl) + } + + public func fetchDiscovery_combine(params: DiscoveryParams) + -> AnyPublisher { + return request(.discover(params)) + } + public func fetchFriends() -> SignalProducer { return request(.friends) } diff --git a/KsApi/ServiceType.swift b/KsApi/ServiceType.swift index 41733ad104..44206e2aae 100644 --- a/KsApi/ServiceType.swift +++ b/KsApi/ServiceType.swift @@ -396,6 +396,12 @@ public protocol ServiceType { -> SignalProducer func blockUser(input: BlockUserInput) -> SignalProducer + + func fetchDiscovery_combine(paginationUrl: String) + -> AnyPublisher + + func fetchDiscovery_combine(params: DiscoveryParams) + -> AnyPublisher } extension ServiceType { diff --git a/Library/Paginator.swift b/Library/Paginator.swift new file mode 100644 index 0000000000..6f5c1d7e2b --- /dev/null +++ b/Library/Paginator.swift @@ -0,0 +1,134 @@ +import Combine +import Foundation + +/** + Used to coordinate the process of paginating through values. This class is specific to the type of pagination + in which a page's results contains a cursor that can be used to request the next page of values. + + This class is designed to work with SwiftUI/Combine. For an example, see `PaginationExampleView.swift`. + + This class is generic over the following types: + + * `Value`: The type of value that is being paginated, i.e. a single row, not the array of rows. The + value must be equatable. + * `Envelope`: The type of response we get from fetching a new page of values. + * `SomeError`: The type of error we might get from fetching a new page of values. + * `Cursor`: The type of value that can be extracted from `Envelope` to request the next page of + values. + * `RequestParams`: The type that allows us to make a request for values without a cursor. + + - parameter valuesFromEnvelope: A function to get an array of values from the results envelope. + - parameter cursorFromEnvelope: A function to get the cursor for the next page from a results envelope. + - parameter requestFromParams: A function to get a request for values from a params value. + - parameter requestFromCursor: A function to get a request for values from a cursor value. + + You can observe the results of `values`, `isLoading`, `error` and `state` to access the loaded data. + + */ + +public class Paginator { + public enum Results: Equatable { + case unloaded + case someLoaded + case allLoaded + case empty + case error + } + + @Published public var values: [Value] + @Published public var isLoading: Bool + @Published public var error: SomeError? + @Published public var state: Results + + private var valuesFromEnvelope: (Envelope) -> [Value] + private var cursorFromEnvelope: (Envelope) -> Cursor? + private var requestFromParams: (RequestParams) -> AnyPublisher + private var requestFromCursor: (Cursor) -> AnyPublisher + private var cancellables = Set() + + private var lastCursor: Cursor? + + public init(valuesFromEnvelope: @escaping ((Envelope) -> [Value]), + cursorFromEnvelope: @escaping ((Envelope) -> Cursor?), + requestFromParams: @escaping ((RequestParams) -> AnyPublisher), + requestFromCursor: @escaping ((Cursor) -> AnyPublisher)) { + self.values = [] + self.isLoading = false + self.error = nil + self.state = .unloaded + + self.valuesFromEnvelope = valuesFromEnvelope + self.cursorFromEnvelope = cursorFromEnvelope + self.requestFromParams = requestFromParams + self.requestFromCursor = requestFromCursor + } + + func handleRequest(_ request: AnyPublisher) { + request + .receive(on: RunLoop.main) + .catch { [weak self] error -> AnyPublisher in + self?.error = error + self?.state = .error + return Empty().eraseToAnyPublisher() + } + .assertNoFailure() + .handleEvents(receiveCompletion: { [weak self] _ in + self?.isLoading = false + }, receiveCancel: { [weak self] in + self?.isLoading = false + }) + .sink(receiveValue: { [weak self] envelope in + guard let self else { return } + + let newValues = self.valuesFromEnvelope(envelope) + self.values.append(contentsOf: newValues) + + let cursor = self.cursorFromEnvelope(envelope) + self.lastCursor = cursor + + if self.values.count == 0 { + self.state = .empty + } else if cursor == nil || newValues.count == 0 { + self.state = .allLoaded + } else { + self.state = .someLoaded + } + }) + .store(in: &self.cancellables) + } + + public func requestFirstPage(withParams params: RequestParams) { + self.cancel() + + self.values = [] + self.isLoading = true + self.error = nil + + let request = self.requestFromParams(params) + self.handleRequest(request) + } + + public func requestNextPage() { + if self.isLoading { + return + } + + if self.state != .someLoaded { + return + } + + self.isLoading = true + guard let cursor = self.lastCursor else { + assert(false, "Requested next page, but there is no cursor.") + } + + let request = self.requestFromCursor(cursor) + self.handleRequest(request) + } + + public func cancel() { + self.cancellables.forEach { cancellable in + cancellable.cancel() + } + } +} diff --git a/Library/PaginatorTests.swift b/Library/PaginatorTests.swift new file mode 100644 index 0000000000..f03fd06613 --- /dev/null +++ b/Library/PaginatorTests.swift @@ -0,0 +1,315 @@ +import Combine +@testable import Library +import XCTest + +struct TestEnvelope { + let values: [Int] + let cursor: Int? + + var publisher: AnyPublisher { + return Just(self).setFailureType(to: ConcreteError.self).eraseToAnyPublisher() + } +} + +struct ConcreteError: Error {} + +final class PaginatorTests: XCTestCase { + let valuesFromEnvelope: (TestEnvelope) -> [Int] = { $0.values } + let cursorFromEnvelope: (TestEnvelope) -> Int? = { $0.cursor } + + func waitTinyInterval() { + _ = XCTWaiter.wait(for: [expectation(description: "Wait a tiny interval of time.")], timeout: 0.05) + } + + func testPaginator_initialState_isUnloaded() { + let paginator = Paginator( + valuesFromEnvelope: valuesFromEnvelope, + cursorFromEnvelope: cursorFromEnvelope, + requestFromParams: { _ in TestEnvelope(values: [], cursor: nil).publisher }, + requestFromCursor: { _ in TestEnvelope(values: [], cursor: nil).publisher } + ) + + XCTAssertEqual(paginator.state, .unloaded) + XCTAssertFalse(paginator.isLoading) + XCTAssertEqual(paginator.values, []) + } + + func testPaginator_requestFirstPage_loadsFirstPage() { + let paginator = Paginator( + valuesFromEnvelope: valuesFromEnvelope, + cursorFromEnvelope: cursorFromEnvelope, + requestFromParams: { _ in TestEnvelope(values: [1, 2, 3], cursor: nil).publisher }, + requestFromCursor: { _ in TestEnvelope(values: [], cursor: nil).publisher } + ) + + paginator.requestFirstPage(withParams: ()) + XCTAssertTrue(paginator.isLoading) + XCTAssertEqual(paginator.values, [], "Values should not have loaded yet") + + self.waitTinyInterval() + + XCTAssertFalse(paginator.isLoading) + XCTAssertEqual(paginator.values, [1, 2, 3]) + XCTAssertNil(paginator.error) + XCTAssertEqual(paginator.state, .allLoaded) + } + + func testPaginator_requestFirstPage_noResults_isEmpty() { + let paginator = Paginator( + valuesFromEnvelope: valuesFromEnvelope, + cursorFromEnvelope: cursorFromEnvelope, + requestFromParams: { _ in TestEnvelope(values: [], cursor: nil).publisher }, + requestFromCursor: { _ in TestEnvelope(values: [], cursor: nil).publisher } + ) + + paginator.requestFirstPage(withParams: ()) + XCTAssertTrue(paginator.isLoading) + XCTAssertEqual(paginator.values, [], "Values should not have loaded yet") + + self.waitTinyInterval() + + XCTAssertFalse(paginator.isLoading) + XCTAssertEqual(paginator.values, []) + XCTAssertNil(paginator.error) + XCTAssertEqual(paginator.state, .empty) + } + + func testPaginator_requestNextPage_hasCursor_loadsNextPage() { + let paginator = Paginator( + valuesFromEnvelope: valuesFromEnvelope, + cursorFromEnvelope: cursorFromEnvelope, + requestFromParams: { _ in TestEnvelope(values: [1, 2, 3], cursor: 1).publisher }, + requestFromCursor: { cursor in + if cursor == 1 { + return TestEnvelope(values: [4, 5, 6], cursor: 2).publisher + } else if cursor == 2 { + return TestEnvelope(values: [7, 8, 9], cursor: nil).publisher + } else { + XCTFail() + return Empty(completeImmediately: true).eraseToAnyPublisher() + } + } + ) + + paginator.requestFirstPage(withParams: ()) + + self.waitTinyInterval() + + XCTAssertEqual(paginator.state, .someLoaded) + XCTAssertEqual(paginator.values, [1, 2, 3]) + + paginator.requestNextPage() + XCTAssertTrue(paginator.isLoading) + self.waitTinyInterval() + + XCTAssertFalse(paginator.isLoading) + XCTAssertEqual(paginator.values, [1, 2, 3, 4, 5, 6]) + XCTAssertNil(paginator.error) + XCTAssertEqual(paginator.state, .someLoaded) + + paginator.requestNextPage() + XCTAssertTrue(paginator.isLoading) + self.waitTinyInterval() + + XCTAssertFalse(paginator.isLoading) + XCTAssertEqual(paginator.values, [1, 2, 3, 4, 5, 6, 7, 8, 9]) + XCTAssertNil(paginator.error) + XCTAssertEqual(paginator.state, .allLoaded) + } + + func testPaginator_requestNextPage_returnsNoCursor_finishes() { + let paginator = Paginator( + valuesFromEnvelope: valuesFromEnvelope, + cursorFromEnvelope: cursorFromEnvelope, + requestFromParams: { _ in TestEnvelope(values: [1, 2, 3], cursor: 1).publisher }, + requestFromCursor: { cursor in + if cursor == 1 { + return TestEnvelope(values: [4, 5, 6], cursor: nil).publisher + } else { + XCTFail() + return Empty(completeImmediately: true).eraseToAnyPublisher() + } + } + ) + + paginator.requestFirstPage(withParams: ()) + + self.waitTinyInterval() + + XCTAssertEqual(paginator.state, .someLoaded) + XCTAssertEqual(paginator.values, [1, 2, 3]) + + paginator.requestNextPage() + XCTAssertTrue(paginator.isLoading) + self.waitTinyInterval() + + XCTAssertFalse(paginator.isLoading) + XCTAssertEqual(paginator.values, [1, 2, 3, 4, 5, 6]) + XCTAssertNil(paginator.error) + XCTAssertEqual(paginator.state, .allLoaded) + + paginator.requestNextPage() + XCTAssertFalse(paginator.isLoading) + XCTAssertEqual(paginator.state, .allLoaded) + + self.waitTinyInterval() + XCTAssertEqual(paginator.values, [1, 2, 3, 4, 5, 6]) + } + + func testPaginator_requestNextPage_returnsNoResults_finishes() { + let paginator = Paginator( + valuesFromEnvelope: valuesFromEnvelope, + cursorFromEnvelope: cursorFromEnvelope, + requestFromParams: { _ in TestEnvelope(values: [1, 2, 3], cursor: 1).publisher }, + requestFromCursor: { cursor in + if cursor == 1 { + return TestEnvelope(values: [4, 5, 6], cursor: 2).publisher + } else if cursor == 2 { + return TestEnvelope(values: [], cursor: 3).publisher + } else { + XCTFail() + return Empty(completeImmediately: true).eraseToAnyPublisher() + } + } + ) + + paginator.requestFirstPage(withParams: ()) + + self.waitTinyInterval() + + XCTAssertEqual(paginator.state, .someLoaded) + XCTAssertEqual(paginator.values, [1, 2, 3]) + + paginator.requestNextPage() + XCTAssertTrue(paginator.isLoading) + self.waitTinyInterval() + + XCTAssertFalse(paginator.isLoading) + XCTAssertEqual(paginator.values, [1, 2, 3, 4, 5, 6]) + XCTAssertNil(paginator.error) + XCTAssertEqual(paginator.state, .someLoaded) + + paginator.requestNextPage() + self.waitTinyInterval() + + XCTAssertFalse(paginator.isLoading) + XCTAssertEqual(paginator.state, .allLoaded) + XCTAssertEqual(paginator.values, [1, 2, 3, 4, 5, 6]) + } + + func testPaginator_cancel_cancelsPendingRequests() { + let paginator = Paginator( + valuesFromEnvelope: valuesFromEnvelope, + cursorFromEnvelope: cursorFromEnvelope, + requestFromParams: { _ in TestEnvelope(values: [1, 2, 3], cursor: nil).publisher }, + requestFromCursor: { _ in TestEnvelope(values: [], cursor: nil).publisher } + ) + + paginator.requestFirstPage(withParams: ()) + XCTAssertTrue(paginator.isLoading) + + // Don't wait the time interval for the request to complete + paginator.cancel() + XCTAssertFalse(paginator.isLoading) + XCTAssertEqual(paginator.values, [], "Cancel should have kept the new values from loading") + + // Now wait, and double-check + self.waitTinyInterval() + XCTAssertEqual(paginator.values, [], "Cancel should have kept the new values from loading") + } + + func testPaginator_requestNextPage_whileLoading_doesNothing() { + let paginator = Paginator( + valuesFromEnvelope: valuesFromEnvelope, + cursorFromEnvelope: cursorFromEnvelope, + requestFromParams: { _ in TestEnvelope(values: [1, 2, 3], cursor: 1).publisher }, + requestFromCursor: { _ in TestEnvelope(values: [4, 5, 6], cursor: nil).publisher } + ) + + paginator.requestFirstPage(withParams: ()) + XCTAssertTrue(paginator.isLoading) + + paginator.requestNextPage() + + self.waitTinyInterval() + XCTAssertEqual( + paginator.values, + [1, 2, 3], + "Second page should not have loaded while first page was still loading" + ) + } + + func testPaginator_requestFirstPage_whileLoading_cancelsPreviousRequest() { + let paginator = Paginator( + valuesFromEnvelope: valuesFromEnvelope, + cursorFromEnvelope: cursorFromEnvelope, + requestFromParams: { _ in TestEnvelope(values: [1, 2, 3], cursor: 1).publisher }, + requestFromCursor: { _ in TestEnvelope(values: [4, 5, 6], cursor: nil).publisher } + ) + + paginator.requestFirstPage(withParams: ()) + XCTAssertTrue(paginator.isLoading) + + self.waitTinyInterval() + XCTAssertEqual(paginator.values, [1, 2, 3]) + XCTAssertFalse(paginator.isLoading) + + paginator.requestNextPage() + XCTAssertTrue(paginator.isLoading) + + // Don't let it load, request the first page again + paginator.requestFirstPage(withParams: ()) + + self.waitTinyInterval() + + XCTAssertEqual( + paginator.values, + [1, 2, 3], + "Second page should not have loaded, because it should have been canceled by reloading the first page" + ) + } + + func testPaginator_requestFirstPage_withError_setsError() { + let paginator = Paginator( + valuesFromEnvelope: valuesFromEnvelope, + cursorFromEnvelope: cursorFromEnvelope, + requestFromParams: { _ in + Fail(outputType: TestEnvelope.self, failure: ConcreteError()).eraseToAnyPublisher() + }, + requestFromCursor: { _ in Empty().eraseToAnyPublisher() } + ) + + paginator.requestFirstPage(withParams: ()) + XCTAssertTrue(paginator.isLoading) + + self.waitTinyInterval() + XCTAssertEqual(paginator.values, []) + XCTAssertFalse(paginator.isLoading) + XCTAssertEqual(paginator.state, .error) + XCTAssertNotNil(paginator.error) + } + + func testPaginator_requestNextPage_withError_setsError() { + let paginator = Paginator( + valuesFromEnvelope: valuesFromEnvelope, + cursorFromEnvelope: cursorFromEnvelope, + requestFromParams: { _ in TestEnvelope(values: [1, 2, 3], cursor: 1).publisher }, + requestFromCursor: { _ in + Fail(outputType: TestEnvelope.self, failure: ConcreteError()).eraseToAnyPublisher() + } + ) + + paginator.requestFirstPage(withParams: ()) + self.waitTinyInterval() + XCTAssertEqual(paginator.values, [1, 2, 3]) + + paginator.requestNextPage() + XCTAssertTrue(paginator.isLoading) + + self.waitTinyInterval() + XCTAssertEqual(paginator.values, [1, 2, 3]) + XCTAssertFalse(paginator.isLoading) + XCTAssertEqual(paginator.state, .error) + XCTAssertNotNil(paginator.error) + } +}