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

FocalPointPicker: Allow updating its value while dragging. #38247

Merged

Conversation

razwan
Copy link
Contributor

@razwan razwan commented Jan 26, 2022

In one of my projects I've created a HOC that adds a snapping functionality to the FocalPointPicker component but after #28676 it stopped working.

The onDrag event is a nice perk that should make possible most of the things that were accomplishable before. However, there's a slight change that doesn't allow me to do what I want and that's checking to see if the user is dragging when the componentDidUpdate runs before updating state.

I can't really see why that's the better thing to do or is it that we could go ahead without it without any worries.
@stokesman, would you mind dropping some knowledge on me?

I would really appreciate any feedback. Thanks!

@razwan razwan requested a review from ajitbohra as a code owner January 26, 2022 16:24
@github-actions github-actions bot added the First-time Contributor Pull request opened by a first-time contributor to Gutenberg repository label Jan 26, 2022
@github-actions
Copy link

👋 Thanks for your first Pull Request and for helping build the future of Gutenberg and WordPress, @razwan! In case you missed it, we'd love to have you join us in our Slack community, where we hold regularly weekly meetings open to anyone to coordinate with each other.

If you want to learn more about WordPress development in general, check out the Core Handbook full of helpful information.

@stokesman
Copy link
Contributor

stokesman commented Jan 31, 2022

Hi @razwan, This is indeed how the logic was before #28676. I believe that I made that change thinking it would be a minor optimization and can't recall any critical function of it. I didn't imagine it could have been a breaking change but your case proves otherwise.

I think changing it back (as in 8705154) should be fine. Though perhaps it would be nicer to intentionally support such use cases. For example, if the doDrag function were to check for a return value from onDrag and if found use it to update the state. Then without even making an HOC, I think it would be possible to create the snapping functionality. What do you think?

Here's my quick take on showing what I mean with the doDrag function (changes only the last two statements):

	doDrag( event ) {
		// Prevents text-selection when dragging.
		event.preventDefault();
		const value = this.getValueFromPoint(
			{ x: event.pageX, y: event.pageY },
			event.shiftKey
		);
		const modifiedValue = this.props.onDrag?.( value, event );
		this.updateValue( modifiedValue ?? value );
	}

@razwan
Copy link
Contributor Author

razwan commented Feb 1, 2022

Hi @stokesman! Thanks for your feedback!

Even though it feels safer, I believe that the change you're proposing is somewhat obscure rather than expected behaviour. It's nonetheless a way of accessing the updateValue method and it's pretty clear once you take a look at the source code. However, what makes me believe that you're change is the way to go, is that my codebase would work as intended with only one prop added to the component (rather than caching the values inside onDrag and updating the value prop when needed)

onDrag={ maybeSnapFocalPoint }

@razwan
Copy link
Contributor Author

razwan commented Feb 1, 2022

I've updated the pull request. @ajitbohra could you take a look at this? Thanks!

@razwan
Copy link
Contributor Author

razwan commented Feb 1, 2022

@stokesman any idea on why this test fails?

@stokesman stokesman added [Package] Components /packages/components [Type] Enhancement A suggestion for improvement. labels Feb 1, 2022
@stokesman
Copy link
Contributor

That test failure is due the the implicit return of the event handlers in the test. This line:

handlers[ name ] = ( ...all ) => eventLogger( name, all );

Needs to add brackets around the function body:

	handlers[ name ] = ( ...all ) => {
 		eventLogger( name, all );
 	}

That does indicate this could be a breaking change for some consuming code inadvertently returning a value. I feel that is so unlikely that we don't need to worry about it but, if we do, guarding against it should be simple enough.

If this approach is deemed favorable, we'll want to update the docs for onDrag and add an entry to the changelog. Also, the title of the PR should be updated to reflect the approach. Again, all that can wait for a second opinion on the proposed change.

@ciampo, given this is component territory, I'll add you as a reviewer.

@stokesman stokesman requested a review from ciampo February 1, 2022 18:59
@ciampo ciampo requested a review from mirka February 2, 2022 16:06
@ciampo
Copy link
Contributor

