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

Show Loader when adding Workspace Edit #4801

Merged
merged 20 commits into from
Sep 9, 2021

Conversation

mananjadhav
Copy link
Collaborator

@mananjadhav mananjadhav commented Aug 24, 2021

Details

  • Add loading flags for Avatar upload and form submission
  • IMP: Added isUploading flag to AvatarWithImagePicker with uploadIndicator
  • Replaced js loop to Animated.loop

Fixed Issues

$ #4487

Tests

  1. Tested by keeping the form contents the same
  2. Updating the workspace name and image
  3. Removing the workspace image
  4. Submitting the form successfully
  5. Uploading a large image

QA Steps

  1. Go to Workspace and click on the Avatar
  2. Check button state when the values are not updated
  3. Change the content/upload the image and see if values are updated
  4. Test Removing the workspace image as well
  5. Submitting the form successfully
  6. Uploading a large image

Tested On

  • Web
  • Mobile Web
  • Desktop
  • iOS
  • Android

Screenshots

Web

v1
https://user-images.githubusercontent.com/3069065/131385161-e70fc702-9ef7-4515-bacd-86ac728d5c03.mp4

Updated
https://user-images.githubusercontent.com/3069065/132609914-524caa8b-ead7-4ec1-97c4-6288d6575dd7.mp4

Mobile Web

v1
https://user-images.githubusercontent.com/3069065/131384341-a5d4da09-e5e7-4807-8cf9-3724c797d2c6.mp4

Updated
https://user-images.githubusercontent.com/3069065/132610024-500c89e5-83d0-4c05-91c6-61688fd223c1.mp4

Desktop

v1
https://user-images.githubusercontent.com/3069065/131384466-c488f178-844d-471b-b9b6-9ca68fa5de65.mp4

Updated
https://user-images.githubusercontent.com/3069065/132610083-e81b6468-c976-4ec2-83cc-f7932de947f3.mp4

iOS

v1
https://user-images.githubusercontent.com/3069065/131384510-c38701d8-74ae-49c2-a116-e50e2fdc19a5.mp4

Updated
https://user-images.githubusercontent.com/3069065/132610283-1a851e62-640f-496d-86d2-83376a6e7125.mp4

Android

v1
https://user-images.githubusercontent.com/3069065/131384679-c6b796b0-f49e-4e76-8d48-3991d2cac241.mp4

Updated
https://user-images.githubusercontent.com/3069065/132610438-29e4b86c-81aa-45fc-ad41-7d77e0ba6c92.mp4

@mananjadhav
Copy link
Collaborator Author

mananjadhav commented Aug 24, 2021

@roryabraham I've raised a draft PR. Need to clarify a couple of things:

  1. Should we throw error messages on any form error or avatar upload? or even a success message?
  2. I've noticed when we upload an Avatar image, there isn't any feedback shown to the user. For now, what I've done is added an isUploading flag to AvatarWithImagePicker which shows the Sync icon instead of Pencil when set true. While it did solve the purpose, it doesn't look great. I would recommend an animation similar to AvatartWithIndicator. Shall I go ahead and add it?
  3. If we agree to do the loader thing in Fix spaces #2, a similar UX is missing for the Profile image update as well. Shall I cover that too in this PR if is a 🟢 for the above approach?

@roryabraham
Copy link
Contributor

Should we throw error messages on any form error or avatar upload? or even a success message

If there's a form error or API error, we should display a user-friendly error message. A success message would be good too, maybe using Growl or some other visual indication.

I've noticed when we upload an Avatar image, there isn't any feedback shown to the user. For now, what I've done is added an isUploading flag to AvatarWithImagePicker which shows the Sync icon instead of Pencil when set true. While it did solve the purpose, it doesn't look great. I would recommend an animation similar to AvatartWithIndicator. Shall I go ahead and add it?

I'm not sure. I'll defer to @Expensify/design here

If we agree to do the loader thing in Fix spaces #2, a similar UX is missing for the Profile image update as well. Shall I cover that too in this PR if is a 🟢 for the above approach?

I assume you meant item 2 of your list, not the PR linked in the comment. I would think yes 🟢, but again will defer to @Expensify/design here.


Lastly, I'll warn you that what I see in this PR so far violates the Expensify/App Philosophy, specifically UI Binds to Disk. My interpretation of that philosophy is that actions should never really be returning a promise – instead the UI binds to data in Onyx, and calls actions that manage the data. It should never be explicitly waiting for a response.

@mananjadhav
Copy link
Collaborator Author

Thanks for the clarification @roryabraham.

If there's a form error or API error, we should display a user-friendly error message. A success message would be good too, maybe using Growl or some other visual indication.

