diff --git a/querybook/webapp/const/keyMap.ts b/querybook/webapp/const/keyMap.ts index 27bea351e..5ea9ef806 100644 --- a/querybook/webapp/const/keyMap.ts +++ b/querybook/webapp/const/keyMap.ts @@ -20,6 +20,10 @@ const DEFAULT_KEY_MAP = { key: 'Enter', name: 'Confirm Modal', }, + submitComment: { + key: 'Cmd-Enter', + name: 'Submit Comment', + }, }, dataDoc: { saveDataDoc: { diff --git a/querybook/webapp/redux/comment/action.ts b/querybook/webapp/redux/comment/action.ts index 7952dadff..ce48180d7 100644 --- a/querybook/webapp/redux/comment/action.ts +++ b/querybook/webapp/redux/comment/action.ts @@ -154,6 +154,24 @@ export function deleteComment(commentId: number): ThunkResult> { }; } +export function undoDeleteComment( + commentId: number +): ThunkResult> { + return async (dispatch) => { + const { data: newComment } = await CommentResource.undoDelete( + commentId + ); + + dispatch({ + type: '@@comment/RECEIVE_COMMENTS', + payload: { + comments: [newComment], + }, + }); + + return newComment; + }; +} export function updateComment( commentId: number, text: ContentState diff --git a/querybook/webapp/resource/comment.ts b/querybook/webapp/resource/comment.ts index 24aecdd57..c42b47e0c 100644 --- a/querybook/webapp/resource/comment.ts +++ b/querybook/webapp/resource/comment.ts @@ -17,6 +17,10 @@ export const CommentResource = { }), softDelete: (commentId: number) => ds.delete(`/comment/${commentId}/`), + undoDelete: (commentId: number) => + ds.update(`/comment/${commentId}/`, { + archived: false, + }), }; export const CellCommentResource = { diff --git a/querybook/webapp/ui/Comment/AddReactionButton.tsx b/querybook/webapp/ui/Comment/AddReactionButton.tsx index 7bc3e7d2d..fb213c3d0 100644 --- a/querybook/webapp/ui/Comment/AddReactionButton.tsx +++ b/querybook/webapp/ui/Comment/AddReactionButton.tsx @@ -49,33 +49,38 @@ export const AddReactionButton: React.FunctionComponent = ({ const addReactionButtonRef = React.useRef(); const [showEmojis, setShowEmojis] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); const handleEmojiClick = React.useCallback( (emoji: string) => { const existingReaction = reactionsByEmoji[emoji]?.find( (reaction) => reaction.created_by === uid ); + setIsLoading(true); if (existingReaction) { dispatch( deleteReactionByCommentId(commentId, existingReaction.id) - ); + ).finally(() => setIsLoading(false)); } else { - dispatch(addReactionByCommentId(commentId, emoji)); + dispatch(addReactionByCommentId(commentId, emoji)).finally(() => + setIsLoading(false) + ); } }, - [commentId, dispatch, uid, reactionsByEmoji] + [reactionsByEmoji, uid, dispatch, commentId] ); return (
setShowEmojis(true)} + disabled={isLoading} /> {showEmojis ? ( void; - deleteComment: () => void; + deleteComment: () => Promise; isBeingEdited: boolean; isBeingRepliedTo: boolean; isChild: boolean; onCreateChildComment: () => void; + onReplyingToClick: () => void; } const formatReactionsByEmoji = ( @@ -45,9 +47,16 @@ export const Comment: React.FunctionComponent = ({ isBeingRepliedTo, isChild, onCreateChildComment, + onReplyingToClick, }) => { + const dispatch: Dispatch = useDispatch(); + const userInfo = useSelector((state: IStoreState) => state.user.myUserInfo); + const [isDeleting, setIsDeleting] = React.useState(false); + const [recentlyArchived, setRecentlyArchived] = + React.useState(false); + const { id, text, @@ -68,6 +77,12 @@ export const Comment: React.FunctionComponent = ({ [reactions] ); + const handleUndoDeleteComment = React.useCallback(() => { + dispatch(undoDeleteComment(comment.id)).finally(() => + setRecentlyArchived(false) + ); + }, [comment.id, dispatch]); + return (
= ({ ) : null} {isBeingRepliedTo ? ( - - replying to - + + replying to + + ) : null} - {archived ? null : ( -
- {isAuthor && !isBeingEdited ? ( -
- editComment(text)} - /> - -
- ) : null} - {isChild ? null : ( -
- + {archived || isDeleting ? null : ( + <> + {isAuthor && !isBeingEdited ? ( +
+ editComment(text)} + /> + { + setIsDeleting(true); + deleteComment().then(() => { + setRecentlyArchived(true); + setIsDeleting(false); + }); + }} + /> +
+ ) : null} + {isChild ? null : ( +
+ +
+ )} +
+
- )} -
- -
-
- )} + + )} + {recentlyArchived ? ( + + ) : null} +
diff --git a/querybook/webapp/ui/Comment/Comments.scss b/querybook/webapp/ui/Comment/Comments.scss index 05e31cb7a..e6bc0f6fd 100644 --- a/querybook/webapp/ui/Comment/Comments.scss +++ b/querybook/webapp/ui/Comment/Comments.scss @@ -26,6 +26,9 @@ color: var(--text-lightest-0); } } + .IconButton.disabled { + color: var(--icon-light); + } } .CommentThread { margin-left: 32px; @@ -129,7 +132,7 @@ .Reaction { background-color: var(--bg-light); border-radius: var(--border-radius-sm); - cursor: default; + cursor: pointer; &.active { background-color: var(--color-accent-lightest-0); } diff --git a/querybook/webapp/ui/Comment/Comments.tsx b/querybook/webapp/ui/Comment/Comments.tsx index d4ac20b29..89e179748 100644 --- a/querybook/webapp/ui/Comment/Comments.tsx +++ b/querybook/webapp/ui/Comment/Comments.tsx @@ -10,6 +10,7 @@ import { IComment, } from 'const/comment'; import { useShallowSelector } from 'hooks/redux/useShallowSelector'; +import { getShortcutSymbols, KeyMap } from 'lib/utils/keyboard'; import { createChildComment, createComment, @@ -34,6 +35,10 @@ interface IProps { const emptyCommentValue = DraftJs.ContentState.createFromText(''); +const ON_SUBMIT_SHORTCUT = getShortcutSymbols( + KeyMap.overallUI.submitComment.key +); + export const Comments: React.FunctionComponent = ({ entityType, entityId, @@ -130,9 +135,7 @@ export const Comments: React.FunctionComponent = ({ }, []); const handleArchiveComment = React.useCallback( - (commentId: number) => { - dispatch(deleteComment(commentId)); - }, + (commentId: number) => dispatch(deleteComment(commentId)), [dispatch] ); @@ -153,7 +156,11 @@ export const Comments: React.FunctionComponent = ({ onCreateChildComment={() => handleEditComment(undefined, undefined, comment.id) } - isBeingRepliedTo={editingCommentParentId === comment.id} + isBeingRepliedTo={ + editingCommentParentId === comment.id && + editingCommentId !== comment.id + } + onReplyingToClick={handleCommentClear} /> ) : null; @@ -215,7 +222,11 @@ export const Comments: React.FunctionComponent = ({ return ( <> - + { @@ -237,7 +248,7 @@ export const Comments: React.FunctionComponent = ({ noPadding size={18} className="mr4" - tooltip="Comment" + tooltip={`Comment (${ON_SUBMIT_SHORTCUT})`} tooltipPos="left" disabled={isTextEmpty} /> diff --git a/querybook/webapp/ui/Comment/Reactions.tsx b/querybook/webapp/ui/Comment/Reactions.tsx index 309b8156e..6a76bff70 100644 --- a/querybook/webapp/ui/Comment/Reactions.tsx +++ b/querybook/webapp/ui/Comment/Reactions.tsx @@ -8,6 +8,7 @@ import { deleteReactionByCommentId, } from 'redux/comment/action'; import { Dispatch, IStoreState } from 'redux/store/types'; +import { Icon } from 'ui/Icon/Icon'; import { StyledText } from 'ui/StyledText/StyledText'; import { AddReactionButton } from './AddReactionButton'; @@ -24,14 +25,17 @@ export const Reactions: React.FunctionComponent = ({ const dispatch: Dispatch = useDispatch(); const userInfo = useSelector((state: IStoreState) => state.user.myUserInfo); + const [isLoadingEmoji, setIsLoadingEmoji] = React.useState( + null + ); + const addEmoji = React.useCallback( (emoji: string) => dispatch(addReactionByCommentId(commentId, emoji)), [commentId, dispatch] ); const deleteEmoji = React.useCallback( - (reactionId) => { - dispatch(deleteReactionByCommentId(commentId, reactionId)); - }, + (reactionId) => + dispatch(deleteReactionByCommentId(commentId, reactionId)), [dispatch, commentId] ); @@ -39,10 +43,13 @@ export const Reactions: React.FunctionComponent = ({ const existingReaction = reactionsByEmoji[emoji].find( (reaction) => reaction.created_by === uid ); + setIsLoadingEmoji(emoji); if (existingReaction) { - deleteEmoji(existingReaction.id); + deleteEmoji(existingReaction.id).finally(() => + setIsLoadingEmoji(null) + ); } else { - addEmoji(emoji); + addEmoji(emoji).finally(() => setIsLoadingEmoji(null)); } }; @@ -61,7 +68,11 @@ export const Reactions: React.FunctionComponent = ({
handleReactionClick(emoji, userInfo.uid)} + onClick={() => + isLoadingEmoji === emoji + ? null + : handleReactionClick(emoji, userInfo.uid) + } > {emoji} = ({ size="small" cursor="default" > - {uids.length} + {isLoadingEmoji === emoji ? ( + + ) : ( + uids.length + )}
); diff --git a/querybook/webapp/ui/FormikField/RichTextField.tsx b/querybook/webapp/ui/FormikField/RichTextField.tsx index 4e5aa430e..af3a1ea3a 100644 --- a/querybook/webapp/ui/FormikField/RichTextField.tsx +++ b/querybook/webapp/ui/FormikField/RichTextField.tsx @@ -7,9 +7,15 @@ import './RichTextField.scss'; export interface IRichTextFieldProps { name: string; + autoFocus?: boolean; + onSubmit?: () => void; } -export const RichTextField: React.FC = ({ name }) => { +export const RichTextField: React.FC = ({ + name, + autoFocus, + onSubmit, +}) => { const [field, meta, helpers] = useField(name); const { value } = meta; @@ -20,5 +26,12 @@ export const RichTextField: React.FC = ({ name }) => { [] ); - return ; + return ( + + ); }; diff --git a/querybook/webapp/ui/Icon/LucideIcons.ts b/querybook/webapp/ui/Icon/LucideIcons.ts index 7d53f820e..bac6f3be9 100644 --- a/querybook/webapp/ui/Icon/LucideIcons.ts +++ b/querybook/webapp/ui/Icon/LucideIcons.ts @@ -85,6 +85,7 @@ import { Quote, RefreshCw, Repeat, + RotateCcw, Save, Scissors, Search, @@ -200,6 +201,7 @@ const AllLucideIcons = { Quote, RefreshCw, Repeat, + RotateCcw, Save, Scissors, Search, diff --git a/querybook/webapp/ui/RichTextEditor/RichTextEditor.tsx b/querybook/webapp/ui/RichTextEditor/RichTextEditor.tsx index 5fad4e1c6..0ea009c3c 100644 --- a/querybook/webapp/ui/RichTextEditor/RichTextEditor.tsx +++ b/querybook/webapp/ui/RichTextEditor/RichTextEditor.tsx @@ -48,6 +48,8 @@ export interface IRichTextEditorProps { decorator?: DraftJs.CompositeDecorator; autoFocus?: boolean; + + onSubmit?: () => void; } export interface IRichTextEditorState { @@ -155,6 +157,7 @@ export const RichTextEditor = React.forwardRef< value, placeholder, autoFocus, + onSubmit, decorator, onChange, @@ -545,6 +548,12 @@ export const RichTextEditor = React.forwardRef< } else if (matchKeyMap(e, KeyMap.richText.italics)) { command = 'italic'; handled = true; + } else if ( + matchKeyMap(e, KeyMap.overallUI.submitComment) && + onSubmit + ) { + onSubmit(); + handled = true; } }