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:
Weiko
2026-07-15 18:50:23 +02:00
committed by GitHub
parent 2dcf53619f
commit 8cf462d5f7
17 changed files with 1325 additions and 740 deletions
@@ -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
@@ -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();
});
});