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 26 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3608411
Cleaning, Add PageInfo to GraphQL requests
suhaibabsi-inst Jan 13, 2025
e536684
API for exhausting all pages
suhaibabsi-inst Jan 14, 2025
8afb79c
Update APICommentLibraryResponse.swift
suhaibabsi-inst Jan 15, 2025
184729f
Fix CI tests issues
suhaibabsi-inst Jan 15, 2025
4880452
Call Next Page Loading in UI
suhaibabsi-inst Jan 15, 2025
c81370d
Paging logic hooked to UI
suhaibabsi-inst Jan 19, 2025
9d7be58
Update HideGradesViewController.swift
suhaibabsi-inst Jan 20, 2025
cb2914a
Fix Comments Library page loading
suhaibabsi-inst Jan 20, 2025
016f310
Fix Assignment picker list paging
suhaibabsi-inst Jan 20, 2025
5082fa8
Update AssignmentPickerListRequest.swift
suhaibabsi-inst Jan 20, 2025
d55c62d
Fix Post Policy Course Sections list paging
suhaibabsi-inst Jan 20, 2025
140b4bc
Resolve SwiftLint issues
suhaibabsi-inst Jan 21, 2025
77742a0
Update PagingButton.swift
suhaibabsi-inst Jan 21, 2025
cc61ded
UI enhancement for load more button
suhaibabsi-inst Jan 21, 2025
a9d5c8f
Add/fix unit tests
suhaibabsi-inst Jan 21, 2025
6a484d1
Fix SwiftLint issues
suhaibabsi-inst Jan 21, 2025
50af206
Paging presenter unit tests
suhaibabsi-inst Jan 23, 2025
4f7b278
Fix UI for post grades policy views
suhaibabsi-inst Jan 23, 2025
424321b
More unit tests
suhaibabsi-inst Jan 28, 2025
49df1a2
More unit tests
suhaibabsi-inst Jan 28, 2025
95b531c
Resolve SwiftLint issues
suhaibabsi-inst Jan 28, 2025
aa1da73
Address code review comments
suhaibabsi-inst Feb 2, 2025
e65b2d5
Merge branch 'master' into techdebt/MBL-18026-GraphQL-Pagination
suhaibabsi-inst Feb 11, 2025
1987d55
Assignment Picker using exhaust, Comments Library using API filter
suhaibabsi-inst Feb 11, 2025
d6c7909
Fixes unit tests
suhaibabsi-inst Feb 11, 2025
c29c842
Fixes unit tests
suhaibabsi-inst Feb 11, 2025
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
160 changes: 160 additions & 0 deletions Core/Core/Common/CommonUI/Presenter/PagingPresenter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//
// 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 PagingPresenter<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 var isLoadingMore: Bool { isLoadingMoreSubject.value }
vargaat marked this conversation as resolved.
Show resolved Hide resolved

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 }
vargaat marked this conversation as resolved.
Show resolved Hide resolved

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

private var subscriptions = Set<AnyCancellable>()

override public func layoutSubviews() {
vargaat marked this conversation as resolved.
Show resolved Hide resolved
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.font = .scaledNamedFont(.semibold16)
label.adjustsFontForContentSizeCategory = true
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 = UIBackgroundConfiguration.listPlainCell()
progressView.isHidden = true
label.isHidden = false
}

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