From 033d297695747fcb86ba7fc9e2b15174f75264b6 Mon Sep 17 00:00:00 2001 From: BugIsGod <87571967+bugisthegod@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:39:46 +0000 Subject: [PATCH] feat: add visual time picker to DateTimePicker (#15057) (#17952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Added a visual time picker dropdown for DateTime fields, replacing the previous text input. Users can now select hours and minutes through an intuitive scrollable interface. (Fixes #15057 ## Changes - **New component**: Add a `TimePickerDropdown` - Visual picker with scrollable hour/minute columns - **Updated**: `DateTimePickerHeader` - Implemented time picker dropdown in `DateTimePickerHeader` and Move month/year picker to right side ## Snapshots image https://github.com/user-attachments/assets/daa5cba5-c86c-46aa-a634-0f5c04523af1 **If there is no enough place at right, auto-move month/year selector to the left side** Hi, @Bonapara I followed the Figma you shared to complete this feature. Could you please review it for me? Thanks a lot. --------- Co-authored-by: Lucas Bordeau --- .../DateTimeFieldInput.stories.tsx | 47 ++- .../field/input/components/DateTimeInput.tsx | 6 + .../date/components/DatePickerHeader.tsx | 21 +- .../date/components/DateTimePicker.tsx | 196 ++++++------ .../date/components/DateTimePickerHeader.tsx | 281 ++++++++++++++---- .../InternalDatePicker.stories.tsx | 42 ++- .../date/hooks/__tests__/useTimeInput.test.ts | 129 ++++++++ .../internal/date/hooks/useTimeInput.ts | 75 +++++ .../internal/date/utils/getTimeBlocks.ts | 4 +- .../useCloseAnyOpenDropdown.test.tsx | 66 ++++ .../dropdown/hooks/useCloseAnyOpenDropdown.ts | 19 +- .../useGoBackToPreviousDropdownFocusId.ts | 15 +- ...SetFocusedDropdownIdAndMemorizePrevious.ts | 12 +- .../previousDropdownFocusIdStackState.ts | 6 + .../states/previousDropdownFocusIdState.ts | 6 - .../display/icon/components/TablerIcons.ts | 1 + packages/twenty-ui/src/display/index.ts | 1 + 17 files changed, 731 insertions(+), 196 deletions(-) create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/__tests__/useTimeInput.test.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useTimeInput.ts create mode 100644 packages/twenty-front/src/modules/ui/layout/dropdown/states/previousDropdownFocusIdStackState.ts delete mode 100644 packages/twenty-front/src/modules/ui/layout/dropdown/states/previousDropdownFocusIdState.ts diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/__stories__/DateTimeFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/__stories__/DateTimeFieldInput.stories.tsx index 86be3cf205..32502cb95d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/__stories__/DateTimeFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/__stories__/DateTimeFieldInput.stories.tsx @@ -135,8 +135,17 @@ type Story = StoryObj; export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + const calendarButton = await canvas.findByRole( + 'button', + { name: 'Select month and year' }, + { timeout: 10000 }, + ); + await userEvent.click(calendarButton); + // Increased timeout to account for lazy-loaded react-datepicker on slower CI runners - const div = await canvas.findByText('January', {}, { timeout: 10000 }); + const div = await body.findByText('January', {}, { timeout: 10000 }); await expect(div.innerText).toContain('January'); }, @@ -145,13 +154,23 @@ export const Default: Story = { export const ClickOutside: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); await expect(handleClickoutsideMocked).toHaveBeenCalledTimes(0); + const calendarButton = await canvas.findByRole( + 'button', + { name: 'Select month and year' }, + { timeout: 10000 }, + ); + await userEvent.click(calendarButton); + // Increased timeout to account for lazy-loaded react-datepicker on slower CI runners - await canvas.findByText('January', {}, { timeout: 10000 }); + await body.findByText('January', {}, { timeout: 10000 }); + const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div'); await userEvent.click(emptyDiv); + await userEvent.click(emptyDiv); await expect(handleClickoutsideMocked).toHaveBeenCalledTimes(1); }, @@ -161,9 +180,19 @@ export const Escape: Story = { play: async ({ canvasElement }) => { await expect(handleEscapeMocked).toHaveBeenCalledTimes(0); const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + const calendarButton = await canvas.findByRole( + 'button', + { name: 'Select month and year' }, + { timeout: 10000 }, + ); + await userEvent.click(calendarButton); // Increased timeout to account for lazy-loaded react-datepicker on slower CI runners - await canvas.findByText('January', {}, { timeout: 10000 }); + await body.findByText('January', {}, { timeout: 10000 }); + + await userEvent.keyboard('{escape}'); await userEvent.keyboard('{escape}'); await expect(handleEscapeMocked).toHaveBeenCalledTimes(1); @@ -174,9 +203,19 @@ export const Enter: Story = { play: async ({ canvasElement }) => { await expect(handleEnterMocked).toHaveBeenCalledTimes(0); const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + const calendarButton = await canvas.findByRole( + 'button', + { name: 'Select month and year' }, + { timeout: 10000 }, + ); + await userEvent.click(calendarButton); // Increased timeout to account for lazy-loaded react-datepicker on slower CI runners - await canvas.findByText('January', {}, { timeout: 10000 }); + await body.findByText('January', {}, { timeout: 10000 }); + + await userEvent.keyboard('{enter}'); await userEvent.keyboard('{enter}'); await expect(handleEnterMocked).toHaveBeenCalledTimes(1); diff --git a/packages/twenty-front/src/modules/ui/field/input/components/DateTimeInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/DateTimeInput.tsx index 41357b80f2..b59a6fc320 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/DateTimeInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/DateTimeInput.tsx @@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from 'react'; import { useRegisterInputEvents } from '@/object-record/record-field/ui/meta-types/input/hooks/useRegisterInputEvents'; import { + DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID, DateTimePicker, MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID, MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID, @@ -65,10 +66,12 @@ export const DateTimeInput = ({ const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown(); const { closeDropdown: closeDropdownYearSelect } = useCloseDropdown(); + const { closeDropdown: closeMonthYearPanel } = useCloseDropdown(); const handleEnter = () => { closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID); closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID); + closeMonthYearPanel(DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID); onEnter(internalValue); }; @@ -76,6 +79,7 @@ export const DateTimeInput = ({ const handleEscape = () => { closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID); closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID); + closeMonthYearPanel(DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID); onEscape(internalValue); }; @@ -87,6 +91,7 @@ export const DateTimeInput = ({ if (currentFocusId === instanceId) { closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID); closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID); + closeMonthYearPanel(DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID); onClickOutside(event, internalValue); } }, @@ -94,6 +99,7 @@ export const DateTimeInput = ({ instanceId, closeDropdownYearSelect, closeDropdownMonthSelect, + closeMonthYearPanel, onClickOutside, internalValue, store, diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePickerHeader.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePickerHeader.tsx index df5defaac8..b10e7c5649 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePickerHeader.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePickerHeader.tsx @@ -7,11 +7,6 @@ import { getMonthSelectOptions } from '@/ui/input/components/internal/date/utils import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext'; import { IconChevronLeft, IconChevronRight } from 'twenty-ui/display'; import { LightIconButton } from 'twenty-ui/input'; -import { - MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID, - MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID, -} from './DateTimePicker'; - import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { Temporal } from 'temporal-polyfill'; @@ -19,6 +14,15 @@ import { SOURCE_LOCALE } from 'twenty-shared/translations'; import { isDefined } from 'twenty-shared/utils'; import { themeCssVariables } from 'twenty-ui/theme-constants'; +const MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID = + 'date-picker-month-and-year-dropdown-month-select'; +const MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID = + 'date-picker-month-and-year-dropdown-year-select'; +const YEARS_SELECT_OPTIONS = Array.from( + { length: 200 }, + (_, i) => new Date().getFullYear() + 50 - i, +).map((year) => ({ label: year.toString(), value: year })); + const StyledCustomDatePickerHeader = styled.div` align-items: center; display: flex; @@ -30,11 +34,6 @@ const StyledCustomDatePickerHeader = styled.div` padding-top: ${themeCssVariables.spacing[2]}; `; -const years = Array.from( - { length: 200 }, - (_, i) => new Date().getFullYear() + 50 - i, -).map((year) => ({ label: year.toString(), value: year })); - type DatePickerHeaderProps = { date: string | null; onChange?: (date: string | null) => void; @@ -89,7 +88,7 @@ export const DatePickerHeader = ({ dropdownId={MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID} onChange={onChangeYear} value={dateParsed?.year} - options={years} + options={YEARS_SELECT_OPTIONS} fullWidth /> diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePicker.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePicker.tsx index 2d7a12d54d..043e005b67 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePicker.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePicker.tsx @@ -5,7 +5,10 @@ import { type RelativeDateFilter, } from 'twenty-shared/utils'; -import { DateTimePickerHeader } from '@/ui/input/components/internal/date/components/DateTimePickerHeader'; +import { + DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID, + DateTimePickerHeader, +} from '@/ui/input/components/internal/date/components/DateTimePickerHeader'; import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader'; import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; @@ -24,11 +27,22 @@ import { useGetShiftedDateToSystemTimeZone } from '@/ui/input/components/interna import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek'; import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { Temporal } from 'temporal-polyfill'; + +export { + MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID, + MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID, +} from '@/ui/input/components/internal/date/components/DatePicker'; +export { DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID } from '@/ui/input/components/internal/date/components/DateTimePickerHeader'; + import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; -export const MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID = - 'date-picker-month-and-year-dropdown-month-select'; -export const MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID = - 'date-picker-month-and-year-dropdown-year-select'; + +const StyledOuterWrapper = styled.div` + align-items: flex-start; + display: flex; + flex-direction: row; + position: relative; + width: 280px; +`; const StyledContainer = styled.div<{ calendarDisabled?: boolean; @@ -361,6 +375,8 @@ export const DateTimePicker = ({ const dateToUse = date ?? Temporal.Now.zonedDateTimeISO(timeZone ?? userTimezone); + const { closeDropdown: closeMonthYearPanel } = useCloseDropdown(); + const { getShiftedDateToSystemTimeZone } = useGetShiftedDateToSystemTimeZone(); @@ -381,21 +397,13 @@ export const DateTimePicker = ({ return { zonedDateTime }; }; - const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown(); - const { closeDropdown: closeDropdownYearSelect } = useCloseDropdown(); - const handleClear = () => { - closeDropdowns(); + closeMonthYearPanel(DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID); onClear?.(); }; - const closeDropdowns = () => { - closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID); - closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID); - }; - const handleClose = (newDate: Temporal.ZonedDateTime) => { - closeDropdowns(); + closeMonthYearPanel(DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID); onClose?.(newDate); }; @@ -472,84 +480,86 @@ export const DateTimePicker = ({ convertFirstDayOfTheWeekToCalendarStartDayNumber(userFirstDayOfTheWeek); return ( - - - - - - - - - - } - > - - isRelative ? ( - - ) : ( - - ) + + + + + + + + + + } - onSelect={handleDateSelect} - selectsMultiple={isRelative} - /> - - {clearable && ( - <> - - - - - - - - )} - + > + + isRelative ? ( + + ) : ( + + ) + } + onSelect={handleDateSelect} + selectsMultiple={isRelative} + /> + + {clearable && ( + <> + + + + + + + + )} + + ); }; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePickerHeader.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePickerHeader.tsx index 5e7ed4f67f..e669fcca1b 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePickerHeader.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePickerHeader.tsx @@ -1,70 +1,202 @@ +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { styled } from '@linaria/react'; +import { useEffect } from 'react'; +import { useIMask } from 'react-imask'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat'; import { Select } from '@/ui/input/components/Select'; - import { DateTimePickerInput } from '@/ui/input/components/internal/date/components/DateTimePickerInput'; +import { useTimeInput } from '@/ui/input/components/internal/date/hooks/useTimeInput'; import { getMonthSelectOptions } from '@/ui/input/components/internal/date/utils/getMonthSelectOptions'; +import { getTimeBlocks } from '@/ui/input/components/internal/date/utils/getTimeBlocks'; +import { getTimeMask } from '@/ui/input/components/internal/date/utils/getTimeMask'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext'; +import { t } from '@lingui/core/macro'; import { type Temporal } from 'temporal-polyfill'; import { SOURCE_LOCALE } from 'twenty-shared/translations'; -import { IconChevronLeft, IconChevronRight } from 'twenty-ui/display'; +import { isDefined } from 'twenty-shared/utils'; +import { + IconCalendar, + IconChevronLeft, + IconChevronRight, + IconClock, +} from 'twenty-ui/display'; import { LightIconButton } from 'twenty-ui/input'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + import { MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID, MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID, } from './DateTimePicker'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; -const StyledCustomDatePickerHeader = styled.div` +export const DATE_TIME_PICKER_MONTH_YEAR_PANEL_DROPDOWN_ID = + 'date-time-picker-month-year-panel'; + +const YEARS_SELECT_OPTIONS = Array.from( + { length: 200 }, + (_, i) => new Date().getFullYear() + 50 - i, +).map((year) => ({ label: year.toString(), value: year })); + +const StyledTimeRow = styled.div` align-items: center; display: flex; gap: ${themeCssVariables.spacing[1]}; - justify-content: flex-end; + justify-content: flex-start; + padding-bottom: ${themeCssVariables.spacing[2]}; padding-left: ${themeCssVariables.spacing[2]}; padding-right: ${themeCssVariables.spacing[2]}; - padding-top: ${themeCssVariables.spacing[2]}; `; +const StyledTimeInputWrapper = styled.div` + flex-grow: 1; +`; + +const StyledTimeInputContainer = styled.div` + align-items: center; + background-color: ${themeCssVariables.background.transparent.lighter}; + border: 1px solid ${themeCssVariables.border.color.medium}; + border-radius: ${themeCssVariables.border.radius.sm}; + box-sizing: border-box; + display: flex; + gap: ${themeCssVariables.spacing[1]}; + height: ${themeCssVariables.spacing[8]}; + padding: 0 ${themeCssVariables.spacing[2]}; + transition: border-color 0.15s ease; + + &:hover { + border-color: ${themeCssVariables.border.color.strong}; + } +`; + +const StyledClockIcon = styled.div` + align-items: center; + color: ${themeCssVariables.font.color.tertiary}; + display: flex; + flex-shrink: 0; +`; + +const StyledTimeInput = styled.input` + background: transparent; + border: none; + color: ${themeCssVariables.font.color.primary}; + flex: 1; + font-family: ${themeCssVariables.font.family}; + font-size: ${themeCssVariables.font.size.md}; + font-weight: ${themeCssVariables.font.weight.regular}; + letter-spacing: 0.05em; + outline: none; + width: 100%; + + &::placeholder { + color: ${themeCssVariables.font.color.light}; + font-weight: ${themeCssVariables.font.weight.medium}; + } + + &:disabled { + color: ${themeCssVariables.font.color.tertiary}; + } +`; + +const StyledRightControls = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[1]}; +`; + +const StyledNavigationButtons = styled.div` + display: flex; + gap: ${themeCssVariables.spacing[1]}; +`; + const StyledSeparator = styled.div` border-bottom: 1px solid ${themeCssVariables.border.color.light}; height: 1px; width: 100%; `; -const years = Array.from( - { length: 200 }, - (_, i) => new Date().getFullYear() + 50 - i, -).map((year) => ({ label: year.toString(), value: year })); +const StyledMonthYearSelector = styled.div` + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[1]}; + padding: ${themeCssVariables.spacing[1]}; + width: 160px; +`; type DateTimePickerHeaderProps = { date: Temporal.ZonedDateTime | null; onChange?: (date: Temporal.ZonedDateTime | null) => void; - onChangeMonth: (month: number) => void; - onChangeYear: (year: number) => void; onAddMonth: () => void; onSubtractMonth: () => void; prevMonthButtonDisabled: boolean; nextMonthButtonDisabled: boolean; hideInput?: boolean; + onChangeMonth: (month: number) => void; + onChangeYear: (year: number) => void; }; export const DateTimePickerHeader = ({ date, onChange, - onChangeMonth, - onChangeYear, onAddMonth, onSubtractMonth, prevMonthButtonDisabled, nextMonthButtonDisabled, hideInput = false, + onChangeMonth, + onChangeYear, }: DateTimePickerHeaderProps) => { + const { timeFormat } = useDateTimeFormat(); + const { formatTime, parseTime, isHour12 } = useTimeInput(timeFormat); + const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState); const userLocale = currentWorkspaceMember?.locale ?? SOURCE_LOCALE; + const { closeDropdown: closeMonthSelect } = useCloseDropdown(); + const { closeDropdown: closeYearSelect } = useCloseDropdown(); + + const closeInnerDropdowns = () => { + closeMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID); + closeYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID); + }; + + const { ref: iMaskRef, setValue } = useIMask( + { + mask: getTimeMask(timeFormat), + blocks: getTimeBlocks(timeFormat), + lazy: false, + autofix: true, + }, + { + defaultValue: isDefined(date) + ? formatTime(date.hour, date.minute) + : undefined, + onComplete: (value) => { + if (!date) return; + + const parsedTime = parseTime(value); + if (!parsedTime) { + return; + } + + onChange?.( + date.with({ hour: parsedTime.hour, minute: parsedTime.minute }), + ); + }, + }, + ); + + useEffect(() => { + if (isDefined(date)) { + setValue(formatTime(date.hour, date.minute)); + } + }, [date, formatTime, setValue]); + + const timeInputRef = iMaskRef as React.Ref; + return ( <> {!hideInput && ( @@ -73,46 +205,87 @@ export const DateTimePickerHeader = ({ )} - - - + + + - - - - + + + + + + ); }; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx index 598702c012..96f27ed260 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx @@ -38,13 +38,20 @@ export const WithOpenMonthSelect: Story = { const canvas = within(canvasElement); const body = within(canvasElement.ownerDocument.body); - // Increased timeout to account for lazy-loaded react-datepicker on slower CI runners - const monthSelect = await canvas.findByText( + // Wait for date picker to load and click calendar icon to open month/year selector + const calendarButton = await canvas.findByRole( + 'button', + { name: 'Select month and year' }, + { timeout: 10000 }, + ); + await userEvent.click(calendarButton); + + // Now find and click the month select + const monthSelect = await body.findByText( 'January', {}, { timeout: 10000 }, ); - await userEvent.click(monthSelect); for (const monthLabel of [ @@ -65,7 +72,7 @@ export const WithOpenMonthSelect: Story = { await userEvent.click(await body.findByText('February')); - expect(await canvas.findByText('February')).toBeInTheDocument(); + expect(await body.findByText('February')).toBeInTheDocument(); }, }; @@ -74,9 +81,15 @@ export const WithOpenYearSelect: Story = { const canvas = within(canvasElement); const body = within(canvasElement.ownerDocument.body); - // Increased timeout to account for lazy-loaded react-datepicker on slower CI runners - const yearSelect = await canvas.findByText('2023', {}, { timeout: 10000 }); + // Wait for date picker to load and click calendar icon to open month/year selector + const calendarButton = await canvas.findByRole( + 'button', + { name: 'Select month and year' }, + { timeout: 10000 }, + ); + await userEvent.click(calendarButton); + const yearSelect = await body.findByText('2023', {}, { timeout: 10000 }); await userEvent.click(yearSelect); for (const yearLabel of ['2024', '2025', '2026']) { @@ -85,6 +98,21 @@ export const WithOpenYearSelect: Story = { await userEvent.click(await body.findByText('2024')); - expect(await canvas.findByText('2024')).toBeInTheDocument(); + expect(await body.findByText('2024')).toBeInTheDocument(); + }, +}; + +export const WithTimeInput: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Increased timeout to account for lazy-loaded react-datepicker on slower CI runners + const timeInput = await canvas.findByPlaceholderText( + /HH:mm/, + {}, + { timeout: 10000 }, + ); + + expect(timeInput).toBeInTheDocument(); }, }; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/__tests__/useTimeInput.test.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/__tests__/useTimeInput.test.ts new file mode 100644 index 0000000000..127b0212e0 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/__tests__/useTimeInput.test.ts @@ -0,0 +1,129 @@ +import { renderHook } from '@testing-library/react'; + +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { useTimeInput } from '@/ui/input/components/internal/date/hooks/useTimeInput'; + +describe('useTimeInput', () => { + describe('24-hour format', () => { + const render24 = () => + renderHook(() => useTimeInput(TimeFormat.HOUR_24)).result.current; + + describe('formatTime', () => { + it('should format with zero-padded hours and minutes', () => { + const { formatTime } = render24(); + expect(formatTime(9, 5)).toBe('09:05'); + }); + + it('should format midnight as 00:00', () => { + const { formatTime } = render24(); + expect(formatTime(0, 0)).toBe('00:00'); + }); + + it('should format 23:59', () => { + const { formatTime } = render24(); + expect(formatTime(23, 59)).toBe('23:59'); + }); + }); + + describe('parseTime', () => { + it('should parse valid 24h time', () => { + const { parseTime } = render24(); + expect(parseTime('14:30')).toEqual({ hour: 14, minute: 30 }); + }); + + it('should parse midnight', () => { + const { parseTime } = render24(); + expect(parseTime('00:00')).toEqual({ hour: 0, minute: 0 }); + }); + + it('should return null for invalid format', () => { + const { parseTime } = render24(); + expect(parseTime('invalid')).toBeNull(); + }); + + it('should return null for out-of-range hour', () => { + const { parseTime } = render24(); + expect(parseTime('25:00')).toBeNull(); + }); + + it('should return null for out-of-range minute', () => { + const { parseTime } = render24(); + expect(parseTime('12:60')).toBeNull(); + }); + }); + + it('should set isHour12 to false', () => { + const { isHour12 } = render24(); + expect(isHour12).toBe(false); + }); + }); + + describe('12-hour format', () => { + const render12 = () => + renderHook(() => useTimeInput(TimeFormat.HOUR_12)).result.current; + + describe('formatTime', () => { + it('should format AM time', () => { + const { formatTime } = render12(); + expect(formatTime(9, 5)).toBe('09:05 AM'); + }); + + it('should format PM time', () => { + const { formatTime } = render12(); + expect(formatTime(14, 30)).toBe('02:30 PM'); + }); + + it('should format midnight as 12:00 AM', () => { + const { formatTime } = render12(); + expect(formatTime(0, 0)).toBe('12:00 AM'); + }); + + it('should format noon as 12:00 PM', () => { + const { formatTime } = render12(); + expect(formatTime(12, 0)).toBe('12:00 PM'); + }); + }); + + describe('parseTime', () => { + it('should parse AM time', () => { + const { parseTime } = render12(); + expect(parseTime('09:30 AM')).toEqual({ hour: 9, minute: 30 }); + }); + + it('should parse PM time', () => { + const { parseTime } = render12(); + expect(parseTime('02:30 PM')).toEqual({ hour: 14, minute: 30 }); + }); + + it('should parse 12:00 AM as midnight', () => { + const { parseTime } = render12(); + expect(parseTime('12:00 AM')).toEqual({ hour: 0, minute: 0 }); + }); + + it('should parse 12:00 PM as noon', () => { + const { parseTime } = render12(); + expect(parseTime('12:00 PM')).toEqual({ hour: 12, minute: 0 }); + }); + + it('should return null for invalid format', () => { + const { parseTime } = render12(); + expect(parseTime('14:30')).toBeNull(); + }); + + it('should return null for out-of-range hour', () => { + const { parseTime } = render12(); + expect(parseTime('13:00 AM')).toBeNull(); + }); + + it('should return null for out-of-range minute', () => { + const { parseTime } = render12(); + expect(parseTime('12:60 PM')).toBeNull(); + }); + }); + + it('should set isHour12 to true', () => { + const { isHour12 } = render12(); + expect(isHour12).toBe(true); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useTimeInput.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useTimeInput.ts new file mode 100644 index 0000000000..65ec783a74 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useTimeInput.ts @@ -0,0 +1,75 @@ +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { useCallback } from 'react'; + +type TimeValue = { + hour: number; + minute: number; +}; + +type UseTimeInputReturn = { + formatTime: (hour: number, minute: number) => string; + parseTime: (timeString: string) => TimeValue | null; + isHour12: boolean; +}; + +export const useTimeInput = (timeFormat: TimeFormat): UseTimeInputReturn => { + const isHour12 = timeFormat === TimeFormat.HOUR_12; + + const formatTime = useCallback( + (hour: number, minute: number): string => { + const hh = isHour12 + ? (hour % 12 || 12).toString().padStart(2, '0') + : hour.toString().padStart(2, '0'); + const mm = minute.toString().padStart(2, '0'); + + if (isHour12) { + const amPm = hour >= 12 ? 'PM' : 'AM'; + return `${hh}:${mm} ${amPm}`; + } + return `${hh}:${mm}`; + }, + [isHour12], + ); + + const parseTime = (timeString: string): TimeValue | null => { + if (isHour12) { + const match = timeString.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i); + if (!match) return null; + + const [, hoursStr, minutesStr, amPmStr] = match; + const hours = parseInt(hoursStr, 10); + const minutes = parseInt(minutesStr, 10); + + const isValidHour = hours >= 1 && hours <= 12; + const isValidMinute = minutes >= 0 && minutes <= 59; + if (isNaN(hours) || isNaN(minutes) || !isValidHour || !isValidMinute) { + return null; + } + + const isPM = amPmStr.toUpperCase() === 'PM'; + const hour24 = (hours % 12) + (isPM ? 12 : 0); + return { hour: hour24, minute: minutes }; + } else { + const match = timeString.match(/^(\d{1,2}):(\d{2})$/); + if (!match) return null; + + const [, hoursStr, minutesStr] = match; + const hours = parseInt(hoursStr, 10); + const minutes = parseInt(minutesStr, 10); + + const isValidHour = hours >= 0 && hours <= 23; + const isValidMinute = minutes >= 0 && minutes <= 59; + if (isNaN(hours) || isNaN(minutes) || !isValidHour || !isValidMinute) { + return null; + } + + return { hour: hours, minute: minutes }; + } + }; + + return { + formatTime, + parseTime, + isHour12, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getTimeBlocks.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getTimeBlocks.ts index f4b734992d..d9f54b4e2c 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getTimeBlocks.ts +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getTimeBlocks.ts @@ -18,7 +18,9 @@ export const getTimeBlocks = (timeFormat: TimeFormat) => { maxLength: 2, }, aa: { - mask: '**', + mask: IMask.MaskedEnum, + enum: ['AM', 'PM'], + prepareChar: (str: string) => str.toUpperCase(), }, }; }; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/__tests__/useCloseAnyOpenDropdown.test.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/__tests__/useCloseAnyOpenDropdown.test.tsx index aab39666b1..77b88c9928 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/__tests__/useCloseAnyOpenDropdown.test.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/__tests__/useCloseAnyOpenDropdown.test.tsx @@ -5,11 +5,16 @@ import { act } from 'react'; import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext'; import { useCloseAnyOpenDropdown } from '@/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown'; import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; +import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState'; import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; +import { previousDropdownFocusIdStackState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdStackState'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore'; const dropdownId = 'test-dropdown-id'; +const dropdownIdA = 'test-dropdown-id-a'; +const dropdownIdB = 'test-dropdown-id-b'; +const dropdownIdC = 'test-dropdown-id-c'; const Wrapper = ({ children }: { children: React.ReactNode }) => { return ( @@ -29,6 +34,8 @@ describe('useCloseAnyOpenDropdown', () => { isDropdownOpenComponentState.atomFamily({ instanceId: dropdownId }), false, ); + jotaiStore.set(activeDropdownFocusIdState.atom, null); + jotaiStore.set(previousDropdownFocusIdStackState.atom, []); }); it('should open dropdown and then close it with closeAnyOpenDropdown', async () => { @@ -64,4 +71,63 @@ describe('useCloseAnyOpenDropdown', () => { expect(result.current.isDropdownOpen).toBe(false); }); + + it('should close all nested dropdowns when multiple are open', () => { + jotaiStore.set( + isDropdownOpenComponentState.atomFamily({ instanceId: dropdownIdA }), + true, + ); + jotaiStore.set( + isDropdownOpenComponentState.atomFamily({ instanceId: dropdownIdB }), + true, + ); + jotaiStore.set( + isDropdownOpenComponentState.atomFamily({ instanceId: dropdownIdC }), + true, + ); + jotaiStore.set(activeDropdownFocusIdState.atom, dropdownIdC); + jotaiStore.set(previousDropdownFocusIdStackState.atom, [ + dropdownIdA, + dropdownIdB, + ]); + + const { result } = renderHook(() => useCloseAnyOpenDropdown(), { + wrapper: Wrapper, + }); + + act(() => { + result.current.closeAnyOpenDropdown(); + }); + + expect( + jotaiStore.get( + isDropdownOpenComponentState.atomFamily({ instanceId: dropdownIdA }), + ), + ).toBe(false); + expect( + jotaiStore.get( + isDropdownOpenComponentState.atomFamily({ instanceId: dropdownIdB }), + ), + ).toBe(false); + expect( + jotaiStore.get( + isDropdownOpenComponentState.atomFamily({ instanceId: dropdownIdC }), + ), + ).toBe(false); + expect(jotaiStore.get(activeDropdownFocusIdState.atom)).toBeNull(); + expect(jotaiStore.get(previousDropdownFocusIdStackState.atom)).toEqual([]); + }); + + it('should do nothing when no dropdowns are open', () => { + const { result } = renderHook(() => useCloseAnyOpenDropdown(), { + wrapper: Wrapper, + }); + + act(() => { + result.current.closeAnyOpenDropdown(); + }); + + expect(jotaiStore.get(activeDropdownFocusIdState.atom)).toBeNull(); + expect(jotaiStore.get(previousDropdownFocusIdStackState.atom)).toEqual([]); + }); }); diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown.ts index ce42ccbfae..3f05ec7fa2 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown.ts +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState'; -import { previousDropdownFocusIdState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdState'; +import { previousDropdownFocusIdStackState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdStackState'; import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById'; import { useStore } from 'jotai'; import { isDefined } from 'twenty-shared/utils'; @@ -16,21 +16,16 @@ export const useCloseAnyOpenDropdown = () => { const store = useStore(); const closeAnyOpenDropdown = useCallback(() => { - const previousDropdownFocusId = store.get( - previousDropdownFocusIdState.atom, - ); - + const previousStack = store.get(previousDropdownFocusIdStackState.atom); const activeDropdownFocusId = store.get(activeDropdownFocusIdState.atom); const thereIsNoDropdownOpen = - !isDefined(activeDropdownFocusId) && !isDefined(previousDropdownFocusId); + !isDefined(activeDropdownFocusId) && previousStack.length === 0; if (thereIsNoDropdownOpen) { return; } - const thereIsOneNestedDropdownOpen = isDefined(previousDropdownFocusId); - if (isDefined(activeDropdownFocusId)) { closeDropdown(activeDropdownFocusId); removeFocusItemFromFocusStackById({ @@ -38,14 +33,14 @@ export const useCloseAnyOpenDropdown = () => { }); } - if (thereIsOneNestedDropdownOpen) { - closeDropdown(previousDropdownFocusId); + for (const previousId of previousStack) { + closeDropdown(previousId); removeFocusItemFromFocusStackById({ - focusId: previousDropdownFocusId, + focusId: previousId, }); } - store.set(previousDropdownFocusIdState.atom, null); + store.set(previousDropdownFocusIdStackState.atom, []); store.set(activeDropdownFocusIdState.atom, null); }, [closeDropdown, removeFocusItemFromFocusStackById, store]); diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId.ts index 2dde02bdcd..ae653fee2a 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId.ts +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId.ts @@ -1,20 +1,23 @@ import { useCallback } from 'react'; import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState'; -import { previousDropdownFocusIdState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdState'; +import { previousDropdownFocusIdStackState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdStackState'; import { useStore } from 'jotai'; -// TODO: this won't work for more than 1 nested dropdown export const useGoBackToPreviousDropdownFocusId = () => { const store = useStore(); const goBackToPreviousDropdownFocusId = useCallback(() => { - const previouslyFocusedDropdownId = store.get( - previousDropdownFocusIdState.atom, - ); + const previousStack = store.get(previousDropdownFocusIdStackState.atom); + + const previouslyFocusedDropdownId = + previousStack[previousStack.length - 1] ?? null; store.set(activeDropdownFocusIdState.atom, previouslyFocusedDropdownId); - store.set(previousDropdownFocusIdState.atom, null); + store.set( + previousDropdownFocusIdStackState.atom, + previousStack.slice(0, -1), + ); }, [store]); return { diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious.ts index 15da4c07d9..c2b5280eec 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious.ts +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious.ts @@ -1,8 +1,9 @@ import { useCallback } from 'react'; import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState'; -import { previousDropdownFocusIdState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdState'; +import { previousDropdownFocusIdStackState } from '@/ui/layout/dropdown/states/previousDropdownFocusIdStackState'; import { useStore } from 'jotai'; +import { isDefined } from 'twenty-shared/utils'; export const useSetActiveDropdownFocusIdAndMemorizePrevious = () => { const store = useStore(); @@ -15,7 +16,14 @@ export const useSetActiveDropdownFocusIdAndMemorizePrevious = () => { return; } - store.set(previousDropdownFocusIdState.atom, activeDropdownFocusId); + if (isDefined(activeDropdownFocusId) && isDefined(dropdownId)) { + const previousStack = store.get(previousDropdownFocusIdStackState.atom); + store.set(previousDropdownFocusIdStackState.atom, [ + ...previousStack, + activeDropdownFocusId, + ]); + } + store.set(activeDropdownFocusIdState.atom, dropdownId); }, [store], diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/states/previousDropdownFocusIdStackState.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/states/previousDropdownFocusIdStackState.ts new file mode 100644 index 0000000000..3853eef73f --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/states/previousDropdownFocusIdStackState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const previousDropdownFocusIdStackState = createAtomState({ + key: 'previousDropdownFocusIdStackState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/states/previousDropdownFocusIdState.ts b/packages/twenty-front/src/modules/ui/layout/dropdown/states/previousDropdownFocusIdState.ts deleted file mode 100644 index 043f4e6f2f..0000000000 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/states/previousDropdownFocusIdState.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; - -export const previousDropdownFocusIdState = createAtomState({ - key: 'previousDropdownFocusIdState', - defaultValue: null, -}); diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 7c143f0d97..80306f1634 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -84,6 +84,7 @@ export { IconCirclePlus, IconCircleX, IconClick, + IconClock, IconClockHour8, IconClockPlay, IconClockShare, diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index 1168da1383..9f23791674 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -159,6 +159,7 @@ export { IconCirclePlus, IconCircleX, IconClick, + IconClock, IconClockHour8, IconClockPlay, IconClockShare,