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

MBL-1014: Replace ReactiveSwift in ReportProjectFormViewModel with Combine #1873

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@
}

struct ReportProjectFormView: View {
@Binding var popToRoot: Bool
let projectID: String
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved these properties into the view model for two reasons:

  1. The view model needs projectId and projectFlaggingKind to submit the report project mutation
  2. Just in general, it seems more MVVM-y to me to have the view model own this state, instead of the view

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I mentioned in a comment below that we could consider injecting the VM with these properties from the outset instead of having the view be something of a middleman here.

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()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@scottkicks OK, just learned something interesting. In a truly react-like way, ReportProjectFormView may be initialized many times - any time something calling it in a closure re-renders, for example. That means that ReportProjectFormViewModel was being initialized multiple times, which is a performance drag (and in the case of some re-renders, could also potentially mean we don't have the correct instance of the viewmodel). Apparently the solution is that the View should own the ViewModel using the @StateObject wrapper, which ensures that the view model is only instantiated once for the entire lifecycle of our view.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also means we can't pass in state directly on init of the viewmodel, or pass the viewmodel directly into the view...so I set some properties on the viewmodel in onAppear.


@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?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that the view model is using Combine, we can bind the state of the view model directly to the SwiftUI view. That means we don't need these extra state variables in the view, or any of the onReceive code to keep them up-to-date.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your point. This pattern makes me wonder what the benefit of @State is. In my experience, SwiftUI views typically handle UI specific state, for the most part, and then only pass what the VM needs when it's needed. Once save is pressed for example. I like the idea of having the VM manage non UI specific state like saveEnabled, bannerMessage, possibly showLoading, etc., but does it make sense for it to own the more UI specific state like retrievedEmail and details?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think that's a really interesting question, and I'm not sure I have a great answer for it, either. The way we've structured things here, the viewmodel actually has no knowledge of the view directly, so at there would be no way for it to even ask the view for its detailsText.

I think @Binding kind of raises some interesting questions about ownership in general, since it's really a two-way relationship between the model and the UI. It would be really interesting to try and find some other examples of MVVM in SwiftUI and see what other patterns folks have cooked up.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At present, my understanding of @State is that it's for a variable that is owned by a view, and/or used by its subviews. At first glance to me, it seems as if we don't have much in the way of state that is ever truly owned by a view - unless it was something like, say, a tab collapsing/expanding.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this - the default should be for the viewmodel to own things (unless it's literally something that only affects the UI). Any time logic can live in the viewmodel instead of the view, it should.

@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())
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this because it makes the UI feel a little smoother. When the e-mail loads, the layout of the UI won't flicker.

.font(Font(UIFont.ksr_body()))
.foregroundColor(Color(.ksr_support_400))
.italic()
.disabled(true)

Check warning on line 35 in Kickstarter-iOS/Features/ReportProject/ReportProjectFormView.swift

View check run for this annotation

Codecov / codecov/patch

Kickstarter-iOS/Features/ReportProject/ReportProjectFormView.swift#L31-L35

Added lines #L31 - L35 were not covered by tests
}
}

Expand All @@ -45,7 +44,7 @@
}

