Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scheduled post Actions #8603

Merged
merged 18 commits into from
Mar 13, 2025
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/actions/remote/scheduled_post.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ jest.mock('@managers/websocket_manager', () => ({
getClient: jest.fn(() => mockWebSocketClient),
}));

jest.mock('@utils/scheduled_post', () => {
return {
isScheduledPostModel: jest.fn(() => false),
};
});

const mockedGetConfigValue = jest.mocked(getConfigValue);

beforeAll(() => {
Expand Down
9 changes: 6 additions & 3 deletions app/actions/remote/scheduled_post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import websocketManager from '@managers/websocket_manager';
import {getConfigValue} from '@queries/servers/system';
import ScheduledPostModel from '@typings/database/models/servers/scheduled_post';
import {getFullErrorMessage} from '@utils/errors';
import {logError} from '@utils/log';
import {isScheduledPostModel} from '@utils/scheduled_post';

import {forceLogoutIfNecessary} from './session';

Expand Down Expand Up @@ -41,11 +43,12 @@ export async function createScheduledPost(serverUrl: string, schedulePost: Sched
}
}

export async function updateScheduledPost(serverUrl: string, scheduledPost: ScheduledPost, connectionId?: string, fetchOnly = false) {
export async function updateScheduledPost(serverUrl: string, scheduledPost: ScheduledPost | ScheduledPostModel, connectionId?: string, fetchOnly = false) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const {operator, database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client = NetworkManager.getClient(serverUrl);
const response = await client.updateScheduledPost(scheduledPost, connectionId);
const normalizedScheduledPost = isScheduledPostModel(scheduledPost) ? await scheduledPost.toApi(database) : scheduledPost;
const response = await client.updateScheduledPost(normalizedScheduledPost, connectionId);

if (response && !fetchOnly) {
await operator.handleScheduledPosts({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {DRAFT_OPTIONS_BUTTON} from '@screens/draft_options';
import {DRAFT_OPTIONS_BUTTON} from '@screens/draft_scheduled_post_options';
import {DRAFT_TYPE_SCHEDULED, type DraftType} from '@screens/global_drafts/constants';
import {openAsBottomSheet} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
Expand Down
240 changes: 240 additions & 0 deletions app/components/post_draft/send_handler/send_handler.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {act, fireEvent, waitFor} from '@testing-library/react-native';
import React from 'react';

import {removeDraft} from '@actions/local/draft';
import {General} from '@constants';
import {DRAFT_TYPE_DRAFT, DRAFT_TYPE_SCHEDULED, type DraftType} from '@screens/global_drafts/constants';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import {sendMessageWithAlert} from '@utils/post';

import SendHandler from './send_handler';

import type {Database} from '@nozbe/watermelondb';

jest.mock('@actions/remote/channel', () => ({
getChannelTimezones: jest.fn().mockResolvedValue({channelTimezones: []}),
}));

jest.mock('@utils/post', () => ({
sendMessageWithAlert: jest.fn(),
persistentNotificationsConfirmation: jest.fn(),
}));

jest.mock('@screens/navigation', () => ({
dismissBottomSheet: jest.fn(),
}));

jest.mock('@actions/local/draft', () => ({
removeDraft: jest.fn(),
}));

describe('components/post_draft/send_handler/SendHandler', () => {
let database: Database;
const baseProps = {
testID: 'test_send_handler',
channelId: 'channel-id',
channelType: General.OPEN_CHANNEL,
channelName: 'test-channel',
rootId: '',
currentUserId: 'current-user-id',
cursorPosition: 0,
enableConfirmNotificationsToChannel: true,
maxMessageLength: 4000,
membersCount: 3,
useChannelMentions: true,
userIsOutOfOffice: false,
customEmojis: [],
value: '',
files: [],
clearDraft: jest.fn(),
updateValue: jest.fn(),
updateCursorPosition: jest.fn(),
updatePostInputTop: jest.fn(),
addFiles: jest.fn(),
uploadFileError: null,
setIsFocused: jest.fn(),
persistentNotificationInterval: 0,
persistentNotificationMaxRecipients: 5,
postPriority: {
priority: 'urgent',
requested_ack: false,
persistent_notifications: false,
} as PostPriority,
};

beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;
});

beforeEach(() => {
jest.clearAllMocks();
});

it('should render DraftInput when not from draft view', () => {
const wrapper = renderWithEverything(
<SendHandler {...baseProps}/>, {database},
);
expect(wrapper.getByTestId('test_send_handler')).toBeTruthy();
});

it('should render SendDraft when from draft view', () => {
const props = {
...baseProps,
isFromDraftView: true,
draftType: DRAFT_TYPE_DRAFT as DraftType,
};
const wrapper = renderWithEverything(
<SendHandler {...props}/>, {database},
);
expect(wrapper.getByTestId('send_draft_button')).toBeTruthy();
expect(wrapper.getByText('Send draft')).toBeTruthy();
});

it('should render Send text when draft type is scheduled', () => {
const props = {
...baseProps,
isFromDraftView: true,
draftType: DRAFT_TYPE_SCHEDULED as DraftType,
};
const wrapper = renderWithEverything(
<SendHandler {...props}/>, {database},
);
expect(wrapper.getByTestId('send_draft_button')).toBeTruthy();
expect(wrapper.getByText('Send')).toBeTruthy();
});

it('should show correct post priority', async () => {
const wrapper = renderWithEverything(
<SendHandler
{...baseProps}
canShowPostPriority={true}
/>, {database},
);

expect(wrapper.getByTestId('test_send_handler')).toBeTruthy();
expect(wrapper.getByText('URGENT')).toBeTruthy();
});

it('should pass correct props to SendDraft component when in draft view', async () => {
const props = {
...baseProps,
isFromDraftView: true,
draftType: DRAFT_TYPE_DRAFT as DraftType,
channelDisplayName: 'Test Channel',
draftReceiverUserName: 'test-user',
postId: 'test-post-id',
value: 'test message',
};

const wrapper = renderWithEverything(
<SendHandler {...props}/>, {database},
);

// Verify the SendDraft button is rendered with correct text
const sendDraftButton = wrapper.getByTestId('send_draft_button');
expect(sendDraftButton).toBeTruthy();
expect(wrapper.getByText('Send draft')).toBeTruthy();

// Verify the button is enabled when there's a message (should be pressable)
fireEvent.press(sendDraftButton);
await waitFor(() => expect(sendMessageWithAlert).toHaveBeenCalled());

// Reset the mock for the next test
jest.clearAllMocks();

// Test with empty message to verify disabled state
const emptyProps = {
...props,
value: '',
files: [],
};

wrapper.rerender(
<SendHandler {...emptyProps}/>,
);

// Button should still exist but pressing it should not trigger send when empty
const emptyButton = wrapper.getByTestId('send_draft_button');
expect(emptyButton).toBeTruthy();

fireEvent.press(emptyButton);
expect(sendMessageWithAlert).not.toHaveBeenCalled();
});

it('should call sendMessageWithAlert with correct params when Send button clicked', async () => {
const props = {
...baseProps,
isFromDraftView: true,
draftType: DRAFT_TYPE_DRAFT as DraftType,
channelName: 'test-channel',
value: 'test message',
postPriority: {
persistent_notifications: false,
} as PostPriority,
};

const wrapper = renderWithEverything(
<SendHandler {...props}/>, {database},
);

const sendButton = wrapper.getByTestId('send_draft_button');
expect(sendButton).toBeTruthy();

await act(async () => {
fireEvent.press(sendButton);
});

await waitFor(() => {
expect(sendMessageWithAlert).toHaveBeenCalledWith(expect.objectContaining({
channelName: 'test-channel',
title: 'Send message now',
intl: expect.any(Object),
sendMessageHandler: expect.any(Function),
}));
});
});

it('should execute sendMessageHandler when send_draft_button is clicked', async () => {
// Mock implementation to capture the sendMessageHandler
let capturedHandler: Function;
jest.mocked(sendMessageWithAlert).mockImplementation((params) => {
capturedHandler = params.sendMessageHandler;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Personal opinion) This is one way to do it, but I think it is more elegant to inspect the mock calls instead of trying to set this variable. 0/5.

return Promise.resolve();
});

const props = {
...baseProps,
isFromDraftView: true,
draftType: DRAFT_TYPE_DRAFT as DraftType,
value: 'test message',
};

const wrapper = renderWithEverything(
<SendHandler {...props}/>, {database},
);

// Find and press the send button
const sendButton = wrapper.getByTestId('send_draft_button');
await act(async () => {
fireEvent.press(sendButton);
});

// Verify sendMessageWithAlert was called
expect(sendMessageWithAlert).toHaveBeenCalledWith(expect.objectContaining({
sendMessageHandler: expect.any(Function),
}));

// Now execute the captured handler to simulate user confirming the send
await act(async () => {
await capturedHandler();
});

// Varify removeDraft function is been called.
expect(removeDraft).toHaveBeenCalled();
});
});
9 changes: 8 additions & 1 deletion app/components/post_draft/send_handler/send_handler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import DraftInput from '@components/post_draft/draft_input/draft_input';
import {PostPriorityType} from '@constants/post';
import {useServerUrl} from '@context/server';
import {useHandleSendMessage} from '@hooks/handle_send_message';
import SendDraft from '@screens/draft_options/send_draft';
import SendDraft from '@screens/draft_scheduled_post_options/send_draft';

import type {DraftType} from '@screens/global_drafts/constants';
import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji';
import type {AvailableScreens} from '@typings/screens/navigation';

Expand Down Expand Up @@ -45,6 +46,8 @@ type Props = {
persistentNotificationMaxRecipients: number;
postPriority: PostPriority;

draftType?: DraftType;
postId?: string;
bottomSheetId?: AvailableScreens;
channelDisplayName?: string;
isFromDraftView?: boolean;
Expand Down Expand Up @@ -88,6 +91,8 @@ export default function SendHandler({
bottomSheetId,
draftReceiverUserName,
isFromDraftView,
draftType,
postId,
}: Props) {
const serverUrl = useServerUrl();

Expand Down Expand Up @@ -134,6 +139,8 @@ export default function SendHandler({
persistentNotificationInterval={persistentNotificationInterval}
persistentNotificationMaxRecipients={persistentNotificationMaxRecipients}
draftReceiverUserName={draftReceiverUserName}
draftType={draftType}
postId={postId}
/>
);
}
Expand Down
3 changes: 3 additions & 0 deletions app/constants/screens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const PINNED_MESSAGES = 'PinnedMessages';
export const POST_OPTIONS = 'PostOptions';
export const POST_PRIORITY_PICKER = 'PostPriorityPicker';
export const REACTIONS = 'Reactions';
export const RESCHEDULE_DRAFT = 'RescheduleDraft';
export const REVIEW_APP = 'ReviewApp';
export const SAVED_MESSAGES = 'SavedMessages';
export const SCHEDULED_POST_OPTIONS = 'ScheduledPostOptions';
Expand Down Expand Up @@ -134,6 +135,7 @@ export default {
POST_OPTIONS,
POST_PRIORITY_PICKER,
REACTIONS,
RESCHEDULE_DRAFT,
REVIEW_APP,
SAVED_MESSAGES,
SEARCH,
Expand Down Expand Up @@ -183,6 +185,7 @@ export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
MANAGE_CHANNEL_MEMBERS,
INVITE,
PERMALINK,
RESCHEDULE_DRAFT,
]);

export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set<string>([
Expand Down
2 changes: 1 addition & 1 deletion app/hooks/handle_send_message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export const useHandleSendMessage = ({

let response: CreateResponse;
if (schedulingInfo) {
response = await createScheduledPost(serverUrl, scheduledPostFromPost(post, schedulingInfo, postPriority));
response = await createScheduledPost(serverUrl, scheduledPostFromPost(post, schedulingInfo, postPriority, postFiles));
} else {
response = await createPost(serverUrl, post, postFiles);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ const DateTimeSelector = ({timezone, handleChange, isMilitaryTime, theme, showIn
};

return (
<View style={styles.container}>
<View
style={styles.container}
testID='custom_date_time_picker'
>
<View style={styles.buttonContainer}>
<Button
testID={'custom_status_clear_after.menu_item.date_and_time.button.date'}
Expand Down
Loading
Loading