From f32c4abb6e0641dffdd4a9041f7592ecbf7ff69a Mon Sep 17 00:00:00 2001 From: Daniel Thorpe <309420+danthorpe@users.noreply.github.com> Date: Sun, 9 Jun 2024 16:15:55 +0100 Subject: [PATCH] feat: Pagination (#16) Co-authored-by: danthorpe --- Sources/ComposableLoadable/Array+.swift | 13 + .../Documentation.docc/Pagination.md | 106 ++++++++ .../ComposableLoadable/IdentifiedArray+.swift | 17 ++ .../{ => Loadable}/Loadable.swift | 0 .../{ => Loadable}/LoadableClient.swift | 0 .../{ => Loadable}/LoadableState.swift | 0 .../{ => Loadable}/LoadingAction.swift | 4 + .../{ => Loadable}/LoadingReducer.swift | 19 ++ .../{ => Loadable}/Typealiases.swift | 0 .../ComposableLoadable/OpenExistential.swift | 4 +- .../Pagination/PaginationContext.swift | 53 ++++ .../Pagination/PaginationDirection.swift | 39 +++ .../Pagination/PaginationFeature+.swift | 50 ++++ .../Pagination/PaginationFeature.swift | 238 ++++++++++++++++++ .../Views/PaginationLoadMore.swift | 82 ++++++ .../ComposableLoadableTests/Array+Tests.swift | 26 ++ .../PaginationFeatureStateTests.swift | 185 ++++++++++++++ .../PaginationFeatureTests.swift | 128 ++++++++++ 18 files changed, 962 insertions(+), 2 deletions(-) create mode 100644 Sources/ComposableLoadable/Array+.swift create mode 100644 Sources/ComposableLoadable/Documentation.docc/Pagination.md create mode 100644 Sources/ComposableLoadable/IdentifiedArray+.swift rename Sources/ComposableLoadable/{ => Loadable}/Loadable.swift (100%) rename Sources/ComposableLoadable/{ => Loadable}/LoadableClient.swift (100%) rename Sources/ComposableLoadable/{ => Loadable}/LoadableState.swift (100%) rename Sources/ComposableLoadable/{ => Loadable}/LoadingAction.swift (96%) rename Sources/ComposableLoadable/{ => Loadable}/LoadingReducer.swift (88%) rename Sources/ComposableLoadable/{ => Loadable}/Typealiases.swift (100%) create mode 100644 Sources/ComposableLoadable/Pagination/PaginationContext.swift create mode 100644 Sources/ComposableLoadable/Pagination/PaginationDirection.swift create mode 100644 Sources/ComposableLoadable/Pagination/PaginationFeature+.swift create mode 100644 Sources/ComposableLoadable/Pagination/PaginationFeature.swift create mode 100644 Sources/ComposableLoadable/Views/PaginationLoadMore.swift create mode 100644 Tests/ComposableLoadableTests/Array+Tests.swift create mode 100644 Tests/ComposableLoadableTests/PaginationFeatureStateTests.swift create mode 100644 Tests/ComposableLoadableTests/PaginationFeatureTests.swift diff --git a/Sources/ComposableLoadable/Array+.swift b/Sources/ComposableLoadable/Array+.swift new file mode 100644 index 000000000..8195b0223 --- /dev/null +++ b/Sources/ComposableLoadable/Array+.swift @@ -0,0 +1,13 @@ +import Foundation + +extension Array { + func unique(by keyPath: KeyPath) -> [Element] { + var seen: Set = [] + return filter { + let id = $0[keyPath: keyPath] + if seen.contains(id) { return false } + seen.insert(id) + return true + } + } +} diff --git a/Sources/ComposableLoadable/Documentation.docc/Pagination.md b/Sources/ComposableLoadable/Documentation.docc/Pagination.md new file mode 100644 index 000000000..bd09b3e41 --- /dev/null +++ b/Sources/ComposableLoadable/Documentation.docc/Pagination.md @@ -0,0 +1,106 @@ +# Pagination + +When an API supports _pagination_, we mean that its response is a subset of items, but with a cursor to load the next "page" of items. + +## Overview + +Most applications which make use of an API to load a set of items because it cannot return all available items in a single request. To load more items, typically the application makes another request using a "pagination cursor". This can go on indefinitely until all of the items have been loaded, and the API returns a nil pagination cursor. + +To that end this library contains a TCA compatible feature to support "infinite" scrolling and paginating items. + +### Basics + +For example lets says that you get a list of messages from an API, something like: + + ```json + { + "messages": [ + /* etc */ + ], + "nextPage": "" + } + ``` + +You can get the next page of messages, by sending the value for `nextPage` as a query parameter in a subsequent request. + +By combining `LoadableState` and `PaginationFeature` from this library, we can write a feature like this: + +```swift +struct Message: Identifiable { + var id: String +} +@Reducer +struct MessagesFeature { + struct State { + // Wrap PaginationFeature state with LoadableState + @LoadableStateWith.State> var messages + } + enum Action { + // The corresponding messages action + case messages( + // is a Loading Action + LoadingAction< + // generic over the kind of request sent to the `load` closure + MessageRequest, + // and the state/action types + PaginationFeature.State, + PaginationFeature.Action + > + ) + } + @Dependency(\.api.loadMessages) var loadMessages + var body: some ReducerOf { + Reduce { state, action in + // etc + return .none + } + .loadable(\.$messages, action: \.messages) { + PaginationFeature { pageRequest in + let response = try await loadMessages(cursor: pageRequest.cursor) + return Page( + next: response.nextPage, + elements: response.messages + ) + } + } load: { request, state in + let response = try await loadMessages() + guard let firstMessage = response.messages.first else { + fatalError("Handle no-results case") + } + return PaginationFeature.State( + selection: firstMessage.id, + next: response.nextPage, + elements: response.messages + ) + } + } +} +``` + +In the above example, we _compose_ `PaginationFeature.State` inside the application feature. `PaginationFeature` is generic of the kind of object which is to be paginated, so `Message` in this example. + +Because we need to also fetch the initial page of messages without any pagination cursors, we _wrap_ the `PaginationFeature.State` with `@LoadableState`. So this means that the `.messages` property itself is an optional, because initially it is in a `.pending` state. + +### Initial Load + +When the feature performs the initial `.load`, the closure calls the API, and constructs `PaginationFeature.State` from the response. In addition to the array of elements returned, the state value accepts `previous` and `next` pagination cursors. Here we define "next" to mean the values in the array which would be sorted after the current page of elements. If you API supports bi-directional pagination, the "previous" cursor is for those before the responses elements, however this value defaults to `nil`. + +### Selection + +The non-optional `selection` property can be used to track which element (across all pages) is currently selected. Because this is not and optional value, it might be necessary to consider how to handle the "empty result" edge case. A good idea would be to have a separate enum based feature for the `ResultsFeature` which includes a case for `.noResults`. + +### Pagination Context + +In some cases, it might be desirable to associate some additional metadata alongside your results. For example, perhaps the API also requires a "result-set-identifier", or it's necessary to keep hold of the total number of result elements. This is data which does not change or require mutation when performing pagination. In which case, create a specialised struct, which should conform to ``PaginationContext`` protocol, and set it as the `context` property. + +## Pagination + +To load another page or to change the selection, use the actions available via `PaginationFeature.Action`. + +### Pagination Direction + +The ``PaginationDirection`` enum has four cases, `.top`, `.bottom` for paging while vertically scrolling. And `.leading`, `.trailing` to enable "horizontal" paging, which will update the selection and also fetch a new page. The directions, `.top` and `.leading` both evaluate to showing or fetching elements which are before the current selection, while `.bottom` and `.trailing` show elements which are after the current selection. In other words, `.top` would prepend new elements to the list, and `.bottom` will append new elements. + +### Load More + +``PaginationLoadMore`` is a SwiftUI view which can be placed in a scroll view to support infinite scrolling. If using a SwiftUI List or a ScrollView, we would expect to place a `ForEach` view followed by a ``PaginationLoadMore`` view, to trigger loading of more elements. diff --git a/Sources/ComposableLoadable/IdentifiedArray+.swift b/Sources/ComposableLoadable/IdentifiedArray+.swift new file mode 100644 index 000000000..a59dcf81c --- /dev/null +++ b/Sources/ComposableLoadable/IdentifiedArray+.swift @@ -0,0 +1,17 @@ +import IdentifiedCollections + +extension IdentifiedArray { + subscript(before id: ID) -> Element? { + guard let idx = index(id: id), idx > startIndex else { return nil } + let beforeIdx = index(before: idx) + guard beforeIdx >= startIndex else { return nil } + return self[beforeIdx] + } + + subscript(after id: ID) -> Element? { + guard let idx = index(id: id), idx < endIndex else { return nil } + let afterIdx = index(after: idx) + guard afterIdx < endIndex else { return nil } + return self[afterIdx] + } +} diff --git a/Sources/ComposableLoadable/Loadable.swift b/Sources/ComposableLoadable/Loadable/Loadable.swift similarity index 100% rename from Sources/ComposableLoadable/Loadable.swift rename to Sources/ComposableLoadable/Loadable/Loadable.swift diff --git a/Sources/ComposableLoadable/LoadableClient.swift b/Sources/ComposableLoadable/Loadable/LoadableClient.swift similarity index 100% rename from Sources/ComposableLoadable/LoadableClient.swift rename to Sources/ComposableLoadable/Loadable/LoadableClient.swift diff --git a/Sources/ComposableLoadable/LoadableState.swift b/Sources/ComposableLoadable/Loadable/LoadableState.swift similarity index 100% rename from Sources/ComposableLoadable/LoadableState.swift rename to Sources/ComposableLoadable/Loadable/LoadableState.swift diff --git a/Sources/ComposableLoadable/LoadingAction.swift b/Sources/ComposableLoadable/Loadable/LoadingAction.swift similarity index 96% rename from Sources/ComposableLoadable/LoadingAction.swift rename to Sources/ComposableLoadable/Loadable/LoadingAction.swift index b747cc1df..ee585a1e2 100644 --- a/Sources/ComposableLoadable/LoadingAction.swift +++ b/Sources/ComposableLoadable/Loadable/LoadingAction.swift @@ -72,3 +72,7 @@ extension LoadingAction where Value: Equatable { } } } + +public struct NoLoadingAction: Equatable { + private init() {} +} diff --git a/Sources/ComposableLoadable/LoadingReducer.swift b/Sources/ComposableLoadable/Loadable/LoadingReducer.swift similarity index 88% rename from Sources/ComposableLoadable/LoadingReducer.swift rename to Sources/ComposableLoadable/Loadable/LoadingReducer.swift index 8334949e0..595813526 100644 --- a/Sources/ComposableLoadable/LoadingReducer.swift +++ b/Sources/ComposableLoadable/Loadable/LoadingReducer.swift @@ -44,6 +44,25 @@ extension Reducer { child: child ) } + + /// Integrate some LoadableState which does not require a child domain + public func loadable( + _ toLoadableState: WritableKeyPath>, + action toLoadingAction: CaseKeyPath>, + load: @escaping @Sendable (Request, State) async throws -> ChildState, + fileID: StaticString = #fileID, + line: UInt = #line + ) -> some ReducerOf { + LoadingReducer( + parent: self, + child: EmptyReducer(), + toLoadableState: toLoadableState, + toLoadingAction: toLoadingAction, + client: LoadingClient(load: load), + fileID: fileID, + line: line + ) + } } // MARK: - Internal API diff --git a/Sources/ComposableLoadable/Typealiases.swift b/Sources/ComposableLoadable/Loadable/Typealiases.swift similarity index 100% rename from Sources/ComposableLoadable/Typealiases.swift rename to Sources/ComposableLoadable/Loadable/Typealiases.swift diff --git a/Sources/ComposableLoadable/OpenExistential.swift b/Sources/ComposableLoadable/OpenExistential.swift index 9ee49b2c9..a8686a075 100644 --- a/Sources/ComposableLoadable/OpenExistential.swift +++ b/Sources/ComposableLoadable/OpenExistential.swift @@ -1,6 +1,6 @@ // Adapted from swift-composable-architecute -// MARK: Equatable +// MARK: - Equatable func _isEqual(_ lhs: Any, _ rhs: Any) -> Bool { (lhs as? any Equatable)?.isEqual(other: rhs) ?? false @@ -12,7 +12,7 @@ extension Equatable { } } -// MARK: Identifiable +// MARK: - Identifiable func _identifiableID(_ value: Any) -> AnyHashable? { func open(_ value: some Identifiable) -> AnyHashable { diff --git a/Sources/ComposableLoadable/Pagination/PaginationContext.swift b/Sources/ComposableLoadable/Pagination/PaginationContext.swift new file mode 100644 index 000000000..6ae6dd50f --- /dev/null +++ b/Sources/ComposableLoadable/Pagination/PaginationContext.swift @@ -0,0 +1,53 @@ +import Foundation + +/// `PaginationContext` allows framework consumers to associate +/// _any_ additional information which might be required for pagination. +/// +/// For example, perhaps there are additional identifiers other +/// than a pagination cursor which is required to load a page of +/// elements. To do this, create a type to hold onto additional +/// info, e.g. +/// +/// ```swift +/// struct AdditionalInfo: Equatable, PaginationContext { +/// let userId: String +/// let searchId: String +/// } +/// ``` +public protocol PaginationContext: Equatable { + + /// A convenience to determine equality between any two + /// contexts. + /// - Parameter to other: any other `PaginationContext` value + /// - Returns: a `Bool` + func isEqual(to: any PaginationContext) -> Bool +} + +extension PaginationContext { + public func isEqual(to other: any PaginationContext) -> Bool { + _isEqual(self, other) + } +} + +/// `NoPaginationContext` can be used in cases where you don't +/// require any kind of context. +public struct NoPaginationContext: PaginationContext { + public static let none = NoPaginationContext() + private init() {} +} + +/// A helpful error which can be thrown if required. +/// +/// For example, if it is necessary to differentiate between +/// different contexts, and the received context cannot be +/// mapped to a specific type, this is a useful error to throw. +public struct UnexpectedPaginationContext: Equatable, Error { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.context.isEqual(to: rhs.context) + } + /// The unexpected context value + public let context: any PaginationContext + public init(context: any PaginationContext) { + self.context = context + } +} diff --git a/Sources/ComposableLoadable/Pagination/PaginationDirection.swift b/Sources/ComposableLoadable/Pagination/PaginationDirection.swift new file mode 100644 index 000000000..514599734 --- /dev/null +++ b/Sources/ComposableLoadable/Pagination/PaginationDirection.swift @@ -0,0 +1,39 @@ +import Foundation + +/// The direction of selection/pagination +/// +/// For example, vertical scrolling is .bottom +/// when revealing elements at the bottom of a list. +/// And .leading/.trailing is for when paging through +/// a selected element sideways. +public enum PaginationDirection: Equatable { + case top, bottom, leading, trailing +} + +extension PaginationDirection { + + var isPrevious: Bool { + switch self { + case .top, .leading: true + case .bottom, .trailing: false + } + } + + var isNext: Bool { + false == isPrevious + } + + var isVerticalScrolling: Bool { + switch self { + case .top, .bottom: true + case .leading, .trailing: false + } + } + + var isHorizontalPaging: Bool { + switch self { + case .leading, .trailing: true + case .top, .bottom: false + } + } +} diff --git a/Sources/ComposableLoadable/Pagination/PaginationFeature+.swift b/Sources/ComposableLoadable/Pagination/PaginationFeature+.swift new file mode 100644 index 000000000..a7e4b5958 --- /dev/null +++ b/Sources/ComposableLoadable/Pagination/PaginationFeature+.swift @@ -0,0 +1,50 @@ +extension PaginationFeature.State.Page: Equatable where Element: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.previous == rhs.previous + && lhs.next == rhs.next + && lhs.elements == rhs.elements + } +} + +extension PaginationFeature.State.PageRequest: Equatable where Element: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.direction == rhs.direction + && lhs.context.isEqual(to: rhs.context) + && lhs.cursor == rhs.cursor + } +} + +extension PaginationFeature.State: Equatable where Element: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.context.isEqual(to: rhs.context) + && lhs.selection == rhs.selection + && lhs.$page == rhs.$page + && lhs.pages == rhs.pages + } +} + +extension PaginationFeature.Action.Delegate: Equatable where Element: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.didSelect(lhs), .didSelect(rhs)): + return lhs == rhs + case let (.didUpdate(context: lhsC, pages: lhsP), .didUpdate(context: rhsC, pages: rhsP)): + return lhsC.isEqual(to: rhsC) && lhsP == rhsP + default: + return false + } + } +} + +extension PaginationFeature.Action: Equatable where Element: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.delegate(lhs), .delegate(rhs)): lhs == rhs + case let (.loadPage(lhs), .loadPage(rhs)): lhs == rhs + case let (.page(lhs), .page(rhs)): lhs == rhs + case let (.select(lhs), .select(rhs)): lhs == rhs + case let (.selectElement(lhs), .selectElement(rhs)): lhs == rhs + default: false + } + } +} diff --git a/Sources/ComposableLoadable/Pagination/PaginationFeature.swift b/Sources/ComposableLoadable/Pagination/PaginationFeature.swift new file mode 100644 index 000000000..82741e1eb --- /dev/null +++ b/Sources/ComposableLoadable/Pagination/PaginationFeature.swift @@ -0,0 +1,238 @@ +import ComposableArchitecture +import Foundation + +/// A Reducer which is generic over some identifiable element, +/// to support paginating a list of `Element`s. +/// +/// - Note: This feature should be used _after_ the first page of elements +/// has already be fetched. i.e. you fetch some elements, and have +/// pagination cursors for previous & next. You can use +/// `LoadableState` etc to do this. +@Reducer public struct PaginationFeature { + + @ObservableState + public struct State { + /// Access the `PaginationContext` + public internal(set) var context: any PaginationContext + + /// The currently selected element + public var selection: Element.ID + + @ObservationStateIgnored + @LoadableState var page + + var pages: [Page] + + /// Access all of the elements loaded so far across all pages + public var elements: IdentifiedArrayOf { + IdentifiedArray(uniqueElements: pages.flatMap(\.elements).unique(by: \.id)) + } + + /// Access the index of the current selection in the overall array of elements + public var selectionIndex: Int { + var idx: Int = 0 + for page in pages { + guard let index = page.elements.firstIndex(where: { $0.id == selection }) else { + idx += page.elements.unique(by: \.id).count + break + } + return idx + index + } + assertionFailure("Unable to find \(selection) in pages") + return NSNotFound + } + + /// Instantiate the ``PaginationFeature.State`` with a result set of `Element` values + /// - Parameters: + /// - context: a value which conforms to ``PaginationContext``, defaults to ``NoPaginationContext.none`` + /// - selection: the selected element ID + /// - previous: the cursor to load the page of elements before the first element + /// - next: the cursor to load the page of elements after the last element + /// - elements: the initial page of elements + public init( + context: any PaginationContext = NoPaginationContext.none, + selection: Element.ID, + previous: PaginationCursor? = nil, + next: PaginationCursor?, + elements: [Element] + ) { + self.context = context + self.selection = selection + let page = Page( + previous: previous, + next: next, + elements: elements + ) + self._page = .init( + request: nil, + success: page + ) + self.pages = [page] + } + + /// A "page" of elements + public struct Page { + var previous: PaginationCursor? + var next: PaginationCursor? + var elements: [Element] + + /// Instantiate a page of elements + /// - Parameters: + /// - previous: the cursor to load the page of elements before the first element + /// - next: the cursor to load the page of elements after the last element + /// - elements: the elements in page + public init(previous: PaginationCursor? = nil, next: PaginationCursor? = nil, elements: [Element]) { + self.previous = previous + self.next = next + self.elements = elements + } + } + + /// A request for a new page + public struct PageRequest { + + /// The `PaginationDirection` direction + public var direction: PaginationDirection + + /// The `PaginationContext` context + public var context: any PaginationContext + + /// The `PaginationCursor` cursor + public var cursor: PaginationCursor + } + + mutating func finished(_ request: PageRequest, page newPage: Page) { + let index: Int? + + if request.direction.isNext { + index = pages.firstIndex { $0.next == request.cursor }.map { pages.index(after: $0) } + } else { + index = pages.firstIndex { $0.previous == request.cursor }.map { pages.index(before: $0) } + } + + guard let index else { + XCTFail("Pagination: Cannot find insertion page for request: \(request)") + return + } + + pages.insert(newPage, at: index) + } + + func getCursor(in direction: PaginationDirection) -> PaginationCursor? { + direction.isPrevious ? page?.previous : page?.next + } + + mutating func selectNewElement(in direction: PaginationDirection) -> Element? { + guard + let element = direction.isNext + ? elements[after: selection] + : elements[before: selection] + else { + return nil + } + selection = element.id + return element + } + + mutating func selectElement(id newElementID: Element.ID) -> Element? { + guard let element = elements[id: newElementID] else { + return nil + } + selection = newElementID + return element + } + + func canPaginate(in direction: PaginationDirection) -> Bool { + nil != getCursor(in: direction) + } + } + + public enum Action { + @CasePathable + public enum Delegate { + /// Sent the selected element changes + case didSelect(Element) + /// Sent when pages are loaded, all of the pages are included + case didUpdate(context: any PaginationContext, pages: [Page]) + } + + /// Delegate actions for parent features to respond to + case delegate(Delegate) + + /// Loads new pages in the specified direction + case loadPage(PaginationDirection) + + /// Internal actions used during the loading of a pages + case page(LoadingAction) + + /// Select the adjacent element in the specified direction. + /// + /// In cases where the current selection is already the first + /// element (in the case of a .top or .trailing) or last element + /// (in the case of a .bottom or .leading) direction, then the + /// next page (in this direction) will be loaded. + case select(PaginationDirection) + + /// Jump to a selection if the element ID is already known. + case selectElement(Element.ID) + } + + public typealias Page = State.Page + public typealias PageRequest = State.PageRequest + + public typealias LoadPage = @Sendable (PageRequest) async throws -> Page + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .loadPage(direction): + guard let cursor = state.getCursor(in: direction) else { + XCTFail("Pagination: Attempt to load page in \(direction) without a cursor") + return .none + } + let request = PageRequest(direction: direction, context: state.context, cursor: cursor) + return .send(.page(.load(request))) + case let .page(.finished(.some(request), .success(page))): + state.finished(request, page: page) + let continueSelection: EffectOf = + request.direction.isHorizontalPaging + ? .send(.select(request.direction)) + : .none + return .send(.delegate(.didUpdate(context: state.context, pages: state.pages))) + .merge(with: continueSelection) + case let .select(direction): + guard let element = state.selectNewElement(in: direction) else { + return .send(.loadPage(direction)) + } + return .send(.delegate(.didSelect(element))) + case let .selectElement(newElementId): + guard let element = state.selectElement(id: newElementId) else { + return .none + } + return .send(.delegate(.didSelect(element))) + default: + return .none + } + } + .loadable(\.$page, action: \.page) { request, state in + guard let request else { return state.page! } + return try await loadPage(request) + } + } + + let loadPage: LoadPage + + /// Create a `PaginationFeature` + /// + /// The feature requires a "dependency" to be able to + /// load a new page of elements. + /// + /// - Parameter loadPage: a closure which will return a new page. It + /// will receive a `PageRequest` and return a `Page` value. + public init(loadPage: @escaping LoadPage) { + self.loadPage = loadPage + } +} + +/// `PaginationCursor` is just a `String` +public typealias PaginationCursor = String diff --git a/Sources/ComposableLoadable/Views/PaginationLoadMore.swift b/Sources/ComposableLoadable/Views/PaginationLoadMore.swift new file mode 100644 index 000000000..36e16c59f --- /dev/null +++ b/Sources/ComposableLoadable/Views/PaginationLoadMore.swift @@ -0,0 +1,82 @@ +import ComposableArchitecture +import SwiftUI + +/// Trigger loading a new page when the view appears +public struct PaginationLoadMore< + Element: Identifiable, + Failure: View, + Loading: View, + NoMoreResults: View +>: View { + + public typealias Retry = () -> Void + public typealias FailureViewBuilder = (any Error, @escaping Retry) -> Failure + public typealias NoMoreResultsViewBuilder = () -> NoMoreResults + public typealias LoadingViewBuilder = () -> Loading + + private let direction: PaginationDirection + private let store: StoreOf> + private let onActive: LoadingViewBuilder + private let onError: FailureViewBuilder + private let noMoreResults: NoMoreResults + + /// Compose inside a scroll view to loads new pages in the specified direction. + /// - Parameters: + /// - store: a TCA Store of a ``PaginationFeature`` + /// - direction: the ``PaginationDirection``, e.g. typically use `.bottom` to load the next page at the bottom of a vertical list. + /// - onError: build a view which receives the error, and a retry closure. + /// - noMoreResults: build a view to display when there are no more results + /// - onActive: build a view to display when fetching a new page + public init( + _ store: StoreOf>, + direction: PaginationDirection, + @ViewBuilder onError: @escaping FailureViewBuilder, + @ViewBuilder noMoreResults: @escaping NoMoreResultsViewBuilder, + @ViewBuilder onActive: @escaping LoadingViewBuilder + ) { + self.direction = direction + self.onActive = onActive + self.onError = onError + self.noMoreResults = noMoreResults() + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + if store.state.canPaginate(in: direction) { + LoadableView(store.scope(state: \.$page, action: \.page), onError: onError, onActive: onActive) { + store.send(.loadPage(direction)) + } + } else { + noMoreResults + } + } + } +} + +extension LoadableView { + fileprivate typealias AppearAction = () -> Void + fileprivate init( + _ store: LoadableStore, + @ViewBuilder onError: @escaping (any Error, @escaping AppearAction) -> ErrorView, + @ViewBuilder onActive: @escaping () -> Loading, + onAppear: @escaping AppearAction = {} + ) + where + Success == OnAppearView, + Pending == Loading, + Failure == FailureView + { + self.init(store) { _ in + OnAppearView(block: onAppear) + } failure: { failureStore in + FailureView(store: failureStore) { error, _ in + onError(error, onAppear) + } + } loading: { _ in + onActive() + } pending: { + onActive() + } + } +} diff --git a/Tests/ComposableLoadableTests/Array+Tests.swift b/Tests/ComposableLoadableTests/Array+Tests.swift new file mode 100644 index 000000000..1991905f6 --- /dev/null +++ b/Tests/ComposableLoadableTests/Array+Tests.swift @@ -0,0 +1,26 @@ +import Foundation +import XCTest + +@testable import ComposableLoadable + +private struct Element: Equatable, Identifiable { + let id: Int + let message: String +} + +final class ArrayExtensionTests: XCTestCase { + func test__unique_by_keypath() { + let elements = [ + Element(id: 10, message: "Hello"), + Element(id: 11, message: "World"), + Element(id: 11, message: "World"), + ] + + XCTAssertEqual( + elements.unique(by: \.id), + [ + Element(id: 10, message: "Hello"), + Element(id: 11, message: "World"), + ]) + } +} diff --git a/Tests/ComposableLoadableTests/PaginationFeatureStateTests.swift b/Tests/ComposableLoadableTests/PaginationFeatureStateTests.swift new file mode 100644 index 000000000..9d00e591b --- /dev/null +++ b/Tests/ComposableLoadableTests/PaginationFeatureStateTests.swift @@ -0,0 +1,185 @@ +import ComposableArchitecture +import XCTest + +@testable import ComposableLoadable + +private struct Message: Equatable, Identifiable { + let id: Int + let message: String +} + +struct TestContext: Equatable, PaginationContext {} + +final class PaginationFeatureStateTests: XCTestCase { + + fileprivate typealias State = PaginationFeature.State + + func test__init() { + let state = State( + context: TestContext(), + selection: 42, + previous: nil, + next: "next-page-cursor", + elements: [ + Message(id: 12, message: "Hello"), + Message(id: 42, message: "World"), + Message(id: 88, message: "Goodbye"), + ] + ) + XCTAssertEqual( + state.pages, + [ + State.Page( + next: "next-page-cursor", + elements: [ + Message(id: 12, message: "Hello"), + Message(id: 42, message: "World"), + Message(id: 88, message: "Goodbye"), + ]) + ]) + + XCTAssertEqual( + state.elements, + IdentifiedArray(uniqueElements: [ + Message(id: 12, message: "Hello"), + Message(id: 42, message: "World"), + Message(id: 88, message: "Goodbye"), + ])) + } + + func test__get_cursor() { + let state = State( + context: TestContext(), + selection: 42, + previous: "previous-page-cursor", + next: "next-page-cursor", + elements: [ + Message(id: 12, message: "Hello"), + Message(id: 42, message: "World"), + Message(id: 88, message: "Goodbye"), + ] + ) + + XCTAssertEqual(state.getCursor(in: .bottom), "next-page-cursor") + XCTAssertEqual(state.getCursor(in: .trailing), "next-page-cursor") + XCTAssertEqual(state.getCursor(in: .top), "previous-page-cursor") + XCTAssertEqual(state.getCursor(in: .leading), "previous-page-cursor") + } + + func test__can_paginate() { + let state = State( + context: TestContext(), + selection: 42, + previous: nil, + next: "next-page-cursor", + elements: [ + Message(id: 12, message: "Hello"), + Message(id: 42, message: "World"), + Message(id: 88, message: "Goodbye"), + ] + ) + + XCTAssertTrue(state.canPaginate(in: .bottom)) + XCTAssertTrue(state.canPaginate(in: .trailing)) + XCTAssertFalse(state.canPaginate(in: .top)) + XCTAssertFalse(state.canPaginate(in: .leading)) + } + + func test__select_element() { + var state = State( + context: TestContext(), + selection: 42, + previous: nil, + next: "next-page-cursor", + elements: [ + Message(id: 12, message: "Hello"), + Message(id: 42, message: "World"), + Message(id: 88, message: "Goodbye"), + ] + ) + + XCTAssertEqual(state.selectElement(id: 88), Message(id: 88, message: "Goodbye")) + XCTAssertEqual(state.selection, 88) + XCTAssertNil(state.selectElement(id: 90)) + } + + func test__select_new_element() { + var state = State( + context: TestContext(), + selection: 42, + previous: nil, + next: "next-page-cursor", + elements: [ + Message(id: 12, message: "Hello"), + Message(id: 42, message: "World"), + Message(id: 88, message: "Goodbye"), + ] + ) + + XCTAssertEqual(state.selectNewElement(in: .bottom), Message(id: 88, message: "Goodbye")) + XCTAssertEqual(state.selection, 88) + XCTAssertEqual(state.selectionIndex, 2) + XCTAssertNil(state.selectNewElement(in: .bottom)) + + XCTAssertEqual(state.selectNewElement(in: .leading), Message(id: 42, message: "World")) + XCTAssertEqual(state.selection, 42) + XCTAssertEqual(state.selectionIndex, 1) + } + + func test__finished_loading_page_request() { + var state = State( + context: TestContext(), + selection: 42, + previous: nil, + next: "page-2-cursor", + elements: [ + Message(id: 12, message: "Hello"), + Message(id: 42, message: "World"), + Message(id: 88, message: "Goodbye"), + ] + ) + + state.finished( + .init( + direction: .bottom, + context: TestContext(), + cursor: "page-2-cursor" + ), + page: .init( + previous: "page-1-cursor", + next: "page-3-cursor", + elements: [ + Message(id: 90, message: "John"), + Message(id: 91, message: "Paul"), + Message(id: 91, message: "George"), + Message(id: 92, message: "Ringo"), + ] + ) + ) + + XCTAssertEqual( + state.pages, + [ + .init( + previous: nil, + next: "page-2-cursor", + elements: [ + Message(id: 12, message: "Hello"), + Message(id: 42, message: "World"), + Message(id: 88, message: "Goodbye"), + ] + ), + .init( + previous: "page-1-cursor", + next: "page-3-cursor", + elements: [ + Message(id: 90, message: "John"), + Message(id: 91, message: "Paul"), + Message(id: 91, message: "George"), + Message(id: 92, message: "Ringo"), + ] + ), + ]) + + } +} diff --git a/Tests/ComposableLoadableTests/PaginationFeatureTests.swift b/Tests/ComposableLoadableTests/PaginationFeatureTests.swift new file mode 100644 index 000000000..5f9f586bf --- /dev/null +++ b/Tests/ComposableLoadableTests/PaginationFeatureTests.swift @@ -0,0 +1,128 @@ +import ComposableArchitecture +import XCTest + +@testable import ComposableLoadable + +private struct Message: Equatable, Identifiable { + let id: Int + let message: String +} + +extension String: Error {} + +final class PaginationFeatureTests: XCTestCase { + + fileprivate typealias TestFeature = PaginationFeature + + @MainActor func test__basics() async throws { + let page1 = TestFeature.Page( + previous: nil, + next: "page-2-cursor", + elements: [ + Message(id: 12, message: "Hello"), + Message(id: 42, message: "World"), + Message(id: 88, message: "Goodbye"), + ] + ) + let page2 = TestFeature.Page( + previous: "page-1-cursor", + next: "page-3-cursor", + elements: [ + Message(id: 90, message: "John"), + Message(id: 91, message: "Paul"), + Message(id: 91, message: "George"), + Message(id: 92, message: "Ringo"), + ] + ) + let page3 = TestFeature.Page( + previous: "page-2-cursor", + next: nil, + elements: [ + Message(id: 101, message: "Thom"), + Message(id: 102, message: "Johnny"), + Message(id: 103, message: "Colin"), + Message(id: 104, message: "Ed"), + Message(id: 105, message: "Phil"), + ] + ) + + let store = TestStore( + initialState: TestFeature.State( + context: TestContext(), + selection: 42, + previous: nil, + next: "page-2-cursor", + elements: [ + Message(id: 12, message: "Hello"), + Message(id: 42, message: "World"), + Message(id: 88, message: "Goodbye"), + ] + ) + ) { + TestFeature { request in + switch request.cursor { + case "page-2-cursor": + return page2 + case "page-3-cursor": + return page3 + default: + throw "Unexpected request cursor: \(request.cursor)" + + } + } + } + + // Select .bottom + + await store.send(.select(.bottom)) { + $0.selection = 88 + } + await store.receive(.delegate(.didSelect(Message(id: 88, message: "Goodbye")))) + + // Select the .trailing element which will require loading + + await store.send(.select(.trailing)) + + await store.receive(.loadPage(.trailing)) + + var request = TestFeature.PageRequest(direction: .trailing, context: TestContext(), cursor: "page-2-cursor") + await store.receive(.page(.load(request))) { + $0.$page.becomeActive(request) + } + + await store.receive(.page(.finished(request, .success(page2)))) { + $0.$page.finish(request, result: .success(page2)) + $0.pages = [ + page1, page2, + ] + } + + await store.receive(.delegate(.didUpdate(context: TestContext(), pages: [page1, page2]))) + + // After the page is loaded, we should still select the .trailing element + + await store.receive(.select(.trailing)) { + $0.selection = 90 + } + + await store.receive(.delegate(.didSelect(Message(id: 90, message: "John")))) + + // Now let's load a new page + + await store.send(.loadPage(.bottom)) + + request = TestFeature.PageRequest(direction: .bottom, context: TestContext(), cursor: "page-3-cursor") + await store.receive(.page(.load(request))) { + $0.$page.becomeActive(request) + } + + await store.receive(.page(.finished(request, .success(page3)))) { + $0.$page.finish(request, result: .success(page3)) + $0.pages = [ + page1, page2, page3, + ] + } + + await store.receive(.delegate(.didUpdate(context: TestContext(), pages: [page1, page2, page3]))) + } +}