note.tsx 6.3 KB

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