ciampo commented Feb 2, 2022

Thank you for the ping, @stokesman ! @mirka or I will try to give this PR a look in the next days :)

@ciampo
Copy link
Contributor

ciampo commented Feb 4, 2022

Hey @stokesman and @razwan, I had a quick look at this PR (and #28676) and, if I understand correctly:

  • before Declunkify focal point picker dragging #28676, FocalPointPicker used to call the onChange callback continuously while the user was dragging with their pointer
  • since Declunkify focal point picker dragging #28676, the onChange callback is only called at the end of the drag interaction, and a new callback was added (onDrag)
  • @razwan's "snap" functionality relied on the onChange callback, and how quickly it updated. But with the changes mentioned above, the snapping functionality breakes

If what I understand is correct (from my limited knowledge of the FocalPointPicker component), I see a couple of potential solutions

  • increase the frequency at which onDrag is called — that means that @razwan could rely on the onDrag callback to achieve the snapping functionality
  • implement the snapping functionality directly into the component (e.g. by adding something like a snapGrid prop)

Please do let me know if what I'm proposing makes sense at all, or if I missed something while going through this PR!

@stokesman
Copy link
Contributor

Hi @ciampo, thanks for having a look. Your last solution suggestion makes sense but it seems the root of this issue wasn't clear. I'll try and clarify/summarize.

The change from #28676 that broke @razwan’s component was the addition to componentDidUpdate of a condition to avoid state updates from props while dragging:

if ( ! isDragging && ( value.x !== x || value.y !== y ) ) {
this.setState( { percentages: this.props.value } );
}

As the title of this PR still reflects, @razwan’s first commit 8705154 merely removed that condition. I agreed that it should be a safe change. Though I had the idea that we could ease the implementation of such a use case and suggested #38247 (comment) an alternate approach.

As I see it we have the following options (last one from @ciampo):

  • Remove the no-update-while-dragging condition 8705154
  • Add the update-state-by-optional-return-from-onDrag feature 467f3a3
  • implement the snapping functionality directly into the component (e.g. by adding something like a snapGrid prop)

I favor either of the latter which are the same general idea. @ciampo’s suggestion is probably a bit safer and more explicit.

@ciampo
Copy link
Contributor

ciampo commented Feb 4, 2022

Your last solution suggestion makes sense but it seems the root of this issue wasn't clear. I'll try and clarify/summarize.

Ah, I see! Thank you for the clarification. As for the suggested approaches:

  1. Remove the no-update-while-dragging condition 8705154

Wouldn't this cause a potential perf regression by calling setState very often?

  1. Add the update-state-by-optional-return-from-onDrag feature 467f3a3

It looks like this solution requires the onDrag function to return a "modified value" of the coordinates — wouldn't that be a bit of an implicit change? I think that commonly, this sort of callbacks (like onDrag in this case) are not expected to return a value.

  1. implement the snapping functionality directly into the component (e.g. by adding something like a snapGrid prop)

This solution looks clear enough

I favor either of the latter which are the same general idea

Same for me. Allowing onDrag to act as a "transform" of the coordinates is potentially powerful and gives more flexibility to the consumers of this component to implement their own functionality on top of it.

On the other hand, it'd be nice if FocalPointPicker offered some snapping functionality out-of-the-box — this last option would be my favourite.

@razwan
Copy link
Contributor Author

razwan commented Feb 7, 2022

Hi @ciampo! Thanks for tuning in!

I agree that implementing a snapping functionality directly into the FocalPointPicker would be favourable in some cases. But this is just one use case. There are plenty of ways in which the component could be extended. For instance you may want to restrict (for whatever reason) the area in which the focal point can be dragged. Also, the snapping functionality requires not only a snapGrid prop, but also some sort of threshold etc. To me, these are the kind of features that developers should be able to develop on top of the core component.

After giving it a second thought I favor dropping the isDragging condition preventing values updating since it doesn't create any troubles out of the box. If someone choses to use the onDrag callback, it's their responsibility to do that in a performant manner.

I don't see how if focal point is snapped (or sticky) while dragging then the performance would suffer. The performance is rather greatly impacted when you update the block attributes while dragging but that's a whole another issue.

Am I missing anything?

@stokesman
Copy link
Contributor

… the snapping functionality requires not only a snapGrid prop, but also some sort of threshold etc.

I imagined that such a snapGrid prop would be take a function that receives the coordinates and can return modified ones. A better name for it would be something like resolvePoint. It is nicer than using onDrag in that there is no concern about cases where an existing implementation returns an incompatible value (e.g. our failing test). It could be made more comprehensive too by calling it from updateValue so that even changes from the input controls or keyboard events could also be affected (instead only the drag events).

While there really should be no performance concern for the first option. I simply prefer that we facilitate such use cases. As @razwan wrote #38247 (comment)

However, what makes me believe that you're change is the way to go, is that my codebase would work as intended with only one prop added to the component (rather than caching the values inside onDrag and updating the value prop when needed)

@ciampo
Copy link
Contributor

ciampo commented Feb 8, 2022

There are plenty of ways in which the component could be extended... To me, these are the kind of features that developers should be able to develop on top of the core component.

This is definitely an interesting angle on the matter — allowing consumers to extend the core component is definitely a powerful pattern

When referring at options highlighted in this comment, if I understand correctly:

  • @razwan's preferred option would be what we called option no. 1 (8705154)
  • @stokesman suggests that we introduce a new prop (e.g. called resolvePoint), called from updateValue. This prop would be a function that accepts a point, and returns the transformed point coordinates.

If that's the case, my preference would be for @stokesman 's solution, which seems cleaner and still enables the extension of the component like @razwan 's wishes for.

@razwan razwan changed the title Update values FocalPointPicker w/ incoming props even if user is interacting Add resolvePoint prop to FocalPointPicker component to allow updating value while dragging Feb 8, 2022
@razwan
Copy link
Contributor Author

razwan commented Feb 8, 2022

This is definitely an interesting angle on the matter — allowing consumers to extend the core component is definitely a powerful pattern

There's a fine line there but I understand that under such heavy development most of the times the focus is on adding the features needed by the core blocks. However I enjoy components that are very little opinionated and highly extensible.

If that's the case, my preference would be for @stokesman 's solution, which seems cleaner and still enables the extension of the component like @razwan 's wishes for.

The resolvePoint solution seems more than reasonable to me. I've made changes to the PR accordingly. Have you can have a look @ciampo.

Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

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

There's a fine line there but I understand that under such heavy development most of the times the focus is on adding the features needed by the core blocks. However I enjoy components that are very little opinionated and highly extensible.

Makes sense. It would be actually cool if you could add a Storybook example in this PR implementing snapping through the new resolvePoint function!

The resolvePoint solution seems more than reasonable to me. I've made changes to the PR accordingly. Have you can have a look @ciampo.

Sure thing! Apart from the inline comment:

  • could you add an entry to the CHANGELOG ?
  • could you add a unit test for the resolvePoint function? A simple way would be to pass, as the resolvePoint prop, a spy function (and then check that it was called correctly). We could add multiple tests to verify that it gets called also while interacting with the keyboard

Thank you!

this.updateValue( value );
this.props.onDrag?.( value, event );
const resolvedValue = this.props.resolvePoint?.( value ) ?? value;
this.updateValue( resolvedValue );
Copy link
Contributor

Choose a reason for hiding this comment

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

Like @stokesman suggested earlier, should we call resolvePoint inside updateValue instead?

@razwan
Copy link
Contributor Author

razwan commented Feb 9, 2022

  • storybook
  • changelog
  • unit test

Done. @ciampo

@ciampo
Copy link
Contributor

ciampo commented Feb 9, 2022

Done

Awesome! I'll try to give a proper look later today

Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

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

Hey @razwan , thank you for working on this.

The code looks good and tests well — I just left a couple of comments, but we're quite close to being able to merge this!

Comment on lines +73 to +76
resolvePoint={ () => {
spy();
return { x: 0.91, y: 0.42 };
} }
Copy link
Contributor

Choose a reason for hiding this comment

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

Q: when checking this test on my local machine, I tried to make the test fail by modifying the resolvePoint function to

resolvePoint={ ( value ) => {
  spy();
  return value;
} }

The test fails (as expected), but the values received by the onChange spy are not x: 0.25, y: 0.25, but instead are x: 0, y: 0. I wasn't expecting this behaviour, do you know why would this happen?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My guess is that the main reason is that the picker doesn't have width and height props. Also, fireEvent is called without any other parameters but am not sure if the case would be the same if the picker had set dimensions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, so I haven't been able to use mouseMove to create a proper emulation of a user interaction. However, I managed that by using fireEvent.keyDown( dragArea, { charCode: 0, keyCode: 38 } ); which triggers onChange with an "expected" value of x: 0.25, y: 0.24 making my test fail. That was because onChange was called with the same value updateValue would received.

I added a second parameter to the updateValue method which is a callback that is passed to the setState call. Moving the onChange call inside that callback fixed the issue and made the test pass. Everything looks fine now.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thank you for the explanation, makes sense!

I suggest we add another unit test to check this onChange baheviour:

diff --git a/packages/components/src/focal-point-picker/test/index.js b/packages/components/src/focal-point-picker/test/index.js
index 145ca0d019..ac4f1af0e3 100644
--- a/packages/components/src/focal-point-picker/test/index.js
+++ b/packages/components/src/focal-point-picker/test/index.js
@@ -76,6 +76,8 @@ describe( 'FocalPointPicker', () => {
 					} }
 				/>
 			);
