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

Issue/9739 domain contact form refactoring #9892

Merged
merged 22 commits into from
Jun 6, 2019

Conversation

khaykov
Copy link
Member

@khaykov khaykov commented May 21, 2019

This PR is a refactoring of a domain contact form.

I extracted the majority of the logic into ViewModel and added tests.
It's my first time working on ViewModel this big so I would appreciate insight on.. anything, really!
I have been staring on this code for too long, so there is a good chance I missed something.

The PR is giant, and I'm not really sure how to make it smaller since there is a lot of things going behind the scene. Good thing half of it are tests!

To test:

Install the wasabi build.

  1. Switch to a site on a business plan with unclaimed domain credit (let me know if you need help with this).
  2. Navigate to Plugins screen, and from there to details of any plugin (like Woocommerce).
  3. Tap on Install button.
  4. In the dialog tap on "REGISTER DOMAIN".
  5. Pick a domain (preferably .blog).
  6. Tap on "CHOOSE DOMAIN" and navigate to contact form details.
  7. If you had contact information stored at WordPress.com it should be prefilled.
  8. All the fields apart from State and Organisation are required, and their emptiness is validated locally.
  9. The validity of input fields content is validated on backend one by one (eg. if you have two fields with malformed you will have to make two registration attempts to get errors for both of them).
  10. Make sure ToS link is clickable.
  11. Every time you change the country States are loaded from remote (some countries, like Germany, do not have states).
  12. Try to register the domain.
  13. PS: Follow domain testing guideline and make sure if you purchase a domain to cancel it.
  • If there are user facing changes, I have added an item to RELEASE-NOTES.txt.

Copy link
Contributor

@planarvoid planarvoid left a comment

Choose a reason for hiding this comment

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

Great job on the refactoring! 💯 I think it makes it so much more readable! Consider my suggestions and let me know what you think 👍

