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:
+31
@@ -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>
|
||||
);
|
||||
};
|
||||
+32
@@ -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>
|
||||
);
|
||||
};
|
||||
+44
@@ -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>
|
||||
);
|
||||
};
|
||||
+24
@@ -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>
|
||||
);
|
||||
};
|
||||
+16
@@ -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>;
|
||||
};
|
||||
+34
@@ -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>
|
||||
);
|
||||
};
|
||||
+21
@@ -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>
|
||||
);
|
||||
};
|
||||
+17
@@ -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>;
|
||||
};
|
||||
+133
@@ -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 () => {},
|
||||
};
|
||||
+18
@@ -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',
|
||||
);
|
||||
+71
@@ -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,
|
||||
};
|
||||
};
|
||||
+2
-1
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user