Skip to content

Commit 2b687d0

Browse files
authored
Link Control require user to manually submit any changes (#50668)
* Implement initial shallow checking * Don’t assyne value when comparing value changes * Fix recrusive re-renders in hook Avoid passing a default when default is handled internally. Set default to be an object as that is what we are tracking. * Fix ENTER submission bug * Require settings changes to be “Applied” * Refactor for readability and tidy * Improve test naming * Add test for new UX * Improve fetching of settings * Rename hook to convey new purpose * Improve test coverage of test * Fix tab stops now Apply is disabled when there are no changes * Wait for settings drawer to open * Extract retrival of settings keys * Move setters to hook * Make API clearer to consumers of hook
1 parent f10f85f commit 2b687d0

File tree

5 files changed

+179
-48
lines changed

5 files changed

+179
-48
lines changed

packages/block-editor/src/components/link-control/index.js

+54-20
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { __ } from '@wordpress/i18n';
1111
import { useRef, useState, useEffect } from '@wordpress/element';
1212
import { focus } from '@wordpress/dom';
1313
import { ENTER } from '@wordpress/keycodes';
14+
import { isShallowEqualObjects } from '@wordpress/is-shallow-equal';
1415

1516
/**
1617
* Internal dependencies
@@ -19,7 +20,7 @@ import LinkControlSettingsDrawer from './settings-drawer';
1920
import LinkControlSearchInput from './search-input';
2021
import LinkPreview from './link-preview';
2122
import useCreatePage from './use-create-page';
22-
import useInternalInputValue from './use-internal-input-value';
23+
import useInternalValue from './use-internal-value';
2324
import { ViewerFill } from './viewer-slot';
2425
import { DEFAULT_LINK_SETTINGS } from './constants';
2526

@@ -136,13 +137,20 @@ function LinkControl( {
136137
const textInputRef = useRef();
137138
const isEndingEditWithFocus = useRef( false );
138139

140+
const settingsKeys = settings.map( ( { id } ) => id );
141+
139142
const [ settingsOpen, setSettingsOpen ] = useState( false );
140143

141-
const [ internalUrlInputValue, setInternalUrlInputValue ] =
142-
useInternalInputValue( value?.url || '' );
144+
const [
145+
internalControlValue,
146+
setInternalControlValue,
147+
setInternalURLInputValue,
148+
setInternalTextInputValue,
149+
createSetInternalSettingValueHandler,
150+
] = useInternalValue( value );
143151

144-
const [ internalTextInputValue, setInternalTextInputValue ] =
145-
useInternalInputValue( value?.title || '' );
152+
const valueHasChanges =
153+
value && ! isShallowEqualObjects( internalControlValue, value );
146154

147155
const [ isEditingLink, setIsEditingLink ] = useState(
148156
forceIsEditingLink !== undefined
@@ -160,6 +168,8 @@ function LinkControl( {
160168
) {
161169
setIsEditingLink( forceIsEditingLink );
162170
}
171+
// Todo: bug if the missing dep is introduced. Will need a fix.
172+
// eslint-disable-next-line react-hooks/exhaustive-deps
163173
}, [ forceIsEditingLink ] );
164174

165175
useEffect( () => {
@@ -208,29 +218,47 @@ function LinkControl( {
208218
};
209219

210220
const handleSelectSuggestion = ( updatedValue ) => {
221+
// Suggestions may contains "settings" values (e.g. `opensInNewTab`)
222+
// which should not overide any existing settings values set by the
223+
// user. This filters out any settings values from the suggestion.
224+
const nonSettingsChanges = Object.keys( updatedValue ).reduce(
225+
( acc, key ) => {
226+
if ( ! settingsKeys.includes( key ) ) {
227+
acc[ key ] = updatedValue[ key ];
228+
}
229+
return acc;
230+
},
231+
{}
232+
);
233+
211234
onChange( {
212-
...updatedValue,
213-
title: internalTextInputValue || updatedValue?.title,
235+
...internalControlValue,
236+
...nonSettingsChanges,
237+
// As title is not a setting, it must be manually applied
238+
// in such a way as to preserve the users changes over
239+
// any "title" value provided by the "suggestion".
240+
title: internalControlValue?.title || updatedValue?.title,
214241
} );
242+
215243
stopEditing();
216244
};
217245

218246
const handleSubmit = () => {
219-
if (
220-
currentUrlInputValue !== value?.url ||
221-
internalTextInputValue !== value?.title
222-
) {
247+
if ( valueHasChanges ) {
248+
// Submit the original value with new stored values applied
249+
// on top. URL is a special case as it may also be a prop.
223250
onChange( {
224251
...value,
252+
...internalControlValue,
225253
url: currentUrlInputValue,
226-
title: internalTextInputValue,
227254
} );
228255
}
229256
stopEditing();
230257
};
231258

232259
const handleSubmitWithEnter = ( event ) => {
233260
const { keyCode } = event;
261+
234262
if (
235263
keyCode === ENTER &&
236264
! currentInputIsEmpty // Disallow submitting empty values.
@@ -241,8 +269,7 @@ function LinkControl( {
241269
};
242270

243271
const resetInternalValues = () => {
244-
setInternalUrlInputValue( value?.url );
245-
setInternalTextInputValue( value?.title );
272+
setInternalControlValue( value );
246273
};
247274

248275
const handleCancel = ( event ) => {
@@ -263,7 +290,8 @@ function LinkControl( {
263290
onCancel?.();
264291
};
265292

266-
const currentUrlInputValue = propInputValue || internalUrlInputValue;
293+
const currentUrlInputValue =
294+
propInputValue || internalControlValue?.url || '';
267295

268296
const currentInputIsEmpty = ! currentUrlInputValue?.trim()?.length;
269297

@@ -306,7 +334,7 @@ function LinkControl( {
306334
value={ currentUrlInputValue }
307335
withCreateSuggestion={ withCreateSuggestion }
308336
onCreateSuggestion={ createPage }
309-
onChange={ setInternalUrlInputValue }
337+
onChange={ setInternalURLInputValue }
310338
onSelect={ handleSelectSuggestion }
311339
showInitialSuggestions={ showInitialSuggestions }
312340
allowDirectEntry={ ! noDirectEntry }
@@ -351,14 +379,18 @@ function LinkControl( {
351379
showTextControl={ showTextControl }
352380
showSettings={ showSettings }
353381
textInputRef={ textInputRef }
354-
internalTextInputValue={ internalTextInputValue }
382+
internalTextInputValue={
383+
internalControlValue?.title
384+
}
355385
setInternalTextInputValue={
356386
setInternalTextInputValue
357387
}
358388
handleSubmitWithEnter={ handleSubmitWithEnter }
359-
value={ value }
389+
value={ internalControlValue }
360390
settings={ settings }
361-
onChange={ onChange }
391+
onChange={ createSetInternalSettingValueHandler(
392+
settingsKeys
393+
) }
362394
/>
363395
) }
364396

@@ -367,7 +399,9 @@ function LinkControl( {
367399
variant="primary"
368400
onClick={ handleSubmit }
369401
className="block-editor-link-control__search-submit"
370-
disabled={ currentInputIsEmpty } // Disallow submitting empty values.
402+
disabled={
403+
! valueHasChanges || currentInputIsEmpty
404+
}
371405
>
372406
{ __( 'Apply' ) }
373407
</Button>

packages/block-editor/src/components/link-control/test/index.js

+61-4
Original file line numberDiff line numberDiff line change
@@ -1784,6 +1784,63 @@ describe( 'Addition Settings UI', () => {
17841784
} )
17851785
).toBeChecked();
17861786
} );
1787+
1788+
it( 'should require settings changes to be submitted/applied', async () => {
1789+
const user = userEvent.setup();
1790+
1791+
const mockOnChange = jest.fn();
1792+
1793+
const selectedLink = {
1794+
...fauxEntitySuggestions[ 0 ],
1795+
// Including a setting here helps to assert on a potential bug
1796+
// whereby settings on the suggestion override the current (internal)
1797+
// settings values set by the user in the UI.
1798+
opensInNewTab: false,
1799+
};
1800+
1801+
render(
1802+
<LinkControl
1803+
value={ selectedLink }
1804+
forceIsEditingLink
1805+
hasTextControl
1806+
onChange={ mockOnChange }
1807+
/>
1808+
);
1809+
1810+
// check that the "Apply" button is disabled by default.
1811+
const submitButton = screen.queryByRole( 'button', {
1812+
name: 'Apply',
1813+
} );
1814+
1815+
expect( submitButton ).toBeDisabled();
1816+
1817+
await toggleSettingsDrawer( user );
1818+
1819+
const opensInNewTabToggle = screen.queryByRole( 'checkbox', {
1820+
name: 'Open in new tab',
1821+
} );
1822+
1823+
// toggle the checkbox
1824+
await user.click( opensInNewTabToggle );
1825+
1826+
// Check settings are **not** directly submitted
1827+
// which would trigger the onChange handler.
1828+
expect( mockOnChange ).not.toHaveBeenCalled();
1829+
1830+
// Check Apply button is now enabled because changes
1831+
// have been detected.
1832+
expect( submitButton ).toBeEnabled();
1833+
1834+
// Submit the changed setting value using the Apply button
1835+
await user.click( submitButton );
1836+
1837+
// Assert the value is updated.
1838+
expect( mockOnChange ).toHaveBeenCalledWith(
1839+
expect.objectContaining( {
1840+
opensInNewTab: true,
1841+
} )
1842+
);
1843+
} );
17871844
} );
17881845

17891846
describe( 'Post types', () => {
@@ -2199,7 +2256,7 @@ describe( 'Controlling link title text', () => {
21992256

22002257
it( 'should allow `ENTER` keypress within the text field to trigger submission of value', async () => {
22012258
const user = userEvent.setup();
2202-
const textValue = 'My new text value';
2259+
const newTextValue = 'My new text value';
22032260
const mockOnChange = jest.fn();
22042261

22052262
render(
@@ -2218,14 +2275,14 @@ describe( 'Controlling link title text', () => {
22182275
expect( textInput ).toBeVisible();
22192276

22202277
await user.clear( textInput );
2221-
await user.keyboard( textValue );
2278+
await user.keyboard( newTextValue );
22222279

22232280
// Attempt to submit the empty search value in the input.
22242281
triggerEnter( textInput );
22252282

22262283
expect( mockOnChange ).toHaveBeenCalledWith(
22272284
expect.objectContaining( {
2228-
title: textValue,
2285+
title: newTextValue,
22292286
url: selectedLink.url,
22302287
} )
22312288
);
@@ -2236,7 +2293,7 @@ describe( 'Controlling link title text', () => {
22362293
).not.toBeInTheDocument();
22372294
} );
22382295

2239-
it( 'should reset state on value change', async () => {
2296+
it( 'should reset state upon controlled value change', async () => {
22402297
const user = userEvent.setup();
22412298
const textValue = 'My new text value';
22422299
const mockOnChange = jest.fn();

packages/block-editor/src/components/link-control/use-internal-input-value.js

-23
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
import { useState, useEffect } from '@wordpress/element';
5+
6+
export default function useInternalValue( value ) {
7+
const [ internalValue, setInternalValue ] = useState( value || {} );
8+
9+
// If the value prop changes, update the internal state.
10+
useEffect( () => {
11+
setInternalValue( ( prevValue ) => {
12+
if ( value && value !== prevValue ) {
13+
return value;
14+
}
15+
16+
return prevValue;
17+
} );
18+
}, [ value ] );
19+
20+
const setInternalURLInputValue = ( nextValue ) => {
21+
setInternalValue( {
22+
...internalValue,
23+
url: nextValue,
24+
} );
25+
};
26+
27+
const setInternalTextInputValue = ( nextValue ) => {
28+
setInternalValue( {
29+
...internalValue,
30+
title: nextValue,
31+
} );
32+
};
33+
34+
const createSetInternalSettingValueHandler =
35+
( settingsKeys ) => ( nextValue ) => {
36+
// Only apply settings values which are defined in the settings prop.
37+
const settingsUpdates = Object.keys( nextValue ).reduce(
38+
( acc, key ) => {
39+
if ( settingsKeys.includes( key ) ) {
40+
acc[ key ] = nextValue[ key ];
41+
}
42+
return acc;
43+
},
44+
{}
45+
);
46+
47+
setInternalValue( {
48+
...internalValue,
49+
...settingsUpdates,
50+
} );
51+
};
52+
53+
return [
54+
internalValue,
55+
setInternalValue,
56+
setInternalURLInputValue,
57+
setInternalTextInputValue,
58+
createSetInternalSettingValueHandler,
59+
];
60+
}

packages/e2e-tests/specs/editor/various/links.test.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -792,8 +792,11 @@ describe( 'Links', () => {
792792
);
793793
await settingsToggle.click();
794794

795+
// Wait for settings to open.
796+
await page.waitForXPath( `//label[text()='Open in new tab']` );
797+
795798
// Move focus back to RichText for the underlying link.
796-
await pressKeyTimes( 'Tab', 5 );
799+
await pressKeyTimes( 'Tab', 4 );
797800

798801
// Make a selection within the RichText.
799802
await pressKeyWithModifier( 'shift', 'ArrowRight' );

0 commit comments

Comments
 (0)