Files
2024-02-15 17:01:50 +00:00

240 lines
5.6 KiB
TypeScript

import {useCallback, useEffect, useRef, useState} from 'react';
import styled from '@emotion/styled';
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
import Input from 'sentry/components/input';
import TextOverflow from 'sentry/components/textOverflow';
import {IconEdit} from 'sentry/icons/iconEdit';
import {space} from 'sentry/styles/space';
import {defined} from 'sentry/utils';
import useKeypress from 'sentry/utils/useKeyPress';
import useOnClickOutside from 'sentry/utils/useOnClickOutside';
type Props = {
onChange: (value: string) => void;
value: string;
'aria-label'?: string;
autoSelect?: boolean;
className?: string;
errorMessage?: React.ReactNode;
isDisabled?: boolean;
maxLength?: number;
name?: string;
successMessage?: React.ReactNode;
};
function EditableText({
value,
onChange,
name,
errorMessage,
successMessage,
maxLength,
isDisabled = false,
autoSelect = false,
className,
'aria-label': ariaLabel,
}: Props) {
const [isEditing, setIsEditing] = useState(false);
const [inputValue, setInputValue] = useState(value);
const isEmpty = !inputValue.trim();
const innerWrapperRef = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const enter = useKeypress('Enter');
const esc = useKeypress('Escape');
function revertValueAndCloseEditor() {
if (value !== inputValue) {
setInputValue(value);
}
if (isEditing) {
setIsEditing(false);
}
}
// check to see if the user clicked outside of this component
useOnClickOutside(innerWrapperRef, () => {
if (!isEditing) {
return;
}
if (isEmpty) {
displayStatusMessage('error');
return;
}
if (inputValue !== value) {
onChange(inputValue);
displayStatusMessage('success');
}
setIsEditing(false);
});
const onEnter = useCallback(() => {
if (enter) {
if (isEmpty) {
displayStatusMessage('error');
return;
}
if (inputValue !== value) {
onChange(inputValue);
displayStatusMessage('success');
}
setIsEditing(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enter, inputValue, onChange]);
const onEsc = useCallback(() => {
if (esc) {
revertValueAndCloseEditor();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [esc]);
useEffect(() => {
revertValueAndCloseEditor();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDisabled, value]);
// focus the cursor in the input field on edit start
useEffect(() => {
if (isEditing) {
const inputElement = inputRef.current;
if (defined(inputElement)) {
inputElement.focus();
}
}
}, [isEditing]);
useEffect(() => {
if (isEditing) {
// if Enter is pressed, save the value and close the editor
onEnter();
// if Escape is pressed, revert the value and close the editor
onEsc();
}
}, [onEnter, onEsc, isEditing]); // watch the Enter and Escape key presses
function displayStatusMessage(status: 'error' | 'success') {
if (status === 'error') {
if (errorMessage) {
addErrorMessage(errorMessage);
}
return;
}
if (successMessage) {
addSuccessMessage(successMessage);
}
}
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
setInputValue(event.target.value);
}
function handleEditClick() {
setIsEditing(true);
}
return (
<Wrapper isDisabled={isDisabled} isEditing={isEditing} className={className}>
{isEditing ? (
<InputWrapper
ref={innerWrapperRef}
isEmpty={isEmpty}
data-test-id="editable-text-input"
>
<StyledInput
aria-label={ariaLabel}
name={name}
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onFocus={event => autoSelect && event.target.select()}
maxLength={maxLength}
/>
<InputLabel>{inputValue}</InputLabel>
</InputWrapper>
) : (
<Label
onClick={isDisabled ? undefined : handleEditClick}
ref={labelRef}
isDisabled={isDisabled}
data-test-id="editable-text-label"
>
<InnerLabel>{inputValue}</InnerLabel>
{!isDisabled && <IconEdit />}
</Label>
)}
</Wrapper>
);
}
export default EditableText;
export const Label = styled('div')<{isDisabled: boolean}>`
display: grid;
grid-auto-flow: column;
align-items: center;
gap: ${space(1)};
cursor: ${p => (p.isDisabled ? 'default' : 'pointer')};
`;
const InnerLabel = styled(TextOverflow)`
border-top: 1px solid transparent;
border-bottom: 1px dotted ${p => p.theme.gray200};
line-height: 38px;
`;
const InputWrapper = styled('div')<{isEmpty: boolean}>`
display: inline-block;
background: ${p => p.theme.gray100};
border-radius: ${p => p.theme.borderRadius};
margin: -${space(0.5)} -${space(1)};
padding: ${space(0.5)} ${space(1)};
max-width: calc(100% + ${space(2)});
`;
const StyledInput = styled(Input)`
border: none !important;
background: transparent;
height: auto;
min-height: 40px;
padding: 0;
font-size: inherit;
&,
&:focus,
&:active,
&:hover {
box-shadow: none;
}
`;
const InputLabel = styled('div')`
height: 0;
opacity: 0;
white-space: pre;
padding: 0 ${space(1)};
`;
const Wrapper = styled('div')<{isDisabled: boolean; isEditing: boolean}>`
display: flex;
${p =>
p.isDisabled &&
`
${InnerLabel} {
border-bottom-color: transparent;
}
`}
`;