I'll put in a message.

I'm not sure. I'll defer to @Expensify/design here

I'll wait for the comments from the design team.

I assume you meant item 2 of your list, not the PR linked in the comment.

Yes. I meant point# 2 from my list. It seems GH linked to the PR due to #.

Lastly, I'll warn you that what I see in this PR so far violates the Expensify/App Philosophy, specifically UI Binds to Disk.

Thanks for highlighting. I only went ahead with the approach because the same file had the following line:

this.uploadAvatarPromise = uploadAvatar(image).then(url => new Promise((resolve) => {

So should I store these loading props to Onyx?

@roryabraham
Copy link
Contributor

So should I store these loading props to Onyx?

For now, I believe this is the approach we'll want to take.

@mananjadhav
Copy link
Collaborator Author

Hi Team, any updates on this on the design front?

@roryabraham
Copy link
Contributor

@mananjadhav In lieu of input from the design team, I'm going to go ahead and 🟢 both of these proposed changes:

I would recommend an animation similar to AvatartWithIndicator. Shall I go ahead and add it?

And

If we agree to do the loader thing in Fix spaces #2, a similar UX is missing for the Profile image update as well. Shall I cover that too in this PR if is a 🟢 for the above approach?

@mananjadhav
Copy link
Collaborator Author

Noted. Thanks for the response @roryabraham. Will update the PR.

@mananjadhav
Copy link
Collaborator Author

@roryabraham I've updated the PR with the following changes:

  1. Growl messages in catch block
  2. Moving all flags to Onyx
  3. Uploading Indicator in the AvatarImagePicker

I haven't added Growl on success because immediately after the policy is saved, we redirect to workspace card with updated info. Let me know if you still feel we should add a Growl message for success.

@mananjadhav mananjadhav marked this pull request as ready for review August 30, 2021 18:41
@mananjadhav mananjadhav requested a review from a team as a code owner August 30, 2021 18:41
@MelvinBot MelvinBot requested review from stitesExpensify and removed request for a team August 30, 2021 18:41
@roryabraham
Copy link
Contributor

@mananjadhav No growl on success sounds fine to me 👍

this.setState({previewAvatarURL: image.uri});

// Store the upload avatar promise so we can wait for it to finish before updating the policy
this.uploadAvatarPromise = uploadAvatar(image).then(url => new Promise((resolve) => {
updateLocalPolicyValues(this.props.policy.id, {isAvatarUploading: false});
Copy link
Contributor

Choose a reason for hiding this comment

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

We can prevent repeating this line by putting in in an always() right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Can you elaborate this please?

Copy link
Contributor

Choose a reason for hiding this comment

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

Can we do a chain like

uploadAvatar(image).then(<stuff>).catch(<stuff>).always(updateLocalPolicyValues(this.props.policy.id, {isAvatarUploading: false});)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Got it. Sure. Will update.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done

src/pages/workspace/WorkspaceEditorPage.js Show resolved Hide resolved
@mananjadhav
Copy link
Collaborator Author

@stitesExpensify @roryabraham I've updated the PR. Additionally, I've also modified the size of the indicator to get it closed to the Avatar and adjusted the icon size.

Screen.Recording.2021-09-03.at.1.23.26.AM.mov

@mananjadhav
Copy link
Collaborator Author

PR updated

roryabraham
roryabraham previously approved these changes Sep 5, 2021
Copy link
Contributor

@roryabraham roryabraham left a comment

Choose a reason for hiding this comment

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

Thanks @mananjadhav, looks good. If you wanted to update AvatarWithIndicator to use Animated.loop for consistency and a small performance boost that would be great!

Someday I hope we can switch over to using Reanimated instead of React Native's Animated. It comes with much better animation performance and in my opinion a simpler API. But that's a conversation for another day 😄

@mananjadhav
Copy link
Collaborator Author

I’ll update the AvatarWithIndicator too.

Yeah I’ve worked on Reanimated v1. But I’ve heard Reanimated V2 last year reduces a lot of boilerplate code too.

@mananjadhav
Copy link
Collaborator Author

mananjadhav commented Sep 5, 2021

PR updated with AvatarWithIndicator Animation.loop change

Copy link
Contributor

@roryabraham roryabraham left a comment

Choose a reason for hiding this comment

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

Okay, I apologize for not noticing these things earlier, but I have a few more comments:

Start both animations in parallel

I just noticed that there are two animations that are basically happening in parallel, so we should just use Animated.parallel, something like this:

/**
 * Start Animation for Indicator
 *
 * @memberof AvatarWithImagePicker
 */
start() {
    Animated.loop(
        Animated.parallel([
            Animated.timing(this.rotate, {
                toValue: 1,
                duration: 2000,
                easing: Easing.linear,
                isInteraction: false,
                useNativeDriver: true,
            }),
            Animated.spring(this.scale, {
                toValue: 1.666,
                tension: 1,
                isInteraction: false,
                useNativeDriver: true,
            }),
        ]),
    ).start();
}

/**
 * Stop Animation for Indicator
 *
 * @memberof AvatarWithImagePicker
 */
stop() {
    Animated.spring(this.scale, {
        toValue: 1,
        tension: 1,
        isInteraction: false,
        useNativeDriver: true,
    }).start(() => {
        this.rotate.resetAnimation();
        this.scale.resetAnimation();
        this.rotate.setValue(0);
    });
}

DRY up animation code

To do this, I think we should:

  1. Create a new file at src/styles/animations/SpinningIndicatorAnimation.js

  2. That file should export a single class called SpinningIndicatorAnimation:

      import {Animated, Easing} from 'react-native';
    
      class SpinningIndicatorAnimation {
          constructor() {
              this.rotate = new Animated.Value(0);
              this.scale = new Animated.Value(1);
              this.start = this.start.bind(this);
              this.stop = this.stop.bind(this);
              this.getSyncingStyles = this.getSyncingStyles.bind(this);
          }
      
          /**
           * Start Animation for Indicator
           *
           * @memberof AvatarWithImagePicker
           */
          start() {
              Animated.loop(
                  Animated.parallel([
                      Animated.timing(this.rotate, {
                          toValue: 1,
                          duration: 2000,
                          easing: Easing.linear,
                          isInteraction: false,
                          useNativeDriver: true,
                      }),
                      Animated.spring(this.scale, {
                          toValue: 1.666,
                          tension: 1,
                          isInteraction: false,
                          useNativeDriver: true,
                      }),
                  ]),
              ).start();
          }
      
          /**
           * Stop Animation for Indicator
           *
           * @memberof AvatarWithImagePicker
           */
          stop() {
              Animated.spring(this.scale, {
                  toValue: 1,
                  tension: 1,
                  isInteraction: false,
                  useNativeDriver: true,
              }).start(() => {
                  this.rotate.resetAnimation();
                  this.scale.resetAnimation();
                  this.rotate.setValue(0);
              });
          }
      
          /**
           * Get Indicator Styles while animating
           *
           * @returns {Object}
           */
          getSyncingStyles() {
              return {
                  transform: [{
                      rotate: this.rotate.interpolate({
                          inputRange: [0, 1],
                          outputRange: ['0deg', '-360deg'],
                      }),
                  },
                  {
                      scale: this.scale,
                  }],
              };
          }
      }
      
      export default SpinningIndicatorAnimation;
  3. Then both AvatarWithImagePicker and AvatarWithIndicator can just do:

    constructor(props) {
        super(props);
        this.animation = new SpinningIndicatorAnimation();
        ...
        ...
    }

    And then replace all uses of the animation methods with just this.animation.start(), this.animation.stop(), or this.animation.getSyncingStyles.

Scaling issue

Lastly, I'm looking at the screenshots provided in the PR description, and it doesn't seem like the indicator is getting bigger when the animation starts. Instead, it actually looks like it's getting smaller and is outside the avatar circle.

@mananjadhav
Copy link
Collaborator Author

Okay, I'll check the Animation updates and implement them.

Lastly, I'm looking at the screenshots provided in the PR description, and it doesn't seem like the indicator is getting bigger > when the animation starts. Instead, it actually looks like it's getting smaller and is outside the avatar circle.

I had updated this comment but not the PR description. Let me update it in the PR description.

#4801 (comment)

@mananjadhav
Copy link
Collaborator Author

@roryabraham There's a comment in AvatarWithIndicator.startRotation,

We need to manually loop the animations as `useNativeDriver` does not work well with `Animated.loop`.

@mananjadhav
Copy link
Collaborator Author

mananjadhav commented Sep 7, 2021

@roryabraham I am updating the SpinnerAnimation and having the following issue with AvatarWithIndicator, which I can't reproduce in staging.new.expensify.com

If I add a couple of messages in Offline mode, and then disable throttling, the Avatar scale doesn't change back to 1. (stays 1.66 and hence it looks bigger), neither does the rotation stop. I've pushed my change and attaching the video of the issue.

Screen.Recording.2021-09-08.at.3.01.11.AM.mov

I am guessing it is because after this.animation.stop() the component isn't rendered and getIndicatorStyles don't have the updated values of this.scale and this.rotate. I also feel Animated.parallel use must be causing it. I am a little confused that I am unable to reproduce the same in the staging URL where we have the old Animation in place.


and a quick, FYI, the following class (not using Animated.parallel) works fine without any issues.

import {Animated, Easing} from 'react-native';

class SpinningIndicatorAnimation {
    constructor() {
        this.rotate = new Animated.Value(0);
        this.scale = new Animated.Value(1);
        this.startRotation = this.startRotation.bind(this);
        this.start = this.start.bind(this);
        this.stop = this.stop.bind(this);
        this.getSyncingStyles = this.getSyncingStyles.bind(this);
    }

    /**
     * We need to manually loop the animations as `useNativeDriver` does not work well with Animated.loop.
     *
     * @memberof AvatarWithIndicator
     */
    startRotation() {
        this.rotate.setValue(0);
        Animated.timing(this.rotate, {
            toValue: 1,
            duration: 2000,
            easing: Easing.linear,
            isInteraction: false,
            useNativeDriver: true,
        }).start(({finished}) => {
            if (finished) {
                this.startRotation();
            }
        });
    }


    /**
     * Start Animation for Indicator
     *
     * @memberof AvatarWithImagePicker
     * @memberof
     */
    start() {
        this.startRotation();
        Animated.spring(this.scale, {
            toValue: 1.666,
            tension: 1,
            isInteraction: false,
            useNativeDriver: true,
        }).start();
    }

    /**
     * Stop Animation for Indicator
     *
     * @memberof AvatarWithImagePicker
     */
    stop() {
        Animated.spring(this.scale, {
            toValue: 1,
            tension: 1,
            isInteraction: false,
            useNativeDriver: true,
        }).start(() => {
            this.rotate.resetAnimation();
            this.scale.resetAnimation();
            this.rotate.setValue(0);
        });
    }

    /**
     * Get Indicator Styles while animating
     *
     * @returns {Object}
     */
    getSyncingStyles() {
        return {
            transform: [{
                rotate: this.rotate.interpolate({
                    inputRange: [0, 1],
                    outputRange: ['0deg', '-360deg'],
                }),
            },
            {
                scale: this.scale,
            }],
        };
    }
}

export default SpinningIndicatorAnimation;



@mananjadhav
Copy link
Collaborator Author

@roryabraham Did you get a chance to look at my comments above?

Meanwhile I'll resolve the conflicts.

@roryabraham
Copy link
Contributor

@mananjadhav Regarding this comment:

We need to manually loop the animations as useNativeDriver does not work well with Animated.loop.

is there some observable problem that occurs when you use Animated.loop as I suggested above? I looked back at the PR which added that comment and there wasn't any additional context explaining the problem. AFAICT there's nothing that seems to go wrong when I switch to using Animated.loop locally, and it makes the code clearer and more performant. So I'd prefer to do that unless we can somehow expand on how "useNativeDriver does not work well with Animated.loop". Hopefully that makes sense!

We can skip the use of Animated.parallel though since you've found an issue with my suggested implementation.

@mananjadhav
Copy link
Collaborator Author

Well, I didn't find any issue on the emulator but I haven't been able to check on a real Android device. Let me see if I can test and accordingly update the PR.

@mananjadhav
Copy link
Collaborator Author

mananjadhav commented Sep 9, 2021

@roryabraham PR updated. I checked on a real Android device and it worked fine. So I am guessing it shouldn't be a problem.

I've also updated the videos with the latest testing of the indicator closer to the image with increased size.

Copy link
Contributor

@roryabraham roryabraham left a comment

Choose a reason for hiding this comment

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

Okay, thanks for making all those changes, this is looking great!

@roryabraham roryabraham merged commit bc035f5 into Expensify:main Sep 9, 2021
@OSBotify
Copy link
Contributor

OSBotify commented Sep 9, 2021

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@OSBotify
Copy link
Contributor

OSBotify commented Sep 9, 2021

🚀 Deployed to staging by @roryabraham in version: 1.0.95-2 🚀

platform result
🤖 android 🤖 success ✅
🖥 desktop 🖥 success ✅
🍎 iOS 🍎 success ✅
🕸 web 🕸 success ✅

@botify
Copy link

botify commented Sep 10, 2021

This has been deployed to production and is now subject to a 7-day regression period.
If no regressions arise, payment will be issued on 2021-09-17. 🎊

@OSBotify
Copy link
Contributor

🚀 Deployed to production by @roryabraham in version: 1.0.96-0 🚀

platform result
🤖 android 🤖 success ✅
🖥 desktop 🖥 success ✅
🍎 iOS 🍎 success ✅
🕸 web 🕸 success ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants