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;
let channel = makeGetChannel()(state, {id: post.channel_id});
let channel = makeGetChannel()(state, post.channel_id);
const user = getCurrentUser(state);
const userStatus = getStatusForUserId(state, user.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) => {
// parse message and emit emoji event
const emojis = matchEmoticons(post.message);
@@ -123,6 +128,7 @@ export function createPost(post: Post, files: FileInfo[], afterSubmit?: (respons
dispatch(storeDraft(post.channel_id, null));
}
afterOptimisticSubmit?.();
return result;
};
}

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

@@ -13,12 +13,12 @@ import {setGlobalItem, actionOnGlobalItemsWithPrefix} from 'actions/storage';
import {
clearCommentDraftUploads,
updateCommentDraft,
onSubmit,
submitPost,
submitCommand,
makeOnSubmit,
makeOnEditLatestPost,
} 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 {StoragePrefixes} from 'utils/constants';
@@ -312,8 +312,7 @@ describe('rhs view actions', () => {
});
});
describe('makeOnSubmit', () => {
const onSubmit = makeOnSubmit(channelId, rootId, latestPostId);
describe('onSubmit', () => {
const draft = {
message: 'test',
fileInfos: [],
@@ -323,7 +322,7 @@ describe('rhs view actions', () => {
};
test('it adds message into history', () => {
store.dispatch(onSubmit(draft));
store.dispatch(onSubmit(draft, {}));
const testStore = mockStore(initialState);
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', () => {
store.dispatch(onSubmit({
message: '/away',
fileInfos: [],
uploadsInProgress: [],
}));
}, {}));
const testStore = mockStore(initialState);
testStore.dispatch(submitCommand(channelId, rootId, {message: '/away', fileInfos: [], uploadsInProgress: []}));
@@ -398,7 +370,7 @@ describe('rhs view actions', () => {
message: 'test msg',
fileInfos: [],
uploadsInProgress: [],
}));
}, {}));
const testStore = mockStore(initialState);
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 * as PostActions from 'actions/post_actions';
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 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);
}
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) => {
const state = getState();
@@ -103,7 +109,7 @@ export function submitPost(channelId: string, rootId: string, draft: PostDraft,
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 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) => {
const {message, channelId, rootId} = draft;
const state = getState();
@@ -191,19 +176,19 @@ export function onSubmit(draft: PostDraft, options: {ignoreSlash?: boolean; afte
const emojis = getCustomEmojisByName(state);
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);
if (latestPostId) {
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) {
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 {
return async (dispatch, getState) => {
const state = getState();
const post = getPost(state, postId) ?? (await dispatch(fetchPost(postId))).data;
if (post) {
const post: Post | undefined = getPost(state, postId) ?? (await dispatch(fetchPost(postId))).data;
if (post && post.state !== 'DELETED' && post.delete_at === 0) {
const channel = getChannelSelector(state, post.channel_id);
if (!channel) {
await dispatch(getChannel(post.channel_id));

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -1,14 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// 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 {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 ThreadDraft from './thread_draft';
import {removeDraft} from 'actions/views/drafts';
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 = {
user: UserProfile;
@@ -18,34 +45,174 @@ type Props = {
isRemote?: boolean;
}
function DraftRow({draft, user, status, displayName, isRemote}: Props) {
switch (draft.type) {
case 'channel':
return (
<ChannelDraft
{...draft}
draftId={String(draft.key)}
user={user}
status={status}
displayName={displayName}
isRemote={isRemote}
/>
);
case 'thread':
return (
<ThreadDraft
{...draft}
rootId={draft.id}
draftId={String(draft.key)}
user={user}
status={status}
displayName={displayName}
isRemote={isRemote}
/>
);
default:
const mockLastBlurAt = {current: 0};
function DraftRow({
draft,
user,
status,
displayName,
isRemote,
}: Props) {
const intl = useIntl();
const rootId = draft.value.rootId;
const channelId = draft.value.channelId;
const [serverError, setServerError] = useState<(ServerError & { submittedMessage?: string }) | null>(null);
const history = useHistory();
const dispatch = useDispatch();
const getChannel = useMemo(() => makeGetChannel(), []);
const getThreadOrSynthetic = useMemo(() => makeGetThreadOrSynthetic(), []);
const rootPostDeleted = useSelector((state: GlobalState) => {
if (!rootId) {
return false;
}
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 (
<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);

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -52,7 +52,7 @@ const ForwardPostModal = ({onExited, post}: Props) => {
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 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 {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 timeoutRef = useRef<NodeJS.Timeout | null>(null);

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

@@ -49,7 +49,7 @@ function makeMapStateToProps() {
}
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 {

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

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

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

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

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

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

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

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

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

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

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

@@ -3493,6 +3493,9 @@
"drafts.draft_title.you": "(you)",
"drafts.empty.subtitle": "Any messages youve started will show here.",
"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.info.sync": "Updated from another device",
"drafts.sidebarLink": "Drafts",

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

@@ -174,7 +174,11 @@ export type CreatePostReturnType = {
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) => {
const state = getState();
const currentUserId = state.entities.users.currentUserId;

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

@@ -481,13 +481,13 @@ describe('makeGetChannel', () => {
test('should return non-DM/non-GM channels directly from the store', () => {
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', () => {
const getChannel = Selectors.makeGetChannel();
expect(getChannel(testState, {id: channel2.id})).toEqual({
expect(getChannel(testState, channel2.id)).toEqual({
...channel2,
display_name: user2.username,
status: 'offline',
@@ -498,7 +498,7 @@ describe('makeGetChannel', () => {
test('should return GMs with computed data added', () => {
const getChannel = Selectors.makeGetChannel();
expect(getChannel(testState, {id: channel3.id})).toEqual({
expect(getChannel(testState, channel3.id)).toEqual({
...channel3,
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];
}
type OldMakeChannelArgument = {id: string};
// 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 teammate_id for DM channels
// - 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(
'makeGetChannel',
getCurrentUserId,
(state: GlobalState) => state.entities.users.profiles,
(state: GlobalState) => state.entities.users.profilesInChannel,
(state: GlobalState, props: {id: string}) => {
const channel = getChannel(state, props.id);
(state: GlobalState, channelId: string | OldMakeChannelArgument) => {
const id = typeof channelId === 'string' ? channelId : channelId.id;
const channel = getChannel(state, id);
if (!channel || !isDirectChannel(channel)) {
return '';
}
@@ -169,7 +172,10 @@ export function makeGetChannel(): (state: GlobalState, props: {id: string}) => C
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,
(currentUserId, profiles, profilesInChannel, teammateStatus, channel, teammateNameDisplay) => {
if (channel) {

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

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