Skip to content

Commit

Permalink
MBL-1014: Replace ReactiveSwift in ReportProjectFormViewModel with Co…
Browse files Browse the repository at this point in the history
…mbine (#1873)

* [msadoon] Add Combine support to Apollo client

* Add createFlaggingInputCombine to Service

* Add fetchGraphUserEmailCombine to Service

* MBL-1014: Replace ReactiveSwift in ReportProjectFormViewModel with Combine

* Use @StateObject for ReportProjectFormViewModel

* Add //TODOs with ticket numbers for leftover tasks
  • Loading branch information
amy-at-kickstarter authored Nov 2, 2023
1 parent b74f1cb commit 36ea6fe
Show file tree
Hide file tree
Showing 13 changed files with 346 additions and 149 deletions.
66 changes: 24 additions & 42 deletions Kickstarter-iOS/Features/ReportProject/ReportProjectFormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,32 @@ enum ReportFormFocusField {
}

struct ReportProjectFormView: View {
@Binding var popToRoot: Bool
let projectID: String
let projectURL: String
let projectFlaggingKind: GraphAPI.FlaggingKind
@Binding var popToRoot: Bool

@SwiftUI.Environment(\.dismiss) private var dismiss
@ObservedObject private var viewModel = ReportProjectFormViewModel()
@StateObject private var viewModel = ReportProjectFormViewModel()

@State private var retrievedEmail = ""
@State private var details: String = ""
@State private var saveEnabled: Bool = false
@State private var saveTriggered: Bool = false
@State private var showLoading: Bool = false
@State private var showBannerMessage = false
@State private var submitSuccess = false
@State private var bannerMessage: MessageBannerViewViewModel?
@FocusState private var focusField: ReportFormFocusField?

var body: some View {
GeometryReader { proxy in
Form {
if !retrievedEmail.isEmpty {
SwiftUI.Section(Strings.Email()) {
SwiftUI.Section(Strings.Email()) {
if let retrievedEmail = viewModel.retrievedEmail, !retrievedEmail.isEmpty {
Text(retrievedEmail)
.font(Font(UIFont.ksr_body()))
.foregroundColor(Color(.ksr_support_400))
.disabled(true)
} else {
Text(Strings.Loading())
.font(Font(UIFont.ksr_body()))
.foregroundColor(Color(.ksr_support_400))
.italic()
.disabled(true)
}
}

Expand All @@ -45,7 +44,7 @@ struct ReportProjectFormView: View {
}

SwiftUI.Section {
TextEditor(text: $details)
TextEditor(text: $viewModel.detailsText)
.frame(minHeight: 75)
.font(Font(UIFont.ksr_body()))
.focused($focusField, equals: .details)
Expand All @@ -59,52 +58,35 @@ struct ReportProjectFormView: View {
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
LoadingBarButtonItem(
saveEnabled: $saveEnabled,
saveTriggered: $saveTriggered,
saveEnabled: $viewModel.saveButtonEnabled,
saveTriggered: $viewModel.saveTriggered,
showLoading: $showLoading,
titleText: Strings.Send()
)
}
}
.onAppear {
focusField = .details

viewModel.projectID = projectID
viewModel.projectFlaggingKind = projectFlaggingKind

viewModel.inputs.viewDidLoad()
viewModel.projectID.send(self.projectID)
viewModel.projectFlaggingKind.send(self.projectFlaggingKind)
}
.onChange(of: details) { detailsText in
viewModel.detailsText.send(detailsText)
}
.onChange(of: saveTriggered) { triggered in
focusField = nil
showLoading = triggered
viewModel.saveTriggered.send(triggered)
}
.onChange(of: bannerMessage) { newValue in
.onReceive(viewModel.$bannerMessage) { newValue in
showLoading = false

/// bannerMessage is set to nil when its done presenting. When it is done, and submit was successful, dismiss this view.
if newValue == nil, self.submitSuccess {
if newValue == nil, viewModel.submitSuccess {
dismiss()
popToRoot = true
} else if newValue?.bannerBackgroundColor == Color(.ksr_alert) {
saveEnabled = true
}
}
.onReceive(viewModel.saveButtonEnabled) { newValue in
saveEnabled = newValue
}
.onReceive(viewModel.submitSuccess) { _ in
submitSuccess = true
}
.onReceive(viewModel.retrievedEmail) { email in
retrievedEmail = email
}
.onReceive(viewModel.bannerMessage) { newValue in
showLoading = false
saveEnabled = false
bannerMessage = newValue
.onReceive(viewModel.$saveTriggered) { triggered in
showLoading = triggered
}
.overlay(alignment: .bottom) {
MessageBannerView(viewModel: $bannerMessage)
MessageBannerView(viewModel: $viewModel.bannerMessage)
.frame(
minWidth: proxy.size.width,
idealWidth: proxy.size.width,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,10 @@ struct RowView: View {
NavigationLink(
destination: {
ReportProjectFormView(
popToRoot: $popToRoot,
projectID: self.projectID,
projectURL: self.projectUrl,
projectFlaggingKind: item.flaggingKind ?? GraphAPI.FlaggingKind.guidelinesViolation,
popToRoot: $popToRoot
projectFlaggingKind: item.flaggingKind ?? GraphAPI.FlaggingKind.guidelinesViolation
)
},
label: { BaseRowView(item: item) }
Expand Down
1 change: 1 addition & 0 deletions Kickstarter-iOS/SharedViews/LoadingBarButtonItem.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Library
import SwiftUI

// TODO(MBL-1039) - Refactor this so that saveTriggered takes a closure, not a binding
struct LoadingBarButtonItem: View {
@Binding var saveEnabled: Bool
@Binding var saveTriggered: Bool
Expand Down
16 changes: 16 additions & 0 deletions Kickstarter.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1492,6 +1492,8 @@
E1A149222ACE013100F49709 /* FetchProjectsEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A149212ACE013100F49709 /* FetchProjectsEnvelope.swift */; };
E1A149242ACE02B300F49709 /* FetchProjectsEnvelope+FetchBackerProjectsQueryDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A149232ACE02B300F49709 /* FetchProjectsEnvelope+FetchBackerProjectsQueryDataTests.swift */; };
E1A149272ACE063400F49709 /* FetchBackerProjectsQueryDataTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A149262ACE063400F49709 /* FetchBackerProjectsQueryDataTemplate.swift */; };
E1AA8ABF2AEABBB100AC98BF /* Signal+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EA34EE2AE1B28400942A04 /* Signal+Combine.swift */; };
E1FDB1E82AEAAC6100285F93 /* CombineTestObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FDB1E72AEAAC6100285F93 /* CombineTestObserver.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -3041,6 +3043,8 @@
E1A149212ACE013100F49709 /* FetchProjectsEnvelope.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchProjectsEnvelope.swift; sourceTree = "<group>"; };
E1A149232ACE02B300F49709 /* FetchProjectsEnvelope+FetchBackerProjectsQueryDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FetchProjectsEnvelope+FetchBackerProjectsQueryDataTests.swift"; sourceTree = "<group>"; };
E1A149262ACE063400F49709 /* FetchBackerProjectsQueryDataTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchBackerProjectsQueryDataTemplate.swift; sourceTree = "<group>"; };
E1EA34EE2AE1B28400942A04 /* Signal+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Signal+Combine.swift"; sourceTree = "<group>"; };
E1FDB1E72AEAAC6100285F93 /* CombineTestObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineTestObserver.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -6486,6 +6490,7 @@
D01588281EEB2ED7006E7684 /* ServiceTests.swift */,
D01588291EEB2ED7006E7684 /* ServiceType.swift */,
D015882A1EEB2ED7006E7684 /* ServiceTypeTests.swift */,
E1AA8ABE2AEABB1900AC98BF /* combine */,
D01587691EEB2ED6006E7684 /* extensions */,
8ADCCDAA2656BC020079D308 /* fragments */,
D015876B1EEB2ED6006E7684 /* lib */,
Expand Down Expand Up @@ -6827,6 +6832,15 @@
path = templates;
sourceTree = "<group>";
};
E1AA8ABE2AEABB1900AC98BF /* combine */ = {
isa = PBXGroup;
children = (
E1EA34EE2AE1B28400942A04 /* Signal+Combine.swift */,
E1FDB1E72AEAAC6100285F93 /* CombineTestObserver.swift */,
);
path = combine;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXHeadersBuildPhase section */
Expand Down Expand Up @@ -8427,6 +8441,7 @@
D0158A1E1EEB30A2006E7684 /* ProjectStatsEnvelope.FundingDateStatsTemplates.swift in Sources */,
D015899B1EEB2ED7006E7684 /* Service.swift in Sources */,
47D7D09A26C2EE5800D2BAB5 /* SignInWithAppleEnvelope+SignInWithAppleMutation.Data.swift in Sources */,
E1FDB1E82AEAAC6100285F93 /* CombineTestObserver.swift in Sources */,
D6ED1B39216D50BE007F7547 /* UserEmailFields.swift in Sources */,
06232D3F2795EC3000A81755 /* TextNode+Helpers.swift in Sources */,
D01588731EEB2ED7006E7684 /* FindFriendsEnvelope.swift in Sources */,
Expand Down Expand Up @@ -8573,6 +8588,7 @@
D015886B1EEB2ED7006E7684 /* DiscoveryParams.swift in Sources */,
D755ECAB232005A70096F189 /* Checkout.swift in Sources */,
8A4E953B2450FE1500A578CF /* Money.swift in Sources */,
E1AA8ABF2AEABBB100AC98BF /* Signal+Combine.swift in Sources */,
4758485026B32110005AAC1C /* GraphAPI.BackingState+BackingState.swift in Sources */,
D01588BF1EEB2ED7006E7684 /* ProjectStatsEnvelope.VideoStatsLenses.swift in Sources */,
8AF34C802343C41C000B211D /* UpdateBackingEnvelope.swift in Sources */,
Expand Down
22 changes: 22 additions & 0 deletions KsApi/MockService.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#if DEBUG
import Combine
import Foundation
import Prelude
import ReactiveSwift
Expand Down Expand Up @@ -617,6 +618,18 @@
return client.performWithResult(mutation: mutation, result: self.createFlaggingResult)
}

internal func createFlaggingInputCombine(input: CreateFlaggingInput)
-> AnyPublisher<EmptyResponseEnvelope, ErrorEnvelope> {
guard let client = self.apolloClient else {
return Empty(completeImmediately: false).eraseToAnyPublisher()
}

let mutation = GraphAPI
.CreateFlaggingMutation(input: GraphAPI.CreateFlaggingInput.from(input))

return client.performWithResult(mutation: mutation, result: self.createFlaggingResult)
}

internal func createPassword(input: CreatePasswordInput)
-> SignalProducer<EmptyResponseEnvelope, ErrorEnvelope> {
guard let client = self.apolloClient else {
Expand Down Expand Up @@ -835,6 +848,15 @@
return client.fetchWithResult(query: fetchGraphUserEmailQuery, result: self.fetchGraphUserEmailResult)
}

func fetchGraphUserEmailCombine() -> AnyPublisher<UserEnvelope<GraphUserEmail>, ErrorEnvelope> {
guard let client = self.apolloClient else {
return Empty(completeImmediately: false).eraseToAnyPublisher()
}

let fetchGraphUserEmailQuery = GraphAPI.FetchUserEmailQuery()
return client.fetchWithResult(query: fetchGraphUserEmailQuery, result: self.fetchGraphUserEmailResult)
}

// TODO: Refactor this test to use `self.apolloClient`, `ErroredBackingsEnvelope` needs to be `Decodable` and tested in-app.
internal func fetchErroredUserBackings(status _: BackingState)
-> SignalProducer<ErroredBackingsEnvelope, ErrorEnvelope> {
Expand Down
36 changes: 36 additions & 0 deletions KsApi/Service.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Apollo
import Combine
import Foundation
import Prelude
import ReactiveExtensions
Expand Down Expand Up @@ -160,6 +161,17 @@ public struct Service: ServiceType {
}
}

public func createFlaggingInputCombine(input: CreateFlaggingInput)
-> AnyPublisher<EmptyResponseEnvelope, ErrorEnvelope> {
return GraphQL.shared.client
.perform(mutation: GraphAPI
.CreateFlaggingMutation(input: GraphAPI.CreateFlaggingInput.from(input)))
.map { _ in
EmptyResponseEnvelope()
}
.eraseToAnyPublisher()
}

public func createPassword(input: CreatePasswordInput)
-> SignalProducer<EmptyResponseEnvelope, ErrorEnvelope> {
return GraphQL.shared.client
Expand Down Expand Up @@ -351,6 +363,30 @@ public struct Service: ServiceType {
.flatMap(UserEnvelope<GraphUserEmail>.envelopeProducer(from:))
}

public func fetchGraphUserEmailCombine()
-> AnyPublisher<UserEnvelope<GraphUserEmail>, ErrorEnvelope> {
GraphQL.shared.client
.fetch(query: GraphAPI.FetchUserEmailQuery())
// TODO: make this a custom extension, we'll want to reuse this pattern
.tryMap { (data: GraphAPI.FetchUserEmailQuery.Data) -> UserEnvelope<GraphUserEmail> in
guard let envelope = UserEnvelope<GraphUserEmail>.userEnvelope(from: data) else {
throw ErrorEnvelope.couldNotParseJSON
}

return envelope
}
.mapError { rawError in

if let error = rawError as? ErrorEnvelope {
return error
}

return ErrorEnvelope.couldNotParseJSON
}

.eraseToAnyPublisher()
}

public func fetchGraphUserSelf()
-> SignalProducer<UserEnvelope<User>, ErrorEnvelope> {
return GraphQL.shared.client
Expand Down
7 changes: 7 additions & 0 deletions KsApi/ServiceType.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Combine
import Prelude
import ReactiveSwift
import UIKit
Expand Down Expand Up @@ -78,6 +79,9 @@ public protocol ServiceType {
func createFlaggingInput(input: CreateFlaggingInput)
-> SignalProducer<EmptyResponseEnvelope, ErrorEnvelope>

func createFlaggingInputCombine(input: CreateFlaggingInput)
-> AnyPublisher<EmptyResponseEnvelope, ErrorEnvelope>

/// Creates the password on a user account
func createPassword(input: CreatePasswordInput) ->
SignalProducer<EmptyResponseEnvelope, ErrorEnvelope>
Expand Down Expand Up @@ -173,6 +177,9 @@ public protocol ServiceType {
func fetchGraphUserEmail()
-> SignalProducer<UserEnvelope<GraphUserEmail>, ErrorEnvelope>

func fetchGraphUserEmailCombine()
-> AnyPublisher<UserEnvelope<GraphUserEmail>, ErrorEnvelope>

/// Fetches GraphQL user fragment and returns User instance.
func fetchGraphUserSelf()
-> SignalProducer<UserEnvelope<User>, ErrorEnvelope>
Expand Down
17 changes: 17 additions & 0 deletions KsApi/combine/CombineTestObserver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Combine
import Foundation

public final class CombineTestObserver<Value, Error: Swift.Error> {
public private(set) var events: [Value] = []
private var subscriptions = Set<AnyCancellable>()

public func observe(_ publisher: any Publisher<Value, Error>) {
publisher.sink { _ in
// TODO(MBL-1017) implement this as part of writing a new test observer for Combine
fatalError("Errors haven't been handled here yet.")
} receiveValue: { [weak self] value in
self?.events.append(value)
}
.store(in: &self.subscriptions)
}
}
18 changes: 18 additions & 0 deletions KsApi/combine/Signal+Combine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Combine
import Foundation
import ReactiveSwift

extension Signal where Error == Never {
var combinePublisher: AnyPublisher<Value, Never> {
let subject = PassthroughSubject<Value, Never>()
self.observeValues { value in
subject.send(value)
}

return subject.eraseToAnyPublisher()
}

public func assign(toCombine published: inout Published<Value>.Publisher) {
self.combinePublisher.assign(to: &published)
}
}
Loading

0 comments on commit 36ea6fe

Please sign in to comment.