Implement Calendar Grid (#14487)

<img width="1512" height="876" alt="image"
src="https://github.com/user-attachments/assets/bb96bc86-8ae8-4a00-b31b-cc51c43b45ef"
/>
This commit is contained in:
Charles Bochet
2025-09-15 10:58:41 +02:00
committed by GitHub
parent b40de0fa07
commit 22bc2712ab
12 changed files with 443 additions and 1 deletions
@@ -0,0 +1,31 @@
import styled from '@emotion/styled';
import { RecordCalendarTopBar } from '@/object-record/record-calendar/components/RecordCalendarTopBar';
import { RecordCalendarMonth } from '@/object-record/record-calendar/month/components/RecordCalendarMonth';
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
const StyledContainerContainer = styled.div`
display: flex;
flex-direction: column;
min-height: calc(100% - ${({ theme }) => theme.spacing(2)});
height: min-content;
`;
export const RecordCalendar = () => {
const recordCalendarId = useAvailableComponentInstanceIdOrThrow(
RecordCalendarComponentInstanceContext,
);
return (
<ScrollWrapper
componentInstanceId={`scroll-wrapper-record-calendar-${recordCalendarId}`}
>
<StyledContainerContainer>
<RecordCalendarTopBar />
<RecordCalendarMonth />
</StyledContainerContainer>
</ScrollWrapper>
);
};
@@ -0,0 +1,32 @@
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { Select } from '@/ui/input/components/Select';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import styled from '@emotion/styled';
const StyledContainer = styled.div`
display: flex;
height: 24px;
`;
export const RecordCalendarTopBar = () => {
const recordCalendarId = useAvailableComponentInstanceIdOrThrow(
RecordCalendarComponentInstanceContext,
);
return (
<StyledContainer>
<Select
dropdownId={`record-calendar-top-bar-layout-select-${recordCalendarId}`}
selectSizeVariant="small"
options={[
{
label: 'Month',
value: 'MONTH',
},
]}
value={''}
onChange={() => {}}
/>
</StyledContainer>
);
};
@@ -0,0 +1,44 @@
import { RecordCalendarMonthBody } from '@/object-record/record-calendar/month/components/RecordCalendarMonthBody';
import { RecordCalendarMonthHeader } from '@/object-record/record-calendar/month/components/RecordCalendarMonthHeader';
import { RecordCalendarMonthContextProvider } from '@/object-record/record-calendar/month/contexts/RecordCalendarMonthContext';
import { useRecordCalendarMonthDaysRange } from '@/object-record/record-calendar/month/hooks/useRecordCalendarMonthDaysRange';
import styled from '@emotion/styled';
const StyledContainer = styled.div`
display: flex;
height: 24px;
flex-direction: column;
`;
export const RecordCalendarMonth = () => {
const currentDay = new Date();
const {
firstDayOfMonth,
lastDayOfMonth,
firstDayOfFirstWeek,
lastDayOfLastWeek,
weekDayLabels,
weekFirstDays,
weekStartsOnDayIndex,
} = useRecordCalendarMonthDaysRange(currentDay);
return (
<RecordCalendarMonthContextProvider
value={{
firstDayOfMonth,
lastDayOfMonth,
firstDayOfFirstWeek,
lastDayOfLastWeek,
weekDayLabels,
weekFirstDays,
weekStartsOnDayIndex,
}}
>
<StyledContainer>
<RecordCalendarMonthHeader />
<RecordCalendarMonthBody />
</StyledContainer>
</RecordCalendarMonthContextProvider>
);
};
@@ -0,0 +1,24 @@
import { RecordCalendarMonthBodyWeek } from '@/object-record/record-calendar/month/components/RecordCalendarMonthBodyWeek';
import { useRecordCalendarMonthContextOrThrow } from '@/object-record/record-calendar/month/contexts/RecordCalendarMonthContext';
import styled from '@emotion/styled';
const StyledContainer = styled.div`
display: flex;
height: 24px;
flex-direction: column;
`;
export const RecordCalendarMonthBody = () => {
const { weekFirstDays } = useRecordCalendarMonthContextOrThrow();
return (
<StyledContainer>
{weekFirstDays.map((weekFirstDay) => (
<RecordCalendarMonthBodyWeek
key={`week-${weekFirstDay.toDateString()}`}
startDayOfWeek={weekFirstDay}
/>
))}
</StyledContainer>
);
};
@@ -0,0 +1,16 @@
import styled from '@emotion/styled';
const StyledContainer = styled.div`
display: flex;
width: calc(100% / 7);
`;
type RecordCalendarMonthBodyDayProps = {
day: Date;
};
export const RecordCalendarMonthBodyDay = ({
day,
}: RecordCalendarMonthBodyDayProps) => {
return <StyledContainer>{day.getDate()}</StyledContainer>;
};
@@ -0,0 +1,34 @@
import { RecordCalendarMonthBodyDay } from '@/object-record/record-calendar/month/components/RecordCalendarMonthBodyDay';
import { useRecordCalendarMonthContextOrThrow } from '@/object-record/record-calendar/month/contexts/RecordCalendarMonthContext';
import styled from '@emotion/styled';
import { eachDayOfInterval, endOfWeek } from 'date-fns';
const StyledContainer = styled.div`
display: flex;
min-height: 122px;
`;
type RecordCalendarMonthBodyWeekProps = {
startDayOfWeek: Date;
};
export const RecordCalendarMonthBodyWeek = ({
startDayOfWeek,
}: RecordCalendarMonthBodyWeekProps) => {
const { weekStartsOnDayIndex } = useRecordCalendarMonthContextOrThrow();
const daysOfWeek = eachDayOfInterval({
start: startDayOfWeek,
end: endOfWeek(startDayOfWeek, {
weekStartsOn: weekStartsOnDayIndex,
}),
});
return (
<StyledContainer>
{daysOfWeek.map((day, index) => (
<RecordCalendarMonthBodyDay key={`day-${index}`} day={day} />
))}
</StyledContainer>
);
};
@@ -0,0 +1,21 @@
import { RecordCalendarMonthHeaderDay } from '@/object-record/record-calendar/month/components/RecordCalendarMonthHeaderDay';
import { useRecordCalendarMonthContextOrThrow } from '@/object-record/record-calendar/month/contexts/RecordCalendarMonthContext';
import styled from '@emotion/styled';
const StyledContainer = styled.div`
display: flex;
height: 24px;
width: 100%;
`;
export const RecordCalendarMonthHeader = () => {
const { weekDayLabels } = useRecordCalendarMonthContextOrThrow();
return (
<StyledContainer>
{weekDayLabels.map((label, index) => (
<RecordCalendarMonthHeaderDay key={`label-${index}`} label={label} />
))}
</StyledContainer>
);
};
@@ -0,0 +1,17 @@
import styled from '@emotion/styled';
type RecordCalendarMonthHeaderDayProps = {
label: string;
};
const StyledLabel = styled.div`
display: flex;
width: calc(100% / 7);
height: 24px;
`;
export const RecordCalendarMonthHeaderDay = ({
label,
}: RecordCalendarMonthHeaderDayProps) => {
return <StyledLabel>{label}</StyledLabel>;
};
@@ -0,0 +1,133 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { type TaskGroups } from '@/activities/tasks/components/TaskGroups';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext';
import { RecordIndexContextProvider } from '@/object-record/record-index/contexts/RecordIndexContext';
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { RecordCalendarMonth } from '@/object-record/record-calendar/month/components/RecordCalendarMonth';
import { currentRecordFieldsComponentState } from '@/object-record/record-field/states/currentRecordFieldsComponentState';
import { type RecordField } from '@/object-record/record-field/types/RecordField';
import { useRecordIndexFieldMetadataDerivedStates } from '@/object-record/record-index/hooks/useRecordIndexFieldMetadataDerivedStates';
import { VIEW_BAR_FILTER_DROPDOWN_ID } from '@/views/constants/ViewBarFilterDropdownId';
import { coreViewsState } from '@/views/states/coreViewState';
import { useSetRecoilState } from 'recoil';
import { ComponentDecorator } from 'twenty-ui/testing';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { IconsProviderDecorator } from '~/testing/decorators/IconsProviderDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { mockedCoreViewsData } from '~/testing/mock-data/views';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
const meta: Meta<typeof RecordCalendarMonth> = {
title: 'Modules/ObjectRecord/RecordCalendar/Month',
component: RecordCalendarMonth,
decorators: [
(Story) => {
const companyObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === CoreObjectNameSingular.Company,
)!;
const instanceId = companyObjectMetadataItem.id;
const setCurrentRecordFields = useSetRecoilComponentState(
currentRecordFieldsComponentState,
instanceId,
);
const setCoreViews = useSetRecoilState(coreViewsState);
const mockCoreView = mockedCoreViewsData[0];
setCoreViews([mockCoreView]);
const setCurrentViewId = useSetRecoilComponentState(
contextStoreCurrentViewIdComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
setCurrentViewId(mockCoreView.id);
const columns = companyObjectMetadataItem.fields.map(
(fieldMetadataItem, index) =>
({
id: fieldMetadataItem.id,
fieldMetadataItemId: fieldMetadataItem.id,
isVisible: true,
position: index,
size: 100,
}) satisfies RecordField,
);
setCurrentRecordFields(columns);
const {
fieldDefinitionByFieldMetadataItemId,
fieldMetadataItemByFieldMetadataItemId,
labelIdentifierFieldMetadataItem,
recordFieldByFieldMetadataItemId,
} = useRecordIndexFieldMetadataDerivedStates(
companyObjectMetadataItem,
instanceId,
);
return (
<RecordIndexContextProvider
value={{
objectPermissionsByObjectMetadataId: {},
indexIdentifierUrl: () => '',
onIndexRecordsLoaded: () => {},
objectNamePlural: CoreObjectNamePlural.Company,
objectNameSingular: CoreObjectNameSingular.Company,
objectMetadataItem: companyObjectMetadataItem,
recordIndexId: instanceId,
viewBarInstanceId: instanceId,
labelIdentifierFieldMetadataItem,
recordFieldByFieldMetadataItemId,
fieldDefinitionByFieldMetadataItemId,
fieldMetadataItemByFieldMetadataItemId,
}}
>
<RecordComponentInstanceContextsWrapper
componentInstanceId={instanceId}
>
<ObjectFilterDropdownComponentInstanceContext.Provider
value={{ instanceId: VIEW_BAR_FILTER_DROPDOWN_ID }}
>
<RecordTableComponentInstanceContext.Provider
value={{
instanceId: instanceId,
}}
>
<ViewComponentInstanceContext.Provider value={{ instanceId }}>
<Story />
</ViewComponentInstanceContext.Provider>
</RecordTableComponentInstanceContext.Provider>
</ObjectFilterDropdownComponentInstanceContext.Provider>
</RecordComponentInstanceContextsWrapper>
</RecordIndexContextProvider>
);
},
ContextStoreDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
ComponentDecorator,
IconsProviderDecorator,
I18nFrontDecorator,
],
};
export default meta;
type Story = StoryObj<typeof TaskGroups>;
export const Default: Story = {
play: async () => {},
};
@@ -0,0 +1,18 @@
import { createRequiredContext } from '~/utils/createRequiredContext';
type RecordCalendarMonthContextValue = {
firstDayOfMonth: Date;
lastDayOfMonth: Date;
firstDayOfFirstWeek: Date;
lastDayOfLastWeek: Date;
weekDayLabels: string[];
weekFirstDays: Date[];
weekStartsOnDayIndex: 0 | 1 | 2 | 3 | 4 | 5 | 6;
};
export const [
RecordCalendarMonthContextProvider,
useRecordCalendarMonthContextOrThrow,
] = createRequiredContext<RecordCalendarMonthContextValue>(
'RecordCalendarMonthContext',
);
@@ -0,0 +1,71 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { CalendarStartDay } from '@/localization/constants/CalendarStartDay';
import { detectCalendarStartDay } from '@/localization/utils/detectCalendarStartDay';
import {
addDays,
eachWeekOfInterval,
endOfWeek,
format,
lastDayOfMonth as lastDayOfMonthFn,
startOfMonth,
startOfWeek,
} from 'date-fns';
import { useRecoilValue } from 'recoil';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
export const useRecordCalendarMonthDaysRange = (selectedDate: Date) => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const dateLocale = useRecoilValue(dateLocaleState);
if (!currentWorkspaceMember) {
throw new Error('Current workspace member not found');
}
const weekStartsOnDayIndex = (
currentWorkspaceMember?.calendarStartDay === CalendarStartDay.SYSTEM
? CalendarStartDay[detectCalendarStartDay()]
: (currentWorkspaceMember?.calendarStartDay ?? 0)
) as 0 | 1 | 2 | 3 | 4 | 5 | 6;
const firstDayOfMonth = startOfMonth(selectedDate);
const lastDayOfMonth = lastDayOfMonthFn(selectedDate);
const firstDayOfFirstWeek = startOfWeek(firstDayOfMonth, {
weekStartsOn: weekStartsOnDayIndex,
locale: dateLocale.localeCatalog,
});
const lastDayOfLastWeek = endOfWeek(lastDayOfMonth, {
weekStartsOn: weekStartsOnDayIndex,
locale: dateLocale.localeCatalog,
});
const daysOfWeekLabels: string[] = [];
for (let i = 0; i < 7; i++) {
const day = addDays(firstDayOfFirstWeek, i);
const label = format(day, 'EEE', { locale: dateLocale.localeCatalog });
daysOfWeekLabels.push(label);
}
const weekFirstDays = eachWeekOfInterval(
{
start: firstDayOfFirstWeek,
end: lastDayOfLastWeek,
},
{
weekStartsOn: weekStartsOnDayIndex,
},
);
return {
weekStartsOnDayIndex,
weekDayLabels: daysOfWeekLabels,
firstDayOfFirstWeek,
firstDayOfMonth,
lastDayOfMonth,
lastDayOfLastWeek,
weekFirstDays,
};
};
@@ -1,5 +1,6 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { RecordCalendar } from '@/object-record/record-calendar/components/RecordCalendar';
import { RecordCalendarContextProvider } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
@@ -38,7 +39,7 @@ export const RecordIndexCalendarContainer = ({
objectPermissions,
}}
>
Calendar View
<RecordCalendar />
</RecordCalendarContextProvider>
</RecordCalendarComponentInstanceContext.Provider>
);