Add set due date functionality to playbooks (#9091)

* Add set due date functionality to playbooks

* Fix i18n

* Fix test

* Add tests

* Address feedback

* Remove unneeded line break

* Fix test
This commit is contained in:
Daniel Espino García 2025-09-12 16:37:56 +02:00 committed by GitHub
parent 4e95364344
commit 0df334dd2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1108 additions and 51 deletions

View file

@ -10,21 +10,21 @@ import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES} from '@constants/custom_status';
import {getDisplayNamePreferenceAsBool} from '@helpers/api/preference';
import {queryDisplayNamePreferences} from '@queries/servers/preference';
import {getCurrentMomentForTimezone, getRoundedTime, getUtcOffsetForTimeZone} from '@utils/helpers';
import {getCurrentMomentForTimezone, getRoundedTime} from '@utils/helpers';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = {
timezone: string | null;
timezone: string;
isMilitaryTime: boolean;
theme: Theme;
handleChange: (currentDate: Moment) => void;
showInitially?: AndroidMode;
initialDate?: Moment;
minuteInterval?: 5 | 30;
}
type AndroidMode = 'date' | 'time';
@ -44,11 +44,18 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const DateTimeSelector = ({timezone, handleChange, isMilitaryTime, theme, showInitially, initialDate}: Props) => {
const DateTimeSelector = ({
timezone,
handleChange,
isMilitaryTime,
theme,
showInitially,
initialDate,
minuteInterval = 30,
}: Props) => {
const styles = getStyleSheet(theme);
const currentTime = getCurrentMomentForTimezone(timezone);
const timezoneOffSetInMinutes = timezone ? getUtcOffsetForTimeZone(timezone) : undefined;
const minimumDate = getRoundedTime(currentTime);
const minimumDate = getRoundedTime(currentTime, minuteInterval);
const defaultDate = initialDate && initialDate.isAfter(minimumDate) ? initialDate : minimumDate;
const [date, setDate] = useState<Moment>(defaultDate);
@ -108,8 +115,8 @@ const DateTimeSelector = ({timezone, handleChange, isMilitaryTime, theme, showIn
onChange={onChange}
textColor={theme.centerChannelColor}
minimumDate={minimumDate.toDate()}
minuteInterval={CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES}
timeZoneOffsetInMinutes={timezoneOffSetInMinutes}
minuteInterval={minuteInterval}
timeZoneName={timezone}
/>
)}
</View>

View file

@ -5,7 +5,7 @@ import DatabaseManager from '@database/manager';
import {getPlaybookChecklistItemById} from '@playbooks/database/queries/item';
import TestHelper from '@test/test_helper';
import {updateChecklistItem, setChecklistItemCommand, setAssignee} from './checklist';
import {updateChecklistItem, setChecklistItemCommand, setAssignee, setDueDate} from './checklist';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -180,3 +180,87 @@ describe('setAssignee', () => {
expect(updated!.assigneeId).toBe('');
});
});
describe('setDueDate', () => {
it('should handle not found database', async () => {
const {error} = await setDueDate('foo', 'itemid', 1234567890);
expect(error).toBeTruthy();
expect((error as Error).message).toContain('foo database not found');
});
it('should handle item not found', async () => {
const {error} = await setDueDate(serverUrl, 'nonexistent', 1234567890);
expect(error).toBe('Item not found: nonexistent');
});
it('should handle database write errors', async () => {
const checklistId = 'checklistid';
const item = TestHelper.createPlaybookItem(checklistId, 0);
await operator.handlePlaybookChecklistItem({items: [{...item, checklist_id: checklistId}], prepareRecordsOnly: false});
const originalWrite = operator.database.write;
operator.database.write = jest.fn().mockRejectedValue(new Error('Database write failed'));
const {error} = await setDueDate(serverUrl, item.id, 1234567890);
expect(error).toBeTruthy();
operator.database.write = originalWrite;
});
it('should set valid due date successfully', async () => {
const checklistId = 'checklistid';
const item = TestHelper.createPlaybookItem(checklistId, 0);
await operator.handlePlaybookChecklistItem({items: [{...item, checklist_id: checklistId}], prepareRecordsOnly: false});
const testDate = 1640995200000; // January 1, 2022 00:00:00 UTC
const {data, error} = await setDueDate(serverUrl, item.id, testDate);
expect(error).toBeUndefined();
expect(data).toBe(true);
const updated = await getPlaybookChecklistItemById(operator.database, item.id);
expect(updated).toBeDefined();
expect(updated!.dueDate).toBe(testDate);
});
it('should set due date to 0 when undefined is passed', async () => {
const checklistId = 'checklistid';
const item = TestHelper.createPlaybookItem(checklistId, 0);
await operator.handlePlaybookChecklistItem({items: [{...item, checklist_id: checklistId}], prepareRecordsOnly: false});
const {data, error} = await setDueDate(serverUrl, item.id, undefined);
expect(error).toBeUndefined();
expect(data).toBe(true);
const updated = await getPlaybookChecklistItemById(operator.database, item.id);
expect(updated).toBeDefined();
expect(updated!.dueDate).toBe(0);
});
it('should set due date to 0 when no date parameter is passed', async () => {
const checklistId = 'checklistid';
const item = TestHelper.createPlaybookItem(checklistId, 0);
await operator.handlePlaybookChecklistItem({items: [{...item, checklist_id: checklistId}], prepareRecordsOnly: false});
const {data, error} = await setDueDate(serverUrl, item.id);
expect(error).toBeUndefined();
expect(data).toBe(true);
const updated = await getPlaybookChecklistItemById(operator.database, item.id);
expect(updated).toBeDefined();
expect(updated!.dueDate).toBe(0);
});
it('should handle setting due date to 0 explicitly', async () => {
const checklistId = 'checklistid';
const item = TestHelper.createPlaybookItem(checklistId, 0);
await operator.handlePlaybookChecklistItem({items: [{...item, checklist_id: checklistId}], prepareRecordsOnly: false});
const {data, error} = await setDueDate(serverUrl, item.id, 0);
expect(error).toBeUndefined();
expect(data).toBe(true);
const updated = await getPlaybookChecklistItemById(operator.database, item.id);
expect(updated).toBeDefined();
expect(updated!.dueDate).toBe(0);
});
});

View file

