diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4415dfc..b09f602 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,20 +23,12 @@ jobs: with: args: --strict env: - DIFF_BASE: ${{ github.base_ref }} - - - macOS: - name: ${{ matrix.name }} runs-on: ${{ matrix.runsOn }} - env: - DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" - timeout-minutes: 20 strategy: fail-fast: false @@ -45,49 +37,37 @@ jobs: - xcode: "Xcode_15.0" runsOn: macos-13 name: "macOS 13, Xcode 15.0, Swift 5.9.0" + params: "-skipMacroValidation" - xcode: "Xcode_14.3.1" runsOn: macos-13 name: "macOS 13, Xcode 14.3.1, Swift 5.8.0" + params: "" - xcode: "Xcode_14.2" runsOn: macOS-12 name: "macOS 12, Xcode 14.2, Swift 5.7.2" - xcode: "Xcode_14.1" runsOn: macOS-12 name: "macOS 12, Xcode 14.1, Swift 5.7.1" + params: "" steps: - uses: actions/checkout@v3 - - name: ${{ matrix.name }} - run: xcodebuild test -scheme "{{ cookiecutter.name }}" -destination "platform=macOS" clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 - + run: xcodebuild test -scheme "Blade-Package" -destination "platform=macOS" clean -enableCodeCoverage YES ${{ matrix.params }} -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v3.1.0 with: - token: ${{ secrets.CODECOV_TOKEN }} - xcode: true - xcode_archive_path: test_output/${{ matrix.name }}.xcresult - - uses: actions/upload-artifact@v4 with: - name: ${{ matrix.name }} - path: test_output - - - iOS: - name: ${{ matrix.name }} runs-on: ${{ matrix.runsOn }} - env: - DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" - timeout-minutes: 20 strategy: fail-fast: false @@ -97,34 +77,25 @@ jobs: name: "iOS 17.0.1" xcode: "Xcode_15.0" runsOn: macos-13 + params: "-skipMacroValidation" - destination: "OS=16.4,name=iPhone 14 Pro" name: "iOS 16.4" xcode: "Xcode_14.3.1" runsOn: macos-13 + params: "" steps: - uses: actions/checkout@v3 - - name: ${{ matrix.name }} - run: xcodebuild test -scheme "{{ cookiecutter.name }}" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 - + run: xcodebuild test -scheme "Blade-Package" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES ${{ matrix.params }} -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 - uses: actions/upload-artifact@v4 with: - name: ${{ matrix.name }} path: test_output - - - - tvOS: - name: ${{ matrix.name }} runs-on: ${{ matrix.runsOn }} - env: - DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" - timeout-minutes: 20 strategy: fail-fast: false @@ -134,44 +105,31 @@ jobs: name: "tvOS 17.0" xcode: "Xcode_15.0" runsOn: macos-13 + params: "-skipMacroValidation" - destination: "OS=16.4,name=Apple TV" name: "tvOS 16.4" xcode: "Xcode_14.3.1" runsOn: macos-13 + params: "" steps: - uses: actions/checkout@v3 - - name: ${{ matrix.name }} - run: xcodebuild test -scheme "{{ cookiecutter.name }}" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 - + run: xcodebuild test -scheme "Blade-Package" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES ${{ matrix.params }} -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v3.1.0 with: - token: ${{ secrets.CODECOV_TOKEN }} - xcode: true - xcode_archive_path: test_output/${{ matrix.name }}.xcresult - - uses: actions/upload-artifact@v4 with: - name: ${{ matrix.name }} - path: test_output - - - watchOS: - name: ${{ matrix.name }} runs-on: ${{ matrix.runsOn }} - env: - DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" - timeout-minutes: 20 strategy: fail-fast: false @@ -181,47 +139,36 @@ jobs: name: "watchOS 10.0" xcode: "Xcode_15.0" runsOn: macos-13 + params: "-skipMacroValidation" - destination: "OS=9.4,name=Apple Watch Series 8 (45mm)" name: "watchOS 9.4" xcode: "Xcode_14.3.1" runsOn: macos-13 + params: "" - destination: "OS=8.5,name=Apple Watch Series 7 (45mm)" name: "watchOS 8.5" xcode: "Xcode_14.3.1" runsOn: macos-13 + params: "" steps: - uses: actions/checkout@v3 - - name: ${{ matrix.name }} - run: xcodebuild test -scheme "{{ cookiecutter.name }}" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 - + run: xcodebuild test -scheme "Blade-Package" -destination "${{ matrix.destination }}" clean -enableCodeCoverage YES ${{ matrix.params }} -resultBundlePath "test_output/${{ matrix.name }}.xcresult" || exit 1 - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v3.1.0 with: - token: ${{ secrets.CODECOV_TOKEN }} - xcode: true - xcode_archive_path: test_output/${{ matrix.name }}.xcresult - - uses: actions/upload-artifact@v4 with: - name: ${{ matrix.name }} - path: test_output - - spm: - name: ${{ matrix.name }} runs-on: ${{ matrix.runsOn }} - env: - DEVELOPER_DIR: "/Applications/${{ matrix.xcode }}.app/Contents/Developer" - timeout-minutes: 20 strategy: fail-fast: false @@ -235,10 +182,11 @@ jobs: runsOn: macos-13 steps: - uses: actions/checkout@v3 - - name: ${{ matrix.name }} - run: swift build -c release --target "{{ cookiecutter.name }}" - + run: | + swift build -c release --target "Blade" + swift build -c release --target "BladeTCA" + merge-test-reports: needs: [iOS, macOS, watchOS, tvOS] runs-on: macos-13 diff --git a/.swiftlint.yml b/.swiftlint.yml index 89efd09..44740c2 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,6 +1,8 @@ excluded: - Tests - Package.swift + - Package@swift-5.7.swift + - Package@swift-5.8.swift - .build # Rules @@ -9,6 +11,7 @@ disabled_rules: - trailing_comma - todo - opening_brace + - multiple_closures_with_trailing_closure opt_in_rules: # some rules are only opt-in - anyobject_protocol @@ -18,7 +21,6 @@ opt_in_rules: # some rules are only opt-in - closure_end_indentation - closure_spacing - collection_alignment - - conditional_returns_on_newline - contains_over_filter_count - contains_over_filter_is_empty - contains_over_first_not_nil diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Blade-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Blade-Package.xcscheme new file mode 100644 index 0000000..0d2f5ea --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Blade-Package.xcscheme @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Blade.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Blade.xcscheme new file mode 100644 index 0000000..12000ba --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Blade.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/BladeTCA.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/BladeTCA.xcscheme new file mode 100644 index 0000000..1f635f4 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/BladeTCA.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e9885a..63e5ce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,12 @@ # Change Log -All notable changes to this project will be documented in this file. \ No newline at end of file +All notable changes to this project will be documented in this file. + +#### 1.x Releases +- `1.0.x` Releases - [1.0.0](#100) + +## [1.0.0](https://github.com/space-code/blade/releases/tag/1.0.0) +Released on 2024-02-05. + +#### Added +- Initial release of Blade. + - Added by [Nikita Vasilev](https://github.com/nik3212). \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f89471..1e2051e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,4 +58,4 @@ See [CODE_OF_CONDUCT.md](https://github.com/space-code/blade/blob/master/CODE_OF --- -*Some of the ideas and wording for the statements above were based on work by the [Docker](https://github.com/docker/docker/blob/master/CONTRIBUTING.md) and [Linux](https://elinux.org/Developer_Certificate_Of_Origin) communities. We commend them for their efforts to facilitate collaboration in their projects.* \ No newline at end of file +*Some of the ideas and wording for the statements above were based on work by the [Docker](https://github.com/docker/docker/blob/master/CONTRIBUTING.md) and [Linux](https://elinux.org/Developer_Certificate_Of_Origin) communities. \ No newline at end of file diff --git a/Makefile b/Makefile index 3edc6d6..856d64b 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ bootstrap: hook mint bootstrap hook: - ln -sf .git/hooks/pre-commit ../../hooks/pre-commit + ln -sf ../../hooks/pre-commit .git/hooks/pre-commit chmod +x .git/hooks/pre-commit mint: diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..4284f1c --- /dev/null +++ b/Package.resolved @@ -0,0 +1,113 @@ +{ + "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "76d7791b5bda47df7e3d4690c4c3aaf089730707", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "a8421d68068d8f45fbceb418fbf22c5dad4afd33", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-composable-architecture", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-composable-architecture.git", + "state" : { + "revision" : "ae491c9e3f66631e72d58db8bb4c27dfc3d3afd4", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "aedcf6f4cd486ccef5b312ccac85d4b3f6e58605", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "adb04a8e35f07edc001877af9f9f97fcc21d409e", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "d1e45f3e1eee2c9193f5369fa9d70a6ddad635e8", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } + }, + { + "identity" : "swiftui-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swiftui-navigation", + "state" : { + "revision" : "78f9d72cf667adb47e2040aa373185c88c63f0dc", + "version" : "1.2.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "b58e6627149808b40634c4552fcf2f44d0b3ca87", + "version" : "1.1.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index ce886f3..fa74cab 100644 --- a/Package.swift +++ b/Package.swift @@ -5,19 +5,36 @@ import PackageDescription let package = Package( name: "Blade", + platforms: [ + .iOS(.v13), + .macOS(.v11), + .tvOS(.v13), + .watchOS(.v7), + .visionOS(.v1), + ], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. - .library( - name: "Blade", - targets: ["Blade"]), + .library(name: "Blade", targets: ["Blade"]), + .library(name: "BladeTCA", targets: ["BladeTCA"]), + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", .upToNextMajor(from: "1.5.5")), ], targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. + .target(name: "Blade"), .target( - name: "Blade"), + name: "BladeTCA", + dependencies: [ + "Blade", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + .testTarget(name: "BladeTests", dependencies: ["Blade"]), .testTarget( - name: "BladeTests", - dependencies: ["Blade"]), + name: "BladeTCATests", + dependencies: [ + "BladeTCA", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), ] ) diff --git a/Package@swift-5.7.swift b/Package@swift-5.7.swift new file mode 100644 index 0000000..e381fcd --- /dev/null +++ b/Package@swift-5.7.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Blade", + platforms: [ + .iOS(.v13), + .macOS(.v11), + .tvOS(.v13), + .watchOS(.v7), + ], + products: [ + .library(name: "Blade", targets: ["Blade"]), + .library(name: "BladeTCA", targets: ["BladeTCA"]), + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", .upToNextMajor(from: "1.5.5")), + ], + targets: [ + .target(name: "Blade"), + .target( + name: "BladeTCA", + dependencies: [ + "Blade", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + .testTarget(name: "BladeTests", dependencies: ["Blade"]), + ] +) diff --git a/Package@swift-5.8.swift b/Package@swift-5.8.swift new file mode 100644 index 0000000..f4caac3 --- /dev/null +++ b/Package@swift-5.8.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.8 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Blade", + platforms: [ + .iOS(.v13), + .macOS(.v11), + .tvOS(.v13), + .watchOS(.v7), + ], + products: [ + .library(name: "Blade", targets: ["Blade"]), + .library(name: "BladeTCA", targets: ["BladeTCA"]), + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", .upToNextMajor(from: "1.5.5")), + ], + targets: [ + .target(name: "Blade"), + .target( + name: "BladeTCA", + dependencies: [ + "Blade", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), + .testTarget(name: "BladeTests", dependencies: ["Blade"]), + ] +) diff --git a/README.md b/README.md index 909b62c..68ca9b2 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,28 @@ +![Blade: a pagination framework that simplifies the integration of pagination into the application](https://raw.githubusercontent.com/space-code/blade/dev/Resources/blade.png) +