hideFormProgressIndicator()
}
private fun setupObservers() {
viewModel.formProgressIndicatorVisible.observe(this,
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd consider having all the UI state fields (not the events/dialogs) in one object. I think when you have this many fields, it might be more readable to have one object (UiModel?). It's easier to test because some of the fields might depend on each other and one object fully represents all the possible states of the UI (for example you can have a logic while building the UiModel that checks that only one progress indicator is visible). I'm not saying that there is anything wrong with your approach though 👍. Let me know what you think. Posting a change can be done like liveData.postValue(liveData.value.copy(updatedField = newValue)).

Copy link
Member Author

Choose a reason for hiding this comment

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

This makes sense! I tried this approach, and when updating tests I realized that I'm not really sure how to test the sequence of updates to UI state. For example, currently I can test that progress indicator was shown, then dismissed, and is currently invisible like this:

verify(formProgressIndicatorObserver, times(1)).onChanged(true)
verify(formProgressIndicatorObserver, times(1)).onChanged(false)
Assertions.assertThat(viewModel.formProgressIndicatorVisible.value).isEqualTo(false)

Do you know how to achieve similar coverage using UI state object (or is it even necessary?)

Copy link
Contributor

Choose a reason for hiding this comment

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

If your UI state object is a data class, you can do something like verify(observer, times(1)).onChanged(stateObject.copy(progressIndicator = true) (the times(1) is not required btw). You have to build the complete UI State object with all the fields though.

What I usually do when I want to test a sequence of objects is that I do something like

val results = mutableListOf<UiState>()
viewModel.uiState.observeForever { if (it != null) results.add(it) }

This way the results contain all the emitted UI states and you can assert anything you want.

Copy link
Member Author

Choose a reason for hiding this comment

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

Awesome, thank you! I took your approach (storing observer results) and it's way better - lets us test a snapshot of whole UI state at each step.

Copy link
Contributor

Choose a reason for hiding this comment

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

yep, that's exactly what I thought we should be doing :-)

domain_privacy_options_radiogroup.setOnCheckedChangeListener { _, _ ->
viewModel.togglePrivacyProtection(domain_privacy_on_radio_button.isChecked)
}
viewModel.privacyProtectionState.removeObserver(this)
Copy link
Contributor

Choose a reason for hiding this comment

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

would it be maybe better to always check the current value in the observer and only update the field if the value has changed? one other option we can consider is removing the on text changed/onclick/onchecked listener before updating the value but I think the first approach is better. The thing is that I think stopping the observer is not the best approach. The LiveData advantage are the continuous updates (even though they might not be necessary in this case). It's possible we might get into a state where the LiveData doesn't match the actual Ui State.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, it is absolutely better! And it works really well with 2-way data binding. Without it we have a problem with the data flow loop (?) - we toggle a radio button, listener assigned to it calls ViewModel, which notifies LiveData. Observer reacts to LiveData and toggles the radio button, which triggers a listener and calls ViewModel, etc. :) I replaced OnCheckedChangeListener with normal click listener, so it works as intended, but I still remove observer for the domainContactDetails data to avoid this (not sure how to avoid doing this).

Copy link
Contributor

Choose a reason for hiding this comment

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

What do you think about this approach:

val currentModel = contactFormToDomainContactModel()
                if (currentModel != domainContactModel) {
                    populateContactForm(domainContactModel!!)
                }

I'm not 100% sure this will always work but if it doesn't, we just have to make sure the fields are the same (because they will all go through the equals method). It should work because we'll be using the same method to collect the current data and updating the viewModel.domainContactDetails field.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ahh, got it! Yes, this will work :)

builder.setTitle(R.string.domain_registration_state_picker_dialog_title)
builder.setItems(states.map { it.name }.toTypedArray()) { _, which ->
if (targetFragment != null && targetFragment is OnStateSelectedListener) {
(targetFragment as OnStateSelectedListener).onStateSelected(states[which])
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm doing something very similar right now and I think it makes sense to create the ViewModel with the Activity context and fetch it here and set the field directly. What do you think? This way you can avoid fragment-fragment communication and you don't need the listeners.

Copy link
Member Author

Choose a reason for hiding this comment

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

This is super clever! I checked your implementation, and it works really well (I see tons of other uses for this too!) but it needs an adapter or something a bit more dynamic than the default implementation of the list in AlerDialog - I'm not sure how to update its content (from LiveData observer) after it is shown. Do you think it's ok to access LiveData value directly (without observer) in this case? This will make things a bit easier.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think your approach for the data is good 👍 . The only thing I was wondering about is the callback. Instead of casting the targetFragment and going through the fragment to the viewModel you can just inject the ViewModel.Factory and call directly the same ViewModel which handles the Fragment.

@planarvoid
Copy link
Contributor

really good changes @khaykov 👍 let me know what you think about my suggestions!

@loremattei loremattei modified the milestones: 12.6 ❄️, 12.7 Jun 3, 2019
@loremattei
Copy link
Contributor

Hey! I'm moving this to 12.7 since 12.6 has been cut. If you want it to land 12.6, please feel free to move it back, target the release branch and ping me to build a new beta.

@khaykov
Copy link
Member Author

khaykov commented Jun 5, 2019

Thanks for the suggestions, @planarvoid ! I learned tons of stuff from them 🙇

builder.show()
val dialogFragment = CountryPickerDialogFragment.newInstance(countries.toCollection(ArrayList()))
dialogFragment.setTargetFragment(this, 0)
dialogFragment.show(fragmentManager, CountryPickerDialogFragment.TAG)
}

private fun showFormProgressIndicator() {
Copy link
Contributor

@planarvoid planarvoid Jun 5, 2019

Choose a reason for hiding this comment

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

NIT: this is a detail but I think it would be fine to have only one method with a boolean parameter that would set the view to visible/gone. The parameter could be named so there is no confusion setFormProgressIndicatorVisibility(visible = ...)


var preloadStep = 0

Assertions.assertThat(uiStateResults.size).isEqualTo(5)
Copy link
Contributor

Choose a reason for hiding this comment

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

NIT: I'd do a static import here :-)

Assertions.assertThat(uiStateResults.size).isEqualTo(5)

// initial state
Assertions.assertThat(uiStateResults[preloadStep].isFormProgressIndicatorVisible).isEqualTo(false)
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it would be more readable to have the uiStateResults[preloadStep] extracted in a variable.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good idea 👍 I can get rid of the majority of comments that way.

Copy link
Contributor

@planarvoid planarvoid left a comment

Choose a reason for hiding this comment

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

Everything looks pretty awesome 💯 great job. I've left a few comments that are mostly about code style so I'm approving the code right now.

@planarvoid
Copy link
Contributor

The domain registration seems to work well so feel free to :shipit: once you implement or ignore my comments 👍

@nbradbury
Copy link
Contributor

Just a friendly reminder to please make sure to manually merge develop into this branch before you merge this PR, as develop now contains the AndroidX migration. The automatic merge can succeed but still break the build. Better to find out now before it breaks develop. Thanks!

…id into issue/9739-domain-contact-form-refactoring

# Conflicts:
#	WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java
#	WordPress/src/main/java/org/wordpress/android/ui/domains/DomainRegistrationDetailsFragment.kt
@peril-wordpress-mobile
Copy link

Warnings
⚠️ PR has more than 500 lines of code changing. Consider splitting into smaller PRs if possible.

Generated by 🚫 dangerJS

@khaykov
Copy link
Member Author

khaykov commented Jun 6, 2019

@planarvoid I implemented your suggestions, so feel free to merge if they look ok!

Copy link
Contributor

@planarvoid planarvoid left a comment

Choose a reason for hiding this comment

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

All looks good, thanks for the changes! :shipit:

@planarvoid planarvoid merged commit 4fab9e7 into develop Jun 6, 2019
@planarvoid planarvoid deleted the issue/9739-domain-contact-form-refactoring branch June 6, 2019 09:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants