From 77e50cf1104a917ebddbb8873258c346741d791d Mon Sep 17 00:00:00 2001 From: Matthew Birtch Date: Mon, 16 Jun 2025 08:01:16 -0400 Subject: [PATCH] MM-61382 Date/Time Picker Input Fix and Refactor (#31330) --- .../custom_status_expiry_3_spec.ts | 12 +- .../custom_status_expiry_4_spec.ts | 2 +- .../channels/status/status_dnd_1_spec.js | 26 +-- .../channels/schedule_message_modal.ts | 6 +- .../scheduled_messages.spec.ts | 140 ++++++++---- .../date_time_input.test.tsx.snap | 9 - .../custom_status/custom_status.scss | 111 +-------- .../custom_status/custom_status_modal.tsx | 3 +- .../custom_status/date_time_input.test.tsx | 50 ----- .../components/date_picker/date_picker.scss | 21 +- .../components/date_picker/date_picker.tsx | 31 ++- .../date_time_picker_modal.tsx | 5 +- .../date_time_picker_modal/style.scss | 74 ------ .../datetime_input.test.tsx.snap | 76 +++++++ .../datetime_input/datetime_input.test.tsx | 210 ++++++++++++++++++ .../datetime_input.tsx} | 191 ++++++++-------- .../dnd_custom_time_picker_modal.scss | 101 --------- .../dnd_custom_time_picker_modal.tsx | 202 +++-------------- ...post_reminder_custom_time_picker_modal.tsx | 3 +- webapp/channels/src/i18n/en.json | 5 +- .../channels/src/sass/components/_inputs.scss | 97 ++++++++ 21 files changed, 674 insertions(+), 701 deletions(-) delete mode 100644 webapp/channels/src/components/custom_status/__snapshots__/date_time_input.test.tsx.snap delete mode 100644 webapp/channels/src/components/custom_status/date_time_input.test.tsx create mode 100644 webapp/channels/src/components/datetime_input/__snapshots__/datetime_input.test.tsx.snap create mode 100644 webapp/channels/src/components/datetime_input/datetime_input.test.tsx rename webapp/channels/src/components/{custom_status/date_time_input.tsx => datetime_input/datetime_input.tsx} (55%) diff --git a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_3_spec.ts b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_3_spec.ts index ede43e3f20..6dfef1d6b7 100644 --- a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_3_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_3_spec.ts @@ -118,17 +118,19 @@ describe('MM-T4065 Setting manual status clear time less than 7 days away', () = it('MM-T4065_7 should show selected time in the time input field', () => { // * Check that the timepicker menu is not present and click to open it - cy.get('#custom_status_modal .dateTime__time-menu #expiryTimeMenu').should('not.exist'); - cy.get('#custom_status_modal .dateTime__time-menu').click(); + cy.get('#expiryTimeMenu').should('not.exist'); + + // # Click the time button + cy.get('#custom_status_modal #time_button').click(); // * Check that the time picker menu is present - cy.get('#custom_status_modal .dateTime__time-menu #expiryTimeMenu').should('exist'); + cy.get('#expiryTimeMenu').should('exist'); // # Choose the last item in the time picker menu - cy.get('#custom_status_modal .dateTime__time-menu #expiryTimeMenu li').last().click(); + cy.get('#expiryTimeMenu li').last().click(); // * Check that the time input contains the correct time - cy.get('.dateTime__time-menu .dateTime__input time').should('have.text', '11:30 PM'); + cy.get('#custom_status_modal #time_button time').should('have.text', '11:30 PM'); }); it('MM-T4065_8 should set custom status when click on Set Status', () => { diff --git a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_4_spec.ts b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_4_spec.ts index 7307dc3709..bb3f0017c8 100644 --- a/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_4_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/custom_status/custom_status_expiry/custom_status_expiry_4_spec.ts @@ -112,7 +112,7 @@ describe('MM-T4066 Setting manual status clear time more than 7 days away', () = cy.get('.date-picker__popper').find(`.rdp-month button[aria-label="${dateToBeSelected.format('Do MMMM (dddd)')}"]`).click(); // * Check that the date input should have the correct value - cy.get('input#customStatus__calendar-input').should('have.value', dateToBeSelected.format('YYYY-MM-DD')); + cy.get('#custom_status_modal [role="button"][aria-label*="Date"]').should('contain.text', dateToBeSelected.format('MMM DD')); }); it('MM-T4066_7 should set custom status when click on Set Status', () => { diff --git a/e2e-tests/cypress/tests/integration/channels/status/status_dnd_1_spec.js b/e2e-tests/cypress/tests/integration/channels/status/status_dnd_1_spec.js index 610ac7d6dc..b9585a70e3 100644 --- a/e2e-tests/cypress/tests/integration/channels/status/status_dnd_1_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/status/status_dnd_1_spec.js @@ -20,8 +20,6 @@ describe('DND Status - Setting Your Own DND Status', () => { ]; before(() => { - cy.shouldHaveFeatureFlag('TimedDND', true); - // # Login as test user and visit channel cy.apiInitSetup({loginAfter: true}).then(({team, channel}) => { cy.visit(`/${team.name}/channels/${channel.name}`); @@ -77,13 +75,13 @@ describe('DND Status - Setting Your Own DND Status', () => { cy.get(`.SubMenuItemContainer li#${dndTimes[4]}`).click(); // # Click on DayPicker input field - cy.get('.DayPickerInput input').click(); + cy.get('.dateTime__date .date-time-input').click(); // * Verify that DayPicker overlay is visible - cy.get('.DayPickerInput-Overlay').should('be.visible'); + cy.get('.date-picker__popper').should('be.visible'); // # Click on tomorrow's day - cy.get('.DayPickerInput-Overlay').find('.DayPicker-Day--today').next('.DayPicker-Day').click(); + cy.get('.date-picker__popper').find('.rdp-day_today').next('.rdp-day').click(); // # Click 'Disable Notification' button cy.get('.DndModal__footer span').should('have.text', 'Disable Notifications').click(); @@ -105,13 +103,13 @@ describe('DND Status - Setting Your Own DND Status', () => { cy.get(`.SubMenuItemContainer li#${dndTimes[4]}`).click(); // # Click on time picker input field - cy.get('.MenuWrapper .DndModal__input').click(); + cy.get('.dateTime__time .date-time-input').click(); // * Verify that time picker menu is visible - cy.get('ul.Menu__content.dropdown-menu').should('be.visible'); + cy.get('#expiryTimeMenu').should('be.visible'); // # Click on last time available in list - cy.get('ul.Menu__content.dropdown-menu').last('.MenuItem').click(); + cy.get('#expiryTimeMenu li').last().click(); // # Click 'Disable Notification' button cy.get('.DndModal__footer span').should('have.text', 'Disable Notifications').click(); @@ -133,22 +131,22 @@ describe('DND Status - Setting Your Own DND Status', () => { cy.get(`.SubMenuItemContainer li#${dndTimes[4]}`).click(); // # Click on DayPicker input field - cy.get('.DayPickerInput input').click(); + cy.get('.dateTime__date .date-time-input').click(); // * Verify that DayPicker overlay is visible - cy.get('.DayPickerInput-Overlay').should('be.visible'); + cy.get('.date-picker__popper').should('be.visible'); // # Click on tomorrow's day - cy.get('.DayPickerInput-Overlay').find('.DayPicker-Day--today').next('.DayPicker-Day').click(); + cy.get('.date-picker__popper').find('.rdp-day_today').next('.rdp-day').click(); // # Click on time picker input field - cy.get('.MenuWrapper .DndModal__input').click(); + cy.get('.dateTime__time .date-time-input').click(); // * Verify that time picker menu is visible - cy.get('ul.Menu__content.dropdown-menu').should('be.visible'); + cy.get('#expiryTimeMenu').should('be.visible'); // # Click on last time available in list - cy.get('ul.Menu__content.dropdown-menu').last('.MenuItem').click(); + cy.get('#expiryTimeMenu li').last().click(); // # Click 'Disable Notification' button cy.get('.DndModal__footer span').should('have.text', 'Disable Notifications').click(); diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/schedule_message_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/schedule_message_modal.ts index 0933084cf7..aa986506c7 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/schedule_message_modal.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/schedule_message_modal.ts @@ -14,7 +14,7 @@ export default class ScheduleMessageModal { constructor(container: Locator) { this.container = container; - this.dateButton = container.locator('#customStatus__calendar-input'); + this.dateButton = container.getByRole('button', {name: /Date/}); this.timeButton = container.getByTestId('time_button'); this.timeOptionDropdown = container.getByLabel('Choose a time'); this.closeButton = container.getByRole('button', {name: 'Close'}); @@ -80,7 +80,7 @@ export default class ScheduleMessageModal { async selectTime(optionIndex: number = 0) { await this.timeButton.click(); - const timeButton = this.timeOptionDropdown.getByTestId(`time_option_${optionIndex}-button`); + const timeButton = this.container.page().getByTestId(`time_option_${optionIndex}`); await expect(timeButton).toBeVisible(); await timeButton.click(); @@ -91,7 +91,7 @@ export default class ScheduleMessageModal { await this.toBeVisible(); const selectedDate = await this.selectDate(dayFromToday); - const fromDateButton = await this.dateButton.inputValue(); + const fromDateButton = await this.dateButton.textContent(); const selectedTime = await this.selectTime(timeOptionIndex); await this.scheduleButton.click(); diff --git a/e2e-tests/playwright/specs/functional/channels/scheduled_messages/scheduled_messages.spec.ts b/e2e-tests/playwright/specs/functional/channels/scheduled_messages/scheduled_messages.spec.ts index 7a6595e961..bebc3d5842 100644 --- a/e2e-tests/playwright/specs/functional/channels/scheduled_messages/scheduled_messages.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/scheduled_messages/scheduled_messages.spec.ts @@ -36,8 +36,7 @@ test( const {selectedDate, selectedTime} = await channelsPage.scheduleMessage(draftMessage, 0, 1); // * Verify scheduled post indicator shows correct date and time - const indicatorMessage = `Message scheduled for ${selectedDate} at ${selectedTime}.`; - await verifyScheduledPostIndicator(channelsPage.centerView.scheduledPostIndicator, indicatorMessage); + await verifyScheduledPostIndicator(channelsPage.centerView.scheduledPostIndicator, selectedDate, selectedTime); // * Verify scheduled post badge in left sidebar shows count of 1 await verifyScheduledPostBadgeOnLeftSidebar(channelsPage, 1); @@ -46,8 +45,7 @@ test( await channelsPage.centerView.scheduledPostIndicator.seeAllLink.click(); // * Verify scheduled post appears with correct information - const sendOnMessage = `Send ${selectedDate} at ${selectedTime}`; - await verifyScheduledPost(scheduledPostsPage, {draftMessage, sendOnMessage, badgeCountOnTab: 1}); + await verifyScheduledPost(scheduledPostsPage, {draftMessage, selectedDate, selectedTime, badgeCountOnTab: 1}); // # Return to the channels page await page.goBack(); @@ -98,17 +96,16 @@ test( const {selectedDate, selectedTime} = await channelsPage.scheduleMessageFromThread(draftMessage, 1); // * Verify scheduled post indicator shows correct date and time - const indicatorMessage = `Message scheduled for ${selectedDate} at ${selectedTime}.`; - await verifyScheduledPostIndicator(sidebarRight.scheduledPostIndicator, indicatorMessage); + await verifyScheduledPostIndicator(sidebarRight.scheduledPostIndicator, selectedDate, selectedTime); // # Navigate to scheduled posts page using indicator link await sidebarRight.scheduledPostIndicator.seeAllLink.click(); // * Verify scheduled post appears with correct information - const sendOnMessage = `Send on ${selectedDate} at ${selectedTime}`; const scheduledPost = await verifyScheduledPost(scheduledPostsPage, { draftMessage, - sendOnMessage, + selectedDate, + selectedTime, badgeCountOnTab: 1, }); @@ -153,8 +150,7 @@ test( const {selectedDate, selectedTime} = await channelsPage.scheduleMessage(draftMessage, 1); // * Verify scheduled message indicator shows correct date and time - const indicatorMessage = `Message scheduled for ${selectedDate} at ${selectedTime}.`; - await verifyScheduledPostIndicator(channelsPage.centerView.scheduledPostIndicator, indicatorMessage); + await verifyScheduledPostIndicator(channelsPage.centerView.scheduledPostIndicator, selectedDate, selectedTime); // * Verify scheduled post badge appears with count of 1 await verifyScheduledPostBadgeOnLeftSidebar(channelsPage, 1); @@ -163,10 +159,10 @@ test( await channelsPage.centerView.scheduledPostIndicator.seeAllLink.click(); // * Verify scheduled post appears with correct information - const sendOnMessage = `Send on ${selectedDate} at ${selectedTime}`; const scheduledPost = await verifyScheduledPost(scheduledPostsPage, { draftMessage, - sendOnMessage, + selectedDate, + selectedTime, badgeCountOnTab: 1, }); @@ -178,8 +174,11 @@ test( await channelsPage.goto(); // * Verify indicator shows the updated scheduled time - const newIndicatorMessage = `Message scheduled for ${newSelectedDate} at ${newSelectedTime}.`; - await verifyScheduledPostIndicator(channelsPage.centerView.scheduledPostIndicator, newIndicatorMessage); + await verifyScheduledPostIndicator( + channelsPage.centerView.scheduledPostIndicator, + newSelectedDate, + newSelectedTime, + ); }, ); @@ -205,17 +204,16 @@ test( const {selectedDate, selectedTime} = await channelsPage.scheduleMessage(draftMessage, 1); // * Verify scheduled message indicator shows correct date and time - const indicatorMessage = `Message scheduled for ${selectedDate} at ${selectedTime}.`; - await verifyScheduledPostIndicator(channelsPage.centerView.scheduledPostIndicator, indicatorMessage); + await verifyScheduledPostIndicator(channelsPage.centerView.scheduledPostIndicator, selectedDate, selectedTime); // # Navigate to scheduled posts page via indicator link await channelsPage.centerView.scheduledPostIndicator.seeAllLink.click(); // * Verify scheduled post appears with correct information - const sendOnMessage = `Send on ${selectedDate} at ${selectedTime}`; const scheduledPost = await verifyScheduledPost(scheduledPostsPage, { draftMessage, - sendOnMessage, + selectedDate, + selectedTime, badgeCountOnTab: 1, }); @@ -256,17 +254,16 @@ test( const {selectedDate, selectedTime} = await channelsPage.scheduleMessage(draftMessage, 1); // * Verify scheduled message indicator shows correct date and time - const indicatorMessage = `Message scheduled for ${selectedDate} at ${selectedTime}.`; - await verifyScheduledPostIndicator(channelsPage.centerView.scheduledPostIndicator, indicatorMessage); + await verifyScheduledPostIndicator(channelsPage.centerView.scheduledPostIndicator, selectedDate, selectedTime); // # Navigate to scheduled posts page via indicator link await channelsPage.centerView.scheduledPostIndicator.seeAllLink.click(); // * Verify scheduled post appears with correct information - const sendOnMessage = `Send on ${selectedDate} at ${selectedTime}`; const scheduledPost = await verifyScheduledPost(scheduledPostsPage, { draftMessage, - sendOnMessage, + selectedDate, + selectedTime, badgeCountOnTab: 1, }); @@ -314,14 +311,20 @@ test( const {selectedDate, selectedTime} = await channelsPage.scheduleMessage(draftMessage, 1); // * Verify appropriate scheduled message indicator appears - let indicatorMessage; - if (pw.isOutsideRemoteUserHour(otherUser.timezone)) { - indicatorMessage = 'You have one scheduled message.'; - } else { - indicatorMessage = `Message scheduled for ${selectedDate} at ${selectedTime}.`; - } await channelsPage.centerView.scheduledPostIndicator.toBeVisible(); - await expect(channelsPage.centerView.scheduledPostIndicator.messageText).toContainText(indicatorMessage); + if (pw.isOutsideRemoteUserHour(otherUser.timezone)) { + // Special case for timezone - expect generic message + await expect(channelsPage.centerView.scheduledPostIndicator.messageText).toContainText( + 'You have one scheduled message.', + ); + } else { + // Normal case - verify the scheduled indicator + await verifyScheduledPostIndicator( + channelsPage.centerView.scheduledPostIndicator, + selectedDate, + selectedTime, + ); + } // # Navigate to scheduled posts page using appropriate link if (pw.isOutsideRemoteUserHour(otherUser.timezone)) { @@ -331,10 +334,10 @@ test( } // * Verify scheduled post appears with correct information - const sendOnMessage = `Send on ${selectedDate} at ${selectedTime}`; const scheduledPost = await verifyScheduledPost(scheduledPostsPage, { draftMessage, - sendOnMessage, + selectedDate, + selectedTime, badgeCountOnTab: 1, }); @@ -399,8 +402,7 @@ test( await scheduledPostsPage.goto(team.name); // * Verify scheduled post appears with correct information - const sendOnMessage = `Send on ${selectedDate} at ${selectedTime}`; - await verifyScheduledPost(scheduledPostsPage, {draftMessage, sendOnMessage, badgeCountOnTab: 1}); + await verifyScheduledPost(scheduledPostsPage, {draftMessage, selectedDate, selectedTime, badgeCountOnTab: 1}); }, ); @@ -426,8 +428,7 @@ test( const {selectedDate, selectedTime} = await channelsPage.scheduleMessage(draftMessage, 2); // * Verify scheduled message indicator shows correct date and time - const indicatorMessage = `Message scheduled for ${selectedDate} at ${selectedTime}.`; - await verifyScheduledPostIndicator(channelsPage.centerView.scheduledPostIndicator, indicatorMessage); + await verifyScheduledPostIndicator(channelsPage.centerView.scheduledPostIndicator, selectedDate, selectedTime); // * Verify scheduled post badge shows count of 1 await verifyScheduledPostBadgeOnLeftSidebar(channelsPage, 1); @@ -436,10 +437,10 @@ test( await channelsPage.centerView.scheduledPostIndicator.seeAllLink.click(); // * Verify scheduled post appears with correct information - const sendOnMessage = `Send on ${selectedDate} at ${selectedTime}`; const scheduledPost = await verifyScheduledPost(scheduledPostsPage, { draftMessage, - sendOnMessage, + selectedDate, + selectedTime, badgeCountOnTab: 1, }); @@ -454,7 +455,7 @@ test( await expect(scheduledPost.panelBody).toContainText(updatedText); // * Verify scheduled date/time remains unchanged - await expect(scheduledPost.panelHeader).toContainText(`Send on ${selectedDate} at ${selectedTime}`); + await expect(scheduledPost.panelHeader).toContainText(selectedTime); // # Send the edited message immediately await scheduledPost.hover(); @@ -506,10 +507,10 @@ test( await channelsPage.centerView.scheduledPostIndicator.seeAllLink.click(); // * Verify scheduled post appears with correct information - const sendOnMessage = `Send on ${selectedDate} at ${selectedTime}`; const scheduledPost = await verifyScheduledPost(scheduledPostsPage, { draftMessage, - sendOnMessage, + selectedDate, + selectedTime, badgeCountOnTab: 1, }); @@ -532,15 +533,36 @@ test( ); /** - * Verifies that the scheduled post indicator is visible and displays the correct date and time. - * - * @param scheduledPostIndicator - The ScheduledPostIndicator instance - * @param messageText - A post message + * Verifies that the scheduled post indicator shows the correct date and time. */ -async function verifyScheduledPostIndicator(scheduledPostIndicator: ScheduledPostIndicator, messageText: string) { +async function verifyScheduledPostIndicator( + scheduledPostIndicator: ScheduledPostIndicator, + selectedDate: string, + selectedTime: string | null, +) { await scheduledPostIndicator.toBeVisible(); await expect(scheduledPostIndicator.icon).toBeVisible(); - await expect(scheduledPostIndicator.messageText).toContainText(messageText); + + if (!selectedTime) { + throw new Error('selectedTime is required'); + } + + // Verify the indicator contains both the time and a valid date + const messageText = await scheduledPostIndicator.messageText.textContent(); + await expect(scheduledPostIndicator.messageText).toContainText(selectedTime); + const datePatterns = [ + selectedDate, // Original date + 'Today', + 'Tomorrow', + ]; + + const hasValidDate = datePatterns.some((pattern) => messageText?.toLowerCase().includes(pattern.toLowerCase())); + + if (!hasValidDate) { + throw new Error( + `Indicator text "${messageText}" does not contain any expected date pattern: ${datePatterns.join(', ')}`, + ); + } } async function verifyScheduledPostBadgeOnLeftSidebar(channelsPage: ChannelsPage, count: number) { @@ -552,9 +574,10 @@ async function verifyScheduledPost( scheduledPostsPage: ScheduledPostsPage, { draftMessage, - sendOnMessage, + selectedDate, + selectedTime, badgeCountOnTab, - }: {draftMessage: string; sendOnMessage: string; badgeCountOnTab: number}, + }: {draftMessage: string; selectedDate: string; selectedTime: string | null; badgeCountOnTab: number}, ) { // * Verify scheduled posts page is visible await scheduledPostsPage.toBeVisible(); @@ -566,7 +589,26 @@ async function verifyScheduledPost( const scheduledPost = await scheduledPostsPage.getLastPost(); await expect(scheduledPost.panelBody).toContainText(draftMessage); - await expect(scheduledPost.panelHeader).toContainText(sendOnMessage); + if (!selectedTime) { + throw new Error('selectedTime is required'); + } + + // Verify the header contains both the time and a valid date + const headerText = await scheduledPost.panelHeader.textContent(); + await expect(scheduledPost.panelHeader).toContainText(selectedTime); + const datePatterns = [ + selectedDate, // Original date + 'Today', + 'Tomorrow', + ]; + + const hasValidDate = datePatterns.some((pattern) => headerText?.toLowerCase().includes(pattern.toLowerCase())); + + if (!hasValidDate) { + throw new Error( + `Header "${headerText}" does not contain any expected date pattern: ${datePatterns.join(', ')}`, + ); + } return scheduledPost; } diff --git a/webapp/channels/src/components/custom_status/__snapshots__/date_time_input.test.tsx.snap b/webapp/channels/src/components/custom_status/__snapshots__/date_time_input.test.tsx.snap deleted file mode 100644 index e5edc492db..0000000000 --- a/webapp/channels/src/components/custom_status/__snapshots__/date_time_input.test.tsx.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/custom_status/date_time_input should match snapshot 1`] = ` - -`; diff --git a/webapp/channels/src/components/custom_status/custom_status.scss b/webapp/channels/src/components/custom_status/custom_status.scss index 3660d747f5..5c85a179de 100644 --- a/webapp/channels/src/components/custom_status/custom_status.scss +++ b/webapp/channels/src/components/custom_status/custom_status.scss @@ -33,46 +33,6 @@ } } } - - .dateTime__calendar-input { - border: 2px solid var(--button-bg); - font-size: 14px; - - &:hover { - border: 2px solid var(--button-bg); - cursor: pointer; - } - - &:focus-within { - box-shadow: 0; - } - - .Input_wrapper { - padding: 0 10px; - } - - button.dateTime__calendar-icon { - margin: 0 8px 0 0; - color: rgba(var(--center-channel-color-rgb), 0.64); - } - - input#customStatus__calendar-input { - &.form-control { - height: auto; - padding: 0; - border: 0; - font-weight: 400; - - &[readonly] { - background: transparent; - } - - &:hover { - cursor: pointer; - } - } - } - } } .GenericModal { @@ -283,7 +243,8 @@ span.emoticon[style]:hover { .statusExpiry { display: inline-block; - margin-top: 12px; + margin: 12px 0 24px 0; + font-size: 14px; &__menu { @@ -295,71 +256,3 @@ span.emoticon[style]:hover { } } } - -.dateTime { - display: flex; - margin: 24px 0 8px; - gap: 16px; - - & &__date { - position: relative; - width: 100%; - - &-icon { - position: absolute; - top: 11.3px; - left: 15px; - } - } - - & &__time { - position: relative; - width: 100%; - - .style--none { - width: 100%; - } - - #expiryTimeMenu .dropdown-menu { - overflow: auto; - min-width: 266px; - max-height: 268px; - } - - &-icon { - position: absolute; - top: 9.5px; - left: 15px; - } - - .dateTime__input { - display: flex; - align-items: center; - } - } - - & &__input { - width: 266px; - height: 40px; - padding: 10px 40px; - border: 2px solid var(--button-bg); - border-radius: 4px; - background: var(--center-channel-bg); - color: var(--center-channel-color); - cursor: pointer; - font-size: 14px; - text-align: left; - - &-title { - position: absolute; - bottom: 32px; - left: 12px; - padding: 0 4px; - background: var(--center-channel-bg); - color: rgba(var(--center-channel-color-rgb), 0.75); - font-size: 10px; - letter-spacing: 0.03em; - line-height: 16px; - } - } -} diff --git a/webapp/channels/src/components/custom_status/custom_status_modal.tsx b/webapp/channels/src/components/custom_status/custom_status_modal.tsx index bb6a113445..0cc4dec9d4 100644 --- a/webapp/channels/src/components/custom_status/custom_status_modal.tsx +++ b/webapp/channels/src/components/custom_status/custom_status_modal.tsx @@ -25,8 +25,8 @@ import {closeModal} from 'actions/views/modals'; import {makeGetCustomStatus, getRecentCustomStatuses, showStatusDropdownPulsatingDot, isCustomStatusExpired} from 'selectors/views/custom_status'; import CustomStatusSuggestion from 'components/custom_status/custom_status_suggestion'; -import DateTimeInput, {getRoundedTime} from 'components/custom_status/date_time_input'; import ExpiryMenu from 'components/custom_status/expiry_menu'; +import DateTimeInput, {getRoundedTime} from 'components/datetime_input/datetime_input'; import RenderEmoji from 'components/emoji/render_emoji'; import useEmojiPicker from 'components/emoji_picker/use_emoji_picker'; import QuickInput, {MaxLengthInput} from 'components/quick_input'; @@ -416,6 +416,7 @@ const CustomStatusModal: React.FC = (props: Props) => { handleChange={setCustomExpiryTime} timezone={timezone} setIsInteracting={setIsInteracting} + relativeDate={true} /> )} diff --git a/webapp/channels/src/components/custom_status/date_time_input.test.tsx b/webapp/channels/src/components/custom_status/date_time_input.test.tsx deleted file mode 100644 index 02426bdcf2..0000000000 --- a/webapp/channels/src/components/custom_status/date_time_input.test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {shallow} from 'enzyme'; -import moment from 'moment-timezone'; -import React from 'react'; -import {Provider} from 'react-redux'; - -import {General} from 'mattermost-redux/constants'; - -import * as i18Selectors from 'selectors/i18n'; - -import mockStore from 'tests/test_store'; - -import DateTimeInput, {getTimeInIntervals} from './date_time_input'; - -jest.mock('selectors/i18n'); - -describe('components/custom_status/date_time_input', () => { - const store = mockStore({}); - - (i18Selectors.getCurrentLocale as jest.Mock).mockReturnValue(General.DEFAULT_LOCALE); - const baseProps = { - time: moment('2021-05-03T14:53:39.127Z'), - handleChange: jest.fn(), - timezone: 'Australia/Sydney', - }; - - it('should match snapshot', () => { - const wrapper = shallow( - - - , - ); - expect(wrapper.dive()).toMatchSnapshot(); - }); - - it.each([ - ['2024-03-02T02:00:00+0100', 48], - ['2024-03-31T02:00:00+0100', 46], - ['2024-10-07T02:00:00+0100', 48], - ['2024-10-27T02:00:00+0100', 48], - ['2025-01-01T03:00:00+0200', 48], - ])('should not infinitely loop on DST', (time, expected) => { - const timezone = 'Europe/Paris'; - - const intervals = getTimeInIntervals(moment.tz(time, timezone).startOf('day')); - expect(intervals).toHaveLength(expected); - }); -}); diff --git a/webapp/channels/src/components/date_picker/date_picker.scss b/webapp/channels/src/components/date_picker/date_picker.scss index 9cacb5816f..2a8c4c7c17 100644 --- a/webapp/channels/src/components/date_picker/date_picker.scss +++ b/webapp/channels/src/components/date_picker/date_picker.scss @@ -1,16 +1,23 @@ -.date-picker__popper { - padding: 12px; - border: 1px solid rgba(var(--center-channel-color-rgb), 0.08); - border-radius: 4px; - margin: 0; - background: var(--center-channel-bg); - box-shadow: 0 8px 24px 0 rgba(0, 0, 0, 0.12); +.date-picker { + &__wrapper { + position: relative; + } + + &__popper { + padding: 16px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; + background: var(--center-channel-bg); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + } } +// React Day Picker styles .rdp { --rdp-accent-color: var(--button-bg); --rdp-background-color: rgba(var(--center-channel-color-rgb), 0.08); --rdp-cell-size: 36px; + margin: 0; .rdp-caption { padding: 4px; diff --git a/webapp/channels/src/components/date_picker/date_picker.tsx b/webapp/channels/src/components/date_picker/date_picker.tsx index cba26545bd..3ba61dbe7c 100644 --- a/webapp/channels/src/components/date_picker/date_picker.tsx +++ b/webapp/channels/src/components/date_picker/date_picker.tsx @@ -27,9 +27,12 @@ type Props = { isPopperOpen: boolean; locale: string; handlePopperOpenState: (isOpen: boolean) => void; + label?: string; + icon?: React.ReactNode; + value?: string; } -const DatePicker = ({children, datePickerProps, isPopperOpen, handlePopperOpenState, locale}: Props) => { +const DatePicker = ({children, datePickerProps, isPopperOpen, handlePopperOpenState, locale, label, icon, value}: Props) => { const [loadedLocales, setLoadedLocales] = useState>({}); const {x, y, strategy, context, refs: {setReference, setFloating}} = useFloating({ open: isPopperOpen, @@ -66,13 +69,34 @@ const DatePicker = ({children, datePickerProps, isPopperOpen, handlePopperOpenSt ); }, []); + const handleWrapperClick = useCallback((e: React.MouseEvent) => { + // Prevent click from bubbling up to parent elements + e.stopPropagation(); + + // Open the popper when clicking anywhere in the wrapper + handlePopperOpenState(true); + }, [handlePopperOpenState]); + return ( -
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handlePopperOpenState(true); + } + }} > - {children} + {label && {label}} + {icon && {icon}} + {value && {value}} + {!value && children}
{isPopperOpen && (
- )}
diff --git a/webapp/channels/src/components/date_time_picker_modal/date_time_picker_modal.tsx b/webapp/channels/src/components/date_time_picker_modal/date_time_picker_modal.tsx index 3ecac3e563..8be5d45f5f 100644 --- a/webapp/channels/src/components/date_time_picker_modal/date_time_picker_modal.tsx +++ b/webapp/channels/src/components/date_time_picker_modal/date_time_picker_modal.tsx @@ -10,7 +10,7 @@ import {GenericModal} from '@mattermost/components'; import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone'; -import DateTimeInput, {getRoundedTime} from 'components/custom_status/date_time_input'; +import DateTimeInput, {getRoundedTime} from 'components/datetime_input/datetime_input'; import Constants from 'utils/constants'; import {isKeyPressed} from 'utils/keyboard'; @@ -107,7 +107,8 @@ export default function DateTimePickerModal({ handleEnterKeyPress={handleEnterKeyPress} className={classnames('date-time-picker-modal', className)} compassDesign={true} - keyboardEscape={false} + keyboardEscape={true} + enforceFocus={false} cancelButtonText={cancelButtonText} autoCloseOnConfirmButton={false} errorText={errorText} diff --git a/webapp/channels/src/components/date_time_picker_modal/style.scss b/webapp/channels/src/components/date_time_picker_modal/style.scss index 0672bdd677..9da67ce76d 100644 --- a/webapp/channels/src/components/date_time_picker_modal/style.scss +++ b/webapp/channels/src/components/date_time_picker_modal/style.scss @@ -2,78 +2,4 @@ .modal-body { overflow: visible; } - - .dateTime__calendar-input { - width: 100%; - font-size: 14px; - - &:focus-within { - box-shadow: 0; - } - - .Input_wrapper { - padding: 0 10px; - } - - button.dateTime__calendar-icon { - margin: 0 8px 0 0; - color: rgba(var(--center-channel-color-rgb), 0.64); - } - - input#customStatus__calendar-input { - &.form-control { - height: auto; - padding: 0; - border: 0; - font-weight: 400; - - &[readonly] { - background: transparent; - } - - &:hover { - cursor: pointer; - } - } - } - } - - .dateTime__time-menu .dateTime__input, .dateTime__calendar-input { - height: 40px; - border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); - - &:hover, &.isOpen { - border-color: rgba(var(--center-channel-color-rgb), 0.16); - box-shadow: 0 0 0 2px var(--button-bg); - cursor: pointer; - } - - .Input_wrapper { - button:hover { - background: none; - } - } - } - - .dateTime__time-menu { - &.MenuWrapper--open { - .dateTime__input { - box-shadow: 0 0 0 2px var(--button-bg); - cursor: pointer; - } - } - - .dateTime__time-icon { - pointer-events: none; - } - } - - .dateTime { - width: 100%; - margin: unset; - - .dateTime__input { - width: 100%; - } - } } diff --git a/webapp/channels/src/components/datetime_input/__snapshots__/datetime_input.test.tsx.snap b/webapp/channels/src/components/datetime_input/__snapshots__/datetime_input.test.tsx.snap new file mode 100644 index 0000000000..9b40d1cc7c --- /dev/null +++ b/webapp/channels/src/components/datetime_input/__snapshots__/datetime_input.test.tsx.snap @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/datetime_input/DateTimeInput should match snapshot 1`] = ` +
+
+
+
+
+ + Date + + + + + + 6/8/2025 + +
+
+
+
+ +
+
+
+`; diff --git a/webapp/channels/src/components/datetime_input/datetime_input.test.tsx b/webapp/channels/src/components/datetime_input/datetime_input.test.tsx new file mode 100644 index 0000000000..565afd79ea --- /dev/null +++ b/webapp/channels/src/components/datetime_input/datetime_input.test.tsx @@ -0,0 +1,210 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent, act} from '@testing-library/react'; +import moment from 'moment-timezone'; +import React from 'react'; + +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; +import * as timezoneUtils from 'utils/timezone'; + +import DateTimeInput, {getTimeInIntervals} from './datetime_input'; + +// Mock timezone utilities +jest.mock('utils/timezone', () => ({ + getCurrentMomentForTimezone: jest.fn(), + isBeforeTime: jest.fn(), +})); + +const mockGetCurrentMomentForTimezone = timezoneUtils.getCurrentMomentForTimezone as jest.MockedFunction; +const mockIsBeforeTime = timezoneUtils.isBeforeTime as jest.MockedFunction; + +describe('components/datetime_input/DateTimeInput', () => { + const baseProps = { + time: moment('2025-06-08T12:09:00.000Z'), + handleChange: jest.fn(), + timezone: 'UTC', + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetCurrentMomentForTimezone.mockReturnValue(moment('2025-06-08T10:00:00.000Z')); + mockIsBeforeTime.mockReturnValue(false); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should match snapshot', () => { + const {container} = renderWithContext( + , + ); + + expect(container).toMatchSnapshot(); + }); + + test('should render date and time selectors', () => { + renderWithContext( + , + ); + + expect(screen.getByText('Date')).toBeInTheDocument(); + expect(screen.getByLabelText('Time')).toBeInTheDocument(); + }); + + test('should not infinitely loop on DST', () => { + const timezone = 'Europe/Paris'; + const time = '2024-03-31T02:00:00+0100'; + + const intervals = getTimeInIntervals(moment.tz(time, timezone).startOf('day')); + expect(intervals.length).toBeGreaterThan(0); + expect(intervals.length).toBeLessThan(100); // Reasonable upper bound + }); + + describe('user interactions', () => { + test('should call setIsInteracting when date picker opens', async () => { + const mockSetIsInteracting = jest.fn(); + const props = { + ...baseProps, + setIsInteracting: mockSetIsInteracting, + }; + + renderWithContext(); + + const dateButton = screen.getByText('Date').closest('.date-time-input'); + + await act(async () => { + fireEvent.click(dateButton!); + }); + + expect(mockSetIsInteracting).toHaveBeenCalledWith(true); + }); + + test('should call setIsInteracting when time menu opens', async () => { + const mockSetIsInteracting = jest.fn(); + const props = { + ...baseProps, + setIsInteracting: mockSetIsInteracting, + }; + + renderWithContext(); + + const timeButton = screen.getByLabelText('Time'); + + await act(async () => { + await userEvent.click(timeButton); + }); + + expect(mockSetIsInteracting).toHaveBeenCalledWith(true); + }); + + test('should close date picker on escape key', async () => { + const mockSetIsInteracting = jest.fn(); + const props = { + ...baseProps, + setIsInteracting: mockSetIsInteracting, + }; + + renderWithContext(); + + // Open date picker first + const dateButton = screen.getByText('Date').closest('.date-time-input'); + + await act(async () => { + fireEvent.click(dateButton!); + }); + + // Press escape key + await act(async () => { + fireEvent.keyDown(document, {key: 'Escape', code: 'Escape'}); + }); + + expect(mockSetIsInteracting).toHaveBeenCalledWith(false); + }); + }); + + describe('date selection', () => { + test('should handle day selection for today with time adjustment', async () => { + mockGetCurrentMomentForTimezone.mockReturnValue(moment('2025-06-08T08:00:00.000Z')); + mockIsBeforeTime.mockReturnValue(true); + + renderWithContext(); + + const dateButton = screen.getByText('Date').closest('.date-time-input'); + + await act(async () => { + fireEvent.click(dateButton!); + }); + + // Simulate clicking on today's date + const todayButton = screen.getByText('8'); // June 8th + + await act(async () => { + fireEvent.click(todayButton); + }); + + expect(baseProps.handleChange).toHaveBeenCalled(); + }); + + test('should handle day selection for future date', async () => { + mockGetCurrentMomentForTimezone.mockReturnValue(moment('2025-06-08T08:00:00.000Z')); + + renderWithContext(); + + const dateButton = screen.getByText('Date').closest('.date-time-input'); + + await act(async () => { + fireEvent.click(dateButton!); + }); + + // Simulate clicking on a future date + const futureButton = screen.getByText('15'); // June 15th + + await act(async () => { + fireEvent.click(futureButton); + }); + + expect(baseProps.handleChange).toHaveBeenCalled(); + }); + }); + + describe('timezone handling', () => { + test('should handle timezone prop', () => { + const props = { + ...baseProps, + timezone: 'America/New_York', + }; + + renderWithContext(); + + expect(mockGetCurrentMomentForTimezone).toHaveBeenCalledWith('America/New_York'); + }); + }); + + describe('custom configuration', () => { + test('should accept custom time picker interval', () => { + const props = { + ...baseProps, + timePickerInterval: 15, + }; + + renderWithContext(); + + // Component should render without errors with custom interval + expect(screen.getByLabelText('Time')).toBeInTheDocument(); + }); + + test('should handle relative date formatting', () => { + const props = { + ...baseProps, + relativeDate: true, + }; + + renderWithContext(); + + // Component should render without errors with relative formatting + expect(screen.getByText('Date')).toBeInTheDocument(); + }); + }); +}); diff --git a/webapp/channels/src/components/custom_status/date_time_input.tsx b/webapp/channels/src/components/datetime_input/datetime_input.tsx similarity index 55% rename from webapp/channels/src/components/custom_status/date_time_input.tsx rename to webapp/channels/src/components/datetime_input/datetime_input.tsx index 94b7c5603e..d65275b3e0 100644 --- a/webapp/channels/src/components/custom_status/date_time_input.tsx +++ b/webapp/channels/src/components/datetime_input/datetime_input.tsx @@ -1,7 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import classNames from 'classnames'; import {DateTime} from 'luxon'; import type {Moment} from 'moment-timezone'; import moment from 'moment-timezone'; @@ -10,21 +9,16 @@ import type {DayModifiers, DayPickerProps} from 'react-day-picker'; import {useIntl} from 'react-intl'; import {useSelector} from 'react-redux'; -import IconButton from '@mattermost/compass-components/components/icon-button'; // eslint-disable-line no-restricted-imports - import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentLocale} from 'selectors/i18n'; import CompassThemeProvider from 'components/compass_theme_provider/compass_theme_provider'; import DatePicker from 'components/date_picker'; +import * as Menu from 'components/menu'; import Timestamp from 'components/timestamp'; -import Input from 'components/widgets/inputs/input/input'; -import Menu from 'components/widgets/menu/menu'; -import MenuWrapper from 'components/widgets/menu/menu_wrapper'; -import type {A11yFocusEventDetail} from 'utils/constants'; -import Constants, {A11yCustomEventTypes} from 'utils/constants'; +import Constants from 'utils/constants'; import {relativeFormatDate} from 'utils/datetime'; import {isKeyPressed} from 'utils/keyboard'; import {getCurrentMomentForTimezone, isBeforeTime} from 'utils/timezone'; @@ -80,20 +74,43 @@ const DateTimeInputContainer: React.FC = ({ const locale = useSelector(getCurrentLocale); const [timeOptions, setTimeOptions] = useState([]); const [isPopperOpen, setIsPopperOpen] = useState(false); + const [isTimeMenuOpen, setIsTimeMenuOpen] = useState(false); + const [menuWidth, setMenuWidth] = useState('200px'); const {formatMessage} = useIntl(); - const timeButtonRef = useRef(null); const theme = useSelector(getTheme); + const timeContainerRef = useRef(null); const handlePopperOpenState = useCallback((isOpen: boolean) => { setIsPopperOpen(isOpen); setIsInteracting?.(isOpen); }, [setIsInteracting]); - const handleKeyDown = useCallback((event: KeyboardEvent) => { - if (isKeyPressed(event, Constants.KeyCodes.ESCAPE) && isPopperOpen) { - handlePopperOpenState(false); + const handleTimeMenuToggle = useCallback((isOpen: boolean) => { + setIsTimeMenuOpen(isOpen); + setIsInteracting?.(isOpen); + + // Measure and set menu width when opening + if (isOpen && timeContainerRef.current) { + const button = timeContainerRef.current.querySelector('button'); + if (button) { + const buttonWidth = button.getBoundingClientRect().width; + setMenuWidth(`${Math.max(buttonWidth, 200)}px`); // Ensure minimum width of 200px + } } - }, [isPopperOpen, handlePopperOpenState]); + }, [setIsInteracting]); + + const handleTimeChange = useCallback((time: Date) => { + handleChange(timezone ? moment.tz(time, timezone) : moment(time)); + }, [handleChange, timezone]); + + const handleKeyDown = useCallback((event: KeyboardEvent) => { + // Handle escape key for date picker when time menu is not open + if (isKeyPressed(event, Constants.KeyCodes.ESCAPE)) { + if (isPopperOpen && !isTimeMenuOpen) { + handlePopperOpenState(false); + } + } + }, [isPopperOpen, isTimeMenuOpen, handlePopperOpenState]); useEffect(() => { document.addEventListener('keydown', handleKeyDown); @@ -131,37 +148,18 @@ const DateTimeInputContainer: React.FC = ({ handlePopperOpenState(false); }; - const handleTimeChange = useCallback((time: Date, e: React.MouseEvent) => { - e.preventDefault(); - handleChange(timezone ? moment.tz(time, timezone) : moment(time)); - focusTimeButton(); - }, [handleChange]); - const currentTime = getCurrentMomentForTimezone(timezone).toDate(); - const focusTimeButton = useCallback(() => { - document.dispatchEvent(new CustomEvent( - A11yCustomEventTypes.FOCUS, { - detail: { - target: timeButtonRef.current, - keyboardOnly: true, - }, - }, - )); - }, []); - const formatDate = (date: Moment): string => { return relativeDate ? relativeFormatDate(date, formatMessage) : DateTime.fromJSDate(date.toDate()).toLocaleString(); }; - const inputIcon = ( - handlePopperOpenState(true)} - icon={'calendar-outline'} - className='dateTime__calendar-icon' - size={'sm'} - aria-haspopup='grid' - /> + const calendarIcon = ( + + ); + + const clockIcon = ( + ); const datePickerProps: DayPickerProps = { @@ -185,65 +183,72 @@ const DateTimeInputContainer: React.FC = ({ handlePopperOpenState={handlePopperOpenState} locale={locale} datePickerProps={datePickerProps} + label={formatMessage({ + id: 'datetime.date', + defaultMessage: 'Date', + })} + icon={calendarIcon} + value={formatDate(time)} > - handlePopperOpenState(true)} - tabIndex={-1} - inputPrefix={inputIcon} - /> + <> -
- + + {formatMessage({ + id: 'datetime.time', + defaultMessage: 'Time', + })} + {clockIcon} + + + + + ), + }} + menu={{ + id: 'expiryTimeMenu', + 'aria-label': formatMessage({id: 'time_dropdown.choose_time', defaultMessage: 'Choose a time'}), + onToggle: handleTimeMenuToggle, + width: menuWidth, + className: 'time-menu-scrollable', + }} > - - - - {Array.isArray(timeOptions) && timeOptions.map((option, index) => ( - - } - /> - ))} - - - + {timeOptions.map((option, index) => ( + + + + } + onClick={() => handleTimeChange(option)} + /> + ))} +
diff --git a/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.scss b/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.scss index 520bc450dc..f87422cf5b 100644 --- a/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.scss +++ b/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.scss @@ -44,104 +44,3 @@ max-height: 268px; } } - -.DndModal__input { - position: relative; - display: flex; - width: 100%; - height: 40px; - align-items: center; - padding: 0 16px; - border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); - border-radius: 4px; - background: rgba(var(--center-channel-bg-rgb), 1); - cursor: pointer; - text-align: left; - - .MenuWrapper--open & { - border-color: var(--button-bg); - box-shadow: inset 0 0 0 1px var(--button-bg); - } - - .icon { - margin: 0 8px 0 0; - } - - input { - position: absolute; - top: 0; - left: 0; - display: flex; - width: 100%; - height: 100%; - align-items: center; - padding: 0 0 2px 40px; - border: none; - border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); - border-radius: 4px; - background: transparent; - cursor: pointer; - font-size: inherit; - - &:focus { - border-color: var(--button-bg); - box-shadow: inset 0 0 0 1px var(--button-bg); - } - } -} - -.DndModal__input--no-border { - border: none; -} - -.DndModal__input__label { - position: absolute; - z-index: 1; - top: -11px; - left: 0; - padding: 0 4px; - margin: 0 14px; - background: rgba(var(--center-channel-bg-rgb), 1); - color: rgba(var(--center-channel-color-rgb), 0.75); - font-size: 10px; -} - -.DndModal__calendar-input { - height: 40px; - - &:hover { - cursor: pointer; - } - - &.popper-open { - border-color: var(--button-bg); - box-shadow: inset 0 0 0 1px var(--button-bg); - color: var(--button-bg); - } - - .Input_wrapper { - padding-left: 8px; - - button.dateTime__calendar-icon { - margin: 0 8px 0 0; - color: rgba(var(--center-channel-color-rgb), 0.64); - - &:active { - background-color: transparent; - } - } - - input#DndModal__calendar-input { - height: auto; - border: 0; - - &[readonly] { - background: transparent; - } - - &:hover { - cursor: pointer; - } - } - } -} diff --git a/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.tsx b/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.tsx index 92f9e06db3..db1a169949 100644 --- a/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.tsx +++ b/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.tsx @@ -1,27 +1,20 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import classNames from 'classnames'; -import {DateTime} from 'luxon'; +import moment from 'moment'; import React from 'react'; -import type {DayPickerProps} from 'react-day-picker'; -import {defineMessage, FormattedMessage} from 'react-intl'; +import {FormattedMessage, injectIntl, type WrappedComponentProps} from 'react-intl'; -import IconButton from '@mattermost/compass-components/components/icon-button'; // eslint-disable-line no-restricted-imports import {GenericModal} from '@mattermost/components'; import type {UserStatus} from '@mattermost/types/users'; import type {Theme} from 'mattermost-redux/selectors/entities/preferences'; import CompassThemeProvider from 'components/compass_theme_provider/compass_theme_provider'; -import DatePicker from 'components/date_picker'; -import Input from 'components/widgets/inputs/input/input'; -import Menu from 'components/widgets/menu/menu'; -import MenuWrapper from 'components/widgets/menu/menu_wrapper'; +import DateTimeInput from 'components/datetime_input/datetime_input'; -import Constants, {A11yCustomEventTypes, UserStatuses} from 'utils/constants'; -import type {A11yFocusEventDetail} from 'utils/constants'; -import {toUTCUnixInSeconds} from 'utils/datetime'; +import Constants, {UserStatuses} from 'utils/constants'; +import {toUTCUnixInSeconds, relativeFormatDate} from 'utils/datetime'; import {isKeyPressed} from 'utils/keyboard'; import {localizeMessage} from 'utils/utils'; @@ -37,36 +30,26 @@ type Props = { actions: { setStatus: (status: UserStatus) => void; }; -}; +} & WrappedComponentProps; type State = { - selectedDate: Date; - selectedTime: string; - timeMenuList: string[]; - dayPickerStartDate: Date; - isPopperOpen: boolean; - popperElement: HTMLDivElement | null; + selectedDateTime: moment.Moment; } -export default class DndCustomTimePicker extends React.PureComponent { - private buttonRef = React.createRef(); +export default injectIntl(class DndCustomTimePicker extends React.PureComponent { constructor(props: Props) { super(props); const {currentDate} = this.props; - const selectedDate: Date = new Date(currentDate); + let selectedDateTime = moment(currentDate); // if current time is > 23:20 then we will set date to tomorrow and show all times if (currentDate.getHours() === 23 && currentDate.getMinutes() > 20) { - selectedDate.setDate(currentDate.getDate() + 1); + selectedDateTime = selectedDateTime.add(1, 'day').startOf('day').add(9, 'hours'); } this.state = { - selectedDate, - dayPickerStartDate: selectedDate, - ...this.makeTimeMenuList(selectedDate), - isPopperOpen: false, - popperElement: null, + selectedDateTime, }; } @@ -80,16 +63,12 @@ export default class DndCustomTimePicker extends React.PureComponent { if (isKeyPressed(event, Constants.KeyCodes.ESCAPE)) { - if (this.state.isPopperOpen) { - this.handlePopperOpenState(false); - } else { - this.props.onExited(); - } + this.props.onExited(); } }; - formatDate = (date: Date): string => { - return DateTime.fromJSDate(date).toFormat('yyyy-MM-dd'); + formatDate = (date: moment.Moment): string => { + return relativeFormatDate(date, this.props.intl.formatMessage); }; getText = () => { @@ -113,13 +92,7 @@ export default class DndCustomTimePicker extends React.PureComponent { - if (this.state.isPopperOpen) { - return; - } - const hours = parseInt(this.state.selectedTime.split(':')[0], 10); - const minutes = parseInt(this.state.selectedTime.split(':')[1], 10); - const endTime = new Date(this.state.selectedDate); - endTime.setHours(hours, minutes); + const endTime = this.state.selectedDateTime.toDate(); if (endTime < new Date()) { return; } @@ -133,49 +106,9 @@ export default class DndCustomTimePicker extends React.PureComponent { + handleDateTimeChange = (newDateTime: moment.Moment) => { this.setState({ - isPopperOpen: false, - selectedDate: day, - ...this.makeTimeMenuList(day), - }); - }; - - makeTimeMenuList = (date: Date): {timeMenuList: string[]; selectedTime: string} => { - const timeMenuItems = []; - let h = 0; - let m = 0; - const curr = this.props.currentDate; - - if (this.formatDate(curr) === this.formatDate(date)) { - h = curr.getHours(); - m = curr.getMinutes(); - if (m > 20) { - h++; - m = 0; - } else { - m = 30; - } - } - - for (let i = h; i < 24; i++) { - for (let j = m / 30; j < 2; j++) { - const t = i.toString().padStart(2, '0') + ':' + (j * 30).toString().padStart(2, '0'); - timeMenuItems.push( - t, - ); - } - } - - return { - timeMenuList: timeMenuItems, - selectedTime: timeMenuItems[0], - }; - }; - - handlePopperOpenState = (isOpen: boolean) => { - this.setState({ - isPopperOpen: isOpen, + selectedDateTime: newDateTime, }); }; @@ -185,53 +118,7 @@ export default class DndCustomTimePicker extends React.PureComponent { - return ( - { - this.setState({ - selectedTime: time, - }); - document.dispatchEvent(new CustomEvent( - A11yCustomEventTypes.FOCUS, { - detail: { - target: this.buttonRef.current, - keyboardOnly: true, - }, - }, - )); - }} - > - {time} - - ); - }); - - const inputIcon = ( - this.handlePopperOpenState(true)} - className='dateTime__calendar-icon' - size={'sm'} - aria-haspopup='grid' - /> - ); - - const dayPickerProps: DayPickerProps = { - initialFocus: isPopperOpen, - mode: 'single', - selected: selectedDate, - onDayClick: this.handleDaySelection, - disabled: [{ - before: dayPickerStartDate, - }], - showOutsideDays: true, - }; + const {selectedDateTime} = this.state; return ( @@ -246,54 +133,19 @@ export default class DndCustomTimePicker extends React.PureComponent
- - this.handlePopperOpenState(true)} - tabIndex={-1} - inputPrefix={inputIcon} - /> - - - - - {timeMenuItems} - - +
); } -} +}); diff --git a/webapp/channels/src/components/post_reminder_custom_time_picker_modal/post_reminder_custom_time_picker_modal.tsx b/webapp/channels/src/components/post_reminder_custom_time_picker_modal/post_reminder_custom_time_picker_modal.tsx index 9ba8f8e748..1f752458e8 100644 --- a/webapp/channels/src/components/post_reminder_custom_time_picker_modal/post_reminder_custom_time_picker_modal.tsx +++ b/webapp/channels/src/components/post_reminder_custom_time_picker_modal/post_reminder_custom_time_picker_modal.tsx @@ -5,8 +5,8 @@ import type {Moment} from 'moment-timezone'; import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; -import {getRoundedTime} from 'components/custom_status/date_time_input'; import DateTimePickerModal from 'components/date_time_picker_modal/date_time_picker_modal'; +import {getRoundedTime} from 'components/datetime_input/datetime_input'; import {toUTCUnixInSeconds} from 'utils/datetime'; import {getCurrentMomentForTimezone} from 'utils/timezone'; @@ -43,6 +43,7 @@ function PostReminderCustomTimePicker({userId, timezone, onExited, postId, actio initialTime={initialReminderTime} onConfirm={handleConfirm} confirmButtonText={confirmButtonText} + relativeDate={true} /> ); } diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 86082e1dc3..30085f1562 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3829,7 +3829,6 @@ "custom_status.expiry_dropdown.thirty_minutes": "30 minutes", "custom_status.expiry_dropdown.this_week": "This week", "custom_status.expiry_dropdown.today": "Today", - "custom_status.expiry.time_picker.title": "Time", "custom_status.expiry.until": "Until {time}", "custom_status.modal_cancel": "Clear Status", "custom_status.modal_confirm": "Set Status", @@ -3845,6 +3844,8 @@ "date_separator.today": "Today", "date_separator.tomorrow": "Tomorrow", "date_separator.yesterday": "Yesterday", + "datetime.date": "Date", + "datetime.time": "Time", "datetime.today": "today", "datetime.yesterday": "yesterday", "deactivate_member_modal.deactivate": "Deactivate", @@ -3892,10 +3893,8 @@ "discard_changes_modal.leave": "Yes, Discard", "discard_changes_modal.message": "You have unsaved changes, are you sure you want to discard them?", "discard_changes_modal.title": "Discard Changes?", - "dnd_custom_time_picker_modal.date": "Date", "dnd_custom_time_picker_modal.defaultMsg": "Disable notifications until", "dnd_custom_time_picker_modal.submitButton": "Disable Notifications", - "dnd_custom_time_picker_modal.time": "Time", "drafts.actions.delete": "Delete draft", "drafts.actions.edit": "Edit draft", "drafts.actions.scheduled": "Schedule draft", diff --git a/webapp/channels/src/sass/components/_inputs.scss b/webapp/channels/src/sass/components/_inputs.scss index ca47e635a8..47cf099971 100644 --- a/webapp/channels/src/sass/components/_inputs.scss +++ b/webapp/channels/src/sass/components/_inputs.scss @@ -126,3 +126,100 @@ input::-webkit-file-upload-button { } } } + +.date-time-input { + position: relative; + display: flex; + min-height: 40px; + align-items: center; + padding: 8px 12px; + border: 1px solid var(--center-channel-color-24); + border-radius: 4px; + background-color: var(--center-channel-bg); + cursor: pointer; + transition: border-color 0.15s ease-in-out; + + &:hover { + border-color: var(--center-channel-color-40); + } + + &:focus, + &--open { + border-color: var(--button-bg); + box-shadow: 0 0 0 1px var(--button-bg); + outline: none; + } + + &__label { + position: absolute; + top: -8px; + left: 12px; + padding: 0 4px; + background: var(--center-channel-bg); + color: var(--center-channel-color-75); + font-size: 10px; + } + + &__icon { + flex-shrink: 0; + margin-right: 8px; + color: var(--center-channel-color-64); + + i { + font-size: 16px; + } + } + + &__value { + overflow: hidden; + color: var(--center-channel-color); + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +// Date Time layout +.dateTime { + display: flex; + margin: 0 0 8px 0; + gap: 24px; + + &__date { + flex: 1; + } + + &__time { + flex: 1; + + // Ensure Menu.Container and its button take full width + .date-time-input { + width: 100%; + } + } +} + +// Modal-specific dateTime layout +.modal { + .dateTime { + width: 100%; + + &__date, + &__time { + width: 100%; + } + + // Ensure all nested elements take full width + .date-time-input, + .date-picker__wrapper, + > div { + width: 100%; + } + } +} + +// Scrollable time menu +.time-menu-scrollable { + max-height: 300px; + overflow-y: auto; +}