SwiftUI.Section {
TextEditor(text: $details)
TextEditor(text: $viewModel.detailsText)

Check warning on line 47 in Kickstarter-iOS/Features/ReportProject/ReportProjectFormView.swift

View check run for this annotation

Codecov / codecov/patch

Kickstarter-iOS/Features/ReportProject/ReportProjectFormView.swift#L47

Added line #L47 was not covered by tests
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's an example of a two-way binding - TextEditor can mutate detailsText, which will update that property in the viewmodel

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My only thought here is that the view could manage state like this until it is ready to be passed to the view model in its final form

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could see us passing state back to the viewmodel with an input method like didSubmitReport(withDetailsText detailsText: String). But this also raises another question, which is whether or not it would work with combine and buttonEnabled.

Based on what I understand right now - a @State variable on a View is not a publisher. Right now we're doing this in the viewmodel:

    self.$detailsText
      .map { !$0.isEmpty }
      .assign(to: &$saveButtonEnabled)

but if detailsText is a @State and not @Published, this pattern doesn't work.

Playing with how this is organized seems like a really good thing to mess around with during a pairing session, it'd be interesting to see what you mean and figure out how else it might work.

.frame(minHeight: 75)
.font(Font(UIFont.ksr_body()))
.focused($focusField, equals: .details)
Expand All @@ -59,52 +58,35 @@
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
LoadingBarButtonItem(
saveEnabled: $saveEnabled,
saveTriggered: $saveTriggered,
saveEnabled: $viewModel.saveButtonEnabled,
saveTriggered: $viewModel.saveTriggered,

Check warning on line 62 in Kickstarter-iOS/Features/ReportProject/ReportProjectFormView.swift

View check run for this annotation

Codecov / codecov/patch

Kickstarter-iOS/Features/ReportProject/ReportProjectFormView.swift#L61-L62

Added lines #L61 - L62 were not covered by tests
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is an interesting one - I'm not actually sure that it makes sense for a triggered action to be a binding like this. Right now, we're modeling saveTriggered as an event stream of Bools. For example, if you press the button, you'll get the following stream:

false - initial state
true - the button was pressed
false - we set it back to false once the loading is done

However, it's not really a state, it's more something that happens. It would make more sense to me to model it internally as a stream of voids, where only one event occurs when the button is pressed.

I actually like Apple's pattern here with the underlying View class Button, which takes a closure that is executed when the button is pressed. This would let us follow more of our old input/output pattern that we used with UIKit:

LoadingBarButtonItem(
  saveEnabled: $saveEnabled,
  showLoading: $showLoading,
  titleText: Strings.Send()
) {
  viewModel.didPressSaveButton()
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout. This makes more sense to me too!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the closure approach better, too!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, I'll throw myself a quick ticket to refactor this. Just to keep this PR a bit more svelte.

showLoading: $showLoading,
titleText: Strings.Send()
)
}
}
.onAppear {
focusField = .details

viewModel.projectID = projectID
viewModel.projectFlaggingKind = projectFlaggingKind

Check warning on line 72 in Kickstarter-iOS/Features/ReportProject/ReportProjectFormView.swift

View check run for this annotation

Codecov / codecov/patch

Kickstarter-iOS/Features/ReportProject/ReportProjectFormView.swift#L71-L72

Added lines #L71 - L72 were not covered by tests

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

Check warning on line 77 in Kickstarter-iOS/Features/ReportProject/ReportProjectFormView.swift

View check run for this annotation

Codecov / codecov/patch

Kickstarter-iOS/Features/ReportProject/ReportProjectFormView.swift#L77

Added line #L77 was not covered by tests
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be cleaner to move the banner logic into the ViewModel, so that the submit, banner display, timeout, and dismiss all happen in the same place. However, that was going to be a bigger piece of refactoring, so I left this as-is.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, that could be cleaner. What if we moved showLoading along with it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think that'd be good too


/// 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

Check warning on line 86 in Kickstarter-iOS/Features/ReportProject/ReportProjectFormView.swift

View check run for this annotation

Codecov / codecov/patch

Kickstarter-iOS/Features/ReportProject/ReportProjectFormView.swift#L86

Added line #L86 was not covered by tests
}
.overlay(alignment: .bottom) {
MessageBannerView(viewModel: $bannerMessage)
MessageBannerView(viewModel: $viewModel.bannerMessage)

Check warning on line 89 in Kickstarter-iOS/Features/ReportProject/ReportProjectFormView.swift

View check run for this annotation

Codecov / codecov/patch

Kickstarter-iOS/Features/ReportProject/ReportProjectFormView.swift#L89

Added line #L89 was not covered by tests
.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 @@
NavigationLink(
destination: {
ReportProjectFormView(
popToRoot: $popToRoot,

Check warning on line 130 in Kickstarter-iOS/Features/ReportProject/ReportProjectInfoView.swift

View check run for this annotation

Codecov / codecov/patch

Kickstarter-iOS/Features/ReportProject/ReportProjectInfoView.swift#L130

Added line #L130 was not covered by tests
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()

Check warning on line 624 in KsApi/MockService.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/MockService.swift#L624

Added line #L624 was not covered by tests
}

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

Check warning on line 628 in KsApi/MockService.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/MockService.swift#L627-L628

Added lines #L627 - L628 were not covered by tests

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()

Check warning on line 853 in KsApi/MockService.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/MockService.swift#L853

Added line #L853 was not covered by tests
}

let fetchGraphUserEmailQuery = GraphAPI.FetchUserEmailQuery()

Check warning on line 856 in KsApi/MockService.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/MockService.swift#L856

Added line #L856 was not covered by tests
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 func createFlaggingInputCombine(input: CreateFlaggingInput)
-> AnyPublisher<EmptyResponseEnvelope, ErrorEnvelope> {
return GraphQL.shared.client
.perform(mutation: GraphAPI
.CreateFlaggingMutation(input: GraphAPI.CreateFlaggingInput.from(input)))

Check warning on line 168 in KsApi/Service.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/Service.swift#L166-L168

Added lines #L166 - L168 were not covered by tests
.map { _ in
EmptyResponseEnvelope()

Check warning on line 170 in KsApi/Service.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/Service.swift#L170

Added line #L170 was not covered by tests
}
.eraseToAnyPublisher()

Check warning on line 172 in KsApi/Service.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/Service.swift#L172

Added line #L172 was not covered by tests
}

public func createPassword(input: CreatePasswordInput)
-> SignalProducer<EmptyResponseEnvelope, ErrorEnvelope> {
return GraphQL.shared.client
Expand Down Expand Up @@ -351,6 +363,30 @@
.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

Check warning on line 370 in KsApi/Service.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/Service.swift#L368-L370

Added lines #L368 - L370 were not covered by tests
.tryMap { (data: GraphAPI.FetchUserEmailQuery.Data) -> UserEnvelope<GraphUserEmail> in
guard let envelope = UserEnvelope<GraphUserEmail>.userEnvelope(from: data) else {
throw ErrorEnvelope.couldNotParseJSON

Check warning on line 373 in KsApi/Service.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/Service.swift#L373

Added line #L373 was not covered by tests
}

return envelope
}
.mapError { rawError in

if let error = rawError as? ErrorEnvelope {
return error

Check warning on line 381 in KsApi/Service.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/Service.swift#L381

Added line #L381 was not covered by tests
}

return ErrorEnvelope.couldNotParseJSON
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not clear if there would be a better error to use here. This would be the case when something in userEnvelope(from: data) threw an exception.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. We could probably use a more verbose error. It looks like the rest of the app currently returns this couldNotParseJSON enveloper

}

.eraseToAnyPublisher()

Check warning on line 387 in KsApi/Service.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/Service.swift#L387

Added line #L387 was not covered by tests
}

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> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's another ticket for fleshing this out. This is just the barest-bones stub to get the tests working.

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

Check warning on line 10 in KsApi/combine/CombineTestObserver.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/combine/CombineTestObserver.swift#L10

Added line #L10 was not covered by tests
fatalError("Errors haven't been handled here yet.")
} receiveValue: { [weak self] value in
self?.events.append(value)

Check warning on line 13 in KsApi/combine/CombineTestObserver.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/combine/CombineTestObserver.swift#L13

Added line #L13 was not covered by tests
}
.store(in: &self.subscriptions)

Check warning on line 15 in KsApi/combine/CombineTestObserver.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/combine/CombineTestObserver.swift#L15

Added line #L15 was not covered by tests
}
}
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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was an experiment in bridging code that I'm not using any more. Kind of nifty though.

var combinePublisher: AnyPublisher<Value, Never> {
let subject = PassthroughSubject<Value, Never>()

Check warning on line 7 in KsApi/combine/Signal+Combine.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/combine/Signal+Combine.swift#L7

Added line #L7 was not covered by tests
self.observeValues { value in
subject.send(value)

Check warning on line 9 in KsApi/combine/Signal+Combine.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/combine/Signal+Combine.swift#L9

Added line #L9 was not covered by tests
}

return subject.eraseToAnyPublisher()

Check warning on line 12 in KsApi/combine/Signal+Combine.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/combine/Signal+Combine.swift#L12

Added line #L12 was not covered by tests
}

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

Check warning on line 16 in KsApi/combine/Signal+Combine.swift

View check run for this annotation

Codecov / codecov/patch

KsApi/combine/Signal+Combine.swift#L16

Added line #L16 was not covered by tests
}
}