allow to markdown and format in comment section in ticket dashboard
This commit is contained in:
parent
a7c3a8f5a8
commit
3bbf72d1d5
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import { Box, Button, Tooltip } from '@mui/material';
|
||||
import FormatBoldIcon from '@mui/icons-material/FormatBold';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { applyFormat, FormatKind } from './commentMarkdown';
|
||||
|
||||
interface CommentFormatToolbarProps {
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | HTMLInputElement | null>;
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function CommentFormatToolbar({
|
||||
textareaRef,
|
||||
value,
|
||||
onChange,
|
||||
disabled
|
||||
}: CommentFormatToolbarProps) {
|
||||
const intl = useIntl();
|
||||
|
||||
const handle = (kind: FormatKind) => () => {
|
||||
applyFormat(textareaRef.current, value, kind, onChange);
|
||||
};
|
||||
|
||||
const btnSx = { minWidth: 32, px: 1, py: 0.25, fontSize: 12, textTransform: 'none' as const };
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
||||
<Tooltip title={intl.formatMessage({ id: 'commentFormatBold', defaultMessage: 'Bold' })}>
|
||||
<span>
|
||||
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('bold')} disabled={disabled}>
|
||||
<FormatBoldIcon fontSize="inherit" />
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={intl.formatMessage({ id: 'commentFormatH1', defaultMessage: 'Heading 1' })}>
|
||||
<span>
|
||||
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('h1')} disabled={disabled}>
|
||||
H1
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={intl.formatMessage({ id: 'commentFormatH2', defaultMessage: 'Heading 2' })}>
|
||||
<span>
|
||||
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('h2')} disabled={disabled}>
|
||||
H2
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={intl.formatMessage({ id: 'commentFormatH3', defaultMessage: 'Heading 3' })}>
|
||||
<span>
|
||||
<Button size="small" variant="outlined" sx={btnSx} onClick={handle('h3')} disabled={disabled}>
|
||||
H3
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentFormatToolbar;
|
||||
|
|
@ -25,6 +25,8 @@ import { FormattedMessage, useIntl } from 'react-intl';
|
|||
import axiosConfig from 'src/Resources/axiosConfig';
|
||||
import { AdminUser, CommentAuthorType, TicketComment } from 'src/interfaces/TicketTypes';
|
||||
import DocumentList from 'src/components/DocumentList';
|
||||
import CommentFormatToolbar from './CommentFormatToolbar';
|
||||
import { renderCommentBody } from './commentMarkdown';
|
||||
|
||||
interface CommentThreadProps {
|
||||
ticketId: number;
|
||||
|
|
@ -55,6 +57,7 @@ function CommentThread({
|
|||
const [mentionedIds, setMentionedIds] = useState<number[]>([]);
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const commentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const editInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const MENTION_EXCLUDED_NAMES = ['inesco energy Master Admin'];
|
||||
|
||||
|
|
@ -280,6 +283,12 @@ function CommentThread({
|
|||
</Box>
|
||||
{isEditing ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 0.5 }}>
|
||||
<CommentFormatToolbar
|
||||
textareaRef={editInputRef}
|
||||
value={editBody}
|
||||
onChange={setEditBody}
|
||||
disabled={savingEdit}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
|
|
@ -287,6 +296,7 @@ function CommentThread({
|
|||
minRows={2}
|
||||
value={editBody}
|
||||
onChange={(e) => setEditBody(e.target.value)}
|
||||
inputRef={editInputRef}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button
|
||||
|
|
@ -307,9 +317,7 @@ function CommentThread({
|
|||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{comment.body}
|
||||
</Typography>
|
||||
renderCommentBody(comment.body)
|
||||
)}
|
||||
<DocumentList ticketCommentId={comment.id} refreshKey={refreshKey} />
|
||||
</Box>
|
||||
|
|
@ -320,6 +328,20 @@ function CommentThread({
|
|||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
|
||||
<CommentFormatToolbar
|
||||
textareaRef={commentInputRef}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
disabled={submitting || uploading}
|
||||
/>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
<FormattedMessage
|
||||
id="commentMarkdownHint"
|
||||
defaultMessage="Markdown: **bold**, #, ##, ###"
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
export type FormatKind = 'bold' | 'h1' | 'h2' | 'h3';
|
||||
|
||||
function renderInline(text: string): React.ReactNode[] {
|
||||
const parts = text.split(/\*\*(.+?)\*\*/g);
|
||||
return parts.map((p, i) =>
|
||||
i % 2 === 1 ? <strong key={i}>{p}</strong> : <React.Fragment key={i}>{p}</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderCommentBody(body: string): JSX.Element {
|
||||
const lines = body.split('\n');
|
||||
return (
|
||||
<Box sx={{ '& > *': { mb: 0.5 } }}>
|
||||
{lines.map((line, idx) => {
|
||||
if (line.startsWith('### ')) {
|
||||
return (
|
||||
<Typography key={idx} variant="subtitle1" sx={{ fontWeight: 600, mt: 1 }}>
|
||||
{renderInline(line.slice(4))}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
if (line.startsWith('## ')) {
|
||||
return (
|
||||
<Typography key={idx} variant="h6" sx={{ mt: 1.5 }}>
|
||||
{renderInline(line.slice(3))}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
if (line.startsWith('# ')) {
|
||||
return (
|
||||
<Typography key={idx} variant="h5" sx={{ mt: 2 }}>
|
||||
{renderInline(line.slice(2))}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Typography key={idx} variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{line ? renderInline(line) : '\u00A0'}
|
||||
</Typography>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function applyFormat(
|
||||
el: HTMLTextAreaElement | HTMLInputElement | null,
|
||||
value: string,
|
||||
kind: FormatKind,
|
||||
onChange: (next: string) => void
|
||||
): void {
|
||||
const start = el?.selectionStart ?? value.length;
|
||||
const end = el?.selectionEnd ?? value.length;
|
||||
|
||||
if (kind === 'bold') {
|
||||
const selected = value.slice(start, end);
|
||||
const wrapped = `**${selected}**`;
|
||||
const next = value.slice(0, start) + wrapped + value.slice(end);
|
||||
onChange(next);
|
||||
const caret = selected.length > 0 ? start + wrapped.length : start + 2;
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(caret, caret);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = kind === 'h1' ? '# ' : kind === 'h2' ? '## ' : '### ';
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||
const nlAfter = value.indexOf('\n', start);
|
||||
const lineEnd = nlAfter === -1 ? value.length : nlAfter;
|
||||
const line = value.slice(lineStart, lineEnd);
|
||||
const stripped = line.replace(/^#{1,3}\s/, '');
|
||||
const newLine = prefix + stripped;
|
||||
const next = value.slice(0, lineStart) + newLine + value.slice(lineEnd);
|
||||
onChange(next);
|
||||
const caret = lineStart + newLine.length;
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(caret, caret);
|
||||
});
|
||||
}
|
||||
|
|
@ -568,6 +568,11 @@
|
|||
"comments": "Kommentare",
|
||||
"noComments": "Noch keine Kommentare.",
|
||||
"commentEdited": "(bearbeitet {time})",
|
||||
"commentMarkdownHint": "Markdown: **fett**, #, ##, ###",
|
||||
"commentFormatBold": "Fett",
|
||||
"commentFormatH1": "Überschrift 1",
|
||||
"commentFormatH2": "Überschrift 2",
|
||||
"commentFormatH3": "Überschrift 3",
|
||||
"addComment": "Hinzufügen",
|
||||
"timeline": "Zeitverlauf",
|
||||
"noTimelineEvents": "Noch keine Ereignisse.",
|
||||
|
|
|
|||
|
|
@ -316,6 +316,11 @@
|
|||
"comments": "Comments",
|
||||
"noComments": "No comments yet.",
|
||||
"commentEdited": "(edited {time})",
|
||||
"commentMarkdownHint": "Markdown: **bold**, #, ##, ###",
|
||||
"commentFormatBold": "Bold",
|
||||
"commentFormatH1": "Heading 1",
|
||||
"commentFormatH2": "Heading 2",
|
||||
"commentFormatH3": "Heading 3",
|
||||
"addComment": "Add",
|
||||
"timeline": "Timeline",
|
||||
"noTimelineEvents": "No events yet.",
|
||||
|
|
|
|||
|
|
@ -568,6 +568,11 @@
|
|||
"comments": "Commentaires",
|
||||
"noComments": "Aucun commentaire pour le moment.",
|
||||
"commentEdited": "(modifié {time})",
|
||||
"commentMarkdownHint": "Markdown : **gras**, #, ##, ###",
|
||||
"commentFormatBold": "Gras",
|
||||
"commentFormatH1": "Titre 1",
|
||||
"commentFormatH2": "Titre 2",
|
||||
"commentFormatH3": "Titre 3",
|
||||
"addComment": "Ajouter",
|
||||
"timeline": "Chronologie",
|
||||
"noTimelineEvents": "Aucun événement pour le moment.",
|
||||
|
|
|
|||
|
|
@ -568,6 +568,11 @@
|
|||
"comments": "Commenti",
|
||||
"noComments": "Nessun commento ancora.",
|
||||
"commentEdited": "(modificato {time})",
|
||||
"commentMarkdownHint": "Markdown: **grassetto**, #, ##, ###",
|
||||
"commentFormatBold": "Grassetto",
|
||||
"commentFormatH1": "Titolo 1",
|
||||
"commentFormatH2": "Titolo 2",
|
||||
"commentFormatH3": "Titolo 3",
|
||||
"addComment": "Aggiungi",
|
||||
"timeline": "Cronologia",
|
||||
"noTimelineEvents": "Nessun evento ancora.",
|
||||
|
|
|
|||
Loading…
Reference in New Issue