@ -67,3 +67,24 @@ export async function setAssignee(serverUrl: string, itemId: string, assigneeId:
return {error};
}
}
export async function setDueDate(serverUrl: string, itemId: string, date?: number) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const item = await getPlaybookChecklistItemById(database, itemId);
if (!item) {
return {error: `Item not found: ${itemId}`};
}
await database.write(async () => {
item.update((i) => {
i.dueDate = date ?? 0;
});
});
return {data: true};
} catch (error) {
logError('failed to set due date', error);
return {error};
}
}

View file

@ -8,6 +8,7 @@ import {
setChecklistItemCommand as localSetChecklistItemCommand,
updateChecklistItem as localUpdateChecklistItem,
setAssignee as localSetAssignee,
setDueDate as localSetDueDate,
} from '@playbooks/actions/local/checklist';
import {
@ -17,6 +18,7 @@ import {
restoreChecklistItem,
setChecklistItemCommand,
setAssignee,
setDueDate,
} from './checklist';
const serverUrl = 'baseHandler.test.com';
@ -34,6 +36,7 @@ const mockClient = {
restoreChecklistItem: jest.fn(),
setAssignee: jest.fn(),
setChecklistItemCommand: jest.fn(),
setDueDate: jest.fn(),
};
jest.mock('@playbooks/actions/local/checklist');
@ -265,4 +268,58 @@ describe('checklist', () => {
expect(localSetChecklistItemCommand).toHaveBeenCalledWith(serverUrl, itemId, command);
});
});
describe('setDueDate', () => {
it('should handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await setDueDate(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, 1234567890);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(localSetDueDate).not.toHaveBeenCalled();
});
it('should handle API exception', async () => {
mockClient.setDueDate.mockImplementationOnce(throwFunc);
const result = await setDueDate(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, 1234567890);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(mockClient.setDueDate).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber, 1234567890);
expect(localSetDueDate).not.toHaveBeenCalled();
});
it('should set due date successfully with valid date', async () => {
mockClient.setDueDate.mockResolvedValueOnce({});
const result = await setDueDate(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, 1234567890);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(mockClient.setDueDate).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber, 1234567890);
expect(localSetDueDate).toHaveBeenCalledWith(serverUrl, itemId, 1234567890);
});
it('should set due date successfully with undefined date', async () => {
mockClient.setDueDate.mockResolvedValueOnce({});
const result = await setDueDate(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(mockClient.setDueDate).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber, undefined);
expect(localSetDueDate).toHaveBeenCalledWith(serverUrl, itemId, undefined);
});
it('should set due date successfully with zero date', async () => {
mockClient.setDueDate.mockResolvedValueOnce({});
const result = await setDueDate(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, 0);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(mockClient.setDueDate).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber, 0);
expect(localSetDueDate).toHaveBeenCalledWith(serverUrl, itemId, 0);
});
});
});

View file