+
+			// Click and press arrow up
 			const dragArea = getByRole( 'button' );
 			act( () => {
 				fireEvent.mouseDown( dragArea );
@@ -98,5 +100,23 @@ describe( 'FocalPointPicker', () => {
 			rerender( <Picker value={ { x: 0.93, y: 0.5 } } /> );
 			expect( xInput.value ).toBe( '93' );
 		} );
+
+		it( 'call onChange with the expected values', async () => {
+			const spyChange = jest.fn();
+			const { getByRole } = render(
+				<Picker value={ { x: 0.14, y: 0.62 } } onChange={ spyChange } />
+			);
+
+			// Click and press arrow up
+			const dragArea = getByRole( 'button' );
+			act( () => {
+				fireEvent.mouseDown( dragArea );
+				fireEvent.keyDown( dragArea, { charCode: 0, keyCode: 38 } );
+			} );
+			expect( spyChange ).toHaveBeenCalledWith( {
+				x: '0.14',
+				y: '0.61',
+			} );
+		} );
 	} );
 } );

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'm not sure I understand what you're asking for. Doesn't the initial test cover this behaviour? Do you want me to remove the onChange part from that test and then make another one in the controllability section that only tests the onChange part?

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 proposing to add another unit test aimed at testing the behaviour that I highlighted earlier and you fixed in your latest commits by using the setState callback to call onChange.

