diff --git a/Demo/Server/app/javascript/controllers/alerts_controller.js b/Demo/Server/app/javascript/controllers/alerts_controller.js new file mode 100644 index 0000000..6e5ddcf --- /dev/null +++ b/Demo/Server/app/javascript/controllers/alerts_controller.js @@ -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.`) + } +} diff --git a/Demo/Server/app/javascript/controllers/index.js b/Demo/Server/app/javascript/controllers/index.js index 373c3ed..9a5ff8e 100644 --- a/Demo/Server/app/javascript/controllers/index.js +++ b/Demo/Server/app/javascript/controllers/index.js @@ -3,3 +3,6 @@ // ./bin/rails generate stimulus controllerName import { application } from "./application" + +import AlertsController from "./alerts_controller" +application.register("alerts", AlertsController) diff --git a/Demo/Server/app/views/layouts/application.html.erb b/Demo/Server/app/views/layouts/application.html.erb index 88d1b84..04eb732 100644 --- a/Demo/Server/app/views/layouts/application.html.erb +++ b/Demo/Server/app/views/layouts/application.html.erb @@ -12,7 +12,7 @@ <%= hotwire_livereload_tags if Rails.env.development? %> - "> + "> <%= render "shared/navbar" %>
<%= render "shared/flash" %> diff --git a/Demo/Server/app/views/navigations/_item.html.erb b/Demo/Server/app/views/navigations/_item.html.erb index 6dcf9df..cb78ca8 100644 --- a/Demo/Server/app/views/navigations/_item.html.erb +++ b/Demo/Server/app/views/navigations/_item.html.erb @@ -5,7 +5,7 @@

<%= name %>

-

<%= description %>

+

<%= local_assigns[:description] || yield %>

diff --git a/Demo/Server/app/views/navigations/show.html.erb b/Demo/Server/app/views/navigations/show.html.erb index cd02889..44d4f6f 100644 --- a/Demo/Server/app/views/navigations/show.html.erb +++ b/Demo/Server/app/views/navigations/show.html.erb @@ -3,7 +3,7 @@

This screen was pushed onto the navigation stack.

This is the default behavior, no custom options are required.

-
+
<%= render "navigations/item", path: second_navigation_path, icon: "bi-arrow-right", @@ -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 alert(). + <% 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 confirm(), like via data-turbo-confirm. + <% end %>
diff --git a/Demo/Server/app/views/resources/show.html.erb b/Demo/Server/app/views/resources/show.html.erb index 047438b..0cdfbf4 100644 --- a/Demo/Server/app/views/resources/show.html.erb +++ b/Demo/Server/app/views/resources/show.html.erb @@ -5,5 +5,5 @@
<%= 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" %>
diff --git a/Sources/TurboNavigationDelegate.swift b/Sources/TurboNavigationDelegate.swift index dd5c6a5..85cf4bc 100644 --- a/Sources/TurboNavigationDelegate.swift +++ b/Sources/TurboNavigationDelegate.swift @@ -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. @@ -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) {} } diff --git a/Sources/TurboNavigator.swift b/Sources/TurboNavigator.swift index 4e90bcc..aaed176 100644 --- a/Sources/TurboNavigator.swift +++ b/Sources/TurboNavigator.swift @@ -5,56 +5,35 @@ 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() @@ -62,6 +41,7 @@ public class TurboNavigator { 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 } @@ -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 @@ -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) { @@ -248,13 +240,19 @@ 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() + } } } @@ -262,16 +260,48 @@ extension TurboNavigator: SessionDelegate { 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) } } diff --git a/Tests/TurboNavigationDelegateTests.swift b/Tests/TurboNavigationDelegateTests.swift index 65aa5f9..b9e5f90 100644 --- a/Tests/TurboNavigationDelegateTests.swift +++ b/Tests/TurboNavigationDelegateTests.swift @@ -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() diff --git a/Tests/TurboNavigatorTests.swift b/Tests/TurboNavigatorTests.swift index 06e0051..49b3de7 100644 --- a/Tests/TurboNavigatorTests.swift +++ b/Tests/TurboNavigatorTests.swift @@ -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 {