Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update GraphQL Queries with Pagination #3064

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions Core/Core/Common/CommonModels/API/API.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,24 @@ public class API {
}
}

@discardableResult
public func makeRequest<Request: APIRequestable>(
_ requestable: Request,
refreshToken: Bool = true
) async throws -> Request.Response {
return try await withCheckedThrowingContinuation { continuation in
makeRequest(requestable) { result, response, error in
if let error {
continuation.resume(throwing: APIError.from(data: nil, response: response, error: error))
} else if let result {
continuation.resume(returning: result)
} else {
continuation.resume(throwing: APIAsyncError.invalidResponse)
}
}
}
}

@discardableResult
public func makeDownloadRequest(_ url: URL,
method: APIMethod? = nil,
Expand Down Expand Up @@ -196,6 +214,25 @@ public class API {
callback(result, urlResponse, error)
}
}

public func exhaust<R>(_ requestable: R, callback: @escaping (R.Response.Page?, URLResponse?, Error?) -> Void) where R: APIPagedRequestable {
exhaust(requestable, result: nil, callback: callback)
}

private func exhaust<R>(_ requestable: R, result: R.Response.Page?, callback: @escaping (R.Response.Page?, URLResponse?, Error?) -> Void) where R: APIPagedRequestable {
makeRequest(requestable) { response, urlResponse, error in
guard let response = response else {
callback(nil, urlResponse, error)
return
}
let result = result == nil ? response.page : result! + response.page
if let next = requestable.nextPageRequest(from: response) as? R {
self.exhaust(next, result: result, callback: callback)
return
}
callback(result, urlResponse, error)
}
}
}

public protocol APITask {
Expand Down Expand Up @@ -247,3 +284,7 @@ public class FollowRedirect: NSObject, URLSessionTaskDelegate {
completionHandler(newRequest)
}
}

public enum APIAsyncError: Error {
case invalidResponse
}
17 changes: 17 additions & 0 deletions Core/Core/Common/CommonModels/API/APICombineExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,23 @@ public extension API {
}.eraseToAnyPublisher()
}

func exhaust<Request: APIPagedRequestable>(
_ requestable: Request
) -> AnyPublisher<(body: Request.Response.Page, urlResponse: HTTPURLResponse?), Error> {
Future { promise in
self.exhaust(requestable, callback: { response, urlResponse, error in
if let response {
promise(.success((body: response,
urlResponse: urlResponse as? HTTPURLResponse)))
} else if let error {
promise(.failure(error))
} else {
promise(.failure(NSError.instructureError("No response or error received.")))
}
})
}.eraseToAnyPublisher()
}

func makeRequest(_ url: URL,
method: APIMethod? = nil)
-> AnyPublisher<URLResponse?, Error> {
Expand Down
12 changes: 12 additions & 0 deletions Core/Core/Common/CommonModels/API/APIGraphQLRequestable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ public struct GraphQLBody<Variables: Codable & Equatable>: Codable, Equatable {
let variables: Variables
}

public protocol PagedResponse: Codable {
associatedtype Page: Codable, RangeReplaceableCollection
var page: Page { get }
}

public protocol APIPagedRequestable: APIRequestable where Response: PagedResponse {
associatedtype NextRequest = Self
func nextPageRequest(from response: Response) -> NextRequest?
}

protocol APIGraphQLRequestable: APIRequestable {
associatedtype Variables: Codable, Equatable

Expand All @@ -46,3 +56,5 @@ extension APIGraphQLRequestable {
GraphQLBody(query: Self.query, operationName: Self.operationName, variables: variables)
}
}

typealias APIGraphQLPagedRequestable = APIGraphQLRequestable & APIPagedRequestable
15 changes: 14 additions & 1 deletion Core/Core/Common/CommonModels/API/APIMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,20 @@ class MockAPITask: APITask {
}

extension URLRequest {
var key: String { "\(httpMethod ?? ""):\(url?.withCanonicalQueryParams?.absoluteString ?? "")" }
var key: String {
let basicKey = "\(httpMethod ?? ""):\(url?.withCanonicalQueryParams?.absoluteString ?? "")"

struct BodyHeader: Codable, Equatable {
let operationName: String
}

guard
let body = httpBody,
let header = try? APIJSONDecoder().decode(BodyHeader.self, from: body)
else { return basicKey }

return basicKey + ":\(header.operationName)"
}
}

class APIMock {
Expand Down
157 changes: 157 additions & 0 deletions Core/Core/Common/CommonUI/Presenter/Paging.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//
// This file is part of Canvas.
// Copyright (C) 2025-present Instructure, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//

import UIKit
import Combine

public protocol PagingViewController: UIViewController {
associatedtype Page: PageModel

func isMoreRow(at indexPath: IndexPath) -> Bool
func loadNextPage()
}

public protocol PageModel {
var nextCursor: String? { get }
}

public class Paging<Controller: PagingViewController> {

private var endCursor: String?
private var isLoadingMoreSubject = CurrentValueSubject<Bool, Never>(false)
private var loadedCursor: String?
private unowned let controller: Controller

public init(controller: Controller) {
self.controller = controller
}

public var hasMore: Bool { endCursor != nil }

public func onPageLoaded(_ page: Controller.Page) {
endCursor = page.nextCursor
isLoadingMoreSubject.send(false)
}

public func onPageLoadingFailed() {
isLoadingMoreSubject.send(false)
}

public func willDisplayRow(at indexPath: IndexPath) {
guard controller.isMoreRow(at: indexPath), isLoadingMoreSubject.value == false else { return }
guard let endCursor, endCursor != loadedCursor else { return }
loadMore()
}

public func willSelectRow(at indexPath: IndexPath) {
guard controller.isMoreRow(at: indexPath), isLoadingMoreSubject.value == false else { return }
loadMore()
}

private func loadMore() {
guard let endCursor else { return }

loadedCursor = endCursor
isLoadingMoreSubject.send(true)

controller.loadNextPage()
}

public func setup(in cell: PageLoadingCell) -> PageLoadingCell {
cell.observeLoading(isLoadingMoreSubject.eraseToAnyPublisher())
return cell
}
}

public class PageLoadingCell: UITableViewCell {
required init?(coder: NSCoder) { nil }

private let progressView = CircleProgressView()
private let label = UILabel()

private var subscriptions = Set<AnyCancellable>()

override public func layoutSubviews() {
super.layoutSubviews()

subviews.forEach { subview in
guard subview != contentView else { return }
subview.isHidden = true
}
}

override public init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)

contentView.addSubview(progressView)

progressView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
progressView.widthAnchor.constraint(equalToConstant: 32),
progressView.heightAnchor.constraint(equalToConstant: 32),
progressView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
progressView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
progressView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10)
])

contentView.addSubview(label)

label.text = String(localized: "Load More", bundle: .core)
label.textColor = .systemBlue
label.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
label.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
label.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 10),
label.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -10)
])
}

override public func prepareForReuse() {
super.prepareForReuse()

subscriptions.forEach({ $0.cancel() })
subscriptions.removeAll()
}

fileprivate func observeLoading(_ loadingPublisher: AnyPublisher<Bool, Never>) {
loadingPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] isLoading in
if isLoading {
self?.setupAsProgressView()
} else {
self?.setupAsButton()
}
}
.store(in: &subscriptions)
}

private func setupAsButton() {
backgroundConfiguration = nil
progressView.isHidden = true
label.isHidden = false
}

private func setupAsProgressView() {
backgroundConfiguration = UIBackgroundConfiguration.clear()
progressView.isHidden = false
label.isHidden = true
}
}
Loading