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

ToolsPanel: Trigger onDeselect/onSelect callbacks directly #61694

Open
wants to merge 2 commits into
base: trunk
Choose a base branch
from

Conversation

stokesman
Copy link
Contributor

@stokesman stokesman commented May 15, 2024

What?

Instead of using an effect hook to run some callbacks runs them from an event.

Why?

Simplification for maintainability. I noticed this while working on #60621.

How?

  • Puts the onDeselect and onSelect callbacks into state accessible by toggleItem.
  • Runs the callbacks from toggleItem which is the event handler for menu item clicks.
  • Removes the effect hook that previously used for the above.

Automated tests

Currently, with the effect being responsible for triggering the callbacks, I wasn’t sure if onSelect or onDeselect might be triggered from controlled updates to an item’s value or interactions with the item’s actual control. I made some unit tests for such and according to these, it was never a thing (they pass on trunk or this branch). I'm not sure if these tests are actually worth adding.

diff --git a/packages/components/src/tools-panel/test/index.tsx b/packages/components/src/tools-panel/test/index.tsx
index 8d2c4f17a8..e40a470b06 100644
--- a/packages/components/src/tools-panel/test/index.tsx
+++ b/packages/components/src/tools-panel/test/index.tsx
@@ -797,6 +797,81 @@ describe( 'ToolsPanel', () => {
 		} );
 	} );
 
+	describe( 'menu item callbacks on item interaction', () => {
+		beforeEach( () => {
+			jest.clearAllMocks();
+		} );
+
+		const onDeselect = jest.fn();
+		const onSelect = jest.fn();
+		const ToolsPanelControllable = ( {
+			toolsPanelItemValue,
+			isOptional = false,
+		}: {
+			toolsPanelItemValue?: boolean;
+			isOptional?: boolean;
+		} ) => {
+			const itemProps = {
+				attributes: { value: toolsPanelItemValue },
+				hasValue: () => toolsPanelItemValue !== undefined,
+				label: isOptional ? 'Optional' : 'Standard',
+				onDeselect,
+				onSelect,
+			};
+
+			// The null panelId below simulates the panel prop when there
+			// are multiple blocks selected.
+			return (
+				<ToolsPanel { ...defaultProps } panelId={ null }>
+					<ToolsPanelItem { ...itemProps }>
+						<input
+							type="checkbox"
+							defaultChecked={ !! toolsPanelItemValue }
+							onChange={ ( event ) => {
+								controlValue = event.target.checked;
+							} }
+						/>
+					</ToolsPanelItem>
+				</ToolsPanel>
+			);
+		};
+
+		it( 'default item’s value changes should not trigger menu item callbacks', async () => {
+			const { rerender } = render(
+				<ToolsPanelControllable toolsPanelItemValue={ false } />
+			);
+
+			const control = screen.getByRole( 'checkbox' );
+			const user = userEvent.setup();
+			await user.click( control );
+			expect( controlProps.onSelect ).not.toHaveBeenCalled();
+			await user.click( control );
+			expect( controlProps.onDeselect ).not.toHaveBeenCalled();
+
+			rerender( <ToolsPanelControllable /> );
+			expect( controlProps.onDeselect ).not.toHaveBeenCalled();
+		} );
+
+		it( 'optional item’s value changes should not trigger menu item callbacks', async () => {
+			const { rerender } = render(
+				<ToolsPanelControllable
+					isOptional
+					toolsPanelItemValue={ false }
+				/>
+			);
+
+			const control = screen.getByRole( 'checkbox' );
+			const user = userEvent.setup();
+			await user.click( control );
+			expect( controlProps.onSelect ).not.toHaveBeenCalled();
+			await user.click( control );
+			expect( controlProps.onDeselect ).not.toHaveBeenCalled();
+
+			rerender( <ToolsPanelControllable isOptional /> );
+			expect( controlProps.onDeselect ).not.toHaveBeenCalled();
+		} );
+	} );
+
 	describe( 'grouped panel items within custom components', () => {
 		it( 'should render grouped items correctly', () => {
 			renderGroupedItemsInPanel();

Testing Instructions

Testing Instructions for Keyboard

Screenshots or screencast

@stokesman stokesman added the [Type] Code Quality Issues or PRs that relate to code quality label May 15, 2024
@stokesman stokesman force-pushed the try/tools-panel-deselect-select-without-effect branch from ba2ce5c to b77c611 Compare September 23, 2024 15:14
@stokesman stokesman added the [Package] Components /packages/components label Sep 23, 2024
@stokesman stokesman changed the title DRAFT ToolsPanel: Trigger onDeselect/onSelect callbacks directly ToolsPanel: Trigger onDeselect/onSelect callbacks directly Sep 23, 2024
@stokesman stokesman marked this pull request as ready for review September 23, 2024 15:29
Copy link

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: stokesman <presstoke@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Instead of via an effect hook.
@stokesman stokesman force-pushed the try/tools-panel-deselect-select-without-effect branch from b77c611 to df3451b Compare September 29, 2024 18:57
Comment on lines -176 to +149
? menuItems?.[ menuGroup ]?.[ label ] !== undefined
: isMenuItemChecked;
const isShown = isShownByDefault ? isRegistered : isMenuItemChecked;
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 related to the purpose of the PR, this was just to reuse isRegistered instead of reevaluating the same condition.

Copy link
Contributor Author

@stokesman stokesman left a comment

Choose a reason for hiding this comment

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

// hasValue is a new function on every render, so do not add it as a
// dependency to the useCallback hook! If needed, we should use a ref.
// eslint-disable-next-line react-hooks/exhaustive-deps
const hasValueCallback = useCallback( hasValue, [ panelId ] );
// resetAllFilter is a new function on every render, so do not add it as a
// dependency to the useCallback hook! If needed, we should use a ref.
// eslint-disable-next-line react-hooks/exhaustive-deps
const resetAllFilterCallback = useCallback( resetAllFilter, [ panelId ] );
const onDeselect = useEvent( onDeselectProp );
const onSelect = useEvent( onSelectProp );

The useEvent calls are added in 5db7309, and I just want to highlight the discrepancy with the callbacks above which are memoized by panelId, the same could be done for onDeselect and onSelect if that seems better. If so, I’d rather return all of them from a single useMemo.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Package] Components /packages/components [Type] Code Quality Issues or PRs that relate to code quality
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant