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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user