Earlier, when removing resolvePoint from this test, the onChange spy was receiving x: 0, y: 0 instead of the expected value. Thanks to your fix, the onChange would now receive the correct values. My proposal is to add an additional unit test to specifically test that (note that, in the new test, the resolvePoint prop is not specified)

Hope I managed to explain myself better :)

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'll add the unit test but I want to make sure we're on the same page.

By using mouseDown and mouseMove we still receive { x: 0.00, y: 0.00 } instead of the values with which the picker is initialised.

The bug that was fixed by moving the onChange inside the setState callback was related to the resolvePoint prop.

this.updateValue( next ); // next was being passed to resolvePoint here
this.props.onChange( next ); // but not here

This unit test becomes a test to future proof the keyboard interaction (rather for the UP key press). Am I right?

Copy link
Contributor

Choose a reason for hiding this comment

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

By using mouseDown and mouseMove we still receive { x: 0.00, y: 0.00 } instead of the values with which the picker is initialised.

I see, I missed that detail. Do you think it's worth investigating this in a follow-up PR?

Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

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

Thank you @razwan and @stokesman for the excellent collaboration effort!

I left a couple of minor comments, but as soon as they are addressed we can merge this PR 🚀

Comment on lines +73 to +76
resolvePoint={ () => {
spy();
return { x: 0.91, y: 0.42 };
} }
Copy link
Contributor

