Skip to content

Commit a6803d7

Browse files
Merge branch 'master' into extract-fixability-v2
2 parents fc22f90 + a5b7783 commit a6803d7

File tree

13 files changed

+175
-134
lines changed

13 files changed

+175
-134
lines changed

static/app/components/editableText.spec.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,21 @@ describe('EditableText', () => {
4444
expect(handleChange).toHaveBeenCalledTimes(0);
4545
});
4646

47+
it('reverts to previous value when allowEmpty is true', async () => {
48+
const handleChange = jest.fn();
49+
50+
render(
51+
<EditableText value="foo" onChange={handleChange} allowEmpty errorMessage="error" />
52+
);
53+
54+
await userEvent.click(screen.getByText('foo'));
55+
await userEvent.clear(screen.getByRole('textbox'));
56+
await userEvent.click(document.body);
57+
58+
expect(handleChange).not.toHaveBeenCalled();
59+
expect(screen.getByText('foo')).toBeInTheDocument();
60+
});
61+
4762
it('displays a disabled value', async () => {
4863
render(<EditableText value="foo" onChange={jest.fn()} isDisabled />);
4964

static/app/components/editableText.tsx

Lines changed: 117 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@ import {Input} from 'sentry/components/core/input';
77
import TextOverflow from 'sentry/components/textOverflow';
88
import {IconEdit} from 'sentry/icons/iconEdit';
99
import {space} from 'sentry/styles/space';
10-
import {defined} from 'sentry/utils';
11-
import useKeypress from 'sentry/utils/useKeyPress';
1210
import useOnClickOutside from 'sentry/utils/useOnClickOutside';
1311

1412
type 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
)}

static/app/views/automations/components/editableAutomationName.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export function EditableAutomationName() {
1010
<FormField name="name" inline={false} flexibleControlStateSize stacked>
1111
{({onChange, value}) => (
1212
<EditableText
13-
isDisabled={false}
13+
allowEmpty
1414
value={value || ''}
1515
onChange={newValue => {
1616
// Mark that the user has manually set the automation name
@@ -21,7 +21,6 @@ export function EditableAutomationName() {
2121
},
2222
});
2323
}}
24-
errorMessage={t('Please set a name for your alert.')}
2524
placeholder={t('New Alert')}
2625
/>
2726
)}

static/app/views/automations/components/forms/automationNameUtils.spec.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,9 @@ describe('automationNameUtils', () => {
7878
actionFilters: [ActionFilterFixture({actions})],
7979
}) as AutomationBuilderState;
8080

81-
it('should return "New Alert" for empty actions', () => {
81+
it('should return "" for empty actions', () => {
8282
const builderState = createBuilderState([]);
83-
expect(getAutomationName(builderState)).toBe('New Alert');
83+
expect(getAutomationName(builderState)).toBe('');
8484
});
8585

8686
it('should return single action description', () => {

static/app/views/automations/components/forms/automationNameUtils.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export function getAutomationName(builderState: AutomationBuilderState): string
4545
const allActions = builderState.actionFilters.flatMap(group => group.actions || []);
4646

4747
if (allActions.length === 0) {
48-
return 'New Alert';
48+
return '';
4949
}
5050

5151
const count = allActions.length;
@@ -78,7 +78,7 @@ export function getAutomationName(builderState: AutomationBuilderState): string
7878
// Fallback if even a single action is too long
7979
if (actionsToInclude === 0) {
8080
if (count === 0) {
81-
automationName = 'New Alert';
81+
automationName = '';
8282
} else {
8383
automationName = t('New Alert (%s actions)', count);
8484
}

static/app/views/detectors/components/forms/common/describeSection.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import styled from '@emotion/styled';
2+
13
import TextareaField from 'sentry/components/forms/fields/textareaField';
24
import {Container} from 'sentry/components/workflowEngine/ui/container';
35
import Section from 'sentry/components/workflowEngine/ui/section';
@@ -10,7 +12,7 @@ export function DescribeSection() {
1012
title={t('Description')}
1113
description={t('Additional context about this monitor for other team members.')}
1214
>
13-
<TextareaField
15+
<MinHeightTextarea
1416
name="description"
1517
stacked
1618
inline={false}
@@ -19,8 +21,17 @@ export function DescribeSection() {
1921
'Example monitor description\n\nTo debug follow these steps:\n1. \u2026\n2. \u2026\n3. \u2026'
2022
)}
2123
rows={6}
24+
autosize
2225
/>
2326
</Section>
2427
</Container>
2528
);
2629
}
30+
31+
// Min height helps prevent resize after placeholder is replaced with user input
32+
const MinHeightTextarea = styled(TextareaField)`
33+
padding: 0;
34+
textarea {
35+
min-height: 140px;
36+
}
37+
`;

static/app/views/detectors/components/forms/cron/fields.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,17 @@ export function cronFormDataToEndpointPayload(
7575
schedule_type: ScheduleType.INTERVAL,
7676
};
7777

78+
const name = data.name || 'New Monitor';
7879
return {
7980
type: 'monitor_check_in_failure',
80-
name: data.name,
81+
name,
8182
description: data.description || null,
8283
owner: data.owner,
8384
projectId: data.projectId,
8485
workflowIds: data.workflowIds,
8586
dataSources: [
8687
{
87-
name: data.name,
88+
name,
8889
config,
8990
},
9091
],

0 commit comments

Comments
 (0)