diff --git a/app/components/data_time_selector/index.tsx b/app/components/data_time_selector/index.tsx index 0a196538d..d867210ce 100644 --- a/app/components/data_time_selector/index.tsx +++ b/app/components/data_time_selector/index.tsx @@ -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(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} /> )} diff --git a/app/products/playbooks/actions/local/checklist.test.ts b/app/products/playbooks/actions/local/checklist.test.ts index e8e49411f..8d7317fb1 100644 --- a/app/products/playbooks/actions/local/checklist.test.ts +++ b/app/products/playbooks/actions/local/checklist.test.ts @@ -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); + }); +}); diff --git a/app/products/playbooks/actions/local/checklist.ts b/app/products/playbooks/actions/local/checklist.ts index 4a8f38b95..185df8640 100644 --- a/app/products/playbooks/actions/local/checklist.ts +++ b/app/products/playbooks/actions/local/checklist.ts @@ -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}; + } +} diff --git a/app/products/playbooks/actions/remote/checklist.test.ts b/app/products/playbooks/actions/remote/checklist.test.ts index aa80775b4..2c0fad3b8 100644 --- a/app/products/playbooks/actions/remote/checklist.test.ts +++ b/app/products/playbooks/actions/remote/checklist.test.ts @@ -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); + }); + }); }); diff --git a/app/products/playbooks/actions/remote/checklist.ts b/app/products/playbooks/actions/remote/checklist.ts index 3d7bac71f..8cce9664f 100644 --- a/app/products/playbooks/actions/remote/checklist.ts +++ b/app/products/playbooks/actions/remote/checklist.ts @@ -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}; + } +}; diff --git a/app/products/playbooks/client/rest.test.ts b/app/products/playbooks/client/rest.test.ts index 94f7ae605..db1b46b30 100644 --- a/app/products/playbooks/client/rest.test.ts +++ b/app/products/playbooks/client/rest.test.ts @@ -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'; diff --git a/app/products/playbooks/client/rest.ts b/app/products/playbooks/client/rest.ts index 63f9d41d2..ba6b5145c 100644 --- a/app/products/playbooks/client/rest.ts +++ b/app/products/playbooks/client/rest.ts @@ -20,6 +20,7 @@ export interface ClientPlaybooksMix { skipChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise; restoreChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise; setAssignee: (playbookRunId: string, checklistNum: number, itemNum: number, assigneeId?: string) => Promise; + setDueDate: (playbookRunId: string, checklistNum: number, itemNum: number, date?: number) => Promise; // skipChecklist: (playbookRunID: string, checklistNum: number) => Promise; @@ -120,6 +121,13 @@ const ClientPlaybooks = >(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( diff --git a/app/products/playbooks/constants/screens.ts b/app/products/playbooks/constants/screens.ts index 76d4603b5..5ff988c42 100644 --- a/app/products/playbooks/constants/screens.ts +++ b/app/products/playbooks/constants/screens.ts @@ -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, }; diff --git a/app/products/playbooks/screens/index.test.tsx b/app/products/playbooks/screens/index.test.tsx index 2b2d12fa4..42245cc1d 100644 --- a/app/products/playbooks/screens/index.test.tsx +++ b/app/products/playbooks/screens/index.test.tsx @@ -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) => {Screens.PLAYBOOK_SELECT_USER}); +jest.mock('@playbooks/screens/select_date', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(SelectDate).mockImplementation((props) => {Screens.PLAYBOOKS_SELECT_DATE}); + describe('Screen Registration', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/app/products/playbooks/screens/index.tsx b/app/products/playbooks/screens/index.tsx index 576565267..f67cf336c 100644 --- a/app/products/playbooks/screens/index.tsx +++ b/app/products/playbooks/screens/index.tsx @@ -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; } diff --git a/app/products/playbooks/screens/navigation.test.ts b/app/products/playbooks/screens/navigation.test.ts index a155bf771..c22121727 100644 --- a/app/products/playbooks/screens/navigation.test.ts +++ b/app/products/playbooks/screens/navigation.test.ts @@ -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, + }, + {}, + ); + }); + }); }); diff --git a/app/products/playbooks/screens/navigation.ts b/app/products/playbooks/screens/navigation.ts index 6a92dcef0..2ed55dc8e 100644 --- a/app/products/playbooks/screens/navigation.ts +++ b/app/products/playbooks/screens/navigation.ts @@ -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, + }, {}); +} diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx index b261cf167..24c56df8d 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx @@ -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(); 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(); + let dueDateItem = getByTestId('checklist_item.due_date'); + expect(dueDateItem).toHaveProp('type', 'none'); + expect(dueDateItem).toHaveProp('action', undefined); + + props.isDisabled = false; + rerender(); + 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(); + 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(); + 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(); diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx index 963e3a511..f67ced663 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx @@ -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} /> { 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', []); }); diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.ts b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.ts index d8899ecd7..b226cc39b 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.ts +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.ts @@ -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$([]), }; }); diff --git a/app/products/playbooks/screens/select_date/index.test.tsx b/app/products/playbooks/screens/select_date/index.test.tsx new file mode 100644 index 000000000..9c5fdd8d5 --- /dev/null +++ b/app/products/playbooks/screens/select_date/index.test.tsx @@ -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 { + 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(, {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(, {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(, {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(, {database}); + + const selectDate = getByTestId('select_date'); + expect(selectDate).toBeTruthy(); + expect(selectDate.props.currentUserTimezone).toBeUndefined(); + }); + }); +}); diff --git a/app/products/playbooks/screens/select_date/index.ts b/app/products/playbooks/screens/select_date/index.ts new file mode 100644 index 000000000..f0eb5d093 --- /dev/null +++ b/app/products/playbooks/screens/select_date/index.ts @@ -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)); diff --git a/app/products/playbooks/screens/select_date/select_date.test.tsx b/app/products/playbooks/screens/select_date/select_date.test.tsx new file mode 100644 index 000000000..b87116458 --- /dev/null +++ b/app/products/playbooks/screens/select_date/select_date.test.tsx @@ -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 { + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + const clearButton = getByText('Clear'); + act(() => { + fireEvent.press(clearButton); + }); + + expect(getByText('None')).toBeTruthy(); + }); + }); +}); diff --git a/app/products/playbooks/screens/select_date/select_date.tsx b/app/products/playbooks/screens/select_date/select_date.tsx new file mode 100644 index 000000000..68c2c1575 --- /dev/null +++ b/app/products/playbooks/screens/select_date/select_date.tsx @@ -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(() => (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 ( + + {dateText} +