editableText.tsx 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. import {useCallback, useEffect, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  4. import Input from 'sentry/components/input';
  5. import TextOverflow from 'sentry/components/textOverflow';
  6. import {IconEdit} from 'sentry/icons/iconEdit';
  7. import space from 'sentry/styles/space';
  8. import {defined} from 'sentry/utils';
  9. import useKeypress from 'sentry/utils/useKeyPress';
  10. import useOnClickOutside from 'sentry/utils/useOnClickOutside';
  11. type Props = {
  12. onChange: (value: string) => void;
  13. value: string;
  14. 'aria-label'?: string;
  15. autoSelect?: boolean;
  16. className?: string;
  17. errorMessage?: React.ReactNode;
  18. isDisabled?: boolean;
  19. maxLength?: number;
  20. name?: string;
  21. successMessage?: React.ReactNode;
  22. };
  23. function EditableText({
  24. value,
  25. onChange,
  26. name,
  27. errorMessage,
  28. successMessage,
  29. maxLength,
  30. isDisabled = false,
  31. autoSelect = false,
  32. className,
  33. 'aria-label': ariaLabel,
  34. }: Props) {
  35. const [isEditing, setIsEditing] = useState(false);
  36. const [inputValue, setInputValue] = useState(value);
  37. const isEmpty = !inputValue.trim();
  38. const innerWrapperRef = useRef<HTMLDivElement>(null);
  39. const labelRef = useRef<HTMLDivElement>(null);
  40. const inputRef = useRef<HTMLInputElement>(null);
  41. const enter = useKeypress('Enter');
  42. const esc = useKeypress('Escape');
  43. function revertValueAndCloseEditor() {
  44. if (value !== inputValue) {
  45. setInputValue(value);
  46. }
  47. if (isEditing) {
  48. setIsEditing(false);
  49. }
  50. }
  51. // check to see if the user clicked outside of this component
  52. useOnClickOutside(innerWrapperRef, () => {
  53. if (!isEditing) {
  54. return;
  55. }
  56. if (isEmpty) {
  57. displayStatusMessage('error');
  58. return;
  59. }
  60. if (inputValue !== value) {
  61. onChange(inputValue);
  62. displayStatusMessage('success');
  63. }
  64. setIsEditing(false);
  65. });
  66. const onEnter = useCallback(() => {
  67. if (enter) {
  68. if (isEmpty) {
  69. displayStatusMessage('error');
  70. return;
  71. }
  72. if (inputValue !== value) {
  73. onChange(inputValue);
  74. displayStatusMessage('success');
  75. }
  76. setIsEditing(false);
  77. }
  78. }, [enter, inputValue, onChange]);
  79. const onEsc = useCallback(() => {
  80. if (esc) {
  81. revertValueAndCloseEditor();
  82. }
  83. }, [esc]);
  84. useEffect(() => {
  85. revertValueAndCloseEditor();
  86. }, [isDisabled, value]);
  87. // focus the cursor in the input field on edit start
  88. useEffect(() => {
  89. if (isEditing) {
  90. const inputElement = inputRef.current;
  91. if (defined(inputElement)) {
  92. inputElement.focus();
  93. }
  94. }
  95. }, [isEditing]);
  96. useEffect(() => {
  97. if (isEditing) {
  98. // if Enter is pressed, save the value and close the editor
  99. onEnter();
  100. // if Escape is pressed, revert the value and close the editor
  101. onEsc();
  102. }
  103. }, [onEnter, onEsc, isEditing]); // watch the Enter and Escape key presses
  104. function displayStatusMessage(status: 'error' | 'success') {
  105. if (status === 'error') {
  106. if (errorMessage) {
  107. addErrorMessage(errorMessage);
  108. }
  109. return;
  110. }
  111. if (successMessage) {
  112. addSuccessMessage(successMessage);
  113. }
  114. }
  115. function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
  116. setInputValue(event.target.value);
  117. }
  118. function handleEditClick() {
  119. setIsEditing(true);
  120. }
  121. return (
  122. <Wrapper isDisabled={isDisabled} isEditing={isEditing} className={className}>
  123. {isEditing ? (
  124. <InputWrapper
  125. ref={innerWrapperRef}
  126. isEmpty={isEmpty}
  127. data-test-id="editable-text-input"
  128. >
  129. <StyledInput
  130. aria-label={ariaLabel}
  131. name={name}
  132. ref={inputRef}
  133. value={inputValue}
  134. onChange={handleInputChange}
  135. onFocus={event => autoSelect && event.target.select()}
  136. maxLength={maxLength}
  137. />
  138. <InputLabel>{inputValue}</InputLabel>
  139. </InputWrapper>
  140. ) : (
  141. <Label
  142. onClick={isDisabled ? undefined : handleEditClick}
  143. ref={labelRef}
  144. isDisabled={isDisabled}
  145. data-test-id="editable-text-label"
  146. >
  147. <InnerLabel>{inputValue}</InnerLabel>
  148. {!isDisabled && <IconEdit />}
  149. </Label>
  150. )}
  151. </Wrapper>
  152. );
  153. }
  154. export default EditableText;
  155. export const Label = styled('div')<{isDisabled: boolean}>`
  156. display: grid;
  157. grid-auto-flow: column;
  158. align-items: center;
  159. gap: ${space(1)};
  160. cursor: ${p => (p.isDisabled ? 'default' : 'pointer')};
  161. `;
  162. const InnerLabel = styled(TextOverflow)`
  163. border-top: 1px solid transparent;
  164. border-bottom: 1px dotted ${p => p.theme.gray200};
  165. `;
  166. const InputWrapper = styled('div')<{isEmpty: boolean}>`
  167. display: inline-block;
  168. background: ${p => p.theme.gray100};
  169. border-radius: ${p => p.theme.borderRadius};
  170. margin: -${space(0.5)} -${space(1)};
  171. padding: ${space(0.5)} ${space(1)};
  172. max-width: calc(100% + ${space(2)});
  173. `;
  174. const StyledInput = styled(Input)`
  175. border: none !important;
  176. background: transparent;
  177. height: auto;
  178. min-height: 34px;
  179. padding: 0;
  180. font-size: inherit;
  181. &,
  182. &:focus,
  183. &:active,
  184. &:hover {
  185. box-shadow: none;
  186. }
  187. `;
  188. const InputLabel = styled('div')`
  189. height: 0;
  190. opacity: 0;
  191. white-space: pre;
  192. padding: 0 ${space(1)};
  193. `;
  194. const Wrapper = styled('div')<{isDisabled: boolean; isEditing: boolean}>`
  195. display: flex;
  196. ${p =>
  197. p.isDisabled &&
  198. `
  199. ${InnerLabel} {
  200. border-bottom-color: transparent;
  201. }
  202. `}
  203. `;