Choose a reason for hiding this comment

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

Thank you for the explanation, makes sense!

I suggest we add another unit test to check this onChange baheviour:

diff --git a/packages/components/src/focal-point-picker/test/index.js b/packages/components/src/focal-point-picker/test/index.js
index 145ca0d019..ac4f1af0e3 100644
--- a/packages/components/src/focal-point-picker/test/index.js
+++ b/packages/components/src/focal-point-picker/test/index.js
@@ -76,6 +76,8 @@ describe( 'FocalPointPicker', () => {
 					} }
 				/>
 			);
+
+			// Click and press arrow up
 			const dragArea = getByRole( 'button' );
 			act( () => {
 				fireEvent.mouseDown( dragArea );
@@ -98,5 +100,23 @@ describe( 'FocalPointPicker', () => {
 			rerender( <Picker value={ { x: 0.93, y: 0.5 } } /> );
 			expect( xInput.value ).toBe( '93' );
 		} );
+
+		it( 'call onChange with the expected values', async () => {
+			const spyChange = jest.fn();
+			const { getByRole } = render(
+				<Picker value={ { x: 0.14, y: 0.62 } } onChange={ spyChange } />
+			);
+
+			// Click and press arrow up
+			const dragArea = getByRole( 'button' );
+			act( () => {
+				fireEvent.mouseDown( dragArea );
+				fireEvent.keyDown( dragArea, { charCode: 0, keyCode: 38 } );
+			} );
+			expect( spyChange ).toHaveBeenCalledWith( {
+				x: '0.14',
+				y: '0.61',
+			} );
+		} );
 	} );
 } );

Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

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

Thank you @razwan for you work! We can merge this PR as soon as conflicts are solved and CI is green 🚀

@stokesman
Copy link
Contributor

Thanks for the work on this @razwan.

We can merge this PR as soon as conflicts are solved and CI is green

Doing so. Thanks for the reviews @ciampo.

@stokesman stokesman merged commit e60cc60 into WordPress:trunk Feb 10, 2022
@github-actions
Copy link

Congratulations on your first merged pull request, @razwan! We'd like to credit you for your contribution in the post announcing the next WordPress release, but we can't find a WordPress.org profile associated with your GitHub account. When you have a moment, visit the following URL and click "link your GitHub account" under "GitHub Username" to link your accounts:

https://profiles.wordpress.org/me/profile/edit/

And if you don't have a WordPress.org account, you can create one on this page:

https://login.wordpress.org/register

Kudos!

@github-actions github-actions bot added this to the Gutenberg 12.7 milestone Feb 10, 2022
@razwan
Copy link
Contributor Author

razwan commented Feb 10, 2022

Thanks @ciampo and @stokesman for your help!
I've just connected my github account on wordpress.org and hopefully I get on the contributors list 😅

On to the next one. See you around!

@razwan razwan deleted the update/focal-point-picker-incoming-props branch February 10, 2022 18:07
@ciampo
Copy link
Contributor

ciampo commented Feb 11, 2022

Thanks @ciampo and @stokesman for your help! I've just connected my github account on wordpress.org and hopefully I get on the contributors list 😅

On to the next one. See you around!

It's been great to collaborate! Please feel free to ping me or @mirka for any @wordpress/components-related issue or PR !

@cbravobernal cbravobernal changed the title Add resolvePoint prop to FocalPointPicker component to allow updating value while dragging FocalPointPicker: Add resolvePoint prop to allow updating its value while dragging. Feb 25, 2022
@cbravobernal cbravobernal changed the title FocalPointPicker: Add resolvePoint prop to allow updating its value while dragging. FocalPointPicker: Allow updating its value while dragging. Feb 26, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
First-time Contributor Pull request opened by a first-time contributor to Gutenberg repository [Package] Components /packages/components [Type] Enhancement A suggestion for improvement.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants