Fix draft sent to show and rise errors (#28097)

* Fix draft sent to show and rise errors

* Remove event and set skipCommands

* Fix tests

* Improve error styles

* Fix lint

* Fix lint

* Fix tests

* Various fixes

* Fix lint

* fix test

* Extract onSubmit options type

* Address feedback

* Fix text
Этот коммит содержится в:
Daniel Espino García
2024-09-26 16:59:20 +02:00
коммит произвёл GitHub
родитель d58b048965
Коммит 0676d300de
43 изменённых файлов: 489 добавлений и 963 удалений

Просмотреть файл

@@ -75,7 +75,7 @@ export function sendDesktopNotification(post, msgProps) {
const teamId = msgProps.team_id; const teamId = msgProps.team_id;
let channel = makeGetChannel()(state, {id: post.channel_id}); let channel = makeGetChannel()(state, post.channel_id);
const user = getCurrentUser(state); const user = getCurrentUser(state);
const userStatus = getStatusForUserId(state, user.id); const userStatus = getStatusForUserId(state, user.id);
const member = getMyChannelMember(state, post.channel_id); const member = getMyChannelMember(state, post.channel_id);

Просмотреть файл

@@ -106,7 +106,12 @@ export function unflagPost(postId: string): ActionFuncAsync {
}; };
} }
export function createPost(post: Post, files: FileInfo[], afterSubmit?: (response: SubmitPostReturnType) => void): ActionFuncAsync<PostActions.CreatePostReturnType, GlobalState> { export function createPost(
post: Post,
files: FileInfo[],
afterSubmit?: (response: SubmitPostReturnType) => void,
afterOptimisticSubmit?: () => void,
): ActionFuncAsync<PostActions.CreatePostReturnType, GlobalState> {
return async (dispatch) => { return async (dispatch) => {
// parse message and emit emoji event // parse message and emit emoji event
const emojis = matchEmoticons(post.message); const emojis = matchEmoticons(post.message);
@@ -123,6 +128,7 @@ export function createPost(post: Post, files: FileInfo[], afterSubmit?: (respons
dispatch(storeDraft(post.channel_id, null)); dispatch(storeDraft(post.channel_id, null));
} }
afterOptimisticSubmit?.();
return result; return result;
}; };
} }

Просмотреть файл

@@ -13,12 +13,12 @@ import {setGlobalItem, actionOnGlobalItemsWithPrefix} from 'actions/storage';
import { import {
clearCommentDraftUploads, clearCommentDraftUploads,
updateCommentDraft, updateCommentDraft,
onSubmit,
submitPost, submitPost,
submitCommand, submitCommand,
makeOnSubmit,
makeOnEditLatestPost, makeOnEditLatestPost,
} from 'actions/views/create_comment'; } from 'actions/views/create_comment';
import {removeDraft, setGlobalDraftSource} from 'actions/views/drafts'; import {setGlobalDraftSource} from 'actions/views/drafts';
import mockStore from 'tests/test_store'; import mockStore from 'tests/test_store';
import {StoragePrefixes} from 'utils/constants'; import {StoragePrefixes} from 'utils/constants';
@@ -312,8 +312,7 @@ describe('rhs view actions', () => {
}); });
}); });
describe('makeOnSubmit', () => { describe('onSubmit', () => {
const onSubmit = makeOnSubmit(channelId, rootId, latestPostId);
const draft = { const draft = {
message: 'test', message: 'test',
fileInfos: [], fileInfos: [],
@@ -323,7 +322,7 @@ describe('rhs view actions', () => {
}; };
test('it adds message into history', () => { test('it adds message into history', () => {
store.dispatch(onSubmit(draft)); store.dispatch(onSubmit(draft, {}));
const testStore = mockStore(initialState); const testStore = mockStore(initialState);
testStore.dispatch(addMessageIntoHistory('test')); testStore.dispatch(addMessageIntoHistory('test'));
@@ -333,39 +332,12 @@ describe('rhs view actions', () => {
); );
}); });
test('it clears comment draft', () => {
store.dispatch(onSubmit(draft));
const testStore = mockStore(initialState);
const key = `${StoragePrefixes.COMMENT_DRAFT}${rootId}`;
testStore.dispatch(removeDraft(key, channelId, rootId));
expect(store.getActions()).toEqual(
expect.arrayContaining(testStore.getActions()),
);
});
test('it submits a reaction when message is +:smile:', () => {
store.dispatch(onSubmit({
message: '+:smile:',
fileInfos: [],
uploadsInProgress: [],
}));
const testStore = mockStore(initialState);
testStore.dispatch(PostActions.submitReaction(latestPostId, '+', 'smile'));
expect(store.getActions()).toEqual(
expect.arrayContaining(testStore.getActions()),
);
});
test('it submits a command when message is /away', () => { test('it submits a command when message is /away', () => {
store.dispatch(onSubmit({ store.dispatch(onSubmit({
message: '/away', message: '/away',
fileInfos: [], fileInfos: [],
uploadsInProgress: [], uploadsInProgress: [],
})); }, {}));
const testStore = mockStore(initialState); const testStore = mockStore(initialState);
testStore.dispatch(submitCommand(channelId, rootId, {message: '/away', fileInfos: [], uploadsInProgress: []})); testStore.dispatch(submitCommand(channelId, rootId, {message: '/away', fileInfos: [], uploadsInProgress: []}));
@@ -398,7 +370,7 @@ describe('rhs view actions', () => {
message: 'test msg', message: 'test msg',
fileInfos: [], fileInfos: [],
uploadsInProgress: [], uploadsInProgress: [],
})); }, {}));
const testStore = mockStore(initialState); const testStore = mockStore(initialState);
testStore.dispatch(submitPost(channelId, rootId, {message: 'test msg', fileInfos: [], uploadsInProgress: []})); testStore.dispatch(submitPost(channelId, rootId, {message: 'test msg', fileInfos: [], uploadsInProgress: []}));

Просмотреть файл

@@ -29,7 +29,7 @@ import {executeCommand} from 'actions/command';
import {runMessageWillBePostedHooks, runSlashCommandWillBePostedHooks} from 'actions/hooks'; import {runMessageWillBePostedHooks, runSlashCommandWillBePostedHooks} from 'actions/hooks';
import * as PostActions from 'actions/post_actions'; import * as PostActions from 'actions/post_actions';
import {actionOnGlobalItemsWithPrefix} from 'actions/storage'; import {actionOnGlobalItemsWithPrefix} from 'actions/storage';
import {updateDraft, removeDraft} from 'actions/views/drafts'; import {updateDraft} from 'actions/views/drafts';
import {Constants, StoragePrefixes} from 'utils/constants'; import {Constants, StoragePrefixes} from 'utils/constants';
import EmojiMap from 'utils/emoji_map'; import EmojiMap from 'utils/emoji_map';
@@ -56,7 +56,13 @@ export function updateCommentDraft(rootId: string, draft?: PostDraft, save = fal
return updateDraft(key, draft ?? null, rootId, save); return updateDraft(key, draft ?? null, rootId, save);
} }
export function submitPost(channelId: string, rootId: string, draft: PostDraft, afterSubmit?: (response: SubmitPostReturnType) => void): ActionFuncAsync<CreatePostReturnType, GlobalState> { export function submitPost(
channelId: string,
rootId: string,
draft: PostDraft,
afterSubmit?: (response: SubmitPostReturnType) => void,
afterOptimisticSubmit?: () => void,
): ActionFuncAsync<CreatePostReturnType, GlobalState> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
@@ -103,7 +109,7 @@ export function submitPost(channelId: string, rootId: string, draft: PostDraft,
post = hookResult.data; post = hookResult.data;
return dispatch(PostActions.createPost(post, draft.fileInfos, afterSubmit)); return dispatch(PostActions.createPost(post, draft.fileInfos, afterSubmit, afterOptimisticSubmit));
}; };
} }
@@ -147,39 +153,18 @@ export function submitCommand(channelId: string, rootId: string, draft: PostDraf
}; };
} }
export function makeOnSubmit(channelId: string, rootId: string, latestPostId: string): (draft: PostDraft, options?: {ignoreSlash?: boolean}) => ActionFuncAsync<boolean, GlobalState> {
return (draft, options = {}) => async (dispatch, getState) => {
const {message} = draft;
dispatch(addMessageIntoHistory(message));
const key = `${StoragePrefixes.COMMENT_DRAFT}${rootId}`;
dispatch(removeDraft(key, channelId, rootId));
const isReaction = Utils.REACTION_PATTERN.exec(message);
const emojis = getCustomEmojisByName(getState());
const emojiMap = new EmojiMap(emojis);
if (isReaction && emojiMap.has(isReaction[2])) {
dispatch(PostActions.submitReaction(latestPostId, isReaction[1], isReaction[2]));
} else if (message.indexOf('/') === 0 && !options.ignoreSlash) {
try {
await dispatch(submitCommand(channelId, rootId, draft));
} catch (err) {
dispatch(updateCommentDraft(rootId, draft, true));
throw err;
}
} else {
dispatch(submitPost(channelId, rootId, draft));
}
return {data: true};
};
}
export type SubmitPostReturnType = CreatePostReturnType & SubmitCommandRerturnType & SubmitReactionReturnType; export type SubmitPostReturnType = CreatePostReturnType & SubmitCommandRerturnType & SubmitReactionReturnType;
export function onSubmit(draft: PostDraft, options: {ignoreSlash?: boolean; afterSubmit?: (response: SubmitPostReturnType) => void}): ActionFuncAsync<SubmitPostReturnType, GlobalState> { export type OnSubmitOptions = {
ignoreSlash?: boolean;
afterSubmit?: (response: SubmitPostReturnType) => void;
afterOptimisticSubmit?: () => void;
}
export function onSubmit(
draft: PostDraft,
options: OnSubmitOptions,
): ActionFuncAsync<SubmitPostReturnType, GlobalState> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const {message, channelId, rootId} = draft; const {message, channelId, rootId} = draft;
const state = getState(); const state = getState();
@@ -191,19 +176,19 @@ export function onSubmit(draft: PostDraft, options: {ignoreSlash?: boolean; afte
const emojis = getCustomEmojisByName(state); const emojis = getCustomEmojisByName(state);
const emojiMap = new EmojiMap(emojis); const emojiMap = new EmojiMap(emojis);
if (isReaction && emojiMap.has(isReaction[2])) { if (isReaction && emojiMap.has(isReaction[2]) && !options.ignoreSlash) {
const latestPostId = getLatestInteractablePostId(state, channelId, rootId); const latestPostId = getLatestInteractablePostId(state, channelId, rootId);
if (latestPostId) { if (latestPostId) {
return dispatch(PostActions.submitReaction(latestPostId, isReaction[1], isReaction[2])); return dispatch(PostActions.submitReaction(latestPostId, isReaction[1], isReaction[2]));
} }
return {error: new Error('no post to react to')}; return {error: new Error('No post to react to')};
} }
if (message.indexOf('/') === 0 && !options.ignoreSlash) { if (message.indexOf('/') === 0 && !options.ignoreSlash) {
return dispatch(submitCommand(channelId, rootId, draft)); return dispatch(submitCommand(channelId, rootId, draft));
} }
return dispatch(submitPost(channelId, rootId, draft, options.afterSubmit)); return dispatch(submitPost(channelId, rootId, draft, options.afterSubmit, options.afterOptimisticSubmit));
}; };
} }