blade

-License -Swift Compatibility -Platform Compatibility -CI - -Number of GitHub contributors -Number of GitHub issues that are open -Number of GitHub closed issues -Number of GitHub stars -Number of GitHub pull requests that are open - -GitHub release; latest by date - + License + Swift Compatibility + Platform Compatibility + CI + CodeCov +
+
+ Number of GitHub contributors + Number of GitHub issues that are open + Number of GitHub closed issues + Number of GitHub stars + Number of GitHub pull requests that are open +
+
+ GitHub release; latest by date +

## Description -`Blade` description. +`Blade` is a pagination framework that simplifies the integration of pagination into the application. - [Usage](#usage) - [Requirements](#requirements) @@ -29,8 +34,133 @@ ## Usage +Blade provides two libraries for working with pagination: `Blade` and `BladeTCA`. `BladeTCA` is an extension designed for working with [Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture). Both support working with offset and cursor-based paginations. + +### Basic Usage + +First, you need to implement page loader for whether cursor or offset based pagination: + +```swift +import Blade + +/// Offset pagination loader + +final class OffsetPageLoader: IOffsetPageLoader { + func loadPage(request: OffsetPaginationRequest) async throws -> Page { + // Implementation here + } +} + +/// Cursor pagination loader + +final class CursorPageLoader: ICursorPageLoader { + func loadPage(request: CursorPaginationRequest) async throws -> Page { + // Implementation here + } +} +``` + +Second, create a `Paginator` instance: + +```swift +import Blade + +/// Offset-based pagination +let paginator = Paginator(configuration: PaginationLimitOffset(firstPage: .zero, limit: 20), offsetPageLoader: OffsetPageLoader()) + +/// Cursor-based pagination +let paginator = Paginator(configuration: PaginationCursorSeek(id: #id_here), offsetPageLoader: CursorPageLoader()) +``` + +Third, the paginator is capable of requesting the first page, the next page, and resetting its state, like this: + +```swift +/// Request an initial page +let page = try await paginator.refresh() + +/// Request next page +let nextPage = try await paginator.nextPage() + +/// Reset state +await paginator.reset() +``` + +### TCA Usage + +If your app uses the [Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture), `Blade` can be easily integrated. + +```swift +import BladeTCA + +// MARK: Reducer + +@Reducer +struct SomeFeature { + // MARK: Types + + struct State: Equatable { + var paginator: PaginatorState + } + + enum Action { + case child(PaginatorAction) + } + + // MARK: Reducer + + var body: some ReducerOf { + Reduce { state, action in + switch state { + case .clild: + return .none + } + } + .paginator( + state: \SomeFeature.State.paginator, + action: /SomeFeature.Action.child, + loadPage: { request, state in + // Load page here + } + ) + } +} + +// MARK: View + +import ComposableArchitecture +import DesignKit +import SwiftUI + +// MARK: - SomeView + +struct SomeView: View { + // MARK: Properties + + private let store: StoreOf + + // MARK: Initialization + + init(store: StoreOf) { + self.store = store + } + + // MARK: View + + var body: some View { + PaginatorListView( + store: store.scope(state: \.paginator, action: { .child($0) }) + ) { state in + // Implement UI here + } + } +``` + ## Requirements +- iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 7.0+ / visionOS 1.0+ +- Xcode 15.0 +- Swift 5.7 + ## Installation ### Swift Package Manager @@ -62,4 +192,4 @@ Please feel free to help out with this project! If you see something that could Nikita Vasilev, nv3212@gmail.com ## License -blade is available under the MIT license. See the LICENSE file for more info. \ No newline at end of file +blade is available under the MIT license. See the LICENSE file for more info. diff --git a/Resources/blade.png b/Resources/blade.png new file mode 100644 index 0000000..8ce176a Binary files /dev/null and b/Resources/blade.png differ diff --git a/Sources/Blade/Classes/Blade.swift b/Sources/Blade/Classes/Blade.swift deleted file mode 100644 index af25d31..0000000 --- a/Sources/Blade/Classes/Blade.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// Blade -// Copyright © 2024 Space Code. All rights reserved. -// - -final class Blade {} diff --git a/Sources/Blade/Classes/Core/Paginator/IPaginator.swift b/Sources/Blade/Classes/Core/Paginator/IPaginator.swift new file mode 100644 index 0000000..b921e6d --- /dev/null +++ b/Sources/Blade/Classes/Core/Paginator/IPaginator.swift @@ -0,0 +1,29 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// Protocol defining the interface for a paginator that manages asynchronous loading of paginated data. +public protocol IPaginator { + /// The type of elements the paginator is handling, which must conform to `Decodable` & `Equatable`. + associatedtype Element: Decodable & Equatable + + /// Asynchronously refreshes the paginator, resetting to the first page and fetching new data. + /// + /// - Returns: A `PageInfo` representing the refreshed data. + /// + /// - Throws: An error if there's an issue during the refresh process. + func refresh() async throws -> Page + + /// Asynchronously loads the next page of data. + /// + /// - Returns: A `PageInfo` representing the newly loaded data. + /// + /// - Throws: An error if there's an issue during the loading process. + func loadNextPage() async throws -> Page + + /// Asynchronously resets the paginator, clearing all previously loaded data and resetting the page index. + func reset() async +} diff --git a/Sources/Blade/Classes/Core/Paginator/PageLoader/ICursorPageLoader.swift b/Sources/Blade/Classes/Core/Paginator/PageLoader/ICursorPageLoader.swift new file mode 100644 index 0000000..1424f7e --- /dev/null +++ b/Sources/Blade/Classes/Core/Paginator/PageLoader/ICursorPageLoader.swift @@ -0,0 +1,19 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +public protocol ICursorPageLoader { + /// The type of elements the paginator is handling, which must conform to `Decodable` & `Equatable`. + associatedtype Element: Decodable & Equatable & Identifiable + + /// Loads a page of elements based on the provided pagination request asynchronously. + /// + /// - Parameters: + /// - request: A `LimitPageRequest` specifying the limit and offset for the requested page. + /// + /// - Returns: An asynchronous task representing the loading process, resolving to a `Page` of elements. + func loadPage(request: CursorPaginationRequest) async throws -> Page +} diff --git a/Sources/Blade/Classes/Core/Paginator/PageLoader/IOffsetPageLoader.swift b/Sources/Blade/Classes/Core/Paginator/PageLoader/IOffsetPageLoader.swift new file mode 100644 index 0000000..140a149 --- /dev/null +++ b/Sources/Blade/Classes/Core/Paginator/PageLoader/IOffsetPageLoader.swift @@ -0,0 +1,19 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +public protocol IOffsetPageLoader { + /// The type of elements the paginator is handling, which must conform to `Decodable` & `Equatable`. + associatedtype Element: Decodable & Equatable + + /// Loads a page of elements based on the provided pagination request asynchronously. + /// + /// - Parameters: + /// - request: A `OffsetPaginationRequest` specifying the limit and offset for the requested page. + /// + /// - Returns: An asynchronous task representing the loading process, resolving to a `Page` of elements. + func loadPage(request: OffsetPaginationRequest) async throws -> Page +} diff --git a/Sources/Blade/Classes/Core/Paginator/Paginator.swift b/Sources/Blade/Classes/Core/Paginator/Paginator.swift new file mode 100644 index 0000000..32789c5 --- /dev/null +++ b/Sources/Blade/Classes/Core/Paginator/Paginator.swift @@ -0,0 +1,98 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - Paginator + +/// Paginator is an actor responsible for paginating and loading data using a provided paginator service. +public actor Paginator { + // MARK: Types + + /// Enum representing errors that may occur during pagination. + public enum Error: Swift.Error { + case alreadyLoading + } + + // MARK: Properties + + /// Internal flag to track whether the paginator is currently loading data. + private var isLoadingInternal = false + + /// Public property to check if the paginator is currently loading. + private(set) var isLoading: Bool { + get { Task.isCancelled || isLoadingInternal } + set { isLoadingInternal = newValue } + } + + /// An array to store the loaded elements. + public private(set) var elements: [T] = [] + + private let paginationStrategy: any IPaginationStrategy + + // MARK: Initialization + + /// Initializes the Paginator with the provided first page number and paginator service. + public init( + configuration: PaginationLimitOffset, + offsetPageLoader: any IOffsetPageLoader + ) { + paginationStrategy = LimitOffsetStrategy( + configuration: configuration, + pageLoader: offsetPageLoader + ) + } + + public init( + configuration: PaginationCursorSeek, + cursorPageLoader: any ICursorPageLoader + ) where T: Identifiable { + paginationStrategy = CursorSeekStrategy( + configuration: configuration, + pageLoader: cursorPageLoader + ) + } + + // MARK: Private + + private func perform(_ task: @autoclosure () async throws -> Page) async throws -> Page { + guard !isLoadingInternal else { throw Error.alreadyLoading } + + isLoadingInternal = true + defer { isLoadingInternal = false } + + return try await task() + } +} + +// MARK: IPaginator + +extension Paginator: IPaginator { + public func refresh() async throws -> Page { + try await perform( + await { + let page = try await paginationStrategy.refresh() + elements = page.items + return page + }() + ) + } + + public func loadNextPage() async throws -> Page { + try await perform( + await { + let page = try await paginationStrategy.loadNextPage() + elements += page.items + return page + }() + ) + } + + public func reset() async { + await paginationStrategy.reset() + elements = [] + isLoadingInternal = false + } +} diff --git a/Sources/Blade/Classes/Core/Paginator/Strategies/CursorSeekStrategy.swift b/Sources/Blade/Classes/Core/Paginator/Strategies/CursorSeekStrategy.swift new file mode 100644 index 0000000..804dd4e --- /dev/null +++ b/Sources/Blade/Classes/Core/Paginator/Strategies/CursorSeekStrategy.swift @@ -0,0 +1,53 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - CursorSeekStrategy + +actor CursorSeekStrategy { + // MARK: Properties + + private let configuration: PaginationCursorSeek + private let pageLoader: any ICursorPageLoader + + private var id: Element.ID + + // MARK: Initialization + + init( + configuration: PaginationCursorSeek, + pageLoader: any ICursorPageLoader + ) { + self.configuration = configuration + self.pageLoader = pageLoader + id = configuration.id + } +} + +// MARK: IPaginationStrategy + +extension CursorSeekStrategy: IPaginationStrategy { + func refresh() async throws -> Page { + let page = try await pageLoader.loadPage(request: CursorPaginationRequest(id: id)) + return page + } + + func loadNextPage() async throws -> Page { + let page = try await pageLoader.loadPage(request: CursorPaginationRequest(id: id)) + + guard let lastID = page.items.last?.id else { + throw URLError(.unknown) + } + + id = lastID + + return page + } + + func reset() async { + id = configuration.id + } +} diff --git a/Sources/Blade/Classes/Core/Paginator/Strategies/LimitOffsetStrategy.swift b/Sources/Blade/Classes/Core/Paginator/Strategies/LimitOffsetStrategy.swift new file mode 100644 index 0000000..e3bfc41 --- /dev/null +++ b/Sources/Blade/Classes/Core/Paginator/Strategies/LimitOffsetStrategy.swift @@ -0,0 +1,47 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - LimitOffsetStrategy + +actor LimitOffsetStrategy { + // MARK: Properties + + private let configuration: PaginationLimitOffset + private let pageLoader: any IOffsetPageLoader + + private var currentPage: Int + + // MARK: Initialization + + init(configuration: PaginationLimitOffset, pageLoader: any IOffsetPageLoader) { + self.configuration = configuration + self.pageLoader = pageLoader + currentPage = configuration.firstPage + } +} + +// MARK: IPaginationStrategy + +extension LimitOffsetStrategy: IPaginationStrategy { + func refresh() async throws -> Page { + currentPage = configuration.firstPage + let page = try await pageLoader.loadPage(request: OffsetPaginationRequest(limit: configuration.limit, offset: .zero)) + currentPage += 1 + return page + } + + func loadNextPage() async throws -> Page { + let page = try await pageLoader.loadPage(request: OffsetPaginationRequest( + limit: configuration.limit, + offset: configuration.limit * (currentPage + 1) + )) + currentPage += 1 + return page + } + + func reset() async {} +} diff --git a/Sources/Blade/Classes/Core/Paginator/Strategies/Protocols/IPaginationStrategy.swift b/Sources/Blade/Classes/Core/Paginator/Strategies/Protocols/IPaginationStrategy.swift new file mode 100644 index 0000000..a0f6e86 --- /dev/null +++ b/Sources/Blade/Classes/Core/Paginator/Strategies/Protocols/IPaginationStrategy.swift @@ -0,0 +1,8 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +protocol IPaginationStrategy: IPaginator where Element: Decodable & Equatable {} diff --git a/Sources/Blade/Classes/Models/Configuration/PaginationCursorSeek.swift b/Sources/Blade/Classes/Models/Configuration/PaginationCursorSeek.swift new file mode 100644 index 0000000..dd57163 --- /dev/null +++ b/Sources/Blade/Classes/Models/Configuration/PaginationCursorSeek.swift @@ -0,0 +1,18 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +public struct PaginationCursorSeek { + // MARK: Properties + + public let id: T.ID + + // MARK: Initialization + + public init(id: T.ID) { + self.id = id + } +} diff --git a/Sources/Blade/Classes/Models/Configuration/PaginationLimitOffset.swift b/Sources/Blade/Classes/Models/Configuration/PaginationLimitOffset.swift new file mode 100644 index 0000000..0ce0315 --- /dev/null +++ b/Sources/Blade/Classes/Models/Configuration/PaginationLimitOffset.swift @@ -0,0 +1,20 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +public struct PaginationLimitOffset { + // MARK: Properties + + public let firstPage: Int + public let limit: Int + + // MARK: Initialization + + public init(firstPage: Int, limit: Int) { + self.firstPage = firstPage + self.limit = limit + } +} diff --git a/Sources/Blade/Classes/Models/Page.swift b/Sources/Blade/Classes/Models/Page.swift new file mode 100644 index 0000000..5b99678 --- /dev/null +++ b/Sources/Blade/Classes/Models/Page.swift @@ -0,0 +1,30 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// A generic struct representing a paginated collection of items. +public struct Page: Equatable { + // MARK: Properties + + /// An array of items of generic type T contained in the current page. + public let items: [T] + + /// A boolean flag indicating whether there are more data available beyond + /// the current page. + public let hasMoreData: Bool + + // MARK: Initialization + + /// Creates a `Page` instance. + /// + /// - Parameters: + /// - items: An array of items of generic type T contained in the current page. + /// - hasMoreData: A boolean flag indicating whether there are more data available beyond the current page. + public init(items: [T], hasMoreData: Bool) { + self.items = items + self.hasMoreData = hasMoreData + } +} diff --git a/Sources/Blade/Classes/Models/Requests/CursorPaginationRequest.swift b/Sources/Blade/Classes/Models/Requests/CursorPaginationRequest.swift new file mode 100644 index 0000000..eea7eb3 --- /dev/null +++ b/Sources/Blade/Classes/Models/Requests/CursorPaginationRequest.swift @@ -0,0 +1,18 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +public struct CursorPaginationRequest: Equatable { + // MARK: Properties + + public let id: T.ID + + // MARK: Initialization + + public init(id: T.ID) { + self.id = id + } +} diff --git a/Sources/Blade/Classes/Models/Requests/OffsetPaginationRequest.swift b/Sources/Blade/Classes/Models/Requests/OffsetPaginationRequest.swift new file mode 100644 index 0000000..ff30950 --- /dev/null +++ b/Sources/Blade/Classes/Models/Requests/OffsetPaginationRequest.swift @@ -0,0 +1,31 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// A struct representing a request for paginated data with specified limits and offsets. +public struct OffsetPaginationRequest: Equatable { + // MARK: Properties + + /// The maximum number of items to be included in a page. + public let limit: Int + + /// The offset indicating the position of the first item in the requested page + /// relative to the entire dataset. + public let offset: Int + + // MARK: Initialization + + /// Creates a ``OffsetPaginationRequest`` instance. + /// + /// - Parameters: + /// - limit: The maximum number of items to be included in a page. + /// - offset: The offset indicating the position of the first item in the requested page + /// relative to the entire dataset. + public init(limit: Int, offset: Int) { + self.limit = limit + self.offset = offset + } +} diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/CursorPositionBuilderStrategy.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/CursorPositionBuilderStrategy.swift new file mode 100644 index 0000000..f2dd580 --- /dev/null +++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/CursorPositionBuilderStrategy.swift @@ -0,0 +1,18 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// A cursor-based paginator position builder. +struct CursorPositionBuilderStrategy: IPositionBuilderStrategy { + /// Creates a next position. + /// + /// - Parameter state: The current state of the paginator. + /// + /// - Returns: The next position offset based on the strategy. + func next(state: PaginatorState) -> State.ID { + state.items.last?.id ?? state.position + } +} diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/OffsetPositionBuilderStrategy.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/OffsetPositionBuilderStrategy.swift new file mode 100644 index 0000000..418fabe --- /dev/null +++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/OffsetPositionBuilderStrategy.swift @@ -0,0 +1,18 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Blade + +/// A offset-based paginator position builder. +struct OffsetPositionBuilderStrategy: IPositionBuilderStrategy { + /// Creates a next position. + /// + /// - Parameter state: The current state of the paginator. + /// + /// - Returns: The next position offset based on the strategy. + func next(state: PaginatorState) -> Int { + state.items.count + } +} diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/Protocols/IPositionBuilderStrategy.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/Protocols/IPositionBuilderStrategy.swift new file mode 100644 index 0000000..3f85ff6 --- /dev/null +++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/PositionBuilders/Protocols/IPositionBuilderStrategy.swift @@ -0,0 +1,19 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// This protocol defines the interface for a strategy used in building positions based on a given state. +protocol IPositionBuilderStrategy { + associatedtype State: Equatable + associatedtype PositionType: Equatable + + /// Takes a state as input and returns the corresponding position. + /// + /// - Parameter state: The state for which the next position needs to be built. + /// + /// - Returns: The resulting position based on the provided state. + func next(state: State) -> PositionType +} diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/CursorRequestBuilderStrategy.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/CursorRequestBuilderStrategy.swift new file mode 100644 index 0000000..d1ca2fc --- /dev/null +++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/CursorRequestBuilderStrategy.swift @@ -0,0 +1,20 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Blade + +/// A request builder strategy for cursor-based pagination. +struct CursorRequestBuilderStrategy: IRequestBuilderStrategy { + // MARK: IRequestBuilderStrategy + + /// Constructs a pagination request based on the provided state. + /// + /// - Parameter state: The current state of the paginator. + /// + /// - Returns: A CursorPaginationRequest with the cursor position from the provided state. + func makeRequest(state: PaginatorState) -> CursorPaginationRequest { + CursorPaginationRequest(id: state.position) + } +} diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/OffsetRequestBuilderStrategy.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/OffsetRequestBuilderStrategy.swift new file mode 100644 index 0000000..1fc7aea --- /dev/null +++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/OffsetRequestBuilderStrategy.swift @@ -0,0 +1,35 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Blade +import Foundation + +/// A request builder strategy for offset-based pagination. +struct OffsetRequestBuilderStrategy: IRequestBuilderStrategy { + // MARK: Properties + + /// The maximum number of items to be included in the pagination request. + private let limit: Int + + // MARK: Initialization + + /// Initializes the OffsetRequestBuilderStrategy with a specified limit. + /// + /// - Parameter limit: The maximum number of items to be included in each pagination request. + init(limit: Int) { + self.limit = limit + } + + // MARK: IRequestBuilderStrategy + + /// Constructs a pagination request based on the provided state. + /// + /// - Parameter state: The current state of the paginator. + /// + /// - Returns: An OffsetPaginationRequest with the offset position and specified limit from the provided state. + func makeRequest(state: PaginatorState) -> OffsetPaginationRequest { + OffsetPaginationRequest(limit: limit, offset: state.position) + } +} diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/Protocols/IRequestBuilderStrategy.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/Protocols/IRequestBuilderStrategy.swift new file mode 100644 index 0000000..c11a87b --- /dev/null +++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/Builders/RequestBuilders/Protocols/IRequestBuilderStrategy.swift @@ -0,0 +1,22 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +/// A protocol for defining request builder strategies in a paginator. +protocol IRequestBuilderStrategy { + /// The state type associated with the strategy, conforming to Equatable. + associatedtype State: Equatable + + /// The request type associated with the strategy, conforming to Equatable. + associatedtype Request: Equatable + + /// Makes a request. + /// + /// - Parameter state: The current state for which a request needs to be built. + /// + /// - Returns: The constructed request based on the provided state. + func makeRequest(state: State) -> Request +} diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/PaginatorIntegrationReducer.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/PaginatorIntegrationReducer.swift new file mode 100644 index 0000000..a05aa9a --- /dev/null +++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/PaginatorIntegrationReducer.swift @@ -0,0 +1,50 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Blade +import ComposableArchitecture + +struct PaginatorIntegrationReducer< + Parent: Reducer, + State: Equatable & Identifiable, + Action: Equatable, + PositionType: Equatable, + Request: Equatable +>: Reducer { + // MARK: Properties + + let parent: Parent + let childState: WritableKeyPath> + let childAction: AnyCasePath> + let loadPage: @Sendable (Request, Parent.State) async throws -> Page + let requestBuilderStrategy: any IRequestBuilderStrategy, Request> + let positionBuilderStrategy: any IPositionBuilderStrategy, PositionType> + + private enum CancelID { case requestPage } + + // MARK: Reducer + + var body: some Reducer { + Scope(state: childState, action: childAction) { + PaginatorReducer( + requestBuilder: requestBuilderStrategy, + positionBuilder: positionBuilderStrategy + ) + } + + Reduce { state, action in + guard let paginatorAction = childAction.extract(from: action), + case let .requestPage(pageRequest) = paginatorAction + else { + return parent.reduce(into: &state, action: action) + } + + return .run { [state] send in + await send(childAction.embed(.response(TaskResult { try await loadPage(pageRequest, state) }))) + } + .cancellable(id: CancelID.requestPage, cancelInFlight: true) + } + } +} diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Internal/PaginatorReducer.swift b/Sources/BladeTCA/Classes/COre/Reducers/Internal/PaginatorReducer.swift new file mode 100644 index 0000000..0797a40 --- /dev/null +++ b/Sources/BladeTCA/Classes/COre/Reducers/Internal/PaginatorReducer.swift @@ -0,0 +1,71 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Blade +import ComposableArchitecture + +struct PaginatorReducer< + State: Equatable & Identifiable, + Action: Equatable, + PositionType: Equatable, + Request: Equatable +>: Reducer { + // MARK: Types + + typealias State = PaginatorState + typealias Action = PaginatorAction + + // MARK: Properties + + private let requestBuilder: any IRequestBuilderStrategy + private let positionBuilder: any IPositionBuilderStrategy + + // MARK: Initialization + + init( + requestBuilder: any IRequestBuilderStrategy, + positionBuilder: any IPositionBuilderStrategy + ) { + self.requestBuilder = requestBuilder + self.positionBuilder = positionBuilder + } + + // MARK: Reducer + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .itemAppeared(id): + guard state.items.last?.id == id else { return .none } + return fetchNextPage(&state) + case .requestPage: + state.isLoading = true + return fetchNextPage(&state) + case let .response(.success(page)): + state.isLoading = false + + state.items.append(contentsOf: page.items) + state.hasMoreData = page.hasMoreData + + state.position = positionBuilder.next(state: state) + + return .none + case .response(.failure): + state.isLoading = false + return .none + } + } + } + + // MARK: Private + + private func fetchNextPage(_ state: inout Self.State) -> Effect { + guard !state.isLoading, state.hasMoreData else { return .none } + state.isLoading = true + + let request: Request = requestBuilder.makeRequest(state: state) + return .send(.requestPage(request)) + } +} diff --git a/Sources/BladeTCA/Classes/COre/Reducers/Reducer+.swift b/Sources/BladeTCA/Classes/COre/Reducers/Reducer+.swift new file mode 100644 index 0000000..093485e --- /dev/null +++ b/Sources/BladeTCA/Classes/COre/Reducers/Reducer+.swift @@ -0,0 +1,60 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Blade +import ComposableArchitecture + +// MARK: - Reducer Extension for Paginator Integration + +/// An extension on the `Reducer` type providing a method for integrating a paginator into a Composable Architecture. +public extension Reducer { + /// Integrates a paginator into a Composable Architecture, facilitating paginated data loading. + /// + /// - Parameters: + /// - limit: The number of items to load per page, with a default value of 20. + /// - state: The key path to the paginator state. + /// - action: The case path to the paginator actions. + /// - loadPage: A closure to load a page of items based on the provided `LimitPageRequest` and current state. + /// + /// - Returns: A reducer for integrating the paginator functionality. + func paginator( + limit: Int = 20, + state: WritableKeyPath>, + action: AnyCasePath>, + loadPage: @Sendable @escaping (OffsetPaginationRequest, State) async throws -> Page + ) -> some Reducer { + PaginatorIntegrationReducer( + parent: self, + childState: state, + childAction: action, + loadPage: loadPage, + requestBuilderStrategy: OffsetRequestBuilderStrategy(limit: limit), + positionBuilderStrategy: OffsetPositionBuilderStrategy() + ) + } + + /// Integrates a paginator into a Composable Architecture, facilitating paginated data loading with cursor-based pagination. + /// + /// - Parameters: + /// - state: The key path to the paginator state. + /// - action: The case path to the paginator actions. + /// - loadPage: A closure to load a page of items based on the provided `CursorPaginationRequest` and current state. + /// + /// - Returns: A reducer for integrating the paginator functionality. + func paginator( + state: WritableKeyPath>, + action: AnyCasePath>>, + loadPage: @Sendable @escaping (CursorPaginationRequest, State) async throws -> Page + ) -> some Reducer { + PaginatorIntegrationReducer( + parent: self, + childState: state, + childAction: action, + loadPage: loadPage, + requestBuilderStrategy: CursorRequestBuilderStrategy(), + positionBuilderStrategy: CursorPositionBuilderStrategy() + ) + } +} diff --git a/Sources/BladeTCA/Classes/Models/PaginatorAction.swift b/Sources/BladeTCA/Classes/Models/PaginatorAction.swift new file mode 100644 index 0000000..5a9ad48 --- /dev/null +++ b/Sources/BladeTCA/Classes/Models/PaginatorAction.swift @@ -0,0 +1,32 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Blade +import ComposableArchitecture +import Foundation + +// MARK: - PaginatorAction + +/// Represents the actions that can be performed on a paginator in a Composable Architecture. +/// +/// - Parameters: +/// - State: The type of state managed by the paginator. +/// - Action: The type of actions that can be associated with the paginator. +public enum PaginatorAction< + State: Equatable & Identifiable, + Action: Equatable, + Request: Equatable +>: Equatable { + // MARK: Action Cases + + /// Indicates that an item with the specified identifier has appeared in the UI. + case itemAppeared(State.ID) + + /// Represents a request to load the next page of items using the provided `RequestType`. + case requestPage(Request) + + /// Represents the response to a page request, containing the result of the operation. + case response(TaskResult>) +} diff --git a/Sources/BladeTCA/Classes/Models/State/CursorPaginatorState.swift b/Sources/BladeTCA/Classes/Models/State/CursorPaginatorState.swift new file mode 100644 index 0000000..ac4a397 --- /dev/null +++ b/Sources/BladeTCA/Classes/Models/State/CursorPaginatorState.swift @@ -0,0 +1,36 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import ComposableArchitecture + +/// Represents the state of a paginator for cursor-based pagination. +public struct CursorPaginatorState: Equatable, IPaginatorState { + // MARK: Properties + + /// The array of identifiable items managed by the paginator. + public var items: IdentifiedArrayOf + + /// A Boolean value indicating whether the paginator is currently loading more data. + var isLoading: Bool + + /// The offset or position in the data set from where the paginator should load more items. + var id: State.ID + + /// A Boolean value indicating whether there is more data available to be loaded. + var hasMoreData: Bool + + // MARK: Initialization + + /// Initializes a paginator state with an array of identifiable items. + /// + /// - Parameters: + /// - items: The array of identifiable items to be managed by the paginator. + public init(items: IdentifiedArrayOf, id: State.ID) { + self.items = items + isLoading = false + hasMoreData = true + self.id = id + } +} diff --git a/Sources/BladeTCA/Classes/Models/State/PaginatorState.swift b/Sources/BladeTCA/Classes/Models/State/PaginatorState.swift new file mode 100644 index 0000000..e9d4285 --- /dev/null +++ b/Sources/BladeTCA/Classes/Models/State/PaginatorState.swift @@ -0,0 +1,39 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import ComposableArchitecture +import Foundation + +// MARK: - OffsetPaginatorState + +/// Represents the state of a paginator for cursor-based pagination. +public struct PaginatorState: Equatable, IPaginatorState { + // MARK: Properties + + /// The array of identifiable items managed by the paginator. + public var items: IdentifiedArrayOf + + /// A Boolean value indicating whether the paginator is currently loading more data. + var isLoading: Bool + + /// The offset or position in the data set from where the paginator should load more items. + var position: T + + /// A Boolean value indicating whether there is more data available to be loaded. + var hasMoreData: Bool + + // MARK: Initialization + + /// Initializes a paginator state with an array of identifiable items. + /// + /// - Parameters: + /// - items: The array of identifiable items to be managed by the paginator. + public init(items: IdentifiedArrayOf, position: T) { + self.items = items + isLoading = false + hasMoreData = true + self.position = position + } +} diff --git a/Sources/BladeTCA/Classes/Models/State/Protocols/IPaginatorState.swift b/Sources/BladeTCA/Classes/Models/State/Protocols/IPaginatorState.swift new file mode 100644 index 0000000..a543def --- /dev/null +++ b/Sources/BladeTCA/Classes/Models/State/Protocols/IPaginatorState.swift @@ -0,0 +1,14 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import ComposableArchitecture + +protocol IPaginatorState { + associatedtype Element: Equatable & Identifiable + + var items: IdentifiedArrayOf { get set } + var isLoading: Bool { get set } + var hasMoreData: Bool { get set } +} diff --git a/Sources/BladeTCA/Classes/Presentation/ViewModifiers/LoadingViewModifier.swift b/Sources/BladeTCA/Classes/Presentation/ViewModifiers/LoadingViewModifier.swift new file mode 100644 index 0000000..ae29da8 --- /dev/null +++ b/Sources/BladeTCA/Classes/Presentation/ViewModifiers/LoadingViewModifier.swift @@ -0,0 +1,33 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import SwiftUI + +struct LoadingViewModifier: ViewModifier { + // MARK: Properties + + let isLoading: Bool + + // MARK: ViewModifier + + func body(content: Content) -> some View { + VStack { + content + + if isLoading { + progressView + } + } + } + + // MARK: Private + + private var progressView: some View { + if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) { + return ProgressView().progressViewStyle(.circular) + } + return EmptyView() + } +} diff --git a/Sources/BladeTCA/Classes/Presentation/Views/PaginatorForEachView.swift b/Sources/BladeTCA/Classes/Presentation/Views/PaginatorForEachView.swift new file mode 100644 index 0000000..865ed87 --- /dev/null +++ b/Sources/BladeTCA/Classes/Presentation/Views/PaginatorForEachView.swift @@ -0,0 +1,46 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import ComposableArchitecture +import SwiftUI + +public struct PaginatorForEachView< + State: Equatable & Identifiable, + Action: Equatable, + Body: View, + PositionType: Equatable, + Request: Equatable +>: View { + // MARK: Types + + private typealias StoreType = ViewStoreOf> + + // MARK: Properties + + public let store: Store, PaginatorAction> + public let content: (State) -> Body + + public init( + store: Store, PaginatorAction>, + content: @escaping (State) -> Body + ) { + self.store = store + self.content = content + } + + // MARK: View + + public var body: some View { + WithViewStore(store, observe: { $0 }) { (viewStore: StoreType) in + ForEach(viewStore.items) { item in + content(item) + .onAppear { + viewStore.send(.itemAppeared(item.id)) + } + } + .modifier(LoadingViewModifier(isLoading: viewStore.isLoading && !viewStore.items.isEmpty)) + } + } +} diff --git a/Sources/BladeTCA/Classes/Presentation/Views/PaginatorListView.swift b/Sources/BladeTCA/Classes/Presentation/Views/PaginatorListView.swift new file mode 100644 index 0000000..9ff9cca --- /dev/null +++ b/Sources/BladeTCA/Classes/Presentation/Views/PaginatorListView.swift @@ -0,0 +1,47 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import BladeTCA +import ComposableArchitecture +import SwiftUI + +public struct PaginatorListView< + State: Equatable & Identifiable, + Action: Equatable, + Body: View, + PositionType: Equatable, + Request: Equatable +>: View { + // MARK: Types + + private typealias StoreType = ViewStoreOf> + + // MARK: Properties + + public let store: Store, PaginatorAction> + public let content: (State) -> Body + + public init( + store: Store, PaginatorAction>, + content: @escaping (State) -> Body + ) { + self.store = store + self.content = content + } + + // MARK: View + + public var body: some View { + WithViewStore(store, observe: { $0 }) { (viewStore: StoreType) in + List(viewStore.items) { item in + content(item) + .onAppear { + viewStore.send(.itemAppeared(item.id)) + } + } + .modifier(LoadingViewModifier(isLoading: viewStore.isLoading && !viewStore.items.isEmpty)) + } + } +} diff --git a/Sources/BladeTCA/Classes/Presentation/Views/PaginatorView.swift b/Sources/BladeTCA/Classes/Presentation/Views/PaginatorView.swift new file mode 100644 index 0000000..fadfde8 --- /dev/null +++ b/Sources/BladeTCA/Classes/Presentation/Views/PaginatorView.swift @@ -0,0 +1,62 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import BladeTCA +import ComposableArchitecture +import SwiftUI + +// MARK: - PaginatorView + +public struct PaginatorView< + State: Equatable & Identifiable, + Action: Equatable, + PositionType: Equatable, + Request: Equatable, + Body: View, + RowContent: View +>: View { + // MARK: Types + + private typealias StoreType = ViewStoreOf> + + // MARK: Properties + + public let store: Store, PaginatorAction> + public let content: ([State], @escaping (State) -> AnyView) -> Body + public let rowContent: (State) -> RowContent + + // MARK: Initialization + + public init( + store: Store, PaginatorAction>, + @ViewBuilder content: @escaping ([State], @escaping (State) -> AnyView) -> Body, + @ViewBuilder rowContent: @escaping (State) -> RowContent + ) { + self.store = store + self.content = content + self.rowContent = rowContent + } + + // MARK: View + + public var body: some View { + WithViewStore(store, observe: { $0 }) { (viewStore: StoreType) in + content(viewStore.items.elements) { item in + rowContent(item) + .onAppear { viewStore.send(.itemAppeared(item.id)) } + .any + } + .modifier(LoadingViewModifier(isLoading: viewStore.isLoading && !viewStore.items.isEmpty)) + } + } +} + +// MARK: Extension + +private extension View { + var any: AnyView { + AnyView(self) + } +} diff --git a/Tests/BladePackage.xctestplan b/Tests/BladePackage.xctestplan new file mode 100644 index 0000000..3033db4 --- /dev/null +++ b/Tests/BladePackage.xctestplan @@ -0,0 +1,44 @@ +{ + "configurations" : [ + { + "id" : "9BC7C122-0678-48E3-A9A8-0D03B540050E", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:", + "identifier" : "Blade", + "name" : "Blade" + }, + { + "containerPath" : "container:", + "identifier" : "BladeTCA", + "name" : "BladeTCA" + } + ] + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "BladeTCATests", + "name" : "BladeTCATests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "BladeTests", + "name" : "BladeTests" + } + } + ], + "version" : 1 +} diff --git a/Tests/BladeTCATests/Helpers/IdentifiedArray+Items.swift b/Tests/BladeTCATests/Helpers/IdentifiedArray+Items.swift new file mode 100644 index 0000000..4fffc34 --- /dev/null +++ b/Tests/BladeTCATests/Helpers/IdentifiedArray+Items.swift @@ -0,0 +1,14 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import ComposableArchitecture +import Foundation + +extension IdentifiedArray where Element == TestItem, ID == UUID { + static func items(count: Int) -> IdentifiedArray { + let elements: [Element] = Array(0 ..< count).map { _ in Element() } + return IdentifiedArray(uniqueElements: elements) + } +} diff --git a/Tests/BladeTCATests/Models/TestItem.swift b/Tests/BladeTCATests/Models/TestItem.swift new file mode 100644 index 0000000..35dbef3 --- /dev/null +++ b/Tests/BladeTCATests/Models/TestItem.swift @@ -0,0 +1,28 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +// MARK: - TestItem + +struct TestItem: Equatable, Identifiable { + // MARK: Properties + + let id: UUID + + // MARK: Initialization + + init(id: UUID = UUID()) { + self.id = id + } +} + +// MARK: - UUID + Identifiable + +extension UUID: Identifiable { + public var id: Self { + self + } +} diff --git a/Tests/BladeTCATests/UnitTests/PaginatorIntegrationReducerTests.swift b/Tests/BladeTCATests/UnitTests/PaginatorIntegrationReducerTests.swift new file mode 100644 index 0000000..0fe9934 --- /dev/null +++ b/Tests/BladeTCATests/UnitTests/PaginatorIntegrationReducerTests.swift @@ -0,0 +1,108 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Blade +@testable import BladeTCA +import ComposableArchitecture +import XCTest + +// MARK: - PaginatorIntegrationReducerTests + +@MainActor +final class PaginatorIntegrationReducerTests: XCTestCase { + // MARK: Tests + + func test_thatPaginatorUpdatesData_whenRequestDidCompleteSuccessfully() async { + let items: IdentifiedArray = .items(count: .limit) + let pageResponse = Page(items: items.elements, hasMoreData: true) + + let store = TestStore( + initialState: PaginatorIntegrationReducer + .State(paginator: .init(items: items, position: .limit)) + ) { + PaginatorIntegrationReducer( + parent: TestReducer(), + childState: \TestReducer.State.paginator, + childAction: /TestReducer.Action.child, + loadPage: { _, _ in pageResponse }, + requestBuilderStrategy: OffsetRequestBuilderStrategy(limit: .limit), + positionBuilderStrategy: OffsetPositionBuilderStrategy() + ) + } + + // 1. Last item did appear + await store.send(.child(.itemAppeared(items[items.count - 1].id))) { + $0.paginator.isLoading = true + } + + // 2. Send a request + await store.receive(.child(.requestPage(OffsetPaginationRequest(limit: .limit, offset: .limit)))) + + // 3. Receive a response + await store.receive(.child(.response(.success(pageResponse)))) { + $0.paginator.isLoading = false + $0.paginator.hasMoreData = true + $0.paginator.position = .limit + } + } + + func test_thatPaginatorDoesNotUpdateData_whenRequestDidFailed() async { + let items: IdentifiedArray = .items(count: .limit) + + let store = TestStore( + initialState: PaginatorIntegrationReducer + .State(paginator: .init(items: items, position: .limit)) + ) { + PaginatorIntegrationReducer( + parent: TestReducer(), + childState: \TestReducer.State.paginator, + childAction: /TestReducer.Action.child, + loadPage: { _, _ in throw URLError(.unknown) }, + requestBuilderStrategy: OffsetRequestBuilderStrategy(limit: .limit), + positionBuilderStrategy: OffsetPositionBuilderStrategy() + ) + } + + await store.send(.child(.itemAppeared(items[items.count - 1].id))) { + $0.paginator.isLoading = true + } + + await store.receive(.child(.requestPage(OffsetPaginationRequest(limit: .limit, offset: .limit)))) + await store.receive(.child(.response(.failure(URLError(.unknown))))) { + $0.paginator.isLoading = false + $0.paginator.hasMoreData = true + } + } +} + +// MARK: PaginatorIntegrationReducerTests.TestReducer + +private extension PaginatorIntegrationReducerTests { + @Reducer + struct TestReducer { + struct State: Equatable { + var paginator: PaginatorState + } + + enum Action: Equatable { + case child(BladeTCA.PaginatorAction) + } + + var body: some ReducerOf { + Reduce { _, action in + switch action { + case .child: + return .none + } + } + } + } +} + +// MARK: - Constants + +private extension Int { + static let limit = 10 +} diff --git a/Tests/BladeTCATests/UnitTests/PaginatorReducerTests.swift b/Tests/BladeTCATests/UnitTests/PaginatorReducerTests.swift new file mode 100644 index 0000000..dd4a1bb --- /dev/null +++ b/Tests/BladeTCATests/UnitTests/PaginatorReducerTests.swift @@ -0,0 +1,105 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Blade +@testable import BladeTCA +import ComposableArchitecture +import XCTest + +// MARK: - PaginatorReducerTests + +@MainActor +final class PaginatorReducerTests: XCTestCase { + // MARK: Types + + private typealias PaginatorAction = BladeTCA.PaginatorAction + + // MARK: Tests + + func test_thatPaginatorOffsetRequestsNextPage_whenLastItemDidAppear() async { + let items: IdentifiedArray = .items(count: .limit) + + let store = TestStore( + initialState: PaginatorReducer.State( + items: items, position: .limit + ) + ) { + PaginatorReducer( + requestBuilder: OffsetRequestBuilderStrategy(limit: .limit), + positionBuilder: OffsetPositionBuilderStrategy() + ) + } + + await store.send(.itemAppeared(items[.limit - 1].id)) { + $0.isLoading = true + } + + await store.receive(.requestPage(OffsetPaginationRequest(limit: .limit, offset: .limit))) + } + + func test_thatPaginatorOffsetDoesNotRequestNextPage_whenLastItemDidNotAppear() async { + let items: IdentifiedArray = .items(count: .limit) + + let store = TestStore(initialState: PaginatorReducer.State( + items: items, + position: .zero + )) { + PaginatorReducer( + requestBuilder: OffsetRequestBuilderStrategy(limit: .limit), + positionBuilder: OffsetPositionBuilderStrategy() + ) + } + + await store.send(.itemAppeared(items[0].id)) + } + + func test_thatPaginatorCursorRequestsNextPage_whenLastItemDidAppear() async throws { + let items: IdentifiedArray = .items(count: .limit) + let id = try XCTUnwrap(items.last?.id) + + let store = TestStore( + initialState: PaginatorReducer>.State( + items: items, position: id + ) + ) { + PaginatorReducer>( + requestBuilder: CursorRequestBuilderStrategy(), + positionBuilder: CursorPositionBuilderStrategy() + ) + } + + await store.send(.itemAppeared(items[.limit - 1].id)) { + $0.isLoading = true + } + + await store.receive(.requestPage(CursorPaginationRequest(id: id))) + } + + func test_thatPaginatorCursorDoesNotRequestNextPage_whenLastItemDidNotAppear() async { + let items: IdentifiedArray = .items(count: .limit) + + let store = TestStore( + initialState: PaginatorReducer>.State( + items: items, + position: items[0].id + ) + ) { + PaginatorReducer>( + requestBuilder: CursorRequestBuilderStrategy(), + positionBuilder: CursorPositionBuilderStrategy() + ) + } + + await store.send(.itemAppeared(items[0].id)) + } +} + +// MARK: - Constants + +private extension Int { + static let limit = 10 +} + +/// https://engineering.monstar-lab.com/en/post/2023/10/26/The-Composable-architecture-and-TDD/ diff --git a/Tests/BladeTests/BladeTests.swift b/Tests/BladeTests/BladeTests.swift deleted file mode 100644 index e3f7faa..0000000 --- a/Tests/BladeTests/BladeTests.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Blade -// Copyright © 2024 Space Code. All rights reserved. -// - -import XCTest - -final class BladeTests: XCTestCase {} diff --git a/Tests/BladeTests/Mocks/CursorPageLoaderMock.swift b/Tests/BladeTests/Mocks/CursorPageLoaderMock.swift new file mode 100644 index 0000000..a345a9e --- /dev/null +++ b/Tests/BladeTests/Mocks/CursorPageLoaderMock.swift @@ -0,0 +1,22 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Blade + +final class CursorPageLoaderMock: ICursorPageLoader { + var invokedLoadPage = false + var invokedLoadPageCount = 0 + var invokedLoadPageParameters: (request: CursorPaginationRequest, Void)? + var invokedLoadPageParametersList = [(request: CursorPaginationRequest, Void)]() + var stubbedLoadPage: Page! + + func loadPage(request: CursorPaginationRequest) async throws -> Page { + invokedLoadPage = true + invokedLoadPageCount += 1 + invokedLoadPageParameters = (request, ()) + invokedLoadPageParametersList.append((request, ())) + return stubbedLoadPage + } +} diff --git a/Tests/BladeTests/Mocks/OffsetPageLoaderMock.swift b/Tests/BladeTests/Mocks/OffsetPageLoaderMock.swift new file mode 100644 index 0000000..1d80496 --- /dev/null +++ b/Tests/BladeTests/Mocks/OffsetPageLoaderMock.swift @@ -0,0 +1,22 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Blade + +final class OffsetPageLoaderMock: IOffsetPageLoader { + var invokedLoadPage = false + var invokedLoadPageCount = 0 + var invokedLoadPageParameters: (request: OffsetPaginationRequest, Void)? + var invokedLoadPageParametersList = [(request: OffsetPaginationRequest, Void)]() + var stubbedLoadPage: Page! + + func loadPage(request: OffsetPaginationRequest) async throws -> Page { + invokedLoadPage = true + invokedLoadPageCount += 1 + invokedLoadPageParameters = (request, ()) + invokedLoadPageParametersList.append((request, ())) + return stubbedLoadPage + } +} diff --git a/Tests/BladeTests/Models/TestItem.swift b/Tests/BladeTests/Models/TestItem.swift new file mode 100644 index 0000000..0587bf7 --- /dev/null +++ b/Tests/BladeTests/Models/TestItem.swift @@ -0,0 +1,18 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Foundation + +struct TestItem: Equatable, Identifiable, Decodable { + // MARK: Properties + + let id: UUID + + // MARK: Initialization + + init(id: UUID = UUID()) { + self.id = id + } +} diff --git a/Tests/BladeTests/UnitTests/CursorPaginatorTests.swift b/Tests/BladeTests/UnitTests/CursorPaginatorTests.swift new file mode 100644 index 0000000..4b96306 --- /dev/null +++ b/Tests/BladeTests/UnitTests/CursorPaginatorTests.swift @@ -0,0 +1,101 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Blade +import XCTest + +// MARK: - CursorPaginatorTests + +final class CursorPaginatorTests: XCTestCase { + // MARK: Properties + + private var pageLoaderMock: CursorPageLoaderMock! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + pageLoaderMock = CursorPageLoaderMock() + } + + override func tearDown() { + pageLoaderMock = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatPaginatorLoadsNextPage() async throws { + // given + let sut = prepareSut() + + pageLoaderMock.stubbedLoadPage = .fake() + + // when + _ = try await sut.loadNextPage() + _ = try await sut.loadNextPage() + + // then + var request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParametersList[0].request) + XCTAssertEqual(request.id, .id) + + request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParametersList[1].request) + XCTAssertNotEqual(request.id, .id) + } + + func test_thatPaginatorRefreshesState() async throws { + // given + let sut = prepareSut() + + pageLoaderMock.stubbedLoadPage = .fake() + + // when + _ = try await sut.refresh() + + // then + let request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParameters?.request) + XCTAssertEqual(request.id, .id) + } + + func test_thatPagaginatorResetsState() async throws { + // given + let sut = prepareSut() + + pageLoaderMock.stubbedLoadPage = .fake() + + // when + _ = try await sut.loadNextPage() + await sut.reset() + _ = try await sut.refresh() + + // then + let request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParameters?.request) + XCTAssertEqual(request.id, .id) + } + + // MARK: Private + + private func prepareSut(id: UUID = .id) -> Paginator { + Paginator( + configuration: .init(id: id), + cursorPageLoader: pageLoaderMock + ) + } +} + +// MARK: - Constants + +private extension UUID { + static let id = UUID() +} + +private extension Page where T == TestItem { + static func fake(numberOfItems: Int = 1, hasMoreData: Bool = true) -> Page { + Page( + items: Array(0 ..< numberOfItems).map { _ in TestItem(id: UUID()) }, + hasMoreData: hasMoreData + ) + } +} diff --git a/Tests/BladeTests/UnitTests/OffsetPaginatorTests.swift b/Tests/BladeTests/UnitTests/OffsetPaginatorTests.swift new file mode 100644 index 0000000..b4bb884 --- /dev/null +++ b/Tests/BladeTests/UnitTests/OffsetPaginatorTests.swift @@ -0,0 +1,108 @@ +// +// Blade +// Copyright © 2024 Space Code. All rights reserved. +// + +import Blade +import XCTest + +// MARK: - OffsetPaginatorTests + +final class OffsetPaginatorTests: XCTestCase { + // MARK: Properties + + private var pageLoaderMock: OffsetPageLoaderMock! + + // MARK: XCTestCase + + override func setUp() { + super.setUp() + pageLoaderMock = OffsetPageLoaderMock() + } + + override func tearDown() { + pageLoaderMock = nil + super.tearDown() + } + + // MARK: Tests + + func test_thatPaginatorLoadsNextPage() async throws { + // given + let sut = prepareSut() + + pageLoaderMock.stubbedLoadPage = .fake() + + // when + _ = try await sut.loadNextPage() + _ = try await sut.loadNextPage() + + // then + var request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParametersList[0].request) + XCTAssertEqual(request.offset, .limit) + XCTAssertEqual(request.limit, .limit) + + request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParametersList[1].request) + XCTAssertEqual(request.offset, 2 * .limit) + XCTAssertEqual(request.limit, .limit) + +// let count = await sut.elements.count +// XCTAssertEqual(count, 2 * .limit) + } + + func test_thatPaginatorRefreshesState() async throws { + // given + let sut = prepareSut() + + pageLoaderMock.stubbedLoadPage = .fake() + + // when + _ = try await sut.refresh() + + // then + let request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParameters?.request) + XCTAssertEqual(request.offset, .zero) + XCTAssertEqual(request.limit, .limit) + } + + func test_thatPagaginatorResetsState() async throws { + // given + let sut = prepareSut() + + pageLoaderMock.stubbedLoadPage = .fake() + + // when + _ = try await sut.loadNextPage() + await sut.reset() + _ = try await sut.refresh() + + // then + let request = try XCTUnwrap(pageLoaderMock.invokedLoadPageParameters?.request) + XCTAssertEqual(request.offset, .zero) + XCTAssertEqual(request.limit, .limit) + } + + // MARK: Private + + private func prepareSut(firstPage: Int = .zero, limit: Int = .limit) -> Paginator { + Paginator( + configuration: .init(firstPage: firstPage, limit: limit), + offsetPageLoader: pageLoaderMock + ) + } +} + +// MARK: - Constants + +private extension Int { + static let limit = 10 +} + +private extension Page where T == TestItem { + static func fake(numberOfItems: Int = 1, hasMoreData: Bool = true) -> Page { + Page( + items: Array(0 ..< numberOfItems).map { _ in TestItem(id: UUID()) }, + hasMoreData: hasMoreData + ) + } +} diff --git a/hooks/pre-commit b/hooks/pre-commit old mode 100644 new mode 100755