-
Notifications
You must be signed in to change notification settings - Fork 4.2k
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
Link Control require user to manually submit any changes #50668
Changes from all commits
2cdd638
5d4d1e8
1dffb00
42913c2
b74ca1a
e95a4d9
c0fcf91
a0a54b0
d8006a3
dea11dd
77a9a6e
48a708b
f09df37
1afabdd
4f16173
3e6f682
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,6 +11,7 @@ import { __ } from '@wordpress/i18n'; | |
import { useRef, useState, useEffect } from '@wordpress/element'; | ||
import { focus } from '@wordpress/dom'; | ||
import { ENTER } from '@wordpress/keycodes'; | ||
import { isShallowEqualObjects } from '@wordpress/is-shallow-equal'; | ||
|
||
/** | ||
* Internal dependencies | ||
|
@@ -19,7 +20,7 @@ import LinkControlSettingsDrawer from './settings-drawer'; | |
import LinkControlSearchInput from './search-input'; | ||
import LinkPreview from './link-preview'; | ||
import useCreatePage from './use-create-page'; | ||
import useInternalInputValue from './use-internal-input-value'; | ||
import useInternalValue from './use-internal-value'; | ||
import { ViewerFill } from './viewer-slot'; | ||
import { DEFAULT_LINK_SETTINGS } from './constants'; | ||
|
||
|
@@ -136,13 +137,20 @@ function LinkControl( { | |
const textInputRef = useRef(); | ||
const isEndingEditWithFocus = useRef( false ); | ||
|
||
const settingsKeys = settings.map( ( { id } ) => id ); | ||
|
||
const [ settingsOpen, setSettingsOpen ] = useState( false ); | ||
|
||
const [ internalUrlInputValue, setInternalUrlInputValue ] = | ||
useInternalInputValue( value?.url || '' ); | ||
const [ | ||
internalControlValue, | ||
setInternalControlValue, | ||
setInternalURLInputValue, | ||
setInternalTextInputValue, | ||
createSetInternalSettingValueHandler, | ||
] = useInternalValue( value ); | ||
|
||
const [ internalTextInputValue, setInternalTextInputValue ] = | ||
useInternalInputValue( value?.title || '' ); | ||
const valueHasChanges = | ||
value && ! isShallowEqualObjects( internalControlValue, value ); | ||
|
||
const [ isEditingLink, setIsEditingLink ] = useState( | ||
forceIsEditingLink !== undefined | ||
|
@@ -160,6 +168,8 @@ function LinkControl( { | |
) { | ||
setIsEditingLink( forceIsEditingLink ); | ||
} | ||
// Todo: bug if the missing dep is introduced. Will need a fix. | ||
// eslint-disable-next-line react-hooks/exhaustive-deps | ||
}, [ forceIsEditingLink ] ); | ||
|
||
useEffect( () => { | ||
|
@@ -208,29 +218,47 @@ function LinkControl( { | |
}; | ||
|
||
const handleSelectSuggestion = ( updatedValue ) => { | ||
// Suggestions may contains "settings" values (e.g. `opensInNewTab`) | ||
// which should not overide any existing settings values set by the | ||
// user. This filters out any settings values from the suggestion. | ||
const nonSettingsChanges = Object.keys( updatedValue ).reduce( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You have a block of code very similar to this above too. Would moving it to the hook mean that we can combine them? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't require a hook just a function but yes 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually in future this code won't be necessary so we can ignore it for now and clean up post-refactor if it's still around. |
||
( acc, key ) => { | ||
if ( ! settingsKeys.includes( key ) ) { | ||
acc[ key ] = updatedValue[ key ]; | ||
} | ||
return acc; | ||
}, | ||
{} | ||
); | ||
|
||
onChange( { | ||
...updatedValue, | ||
title: internalTextInputValue || updatedValue?.title, | ||
...internalControlValue, | ||
...nonSettingsChanges, | ||
// As title is not a setting, it must be manually applied | ||
// in such a way as to preserve the users changes over | ||
// any "title" value provided by the "suggestion". | ||
title: internalControlValue?.title || updatedValue?.title, | ||
} ); | ||
|
||
stopEditing(); | ||
}; | ||
|
||
const handleSubmit = () => { | ||
if ( | ||
currentUrlInputValue !== value?.url || | ||
internalTextInputValue !== value?.title | ||
) { | ||
if ( valueHasChanges ) { | ||
Comment on lines
-219
to
+247
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of testing for individual properties of the value, this uses a shallow comparison to check if the external (controlled) and internal values are different. If there is a difference then the internal value has changed and thus we need to submit those changes. |
||
// Submit the original value with new stored values applied | ||
// on top. URL is a special case as it may also be a prop. | ||
onChange( { | ||
...value, | ||
...internalControlValue, | ||
url: currentUrlInputValue, | ||
title: internalTextInputValue, | ||
} ); | ||
} | ||
stopEditing(); | ||
}; | ||
|
||
const handleSubmitWithEnter = ( event ) => { | ||
const { keyCode } = event; | ||
|
||
if ( | ||
keyCode === ENTER && | ||
! currentInputIsEmpty // Disallow submitting empty values. | ||
|
@@ -241,8 +269,7 @@ function LinkControl( { | |
}; | ||
|
||
const resetInternalValues = () => { | ||
setInternalUrlInputValue( value?.url ); | ||
setInternalTextInputValue( value?.title ); | ||
setInternalControlValue( value ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of resetting individual properties, "reset" now just means "sync back to whatever |
||
}; | ||
|
||
const handleCancel = ( event ) => { | ||
|
@@ -263,7 +290,8 @@ function LinkControl( { | |
onCancel?.(); | ||
}; | ||
|
||
const currentUrlInputValue = propInputValue || internalUrlInputValue; | ||
const currentUrlInputValue = | ||
propInputValue || internalControlValue?.url || ''; | ||
|
||
const currentInputIsEmpty = ! currentUrlInputValue?.trim()?.length; | ||
|
||
|
@@ -306,7 +334,7 @@ function LinkControl( { | |
value={ currentUrlInputValue } | ||
withCreateSuggestion={ withCreateSuggestion } | ||
onCreateSuggestion={ createPage } | ||
onChange={ setInternalUrlInputValue } | ||
onChange={ setInternalURLInputValue } | ||
onSelect={ handleSelectSuggestion } | ||
showInitialSuggestions={ showInitialSuggestions } | ||
allowDirectEntry={ ! noDirectEntry } | ||
|
@@ -351,14 +379,18 @@ function LinkControl( { | |
showTextControl={ showTextControl } | ||
showSettings={ showSettings } | ||
textInputRef={ textInputRef } | ||
internalTextInputValue={ internalTextInputValue } | ||
internalTextInputValue={ | ||
internalControlValue?.title | ||
} | ||
setInternalTextInputValue={ | ||
setInternalTextInputValue | ||
} | ||
handleSubmitWithEnter={ handleSubmitWithEnter } | ||
value={ value } | ||
value={ internalControlValue } | ||
settings={ settings } | ||
onChange={ onChange } | ||
onChange={ createSetInternalSettingValueHandler( | ||
settingsKeys | ||
) } | ||
/> | ||
) } | ||
|
||
|
@@ -367,7 +399,9 @@ function LinkControl( { | |
variant="primary" | ||
onClick={ handleSubmit } | ||
className="block-editor-link-control__search-submit" | ||
disabled={ currentInputIsEmpty } // Disallow submitting empty values. | ||
disabled={ | ||
! valueHasChanges || currentInputIsEmpty | ||
} | ||
Comment on lines
+402
to
+404
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This means the |
||
> | ||
{ __( 'Apply' ) } | ||
</Button> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -1784,6 +1784,63 @@ describe( 'Addition Settings UI', () => { | |
} ) | ||
).toBeChecked(); | ||
} ); | ||
|
||
it( 'should require settings changes to be submitted/applied', async () => { | ||
const user = userEvent.setup(); | ||
|
||
const mockOnChange = jest.fn(); | ||
|
||
const selectedLink = { | ||
...fauxEntitySuggestions[ 0 ], | ||
// Including a setting here helps to assert on a potential bug | ||
// whereby settings on the suggestion override the current (internal) | ||
// settings values set by the user in the UI. | ||
opensInNewTab: false, | ||
}; | ||
|
||
render( | ||
<LinkControl | ||
value={ selectedLink } | ||
forceIsEditingLink | ||
hasTextControl | ||
onChange={ mockOnChange } | ||
/> | ||
); | ||
|
||
// check that the "Apply" button is disabled by default. | ||
const submitButton = screen.queryByRole( 'button', { | ||
name: 'Apply', | ||
} ); | ||
|
||
expect( submitButton ).toBeDisabled(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When there are no changes Submit should be disabled. |
||
|
||
await toggleSettingsDrawer( user ); | ||
|
||
const opensInNewTabToggle = screen.queryByRole( 'checkbox', { | ||
name: 'Open in new tab', | ||
} ); | ||
|
||
// toggle the checkbox | ||
await user.click( opensInNewTabToggle ); | ||
|
||
// Check settings are **not** directly submitted | ||
// which would trigger the onChange handler. | ||
expect( mockOnChange ).not.toHaveBeenCalled(); | ||
Comment on lines
+1824
to
+1828
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Previously changing a setting automatically "submitted" the value (i.e. calls |
||
|
||
// Check Apply button is now enabled because changes | ||
// have been detected. | ||
expect( submitButton ).toBeEnabled(); | ||
|
||
// Submit the changed setting value using the Apply button | ||
await user.click( submitButton ); | ||
|
||
// Assert the value is updated. | ||
expect( mockOnChange ).toHaveBeenCalledWith( | ||
expect.objectContaining( { | ||
opensInNewTab: true, | ||
} ) | ||
); | ||
Comment on lines
+1832
to
+1842
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now a user is required to manually submit any changes to the settings. |
||
} ); | ||
} ); | ||
|
||
describe( 'Post types', () => { | ||
|
@@ -2199,7 +2256,7 @@ describe( 'Controlling link title text', () => { | |
|
||
it( 'should allow `ENTER` keypress within the text field to trigger submission of value', async () => { | ||
const user = userEvent.setup(); | ||
const textValue = 'My new text value'; | ||
const newTextValue = 'My new text value'; | ||
const mockOnChange = jest.fn(); | ||
|
||
render( | ||
|
@@ -2218,14 +2275,14 @@ describe( 'Controlling link title text', () => { | |
expect( textInput ).toBeVisible(); | ||
|
||
await user.clear( textInput ); | ||
await user.keyboard( textValue ); | ||
await user.keyboard( newTextValue ); | ||
|
||
// Attempt to submit the empty search value in the input. | ||
triggerEnter( textInput ); | ||
|
||
expect( mockOnChange ).toHaveBeenCalledWith( | ||
expect.objectContaining( { | ||
title: textValue, | ||
title: newTextValue, | ||
url: selectedLink.url, | ||
} ) | ||
); | ||
|
@@ -2236,7 +2293,7 @@ describe( 'Controlling link title text', () => { | |
).not.toBeInTheDocument(); | ||
} ); | ||
|
||
it( 'should reset state on value change', async () => { | ||
it( 'should reset state upon controlled value change', async () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unrelated change but it helps improve the meaning of the test. |
||
const user = userEvent.setup(); | ||
const textValue = 'My new text value'; | ||
const mockOnChange = jest.fn(); | ||
|
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
/** | ||
* WordPress dependencies | ||
*/ | ||
import { useState, useEffect } from '@wordpress/element'; | ||
|
||
export default function useInternalValue( value ) { | ||
const [ internalValue, setInternalValue ] = useState( value || {} ); | ||
|
||
// If the value prop changes, update the internal state. | ||
useEffect( () => { | ||
setInternalValue( ( prevValue ) => { | ||
if ( value && value !== prevValue ) { | ||
return value; | ||
} | ||
|
||
return prevValue; | ||
} ); | ||
}, [ value ] ); | ||
Comment on lines
+9
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you know if this sync hook is still needed? Now that value is an object; the synchronization can cause the content loss. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So we originally introduced the pattern to fix an issue whereby programmatically updating @Mamaduka The example is codified in one of the unit tests. Basically if you create x2 separate and different links in a paragraph block and then click between them the UI will reflect the value of that link. Previous to this sync you would continue to see the previous link's value in the UI even if you clicked on another link. You won't be able to replicate any more because we implemented another fix which passes a I think we could look to remove the sync. Perhaps a PoC PR is a good place to start. I'll spin up. I've never liked this code and I agree is prone to error. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, Dave. Let’s continue discussion in the new issue. |
||
|
||
const setInternalURLInputValue = ( nextValue ) => { | ||
setInternalValue( { | ||
...internalValue, | ||
url: nextValue, | ||
} ); | ||
}; | ||
|
||
const setInternalTextInputValue = ( nextValue ) => { | ||
setInternalValue( { | ||
...internalValue, | ||
title: nextValue, | ||
} ); | ||
}; | ||
|
||
const createSetInternalSettingValueHandler = | ||
( settingsKeys ) => ( nextValue ) => { | ||
// Only apply settings values which are defined in the settings prop. | ||
const settingsUpdates = Object.keys( nextValue ).reduce( | ||
( acc, key ) => { | ||
if ( settingsKeys.includes( key ) ) { | ||
acc[ key ] = nextValue[ key ]; | ||
} | ||
return acc; | ||
}, | ||
{} | ||
); | ||
|
||
setInternalValue( { | ||
...internalValue, | ||
...settingsUpdates, | ||
} ); | ||
}; | ||
|
||
return [ | ||
internalValue, | ||
setInternalValue, | ||
setInternalURLInputValue, | ||
setInternalTextInputValue, | ||
createSetInternalSettingValueHandler, | ||
]; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change is unrelated and is caused by the enabling of the relevant eslint rule. Fixing the code to conform causes a bug so I don't want to fix this now.