@ -8,6 +8,7 @@ import {
setChecklistItemCommand as localSetChecklistItemCommand,
updateChecklistItem as localUpdateChecklistItem,
setAssignee as localSetAssignee,
setDueDate as localSetDueDate,
} from '@playbooks/actions/local/checklist';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
@ -134,3 +135,24 @@ export const setAssignee = async (
return {error};
}
};
export const setDueDate = async (
serverUrl: string,
playbookRunId: string,
itemId: string,
checklistNumber: number,
itemNumber: number,
date?: number,
) => {
try {
const client = NetworkManager.getClient(serverUrl);
await client.setDueDate(playbookRunId, checklistNumber, itemNumber, date);
await localSetDueDate(serverUrl, itemId, date);
return {data: true};
} catch (error) {
logDebug('error on setDueDate', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};

View file

@ -279,6 +279,33 @@ describe('setChecklistItemCommand', () => {
});
});
describe('setDueDate', () => {
test('should set due date successfully with valid date', async () => {
const playbookRunId = 'run123';
const checklistNum = 1;
const itemNum = 2;
const date = 1640995200000; // January 1, 2022 00:00:00 UTC
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNum}/item/${itemNum}/duedate`;
const expectedOptions = {method: 'put', body: {due_date: date}};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.setDueDate(playbookRunId, checklistNum, itemNum, date);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should handle error when setting due date', async () => {
const playbookRunId = 'run123';
const checklistNum = 1;
const itemNum = 2;
const date = 1640995200000;
jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error'));
await expect(client.setDueDate(playbookRunId, checklistNum, itemNum, date)).rejects.toThrow('Network error');
});
});
describe('setOwner', () => {
test('should set owner successfully', async () => {
const playbookRunId = 'run123';

View file

@ -20,6 +20,7 @@ export interface ClientPlaybooksMix {
skipChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise<void>;
restoreChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise<void>;
setAssignee: (playbookRunId: string, checklistNum: number, itemNum: number, assigneeId?: string) => Promise<void>;
setDueDate: (playbookRunId: string, checklistNum: number, itemNum: number, date?: number) => Promise<void>;
// skipChecklist: (playbookRunID: string, checklistNum: number) => Promise<void>;
@ -120,6 +121,13 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBas
);
};
setDueDate = async (playbookRunId: string, checklistNum: number, itemNum: number, date?: number) => {
await this.doFetch(
`${this.getPlaybookRunRoute(playbookRunId)}/checklists/${checklistNum}/item/${itemNum}/duedate`,
{method: 'put', body: {due_date: date}},
);
};
// Slash Commands
runChecklistItemSlashCommand = async (playbookRunId: string, checklistNumber: number, itemNumber: number) => {
const data = await this.doFetch(

View file

@ -5,10 +5,12 @@ export const PLAYBOOKS_RUNS = 'PlaybookRuns';
export const PLAYBOOK_RUN = 'PlaybookRun';
export const PLAYBOOK_EDIT_COMMAND = 'PlaybookEditCommand';
export const PLAYBOOK_SELECT_USER = 'PlaybookSelectUser';
export const PLAYBOOKS_SELECT_DATE = 'PlaybooksSelectDate';
export default {
PLAYBOOKS_RUNS,
PLAYBOOK_RUN,
PLAYBOOK_EDIT_COMMAND,
PLAYBOOK_SELECT_USER,
PLAYBOOKS_SELECT_DATE,
};

View file

@ -10,6 +10,7 @@ import {render} from '@test/intl-test-helper';
import EditCommand from './edit_command';
import PlaybookRun from './playbook_run';
import PlaybookRuns from './playbooks_runs';
import SelectDate from './select_date';
import SelectUser from './select_user';
import {loadPlaybooksScreen} from '.';
@ -45,6 +46,12 @@ jest.mock('@playbooks/screens/select_user', () => ({
}));
jest.mocked(SelectUser).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOK_SELECT_USER}</Text>);
jest.mock('@playbooks/screens/select_date', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(SelectDate).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOKS_SELECT_DATE}</Text>);
describe('Screen Registration', () => {
beforeEach(() => {
jest.clearAllMocks();

View file

@ -14,6 +14,8 @@ export function loadPlaybooksScreen(screenName: string | number) {
return withServerDatabase(require('@playbooks/screens/edit_command').default);
case Screens.PLAYBOOK_SELECT_USER:
return withServerDatabase(require('@playbooks/screens/select_user').default);
case Screens.PLAYBOOKS_SELECT_DATE:
return withServerDatabase(require('@playbooks/screens/select_date').default);
default:
return undefined;
}

View file

@ -6,7 +6,7 @@ import {goToScreen} from '@screens/navigation';
import TestHelper from '@test/test_helper';
import {changeOpacity} from '@utils/theme';
import {goToPlaybookRuns, goToPlaybookRun, goToSelectUser} from './navigation';
import {goToPlaybookRuns, goToPlaybookRun, goToSelectUser, goToSelectDate} from './navigation';
jest.mock('@screens/navigation', () => ({
goToScreen: jest.fn(),
@ -88,4 +88,47 @@ describe('Playbooks Navigation', () => {
);
});
});
describe('goToSelectDate', () => {
it('should navigate to select date screen with selected date', async () => {
const onSave = jest.fn();
const selectedDate = 1640995200000; // January 1, 2022
await goToSelectDate(mockIntl, onSave, selectedDate);
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
id: 'playbooks.select_date.title',
defaultMessage: 'Due date',
});
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOKS_SELECT_DATE,
'Due date',
{
onSave,
selectedDate,
},
{},
);
});
it('should navigate to select date screen without selected date', async () => {
const onSave = jest.fn();
await goToSelectDate(mockIntl, onSave, undefined);
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
id: 'playbooks.select_date.title',
defaultMessage: 'Due date',
});
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOKS_SELECT_DATE,
'Due date',
{
onSave,
selectedDate: undefined,
},
{},
);
});
});
});

View file

@ -39,3 +39,15 @@ export async function goToSelectUser(
handleRemove,
}, {});
}
export async function goToSelectDate(
intl: IntlShape,
onSave: (date: number | undefined) => void,
selectedDate: number | undefined,
) {
const title = intl.formatMessage({id: 'playbooks.select_date.title', defaultMessage: 'Due date'});
goToScreen(Screens.PLAYBOOKS_SELECT_DATE, title, {
onSave,
selectedDate,
}, {});
}

View file

@ -9,8 +9,8 @@ import {ScrollView} from 'react-native';
import OptionBox from '@components/option_box';
import OptionItem from '@components/option_item';
import {useIsTablet} from '@hooks/device';
import {setAssignee, setChecklistItemCommand} from '@playbooks/actions/remote/checklist';
import {goToSelectUser} from '@playbooks/screens/navigation';
import {setAssignee, setChecklistItemCommand, setDueDate} from '@playbooks/actions/remote/checklist';
import {goToSelectDate, goToSelectUser} from '@playbooks/screens/navigation';
import {dismissBottomSheet, goToScreen, openUserProfileModal} from '@screens/navigation';
import {renderWithIntl} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
@ -42,6 +42,7 @@ jest.mock('@context/server', () => ({
useServerUrl: jest.fn().mockReturnValue('server-url'),
}));
jest.mock('@playbooks/screens/navigation');
jest.mock('@utils/snack_bar');
describe('ChecklistItemBottomSheet', () => {
@ -90,6 +91,7 @@ describe('ChecklistItemBottomSheet', () => {
onRunCommand: mockOnRunCommand,
teammateNameDisplay: mockTeammateNameDisplay,
isDisabled: false,
currentUserTimezone: {useAutomaticTimezone: false, automaticTimezone: '', manualTimezone: 'America/New_York'},
participantIds: ['user-1', 'user-2'],
};
}
@ -289,7 +291,7 @@ describe('ChecklistItemBottomSheet', () => {
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const dueDateItem = getByTestId('checklist_item.due_date');
expect(dueDateItem.props.info).toBe('Saturday, January 1 at 12:00 AM');
expect(dueDateItem.props.info).toBe('Saturday, January 1 at 07:00 PM');
jest.useRealTimers();
});
@ -327,6 +329,21 @@ describe('ChecklistItemBottomSheet', () => {
expect(commandItem).toHaveProp('action', expect.any(Function));
});
it('set date is disabled when isDisabled is true', () => {
const props = getBaseProps();
props.isDisabled = true;
const {getByTestId, rerender} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
let dueDateItem = getByTestId('checklist_item.due_date');
expect(dueDateItem).toHaveProp('type', 'none');
expect(dueDateItem).toHaveProp('action', undefined);
props.isDisabled = false;
rerender(<ChecklistItemBottomSheet {...props}/>);
dueDateItem = getByTestId('checklist_item.due_date');
expect(dueDateItem).toHaveProp('type', 'arrow');
expect(dueDateItem).toHaveProp('action', expect.any(Function));
});
it('displays correct command information', () => {
const props = getBaseProps();
props.item = TestHelper.fakePlaybookChecklistItemModel({
@ -408,6 +425,47 @@ describe('ChecklistItemBottomSheet', () => {
);
});
it('opens the set date modal when the due date is clicked', async () => {
const props = getBaseProps();
props.checklistNumber = 2;
props.itemNumber = 4;
props.item = TestHelper.fakePlaybookChecklistItemModel({
...props.item,
dueDate: 1640995200000, // 2022-01-01
});
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const dueDateItem = getByTestId('checklist_item.due_date');
act(() => {
dueDateItem.props.action();
});
expect(goToSelectDate).toHaveBeenCalledWith(
expect.anything(),
expect.any(Function),
1640995200000,
);
const setDate = jest.mocked(goToSelectDate).mock.calls[0][1];
await act(async () => {
setDate(1672531200000);
});
expect(setDueDate).toHaveBeenCalledWith(
'server-url',
'run-1',
'item-1',
2,
4,
1672531200000,
);
});
it('does not open the set date modal when the due date is clicked and isDisabled is true', async () => {
const props = getBaseProps();
props.isDisabled = true;
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const dueDateItem = getByTestId('checklist_item.due_date');
expect(dueDateItem.props.action).toBeUndefined();
});
it('handles user profile modal opening correctly', async () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, type ComponentProps} from 'react';
import {defineMessages, useIntl, type IntlShape} from 'react-intl';
import {defineMessages, useIntl} from 'react-intl';
import {View, Text} from 'react-native';
import MenuDivider from '@components/menu_divider';
@ -10,13 +10,14 @@ import OptionBox from '@components/option_box';
import OptionItem, {ITEM_HEIGHT} from '@components/option_item';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {setAssignee, setChecklistItemCommand} from '@playbooks/actions/remote/checklist';
import {goToSelectUser} from '@playbooks/screens/navigation';
import {setAssignee, setChecklistItemCommand, setDueDate} from '@playbooks/actions/remote/checklist';
import {goToSelectDate, goToSelectUser} from '@playbooks/screens/navigation';
import {getDueDateString} from '@playbooks/utils/time';
import {dismissBottomSheet, goToScreen, openUserProfileModal} from '@screens/navigation';
import {toMilliseconds} from '@utils/datetime';
import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
import {getTimezone} from '@utils/user';
import Checkbox from '../checkbox';
@ -64,10 +65,6 @@ const messages = defineMessages({
id: 'playbooks.checklist_item.none',
defaultMessage: 'None',
},
dateAtTime: {
id: 'playbooks.checklist_item.date_at_time',
defaultMessage: '{date} at {time}',
},
});
const ACTION_BUTTON_HEIGHT = 62;
@ -127,22 +124,10 @@ type Props = {
onRunCommand: () => void;
teammateNameDisplay: string;
isDisabled: boolean;
currentUserTimezone: UserTimezone | null | undefined;
participantIds: string[];
};
function getDueDateInfo(intl: IntlShape, dueDate: number | undefined) {
if (!dueDate) {
return intl.formatMessage(messages.none);
}
const dateObject = new Date(dueDate);
const dateString = dateObject.toLocaleDateString(intl.locale, {month: 'long', day: 'numeric', weekday: 'long'});
if (Math.abs(dueDate - Date.now()) < toMilliseconds({days: 1})) {
const timeString = dateObject.toLocaleTimeString(intl.locale, {hour: '2-digit', minute: '2-digit'});
return intl.formatMessage(messages.dateAtTime, {date: dateString, time: timeString});
}
return dateString;
}
const ChecklistItemBottomSheet = ({
runId,
checklistNumber,
@ -155,6 +140,7 @@ const ChecklistItemBottomSheet = ({
onRunCommand,
teammateNameDisplay,
isDisabled,
currentUserTimezone,
participantIds,
}: Props) => {
const theme = useTheme();
@ -162,6 +148,8 @@ const ChecklistItemBottomSheet = ({
const intl = useIntl();
const serverUrl = useServerUrl();
const timezone = getTimezone(currentUserTimezone);
const dueDate = 'dueDate' in item ? item.dueDate : item.due_date;
const isChecked = item.state === 'closed';
const isSkipped = item.state === 'skipped';
@ -248,6 +236,12 @@ const ChecklistItemBottomSheet = ({
};
}, [assignee, intl, onUserChipPress, teammateNameDisplay]);
const openEditDateModal = useCallback(async () => {
goToSelectDate(intl, (date) => {
setDueDate(serverUrl, runId, item.id, checklistNumber, itemNumber, date);
}, dueDate);
}, [intl, dueDate, serverUrl, runId, item.id, checklistNumber, itemNumber]);
const handleSelect = useCallback(async (selected: UserProfile) => {
const res = await setAssignee(serverUrl, runId, item.id, checklistNumber, itemNumber, selected.id);
if (res.error) {
@ -283,11 +277,12 @@ const ChecklistItemBottomSheet = ({
action={isDisabled ? undefined : openUserSelector}
/>
<OptionItem
type='none'
type={isDisabled ? 'none' : 'arrow'}
icon='calendar-outline'
label={intl.formatMessage(messages.dueDate)}
info={getDueDateInfo(intl, dueDate)}
info={getDueDateString(intl, dueDate, timezone)}
testID='checklist_item.due_date'
action={isDisabled ? undefined : openEditDateModal}
/>
<OptionItem
type={isDisabled ? 'none' : 'arrow'}

View file

@ -4,6 +4,7 @@
import React, {type ComponentProps} from 'react';
import {General} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getPlaybookChecklistItemById} from '@playbooks/database/queries/item';
import {act, renderWithEverything, waitFor} from '@test/intl-test-helper';
@ -71,6 +72,8 @@ describe('ChecklistItemBottomSheet Enhanced Component', () => {
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
operator = serverDatabaseAndOperator.operator;
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'currentUser'}], prepareRecordsOnly: false});
await addUserToDatabase(TestHelper.fakeUser({id: 'currentUser', timezone: {useAutomaticTimezone: false, manualTimezone: 'America/New_York', automaticTimezone: 'America/New_York'}}));
jest.clearAllMocks();
});
@ -98,6 +101,7 @@ describe('ChecklistItemBottomSheet Enhanced Component', () => {
expect(bottomSheet).toBeTruthy();
expect(bottomSheet).toHaveProp('item', item);
expect(bottomSheet).toHaveProp('assignee', expect.objectContaining({id: 'user-1'}));
expect(bottomSheet).toHaveProp('currentUserTimezone', expect.objectContaining({useAutomaticTimezone: false, manualTimezone: 'America/New_York', automaticTimezone: 'America/New_York'}));
expect(bottomSheet).toHaveProp('participantIds', ['user-1', 'user-2']);
});
@ -187,6 +191,7 @@ describe('ChecklistItemBottomSheet Enhanced Component', () => {
expect(bottomSheet).toBeTruthy();
expect(bottomSheet).toHaveProp('item', apiItem);
expect(bottomSheet).toHaveProp('assignee', expect.objectContaining({id: 'user-1'}));
expect(bottomSheet).toHaveProp('currentUserTimezone', expect.objectContaining({useAutomaticTimezone: false, manualTimezone: 'America/New_York', automaticTimezone: 'America/New_York'}));
expect(bottomSheet).toHaveProp('participantIds', []);
});

View file

@ -5,7 +5,7 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$, switchMap} from 'rxjs';
import {observeParticipantsIdsFromPlaybookModel, observePlaybookRunById} from '@playbooks/database/queries/run';
import {observeUser} from '@queries/servers/user';
import {observeCurrentUser, observeUser} from '@queries/servers/user';
import ChecklistItemBottomSheet, {BOTTOM_SHEET_HEIGHT} from './checklist_item_bottom_sheet';
@ -18,6 +18,7 @@ type OwnProps = {
} & WithDatabaseArgs;
const enhanced = withObservables(['item', 'runId'], ({item, runId, database}: OwnProps) => {
const currentUserTimezone = observeCurrentUser(database).pipe(switchMap((u) => of$(u?.timezone)));
if ('observe' in item) {
const observedItem = item.observe();
@ -35,6 +36,7 @@ const enhanced = withObservables(['item', 'runId'], ({item, runId, database}: Ow
return {
item: observedItem,
assignee,
currentUserTimezone,
participantIds: observePlaybookRunById(database, runId).pipe(
switchMap((run) => observeParticipantsIdsFromPlaybookModel(run, true)),
),
@ -46,6 +48,7 @@ const enhanced = withObservables(['item', 'runId'], ({item, runId, database}: Ow
return {
item: of$(item),
assignee,
currentUserTimezone,
participantIds: of$([]),
};
});

View file

@ -0,0 +1,157 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {act, renderWithEverything, waitFor} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import SelectDate from './select_date';
import SelectDateEnhanced from './index';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database} from '@nozbe/watermelondb';
import type {AvailableScreens} from '@typings/screens/navigation';
// Mock dependencies
jest.mock('./select_date', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(SelectDate).mockImplementation(
(props) => React.createElement('SelectDate', {testID: 'select_date', ...props}),
);
describe('SelectDate Enhanced Component', () => {
const serverUrl = 'server-url';
let database: Database;
let operator: ServerDataOperator;
function getBaseProps(): ComponentProps<typeof SelectDateEnhanced> {
return {
componentId: 'SelectDate' as AvailableScreens,
selectedDate: undefined,
onSave: jest.fn(),
};
}
async function addUserToDatabase(user: UserProfile) {
await operator.handleUsers({
prepareRecordsOnly: false,
users: [user],
});
}
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
operator = serverDatabaseAndOperator.operator;
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'currentUser'}], prepareRecordsOnly: false});
jest.clearAllMocks();
});
afterEach(() => {
DatabaseManager.destroyServerDatabase(serverUrl);
});
describe('with database integration', () => {
it('should render enhanced component with currentUserTimezone from database', async () => {
const user = TestHelper.fakeUser({
id: 'currentUser',
timezone: {
useAutomaticTimezone: false,
manualTimezone: 'America/New_York',
automaticTimezone: 'America/New_York',
},
});
await addUserToDatabase(user);
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<SelectDateEnhanced {...props}/>, {database});
const selectDate = getByTestId('select_date');
expect(selectDate).toBeTruthy();
expect(selectDate).toHaveProp('currentUserTimezone', expect.objectContaining({
useAutomaticTimezone: false,
manualTimezone: 'America/New_York',
automaticTimezone: 'America/New_York',
}));
});
it('should handle timezone changes through observable', async () => {
const user = TestHelper.fakeUser({
id: 'currentUser',
timezone: {
useAutomaticTimezone: false,
manualTimezone: 'America/New_York',
automaticTimezone: 'America/New_York',
},
});
await addUserToDatabase(user);
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<SelectDateEnhanced {...props}/>, {database});
const selectDate = getByTestId('select_date');
expect(selectDate).toHaveProp('currentUserTimezone', expect.objectContaining({
manualTimezone: 'America/New_York',
}));
// Update user timezone
await act(async () => {
database.write(async () => {
const userRecord = await database.get('User').find('currentUser');
userRecord.update((u) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(u as any).timezone = {
useAutomaticTimezone: true,
manualTimezone: '',
automaticTimezone: 'Europe/London',
};
});
});
});
await waitFor(() => {
expect(selectDate).toHaveProp('currentUserTimezone', expect.objectContaining({
useAutomaticTimezone: true,
manualTimezone: '',
automaticTimezone: 'Europe/London',
}));
});
});
it('should handle missing current user gracefully', async () => {
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<SelectDateEnhanced {...props}/>, {database});
const selectDate = getByTestId('select_date');
expect(selectDate).toBeTruthy();
expect(selectDate.props.currentUserTimezone).toBeUndefined();
});
it('should handle user without timezone gracefully', async () => {
const user = TestHelper.fakeUser({
id: 'currentUser',
timezone: undefined,
});
await addUserToDatabase(user);
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<SelectDateEnhanced {...props}/>, {database});
const selectDate = getByTestId('select_date');
expect(selectDate).toBeTruthy();
expect(selectDate.props.currentUserTimezone).toBeUndefined();
});
});
});

View file

@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeCurrentUser} from '@queries/servers/user';
import SelectDate from './select_date';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const currentUserTimezone = observeCurrentUser(database).pipe(switchMap((u) => of$(u?.timezone)));
return {
currentUserTimezone,
};
});
export default withDatabase(enhanced(SelectDate));

View file

@ -0,0 +1,254 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment';
import React, {type ComponentProps} from 'react';
import {Keyboard} from 'react-native';
import DateTimeSelector from '@components/data_time_selector';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {popTopScreen, setButtons} from '@screens/navigation';
import {act, fireEvent, renderWithIntlAndTheme} from '@test/intl-test-helper';
import SelectDate from './select_date';
import type {AvailableScreens} from '@typings/screens/navigation';
// Mock dependencies
jest.mock('@components/data_time_selector');
jest.mocked(DateTimeSelector).mockImplementation(
(props) => React.createElement('DateTimeSelector', {testID: 'date-time-selector', ...props}),
);
jest.mock('@hooks/navigation_button_pressed');
jest.mock('@hooks/android_back_handler');
describe('SelectDate', () => {
beforeEach(() => {
jest.clearAllMocks();
});
function getBaseProps(): ComponentProps<typeof SelectDate> {
const mockOnSave = jest.fn();
const mockComponentId: AvailableScreens = 'SelectDate';
const mockCurrentUserTimezone: UserTimezone = {
automaticTimezone: 'UTC',
manualTimezone: '',
useAutomaticTimezone: true,
};
return {
componentId: mockComponentId,
selectedDate: undefined,
currentUserTimezone: mockCurrentUserTimezone,
onSave: mockOnSave,
};
}
it('renders correctly with no selected date', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-09-11T17:40:18.086Z'));
const props = getBaseProps();
const {getByTestId, getByText} = renderWithIntlAndTheme(<SelectDate {...props}/>);
const dateTimeSelector = getByTestId('date-time-selector');
expect(dateTimeSelector).toBeTruthy();
expect(moment('2025-09-11T17:40:18.086Z').isSame(dateTimeSelector.props.initialDate)).toBeTruthy();
expect(dateTimeSelector).toHaveProp('timezone', 'UTC');
expect(dateTimeSelector).toHaveProp('minuteInterval', 5);
expect(dateTimeSelector).toHaveProp('handleChange', expect.any(Function));
expect(getByText('Clear')).toBeTruthy();
expect(getByText('None')).toBeTruthy();
jest.useRealTimers();
});
it('renders correctly with selected date', () => {
const selectedDate = 1699705458086;
const props = {
...getBaseProps(),
selectedDate,
};
const {getByTestId, getByText} = renderWithIntlAndTheme(<SelectDate {...props}/>);
const dateTimeSelector = getByTestId('date-time-selector');
expect(dateTimeSelector).toBeTruthy();
expect(moment(selectedDate).isSame(dateTimeSelector.props.initialDate)).toBeTruthy();
expect(dateTimeSelector).toHaveProp('timezone', 'UTC');
expect(dateTimeSelector).toHaveProp('minuteInterval', 5);
expect(dateTimeSelector).toHaveProp('handleChange', expect.any(Function));
expect(getByText('Clear')).toBeTruthy();
expect(getByText('Saturday, November 11 at 12:24 PM')).toBeTruthy();
});
it('sets up navigation buttons correctly', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<SelectDate {...props}/>);
expect(setButtons).toHaveBeenCalledWith(props.componentId, {
rightButtons: [expect.objectContaining({
id: 'save-due-date',
text: 'Save',
enabled: false,
showAsAction: 'always',
color: '#ffffff',
})],
});
});
it('sets up navigation button pressed handler', async () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<SelectDate {...props}/>);
jest.clearAllMocks();
// Change the date to enable the save button
const dateTimeSelector = getByTestId('date-time-selector');
await act(async () => {
dateTimeSelector.props.handleChange(moment(1234567890));
});
expect(useNavButtonPressed).toHaveBeenCalledWith(
'save-due-date',
props.componentId,
expect.any(Function),
[expect.any(Function)],
);
const saveHandler = jest.mocked(useNavButtonPressed).mock.calls[0][2];
saveHandler();
expect(props.onSave).toHaveBeenCalledWith(expect.any(Number));
expect(popTopScreen).toHaveBeenCalledWith(props.componentId);
});
it('sets up Android back handler', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<SelectDate {...props}/>);
expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith(
props.componentId,
expect.any(Function),
);
const closeHandler = jest.mocked(useAndroidHardwareBackHandler).mock.calls[0][1];
closeHandler();
expect(popTopScreen).toHaveBeenCalledWith(props.componentId);
expect(Keyboard.dismiss).toHaveBeenCalled();
});
describe('canSave logic', () => {
it('returns false when no date and no selectedDate', async () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<SelectDate {...props}/>);
// Simulate no date selected
const dateTimeSelector = getByTestId('date-time-selector');
await act(async () => {
dateTimeSelector.props.handleChange(undefined);
});
expect(setButtons).toHaveBeenCalledWith(props.componentId, {
rightButtons: [expect.objectContaining({
enabled: false,
})],
});
});
it('returns true when date changes from undefined to defined', async () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<SelectDate {...props}/>);
// Simulate date selection
const newDate = moment();
const dateTimeSelector = getByTestId('date-time-selector');
await act(async () => {
dateTimeSelector.props.handleChange(newDate);
});
expect(setButtons).toHaveBeenCalledWith(props.componentId, {
rightButtons: [expect.objectContaining({
enabled: true,
})],
});
});
it('returns true when date changes from defined to undefined', async () => {
const selectedDate = Date.now();
const props = getBaseProps();
props.selectedDate = selectedDate;
const {getByTestId} = renderWithIntlAndTheme(<SelectDate {...props}/>);
// Simulate clearing date
const dateTimeSelector = getByTestId('date-time-selector');
await act(async () => {
dateTimeSelector.props.handleChange(undefined);
});
expect(setButtons).toHaveBeenCalledWith(props.componentId, {
rightButtons: [expect.objectContaining({
enabled: true,
})],
});
});
it('returns true when date is different from selectedDate', async () => {
const selectedDate = Date.now();
const props = getBaseProps();
props.selectedDate = selectedDate;
const {getByTestId} = renderWithIntlAndTheme(<SelectDate {...props}/>);
// Simulate different date selection
const newDate = moment(selectedDate).add(1, 'day');
const dateTimeSelector = getByTestId('date-time-selector');
await act(async () => {
dateTimeSelector.props.handleChange(newDate);
});
expect(setButtons).toHaveBeenCalledWith(props.componentId, {
rightButtons: [expect.objectContaining({
enabled: true,
})],
});
});
it('returns false when date is same as selectedDate', async () => {
const selectedDate = Date.now();
const props = getBaseProps();
props.selectedDate = selectedDate;
const {getByTestId} = renderWithIntlAndTheme(<SelectDate {...props}/>);
// Simulate same date selection
const sameDate = moment(selectedDate);
const dateTimeSelector = getByTestId('date-time-selector');
await act(async () => {
dateTimeSelector.props.handleChange(sameDate);
});
expect(setButtons).toHaveBeenCalledWith(props.componentId, {
rightButtons: [expect.objectContaining({
enabled: false,
})],
});
});
});
describe('handleClear', () => {
it('clears the selected date when Clear button is pressed', () => {
const selectedDate = Date.now();
const props = getBaseProps();
props.selectedDate = selectedDate;
const {getByText} = renderWithIntlAndTheme(<SelectDate {...props}/>);
const clearButton = getByText('Clear');
act(() => {
fireEvent.press(clearButton);
});
expect(getByText('None')).toBeTruthy();
});
});
});

View file

@ -0,0 +1,137 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment, {type Moment} from 'moment';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Text, View} from 'react-native';
import Button from '@components/button';
import DateTimeSelector from '@components/data_time_selector';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {getDueDateString} from '@playbooks/utils/time';
import {buildNavigationButton, popTopScreen, setButtons} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {getTimezone} from '@utils/user';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
componentId: AvailableScreens;
selectedDate?: number;
currentUserTimezone: UserTimezone | null | undefined;
onSave: (date: number | undefined) => void;
}
const SAVE_BUTTON_ID = 'save-due-date';
function close (componentId: AvailableScreens) {
Keyboard.dismiss();
popTopScreen(componentId);
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
container: {
flex: 1,
padding: 16,
gap: 8,
},
dateText: {
...typography('Body', 300, 'Regular'),
color: theme.centerChannelColor,
},
}));
export default function SelectDate({
componentId,
selectedDate,
currentUserTimezone,
onSave,
}: Props) {
const [date, setDate] = useState<Moment | undefined>(() => (selectedDate ? moment(selectedDate) : undefined));
const theme = useTheme();
const intl = useIntl();
const styles = getStyleSheet(theme);
const initialDate = useMemo(() => moment(selectedDate), [selectedDate]);
const canSave = useMemo(() => {
if (!date && !selectedDate) {
return false;
}
if ((date && !selectedDate) || (!date && selectedDate)) {
return true;
}
if (!date) {
return true;
}
return !date.isSame(selectedDate);
}, [date, selectedDate]);
const rightButton = useMemo(() => {
const base = buildNavigationButton(
SAVE_BUTTON_ID,
'playbooks.edit_due_date.save.button',
undefined,
intl.formatMessage({id: 'playbooks.edit_due_date.save.button', defaultMessage: 'Save'}),
);
base.enabled = canSave;
base.showAsAction = 'always';
base.color = theme.sidebarHeaderTextColor;
return base;
}, [intl, canSave, theme.sidebarHeaderTextColor]);
useEffect(() => {
setButtons(componentId, {
rightButtons: [rightButton],
});
}, [componentId, rightButton]);
const handleSave = useCallback(() => {
if (!canSave) {
return;
}
onSave(date?.valueOf());
close(componentId);
}, [canSave, date, onSave, componentId]);
const handleClear = useCallback(() => {
setDate(undefined);
}, []);
const handleClose = useCallback(() => {
close(componentId);
}, [componentId]);
useNavButtonPressed(SAVE_BUTTON_ID, componentId, handleSave, [handleSave]);
useAndroidHardwareBackHandler(componentId, handleClose);
const timezone = getTimezone(currentUserTimezone);
const dateText = getDueDateString(intl, date?.valueOf(), timezone, true);
return (
<View style={styles.container}>
<Text style={styles.dateText}>{dateText}</Text>
<Button
text={intl.formatMessage({id: 'playbooks.edit_due_date.clear.button', defaultMessage: 'Clear'})}
onPress={handleClear}
emphasis='link'
theme={theme}
size='lg'
/>
<DateTimeSelector
handleChange={setDate}
theme={theme}
initialDate={initialDate}
timezone={timezone}
minuteInterval={5}
/>
</View>
);
}

View file

@ -0,0 +1,98 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createIntl} from 'react-intl';
import {toMilliseconds} from '@utils/datetime';
import {getDueDateString} from './time';
describe('getDueDateString', () => {
const mockIntl = createIntl({
locale: 'en-US',
messages: {
'playbooks.due_date.none': 'None',
'playbooks.due_date.date_at_time': '{date} at {time}',
},
});
describe('when dueDate is undefined', () => {
it('should return "None" message', () => {
const result = getDueDateString(mockIntl, undefined, 'America/New_York');
expect(result).toBe('None');
});
});
describe('when dueDate is 0', () => {
it('should return "None" message', () => {
const result = getDueDateString(mockIntl, 0, 'America/New_York');
expect(result).toBe('None');
});
});
describe('when dueDate is valid and more than 1 day away', () => {
it('should return date only without time', () => {
const futureDate = Date.now() + toMilliseconds({days: 2});
const result = getDueDateString(mockIntl, futureDate, 'America/New_York');
// Should not contain "at" since time is not shown
expect(result).not.toContain(' at ');
expect(result).toMatch(/^\w+, \w+ \d+$/); // Format: "Monday, January 15"
});
});
describe('when dueDate is within 1 day', () => {
it('should return date with time', () => {
const nearDate = Date.now() + toMilliseconds({hours: 12});
const result = getDueDateString(mockIntl, nearDate, 'America/New_York');
expect(result).toContain(' at ');
expect(result).toMatch(/^\w+, \w+ \d+ at \d{2}:\d{2} [AP]M$/); // Format: "Monday, January 15 at 14:30"
});
it('should use the provided timezone for time formatting', () => {
const nearDate = Date.now() + toMilliseconds({hours: 12});
const resultNY = getDueDateString(mockIntl, nearDate, 'America/New_York');
const resultLA = getDueDateString(mockIntl, nearDate, 'America/Los_Angeles');
// Times should be different due to different timezones
expect(resultNY).not.toBe(resultLA);
});
});
describe('when showAlwaysTime is true', () => {
it('should always show time even for dates more than 1 day away', () => {
const futureDate = Date.now() + toMilliseconds({days: 2});
const result = getDueDateString(mockIntl, futureDate, 'America/New_York', true);
expect(result).toContain(' at ');
expect(result).toMatch(/^\w+, \w+ \d+ at \d{2}:\d{2} [AP]M$/);
});
it('should show time for dates within 1 day when showAlwaysTime is true', () => {
const nearDate = Date.now() + toMilliseconds({hours: 12});
const result = getDueDateString(mockIntl, nearDate, 'America/New_York', true);
expect(result).toContain(' at ');
expect(result).toMatch(/^\w+, \w+ \d+ at \d{2}:\d{2} [AP]M$/);
});
});
describe('edge cases', () => {
it('should handle dates in the past', () => {
const pastDate = Date.now() - toMilliseconds({hours: 12});
const result = getDueDateString(mockIntl, pastDate, 'America/New_York');
// Should show time since it's within 1 day (in the past)
expect(result).toContain(' at ');
});
it('should handle different timezone formats', () => {
const nearDate = Date.now() + toMilliseconds({hours: 12});
const resultUTC = getDueDateString(mockIntl, nearDate, 'UTC');
const resultEST = getDueDateString(mockIntl, nearDate, 'America/New_York');
expect(resultUTC).not.toBe(resultEST);
});
});
});

View file

@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {defineMessages, type IntlShape} from 'react-intl';
import {toMilliseconds} from '@utils/datetime';
const messages = defineMessages({
none: {
id: 'playbooks.due_date.none',
defaultMessage: 'None',
},
dateAtTime: {
id: 'playbooks.due_date.date_at_time',
defaultMessage: '{date} at {time}',
},
});
export function getDueDateString(intl: IntlShape, dueDate: number | undefined, timezone: string, showAlwaysTime: boolean = false) {
if (!dueDate) {
return intl.formatMessage(messages.none);
}
const dateObject = new Date(dueDate);
const dateString = dateObject.toLocaleDateString(intl.locale, {month: 'long', day: 'numeric', weekday: 'long'});
if (showAlwaysTime || Math.abs(dueDate - Date.now()) < toMilliseconds({days: 1})) {
const timeString = dateObject.toLocaleTimeString(intl.locale, {timeZone: timezone, hour: '2-digit', minute: '2-digit'});
return intl.formatMessage(messages.dateAtTime, {date: dateString, time: timeString});
}
return dateString;
}

View file

@ -13,7 +13,7 @@ import {removeRecentCustomStatus, updateCustomStatus, unsetCustomStatus} from '@
import CompassIcon from '@components/compass_icon';
import TabletTitle from '@components/tablet_title';
import {Events, Screens} from '@constants';
import {CustomStatusDurationEnum, SET_CUSTOM_STATUS_FAILURE} from '@constants/custom_status';
import {CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES, CustomStatusDurationEnum, SET_CUSTOM_STATUS_FAILURE} from '@constants/custom_status';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
@ -179,7 +179,7 @@ const CustomStatus = ({
return getUserCustomStatus(currentUser);
}, [currentUser]);
const initialStatus = useMemo(() => {
const [initialStatus] = useState(() => {
const userTimezone = getTimezone(currentUser?.timezone);
// May be a ref
@ -187,7 +187,7 @@ const CustomStatus = ({
const currentTime = getCurrentMomentForTimezone(userTimezone ?? '');
let initialCustomExpiryTime = getRoundedTime(currentTime);
let initialCustomExpiryTime = getRoundedTime(currentTime, CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES);
const isCurrentCustomStatusSet = !isCustomStatusExpired && (storedStatus?.text || storedStatus?.emoji);
if (isCurrentCustomStatusSet && storedStatus?.duration === 'date_and_time' && storedStatus?.expires_at) {
initialCustomExpiryTime = moment(storedStatus?.expires_at);
@ -199,7 +199,7 @@ const CustomStatus = ({
expiresAt: initialCustomExpiryTime,
text: isCurrentCustomStatusSet ? storedStatus?.text : '',
};
}, []);
});
const [newStatus, dispatchStatus] = useReducer(reducer, initialStatus);
@ -353,7 +353,7 @@ const CustomStatus = ({
}],
},
});
}, [isBtnEnabled]);
}, [componentId, intl, isBtnEnabled, theme.sidebarHeaderTextColor]);
return (
<View

View file

@ -10,7 +10,7 @@ import CompassIcon from '@components/compass_icon';
import CustomStatusExpiry from '@components/custom_status/custom_status_expiry';
import CustomStatusText from '@components/custom_status/custom_status_text';
import DateTimePicker from '@components/data_time_selector';
import {CST, CustomStatusDurationEnum} from '@constants/custom_status';
import {CST, CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES, CustomStatusDurationEnum} from '@constants/custom_status';
import {useTheme} from '@context/theme';
import {usePreventDoubleTap} from '@hooks/utils';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -136,6 +136,7 @@ const ClearAfterMenuItem = ({currentUser, duration, expiryTime = '', handleItemC
handleChange={handleCustomExpiresAtChange}
theme={theme}
timezone={getTimezone(currentUser?.timezone)}
minuteInterval={CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES}
/>
)}
</View>

View file

@ -207,12 +207,16 @@ describe('Helpers', () => {
describe('getRoundedTime', () => {
test('should round time to nearest interval', () => {
const time = moment('2024-06-01T12:34:00Z');
const result = getRoundedTime(time);
expect(result.minute() % 30).toBe(0); // Assuming CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES is 15
const result = getRoundedTime(time, 3);
expect(result.minute() % 3).toBe(0);
const time2 = moment('2024-06-01T12:00:00Z');
const result2 = getRoundedTime(time2);
expect(result2.minute() % 30).toBe(0);
const result2 = getRoundedTime(time2, 7);
expect(result2.minute() % 7).toBe(0);
const time3 = moment('2024-06-01T12:34:00Z');
const result3 = getRoundedTime(time3, 11);
expect(result3.minute() % 11).toBe(0);
});
});

View file

@ -5,7 +5,6 @@ import RNUtils, {type SplitViewResult} from '@mattermost/rnutils';
import moment, {type Moment} from 'moment-timezone';
import {Platform} from 'react-native';
import {CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES} from '@constants/custom_status';
import {STATUS_BAR_HEIGHT} from '@constants/view';
// isMinimumServerVersion will return true if currentVersion is equal to higher or than
@ -143,8 +142,7 @@ export function toTitleCase(str: string) {
return str.replace(/\w\S*/g, doTitleCase);
}
export function getRoundedTime(value: Moment) {
const roundedTo = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES;
export function getRoundedTime(value: Moment, roundedTo: number) {
const start = moment(value);
const diff = start.minute() % roundedTo;
if (diff === 0) {

View file

@ -993,17 +993,20 @@
"playbooks.checklist_item.check": "Check",
"playbooks.checklist_item.checked": "Checked",
"playbooks.checklist_item.command": "Command",
"playbooks.checklist_item.date_at_time": "{date} at {time}",
"playbooks.checklist_item.due_date": "Due date",
"playbooks.checklist_item.none": "None",
"playbooks.checklist_item.rerun_command": "Rerun command",
"playbooks.checklist_item.run_command": "Run command",
"playbooks.checklist_item.skip": "Skip",
"playbooks.checklist_item.skipped": "Skipped",
"playbooks.due_date.date_at_time": "{date} at {time}",
"playbooks.due_date.none": "None",
"playbooks.edit_command.label": "Command",
"playbooks.edit_command.placeholder": "Type a command here",
"playbooks.edit_command.save.button": "Save",
"playbooks.edit_command.title": "Slash command",
"playbooks.edit_due_date.clear.button": "Clear",
"playbooks.edit_due_date.save.button": "Save",
"playbooks.fetch_error.description": "You don't have permission to view this run, or it may no longer exist.",
"playbooks.fetch_error.OK": "Okay",
"playbooks.fetch_error.title": "Unable to open Run",
@ -1035,6 +1038,7 @@
"playbooks.runs.in_progress.description": "When a run starts in this channel, youll see it here.",
"playbooks.runs.in_progress.title": "No in progress runs",
"playbooks.runs.show_more": "Show More",
"playbooks.select_date.title": "Due date",
"playbooks.select_user.no_assignee": "No Assignee",
"playbooks.select_user.not_participants": "NOT PARTICIPATING",
"playbooks.select_user.participants": "RUN PARTICIPANTS",