note.tsx 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import {useCallback, useMemo, useState} from 'react';
  2. import type {MentionsInputProps} from 'react-mentions';
  3. import {Mention, MentionsInput} from 'react-mentions';
  4. import type {Theme} from '@emotion/react';
  5. import {useTheme} from '@emotion/react';
  6. import styled from '@emotion/styled';
  7. import {mentionStyle} from 'sentry/components/activity/note/mentionStyle';
  8. import type {
  9. CreateError,
  10. MentionChangeEvent,
  11. Mentioned,
  12. } from 'sentry/components/activity/note/types';
  13. import {Button} from 'sentry/components/button';
  14. import ButtonBar from 'sentry/components/buttonBar';
  15. import {t} from 'sentry/locale';
  16. import {space} from 'sentry/styles/space';
  17. import type {NoteType} from 'sentry/types/alerts';
  18. import domId from 'sentry/utils/domId';
  19. import {useMembers} from 'sentry/utils/useMembers';
  20. import {useTeams} from 'sentry/utils/useTeams';
  21. type Props = {
  22. errorJSON?: CreateError | null;
  23. /**
  24. * This is the id of the server's note object and is meant to indicate that
  25. * you are editing an existing item
  26. */
  27. noteId?: string;
  28. onCancel?: () => void;
  29. onChange?: (e: MentionChangeEvent, extra: {updating?: boolean}) => void;
  30. onCreate?: (data: NoteType) => void;
  31. onUpdate?: (data: NoteType) => void;
  32. placeholder?: string;
  33. /**
  34. * The note text itself
  35. */
  36. text?: string;
  37. };
  38. function StreamlinedNoteInput({
  39. text,
  40. onCreate,
  41. onChange,
  42. onUpdate,
  43. onCancel,
  44. noteId,
  45. errorJSON,
  46. placeholder,
  47. }: Props) {
  48. const theme = useTheme();
  49. const {members} = useMembers();
  50. const {teams} = useTeams();
  51. const suggestMembers = members.map(member => ({
  52. id: `user:${member.id}`,
  53. display: member.name,
  54. }));
  55. const suggestTeams = teams.map(team => ({
  56. id: `team:${team.id}`,
  57. display: `#${team.slug}`,
  58. }));
  59. const [value, setValue] = useState(text ?? '');
  60. const [memberMentions, setMemberMentions] = useState<Mentioned[]>([]);
  61. const [teamMentions, setTeamMentions] = useState<Mentioned[]>([]);
  62. const [isSubmitVisible, setIsSubmitVisible] = useState(false);
  63. const canSubmit = value.trim() !== '';
  64. const cleanMarkdown = value
  65. .replace(/\[sentry\.strip:member\]/g, '@')
  66. .replace(/\[sentry\.strip:team\]/g, '');
  67. const existingItem = !!noteId;
  68. // each mention looks like [id, display]
  69. const finalizedMentions = [...memberMentions, ...teamMentions]
  70. .filter(mention => value.includes(mention[1]))
  71. .map(mention => mention[0]);
  72. const submitForm = useCallback(
  73. () =>
  74. existingItem
  75. ? onUpdate?.({text: cleanMarkdown, mentions: finalizedMentions})
  76. : onCreate?.({text: cleanMarkdown, mentions: finalizedMentions}),
  77. [existingItem, onUpdate, cleanMarkdown, finalizedMentions, onCreate]
  78. );
  79. const displaySubmitButton = useCallback(() => {
  80. setIsSubmitVisible(true);
  81. }, []);
  82. const handleSubmit = useCallback(
  83. (
  84. e:
  85. | React.FormEvent<HTMLFormElement>
  86. | React.KeyboardEvent<HTMLTextAreaElement>
  87. | React.KeyboardEvent<HTMLInputElement>
  88. ) => {
  89. e.preventDefault();
  90. submitForm();
  91. },
  92. [submitForm]
  93. );
  94. const handleAddMember = useCallback(
  95. (id: React.ReactText, display: string) =>
  96. setMemberMentions(existing => [...existing, [`${id}`, display]]),
  97. []
  98. );
  99. const handleAddTeam = useCallback(
  100. (id: React.ReactText, display: string) =>
  101. setTeamMentions(existing => [...existing, [`${id}`, display]]),
  102. []
  103. );
  104. const handleChange = useCallback<NonNullable<MentionsInputProps['onChange']>>(
  105. e => {
  106. setValue(e.target.value);
  107. onChange?.(e, {updating: existingItem});
  108. },
  109. [existingItem, onChange]
  110. );
  111. const handleKeyDown = useCallback<NonNullable<MentionsInputProps['onKeyDown']>>(
  112. e => {
  113. if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && canSubmit) {
  114. handleSubmit(e);
  115. }
  116. },
  117. [canSubmit, handleSubmit]
  118. );
  119. const errorId = useMemo(() => domId('note-error-'), []);
  120. const errorMessage =
  121. (errorJSON &&
  122. (typeof errorJSON.detail === 'string'
  123. ? errorJSON.detail
  124. : errorJSON.detail?.message || t('Unable to post comment'))) ||
  125. null;
  126. return (
  127. <NoteInputForm data-test-id="note-input-form" noValidate onSubmit={handleSubmit}>
  128. <MentionsInput
  129. aria-label={existingItem ? t('Edit comment') : t('Add a comment')}
  130. aria-errormessage={errorMessage ? errorId : undefined}
  131. style={{
  132. ...mentionStyle({theme, minHeight: 14, streamlined: true}),
  133. width: '100%',
  134. }}
  135. placeholder={placeholder}
  136. onChange={handleChange}
  137. onKeyDown={handleKeyDown}
  138. onFocus={displaySubmitButton}
  139. value={value}
  140. required
  141. >
  142. <Mention
  143. trigger="@"
  144. data={suggestMembers}
  145. onAdd={handleAddMember}
  146. displayTransform={(_id, display) => `@${display}`}
  147. markup="**[sentry.strip:member]__display__**"
  148. appendSpaceOnAdd
  149. />
  150. <Mention
  151. trigger="#"
  152. data={suggestTeams}
  153. onAdd={handleAddTeam}
  154. markup="**[sentry.strip:team]__display__**"
  155. appendSpaceOnAdd
  156. />
  157. </MentionsInput>
  158. {(isSubmitVisible || existingItem) && (
  159. <ButtonBar gap={0.5}>
  160. {existingItem && (
  161. <Button size="xs" onClick={onCancel}>
  162. {t('Cancel')}
  163. </Button>
  164. )}
  165. <Button
  166. priority="primary"
  167. size="xs"
  168. disabled={!canSubmit}
  169. aria-label={existingItem ? t('Save comment') : t('Submit comment')}
  170. type="submit"
  171. >
  172. {existingItem ? t('Save') : t('Comment')}
  173. </Button>
  174. </ButtonBar>
  175. )}
  176. </NoteInputForm>
  177. );
  178. }
  179. export {StreamlinedNoteInput};
  180. const getNoteInputErrorStyles = (p: {theme: Theme; error?: string}) => {
  181. if (!p.error) {
  182. return '';
  183. }
  184. return `
  185. color: ${p.theme.error};
  186. margin: -1px;
  187. border: 1px solid ${p.theme.error};
  188. border-radius: ${p.theme.borderRadius};
  189. &:before {
  190. display: block;
  191. content: '';
  192. width: 0;
  193. height: 0;
  194. border-top: 7px solid transparent;
  195. border-bottom: 7px solid transparent;
  196. border-right: 7px solid ${p.theme.red300};
  197. position: absolute;
  198. left: -7px;
  199. top: 12px;
  200. }
  201. &:after {
  202. display: block;
  203. content: '';
  204. width: 0;
  205. height: 0;
  206. border-top: 6px solid transparent;
  207. border-bottom: 6px solid transparent;
  208. border-right: 6px solid #fff;
  209. position: absolute;
  210. left: -5px;
  211. top: 12px;
  212. }
  213. `;
  214. };
  215. const NoteInputForm = styled('form')<{error?: string}>`
  216. display: flex;
  217. flex-direction: column;
  218. gap: ${space(0.75)};
  219. align-items: flex-end;
  220. width: 100%;
  221. transition: padding 0.2s ease-in-out;
  222. ${p => getNoteInputErrorStyles(p)};
  223. `;