note.tsx 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  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 {t} from 'sentry/locale';
  14. import type {NoteType} from 'sentry/types/alerts';
  15. import domId from 'sentry/utils/domId';
  16. import {useMembers} from 'sentry/utils/useMembers';
  17. import {useTeams} from 'sentry/utils/useTeams';
  18. type Props = {
  19. errorJSON?: CreateError | null;
  20. /**
  21. * This is the id of the server's note object and is meant to indicate that
  22. * you are editing an existing item
  23. */
  24. noteId?: string;
  25. onChange?: (e: MentionChangeEvent, extra: {updating?: boolean}) => void;
  26. onCreate?: (data: NoteType) => void;
  27. onUpdate?: (data: NoteType) => void;
  28. placeholder?: string;
  29. /**
  30. * The note text itself
  31. */
  32. text?: string;
  33. };
  34. function StreamlinedNoteInput({
  35. text,
  36. onCreate,
  37. onChange,
  38. onUpdate,
  39. noteId,
  40. errorJSON,
  41. placeholder,
  42. }: Props) {
  43. const theme = useTheme();
  44. const {members} = useMembers();
  45. const {teams} = useTeams();
  46. const suggestMembers = members.map(member => ({
  47. id: `user:${member.id}`,
  48. display: member.name,
  49. }));
  50. const suggestTeams = teams.map(team => ({
  51. id: `team:${team.id}`,
  52. display: `#${team.slug}`,
  53. }));
  54. const [value, setValue] = useState(text ?? '');
  55. const [memberMentions, setMemberMentions] = useState<Mentioned[]>([]);
  56. const [teamMentions, setTeamMentions] = useState<Mentioned[]>([]);
  57. const canSubmit = value.trim() !== '';
  58. const cleanMarkdown = value
  59. .replace(/\[sentry\.strip:member\]/g, '@')
  60. .replace(/\[sentry\.strip:team\]/g, '');
  61. const existingItem = !!noteId;
  62. // each mention looks like [id, display]
  63. const finalizedMentions = [...memberMentions, ...teamMentions]
  64. .filter(mention => value.includes(mention[1]))
  65. .map(mention => mention[0]);
  66. const submitForm = useCallback(
  67. () =>
  68. existingItem
  69. ? onUpdate?.({text: cleanMarkdown, mentions: finalizedMentions})
  70. : onCreate?.({text: cleanMarkdown, mentions: finalizedMentions}),
  71. [existingItem, onUpdate, cleanMarkdown, finalizedMentions, onCreate]
  72. );
  73. const handleSubmit = useCallback(
  74. (e: React.MouseEvent<HTMLFormElement>) => {
  75. e.preventDefault();
  76. submitForm();
  77. },
  78. [submitForm]
  79. );
  80. const handleAddMember = useCallback(
  81. (id: React.ReactText, display: string) =>
  82. setMemberMentions(existing => [...existing, [`${id}`, display]]),
  83. []
  84. );
  85. const handleAddTeam = useCallback(
  86. (id: React.ReactText, display: string) =>
  87. setTeamMentions(existing => [...existing, [`${id}`, display]]),
  88. []
  89. );
  90. const handleChange: MentionsInputProps['onChange'] = useCallback(
  91. e => {
  92. setValue(e.target.value);
  93. onChange?.(e, {updating: existingItem});
  94. },
  95. [existingItem, onChange]
  96. );
  97. const handleKeyDown: MentionsInputProps['onKeyDown'] = useCallback(
  98. e => {
  99. if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && canSubmit) {
  100. handleSubmit(e);
  101. }
  102. },
  103. [canSubmit, handleSubmit]
  104. );
  105. const errorId = useMemo(() => domId('note-error-'), []);
  106. const errorMessage =
  107. (errorJSON &&
  108. (typeof errorJSON.detail === 'string'
  109. ? errorJSON.detail
  110. : errorJSON.detail?.message || t('Unable to post comment'))) ||
  111. null;
  112. return (
  113. <NoteInputForm data-test-id="note-input-form" noValidate onSubmit={handleSubmit}>
  114. <MentionsInput
  115. aria-errormessage={errorMessage ? errorId : undefined}
  116. style={mentionStyle({theme, minHeight: 14, streamlined: true})}
  117. placeholder={placeholder}
  118. onChange={handleChange}
  119. onKeyDown={handleKeyDown}
  120. value={value}
  121. required
  122. >
  123. <Mention
  124. trigger="@"
  125. data={suggestMembers}
  126. onAdd={handleAddMember}
  127. displayTransform={(_id, display) => `@${display}`}
  128. markup="**[sentry.strip:member]__display__**"
  129. appendSpaceOnAdd
  130. />
  131. <Mention
  132. trigger="#"
  133. data={suggestTeams}
  134. onAdd={handleAddTeam}
  135. markup="**[sentry.strip:team]__display__**"
  136. appendSpaceOnAdd
  137. />
  138. </MentionsInput>
  139. </NoteInputForm>
  140. );
  141. }
  142. export {StreamlinedNoteInput};
  143. const getNoteInputErrorStyles = (p: {theme: Theme; error?: string}) => {
  144. if (!p.error) {
  145. return '';
  146. }
  147. return `
  148. color: ${p.theme.error};
  149. margin: -1px;
  150. border: 1px solid ${p.theme.error};
  151. border-radius: ${p.theme.borderRadius};
  152. &:before {
  153. display: block;
  154. content: '';
  155. width: 0;
  156. height: 0;
  157. border-top: 7px solid transparent;
  158. border-bottom: 7px solid transparent;
  159. border-right: 7px solid ${p.theme.red300};
  160. position: absolute;
  161. left: -7px;
  162. top: 12px;
  163. }
  164. &:after {
  165. display: block;
  166. content: '';
  167. width: 0;
  168. height: 0;
  169. border-top: 6px solid transparent;
  170. border-bottom: 6px solid transparent;
  171. border-right: 6px solid #fff;
  172. position: absolute;
  173. left: -5px;
  174. top: 12px;
  175. }
  176. `;
  177. };
  178. const NoteInputForm = styled('form')<{error?: string}>`
  179. transition: padding 0.2s ease-in-out;
  180. ${p => getNoteInputErrorStyles(p)};
  181. `;