diff --git a/Package.swift b/Package.swift index 300e441..8bf63af 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,7 @@ let package = Package( name: "Roadmap", platforms: [ .iOS(.v17), - .macOS(.v14), + .macOS(.v12), .visionOS(.v1) ], products: [ diff --git a/Sources/Roadmap/DataProviders/FeaturesFetcher.swift b/Sources/Roadmap/DataProviders/FeaturesFetcher.swift index ef0d66e..3aba6cd 100644 --- a/Sources/Roadmap/DataProviders/FeaturesFetcher.swift +++ b/Sources/Roadmap/DataProviders/FeaturesFetcher.swift @@ -8,15 +8,8 @@ import Foundation import OSLog -struct FeaturesFetcher { - let featureRequest: URLRequest +public protocol FeaturesFetcher: Sendable { + var featureRequest: URLRequest { get } - func fetch() async -> [RoadmapFeature] { - do { - return try await JSONDataFetcher.loadJSON(request: featureRequest) - } catch { - Logger.roadmap.error("error: \(error.localizedDescription)") - return [] - } - } + func fetch() async -> [RoadmapFeature] } diff --git a/Sources/Roadmap/DataProviders/FeaturesFetcherJSON.swift b/Sources/Roadmap/DataProviders/FeaturesFetcherJSON.swift new file mode 100644 index 0000000..78f9777 --- /dev/null +++ b/Sources/Roadmap/DataProviders/FeaturesFetcherJSON.swift @@ -0,0 +1,22 @@ +// +// FeaturesFetcher.swift +// Roadmap +// +// Created by Antoine van der Lee on 19/02/2023. +// + +import Foundation +import OSLog + +struct FeaturesFetcherJSON: FeaturesFetcher { + let featureRequest: URLRequest + + func fetch() async -> [RoadmapFeature] { + do { + return try await JSONDataFetcher.loadJSON(request: featureRequest) + } catch { + Logger.roadmap.error("error: \(error.localizedDescription)") + return [] + } + } +} diff --git a/Sources/Roadmap/RoadmapConfiguration.swift b/Sources/Roadmap/RoadmapConfiguration.swift index 294e8b5..cbbcd21 100644 --- a/Sources/Roadmap/RoadmapConfiguration.swift +++ b/Sources/Roadmap/RoadmapConfiguration.swift @@ -14,7 +14,10 @@ public struct RoadmapConfiguration: Sendable { /// The interface for retrieving and saving votes. public let voter: FeatureVoter - + + /// The interface for retrieving features + public let fetcher: FeaturesFetcher + /// Pick a `RoadmapStyle` that fits your app best. By default the `.standard` option is used. public let style: RoadmapStyle @@ -60,15 +63,20 @@ public struct RoadmapConfiguration: Sendable { guard let url = roadmapJSONURL ?? roadmapRequest?.url else { fatalError("Missing URL") } - - self.roadmapRequest = roadmapRequest ?? URLRequest(url: url) - self.voter = voter - self.style = style - self.shuffledOrder = shuffledOrder - self.sorting = sorting - self.allowVotes = allowVotes - self.allowSearching = allowSearching - self.allowsFilterByStatus = allowsFilterByStatus + + let roadmapRequest = roadmapRequest ?? URLRequest(url: url) + + self.init( + roadmapRequest: roadmapRequest, + voter: voter, + fetcher: FeaturesFetcherJSON(featureRequest: roadmapRequest), + style: style, + shuffledOrder: shuffledOrder, + sorting: sorting, + allowVotes: allowVotes, + allowSearching: allowSearching, + allowsFilterByStatus: allowsFilterByStatus + ) } /// Creates a new Roadmap configuration instance. @@ -102,6 +110,37 @@ public struct RoadmapConfiguration: Sendable { ) } + /// Creates a new Roadmap configuration instance. + /// - Parameters: + /// - roadmapRequest: The Request pointing to the web endpoint. + /// - voter: The interface to use for retrieving and persisting votes. + /// - fetcher: The interface to fetch in the `RoadmapFeature` format + /// - style: Pick a `RoadmapStyle` that fits your app best. By default the `.standard` option is used. + /// - shuffledOrder: Set this to true to have a different order of features everytime the view is presented + /// - sorting: If set, will be used for sorting features. + /// - allowVotes: Set this to true to if you want to let users vote. Set it to false for read-only mode. This can be used to only let paying users vote for example. + /// - allowSearching: Set this to true to if you want to add a search bar so users can filter which features are shown. + public init( + roadmapRequest: URLRequest, + voter: FeatureVoter, + fetcher: FeaturesFetcher, + style: RoadmapStyle = RoadmapTemplate.standard.style, + shuffledOrder: Bool = false, + sorting: (@Sendable (RoadmapFeature, RoadmapFeature) -> Bool)? = nil, + allowVotes: Bool = true, + allowSearching: Bool = false, + allowsFilterByStatus: Bool = false + ) { + self.roadmapRequest = roadmapRequest + self.voter = voter + self.fetcher = fetcher + self.style = style + self.shuffledOrder = shuffledOrder + self.sorting = sorting + self.allowVotes = allowVotes + self.allowSearching = allowSearching + self.allowsFilterByStatus = allowsFilterByStatus + } } extension RoadmapConfiguration { diff --git a/Sources/Roadmap/RoadmapFeatureView.swift b/Sources/Roadmap/RoadmapFeatureView.swift index 21c5fa9..535b034 100644 --- a/Sources/Roadmap/RoadmapFeatureView.swift +++ b/Sources/Roadmap/RoadmapFeatureView.swift @@ -7,6 +7,7 @@ import SwiftUI +@available(macOS 14.0, iOS 17.0, visionOS 1.0, *) struct RoadmapFeatureView: View { @Environment(\.dynamicTypeSize) var typeSize @State var viewModel: RoadmapFeatureViewModel @@ -110,6 +111,7 @@ struct RoadmapFeatureView: View { } } +@available(macOS 14.0, iOS 17.0, visionOS 1.0, *) #Preview { RoadmapFeatureView(viewModel: .init(feature: .sample(), configuration: .sampleURL())) } diff --git a/Sources/Roadmap/RoadmapFeatureViewModel.swift b/Sources/Roadmap/RoadmapFeatureViewModel.swift index 30a8737..3a8411e 100644 --- a/Sources/Roadmap/RoadmapFeatureViewModel.swift +++ b/Sources/Roadmap/RoadmapFeatureViewModel.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI +@available(macOS 14.0, iOS 17.0, visionOS 1.0, *) @MainActor @Observable final class RoadmapFeatureViewModel { diff --git a/Sources/Roadmap/RoadmapView.swift b/Sources/Roadmap/RoadmapView.swift index fcbd23e..11e896d 100644 --- a/Sources/Roadmap/RoadmapView.swift +++ b/Sources/Roadmap/RoadmapView.swift @@ -7,6 +7,7 @@ import SwiftUI +@available(macOS 14.0, iOS 17.0, visionOS 1.0, *) public struct RoadmapView: View { @State var viewModel: RoadmapViewModel let header: Header @@ -37,7 +38,7 @@ public struct RoadmapView: View { .listStyle(.plain) .conditionalSearchable(if: viewModel.allowSearching, text: $viewModel.searchText) } - + var featuresList: some View { VStack { if viewModel.allowsFilterByStatus { @@ -76,32 +77,35 @@ public struct RoadmapView: View { } } +@available(macOS 14.0, iOS 17.0, visionOS 1.0, *) public extension RoadmapView where Header == EmptyView, Footer == EmptyView { init(configuration: RoadmapConfiguration) { self.init(viewModel: .init(configuration: configuration), header: EmptyView(), footer: EmptyView(), selectedFilter: RoadmapViewModel.allStatusFilter) } } +@available(macOS 14.0, iOS 17.0, visionOS 1.0, *) public extension RoadmapView where Header: View, Footer == EmptyView { init(configuration: RoadmapConfiguration, @ViewBuilder header: () -> Header) { self.init(viewModel: .init(configuration: configuration), header: header(), footer: EmptyView(), selectedFilter: RoadmapViewModel.allStatusFilter) } } +@available(macOS 14.0, iOS 17.0, visionOS 1.0, *) public extension RoadmapView where Header == EmptyView, Footer: View { init(configuration: RoadmapConfiguration, @ViewBuilder footer: () -> Footer) { self.init(viewModel: .init(configuration: configuration), header: EmptyView(), footer: footer(), selectedFilter: RoadmapViewModel.allStatusFilter) } } +@available(macOS 14.0, iOS 17.0, visionOS 1.0, *) public extension RoadmapView where Header: View, Footer: View { init(configuration: RoadmapConfiguration, @ViewBuilder header: () -> Header, @ViewBuilder footer: () -> Footer) { self.init(viewModel: .init(configuration: configuration), header: header(), footer: footer(), selectedFilter: RoadmapViewModel.allStatusFilter) } } -struct RoadmapView_Previews: PreviewProvider { - static var previews: some View { - RoadmapView(configuration: .sampleURL()) - } +@available(macOS 14.0, iOS 17.0, visionOS 1.0, *) +#Preview { + RoadmapView(configuration: .sampleURL()) } diff --git a/Sources/Roadmap/RoadmapViewModel.swift b/Sources/Roadmap/RoadmapViewModel.swift index 90e0b81..d7dfe29 100644 --- a/Sources/Roadmap/RoadmapViewModel.swift +++ b/Sources/Roadmap/RoadmapViewModel.swift @@ -7,6 +7,7 @@ import Foundation +@available(macOS 14.0, iOS 17.0, visionOS 1.0, *) @MainActor @Observable final class RoadmapViewModel { @@ -57,23 +58,24 @@ final class RoadmapViewModel { var statuses: [String] = [] private let configuration: RoadmapConfiguration - + private let fetcher: FeaturesFetcher + init(configuration: RoadmapConfiguration) { self.configuration = configuration self.allowSearching = configuration.allowSearching self.allowsFilterByStatus = configuration.allowsFilterByStatus + self.fetcher = configuration.fetcher loadFeatures(request: configuration.roadmapRequest) } func loadFeatures(request: URLRequest) { - Task { @MainActor in if configuration.shuffledOrder { - self.features = await FeaturesFetcher(featureRequest: request).fetch().shuffled() + self.features = await fetcher.fetch().shuffled() } else if let sorting = configuration.sorting { - self.features = await FeaturesFetcher(featureRequest: request).fetch().sorted(by: sorting) + self.features = await fetcher.fetch().sorted(by: sorting) } else { - self.features = await FeaturesFetcher(featureRequest: request).fetch() + self.features = await fetcher.fetch() } self.statuses = { diff --git a/Sources/Roadmap/RoadmapVoteButton.swift b/Sources/Roadmap/RoadmapVoteButton.swift index 32f55a0..0cad27c 100644 --- a/Sources/Roadmap/RoadmapVoteButton.swift +++ b/Sources/Roadmap/RoadmapVoteButton.swift @@ -7,6 +7,7 @@ import SwiftUI +@available(macOS 14.0, iOS 17.0, visionOS 1.0, *) struct RoadmapVoteButton: View { @State var viewModel: RoadmapFeatureViewModel @Environment(\.dynamicTypeSize) private var typeSize diff --git a/Tests/RoadmapTests/RoadmapTests.swift b/Tests/RoadmapTests/RoadmapTests.swift index cd82f20..458bbf6 100644 --- a/Tests/RoadmapTests/RoadmapTests.swift +++ b/Tests/RoadmapTests/RoadmapTests.swift @@ -2,6 +2,7 @@ import XCTest final class RoadmapTests: XCTestCase { + @MainActor func testFeatureVoter() async throws { let featureID = "test" @@ -30,9 +31,27 @@ final class RoadmapTests: XCTestCase { XCTAssertEqual(model.voteCount, 1) XCTAssertFalse(feature.hasVoted) } + + @MainActor + func testFeatureFetcher() async throws { + let request = URLRequest(url: URL(string: "http://localhost:3000/api")!) + let fetcher = FeaturesFetcherMock() + let voter = InMemoryFeatureVoter() + let configuration = RoadmapConfiguration( + roadmapRequest: request, + voter: voter, + fetcher: fetcher + ) + + let model = RoadmapViewModel(configuration: configuration) + try await Task.sleep(nanoseconds: 500_000_000) + let features = model.filteredFeatures + XCTAssertTrue(features.count > 0) + } } -class InMemoryFeatureVoter: FeatureVoter { +@MainActor +fileprivate class InMemoryFeatureVoter: FeatureVoter { var count: [String: Int] = [:] func fetch(for feature: RoadmapFeature) async -> Int { @@ -49,3 +68,16 @@ class InMemoryFeatureVoter: FeatureVoter { return count[feature.id] } } + +fileprivate final class FeaturesFetcherMock: FeaturesFetcher { + var featureRequest: URLRequest { + URLRequest(url: URL(string: "http://localhost/api")!) + } + + func fetch() async -> [Roadmap.RoadmapFeature] { + [ + RoadmapFeature.sample(), + RoadmapFeature.sample() + ] + } +}