Skip to content
This repository has been archived by the owner on Oct 10, 2024. It is now read-only.

Pass all SessionDelegate callbacks through to TurboNavigationDelegate #73

Closed
wants to merge 4 commits into from
Closed
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
14 changes: 14 additions & 0 deletions Demo/Server/app/javascript/controllers/alerts_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
showAlert(event) {
event.preventDefault()
alert(event.currentTarget.dataset["title"])
}

showConfirm(event) {
event.preventDefault()
const result = confirm(event.currentTarget.dataset["title"])
alert(`You ${result ? "confirmed" : "cancelled"} the dialog.`)
}
}
3 changes: 3 additions & 0 deletions Demo/Server/app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@
// ./bin/rails generate stimulus controllerName

import { application } from "./application"

import AlertsController from "./alerts_controller"
application.register("alerts", AlertsController)
2 changes: 1 addition & 1 deletion Demo/Server/app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<%= hotwire_livereload_tags if Rails.env.development? %>
</head>

<body class="<%= "turbo-native" if turbo_native_app? %>">
<body class="mb-5 <%= "turbo-native" if turbo_native_app? %>">
<%= render "shared/navbar" %>
<main class="container">
<%= render "shared/flash" %>
Expand Down
2 changes: 1 addition & 1 deletion Demo/Server/app/views/navigations/_item.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<div class="w-100 mx-auto">
<p class="fs-5 mb-0"><%= name %></p>
<p class="mb-0 text-muted"><%= description %></p>
<p class="mb-0 text-muted"><%= local_assigns[:description] || yield %></p>
</div>

<div class="col flex-grow-0 flex-shrink-1 my-auto">
Expand Down
18 changes: 17 additions & 1 deletion Demo/Server/app/views/navigations/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<p class="my-3">This screen was pushed onto the navigation stack.</p>
<p>This is the default behavior, no custom options are required.</p>

<div class="list-group list-group-flush mx-n3 sm:mx-0">
<div class="list-group list-group-flush mx-n3 sm:mx-0" data-controller="alerts">
<%= render "navigations/item",
path: second_navigation_path,
icon: "bi-arrow-right",
Expand Down Expand Up @@ -34,4 +34,20 @@
icon: "bi-bug",
name: "Error handling",
description: "Visit a page that does not exist (404)." %>

<%= render "navigations/item",
path: "#",
icon: "bi-exclamation-circle",
name: "JavaScript alert dialog",
data: {action: "alerts#showAlert", title: "A JavaScript alert."} do %>
Present a JavaScript <code>alert()</code>.
<% end %>

<%= render "navigations/item",
path: "#",
icon: "bi-question-circle",
name: "JavaScript confirm dialog",
data: {action: "alerts#showConfirm", title: "Are you sure?"} do %>
Present a JavaScript <code>confirm()</code>, like via <code>data-turbo-confirm</code>.
<% end %>
</div>
2 changes: 1 addition & 1 deletion Demo/Server/app/views/resources/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@

<div class="btn-group d-flex d-sm-inline-flex mt-4" role="group">
<%= link_to "Edit resource", edit_resource_path(@resource), class: "btn btn-outline-secondary" %>
<%= link_to "Delete resource", resource_path(@resource), data: {turbo_method: :delete}, class: "btn btn-outline-danger" %>
<%= link_to "Delete resource", resource_path(@resource), data: {turbo_method: :delete, turbo_confirm: "Delete this resource?"}, class: "btn btn-outline-danger" %>
</div>
49 changes: 15 additions & 34 deletions Sources/TurboNavigationDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@ import WebKit
/// Implement to be notified when certain navigations are performed
/// or to render a native controller instead of a Turbo web visit.
public protocol TurboNavigationDelegate: AnyObject {
typealias RetryBlock = () -> Void

/// Respond to authentication challenge presented by web servers behing basic auth.
func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
/// Optional. Override to provide a custom implementation of `WKUIDelegate`, like handling JavaScript alerts.
var webViewDelegate: WKUIDelegate? { get }

/// Optional. Accept or reject a visit proposal.
/// If accepted, you may provide a view controller to be displayed, otherwise a new `VisitableViewController` is displayed.
Expand All @@ -19,48 +17,31 @@ public protocol TurboNavigationDelegate: AnyObject {
/// - Returns: how to react to the visit proposal
func handle(proposal: VisitProposal) -> ProposalResult

/// Optional. An error occurred loading the request, present it to the user.
/// Retry the request by executing the closure.
/// If not implemented, will present the error's localized description and a Retry button.
func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock)

/// Optional. Implement to customize handling of external URLs.
/// If not implemented, will present `SFSafariViewController` as a modal and load the URL.
func openExternalURL(_ url: URL, from controller: UIViewController)
// MARK: - SessionDelegate overrides
/// Optional. Implement these functions when via an extension.

/// Optional. Implement to become the web view's navigation delegate after the initial cold boot visit is completed.
/// https://github.com/hotwired/turbo-ios/blob/main/Docs/Overview.md#becoming-the-web-views-navigation-delegate
func session(_ session: Session, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
func sessionDidLoadWebView(_ session: Session)

/// Optional. Useful for interacting with the web view after the page loads.
func sessionDidStartRequest(_ session: Session)
func sessionDidFinishRequest(_ session: Session)
func sessionDidStartFormSubmission(_ session: Session)
}

public extension TurboNavigationDelegate {
func handle(proposal: VisitProposal) -> ProposalResult { .accept }
var webViewDelegate: WKUIDelegate? { nil }

func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) {
if let errorPresenter = visitable as? ErrorPresenter {
errorPresenter.presentError(error) {
retry()
}
}
}
func handle(proposal: VisitProposal) -> ProposalResult { .accept }

func openExternalURL(_ url: URL, from controller: UIViewController) {
let safariViewController = SFSafariViewController(url: url)
safariViewController.modalPresentationStyle = .pageSheet
if #available(iOS 15.0, *) {
safariViewController.preferredControlTintColor = .tintColor
}
controller.present(safariViewController, animated: true)
}
// MARK: - SessionDelegate