Просмотреть файл

@@ -539,8 +539,8 @@ export function selectPost(post: Post, previousRhsState?: RhsState) {
export function selectPostById(postId: string): ActionFuncAsync { export function selectPostById(postId: string): ActionFuncAsync {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
const post = getPost(state, postId) ?? (await dispatch(fetchPost(postId))).data; const post: Post | undefined = getPost(state, postId) ?? (await dispatch(fetchPost(postId))).data;
if (post) { if (post && post.state !== 'DELETED' && post.delete_at === 0) {
const channel = getChannelSelector(state, post.channel_id); const channel = getChannelSelector(state, post.channel_id);
if (!channel) { if (!channel) {
await dispatch(getChannel(post.channel_id)); await dispatch(getChannel(post.channel_id));

Просмотреть файл

@@ -108,7 +108,7 @@ const AdvancedTextEditor = ({
const isRHS = Boolean(postId && !isThreadView); const isRHS = Boolean(postId && !isThreadView);
const currentUserId = useSelector(getCurrentUserId); const currentUserId = useSelector(getCurrentUserId);
const channelDisplayName = useSelector((state: GlobalState) => getChannelSelector(state, {id: channelId})?.display_name || ''); const channelDisplayName = useSelector((state: GlobalState) => getChannelSelector(state, channelId)?.display_name || '');
const draftFromStore = useSelector((state: GlobalState) => getDraftSelector(state, channelId, postId)); const draftFromStore = useSelector((state: GlobalState) => getDraftSelector(state, channelId, postId));
const badConnection = useSelector((state: GlobalState) => connectionErrorCount(state) > 1); const badConnection = useSelector((state: GlobalState) => connectionErrorCount(state) > 1);
const maxPostSize = useSelector((state: GlobalState) => parseInt(getConfig(state).MaxPostSize || '', 10) || Constants.DEFAULT_CHARACTER_LIMIT); const maxPostSize = useSelector((state: GlobalState) => parseInt(getConfig(state).MaxPostSize || '', 10) || Constants.DEFAULT_CHARACTER_LIMIT);
@@ -253,7 +253,21 @@ const AdvancedTextEditor = ({
isValidPersistentNotifications, isValidPersistentNotifications,
onSubmitCheck: prioritySubmitCheck, onSubmitCheck: prioritySubmitCheck,
} = usePriority(draft, handleDraftChange, focusTextbox, showPreview); } = usePriority(draft, handleDraftChange, focusTextbox, showPreview);
const [handleSubmit, errorClass] = useSubmit(draft, postError, channelId, postId, serverError, lastBlurAt, focusTextbox, setServerError, setPostError, setShowPreview, handleDraftChange, prioritySubmitCheck, afterSubmit); const [handleSubmit, errorClass] = useSubmit(
draft,
postError,
channelId,
postId,
serverError,
lastBlurAt,
focusTextbox,
setServerError,
setShowPreview,
handleDraftChange,
prioritySubmitCheck,
undefined,
afterSubmit,
);
const [handleKeyDown, postMsgKeyPress] = useKeyHandler( const [handleKeyDown, postMsgKeyPress] = useKeyHandler(
draft, draft,
channelId, channelId,
@@ -272,6 +286,8 @@ const AdvancedTextEditor = ({
toggleEmojiPicker, toggleEmojiPicker,
); );
const noArgumentHandleSubmit = useCallback(() => handleSubmit(), [handleSubmit]);
const handlePostError = useCallback((err: React.ReactNode) => { const handlePostError = useCallback((err: React.ReactNode) => {
setPostError(err); setPostError(err);
}, []); }, []);
@@ -388,6 +404,7 @@ const AdvancedTextEditor = ({
// Remove show preview when we switch channels or posts // Remove show preview when we switch channels or posts
useEffect(() => { useEffect(() => {
setShowPreview(false); setShowPreview(false);
setServerError(null);
}, [channelId, postId]); }, [channelId, postId]);
// Remove uploads in progress on mount // Remove uploads in progress on mount
@@ -532,7 +549,7 @@ const AdvancedTextEditor = ({
id={postId ? undefined : 'create_post'} id={postId ? undefined : 'create_post'}
data-testid={postId ? undefined : 'create-post'} data-testid={postId ? undefined : 'create-post'}
className={(!postId && !fullWidthTextBox) ? 'center' : undefined} className={(!postId && !fullWidthTextBox) ? 'center' : undefined}
onSubmit={handleSubmit} onSubmit={noArgumentHandleSubmit}
> >
{canPost && (draft.fileInfos.length > 0 || draft.uploadsInProgress.length > 0) && ( {canPost && (draft.fileInfos.length > 0 || draft.uploadsInProgress.length > 0) && (
<FileLimitStickyBanner/> <FileLimitStickyBanner/>
@@ -657,7 +674,7 @@ const AdvancedTextEditor = ({
<MessageSubmitError <MessageSubmitError
error={serverError} error={serverError}
submittedMessage={serverError.submittedMessage} submittedMessage={serverError.submittedMessage}
handleSubmit={handleSubmit} handleSubmit={noArgumentHandleSubmit}
/> />
)} )}
<MsgTyping <MsgTyping

Просмотреть файл

@@ -1,14 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {FormEvent, memo} from 'react'; import React, {memo} from 'react';
import {useIntl} from 'react-intl'; import {useIntl} from 'react-intl';
import styled from 'styled-components'; import styled from 'styled-components';
import {SendIcon} from '@mattermost/compass-icons/components'; import {SendIcon} from '@mattermost/compass-icons/components';
type SendButtonProps = { type SendButtonProps = {
handleSubmit: (e: React.FormEvent) => void; handleSubmit: () => void;
disabled: boolean; disabled: boolean;
} }
@@ -46,7 +46,7 @@ const SendButton = ({disabled, handleSubmit}: SendButtonProps) => {
const sendMessage = (e: React.FormEvent) => { const sendMessage = (e: React.FormEvent) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
handleSubmit(e); handleSubmit();
}; };
return ( return (

Просмотреть файл

@@ -39,7 +39,7 @@ const useKeyHandler = (
focusTextbox: (forceFocus?: boolean) => void, focusTextbox: (forceFocus?: boolean) => void,
applyMarkdown: (params: ApplyMarkdownOptions) => void, applyMarkdown: (params: ApplyMarkdownOptions) => void,
handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void, handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void,
handleSubmit: (e: React.FormEvent, submittingDraft?: PostDraft) => void, handleSubmit: (submittingDraft?: PostDraft) => void,
emitTypingEvent: () => void, emitTypingEvent: () => void,
toggleShowPreview: () => void, toggleShowPreview: () => void,
toggleAdvanceTextEditor: () => void, toggleAdvanceTextEditor: () => void,
@@ -124,19 +124,9 @@ const useKeyHandler = (
} }
if (allowSending && isValidPersistentNotifications) { if (allowSending && isValidPersistentNotifications) {
e.persist?.(); e.preventDefault();
const updatedDraft = (withClosedCodeBlock && message) ? {...draft, message} : undefined;
// textboxRef.current?.blur(); handleSubmit(updatedDraft);
if (withClosedCodeBlock && message) {
handleSubmit(e, {...draft, message});
} else {
handleSubmit(e);
}
// setTimeout(() => {
// focusTextbox();
// });
} }
emitTypingEvent(); emitTypingEvent();

Просмотреть файл

@@ -16,7 +16,7 @@ import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'
import {getCurrentUserId, getStatusForUserId} from 'mattermost-redux/selectors/entities/users'; import {getCurrentUserId, getStatusForUserId} from 'mattermost-redux/selectors/entities/users';
import {scrollPostListToBottom} from 'actions/views/channel'; import {scrollPostListToBottom} from 'actions/views/channel';
import type {SubmitPostReturnType} from 'actions/views/create_comment'; import type {OnSubmitOptions, SubmitPostReturnType} from 'actions/views/create_comment';
import {onSubmit} from 'actions/views/create_comment'; import {onSubmit} from 'actions/views/create_comment';
import {openModal} from 'actions/views/modals'; import {openModal} from 'actions/views/modals';
@@ -57,13 +57,14 @@ const useSubmit = (
lastBlurAt: React.MutableRefObject<number>, lastBlurAt: React.MutableRefObject<number>,
focusTextbox: (forceFocust?: boolean) => void, focusTextbox: (forceFocust?: boolean) => void,
setServerError: (err: (ServerError & { submittedMessage?: string }) | null) => void, setServerError: (err: (ServerError & { submittedMessage?: string }) | null) => void,
setPostError: (err: React.ReactNode) => void,
setShowPreview: (showPreview: boolean) => void, setShowPreview: (showPreview: boolean) => void,
handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void, handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void,
prioritySubmitCheck: (onConfirm: () => void) => boolean, prioritySubmitCheck: (onConfirm: () => void) => boolean,
afterOptimisticSubmit?: () => void,
afterSubmit?: (response: SubmitPostReturnType) => void, afterSubmit?: (response: SubmitPostReturnType) => void,
skipCommands?: boolean,
): [ ): [
(e: React.FormEvent, submittingDraft?: PostDraft) => void, (submittingDraft?: PostDraft) => Promise<void>,
string | null, string | null,
] => { ] => {
const getGroupMentions = useGroups(channelId, draft.message); const getGroupMentions = useGroups(channelId, draft.message);
@@ -117,9 +118,7 @@ const useSubmit = (
})); }));
}, [dispatch]); }, [dispatch]);
const doSubmit = useCallback(async (e?: React.FormEvent, submittingDraft = draft) => { const doSubmit = useCallback(async (submittingDraft = draft) => {
e?.preventDefault();
if (submittingDraft.uploadsInProgress.length > 0) { if (submittingDraft.uploadsInProgress.length > 0) {
isDraftSubmitting.current = false; isDraftSubmitting.current = false;
return; return;
@@ -135,6 +134,7 @@ const useSubmit = (
} }
if (submittingDraft.message.trim().length === 0 && submittingDraft.fileInfos.length === 0) { if (submittingDraft.message.trim().length === 0 && submittingDraft.fileInfos.length === 0) {
isDraftSubmitting.current = false;
return; return;
} }
@@ -145,6 +145,7 @@ const useSubmit = (
} }
if (serverError && !isErrorInvalidSlashCommand(serverError)) { if (serverError && !isErrorInvalidSlashCommand(serverError)) {
isDraftSubmitting.current = false;
return; return;
} }
@@ -154,13 +155,15 @@ const useSubmit = (
setServerError(null); setServerError(null);
const ignoreSlash = isErrorInvalidSlashCommand(serverError) && serverError?.submittedMessage === submittingDraft.message; const ignoreSlash = skipCommands || (isErrorInvalidSlashCommand(serverError) && serverError?.submittedMessage === submittingDraft.message);
const options = {ignoreSlash, afterSubmit}; const options: OnSubmitOptions = {ignoreSlash, afterSubmit, afterOptimisticSubmit};
try { try {
await dispatch(onSubmit(submittingDraft, options)); const res = await dispatch(onSubmit(submittingDraft, options));
if (res.error) {
throw res.error;
}
setPostError(null);
setServerError(null); setServerError(null);
handleDraftChange({ handleDraftChange({
message: '', message: '',
@@ -180,6 +183,8 @@ const useSubmit = (
...err, ...err,
submittedMessage: submittingDraft.message, submittedMessage: submittingDraft.message,
}); });
} else {
setServerError(err as any);
} }
isDraftSubmitting.current = false; isDraftSubmitting.current = false;
return; return;
@@ -190,7 +195,22 @@ const useSubmit = (
} }
isDraftSubmitting.current = false; isDraftSubmitting.current = false;
}, [handleDraftChange, dispatch, draft, focusTextbox, isRootDeleted, postError, serverError, showPostDeletedModal, channelId, postId, lastBlurAt, setPostError, setServerError, afterSubmit]); }, [draft,
postError,
isRootDeleted,
serverError,
lastBlurAt,
focusTextbox,
setServerError,
skipCommands,
afterSubmit,
afterOptimisticSubmit,
postId,
showPostDeletedModal,
dispatch,
handleDraftChange,
channelId,
]);
const showNotifyAllModal = useCallback((mentions: string[], channelTimezoneCount: number, memberNotifyCount: number) => { const showNotifyAllModal = useCallback((mentions: string[], channelTimezoneCount: number, memberNotifyCount: number) => {
dispatch(openModal({ dispatch(openModal({
@@ -205,11 +225,15 @@ const useSubmit = (
})); }));
}, [doSubmit, dispatch]); }, [doSubmit, dispatch]);
const handleSubmit = useCallback(async (e: React.FormEvent, submittingDraft = draft) => { const handleSubmit = useCallback(async (submittingDraft = draft) => {
if (!channel) { if (!channel) {
return; return;
} }
e.preventDefault();
if (isDraftSubmitting.current) {
return;
}
setShowPreview(false); setShowPreview(false);
isDraftSubmitting.current = true; isDraftSubmitting.current = true;
@@ -249,59 +273,61 @@ const useSubmit = (
return; return;
} }
const status = getStatusFromSlashCommand(submittingDraft.message); if (!skipCommands) {
if (userIsOutOfOffice && status) { const status = getStatusFromSlashCommand(submittingDraft.message);
const resetStatusModalData = { if (userIsOutOfOffice && status) {
modalId: ModalIdentifiers.RESET_STATUS, const resetStatusModalData = {
dialogType: ResetStatusModal, modalId: ModalIdentifiers.RESET_STATUS,
dialogProps: {newStatus: status}, dialogType: ResetStatusModal,
}; dialogProps: {newStatus: status},
};
dispatch(openModal(resetStatusModalData)); dispatch(openModal(resetStatusModalData));
handleDraftChange({ handleDraftChange({
...submittingDraft, ...submittingDraft,
message: '', message: '',
}); });
isDraftSubmitting.current = false; isDraftSubmitting.current = false;
return; return;
}
if (submittingDraft.message.trimEnd() === '/header') {
const editChannelHeaderModalData = {
modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER,
dialogType: EditChannelHeaderModal,
dialogProps: {channel},
};
dispatch(openModal(editChannelHeaderModalData));
handleDraftChange({
...submittingDraft,
message: '',
});
isDraftSubmitting.current = false;
return;
}
if (!isDirectOrGroup && submittingDraft.message.trimEnd() === '/purpose') {
const editChannelPurposeModalData = {
modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE,
dialogType: EditChannelPurposeModal,
dialogProps: {channel},
};
dispatch(openModal(editChannelPurposeModalData));
handleDraftChange({
...submittingDraft,
message: '',
});
isDraftSubmitting.current = false;
return;
}
} }
if (submittingDraft.message.trimEnd() === '/header') { await doSubmit(submittingDraft);
const editChannelHeaderModalData = {
modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER,
dialogType: EditChannelHeaderModal,
dialogProps: {channel},
};
dispatch(openModal(editChannelHeaderModalData));
handleDraftChange({
...submittingDraft,
message: '',
});
isDraftSubmitting.current = false;
return;
}
if (!isDirectOrGroup && submittingDraft.message.trimEnd() === '/purpose') {
const editChannelPurposeModalData = {
modalId: ModalIdentifiers.EDIT_CHANNEL_PURPOSE,
dialogType: EditChannelPurposeModal,
dialogProps: {channel},
};
dispatch(openModal(editChannelPurposeModalData));
handleDraftChange({
...submittingDraft,
message: '',
});
isDraftSubmitting.current = false;
return;
}
await doSubmit(e, submittingDraft);
}, [ }, [
doSubmit, doSubmit,
draft, draft,
@@ -311,6 +337,7 @@ const useSubmit = (
channelMembersCount, channelMembersCount,
dispatch, dispatch,
enableConfirmNotificationsToChannel, enableConfirmNotificationsToChannel,
skipCommands,
handleDraftChange, handleDraftChange,
showNotifyAllModal, showNotifyAllModal,
useChannelMentions, useChannelMentions,

Просмотреть файл

@@ -1,101 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/drafts/drafts_row should match snapshot for channel draft 1`] = `
<ContextProvider
value={
Object {
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"subscription": Subscription {
"handleChangeWrapper": [Function],
"listeners": Object {
"notify": [Function],
},
"onStateChange": [Function],
"parentSub": undefined,
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"unsubscribe": null,
},
}
}
>
<Memo(ChannelDraft)
channel={
Object {
"id": "",
}
}
channelUrl=""
displayName=""
draftId=""
id={Object {}}
isRemote={false}
postPriorityEnabled={false}
status={Object {}}
type="channel"
user={Object {}}
value={Object {}}
/>
</ContextProvider>
`;
exports[`components/drafts/drafts_row should match snapshot for undefined channel 1`] = `
<ContextProvider
value={
Object {
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"subscription": Subscription {
"handleChangeWrapper": [Function],
"listeners": Object {
"notify": [Function],
},
"onStateChange": [Function],
"parentSub": undefined,
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"unsubscribe": null,
},
}
}
>
<Memo(ChannelDraft)
channel={null}
channelUrl=""
displayName=""
draftId=""
id={Object {}}
isRemote={false}
postPriorityEnabled={false}
status={Object {}}
type="channel"
user={Object {}}
value={Object {}}
/>
</ContextProvider>
`;

Просмотреть файл

@@ -1,64 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import {Provider} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import type {UserProfile, UserStatus} from '@mattermost/types/users';
import mockStore from 'tests/test_store';
import type {PostDraft} from 'types/store/draft';
import ChannelDraft from './channel_draft';
describe('components/drafts/drafts_row', () => {
const baseProps = {
channel: {
id: '',
} as Channel,
channelUrl: '',
displayName: '',
draftId: '',
id: {} as Channel['id'],
status: {} as UserStatus['status'],
type: 'channel' as 'channel' | 'thread',
user: {} as UserProfile,
value: {} as PostDraft,
postPriorityEnabled: false,
isRemote: false,
};
it('should match snapshot for channel draft', () => {
const store = mockStore();
const wrapper = shallow(
<Provider store={store}>
<ChannelDraft
{...baseProps}
/>
</Provider>,
);
expect(wrapper).toMatchSnapshot();
});
it('should match snapshot for undefined channel', () => {
const store = mockStore();
const props = {
...baseProps,
channel: null as unknown as Channel,
};
const wrapper = shallow(
<Provider store={store}>
<ChannelDraft
{...props}
/>
</Provider>,
);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@@ -1,159 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback} from 'react';
import {useDispatch} from 'react-redux';
import {useHistory} from 'react-router-dom';
import type {Channel} from '@mattermost/types/channels';
import type {Post, PostMetadata} from '@mattermost/types/posts';
import type {UserProfile, UserStatus} from '@mattermost/types/users';
import {createPost} from 'actions/post_actions';
import {removeDraft} from 'actions/views/drafts';
import {openModal} from 'actions/views/modals';
import PersistNotificationConfirmModal from 'components/persist_notification_confirm_modal';
import {ModalIdentifiers} from 'utils/constants';
import {hasRequestedPersistentNotifications, specialMentionsInText} from 'utils/post_utils';
import type {PostDraft} from 'types/store/draft';
import DraftActions from '../draft_actions';
import DraftTitle from '../draft_title';
import Panel from '../panel/panel';
import PanelBody from '../panel/panel_body';
import Header from '../panel/panel_header';
type Props = {
channel?: Channel;
channelUrl: string;
displayName: string;
draftId: string;
id: Channel['id'];
postPriorityEnabled: boolean;
status: UserStatus['status'];
type: 'channel' | 'thread';
user: UserProfile;
value: PostDraft;
isRemote?: boolean;
}
function ChannelDraft({
channel,
channelUrl,
displayName,
draftId,
postPriorityEnabled,
status,
type,
user,
value,
isRemote,
id: channelId,
}: Props) {
const dispatch = useDispatch();
const history = useHistory();
const handleOnEdit = useCallback(() => {
history.push(channelUrl);
}, [history, channelUrl]);
const handleOnDelete = useCallback((id: string) => {
dispatch(removeDraft(id, channelId));
}, [dispatch, channelId]);
const doSubmit = useCallback((id: string, post: Post) => {
dispatch(createPost(post, value.fileInfos));
dispatch(removeDraft(id, channelId));
history.push(channelUrl);
}, [dispatch, history, value.fileInfos, channelId, channelUrl]);
const showPersistNotificationModal = useCallback((id: string, post: Post) => {
if (!channel) {
return;
}
dispatch(openModal({
modalId: ModalIdentifiers.PERSIST_NOTIFICATION_CONFIRM_MODAL,
dialogType: PersistNotificationConfirmModal,
dialogProps: {
message: post.message,
channelType: channel.type,
specialMentions: specialMentionsInText(post.message),
onConfirm: () => doSubmit(id, post),
},
}));
}, [channel, dispatch, doSubmit]);
const handleOnSend = useCallback(async (id: string) => {
const post = {} as Post;
post.file_ids = [];
post.message = value.message;
post.props = value.props || {};
post.user_id = user.id;
post.channel_id = value.channelId;
post.metadata = (value.metadata || {}) as PostMetadata;
if (post.message.trim().length === 0 && value.fileInfos.length === 0) {
return;
}
if (postPriorityEnabled && hasRequestedPersistentNotifications(value?.metadata?.priority)) {
showPersistNotificationModal(id, post);
return;
}
doSubmit(id, post);
}, [doSubmit, postPriorityEnabled, value, user.id, showPersistNotificationModal]);
if (!channel) {
return null;
}
return (
<Panel onClick={handleOnEdit}>
{({hover}) => (
<>
<Header
hover={hover}
actions={(
<DraftActions
channelDisplayName={channel.display_name}
channelType={channel.type}
channelName={channel.name}
userId={user.id}
draftId={draftId}
onDelete={handleOnDelete}
onEdit={handleOnEdit}
onSend={handleOnSend}
/>
)}
title={(
<DraftTitle
channel={channel}
type={type}
userId={user.id}
/>
)}
timestamp={value.updateAt}
remote={isRemote || false}
/>
<PanelBody
channelId={channelId}
displayName={displayName}
fileInfos={value.fileInfos}
message={value.message}
status={status}
priority={value.metadata?.priority}
uploadsInProgress={value.uploadsInProgress}
userId={user.id}
username={user.username}
/>
</>
)}
</Panel>
);
}
export default memo(ChannelDraft);

Просмотреть файл

@@ -1,36 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {isPostPriorityEnabled} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getChannelURL} from 'selectors/urls';
import type {GlobalState} from 'types/store';
import ChannelDraft from './channel_draft';
type OwnProps = {
id: string;
}
function makeMapStateToProps() {
const getChannel = makeGetChannel();
return (state: GlobalState, ownProps: OwnProps) => {
const channel = getChannel(state, ownProps);
const teamId = getCurrentTeamId(state);
const channelUrl = channel ? getChannelURL(state, channel, teamId) : '';
return {
channel,
channelUrl,
postPriorityEnabled: isPostPriorityEnabled(state),
};
};
}
export default connect(makeMapStateToProps)(ChannelDraft);

Просмотреть файл

@@ -11,11 +11,11 @@ exports[`components/drafts/draft_actions/action should match snapshot 1`] = `
> >
<button <button
className="DraftAction__button" className="DraftAction__button"
id="draft_{icon}_" id="draft__"
onClick={[MockFunction]} onClick={[MockFunction]}
> >
<i <i
className="icon " className="icon"
/> />
</button> </button>
</WithTooltip> </WithTooltip>

Просмотреть файл

@@ -33,6 +33,8 @@ exports[`components/drafts/draft_actions should match snapshot 1`] = `
} }
> >
<Memo(DraftActions) <Memo(DraftActions)
canEdit={true}
canSend={true}
displayName="" displayName=""
draftId="" draftId=""
onDelete={[MockFunction]} onDelete={[MockFunction]}

Просмотреть файл

@@ -4,9 +4,10 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React from 'react';
import './action.scss';
import WithTooltip from 'components/with_tooltip'; import WithTooltip from 'components/with_tooltip';
import './action.scss';
type Props = { type Props = {
icon: string; icon: string;
id: string; id: string;
@@ -15,7 +16,13 @@ type Props = {
tooltipText: React.ReactElement | string; tooltipText: React.ReactElement | string;
}; };
function Action({name, icon, onClick, id, tooltipText}: Props) { function Action({
name,
icon,
onClick,
id,
tooltipText,
}: Props) {
return ( return (
<div className='DraftAction'> <div className='DraftAction'>
<WithTooltip <WithTooltip
@@ -28,10 +35,15 @@ function Action({name, icon, onClick, id, tooltipText}: Props) {
'DraftAction__button', 'DraftAction__button',
{'DraftAction__button--delete': name === 'delete'}, {'DraftAction__button--delete': name === 'delete'},
)} )}
id={`draft_{icon}_${id}`} id={`draft_${icon}_${id}`}
onClick={onClick} onClick={onClick}
> >
<i className={`icon ${icon}`}/> <i
className={classNames(
'icon',
icon,
)}
/>
</button> </button>
</WithTooltip> </WithTooltip>
</div> </div>

Просмотреть файл

@@ -16,6 +16,8 @@ describe('components/drafts/draft_actions', () => {
onDelete: jest.fn(), onDelete: jest.fn(),
onEdit: jest.fn(), onEdit: jest.fn(),
onSend: jest.fn(), onSend: jest.fn(),
canSend: true,
canEdit: true,
}; };
it('should match snapshot', () => { it('should match snapshot', () => {

Просмотреть файл

@@ -15,18 +15,20 @@ import SendDraftModal from './send_draft_modal';
type Props = { type Props = {
displayName: string; displayName: string;
draftId: string; onDelete: () => void;
onDelete: (draftId: string) => void;
onEdit: () => void; onEdit: () => void;
onSend: (draftId: string) => void; onSend: () => void;
canEdit: boolean;
canSend: boolean;
} }
function DraftActions({ function DraftActions({
displayName, displayName,
draftId,
onDelete, onDelete,
onEdit, onEdit,
onSend, onSend,
canEdit,
canSend,
}: Props) { }: Props) {
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -36,10 +38,10 @@ function DraftActions({
dialogType: DeleteDraftModal, dialogType: DeleteDraftModal,
dialogProps: { dialogProps: {
displayName, displayName,
onConfirm: () => onDelete(draftId), onConfirm: onDelete,
}, },
})); }));
}, [displayName]); }, [dispatch, displayName, onDelete]);
const handleSend = useCallback(() => { const handleSend = useCallback(() => {
dispatch(openModal({ dispatch(openModal({
@@ -47,10 +49,10 @@ function DraftActions({
dialogType: SendDraftModal, dialogType: SendDraftModal,
dialogProps: { dialogProps: {
displayName, displayName,
onConfirm: () => onSend(draftId), onConfirm: onSend,
}, },
})); }));
}, [displayName]); }, [dispatch, displayName, onSend]);
return ( return (
<> <>
@@ -66,30 +68,34 @@ function DraftActions({
)} )}
onClick={handleDelete} onClick={handleDelete}
/> />
<Action {canEdit && (
icon='icon-pencil-outline' <Action
id='edit' icon='icon-pencil-outline'
name='edit' id='edit'
tooltipText={( name='edit'
<FormattedMessage tooltipText={(
id='drafts.actions.edit' <FormattedMessage
defaultMessage='Edit draft' id='drafts.actions.edit'
/> defaultMessage='Edit draft'
)} />
onClick={onEdit} )}
/> onClick={onEdit}
<Action />
icon='icon-send-outline' )}
id='send' {canSend && (
name='send' <Action
tooltipText={( icon='icon-send-outline'
<FormattedMessage id='send'
id='drafts.actions.send' name='send'
defaultMessage='Send draft' tooltipText={(
/> <FormattedMessage
)} id='drafts.actions.send'
onClick={handleSend} defaultMessage='Send draft'
/> />
)}
onClick={handleSend}
/>
)}
</> </>
); );
} }

Просмотреть файл

@@ -1,14 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {memo} from 'react'; import noop from 'lodash/noop';
import React, {memo, useCallback, useMemo, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {useHistory} from 'react-router-dom';
import type {ServerError} from '@mattermost/types/errors';
import type {UserProfile, UserStatus} from '@mattermost/types/users'; import type {UserProfile, UserStatus} from '@mattermost/types/users';
import type {Draft} from 'selectors/drafts'; import {getPost as getPostAction} from 'mattermost-redux/actions/posts';
import {Permissions} from 'mattermost-redux/constants';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {makeGetThreadOrSynthetic} from 'mattermost-redux/selectors/entities/threads';
import ChannelDraft from './channel_draft'; import {removeDraft} from 'actions/views/drafts';
import ThreadDraft from './thread_draft'; import {selectPostById} from 'actions/views/rhs';
import type {Draft} from 'selectors/drafts';
import {getChannelURL} from 'selectors/urls';
import usePriority from 'components/advanced_text_editor/use_priority';
import useSubmit from 'components/advanced_text_editor/use_submit';
import Constants, {StoragePrefixes} from 'utils/constants';
import type {GlobalState} from 'types/store';
import DraftActions from './draft_actions';
import DraftTitle from './draft_title';
import Panel from './panel/panel';
import PanelBody from './panel/panel_body';
import Header from './panel/panel_header';
type Props = { type Props = {
user: UserProfile; user: UserProfile;
@@ -18,34 +45,174 @@ type Props = {
isRemote?: boolean; isRemote?: boolean;
} }
function DraftRow({draft, user, status, displayName, isRemote}: Props) { const mockLastBlurAt = {current: 0};
switch (draft.type) {
case 'channel': function DraftRow({
return ( draft,
<ChannelDraft user,
{...draft} status,
draftId={String(draft.key)} displayName,
user={user} isRemote,
status={status} }: Props) {
displayName={displayName} const intl = useIntl();
isRemote={isRemote}
/> const rootId = draft.value.rootId;
); const channelId = draft.value.channelId;
case 'thread':
return ( const [serverError, setServerError] = useState<(ServerError & { submittedMessage?: string }) | null>(null);
<ThreadDraft
{...draft} const history = useHistory();
rootId={draft.id} const dispatch = useDispatch();
draftId={String(draft.key)}
user={user} const getChannel = useMemo(() => makeGetChannel(), []);
status={status} const getThreadOrSynthetic = useMemo(() => makeGetThreadOrSynthetic(), []);
displayName={displayName}
isRemote={isRemote} const rootPostDeleted = useSelector((state: GlobalState) => {
/> if (!rootId) {
); return false;
default: }
const rootPost = getPost(state, rootId);
return !rootPost || rootPost.delete_at > 0 || rootPost.state === 'DELETED';
});
const tooLong = useSelector((state: GlobalState) => {
const maxPostSize = parseInt(getConfig(state).MaxPostSize || '', 10) || Constants.DEFAULT_CHARACTER_LIMIT;
return draft.value.message.length > maxPostSize;
});
const readOnly = !useSelector((state: GlobalState) => {
const channel = getChannel(state, channelId);
return channel ? haveIChannelPermission(state, channel.team_id, channel.id, Permissions.CREATE_POST) : false;
});
let postError = '';
if (rootPostDeleted) {
postError = intl.formatMessage({id: 'drafts.error.post_not_found', defaultMessage: 'Thread not found'});
} else if (tooLong) {
postError = intl.formatMessage({id: 'drafts.error.too_long', defaultMessage: 'Message too long'});
} else if (readOnly) {
postError = intl.formatMessage({id: 'drafts.error.read_only', defaultMessage: 'Channel is read only'});
}
const canSend = !postError;
const canEdit = !(rootPostDeleted || readOnly);
const channel = useSelector((state: GlobalState) => getChannel(state, channelId));
const channelUrl = useSelector((state: GlobalState) => {
if (!channel) {
return '';
}
const teamId = getCurrentTeamId(state);
return getChannelURL(state, channel, teamId);
});
const goToMessage = useCallback(async () => {
if (rootId) {
if (rootPostDeleted) {
return;
}
await dispatch(selectPostById(rootId));
return;
}
history.push(channelUrl);
}, [channelUrl, dispatch, history, rootId, rootPostDeleted]);
const {onSubmitCheck: prioritySubmitCheck} = usePriority(draft.value, noop, noop, false);
const [handleOnSend] = useSubmit(
draft.value,
postError,
channelId,
rootId,
serverError,
mockLastBlurAt,
noop,
setServerError,
noop,
noop,
prioritySubmitCheck,
goToMessage,
undefined,
true,
);
const thread = useSelector((state: GlobalState) => {
if (!rootId) {
return undefined;
}
const post = getPost(state, rootId);
if (!post) {
return undefined;
}
return getThreadOrSynthetic(state, post);
});
const handleOnDelete = useCallback(() => {
let key = `${StoragePrefixes.DRAFT}${channelId}`;
if (rootId) {
key = `${StoragePrefixes.COMMENT_DRAFT}${rootId}`;
}
dispatch(removeDraft(key, channelId, rootId));
}, [dispatch, channelId, rootId]);
useEffect(() => {
if (rootId && !thread?.id) {
dispatch(getPostAction(rootId));
}
}, [thread?.id]);
if (!channel) {
return null; return null;
} }
return (
<Panel
onClick={goToMessage}
hasError={Boolean(postError)}
>
{({hover}) => (
<>
<Header
hover={hover}
actions={(
<DraftActions
channelDisplayName={channel.display_name}
channelName={channel.name}
channelType={channel.type}
userId={user.id}
onDelete={handleOnDelete}
onEdit={goToMessage}
onSend={handleOnSend}
canEdit={canEdit}
canSend={canSend}
/>
)}
title={(
<DraftTitle
type={draft.type}
channel={channel}
userId={user.id}
/>
)}
timestamp={draft.value.updateAt}
remote={isRemote || false}
error={postError || serverError?.message}
/>
<PanelBody
channelId={channel.id}
displayName={displayName}
fileInfos={draft.value.fileInfos}
message={draft.value.message}
status={status}
uploadsInProgress={draft.value.uploadsInProgress}
userId={user.id}
username={user.username}
/>
</>
)}
</Panel>
);
} }
export default memo(DraftRow); export default memo(DraftRow);

Просмотреть файл

@@ -18,4 +18,8 @@
box-shadow: var(--elevation-2); box-shadow: var(--elevation-2);
transition-duration: 0.15s; transition-duration: 0.15s;
} }
&.draftError {
border-color: var(--error-text);
}
} }

Просмотреть файл

@@ -10,6 +10,7 @@ describe('components/drafts/panel/', () => {
const baseProps = { const baseProps = {
children: jest.fn(), children: jest.fn(),
onClick: jest.fn(), onClick: jest.fn(),
hasError: false,
}; };
it('should match snapshot', () => { it('should match snapshot', () => {

Просмотреть файл

@@ -1,19 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {memo, useState} from 'react'; import React, {memo, useState} from 'react';
import {makeIsEligibleForClick} from 'utils/utils'; import {makeIsEligibleForClick} from 'utils/utils';
import './panel.scss'; import './panel.scss';
type Props = { type Props = {
children: ({hover}: {hover: boolean}) => React.ReactNode; children: ({hover}: {hover: boolean}) => React.ReactNode;
onClick: () => void; onClick: () => void;
hasError: boolean;
}; };
const isEligibleForClick = makeIsEligibleForClick('.hljs, code'); const isEligibleForClick = makeIsEligibleForClick('.hljs, code');
function Panel({children, onClick}: Props) { function Panel({
children,
onClick,
hasError,
}: Props) {
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
const handleMouseOver = () => { const handleMouseOver = () => {
@@ -32,7 +39,12 @@ function Panel({children, onClick}: Props) {
return ( return (
<article <article
className='Panel' className={classNames(
'Panel',
{
draftError: hasError,
},
)}
onMouseOver={handleMouseOver} onMouseOver={handleMouseOver}
onClick={handleOnClick} onClick={handleOnClick}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}

Просмотреть файл

@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import type {ComponentProps} from 'react';
import React from 'react'; import React from 'react';
import {Provider} from 'react-redux'; import {Provider} from 'react-redux';
@@ -16,7 +17,7 @@ import type {PostDraft} from 'types/store/draft';
import PanelBody from './panel_body'; import PanelBody from './panel_body';
describe('components/drafts/panel/panel_body', () => { describe('components/drafts/panel/panel_body', () => {
const baseProps = { const baseProps: ComponentProps<typeof PanelBody> = {
channelId: 'channel_id', channelId: 'channel_id',
displayName: 'display_name', displayName: 'display_name',
fileInfos: [] as PostDraft['fileInfos'], fileInfos: [] as PostDraft['fileInfos'],

Просмотреть файл

@@ -55,7 +55,6 @@ function PanelBody({
}, [currentRelativeTeamUrl]); }, [currentRelativeTeamUrl]);
return ( return (
<div className='DraftPanelBody post'> <div className='DraftPanelBody post'>
<div className='DraftPanelBody__left post__img'> <div className='DraftPanelBody__left post__img'>
<ProfilePicture <ProfilePicture

Просмотреть файл

@@ -27,9 +27,17 @@ type Props = {
timestamp: number; timestamp: number;
remote: boolean; remote: boolean;
title: React.ReactNode; title: React.ReactNode;
error?: string;
}; };
function PanelHeader({actions, hover, timestamp, remote, title}: Props) { function PanelHeader({
actions,
hover,
timestamp,
remote,
title,
error,
}: Props) {
return ( return (
<header className='PanelHeader'> <header className='PanelHeader'>
<div className='PanelHeader__left'>{title}</div> <div className='PanelHeader__left'>{title}</div>
@@ -62,11 +70,21 @@ function PanelHeader({actions, hover, timestamp, remote, title}: Props) {
/> />
)} )}
</div> </div>
<Tag {!error && (
variant={'danger'} <Tag
uppercase={true} variant={'danger'}
text={'draft'} uppercase={true}
/> text={'draft'}
/>
)}
{error && (
<Tag
text={error}
variant={'danger'}
uppercase={true}
icon={'alert-outline'}
/>
)}
</div> </div>
</div> </div>
</header> </header>

Просмотреть файл

@@ -1,111 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/drafts/drafts_row should match snapshot for channel draft 1`] = `
<ContextProvider
value={
Object {
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"subscription": Subscription {
"handleChangeWrapper": [Function],
"listeners": Object {
"notify": [Function],
},
"onStateChange": [Function],
"parentSub": undefined,
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"unsubscribe": null,
},
}
}
>
<Memo(ThreadDraft)
channel={
Object {
"id": "",
}
}
channelUrl=""
displayName=""
draftId=""
id={Object {}}
isRemote={false}
rootId=""
status={Object {}}
thread={
Object {
"id": "",
}
}
type="thread"
user={Object {}}
value={Object {}}
/>
</ContextProvider>
`;
exports[`components/drafts/drafts_row should match snapshot for undefined thread 1`] = `
<ContextProvider
value={
Object {
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"subscription": Subscription {
"handleChangeWrapper": [Function],
"listeners": Object {
"notify": [Function],
},
"onStateChange": [Function],
"parentSub": undefined,
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"unsubscribe": null,
},
}
}
>
<Memo(ThreadDraft)
channel={
Object {
"id": "",
}
}
channelUrl=""
displayName=""
draftId=""
id={Object {}}
isRemote={false}
rootId=""
status={Object {}}
thread={null}
type="thread"
user={Object {}}
value={Object {}}
/>
</ContextProvider>
`;

Просмотреть файл

@@ -1,39 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {makeGetThreadOrSynthetic} from 'mattermost-redux/selectors/entities/threads';
import type {GlobalState} from 'types/store';
import type {PostDraft} from 'types/store/draft';
import ThreadDraft from './thread_draft';
type OwnProps = {
id: string;
value: PostDraft;
}
function makeMapStatetoProps() {
const getThreadOrSynthetic = makeGetThreadOrSynthetic();
const getChannel = makeGetChannel();
return (state: GlobalState, ownProps: OwnProps) => {
const channel = getChannel(state, {id: ownProps.value.channelId});
const post = getPost(state, ownProps.id);
let thread;
if (post) {
thread = getThreadOrSynthetic(state, post);
}
return {
channel,
thread,
};
};
}
export default connect(makeMapStatetoProps)(ThreadDraft);

Просмотреть файл

@@ -1,68 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import {Provider} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import type {UserThread, UserThreadSynthetic} from '@mattermost/types/threads';
import type {UserProfile, UserStatus} from '@mattermost/types/users';
import mockStore from 'tests/test_store';
import type {PostDraft} from 'types/store/draft';
import ThreadDraft from './thread_draft';
describe('components/drafts/drafts_row', () => {
const baseProps = {
channel: {
id: '',
} as Channel,
channelUrl: '',
displayName: '',
draftId: '',
rootId: '' as UserThread['id'] | UserThreadSynthetic['id'],
id: {} as Channel['id'],
status: {} as UserStatus['status'],
thread: {
id: '',
} as UserThread | UserThreadSynthetic,
type: 'thread' as 'channel' | 'thread',
user: {} as UserProfile,
value: {} as PostDraft,
isRemote: false,
};
it('should match snapshot for channel draft', () => {
const store = mockStore();
const wrapper = shallow(
<Provider store={store}>
<ThreadDraft
{...baseProps}
/>
</Provider>,
);
expect(wrapper).toMatchSnapshot();
});
it('should match snapshot for undefined thread', () => {
const store = mockStore();
const props = {
...baseProps,
thread: null as unknown as UserThread | UserThreadSynthetic,
};
const wrapper = shallow(
<Provider store={store}>
<ThreadDraft
{...props}
/>
</Provider>,
);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@@ -1,130 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useCallback, useMemo, useEffect} from 'react';
import {useDispatch} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import type {Post} from '@mattermost/types/posts';
import type {UserThread, UserThreadSynthetic} from '@mattermost/types/threads';
import type {UserProfile, UserStatus} from '@mattermost/types/users';
import {getPost} from 'mattermost-redux/actions/posts';
import {makeOnSubmit} from 'actions/views/create_comment';
import {removeDraft} from 'actions/views/drafts';
import {selectPost} from 'actions/views/rhs';
import type {PostDraft} from 'types/store/draft';
import DraftActions from '../draft_actions';
import DraftTitle from '../draft_title';
import Panel from '../panel/panel';
import PanelBody from '../panel/panel_body';
import Header from '../panel/panel_header';
type Props = {
channel?: Channel;
displayName: string;
draftId: string;
rootId: UserThread['id'] | UserThreadSynthetic['id'];
status: UserStatus['status'];
thread?: UserThread | UserThreadSynthetic;
type: 'channel' | 'thread';
user: UserProfile;
value: PostDraft;
isRemote?: boolean;
}
function ThreadDraft({
channel,
displayName,
draftId,
rootId,
status,
thread,
type,
user,
value,
isRemote,
}: Props) {
const dispatch = useDispatch();
useEffect(() => {
if (!thread?.id) {
dispatch(getPost(rootId));
}
}, [thread?.id]);
const onSubmit = useMemo(() => {
if (thread?.id) {
return makeOnSubmit(value.channelId, thread.id, '');
}
return () => Promise.resolve({data: true});
}, [value.channelId, thread?.id]);
const handleOnDelete = useCallback((id: string) => {
dispatch(removeDraft(id, value.channelId, rootId));
}, [value.channelId, rootId, dispatch]);
const handleOnEdit = useCallback(() => {
dispatch(selectPost({id: rootId, channel_id: value.channelId} as Post));
}, [value.channelId, dispatch, rootId]);
const handleOnSend = useCallback(async (id: string) => {
await dispatch(onSubmit(value));
handleOnDelete(id);
handleOnEdit();
}, [value, onSubmit, dispatch, handleOnDelete, handleOnEdit]);
if (!thread || !channel) {
return null;
}
return (
<Panel onClick={handleOnEdit}>
{({hover}) => (
<>
<Header
hover={hover}
actions={(
<DraftActions
channelDisplayName={channel.display_name}
channelName={channel.name}
channelType={channel.type}
userId={user.id}
draftId={draftId}
onDelete={handleOnDelete}
onEdit={handleOnEdit}
onSend={handleOnSend}
/>
)}
title={(
<DraftTitle
type={type}
channel={channel}
userId={user.id}
/>
)}
timestamp={value.updateAt}
remote={isRemote || false}
/>
<PanelBody
channelId={channel.id}
displayName={displayName}
fileInfos={value.fileInfos}
message={value.message}
status={status}
uploadsInProgress={value.uploadsInProgress}
userId={user.id}
username={user.username}
/>
</>
)}
</Panel>
);
}
export default memo(ThreadDraft);

Просмотреть файл

@@ -34,7 +34,7 @@ const FilePreviewModalInfo: React.FC<Props> = (props: Props) => {
const user = useSelector((state: GlobalState) => selectUser(state, props.post?.user_id ?? '')) as UserProfile | undefined; const user = useSelector((state: GlobalState) => selectUser(state, props.post?.user_id ?? '')) as UserProfile | undefined;
const channel = useSelector((state: GlobalState) => { const channel = useSelector((state: GlobalState) => {
const getChannel = makeGetChannel(); const getChannel = makeGetChannel();
return getChannel(state, {id: props.post?.channel_id ?? ''}); return getChannel(state, props.post?.channel_id ?? '');
}); });
const name = useSelector((state: GlobalState) => displayNameGetter(state, props.post?.user_id ?? '', true)); const name = useSelector((state: GlobalState) => displayNameGetter(state, props.post?.user_id ?? '', true));

Просмотреть файл

@@ -52,7 +52,7 @@ const ForwardPostModal = ({onExited, post}: Props) => {
const getChannel = useMemo(makeGetChannel, []); const getChannel = useMemo(makeGetChannel, []);
const channel = useSelector((state: GlobalState) => getChannel(state, {id: post.channel_id})); const channel = useSelector((state: GlobalState) => getChannel(state, post.channel_id));
const currentTeam = useSelector(getCurrentTeam); const currentTeam = useSelector(getCurrentTeam);
const relativePermaLink = useSelector((state: GlobalState) => (currentTeam ? getPermalinkURL(state, currentTeam.id, post.id) : '')); const relativePermaLink = useSelector((state: GlobalState) => (currentTeam ? getPermalinkURL(state, currentTeam.id, post.id) : ''));

Просмотреть файл

@@ -63,7 +63,7 @@ const preventActionOnPreview = (e: React.MouseEvent) => {
const MoveThreadModal = ({onExited, post, actions}: Props) => { const MoveThreadModal = ({onExited, post, actions}: Props) => {
const {formatMessage} = useIntl(); const {formatMessage} = useIntl();
const originalChannel = useSelector((state: GlobalState) => getChannel(state, {id: post.channel_id})); const originalChannel = useSelector((state: GlobalState) => getChannel(state, post.channel_id));
const currentTeam = useSelector(getCurrentTeam); const currentTeam = useSelector(getCurrentTeam);
const timeoutRef = useRef<NodeJS.Timeout | null>(null); const timeoutRef = useRef<NodeJS.Timeout | null>(null);

Просмотреть файл

@@ -49,7 +49,7 @@ function makeMapStateToProps() {
} }
if (ownProps.metadata.channel_type === General.DM_CHANNEL) { if (ownProps.metadata.channel_type === General.DM_CHANNEL) {
channelDisplayName = getChannel(state, {id: ownProps.metadata.channel_id})?.display_name || ''; channelDisplayName = getChannel(state, ownProps.metadata.channel_id)?.display_name || '';
} }
return { return {

Просмотреть файл

@@ -32,7 +32,7 @@ function makeMapStateToProps() {
const getUnreadCount = makeGetChannelUnreadCount(); const getUnreadCount = makeGetChannelUnreadCount();
return (state: GlobalState, ownProps: OwnProps) => { return (state: GlobalState, ownProps: OwnProps) => {
const channel = getChannel(state, {id: ownProps.channelId}); const channel = getChannel(state, ownProps.channelId);
const currentTeam = getCurrentTeam(state); const currentTeam = getCurrentTeam(state);
const currentChannelId = getCurrentChannelId(state); const currentChannelId = getCurrentChannelId(state);

Просмотреть файл

@@ -32,7 +32,7 @@ function makeMapStateToProps() {
return { return {
post, post,
channel: getChannel(state, {id: post.channel_id}), channel: getChannel(state, post.channel_id),
currentRelativeTeamUrl: getCurrentRelativeTeamUrl(state), currentRelativeTeamUrl: getCurrentRelativeTeamUrl(state),
displayName: getDisplayName(state, post.user_id, true), displayName: getDisplayName(state, post.user_id, true),
postsInThread: getPostsForThread(state, post.id), postsInThread: getPostsForThread(state, post.id),

Просмотреть файл

@@ -54,7 +54,7 @@ const ThreadPane = ({
}, },
} = thread; } = thread;
const channel = useSelector((state: GlobalState) => getChannel(state, {id: channelId})); const channel = useSelector((state: GlobalState) => getChannel(state, channelId));
const post = useSelector((state: GlobalState) => getPost(state, thread.id)); const post = useSelector((state: GlobalState) => getPost(state, thread.id));
const postsInThread = useSelector((state: GlobalState) => getPostsForThread(state, post.id)); const postsInThread = useSelector((state: GlobalState) => getPostsForThread(state, post.id));
const selectHandler = useCallback(() => select(), []); const selectHandler = useCallback(() => select(), []);
@@ -72,7 +72,7 @@ const ThreadPane = ({
const followHandler = useCallback(() => { const followHandler = useCallback(() => {
dispatch(setThreadFollow(currentUserId, currentTeamId, threadId, !isFollowing)); dispatch(setThreadFollow(currentUserId, currentTeamId, threadId, !isFollowing));
}, [currentUserId, currentTeamId, threadId, isFollowing, setThreadFollow]); }, [dispatch, currentUserId, currentTeamId, threadId, isFollowing]);
return ( return (
<div <div

Просмотреть файл

@@ -55,7 +55,7 @@ function makeMapStateToProps() {
if (selected) { if (selected) {
postIds = getPostIdsForThread(state, selected.id); postIds = getPostIdsForThread(state, selected.id);
userThread = getThread(state, selected.id); userThread = getThread(state, selected.id);
channel = getChannel(state, {id: selected.channel_id}); channel = getChannel(state, selected.channel_id);
} }
return { return {

Просмотреть файл

@@ -38,7 +38,7 @@ const CreateComment = forwardRef<HTMLDivElement, Props>(({
if (threadIsLimited) { if (threadIsLimited) {
return null; return null;
} }
return getChannel(state, {id: rootPost.channel_id}); return getChannel(state, rootPost.channel_id);
}); });
if (!channel || threadIsLimited) { if (!channel || threadIsLimited) {
return null; return null;

Просмотреть файл

@@ -3493,6 +3493,9 @@
"drafts.draft_title.you": "(you)", "drafts.draft_title.you": "(you)",
"drafts.empty.subtitle": "Any messages youve started will show here.", "drafts.empty.subtitle": "Any messages youve started will show here.",
"drafts.empty.title": "No drafts at the moment", "drafts.empty.title": "No drafts at the moment",
"drafts.error.post_not_found": "Thread not found",
"drafts.error.read_only": "Channel is read only",
"drafts.error.too_long": "Message too long",
"drafts.heading": "Drafts", "drafts.heading": "Drafts",
"drafts.info.sync": "Updated from another device", "drafts.info.sync": "Updated from another device",
"drafts.sidebarLink": "Drafts", "drafts.sidebarLink": "Drafts",

Просмотреть файл

@@ -174,7 +174,11 @@ export type CreatePostReturnType = {
pending?: string; pending?: string;
} }
export function createPost(post: Post, files: any[] = [], afterSubmit?: (response: any) => void): ActionFuncAsync<CreatePostReturnType, GlobalState> { export function createPost(
post: Post,
files: any[] = [],
afterSubmit?: (response: any) => void,
): ActionFuncAsync<CreatePostReturnType, GlobalState> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
const currentUserId = state.entities.users.currentUserId; const currentUserId = state.entities.users.currentUserId;

Просмотреть файл

@@ -481,13 +481,13 @@ describe('makeGetChannel', () => {
test('should return non-DM/non-GM channels directly from the store', () => { test('should return non-DM/non-GM channels directly from the store', () => {
const getChannel = Selectors.makeGetChannel(); const getChannel = Selectors.makeGetChannel();
expect(getChannel(testState, {id: channel1.id})).toBe(channel1); expect(getChannel(testState, channel1.id)).toBe(channel1);
}); });
test('should return DMs with computed data added', () => { test('should return DMs with computed data added', () => {
const getChannel = Selectors.makeGetChannel(); const getChannel = Selectors.makeGetChannel();
expect(getChannel(testState, {id: channel2.id})).toEqual({ expect(getChannel(testState, channel2.id)).toEqual({
...channel2, ...channel2,
display_name: user2.username, display_name: user2.username,
status: 'offline', status: 'offline',
@@ -498,7 +498,7 @@ describe('makeGetChannel', () => {
test('should return GMs with computed data added', () => { test('should return GMs with computed data added', () => {
const getChannel = Selectors.makeGetChannel(); const getChannel = Selectors.makeGetChannel();
expect(getChannel(testState, {id: channel3.id})).toEqual({ expect(getChannel(testState, channel3.id)).toEqual({
...channel3, ...channel3,
display_name: [user2.username, user3.username].sort(sortUsernames).join(', '), display_name: [user2.username, user3.username].sort(sortUsernames).join(', '),
}); });

Просмотреть файл

@@ -147,18 +147,21 @@ export function getChannelMember(state: GlobalState, channelId: string, userId:
return getChannelMembersInChannels(state)[channelId]?.[userId]; return getChannelMembersInChannels(state)[channelId]?.[userId];
} }
type OldMakeChannelArgument = {id: string};
// makeGetChannel returns a selector that returns a channel from the store with the following filled in for DM/GM channels: // makeGetChannel returns a selector that returns a channel from the store with the following filled in for DM/GM channels:
// - The display_name set to the other user(s) names, following the Teammate Name Display setting // - The display_name set to the other user(s) names, following the Teammate Name Display setting
// - The teammate_id for DM channels // - The teammate_id for DM channels
// - The status of the other user in a DM channel // - The status of the other user in a DM channel
export function makeGetChannel(): (state: GlobalState, props: {id: string}) => Channel | undefined { export function makeGetChannel(): (state: GlobalState, id: string) => Channel | undefined {
return createSelector( return createSelector(
'makeGetChannel', 'makeGetChannel',
getCurrentUserId, getCurrentUserId,
(state: GlobalState) => state.entities.users.profiles, (state: GlobalState) => state.entities.users.profiles,
(state: GlobalState) => state.entities.users.profilesInChannel, (state: GlobalState) => state.entities.users.profilesInChannel,
(state: GlobalState, props: {id: string}) => { (state: GlobalState, channelId: string | OldMakeChannelArgument) => {
const channel = getChannel(state, props.id); const id = typeof channelId === 'string' ? channelId : channelId.id;
const channel = getChannel(state, id);
if (!channel || !isDirectChannel(channel)) { if (!channel || !isDirectChannel(channel)) {
return ''; return '';
} }
@@ -169,7 +172,10 @@ export function makeGetChannel(): (state: GlobalState, props: {id: string}) => C
return teammateStatus || 'offline'; return teammateStatus || 'offline';
}, },
(state: GlobalState, props: {id: string}) => getChannel(state, props.id), (state: GlobalState, channelId: string | OldMakeChannelArgument) => {
const id = typeof channelId === 'string' ? channelId : channelId.id;
return getChannel(state, id);
},
getTeammateNameDisplaySetting, getTeammateNameDisplaySetting,
(currentUserId, profiles, profilesInChannel, teammateStatus, channel, teammateNameDisplay) => { (currentUserId, profiles, profilesInChannel, teammateStatus, channel, teammateNameDisplay) => {
if (channel) { if (channel) {

Просмотреть файл

@@ -57,7 +57,7 @@ export const getSelectedChannel = (() => {
return (state: GlobalState) => { return (state: GlobalState) => {
const channelId = getSelectedChannelId(state); const channelId = getSelectedChannelId(state);
return getChannel(state, {id: channelId}); return getChannel(state, channelId);
}; };
})(); })();