@@ -7,13 +7,16 @@ import {Input} from 'sentry/components/core/input';
77import TextOverflow from 'sentry/components/textOverflow' ;
88import { IconEdit } from 'sentry/icons/iconEdit' ;
99import { space } from 'sentry/styles/space' ;
10- import { defined } from 'sentry/utils' ;
11- import useKeypress from 'sentry/utils/useKeyPress' ;
1210import useOnClickOutside from 'sentry/utils/useOnClickOutside' ;
1311
1412type Props = {
1513 onChange : ( value : string ) => void ;
1614 value : string ;
15+ /**
16+ * When true, clearing the input and blurring cancels the edit and restores
17+ * the previous value instead of showing an error toast.
18+ */
19+ allowEmpty ?: boolean ;
1720 'aria-label' ?: string ;
1821 autoSelect ?: boolean ;
1922 className ?: string ;
@@ -40,136 +43,170 @@ function EditableText({
4043 className,
4144 'aria-label' : ariaLabel ,
4245 placeholder,
46+ allowEmpty = false ,
4347} : Props ) {
4448 const [ isEditing , setIsEditing ] = useState ( false ) ;
45- const [ inputValue , setInputValue ] = useState ( value ) ;
49+ // Immediately reflect the last committed value while we wait for the parent prop update
50+ const [ optimisticValue , setOptimisticValue ] = useState < string | null > ( null ) ;
51+ // Current keystrokes while editing; cleared whenever editing ends
52+ const [ draftValue , setDraftValue ] = useState < string | null > ( null ) ;
4653
47- const isEmpty = ! inputValue . trim ( ) ;
54+ const currentValue = optimisticValue ?? value ;
55+ const currentDraft = draftValue ?? currentValue ;
56+ const isDraftEmpty = ! currentDraft . trim ( ) ;
4857
4958 const innerWrapperRef = useRef < HTMLDivElement > ( null ) ;
5059 const labelRef = useRef < HTMLDivElement > ( null ) ;
5160 const inputRef = useRef < HTMLInputElement > ( null ) ;
61+ const previousValueRef = useRef ( value ) ;
62+
63+ const showStatusMessage = useCallback (
64+ ( status : 'error' | 'success' ) => {
65+ if ( status === 'error' ) {
66+ if ( errorMessage ) {
67+ addErrorMessage ( errorMessage ) ;
68+ }
69+ return ;
70+ }
5271
53- const enter = useKeypress ( 'Enter' ) ;
54- const esc = useKeypress ( 'Escape' ) ;
72+ if ( successMessage ) {
73+ addSuccessMessage ( successMessage ) ;
74+ }
75+ } ,
76+ [ errorMessage , successMessage ]
77+ ) ;
5578
56- function revertValueAndCloseEditor ( ) {
57- if ( value !== inputValue ) {
58- setInputValue ( value ) ;
59- }
79+ const exitEditing = useCallback ( ( ) => {
80+ setIsEditing ( false ) ;
81+ } , [ ] ) ;
6082
61- if ( isEditing ) {
62- setIsEditing ( false ) ;
63- }
64- }
83+ const handleCancel = useCallback ( ( ) => {
84+ setDraftValue ( null ) ;
85+ exitEditing ( ) ;
86+ } , [ exitEditing ] ) ;
6587
66- // check to see if the user clicked outside of this component
67- useOnClickOutside ( innerWrapperRef , ( ) => {
68- if ( ! isEditing ) {
69- return ;
88+ const handleCommit = useCallback ( ( ) => {
89+ if ( isDraftEmpty ) {
90+ showStatusMessage ( 'error' ) ;
91+ return false ;
7092 }
7193
72- if ( isEmpty ) {
73- displayStatusMessage ( 'error' ) ;
74- return ;
94+ if ( currentDraft !== currentValue ) {
95+ onChange ( currentDraft ) ;
96+ showStatusMessage ( 'success' ) ;
7597 }
7698
77- if ( inputValue !== value ) {
78- onChange ( inputValue ) ;
79- displayStatusMessage ( 'success' ) ;
99+ exitEditing ( ) ;
100+ setOptimisticValue ( currentDraft ) ;
101+ setDraftValue ( null ) ;
102+ return true ;
103+ } , [
104+ currentDraft ,
105+ currentValue ,
106+ exitEditing ,
107+ isDraftEmpty ,
108+ onChange ,
109+ showStatusMessage ,
110+ ] ) ;
111+
112+ const handleEmptyBlur = useCallback ( ( ) => {
113+ if ( allowEmpty ) {
114+ handleCancel ( ) ;
115+ } else {
116+ showStatusMessage ( 'error' ) ;
80117 }
118+ } , [ allowEmpty , handleCancel , showStatusMessage ] ) ;
81119
82- setIsEditing ( false ) ;
83- } ) ;
84-
85- const onEnter = useCallback ( ( ) => {
86- if ( enter ) {
87- if ( isEmpty ) {
88- displayStatusMessage ( 'error' ) ;
89- return ;
90- }
91-
92- if ( inputValue !== value ) {
93- onChange ( inputValue ) ;
94- displayStatusMessage ( 'success' ) ;
95- }
96-
97- setIsEditing ( false ) ;
120+ // Close editing if the field becomes disabled (e.g. form revalidation)
121+ useEffect ( ( ) => {
122+ if ( isDisabled ) {
123+ handleCancel ( ) ;
98124 }
99- // eslint-disable-next-line react-hooks/exhaustive-deps
100- } , [ enter , inputValue , onChange ] ) ;
125+ } , [ handleCancel , isDisabled ] ) ;
101126
102- const onEsc = useCallback ( ( ) => {
103- if ( esc ) {
104- revertValueAndCloseEditor ( ) ;
127+ // Reset our optimistic/draft state whenever the controlled value changes externally
128+ useEffect ( ( ) => {
129+ if ( previousValueRef . current === value ) {
130+ return ;
105131 }
106- // eslint-disable-next-line react-hooks/exhaustive-deps
107- } , [ esc ] ) ;
108132
109- useEffect ( ( ) => {
110- revertValueAndCloseEditor ( ) ;
111- // eslint-disable-next-line react-hooks/exhaustive-deps
112- } , [ isDisabled , value ] ) ;
133+ previousValueRef . current = value ;
134+ setOptimisticValue ( null ) ;
113135
114- // focus the cursor in the input field on edit start
115- useEffect ( ( ) => {
116136 if ( isEditing ) {
117- const inputElement = inputRef . current ;
118- if ( defined ( inputElement ) ) {
119- inputElement . focus ( ) ;
120- }
137+ setDraftValue ( null ) ;
138+ exitEditing ( ) ;
121139 }
122- } , [ isEditing ] ) ;
140+ } , [ exitEditing , isEditing , value ] ) ;
123141
142+ // Focus the input whenever we enter editing mode
124143 useEffect ( ( ) => {
125144 if ( isEditing ) {
126- // if Enter is pressed, save the value and close the editor
127- onEnter ( ) ;
128- // if Escape is pressed, revert the value and close the editor
129- onEsc ( ) ;
145+ inputRef . current ?. focus ( ) ;
130146 }
131- } , [ onEnter , onEsc , isEditing ] ) ; // watch the Enter and Escape key presses
147+ } , [ isEditing ] ) ;
132148
133- function displayStatusMessage ( status : 'error' | 'success' ) {
134- if ( status === 'error' ) {
135- if ( errorMessage ) {
136- addErrorMessage ( errorMessage ) ;
137- }
149+ const handleClickOutside = useCallback ( ( ) => {
150+ if ( ! isEditing ) {
138151 return ;
139152 }
140153
141- if ( successMessage ) {
142- addSuccessMessage ( successMessage ) ;
154+ if ( isDraftEmpty ) {
155+ handleEmptyBlur ( ) ;
156+ return ;
143157 }
144- }
145158
146- function handleInputChange ( event : React . ChangeEvent < HTMLInputElement > ) {
147- setInputValue ( event . target . value ) ;
148- }
159+ handleCommit ( ) ;
160+ } , [ handleCommit , handleEmptyBlur , isDraftEmpty , isEditing ] ) ;
161+
162+ useOnClickOutside ( innerWrapperRef , handleClickOutside ) ;
163+
164+ const handleInputChange = useCallback ( ( event : React . ChangeEvent < HTMLInputElement > ) => {
165+ setDraftValue ( event . target . value ) ;
166+ } , [ ] ) ;
167+
168+ const handleEditClick = useCallback ( ( ) => {
169+ if ( isDisabled ) {
170+ return ;
171+ }
149172
150- function handleEditClick ( ) {
173+ setDraftValue ( currentValue ) ;
151174 setIsEditing ( true ) ;
152- }
175+ } , [ currentValue , isDisabled ] ) ;
176+
177+ const handleKeyDown = useCallback (
178+ ( event : React . KeyboardEvent < HTMLInputElement > ) => {
179+ if ( event . key === 'Enter' ) {
180+ event . preventDefault ( ) ;
181+ handleCommit ( ) ;
182+ } else if ( event . key === 'Escape' ) {
183+ event . preventDefault ( ) ;
184+ handleCancel ( ) ;
185+ }
186+ } ,
187+ [ handleCancel , handleCommit ]
188+ ) ;
153189
154190 return (
155191 < Wrapper isDisabled = { isDisabled } isEditing = { isEditing } className = { className } >
156192 { isEditing ? (
157193 < InputWrapper
158194 ref = { innerWrapperRef }
159- isEmpty = { isEmpty }
195+ isEmpty = { isDraftEmpty }
160196 data-test-id = "editable-text-input"
161197 >
162198 < StyledInput
163199 aria-label = { ariaLabel }
164200 name = { name }
165201 ref = { inputRef }
166- value = { inputValue }
202+ value = { currentDraft }
167203 onChange = { handleInputChange }
204+ onKeyDown = { handleKeyDown }
168205 onFocus = { event => autoSelect && event . target . select ( ) }
169206 maxLength = { maxLength }
170207 placeholder = { placeholder }
171208 />
172- < InputLabel > { inputValue } </ InputLabel >
209+ < InputLabel > { currentDraft } </ InputLabel >
173210 </ InputWrapper >
174211 ) : (
175212 < Label
@@ -178,7 +215,7 @@ function EditableText({
178215 isDisabled = { isDisabled }
179216 data-test-id = "editable-text-label"
180217 >
181- < InnerLabel > { inputValue || placeholder } </ InnerLabel >
218+ < InnerLabel > { currentValue || placeholder } </ InnerLabel >
182219 { ! isDisabled && < IconEdit /> }
183220 </ Label >
184221 ) }
0 commit comments