Add day view support to record calendar (#22922)
## Summary - Add a calendar day view and wire it into the record calendar layout selection - Update the top bar, time grid, and week/day drag and drop handling to support the new view - Extend supported layout logic and public feature flags for calendar day view access - Add coverage for calendar view content, calendar container behavior, top bar behavior, day view rendering, supported layout resolution, and week event drop handling <img width="1276" height="852" alt="Screenshot 2026-07-15 at 17 48 33" src="https://github.com/user-attachments/assets/b1d9d255-2d64-4adb-82b9-3e500cb0d561" /> <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22922?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+38
-11
@@ -17,6 +17,7 @@ import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Pill } from 'twenty-ui/data-display';
|
||||
import {
|
||||
IconCalendarEvent,
|
||||
IconCalendarMonth,
|
||||
IconCalendarWeek,
|
||||
IconChevronLeft,
|
||||
@@ -28,6 +29,8 @@ import {
|
||||
ViewCalendarLayout,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const RECORD_CALENDAR_TIMELINE_VIEW_ID = 'record-calendar-timeline-view';
|
||||
|
||||
export const ObjectOptionsDropdownCalendarViewContent = () => {
|
||||
const { resetContent } = useObjectOptionsDropdown();
|
||||
const recordIndexCalendarLayout = useAtomStateValue(
|
||||
@@ -53,16 +56,18 @@ export const ObjectOptionsDropdownCalendarViewContent = () => {
|
||||
const { closeDropdown } = useObjectOptionsDropdown();
|
||||
|
||||
const selectableItemIdArray = [
|
||||
ViewCalendarLayout.DAY,
|
||||
ViewCalendarLayout.WEEK,
|
||||
ViewCalendarLayout.MONTH,
|
||||
ViewCalendarLayout.DAY,
|
||||
RECORD_CALENDAR_TIMELINE_VIEW_ID,
|
||||
];
|
||||
|
||||
const handleCalendarViewChange = async (calendarView: ViewCalendarLayout) => {
|
||||
if (
|
||||
calendarView === ViewCalendarLayout.WEEK &&
|
||||
!isCalendarWeekViewEnabled
|
||||
) {
|
||||
const isTimeGridLayout =
|
||||
calendarView === ViewCalendarLayout.DAY ||
|
||||
calendarView === ViewCalendarLayout.WEEK;
|
||||
|
||||
if (isTimeGridLayout && !isCalendarWeekViewEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,6 +101,31 @@ export const ObjectOptionsDropdownCalendarViewContent = () => {
|
||||
focusId={OBJECT_OPTIONS_DROPDOWN_ID}
|
||||
selectableItemIdArray={selectableItemIdArray}
|
||||
>
|
||||
<SelectableListItem
|
||||
itemId={ViewCalendarLayout.DAY}
|
||||
onEnter={() => {
|
||||
if (isCalendarWeekViewEnabled) {
|
||||
handleCalendarViewChange(ViewCalendarLayout.DAY);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItemSelect
|
||||
LeftIcon={IconCalendarEvent}
|
||||
text={t`Day`}
|
||||
selected={supportedCalendarLayout === ViewCalendarLayout.DAY}
|
||||
onClick={
|
||||
isCalendarWeekViewEnabled
|
||||
? () => handleCalendarViewChange(ViewCalendarLayout.DAY)
|
||||
: undefined
|
||||
}
|
||||
focused={selectedItemId === ViewCalendarLayout.DAY}
|
||||
contextualText={
|
||||
isCalendarWeekViewEnabled ? undefined : <Pill label={t`Soon`} />
|
||||
}
|
||||
contextualTextPosition="right"
|
||||
disabled={!isCalendarWeekViewEnabled}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
<SelectableListItem
|
||||
itemId={ViewCalendarLayout.WEEK}
|
||||
onEnter={() => {
|
||||
@@ -133,15 +163,12 @@ export const ObjectOptionsDropdownCalendarViewContent = () => {
|
||||
focused={selectedItemId === ViewCalendarLayout.MONTH}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
<SelectableListItem
|
||||
itemId={ViewCalendarLayout.DAY}
|
||||
onEnter={() => handleCalendarViewChange(ViewCalendarLayout.DAY)}
|
||||
>
|
||||
<SelectableListItem itemId={RECORD_CALENDAR_TIMELINE_VIEW_ID}>
|
||||
<MenuItemSelect
|
||||
LeftIcon={IconTimelineEvent}
|
||||
text={t`Timeline`}
|
||||
selected={recordIndexCalendarLayout === ViewCalendarLayout.DAY}
|
||||
focused={selectedItemId === ViewCalendarLayout.DAY}
|
||||
selected={false}
|
||||
focused={selectedItemId === RECORD_CALENDAR_TIMELINE_VIEW_ID}
|
||||
contextualText={<Pill label={t`Soon`} />}
|
||||
contextualTextPosition="right"
|
||||
disabled
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import { ObjectOptionsDropdownCalendarViewContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarViewContent';
|
||||
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
|
||||
|
||||
const mockCloseDropdown = jest.fn();
|
||||
const mockResetContent = jest.fn();
|
||||
const mockSetRecordIndexCalendarLayout = jest.fn();
|
||||
const mockUpdateCurrentView = jest.fn();
|
||||
const mockUseIsFeatureEnabled = jest.fn();
|
||||
const mockUseAtomStateValue = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
'@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown',
|
||||
() => ({
|
||||
useObjectOptionsDropdown: jest.fn(() => ({
|
||||
closeDropdown: mockCloseDropdown,
|
||||
resetContent: mockResetContent,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
jest.mock('@/ui/layout/dropdown/components/DropdownContent', () => ({
|
||||
DropdownContent: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
jest.mock(
|
||||
'@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader',
|
||||
() => ({
|
||||
DropdownMenuHeader: ({ children }: { children: React.ReactNode }) =>
|
||||
children,
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent',
|
||||
() => ({ DropdownMenuHeaderLeftComponent: () => null }),
|
||||
);
|
||||
jest.mock('@/ui/layout/dropdown/components/DropdownMenuItemsContainer', () => ({
|
||||
DropdownMenuItemsContainer: ({ children }: { children: React.ReactNode }) =>
|
||||
children,
|
||||
}));
|
||||
jest.mock('@/ui/layout/selectable-list/components/SelectableList', () => ({
|
||||
SelectableList: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
jest.mock('@/ui/layout/selectable-list/components/SelectableListItem', () => ({
|
||||
SelectableListItem: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
jest.mock(
|
||||
'@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue',
|
||||
() => ({ useAtomComponentStateValue: jest.fn(() => null) }),
|
||||
);
|
||||
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => ({
|
||||
useAtomStateValue: (...args: unknown[]) => mockUseAtomStateValue(...args),
|
||||
}));
|
||||
jest.mock('@/ui/utilities/state/jotai/hooks/useSetAtomState', () => ({
|
||||
useSetAtomState: jest.fn(() => mockSetRecordIndexCalendarLayout),
|
||||
}));
|
||||
jest.mock('@/views/hooks/useUpdateCurrentView', () => ({
|
||||
useUpdateCurrentView: jest.fn(() => ({
|
||||
updateCurrentView: mockUpdateCurrentView,
|
||||
})),
|
||||
}));
|
||||
jest.mock('@/workspace/hooks/useIsFeatureEnabled', () => ({
|
||||
useIsFeatureEnabled: (...args: unknown[]) => mockUseIsFeatureEnabled(...args),
|
||||
}));
|
||||
jest.mock('twenty-ui/data-display', () => ({
|
||||
Pill: ({ label }: { label: string }) => <span>{label}</span>,
|
||||
}));
|
||||
jest.mock('twenty-ui/navigation', () => ({
|
||||
MenuItemSelect: ({
|
||||
contextualText,
|
||||
disabled,
|
||||
onClick,
|
||||
selected,
|
||||
text,
|
||||
}: {
|
||||
contextualText?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
selected: boolean;
|
||||
text: string;
|
||||
}) => (
|
||||
<button data-selected={selected} disabled={disabled} onClick={onClick}>
|
||||
<span>{text}</span>
|
||||
{contextualText}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('ObjectOptionsDropdownCalendarViewContent', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseAtomStateValue.mockReturnValue(ViewCalendarLayout.MONTH);
|
||||
mockUseIsFeatureEnabled.mockReturnValue(true);
|
||||
mockUpdateCurrentView.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('offers Day, Week, and Month while keeping Timeline disabled', () => {
|
||||
render(<ObjectOptionsDropdownCalendarViewContent />);
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.textContent?.replace('Soon', '')),
|
||||
).toEqual(['Day', 'Week', 'Month', 'Timeline']);
|
||||
expect(screen.getByText('Day').closest('button')).toBeEnabled();
|
||||
expect(screen.getByText('Timeline').closest('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('persists Day without treating it as Timeline', async () => {
|
||||
render(<ObjectOptionsDropdownCalendarViewContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Day' }));
|
||||
|
||||
expect(mockSetRecordIndexCalendarLayout).toHaveBeenCalledWith(
|
||||
ViewCalendarLayout.DAY,
|
||||
);
|
||||
expect(mockUpdateCurrentView).toHaveBeenCalledWith({
|
||||
calendarLayout: ViewCalendarLayout.DAY,
|
||||
});
|
||||
await waitFor(() => expect(mockCloseDropdown).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('keeps Day and Week unavailable when the feature flag is disabled', () => {
|
||||
mockUseIsFeatureEnabled.mockReturnValue(false);
|
||||
|
||||
render(<ObjectOptionsDropdownCalendarViewContent />);
|
||||
|
||||
const dayButton = screen.getByText('Day').closest('button');
|
||||
const weekButton = screen.getByText('Week').closest('button');
|
||||
|
||||
expect(dayButton).toBeDisabled();
|
||||
expect(weekButton).toBeDisabled();
|
||||
|
||||
if (dayButton !== null) {
|
||||
fireEvent.click(dayButton);
|
||||
}
|
||||
|
||||
expect(mockSetRecordIndexCalendarLayout).not.toHaveBeenCalled();
|
||||
expect(mockUpdateCurrentView).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+4
-1
@@ -4,6 +4,7 @@ import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/cons
|
||||
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
|
||||
import { RecordCalendarTopBar } from '@/object-record/record-calendar/components/RecordCalendarTopBar';
|
||||
import { RECORD_CALENDAR_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-calendar/constants/RecordCalendarClickOutsideListenerId';
|
||||
import { RecordCalendarDay } from '@/object-record/record-calendar/day/components/RecordCalendarDay';
|
||||
import { RecordCalendarMonth } from '@/object-record/record-calendar/month/components/RecordCalendarMonth';
|
||||
import { RecordCalendarWeek } from '@/object-record/record-calendar/week/components/RecordCalendarWeek';
|
||||
import { RECORD_CALENDAR_CARD_CLICK_OUTSIDE_ID } from '@/object-record/record-calendar/record-calendar-card/constants/RecordCalendarCardClickOutsideId';
|
||||
@@ -80,7 +81,9 @@ export const RecordCalendar = () => {
|
||||
<ScrollWrapper
|
||||
componentInstanceId={`scroll-wrapper-record-calendar-${recordCalendarId}`}
|
||||
>
|
||||
{supportedCalendarLayout === ViewCalendarLayout.WEEK ? (
|
||||
{supportedCalendarLayout === ViewCalendarLayout.DAY ? (
|
||||
<RecordCalendarDay />
|
||||
) : supportedCalendarLayout === ViewCalendarLayout.WEEK ? (
|
||||
<RecordCalendarWeek />
|
||||
) : (
|
||||
<RecordCalendarMonth />
|
||||
|
||||
+44
-28
@@ -1,3 +1,4 @@
|
||||
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
|
||||
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
|
||||
@@ -80,6 +81,7 @@ export const RecordCalendarTopBar = () => {
|
||||
});
|
||||
|
||||
const dateLocale = useAtomStateValue(dateLocaleState);
|
||||
const { timeZone } = useDateTimeFormat();
|
||||
const { firstDayOfWeek, lastDayOfWeek } = useRecordCalendarWeekDaysRange(
|
||||
recordCalendarSelectedDate,
|
||||
);
|
||||
@@ -97,26 +99,33 @@ export const RecordCalendarTopBar = () => {
|
||||
};
|
||||
|
||||
const handlePreviousPeriod = () => {
|
||||
setRecordCalendarSelectedDate(
|
||||
supportedCalendarLayout === ViewCalendarLayout.WEEK
|
||||
? recordCalendarSelectedDate.subtract({ weeks: 1 })
|
||||
: recordCalendarSelectedDate.subtract({ months: 1 }),
|
||||
);
|
||||
const previousDate =
|
||||
supportedCalendarLayout === ViewCalendarLayout.DAY
|
||||
? recordCalendarSelectedDate.subtract({ days: 1 })
|
||||
: supportedCalendarLayout === ViewCalendarLayout.WEEK
|
||||
? recordCalendarSelectedDate.subtract({ weeks: 1 })
|
||||
: recordCalendarSelectedDate.subtract({ months: 1 });
|
||||
|
||||
setRecordCalendarSelectedDate(previousDate);
|
||||
};
|
||||
|
||||
const handleNextPeriod = () => {
|
||||
setRecordCalendarSelectedDate(
|
||||
supportedCalendarLayout === ViewCalendarLayout.WEEK
|
||||
? recordCalendarSelectedDate.add({ weeks: 1 })
|
||||
: recordCalendarSelectedDate.add({ months: 1 }),
|
||||
);
|
||||
const nextDate =
|
||||
supportedCalendarLayout === ViewCalendarLayout.DAY
|
||||
? recordCalendarSelectedDate.add({ days: 1 })
|
||||
: supportedCalendarLayout === ViewCalendarLayout.WEEK
|
||||
? recordCalendarSelectedDate.add({ weeks: 1 })
|
||||
: recordCalendarSelectedDate.add({ months: 1 });
|
||||
|
||||
setRecordCalendarSelectedDate(nextDate);
|
||||
};
|
||||
|
||||
const handleCalendarLayoutChange = (calendarLayout: ViewCalendarLayout) => {
|
||||
if (
|
||||
calendarLayout === ViewCalendarLayout.WEEK &&
|
||||
!isCalendarWeekViewEnabled
|
||||
) {
|
||||
const isTimeGridLayout =
|
||||
calendarLayout === ViewCalendarLayout.DAY ||
|
||||
calendarLayout === ViewCalendarLayout.WEEK;
|
||||
|
||||
if (isTimeGridLayout && !isCalendarWeekViewEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -125,23 +134,27 @@ export const RecordCalendarTopBar = () => {
|
||||
};
|
||||
|
||||
const handleTodayClick = () => {
|
||||
setRecordCalendarSelectedDate(Temporal.Now.plainDateISO());
|
||||
setRecordCalendarSelectedDate(Temporal.Now.plainDateISO(timeZone));
|
||||
};
|
||||
|
||||
const formattedDate =
|
||||
supportedCalendarLayout === ViewCalendarLayout.WEEK
|
||||
? formatRecordCalendarWeekRange({
|
||||
firstDayOfWeek,
|
||||
lastDayOfWeek,
|
||||
locale: dateLocale.localeCatalog,
|
||||
supportedCalendarLayout === ViewCalendarLayout.DAY
|
||||
? recordCalendarSelectedDate.toLocaleString(dateLocale.locale, {
|
||||
dateStyle: 'full',
|
||||
})
|
||||
: format(
|
||||
turnPlainDateToShiftedDateInSystemTimeZone(
|
||||
recordCalendarSelectedDate,
|
||||
),
|
||||
'MMMM yyyy',
|
||||
{ locale: dateLocale.localeCatalog },
|
||||
);
|
||||
: supportedCalendarLayout === ViewCalendarLayout.WEEK
|
||||
? formatRecordCalendarWeekRange({
|
||||
firstDayOfWeek,
|
||||
lastDayOfWeek,
|
||||
locale: dateLocale.localeCatalog,
|
||||
})
|
||||
: format(
|
||||
turnPlainDateToShiftedDateInSystemTimeZone(
|
||||
recordCalendarSelectedDate,
|
||||
),
|
||||
'MMMM yyyy',
|
||||
{ locale: dateLocale.localeCatalog },
|
||||
);
|
||||
|
||||
const dropdownContentOffset = { x: 140, y: 0 } satisfies DropdownOffset;
|
||||
|
||||
@@ -153,6 +166,7 @@ export const RecordCalendarTopBar = () => {
|
||||
dropdownId={`record-calendar-layout-${recordCalendarId}`}
|
||||
value={supportedCalendarLayout}
|
||||
options={[
|
||||
{ label: t`Day`, value: ViewCalendarLayout.DAY },
|
||||
{ label: t`Week`, value: ViewCalendarLayout.WEEK },
|
||||
{ label: t`Month`, value: ViewCalendarLayout.MONTH },
|
||||
]}
|
||||
@@ -186,7 +200,7 @@ export const RecordCalendarTopBar = () => {
|
||||
}
|
||||
dropdownOffset={dropdownContentOffset}
|
||||
/>
|
||||
{supportedCalendarLayout !== ViewCalendarLayout.WEEK && (
|
||||
{supportedCalendarLayout === ViewCalendarLayout.MONTH && (
|
||||
<TimeZoneAbbreviation instant={Temporal.Now.instant()} />
|
||||
)}
|
||||
</StyledLeftSection>
|
||||
@@ -194,6 +208,7 @@ export const RecordCalendarTopBar = () => {
|
||||
<StyledNavigationSection>
|
||||
<StyledNavigationButtonContainer>
|
||||
<Button
|
||||
ariaLabel={t`Previous period`}
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
Icon={IconChevronLeft}
|
||||
@@ -208,6 +223,7 @@ export const RecordCalendarTopBar = () => {
|
||||
/>
|
||||
<StyledNavigationButtonContainer>
|
||||
<Button
|
||||
ariaLabel={t`Next period`}
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
Icon={IconChevronRight}
|
||||
|
||||
+29
-6
@@ -12,6 +12,12 @@ jest.mock(
|
||||
RecordCalendarTopBar: () => <div data-testid="calendar-top-bar" />,
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'@/object-record/record-calendar/day/components/RecordCalendarDay',
|
||||
() => ({
|
||||
RecordCalendarDay: () => <div data-testid="calendar-day" />,
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'@/object-record/record-calendar/month/components/RecordCalendarMonth',
|
||||
() => ({
|
||||
@@ -64,16 +70,32 @@ describe('RecordCalendar', () => {
|
||||
useAtomStateValueMock.mockReturnValue(ViewCalendarLayout.WEEK);
|
||||
});
|
||||
|
||||
it('renders month when a persisted week layout is disabled', () => {
|
||||
useIsFeatureEnabledMock.mockReturnValue(false);
|
||||
it.each([ViewCalendarLayout.DAY, ViewCalendarLayout.WEEK])(
|
||||
'renders month when a persisted %s layout is disabled',
|
||||
(calendarLayout) => {
|
||||
useAtomStateValueMock.mockReturnValue(calendarLayout);
|
||||
useIsFeatureEnabledMock.mockReturnValue(false);
|
||||
|
||||
render(<RecordCalendar />);
|
||||
|
||||
expect(screen.getByTestId('calendar-month')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('calendar-day')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('calendar-week')).not.toBeInTheDocument();
|
||||
expect(useIsFeatureEnabledMock).toHaveBeenCalledWith(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('renders day when the day layout is enabled', () => {
|
||||
useAtomStateValueMock.mockReturnValue(ViewCalendarLayout.DAY);
|
||||
useIsFeatureEnabledMock.mockReturnValue(true);
|
||||
|
||||
render(<RecordCalendar />);
|
||||
|
||||
expect(screen.getByTestId('calendar-month')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('calendar-day')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('calendar-week')).not.toBeInTheDocument();
|
||||
expect(useIsFeatureEnabledMock).toHaveBeenCalledWith(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
expect(screen.queryByTestId('calendar-month')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders week when the week layout is enabled', () => {
|
||||
@@ -82,6 +104,7 @@ describe('RecordCalendar', () => {
|
||||
render(<RecordCalendar />);
|
||||
|
||||
expect(screen.getByTestId('calendar-week')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('calendar-day')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('calendar-month')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { enUS } from 'date-fns/locale';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { RecordCalendarTopBar } from '@/object-record/record-calendar/components/RecordCalendarTopBar';
|
||||
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
|
||||
|
||||
const mockSetRecordCalendarSelectedDate = jest.fn();
|
||||
const mockSetRecordIndexCalendarLayout = jest.fn();
|
||||
const mockUpdateCurrentView = jest.fn();
|
||||
const mockUseAtomComponentState = jest.fn();
|
||||
const mockUseAtomState = jest.fn();
|
||||
const mockUseAtomStateValue = jest.fn();
|
||||
const mockUseRecordCalendarWeekDaysRange = jest.fn();
|
||||
|
||||
jest.mock('@/localization/hooks/useDateTimeFormat', () => ({
|
||||
useDateTimeFormat: jest.fn(() => ({ timeZone: 'UTC' })),
|
||||
}));
|
||||
jest.mock(
|
||||
'@/object-record/record-calendar/week/hooks/useRecordCalendarWeekDaysRange',
|
||||
() => ({
|
||||
useRecordCalendarWeekDaysRange: (...args: unknown[]) =>
|
||||
mockUseRecordCalendarWeekDaysRange(...args),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'@/ui/input/components/internal/date/components/DatePickerWithoutCalendar',
|
||||
() => ({ DatePickerWithoutCalendar: () => null }),
|
||||
);
|
||||
jest.mock(
|
||||
'@/ui/input/components/internal/date/components/TimeZoneAbbreviation',
|
||||
() => ({
|
||||
TimeZoneAbbreviation: () => <span data-testid="time-zone" />,
|
||||
}),
|
||||
);
|
||||
jest.mock('@/ui/input/components/Select', () => ({
|
||||
Select: ({ options }: { options: { label: string; value: string }[] }) => (
|
||||
<select data-testid="layout-select">
|
||||
{options.map(({ label, value }) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
jest.mock('@/ui/input/components/SelectControl', () => ({
|
||||
SelectControl: ({
|
||||
selectedOption,
|
||||
}: {
|
||||
selectedOption: { label: string };
|
||||
}) => <span data-testid="selected-date">{selectedOption.label}</span>,
|
||||
}));
|
||||
jest.mock('@/ui/layout/dropdown/components/Dropdown', () => ({
|
||||
Dropdown: ({ clickableComponent }: { clickableComponent: React.ReactNode }) =>
|
||||
clickableComponent,
|
||||
}));
|
||||
jest.mock('@/ui/layout/dropdown/hooks/useCloseDropdown', () => ({
|
||||
useCloseDropdown: jest.fn(() => ({ closeDropdown: jest.fn() })),
|
||||
}));
|
||||
jest.mock(
|
||||
'@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow',
|
||||
() => ({
|
||||
useAvailableComponentInstanceIdOrThrow: jest.fn(() => 'calendar-id'),
|
||||
}),
|
||||
);
|
||||
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomComponentState', () => ({
|
||||
useAtomComponentState: (...args: unknown[]) =>
|
||||
mockUseAtomComponentState(...args),
|
||||
}));
|
||||
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomState', () => ({
|
||||
useAtomState: (...args: unknown[]) => mockUseAtomState(...args),
|
||||
}));
|
||||
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => ({
|
||||
useAtomStateValue: (...args: unknown[]) => mockUseAtomStateValue(...args),
|
||||
}));
|
||||
jest.mock('@/views/hooks/useUpdateCurrentView', () => ({
|
||||
useUpdateCurrentView: jest.fn(() => ({
|
||||
updateCurrentView: mockUpdateCurrentView,
|
||||
})),
|
||||
}));
|
||||
jest.mock('@/workspace/hooks/useIsFeatureEnabled', () => ({
|
||||
useIsFeatureEnabled: jest.fn(() => true),
|
||||
}));
|
||||
jest.mock('twenty-ui/input', () => ({
|
||||
Button: ({
|
||||
ariaLabel,
|
||||
onClick,
|
||||
title,
|
||||
}: {
|
||||
ariaLabel?: string;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
}) => (
|
||||
<button aria-label={ariaLabel} onClick={onClick}>
|
||||
{title}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('RecordCalendarTopBar', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseAtomComponentState.mockReturnValue([
|
||||
Temporal.PlainDate.from('2026-07-15'),
|
||||
mockSetRecordCalendarSelectedDate,
|
||||
]);
|
||||
mockUseAtomState.mockReturnValue([
|
||||
ViewCalendarLayout.DAY,
|
||||
mockSetRecordIndexCalendarLayout,
|
||||
]);
|
||||
mockUseAtomStateValue.mockReturnValue({
|
||||
locale: 'en-US',
|
||||
localeCatalog: enUS,
|
||||
});
|
||||
mockUseRecordCalendarWeekDaysRange.mockReturnValue({
|
||||
firstDayOfWeek: Temporal.PlainDate.from('2026-07-13'),
|
||||
lastDayOfWeek: Temporal.PlainDate.from('2026-07-19'),
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Day, Week, and Month with the selected full date', () => {
|
||||
render(<RecordCalendarTopBar />);
|
||||
|
||||
expect(screen.getByTestId('selected-date')).toHaveTextContent(
|
||||
'Wednesday, July 15, 2026',
|
||||
);
|
||||
expect(
|
||||
Array.from(
|
||||
screen.getByTestId('layout-select').querySelectorAll('option'),
|
||||
).map((option) => option.textContent),
|
||||
).toEqual(['Day', 'Week', 'Month']);
|
||||
expect(screen.queryByTestId('time-zone')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates the Day layout one day at a time', () => {
|
||||
render(<RecordCalendarTopBar />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous period' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next period' }));
|
||||
|
||||
expect(mockSetRecordCalendarSelectedDate).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
Temporal.PlainDate.from('2026-07-14'),
|
||||
);
|
||||
expect(mockSetRecordCalendarSelectedDate).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
Temporal.PlainDate.from('2026-07-16'),
|
||||
);
|
||||
});
|
||||
});
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
|
||||
import { RecordCalendarTimeGrid } from '@/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { format } from 'date-fns';
|
||||
import { turnPlainDateToShiftedDateInSystemTimeZone } from 'twenty-shared/utils';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
|
||||
export const RecordCalendarDay = () => {
|
||||
const recordCalendarId = useAvailableComponentInstanceIdOrThrow(
|
||||
RecordCalendarComponentInstanceContext,
|
||||
);
|
||||
const recordCalendarSelectedDate = useAtomComponentStateValue(
|
||||
recordCalendarSelectedDateComponentState,
|
||||
recordCalendarId,
|
||||
);
|
||||
const dateLocale = useAtomStateValue(dateLocaleState);
|
||||
|
||||
return (
|
||||
<RecordCalendarTimeGrid
|
||||
days={[
|
||||
{
|
||||
date: recordCalendarSelectedDate,
|
||||
label: format(
|
||||
turnPlainDateToShiftedDateInSystemTimeZone(
|
||||
recordCalendarSelectedDate,
|
||||
),
|
||||
'EEE',
|
||||
{ locale: dateLocale.localeCatalog },
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { RecordCalendarDay } from '@/object-record/record-calendar/day/components/RecordCalendarDay';
|
||||
|
||||
jest.mock(
|
||||
'@/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid',
|
||||
() => ({
|
||||
RecordCalendarTimeGrid: ({
|
||||
days,
|
||||
}: {
|
||||
days: { date: Temporal.PlainDate; label: string }[];
|
||||
}) => (
|
||||
<div data-testid="time-grid" data-day-count={days.length}>
|
||||
{days.map(({ date, label }) => `${date.toString()}:${label}`).join(',')}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow',
|
||||
() => ({
|
||||
useAvailableComponentInstanceIdOrThrow: jest.fn(() => 'calendar-id'),
|
||||
}),
|
||||
);
|
||||
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue', () => {
|
||||
const { Temporal } = jest.requireActual('temporal-polyfill');
|
||||
|
||||
return {
|
||||
useAtomComponentStateValue: jest.fn(() =>
|
||||
Temporal.PlainDate.from('2026-07-15'),
|
||||
),
|
||||
};
|
||||
});
|
||||
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => {
|
||||
const { enUS } = jest.requireActual('date-fns/locale');
|
||||
|
||||
return {
|
||||
useAtomStateValue: jest.fn(() => ({ localeCatalog: enUS })),
|
||||
};
|
||||
});
|
||||
|
||||
describe('RecordCalendarDay', () => {
|
||||
it('renders the selected date as the only time-grid day', () => {
|
||||
render(<RecordCalendarDay />);
|
||||
|
||||
expect(screen.getByTestId('time-grid')).toHaveAttribute(
|
||||
'data-day-count',
|
||||
'1',
|
||||
);
|
||||
expect(screen.getByTestId('time-grid')).toHaveTextContent('2026-07-15:Wed');
|
||||
});
|
||||
});
|
||||
+650
@@ -0,0 +1,650 @@
|
||||
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
|
||||
import { RecordCalendarAddNew } from '@/object-record/record-calendar/components/RecordCalendarAddNew';
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector';
|
||||
import { RecordCalendarTimeGridAllDayCell } from '@/object-record/record-calendar/time-grid/components/RecordCalendarTimeGridAllDayCell';
|
||||
import { RecordCalendarWeekEvent } from '@/object-record/record-calendar/week/components/RecordCalendarWeekEvent';
|
||||
import { RecordCalendarWeekDragDropContext } from '@/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext';
|
||||
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
|
||||
import { computeRecordCalendarWeekEventLayouts } from '@/object-record/record-calendar/week/utils/computeRecordCalendarWeekEventLayouts';
|
||||
import { getRecordCalendarWeekTimedEventMetrics } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekTimedEventMetrics';
|
||||
import { getRecordCalendarWeekSlotIndex } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekSlotIndex';
|
||||
import {
|
||||
type RecordCalendarWeekActiveSlot,
|
||||
type RecordCalendarWeekSlotInteractionMode,
|
||||
updateRecordCalendarWeekActiveSlot,
|
||||
} from '@/object-record/record-calendar/week/utils/updateRecordCalendarWeekActiveSlot';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation';
|
||||
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { format } from 'date-fns';
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
import { useStore } from 'jotai';
|
||||
import {
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
isDefined,
|
||||
isPlainDateInWeekend,
|
||||
isSamePlainDate,
|
||||
} from 'twenty-shared/utils';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
const CURRENT_TIME_REFRESH_INTERVAL_IN_MILLISECONDS = 60_000;
|
||||
const DEFAULT_KEYBOARD_SLOT_INDEX = 18;
|
||||
|
||||
const StyledContainer = styled.div<{ minWidthInPixels: number }>`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
border-top: 0;
|
||||
min-width: ${({ minWidthInPixels }) => `${minWidthInPixels}px`};
|
||||
overflow: clip;
|
||||
`;
|
||||
|
||||
const StyledHeader = styled.div<{ dayCount: number }>`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border-bottom: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-top: 1px solid ${themeCssVariables.border.color.light};
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px
|
||||
repeat(${({ dayCount }) => dayCount}, minmax(120px, 1fr));
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
`;
|
||||
|
||||
const StyledHeaderGutter = styled.div`
|
||||
align-items: center;
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
height: 32px;
|
||||
justify-content: flex-end;
|
||||
padding-right: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledDayHeader = styled.div`
|
||||
align-items: center;
|
||||
border-left: 1px solid ${themeCssVariables.border.color.light};
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledDayNumber = styled.span<{ isToday: boolean }>`
|
||||
align-items: center;
|
||||
background: ${({ isToday }) =>
|
||||
isToday ? themeCssVariables.color.blue : 'transparent'};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
color: ${({ isToday }) =>
|
||||
isToday
|
||||
? themeCssVariables.font.color.inverted
|
||||
: themeCssVariables.font.color.primary};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
font-weight: ${({ isToday }) =>
|
||||
isToday
|
||||
? themeCssVariables.font.weight.medium
|
||||
: themeCssVariables.font.weight.regular};
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
const StyledAllDayLabel = styled(StyledHeaderGutter)`
|
||||
border-top: 1px solid ${themeCssVariables.border.color.light};
|
||||
height: 28px;
|
||||
`;
|
||||
|
||||
const StyledGrid = styled.div<{ dayCount: number }>`
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px
|
||||
repeat(${({ dayCount }) => dayCount}, minmax(120px, 1fr));
|
||||
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledTimeGutter = styled.div`
|
||||
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledHourLabel = styled.span<{ topInPixels: number }>`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
position: absolute;
|
||||
right: ${themeCssVariables.spacing[1]};
|
||||
top: ${({ topInPixels }) => `${topInPixels}px`};
|
||||
transform: translateY(-50%);
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledDayColumn = styled.div<{ isWeekend: boolean }>`
|
||||
background-color: ${({ isWeekend }) =>
|
||||
isWeekend
|
||||
? themeCssVariables.background.secondary
|
||||
: themeCssVariables.background.primary};
|
||||
background-image: repeating-linear-gradient(
|
||||
to bottom,
|
||||
${themeCssVariables.border.color.light} 0,
|
||||
${themeCssVariables.border.color.light} 1px,
|
||||
transparent 1px,
|
||||
transparent ${RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight}px
|
||||
);
|
||||
border-left: 1px solid ${themeCssVariables.border.color.light};
|
||||
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledSlotAddNewPositioner = styled.div<{ topInPixels: number }>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.slotHeight}px;
|
||||
left: ${themeCssVariables.spacing['0.5']};
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: ${({ topInPixels }) => `${topInPixels}px`};
|
||||
z-index: 0;
|
||||
|
||||
> div {
|
||||
pointer-events: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCurrentTimeLine = styled.div<{ topInPixels: number }>`
|
||||
background: ${themeCssVariables.color.red8};
|
||||
height: 1px;
|
||||
left: ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: ${({ topInPixels }) => `${topInPixels}px`};
|
||||
z-index: 2;
|
||||
|
||||
&::after {
|
||||
background: ${themeCssVariables.color.red8};
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
height: 5px;
|
||||
left: -2px;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 5px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCurrentTimeLabel = styled.span<{ topInPixels: number }>`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
color: ${themeCssVariables.color.red9};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
padding-right: ${themeCssVariables.spacing['0.5']};
|
||||
position: absolute;
|
||||
right: calc(100% - ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth - 1}px);
|
||||
top: ${({ topInPixels }) => `${topInPixels}px`};
|
||||
transform: translateY(-50%);
|
||||
white-space: nowrap;
|
||||
z-index: 3;
|
||||
`;
|
||||
|
||||
const StyledScrollAnchor = styled.div<{ topInPixels: number }>`
|
||||
height: 1px;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: ${({ topInPixels }) => `${topInPixels}px`};
|
||||
width: 1px;
|
||||
`;
|
||||
|
||||
type WeekDayCellProps = {
|
||||
calendarEndFieldName?: string;
|
||||
calendarFieldName: string;
|
||||
calendarFieldType: FieldMetadataType;
|
||||
day: Temporal.PlainDate;
|
||||
timeFormat: string;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
type RecordCalendarWeekDayColumnProps = Omit<WeekDayCellProps, 'day'> & {
|
||||
activeSlotIndex: number | null;
|
||||
dayString: string;
|
||||
onActiveSlotIndexChange: (
|
||||
day: string,
|
||||
slotIndex: number | null,
|
||||
interactionMode: RecordCalendarWeekSlotInteractionMode,
|
||||
) => void;
|
||||
};
|
||||
|
||||
const RecordCalendarWeekDayColumn = memo(
|
||||
({
|
||||
activeSlotIndex,
|
||||
calendarEndFieldName,
|
||||
calendarFieldName,
|
||||
calendarFieldType,
|
||||
dayString,
|
||||
onActiveSlotIndexChange,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
}: RecordCalendarWeekDayColumnProps) => {
|
||||
const day = Temporal.PlainDate.from(dayString);
|
||||
const store = useStore();
|
||||
const recordIds = useAtomComponentFamilySelectorValue(
|
||||
calendarDayRecordIdsComponentFamilySelector,
|
||||
{ day, timeZone },
|
||||
);
|
||||
|
||||
const eventLayoutInputs =
|
||||
calendarFieldType === FieldMetadataType.DATE
|
||||
? []
|
||||
: recordIds
|
||||
.map((recordId) => {
|
||||
const record = store.get(
|
||||
recordStoreFamilyState.atomFamily(recordId),
|
||||
);
|
||||
const recordDate = record?.[calendarFieldName];
|
||||
const recordEndDate = isDefined(calendarEndFieldName)
|
||||
? record?.[calendarEndFieldName]
|
||||
: undefined;
|
||||
const metrics = getRecordCalendarWeekTimedEventMetrics({
|
||||
day,
|
||||
startDateTime: recordDate,
|
||||
endDateTime: recordEndDate,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
if (metrics === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...metrics,
|
||||
recordId,
|
||||
};
|
||||
})
|
||||
.filter(isDefined);
|
||||
|
||||
const eventLayouts =
|
||||
computeRecordCalendarWeekEventLayouts(eventLayoutInputs);
|
||||
|
||||
const handleMouseMove = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest('[data-selectable-id]') !== null
|
||||
) {
|
||||
onActiveSlotIndexChange(dayString, null, 'pointer');
|
||||
return;
|
||||
}
|
||||
|
||||
const columnRect = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
onActiveSlotIndexChange(
|
||||
dayString,
|
||||
getRecordCalendarWeekSlotIndex({
|
||||
columnHeight: columnRect.height,
|
||||
columnTop: columnRect.top,
|
||||
pointerY: event.clientY,
|
||||
}),
|
||||
'pointer',
|
||||
);
|
||||
};
|
||||
|
||||
const handleFocus = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
event.target === event.currentTarget &&
|
||||
event.currentTarget.matches(':focus-visible')
|
||||
) {
|
||||
onActiveSlotIndexChange(
|
||||
dayString,
|
||||
activeSlotIndex ?? DEFAULT_KEYBOARD_SLOT_INDEX,
|
||||
'keyboard',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
onActiveSlotIndexChange(dayString, null, 'keyboard');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const slotCount =
|
||||
(RECORD_CALENDAR_WEEK_DIMENSIONS.hoursInDay * 60) /
|
||||
RECORD_CALENDAR_WEEK_DIMENSIONS.snapIntervalInMinutes;
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
|
||||
const slotDelta = event.key === 'ArrowDown' ? 1 : -1;
|
||||
|
||||
onActiveSlotIndexChange(
|
||||
dayString,
|
||||
Math.min(
|
||||
slotCount - 1,
|
||||
Math.max(
|
||||
0,
|
||||
(activeSlotIndex ?? DEFAULT_KEYBOARD_SLOT_INDEX) + slotDelta,
|
||||
),
|
||||
),
|
||||
'keyboard',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledDayColumn
|
||||
aria-label={day.toLocaleString(undefined, { dateStyle: 'full' })}
|
||||
isWeekend={isPlainDateInWeekend(day)}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseLeave={() => onActiveSlotIndexChange(dayString, null, 'pointer')}
|
||||
onMouseMove={handleMouseMove}
|
||||
tabIndex={0}
|
||||
>
|
||||
{isDefined(activeSlotIndex) && (
|
||||
<StyledSlotAddNewPositioner
|
||||
data-testid="record-calendar-week-slot-add"
|
||||
topInPixels={
|
||||
activeSlotIndex * RECORD_CALENDAR_WEEK_DIMENSIONS.slotHeight
|
||||
}
|
||||
>
|
||||
<RecordCalendarAddNew
|
||||
cardDate={day}
|
||||
cardTime={Temporal.PlainTime.from('00:00').add({
|
||||
minutes:
|
||||
activeSlotIndex *
|
||||
RECORD_CALENDAR_WEEK_DIMENSIONS.snapIntervalInMinutes,
|
||||
})}
|
||||
compact
|
||||
/>
|
||||
</StyledSlotAddNewPositioner>
|
||||
)}
|
||||
{eventLayouts.map(
|
||||
({
|
||||
columnCount,
|
||||
columnIndex,
|
||||
endInPixels,
|
||||
recordId,
|
||||
startInPixels,
|
||||
}) => (
|
||||
<RecordCalendarWeekEvent
|
||||
key={recordId}
|
||||
calendarDay={day}
|
||||
calendarEndFieldName={calendarEndFieldName}
|
||||
calendarFieldName={calendarFieldName}
|
||||
calendarFieldType={calendarFieldType}
|
||||
columnCount={columnCount}
|
||||
columnIndex={columnIndex}
|
||||
endInPixels={endInPixels}
|
||||
isAllDay={false}
|
||||
recordId={recordId}
|
||||
startInPixels={startInPixels}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</StyledDayColumn>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type RecordCalendarTimeGridDay = {
|
||||
date: Temporal.PlainDate;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type RecordCalendarTimeGridProps = {
|
||||
days: readonly RecordCalendarTimeGridDay[];
|
||||
minWidthInPixels?: number;
|
||||
};
|
||||
|
||||
export const RecordCalendarTimeGrid = ({
|
||||
days,
|
||||
minWidthInPixels = 0,
|
||||
}: RecordCalendarTimeGridProps) => {
|
||||
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
|
||||
const { timeFormat, timeZone } = useDateTimeFormat();
|
||||
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
);
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
);
|
||||
const scrollAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [currentInstant, setCurrentInstant] = useState(() =>
|
||||
Temporal.Now.instant(),
|
||||
);
|
||||
const [activeSlot, setActiveSlot] =
|
||||
useState<RecordCalendarWeekActiveSlot | null>(null);
|
||||
|
||||
const handleActiveSlotIndexChange = useCallback(
|
||||
(
|
||||
day: string,
|
||||
slotIndex: number | null,
|
||||
interactionMode: RecordCalendarWeekSlotInteractionMode,
|
||||
) => {
|
||||
setActiveSlot((currentActiveSlot) =>
|
||||
updateRecordCalendarWeekActiveSlot({
|
||||
currentActiveSlot,
|
||||
day,
|
||||
interactionMode,
|
||||
slotIndex,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearActivePointerSlot = useCallback(() => {
|
||||
setActiveSlot((currentActiveSlot) =>
|
||||
currentActiveSlot?.interactionMode === 'pointer'
|
||||
? null
|
||||
: currentActiveSlot,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentMouseMove = (event: globalThis.MouseEvent) => {
|
||||
const eventTarget = event.target;
|
||||
|
||||
if (
|
||||
!(eventTarget instanceof Node) ||
|
||||
gridRef.current?.contains(eventTarget) !== true
|
||||
) {
|
||||
clearActivePointerSlot();
|
||||
}
|
||||
};
|
||||
|
||||
const clearActiveSlot = () => setActiveSlot(null);
|
||||
|
||||
document.addEventListener('mousemove', handleDocumentMouseMove);
|
||||
document.addEventListener('mouseleave', clearActivePointerSlot);
|
||||
window.addEventListener('blur', clearActiveSlot);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleDocumentMouseMove);
|
||||
document.removeEventListener('mouseleave', clearActivePointerSlot);
|
||||
window.removeEventListener('blur', clearActiveSlot);
|
||||
};
|
||||
}, [clearActivePointerSlot]);
|
||||
|
||||
const calendarFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(field) => field.id === recordIndexCalendarFieldMetadataId,
|
||||
);
|
||||
const calendarEndFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(field) => field.id === recordIndexCalendarEndFieldMetadataId,
|
||||
);
|
||||
|
||||
const now = currentInstant.toZonedDateTimeISO(timeZone);
|
||||
const today = now.toPlainDate();
|
||||
const isCurrentPeriod = days.some(({ date }) => isSamePlainDate(date, today));
|
||||
const currentTimeTopInPixels =
|
||||
(now.hour + now.minute / 60) * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight;
|
||||
const initialScrollHour = isCurrentPeriod ? Math.max(now.hour - 2, 0) : 8;
|
||||
const periodKey = days.map(({ date }) => date.toString()).join(',');
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
setCurrentInstant(Temporal.Now.instant());
|
||||
}, CURRENT_TIME_REFRESH_INTERVAL_IN_MILLISECONDS);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
const isAllDayView =
|
||||
calendarFieldMetadataItem?.type === FieldMetadataType.DATE;
|
||||
const isTimedView =
|
||||
calendarFieldMetadataItem?.type === FieldMetadataType.DATE_TIME;
|
||||
const compatibleCalendarEndFieldName =
|
||||
isTimedView &&
|
||||
calendarEndFieldMetadataItem?.type === FieldMetadataType.DATE_TIME
|
||||
? calendarEndFieldMetadataItem.name
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTimedView) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollAnchorRef.current?.scrollIntoView({
|
||||
block: 'center',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}, [isTimedView, periodKey]);
|
||||
|
||||
if (
|
||||
!isDefined(calendarFieldMetadataItem) ||
|
||||
(!isAllDayView && !isTimedView)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RecordCalendarWeekDragDropContext
|
||||
days={days.map(({ date }) => date)}
|
||||
gridRef={gridRef}
|
||||
>
|
||||
<StyledContainer minWidthInPixels={minWidthInPixels}>
|
||||
<StyledHeader dayCount={days.length}>
|
||||
<StyledHeaderGutter>
|
||||
{isTimedView && <TimeZoneAbbreviation instant={currentInstant} />}
|
||||
</StyledHeaderGutter>
|
||||
{days.map(({ date, label }) => {
|
||||
const isToday = isSamePlainDate(date, today);
|
||||
|
||||
return (
|
||||
<StyledDayHeader key={date.toString()}>
|
||||
<span>{label}</span>
|
||||
<StyledDayNumber isToday={isToday}>{date.day}</StyledDayNumber>
|
||||
</StyledDayHeader>
|
||||
);
|
||||
})}
|
||||
{isAllDayView && (
|
||||
<>
|
||||
<StyledAllDayLabel>{t`All day`}</StyledAllDayLabel>
|
||||
{days.map(({ date }) => (
|
||||
<RecordCalendarTimeGridAllDayCell
|
||||
key={`all-day-${date.toString()}`}
|
||||
calendarFieldName={calendarFieldMetadataItem.name}
|
||||
calendarFieldType={calendarFieldMetadataItem.type}
|
||||
day={date}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</StyledHeader>
|
||||
{isTimedView && (
|
||||
<StyledGrid
|
||||
ref={gridRef}
|
||||
dayCount={days.length}
|
||||
onMouseLeave={clearActivePointerSlot}
|
||||
>
|
||||
<StyledTimeGutter>
|
||||
{Array.from(
|
||||
{ length: RECORD_CALENDAR_WEEK_DIMENSIONS.hoursInDay },
|
||||
(_, hour) => (
|
||||
<StyledHourLabel
|
||||
key={hour}
|
||||
topInPixels={
|
||||
hour * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight
|
||||
}
|
||||
>
|
||||
{format(new Date(2000, 0, 1, hour), timeFormat)}
|
||||
</StyledHourLabel>
|
||||
),
|
||||
)}
|
||||
<StyledScrollAnchor
|
||||
ref={scrollAnchorRef}
|
||||
topInPixels={
|
||||
initialScrollHour * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight
|
||||
}
|
||||
/>
|
||||
</StyledTimeGutter>
|
||||
{days.map(({ date }) => {
|
||||
const dateString = date.toString();
|
||||
|
||||
return (
|
||||
<RecordCalendarWeekDayColumn
|
||||
key={dateString}
|
||||
activeSlotIndex={
|
||||
activeSlot?.day === dateString ? activeSlot.slotIndex : null
|
||||
}
|
||||
calendarEndFieldName={compatibleCalendarEndFieldName}
|
||||
calendarFieldName={calendarFieldMetadataItem.name}
|
||||
calendarFieldType={calendarFieldMetadataItem.type}
|
||||
dayString={dateString}
|
||||
onActiveSlotIndexChange={handleActiveSlotIndexChange}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isCurrentPeriod && (
|
||||
<>
|
||||
<StyledCurrentTimeLine topInPixels={currentTimeTopInPixels} />
|
||||
<StyledCurrentTimeLabel topInPixels={currentTimeTopInPixels}>
|
||||
{formatInTimeZone(
|
||||
new Date(currentInstant.toString()),
|
||||
timeZone,
|
||||
timeFormat,
|
||||
)}
|
||||
</StyledCurrentTimeLabel>
|
||||
</>
|
||||
)}
|
||||
</StyledGrid>
|
||||
)}
|
||||
</StyledContainer>
|
||||
</RecordCalendarWeekDragDropContext>
|
||||
);
|
||||
};
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector';
|
||||
import { RecordCalendarWeekEvent } from '@/object-record/record-calendar/week/components/RecordCalendarWeekEvent';
|
||||
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledAllDayCell = styled.div`
|
||||
border-left: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-top: 1px solid ${themeCssVariables.border.color.light};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing['0.5']};
|
||||
min-height: 28px;
|
||||
min-width: 0;
|
||||
padding: ${themeCssVariables.spacing['0.5']};
|
||||
`;
|
||||
|
||||
type RecordCalendarTimeGridAllDayCellProps = {
|
||||
calendarFieldName: string;
|
||||
calendarFieldType: FieldMetadataType;
|
||||
day: Temporal.PlainDate;
|
||||
timeFormat: string;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export const RecordCalendarTimeGridAllDayCell = ({
|
||||
calendarFieldName,
|
||||
calendarFieldType,
|
||||
day,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
}: RecordCalendarTimeGridAllDayCellProps) => {
|
||||
const recordIds = useAtomComponentFamilySelectorValue(
|
||||
calendarDayRecordIdsComponentFamilySelector,
|
||||
{ day, timeZone },
|
||||
);
|
||||
|
||||
const allDayRecordIds =
|
||||
calendarFieldType === FieldMetadataType.DATE ? recordIds : [];
|
||||
|
||||
return (
|
||||
<StyledAllDayCell>
|
||||
{allDayRecordIds.map((recordId) => (
|
||||
<RecordCalendarWeekEvent
|
||||
key={recordId}
|
||||
calendarDay={day}
|
||||
calendarFieldName={calendarFieldName}
|
||||
calendarFieldType={calendarFieldType}
|
||||
isAllDay
|
||||
recordId={recordId}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
))}
|
||||
</StyledAllDayCell>
|
||||
);
|
||||
};
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { RecordCalendarTimeGridAllDayCell } from '@/object-record/record-calendar/time-grid/components/RecordCalendarTimeGridAllDayCell';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
const mockUseAtomComponentFamilySelectorValue = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
'@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue',
|
||||
() => ({
|
||||
useAtomComponentFamilySelectorValue: (...args: unknown[]) =>
|
||||
mockUseAtomComponentFamilySelectorValue(...args),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'@/object-record/record-calendar/week/components/RecordCalendarWeekEvent',
|
||||
() => ({
|
||||
RecordCalendarWeekEvent: ({
|
||||
isAllDay,
|
||||
recordId,
|
||||
}: {
|
||||
isAllDay: boolean;
|
||||
recordId: string;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="all-day-event"
|
||||
data-is-all-day={isAllDay}
|
||||
data-record-id={recordId}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
describe('RecordCalendarTimeGridAllDayCell', () => {
|
||||
it('renders every all-day record in order', () => {
|
||||
mockUseAtomComponentFamilySelectorValue.mockReturnValue([
|
||||
'first',
|
||||
'second',
|
||||
'third',
|
||||
'fourth',
|
||||
'fifth',
|
||||
]);
|
||||
|
||||
render(
|
||||
<RecordCalendarTimeGridAllDayCell
|
||||
calendarFieldName="date"
|
||||
calendarFieldType={FieldMetadataType.DATE}
|
||||
day={Temporal.PlainDate.from('2026-07-15')}
|
||||
timeFormat="HH:mm"
|
||||
timeZone="UTC"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getAllByTestId('all-day-event').map((element) => ({
|
||||
isAllDay: element.dataset.isAllDay,
|
||||
recordId: element.dataset.recordId,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
isAllDay: 'true',
|
||||
recordId: 'first',
|
||||
},
|
||||
{
|
||||
isAllDay: 'true',
|
||||
recordId: 'second',
|
||||
},
|
||||
{
|
||||
isAllDay: 'true',
|
||||
recordId: 'third',
|
||||
},
|
||||
{
|
||||
isAllDay: 'true',
|
||||
recordId: 'fourth',
|
||||
},
|
||||
{
|
||||
isAllDay: 'true',
|
||||
recordId: 'fifth',
|
||||
},
|
||||
]);
|
||||
expect(screen.queryByText(/^\+\d+$/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -5,7 +5,7 @@ describe('getSupportedRecordCalendarLayout', () => {
|
||||
it.each([
|
||||
[ViewCalendarLayout.WEEK, ViewCalendarLayout.WEEK],
|
||||
[ViewCalendarLayout.MONTH, ViewCalendarLayout.MONTH],
|
||||
[ViewCalendarLayout.DAY, ViewCalendarLayout.MONTH],
|
||||
[ViewCalendarLayout.DAY, ViewCalendarLayout.DAY],
|
||||
[null, ViewCalendarLayout.MONTH],
|
||||
[undefined, ViewCalendarLayout.MONTH],
|
||||
])(
|
||||
|
||||
+8
-3
@@ -8,7 +8,12 @@ type GetSupportedRecordCalendarLayoutArgs = {
|
||||
export const getSupportedRecordCalendarLayout = ({
|
||||
calendarLayout,
|
||||
isCalendarWeekViewEnabled,
|
||||
}: GetSupportedRecordCalendarLayoutArgs) =>
|
||||
isCalendarWeekViewEnabled && calendarLayout === ViewCalendarLayout.WEEK
|
||||
? ViewCalendarLayout.WEEK
|
||||
}: GetSupportedRecordCalendarLayoutArgs) => {
|
||||
const isTimeGridLayout =
|
||||
calendarLayout === ViewCalendarLayout.DAY ||
|
||||
calendarLayout === ViewCalendarLayout.WEEK;
|
||||
|
||||
return isCalendarWeekViewEnabled && isTimeGridLayout
|
||||
? calendarLayout
|
||||
: ViewCalendarLayout.MONTH;
|
||||
};
|
||||
|
||||
+8
-684
@@ -1,483 +1,13 @@
|
||||
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
|
||||
import { RecordCalendarAddNew } from '@/object-record/record-calendar/components/RecordCalendarAddNew';
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
|
||||
import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector';
|
||||
import { RecordCalendarWeekEvent } from '@/object-record/record-calendar/week/components/RecordCalendarWeekEvent';
|
||||
import { RecordCalendarWeekDragDropContext } from '@/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext';
|
||||
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
|
||||
import { RecordCalendarTimeGrid } from '@/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid';
|
||||
import { useRecordCalendarWeekDaysRange } from '@/object-record/record-calendar/week/hooks/useRecordCalendarWeekDaysRange';
|
||||
import { computeRecordCalendarWeekEventLayouts } from '@/object-record/record-calendar/week/utils/computeRecordCalendarWeekEventLayouts';
|
||||
import { getRecordCalendarWeekTimedEventMetrics } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekTimedEventMetrics';
|
||||
import { getRecordCalendarWeekSlotIndex } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekSlotIndex';
|
||||
import {
|
||||
type RecordCalendarWeekActiveSlot,
|
||||
type RecordCalendarWeekSlotInteractionMode,
|
||||
updateRecordCalendarWeekActiveSlot,
|
||||
} from '@/object-record/record-calendar/week/utils/updateRecordCalendarWeekActiveSlot';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { format } from 'date-fns';
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
import { useStore } from 'jotai';
|
||||
import {
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
isDefined,
|
||||
isPlainDateInWeekend,
|
||||
isSamePlainDate,
|
||||
} from 'twenty-shared/utils';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
const CURRENT_TIME_REFRESH_INTERVAL_IN_MILLISECONDS = 60_000;
|
||||
const DEFAULT_KEYBOARD_SLOT_INDEX = 18;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
min-width: 1000px;
|
||||
overflow: clip;
|
||||
`;
|
||||
|
||||
const StyledHeader = styled.div`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border-bottom: 1px solid ${themeCssVariables.border.color.light};
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px
|
||||
repeat(7, minmax(120px, 1fr));
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
`;
|
||||
|
||||
const StyledHeaderGutter = styled.div`
|
||||
align-items: center;
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
height: 32px;
|
||||
justify-content: flex-end;
|
||||
padding-right: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledDayHeader = styled.div`
|
||||
align-items: center;
|
||||
border-left: 1px solid ${themeCssVariables.border.color.light};
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledDayNumber = styled.span<{ isToday: boolean }>`
|
||||
align-items: center;
|
||||
background: ${({ isToday }) =>
|
||||
isToday ? themeCssVariables.color.blue : 'transparent'};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
color: ${({ isToday }) =>
|
||||
isToday
|
||||
? themeCssVariables.font.color.inverted
|
||||
: themeCssVariables.font.color.primary};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
font-weight: ${({ isToday }) =>
|
||||
isToday
|
||||
? themeCssVariables.font.weight.medium
|
||||
: themeCssVariables.font.weight.regular};
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
`;
|
||||
|
||||
const StyledAllDayLabel = styled(StyledHeaderGutter)`
|
||||
border-top: 1px solid ${themeCssVariables.border.color.light};
|
||||
height: 28px;
|
||||
`;
|
||||
|
||||
const StyledAllDayCell = styled.div`
|
||||
align-items: center;
|
||||
border-left: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-top: 1px solid ${themeCssVariables.border.color.light};
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing['0.5']};
|
||||
height: 28px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: ${themeCssVariables.spacing['0.5']};
|
||||
`;
|
||||
|
||||
const StyledAdditionalEventCount = styled.span`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px
|
||||
repeat(7, minmax(120px, 1fr));
|
||||
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledTimeGutter = styled.div`
|
||||
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledHourLabel = styled.span<{ topInPixels: number }>`
|
||||
color: ${themeCssVariables.font.color.light};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
position: absolute;
|
||||
right: ${themeCssVariables.spacing[1]};
|
||||
top: ${({ topInPixels }) => `${topInPixels}px`};
|
||||
transform: translateY(-50%);
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledDayColumn = styled.div<{ isWeekend: boolean }>`
|
||||
background-color: ${({ isWeekend }) =>
|
||||
isWeekend
|
||||
? themeCssVariables.background.secondary
|
||||
: themeCssVariables.background.primary};
|
||||
background-image: repeating-linear-gradient(
|
||||
to bottom,
|
||||
${themeCssVariables.border.color.light} 0,
|
||||
${themeCssVariables.border.color.light} 1px,
|
||||
transparent 1px,
|
||||
transparent ${RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight}px
|
||||
);
|
||||
border-left: 1px solid ${themeCssVariables.border.color.light};
|
||||
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledSlotAddNewPositioner = styled.div<{ topInPixels: number }>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.slotHeight}px;
|
||||
left: ${themeCssVariables.spacing['0.5']};
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: ${({ topInPixels }) => `${topInPixels}px`};
|
||||
z-index: 0;
|
||||
|
||||
> div {
|
||||
pointer-events: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCurrentTimeLine = styled.div<{ topInPixels: number }>`
|
||||
background: ${themeCssVariables.color.red8};
|
||||
height: 1px;
|
||||
left: ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: ${({ topInPixels }) => `${topInPixels}px`};
|
||||
z-index: 2;
|
||||
|
||||
&::after {
|
||||
background: ${themeCssVariables.color.red8};
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
height: 5px;
|
||||
left: -2px;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 5px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCurrentTimeLabel = styled.span<{ topInPixels: number }>`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
color: ${themeCssVariables.color.red9};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
padding-right: ${themeCssVariables.spacing['0.5']};
|
||||
position: absolute;
|
||||
right: calc(100% - ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth - 1}px);
|
||||
top: ${({ topInPixels }) => `${topInPixels}px`};
|
||||
transform: translateY(-50%);
|
||||
white-space: nowrap;
|
||||
z-index: 3;
|
||||
`;
|
||||
|
||||
const StyledScrollAnchor = styled.div<{ topInPixels: number }>`
|
||||
height: 1px;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: ${({ topInPixels }) => `${topInPixels}px`};
|
||||
width: 1px;
|
||||
`;
|
||||
|
||||
type WeekDayCellProps = {
|
||||
calendarEndFieldName?: string;
|
||||
calendarFieldName: string;
|
||||
calendarFieldType: FieldMetadataType;
|
||||
day: Temporal.PlainDate;
|
||||
timeFormat: string;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
type RecordCalendarWeekAllDayCellProps = WeekDayCellProps;
|
||||
type RecordCalendarWeekDayColumnProps = Omit<WeekDayCellProps, 'day'> & {
|
||||
activeSlotIndex: number | null;
|
||||
dayString: string;
|
||||
onActiveSlotIndexChange: (
|
||||
day: string,
|
||||
slotIndex: number | null,
|
||||
interactionMode: RecordCalendarWeekSlotInteractionMode,
|
||||
) => void;
|
||||
};
|
||||
|
||||
const RecordCalendarWeekAllDayCell = ({
|
||||
calendarFieldName,
|
||||
calendarFieldType,
|
||||
day,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
}: RecordCalendarWeekAllDayCellProps) => {
|
||||
const recordIds = useAtomComponentFamilySelectorValue(
|
||||
calendarDayRecordIdsComponentFamilySelector,
|
||||
{ day, timeZone },
|
||||
);
|
||||
|
||||
const allDayRecordIds =
|
||||
calendarFieldType === FieldMetadataType.DATE ? recordIds : [];
|
||||
|
||||
return (
|
||||
<StyledAllDayCell>
|
||||
{allDayRecordIds.slice(0, 1).map((recordId) => (
|
||||
<RecordCalendarWeekEvent
|
||||
key={recordId}
|
||||
calendarDay={day}
|
||||
calendarFieldName={calendarFieldName}
|
||||
calendarFieldType={calendarFieldType}
|
||||
isAllDay
|
||||
recordId={recordId}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
))}
|
||||
{allDayRecordIds.length > 1 && (
|
||||
<StyledAdditionalEventCount>
|
||||
+{allDayRecordIds.length - 1}
|
||||
</StyledAdditionalEventCount>
|
||||
)}
|
||||
</StyledAllDayCell>
|
||||
);
|
||||
};
|
||||
|
||||
const RecordCalendarWeekDayColumn = memo(
|
||||
({
|
||||
activeSlotIndex,
|
||||
calendarEndFieldName,
|
||||
calendarFieldName,
|
||||
calendarFieldType,
|
||||
dayString,
|
||||
onActiveSlotIndexChange,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
}: RecordCalendarWeekDayColumnProps) => {
|
||||
const day = Temporal.PlainDate.from(dayString);
|
||||
const store = useStore();
|
||||
const recordIds = useAtomComponentFamilySelectorValue(
|
||||
calendarDayRecordIdsComponentFamilySelector,
|
||||
{ day, timeZone },
|
||||
);
|
||||
|
||||
const eventLayoutInputs =
|
||||
calendarFieldType === FieldMetadataType.DATE
|
||||
? []
|
||||
: recordIds
|
||||
.map((recordId) => {
|
||||
const record = store.get(
|
||||
recordStoreFamilyState.atomFamily(recordId),
|
||||
);
|
||||
const recordDate = record?.[calendarFieldName];
|
||||
const recordEndDate = isDefined(calendarEndFieldName)
|
||||
? record?.[calendarEndFieldName]
|
||||
: undefined;
|
||||
const metrics = getRecordCalendarWeekTimedEventMetrics({
|
||||
day,
|
||||
startDateTime: recordDate,
|
||||
endDateTime: recordEndDate,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
if (metrics === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...metrics,
|
||||
recordId,
|
||||
};
|
||||
})
|
||||
.filter(isDefined);
|
||||
|
||||
const eventLayouts =
|
||||
computeRecordCalendarWeekEventLayouts(eventLayoutInputs);
|
||||
|
||||
const handleMouseMove = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest('[data-selectable-id]') !== null
|
||||
) {
|
||||
onActiveSlotIndexChange(dayString, null, 'pointer');
|
||||
return;
|
||||
}
|
||||
|
||||
const columnRect = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
onActiveSlotIndexChange(
|
||||
dayString,
|
||||
getRecordCalendarWeekSlotIndex({
|
||||
columnHeight: columnRect.height,
|
||||
columnTop: columnRect.top,
|
||||
pointerY: event.clientY,
|
||||
}),
|
||||
'pointer',
|
||||
);
|
||||
};
|
||||
|
||||
const handleFocus = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
event.target === event.currentTarget &&
|
||||
event.currentTarget.matches(':focus-visible')
|
||||
) {
|
||||
onActiveSlotIndexChange(
|
||||
dayString,
|
||||
activeSlotIndex ?? DEFAULT_KEYBOARD_SLOT_INDEX,
|
||||
'keyboard',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
onActiveSlotIndexChange(dayString, null, 'keyboard');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const slotCount =
|
||||
(RECORD_CALENDAR_WEEK_DIMENSIONS.hoursInDay * 60) /
|
||||
RECORD_CALENDAR_WEEK_DIMENSIONS.snapIntervalInMinutes;
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
|
||||
const slotDelta = event.key === 'ArrowDown' ? 1 : -1;
|
||||
|
||||
onActiveSlotIndexChange(
|
||||
dayString,
|
||||
Math.min(
|
||||
slotCount - 1,
|
||||
Math.max(
|
||||
0,
|
||||
(activeSlotIndex ?? DEFAULT_KEYBOARD_SLOT_INDEX) + slotDelta,
|
||||
),
|
||||
),
|
||||
'keyboard',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledDayColumn
|
||||
aria-label={day.toLocaleString(undefined, { dateStyle: 'full' })}
|
||||
isWeekend={isPlainDateInWeekend(day)}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
onMouseLeave={() => onActiveSlotIndexChange(dayString, null, 'pointer')}
|
||||
onMouseMove={handleMouseMove}
|
||||
tabIndex={0}
|
||||
>
|
||||
{isDefined(activeSlotIndex) && (
|
||||
<StyledSlotAddNewPositioner
|
||||
data-testid="record-calendar-week-slot-add"
|
||||
topInPixels={
|
||||
activeSlotIndex * RECORD_CALENDAR_WEEK_DIMENSIONS.slotHeight
|
||||
}
|
||||
>
|
||||
<RecordCalendarAddNew
|
||||
cardDate={day}
|
||||
cardTime={Temporal.PlainTime.from('00:00').add({
|
||||
minutes:
|
||||
activeSlotIndex *
|
||||
RECORD_CALENDAR_WEEK_DIMENSIONS.snapIntervalInMinutes,
|
||||
})}
|
||||
compact
|
||||
/>
|
||||
</StyledSlotAddNewPositioner>
|
||||
)}
|
||||
{eventLayouts.map(
|
||||
({
|
||||
columnCount,
|
||||
columnIndex,
|
||||
endInPixels,
|
||||
recordId,
|
||||
startInPixels,
|
||||
}) => (
|
||||
<RecordCalendarWeekEvent
|
||||
key={recordId}
|
||||
calendarDay={day}
|
||||
calendarEndFieldName={calendarEndFieldName}
|
||||
calendarFieldName={calendarFieldName}
|
||||
calendarFieldType={calendarFieldType}
|
||||
columnCount={columnCount}
|
||||
columnIndex={columnIndex}
|
||||
endInPixels={endInPixels}
|
||||
isAllDay={false}
|
||||
recordId={recordId}
|
||||
startInPixels={startInPixels}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</StyledDayColumn>
|
||||
);
|
||||
},
|
||||
);
|
||||
const RECORD_CALENDAR_WEEK_MIN_WIDTH_IN_PIXELS = 1000;
|
||||
|
||||
export const RecordCalendarWeek = () => {
|
||||
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
|
||||
const { timeFormat, timeZone } = useDateTimeFormat();
|
||||
const recordCalendarId = useAvailableComponentInstanceIdOrThrow(
|
||||
RecordCalendarComponentInstanceContext,
|
||||
);
|
||||
@@ -485,220 +15,14 @@ export const RecordCalendarWeek = () => {
|
||||
recordCalendarSelectedDateComponentState,
|
||||
recordCalendarId,
|
||||
);
|
||||
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
const { weekDays } = useRecordCalendarWeekDaysRange(
|
||||
recordCalendarSelectedDate,
|
||||
);
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
);
|
||||
const scrollAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [currentInstant, setCurrentInstant] = useState(() =>
|
||||
Temporal.Now.instant(),
|
||||
);
|
||||
const [activeSlot, setActiveSlot] =
|
||||
useState<RecordCalendarWeekActiveSlot | null>(null);
|
||||
|
||||
const handleActiveSlotIndexChange = useCallback(
|
||||
(
|
||||
day: string,
|
||||
slotIndex: number | null,
|
||||
interactionMode: RecordCalendarWeekSlotInteractionMode,
|
||||
) => {
|
||||
setActiveSlot((currentActiveSlot) =>
|
||||
updateRecordCalendarWeekActiveSlot({
|
||||
currentActiveSlot,
|
||||
day,
|
||||
interactionMode,
|
||||
slotIndex,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearActivePointerSlot = useCallback(() => {
|
||||
setActiveSlot((currentActiveSlot) =>
|
||||
currentActiveSlot?.interactionMode === 'pointer'
|
||||
? null
|
||||
: currentActiveSlot,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentMouseMove = (event: globalThis.MouseEvent) => {
|
||||
const eventTarget = event.target;
|
||||
|
||||
if (
|
||||
!(eventTarget instanceof Node) ||
|
||||
gridRef.current?.contains(eventTarget) !== true
|
||||
) {
|
||||
clearActivePointerSlot();
|
||||
}
|
||||
};
|
||||
|
||||
const clearActiveSlot = () => setActiveSlot(null);
|
||||
|
||||
document.addEventListener('mousemove', handleDocumentMouseMove);
|
||||
document.addEventListener('mouseleave', clearActivePointerSlot);
|
||||
window.addEventListener('blur', clearActiveSlot);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleDocumentMouseMove);
|
||||
document.removeEventListener('mouseleave', clearActivePointerSlot);
|
||||
window.removeEventListener('blur', clearActiveSlot);
|
||||
};
|
||||
}, [clearActivePointerSlot]);
|
||||
|
||||
const { firstDayOfWeek, lastDayOfWeek, weekDays } =
|
||||
useRecordCalendarWeekDaysRange(recordCalendarSelectedDate);
|
||||
|
||||
const calendarFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(field) => field.id === recordIndexCalendarFieldMetadataId,
|
||||
);
|
||||
const calendarEndFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(field) => field.id === recordIndexCalendarEndFieldMetadataId,
|
||||
);
|
||||
|
||||
const now = currentInstant.toZonedDateTimeISO(timeZone);
|
||||
const today = now.toPlainDate();
|
||||
const isCurrentWeek =
|
||||
Temporal.PlainDate.compare(today, firstDayOfWeek) >= 0 &&
|
||||
Temporal.PlainDate.compare(today, lastDayOfWeek) <= 0;
|
||||
const currentTimeTopInPixels =
|
||||
(now.hour + now.minute / 60) * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight;
|
||||
const initialScrollHour = isCurrentWeek ? Math.max(now.hour - 2, 0) : 8;
|
||||
const firstDayOfWeekString = firstDayOfWeek.toString();
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
setCurrentInstant(Temporal.Now.instant());
|
||||
}, CURRENT_TIME_REFRESH_INTERVAL_IN_MILLISECONDS);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
const isAllDayView =
|
||||
calendarFieldMetadataItem?.type === FieldMetadataType.DATE;
|
||||
const isTimedView =
|
||||
calendarFieldMetadataItem?.type === FieldMetadataType.DATE_TIME;
|
||||
const compatibleCalendarEndFieldName =
|
||||
isTimedView &&
|
||||
calendarEndFieldMetadataItem?.type === FieldMetadataType.DATE_TIME
|
||||
? calendarEndFieldMetadataItem.name
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTimedView) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollAnchorRef.current?.scrollIntoView({
|
||||
block: 'center',
|
||||
inline: 'nearest',
|
||||
});
|
||||
}, [firstDayOfWeekString, isTimedView]);
|
||||
|
||||
if (
|
||||
!isDefined(calendarFieldMetadataItem) ||
|
||||
(!isAllDayView && !isTimedView)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RecordCalendarWeekDragDropContext
|
||||
gridRef={gridRef}
|
||||
weekDays={weekDays.map(({ date }) => date)}
|
||||
>
|
||||
<StyledContainer>
|
||||
<StyledHeader>
|
||||
<StyledHeaderGutter>
|
||||
{isTimedView && <TimeZoneAbbreviation instant={currentInstant} />}
|
||||
</StyledHeaderGutter>
|
||||
{weekDays.map(({ date, label }) => {
|
||||
const isToday = isSamePlainDate(date, today);
|
||||
|
||||
return (
|
||||
<StyledDayHeader key={date.toString()}>
|
||||
<span>{label}</span>
|
||||
<StyledDayNumber isToday={isToday}>{date.day}</StyledDayNumber>
|
||||
</StyledDayHeader>
|
||||
);
|
||||
})}
|
||||
{isAllDayView && (
|
||||
<>
|
||||
<StyledAllDayLabel>{t`All day`}</StyledAllDayLabel>
|
||||
{weekDays.map(({ date }) => (
|
||||
<RecordCalendarWeekAllDayCell
|
||||
key={`all-day-${date.toString()}`}
|
||||
calendarFieldName={calendarFieldMetadataItem.name}
|
||||
calendarFieldType={calendarFieldMetadataItem.type}
|
||||
day={date}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</StyledHeader>
|
||||
{isTimedView && (
|
||||
<StyledGrid ref={gridRef} onMouseLeave={clearActivePointerSlot}>
|
||||
<StyledTimeGutter>
|
||||
{Array.from(
|
||||
{ length: RECORD_CALENDAR_WEEK_DIMENSIONS.hoursInDay },
|
||||
(_, hour) => (
|
||||
<StyledHourLabel
|
||||
key={hour}
|
||||
topInPixels={
|
||||
hour * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight
|
||||
}
|
||||
>
|
||||
{format(new Date(2000, 0, 1, hour), timeFormat)}
|
||||
</StyledHourLabel>
|
||||
),
|
||||
)}
|
||||
<StyledScrollAnchor
|
||||
ref={scrollAnchorRef}
|
||||
topInPixels={
|
||||
initialScrollHour * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight
|
||||
}
|
||||
/>
|
||||
</StyledTimeGutter>
|
||||
{weekDays.map(({ date }) => {
|
||||
const dateString = date.toString();
|
||||
|
||||
return (
|
||||
<RecordCalendarWeekDayColumn
|
||||
key={dateString}
|
||||
activeSlotIndex={
|
||||
activeSlot?.day === dateString ? activeSlot.slotIndex : null
|
||||
}
|
||||
calendarEndFieldName={compatibleCalendarEndFieldName}
|
||||
calendarFieldName={calendarFieldMetadataItem.name}
|
||||
calendarFieldType={calendarFieldMetadataItem.type}
|
||||
dayString={dateString}
|
||||
onActiveSlotIndexChange={handleActiveSlotIndexChange}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isCurrentWeek && (
|
||||
<>
|
||||
<StyledCurrentTimeLine topInPixels={currentTimeTopInPixels} />
|
||||
<StyledCurrentTimeLabel topInPixels={currentTimeTopInPixels}>
|
||||
{formatInTimeZone(
|
||||
new Date(currentInstant.toString()),
|
||||
timeZone,
|
||||
timeFormat,
|
||||
)}
|
||||
</StyledCurrentTimeLabel>
|
||||
</>
|
||||
)}
|
||||
</StyledGrid>
|
||||
)}
|
||||
</StyledContainer>
|
||||
</RecordCalendarWeekDragDropContext>
|
||||
<RecordCalendarTimeGrid
|
||||
days={weekDays}
|
||||
minWidthInPixels={RECORD_CALENDAR_WEEK_MIN_WIDTH_IN_PIXELS}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-4
@@ -32,14 +32,14 @@ type DragEndPayload = Parameters<
|
||||
|
||||
type RecordCalendarWeekDragDropContextProps = {
|
||||
children: ReactNode;
|
||||
days: Temporal.PlainDate[];
|
||||
gridRef: RefObject<HTMLDivElement | null>;
|
||||
weekDays: Temporal.PlainDate[];
|
||||
};
|
||||
|
||||
export const RecordCalendarWeekDragDropContext = ({
|
||||
children,
|
||||
days,
|
||||
gridRef,
|
||||
weekDays,
|
||||
}: RecordCalendarWeekDragDropContextProps) => {
|
||||
const [grabOffsetY, setGrabOffsetY] = useState(0);
|
||||
const { processRecordCalendarWeekEventDrop } =
|
||||
@@ -76,7 +76,7 @@ export const RecordCalendarWeekDragDropContext = ({
|
||||
|
||||
const gridRect = gridRef.current.getBoundingClientRect();
|
||||
const resolvedDrop = resolveRecordCalendarWeekEventDrop({
|
||||
dayCount: weekDays.length,
|
||||
dayCount: days.length,
|
||||
grabOffsetY,
|
||||
gridRect,
|
||||
pointerX: operation.position.current.x,
|
||||
@@ -87,7 +87,7 @@ export const RecordCalendarWeekDragDropContext = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const destinationDay = weekDays[resolvedDrop.dayIndex];
|
||||
const destinationDay = days[resolvedDrop.dayIndex];
|
||||
|
||||
if (!isDefined(destinationDay)) {
|
||||
return;
|
||||
|
||||
+13
@@ -28,6 +28,19 @@ describe('resolveRecordCalendarWeekEventDrop', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves every valid horizontal position to the only visible day', () => {
|
||||
expect(
|
||||
resolveRecordCalendarWeekEventDrop({
|
||||
dayCount: 1,
|
||||
grabOffsetY: 0,
|
||||
gridRect,
|
||||
pointerX: gridRect.left + gridRect.width - 1,
|
||||
pointerY:
|
||||
gridRect.top + 14.25 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
|
||||
}),
|
||||
).toEqual({ dayIndex: 0, destinationMinutes: 14 * 60 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['time gutter', gridRect.left + 20, gridRect.top + 100],
|
||||
['left of grid', gridRect.left - 1, gridRect.top + 100],
|
||||
|
||||
+2
-2
@@ -15,9 +15,9 @@ export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
|
||||
{
|
||||
key: FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
metadata: {
|
||||
label: 'Calendar Week View',
|
||||
label: 'Calendar Day and Week Views',
|
||||
description:
|
||||
'Display calendar records in a weekly layout with optional end dates',
|
||||
'Display calendar records in daily or weekly layouts with optional end dates',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user