func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
func session(_ session: Session, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
completionHandler(.performDefaultHandling, nil)
}

func sessionDidFinishRequest(_ session: Session) {}

func sessionDidLoadWebView(_ session: Session) {}

func sessionDidStartRequest(_ session: Session) {}
func sessionDidFinishRequest(_ session: Session) {}
func sessionDidStartFormSubmission(_ session: Session) {}
}
128 changes: 79 additions & 49 deletions Sources/TurboNavigator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,63 +5,43 @@ import WebKit

/// Handles navigation to new URLs using the following rules:
/// https://github.com/joemasilotti/TurboNavigator#handled-flows
public class TurboNavigator {
public class TurboNavigator: NSObject {
/// Default initializer.
/// - Parameters:
/// - delegate: handle custom controller routing
/// - pathConfiguration: assigned to internal `Session` instances for custom configuration
/// - navigationController: optional: override the main navigation stack
/// - modalNavigationController: optional: override the modal navigation stack
public init(delegate: TurboNavigationDelegate,
pathConfiguration: PathConfiguration? = nil,
navigationController: UINavigationController = UINavigationController(),
modalNavigationController: UINavigationController = UINavigationController())
/// - delegate: Handle custom controller routing.
/// - pathConfiguration: Optional. Remotely configure settings and path rules.
/// - navigationController: Optional. Override the main navigation stack.
/// - modalNavigationController: Optional. Override the modal navigation stack.
public init(
delegate: TurboNavigationDelegate,
pathConfiguration: PathConfiguration? = nil,
navigationController: UINavigationController = UINavigationController(),
modalNavigationController: UINavigationController = UINavigationController())
{
self.session = Session(webView: TurboConfig.shared.makeWebView())
self.modalSession = Session(webView: TurboConfig.shared.makeWebView())
self.delegate = delegate
self.pathConfiguration = pathConfiguration
self.navigationController = navigationController
self.modalNavigationController = modalNavigationController

session.delegate = self
modalSession.delegate = self
session.pathConfiguration = pathConfiguration
modalSession.pathConfiguration = pathConfiguration
super.init()
}

/// Provide `Turbo.Session` instances with preconfigured path configurations and delegates.
/// Note that TurboNavigationDelegate.controller(_:forProposal:) will no longer be called.
/// - Parameters:
/// - preconfiguredMainSession: a session whose delegate is not `TurboNavigator`
/// - preconfiguredModalSession: a session whose delegate is not `TurboNavigator`
/// - delegate: handle non-routing behavior, like custom error handling
/// - navigationController: optional: override the main navigation stack
/// - modalNavigationController: optional: override the modal navigation stack
public init(preconfiguredMainSession: Turbo.Session,
preconfiguredModalSession: Turbo.Session,
delegate: TurboNavigationDelegate,
navigationController: UINavigationController = UINavigationController(),
modalNavigationController: UINavigationController = UINavigationController())
{
self.session = preconfiguredMainSession
self.modalSession = preconfiguredModalSession
self.navigationController = navigationController
self.modalNavigationController = modalNavigationController
/// Set this as the `rootViewController` of your application's `UIWindow`.
public var rootViewController: UIViewController { navigationController }

self.delegate = delegate
/// `navigationController` or `modalNavigationController`, whichever is being shown.
public var currentNavigationController: UINavigationController {
navigationController.presentedViewController != nil ? modalNavigationController : navigationController
}

public var rootViewController: UIViewController { navigationController }
public let navigationController: UINavigationController
public let modalNavigationController: UINavigationController

/// Follows rules from `pathConfiguration` to route a `URL` to the stack.
public func route(_ url: URL) {
let options = VisitOptions(action: .advance, response: nil)
let properties = session.pathConfiguration?.properties(for: url) ?? PathProperties()
let proposal = VisitProposal(url: url, options: options, properties: properties)
route(proposal)
}

/// Follows rules from `pathConfiguration` to route a `VisitProposal` to the stack.
public func route(_ proposal: VisitProposal) {
guard let controller = controller(for: proposal) else { return }

Expand Down Expand Up @@ -89,8 +69,11 @@ public class TurboNavigator {

// MARK: Internal

let session: Session
let modalSession: Session
let navigationController: UINavigationController
let modalNavigationController: UINavigationController

lazy var session = makeSession()
lazy var modalSession = makeSession()

// MARK: Private

Expand All @@ -100,6 +83,15 @@ public class TurboNavigator {
}

private unowned let delegate: TurboNavigationDelegate
private let pathConfiguration: PathConfiguration?

private func makeSession() -> Session {
let session = Session(webView: TurboConfig.shared.makeWebView())
session.delegate = self
session.pathConfiguration = pathConfiguration
session.webView.uiDelegate = delegate.webViewDelegate ?? self
return session
}

private func controller(for proposal: VisitProposal) -> UIViewController? {
switch delegate.handle(proposal: proposal) {
Expand Down Expand Up @@ -248,30 +240,68 @@ extension TurboNavigator: SessionDelegate {
}

public func session(_ session: Session, openExternalURL url: URL) {
let controller = session === modalSession ? modalNavigationController : navigationController
delegate.openExternalURL(url, from: controller)
let safariViewController = SFSafariViewController(url: url)
safariViewController.modalPresentationStyle = .pageSheet
if #available(iOS 15.0, *) {
safariViewController.preferredControlTintColor = .tintColor
}
currentNavigationController.visibleViewController?.present(safariViewController, animated: true)
}

public func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {
delegate.visitableDidFailRequest(visitable, error: error) {
session.reload()
if let errorPresenter = visitable as? ErrorPresenter {
errorPresenter.presentError(error) {
session.reload()
}
}
}

public func sessionWebViewProcessDidTerminate(_ session: Session) {
session.reload()
}

// MARK: SessionDelegate → TurboNavigationDelegate

public func session(_ session: Session, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
delegate.didReceiveAuthenticationChallenge(challenge, completionHandler: completionHandler)
delegate.session(session, didReceiveAuthenticationChallenge: challenge, completionHandler: completionHandler)
}

public func sessionDidLoadWebView(_ session: Session) {
delegate.sessionDidLoadWebView(session)
}

public func sessionDidStartRequest(_ session: Session) {
delegate.sessionDidStartRequest(session)
}

public func sessionDidFinishRequest(_ session: Session) {
delegate.sessionDidFinishRequest(session)
}

public func sessionDidLoadWebView(_ session: Session) {
session.webView.navigationDelegate = session
delegate.sessionDidLoadWebView(session)
public func sessionDidStartFormSubmission(_ session: Session) {
delegate.sessionDidStartFormSubmission(session)
}
}

// MARK: - WKUIDelegate

extension TurboNavigator: WKUIDelegate {
public func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in
completionHandler()
})
currentNavigationController.visibleViewController?.present(alert, animated: true)
}

public func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .destructive) { _ in
completionHandler(true)
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
completionHandler(false)
})
currentNavigationController.visibleViewController?.present(alert, animated: true)
}
}
10 changes: 0 additions & 10 deletions Tests/TurboNavigationDelegateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,6 @@ final class TurboNavigationDelegateTests: XCTestCase {
XCTAssertEqual(result, .accept)
}

func test_openExternalURL_presentsSafariViewController() throws {
let url = URL(string: "https://example.com")!
let controller = TestableNavigationController()

delegate.openExternalURL(url, from: controller)

XCTAssert(controller.presentedViewController is SFSafariViewController)
XCTAssertEqual(controller.modalPresentationStyle, .pageSheet)
}

// MARK: Private

private let delegate = DefaultDelegate()
Expand Down
16 changes: 16 additions & 0 deletions Tests/TurboNavigatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,22 @@ final class TurboNavigatorTests: XCTestCase {
XCTAssertNotEqual(navigator.session.activeVisitable?.visitableURL, proposal.url)
}

func test_currentNavigationController_startsAsRoot() {
XCTAssertIdentical(navigator.currentNavigationController, navigator.navigationController)
}

func test_currentNavigationController_modalPresented_isModal() {
navigator.route(VisitProposal(path: "/one"))
navigator.route(VisitProposal(path: "/two", context: .modal))
XCTAssertIdentical(navigator.currentNavigationController, navigator.modalNavigationController)
}

func test_currentNavigationController_mainNavigation_isRoot() {
navigator.route(VisitProposal(path: "/one", context: .modal))
navigator.route(VisitProposal(path: "/two"))
XCTAssertIdentical(navigator.currentNavigationController, navigator.navigationController)
}

// MARK: Private

private enum Context {
Expand Down